diff options
Diffstat (limited to 'claude-templates/.ai/scripts')
46 files changed, 3964 insertions, 3690 deletions
diff --git a/claude-templates/.ai/scripts/agent-roster b/claude-templates/.ai/scripts/agent-roster new file mode 100755 index 0000000..f32b744 --- /dev/null +++ b/claude-templates/.ai/scripts/agent-roster @@ -0,0 +1,84 @@ +#!/usr/bin/env bash +# agent-roster — list other live Claude agents working in this project. +# +# The single source of "who else is live in this project." Both launchers +# (ai --helper) and the in-session startup check call this rather than +# reimplementing the scan, so concurrent-agent detection has one definition. +# +# Scan (stateless): enumerate running Claude processes (pgrep -x claude), read +# each one's working directory from /proc/<pid>/cwd, keep those whose cwd is +# the project root or inside it, and drop the scanner's own process ancestry +# (walk parent pids from /proc/self up). What remains is the set of *other* +# live agents in this project. +# +# Usage: agent-roster [project-root] (default: $PWD) +# Output: one "pid<TAB>cwd" line per other agent +# Exit: 0 = alone (no other agents) +# 1 = one or more other agents (and printed) +# 2 = roster unavailable (no /proc; non-Linux or absent) +# +# Known limits, accepted for v1: a session not running as a local process on +# this machine (a cloud session against the same checkout) is invisible, and +# the match is on process cwd, so an agent started from outside the project +# tree isn't seen. Both are edge shapes the operator created deliberately. +# +# The boundary (pgrep, /proc, self pid) is injectable so the filtering logic +# is testable without spawning real agents: ROSTER_PGREP, ROSTER_PROC, +# ROSTER_SELF_PID. Production defaults need no environment. +set -euo pipefail + +PGREP="${ROSTER_PGREP:-pgrep}" +PROC="${ROSTER_PROC:-/proc}" +SELF_PID="${ROSTER_SELF_PID:-$$}" + +root="${1:-$PWD}" +root="${root%/}" + +# Linux /proc is the substrate. Absent (non-Linux, or unreadable) means the +# scan can't run; say so explicitly rather than reporting a false "alone". +if [ ! -d "$PROC" ]; then + echo "agent-roster: roster unavailable (no $PROC; non-Linux or absent)" >&2 + exit 2 +fi + +# pgrep is the enumeration boundary. Without it the scan can't run, and the +# no-match exit code (1) below is indistinguishable from "tool missing" once +# swallowed, so check up front rather than report a false "alone". +if ! command -v "$PGREP" >/dev/null 2>&1; then + echo "agent-roster: roster unavailable ($PGREP not found)" >&2 + exit 2 +fi + +# Build the scanner's ancestry set: SELF_PID and every parent up to init. +# A Claude found by pgrep that lands in this set is the current session (or its +# launcher chain), not another agent. +ancestry=" " +pid="$SELF_PID" +while [ -n "$pid" ] && [ "$pid" != "0" ] && [ "$pid" != "1" ]; do + ancestry="${ancestry}${pid} " + status="$PROC/$pid/status" + [ -r "$status" ] || break + pid="$(awk '/^PPid:/{print $2; exit}' "$status")" +done + +found=0 +while read -r candidate; do + [ -n "$candidate" ] || continue + case "$ancestry" in + *" $candidate "*) continue ;; # scanner's own ancestry + esac + # cwd may be gone if the process exited between pgrep and here; skip it. + cwd="$(readlink "$PROC/$candidate/cwd" 2>/dev/null)" || continue + [ -n "$cwd" ] || continue + # Keep only agents at or inside the project root. The trailing slashes make + # the prefix test exact, so /foo/project-other doesn't match /foo/project. + case "$cwd/" in + "$root"/*) ;; + *) continue ;; + esac + printf '%s\t%s\n' "$candidate" "$cwd" + found=1 +done < <("$PGREP" -x claude 2>/dev/null || true) + +[ "$found" -eq 1 ] && exit 1 +exit 0 diff --git a/claude-templates/.ai/scripts/capture-guard b/claude-templates/.ai/scripts/capture-guard new file mode 100755 index 0000000..6c01f2f --- /dev/null +++ b/claude-templates/.ai/scripts/capture-guard @@ -0,0 +1,91 @@ +#!/usr/bin/env bash +# capture-guard — detect live org-capture buffers visiting a target file +# before a workflow edits that file on disk. +# +# Editing a file on disk while Emacs has an indirect org-capture buffer +# cloned from it reverts the base buffer underneath the capture, wedging it: +# the capture can no longer finalize cleanly with C-c C-c, and a freshly-typed +# item can be lost or written back against post-edit content. inbox.org +# roam mode Phase D edits ~/org/roam/inbox.org, the file Craig captures into constantly, +# so it calls this guard first. See claude-rules/emacs.md. +# +# Usage: capture-guard [--wait[=SECONDS]] [TARGET_FILE] (default ~/org/roam/inbox.org) +# +# Single-shot (default): check once. +# exit 0 — safe to edit: no Emacs, daemon unreachable, or no capture buffer +# visits TARGET_FILE. +# exit 1 — a live capture buffer visits TARGET_FILE; its name(s) printed to +# stdout, comma-separated. +# +# --wait[=SECONDS]: poll until the capture clears or SECONDS elapse (default +# 30), re-checking every ~10s. Org captures are usually transient — a few +# seconds of mid-finalize state — so a short wait clears most false alarms +# before a caller has to surface or skip. Same exit codes: exit 0 the moment +# it's clear, exit 1 if still blocked at the deadline (last buffer list on +# stdout). The common case (nothing capturing) returns instantly without +# sleeping. +# +# Conservative by construction: any uncertainty (no Emacs, query failure) +# resolves to "safe," so the guard never blocks a workflow that would have +# been fine. It only stops the one case it can positively confirm. + +set -euo pipefail + +WAIT_TOTAL=0 +case "${1:-}" in + --wait) WAIT_TOTAL=30; shift ;; + --wait=*) WAIT_TOTAL="${1#--wait=}"; shift ;; +esac + +TARGET="${1:-$HOME/org/roam/inbox.org}" +INTERVAL=10 + +# Names of capture buffers whose base buffer visits TARGET. file-equal-p +# normalizes symlinks and ./.. so the match survives path spelling; it also +# returns nil when TARGET doesn't exist, which collapses to "safe" below. +lisp='(let ((target (expand-file-name "'"$TARGET"'"))) + (mapconcat (function buffer-name) + (seq-filter + (lambda (b) + (and (string-prefix-p "CAPTURE" (buffer-name b)) + (let* ((base (or (buffer-base-buffer b) b)) + (f (buffer-file-name base))) + (and f (file-equal-p f target))))) + (buffer-list)) + ","))' + +LAST_BUFS="" + +# detect — return 0 (safe) or 1 (blocked, name(s) in LAST_BUFS). Any +# uncertainty resolves to safe, matching the single-shot contract. +detect() { + command -v emacsclient >/dev/null 2>&1 || return 0 + emacsclient -e t >/dev/null 2>&1 || return 0 + local bufs + bufs="$(emacsclient -e "$lisp" 2>/dev/null)" || return 0 + bufs="${bufs#\"}" + bufs="${bufs%\"}" + if [ -n "$bufs" ]; then + LAST_BUFS="$bufs" + return 1 + fi + return 0 +} + +# Poll loop. With WAIT_TOTAL=0 (single-shot) it checks once and falls straight +# through to the exit-1 branch on a block, never sleeping. Each sleep is capped +# to the remaining budget so a short --wait never overshoots its deadline. +elapsed=0 +while :; do + if detect; then + exit 0 + fi + if [ "$elapsed" -ge "$WAIT_TOTAL" ]; then + echo "$LAST_BUFS" + exit 1 + fi + remaining=$((WAIT_TOTAL - elapsed)) + step=$((remaining < INTERVAL ? remaining : INTERVAL)) + sleep "$step" + elapsed=$((elapsed + step)) +done diff --git a/claude-templates/.ai/scripts/cross-agent-comms/cross-agent-discover b/claude-templates/.ai/scripts/cross-agent-comms/cross-agent-discover deleted file mode 100755 index 152cf27..0000000 --- a/claude-templates/.ai/scripts/cross-agent-comms/cross-agent-discover +++ /dev/null @@ -1,230 +0,0 @@ -#!/usr/bin/env python3 -"""Enumerate cross-agent destinations: local projects + tailnet peers. - -See cross-agent-discover.md. Local: scan ~/projects/*/.ai/. Peers: read -peers.toml, SSH-probe each for reachability. --enumerate-remote optionally -runs `ls -d ~/projects/*/.ai/` over SSH to list remote projects. - -Cache results for 5 min at ~/.cache/cross-agent-comms/discovery.json so -repeated invocations don't re-probe. - -HALT: prints a banner; otherwise continues. -""" - -from __future__ import annotations - -import argparse -import datetime as _dt -import json -import os -import subprocess -import sys -import time -import tomllib -from pathlib import Path - -CONFIG_DIR = Path.home() / ".config" / "cross-agent-comms" -PEERS_TOML = CONFIG_DIR / "peers.toml" -HALT_FILE = CONFIG_DIR / "HALT" -CACHE_DIR = Path.home() / ".cache" / "cross-agent-comms" -CACHE_FILE = CACHE_DIR / "discovery.json" -CACHE_TTL_SECONDS = 300 - -EXIT_OK = 0 -EXIT_GENERAL = 1 -EXIT_PEERS_TOML = 1 - - -def err(msg: str) -> None: - print(msg, file=sys.stderr) - - -def render_banner_if_halt() -> None: - if not HALT_FILE.exists(): - return - try: - reason = HALT_FILE.read_text().strip() - except OSError: - reason = "(HALT file unreadable; treated as halted)" - print("⚠ HALT ACTIVE — cross-agent comms paused") - if reason: - print(f" reason: {reason}") - print() - - -def enumerate_local_projects() -> list[str]: - projects_dir = Path.home() / "projects" - if not projects_dir.is_dir(): - return [] - found = [] - for child in sorted(projects_dir.iterdir()): - if child.is_dir() and (child / ".ai").is_dir(): - found.append(child.name) - return found - - -def load_peers() -> dict: - if not PEERS_TOML.exists(): - return {"peers": {}} - try: - return tomllib.loads(PEERS_TOML.read_text()) - except (tomllib.TOMLDecodeError, OSError) as e: - err(f"cannot parse peers.toml: {e}") - sys.exit(EXIT_PEERS_TOML) - - -def probe_peer_reachability(host: str, ssh_user: str | None) -> tuple[bool, str | None]: - """Run a short SSH probe with BatchMode=yes (no interactive prompt).""" - target = f"{ssh_user}@{host}" if ssh_user else host - try: - result = subprocess.run( - ["ssh", "-o", "ConnectTimeout=2", "-o", "BatchMode=yes", target, "true"], - capture_output=True, - text=True, - timeout=5, - ) - except (FileNotFoundError, subprocess.TimeoutExpired): - return False, "ssh probe failed" - if result.returncode == 0: - return True, None - return False, (result.stderr.strip().splitlines() or [f"exit {result.returncode}"])[-1] - - -def enumerate_remote_projects(host: str, ssh_user: str | None) -> list[str] | None: - target = f"{ssh_user}@{host}" if ssh_user else host - try: - result = subprocess.run( - [ - "ssh", "-o", "ConnectTimeout=3", "-o", "BatchMode=yes", target, - "ls -d ~/projects/*/.ai/ 2>/dev/null", - ], - capture_output=True, - text=True, - timeout=10, - ) - except (FileNotFoundError, subprocess.TimeoutExpired): - return None - if result.returncode != 0: - return None - projects = [] - for line in result.stdout.splitlines(): - # Each line looks like /home/<user>/projects/<name>/.ai/ - parts = line.rstrip("/").split("/") - if len(parts) >= 2 and parts[-1] == ".ai": - projects.append(parts[-2]) - return projects - - -def read_cache() -> dict | None: - if not CACHE_FILE.exists(): - return None - try: - age = time.time() - CACHE_FILE.stat().st_mtime - if age > CACHE_TTL_SECONDS: - return None - return json.loads(CACHE_FILE.read_text()) - except (OSError, json.JSONDecodeError): - return None - - -def write_cache(payload: dict) -> None: - CACHE_DIR.mkdir(parents=True, exist_ok=True) - CACHE_FILE.write_text(json.dumps(payload, indent=2)) - - -def discover(peer_filter: str | None, enumerate_remote: bool) -> dict: - local = enumerate_local_projects() - peers_cfg = load_peers().get("peers", {}) - - peers_out = [] - for name, cfg in sorted(peers_cfg.items()): - if peer_filter and name != peer_filter: - continue - host = cfg.get("host", name) - ssh_user = cfg.get("ssh_user") - reachable, error = probe_peer_reachability(host, ssh_user) - entry = { - "name": name, - "host": host, - "reachable": reachable, - } - if not reachable: - entry["error"] = error - if enumerate_remote and reachable: - entry["projects"] = enumerate_remote_projects(host, ssh_user) or [] - peers_out.append(entry) - - return { - "scanned_at": _dt.datetime.now(_dt.timezone.utc).isoformat(), - "halt_active": HALT_FILE.exists(), - "local": local, - "peers": peers_out, - } - - -def render_table(payload: dict, enumerate_remote: bool) -> None: - local = payload.get("local", []) - print(f"Local ({_local_hostname()}):") - if local: - wrapped = ", ".join(local) - print(f" {wrapped} [{len(local)} project{'s' if len(local) != 1 else ''}]") - else: - print(" (no projects with .ai/ found)") - print() - - peers = payload.get("peers", []) - if not peers: - print("Peers (from peers.toml):") - print(" (no peers configured)") - return - - print("Peers (from ~/.config/cross-agent-comms/peers.toml):") - for p in peers: - marker = "✓ reachable" if p.get("reachable") else f"✗ UNREACHABLE ({p.get('error', 'unknown')})" - print(f" {p['name']:<16} {p['host']:<24} {marker}") - if enumerate_remote and p.get("projects"): - wrapped = ", ".join(p["projects"]) - print(f" projects: {wrapped}") - - -def _local_hostname() -> str: - import socket - return socket.gethostname().split(".")[0] - - -def main() -> int: - parser = argparse.ArgumentParser(description="Discover cross-agent destinations.") - parser.add_argument("--enumerate-remote", action="store_true", - help="SSH into each peer and list ~/projects/*/.ai/") - parser.add_argument("--no-cache", action="store_true", help="Skip cache; force fresh probe") - parser.add_argument("--peer", help="Limit to a single peer name from peers.toml") - parser.add_argument("--json", action="store_true", help="Machine-readable output") - args = parser.parse_args() - - render_banner_if_halt() - - payload = None - if not args.no_cache: - cached = read_cache() - if cached is not None: - # Honor --peer filter on cached payload. - if args.peer: - cached["peers"] = [p for p in cached.get("peers", []) if p["name"] == args.peer] - payload = cached - - if payload is None: - payload = discover(args.peer, args.enumerate_remote) - if not args.no_cache and not args.peer: - # Only cache full (unfiltered) discoveries. - write_cache(payload) - - if args.json: - print(json.dumps(payload, indent=2)) - return EXIT_OK - - render_table(payload, args.enumerate_remote) - return EXIT_OK - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/claude-templates/.ai/scripts/cross-agent-comms/cross-agent-discover.md b/claude-templates/.ai/scripts/cross-agent-comms/cross-agent-discover.md deleted file mode 100644 index 95134bb..0000000 --- a/claude-templates/.ai/scripts/cross-agent-comms/cross-agent-discover.md +++ /dev/null @@ -1,155 +0,0 @@ -# cross-agent-discover - -**Purpose.** Enumerate available cross-agent destinations — local projects on -this machine and remote projects on tailnet peers. Validates SSH reachability -for cross-machine destinations before reporting them as usable. - -## Usage - -``` -cross-agent-discover [--enumerate-remote] [--no-cache] [--peer <name>] -``` - -No args required for the common case (local enumeration + peer reachability). - -### Flags - -| Flag | Default | Purpose | -|---|---|---| -| `--enumerate-remote` | off | SSH into each peer and list projects under `~/projects/*/.ai/`. Off by default because SSH adds latency; turn on when you want to see what's available on a remote machine you haven't fully configured. | -| `--no-cache` | off | Skip the 5-minute cache; force fresh discovery. | -| `--peer <name>` | (all) | Limit to a single peer from `peers.toml`. | -| `--json` | off | Machine-readable output. | - -## Output - -### Default - -``` -$ cross-agent-discover -Local (ratio): - career, claude-templates, clipper, danneel, documents, elibrary, - finances, health, homelab, jr-estate, kit, little-elisper, - philosophy, website [14 projects] - -Peers (from ~/.config/cross-agent-comms/peers.toml): - velox.local reachable (last seen 2 sec ago) - bastion.local UNREACHABLE (ssh exit 255: connection refused) -``` - -### With `--enumerate-remote` - -``` -$ cross-agent-discover --enumerate-remote -Local (ratio): - ... (as above) - -velox.local (reachable): - career, homelab [2 projects] -``` - -## Configuration - -Reads `~/.config/cross-agent-comms/peers.toml`: - -```toml -# Each peer is a remote machine reachable via SSH (typically over Tailscale). - -[peers.velox] -host = "velox.local" -ssh_user = "cjennings" - -[peers.bastion] -host = "bastion.local" -ssh_user = "cjennings" -``` - -Peers entries describe machines, NOT projects. Projects are enumerated -on-demand under `~/projects/*/.ai/` either locally or via SSH. - -## Cache - -Successful discovery results are cached at -`~/.cache/cross-agent-comms/discovery.json` for 5 minutes. Repeated invocations -within the window read from cache. - -`--no-cache` forces a fresh probe. Useful when adding a new peer or after a -network change. - -## SSH reachability check - -For each peer, runs: - -``` -ssh -o ConnectTimeout=2 -o BatchMode=yes <user>@<host> true -``` - -`BatchMode=yes` prevents interactive password prompts — peers that don't have -key-based auth set up are reported as UNREACHABLE. - -If `--enumerate-remote` is set, on success runs: - -``` -ssh <user>@<host> 'ls -d ~/projects/*/.ai/ 2>/dev/null' -``` - -## Failure modes - -| Symptom | Likely cause | Fix | -|---|---|---| -| Peer reported UNREACHABLE | Tailscale not connected, SSH key not authorized, host firewalled | `tailscale status`; `ssh -v <peer>` to debug. | -| Local list is empty | Glob misresolved, or `~/projects/` doesn't exist | Check `ls -d ~/projects/*/.ai/`. | -| `--enumerate-remote` slow | Cold cache, slow tailnet, many peers | First run is slow, subsequent runs hit cache. Use `--peer <name>` to scope. | -| Peer unexpectedly missing from output | Not in `peers.toml`, or `peers.toml` malformed | `cat ~/.config/cross-agent-comms/peers.toml` and validate. | - -## HALT awareness - -Checks `~/.config/cross-agent-comms/HALT` at start. If HALT exists, prints a -prominent banner before normal output: - -``` -$ cross-agent-discover -⚠ HALT ACTIVE — cross-agent comms paused - Reason: <reason from HALT file body, if any> - Resume with: cross-agent-resume - -(enumeration continues normally — HALT does not suppress visibility) - -Local (ratio): - career, claude-templates, ... - -Peers: - velox.local reachable -``` - -Discover is read-only. Like `cross-agent-status`, it always runs so the user -keeps visibility into what destinations exist regardless of halt state. The -banner makes the halt state impossible to miss. - -If the HALT file exists but is unreadable, print a warning banner and -continue. - -See `cross-agent-halt.md` for the full halt mechanism. - -## Examples - -```bash -# Common: see what's available -cross-agent-discover - -# Force fresh probe after network change -cross-agent-discover --no-cache - -# What's on velox specifically -cross-agent-discover --peer velox --enumerate-remote - -# Pipe to grep -cross-agent-discover --json | jq '.peers[] | select(.reachable)' -``` - -## See also - -- `cross-agent-send` — uses `peers.toml` for routing destinations. -- `cross-agent-status` — local pending messages. -- `cross-agent-comms.org` — protocol spec, `* Limitations` section - explains the cross-machine model. diff --git a/claude-templates/.ai/scripts/cross-agent-comms/cross-agent-halt b/claude-templates/.ai/scripts/cross-agent-comms/cross-agent-halt deleted file mode 100755 index df25115..0000000 --- a/claude-templates/.ai/scripts/cross-agent-comms/cross-agent-halt +++ /dev/null @@ -1,134 +0,0 @@ -#!/usr/bin/env python3 -"""Failsafe halt for cross-agent comms. - -See cross-agent-halt.md. Touches ~/.config/cross-agent-comms/HALT and stops -the cross-agent-watch systemd user service. With --tailnet, propagates the -HALT file to every peer in peers.toml via SSH; reports per-peer status with -non-zero exit on partial halt. - -Does NOT pkill in-flight scripts — they detect HALT on next iteration and -stop themselves. -""" - -from __future__ import annotations - -import argparse -import subprocess -import sys -import tomllib -from pathlib import Path - -CONFIG_DIR = Path.home() / ".config" / "cross-agent-comms" -HALT_FILE = CONFIG_DIR / "HALT" -PEERS_TOML = CONFIG_DIR / "peers.toml" - -EXIT_OK = 0 -EXIT_PARTIAL = 1 - - -def err(msg: str) -> None: - print(msg, file=sys.stderr) - - -def write_halt_file(reason: str) -> None: - CONFIG_DIR.mkdir(parents=True, exist_ok=True) - HALT_FILE.write_text((reason + "\n") if reason else "") - - -def stop_watcher_service() -> None: - """Best-effort stop of the systemd watcher service. Failures are logged but not fatal.""" - try: - subprocess.run( - ["systemctl", "--user", "stop", "cross-agent-watch.path"], - capture_output=True, text=True, timeout=5, - ) - except (FileNotFoundError, subprocess.TimeoutExpired): - # Watcher service may not be installed — fine. - pass - - -def load_peers() -> dict: - if not PEERS_TOML.exists(): - return {} - try: - return tomllib.loads(PEERS_TOML.read_text()) - except (tomllib.TOMLDecodeError, OSError) as e: - err(f"cannot parse peers.toml: {e}") - return {} - - -def ssh_touch_halt(host: str, ssh_user: str | None, reason: str) -> tuple[bool, str]: - target = f"{ssh_user}@{host}" if ssh_user else host - # Build the remote command. Quote the reason carefully. - remote_cmd = ( - f"mkdir -p ~/.config/cross-agent-comms && " - f"printf %s {_sh_quote(reason)} > ~/.config/cross-agent-comms/HALT" - ) - try: - result = subprocess.run( - ["ssh", "-o", "ConnectTimeout=3", "-o", "BatchMode=yes", target, remote_cmd], - capture_output=True, text=True, timeout=10, - ) - except (FileNotFoundError, subprocess.TimeoutExpired): - return False, "ssh unavailable or timed out" - if result.returncode == 0: - return True, "HALT file written" - return False, (result.stderr.strip().splitlines() or [f"exit {result.returncode}"])[-1] - - -def _sh_quote(s: str) -> str: - return "'" + s.replace("'", "'\"'\"'") + "'" - - -def main() -> int: - parser = argparse.ArgumentParser(description="Halt all cross-agent comms on this machine (and optionally tailnet).") - parser.add_argument("reason", nargs="?", default="", help="Optional human-readable reason") - parser.add_argument("--tailnet", action="store_true", - help="Propagate HALT to every peer in peers.toml") - args = parser.parse_args() - - # Local halt. - write_halt_file(args.reason) - stop_watcher_service() - print("Halting locally ✓ (HALT file written)") - - if not args.tailnet: - print() - print(f"Halt active. Remove {HALT_FILE} or run cross-agent-resume to clear.") - print("Agent polling will stop within ~5 min (one cadence cycle).") - return EXIT_OK - - peers = load_peers().get("peers", {}) - if not peers: - print() - print("No peers configured in peers.toml — local-only halt complete.") - return EXIT_OK - - print() - successes = 1 # local already counted - failures = [] - for name, cfg in sorted(peers.items()): - host = cfg.get("host", name) - ssh_user = cfg.get("ssh_user") - ok, detail = ssh_touch_halt(host, ssh_user, args.reason) - marker = "✓" if ok else "✗" - print(f"Halting {host:<28} {marker} ({detail})") - if ok: - successes += 1 - else: - failures.append(f"{name} ({host}): {detail}") - - print() - total = len(peers) + 1 - if failures: - print(f"PARTIAL HALT: {successes}/{total} machines halted.") - for f in failures: - print(f" - {f}") - print("Resolve the failures or manually halt each machine.") - return EXIT_PARTIAL - print(f"Halt active across {total} machine(s).") - return EXIT_OK - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/claude-templates/.ai/scripts/cross-agent-comms/cross-agent-halt.md b/claude-templates/.ai/scripts/cross-agent-comms/cross-agent-halt.md deleted file mode 100644 index b817fbc..0000000 --- a/claude-templates/.ai/scripts/cross-agent-comms/cross-agent-halt.md +++ /dev/null @@ -1,134 +0,0 @@ -# cross-agent-halt - -**Purpose.** Failsafe stop for all cross-agent activity on the local machine -(or, with `--tailnet`, across all configured peers). Creates the HALT file -that every component in the protocol checks; within one polling cadence -(~5 min) all polling, sending, watching, and receiving stops. - -This is the user's emergency brake. Use when something is misbehaving and -visiting individual sessions is too slow. - -## Usage - -``` -cross-agent-halt [reason] [--tailnet] [--no-stop-watcher] -``` - -### Positional argument - -| Position | Meaning | Example | -|---|---|---| -| 1 | Optional human-readable reason for the halt. Written into the HALT file's body. Helps future-you remember why you stopped things. | `"investigating runaway poll loop, 2026-04-27"` | - -### Flags - -| Flag | Default | Purpose | -|---|---|---| -| `--tailnet` | local only | Propagate halt to every peer in `peers.toml` via SSH over Tailscale. | -| `--no-stop-watcher` | (stops watcher) | Skip stopping the `cross-agent-watch.path` systemd unit. Useful if the watcher is intentionally separate from comms (rare). | - -## Behavior - -### Local halt (default) - -1. Write the HALT file: `~/.config/cross-agent-comms/HALT`. If a `[reason]` was - passed, write it as the file's body. Otherwise the file is empty (existence - alone triggers halt). -2. Stop the watcher service: `systemctl --user stop cross-agent-watch.path` - (and the corresponding `.service` if running). -3. Print a summary: - ``` - ✓ HALT file written: ~/.config/cross-agent-comms/HALT - ✓ Watcher service stopped (cross-agent-watch.path) - - In-flight sends will complete their current rsync step (~seconds), then - stop. New sends are blocked. - - Active agent polling sessions stop within one cadence (~5 min). - - Use `cross-agent-resume` to clear HALT. - Per-session polling does NOT auto-resume — you re-engage each session by - telling its agent to resume polling. - ``` -4. Exit 0. - -### Cross-tailnet halt (`--tailnet`) - -1. Apply local halt steps 1-2 first. -2. Read `peers.toml` for the list of remote machines. -3. For each peer, SSH and write the HALT file: - ``` - ssh <user>@<host> "echo '<reason>' > ~/.config/cross-agent-comms/HALT && \ - systemctl --user stop cross-agent-watch.path" - ``` -4. Track per-peer success/failure. Print results: - ``` - Halting velox.local ✓ (HALT file written) - Halting bastion.local ✗ (ssh exit 255: no route to host) - Halting locally ✓ (HALT file written) - - PARTIAL HALT: 2/3 machines halted. bastion.local needs manual halt. - ``` -5. Exit 0 if all peers halted; exit 1 if any peer failed (so scripts can - detect partial halt). The local halt always succeeds — even on `--tailnet`, - if remote peers fail, local is still halted. - -## What "halt active" means for each component - -| Component | Behavior under HALT | -|---|---| -| `cross-agent-send` | Refuses to send. Exits 5 with "halt active; remove ~/.config/cross-agent-comms/HALT to resume." Checks HALT at start AND between each retry/rsync step, so an in-flight send completes its current step then stops. | -| `cross-agent-recv` | Refuses to verify or dedup. Exits 5 with same message. Inbound files are **left in place** — not moved, not rejected — so resume picks them up cleanly via cold-start. | -| `cross-agent-watch` | Continues running but suppresses notifications. Logs each event with `(suppressed by HALT)` so the operator can see what would have fired. | -| `cross-agent-status` | Prints prominent `⚠ HALT ACTIVE` banner before normal output. Continues to enumerate (read-only). | -| `cross-agent-discover` | Same banner. Continues (read-only). | -| Agent polling loops | Check HALT on every wake. If set: write a final `progress` note to any active conversation ("HALT fired locally; pausing"), surface "(HALT active; cross-agent comms paused)" in every user response, and stop rescheduling. Polling decays naturally within one cadence. | -| Conversation initiator | Refuses to write sequence 1 of any new conversation. Surfaces refusal to user. | -| Startup workflow (Phase A) | Checks HALT at session boot. If set, surfaces immediately and skips cross-agent inbox checks. | - -## Failure modes - -| Symptom | Cause | Fix | -|---|---|---| -| `~/.config/cross-agent-comms/HALT` already exists | Halt was already active | OK — running halt again refreshes the reason text. Safe. | -| `systemctl --user stop` fails | Watcher service not installed, or systemd not available | The HALT file is still written — components that check HALT will still stop. The systemctl failure surfaces as a non-fatal warning. | -| `--tailnet` halts some peers but not others | One or more peers unreachable | Exit 1 with per-peer status. Manually halt the unreachable peers (visit each machine, `touch ~/.config/cross-agent-comms/HALT`), or fix the network and re-run. | -| Permission denied writing the HALT file | `~/.config/cross-agent-comms/` doesn't exist or is owned by another user | `mkdir -p ~/.config/cross-agent-comms/`; check ownership. | - -## What halt does NOT do - -- Does not kill running Claude sessions. Polling stops within ~5 min, but the - session itself stays alive and can be re-engaged after resume. -- Does not delete pending messages. Inbound files in `inbox/from-agents/` - remain; they get processed when polling resumes. -- Does not abort in-flight rsync push mid-byte. Atomic-write semantics - guarantee in-flight messages either complete cleanly or leave only `.tmp.*` - files (which receivers ignore). - -## Examples - -```bash -# Quick halt with no reason -cross-agent-halt - -# Halt with a memo -cross-agent-halt "runaway poll loop in homelab session, debugging" - -# Halt all tailnet peers + local -cross-agent-halt --tailnet "shutting down for system update" - -# Halt protocol comms but leave the watcher service running -cross-agent-halt --no-stop-watcher -``` - -## Recovery - -Always pair with `cross-agent-resume` when the situation is resolved: - -```bash -cross-agent-resume # local -cross-agent-resume --tailnet # all peers -``` - -## See also - -- `cross-agent-resume` — counterpart that clears HALT. -- `cross-agent-status` — see HALT state at a glance. -- `cross-agent-comms.org` — protocol spec, `* Halt mechanism` section. diff --git a/claude-templates/.ai/scripts/cross-agent-comms/cross-agent-recv b/claude-templates/.ai/scripts/cross-agent-comms/cross-agent-recv deleted file mode 100755 index b67533a..0000000 --- a/claude-templates/.ai/scripts/cross-agent-comms/cross-agent-recv +++ /dev/null @@ -1,250 +0,0 @@ -#!/usr/bin/env python3 -"""Cross-agent message receiver. - -See cross-agent-recv.md for the full contract. Reads one message file and -emits a structured decision the agent acts on: - - process | dedup | query | reject - -Decision exit codes: - 0 = process 1 = dedup 2 = query 3 = reject - -When HALT is set, the script refuses to verify or dedup and leaves the -inbound file in place — resume picks it up via cold-start. -""" - -from __future__ import annotations - -import argparse -import hashlib -import json -import re -import shutil -import subprocess -import sys -from pathlib import Path - -CONFIG_DIR = Path.home() / ".config" / "cross-agent-comms" -HALT_FILE = CONFIG_DIR / "HALT" -EXPECTED_PROTOCOL_VERSION = "5" - -REQUIRED_FRONTMATTER = ["TITLE", "CONVERSATION_ID", "MESSAGE_TYPE", "SEQUENCE", "TIMESTAMP", "PROTOCOL_VERSION"] -VALID_MESSAGE_TYPES = {"request", "progress", "query", "pushback", "complete", "release", "escalate"} - -DEC_PROCESS = "process" -DEC_DEDUP = "dedup" -DEC_QUERY = "query" -DEC_REJECT = "reject" - -EXIT_FOR_DECISION = { - DEC_PROCESS: 0, - DEC_DEDUP: 1, - DEC_QUERY: 2, - DEC_REJECT: 3, -} - -EXIT_HALT = 5 - - -def err(msg: str) -> None: - print(msg, file=sys.stderr) - - -def check_halt() -> None: - if HALT_FILE.exists(): - try: - reason = HALT_FILE.read_text().strip() - except OSError: - err("halt active (HALT file present but unreadable; treated as halted)") - sys.exit(EXIT_HALT) - msg = "halt active; leaving inbound message in place (resume will pick up)" - if reason: - msg = f"{msg}: {reason}" - err(msg) - sys.exit(EXIT_HALT) - - -def parse_frontmatter(path: Path) -> dict[str, str]: - try: - text = path.read_text() - except OSError as e: - return {"_parse_error": f"cannot read: {e}"} - fm: dict[str, str] = {} - for line in text.splitlines(): - line = line.rstrip() - if not line: - if fm: - break - continue - m = re.match(r"#\+([A-Z_]+):\s*(.*)", line) - if m: - fm[m.group(1)] = m.group(2).strip() - elif fm: - break - return fm - - -def emit_decision( - decision: str, - reason: str | None, - fm: dict[str, str], - sha256: str | None, - args: argparse.Namespace, -) -> int: - payload = { - "decision": decision, - "reason": reason, - "message_type": fm.get("MESSAGE_TYPE"), - "conversation_id": fm.get("CONVERSATION_ID"), - "sequence": fm.get("SEQUENCE"), - "timestamp": fm.get("TIMESTAMP"), - "sha256": sha256, - } - if args.json: - print(json.dumps(payload, indent=None if args.compact_json else 2)) - else: - print(f"decision: {decision}") - if reason: - print(f"reason: {reason}") - for k in ("message_type", "conversation_id", "sequence", "timestamp"): - v = payload[k] - if v is not None: - print(f"{k}: {v}") - if sha256: - print(f"sha256: {sha256}") - return EXIT_FOR_DECISION[decision] - - -def gpg_verify(message_path: Path, sig_path: Path) -> tuple[bool, str]: - try: - result = subprocess.run( - ["gpg", "--verify", str(sig_path), str(message_path)], - capture_output=True, - text=True, - ) - except FileNotFoundError: - return False, "gpg not installed" - if result.returncode == 0: - return True, "" - return False, result.stderr.strip().splitlines()[-1] if result.stderr.strip() else f"exit {result.returncode}" - - -def sha256_of(path: Path) -> str: - h = hashlib.sha256() - with path.open("rb") as f: - for chunk in iter(lambda: f.read(65536), b""): - h.update(chunk) - return h.hexdigest() - - -def find_dedup_match(message_path: Path, fm: dict[str, str], my_hash: str) -> tuple[str, str | None]: - """Scan the message's directory for same-CONVERSATION_ID/SEQUENCE files. - - Returns (decision, reason) — decision is DEC_DEDUP for an exact-hash match, - or DEC_PROCESS when no match or hash differs (sequence collision is OK). - """ - parent = message_path.parent - conv_id = fm["CONVERSATION_ID"] - sequence = fm["SEQUENCE"] - for sibling in parent.iterdir(): - if sibling == message_path or not sibling.is_file() or sibling.suffix != ".org": - continue - sib_fm = parse_frontmatter(sibling) - if sib_fm.get("CONVERSATION_ID") != conv_id or sib_fm.get("SEQUENCE") != sequence: - continue - # Same conv-id + same sequence — check hash. - if sha256_of(sibling) == my_hash: - return DEC_DEDUP, f"identical retry of {sibling.name}" - return DEC_PROCESS, None - - -def check_requires_tools(fm: dict[str, str]) -> tuple[bool, list[str]]: - """REQUIRES_TOOLS is a comma-separated list of tool names. - - For v5, "tool available" is a heuristic: an executable on PATH whose name - matches the tool slug. MCP availability is currently out of scope (no - portable way to query it from a CLI). - """ - tools_field = fm.get("REQUIRES_TOOLS") - if not tools_field: - return True, [] - tools = [t.strip() for t in tools_field.split(",") if t.strip()] - missing = [t for t in tools if shutil.which(t) is None] - return len(missing) == 0, missing - - -def main() -> int: - parser = argparse.ArgumentParser(description="Receive and decide on a cross-agent message.") - parser.add_argument("message_file", type=Path) - parser.add_argument("--no-verify", action="store_true", help="Skip GPG verification (testing only)") - parser.add_argument("--no-dedup", action="store_true", help="Skip SHA-256 dedup against existing files") - parser.add_argument("--protocol-version", default=EXPECTED_PROTOCOL_VERSION, - help="Override expected protocol version (default: 5)") - parser.add_argument("--json", action="store_true", help="Emit JSON output") - parser.add_argument("--compact-json", action="store_true", help="Compact JSON (no indent)") - args = parser.parse_args() - - check_halt() - - if not args.message_file.is_file(): - err(f"message file not found: {args.message_file}") - return EXIT_FOR_DECISION[DEC_REJECT] - - fm = parse_frontmatter(args.message_file) - if "_parse_error" in fm: - return emit_decision(DEC_REJECT, fm["_parse_error"], {}, None, args) - - # Step 1: frontmatter sanity-check. - missing = [k for k in REQUIRED_FRONTMATTER if k not in fm] - if missing: - return emit_decision( - DEC_REJECT, f"frontmatter missing required fields: {', '.join(missing)}", fm, None, args - ) - if fm["MESSAGE_TYPE"] not in VALID_MESSAGE_TYPES: - return emit_decision( - DEC_REJECT, f"invalid MESSAGE_TYPE: {fm['MESSAGE_TYPE']!r}", fm, None, args - ) - - # Step 2: PROTOCOL_VERSION check. - if fm["PROTOCOL_VERSION"] != args.protocol_version: - return emit_decision( - DEC_QUERY, - f"PROTOCOL_VERSION mismatch: expected {args.protocol_version}, got {fm['PROTOCOL_VERSION']}", - fm, - None, - args, - ) - - # Step 3: GPG verify. - if not args.no_verify: - sig_path = args.message_file.with_suffix(args.message_file.suffix + ".asc") - if not sig_path.is_file(): - return emit_decision(DEC_REJECT, f"signature file missing: {sig_path.name}", fm, None, args) - ok, gpg_err = gpg_verify(args.message_file, sig_path) - if not ok: - return emit_decision(DEC_REJECT, f"gpg verify failed: {gpg_err}", fm, None, args) - - # Step 4: SHA-256 dedup. - my_hash = sha256_of(args.message_file) - if not args.no_dedup: - decision, reason = find_dedup_match(args.message_file, fm, my_hash) - if decision == DEC_DEDUP: - return emit_decision(DEC_DEDUP, reason, fm, my_hash, args) - - # Step 5: REQUIRES_TOOLS check. - ok, missing_tools = check_requires_tools(fm) - if not ok: - return emit_decision( - DEC_QUERY, - f"required tools unavailable: {', '.join(missing_tools)}", - fm, - my_hash, - args, - ) - - # Step 6: process. - return emit_decision(DEC_PROCESS, None, fm, my_hash, args) - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/claude-templates/.ai/scripts/cross-agent-comms/cross-agent-recv.md b/claude-templates/.ai/scripts/cross-agent-comms/cross-agent-recv.md deleted file mode 100644 index 247a27a..0000000 --- a/claude-templates/.ai/scripts/cross-agent-comms/cross-agent-recv.md +++ /dev/null @@ -1,218 +0,0 @@ -# cross-agent-recv - -**Purpose.** The canonical receiver-side processor. Reads a single incoming -message file and reports a structured decision the agent acts on: -process / dedup / query / reject. - -The script handles only mechanical checks (frontmatter, signature, dedup, -version, tools). Substance-level decisions like `pushback` ("I disagree with -this request") happen one layer up — after the agent reads the message body -the script returns as `process`-able. - -This is the read-side counterpart to `cross-agent-send`. Together they are the -two halves of the per-message contract. The agent's polling loop calls -`cross-agent-recv` on every new file in `inbox/from-agents/` and dispatches on -the decision. - -Without this script, every receiver implementation re-invents GPG verify + -frontmatter sanity-check + SHA-256 dedup. With it, behavior is consistent -across projects. - -## Usage - -``` -cross-agent-recv <message-file> -``` - -Single positional argument: a `.org` file in `inbox/from-agents/`. The matching -`.asc` signature file must be present alongside it. - -### Flags - -| Flag | Default | Purpose | -|---|---|---| -| `--no-verify` | (verify on) | Skip GPG verification. Testing only. | -| `--no-dedup` | (dedup on) | Skip SHA-256 dedup against existing files. Testing only. | -| `--protocol-version <N>` | 5 | Override the expected protocol version. Useful for testing forward-compatibility checks. | -| `--json` | off | Output decision as JSON for easier parsing by the agent. | - -## Behavior - -Runs the receiver checks in order. First failure determines the decision. - -### Step 1 — Frontmatter sanity-check - -Parse the message's org-mode frontmatter. Required fields: - -- `#+TITLE` -- `#+CONVERSATION_ID` -- `#+MESSAGE_TYPE` (must be one of: `request`, `progress`, `query`, `pushback`, - `complete`, `release`, `escalate`) -- `#+SEQUENCE` (integer) -- `#+TIMESTAMP` (ISO 8601 with explicit offset) -- `#+PROTOCOL_VERSION` (must match the expected version; default 5) - -Any required field missing, malformed, or the protocol version mismatched → -decision = `reject` (frontmatter) or `query` (version mismatch — see below). - -### Step 2 — Protocol-version check - -If `PROTOCOL_VERSION` doesn't match the expected: - -- Decision = `query`. Action: receiver should write a `query` reply asking the - sender to upgrade to the expected protocol version. - -### Step 3 — Signature verification - -Look for `<message-file>.asc` alongside the `.org`. If missing or `gpg ---verify` fails: - -- Decision = `reject` (signature). Surface to user; do not act. - -The `.asc` file MUST be present when the `.org` is — `cross-agent-send` -guarantees this with its strict ordering (`.asc` lands first). If the `.asc` -is missing despite the `.org` being present, the sender violated atomic-write -ordering or the file was tampered with in transit. - -### Step 4 — SHA-256 dedup - -Compute SHA-256 of the message file. Scan the same directory for existing -files matching `CONVERSATION_ID + SEQUENCE`: - -- No match → decision = `process` (new message, dispatch by type). -- Match with **identical** SHA-256 → decision = `dedup` (silent retry; do not - reprocess). -- Match with **different** SHA-256 → decision = `process` (sequence collision - with non-identical content; both are legitimate, ordered by `#+TIMESTAMP`). - -### Step 5 — REQUIRES_TOOLS optional check - -If the message has a `#+REQUIRES_TOOLS` field, verify each named tool/MCP is -available in the receiver's environment. - -- All available → `process`. -- One or more missing → decision = `query`. The agent should write a `query` - reply naming the missing tools, asking the sender to reframe the request to - avoid them. - -### Step 6 — Dispatch decision - -If all checks pass, decision = `process` with the parsed `MESSAGE_TYPE` so the -agent's main loop knows which handler to invoke. - -## Output - -### Default (human-readable) - -``` -$ cross-agent-recv inbox/from-agents/20260427T091015Z-from-homelab-prep-fixup.org -decision: process -message_type: request -conversation_id: prep-fixup -sequence: 6 -sha256: a1b2c3d4... -``` - -### `--json` - -```json -{ - "decision": "process", - "reason": null, - "message_type": "request", - "conversation_id": "prep-fixup", - "sequence": 6, - "timestamp": "2026-04-27T04:11:42-05:00", - "sha256": "a1b2c3d4..." -} -``` - -For decisions other than `process`, `reason` carries a human-readable -explanation: - -```json -{ - "decision": "query", - "reason": "PROTOCOL_VERSION mismatch: expected 5, got 4", - "conversation_id": "prep-fixup", - "sequence": 6 -} -``` - -## Decision exit codes - -| Decision | Exit code | Agent action | -|---|---|---| -| `process` | 0 | Dispatch to the message-type handler | -| `dedup` | 1 | Silent — do nothing further | -| `query` | 2 | Write a `query` reply (see `reason` for what to ask) | -| `reject` | 3 | Surface to user; do not auto-reply | - -The agent reads stdout/JSON to learn the decision; it can also key off exit -code for simpler bash-style dispatching. - -## Failure modes - -| Symptom | Cause | Fix | -|---|---|---| -| `decision: reject (frontmatter)` | Required field missing or malformed | Open the message; fix or surface to user. The sender should not have produced this file. | -| `decision: reject (signature)` | `.asc` missing, GPG verify failed, or signer unknown | Check that `.asc` exists alongside `.org`. If yes, run `gpg --verify <msg>.asc <msg>` manually for diagnostic output. | -| `decision: query (PROTOCOL_VERSION)` | Sender on older/newer protocol | Reply with a `query` asking sender to upgrade. Both sides should align before continuing. | -| `decision: query (REQUIRES_TOOLS)` | Receiver lacks one of the named tools | Reply with a `query` naming the missing tools; sender should reframe to avoid. | -| `decision: dedup` | Already-processed identical retry | No action. The script handled it correctly. | - -## HALT awareness - -Checks `~/.config/cross-agent-comms/HALT` at the start of every invocation. If -HALT exists, exits with code 5 ("halt active; remove -~/.config/cross-agent-comms/HALT to resume") without verifying, deduping, or -returning a decision. - -**The inbound file is left in place** — not moved, not rejected, not -deduped. When HALT clears and polling resumes, the file gets picked up via -the normal cold-start handling (whichever surfaces first: watcher -notification, startup workflow check, or the next agent poll). Reversibility -is preserved. - -If the HALT file exists but is unreadable, fail-closed — treat as if HALT is -set. - -See `cross-agent-halt.md` for the full halt mechanism. - -## Examples - -```bash -# Basic invocation in an agent's polling loop -for msg in inbox/from-agents/*.org; do - decision=$(cross-agent-recv --json "$msg") - case "$(echo "$decision" | jq -r '.decision')" in - process) handle_message "$msg" ;; - dedup) ;; # silent - query) write_query_reply "$msg" "$decision" ;; - reject) surface_to_user "$msg" "$decision" ;; - esac -done - -# Test signature verification only -cross-agent-recv --no-dedup inbox/from-agents/test-msg.org - -# Test against a future protocol version -cross-agent-recv --protocol-version 6 inbox/from-agents/future-msg.org -``` - -## Performance - -The script is fast (single SHA-256 compute, single GPG verify, frontmatter -parse). For typical messages (single-digit KB), runs in well under 100ms. -Dedup-scan is O(N) over files in the directory; if a project's -`inbox/from-agents/` accumulates hundreds of files, archive released -conversations to keep the scan fast. - -## See also - -- `cross-agent-send` — counterpart writer. -- `cross-agent-watch` — fires when a new message arrives; agent then calls - `cross-agent-recv` to process it. -- `cross-agent-status` — pending-message snapshot (uses similar - released-vs-unreleased logic, but doesn't process individual messages). -- `cross-agent-comms.org` — protocol spec, the "what" the script implements. diff --git a/claude-templates/.ai/scripts/cross-agent-comms/cross-agent-resume b/claude-templates/.ai/scripts/cross-agent-comms/cross-agent-resume deleted file mode 100755 index 1fb83bc..0000000 --- a/claude-templates/.ai/scripts/cross-agent-comms/cross-agent-resume +++ /dev/null @@ -1,145 +0,0 @@ -#!/usr/bin/env python3 -"""Resume cross-agent comms after a halt. - -See cross-agent-resume.md. Removes ~/.config/cross-agent-comms/HALT and -restarts the cross-agent-watch systemd user service. With --tailnet, -propagates the removal to every peer in peers.toml via SSH; reports -per-peer status with non-zero exit on partial resume. - -Per the asymmetry rule: clearing HALT does NOT auto-resume agent polling. -Each session must explicitly re-engage. -""" - -from __future__ import annotations - -import argparse -import subprocess -import sys -import tomllib -from pathlib import Path - -CONFIG_DIR = Path.home() / ".config" / "cross-agent-comms" -HALT_FILE = CONFIG_DIR / "HALT" -PEERS_TOML = CONFIG_DIR / "peers.toml" - -EXIT_OK = 0 -EXIT_PARTIAL = 1 - - -def err(msg: str) -> None: - print(msg, file=sys.stderr) - - -def remove_halt_file() -> bool: - """Returns True if HALT was removed, False if it didn't exist.""" - if HALT_FILE.exists(): - try: - HALT_FILE.unlink() - return True - except OSError as e: - err(f"could not remove HALT: {e}") - return False - return False - - -def start_watcher_service() -> None: - """Best-effort start of the systemd watcher path unit.""" - try: - subprocess.run( - ["systemctl", "--user", "start", "cross-agent-watch.path"], - capture_output=True, text=True, timeout=5, - ) - except (FileNotFoundError, subprocess.TimeoutExpired): - pass - - -def load_peers() -> dict: - if not PEERS_TOML.exists(): - return {} - try: - return tomllib.loads(PEERS_TOML.read_text()) - except (tomllib.TOMLDecodeError, OSError) as e: - err(f"cannot parse peers.toml: {e}") - return {} - - -def ssh_remove_halt(host: str, ssh_user: str | None) -> tuple[bool, str]: - target = f"{ssh_user}@{host}" if ssh_user else host - remote_cmd = "rm -f ~/.config/cross-agent-comms/HALT" - try: - result = subprocess.run( - ["ssh", "-o", "ConnectTimeout=3", "-o", "BatchMode=yes", target, remote_cmd], - capture_output=True, text=True, timeout=10, - ) - except (FileNotFoundError, subprocess.TimeoutExpired): - return False, "ssh unavailable or timed out" - if result.returncode == 0: - return True, "HALT cleared" - return False, (result.stderr.strip().splitlines() or [f"exit {result.returncode}"])[-1] - - -def print_re_engage_instructions() -> None: - print() - print("Halt cleared. Watcher restarted.") - print() - print("Agent polling does NOT auto-resume — per the failsafe asymmetry rule,") - print("agents stay paused until you explicitly re-engage each session.") - print("Open the relevant Claude session and tell the agent to resume polling") - print("for its conversation.") - - -def main() -> int: - parser = argparse.ArgumentParser(description="Resume cross-agent comms after a halt.") - parser.add_argument("--tailnet", action="store_true", - help="Propagate HALT removal to every peer in peers.toml") - args = parser.parse_args() - - removed = remove_halt_file() - start_watcher_service() - if removed: - print("Resuming locally ✓ (HALT cleared)") - else: - print("Resuming locally ✓ (no HALT was active)") - - if not args.tailnet: - print_re_engage_instructions() - return EXIT_OK - - peers = load_peers().get("peers", {}) - if not peers: - print() - print("No peers configured in peers.toml — local-only resume complete.") - print_re_engage_instructions() - return EXIT_OK - - print() - successes = 1 - failures = [] - for name, cfg in sorted(peers.items()): - host = cfg.get("host", name) - ssh_user = cfg.get("ssh_user") - ok, detail = ssh_remove_halt(host, ssh_user) - marker = "✓" if ok else "✗" - print(f"Resuming {host:<27} {marker} ({detail})") - if ok: - successes += 1 - else: - failures.append(f"{name} ({host}): {detail}") - - print() - total = len(peers) + 1 - if failures: - print(f"PARTIAL RESUME: {successes}/{total} machines cleared.") - for f in failures: - print(f" - {f}") - print("Resolve the failures or manually clear HALT on each machine.") - print_re_engage_instructions() - return EXIT_PARTIAL - - print(f"Resume complete across {total} machine(s).") - print_re_engage_instructions() - return EXIT_OK - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/claude-templates/.ai/scripts/cross-agent-comms/cross-agent-resume.md b/claude-templates/.ai/scripts/cross-agent-comms/cross-agent-resume.md deleted file mode 100644 index 8aa8357..0000000 --- a/claude-templates/.ai/scripts/cross-agent-comms/cross-agent-resume.md +++ /dev/null @@ -1,117 +0,0 @@ -# cross-agent-resume - -**Purpose.** Clear the HALT file and restart the watcher service. Counterpart -to `cross-agent-halt`. Resuming agent polling is **explicit per-session** — -this script doesn't auto-revive halted polling loops; you tell each session -to re-engage. - -## Usage - -``` -cross-agent-resume [--tailnet] -``` - -### Flags - -| Flag | Default | Purpose | -|---|---|---| -| `--tailnet` | local only | Clear HALT on every peer in `peers.toml` via SSH over Tailscale. | - -## Behavior - -### Local resume (default) - -1. Remove the HALT file: `rm -f ~/.config/cross-agent-comms/HALT`. (Use `-f` - so a missing file isn't an error — running resume when not halted is safe.) -2. Restart the watcher service: `systemctl --user start cross-agent-watch.path`. -3. Print a summary: - ``` - ✓ HALT file removed - ✓ Watcher service started (cross-agent-watch.path) - - cross-agent-send and cross-agent-recv will accept new operations. - - Inbound messages held during halt will be picked up by the watcher. - - Agent polling does NOT auto-resume. To re-engage polling in a paused - session, open that Claude session and tell the agent to resume. - ``` -4. Exit 0. - -### Cross-tailnet resume (`--tailnet`) - -1. Apply local resume steps 1-2 first. -2. Read `peers.toml` for the list of remote machines. -3. For each peer, SSH: - ``` - ssh <user>@<host> "rm -f ~/.config/cross-agent-comms/HALT && \ - systemctl --user start cross-agent-watch.path" - ``` -4. Track per-peer success/failure: - ``` - Resuming velox.local ✓ (HALT cleared, watcher started) - Resuming bastion.local ✗ (ssh exit 255: no route to host) - Resuming locally ✓ - - PARTIAL RESUME: 2/3 machines resumed. bastion.local still halted. - ``` -5. Exit 0 if all peers resumed; exit 1 on any failure. - -## Why agent polling doesn't auto-resume - -Two reasons the asymmetry is deliberate: - -1. *Auto-resume could silently invert intentional kills.* If you halted - because a session was misbehaving, removing HALT shouldn't quietly revive - that session's polling. You re-engage explicitly so you're aware of which - sessions came back online. - -2. *You may want to inspect before resuming.* After a halt, you might want to - read pending messages, fix configuration, or kill a particular Claude - session entirely. Per-session resume forces that pause. - -## Re-engaging polling in a Claude session - -After `cross-agent-resume`, open the relevant Claude session and say something -like: - -``` -HALT is cleared; resume polling. -``` - -The agent will check the HALT file (now absent), re-create its polling -schedule, and continue the in-flight conversation from wherever it left off. -The conversation file is intact; the receiver will pick up any new messages -that arrived during the halt window. - -## Failure modes - -| Symptom | Cause | Fix | -|---|---|---| -| HALT file doesn't exist | Already resumed (or never halted) | OK — `-f` makes this a no-op. | -| `systemctl --user start` fails | Watcher service not installed | Install per `cross-agent-watch.md`'s systemd recipe. | -| `--tailnet` resumes some peers but not others | Same as halt: peer unreachable | Per-peer status reported; resolve manually for unreachable peers. | -| Permission denied removing HALT file | File owned by another user | Check ownership; HALT files should be owned by the running user. | - -## Examples - -```bash -# Local resume after a halt -cross-agent-resume - -# Resume all tailnet peers + local -cross-agent-resume --tailnet -``` - -## Recovery flow - -After a halt: - -1. Investigate whatever caused the halt (runaway loop, bad config, etc.). -2. Fix the underlying issue. -3. Run `cross-agent-resume`. -4. Open each Claude session that was polling and tell its agent to re-engage. -5. Confirm operation with `cross-agent-status`. - -## See also - -- `cross-agent-halt` — counterpart that creates the HALT file. -- `cross-agent-status` — verify HALT cleared and see pending messages. -- `cross-agent-comms.org` — protocol spec, `* Halt mechanism` section. diff --git a/claude-templates/.ai/scripts/cross-agent-comms/cross-agent-send b/claude-templates/.ai/scripts/cross-agent-comms/cross-agent-send deleted file mode 100755 index 68c010a..0000000 --- a/claude-templates/.ai/scripts/cross-agent-comms/cross-agent-send +++ /dev/null @@ -1,356 +0,0 @@ -#!/usr/bin/env python3 -"""Cross-agent message sender. - -See cross-agent-send.md for the full contract. Briefly: - -- Destination as <machine>.<project>; resolved via peers.toml. -- Same-machine: cp to receiver's inbox/from-agents/ with atomic rename. -- Cross-machine: rsync over SSH (typically Tailscale) with retry+backoff. -- GPG-signs by default; .asc renames before .org so receivers never see - a .org without its sibling signature. -- Generates the canonical filename; user's input filename is ignored. -- Honors the HALT file: refuses to send and exits with code 5 when set. -""" - -from __future__ import annotations - -import argparse -import datetime as _dt -import json -import os -import re -import shutil -import socket -import subprocess -import sys -import tempfile -import time -import tomllib -from pathlib import Path - -CONFIG_DIR = Path.home() / ".config" / "cross-agent-comms" -PEERS_TOML = CONFIG_DIR / "peers.toml" -HALT_FILE = CONFIG_DIR / "HALT" -STATE_DIR = Path.home() / ".local" / "state" / "cross-agent-comms" -FAILED_SENDS_DIR = STATE_DIR / "failed-sends" - -EXIT_OK = 0 -EXIT_GENERAL = 1 -EXIT_DEST_NOT_FOUND = 2 -EXIT_CROSS_MACHINE_FAILED = 3 -EXIT_FRONTMATTER = 4 -EXIT_HALT = 5 - -REQUIRED_FRONTMATTER = ["CONVERSATION_ID", "MESSAGE_TYPE", "SEQUENCE", "TIMESTAMP", "PROTOCOL_VERSION"] -VALID_MESSAGE_TYPES = {"request", "progress", "query", "pushback", "complete", "release", "escalate"} - - -def err(msg: str) -> None: - print(msg, file=sys.stderr) - - -def check_halt() -> None: - """Exit with code 5 if HALT file exists.""" - if HALT_FILE.exists(): - try: - reason = HALT_FILE.read_text().strip() - except OSError: - # Fail-closed on unreadable HALT. - err("halt active (HALT file present but unreadable; treated as halted)") - err(f"remove {HALT_FILE} to resume") - sys.exit(EXIT_HALT) - msg = "halt active" - if reason: - msg += f": {reason}" - err(msg) - err(f"remove {HALT_FILE} to resume") - sys.exit(EXIT_HALT) - - -def parse_frontmatter(path: Path) -> dict[str, str]: - """Extract org-mode #+KEY: value frontmatter from the top of the file.""" - try: - text = path.read_text() - except OSError as e: - err(f"cannot read message file: {e}") - sys.exit(EXIT_GENERAL) - - frontmatter: dict[str, str] = {} - for line in text.splitlines(): - line = line.rstrip() - if not line: - # Blank line ends the frontmatter block. - if frontmatter: - break - continue - m = re.match(r"#\+([A-Z_]+):\s*(.*)", line) - if m: - frontmatter[m.group(1)] = m.group(2).strip() - else: - # First non-frontmatter line ends parsing. - if frontmatter: - break - return frontmatter - - -def validate_frontmatter(fm: dict[str, str]) -> None: - missing = [k for k in REQUIRED_FRONTMATTER if k not in fm] - if missing: - err(f"frontmatter missing required fields: {', '.join(missing)}") - sys.exit(EXIT_FRONTMATTER) - if fm["MESSAGE_TYPE"] not in VALID_MESSAGE_TYPES: - err(f"invalid MESSAGE_TYPE: {fm['MESSAGE_TYPE']!r}; expected one of {sorted(VALID_MESSAGE_TYPES)}") - sys.exit(EXIT_FRONTMATTER) - try: - int(fm["SEQUENCE"]) - except ValueError: - err(f"SEQUENCE must be an integer; got {fm['SEQUENCE']!r}") - sys.exit(EXIT_FRONTMATTER) - - -def load_peers() -> dict: - if not PEERS_TOML.exists(): - return {} - try: - return tomllib.loads(PEERS_TOML.read_text()) - except (tomllib.TOMLDecodeError, OSError) as e: - err(f"cannot read {PEERS_TOML}: {e}") - sys.exit(EXIT_GENERAL) - - -def resolve_destination(dest: str, peers: dict) -> tuple[str, str, str | None, str | None]: - """Resolve <machine>.<project> to (machine, project, host, ssh_user). - - host is None for same-machine destinations. - """ - if "." not in dest: - err(f"destination must be <machine>.<project>; got {dest!r}") - sys.exit(EXIT_DEST_NOT_FOUND) - machine, project = dest.split(".", 1) - - local_hostname = socket.gethostname().split(".")[0] - is_local = machine == local_hostname or machine == "local" - - host = None - ssh_user = None - if not is_local: - peer_cfg = peers.get("peers", {}).get(machine) - if peer_cfg is None: - available = list(peers.get("peers", {}).keys()) - err(f"destination not found in peers.toml; available peers: {available or '(none)'}") - sys.exit(EXIT_DEST_NOT_FOUND) - host = peer_cfg.get("host", machine) - ssh_user = peer_cfg.get("ssh_user", os.environ.get("USER")) - - return machine, project, host, ssh_user - - -def resolve_inbox_path(project: str, peers: dict) -> str: - """Inbox path on the receiver. Defaults to ~/projects/<project>/inbox/from-agents.""" - proj_cfg = peers.get("projects", {}).get(project) - if proj_cfg and "inbox_path" in proj_cfg: - return os.path.expanduser(proj_cfg["inbox_path"]) - return f"~/projects/{project}/inbox/from-agents" - - -def derive_sender_project() -> str: - """Walk up from CWD looking for ~/projects/<name>/. - - Returns the project name if found; falls back to the basename of CWD. - """ - cwd = Path.cwd().resolve() - projects_root = (Path.home() / "projects").resolve() - try: - rel = cwd.relative_to(projects_root) - return rel.parts[0] - except ValueError: - return cwd.name - - -def generate_canonical_filename(sender: str, conv_id: str) -> str: - """YYYYMMDDTHHMMSSZ-from-<sender>-<conv-id>.org""" - now = _dt.datetime.now(_dt.timezone.utc) - timestamp = now.strftime("%Y%m%dT%H%M%SZ") - return f"{timestamp}-from-{sender}-{conv_id}.org" - - -def sign(message_path: Path, sig_path: Path, key: str | None) -> None: - """gpg --detach-sign --armor --output <sig> [--local-user <key>] <message>""" - cmd = ["gpg", "--detach-sign", "--armor", "--yes", "--output", str(sig_path)] - if key: - cmd.extend(["--local-user", key]) - cmd.append(str(message_path)) - try: - result = subprocess.run(cmd, capture_output=True, text=True) - except FileNotFoundError: - err("gpg not found; install gnupg or use --no-sign for testing") - sys.exit(EXIT_GENERAL) - if result.returncode != 0: - err(f"signing failed: {result.stderr.strip()}") - sys.exit(EXIT_GENERAL) - - -def same_machine_deliver(message_path: Path, sig_path: Path | None, target_dir: Path, canonical_name: str) -> None: - """Atomic-write delivery: stage .asc, mv to final, then stage .org, mv to final.""" - target_dir.mkdir(parents=True, exist_ok=True) - final_msg = target_dir / canonical_name - final_sig = target_dir / f"{canonical_name}.asc" - - if sig_path is not None: - # Stage .asc first, mv to final, THEN stage .org and mv to final. - with tempfile.NamedTemporaryFile( - mode="wb", dir=target_dir, prefix=f".tmp.{canonical_name}.asc.", delete=False - ) as tmp: - tmp.write(sig_path.read_bytes()) - tmp_sig_path = Path(tmp.name) - os.replace(tmp_sig_path, final_sig) - - # Re-check HALT between .asc and .org per the layered-checks rule. - check_halt() - - with tempfile.NamedTemporaryFile( - mode="wb", dir=target_dir, prefix=f".tmp.{canonical_name}.", delete=False - ) as tmp: - tmp.write(message_path.read_bytes()) - tmp_msg_path = Path(tmp.name) - os.replace(tmp_msg_path, final_msg) - - -def cross_machine_deliver( - message_path: Path, - sig_path: Path | None, - canonical_name: str, - host: str, - ssh_user: str, - inbox_path: str, - retries: int, -) -> bool: - """rsync push the .asc first (if signed), re-check HALT, then push the .org. - - Returns True on success, False on persistent failure (after retries). - """ - # Stage local copies with the canonical name so rsync sets the right - # destination filename. - with tempfile.TemporaryDirectory(prefix="cross-agent-send-") as staging: - staging_dir = Path(staging) - local_msg = staging_dir / canonical_name - local_msg.write_bytes(message_path.read_bytes()) - local_sig = None - if sig_path is not None: - local_sig = staging_dir / f"{canonical_name}.asc" - local_sig.write_bytes(sig_path.read_bytes()) - - backoffs = [5, 30, 120] - # Step 1: push .asc first if signed. - if local_sig is not None: - if not _rsync_with_retries(local_sig, host, ssh_user, inbox_path, retries, backoffs): - return False - - # Re-check HALT between .asc and .org per the layered-checks rule. - check_halt() - - # Step 2: push .org. - if not _rsync_with_retries(local_msg, host, ssh_user, inbox_path, retries, backoffs): - return False - - return True - - -def _rsync_with_retries( - src: Path, host: str, ssh_user: str, inbox_path: str, retries: int, backoffs: list[int] -) -> bool: - target = f"{ssh_user}@{host}:{inbox_path}/" - last_err = "" - for attempt in range(retries + 1): - if attempt > 0: - check_halt() - wait = backoffs[min(attempt - 1, len(backoffs) - 1)] - err(f"rsync attempt {attempt} failed: {last_err}; retrying in {wait}s") - time.sleep(wait) - try: - result = subprocess.run( - ["rsync", "-a", str(src), target], - capture_output=True, - text=True, - ) - except FileNotFoundError: - err("rsync not found; install rsync") - return False - if result.returncode == 0: - return True - last_err = result.stderr.strip() or f"exit {result.returncode}" - err(f"rsync failed after {retries + 1} attempts: {last_err}") - return False - - -def write_failed_send_marker(dest: str, message_path: Path, error: str, retry_log: list[str]) -> None: - FAILED_SENDS_DIR.mkdir(parents=True, exist_ok=True) - timestamp = _dt.datetime.now(_dt.timezone.utc).strftime("%Y%m%dT%H%M%SZ") - safe_basename = re.sub(r"[^A-Za-z0-9._-]", "_", message_path.name) - marker = FAILED_SENDS_DIR / f"{timestamp}-{dest.replace('.', '-')}-{safe_basename}.json" - marker.write_text(json.dumps( - { - "timestamp": timestamp, - "destination": dest, - "message_path": str(message_path), - "error": error, - "retry_log": retry_log, - }, - indent=2, - )) - err(f"marker written: {marker}") - - -def main() -> int: - parser = argparse.ArgumentParser(description="Send a cross-agent message.") - parser.add_argument("destination", help="Destination as <machine>.<project>") - parser.add_argument("message_file", type=Path, help="Path to the message body file") - parser.add_argument("--no-sign", action="store_true", help="Skip GPG signing (testing only)") - parser.add_argument("--retries", type=int, default=3, help="Retry count for cross-machine sends") - parser.add_argument("--key", help="GPG key id to sign with (default: user's primary)") - args = parser.parse_args() - - check_halt() - - if not args.message_file.is_file(): - err(f"message file not found: {args.message_file}") - return EXIT_GENERAL - - fm = parse_frontmatter(args.message_file) - validate_frontmatter(fm) - - peers = load_peers() - machine, project, host, ssh_user = resolve_destination(args.destination, peers) - inbox_path = resolve_inbox_path(project, peers) - - sender = derive_sender_project() - canonical_name = generate_canonical_filename(sender, fm["CONVERSATION_ID"]) - - sig_tmp = None - if not args.no_sign: - sig_tmp = args.message_file.with_suffix(args.message_file.suffix + ".asc.tmp") - sign(args.message_file, sig_tmp, args.key) - - try: - if host is None: - # Same-machine delivery. - target_dir = Path(os.path.expanduser(inbox_path)) - same_machine_deliver(args.message_file, sig_tmp, target_dir, canonical_name) - print(f"sent: {target_dir}/{canonical_name}") - return EXIT_OK - else: - ok = cross_machine_deliver( - args.message_file, sig_tmp, canonical_name, host, ssh_user, inbox_path, args.retries - ) - if ok: - print(f"sent: {ssh_user}@{host}:{inbox_path}/{canonical_name}") - return EXIT_OK - write_failed_send_marker(args.destination, args.message_file, "rsync failed after retries", []) - return EXIT_CROSS_MACHINE_FAILED - finally: - if sig_tmp is not None and sig_tmp.exists(): - sig_tmp.unlink() - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/claude-templates/.ai/scripts/cross-agent-comms/cross-agent-send.md b/claude-templates/.ai/scripts/cross-agent-comms/cross-agent-send.md deleted file mode 100644 index 29bfb24..0000000 --- a/claude-templates/.ai/scripts/cross-agent-comms/cross-agent-send.md +++ /dev/null @@ -1,199 +0,0 @@ -# cross-agent-send - -**Purpose.** Send a cross-agent message file to a specific destination. Handles -peer-config lookup, GPG signing, atomic write (same-machine) or rsync push -(cross-machine), retry-with-backoff, and failure surfacing. - -This is the canonical writer. The protocol spec defers all writer mechanics to -this script. - -## Usage - -``` -cross-agent-send <destination> <message-file> [--no-sign] [--retries N] -``` - -### Positional arguments - -| Position | Meaning | Example | -|---|---|---| -| 1 | Destination as `<machine>.<project>` | `homelab.career`, `velox.career` | -| 2 | Message file (already-formatted `.org`) | `/tmp/my-message.org` | - -### Flags - -| Flag | Default | Purpose | -|---|---|---| -| `--no-sign` | (signing on) | Skip GPG signing. Use only for testing; receivers reject unsigned messages by default. | -| `--retries N` | 3 | Override retry count for cross-machine sends. | -| `--key <key-id>` | (user's primary key) | GPG key to sign with. Resolution order: `--key` flag, `GPG_USER` env, `git config user.signingkey`, then the first secret key in the keyring. | - -## Behavior - -### Filename generation (script-controlled) - -The script generates the canonical destination filename from the message's -frontmatter and sender context. The user's input filename is ignored — pass any -path, the script names the destination correctly: - -``` -<UTC-now>T<HHMMSS>Z-from-<sender-slug>-<short-conv-id>.org -``` - -`<sender-slug>` comes from the sender machine's project name (config or -hostname-based). `<short-conv-id>` is read from the message's -`#+CONVERSATION_ID` frontmatter field. UTC timestamp is generated at send time. - -The script also performs the **sender-side max-seen scan** before writing: it -reads the receiver's `from-agents/` directory, finds the highest existing -sequence in this conversation across both sender prefixes, and (best-effort) -suggests `max(seen) + 1` for the next sequence. The user/agent is responsible -for setting `#+SEQUENCE` in the message body; the script only advises. - -### Same-machine destinations - -Resolved when the destination's machine matches the current hostname (or is -not in `peers.toml` as a remote). Steps: - -1. Parse frontmatter; extract `CONVERSATION_ID` and `TIMESTAMP`. Validate per - the *Validation before send* section below. -2. Generate canonical filename per *Filename generation* above. -3. Sign: `gpg --detach-sign --armor --output <canonical>.asc --local-user <key> <input>`. -4. Compute target: read `peers.toml` for the project's `inbox_path`. If - missing, fall back to `~/projects/<project>/inbox/from-agents/`. -5. **Atomic write with strict ordering** (signature must precede message): - - Stage `.asc`: write to `<target>/.tmp.XXXXXX-<canonical>.asc`, - then `mv` to `<target>/<canonical>.asc`. - - **Then** stage `.org`: write to `<target>/.tmp.XXXXXX-<canonical>`, - then `mv` to `<target>/<canonical>`. - - Receivers only act on `.org` files; staging the `.asc` first guarantees - the signature is present when the receiver opens the message. Out-of-order - would race: receiver could read the `.org` before the `.asc` lands and - fail GPG verify even though the sender did everything right. -6. Exit 0 on success. Exit non-zero if any step fails. - -### Cross-machine destinations - -Steps: - -1. Parse + generate canonical filename, as same-machine steps 1-2. -2. Sign locally to `<input>.asc` (or a tmp staging file). -3. rsync push **with the same .asc-first ordering**: - - `rsync -a <input>.asc <ssh-user>@<host>:<inbox_path>/<canonical>.asc` - - **Then** `rsync -a <input> <ssh-user>@<host>:<inbox_path>/<canonical>` - rsync writes to a hidden temp file then renames atomically by default - (`--inplace` would defeat this; do not pass it). -4. Retry on failure: 5s, 30s, 120s backoff, then surface error. -5. On persistent failure: write a marker file to - `~/.local/state/cross-agent-comms/failed-sends/<timestamp>-<dest>-<canonical>.json` - containing the destination, message path, error, and retry log. Exit non-zero. - -### Validation before send - -- Destination resolves via `peers.toml` (or local fallback). If neither, exit - immediately with `destination not found in peers.toml; available: <list>`. -- Message file must be readable, non-empty, and have valid org-mode frontmatter - with **all** of the following required fields: - - `#+TITLE` - - `#+CONVERSATION_ID` - - `#+MESSAGE_TYPE` - - `#+SEQUENCE` - - `#+TIMESTAMP` - - `#+PROTOCOL_VERSION` (must equal `5` for v5) - - If any required field is missing or malformed, exit immediately with a parse - error naming the offending field. - -- Optional fields the script recognizes and passes through (no special - handling beyond preservation): - - `#+REQUIRES_TOOLS` — comma-separated tool/MCP slugs the receiver needs. - - `#+RELEASE_STATUS` — valid only on `MESSAGE_TYPE: release`. Values per - spec: `complete`, `cancelled`, `withdrawn-after-pushback`, - `abandoned-after-escalation`. - - `#+WORKFLOW_VERSION` — sender's version of the cross-agent-comms workflow - file. Currently advisory; receiver may warn on mismatch but does not block. - -## Configuration - -Reads `~/.config/cross-agent-comms/peers.toml` for peer routing: - -```toml -[peers.velox] -host = "velox.local" -ssh_user = "cjennings" - -# Optional: per-project inbox-path overrides for non-default layouts. -[projects.work] -inbox_path = "~/projects/work/inbox/from-agents" - -[projects.homelab] -inbox_path = "~/projects/homelab/inbox/from-agents" -``` - -If a project entry is omitted, defaults to `~/projects/<project>/inbox/from-agents`. - -## Failure modes - -| Symptom | Cause | Fix | -|---|---|---| -| `destination not found in peers.toml` | Misspelled destination, or peer not configured | Run `cross-agent-discover` to see available destinations. | -| `signing failed: no secret key` | GPG key missing or not in keyring | `gpg --list-secret-keys` to confirm. Override with `--key <id>`. | -| `signing failed: pinentry timed out` | Headless session, GUI pinentry unavailable | Confirm `pinentry-program` in `gpg-agent.conf` matches available pinentry. Per protocols.org, GUI pinentry works from Claude Code. | -| `rsync exit 255` | SSH unreachable | `cross-agent-discover --peer <name>` to confirm reachability. | -| `rsync exit 23` | Permission denied at destination | Check destination directory perms (`chmod 700`) and ownership. | -| Marker file written to `failed-sends/` | Persistent cross-machine failure | Inspect the marker's `error` field. After fixing, retry: `cross-agent-send <dest> <msg>` (the marker is for visibility; it does not auto-retry). | -| Receiver complains "unsigned message" | `--no-sign` was used in production | Don't use `--no-sign` outside testing. | - -## HALT awareness - -Checks `~/.config/cross-agent-comms/HALT` at the start of every send AND -between the `.asc` and `.org` rsync calls AND between each retry iteration. -On HALT exists, exits with code 5 ("halt active; remove -~/.config/cross-agent-comms/HALT to resume") without writing or pushing -further. - -Worst case: one in-flight send completes its current rsync step within a few -seconds before halt kicks in for the next step. New sends are blocked -immediately. No `pkill` needed — the per-iteration check stops things -naturally. - -If the HALT file exists but is unreadable (permissions wrong), fail-closed — -treat as if HALT is set. Safer than fail-open. - -See `cross-agent-halt.md` for the full halt mechanism. - -## Examples - -```bash -# Same-machine send -cross-agent-send homelab.career /tmp/my-message.org - -# Cross-machine send via Tailscale -cross-agent-send velox.career /tmp/my-message.org - -# Test send without signing (receiver will reject) -cross-agent-send homelab.career /tmp/test.org --no-sign - -# Override retry count for a flaky link -cross-agent-send velox.career /tmp/my-message.org --retries 10 - -# After a delivery failure, inspect the marker -cat ~/.local/state/cross-agent-comms/failed-sends/*.json | jq . -``` - -## Exit codes - -| Code | Meaning | -|---|---| -| 0 | Sent successfully. | -| 1 | General error (parse failure, signing failure, etc.). | -| 2 | Destination not found in peers.toml. | -| 3 | Cross-machine delivery failed after retries. Marker file written. | -| 4 | Frontmatter validation failed. | - -## See also - -- `cross-agent-discover` — validate destinations before sending. -- `cross-agent-watch` — receiver-side notification. -- `cross-agent-status` — see what's queued. -- `cross-agent-comms.org` — protocol spec, the "what" the script implements. diff --git a/claude-templates/.ai/scripts/cross-agent-comms/cross-agent-status b/claude-templates/.ai/scripts/cross-agent-comms/cross-agent-status deleted file mode 100755 index 4eee75b..0000000 --- a/claude-templates/.ai/scripts/cross-agent-comms/cross-agent-status +++ /dev/null @@ -1,185 +0,0 @@ -#!/usr/bin/env python3 -"""Point-in-time snapshot of pending cross-agent messages across local projects. - -See cross-agent-status.md. Pending = messages in inbox/from-agents/ whose -CONVERSATION_ID has no MESSAGE_TYPE: release at a later #+TIMESTAMP. - -HALT: prints a prominent banner before normal output, but continues to enumerate. -""" - -from __future__ import annotations - -import argparse -import glob -import json -import os -import re -import sys -from pathlib import Path - -CONFIG_DIR = Path.home() / ".config" / "cross-agent-comms" -HALT_FILE = CONFIG_DIR / "HALT" -DEFAULT_GLOB = str(Path.home() / "projects" / "*" / "inbox" / "from-agents") + "/" - - -def parse_frontmatter(path: Path) -> dict[str, str]: - try: - text = path.read_text() - except OSError: - return {} - fm: dict[str, str] = {} - for line in text.splitlines(): - line = line.rstrip() - if not line: - if fm: - break - continue - m = re.match(r"#\+([A-Z_]+):\s*(.*)", line) - if m: - fm[m.group(1)] = m.group(2).strip() - elif fm: - break - return fm - - -def project_name_from_path(path: str) -> str: - """Walk up from path to find ~/projects/<name>/...""" - home = str(Path.home()) - parts = Path(path).parts - for i, part in enumerate(parts): - if part == "projects" and i + 1 < len(parts) and str(Path(*parts[: i + 1])) == os.path.join(home, "projects"): - return parts[i + 1] - # Fallback: dir three levels up from the .org file (project/inbox/from-agents/file.org) - return Path(path).parent.parent.parent.name - - -def scan_project(inbox_dir: Path) -> tuple[int, str | None, int | None]: - """Return (pending_count, most_recent_filename_or_None, most_recent_age_seconds_or_None).""" - if not inbox_dir.is_dir(): - return 0, None, None - - # Group .org files by CONVERSATION_ID, also collect release timestamps per conv. - org_files = sorted(inbox_dir.glob("*.org")) - if not org_files: - return 0, None, None - - by_conv: dict[str, list[tuple[str, str, Path]]] = {} # conv_id -> [(timestamp, msg_type, path)] - for f in org_files: - fm = parse_frontmatter(f) - conv = fm.get("CONVERSATION_ID") - ts = fm.get("TIMESTAMP") - mt = fm.get("MESSAGE_TYPE") - if not conv or not ts or not mt: - # Malformed file: count as pending under conv "_unparseable". - by_conv.setdefault("_unparseable", []).append(("", "request", f)) - continue - by_conv.setdefault(conv, []).append((ts, mt, f)) - - pending_files: list[Path] = [] - for conv, entries in by_conv.items(): - entries.sort(key=lambda e: e[0]) - # Find the latest release timestamp. - release_ts = None - for ts, mt, _f in entries: - if mt == "release" and (release_ts is None or ts > release_ts): - release_ts = ts - for ts, mt, f in entries: - if mt == "release": - continue - if release_ts is not None and ts <= release_ts: - continue - pending_files.append(f) - - if not pending_files: - return 0, None, None - - # Most-recent by mtime (proxy for arrival order). - most_recent = max(pending_files, key=lambda p: p.stat().st_mtime) - import time - age = int(time.time() - most_recent.stat().st_mtime) - return len(pending_files), most_recent.name, age - - -def fmt_age(seconds: int | None) -> str: - if seconds is None: - return "—" - if seconds < 60: - return f"{seconds}s ago" - if seconds < 3600: - return f"{seconds // 60} min ago" - if seconds < 86400: - return f"{seconds // 3600} hr ago" - return f"{seconds // 86400} day(s) ago" - - -def render_banner_if_halt() -> None: - if not HALT_FILE.exists(): - return - try: - reason = HALT_FILE.read_text().strip() - except OSError: - reason = "(HALT file unreadable; treated as halted)" - print("⚠ HALT ACTIVE — cross-agent comms paused") - if reason: - print(f" reason: {reason}") - print(f" clear: rm {HALT_FILE} (or: cross-agent-resume)") - print() - - -def main() -> int: - parser = argparse.ArgumentParser(description="Snapshot of pending cross-agent messages across local projects.") - parser.add_argument("--json", action="store_true", help="Emit JSON output") - parser.add_argument("--projects-glob", default=DEFAULT_GLOB, - help=f"Glob for project from-agents dirs (default: {DEFAULT_GLOB})") - args = parser.parse_args() - - render_banner_if_halt() - - matched = sorted(glob.glob(args.projects_glob)) - rows = [] - for path in matched: - inbox = Path(path) - if not inbox.is_dir(): - continue - proj = project_name_from_path(path) - count, most_recent, age = scan_project(inbox) - rows.append({ - "name": proj, - "pending_count": count, - "most_recent": ( - {"filename": most_recent, "age_seconds": age} - if most_recent else None - ), - }) - - # Sort: pending-first, then alphabetical by name. - rows.sort(key=lambda r: (-r["pending_count"], r["name"])) - - if args.json: - import datetime as _dt - payload = { - "scanned_at": _dt.datetime.now(_dt.timezone.utc).isoformat(), - "halt_active": HALT_FILE.exists(), - "projects": rows, - } - print(json.dumps(payload, indent=2)) - return 0 - - if not rows: - print("No projects with inbox/from-agents/ found — 0 pending.") - return 0 - - # Human-readable table. - name_w = max(len("project"), max(len(r["name"]) for r in rows)) - print(f"{'project':<{name_w}} pending most-recent") - for r in rows: - most_recent_str = "—" - if r["most_recent"]: - most_recent_str = f"{r['most_recent']['filename']} ({fmt_age(r['most_recent']['age_seconds'])})" - print(f"{r['name']:<{name_w}} {r['pending_count']:<7} {most_recent_str}") - - return 0 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/claude-templates/.ai/scripts/cross-agent-comms/cross-agent-status.md b/claude-templates/.ai/scripts/cross-agent-comms/cross-agent-status.md deleted file mode 100644 index 070330c..0000000 --- a/claude-templates/.ai/scripts/cross-agent-comms/cross-agent-status.md +++ /dev/null @@ -1,139 +0,0 @@ -# cross-agent-status - -**Purpose.** Point-in-time snapshot of pending cross-agent messages across -every project on this machine. Run from any terminal. No daemon required. - -This is the user-pull layer of the cold-start story — `cross-agent-watch` -pushes notifications, `cross-agent-status` lets the user query. - -## Usage - -``` -cross-agent-status [--json] [--projects-glob <glob>] -``` - -No args required. - -### Flags - -| Flag | Default | Purpose | -|---|---|---| -| `--json` | off (table) | Output as JSON for scripting. | -| `--projects-glob <glob>` | `~/projects/*/inbox/from-agents/` | Override which directories to scan. | - -## Output - -### Default (table) - -``` -$ cross-agent-status -project pending most-recent -career 0 — -claude-templates 0 — -clipper 0 — -homelab 1 20260427T085611Z-from-career-question.org (3 min ago) -finances 0 — -... (other 9 projects) -``` - -Sort: pending-first, then alphabetical. - -### `--json` - -```json -{ - "scanned_at": "2026-04-27T04:13:00-05:00", - "projects": [ - { - "name": "homelab", - "pending_count": 1, - "most_recent": { - "filename": "20260427T085611Z-from-career-question.org", - "age_seconds": 180 - } - }, - ... - ] -} -``` - -## Pending semantics - -A message is "pending" if it sits in `inbox/from-agents/` AND no -`MESSAGE_TYPE: release` exists for the same `CONVERSATION_ID` after it. - -Concretely: - -1. Scan each project's `inbox/from-agents/` for `.org` files. -2. Group by `CONVERSATION_ID` from frontmatter. -3. For each conversation, find the highest-`#+TIMESTAMP` message with - `MESSAGE_TYPE: release`. -4. Messages with `#+TIMESTAMP` after that release (or in conversations with no - release) count as pending. - -Files without parseable frontmatter are counted as pending and noted in the -output (single warning row per project). - -## Failure modes - -| Symptom | Likely cause | Fix | -|---|---|---| -| Project missing from output | Project's `.ai/` directory exists but `inbox/from-agents/` does not | Created lazily on first cross-agent message; `mkdir -p` to surface in output. | -| All projects show "0 pending" but you know one has messages | Glob misresolved, OR all messages are post-release | `cross-agent-status --projects-glob` with explicit path to confirm. | -| Warning row "N files unparseable in <project>" | Message file has invalid frontmatter | Open the file, fix or move out. | - -## Performance - -Scans every `.org` file in every watched directory. For Craig's setup (14 -projects, single-digit messages each), runs in <100ms. If a project -accumulates hundreds of post-release messages, archive them per the persistence -guidance in the protocol spec. - -## HALT awareness - -Checks `~/.config/cross-agent-comms/HALT` at start. If HALT exists, prints a -prominent banner before normal output: - -``` -$ cross-agent-status -⚠ HALT ACTIVE — cross-agent comms paused - Reason: investigating runaway poll loop, 2026-04-27 - HALT file: ~/.config/cross-agent-comms/HALT - Resume with: cross-agent-resume - -(snapshot continues normally — HALT does not suppress visibility) - -project pending most-recent -career 0 — -homelab 1 20260427T085611Z-from-career-question.org (3 min ago) -... -``` - -Status is read-only, so it always runs. The banner ensures the user can't -miss that halt is active when checking inbox state. Reason text comes from -the HALT file's body; if empty, omit the reason line. - -If the HALT file exists but is unreadable, print a warning banner ("HALT -file present but unreadable; treat as halted") and continue with normal -output. - -See `cross-agent-halt.md` for the full halt mechanism. - -## Examples - -```bash -# Snapshot -cross-agent-status - -# JSON for piping -cross-agent-status --json | jq '.projects[] | select(.pending_count > 0)' - -# Single-project query -cross-agent-status --projects-glob ~/projects/work/inbox/from-agents/ -``` - -## See also - -- `cross-agent-watch` — push notifications on new arrivals. -- `cross-agent-discover` — enumerate available agents (cross-machine). -- `cross-agent-comms.org` — protocol spec. diff --git a/claude-templates/.ai/scripts/cross-agent-comms/cross-agent-watch b/claude-templates/.ai/scripts/cross-agent-comms/cross-agent-watch deleted file mode 100755 index f50ba26..0000000 --- a/claude-templates/.ai/scripts/cross-agent-comms/cross-agent-watch +++ /dev/null @@ -1,106 +0,0 @@ -#!/usr/bin/env bash -# cross-agent-watch — desktop-notify on new cross-agent messages. -# -# See cross-agent-watch.md. Watches every ~/projects/*/inbox/from-agents/ by -# default. inotifywait fires create + moved_to events; .tmp.* files are -# filtered out. HALT suppresses notifications but the watcher keeps running -# and logs each event with "(suppressed by HALT)". - -set -uo pipefail - -# Defaults. -PROJECTS_GLOB="${HOME}/projects/*/inbox/from-agents/" -LOG_FILE="${HOME}/.local/state/cross-agent-comms/watch.log" -HALT_FILE="${HOME}/.config/cross-agent-comms/HALT" -QUIET=0 -NO_NOTIFY=0 - -# Arg parsing. -while [[ $# -gt 0 ]]; do - case "$1" in - --projects-glob) - PROJECTS_GLOB="$2"; shift 2 ;; - --log) - LOG_FILE="$2"; shift 2 ;; - --quiet) - QUIET=1; shift ;; - --no-notify) - NO_NOTIFY=1; shift ;; - -h|--help) - cat <<EOF -Usage: cross-agent-watch [--projects-glob GLOB] [--log PATH] [--quiet] [--no-notify] - -Watches inbox/from-agents/ directories for new cross-agent messages and fires -desktop notifications. See cross-agent-watch.md for details. -EOF - exit 0 ;; - *) - echo "unknown flag: $1" >&2; exit 1 ;; - esac -done - -# Resolve glob to a concrete list of directories. -# shellcheck disable=SC2086 -DIRS=( $PROJECTS_GLOB ) -# Filter out non-existent paths (glob may include literal pattern when no match). -EXISTING=() -for d in "${DIRS[@]}"; do - if [[ -d "$d" ]]; then - EXISTING+=( "$d" ) - fi -done - -if [[ ${#EXISTING[@]} -eq 0 ]]; then - echo "cross-agent-watch: glob resolved 0 directories: $PROJECTS_GLOB" >&2 - exit 1 -fi - -# Ensure log dir exists. -mkdir -p "$(dirname "$LOG_FILE")" - -[[ $QUIET -eq 0 ]] && echo "cross-agent-watch: watching ${#EXISTING[@]} dir(s); log: $LOG_FILE" - -# Helper: project name from path like /home/.../projects/<name>/inbox/from-agents/... -project_name() { - local path="$1" - # Match ~/projects/<name>/... - if [[ "$path" =~ ${HOME}/projects/([^/]+)/ ]]; then - echo "${BASH_REMATCH[1]}" - else - basename "$(dirname "$(dirname "$path")")" - fi -} - -# Main loop. inotifywait emits one line per event in the format -# "<full-path>" because we passed --format '%w%f'. -inotifywait -m -e create,moved_to --format '%w%f' "${EXISTING[@]}" 2>/dev/null \ - | while IFS= read -r path; do - filename="$(basename "$path")" - - # Filter .tmp.* staging files. - case "$filename" in - .tmp.*) continue ;; - esac - - # Filter .asc sidecars — they land first per the atomic-write ordering; - # the .org event will fire after. - case "$filename" in - *.asc) continue ;; - esac - - proj="$(project_name "$path")" - iso="$(date -u "+%Y-%m-%dT%H:%M:%SZ")" - - if [[ -e "$HALT_FILE" ]]; then - printf '%s\t%s\t%s\t(suppressed by HALT)\n' "$iso" "$proj" "$filename" >> "$LOG_FILE" - [[ $QUIET -eq 0 ]] && echo "[$iso] $proj: $filename (suppressed by HALT)" - continue - fi - - printf '%s\t%s\t%s\n' "$iso" "$proj" "$filename" >> "$LOG_FILE" - [[ $QUIET -eq 0 ]] && echo "[$iso] $proj: $filename" - - if [[ $NO_NOTIFY -eq 0 ]]; then - notify info "Cross-agent message" "${proj}: ${filename}" --persist 2>/dev/null || true - fi - done diff --git a/claude-templates/.ai/scripts/cross-agent-comms/cross-agent-watch.md b/claude-templates/.ai/scripts/cross-agent-comms/cross-agent-watch.md deleted file mode 100644 index 04e8005..0000000 --- a/claude-templates/.ai/scripts/cross-agent-comms/cross-agent-watch.md +++ /dev/null @@ -1,130 +0,0 @@ -# cross-agent-watch - -**Purpose.** Long-running watcher that fires desktop notifications when new -cross-agent messages land in any project's `inbox/from-agents/` directory. -This is the primary cold-start mechanism: messages get noticed even when no -Claude session is active. - -## Usage - -``` -cross-agent-watch [--projects-glob <glob>] [--log <path>] -``` - -No args required. Defaults: - -- Watches `~/projects/*/inbox/from-agents/` (matches every project with the - cross-agent-comms convention). -- Logs each event to `~/.local/state/cross-agent-comms/watch.log`. - -### Flags - -| Flag | Default | Purpose | -|---|---|---| -| `--projects-glob <glob>` | `~/projects/*/inbox/from-agents/` | Override which directories to watch. Useful for testing on a single project. | -| `--log <path>` | `~/.local/state/cross-agent-comms/watch.log` | Override log location. Set to `/dev/null` to disable logging. | -| `--quiet` | off | Suppress stdout output. Notifications still fire. | -| `--no-notify` | off | Skip `notify` calls. Useful for testing the watcher loop without spamming notifications. | - -## Behavior - -1. Resolves the projects-glob to a concrete list of directories at startup. - New projects added to `~/projects/` after startup are NOT picked up — restart - the watcher to re-resolve. -2. Runs `inotifywait -m -e create,moved_to --format '%w%f'` against each - watched directory. -3. For each event, calls - `notify info "Cross-agent message" "<project>: <filename>" --persist`. The - `--persist` flag keeps the page on screen until dismissed, so an inbound - message that arrives while Craig is away from the desk isn't missed. -4. Appends an event line to the log: - `<ISO-8601-timestamp>\t<project>\t<filename>`. - -## Event filtering - -- Watches `create` AND `moved_to` events. The `moved_to` part is critical for - the atomic-write convention (`mktemp` + `mv` produces a `moved_to`, not a - `create`). -- Files starting with `.tmp.` are ignored — they're staging files from - in-progress writes that should never produce a notification. - -## Installation - -### Option A — tmux pane (personal, easy) - -Run in a tmux pane that survives session disconnects: - -``` -tmux new -d -s cross-agent-watch 'cross-agent-watch' -``` - -### Option B — systemd user service (production) - -Provided files: - -- `~/.config/systemd/user/cross-agent-watch.service` -- `~/.config/systemd/user/cross-agent-watch.path` - -Enable with: - -``` -systemctl --user enable --now cross-agent-watch.path -``` - -The path unit triggers the service unit on filesystem changes; the service -unit re-execs `cross-agent-watch` if it dies. Survives reboot. - -## Failure modes - -| Symptom | Likely cause | Fix | -|---|---|---| -| No notifications fire on new files | inotifywait not running, or glob resolved to zero dirs | Check `cross-agent-watch --projects-glob ... --quiet` exits non-zero immediately. Log shows `"resolved 0 directories"`. | -| Notifications fire on `.tmp.` files | Filter regression | Verify `inotifywait` events show the `.tmp.` files; if so check this script's filter logic. | -| Some files missed under rapid bursts | inotify queue overflow | Increase `fs.inotify.max_queued_events` sysctl. Default 16384 is usually fine. | -| Permission denied on a watched dir | Directory perms wrong | `chmod 700 <dir>` and confirm owner. | - -## HALT awareness - -Checks `~/.config/cross-agent-comms/HALT` on each iteration (each inotifywait -event fired). If HALT exists, the watcher continues running but **suppresses -the `notify` call**. The event is still logged, with `(suppressed by HALT)` -appended: - -``` -2026-04-27T04:42:00-05:00 career 20260427T094200Z-from-homelab-test.org (suppressed by HALT) -``` - -Logged-but-suppressed events are useful for the operator to see what would -have fired during the halt window — helpful for diagnosing whatever caused -the halt. - -When HALT clears, suppression stops; subsequent events fire normally. Backlog -events that arrived during halt are NOT replayed — they get picked up via -cold-start handling (status CLI, agent startup check, or the next agent -poll once polling resumes). - -If the HALT file exists but is unreadable, fail-closed (suppress) — safer -than fail-open. - -See `cross-agent-halt.md` for the full halt mechanism. - -## Examples - -```bash -# Watch all projects, log everything, fire notifications -cross-agent-watch - -# Test against a single project, no notifications, verbose -cross-agent-watch \ - --projects-glob "$HOME/projects/work/inbox/from-agents/" \ - --no-notify - -# Production-style: quiet stdout, log only -cross-agent-watch --quiet -``` - -## See also - -- `cross-agent-status` — point-in-time snapshot of pending messages. -- `cross-agent-send` — counterpart writer. -- `cross-agent-comms.org` — protocol spec. diff --git a/claude-templates/.ai/scripts/flashcard-to-anki.py b/claude-templates/.ai/scripts/flashcard-to-anki.py index 7227683..ca4c70b 100755 --- a/claude-templates/.ai/scripts/flashcard-to-anki.py +++ b/claude-templates/.ai/scripts/flashcard-to-anki.py @@ -13,9 +13,11 @@ Parses org-drill structure: text (sans :drill: tag). Back = entry body with newlines converted to <br>. -Deck name defaults to the input basename, case preserved. Deck and model -IDs are derived from the deck name via stable hash so re-importing the -same deck updates existing cards instead of duplicating them. +Deck name defaults to the org #+TITLE: (so the phone deck reads as the +curated title), falling back to the input basename when the source has +no #+TITLE. Deck and model IDs are derived from the deck name via stable +hash so re-importing the same deck updates existing cards instead of +duplicating them. Output defaults to ~/sync/phone/anki/<input-basename>.apkg. The .apkg is a mobile-Anki artifact the phone picks up from its sync dir, so it lands @@ -177,7 +179,19 @@ def build(cards: list[tuple[str, str, str]], deck_name: str) -> genanki.Deck: return deck -def default_deck_name(input_path: Path) -> str: +def default_deck_name(input_path: Path, org_text: str) -> str: + """Deck name defaults to the org #+TITLE:, falling back to the basename. + + The #+TITLE drives both the org-drill display in Emacs and the Anki + deck name on the phone, so the consumed deck reads as the curated + title ("Refutations") rather than the filename slug + ("refutation-drill"). Falls back to the input basename (case + preserved) when the source has no non-empty #+TITLE line. + """ + for line in org_text.splitlines(): + m = re.match(r"^#\+TITLE:\s*(.*\S)\s*$", line, re.IGNORECASE) + if m: + return m.group(1).strip() return input_path.stem @@ -197,7 +211,7 @@ def main() -> int: ) parser.add_argument( "--deck", - help="Deck name. Defaults to the input basename.", + help="Deck name. Defaults to the org #+TITLE, or the input basename.", ) parser.add_argument( "--output", @@ -213,7 +227,7 @@ def main() -> int: return 1 org_text = input_path.read_text(encoding="utf-8") - deck_name = args.deck or default_deck_name(input_path) + deck_name = args.deck or default_deck_name(input_path, org_text) output_path: Path = (args.output or default_output_path(input_path)).expanduser().resolve() output_path.parent.mkdir(parents=True, exist_ok=True) diff --git a/claude-templates/.ai/scripts/inbox-send.py b/claude-templates/.ai/scripts/inbox-send.py index 5373bd4..1ebb636 100755 --- a/claude-templates/.ai/scripts/inbox-send.py +++ b/claude-templates/.ai/scripts/inbox-send.py @@ -136,8 +136,21 @@ def slugify_filename(stem: str, max_length: int = MAX_SLUG_LENGTH) -> str: return truncated.strip("-._") +def display_name(path: Path) -> str: + """The name a project is referred to by — its basename with dots stripped. + + Dotted directories (`.emacs.d`, `.dotfiles`) are awkward to name in + conversation, so they're addressed dot-stripped: `emacsd`, `dotfiles`. + """ + return path.name.replace(".", "") + + def find_target(target_name: str, projects: list[Path]) -> Path | None: - """Resolve `target_name` against the project list (basename or numeric index).""" + """Resolve `target_name` against the project list (basename or numeric index). + + An exact basename match wins. Failing that, a dot-stripped alias matches — + so `emacsd` resolves `.emacs.d` and `dotfiles` resolves `.dotfiles`. + """ if target_name.isdigit(): idx = int(target_name) - 1 if 0 <= idx < len(projects): @@ -146,6 +159,10 @@ def find_target(target_name: str, projects: list[Path]) -> Path | None: for p in projects: if p.name == target_name: return p + norm = target_name.replace(".", "") + for p in projects: + if display_name(p) == norm: + return p return None @@ -160,6 +177,23 @@ def build_text_org(message: str, source_name: str, timestamp: str) -> str: ) +def uniquify(dest: Path) -> Path: + """Return dest, or dest with a -2/-3/... stem suffix when it already exists. + + Two sends in the same minute whose text starts with the same phrase + derive identical filenames, and the second silently overwrote the + first (a message was lost this way, 2026-07-02). Never overwrite. + """ + if not dest.exists(): + return dest + n = 2 + while True: + candidate = dest.with_name(f"{dest.stem}-{n}{dest.suffix}") + if not candidate.exists(): + return candidate + n += 1 + + def send_text( target_inbox: Path, message: str, @@ -174,7 +208,7 @@ def send_text( if not slug: raise ValueError(f"could not derive a slug from text: {message!r}") filename = f"{now.strftime(TS_FILENAME_FMT)}-from-{source_name}-{slug}.org" - dest = target_inbox / filename + dest = uniquify(target_inbox / filename) dest.write_text(build_text_org(message, source_name, now.strftime(TS_DOC_FMT))) return dest @@ -194,7 +228,7 @@ def send_file( raise ValueError(f"could not derive a slug from file: {src_path}") ext = src_path.suffix filename = f"{now.strftime(TS_FILENAME_FMT)}-from-{source_name}-{slug}{ext}" - dest = target_inbox / filename + dest = uniquify(target_inbox / filename) shutil.copy2(src_path, dest) return dest @@ -206,9 +240,9 @@ def print_project_list(projects: list[Path], current: Path | None) -> None: print("No projects (.ai/ + inbox/) found under the configured roots.") return print(f"Available .ai projects ({len(others)}):") - width = max(len(p.name) for p in others) + width = max(len(display_name(p)) for p in others) for i, p in enumerate(others, 1): - print(f" {i}. {p.name:<{width}} {p}") + print(f" {i}. {display_name(p):<{width}} {p}") def main() -> int: diff --git a/claude-templates/.ai/scripts/lint-org.el b/claude-templates/.ai/scripts/lint-org.el index 8f55cc6..55727ef 100644 --- a/claude-templates/.ai/scripts/lint-org.el +++ b/claude-templates/.ai/scripts/lint-org.el @@ -2,16 +2,19 @@ ;; ;; Usage: ;; emacs --batch -q -l lint-org.el FILE.org [FILE.org ...] +;; report only (the default) — categorize without modifying the file. +;; A linter reports, it doesn't write; mutation requires --fix. +;; --check is accepted as an explicit alias of this default. +;; +;; emacs --batch -q -l lint-org.el --fix FILE.org [FILE.org ...] ;; apply mechanical fixes in place, emit judgment items on stdout for the ;; command layer to walk ;; -;; emacs --batch -q -l lint-org.el --check FILE.org [FILE.org ...] -;; report only — categorize without modifying the file -;; -;; emacs --batch -q -l lint-org.el --followups-file=PATH FILE.org +;; emacs --batch -q -l lint-org.el --fix --followups-file=PATH FILE.org ;; apply mechanical fixes; if any judgment items remain, append them to ;; PATH as an org section dated today. Used by wrap-it-up to defer the ;; judgment walk to the next morning's review without blocking the wrap. +;; (--followups-file only writes in --fix mode.) ;; ;; Mechanical categories (auto-fixed): ;; item-number add [@N] directive to drifted bullets @@ -29,6 +32,13 @@ ;; link-to-local-file broken file: links ;; invalid-fuzzy-link broken *Heading refs ;; suspicious-language-in-src-block unknown source-block language +;; org-table-standard table wider than budget / missing rules +;; level-2-dated-header ** dated header instead of a keyword +;; indented-heading whitespace before stars (demoted to body) +;; empty-heading bare stars with no title +;; malformed-priority-cookie [#x]-shaped token org rejected +;; level2-done-without-closed completed level-2 task with no CLOSED +;; subtask-done-not-dated level-3+ done sub-task still a DONE keyword ;; (anything else) surfaced as judgment with checker name ;; ;; Output format on stdout: @@ -59,7 +69,9 @@ Each plist has :kind (mechanical-fixed | judgment), :line, :checker, :msg. Mechanical entries from --check mode also carry :preview t.") (defvar lo-check-only nil - "Non-nil means run in report-only mode — no buffer writes.") + "Non-nil means run in report-only mode — no buffer writes. +The CLI defaults this to t (a linter reports, it doesn't write); +`--fix' is what enables writes on a command-line run.") (defvar lo-current-file nil "Path of the file currently being processed.") (defvar lo-followups-file nil @@ -348,24 +360,195 @@ logical row, matching wrap-org-table.el's grouping." (defun lo--check-tables () "Scan the current buffer for org tables violating the table standard. -Emits one judgment item per violating table." +Emits one judgment item per violating table. Pipe-led lines inside +#+begin_/#+end_ blocks are content (ASCII art, shell pipes), not tables, +and are skipped — the same block rule `wot-process-file' applies." (save-excursion (goto-char (point-min)) - (while (re-search-forward "^[ \t]*|" nil t) - (let ((start-line (line-number-at-pos)) - (lines nil)) - (beginning-of-line) - (while (and (not (eobp)) (looking-at "[ \t]*|")) - (push (buffer-substring-no-properties (line-beginning-position) - (line-end-position)) - lines) + (let ((in-block nil)) ; the open block's type, e.g. "example" — nil outside + (while (not (eobp)) + (cond + ;; Type-matched close only: literal #+end_src quoted inside an + ;; example block must not clear the flag (see wot-process-file). + ((and (not in-block) + (looking-at "^[ \t]*#\\+begin_\\([^ \t\n]+\\)")) + (setq in-block (downcase (match-string 1))) (forward-line 1)) - (let ((violations (lo--table-violations (nreverse lines)))) - (when violations - (lo--emit-judgment - 'org-table-standard start-line - (format "table violates the org-table standard: %s — wrap-org-table.el reflows it" - (string-join violations "; "))))))))) + ((and in-block + (looking-at-p (format "^[ \t]*#\\+end_%s\\([ \t]\\|$\\)" + (regexp-quote in-block)))) + (setq in-block nil) + (forward-line 1)) + ((and (not in-block) (looking-at-p "^[ \t]*|")) + (let ((start-line (line-number-at-pos)) + (lines nil)) + (while (and (not (eobp)) (looking-at "[ \t]*|")) + (push (buffer-substring-no-properties (line-beginning-position) + (line-end-position)) + lines) + (forward-line 1)) + (let ((violations (lo--table-violations (nreverse lines)))) + (when violations + (lo--emit-judgment + 'org-table-standard start-line + (format "table violates the org-table standard: %s — wrap-org-table.el reflows it" + (string-join violations "; "))))))) + (t (forward-line 1))))))) + +;;; --------------------------------------------------------------------------- +;;; level-2 dated-header check (claude-rules/todo-format.md) +;; +;; A completed task or resolved VERIFY at level 2 must carry a terminal +;; keyword (DONE/CANCELLED + CLOSED:), never a dated heading. A `** <date>' +;; header has no keyword, so todo-cleanup's --archive-done can never archive +;; it (it accumulates in Open Work forever) and task-review drops it from +;; selection. Judgment-only, never auto-fixed: the repair needs a +;; DONE-vs-CANCELLED call and the original heading text, which is a judgment +;; the sweep can't make. Targets todo/task files; a dated-log-format org +;; file using `** <date>' headings intentionally will false-positive here, in +;; which case the human dismisses the judgment item. + +(defun lo--check-level2-dated-headers () + "Flag level-2 headings whose text begins with a YYYY-MM-DD date. +Emits one judgment item per offending heading (checker +`level-2-dated-header')." + (save-excursion + (goto-char (point-min)) + (while (re-search-forward + "^\\*\\* \\([0-9]\\{4\\}-[0-9]\\{2\\}-[0-9]\\{2\\}\\)" nil t) + (lo--emit-judgment + 'level-2-dated-header (line-number-at-pos) + "level-2 dated header is a completion defect (todo-format.md): a ** task or VERIFY closes with DONE/CANCELLED + CLOSED:, not a dated heading — convert it so --archive-done can archive it")))) + +;;; --------------------------------------------------------------------------- +;;; structural heading checks (mistakes org-lint does not cover) +;; +;; org-lint validates links, drawers, blocks, and babel — but not heading +;; well-formedness. These four catch hand-edit defects it misses, all +;; judgment-only (each repair is a human call) and regex-based (no dependence on +;; which TODO keywords the batch Emacs happens to recognize): +;; +;; indented-heading leading whitespace before two-or-more stars; org +;; demotes it to body text, so the task vanishes from +;; the agenda and never archives. The worst case — an +;; invisible task — and silent. Single `*' is left +;; alone (a valid indented plain-list bullet). +;; empty-heading a line of bare stars with no title. +;; malformed-priority-cookie a `[#x]'-shaped token org rejected (lowercase, +;; multi-char, non-letter) sitting where a cookie +;; would be. +;; level2-done-without-closed a level-2 DONE/CANCELLED with no CLOSED line — +;; directly relevant to todo-cleanup's aging step, +;; which archives an undated completed task at once. + +(defconst lo-done-keywords '("DONE" "CANCELLED") + "Heading keywords treated as completed for `lo--check-level2-done-without-closed'.") + +(defun lo--check-indented-headings () + "Flag lines that are whitespace + two-or-more stars + space outside any block. +Org parses a heading only at column 0, so leading whitespace silently demotes a +would-be heading to body text. Two-or-more stars is required: an indented +single `*' is a valid plain-list bullet, not a lost heading, so flagging it +false-positives on legitimate lists; `**'+ is never a bullet, so an indented one +is unambiguously a demoted level-2+ heading turned invisible. Lines inside +`#+begin_/#+end_' blocks are skipped — indented asterisks there are legitimate +content." + (save-excursion + (goto-char (point-min)) + (let ((in-block nil)) + (while (not (eobp)) + (cond + ((looking-at-p "^[ \t]*#\\+begin_") (setq in-block t)) + ((looking-at-p "^[ \t]*#\\+end_") (setq in-block nil)) + ((and (not in-block) (looking-at-p "^[ \t]+\\*\\*+[ \t]")) + (lo--emit-judgment + 'indented-heading (line-number-at-pos) + "indented heading: leading whitespace before the stars demotes this to body text — org won't treat it as a heading (it vanishes from the agenda and never archives); dedent to column 0"))) + (forward-line 1))))) + +(defun lo--check-empty-headings () + "Flag headings that are bare stars with no title text. +A line of nothing but stars is an empty heading — a stray heading-star carrying +no content." + (save-excursion + (goto-char (point-min)) + (while (re-search-forward "^\\*+[ \t]*$" nil t) + (lo--emit-judgment + 'empty-heading (line-number-at-pos) + "empty heading: a line of stars with no title — delete it or give it a title")))) + +(defun lo--check-malformed-priority-cookies () + "Flag a heading whose first cookie-shaped token is not a valid priority. +A valid cookie is a single uppercase letter in `[#A]' form. Verbatim-wrapped +cookies (`=[#D]=' quoted in a dated-log title) are skipped. Only the first +token on the line is checked, so a real cookie earlier on the line means a +later `[#x]' in the title is left alone." + (save-excursion + (goto-char (point-min)) + ;; Case-sensitive: a cookie is uppercase only, and case-fold-search defaults + ;; to t (which would accept [#a] as valid). + (let ((case-fold-search nil)) + (while (re-search-forward "^\\*+ " nil t) + (let ((eol (line-end-position)) (hline (line-number-at-pos))) + (when (re-search-forward "\\[#\\([^]]*\\)\\]" eol t) + (let ((inner (match-string 1)) + (before (char-before (match-beginning 0))) + (after (char-after (match-end 0)))) + (unless (or (eq before ?=) (eq after ?=) + (string-match-p "\\`[A-Z]\\'" inner)) + (lo--emit-judgment + 'malformed-priority-cookie hline + (format "malformed priority cookie [#%s] — a cookie is a single uppercase letter ([#A]) right after the keyword; fix or remove it" + inner))))) + (goto-char eol)))))) + +(defun lo--check-level2-done-without-closed () + "Flag a level-2 DONE/CANCELLED heading with no CLOSED line in its own entry. +todo-cleanup's `--archive-done' aging step archives a completed task with no +parseable CLOSED date immediately, so an undated completed task silently leaves +the live file on the next `task-sorted'." + (save-excursion + (goto-char (point-min)) + ;; Case-sensitive: DONE/CANCELLED are uppercase keywords, not the words + ;; "done"/"cancelled" in a heading title (case-fold-search defaults to t). + (let ((case-fold-search nil) + (re (format "^\\*\\* \\(%s\\) " + (mapconcat #'regexp-quote lo-done-keywords "\\|")))) + (while (re-search-forward re nil t) + (let ((hline (line-number-at-pos)) + (entry-end (save-excursion (outline-next-heading) (point)))) + (save-excursion + (forward-line 1) + (unless (re-search-forward "^[ \t]*CLOSED:[ \t]*\\[" entry-end t) + (lo--emit-judgment + 'level2-done-without-closed hline + "level-2 DONE/CANCELLED has no CLOSED date — add CLOSED: [YYYY-MM-DD Day]; task-sorted's aging step archives an undated completed task immediately")))))))) + +;;; --------------------------------------------------------------------------- +;;; level-3+ dated-header check (claude-rules/todo-format.md) +;; +;; The inverse of the level-2 check above. A completed sub-task — a heading at +;; level 3 or deeper, under a parent task — becomes a dated event-log entry, not +;; a DONE keyword, so the parent's subtree grows a chronological history instead +;; of a long tail of nested DONE lines. An interactive org close +;; (`org-log-done' → DONE + CLOSED) leaves the keyword in place, and +;; `--archive-done' only touches level 2, so these accumulate. Flag them for +;; conversion. Judgment-only and regex-based (independent of which TODO keywords +;; the batch Emacs recognizes); todo-cleanup.el --convert-subtasks does the fix. + +(defun lo--check-subtask-done-not-dated () + "Flag level-3+ headings carrying a done keyword (DONE/CANCELLED/FAILED). +Emits one judgment item per offending heading (checker +`subtask-done-not-dated')." + (save-excursion + (goto-char (point-min)) + ;; Case-sensitive: the keywords are uppercase, not the words in a title. + (let ((case-fold-search nil)) + (while (re-search-forward + "^\\*\\{3,\\} \\(DONE\\|CANCELLED\\|FAILED\\) " nil t) + (lo--emit-judgment + 'subtask-done-not-dated (line-number-at-pos) + "level-3+ done sub-task should be a dated event-log entry (todo-format.md): run todo-cleanup.el --convert-subtasks to rewrite it"))))) ;;; --------------------------------------------------------------------------- ;;; File processing @@ -401,6 +584,14 @@ left unmodified and mechanical entries are recorded with :preview t." ;; After org-lint items: the custom table-standard scan. Runs on the ;; post-fix buffer; judgment-only, so order doesn't perturb fixes. (lo--check-tables) + ;; Same shape: flag level-2 dated headers (completion defects). + (lo--check-level2-dated-headers) + ;; Structural heading defects org-lint doesn't cover. + (lo--check-indented-headings) + (lo--check-empty-headings) + (lo--check-malformed-priority-cookies) + (lo--check-level2-done-without-closed) + (lo--check-subtask-done-not-dated) (when (and (not lo-check-only) (buffer-modified-p)) (save-buffer))) (with-current-buffer buf (set-buffer-modified-p nil)) @@ -507,6 +698,13 @@ After printing, also append judgments to `lo-followups-file' when set." ;;; CLI (defun lo-main () + ;; Report-only is the CLI default; --fix is the only way a command-line run + ;; writes to disk. The old mutate-by-default reformatted five files in one + ;; pass before anyone confirmed anything (work project, 2026-07-09). + (setq lo-check-only t) + (when (member "--fix" command-line-args-left) + (setq lo-check-only nil) + (setq command-line-args-left (delete "--fix" command-line-args-left))) (when (member "--check" command-line-args-left) (setq lo-check-only t) (setq command-line-args-left (delete "--check" command-line-args-left))) @@ -518,7 +716,7 @@ After printing, also append judgments to `lo-followups-file' when set." (setq command-line-args-left (delete followups command-line-args-left)))) (if (null command-line-args-left) (progn - (princ "Usage: emacs --batch -q -l lint-org.el [--check] [--followups-file=PATH] FILE.org ...\n") + (princ "Usage: emacs --batch -q -l lint-org.el [--fix] [--check] [--followups-file=PATH] FILE.org ...\n") (kill-emacs 1)) (let ((files command-line-args-left)) (setq command-line-args-left nil) @@ -537,7 +735,7 @@ this file without firing the CLI dispatch — under `ert-run-tests-batch-and-exi the trailing args are things like `-f ert-run-tests-batch-and-exit'." (and command-line-args-left (cl-every (lambda (a) - (cond ((member a '("--check")) t) + (cond ((member a '("--check" "--fix")) t) ((string-prefix-p "--followups-file=" a) t) ((string-prefix-p "-" a) nil) (t (file-readable-p a)))) diff --git a/claude-templates/.ai/scripts/route-batch b/claude-templates/.ai/scripts/route-batch new file mode 100755 index 0000000..8f27d19 --- /dev/null +++ b/claude-templates/.ai/scripts/route-batch @@ -0,0 +1,175 @@ +#!/usr/bin/env python3 +"""route-batch — the wrap-up router's mechanical go path. + +The wrap-up cross-project router (wrap-it-up.org Step 3; wrapup-routing spec +D7/D8/D9) surfaces the local tasks that inbox process mode stamped with +:ROUTE_CANDIDATE: <destination> at file time, and on "go" delivers each to its +destination project's inbox. This script does the mechanical half so the +subtree surgery is deterministic: + + route-batch --list [--todo todo.org] + One "<destination>\t<heading>" line per :ROUTE_CANDIDATE:-tagged task. + Silent with exit 0 when there are no candidates (the workflow's + empty-set-equals-zero-interaction rule). Read-only. + + route-batch --go [--todo todo.org] + For each candidate, bottom-up: extract the task's whole subtree + (children ride along), drop the :ROUTE_CANDIDATE: line (and the + property drawer if that leaves it empty), promote the subtree so its + top heading is level 1, write it to a temp file, and deliver it via + the sibling inbox-send.py to the destination's inbox/ (one file per + task, from-<source> provenance stamped by inbox-send). Only after a + successful send is the subtree removed from the local todo.org — a + failed send leaves that task in place, is reported, and the run exits + non-zero after attempting the rest. + +The candidate set is exactly the tagged tasks — never the standing backlog. +Discovery, roots, and the source-project name all come from inbox-send.py +(INBOX_SEND_ROOTS sandboxes it in tests). The reject-from-another-project +flow in inbox process mode is the mis-route recovery; that path is why +removing the local source after a successful send is safe. +""" + +import argparse +import os +import re +import subprocess +import sys +import tempfile +from pathlib import Path + +HEADING_RE = re.compile(r"^(\*+)\s+(.*)$") +MARKER_RE = re.compile(r"^\s*:ROUTE_CANDIDATE:\s+(\S+)\s*$") + + +def find_candidates(lines): + """[(heading_idx, end_idx, marker_idx, destination, heading_text)] — + end_idx is one past the subtree's last line.""" + candidates = [] + for i, line in enumerate(lines): + m = MARKER_RE.match(line) + if not m: + continue + head_idx = None + for j in range(i, -1, -1): + hm = HEADING_RE.match(lines[j]) + if hm: + head_idx = j + level = len(hm.group(1)) + heading = hm.group(2) + break + if head_idx is None: + continue + end = len(lines) + for k in range(head_idx + 1, len(lines)): + km = HEADING_RE.match(lines[k]) + if km and len(km.group(1)) <= level: + end = k + break + candidates.append((head_idx, end, i, m.group(1), heading)) + return candidates + + +def extract_handoff(lines, head_idx, end): + """The subtree as handoff text: every :ROUTE_CANDIDATE: line dropped + (a marker is meaningless at the destination), empty drawers pruned, + headings promoted so the task is level 1.""" + sub = [l for l in lines[head_idx:end] if not MARKER_RE.match(l)] + + pruned = [] + i = 0 + while i < len(sub): + if sub[i].strip() == ":PROPERTIES:" and i + 1 < len(sub) and sub[i + 1].strip() == ":END:": + i += 2 + continue + pruned.append(sub[i]) + i += 1 + + shift = len(HEADING_RE.match(pruned[0]).group(1)) - 1 + if shift > 0: + pruned = [l[shift:] if HEADING_RE.match(l) else l for l in pruned] + return "\n".join(pruned).rstrip() + "\n" + + +def send(destination, handoff_text, slug): + inbox_send = Path(__file__).with_name("inbox-send.py") + with tempfile.NamedTemporaryFile( + "w", suffix=".org", prefix=f"route-{slug}-", delete=False, encoding="utf-8" + ) as tf: + tf.write(handoff_text) + tmp = tf.name + try: + result = subprocess.run( + [sys.executable, str(inbox_send), destination, "--file", tmp], + capture_output=True, text=True, + ) + return result.returncode == 0, (result.stderr or result.stdout).strip() + finally: + os.unlink(tmp) + + +def main(): + ap = argparse.ArgumentParser(prog="route-batch") + mode = ap.add_mutually_exclusive_group(required=True) + mode.add_argument("--list", action="store_true", dest="list_mode") + mode.add_argument("--go", action="store_true") + ap.add_argument("--todo", default="todo.org") + args = ap.parse_args() + + todo_path = Path(args.todo) + if not todo_path.is_file(): + return 0 # no todo file, no candidates + lines = todo_path.read_text(encoding="utf-8").splitlines() + candidates = find_candidates(lines) + + # Two markers in one task's drawer are one candidate, not two: same span + + # same destination dedupes. Everything else that overlaps — a tagged child + # inside a tagged parent, one task tagged for two destinations — is a + # conflict: routing either span would silently take the other (or, with a + # stale end index, a bystander task) along. Conflicts are left in place + # and reported; the human untangles which project the pieces belong to. + deduped = [] + for cand in candidates: + if not any(c[0] == cand[0] and c[1] == cand[1] and c[3] == cand[3] for c in deduped): + deduped.append(cand) + conflicted = set() + for a in deduped: + for b in deduped: + if a is not b and a[0] <= b[0] and b[1] <= a[1]: + conflicted.add(a) + conflicted.add(b) + routable = [c for c in deduped if c not in conflicted] + + if not deduped: + return 0 + + if args.list_mode: + for _h, _e, _m, dest, heading in deduped: + flag = "\tCONFLICT (overlapping candidates — resolve by hand)" if (_h, _e, _m, dest, heading) in conflicted else "" + print(f"{dest}\t{heading}{flag}") + return 0 + + failures = 0 + for _h, _e, _m, dest, heading in sorted(conflicted): + failures += 1 + print(f"CONFLICT: {dest}\t{heading}\t(overlapping candidate subtrees — left in place, resolve by hand)") + + # Bottom-up so earlier indices stay valid as subtrees are removed; the + # file is rewritten after every successful send so a crash mid-run never + # leaves an already-sent task still present locally. + for head_idx, end, _marker_idx, dest, heading in sorted(routable, reverse=True): + handoff = extract_handoff(lines, head_idx, end) + slug = re.sub(r"[^a-z0-9]+", "-", heading.lower()).strip("-")[:40] or "task" + ok, detail = send(dest, handoff, slug) + if ok: + del lines[head_idx:end] + todo_path.write_text("\n".join(lines).rstrip("\n") + "\n", encoding="utf-8") + print(f"routed: {dest}\t{heading}") + else: + failures += 1 + print(f"FAILED: {dest}\t{heading}\t({detail})") + return 1 if failures else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/claude-templates/.ai/scripts/route_recommend.py b/claude-templates/.ai/scripts/route_recommend.py new file mode 100644 index 0000000..7b36405 --- /dev/null +++ b/claude-templates/.ai/scripts/route_recommend.py @@ -0,0 +1,136 @@ +#!/usr/bin/env python3 +"""Wrap-up routing recommendation engine. + +Given an inbox keeper's text and a list of candidate project names, infer which +project the item belongs to, with a confidence tier: + + strong a project's name (or its dot-stripped form, or a path containing it) + appears literally in the item + weak a distinctive name token overlaps, but the full name doesn't + none no overlap; the item stays put + +A multi-way tie at the top tier is ambiguous, so it downgrades to weak with a +deterministic pick (most token overlap, then alphabetical). An empty candidate +list yields none. + +The pure core is `recommend(item, projects) -> (destination, confidence)` — the +shape the wrap-up router (Phase 4) and the process-inbox marker (Phase 2) both +call. The CLI wires it to inbox-send.py's `discover_projects` so the candidate +set is the same project universe inbox-send already knows. + +CLI: + route_recommend.py --item "<text>" [--exclude <current-project>] +prints "<destination>\\t<confidence>" on a match, or "none". +""" + +import argparse +import importlib.util +import re +import sys +from pathlib import Path + +# A distinctive-enough token for weak matching; shorter tokens (of, to, id) are +# too noisy to route on. +MIN_WEAK_TOKEN = 4 + +_TOKEN_RE = re.compile(r"[a-z0-9]+") + + +def _tokens(text: str) -> set[str]: + return set(_TOKEN_RE.findall(text.lower())) + + +def _name_variants(name: str) -> set[str]: + """A project name and its dot-stripped alias (.emacs.d -> emacsd).""" + return {v for v in (name.lower(), name.replace(".", "").lower()) if v} + + +def _literal_present(name: str, item_lower: str) -> bool: + """True if a name variant appears in the item on word-ish boundaries. + + Boundaries keep 'home' from matching inside 'homeowner' while still + matching it inside a path ('~/code/home/...') or a hyphenated name. + """ + for variant in _name_variants(name): + if re.search(r"(?<![a-z0-9])" + re.escape(variant) + r"(?![a-z0-9])", item_lower): + return True + return False + + +def _tiebreak(candidates: list[str], item_tokens: set[str]) -> str: + """Most token overlap first, then alphabetical — deterministic.""" + return sorted(candidates, key=lambda p: (-len(_tokens(p) & item_tokens), p))[0] + + +def recommend(item: str, projects: list[str]) -> tuple[str | None, str]: + """Infer the destination project for `item` from `projects`. + + Returns (destination, confidence). confidence is "strong" / "weak" / "none"; + destination is None exactly when confidence is "none". + """ + if not projects: + return (None, "none") + + item_lower = item.lower() + item_tokens = _tokens(item) + + strong: list[str] = [] + weak: list[str] = [] + for project in projects: + if _literal_present(project, item_lower): + strong.append(project) + continue + name_tokens = {t for t in _tokens(project) if len(t) >= MIN_WEAK_TOKEN} + if name_tokens & item_tokens: + weak.append(project) + + if len(strong) == 1: + return (strong[0], "strong") + if len(strong) > 1: + return (_tiebreak(strong, item_tokens), "weak") + if len(weak) == 1: + return (weak[0], "weak") + if len(weak) > 1: + return (_tiebreak(weak, item_tokens), "weak") + return (None, "none") + + +def _load_inbox_send(): + """Load the sibling kebab-named inbox-send.py as a module for its discovery.""" + path = Path(__file__).with_name("inbox-send.py") + spec = importlib.util.spec_from_file_location("inbox_send", path) + if spec is None or spec.loader is None: + raise ImportError(f"cannot load {path}") + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def discover_destination_names(exclude: str | None = None) -> list[str]: + """The candidate project names, reusing inbox-send's discovery. + + `exclude` drops the current project (matched by exact name or dot-stripped + alias) so the engine never recommends routing an item to where it already is. + """ + mod = _load_inbox_send() + names = [p.name for p in mod.discover_projects(mod.resolve_roots())] + if exclude: + drop = _name_variants(exclude) + names = [n for n in names if not (_name_variants(n) & drop)] + return names + + +def main() -> int: + parser = argparse.ArgumentParser(description="Recommend a routing destination for an inbox keeper.") + parser.add_argument("--item", required=True, help="the keeper's text") + parser.add_argument("--exclude", help="current project to exclude from candidates") + args = parser.parse_args() + + projects = discover_destination_names(exclude=args.exclude) + destination, confidence = recommend(args.item, projects) + print("none" if destination is None else f"{destination}\t{confidence}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/claude-templates/.ai/scripts/self-inject.sh b/claude-templates/.ai/scripts/self-inject.sh new file mode 100755 index 0000000..e7340c1 --- /dev/null +++ b/claude-templates/.ai/scripts/self-inject.sh @@ -0,0 +1,68 @@ +#!/bin/sh +# self-inject.sh — type text into the tmux pane running this agent session. +# +# The building block for AUTO-FLUSH: an agent checkpoints its session-context, +# then has tmux type "/clear" and a resume prompt at its own idle prompt, so a +# session flushes with no human at the keyboard. +# +# Usage: +# self-inject.sh -t %PANE <delay> <text> [<delay2> <text2> ...] +# self-inject.sh <delay> <text> [...] # derive pane from ancestry +# self-inject.sh [-t %PANE] # no pairs: report the pane +# +# Each pair: sleep <delay> seconds, then type <text> literally and press Enter. +# +# TWO HARD-WON GOTCHAS (2026-07-02, archsetup session): +# 1. A detached child (setsid/nohup/&) of an agent tool call DIES when the +# tool call ends — the harness cleans up the process group. The arm step +# must run under the tmux SERVER instead: +# tmux run-shell -b "self-inject.sh -t %1 25 '/clear' 15 'go — resume...'" +# 2. Under tmux run-shell the process is a child of the tmux server, so +# ancestry-based pane detection CANNOT work there. Derive the pane FIRST, +# synchronously from the agent's own shell (no -t), then pass it +# explicitly with -t when arming. +# +# Collision hazard: if the user happens to be typing when the send fires, the +# injected text merges into their input line (a real /clear became "/clearto" +# mid-word). Auto-flush is for sessions running unattended; warn the user to +# keep hands off for the armed window if they're present. + +PANE="" +if [ "$1" = "-t" ]; then + PANE=$2; shift 2 +fi + +ppid_of() { + # /proc/<pid>/stat: pid (comm) state ppid ... — comm may contain spaces, + # so take the 2nd field after the LAST ')'. + stat=$(cat "/proc/$1/stat" 2>/dev/null) || return 1 + # shellcheck disable=SC2086 # word-splitting the stat tail is the point + set -- ${stat##*) } + echo "$2" +} + +find_pane() { + anc=" " + pid=$$ + while [ -n "$pid" ] && [ "$pid" -gt 1 ] 2>/dev/null; do + anc="$anc$pid " + pid=$(ppid_of "$pid") || break + done + tmux list-panes -a -F "#{pane_pid} #{pane_id}" 2>/dev/null | \ + while read -r ppid pane; do + case "$anc" in *" $ppid "*) echo "$pane"; break;; esac + done +} + +[ -n "$PANE" ] || PANE=$(find_pane) +[ -n "$PANE" ] || { echo "self-inject: no owning pane found (pass -t %PANE)" >&2; exit 1; } + +# With no delay/text pairs, just report the pane (the derive-first step). +[ $# -ge 2 ] || { echo "$PANE"; exit 0; } + +while [ $# -ge 2 ]; do + sleep "$1" + tmux send-keys -t "$PANE" -l "$2" + tmux send-keys -t "$PANE" Enter + shift 2 +done diff --git a/claude-templates/.ai/scripts/session-context-path b/claude-templates/.ai/scripts/session-context-path index 8cc56f6..670a610 100755 --- a/claude-templates/.ai/scripts/session-context-path +++ b/claude-templates/.ai/scripts/session-context-path @@ -10,6 +10,14 @@ # instead of clobbering the singleton. The id is sanitized to filename-safe # characters so a stray value can't escape the .d/ directory. # +# The id must be unique per run; the spawner appends an epoch on the tail +# (recommended shape host.project.runtime.<epoch>) so a re-run of the same +# logical agent gets a fresh anchor instead of resolving to a prior run's +# leftover. The epoch is never minted here: this resolver is called many times +# per session and must return the same path each call, so it can't generate a +# new value. See protocols.org "Agent-scoped path". A bare, reused id (just +# "codex") is the bug that motivated this note. +# # Workflows call this to resolve the path; both startup (existence check) and # wrap-up (rename source) read/write through it. Callers should fall back to # .ai/session-context.org if this script isn't present yet (older checkouts diff --git a/claude-templates/.ai/scripts/spec-sort b/claude-templates/.ai/scripts/spec-sort new file mode 100755 index 0000000..ebfef82 --- /dev/null +++ b/claude-templates/.ai/scripts/spec-sort @@ -0,0 +1,715 @@ +#!/usr/bin/env python3 +"""spec-sort — one-time docs-pile retrofit for the docs-lifecycle convention. + +Classifies every docs/**/*.org outside docs/specs/ by one predicate: a doc +carrying BOTH a "Decisions" heading AND an "Implementation phases" heading is +a spec candidate; everything else is a note. For each candidate it shows an +evidence panel (Status field, decision/finding cookies, the linking todo.org +task, recent dated history, cheap existence checks on phase-named artifacts) +and proposes a lifecycle keyword the evidence supports — conservative +non-terminal (DRAFT) when inconclusive. The helper proposes; a human confirms +every move. + +Dry-run report is the default. --apply executes under the fail-safe contract: + + - Clean-worktree preflight: refuses on a dirty git tree (exit 2) unless + --allow-dirty, which prints exactly what recovery loses. + - Every candidate must be addressed with --confirm REL=KEYWORD or + --skip REL; terminal keywords (IMPLEMENTED SUPERSEDED CANCELLED) also + need --reason REL=TEXT, recorded in the status-history line. + - The full move + relink plan is computed and validated first (every + destination free, every link resolvable), written to a plan file, and + only then executed from that recorded plan. + - Bare-path mentions of a moving doc inside the rewritten roots are + reported, never rewritten; they block --apply until --acknowledge-bare + explicitly waives them. + - Mid-apply failure stops the run, names what was and wasn't applied, and + prints the git-restore recovery recipe (plus deletion of newly created + destination copies, which git restore can't remove). + - After a successful apply, a residue scan across the rewritten roots must + find no link still resolving to an old path, or spec-sort exits non-zero + naming the residue. + +Per move: rename to carry the -spec.org suffix, prepend the status heading +(:ID: UUID + dated history line), rewrite the keyword header to the +two-sequence form, mirror the keyword into the Metadata Status field, and +recompute every affected file: link (inbound links to the moved doc AND the +moved doc's own outbound relative links). Rewritten roots: todo.org, +.ai/notes.org, docs/**, .ai/project-workflows/, .ai/project-scripts/. +Reported-never-rewritten: .ai/sessions/ (frozen history) and synced template +paths (.ai/workflows/, .ai/scripts/, .ai/protocols.org — the report names +the canonical claude-templates file instead). + +Finally stamps :LAST_SPEC_SORT: YYYY-MM-DD in .ai/notes.org's +* Workflow State section (created idempotently), which permanently clears +the startup nudge. A run with zero candidates still stamps. + +Exit codes: 0 done (or clean report), 1 blocked (confirm gate, validation, +bare mentions, residue, mid-apply failure), 2 usage / preflight refusal. + +Test hook: SPEC_SORT_INJECT_FAIL_AFTER=N aborts the apply after N write +operations, exercising the recovery path in the bats suite. +""" + +import argparse +import json +import os +import re +import subprocess +import sys +import tempfile +import uuid +from datetime import datetime + +LIFECYCLE = ("DRAFT", "READY", "DOING", "IMPLEMENTED", "SUPERSEDED", "CANCELLED") +TERMINAL = {"IMPLEMENTED", "SUPERSEDED", "CANCELLED"} +TODO_HEADER = [ + "#+TODO: TODO | DONE", + "#+TODO: DRAFT READY DOING | IMPLEMENTED SUPERSEDED CANCELLED", +] + +# Project-owned surfaces whose file: links get rewritten. +REWRITE_ROOTS = ("todo.org", ".ai/notes.org", "docs", ".ai/project-workflows", ".ai/project-scripts") +# Frozen or synced surfaces: occurrences are reported, never rewritten. +REPORT_ROOTS = (".ai/sessions", ".ai/workflows", ".ai/scripts", ".ai/protocols.org") +# Synced template paths map to their canonical rulesets file for the report. +SYNCED_PREFIX = (".ai/workflows", ".ai/scripts", ".ai/protocols.org") + +LINK_RE = re.compile(r"\[\[file:([^\]\[]+)\](?:\[([^\]\[]*)\])?\]") +HEADING_RE = re.compile(r"^(\*+)\s+(.*)$") +COOKIE_RE = re.compile(r"\[\d+/\d+\]") +DATED_RE = re.compile(r"\b\d{4}-\d{2}-\d{2}\b") + + +def read_text(path): + try: + with open(path, encoding="utf-8") as f: + return f.read() + except (UnicodeDecodeError, OSError): + return None + + +def heading_text(line): + """Heading text with the org keyword and priority cookie stripped.""" + m = HEADING_RE.match(line) + if not m: + return None + text = re.sub(r"^[A-Z]+\s+", "", m.group(2)) + text = re.sub(r"^\[#[A-Z]\]\s+", "", text) + return text.strip() + + +def has_spine(content): + """The classification predicate: Decisions AND Implementation phases.""" + dec = imp = False + for line in content.splitlines(): + t = heading_text(line) + if t is None: + continue + tl = t.lower() + if tl.startswith("decisions"): + dec = True + elif tl.startswith("implementation phases"): + imp = True + return dec and imp + + +def walk_files(root, rel_base): + """Yield project-relative paths of files under rel_base (file or dir).""" + abs_base = os.path.join(root, rel_base) + if os.path.isfile(abs_base): + yield rel_base + return + for dirpath, dirs, files in os.walk(abs_base): + dirs.sort() + for name in sorted(files): + yield os.path.relpath(os.path.join(dirpath, name), root) + + +def classify(root): + """Split docs/**/*.org outside docs/specs/ into candidates / anomalies / notes.""" + candidates, anomalies, notes = [], [], [] + docs = os.path.join(root, "docs") + if not os.path.isdir(docs): + return candidates, anomalies, notes + for rel in walk_files(root, "docs"): + if not rel.endswith(".org"): + continue + parts = rel.split(os.sep) + if len(parts) > 1 and parts[1] == "specs": + continue + content = read_text(os.path.join(root, rel)) + if content is None: + continue + if has_spine(content): + candidates.append(rel) + elif os.path.basename(rel).endswith("-spec.org"): + anomalies.append(rel) + else: + notes.append(rel) + return candidates, anomalies, notes + + +def dest_for(rel): + base = os.path.basename(rel) + if not base.endswith("-spec.org"): + base = base[: -len(".org")] + "-spec.org" + return os.path.join("docs", "specs", base) + + +# ---- Evidence panel --------------------------------------------------- + + +def todo_task_for(root, rel): + """Heading of the first todo.org task whose subtree mentions the doc.""" + content = read_text(os.path.join(root, "todo.org")) + if content is None: + return None + lines = content.splitlines() + basename = os.path.basename(rel) + for i, line in enumerate(lines): + if basename in line or rel in line: + for j in range(i, -1, -1): + if HEADING_RE.match(lines[j]): + return lines[j].lstrip("* ").strip() + return None + return None + + +def gather_evidence(root, rel, content): + ev = {} + m = re.search(r"^\|\s*Status\s*\|\s*([^|]*)\|", content, re.MULTILINE | re.IGNORECASE) + ev["status"] = m.group(1).strip() if m else None + + cookies = [] + for line in content.splitlines(): + t = heading_text(line) + if t and COOKIE_RE.search(t) and ( + t.lower().startswith("decisions") or t.lower().startswith("review findings") + ): + cookies.append(t) + ev["cookies"] = cookies + + ev["todo"] = todo_task_for(root, rel) + kw = None + if ev["todo"]: + m = re.match(r"([A-Z]+)\s", ev["todo"]) + kw = m.group(1) if m else None + ev["todo_keyword"] = kw + + dated = [ln.strip() for ln in content.splitlines() if DATED_RE.search(ln)] + ev["history"] = dated[-1][:100] if dated else None + + # Cheap artifact check: =path= tokens inside the Implementation phases section. + artifacts, exists = [], 0 + section = re.split(r"^\*+\s+.*implementation phases.*$", content, maxsplit=1, flags=re.MULTILINE | re.IGNORECASE) + if len(section) > 1: + for tok in re.findall(r"=([^=\s]+)=", section[1]): + if "/" in tok: + artifacts.append(tok) + if os.path.exists(os.path.join(root, tok)): + exists += 1 + ev["artifacts"] = (exists, artifacts) + return ev + + +def propose_keyword(ev): + s = (ev["status"] or "").lower() + words = set(re.findall(r"[a-z]+", s)) + if words & {"implemented", "shipped", "complete", "completed", "done"}: + return "IMPLEMENTED" + if words & {"superseded"}: + return "SUPERSEDED" + if words & {"cancelled", "canceled", "dead", "abandoned"}: + return "CANCELLED" + if words & {"doing", "implementing"} or "in progress" in s or "in-progress" in s: + return "DOING" + if ev["todo_keyword"] == "DOING": + return "DOING" + if words & {"ready", "approved", "accepted"}: + return "READY" + return "DRAFT" # conservative non-terminal default + + +# ---- Link scanning ---------------------------------------------------- + + +def rewrite_files(root): + """Project-relative *.org files under the rewritten roots.""" + seen = [] + for base in REWRITE_ROOTS: + if not os.path.exists(os.path.join(root, base)): + continue + for rel in walk_files(root, base): + if rel.endswith(".org") and rel not in seen: + seen.append(rel) + return seen + + +def resolve_target(root, linker_rel, raw_target, moved): + """Resolve a file: link target to a project-relative path (org semantics + first — relative to the linking file's directory — then project-root + anchoring as a fallback for root-anchored links).""" + if raw_target.startswith(("/", "~", "http:", "https:")): + return None + rel_a = os.path.normpath(os.path.join(os.path.dirname(linker_rel), raw_target)) + if rel_a in moved or os.path.exists(os.path.join(root, rel_a)): + return rel_a + rel_b = os.path.normpath(raw_target) + if rel_b in moved or os.path.exists(os.path.join(root, rel_b)): + return rel_b + return rel_a + + +def plan_link_edits(root, moved): + """Compute every link rewrite: inbound links to moved docs and moved + docs' own outbound relative links. Returns ({linker_rel: [(old, new)]}, + [ambiguity descriptions]) — a link whose file-relative and root-anchored + readings are both live and disagree about a moving doc blocks validation + rather than being rewritten against a guess.""" + edits = {} + ambiguous = [] + for linker in rewrite_files(root): + content = read_text(os.path.join(root, linker)) + if content is None: + continue + linker_post = moved.get(linker, linker) + for m in LINK_RE.finditer(content): + raw = m.group(1) + desc = m.group(2) + target_path, sep, anchor = raw.partition("::") + target = resolve_target(root, linker, target_path, moved) + if target is None: + continue + rel_a = os.path.normpath(os.path.join(os.path.dirname(linker), target_path)) + rel_b = os.path.normpath(target_path) + if rel_a != rel_b: + live_a = rel_a in moved or os.path.exists(os.path.join(root, rel_a)) + live_b = rel_b in moved or os.path.exists(os.path.join(root, rel_b)) + if live_a and live_b and (rel_a in moved or rel_b in moved): + ambiguous.append( + "%s: [[file:%s]] reads as %s (file-relative) or %s (root-anchored) " + "and a moving doc is involved — resolve the link by hand" % (linker, raw, rel_a, rel_b)) + continue + if target not in moved and linker not in moved: + continue + if target not in moved and not os.path.exists(os.path.join(root, target)): + continue # already broken before this run; not ours to guess + target_post = moved.get(target, target) + new_path = os.path.relpath(target_post, os.path.dirname(linker_post) or ".") + new_raw = new_path + (sep + anchor if sep else "") + if new_raw == raw: + continue + new_link = "[[file:%s]%s]" % (new_raw, "[%s]" % desc if desc is not None else "") + if m.group(0) != new_link: + edits.setdefault(linker, []).append((m.group(0), new_link)) + return edits, ambiguous + + +def scan_bare_mentions(root, moved): + """Bare-path mentions of moving docs in the rewritten roots — text + occurrences outside any [[...]] link. Reported, never rewritten.""" + found = [] + for base in REWRITE_ROOTS: + if not os.path.exists(os.path.join(root, base)): + continue + for rel in walk_files(root, base): + content = read_text(os.path.join(root, rel)) + if content is None: + continue + for i, line in enumerate(content.splitlines(), 1): + stripped = re.sub(r"\[\[[^\]]*\](?:\[[^\]]*\])?\]", "", line) + for src in moved: + if src in stripped: + found.append((rel, i, src)) + return found + + +def scan_report_only(root, moved): + """Occurrences of moving docs in frozen/synced surfaces.""" + reports = [] + for base in REPORT_ROOTS: + if not os.path.exists(os.path.join(root, base)): + continue + for rel in walk_files(root, base): + content = read_text(os.path.join(root, rel)) + if content is None: + continue + for src in moved: + if src in content: + if rel.startswith(SYNCED_PREFIX): + note = ("synced template, not rewritten — a local edit is reverted by the " + "next sync; edit the canonical claude-templates/%s instead" % rel) + else: + note = "frozen history; not rewritten" + reports.append((rel, src, note)) + return reports + + +# ---- Content transforms ----------------------------------------------- + + +def transform_spec(content, keyword, reason, title, doc_id, link_edits): + """Apply the retrofit rewrite to a moving spec's content: two-sequence + keyword header, prepended status heading, Status-field mirror, and the + doc's own link edits.""" + for old, new in link_edits: + content = content.replace(old, new) + lines = content.splitlines() + + todo_idx = None + kept = [] + for line in lines: + if line.startswith("#+TODO:"): + if todo_idx is None: + todo_idx = len(kept) + continue + kept.append(line) + lines = kept + if todo_idx is None: + todo_idx = 0 + while todo_idx < len(lines) and lines[todo_idx].startswith("#+"): + todo_idx += 1 + lines[todo_idx:todo_idx] = TODO_HEADER + + head_end = 0 + while head_end < len(lines) and (lines[head_end].startswith("#+") or not lines[head_end].strip()): + head_end += 1 + ts = datetime.now().astimezone().strftime("%Y-%m-%d %a @ %H:%M:%S %z") + provenance = "reason: %s" % reason if reason else "evidence-based, human-confirmed" + block = [ + "* %s %s" % (keyword, title), + ":PROPERTIES:", + ":ID: %s" % doc_id, + ":END:", + "- %s — retrofitted by spec-sort; status set to %s (%s)" % (ts, keyword, provenance), + "", + ] + lines[head_end:head_end] = block + + out = [] + mirrored = False + for line in lines: + m = re.match(r"^(\|\s*Status\s*\|)([^|]*)(\|.*)$", line, re.IGNORECASE) + if m and not mirrored: + value = " %s" % keyword.lower() + width = len(m.group(2)) + line = m.group(1) + (value.ljust(width) if len(value) <= width else value + " ") + m.group(3) + mirrored = True + out.append(line) + return "\n".join(out) + "\n" + + +def title_for(content, rel): + m = re.search(r"^#\+TITLE:\s*(.+)$", content, re.MULTILINE | re.IGNORECASE) + if m: + return m.group(1).strip() + base = os.path.basename(rel)[: -len(".org")] + return base[: -len("-spec")] if base.endswith("-spec") else base + + +# ---- Marker ------------------------------------------------------------ + + +def stamp_marker(root, date): + path = os.path.join(root, ".ai", "notes.org") + os.makedirs(os.path.dirname(path), exist_ok=True) + content = read_text(path) or "" + line = ":LAST_SPEC_SORT: %s" % date + if ":LAST_SPEC_SORT:" in content: + content = re.sub(r":LAST_SPEC_SORT:.*", line, content, count=1) + elif re.search(r"^\* Workflow State\s*$", content, re.MULTILINE): + content = re.sub(r"(^\* Workflow State\s*$)", r"\1\n" + line, content, count=1, flags=re.MULTILINE) + else: + if content and not content.endswith("\n"): + content += "\n" + content += "\n* Workflow State\n\n%s\n" % line + with open(path, "w", encoding="utf-8") as f: + f.write(content) + + +# ---- Apply ------------------------------------------------------------- + + +class ApplyFailure(Exception): + """Mid-apply failure: args are (applied_labels, remaining_ops, cause).""" + + +def apply_plan(root, plan, fail_after): + """Execute the recorded plan. Returns the applied-op labels; raises + ApplyFailure mid-way on a write error or when the test hook fires.""" + ops = [] + for mv in plan["moves"]: + ops.append(("move", mv)) + for linker, edits in plan["link_edits"].items(): + if linker in {mv["src"] for mv in plan["moves"]}: + continue # a moving doc's own edits ride along in its transform + ops.append(("relink", (linker, edits))) + + applied = [] + specs_dir = os.path.join(root, "docs", "specs") + if plan["moves"] and not os.path.isdir(specs_dir): + os.makedirs(specs_dir) + plan["created_dirs"].append(os.path.join("docs", "specs")) + + for n, (kind, payload) in enumerate(ops, 1): + if fail_after and n > fail_after: + raise ApplyFailure(applied, ops[n - 1:], "injected test failure") + try: + if kind == "move": + mv = payload + content = read_text(os.path.join(root, mv["src"])) + new = transform_spec(content, mv["keyword"], mv["reason"], mv["title"], mv["id"], + plan["link_edits"].get(mv["src"], [])) + with open(os.path.join(root, mv["dest"]), "w", encoding="utf-8") as f: + f.write(new) + os.remove(os.path.join(root, mv["src"])) + applied.append("move %s -> %s" % (mv["src"], mv["dest"])) + else: + linker, edits = payload + path = os.path.join(root, linker) + content = read_text(path) + for old, new in edits: + content = content.replace(old, new) + with open(path, "w", encoding="utf-8") as f: + f.write(content) + applied.append("relink %s (%d link%s)" % (linker, len(edits), "s" if len(edits) != 1 else "")) + except OSError as exc: + raise ApplyFailure(applied, ops[n - 1:], str(exc)) + return applied + + +def residue_check(root, plan): + """Post-apply: no link in the rewritten roots may still resolve to an + old path; bare mentions beyond the acknowledged set fail too.""" + moved = {mv["src"]: mv["dest"] for mv in plan["moves"]} + residue = [] + for linker in rewrite_files(root): + content = read_text(os.path.join(root, linker)) + if content is None: + continue + for m in LINK_RE.finditer(content): + target_path = m.group(1).partition("::")[0] + target = resolve_target(root, linker, target_path, {}) + if target in moved: + residue.append("%s: link still resolves to %s" % (linker, target)) + # Acknowledged mentions were recorded pre-apply; a mention inside a moved + # doc now lives at the doc's destination, so map the file side through the + # moves before comparing. + acknowledged = {(moved.get(f, f), src) for f, _ln, src in plan["bare"]} + for f, ln, src in scan_bare_mentions(root, moved): + if (f, src) not in acknowledged: + residue.append("%s:%d: bare mention of %s" % (f, ln, src)) + return residue + + +def print_recovery(plan, applied, not_applied): + print("FAILURE — the apply did not complete.") + print(" applied:") + for a in applied or ["(nothing)"]: + print(" %s" % a) + print(" not applied:") + for kind, payload in not_applied: + if kind == "move": + print(" move %s -> %s" % (payload["src"], payload["dest"])) + else: + print(" relink %s" % payload[0]) + print("RECOVERY — restore the pre-run state (safe: preflight required a clean tree):") + touched = [mv["src"] for mv in plan["moves"]] + [l for l in plan["link_edits"] if l not in {mv["src"] for mv in plan["moves"]}] + print(" git restore -- %s" % " ".join(touched)) + created = [mv["dest"] for mv in plan["moves"]] + print(" rm -f -- %s # git restore can't remove the created copies" % " ".join(created)) + for d in plan.get("created_dirs", []): + print(" rmdir --ignore-fail-on-non-empty -- %s" % d) + + +# ---- Main --------------------------------------------------------------- + + +def parse_kv(pairs, label): + out = {} + for item in pairs or []: + if "=" not in item: + sys.exit("spec-sort: %s expects REL=VALUE, got %r" % (label, item)) + k, v = item.split("=", 1) + out[os.path.normpath(k)] = v + return out + + +def main(): + ap = argparse.ArgumentParser(prog="spec-sort", add_help=True) + ap.add_argument("--project-root", default=".") + ap.add_argument("--apply", action="store_true") + ap.add_argument("--allow-dirty", action="store_true") + ap.add_argument("--acknowledge-bare", action="store_true") + ap.add_argument("--confirm", action="append", metavar="REL=KEYWORD") + ap.add_argument("--reason", action="append", metavar="REL=TEXT") + ap.add_argument("--skip", action="append", metavar="REL") + ap.add_argument("--plan-file") + args = ap.parse_args() + + root = os.path.abspath(args.project_root) + confirms = parse_kv(args.confirm, "--confirm") + reasons = parse_kv(args.reason, "--reason") + skips = {os.path.normpath(s) for s in (args.skip or [])} + + candidates, anomalies, notes = classify(root) + if not candidates and not anomalies and not notes and not os.path.isdir(os.path.join(root, "docs")): + return 0 # no docs pile at all — silent no-op + + for named in list(confirms) + list(skips) + list(reasons): + if named not in candidates: + print("spec-sort: %s is not a spec candidate" % named) + return 1 + for rel, kw in confirms.items(): + if kw not in LIFECYCLE: + print("spec-sort: %r is not a lifecycle keyword (%s)" % (kw, " ".join(LIFECYCLE))) + return 1 + + # ---- Build the plan (shared by report and apply) ---- + moves = [] + for rel in candidates: + if rel in skips: + continue + if args.apply and rel not in confirms: + continue # gate failure reported below + content = read_text(os.path.join(root, rel)) + moves.append({ + "src": rel, + "dest": dest_for(rel), + "keyword": confirms.get(rel, None), + "reason": reasons.get(rel), + "title": title_for(content, rel), + "id": str(uuid.uuid4()), + }) + moved_map = {mv["src"]: mv["dest"] for mv in moves} + link_edits, ambiguous = plan_link_edits(root, moved_map) + bare = scan_bare_mentions(root, moved_map) + reports = scan_report_only(root, moved_map) + + # ---- Report ---- + for rel in candidates: + content = read_text(os.path.join(root, rel)) + ev = gather_evidence(root, rel, content) + proposed = propose_keyword(ev) + print("CANDIDATE %s -> %s" % (rel, dest_for(rel))) + suffix = " (terminal — requires --reason to apply)" if proposed in TERMINAL else "" + print(" proposed keyword: %s%s" % (proposed, suffix)) + print(" evidence:") + print(" status field: %s" % (ev["status"] or "(none)")) + print(" cookies: %s" % ("; ".join(ev["cookies"]) or "(none)")) + print(" todo.org: %s" % (ev["todo"] or "(no linking task)")) + print(" history: %s" % (ev["history"] or "(none)")) + n_exist, artifacts = ev["artifacts"] + if artifacts: + print(" artifacts: %d/%d named paths exist (%s)" % (n_exist, len(artifacts), ", ".join(artifacts))) + else: + print(" artifacts: (none named)") + for rel in anomalies: + print("ANOMALY %s: named -spec.org but lacks the spec spine (Decisions + Implementation phases); surfaced, not moved" % rel) + for rel in notes: + print("NOTE %s" % rel) + for linker, edits in sorted(link_edits.items()): + for old, new in edits: + print("RELINK %s: %s -> %s" % (linker, old, new)) + for a in ambiguous: + print("AMBIGUOUS %s" % a) + for f, ln, src in bare: + print("BARE-PATH %s:%d: %s (reported for manual handling, never rewritten)" % (f, ln, src)) + for rel, src, note in reports: + print("REPORT %s: reference to %s (%s)" % (rel, src, note)) + + if not args.apply: + if candidates or anomalies or notes: + print("DRY RUN — no changes written. Pass --apply with per-candidate --confirm/--skip to execute.") + return 0 + + # ---- Apply: preflight ---- + try: + porcelain = subprocess.run( + ["git", "status", "--porcelain"], cwd=root, + capture_output=True, text=True, check=True, + ).stdout + except (subprocess.CalledProcessError, FileNotFoundError): + print("spec-sort: --apply needs a git worktree (recovery depends on git restore)") + return 2 + if porcelain.strip(): + dirty = [ln[3:] for ln in porcelain.splitlines()] + if not args.allow_dirty: + print("spec-sort: refusing --apply on a dirty worktree (%d path%s). Commit or stash first, or pass --allow-dirty." + % (len(dirty), "s" if len(dirty) != 1 else "")) + return 2 + print("WARNING --allow-dirty: recovery via git restore would also revert your pre-existing uncommitted changes:") + for p in dirty: + print(" %s" % p) + + # ---- Apply: confirm gate ---- + unaddressed = [rel for rel in candidates if rel not in confirms and rel not in skips] + if unaddressed: + print("spec-sort: unconfirmed candidate(s) — pass --confirm REL=KEYWORD or --skip REL for each:") + for rel in unaddressed: + print(" %s" % rel) + return 1 + for mv in moves: + if mv["keyword"] in TERMINAL and not mv["reason"]: + print("spec-sort: %s -> %s is a terminal state and requires an explicit --reason %s=TEXT" + % (mv["src"], mv["keyword"], mv["src"])) + return 1 + + # ---- Apply: validation ---- + problems = [] + dests = {} + for mv in moves: + if os.path.exists(os.path.join(root, mv["dest"])): + problems.append("%s: destination exists (%s)" % (mv["src"], mv["dest"])) + if mv["dest"] in dests: + problems.append("%s and %s: destination exists twice (%s)" % (mv["src"], dests[mv["dest"]], mv["dest"])) + dests[mv["dest"]] = mv["src"] + for a in ambiguous: + problems.append("ambiguous link: %s" % a) + if bare and not args.acknowledge_bare: + problems.append("bare-path mention(s) listed above need manual handling — re-run with --acknowledge-bare to proceed without rewriting them") + if problems: + print("spec-sort: validation blocked — nothing written:") + for p in problems: + print(" %s" % p) + return 1 + + # ---- Apply: record the plan, then execute from it ---- + today = datetime.now().astimezone().strftime("%Y-%m-%d") + plan = { + "root": root, "date": today, "moves": moves, + "link_edits": link_edits, "bare": bare, + "reports": [list(r) for r in reports], "created_dirs": [], + } + plan_path = args.plan_file or os.path.join( + tempfile.gettempdir(), "spec-sort-plan-%s.json" % os.path.basename(root)) + with open(plan_path, "w", encoding="utf-8") as f: + json.dump(plan, f, indent=2) + print("plan written: %s" % plan_path) + + fail_after = int(os.environ.get("SPEC_SORT_INJECT_FAIL_AFTER", "0") or 0) + try: + applied = apply_plan(root, plan, fail_after) + except ApplyFailure as exc: + print("write failed: %s" % exc.args[2]) + print_recovery(plan, exc.args[0], exc.args[1]) + return 1 + + residue = residue_check(root, plan) + if residue: + print("spec-sort: residue after apply — old paths still referenced:") + for r in residue: + print(" %s" % r) + print_recovery(plan, applied, []) + return 1 + + stamp_marker(root, today) + for a in applied: + print("applied: %s" % a) + print("spec-sort: done — %d spec(s) sorted, :LAST_SPEC_SORT: %s stamped" % (len(moves), today)) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/claude-templates/.ai/scripts/task-review-staleness.sh b/claude-templates/.ai/scripts/task-review-staleness.sh index ed43712..50e0257 100755 --- a/claude-templates/.ai/scripts/task-review-staleness.sh +++ b/claude-templates/.ai/scripts/task-review-staleness.sh @@ -19,9 +19,14 @@ # deeper headings, and cookie-less headings are not review units. # # A task is stale (count mode), and sorts oldest (list mode), when its -# :LAST_REVIEWED: property is missing or unparseable (NIL sorts first), or -# when its age strictly exceeds the threshold (age > N days; age == N is -# still fresh). +# :LAST_REVIEWED: property is missing (NIL sorts first) or when its age +# strictly exceeds the threshold (age > N days; age == N is still fresh). +# +# :LAST_REVIEWED: accepts a bare date (2026-07-09) or an org-native +# timestamp ([2026-07-09 Thu] or <2026-07-09 Thu ...>); both normalize to +# the ISO date. A value that is present but parses to neither is a data +# error: the script warns loudly to stderr (file:line:value) and leaves it +# out of the stale count rather than silently treating it as never-reviewed. set -euo pipefail @@ -46,7 +51,7 @@ num="$2" # only the property drawer between a qualifying heading and the next heading # is scanned. extract_tasks() { - awk ' + awk -v fname="$todo_file" ' function flush() { if (in_task) printf "%s\t%s\t%s\n", hline, (have_lr ? lr : "NONE"), heading } @@ -65,7 +70,21 @@ extract_tasks() { v = $0 sub(/^[ \t]*:LAST_REVIEWED:[ \t]*/, "", v) sub(/[ \t]*$/, "", v) - lr = v; have_lr = 1 + raw = v + # Accept org-native stamps: strip a leading [ or < (inactive/active + # timestamp bracket) and take the leading ISO date, so [2026-07-09 Thu] + # and 2026-07-09 both normalize to 2026-07-09. + sub(/^[[<]/, "", v) + if (match(v, /^[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]/)) { + lr = substr(v, RSTART, RLENGTH); have_lr = 1 + } else { + # Present but unparseable — a data error, not "never reviewed". Warn + # loudly (file:line:value) instead of silently folding it into the + # stale count, where a re-review in the same bad format would never + # drop the number and nothing would explain why. + printf "task-review-staleness: %s:%d: unparseable :LAST_REVIEWED: %s (expected YYYY-MM-DD or [YYYY-MM-DD Day])\n", fname, NR, raw > "/dev/stderr" + lr = "INVALID"; have_lr = 1 + } next } END { flush() } @@ -98,9 +117,16 @@ while IFS=$'\t' read -r hline value heading; do continue fi - # Unparseable date → treat as NIL (stale). + # Malformed stamp: extract_tasks already warned. A data error is not + # "never reviewed", so it stays out of the stale count. + if [ "$value" = "INVALID" ]; then + continue + fi + + # A shape-valid but impossible date (e.g. 2026-13-45) slips past the awk + # regex; warn and skip rather than silently counting it. if ! rev_epoch=$(date -d "$value" +%s 2>/dev/null); then - count=$((count + 1)) + echo "task-review-staleness: $todo_file: unparseable :LAST_REVIEWED: $value" >&2 continue fi diff --git a/claude-templates/.ai/scripts/tests/agent-roster.bats b/claude-templates/.ai/scripts/tests/agent-roster.bats new file mode 100644 index 0000000..939a7df --- /dev/null +++ b/claude-templates/.ai/scripts/tests/agent-roster.bats @@ -0,0 +1,141 @@ +#!/usr/bin/env bats +# Tests for agent-roster: report other live Claude agents in a project. +# +# pgrep and /proc are the system boundary, so the test injects both and runs +# the real include/exclude logic against fixtures — no Claude processes are +# spawned. Injection points: +# ROSTER_PGREP command standing in for pgrep (a stub printing $FAKE_PIDS) +# ROSTER_PROC proc dir (a fixture of <pid>/cwd symlinks + <pid>/status) +# ROSTER_SELF_PID the scanner's own pid, so the ancestry walk is testable +# +# Fixture process tree: pid 1000 (the scanner) is a child of 999 (the current +# session's claude), which is a child of init (1). So 999 must always be +# excluded as scanner ancestry; other claude pids are judged by cwd. + +setup() { + SCRIPT_DIR="$(cd "$(dirname "$BATS_TEST_FILENAME")/.." && pwd)" + ROSTER="$SCRIPT_DIR/agent-roster" + + PROC="$BATS_TEST_TMPDIR/proc" + ROOT="$BATS_TEST_TMPDIR/project" + mkdir -p "$PROC" "$ROOT/sub" "$BATS_TEST_TMPDIR/elsewhere" + + PGREP_STUB="$BATS_TEST_TMPDIR/pgrep" + cat >"$PGREP_STUB" <<'EOF' +#!/usr/bin/env bash +printf '%s\n' $FAKE_PIDS +EOF + chmod +x "$PGREP_STUB" + + SELF=1000 + # scanner (1000) <- session claude (999) <- init (1) + mkproc 1000 "$ROOT" 999 + mkproc 999 "$ROOT" 1 +} + +# mkproc PID CWD PPID — register a fake process in the fixture proc dir. +mkproc() { + mkdir -p "$PROC/$1" + ln -sf "$2" "$PROC/$1/cwd" + printf 'PPid:\t%s\n' "$3" >"$PROC/$1/status" +} + +run_roster() { + ROSTER_PGREP="$PGREP_STUB" ROSTER_PROC="$PROC" ROSTER_SELF_PID="$SELF" \ + FAKE_PIDS="$FAKE_PIDS" run "$ROSTER" "$ROOT" +} + +@test "agent-roster: alone (only the session's own claude) exits 0, no output" { + FAKE_PIDS="999" + run_roster + [ "$status" -eq 0 ] + [ -z "$output" ] +} + +@test "agent-roster: one other agent in-project is printed, exit 1" { + mkproc 2000 "$ROOT" 1 + FAKE_PIDS="999 2000" + run_roster + [ "$status" -eq 1 ] + [[ "$output" == *"2000"* ]] + [[ "$output" == *"$ROOT"* ]] +} + +@test "agent-roster: two other agents both printed, exit 1" { + mkproc 2000 "$ROOT" 1 + mkproc 2001 "$ROOT/sub" 1 + FAKE_PIDS="999 2000 2001" + run_roster + [ "$status" -eq 1 ] + [[ "$output" == *"2000"* ]] + [[ "$output" == *"2001"* ]] + [ "${#lines[@]}" -eq 2 ] +} + +@test "agent-roster: the scanner's session-claude ancestor is excluded even with matching cwd" { + # 999 has cwd == ROOT but is scanner ancestry; must not appear. + FAKE_PIDS="999" + run_roster + [ "$status" -eq 0 ] + [[ "$output" != *"999"* ]] +} + +@test "agent-roster: cwd outside the project root is excluded" { + mkproc 3000 "$BATS_TEST_TMPDIR/elsewhere" 1 + FAKE_PIDS="999 3000" + run_roster + [ "$status" -eq 0 ] + [ -z "$output" ] +} + +@test "agent-roster: cwd in a subdirectory of root is included" { + mkproc 2002 "$ROOT/sub" 1 + FAKE_PIDS="999 2002" + run_roster + [ "$status" -eq 1 ] + [[ "$output" == *"2002"* ]] +} + +@test "agent-roster: a sibling path sharing a prefix is not a false match" { + # ROOT is .../project; .../project-other must not count as inside it. + mkdir -p "$BATS_TEST_TMPDIR/project-other" + mkproc 3100 "$BATS_TEST_TMPDIR/project-other" 1 + FAKE_PIDS="999 3100" + run_roster + [ "$status" -eq 0 ] + [ -z "$output" ] +} + +@test "agent-roster: a pid that vanished between pgrep and the proc read is skipped" { + # 4000 has no fixture dir, simulating a process gone by readlink time. + FAKE_PIDS="999 4000" + run_roster + [ "$status" -eq 0 ] + [ -z "$output" ] +} + +@test "agent-roster: missing proc reports unavailable on stderr, exit 2, never silent-alone" { + ROSTER_PGREP="$PGREP_STUB" ROSTER_PROC="$BATS_TEST_TMPDIR/nonexistent" \ + ROSTER_SELF_PID="$SELF" FAKE_PIDS="999 2000" run "$ROSTER" "$ROOT" + [ "$status" -eq 2 ] + [[ "$output" == *"roster unavailable"* ]] +} + +@test "agent-roster: a missing pgrep reports unavailable, exit 2, never silent-alone" { + # If pgrep itself is absent, the scan can't run; reporting "alone" would be a + # false negative the "never silent-alone" invariant forbids. + mkproc 2000 "$ROOT" 1 + ROSTER_PGREP="$BATS_TEST_TMPDIR/no-such-pgrep" ROSTER_PROC="$PROC" \ + ROSTER_SELF_PID="$SELF" FAKE_PIDS="999 2000" run "$ROSTER" "$ROOT" + [ "$status" -eq 2 ] + [[ "$output" == *"roster unavailable"* ]] +} + +@test "agent-roster: defaults project root to PWD when no argument is given" { + mkproc 2000 "$ROOT" 1 + FAKE_PIDS="999 2000" + ROSTER_PGREP="$PGREP_STUB" ROSTER_PROC="$PROC" ROSTER_SELF_PID="$SELF" \ + FAKE_PIDS="$FAKE_PIDS" run env -C "$ROOT" "$ROSTER" + [ "$status" -eq 1 ] + [[ "$output" == *"2000"* ]] +} diff --git a/claude-templates/.ai/scripts/tests/capture-guard.bats b/claude-templates/.ai/scripts/tests/capture-guard.bats new file mode 100644 index 0000000..31632a4 --- /dev/null +++ b/claude-templates/.ai/scripts/tests/capture-guard.bats @@ -0,0 +1,130 @@ +#!/usr/bin/env bats +# +# Tests for claude-templates/.ai/scripts/capture-guard — detects live +# org-capture buffers visiting a target file before a workflow edits that +# file on disk (the roam inbox, in inbox.org roam mode Phase D). Editing the file +# underneath an indirect org-capture buffer wedges the capture (see emacs.md). +# +# Contract under test: +# capture-guard [TARGET_FILE] (default TARGET_FILE = ~/org/roam/inbox.org) +# exit 0 → safe to edit: emacsclient absent, daemon unreachable, or no +# capture buffer visits TARGET_FILE. +# exit 1 → a live capture buffer visits TARGET_FILE; its name(s) printed. +# +# Strategy: the emacsclient boundary is mocked with a PATH stub. The stub +# answers the reachability probe (`-e t`) per STUB_REACHABLE and returns a +# canned, real-emacsclient-shaped result (quoted string) for the buffer query +# per STUB_BUFS. The script's own quote-stripping and exit logic is the code +# under test; the file-equal-p precision is real-Emacs behavior we trust. + +SCRIPT="$(cd "$(dirname "$BATS_TEST_FILENAME")/.." && pwd)/capture-guard" +BASH_BIN="$(command -v bash)" + +setup() { + TEST_DIR="$(mktemp -d -t capture-guard-bats.XXXXXX)" + STUB_DIR="$TEST_DIR/bin" + mkdir -p "$STUB_DIR" + + cat > "$STUB_DIR/emacsclient" <<'STUB' +#!/usr/bin/env bash +# Mock emacsclient. `-e t` is the reachability probe; anything else is the +# buffer query, answered with the real-emacsclient-shaped quoted string. +expr="$2" +if [ "$expr" = "t" ]; then + [ "${STUB_REACHABLE:-1}" = "1" ] && { echo t; exit 0; } + exit 1 +fi +printf '%s\n' "${STUB_BUFS:-\"\"}" +exit 0 +STUB + chmod +x "$STUB_DIR/emacsclient" + + EMPTY_DIR="$TEST_DIR/empty" + mkdir -p "$EMPTY_DIR" +} + +teardown() { + rm -rf "$TEST_DIR" +} + +# ---- Safe-to-edit (exit 0) cases ------------------------------------ + +@test "capture-guard: emacsclient absent is safe (exit 0, no output)" { + run env PATH="$EMPTY_DIR" "$BASH_BIN" "$SCRIPT" + [ "$status" -eq 0 ] + [ -z "$output" ] +} + +@test "capture-guard: daemon unreachable is safe (exit 0)" { + run env PATH="$STUB_DIR:$PATH" STUB_REACHABLE=0 "$BASH_BIN" "$SCRIPT" + [ "$status" -eq 0 ] + [ -z "$output" ] +} + +@test "capture-guard: reachable with no capture buffers is safe (exit 0)" { + run env PATH="$STUB_DIR:$PATH" STUB_REACHABLE=1 STUB_BUFS='""' "$BASH_BIN" "$SCRIPT" + [ "$status" -eq 0 ] + [ -z "$output" ] +} + +# ---- Blocked (exit 1) cases ----------------------------------------- + +@test "capture-guard: one live capture buffer blocks (exit 1, name printed)" { + run env PATH="$STUB_DIR:$PATH" STUB_REACHABLE=1 STUB_BUFS='"CAPTURE-inbox.org"' \ + "$BASH_BIN" "$SCRIPT" + [ "$status" -eq 1 ] + [[ "$output" == *"CAPTURE-inbox.org"* ]] +} + +@test "capture-guard: multiple live capture buffers all reported (exit 1)" { + run env PATH="$STUB_DIR:$PATH" STUB_REACHABLE=1 \ + STUB_BUFS='"CAPTURE-inbox.org,CAPTURE-2-inbox.org"' \ + "$BASH_BIN" "$SCRIPT" + [ "$status" -eq 1 ] + [[ "$output" == *"CAPTURE-inbox.org"* ]] + [[ "$output" == *"CAPTURE-2-inbox.org"* ]] +} + +@test "capture-guard: blocked output does not contain stray surrounding quotes" { + run env PATH="$STUB_DIR:$PATH" STUB_REACHABLE=1 STUB_BUFS='"CAPTURE-inbox.org"' \ + "$BASH_BIN" "$SCRIPT" + [ "$status" -eq 1 ] + [[ "$output" != \"* ]] + [[ "$output" != *\" ]] +} + +# ---- Argument handling ---------------------------------------------- + +@test "capture-guard: accepts an explicit target-file argument" { + run env PATH="$STUB_DIR:$PATH" STUB_REACHABLE=1 STUB_BUFS='""' \ + "$BASH_BIN" "$SCRIPT" "$TEST_DIR/some-other-inbox.org" + [ "$status" -eq 0 ] + [ -z "$output" ] +} + +# ---- --wait poll mode ----------------------------------------------- + +@test "capture-guard --wait: returns 0 instantly when already safe (no sleep)" { + SECONDS=0 + run env PATH="$STUB_DIR:$PATH" STUB_REACHABLE=1 STUB_BUFS='""' \ + "$BASH_BIN" "$SCRIPT" --wait + [ "$status" -eq 0 ] + [ -z "$output" ] + [ "$SECONDS" -lt 2 ] # didn't poll-sleep +} + +@test "capture-guard --wait=1: times out to exit 1 when persistently blocked" { + # Stub always reports the buffer, so it never clears — the short budget + # forces a timeout. Capped sleep keeps this near 1s. + run env PATH="$STUB_DIR:$PATH" STUB_REACHABLE=1 STUB_BUFS='"CAPTURE-inbox.org"' \ + "$BASH_BIN" "$SCRIPT" --wait=1 + [ "$status" -eq 1 ] + [[ "$output" == *"CAPTURE-inbox.org"* ]] +} + +@test "capture-guard --wait=N accepts a target after the flag" { + run env PATH="$STUB_DIR:$PATH" STUB_REACHABLE=1 STUB_BUFS='""' \ + "$BASH_BIN" "$SCRIPT" --wait=1 "$TEST_DIR/some-other-inbox.org" + [ "$status" -eq 0 ] + [ -z "$output" ] +} diff --git a/claude-templates/.ai/scripts/tests/lint-org-cli.bats b/claude-templates/.ai/scripts/tests/lint-org-cli.bats index d457696..b9faef6 100644 --- a/claude-templates/.ai/scripts/tests/lint-org-cli.bats +++ b/claude-templates/.ai/scripts/tests/lint-org-cli.bats @@ -20,6 +20,24 @@ teardown() { [[ "$output" == *"lint-org: file="* ]] } +@test "lint-org.el default invocation is report-only — file untouched" { + # bare #+begin_src is a mechanical fix (→ #+begin_example) that the old + # default applied on disk; a linter reports, it doesn't write + printf '* H\n\n#+begin_src\nx\n#+end_src\n' > "$TMPFILE" + before="$(cat "$TMPFILE")" + run emacs --batch -q -l "$SCRIPTS_DIR/lint-org.el" "$TMPFILE" + [ "$status" -eq 0 ] + [[ "$output" == *"would-fix"* ]] + [ "$(cat "$TMPFILE")" = "$before" ] +} + +@test "lint-org.el --fix applies mechanical fixes on disk" { + printf '* H\n\n#+begin_src\nx\n#+end_src\n' > "$TMPFILE" + run emacs --batch -q -l "$SCRIPTS_DIR/lint-org.el" --fix "$TMPFILE" + [ "$status" -eq 0 ] + grep -q '#+begin_example' "$TMPFILE" +} + @test "wrap-org-table.el loads and runs without -L on the load path" { run emacs --batch -q -l "$SCRIPTS_DIR/wrap-org-table.el" --width=120 "$TMPFILE" [ "$status" -eq 0 ] diff --git a/claude-templates/.ai/scripts/tests/route-batch.bats b/claude-templates/.ai/scripts/tests/route-batch.bats new file mode 100644 index 0000000..84ded5f --- /dev/null +++ b/claude-templates/.ai/scripts/tests/route-batch.bats @@ -0,0 +1,202 @@ +#!/usr/bin/env bats +# +# Tests for claude-templates/.ai/scripts/route-batch — the wrap-up router's +# mechanical go path (wrapup-routing spec, Phase 4 / D7 / D9). +# +# Contract under test: +# route-batch --list one "<destination>\t<heading>" line per task +# carrying :ROUTE_CANDIDATE:; silent when none; +# never modifies anything +# route-batch --go per candidate: write the subtree (minus the +# :ROUTE_CANDIDATE: line) as a one-task handoff, +# deliver via inbox-send to the destination's +# inbox/, then remove the subtree from the local +# todo.org. Send failure leaves the task in +# place and exits non-zero. Empty set: no-op. +# +# Strategy: fixture roots under $TEST_DIR hold a source project and two +# destination projects; INBOX_SEND_ROOTS sandboxes inbox-send's discovery to +# them (the same hook inbox-send's own tests use). + +SCRIPT="$(cd "$(dirname "$BATS_TEST_FILENAME")/.." && pwd)/route-batch" + +setup() { + TEST_DIR="$(mktemp -d -t route-batch-bats.XXXXXX)" + ROOTS="$TEST_DIR/roots" + SRC="$ROOTS/srcproj" + mkdir -p "$SRC/.ai" "$SRC/inbox" \ + "$ROOTS/alpha/.ai" "$ROOTS/alpha/inbox" \ + "$ROOTS/beta/.ai" "$ROOTS/beta/inbox" + touch "$ROOTS/alpha/todo.org" # alpha has a todo.org; beta deliberately not + + cat > "$SRC/todo.org" <<'EOF' +* Srcproj Open Work +** TODO [#B] Alpha-bound task :feature: +:PROPERTIES: +:ROUTE_CANDIDATE: alpha +:END: +Body line about the alpha work. +*** TODO Sub-task that rides along +** TODO [#C] Purely local task +Local body stays put. +** TODO [#C] Beta-bound task :quick: +:PROPERTIES: +:CREATED: [2026-07-01 Tue] +:ROUTE_CANDIDATE: beta +:END: +Beta body. +EOF + + export INBOX_SEND_ROOTS="$ROOTS" + cd "$SRC" +} + +teardown() { + rm -rf "$TEST_DIR" +} + +# ---- --list ------------------------------------------------------------ + +@test "route-batch --list: one destination+heading line per candidate, backlog excluded" { + run "$SCRIPT" --list + [ "$status" -eq 0 ] + [[ "$output" == *"alpha"*"Alpha-bound task"* ]] + [[ "$output" == *"beta"*"Beta-bound task"* ]] + [[ "$output" != *"Purely local task"* ]] +} + +@test "route-batch --list: empty candidate set is silent (exit 0)" { + sed -i '/:ROUTE_CANDIDATE:/d' todo.org + run "$SCRIPT" --list + [ "$status" -eq 0 ] + [ -z "$output" ] +} + +@test "route-batch --list: modifies nothing (skip leaves all in place)" { + before="$(cat todo.org)" + run "$SCRIPT" --list + [ "$status" -eq 0 ] + [ "$(cat todo.org)" = "$before" ] + [ -z "$(ls "$ROOTS/alpha/inbox" "$ROOTS/beta/inbox" 2>/dev/null | grep -v ':')" ] +} + +# ---- --go -------------------------------------------------------------- + +@test "route-batch --go: delivers each candidate to its destination inbox with provenance" { + run "$SCRIPT" --go + [ "$status" -eq 0 ] + alpha_file=$(find "$ROOTS/alpha/inbox" -name '*from-srcproj*' -type f) + beta_file=$(find "$ROOTS/beta/inbox" -name '*from-srcproj*' -type f) + [ -n "$alpha_file" ] + [ -n "$beta_file" ] + grep -q 'Alpha-bound task' "$alpha_file" + grep -q 'Sub-task that rides along' "$alpha_file" # children ride along + grep -q 'Beta-bound task' "$beta_file" + ! grep -q ':ROUTE_CANDIDATE:' "$alpha_file" + ! grep -q ':ROUTE_CANDIDATE:' "$beta_file" +} + +@test "route-batch --go: removes routed subtrees from todo.org, leaves local tasks" { + run "$SCRIPT" --go + [ "$status" -eq 0 ] + ! grep -q 'Alpha-bound task' todo.org + ! grep -q 'Sub-task that rides along' todo.org + ! grep -q 'Beta-bound task' todo.org + grep -q 'Purely local task' todo.org + grep -q 'Local body stays put' todo.org +} + +@test "route-batch --go: a kept property drawer survives minus the marker" { + run "$SCRIPT" --go + [ "$status" -eq 0 ] + beta_file=$(find "$ROOTS/beta/inbox" -name '*from-srcproj*' -type f) + grep -q ':CREATED: \[2026-07-01 Tue\]' "$beta_file" +} + +@test "route-batch --go: destination with inbox/ but no todo.org still delivers" { + run "$SCRIPT" --go + [ "$status" -eq 0 ] + [ ! -f "$ROOTS/beta/todo.org" ] + [ -n "$(find "$ROOTS/beta/inbox" -name '*from-srcproj*' -type f)" ] +} + +@test "route-batch --go: empty candidate set is a silent no-op (exit 0)" { + sed -i '/:ROUTE_CANDIDATE:/d' todo.org + before="$(cat todo.org)" + run "$SCRIPT" --go + [ "$status" -eq 0 ] + [ -z "$output" ] + [ "$(cat todo.org)" = "$before" ] +} + +@test "route-batch --go: a failed send leaves that task in place, marker intact, and exits non-zero" { + sed -i 's/:ROUTE_CANDIDATE: beta/:ROUTE_CANDIDATE: ghost/' todo.org + run "$SCRIPT" --go + [ "$status" -ne 0 ] + grep -q 'Beta-bound task' todo.org # failed route stays local + grep -q ':ROUTE_CANDIDATE: ghost' todo.org # marker survives so it resurfaces next wrap + ! grep -q 'Alpha-bound task' todo.org # the good route still landed + [ -n "$(find "$ROOTS/alpha/inbox" -name '*from-srcproj*' -type f)" ] +} + +@test "route-batch --go: handoff headings are promoted to top level" { + run "$SCRIPT" --go + [ "$status" -eq 0 ] + alpha_file=$(find "$ROOTS/alpha/inbox" -name '*from-srcproj*' -type f) + grep -q '^\* TODO \[#B\] Alpha-bound task' "$alpha_file" + grep -q '^\*\* TODO Sub-task that rides along' "$alpha_file" +} + +@test "route-batch --go: a drawer emptied by the marker strip is pruned from the handoff" { + run "$SCRIPT" --go + [ "$status" -eq 0 ] + alpha_file=$(find "$ROOTS/alpha/inbox" -name '*from-srcproj*' -type f) + ! grep -q ':PROPERTIES:' "$alpha_file" +} + +# ---- Overlapping candidates (nested marker data-loss regression) -------- + +@test "route-batch --go: nested candidates conflict — both stay, bystander survives, exit non-zero" { + cat > todo.org <<'EOF' +* Srcproj Open Work +** TODO [#B] Parent bound for alpha +:PROPERTIES: +:ROUTE_CANDIDATE: alpha +:END: +Parent body. +*** TODO Child bound for beta +:PROPERTIES: +:ROUTE_CANDIDATE: beta +:END: +Child body. +** TODO [#C] Innocent bystander task +Bystander body. +EOF + run "$SCRIPT" --go + [ "$status" -ne 0 ] + [[ "$output" == *"CONFLICT"* ]] + grep -q 'Parent bound for alpha' todo.org + grep -q 'Child bound for beta' todo.org + grep -q 'Innocent bystander task' todo.org + grep -q 'Bystander body' todo.org + [ -z "$(find "$ROOTS/alpha/inbox" "$ROOTS/beta/inbox" -name '*from-srcproj*' -type f)" ] +} + +@test "route-batch: duplicate identical markers in one drawer dedupe to a single route" { + cat > todo.org <<'EOF' +* Srcproj Open Work +** TODO [#B] Double-tagged for alpha +:PROPERTIES: +:ROUTE_CANDIDATE: alpha +:ROUTE_CANDIDATE: alpha +:END: +Body. +EOF + run "$SCRIPT" --list + [ "$status" -eq 0 ] + [ "$(echo "$output" | grep -c 'Double-tagged')" -eq 1 ] + [[ "$output" != *"CONFLICT"* ]] + run "$SCRIPT" --go + [ "$status" -eq 0 ] + [ "$(find "$ROOTS/alpha/inbox" -name '*from-srcproj*' -type f | wc -l)" -eq 1 ] +} diff --git a/claude-templates/.ai/scripts/tests/self-inject.bats b/claude-templates/.ai/scripts/tests/self-inject.bats new file mode 100644 index 0000000..482f61d --- /dev/null +++ b/claude-templates/.ai/scripts/tests/self-inject.bats @@ -0,0 +1,78 @@ +#!/usr/bin/env bats +# Tests for self-inject.sh — tmux is the external boundary, stubbed with a +# recording fake so no real server is needed. + +setup() { + SCRIPT="$BATS_TEST_DIRNAME/../self-inject.sh" + STUB_DIR="$BATS_TEST_TMPDIR/bin" + LOG="$BATS_TEST_TMPDIR/tmux.log" + mkdir -p "$STUB_DIR" +} + +# A tmux stub that records every invocation and answers list-panes from +# $STUB_PANES (empty by default, so pane derivation fails unless a test +# provides ancestry-matching output). +make_stub() { + cat > "$STUB_DIR/tmux" <<'EOF' +#!/bin/sh +echo "$@" >> "$LOG" +case "$1" in + list-panes) printf '%s\n' "$STUB_PANES" ;; +esac +EOF + chmod +x "$STUB_DIR/tmux" +} + +@test "self-inject: -t pane with no pairs echoes the pane and exits 0" { + make_stub + run env PATH="$STUB_DIR:$PATH" LOG="$LOG" STUB_PANES="" sh "$SCRIPT" -t %42 + [ "$status" -eq 0 ] + [ "$output" = "%42" ] + # Pane was supplied, nothing sent: tmux must not have been called. + [ ! -e "$LOG" ] +} + +@test "self-inject: no pane derivable and no -t exits 1 with an error" { + make_stub + run env PATH="$STUB_DIR:$PATH" LOG="$LOG" STUB_PANES="" sh "$SCRIPT" 0 "hello" + [ "$status" -eq 1 ] + case "$output" in *"no owning pane"*) : ;; *) false ;; esac +} + +@test "self-inject: derives the pane from process ancestry via list-panes" { + make_stub + # The stub reports the bats test process itself as a pane's pane_pid; + # the script runs as our child, so that pid is in its ancestry. + run env PATH="$STUB_DIR:$PATH" LOG="$LOG" STUB_PANES="$$ %7" sh "$SCRIPT" + [ "$status" -eq 0 ] + [ "$output" = "%7" ] +} + +@test "self-inject: one delay/text pair sends literal text then Enter" { + make_stub + run env PATH="$STUB_DIR:$PATH" LOG="$LOG" STUB_PANES="" sh "$SCRIPT" -t %3 0 "/clear" + [ "$status" -eq 0 ] + run cat "$LOG" + [ "${lines[0]}" = "send-keys -t %3 -l /clear" ] + [ "${lines[1]}" = "send-keys -t %3 Enter" ] +} + +@test "self-inject: multiple pairs send in order" { + make_stub + run env PATH="$STUB_DIR:$PATH" LOG="$LOG" STUB_PANES="" \ + sh "$SCRIPT" -t %3 0 "/clear" 0 "go — resume" + [ "$status" -eq 0 ] + run cat "$LOG" + [ "${lines[0]}" = "send-keys -t %3 -l /clear" ] + [ "${lines[1]}" = "send-keys -t %3 Enter" ] + [ "${lines[2]}" = "send-keys -t %3 -l go — resume" ] + [ "${lines[3]}" = "send-keys -t %3 Enter" ] +} + +@test "self-inject: dangling odd argument after pairs is ignored" { + make_stub + run env PATH="$STUB_DIR:$PATH" LOG="$LOG" STUB_PANES="" sh "$SCRIPT" -t %3 0 "one" 99 + [ "$status" -eq 0 ] + run cat "$LOG" + [ "${#lines[@]}" -eq 2 ] +} diff --git a/claude-templates/.ai/scripts/tests/spec-sort.bats b/claude-templates/.ai/scripts/tests/spec-sort.bats new file mode 100644 index 0000000..583e458 --- /dev/null +++ b/claude-templates/.ai/scripts/tests/spec-sort.bats @@ -0,0 +1,453 @@ +#!/usr/bin/env bats +# +# Tests for claude-templates/.ai/scripts/spec-sort — the one-time docs-pile +# retrofit from the docs-lifecycle spec: classify docs/**/*.org outside +# docs/specs/ (spec candidate iff it carries BOTH a Decisions heading AND an +# Implementation phases heading), show an evidence panel, and on --apply +# move + rename confirmed candidates to docs/specs/*-spec.org, prepend the +# status heading (:ID:, dated history line), rewrite the keyword header to +# the two-sequence form, relink file: links across the rewritten roots, +# stamp :LAST_SPEC_SORT: in .ai/notes.org. +# +# Contract under test (docs/specs/2026-07-01-docs-lifecycle-spec.org, +# "The retrofit"): +# - dry-run report is the default; --apply writes +# - --apply refuses on a dirty worktree (exit 2) unless --allow-dirty +# - every candidate needs --confirm REL=KEYWORD or --skip REL (exit 1 +# otherwise); terminal keywords need --reason REL=TEXT +# - plan validated before the first write; destination collisions block +# - bare-path mentions in rewritten roots block --apply until +# --acknowledge-bare waives them (reported, never rewritten) +# - mid-apply failure names applied/not-applied + git restore recovery +# - idempotent: a sorted project yields no candidates, no changes +# +# Strategy: each test builds a throwaway git project fixture and runs the +# real script against it. Mid-apply failure is forced via the test-only +# SPEC_SORT_INJECT_FAIL_AFTER env hook. + +SCRIPT="$(cd "$(dirname "$BATS_TEST_FILENAME")/.." && pwd)/spec-sort" + +setup() { + TEST_DIR="$(mktemp -d -t spec-sort-bats.XXXXXX)" + PROJ="$TEST_DIR/proj" + mkdir -p "$PROJ" +} + +teardown() { + rm -rf "$TEST_DIR" +} + +# Standard fixture: one spec candidate, one note, a stray root spec with a +# spine, an anomaly (-spec.org name, no spine), inbound links from todo.org, +# a sibling note, a session archive (report-only surface), and .ai/notes.org +# with a Workflow State section. +make_project() { + cd "$PROJ" + git init -q + git config user.email test@test + git config user.name test + mkdir -p docs/design .ai/sessions + + cat > docs/design/widget.org <<'EOF' +#+TITLE: Widget Feature +#+DATE: 2026-05-01 +#+TODO: DRAFT REVIEW | SHIPPED + +* Metadata +| Status | draft | +| Owner | Craig | + +* Summary +The widget feature. See [[file:scratch-note.org][the note]]. + +* Decisions [1/2] +** DONE Pick the widget shape +** TODO Pick the color + +* Implementation phases +** Phase 1 — build =src/widget.py= +EOF + + cat > docs/design/scratch-note.org <<'EOF' +#+TITLE: Scratch Note + +* Metadata +| Status | n/a | + +* Thoughts +See [[file:widget.org][the widget spec]]. +EOF + + cat > docs/rooty-spec.org <<'EOF' +#+TITLE: Rooty + +* Decisions +** DONE Only decision + +* Implementation phases +** Phase 1 — nothing +EOF + + cat > docs/lonely-spec.org <<'EOF' +#+TITLE: Lonely +Just prose, no spine. +EOF + + cat > todo.org <<'EOF' +* Open Work +** DOING [#B] Widget feature +Spec: [[file:docs/design/widget.org][widget spec]]. +Summary anchor: [[file:docs/design/widget.org::*Summary][the summary]]. +EOF + + cat > .ai/notes.org <<'EOF' +* Active Reminders + +* Workflow State +:LAST_AUDIT: 2026-06-28 +EOF + + cat > .ai/sessions/2026-06-01-old.org <<'EOF' +Old log: [[file:../../docs/design/widget.org][widget]] +EOF + + git add -A + git commit -qm init +} + +# Confirm flags that satisfy the gate for the standard fixture's candidates. +CONFIRM_ALL=(--confirm docs/design/widget.org=DRAFT --confirm docs/rooty-spec.org=DRAFT) + +# ---- Classification (dry-run) ---------------------------------------- + +@test "spec-sort: dry-run classifies the spine-carrying doc as a candidate" { + make_project + run "$SCRIPT" + [ "$status" -eq 0 ] + [[ "$output" == *"CANDIDATE docs/design/widget.org -> docs/specs/widget-spec.org"* ]] +} + +@test "spec-sort: a Metadata table alone does not qualify — note stays a note" { + make_project + run "$SCRIPT" + [ "$status" -eq 0 ] + [[ "$output" == *"NOTE docs/design/scratch-note.org"* ]] + [[ "$output" != *"CANDIDATE docs/design/scratch-note.org"* ]] +} + +@test "spec-sort: stray root spec with a spine is a candidate, suffix not doubled" { + make_project + run "$SCRIPT" + [ "$status" -eq 0 ] + [[ "$output" == *"CANDIDATE docs/rooty-spec.org -> docs/specs/rooty-spec.org"* ]] + [[ "$output" != *"rooty-spec-spec.org"* ]] +} + +@test "spec-sort: -spec.org name without a spine is an anomaly, never auto-moved" { + make_project + run "$SCRIPT" + [ "$status" -eq 0 ] + [[ "$output" == *"ANOMALY docs/lonely-spec.org"* ]] + [[ "$output" != *"CANDIDATE docs/lonely-spec.org"* ]] +} + +@test "spec-sort: docs/specs/ contents are excluded from classification" { + make_project + mkdir -p docs/specs + cp docs/design/widget.org docs/specs/sorted-spec.org + git add -A && git commit -qm more + run "$SCRIPT" + [ "$status" -eq 0 ] + [[ "$output" != *"CANDIDATE docs/specs/sorted-spec.org"* ]] +} + +@test "spec-sort: no docs/ directory is a silent no-op" { + cd "$PROJ" + git init -q + git config user.email test@test + git config user.name test + echo x > README.md + git add -A && git commit -qm init + run "$SCRIPT" + [ "$status" -eq 0 ] + [ -z "$output" ] +} + +# ---- Evidence panel --------------------------------------------------- + +@test "spec-sort: evidence panel shows status field, cookies, and todo.org task" { + make_project + run "$SCRIPT" + [ "$status" -eq 0 ] + [[ "$output" == *"status field: draft"* ]] + [[ "$output" == *"Decisions [1/2]"* ]] + [[ "$output" == *"todo.org:"*"DOING"*"Widget feature"* ]] +} + +@test "spec-sort: keyword proposal follows the evidence — DOING from the linked DOING task" { + make_project + run "$SCRIPT" + [ "$status" -eq 0 ] + # status field says draft, but the linking todo.org task is DOING — the + # panel proposes the state the strongest evidence supports + [[ "$output" == *"proposed keyword: DOING"* ]] +} + +@test "spec-sort: an 'incomplete' status field never proposes the terminal IMPLEMENTED" { + make_project + sed -i 's/| Status | draft |/| Status | incomplete |/' docs/design/widget.org + git add -A && git commit -qm status + run "$SCRIPT" + [ "$status" -eq 0 ] + [[ "$output" != *"proposed keyword: IMPLEMENTED"* ]] +} + +# ---- Confirm gate ----------------------------------------------------- + +@test "spec-sort --apply: refuses when a candidate is neither confirmed nor skipped" { + make_project + run "$SCRIPT" --apply --confirm docs/design/widget.org=DRAFT + [ "$status" -eq 1 ] + [[ "$output" == *"unconfirmed"* ]] + [[ "$output" == *"docs/rooty-spec.org"* ]] + [ -f docs/design/widget.org ] # nothing moved +} + +@test "spec-sort --apply: a terminal keyword without --reason refuses" { + make_project + run "$SCRIPT" --apply --confirm docs/design/widget.org=IMPLEMENTED --skip docs/rooty-spec.org + [ "$status" -eq 1 ] + [[ "$output" == *"--reason"* ]] + [ -f docs/design/widget.org ] +} + +@test "spec-sort --apply: a terminal keyword with --reason records it in the history line" { + make_project + run "$SCRIPT" --apply --confirm docs/design/widget.org=IMPLEMENTED \ + --reason "docs/design/widget.org=shipped in v2, confirmed against src" \ + --skip docs/rooty-spec.org + [ "$status" -eq 0 ] + grep -q '^\* IMPLEMENTED Widget Feature' docs/specs/widget-spec.org + grep -q 'shipped in v2, confirmed against src' docs/specs/widget-spec.org +} + +@test "spec-sort --apply: --skip leaves the candidate in place and still stamps the marker" { + make_project + run "$SCRIPT" --apply --skip docs/design/widget.org --skip docs/rooty-spec.org + [ "$status" -eq 0 ] + [ -f docs/design/widget.org ] + grep -q ':LAST_SPEC_SORT:' .ai/notes.org +} + +# ---- Preflight -------------------------------------------------------- + +@test "spec-sort --apply: refuses on a dirty worktree (exit 2)" { + make_project + echo "drift" >> todo.org + run "$SCRIPT" --apply "${CONFIRM_ALL[@]}" + [ "$status" -eq 2 ] + [[ "$output" == *"dirty"* ]] + [ -f docs/design/widget.org ] +} + +@test "spec-sort --apply --allow-dirty: proceeds and names what recovery loses" { + make_project + echo "drift" >> todo.org + git add todo.org && git commit -qm drift # keep the link intact; dirty a different file + echo "scratch" > untracked-note.txt + echo "local edit" >> .ai/notes.org + run "$SCRIPT" --apply --allow-dirty "${CONFIRM_ALL[@]}" + [ "$status" -eq 0 ] + [[ "$output" == *"pre-existing"* ]] + [[ "$output" == *".ai/notes.org"* ]] + [ -f docs/specs/widget-spec.org ] +} + +# ---- Move + rename + rewrite ------------------------------------------ + +@test "spec-sort --apply: moves, renames to -spec.org, prepends status heading with :ID: and history" { + make_project + run "$SCRIPT" --apply "${CONFIRM_ALL[@]}" + [ "$status" -eq 0 ] + [ -f docs/specs/widget-spec.org ] + [ ! -f docs/design/widget.org ] + grep -q '^\* DRAFT Widget Feature' docs/specs/widget-spec.org + grep -q ':ID:' docs/specs/widget-spec.org + grep -q 'retrofitted by spec-sort' docs/specs/widget-spec.org +} + +@test "spec-sort --apply: keyword header rewritten to the two-sequence form" { + make_project + run "$SCRIPT" --apply "${CONFIRM_ALL[@]}" + [ "$status" -eq 0 ] + grep -q '^#+TODO: TODO | DONE$' docs/specs/widget-spec.org + grep -q '^#+TODO: DRAFT READY DOING | IMPLEMENTED SUPERSEDED CANCELLED$' docs/specs/widget-spec.org + ! grep -q 'DRAFT REVIEW | SHIPPED' docs/specs/widget-spec.org +} + +@test "spec-sort --apply: Metadata Status field mirrors the confirmed keyword in lowercase" { + make_project + run "$SCRIPT" --apply --confirm docs/design/widget.org=READY --skip docs/rooty-spec.org + [ "$status" -eq 0 ] + grep -q '^\* READY Widget Feature' docs/specs/widget-spec.org + grep -Eq '^\| Status[[:space:]]*\|[[:space:]]*ready' docs/specs/widget-spec.org +} + +# ---- Relink ----------------------------------------------------------- + +@test "spec-sort --apply: rewrites the todo.org link, preserving the description" { + make_project + run "$SCRIPT" --apply "${CONFIRM_ALL[@]}" + [ "$status" -eq 0 ] + grep -q '\[\[file:docs/specs/widget-spec.org\]\[widget spec\]\]' todo.org + ! grep -q 'docs/design/widget.org' todo.org +} + +@test "spec-sort --apply: preserves a ::anchor suffix through the rewrite" { + make_project + run "$SCRIPT" --apply "${CONFIRM_ALL[@]}" + [ "$status" -eq 0 ] + grep -q '\[\[file:docs/specs/widget-spec.org::\*Summary\]\[the summary\]\]' todo.org +} + +@test "spec-sort --apply: recomputes a sibling note's relative link to the moved spec" { + make_project + run "$SCRIPT" --apply "${CONFIRM_ALL[@]}" + [ "$status" -eq 0 ] + grep -q '\[\[file:../specs/widget-spec.org\]\[the widget spec\]\]' docs/design/scratch-note.org +} + +@test "spec-sort --apply: recomputes the moved spec's own outbound link to an unmoved note" { + make_project + run "$SCRIPT" --apply "${CONFIRM_ALL[@]}" + [ "$status" -eq 0 ] + grep -q '\[\[file:../design/scratch-note.org\]\[the note\]\]' docs/specs/widget-spec.org +} + +@test "spec-sort: session archives are reported, never rewritten" { + make_project + run "$SCRIPT" + [ "$status" -eq 0 ] + [[ "$output" == *"REPORT .ai/sessions/2026-06-01-old.org"* ]] + run "$SCRIPT" --apply "${CONFIRM_ALL[@]}" + [ "$status" -eq 0 ] + grep -q 'docs/design/widget.org' .ai/sessions/2026-06-01-old.org +} + +@test "spec-sort: a synced template path report names the canonical rulesets file" { + make_project + mkdir -p .ai/workflows + echo 'See [[file:../../docs/design/widget.org][widget]]' > .ai/workflows/startup.org + git add -A && git commit -qm wf + run "$SCRIPT" + [ "$status" -eq 0 ] + [[ "$output" == *"REPORT .ai/workflows/startup.org"* ]] + [[ "$output" == *"claude-templates/.ai/workflows/startup.org"* ]] +} + +# ---- Bare-path mentions ----------------------------------------------- + +@test "spec-sort --apply: a bare-path mention in a rewritten root blocks until acknowledged" { + make_project + echo "raw mention: docs/design/widget.org needs review" >> todo.org + git add -A && git commit -qm bare + run "$SCRIPT" --apply "${CONFIRM_ALL[@]}" + [ "$status" -eq 1 ] + [[ "$output" == *"BARE"* ]] + [ -f docs/design/widget.org ] # nothing moved + run "$SCRIPT" --apply --acknowledge-bare "${CONFIRM_ALL[@]}" + [ "$status" -eq 0 ] + grep -q 'raw mention: docs/design/widget.org' todo.org # reported, never rewritten +} + +@test "spec-sort --apply: a moving doc's bare mention of its own old path is acknowledgeable, not post-apply residue" { + make_project + echo "History: docs/design/widget.org was drafted in May." >> docs/design/widget.org + git add -A && git commit -qm selfmention + run "$SCRIPT" --apply "${CONFIRM_ALL[@]}" + [ "$status" -eq 1 ] + [[ "$output" == *"BARE"* ]] + run "$SCRIPT" --apply --acknowledge-bare "${CONFIRM_ALL[@]}" + [ "$status" -eq 0 ] # the acknowledged mention rides along to docs/specs/; not residue + grep -q ':LAST_SPEC_SORT:' .ai/notes.org +} + +# ---- Plan validation --------------------------------------------------- + +@test "spec-sort --apply: a destination collision blocks validation, nothing moved" { + make_project + mkdir -p docs/specs + echo "occupied" > docs/specs/widget-spec.org + git add -A && git commit -qm occupy + run "$SCRIPT" --apply "${CONFIRM_ALL[@]}" + [ "$status" -eq 1 ] + [[ "$output" == *"destination exists"* ]] + [ -f docs/design/widget.org ] + [ "$(cat docs/specs/widget-spec.org)" = "occupied" ] +} + +@test "spec-sort --apply: writes the plan file before executing" { + make_project + run "$SCRIPT" --apply --plan-file "$TEST_DIR/plan.json" "${CONFIRM_ALL[@]}" + [ "$status" -eq 0 ] + [ -f "$TEST_DIR/plan.json" ] + grep -q 'widget-spec.org' "$TEST_DIR/plan.json" +} + +# ---- Mid-apply failure recovery ---------------------------------------- + +@test "spec-sort --apply: forced mid-apply failure yields named recovery, not a half-migrated shrug" { + make_project + run env SPEC_SORT_INJECT_FAIL_AFTER=1 "$SCRIPT" --apply "${CONFIRM_ALL[@]}" + [ "$status" -eq 1 ] + [[ "$output" == *"RECOVERY"* ]] + [[ "$output" == *"git restore"* ]] + [[ "$output" == *"applied"* ]] + [[ "$output" == *"not applied"* ]] + ! grep -q ':LAST_SPEC_SORT:' .ai/notes.org # no stamp on a failed apply +} + +# ---- Idempotence + marker ---------------------------------------------- + +@test "spec-sort --apply: stamps :LAST_SPEC_SORT: in the Workflow State section" { + make_project + run "$SCRIPT" --apply "${CONFIRM_ALL[@]}" + [ "$status" -eq 0 ] + grep -q ':LAST_SPEC_SORT: ' .ai/notes.org + # lands inside the Workflow State section, alongside the existing marker + awk '/^\* Workflow State/{ws=1} ws && /:LAST_SPEC_SORT:/{found=1} END{exit !found}' .ai/notes.org +} + +@test "spec-sort --apply: creates the Workflow State section when notes.org lacks it" { + make_project + printf '* Active Reminders\n' > .ai/notes.org + git add -A && git commit -qm notes + run "$SCRIPT" --apply "${CONFIRM_ALL[@]}" + [ "$status" -eq 0 ] + grep -q '^\* Workflow State' .ai/notes.org + grep -q ':LAST_SPEC_SORT: ' .ai/notes.org +} + +@test "spec-sort --apply: zero candidates still stamps the marker (clears the nudge)" { + make_project + rm docs/design/widget.org docs/rooty-spec.org docs/lonely-spec.org + git add -A && git commit -qm notes-only + run "$SCRIPT" --apply + [ "$status" -eq 0 ] + grep -q ':LAST_SPEC_SORT:' .ai/notes.org +} + +@test "spec-sort: a second run after a successful apply finds nothing to do" { + make_project + run "$SCRIPT" --apply "${CONFIRM_ALL[@]}" + [ "$status" -eq 0 ] + git add -A && git commit -qm sorted + run "$SCRIPT" + [ "$status" -eq 0 ] + [[ "$output" != *"CANDIDATE"* ]] + run "$SCRIPT" --apply + [ "$status" -eq 0 ] + run git status --porcelain + # only the re-stamped marker (same date) may differ — tree stays clean + [ -z "$(git status --porcelain -- docs todo.org)" ] +} diff --git a/claude-templates/.ai/scripts/tests/task-review-staleness.bats b/claude-templates/.ai/scripts/tests/task-review-staleness.bats index 488b023..79aad79 100644 --- a/claude-templates/.ai/scripts/tests/task-review-staleness.bats +++ b/claude-templates/.ai/scripts/tests/task-review-staleness.bats @@ -49,6 +49,16 @@ task_unreviewed() { printf '** %s [#%s] %s\nBody.\n\n' "$keyword" "$prio" "$title" >> "$TODO" } +# Emit a qualifying task whose LAST_REVIEWED is an org-native inactive +# timestamp — [YYYY-MM-DD Day] — matching the CREATED:/CLOSED: cookies that +# sit in the same drawer. The date is derived from an ISO date via `date`. +task_reviewed_org() { + local keyword="$1" prio="$2" title="$3" isodate="$4" + local org="[$(date -d "$isodate" '+%F %a')]" + printf '** %s [#%s] %s\n:PROPERTIES:\n:LAST_REVIEWED: %s\n:END:\nBody.\n\n' \ + "$keyword" "$prio" "$title" "$org" >> "$TODO" +} + # ---- Normal cases ---------------------------------------------------- @test "staleness: empty file reports zero" { @@ -85,6 +95,20 @@ task_unreviewed() { [ "$output" = "2" ] } +@test "staleness: org-native bracketed LAST_REVIEWED parses — recent is fresh" { + task_reviewed_org TODO A "Reviewed five days ago, org stamp" "$D5" + run bash "$SCRIPT" "$TODO" 30 + [ "$status" -eq 0 ] + [ "$output" = "0" ] +} + +@test "staleness: org-native bracketed LAST_REVIEWED parses — old is stale" { + task_reviewed_org TODO A "Reviewed forty days ago, org stamp" "$D40" + run bash "$SCRIPT" "$TODO" 30 + [ "$status" -eq 0 ] + [ "$output" = "1" ] +} + # ---- Boundary cases -------------------------------------------------- @test "staleness: age exactly equal to threshold is fresh" { @@ -136,9 +160,23 @@ task_unreviewed() { [ "$output" = "0" ] } -@test "staleness: malformed LAST_REVIEWED is treated as stale" { +@test "staleness: malformed LAST_REVIEWED warns to stderr and is not counted" { task_reviewed TODO A "Bad date" "not-a-date" - run bash "$SCRIPT" "$TODO" 30 + # stdout carries only the count — the malformed stamp is not folded in. + run bash -c "bash '$SCRIPT' '$TODO' 30 2>/dev/null" + [ "$status" -eq 0 ] + [ "$output" = "0" ] + # stderr carries the loud warning naming the offending value. + run bash -c "bash '$SCRIPT' '$TODO' 30 2>&1 1>/dev/null" + [ "$status" -eq 0 ] + [[ "$output" == *"not-a-date"* ]] + [[ "$output" == *"LAST_REVIEWED"* ]] +} + +@test "staleness: malformed stamp is excluded while real stale tasks still count" { + task_reviewed TODO A "Real stale" "$D40" + task_reviewed TODO B "Broken stamp" "garbage" + run bash -c "bash '$SCRIPT' '$TODO' 30 2>/dev/null" [ "$status" -eq 0 ] [ "$output" = "1" ] } @@ -161,6 +199,17 @@ task_unreviewed() { [[ "${lines[2]}" == *"Reviewed recently"* ]] } +@test "staleness --list: org-native bracketed stamp sorts by its real date" { + task_reviewed TODO A "Bare recent" "$D5" + task_reviewed_org TODO B "Org-stamped old" "$D40" + run bash "$SCRIPT" --list "$TODO" 10 + [ "$status" -eq 0 ] + # The org-bracketed old stamp must sort ahead of the bare recent one — + # proof it parsed to a real date rather than falling to 0000-00-00. + [[ "${lines[0]}" == *"Org-stamped old"* ]] + [[ "${lines[1]}" == *"Bare recent"* ]] +} + @test "staleness --list: takes only the requested count" { task_unreviewed TODO A "First" task_reviewed TODO B "Second" "$D40" diff --git a/claude-templates/.ai/scripts/tests/test-lint-org.el b/claude-templates/.ai/scripts/tests/test-lint-org.el index 3a83602..8e3e190 100644 --- a/claude-templates/.ai/scripts/tests/test-lint-org.el +++ b/claude-templates/.ai/scripts/tests/test-lint-org.el @@ -620,6 +620,29 @@ followups file on the next run." ;;; --------------------------------------------------------------------------- ;;; org-table-standard check (width budget + rules between rows) +(ert-deftest lo-table-inside-example-block-not-flagged () + "Pipe-led ASCII art inside an example block is not a table; no judgment." + (let* ((run (lo-test--run + "* H\n\n#+begin_example\n| client |----->| server |\n| box | | box |\n#+end_example\n" + 1 t)) + (judgments (lo-test--judgments (plist-get run :issues)))) + (should-not (memq 'org-table-standard (lo-test--checkers judgments))))) + +(ert-deftest lo-table-inside-src-block-not-flagged () + "Shell pipes inside a src block are not a table; no judgment." + (let* ((run (lo-test--run + "* H\n\n#+begin_src sh\n| sort\n| uniq -c\n#+end_src\n" 1 t)) + (judgments (lo-test--judgments (plist-get run :issues)))) + (should-not (memq 'org-table-standard (lo-test--checkers judgments))))) + +(ert-deftest lo-real-table-after-block-still-flagged () + "Block safety must not mask a genuine violation later in the file." + (let* ((run (lo-test--run + "* H\n\n#+begin_example\n| art |\n#+end_example\n\n| a | b |\n| 1 | 2 |\n" + 1 t)) + (judgments (lo-test--judgments (plist-get run :issues)))) + (should (memq 'org-table-standard (lo-test--checkers judgments))))) + (ert-deftest lo-table-over-budget-emits-judgment () "A table line rendering wider than 120 surfaces as an org-table-standard judgment." (let* ((wide (make-string 130 ?x)) @@ -659,5 +682,138 @@ missing-rules violation." (judgments (lo-test--judgments (plist-get run :issues)))) (should-not (memq 'org-table-standard (lo-test--checkers judgments))))) +;;; --------------------------------------------------------------------------- +;;; level-2 dated-header check (claude-rules/todo-format.md) + +(ert-deftest lo-level2-dated-header-is-judgment () + "A level-2 heading beginning with a YYYY-MM-DD date is flagged." + (let* ((out (lo-test--run + "* Open Work\n\n** 2026-06-20 Sat @ 10:00:00 -0500 Something resolved\nBody.\n")) + (res (plist-get out :result)) + (judgments (lo-test--judgments (plist-get out :issues)))) + (should (= 0 (plist-get out :fixes))) ; judgment-only, never auto-fixed + (should (member 'level-2-dated-header (lo-test--checkers judgments))))) + +(ert-deftest lo-level2-done-task-not-flagged () + "A level-2 task closed with a terminal keyword + CLOSED: is fine." + (let* ((out (lo-test--run + "* Open Work\n\n** DONE [#B] Something resolved\nCLOSED: [2026-06-20 Sat]\nBody.\n")) + (judgments (lo-test--judgments (plist-get out :issues)))) + (should-not (member 'level-2-dated-header (lo-test--checkers judgments))))) + +(ert-deftest lo-level3-dated-entry-not-flagged () + "A dated event-log entry at level 3 is the correct sub-task shape, not a defect." + (let* ((out (lo-test--run + "* Open Work\n\n** TODO [#B] Parent task\n*** 2026-06-20 Sat @ 10:00:00 -0500 sub-entry landed\nBody.\n")) + (judgments (lo-test--judgments (plist-get out :issues)))) + (should-not (member 'level-2-dated-header (lo-test--checkers judgments))))) + +;;; subtask-done-not-dated check (the inverse: level-3+ done keyword) + +(ert-deftest lo-subtask-done-not-dated-flags-level3 () + "A level-3 DONE sub-task still carrying the keyword is flagged for conversion." + (let* ((out (lo-test--run + "* Open Work\n\n** TODO [#B] Parent\n*** DONE [#C] Sub-task done\nCLOSED: [2026-06-20 Sat 10:00]\nBody.\n")) + (judgments (lo-test--judgments (plist-get out :issues)))) + (should (= 0 (plist-get out :fixes))) ; judgment-only, never auto-fixed + (should (member 'subtask-done-not-dated (lo-test--checkers judgments))))) + +(ert-deftest lo-subtask-done-not-dated-flags-level4-cancelled () + "A level-4 CANCELLED sub-task is flagged too." + (let* ((out (lo-test--run + "* Open Work\n\n** PROJECT [#B] Parent\n*** TODO Mid\n**** CANCELLED Deep abandoned\nCLOSED: [2026-06-20 Sat]\n")) + (judgments (lo-test--judgments (plist-get out :issues)))) + (should (member 'subtask-done-not-dated (lo-test--checkers judgments))))) + +(ert-deftest lo-subtask-done-not-dated-ignores-level2 () + "A level-2 DONE task is a top-level task, not a sub-task — this checker skips it." + (let* ((out (lo-test--run + "* Open Work\n\n** DONE [#B] Top-level\nCLOSED: [2026-06-20 Sat]\nBody.\n")) + (judgments (lo-test--judgments (plist-get out :issues)))) + (should-not (member 'subtask-done-not-dated (lo-test--checkers judgments))))) + +(ert-deftest lo-subtask-done-not-dated-ignores-dated-and-lowercase () + "An already-dated level-3 entry, and the word done in a title, are not flagged." + (let* ((out (lo-test--run + "* Open Work\n\n** TODO [#B] Parent\n*** 2026-06-20 Sat @ 10:00:00 -0400 landed\n*** TODO wrap the done cleanup\n")) + (judgments (lo-test--judgments (plist-get out :issues)))) + (should-not (member 'subtask-done-not-dated (lo-test--checkers judgments))))) + +;;; --------------------------------------------------------------------------- +;;; structural heading checks (org-lint gaps) + +(defun lo-test--checker-lines (issues checker) + "Lines of judgment ISSUES whose :checker is CHECKER, document order." + (mapcar (lambda (i) (plist-get i :line)) + (cl-remove-if-not + (lambda (i) (and (eq (plist-get i :kind) 'judgment) + (eq (plist-get i :checker) checker))) + (reverse issues)))) + +(ert-deftest lo-indented-heading-flags-leading-whitespace () + "Error: a heading indented off column 0 is flagged (org demotes it to body)." + (let* ((out (lo-test--run "* Open\n ** TODO indented and lost\n** TODO fine\n")) + (j (lo-test--judgments (plist-get out :issues)))) + (should (member 'indented-heading (lo-test--checkers j))) + (should (= 1 (length (lo-test--checker-lines (plist-get out :issues) + 'indented-heading)))))) + +(ert-deftest lo-indented-heading-skips-stars-inside-blocks () + "Boundary: indented stars inside a #+begin_/#+end_ block are legitimate content." + (let* ((out (lo-test--run "* Open\n#+begin_example\n ** not a heading\n#+end_example\n")) + (j (lo-test--judgments (plist-get out :issues)))) + (should-not (member 'indented-heading (lo-test--checkers j))))) + +(ert-deftest lo-indented-heading-skips-single-star-list-bullets () + "Normal: an indented single `*' is a valid plain-list bullet, not a demoted +heading, so it is not flagged — only two-or-more indented stars are." + (let* ((out (lo-test--run "* Open\nintro line\n * first bullet\n * second bullet\n * nested bullet\n")) + (j (lo-test--judgments (plist-get out :issues)))) + (should-not (member 'indented-heading (lo-test--checkers j))))) + +(ert-deftest lo-empty-heading-flags-bare-stars () + "Error: a line of bare stars with no title is flagged." + (let* ((out (lo-test--run "* Open\n** \n** TODO real\n")) + (j (lo-test--judgments (plist-get out :issues)))) + (should (member 'empty-heading (lo-test--checkers j))))) + +(ert-deftest lo-malformed-priority-flags-lowercase-and-skips-valid () + "Error + Normal: a lowercase/oversized cookie flags; a valid [#B] stays silent." + (let* ((bad (lo-test--run "* Open\n** TODO [#a] lowercase cookie\n** TODO [#BB] oversized\n")) + (ok (lo-test--run "* Open\n** TODO [#B] valid cookie\n")) + (jo (lo-test--judgments (plist-get ok :issues)))) + (should (= 2 (length (lo-test--checker-lines (plist-get bad :issues) + 'malformed-priority-cookie)))) + (should-not (member 'malformed-priority-cookie (lo-test--checkers jo))))) + +(ert-deftest lo-malformed-priority-skips-verbatim-cookie-in-title () + "Boundary: a dated-log title quoting =[#D]= verbatim is not a real cookie." + (let* ((out (lo-test--run "* Open\n** TODO [#B] parent\n*** 2026-05-14 reprioritized =[#D]= -> =[#B]=\n")) + (j (lo-test--judgments (plist-get out :issues)))) + (should-not (member 'malformed-priority-cookie (lo-test--checkers j))))) + +(ert-deftest lo-done-without-closed-flags-undated-level2 () + "Error: a level-2 DONE with no CLOSED line is flagged; a dated one is not." + (let* ((bad (lo-test--run "* Resolved\n** DONE undated finished\nbody\n")) + (jb (lo-test--judgments (plist-get bad :issues))) + (ok (lo-test--run "* Resolved\n** DONE dated\nCLOSED: [2026-06-29 Mon]\n")) + (jo (lo-test--judgments (plist-get ok :issues)))) + (should (member 'level2-done-without-closed (lo-test--checkers jb))) + (should-not (member 'level2-done-without-closed (lo-test--checkers jo))))) + +(ert-deftest lo-done-without-closed-ignores-deeper-levels () + "Boundary: a level-3 DONE (a dated-log sub-entry) need not carry CLOSED." + (let* ((out (lo-test--run "* Resolved\n** DONE parent\nCLOSED: [2026-06-29 Mon]\n*** DONE nested no-closed\n")) + (j (lo-test--judgments (plist-get out :issues)))) + (should-not (member 'level2-done-without-closed (lo-test--checkers j))))) + +(ert-deftest lo-structural-checks-silent-on-clean-file () + "Normal: a well-formed file trips none of the four structural checkers." + (let* ((out (lo-test--run "* Open Work\n** TODO [#A] a task :tag:\n** DOING [#B] another\n* Resolved\n** DONE [#C] done\nCLOSED: [2026-06-29 Mon]\n")) + (checkers (lo-test--checkers (lo-test--judgments (plist-get out :issues))))) + (dolist (c '(indented-heading empty-heading malformed-priority-cookie + level2-done-without-closed)) + (should-not (member c checkers))))) + (provide 'test-lint-org) ;;; test-lint-org.el ends here diff --git a/claude-templates/.ai/scripts/tests/test-todo-cleanup.el b/claude-templates/.ai/scripts/tests/test-todo-cleanup.el index ad9260b..ffbf2fb 100644 --- a/claude-templates/.ai/scripts/tests/test-todo-cleanup.el +++ b/claude-templates/.ai/scripts/tests/test-todo-cleanup.el @@ -30,16 +30,20 @@ ;;; Harness (defun tc-test--reset (&optional check) - (setq tc-fixes 0 tc-archived 0 tc-bumped 0 tc-issues nil + (setq tc-fixes 0 tc-archived 0 tc-bumped 0 tc-archived-to-file 0 tc-issues nil tc-check-only (and check t) tc-archive-done t tc-sync-child-priority nil - tc-current-file nil)) + tc-current-file nil + ;; Aging step OFF by default so the in-file-move tests are unaffected by + ;; the wall clock; the aging harness re-enables it with fixed params. + tc-archive-retain-days nil tc-archive-reference-date nil tc-archive-file nil)) (defun tc-test--reset-sync (&optional check) - (setq tc-fixes 0 tc-archived 0 tc-bumped 0 tc-issues nil + (setq tc-fixes 0 tc-archived 0 tc-bumped 0 tc-archived-to-file 0 tc-issues nil tc-check-only (and check t) tc-archive-done nil tc-sync-child-priority t - tc-current-file nil)) + tc-current-file nil + tc-archive-retain-days nil tc-archive-reference-date nil tc-archive-file nil)) (defun tc-test--drop-buffer (file) (let ((buf (find-buffer-visiting file))) @@ -355,6 +359,200 @@ from the heading line through (not including) the next level-1 heading or EOF." (should (tc-test--has (plist-get out :report) "skipped")))) ;;; --------------------------------------------------------------------------- +;;; --archive-done file-aging: keep last week in-file, move older to task-archive + +(defun tc-test--age (content &optional opts) + "Run `--archive-done' with the file-aging step enabled. +OPTS is a plist: :retain (days; default 7, may be nil to disable), :ref +\(YEAR MONTH DAY reference date), :runs (default 1), :check. Writes CONTENT to a +temp todo file and points `tc-archive-file' at a not-yet-existing temp archive. +Returns a plist: :result (todo contents), :archive (archive-file contents or +nil), :archived (in-file move count), :to-file (aged count), :issues — all from +the last run." + (let* ((retain (if (plist-member opts :retain) (plist-get opts :retain) 7)) + (ref (plist-get opts :ref)) + (runs (or (plist-get opts :runs) 1)) + (check (plist-get opts :check)) + (todo (make-temp-file "tc-age-todo-" nil ".org")) + (adir (make-temp-file "tc-age-arch-" t)) + (afile (expand-file-name "task-archive.org" adir)) + last) + (unwind-protect + (progn + (with-temp-file todo (insert content)) + (dotimes (_ runs) + (tc-test--reset check) + (setq tc-archive-retain-days retain + tc-archive-reference-date ref + tc-archive-file afile) + (tc-process-file todo) + (setq last (list :archived tc-archived :to-file tc-archived-to-file + :issues tc-issues)) + (tc-test--drop-buffer todo)) + (append + last + (list :result (with-temp-buffer (insert-file-contents todo) (buffer-string)) + :archive (and (file-readable-p afile) + (with-temp-buffer (insert-file-contents afile) + (buffer-string)))))) + (tc-test--drop-buffer todo) + (delete-file todo) + (delete-directory adir t)))) + +;; Reference "today" for these fixtures is 2026-06-29; with retain 7 the cutoff +;; is 2026-06-22, so a task closed on or after 2026-06-22 stays in-file. +(defconst tc-test--age-resolved "\ +* Age Open Work +** TODO [#A] still open +* Age Resolved +** DONE [#B] recent within window +CLOSED: [2026-06-25 Thu] +recent body +** DONE [#C] old beyond window +CLOSED: [2026-05-01 Fri] +old body line +** CANCELLED [#C] old cancelled too +CLOSED: [2026-04-15 Wed] +** DONE [#B] exactly at cutoff stays +CLOSED: [2026-06-22 Sun] +** DONE [#C] undated no-date archived +no closed date in this body +") + +(defconst tc-test--age-straggler "\ +* Age Open Work +** TODO [#A] still open +** DONE [#C] old straggler +CLOSED: [2026-03-01 Sun] +straggler body +* Age Resolved +** DONE [#B] recent stays +CLOSED: [2026-06-26 Fri] +") + +(ert-deftest tc-age-moves-old-and-undated-resolved () + "Normal: closed-beyond-window AND undated subtrees leave the file; only those +closed within the window (cutoff inclusive) stay." + (let* ((out (tc-test--age tc-test--age-resolved '(:ref (2026 6 29)))) + (resolved (tc-test--section (plist-get out :result) "Age Resolved")) + (arch (plist-get out :archive))) + (should (= 3 (plist-get out :to-file))) + (should-not (tc-test--has resolved "old beyond window")) + (should-not (tc-test--has resolved "old cancelled too")) + (should-not (tc-test--has resolved "undated no-date archived")) + (should (tc-test--has resolved "recent within window")) + (should (tc-test--has resolved "exactly at cutoff stays")) + (should arch) + (should (tc-test--has arch "Resolved (archived)")) + (should (tc-test--has arch "old beyond window")) + (should (tc-test--has arch "old body line")) + (should (tc-test--has arch "old cancelled too")) + (should (tc-test--has arch "undated no-date archived")) + (should-not (tc-test--has arch "recent within window")))) + +(ert-deftest tc-age-disabled-when-retain-nil () + "Boundary: nil retain disables the aging step entirely (legacy behavior)." + (let ((out (tc-test--age tc-test--age-resolved '(:retain nil :ref (2026 6 29))))) + (should (= 0 (plist-get out :to-file))) + (should (equal tc-test--age-resolved (plist-get out :result))) + (should-not (plist-get out :archive)))) + +(ert-deftest tc-age-is-idempotent () + "Boundary: a second run finds nothing new to age; the todo file is stable." + (let ((once (tc-test--age tc-test--age-resolved '(:ref (2026 6 29) :runs 1))) + (twice (tc-test--age tc-test--age-resolved '(:ref (2026 6 29) :runs 2)))) + (should (equal (plist-get once :result) (plist-get twice :result))) + (should (= 0 (plist-get twice :to-file))))) + +(ert-deftest tc-age-check-mode-previews-without-writing () + "Boundary: --check reports the aged count but writes neither file." + (let ((out (tc-test--age tc-test--age-resolved '(:ref (2026 6 29) :check t)))) + (should (= 3 (plist-get out :to-file))) + (should (equal tc-test--age-resolved (plist-get out :result))) + (should-not (plist-get out :archive)))) + +(ert-deftest tc-age-straggler-moves-through-to-archive () + "Normal: an old-dated DONE in Open Work moves to Resolved then ages out in one run." + (let* ((out (tc-test--age tc-test--age-straggler '(:ref (2026 6 29)))) + (open (tc-test--section (plist-get out :result) "Age Open Work")) + (resolved (tc-test--section (plist-get out :result) "Age Resolved")) + (arch (plist-get out :archive))) + (should-not (tc-test--has open "old straggler")) + (should-not (tc-test--has resolved "old straggler")) + (should (tc-test--has arch "old straggler")) + (should (tc-test--has arch "straggler body")) + (should (tc-test--has resolved "recent stays")) + (should (= 1 (plist-get out :archived))) + (should (= 1 (plist-get out :to-file))))) + +(ert-deftest tc-age-append-preserves-existing-archive () + "Error/edge: appending to a populated archive keeps prior entries and one scaffold." + (let* ((adir (make-temp-file "tc-arch-" t)) + (afile (expand-file-name "task-archive.org" adir))) + (unwind-protect + (progn + (tc--append-subtrees-to-archive-file afile (list "** DONE one\n")) + (tc--append-subtrees-to-archive-file afile (list "** DONE two\n")) + (let ((content (with-temp-buffer (insert-file-contents afile) + (buffer-string))) + (n 0) (start 0)) + (should (tc-test--has content "** DONE one")) + (should (tc-test--has content "** DONE two")) + (should (tc-test--before-p content "** DONE one" "** DONE two")) + (while (string-match "\\* Resolved (archived)" content start) + (setq n (1+ n) start (match-end 0))) + (should (= 1 n)))) + (delete-directory adir t)))) + +;;; --------------------------------------------------------------------------- +;;; --archive-done aging: the archive follows the todo file's gitignore status + +(defun tc-test--age-in-git-repo (gitignore-todo) + "Init a temp git repo, write todo.org with an old Resolved entry, optionally +gitignore todo.org, then run `--archive-done' aging with the DEFAULT archive path +(archive/task-archive.org beside the todo file). Return a plist: :gitignore (final +.gitignore contents or nil), :archive-ignored (whether git ignores the archive), +:archive-exists." + (let* ((root (make-temp-file "tc-git-" t)) + (todo (expand-file-name "todo.org" root)) + (archive (expand-file-name "archive/task-archive.org" root)) + (gi (expand-file-name ".gitignore" root))) + (unwind-protect + (let ((default-directory root)) + (call-process "git" nil nil nil "init" "-q") + (with-temp-file todo (insert tc-test--age-resolved)) + (when gitignore-todo (with-temp-file gi (insert "/todo.org\n"))) + (tc-test--reset nil) + (setq tc-archive-retain-days 7 + tc-archive-reference-date '(2026 6 29) + tc-archive-file nil) ; default path, beside the todo file + (tc-process-file todo) + (tc-test--drop-buffer todo) + (list :gitignore (and (file-readable-p gi) + (with-temp-buffer (insert-file-contents gi) + (buffer-string))) + :archive-ignored + (eq 0 (call-process "git" nil nil nil "check-ignore" "-q" archive)) + :archive-exists (file-readable-p archive))) + (delete-directory root t)))) + +(ert-deftest tc-age-self-protect-gitignores-archive-when-todo-ignored () + "When the todo file is gitignored, the aged-out archive is added to .gitignore +so it inherits the same privacy." + (let ((out (tc-test--age-in-git-repo t))) + (should (plist-get out :archive-exists)) + (should (string-match-p "task-archive" (or (plist-get out :gitignore) ""))) + (should (plist-get out :archive-ignored)))) + +(ert-deftest tc-age-self-protect-leaves-tracked-todo-archive-tracked () + "When the todo file is tracked, the archive is not gitignored — no .gitignore +entry is added for it." + (let ((out (tc-test--age-in-git-repo nil))) + (should (plist-get out :archive-exists)) + (should-not (plist-get out :archive-ignored)) + (should-not (string-match-p "task-archive" (or (plist-get out :gitignore) ""))))) + +;;; --------------------------------------------------------------------------- ;;; Realistic synthetic sample (committed under fixtures/) (defun tc-test--sample-file () @@ -570,5 +768,176 @@ in ISSUES, in document order." (should (= 2 (plist-get once :bumped))) (should (= 2 (plist-get twice :bumped))))) +;;; --------------------------------------------------------------------------- +;;; --convert-subtasks harness + tests + +(defun tc-test--reset-convert (&optional check) + (setq tc-fixes 0 tc-archived 0 tc-bumped 0 tc-converted 0 tc-archived-to-file 0 + tc-issues nil + tc-check-only (and check t) + tc-archive-done nil tc-sync-child-priority nil tc-convert-subtasks t + tc-current-file nil + tc-archive-retain-days nil tc-archive-reference-date nil tc-archive-file nil)) + +(defun tc-test--convert (content &optional runs check) + "Write CONTENT to a temp .org file, run `--convert-subtasks' RUNS times (default 1). +Return a plist: :result final file contents, :converted count from the last run, +:issues from the last run. CHECK non-nil ⇒ --check (preview, no writes)." + (let ((file (make-temp-file "tc-test-" nil ".org")) + last-converted last-issues) + (unwind-protect + (progn + (with-temp-file file (insert content)) + (dotimes (_ (or runs 1)) + (tc-test--reset-convert check) + (tc-process-file file) + (setq last-converted tc-converted last-issues tc-issues) + (tc-test--drop-buffer file)) + (list :result (with-temp-buffer (insert-file-contents file) + (buffer-string)) + :converted last-converted + :issues last-issues)) + (tc-test--drop-buffer file) + (delete-file file)))) + +;; The UTC offset in a converted header is the test machine's local offset for +;; that date, so assertions match it as `[-+]NNNN' rather than a fixed value — +;; the mode's job is to emit a well-formed offset, not to run in one timezone. + +(defconst tc-test--convert-timed + "* Project Open Work +** TODO [#B] Parent task +*** DONE [#C] F12 opens the terminal :feature:quick: +CLOSED: [2026-06-27 Sat 12:50] +Verified live: docks, toggles, colors clean. +") + +(ert-deftest tc-convert-timed-subtask-normal () + "Normal: a timed CLOSED close becomes a dated header, keyword/priority/tags/CLOSED gone." + (let* ((out (tc-test--convert tc-test--convert-timed)) + (res (plist-get out :result))) + (should (= 1 (plist-get out :converted))) + (should (string-match-p + "^\\*\\*\\* 2026-06-27 Sat @ 12:50:00 [-+][0-9]\\{4\\} F12 opens the terminal$" + res)) + (should-not (string-match-p "CLOSED:" res)) + (should-not (string-match-p "DONE" res)) + (should (string-match-p "Verified live: docks, toggles, colors clean\\." res)) + (should (string-match-p "^\\*\\* TODO \\[#B\\] Parent task$" res)))) + +(defconst tc-test--convert-dateonly + "* Project Open Work +** PROJECT [#B] Parent +**** DONE [#B] Write full spec :refactor: +CLOSED: [2026-05-04 Mon] +Body. +") + +(ert-deftest tc-convert-dateonly-boundary-midnight () + "Boundary: a date-only CLOSED (no time) yields 00:00:00, at level 4." + (let ((res (plist-get (tc-test--convert tc-test--convert-dateonly) :result))) + (should (string-match-p + "^\\*\\*\\*\\* 2026-05-04 Mon @ 00:00:00 [-+][0-9]\\{4\\} Write full spec$" + res)) + (should-not (string-match-p "CLOSED:" res)))) + +(defconst tc-test--convert-level2 + "* Project Open Work +** DONE [#B] Top-level task +CLOSED: [2026-06-01 Mon 09:00] +Body. +") + +(ert-deftest tc-convert-leaves-level-2-alone-boundary () + "Boundary: a level-2 DONE task is a top-level task, not a sub-task — untouched." + (let ((out (tc-test--convert tc-test--convert-level2))) + (should (= 0 (plist-get out :converted))) + (should (equal tc-test--convert-level2 (plist-get out :result))))) + +(ert-deftest tc-convert-idempotent-boundary () + "Boundary: a second run over an already-dated entry converts nothing new." + (let ((once (tc-test--convert tc-test--convert-timed 1)) + (twice (tc-test--convert tc-test--convert-timed 2))) + (should (equal (plist-get once :result) (plist-get twice :result))) + (should (= 0 (plist-get twice :converted))))) + +(defconst tc-test--convert-nested + "* Project Open Work +** TODO [#B] Parent +*** DONE Outer sub :feature: +CLOSED: [2026-06-10 Wed 08:15] +**** DONE Inner sub +CLOSED: [2026-06-09 Tue 07:00] +Inner body. +") + +(ert-deftest tc-convert-nested-done-subtasks-boundary () + "Boundary: a done sub-task nested under a done sub-task — both convert." + (let* ((out (tc-test--convert tc-test--convert-nested)) + (res (plist-get out :result))) + (should (= 2 (plist-get out :converted))) + (should (string-match-p + "^\\*\\*\\* 2026-06-10 Wed @ 08:15:00 [-+][0-9]\\{4\\} Outer sub$" res)) + (should (string-match-p + "^\\*\\*\\*\\* 2026-06-09 Tue @ 07:00:00 [-+][0-9]\\{4\\} Inner sub$" res)) + (should-not (string-match-p "CLOSED:" res)))) + +(defconst tc-test--convert-cancelled + "* Project Open Work +** TODO [#B] Parent +*** CANCELLED [#C] Abandoned idea :feature: +CLOSED: [2026-06-15 Mon 10:00] +") + +(ert-deftest tc-convert-cancelled-subtask-boundary () + "Boundary: a CANCELLED sub-task converts too (terminal state)." + (let ((res (plist-get (tc-test--convert tc-test--convert-cancelled) :result))) + (should (string-match-p + "^\\*\\*\\* 2026-06-15 Mon @ 10:00:00 [-+][0-9]\\{4\\} Abandoned idea$" res)) + (should-not (string-match-p "CANCELLED" res)))) + +(defconst tc-test--convert-noclosed + "* Project Open Work +** TODO [#B] Parent +*** DONE Orphan with no closed date +Body only. +") + +(ert-deftest tc-convert-skips-subtask-without-closed-error () + "Error: a done sub-task with no parseable CLOSED is flagged and left unchanged." + (let ((out (tc-test--convert tc-test--convert-noclosed))) + (should (= 0 (plist-get out :converted))) + (should (equal tc-test--convert-noclosed (plist-get out :result))) + (should (cl-some (lambda (i) (eq (plist-get i :kind) 'convert-skip)) + (plist-get out :issues))))) + +(ert-deftest tc-convert-check-mode-previews-without-writing () + "Check mode reports the conversion but writes nothing." + (let ((out (tc-test--convert tc-test--convert-timed 1 t))) + (should (= 1 (plist-get out :converted))) + (should (equal tc-test--convert-timed (plist-get out :result))) + (should (cl-some (lambda (i) (eq (plist-get i :kind) 'convert-would)) + (plist-get out :issues))))) + +(defconst tc-test--convert-closed-with-deadline + "* Project Open Work +** TODO [#B] Parent task +*** DONE [#C] Ship the panel :feature: +CLOSED: [2026-06-27 Sat 12:50] DEADLINE: <2026-06-30 Tue> +Body line. +") + +(ert-deftest tc-convert-preserves-deadline-on-shared-planning-line-boundary () + "Boundary: removing the CLOSED cookie keeps a DEADLINE sharing its planning line." + (let* ((out (tc-test--convert tc-test--convert-closed-with-deadline)) + (res (plist-get out :result))) + (should (= 1 (plist-get out :converted))) + (should (string-match-p + "^\\*\\*\\* 2026-06-27 Sat @ 12:50:00 [-+][0-9]\\{4\\} Ship the panel$" + res)) + (should-not (string-match-p "CLOSED:" res)) + (should (string-match-p "^DEADLINE: <2026-06-30 Tue>$" res)) + (should (string-match-p "^Body line\\.$" res)))) + (provide 'test-todo-cleanup) ;;; test-todo-cleanup.el ends here diff --git a/claude-templates/.ai/scripts/tests/test-wrap-org-table.el b/claude-templates/.ai/scripts/tests/test-wrap-org-table.el index 8d1ecb6..0b3b375 100644 --- a/claude-templates/.ai/scripts/tests/test-wrap-org-table.el +++ b/claude-templates/.ai/scripts/tests/test-wrap-org-table.el @@ -186,3 +186,45 @@ (should (string-match-p "Prose before\\." content)) (should (string-match-p "Prose after\\." content)))) (delete-file file)))) + +;;; --------------------------------------------------------------------------- +;;; block safety — pipe lines inside #+begin_/#+end_ blocks are never tables + +(defconst wot-test--block-content + "#+begin_example +| client |----->| server | +| box | | box | +#+end_example +" + "An example block whose ASCII-art lines start with pipes.") + +(defun wot-test--process-content (content budget) + "Write CONTENT to a temp file, run `wot-process-file' at BUDGET, return result." + (let ((file (make-temp-file "wot-test" nil ".org"))) + (unwind-protect + (progn + (with-temp-file file (insert content)) + (wot-process-file file budget) + (with-temp-buffer (insert-file-contents file) (buffer-string))) + (delete-file file)))) + +(ert-deftest wot-process-file-leaves-example-block-byte-identical () + (let ((content (concat "* Diagram\n\n" wot-test--block-content))) + (should (equal (wot-test--process-content content 120) content)))) + +(ert-deftest wot-process-file-reformats-table-but-not-block () + (let* ((content (concat "* Doc\n\n" wot-test--block-content "\n" + wot-test--wide-input)) + (result (wot-test--process-content content 40))) + (should (string-match-p (regexp-quote wot-test--block-content) result)) + (should (string-match-p (regexp-quote wot-test--wide-expected) result)))) + +(ert-deftest wot-process-file-skips-pipes-in-src-block () + (let ((content "* Pipeline\n\n#+begin_src sh\n| sort\n| uniq -c\n#+end_src\n")) + (should (equal (wot-test--process-content content 120) content)))) + +(ert-deftest wot-process-file-literal-inner-end-marker-stays-in-block () + "A literal #+end_src quoted inside an example block must not close it." + (let ((content (concat "* Doc\n\n#+begin_example\n#+begin_src sh\nx\n" + "#+end_src\n| art |----| art |\n#+end_example\n"))) + (should (equal (wot-test--process-content content 120) content)))) diff --git a/claude-templates/.ai/scripts/tests/test_cross_agent_discover.py b/claude-templates/.ai/scripts/tests/test_cross_agent_discover.py deleted file mode 100644 index f0d2bb7..0000000 --- a/claude-templates/.ai/scripts/tests/test_cross_agent_discover.py +++ /dev/null @@ -1,204 +0,0 @@ -"""Tests for cross-agent-discover (TDD: tests written before implementation).""" - -from __future__ import annotations - -import json -import os -import subprocess -import textwrap -from pathlib import Path - -import pytest - -SCRIPT = Path(__file__).resolve().parent.parent / "cross-agent-comms" / "cross-agent-discover" - - -def _run(args: list[str], env: dict | None = None) -> subprocess.CompletedProcess: - return subprocess.run([str(SCRIPT), *args], capture_output=True, text=True, env=env) - - -@pytest.fixture -def fake_home(tmp_path, monkeypatch): - home = tmp_path / "home" - home.mkdir() - monkeypatch.setenv("HOME", str(home)) - return home - - -def _make_project(home: Path, name: str) -> Path: - proj = home / "projects" / name - (proj / ".ai").mkdir(parents=True) - return proj - - -def _write_peers_toml(home: Path, content: str) -> Path: - cfg = home / ".config" / "cross-agent-comms" - cfg.mkdir(parents=True, exist_ok=True) - peers = cfg / "peers.toml" - peers.write_text(content) - return peers - - -def test_discover_help(fake_home): - result = _run(["--help"], env={**os.environ, "HOME": str(fake_home)}) - assert result.returncode == 0 - assert "discover" in result.stdout.lower() or "enumerate" in result.stdout.lower() - - -def test_discover_local_only_no_projects(fake_home): - """Empty home → reports zero local projects, zero peers.""" - result = _run(["--no-cache"], env={**os.environ, "HOME": str(fake_home)}) - assert result.returncode == 0 - # No crash; mentions local somehow. - assert "local" in result.stdout.lower() or "0 project" in result.stdout.lower() - - -def test_discover_lists_local_projects(fake_home): - _make_project(fake_home, "homelab") - _make_project(fake_home, "career") - _make_project(fake_home, "claude-templates") - result = _run(["--no-cache"], env={**os.environ, "HOME": str(fake_home)}) - assert result.returncode == 0 - assert "homelab" in result.stdout - assert "career" in result.stdout - assert "claude-templates" in result.stdout - - -def test_discover_excludes_dirs_without_ai_subdir(fake_home): - """Directories under ~/projects/ that lack .ai/ are NOT projects.""" - _make_project(fake_home, "real-project") - (fake_home / "projects" / "not-a-project").mkdir(parents=True) - result = _run(["--no-cache"], env={**os.environ, "HOME": str(fake_home)}) - assert result.returncode == 0 - assert "real-project" in result.stdout - assert "not-a-project" not in result.stdout - - -def test_discover_no_peers_toml_just_local(fake_home): - _make_project(fake_home, "homelab") - result = _run(["--no-cache"], env={**os.environ, "HOME": str(fake_home)}) - assert result.returncode == 0 - # No peers section since no toml. - assert "homelab" in result.stdout - - -def test_discover_lists_peers_from_toml(fake_home): - _write_peers_toml(fake_home, textwrap.dedent("""\ - [peers.velox] - host = "velox" - ssh_user = "cjennings" - - [peers.bastion] - host = "bastion.local" - ssh_user = "cjennings" - """)) - _make_project(fake_home, "homelab") - result = _run(["--no-cache"], env={**os.environ, "HOME": str(fake_home)}) - assert result.returncode == 0 - assert "velox" in result.stdout - assert "bastion" in result.stdout - - -def test_discover_malformed_peers_toml_errors_clearly(fake_home): - _write_peers_toml(fake_home, "not valid toml at all = = =") - result = _run(["--no-cache"], env={**os.environ, "HOME": str(fake_home)}) - assert result.returncode != 0 - assert "peers.toml" in result.stderr or "TOML" in result.stderr or "parse" in result.stderr.lower() - - -def test_discover_json_output_schema(fake_home): - _make_project(fake_home, "homelab") - _make_project(fake_home, "career") - _write_peers_toml(fake_home, textwrap.dedent("""\ - [peers.velox] - host = "velox" - """)) - result = _run(["--json", "--no-cache"], env={**os.environ, "HOME": str(fake_home)}) - assert result.returncode == 0 - payload = json.loads(result.stdout) - assert "local" in payload - assert "peers" in payload - assert isinstance(payload["local"], list) - assert isinstance(payload["peers"], list) - assert "homelab" in payload["local"] - assert "career" in payload["local"] - velox = next((p for p in payload["peers"] if p["name"] == "velox"), None) - assert velox is not None - # Reachability is a key — value depends on actual SSH state. - assert "reachable" in velox - - -def test_discover_peer_scope(fake_home): - _write_peers_toml(fake_home, textwrap.dedent("""\ - [peers.velox] - host = "velox" - - [peers.bastion] - host = "bastion.local" - """)) - result = _run(["--peer", "velox", "--no-cache", "--json"], env={**os.environ, "HOME": str(fake_home)}) - assert result.returncode == 0 - payload = json.loads(result.stdout) - peer_names = [p["name"] for p in payload["peers"]] - assert "velox" in peer_names - assert "bastion" not in peer_names - - -def test_discover_unreachable_peer_marked(fake_home): - """A peer with a definitely-unreachable host gets reachable=False.""" - _write_peers_toml(fake_home, textwrap.dedent("""\ - [peers.bogus] - host = "definitely-not-a-real-host.invalid" - ssh_user = "nobody" - """)) - result = _run(["--no-cache", "--json"], env={**os.environ, "HOME": str(fake_home)}, ) - assert result.returncode == 0 - payload = json.loads(result.stdout) - bogus = next((p for p in payload["peers"] if p["name"] == "bogus"), None) - assert bogus is not None - assert bogus["reachable"] is False - - -def test_discover_cache_hit_within_window(fake_home): - """Second invocation within 5 min reads cache (skip the SSH probe).""" - _make_project(fake_home, "homelab") - # First call populates cache. - result1 = _run(["--json"], env={**os.environ, "HOME": str(fake_home)}) - assert result1.returncode == 0 - cache = fake_home / ".cache" / "cross-agent-comms" / "discovery.json" - assert cache.exists() - # Tamper with the cache to a marker only the cache path can produce. - payload = json.loads(cache.read_text()) - payload["_test_marker"] = True - cache.write_text(json.dumps(payload)) - # Second call (no --no-cache) should return the tampered payload. - result2 = _run(["--json"], env={**os.environ, "HOME": str(fake_home)}) - assert result2.returncode == 0 - payload2 = json.loads(result2.stdout) - assert payload2.get("_test_marker") is True - - -def test_discover_no_cache_flag_bypasses(fake_home): - """--no-cache ignores even a fresh cache.""" - _make_project(fake_home, "homelab") - cache_dir = fake_home / ".cache" / "cross-agent-comms" - cache_dir.mkdir(parents=True) - cache_dir.joinpath("discovery.json").write_text(json.dumps({ - "_test_marker": True, "local": [], "peers": [] - })) - result = _run(["--no-cache", "--json"], env={**os.environ, "HOME": str(fake_home)}) - assert result.returncode == 0 - payload = json.loads(result.stdout) - # Cache marker should NOT appear in fresh result. - assert payload.get("_test_marker") is None or payload.get("_test_marker") is False - assert "homelab" in payload["local"] - - -def test_discover_halt_shows_banner(fake_home): - halt = fake_home / ".config" / "cross-agent-comms" / "HALT" - halt.parent.mkdir(parents=True) - halt.write_text("halted") - _make_project(fake_home, "homelab") - result = _run(["--no-cache"], env={**os.environ, "HOME": str(fake_home)}) - assert result.returncode == 0 # discover continues to print under HALT - assert "HALT" in result.stdout diff --git a/claude-templates/.ai/scripts/tests/test_cross_agent_halt.py b/claude-templates/.ai/scripts/tests/test_cross_agent_halt.py deleted file mode 100644 index f8bf0b3..0000000 --- a/claude-templates/.ai/scripts/tests/test_cross_agent_halt.py +++ /dev/null @@ -1,204 +0,0 @@ -"""Tests for cross-agent-halt and cross-agent-resume (TDD).""" - -from __future__ import annotations - -import os -import subprocess -import textwrap -from pathlib import Path - -import pytest - -HALT_SCRIPT = Path(__file__).resolve().parent.parent / "cross-agent-comms" / "cross-agent-halt" -RESUME_SCRIPT = Path(__file__).resolve().parent.parent / "cross-agent-comms" / "cross-agent-resume" - - -def _run(script: Path, args: list[str], env: dict | None = None) -> subprocess.CompletedProcess: - return subprocess.run([str(script), *args], capture_output=True, text=True, env=env) - - -@pytest.fixture -def isolated_env(tmp_path, monkeypatch): - """Isolated HOME + a fake systemctl that records calls without acting.""" - fake_home = tmp_path / "home" - fake_home.mkdir() - fake_bin = tmp_path / "bin" - fake_bin.mkdir() - # Fake systemctl: no-op, exit 0. - fake_systemctl = fake_bin / "systemctl" - fake_systemctl.write_text("#!/usr/bin/env bash\nexit 0\n") - fake_systemctl.chmod(0o755) - # Fake ssh: succeed only for known-good host. - fake_ssh = fake_bin / "ssh" - fake_ssh.write_text(textwrap.dedent("""\ - #!/usr/bin/env bash - # Find the destination arg (skip flags). - target="" - for arg in "$@"; do - case "$arg" in - -*|*=*) ;; - *@*|localhost|*.local|*.invalid) target="$arg"; break ;; - *) target="$arg"; break ;; - esac - done - case "$target" in - *invalid*|*unreachable*) exit 255 ;; - *) exit 0 ;; - esac - """)) - fake_ssh.chmod(0o755) - - monkeypatch.setenv("HOME", str(fake_home)) - # Prepend our fake bin so systemctl + ssh are intercepted, but keep real /bin etc. - monkeypatch.setenv("PATH", f"{fake_bin}:{os.environ.get('PATH', '')}") - return fake_home - - -# ---- cross-agent-halt ---- - - -def test_halt_help(isolated_env): - result = _run(HALT_SCRIPT, ["--help"], env={**os.environ, "HOME": str(isolated_env), - "PATH": os.environ["PATH"]}) - assert result.returncode == 0 - assert "halt" in result.stdout.lower() - - -def test_halt_creates_halt_file(isolated_env): - halt_file = isolated_env / ".config" / "cross-agent-comms" / "HALT" - assert not halt_file.exists() - result = _run(HALT_SCRIPT, [], env={**os.environ, "HOME": str(isolated_env), - "PATH": os.environ["PATH"]}) - assert result.returncode == 0 - assert halt_file.exists() - - -def test_halt_with_reason_writes_body(isolated_env): - result = _run(HALT_SCRIPT, ["pausing for incident review"], - env={**os.environ, "HOME": str(isolated_env), "PATH": os.environ["PATH"]}) - assert result.returncode == 0 - halt_file = isolated_env / ".config" / "cross-agent-comms" / "HALT" - assert halt_file.exists() - assert "pausing for incident review" in halt_file.read_text() - - -def test_halt_idempotent(isolated_env): - """Running halt twice doesn't error.""" - halt_file = isolated_env / ".config" / "cross-agent-comms" / "HALT" - r1 = _run(HALT_SCRIPT, [], env={**os.environ, "HOME": str(isolated_env), "PATH": os.environ["PATH"]}) - assert r1.returncode == 0 - assert halt_file.exists() - r2 = _run(HALT_SCRIPT, [], env={**os.environ, "HOME": str(isolated_env), "PATH": os.environ["PATH"]}) - assert r2.returncode == 0 - assert halt_file.exists() - - -def test_halt_does_not_pkill(isolated_env): - """Per design: halt does NOT call pkill. Verify by checking no pkill process gets launched.""" - # Replace pkill in PATH with something that fails loudly so we'd see if halt invoked it. - fake_bin = isolated_env.parent / "bin" - pkill = fake_bin / "pkill" - pkill.write_text("#!/usr/bin/env bash\necho 'PKILL CALLED' >&2\nexit 99\n") - pkill.chmod(0o755) - result = _run(HALT_SCRIPT, [], env={**os.environ, "HOME": str(isolated_env), "PATH": os.environ["PATH"]}) - assert result.returncode == 0 - assert "PKILL CALLED" not in result.stderr - - -def test_halt_tailnet_reports_per_peer(isolated_env): - """--tailnet iterates peers.toml and reports per-peer status.""" - cfg = isolated_env / ".config" / "cross-agent-comms" - cfg.mkdir(parents=True) - (cfg / "peers.toml").write_text(textwrap.dedent("""\ - [peers.velox] - host = "velox" - ssh_user = "cjennings" - - [peers.bogus] - host = "definitely-unreachable.invalid" - ssh_user = "cjennings" - """)) - result = _run(HALT_SCRIPT, ["--tailnet"], - env={**os.environ, "HOME": str(isolated_env), "PATH": os.environ["PATH"]}) - # Partial halt → exit 1. - assert result.returncode == 1 - assert "velox" in result.stdout - assert "bogus" in result.stdout - # ✓ marker for velox, ✗ for bogus. - assert "✓" in result.stdout - assert "✗" in result.stdout - assert "PARTIAL" in result.stdout or "partial" in result.stdout.lower() - - -def test_halt_tailnet_all_reachable_exits_zero(isolated_env): - cfg = isolated_env / ".config" / "cross-agent-comms" - cfg.mkdir(parents=True) - (cfg / "peers.toml").write_text(textwrap.dedent("""\ - [peers.velox] - host = "velox" - ssh_user = "cjennings" - """)) - result = _run(HALT_SCRIPT, ["--tailnet"], - env={**os.environ, "HOME": str(isolated_env), "PATH": os.environ["PATH"]}) - assert result.returncode == 0 - assert "velox" in result.stdout - - -# ---- cross-agent-resume ---- - - -def test_resume_help(isolated_env): - result = _run(RESUME_SCRIPT, ["--help"], - env={**os.environ, "HOME": str(isolated_env), "PATH": os.environ["PATH"]}) - assert result.returncode == 0 - assert "resume" in result.stdout.lower() - - -def test_resume_removes_halt_file(isolated_env): - halt_file = isolated_env / ".config" / "cross-agent-comms" / "HALT" - halt_file.parent.mkdir(parents=True) - halt_file.write_text("halted") - assert halt_file.exists() - result = _run(RESUME_SCRIPT, [], - env={**os.environ, "HOME": str(isolated_env), "PATH": os.environ["PATH"]}) - assert result.returncode == 0 - assert not halt_file.exists() - - -def test_resume_when_no_halt_active_succeeds(isolated_env): - """No HALT to clear is not an error.""" - result = _run(RESUME_SCRIPT, [], - env={**os.environ, "HOME": str(isolated_env), "PATH": os.environ["PATH"]}) - assert result.returncode == 0 - - -def test_resume_prints_per_session_instructions(isolated_env): - """Resume must surface that polling does NOT auto-resume.""" - halt_file = isolated_env / ".config" / "cross-agent-comms" / "HALT" - halt_file.parent.mkdir(parents=True) - halt_file.write_text("halted") - result = _run(RESUME_SCRIPT, [], - env={**os.environ, "HOME": str(isolated_env), "PATH": os.environ["PATH"]}) - assert result.returncode == 0 - out = result.stdout.lower() - assert "polling" in out - assert "auto" in out or "explicit" in out or "session" in out - - -def test_resume_tailnet_partial_failure_exit_1(isolated_env): - cfg = isolated_env / ".config" / "cross-agent-comms" - cfg.mkdir(parents=True) - (cfg / "peers.toml").write_text(textwrap.dedent("""\ - [peers.velox] - host = "velox" - - [peers.bogus] - host = "unreachable-host.invalid" - """)) - halt_file = cfg / "HALT" - halt_file.write_text("halted") - result = _run(RESUME_SCRIPT, ["--tailnet"], - env={**os.environ, "HOME": str(isolated_env), "PATH": os.environ["PATH"]}) - assert result.returncode == 1 - assert "velox" in result.stdout - assert "bogus" in result.stdout diff --git a/claude-templates/.ai/scripts/tests/test_cross_agent_recv.py b/claude-templates/.ai/scripts/tests/test_cross_agent_recv.py deleted file mode 100644 index 27c53a5..0000000 --- a/claude-templates/.ai/scripts/tests/test_cross_agent_recv.py +++ /dev/null @@ -1,176 +0,0 @@ -"""Tests for cross-agent-recv.""" - -from __future__ import annotations - -import json -import os -import subprocess -from pathlib import Path - -import pytest - -SCRIPT = Path(__file__).resolve().parent.parent / "cross-agent-comms" / "cross-agent-recv" - - -def _make_message(path: Path, *, conv_id: str = "test-conv", seq: int = 1, msg_type: str = "request", - proto_version: str = "5", title: str = "Test", requires_tools: str | None = None, - body: str = "Body.\n") -> Path: - fm_lines = [ - f"#+TITLE: {title}", - f"#+CONVERSATION_ID: {conv_id}", - f"#+MESSAGE_TYPE: {msg_type}", - f"#+SEQUENCE: {seq}", - "#+TIMESTAMP: 2026-04-27T05:00:00-05:00", - f"#+PROTOCOL_VERSION: {proto_version}", - ] - if requires_tools: - fm_lines.append(f"#+REQUIRES_TOOLS: {requires_tools}") - path.write_text("\n".join(fm_lines) + "\n\n" + body) - return path - - -def _run(args: list[str], env: dict | None = None) -> subprocess.CompletedProcess: - return subprocess.run([str(SCRIPT), *args], capture_output=True, text=True, env=env) - - -@pytest.fixture -def isolated_env(tmp_path, monkeypatch): - fake_home = tmp_path / "home" - fake_home.mkdir() - monkeypatch.setenv("HOME", str(fake_home)) - return fake_home - - -def test_recv_help(isolated_env): - result = _run(["--help"], env={**os.environ, "HOME": str(isolated_env)}) - assert result.returncode == 0 - assert "Receive and decide" in result.stdout - - -def test_recv_missing_file_rejects(isolated_env, tmp_path): - result = _run([str(tmp_path / "nope.org")], env={**os.environ, "HOME": str(isolated_env)}) - assert result.returncode == 3 # reject - - -def test_recv_malformed_frontmatter_rejects(isolated_env, tmp_path): - bad = tmp_path / "bad.org" - bad.write_text("not org-mode at all\n") - result = _run([str(bad), "--no-verify"], env={**os.environ, "HOME": str(isolated_env)}) - assert result.returncode == 3 - assert "decision: reject" in result.stdout - - -def test_recv_missing_required_field_rejects(isolated_env, tmp_path): - msg = tmp_path / "msg.org" - # Missing PROTOCOL_VERSION among others. - msg.write_text("#+TITLE: x\n#+CONVERSATION_ID: c\n\nBody.\n") - result = _run([str(msg), "--no-verify"], env={**os.environ, "HOME": str(isolated_env)}) - assert result.returncode == 3 - assert "missing required" in result.stdout - - -def test_recv_protocol_version_mismatch_query(isolated_env, tmp_path): - msg = _make_message(tmp_path / "msg.org", proto_version="4") - result = _run([str(msg), "--no-verify"], env={**os.environ, "HOME": str(isolated_env)}) - assert result.returncode == 2 # query - assert "PROTOCOL_VERSION mismatch" in result.stdout - - -def test_recv_invalid_message_type_rejects(isolated_env, tmp_path): - msg = _make_message(tmp_path / "msg.org", msg_type="banana") - result = _run([str(msg), "--no-verify"], env={**os.environ, "HOME": str(isolated_env)}) - assert result.returncode == 3 - assert "invalid MESSAGE_TYPE" in result.stdout - - -def test_recv_missing_signature_rejects(isolated_env, tmp_path): - """When verify is on, a missing .asc sibling rejects.""" - msg = _make_message(tmp_path / "msg.org") - # No .asc sidecar. - result = _run([str(msg)], env={**os.environ, "HOME": str(isolated_env)}) - assert result.returncode == 3 - assert "signature file missing" in result.stdout - - -def test_recv_valid_processes(isolated_env, tmp_path): - """A valid message with --no-verify and no dedup match → process.""" - msg = _make_message(tmp_path / "msg.org") - result = _run([str(msg), "--no-verify"], env={**os.environ, "HOME": str(isolated_env)}) - assert result.returncode == 0 # process - assert "decision: process" in result.stdout - assert "sha256:" in result.stdout - - -def test_recv_dedup_against_identical_existing(isolated_env, tmp_path): - """Same content + same SEQUENCE in same dir → dedup.""" - inbox = tmp_path / "inbox" - inbox.mkdir() - first = _make_message(inbox / "20260427T100000Z-from-x-c.org", conv_id="c", seq=5) - # Second message with same content — name differs (canonical-style would have different timestamp). - second = _make_message(inbox / "20260427T100100Z-from-x-c.org", conv_id="c", seq=5) - # Bodies must be byte-identical for hash equality. - second.write_bytes(first.read_bytes()) - result = _run([str(second), "--no-verify"], env={**os.environ, "HOME": str(isolated_env)}) - assert result.returncode == 1 # dedup - assert "decision: dedup" in result.stdout - - -def test_recv_collision_with_different_content_processes(isolated_env, tmp_path): - """Same SEQUENCE + same CONVERSATION_ID but different content → process both.""" - inbox = tmp_path / "inbox" - inbox.mkdir() - _make_message(inbox / "20260427T100000Z-from-x-c.org", conv_id="c", seq=5, body="First body.\n") - second = _make_message(inbox / "20260427T100100Z-from-x-c.org", conv_id="c", seq=5, body="Different body.\n") - result = _run([str(second), "--no-verify"], env={**os.environ, "HOME": str(isolated_env)}) - assert result.returncode == 0 # process - assert "decision: process" in result.stdout - - -def test_recv_requires_tools_missing_query(isolated_env, tmp_path): - """REQUIRES_TOOLS naming a definitely-missing binary → query.""" - msg = _make_message(tmp_path / "msg.org", requires_tools="definitely-not-installed-xyzzy-9000") - result = _run([str(msg), "--no-verify"], env={**os.environ, "HOME": str(isolated_env)}) - assert result.returncode == 2 # query - assert "required tools unavailable" in result.stdout - - -def test_recv_requires_tools_present_processes(isolated_env, tmp_path): - """REQUIRES_TOOLS naming a real binary → process.""" - msg = _make_message(tmp_path / "msg.org", requires_tools="ls,cat") - result = _run([str(msg), "--no-verify"], env={**os.environ, "HOME": str(isolated_env)}) - assert result.returncode == 0 - assert "decision: process" in result.stdout - - -def test_recv_json_output(isolated_env, tmp_path): - msg = _make_message(tmp_path / "msg.org") - result = _run([str(msg), "--no-verify", "--json"], env={**os.environ, "HOME": str(isolated_env)}) - assert result.returncode == 0 - payload = json.loads(result.stdout) - assert payload["decision"] == "process" - assert payload["message_type"] == "request" - assert payload["conversation_id"] == "test-conv" - - -def test_recv_halt_blocks(isolated_env, tmp_path): - halt = isolated_env / ".config" / "cross-agent-comms" / "HALT" - halt.parent.mkdir(parents=True) - halt.write_text("halted\n") - msg = _make_message(tmp_path / "msg.org") - result = _run([str(msg), "--no-verify"], env={**os.environ, "HOME": str(isolated_env)}) - assert result.returncode == 5 - assert "halt active" in result.stderr.lower() - - -def test_recv_halt_leaves_message_in_place(isolated_env, tmp_path): - """Per spec: under HALT, recv must NOT move/dedup/reject — leave file in place.""" - halt = isolated_env / ".config" / "cross-agent-comms" / "HALT" - halt.parent.mkdir(parents=True) - halt.write_text("halted\n") - msg = _make_message(tmp_path / "msg.org") - pre_content = msg.read_text() - result = _run([str(msg), "--no-verify"], env={**os.environ, "HOME": str(isolated_env)}) - assert result.returncode == 5 - # File still exists with same content. - assert msg.exists() - assert msg.read_text() == pre_content diff --git a/claude-templates/.ai/scripts/tests/test_cross_agent_send.py b/claude-templates/.ai/scripts/tests/test_cross_agent_send.py deleted file mode 100644 index f716e95..0000000 --- a/claude-templates/.ai/scripts/tests/test_cross_agent_send.py +++ /dev/null @@ -1,210 +0,0 @@ -"""Tests for cross-agent-send. - -Subprocess-based: treat the script as a black-box CLI and assert on its -exit codes, stdout, and the files it produces. -""" - -from __future__ import annotations - -import os -import subprocess -import textwrap -from pathlib import Path - -import pytest - -SCRIPT = Path(__file__).resolve().parent.parent / "cross-agent-comms" / "cross-agent-send" - - -def _make_message(tmp_path: Path, conv_id: str = "test-conv", seq: int = 1, msg_type: str = "request", - proto_version: str = "5") -> Path: - msg = tmp_path / "msg.org" - msg.write_text(textwrap.dedent(f"""\ - #+TITLE: Test message - #+CONVERSATION_ID: {conv_id} - #+MESSAGE_TYPE: {msg_type} - #+SEQUENCE: {seq} - #+TIMESTAMP: 2026-04-27T05:00:00-05:00 - #+PROTOCOL_VERSION: {proto_version} - - Body. - """)) - return msg - - -def _run(args: list[str], env: dict | None = None, cwd: Path | None = None) -> subprocess.CompletedProcess: - return subprocess.run( - [str(SCRIPT), *args], - capture_output=True, - text=True, - env=env, - cwd=cwd, - ) - - -@pytest.fixture -def isolated_env(tmp_path, monkeypatch): - """Redirect HOME so peers.toml, HALT, marker files are scoped to the test.""" - fake_home = tmp_path / "home" - fake_home.mkdir() - monkeypatch.setenv("HOME", str(fake_home)) - # Pre-create projects/ so derive_sender_project has somewhere to look. - (fake_home / "projects" / "homelab").mkdir(parents=True) - return fake_home - - -def test_send_help(isolated_env): - """--help works without side effects.""" - result = _run(["--help"], env={**os.environ, "HOME": str(isolated_env)}) - assert result.returncode == 0 - assert "Send a cross-agent message" in result.stdout - - -def test_send_missing_message_file(isolated_env): - """Nonexistent message file returns general error.""" - import socket - machine = socket.gethostname().split(".")[0] - result = _run( - [f"{machine}.homelab", str(isolated_env / "nonexistent.org")], - env={**os.environ, "HOME": str(isolated_env)}, - ) - assert result.returncode == 1 - assert "not found" in result.stderr.lower() - - -def test_send_invalid_destination_format(isolated_env, tmp_path): - """Destination without . returns dest-not-found exit code.""" - msg = _make_message(tmp_path) - result = _run( - ["bogus", str(msg)], - env={**os.environ, "HOME": str(isolated_env)}, - ) - assert result.returncode == 2 - assert "<machine>.<project>" in result.stderr or "destination" in result.stderr.lower() - - -def test_send_dest_not_in_peers(isolated_env, tmp_path): - """Cross-machine destination with no peers.toml entry exits 2.""" - msg = _make_message(tmp_path) - result = _run( - ["unknownmachine.homelab", str(msg)], - env={**os.environ, "HOME": str(isolated_env)}, - ) - assert result.returncode == 2 - assert "not found in peers" in result.stderr - - -def test_send_frontmatter_missing_required(isolated_env, tmp_path): - """Message missing required fields exits 4.""" - bad = tmp_path / "bad.org" - bad.write_text("#+TITLE: nope\n\nBody.\n") - import socket - machine = socket.gethostname().split(".")[0] - result = _run( - [f"{machine}.homelab", str(bad)], - env={**os.environ, "HOME": str(isolated_env)}, - ) - assert result.returncode == 4 - assert "missing required fields" in result.stderr - - -def test_send_invalid_message_type(isolated_env, tmp_path): - """Unknown MESSAGE_TYPE exits 4.""" - msg = _make_message(tmp_path, msg_type="frobnicate") - import socket - machine = socket.gethostname().split(".")[0] - result = _run( - [f"{machine}.homelab", str(msg)], - env={**os.environ, "HOME": str(isolated_env)}, - ) - assert result.returncode == 4 - assert "MESSAGE_TYPE" in result.stderr - - -def test_send_halt_blocks(isolated_env, tmp_path): - """When HALT exists, send refuses with exit 5.""" - halt = isolated_env / ".config" / "cross-agent-comms" / "HALT" - halt.parent.mkdir(parents=True) - halt.write_text("test halt\n") - msg = _make_message(tmp_path) - import socket - machine = socket.gethostname().split(".")[0] - result = _run( - [f"{machine}.homelab", str(msg)], - env={**os.environ, "HOME": str(isolated_env)}, - ) - assert result.returncode == 5 - assert "halt active" in result.stderr.lower() - - -def test_send_same_machine_no_sign_delivers(isolated_env, tmp_path): - """Same-machine delivery with --no-sign produces a canonically named file.""" - msg = _make_message(tmp_path, conv_id="my-conv") - import socket - machine = socket.gethostname().split(".")[0] - # Sender is derived from CWD walking up to ~/projects/<name>/ - cwd = isolated_env / "projects" / "homelab" - result = _run( - [f"{machine}.homelab", str(msg), "--no-sign"], - env={**os.environ, "HOME": str(isolated_env)}, - cwd=cwd, - ) - assert result.returncode == 0, f"stderr={result.stderr}" - inbox = isolated_env / "projects" / "homelab" / "inbox" / "from-agents" - files = list(inbox.glob("*-from-homelab-my-conv.org")) - assert len(files) == 1 - # No sig file with --no-sign. - assert not list(inbox.glob("*.asc")) - # Canonical filename pattern. - assert files[0].name.startswith("2026") and files[0].name.endswith("-from-homelab-my-conv.org") - - -def test_send_same_machine_signed_writes_asc(isolated_env, tmp_path): - """Signed delivery writes both .org and .asc.""" - msg = _make_message(tmp_path, conv_id="signed-conv") - import socket - machine = socket.gethostname().split(".")[0] - cwd = isolated_env / "projects" / "homelab" - # Use the real GPG keyring (not isolating GPG — Craig's existing keys are fine for tests). - real_env = {**os.environ, "HOME": str(isolated_env), "GNUPGHOME": str(Path.home() / ".gnupg")} - result = _run( - [f"{machine}.homelab", str(msg)], - env=real_env, - cwd=cwd, - ) - if result.returncode != 0: - pytest.skip(f"GPG signing unavailable in this environment: {result.stderr}") - inbox = isolated_env / "projects" / "homelab" / "inbox" / "from-agents" - org_files = list(inbox.glob("*-from-homelab-signed-conv.org")) - asc_files = list(inbox.glob("*-from-homelab-signed-conv.org.asc")) - assert len(org_files) == 1 - assert len(asc_files) == 1 - - -def test_send_filename_ignores_input_basename(isolated_env, tmp_path): - """User's input filename is ignored; canonical filename is generated.""" - weird = tmp_path / "weird-user-name.org" - weird.write_text(textwrap.dedent("""\ - #+TITLE: Title - #+CONVERSATION_ID: ignored-input - #+MESSAGE_TYPE: request - #+SEQUENCE: 1 - #+TIMESTAMP: 2026-04-27T05:00:00-05:00 - #+PROTOCOL_VERSION: 5 - - Body. - """)) - import socket - machine = socket.gethostname().split(".")[0] - cwd = isolated_env / "projects" / "homelab" - result = _run( - [f"{machine}.homelab", str(weird), "--no-sign"], - env={**os.environ, "HOME": str(isolated_env)}, - cwd=cwd, - ) - assert result.returncode == 0 - inbox = isolated_env / "projects" / "homelab" / "inbox" / "from-agents" - # No file named after the user's input. - assert not (inbox / "weird-user-name.org").exists() - # Canonical naming used. - assert list(inbox.glob("*-from-homelab-ignored-input.org")) diff --git a/claude-templates/.ai/scripts/tests/test_cross_agent_status.py b/claude-templates/.ai/scripts/tests/test_cross_agent_status.py deleted file mode 100644 index bb5b8ba..0000000 --- a/claude-templates/.ai/scripts/tests/test_cross_agent_status.py +++ /dev/null @@ -1,165 +0,0 @@ -"""Tests for cross-agent-status (TDD: tests written before implementation).""" - -from __future__ import annotations - -import json -import os -import subprocess -import textwrap -from pathlib import Path - -import pytest - -SCRIPT = Path(__file__).resolve().parent.parent / "cross-agent-comms" / "cross-agent-status" - - -def _make_msg(path: Path, *, conv_id: str, seq: int, msg_type: str = "request", - proto_version: str = "5", timestamp: str = "2026-04-27T05:00:00-05:00") -> Path: - path.parent.mkdir(parents=True, exist_ok=True) - path.write_text(textwrap.dedent(f"""\ - #+TITLE: T - #+CONVERSATION_ID: {conv_id} - #+MESSAGE_TYPE: {msg_type} - #+SEQUENCE: {seq} - #+TIMESTAMP: {timestamp} - #+PROTOCOL_VERSION: {proto_version} - - Body. - """)) - return path - - -def _run(args: list[str], env: dict | None = None) -> subprocess.CompletedProcess: - return subprocess.run([str(SCRIPT), *args], capture_output=True, text=True, env=env) - - -@pytest.fixture -def fake_projects(tmp_path, monkeypatch): - """Create a fake ~/projects/<name>/inbox/from-agents/ tree under tmp_path.""" - home = tmp_path / "home" - home.mkdir() - monkeypatch.setenv("HOME", str(home)) - return home - - -def test_status_help(fake_projects): - result = _run(["--help"], env={**os.environ, "HOME": str(fake_projects)}) - assert result.returncode == 0 - assert "snapshot" in result.stdout.lower() or "pending" in result.stdout.lower() - - -def test_status_no_projects_clean_output(fake_projects): - result = _run([], env={**os.environ, "HOME": str(fake_projects)}) - assert result.returncode == 0 - # Empty machine prints either header-only table or "no projects" — accept either. - # No crash, no pending claims. - assert "pending" in result.stdout.lower() or result.stdout.strip() == "" - - -def test_status_one_pending_shows_up(fake_projects): - inbox = fake_projects / "projects" / "homelab" / "inbox" / "from-agents" - _make_msg(inbox / "20260427T100000Z-from-career-fixup.org", conv_id="fixup", seq=1) - result = _run([], env={**os.environ, "HOME": str(fake_projects)}) - assert result.returncode == 0 - assert "homelab" in result.stdout - assert "1" in result.stdout # pending count - assert "20260427T100000Z-from-career-fixup.org" in result.stdout - - -def test_status_released_conversation_zero_pending(fake_projects): - """A conversation with a release message in it counts as 0 pending.""" - inbox = fake_projects / "projects" / "homelab" / "inbox" / "from-agents" - _make_msg(inbox / "20260427T100000Z-from-career-done.org", conv_id="done", seq=1) - _make_msg(inbox / "20260427T100100Z-from-homelab-done.org", conv_id="done", seq=2, msg_type="release") - result = _run([], env={**os.environ, "HOME": str(fake_projects)}) - assert result.returncode == 0 - # Check the homelab row shows 0 pending. - lines = [ln for ln in result.stdout.splitlines() if "homelab" in ln] - # At least one homelab line should show 0 pending or "—". - assert any("0" in ln or "—" in ln for ln in lines) - - -def test_status_partial_release(fake_projects): - """Conversation with release + a later message → that later message counts as pending.""" - inbox = fake_projects / "projects" / "homelab" / "inbox" / "from-agents" - _make_msg(inbox / "20260427T100000Z-from-career-x.org", conv_id="x", seq=1, - timestamp="2026-04-27T05:00:00-05:00") - _make_msg(inbox / "20260427T100100Z-from-homelab-x.org", conv_id="x", seq=2, msg_type="release", - timestamp="2026-04-27T05:01:00-05:00") - # New message AFTER release: starts a fresh thread that's pending. - _make_msg(inbox / "20260427T200000Z-from-career-x.org", conv_id="x", seq=3, - timestamp="2026-04-27T15:00:00-05:00") - result = _run([], env={**os.environ, "HOME": str(fake_projects)}) - assert result.returncode == 0 - homelab_line = next(ln for ln in result.stdout.splitlines() if "homelab" in ln) - assert "1" in homelab_line # the post-release message is pending - - -def test_status_multiple_projects(fake_projects): - inbox_a = fake_projects / "projects" / "homelab" / "inbox" / "from-agents" - inbox_b = fake_projects / "projects" / "career" / "inbox" / "from-agents" - _make_msg(inbox_a / "20260427T100000Z-from-x-a.org", conv_id="a", seq=1) - _make_msg(inbox_b / "20260427T100100Z-from-x-b.org", conv_id="b", seq=1) - _make_msg(inbox_b / "20260427T100200Z-from-x-c.org", conv_id="c", seq=1) - result = _run([], env={**os.environ, "HOME": str(fake_projects)}) - assert result.returncode == 0 - # career has 2 pending, homelab has 1. - career_line = next(ln for ln in result.stdout.splitlines() if "career" in ln) - homelab_line = next(ln for ln in result.stdout.splitlines() if "homelab" in ln) - assert "2" in career_line - assert "1" in homelab_line - - -def test_status_json_output(fake_projects): - inbox = fake_projects / "projects" / "homelab" / "inbox" / "from-agents" - _make_msg(inbox / "20260427T100000Z-from-career-test.org", conv_id="test", seq=1) - result = _run(["--json"], env={**os.environ, "HOME": str(fake_projects)}) - assert result.returncode == 0 - payload = json.loads(result.stdout) - assert "projects" in payload - assert isinstance(payload["projects"], list) - homelab = next((p for p in payload["projects"] if p["name"] == "homelab"), None) - assert homelab is not None - assert homelab["pending_count"] == 1 - - -def test_status_sort_pending_first(fake_projects): - """Projects with pending messages sort before projects with 0.""" - (fake_projects / "projects" / "alpha" / "inbox" / "from-agents").mkdir(parents=True) - inbox_zeta = fake_projects / "projects" / "zeta" / "inbox" / "from-agents" - _make_msg(inbox_zeta / "20260427T100000Z-from-x-z.org", conv_id="z", seq=1) - result = _run([], env={**os.environ, "HOME": str(fake_projects)}) - assert result.returncode == 0 - lines = result.stdout.splitlines() - zeta_idx = next(i for i, ln in enumerate(lines) if "zeta" in ln) - alpha_idx = next(i for i, ln in enumerate(lines) if "alpha" in ln) - assert zeta_idx < alpha_idx, "pending project should sort before zero-pending project" - - -def test_status_halt_shows_banner(fake_projects): - halt = fake_projects / ".config" / "cross-agent-comms" / "HALT" - halt.parent.mkdir(parents=True) - halt.write_text("halted for test") - inbox = fake_projects / "projects" / "homelab" / "inbox" / "from-agents" - _make_msg(inbox / "20260427T100000Z-from-x-x.org", conv_id="x", seq=1) - result = _run([], env={**os.environ, "HOME": str(fake_projects)}) - assert result.returncode == 0 # status continues to print under HALT - assert "HALT" in result.stdout - # Banner should mention the reason. - assert "halted for test" in result.stdout - - -def test_status_projects_glob_override(fake_projects): - inbox = fake_projects / "projects" / "homelab" / "inbox" / "from-agents" - _make_msg(inbox / "20260427T100000Z-from-x-a.org", conv_id="a", seq=1) - other_inbox = fake_projects / "projects" / "career" / "inbox" / "from-agents" - _make_msg(other_inbox / "20260427T100100Z-from-x-b.org", conv_id="b", seq=1) - # Glob limits to homelab only. - result = _run( - ["--projects-glob", str(fake_projects / "projects" / "homelab" / "inbox" / "from-agents") + "/"], - env={**os.environ, "HOME": str(fake_projects)}, - ) - assert result.returncode == 0 - assert "homelab" in result.stdout - # career not in scope. - assert "career" not in result.stdout diff --git a/claude-templates/.ai/scripts/tests/test_cross_agent_watch.py b/claude-templates/.ai/scripts/tests/test_cross_agent_watch.py deleted file mode 100644 index 417cc19..0000000 --- a/claude-templates/.ai/scripts/tests/test_cross_agent_watch.py +++ /dev/null @@ -1,155 +0,0 @@ -"""Tests for cross-agent-watch. - -Black-box: spawn the script, drop files into a watched dir, read the log. -Tests use --no-notify to avoid firing real desktop notifications. -""" - -from __future__ import annotations - -import os -import subprocess -import time -from pathlib import Path - -import pytest - -SCRIPT = Path(__file__).resolve().parent.parent / "cross-agent-comms" / "cross-agent-watch" - - -def _spawn(watched_dir: Path, log_path: Path, env: dict) -> subprocess.Popen: - return subprocess.Popen( - [ - str(SCRIPT), - "--projects-glob", str(watched_dir) + "/", - "--log", str(log_path), - "--no-notify", - "--quiet", - ], - stdout=subprocess.DEVNULL, - stderr=subprocess.PIPE, - env=env, - ) - - -def _wait_for_log_lines(log_path: Path, expected: int, timeout: float = 5.0) -> list[str]: - deadline = time.time() + timeout - while time.time() < deadline: - if log_path.exists(): - lines = [ln for ln in log_path.read_text().splitlines() if ln] - if len(lines) >= expected: - return lines - time.sleep(0.1) - if log_path.exists(): - return [ln for ln in log_path.read_text().splitlines() if ln] - return [] - - -@pytest.fixture -def isolated_env(tmp_path, monkeypatch): - fake_home = tmp_path / "home" - fake_home.mkdir() - monkeypatch.setenv("HOME", str(fake_home)) - return fake_home - - -def test_watch_help(isolated_env): - result = subprocess.run( - [str(SCRIPT), "--help"], - capture_output=True, text=True, - env={**os.environ, "HOME": str(isolated_env)}, - ) - assert result.returncode == 0 - assert "Usage:" in result.stdout - - -def test_watch_empty_glob_exits_nonzero(isolated_env): - """Glob resolving to zero dirs should exit non-zero with a clear message.""" - result = subprocess.run( - [str(SCRIPT), "--projects-glob", "/nonexistent/path/*/foo/", "--no-notify", "--quiet"], - capture_output=True, text=True, - env={**os.environ, "HOME": str(isolated_env)}, - timeout=3, - ) - assert result.returncode != 0 - assert "0 directories" in result.stderr - - -def test_watch_logs_org_file_create(isolated_env, tmp_path): - watched = tmp_path / "watched" - watched.mkdir() - log = tmp_path / "watch.log" - proc = _spawn(watched, log, {**os.environ, "HOME": str(isolated_env)}) - try: - # Give inotifywait a moment to attach. - time.sleep(0.3) - (watched / "test-msg.org").write_text("hello") - lines = _wait_for_log_lines(log, expected=1, timeout=3.0) - assert len(lines) >= 1 - assert "test-msg.org" in lines[-1] - finally: - proc.terminate() - proc.wait(timeout=2) - - -def test_watch_filters_tmp_files(isolated_env, tmp_path): - """Files starting with .tmp. must NOT trigger log entries.""" - watched = tmp_path / "watched" - watched.mkdir() - log = tmp_path / "watch.log" - proc = _spawn(watched, log, {**os.environ, "HOME": str(isolated_env)}) - try: - time.sleep(0.3) - (watched / ".tmp.staging-file.org").write_text("hello") - # Wait briefly to confirm nothing logs. - time.sleep(0.5) - if log.exists(): - content = log.read_text() - assert ".tmp.staging-file" not in content - # Then drop a real file to confirm watcher is alive. - (watched / "real.org").write_text("real") - lines = _wait_for_log_lines(log, expected=1, timeout=3.0) - assert any("real.org" in ln for ln in lines) - finally: - proc.terminate() - proc.wait(timeout=2) - - -def test_watch_filters_asc_sidecars(isolated_env, tmp_path): - """Only .org events fire; .asc sidecars are silent.""" - watched = tmp_path / "watched" - watched.mkdir() - log = tmp_path / "watch.log" - proc = _spawn(watched, log, {**os.environ, "HOME": str(isolated_env)}) - try: - time.sleep(0.3) - (watched / "msg.org.asc").write_text("sig") - time.sleep(0.5) - if log.exists(): - assert "msg.org.asc" not in log.read_text() - # .org event still works. - (watched / "msg.org").write_text("body") - lines = _wait_for_log_lines(log, expected=1, timeout=3.0) - assert any(ln.endswith("msg.org") for ln in lines) - finally: - proc.terminate() - proc.wait(timeout=2) - - -def test_watch_halt_suppresses_but_logs(isolated_env, tmp_path): - """When HALT is set, watcher logs the event with (suppressed by HALT) marker.""" - halt = isolated_env / ".config" / "cross-agent-comms" / "HALT" - halt.parent.mkdir(parents=True) - halt.write_text("halted") - watched = tmp_path / "watched" - watched.mkdir() - log = tmp_path / "watch.log" - proc = _spawn(watched, log, {**os.environ, "HOME": str(isolated_env)}) - try: - time.sleep(0.3) - (watched / "halted-event.org").write_text("body") - lines = _wait_for_log_lines(log, expected=1, timeout=3.0) - assert len(lines) >= 1 - assert "suppressed by HALT" in lines[-1] - finally: - proc.terminate() - proc.wait(timeout=2) diff --git a/claude-templates/.ai/scripts/tests/test_flashcard_to_anki.py b/claude-templates/.ai/scripts/tests/test_flashcard_to_anki.py index 058b0cd..87008a8 100644 --- a/claude-templates/.ai/scripts/tests/test_flashcard_to_anki.py +++ b/claude-templates/.ai/scripts/tests/test_flashcard_to_anki.py @@ -34,14 +34,33 @@ def test_default_output_path_targets_phone_anki_dir(drill): assert result == Path.home() / "sync" / "phone" / "anki" / "health-drill.apkg" -def test_default_deck_name_is_raw_basename(drill): - """Deck name is the input basename with case preserved; #+TITLE is ignored.""" - assert drill.default_deck_name(Path("/x/deepsat.org")) == "deepsat" +def test_default_deck_name_uses_org_title(drill): + """The #+TITLE drives the Anki deck name, not the filename slug.""" + org = "#+TITLE: Refutations\n* Section\n** Q? :drill:\na\n" + assert drill.default_deck_name(Path("/x/refutation-drill.org"), org) == "Refutations" -def test_default_deck_name_keeps_hyphens(drill): - """A hyphenated basename is kept verbatim rather than title-cased.""" - assert drill.default_deck_name(Path("/x/health-drill.org")) == "health-drill" +def test_default_deck_name_title_is_trimmed(drill): + """Surrounding whitespace on the #+TITLE value is stripped.""" + org = "#+TITLE: DeepSat Flashcards \n" + assert drill.default_deck_name(Path("/x/deepsat.org"), org) == "DeepSat Flashcards" + + +def test_default_deck_name_title_match_is_case_insensitive(drill): + """A lowercase #+title: keyword is still recognized.""" + org = "#+title: Health Flashcards\n" + assert drill.default_deck_name(Path("/x/health-drill.org"), org) == "Health Flashcards" + + +def test_default_deck_name_falls_back_to_basename_without_title(drill): + """No #+TITLE line falls back to the input basename, case preserved.""" + org = "* Section\n** Q? :drill:\na\n" + assert drill.default_deck_name(Path("/x/deepsat.org"), org) == "deepsat" + + +def test_default_deck_name_blank_title_falls_back_to_basename(drill): + """An empty #+TITLE value is ignored in favour of the basename.""" + assert drill.default_deck_name(Path("/x/health-drill.org"), "#+TITLE: \n") == "health-drill" # --- section_to_tag (pure) --- diff --git a/claude-templates/.ai/scripts/tests/test_inbox_send.py b/claude-templates/.ai/scripts/tests/test_inbox_send.py index a0094dc..f75d7a1 100644 --- a/claude-templates/.ai/scripts/tests/test_inbox_send.py +++ b/claude-templates/.ai/scripts/tests/test_inbox_send.py @@ -97,6 +97,52 @@ class TestInboxSendDiscovery: result = run_script(["--list"], roots=[tmp_path / "does-not-exist"]) assert result.returncode == 0 + def test_inbox_send_list_displays_dot_stripped_name(self, project_root, run_script, tmp_path): + """Dotted project basenames display dot-stripped (.emacs.d → emacsd).""" + project_root(".emacs.d") + result = run_script(["--list"], roots=[tmp_path / "projects"]) + assert "emacsd" in result.stdout + + +class TestInboxSendDotAlias: + """A dotted project basename resolves both verbatim and dot-stripped.""" + + def test_resolves_by_dot_stripped_alias(self, project_root, run_script, tmp_path): + """'emacsd' delivers to the .emacs.d project.""" + project_root(".emacs.d") + cwd = project_root("source") + run_script( + ["emacsd", "--text", "hi"], + cwd=cwd, roots=[tmp_path / "projects"], + ) + files = list((tmp_path / "projects" / ".emacs.d" / "inbox").iterdir()) + assert len(files) == 1 + + def test_resolves_by_exact_dotted_name_still(self, project_root, run_script, tmp_path): + """Backward-compat: the verbatim '.emacs.d' target still resolves.""" + project_root(".emacs.d") + cwd = project_root("source") + run_script( + [".emacs.d", "--text", "hi"], + cwd=cwd, roots=[tmp_path / "projects"], + ) + files = list((tmp_path / "projects" / ".emacs.d" / "inbox").iterdir()) + assert len(files) == 1 + + def test_exact_match_wins_over_alias(self, project_root, run_script, tmp_path): + """An exact basename match is preferred over a dot-stripped collision.""" + project_root("emacsd") # exact + project_root(".emacs.d") # would also normalize to 'emacsd' + cwd = project_root("source") + run_script( + ["emacsd", "--text", "hi"], + cwd=cwd, roots=[tmp_path / "projects"], + ) + exact = list((tmp_path / "projects" / "emacsd" / "inbox").iterdir()) + dotted = list((tmp_path / "projects" / ".emacs.d" / "inbox").iterdir()) + assert len(exact) == 1 + assert dotted == [] + # ---------------------------------------------------------------------- # Slug derivation from text and from filenames @@ -355,3 +401,78 @@ class TestInboxSendErrors: assert result.returncode != 0 files = list((tmp_path / "projects" / "target" / "inbox").iterdir()) assert files == [] + + +# ---------------------------------------------------------------------- +# Filename collisions (two sends deriving the same name must not overwrite) +# ---------------------------------------------------------------------- + +def _load_module(): + import importlib.util + spec = importlib.util.spec_from_file_location("inbox_send", SCRIPT) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + return mod + + +class TestFilenameCollisions: + """Two sends in the same minute with the same leading phrase derived + identical filenames and the second silently overwrote the first + (a message was lost this way, 2026-07-02).""" + + def test_send_text_same_minute_same_phrase_keeps_both(self, tmp_path): + from datetime import datetime + mod = _load_module() + inbox = tmp_path / "inbox" + inbox.mkdir() + now = datetime(2026, 7, 2, 5, 42, 0) + prefix = "identical leading phrase long enough to fill the whole slug budget entirely" + first = mod.send_text(inbox, prefix + " tail one", "archsetup", None, now) + second = mod.send_text(inbox, prefix + " tail two", "archsetup", None, now) + assert first != second + assert first.exists() and second.exists() + assert first.name != second.name + assert "tail one" in first.read_text() + assert "tail two" in second.read_text() + + def test_send_text_collision_suffix_increments(self, tmp_path): + from datetime import datetime + mod = _load_module() + inbox = tmp_path / "inbox" + inbox.mkdir() + now = datetime(2026, 7, 2, 5, 42, 0) + paths = [mod.send_text(inbox, "same lead phrase differs later A", "src", "fixed-slug", now) + for _ in range(3)] + names = [p.name for p in paths] + assert names[0].endswith("fixed-slug.org") + assert names[1].endswith("fixed-slug-2.org") + assert names[2].endswith("fixed-slug-3.org") + + def test_send_file_collision_preserves_extension(self, tmp_path): + from datetime import datetime + mod = _load_module() + inbox = tmp_path / "inbox" + inbox.mkdir() + src = tmp_path / "note.org" + src.write_text("body one") + now = datetime(2026, 7, 2, 5, 42, 0) + first = mod.send_file(inbox, src, "src", None, now) + src.write_text("body two") + second = mod.send_file(inbox, src, "src", None, now) + assert second.name.endswith("note-2.org") + assert first.read_text() == "body one" + assert second.read_text() == "body two" + + def test_cli_two_rapid_sends_lose_nothing(self, project_root, run_script, tmp_path): + project_root("sender") + target = project_root("receiver") + roots = [tmp_path / "projects"] + prefix = "identical leading phrase long enough to fill the whole slug budget entirely" + run_script(["receiver", "--text", prefix + " message one"], + cwd=tmp_path / "projects" / "sender", roots=roots) + run_script(["receiver", "--text", prefix + " message two"], + cwd=tmp_path / "projects" / "sender", roots=roots) + files = list((target / "inbox").iterdir()) + assert len(files) == 2 + bodies = "".join(f.read_text() for f in files) + assert "message one" in bodies and "message two" in bodies diff --git a/claude-templates/.ai/scripts/tests/test_route_recommend.py b/claude-templates/.ai/scripts/tests/test_route_recommend.py new file mode 100644 index 0000000..acc4755 --- /dev/null +++ b/claude-templates/.ai/scripts/tests/test_route_recommend.py @@ -0,0 +1,124 @@ +"""Tests for route_recommend.py — the wrap-up routing recommendation engine. + +The core is a pure function recommend(item, projects) -> (destination, confidence): +- strong: a project's name (or its dot-stripped form) appears literally in the item +- weak: a distinctive name token overlaps, but the full name doesn't +- none: no overlap; the item stays put (destination is None) + +A multi-way tie at the top tier downgrades to weak with a deterministic pick. +An empty project list yields none. + +The CLI wires this to inbox-send.py's discover_projects (sandboxed here via the +INBOX_SEND_ROOTS env var, the same hook inbox-send's own tests use). +""" + +import subprocess +import sys +from pathlib import Path + +SCRIPTS = Path(__file__).parent.parent +SCRIPT = SCRIPTS / "route_recommend.py" +sys.path.insert(0, str(SCRIPTS)) + +import route_recommend as rr # noqa: E402 + + +# --- pure function: the five spec'd cases ----------------------------------- + +def test_strong_match_named_literally(): + dest, conf = rr.recommend("fix the rulesets refactor command", ["rulesets", "home", "work"]) + assert (dest, conf) == ("rulesets", "strong") + + +def test_strong_match_via_dot_stripped_name(): + # ".emacs.d" addressed as "emacsd" in the item is still a literal hit. + dest, conf = rr.recommend("update the emacsd ai-term module", [".emacs.d", "rulesets"]) + assert (dest, conf) == (".emacs.d", "strong") + + +def test_strong_match_dotted_name_verbatim(): + dest, conf = rr.recommend("patch .emacs.d startup", [".emacs.d", "rulesets"]) + assert (dest, conf) == (".emacs.d", "strong") + + +def test_weak_match_topic_token_only(): + # "wttrin" is a token of "emacs-wttrin" but the full name isn't present. + dest, conf = rr.recommend("the wttrin weather bug", ["emacs-wttrin", "rulesets"]) + assert (dest, conf) == ("emacs-wttrin", "weak") + + +def test_no_match_stays_put(): + dest, conf = rr.recommend("calibrate the telescope mount", ["rulesets", "deepsat"]) + assert dest is None + assert conf == "none" + + +def test_two_project_strong_tie_downgrades_to_weak(): + # Both named literally → ambiguous → weak, deterministic tie-break (alphabetical). + dest, conf = rr.recommend("sync rulesets and home configs", ["rulesets", "home", "work"]) + assert conf == "weak" + assert dest == "home" # tie-break: most-overlap then alphabetical + + +def test_empty_project_list_is_none(): + assert rr.recommend("anything at all", []) == (None, "none") + + +# --- boundary / robustness -------------------------------------------------- + +def test_literal_name_requires_word_boundary(): + # "home" must not match inside "homeowner". + dest, conf = rr.recommend("the homeowner association meeting", ["home", "rulesets"]) + assert dest is None and conf == "none" + + +def test_path_mention_counts_as_literal(): + dest, conf = rr.recommend("edit ~/code/rulesets/Makefile", ["rulesets", "home"]) + assert (dest, conf) == ("rulesets", "strong") + + +def test_strong_beats_weak_when_both_present(): + # "rulesets" named literally (strong) outranks an emacs-wttrin token hit (weak). + dest, conf = rr.recommend("the wttrin fix belongs in rulesets", ["rulesets", "emacs-wttrin"]) + assert (dest, conf) == ("rulesets", "strong") + + +# --- CLI + discovery reuse (sandboxed roots) -------------------------------- + +def _run(args, roots, item): + import os + env = {"PATH": os.environ.get("PATH", ""), "HOME": os.environ.get("HOME", "/tmp"), + "INBOX_SEND_ROOTS": ":".join(str(r) for r in roots)} + return subprocess.run([sys.executable, str(SCRIPT), "--item", item, *args], + capture_output=True, text=True, env=env) + + +def _mk_project(tmp_path, name): + proj = tmp_path / "projects" / name + (proj / ".ai").mkdir(parents=True, exist_ok=True) + (proj / "inbox").mkdir(exist_ok=True) + return proj + + +def test_cli_discovers_and_recommends(tmp_path): + _mk_project(tmp_path, "foo") + _mk_project(tmp_path, "bar") + r = _run([], roots=[tmp_path / "projects"], item="fix the foo widget") + assert r.returncode == 0 + assert r.stdout.strip() == "foo\tstrong" + + +def test_cli_no_match_prints_none(tmp_path): + _mk_project(tmp_path, "foo") + r = _run([], roots=[tmp_path / "projects"], item="unrelated grocery list") + assert r.returncode == 0 + assert r.stdout.strip() == "none" + + +def test_cli_exclude_drops_current_project(tmp_path): + _mk_project(tmp_path, "foo") + _mk_project(tmp_path, "bar") + # Item names foo, but foo is excluded as the current project → no other match. + r = _run(["--exclude", "foo"], roots=[tmp_path / "projects"], item="fix the foo widget") + assert r.returncode == 0 + assert r.stdout.strip() == "none" diff --git a/claude-templates/.ai/scripts/todo-cleanup.el b/claude-templates/.ai/scripts/todo-cleanup.el index 6b3081a..bd8166d 100644 --- a/claude-templates/.ai/scripts/todo-cleanup.el +++ b/claude-templates/.ai/scripts/todo-cleanup.el @@ -5,10 +5,12 @@ ;; emacs --batch -q -l todo-cleanup.el --check todo.org # hygiene report only ;; emacs --batch -q -l todo-cleanup.el --archive-done todo.org # archive completed subtrees ;; emacs --batch -q -l todo-cleanup.el --archive-done --check todo.org # preview the archive +;; emacs --batch -q -l todo-cleanup.el --convert-subtasks todo.org # dated-rewrite done level-3+ sub-tasks +;; emacs --batch -q -l todo-cleanup.el --convert-subtasks --check todo.org # preview the conversion ;; emacs --batch -q -l todo-cleanup.el --sync-child-priority todo.org # bump children whose priority drifted below the parent's ;; emacs --batch -q -l todo-cleanup.el --check-child-priority todo.org # preview the sync (same as --sync-child-priority --check) ;; -;; Three independent modes: +;; Four independent modes: ;; ;; * Default (hygiene). Designed for the wrap-it-up workflow: cheap, idempotent, ;; safe to run every session. @@ -25,14 +27,46 @@ ;; line isn't in canonical position. Reports these for manual fix; doesn't ;; auto-rewrite (preserving real state-log history is judgement work). ;; -;; * --archive-done (opt-in). Moves every level-2 subtree whose TODO state is -;; DONE or CANCELLED out of the "Open Work" section and into the "Resolved" -;; section of the same file, subtree intact. The sections are matched by a -;; unique level-1 heading containing "Open Work" (case-insensitive) and one -;; containing "Resolved"; if either is missing or ambiguous, the file is -;; skipped with a message. Only direct level-2 children move — a DONE entry -;; nested under an open parent stays put. Archiving is consequential, so it's -;; never run by default; it does *not* also run the hygiene passes. +;; * --archive-done (opt-in). Two steps, in order: +;; +;; 1. Moves every level-2 subtree whose TODO state is DONE or CANCELLED out of +;; the "Open Work" section and into the "Resolved" section of the same +;; file, subtree intact. The sections are matched by a unique level-1 +;; heading containing "Open Work" (case-insensitive) and one containing +;; "Resolved"; if either is missing or ambiguous, the file is skipped with +;; a message. Only direct level-2 children move — a DONE entry nested under +;; an open parent stays put. +;; +;; 2. Ages the "Resolved" section: a level-2 DONE/CANCELLED subtree whose +;; CLOSED date is older than `tc-archive-retain-days' (default 7) is moved +;; out to `tc-archive-file' (default `archive/task-archive.org' beside the +;; todo file), keeping only the last week of closed tasks in the file +;; itself. Only subtrees closed within the window stay; older ones, and +;; those with no parseable CLOSED date, are moved out. Set +;; `tc-archive-retain-days' to nil to disable this step (legacy in-file-only +;; behavior). The aging date is `tc-archive-reference-date' when set +;; (tests), otherwise the real current date. The archive inherits the todo +;; file's gitignore status: when the todo file is gitignored, the archive +;; path is added to .gitignore before the first write, so private task +;; history never lands in a tracked path (see +;; `tc--ensure-archive-gitignored'). +;; +;; Archiving is consequential, so it's never run by default; it does *not* +;; also run the hygiene passes. +;; +;; * --convert-subtasks (opt-in). Rewrites every level-3-and-deeper heading whose +;; TODO state is DONE/CANCELLED/FAILED into a dated event-log entry +;; (`<stars> YYYY-MM-DD Day @ HH:MM:SS -ZZZZ <text>'), dropping the keyword, +;; priority cookie, and tags, and removing the now-redundant CLOSED line. The +;; date and time come from that entry's own CLOSED cookie; a date-only close +;; yields 00:00:00, and the UTC offset is computed DST-aware for that date. +;; This enforces the todo-format depth rule that interactive closes +;; (`org-log-done' → DONE + CLOSED) and `--archive-done' (level-2 only) leave +;; unapplied. The heading text is preserved verbatim — a batch tool can't +;; past-tense an imperative title reliably. Idempotent (an already-dated +;; heading has no done keyword); a done sub-task with no parseable CLOSED date +;; is flagged and left alone, never stamped with a fabricated date. Like +;; --archive-done it does not also run the hygiene passes. ;; ;; * --sync-child-priority (opt-in). Walks every heading with a priority cookie ;; ([#A]-[#D]) and, for each of its direct child headings whose own priority @@ -52,13 +86,19 @@ (require 'org) (require 'cl-lib) +(require 'calendar) (setq org-todo-keywords - '((sequence "TODO" "DOING" "WAITING" "NEXT" "|" "DONE" "CANCELLED"))) + '((sequence "TODO" "DOING" "WAITING" "NEXT" "|" "DONE" "CANCELLED" "FAILED"))) (defconst tc-done-states '("DONE" "CANCELLED") "TODO keywords that mark an entry as completed for `--archive-done'.") +(defconst tc--convert-done-states '("DONE" "CANCELLED" "FAILED") + "TODO keywords whose level-3-and-deeper entries `--convert-subtasks' rewrites +to dated event-log entries. Broader than `tc-done-states' because a FAILED +sub-task is terminal too and belongs in the parent's dated history.") + (defconst tc--priority-cookie-regexp "\\[#\\([A-Z]\\)\\]" "Regexp matching an org priority cookie. Match group 1 is the letter.") @@ -70,11 +110,30 @@ every heading below it.") (defvar tc-fixes 0) (defvar tc-archived 0) (defvar tc-bumped 0) +(defvar tc-converted 0) (defvar tc-issues nil) (defvar tc-check-only nil) (defvar tc-archive-done nil) (defvar tc-sync-child-priority nil) +(defvar tc-convert-subtasks nil) (defvar tc-current-file nil) +(defvar tc-current-dir nil) +(defvar tc-archived-to-file 0) + +(defvar tc-archive-retain-days 7 + "Retention window for the `--archive-done' file-aging step. A closed Resolved +subtree whose CLOSED date is within this many days of the reference date stays +in the in-file Resolved section; an older one is moved out to `tc-archive-file'. +A subtree with no parseable CLOSED date stays. nil disables the aging step +entirely, leaving the legacy in-file-only behavior.") + +(defvar tc-archive-reference-date nil + "(YEAR MONTH DAY) treated as \"today\" when aging Resolved subtrees out to a +file; nil means the real current date. Set in tests for determinism.") + +(defvar tc-archive-file nil + "Destination file for aged-out Resolved subtrees; nil means +`archive/task-archive.org' beside the todo file being processed.") ;;; --------------------------------------------------------------------------- ;;; Hygiene mode @@ -224,7 +283,8 @@ are reported but not performed." :line (line-number-at-pos) :heading (org-get-heading t t t t)) tc-issues) - (cl-incf tc-archived)))) + (cl-incf tc-archived))) + (tc-archive-old-resolved-to-file)) (t (catch 'done (while t @@ -252,7 +312,171 @@ are reported but not performed." (cl-incf tc-archived) (push (list :kind 'archive-moved :file tc-current-file :line line :heading heading) - tc-issues))))))))) + tc-issues))))) + (tc-archive-old-resolved-to-file))))) + +;;; --------------------------------------------------------------------------- +;;; --archive-done: age old Resolved subtrees out to a file + +(defconst tc-archive-file-scaffold + "#+TITLE: Task Archive\n#+FILETAGS: :archive:\n\n* Resolved (archived)\n" + "Initial content written to a fresh `tc-archive-file'. Aged subtrees are +appended as level-2 children under the level-1 heading.") + +(defun tc--reference-absolute () + "Absolute (Gregorian serial) day number of the aging reference date — +`tc-archive-reference-date' when set, otherwise the real current date." + (if tc-archive-reference-date + (pcase-let ((`(,y ,m ,d) tc-archive-reference-date)) + (calendar-absolute-from-gregorian (list m d y))) + (pcase-let ((`(,m ,d ,y) (calendar-current-date))) + (calendar-absolute-from-gregorian (list m d y))))) + +(defun tc--closed-absolute-in-region (beg end) + "Absolute day number of the first CLOSED: [YYYY-MM-DD ...] line in BEG..END, +or nil when the region carries no parseable CLOSED date. The task's own CLOSED +line sits in canonical position directly under the heading, so the first match +in the subtree is the task's close." + (save-excursion + (goto-char beg) + (when (re-search-forward + "CLOSED:[ \t]*\\[\\([0-9][0-9][0-9][0-9]\\)-\\([0-9][0-9]\\)-\\([0-9][0-9]\\)" + end t) + (calendar-absolute-from-gregorian + (list (string-to-number (match-string 2)) + (string-to-number (match-string 3)) + (string-to-number (match-string 1))))))) + +(defun tc--archive-file-path () + "Resolve the destination file for aged-out subtrees: `tc-archive-file' if set, +else `archive/task-archive.org' beside the todo file being processed." + (or tc-archive-file + (and tc-current-dir + (expand-file-name "archive/task-archive.org" tc-current-dir)))) + +(defun tc--git-ignored-p (path) + "Non-nil when PATH is gitignored (git check-ignore exits 0). nil on any git +error or when git is unavailable." + (let ((default-directory (or tc-current-dir default-directory))) + (eq 0 (ignore-errors + (call-process "git" nil nil nil "check-ignore" "-q" + (expand-file-name path)))))) + +(defun tc--ensure-archive-gitignored (archive-path) + "Keep the aged-out archive as private as the todo file it derives from. When the +todo file being processed is gitignored but ARCHIVE-PATH is not, append a +root-relative ignore entry for ARCHIVE-PATH to the project's .gitignore. No-op +when the todo file is tracked, the archive is already ignored, or there is no git +work tree — so track-mode projects (todo file tracked) leave the archive tracked +too. This is what makes the aging step safe to ship to gitignore-mode projects, +where todo.org is private: the archive inherits that privacy instead of leaking +previously-ignored task history into a tracked path." + (when (and tc-current-file tc-current-dir) + (let* ((todo (expand-file-name tc-current-file tc-current-dir)) + (default-directory tc-current-dir) + (root (with-temp-buffer + (when (eq 0 (ignore-errors + (call-process "git" nil (current-buffer) nil + "rev-parse" "--show-toplevel"))) + (string-trim (buffer-string)))))) + (when (and root (> (length root) 0) (file-directory-p root) + (tc--git-ignored-p todo) + (not (tc--git-ignored-p archive-path))) + (let ((entry (concat "/" (file-relative-name + (expand-file-name archive-path) root))) + (gi (expand-file-name ".gitignore" root))) + (with-temp-buffer + (when (file-readable-p gi) (insert-file-contents gi)) + (unless (save-excursion + (goto-char (point-min)) + (re-search-forward (concat "^" (regexp-quote entry) "$") nil t)) + (goto-char (point-max)) + (unless (bolp) (insert "\n")) + (insert "\n# Claude Code: task archive (follows todo file privacy)\n" + entry "\n") + (write-region (point-min) (point-max) gi nil 'silent)))))))) + +(defun tc--append-subtrees-to-archive-file (path texts) + "Append TEXTS (subtree strings) under the level-1 heading in PATH, creating the +file with `tc-archive-file-scaffold' and the parent directory when absent. +Ensures the archive inherits the todo file's gitignore status first." + (when (and path texts) + (tc--ensure-archive-gitignored path) + (let ((dir (file-name-directory path))) + (when (and dir (not (file-directory-p dir))) + (make-directory dir t))) + (with-temp-buffer + (when (file-readable-p path) + (insert-file-contents path)) + (when (= (point-min) (point-max)) + (insert tc-archive-file-scaffold)) + ;; Guarantee a level-1 heading to append under (older files might lack one). + (goto-char (point-min)) + (unless (re-search-forward "^\\* " nil t) + (goto-char (point-max)) + (unless (bolp) (insert "\n")) + (insert "* Resolved (archived)\n")) + (goto-char (point-max)) + (unless (bolp) (insert "\n")) + (dolist (text texts) + (insert text) + (unless (bolp) (insert "\n"))) + (write-region (point-min) (point-max) path nil 'silent)))) + +(defun tc-archive-old-resolved-to-file () + "Move level-2 DONE/CANCELLED subtrees in the \"Resolved\" section whose CLOSED +date predates the `tc-archive-retain-days' window out to `tc--archive-file-path'. +Only subtrees closed within the window stay; older ones, and those with no +parseable CLOSED date, are moved out. A nil `tc-archive-retain-days' disables the +step. Honors `tc-check-only' (report only)." + (when tc-archive-retain-days + (let ((res (tc--find-section "resolved"))) + (when (integerp res) + (let* ((cutoff (- (tc--reference-absolute) tc-archive-retain-days)) + (moves nil)) + (dolist (pos (tc--done-level-2-children res)) + (save-excursion + (goto-char pos) + (let* ((region (tc--subtree-region)) + (beg (car region)) + (end (cdr region)) + (closed (tc--closed-absolute-in-region beg end))) + ;; Archive anything not provably within the window: closed + ;; before the cutoff, or with no parseable CLOSED date at all. + (when (or (null closed) (< closed cutoff)) + (push (list :beg beg :end end + :heading (org-get-heading t t t t) + :line (line-number-at-pos beg)) + moves))))) + (setq moves (nreverse moves)) ; document order + (cond + ((null moves) nil) + (tc-check-only + (dolist (m moves) + (cl-incf tc-archived-to-file) + (push (list :kind 'archive-file-would :file tc-current-file + :line (plist-get m :line) :heading (plist-get m :heading)) + tc-issues))) + (t + ;; Capture text before any deletion (positions are still valid), then + ;; delete bottom-up so earlier subtree positions stay correct. + (let ((texts (mapcar + (lambda (m) + (concat (string-trim-right + (buffer-substring-no-properties + (plist-get m :beg) (plist-get m :end)) + "[ \t\n]+") + "\n")) + moves))) + (dolist (m (sort (copy-sequence moves) + (lambda (a b) (> (plist-get a :beg) (plist-get b :beg))))) + (delete-region (plist-get m :beg) (plist-get m :end))) + (tc--append-subtrees-to-archive-file (tc--archive-file-path) texts) + (dolist (m moves) + (cl-incf tc-archived-to-file) + (push (list :kind 'archive-file-moved :file tc-current-file + :line (plist-get m :line) :heading (plist-get m :heading)) + tc-issues)))))))))) ;;; --------------------------------------------------------------------------- ;;; --sync-child-priority mode @@ -377,10 +601,143 @@ before their descendants — a [#A] → [#B] → [#D] chain collapses in one pas (org-map-entries #'tc-sync-child-priority-at-heading nil 'file)) ;;; --------------------------------------------------------------------------- +;;; --convert-subtasks mode +;; +;; A sub-task (a heading at level 3 or deeper, i.e. under a parent task) that is +;; marked DONE/CANCELLED/FAILED should become a dated event-log entry per the +;; todo-format depth rule: drop the keyword, priority cookie, and tags, and +;; rewrite the heading to `<stars> YYYY-MM-DD Day @ HH:MM:SS -ZZZZ <text>' so the +;; parent's subtree grows a chronological history instead of a long tail of +;; nested DONE lines. Nothing enforced this before: `org-log-done' just flips an +;; interactive close to DONE + CLOSED, and `--archive-done' only touches level 2. +;; So level-3+ closes piled up as DONE keywords. This mode converts them +;; mechanically, pulling the timestamp from each entry's own CLOSED cookie. The +;; heading text is kept verbatim (a batch tool can't reliably past-tense an +;; imperative title, and guessing prose in the task file is worse than leaving it +;; as written). Idempotent: an already-dated heading has no done keyword, so it +;; is skipped. A done sub-task with no parseable CLOSED cookie can't be dated, so +;; it is flagged and left alone rather than stamped with a fabricated date. + +(defun tc--closed-parts-in-entry () + "Return a plist (:year :month :day :dow :hour :minute) from the CLOSED cookie +of the entry at point, or nil when the entry has no parseable CLOSED line. +:hour and :minute are nil when the cookie carries only a date. The CLOSED line +sits in canonical position directly under the heading, so the first match within +the entry is the task's own close." + (save-excursion + (org-back-to-heading t) + (let ((end (save-excursion + (or (outline-next-heading) (goto-char (point-max))) + (point)))) + (when (re-search-forward + (concat "CLOSED:[ \t]*\\[\\([0-9]\\{4\\}\\)-\\([0-9]\\{2\\}\\)-\\([0-9]\\{2\\}\\)" + "[ \t]+\\([A-Za-z]+\\)" + "\\(?:[ \t]+\\([0-9]\\{2\\}\\):\\([0-9]\\{2\\}\\)\\)?\\]") + end t) + (list :year (match-string 1) :month (match-string 2) :day (match-string 3) + :dow (match-string 4) + :hour (match-string 5) :minute (match-string 6)))))) + +(defun tc--tz-offset-string (year month day hour minute) + "Return the local UTC offset (e.g. \"-0500\") for the given wall-clock instant. +DST-aware: `encode-time' with an unknown-DST field lets the system pick the +correct offset for that date, so a summer close reads -0400 and a winter one +-0500 without hardcoding either." + (format-time-string + "%z" (encode-time (list 0 minute hour day month year nil -1 nil)))) + +(defun tc--dated-header-line (level parts title) + "Build the dated event-log heading string from LEVEL, CLOSED PARTS, and TITLE. +Missing time in PARTS defaults to 00:00:00 (the close logged only a date)." + (let* ((year (plist-get parts :year)) + (month (plist-get parts :month)) + (day (plist-get parts :day)) + (dow (plist-get parts :dow)) + (hh (or (plist-get parts :hour) "00")) + (mm (or (plist-get parts :minute) "00")) + (tz (tc--tz-offset-string (string-to-number year) + (string-to-number month) + (string-to-number day) + (string-to-number hh) + (string-to-number mm)))) + (format "%s %s-%s-%s %s @ %s:%s:00 %s %s" + (make-string level ?*) year month day dow hh mm tz title))) + +(defun tc--convert-collect-targets () + "Markers at every heading at level >= 3 whose TODO state is a done state. +Collected up front so the rewrite loop can edit the buffer without disturbing an +in-progress `org-map-entries' walk; markers track their headings across edits." + (let (targets) + (org-map-entries + (lambda () + (when (and (>= (org-current-level) 3) + (member (org-get-todo-state) tc--convert-done-states)) + (push (copy-marker (point)) targets))) + nil 'file) + (nreverse targets))) + +(defun tc--convert-one-subtask (marker) + "Convert the done sub-task heading at MARKER to a dated event-log entry. +Under `tc-check-only' the conversion is reported but not performed." + (goto-char marker) + (org-back-to-heading t) + (let* ((level (org-current-level)) + (title (org-get-heading t t t t)) + (line (line-number-at-pos)) + (parts (tc--closed-parts-in-entry))) + (cond + ((null parts) + (push (list :kind 'convert-skip :file tc-current-file + :line line :heading title + :detail "no CLOSED date to derive the timestamp") + tc-issues)) + (t + (let ((new (tc--dated-header-line level parts title))) + (cl-incf tc-converted) + (if tc-check-only + (push (list :kind 'convert-would :file tc-current-file + :line line :heading title :new new) + tc-issues) + ;; Replace the heading line, then drop the now-redundant CLOSED + ;; cookie from the entry (its date now lives in the header). Only + ;; the cookie goes: a planning line can also carry DEADLINE: or + ;; SCHEDULED: beside it, and those survive on their line. A line + ;; left blank by the removal is deleted whole. + (delete-region (line-beginning-position) (line-end-position)) + (insert new) + (let ((end (save-excursion + (or (outline-next-heading) (goto-char (point-max))) + (point)))) + (save-excursion + (when (re-search-forward "CLOSED:[ \t]*\\[[^]]*\\][ \t]*" end t) + (replace-match "") + (let ((bol (line-beginning-position)) + (eol (line-end-position))) + (if (string-match-p "\\`[ \t]*\\'" + (buffer-substring bol eol)) + (delete-region bol (min (1+ eol) (point-max))) + (goto-char bol) + (when (looking-at "[ \t]+") + (replace-match ""))))))) + (push (list :kind 'convert-done :file tc-current-file + :line line :heading title :new new) + tc-issues))))))) + +(defun tc-convert-subtasks-in-file () + "Rewrite every level-3-and-deeper DONE/CANCELLED/FAILED heading to a dated +event-log entry, pulling the timestamp from its CLOSED cookie. Honors +`tc-check-only'." + (let ((targets (tc--convert-collect-targets))) + (dolist (m targets) + (tc--convert-one-subtask m) + (set-marker m nil)))) + +;;; --------------------------------------------------------------------------- ;;; Driver + reporting (defun tc-process-file (file) (setq tc-current-file (file-name-nondirectory file)) + (setq tc-current-dir (file-name-directory (expand-file-name file))) (with-current-buffer (find-file-noselect file) (org-mode) (cond @@ -388,6 +745,8 @@ before their descendants — a [#A] → [#B] → [#D] chain collapses in one pas (tc-archive-done-in-file)) (tc-sync-child-priority (tc-sync-child-priority-in-file)) + (tc-convert-subtasks + (tc-convert-subtasks-in-file)) (t ;; Pass 1: auto-fix bogus state logs (or report under --check). (org-map-entries #'tc-fix-bogus-state-log-in-entry nil 'file) @@ -420,6 +779,21 @@ before their descendants — a [#A] → [#B] → [#D] chain collapses in one pas (plist-get i :file) (plist-get i :line) (if tc-check-only "would move" "moved") + (plist-get i :heading))))))) + ;; Aged-out subtrees: only reported when some moved (or would). Additive to + ;; the in-file report above, and absent when the aging step is disabled. + (when (> tc-archived-to-file 0) + (princ (format "todo-cleanup --archive-done: %d aged subtree(s) %s task-archive.org%s\n" + tc-archived-to-file + (if tc-check-only "would move to" "moved to") + (if tc-check-only " — CHECK MODE (no writes)" ""))) + (dolist (i (reverse tc-issues)) + (pcase (plist-get i :kind) + ((or 'archive-file-moved 'archive-file-would) + (princ (format " %s:%d: %s %s\n" + (plist-get i :file) + (plist-get i :line) + (if tc-check-only "would archive" "archived") (plist-get i :heading))))))))) (defun tc--emit-hygiene-report () @@ -467,9 +841,34 @@ before their descendants — a [#A] → [#B] → [#D] chain collapses in one pas (plist-get i :child-heading) (plist-get i :parent-heading))))))) +(defun tc--emit-convert-report () + ;; Silent on a real-mode no-op (nothing to convert and nothing skipped), for + ;; the same reason as the archive report: the wrap runs cleanup passes more + ;; than once, and a vocal \"0 converted\" reads as noise. Check mode always + ;; reports (the preview is what the caller asked for), and a skip always + ;; reports (a done sub-task with no CLOSED date is a real condition to see). + (let ((has-skip (cl-some (lambda (i) (eq (plist-get i :kind) 'convert-skip)) + tc-issues))) + (when (or tc-check-only (> tc-converted 0) has-skip) + (princ (format "todo-cleanup --convert-subtasks: %d sub-task(s) %s%s\n" + tc-converted + (if tc-check-only "would convert" "converted") + (if tc-check-only " — CHECK MODE (no writes)" ""))) + (dolist (i (reverse tc-issues)) + (pcase (plist-get i :kind) + ((or 'convert-done 'convert-would) + (princ (format " %s:%d: %s\n → %s\n" + (plist-get i :file) (plist-get i :line) + (plist-get i :heading) (plist-get i :new)))) + ('convert-skip + (princ (format " skipped %s:%d: %s — %s\n" + (plist-get i :file) (plist-get i :line) + (plist-get i :heading) (plist-get i :detail))))))))) + (defun tc-emit-report () (cond (tc-archive-done (tc--emit-archive-report)) (tc-sync-child-priority (tc--emit-sync-report)) + (tc-convert-subtasks (tc--emit-convert-report)) (t (tc--emit-hygiene-report)))) (defun tc-main () @@ -484,6 +883,9 @@ before their descendants — a [#A] → [#B] → [#D] chain collapses in one pas (when (member "--sync-child-priority" command-line-args-left) (setq tc-sync-child-priority t) (setq command-line-args-left (delete "--sync-child-priority" command-line-args-left))) + (when (member "--convert-subtasks" command-line-args-left) + (setq tc-convert-subtasks t) + (setq command-line-args-left (delete "--convert-subtasks" command-line-args-left))) ;; --check-child-priority is the report-only alias for ;; `--sync-child-priority --check'. (when (member "--check-child-priority" command-line-args-left) @@ -491,7 +893,7 @@ before their descendants — a [#A] → [#B] → [#D] chain collapses in one pas (setq command-line-args-left (delete "--check-child-priority" command-line-args-left))) (if (null command-line-args-left) (progn - (princ "Usage: emacs --batch -q -l todo-cleanup.el [--check] [--archive-done | --sync-child-priority | --check-child-priority] FILE...\n") + (princ "Usage: emacs --batch -q -l todo-cleanup.el [--check] [--archive-done | --convert-subtasks | --sync-child-priority | --check-child-priority] FILE...\n") (kill-emacs 1)) (let ((files command-line-args-left)) (setq command-line-args-left nil) @@ -510,6 +912,7 @@ ert-run-tests-batch-and-exit'." (cl-every (lambda (a) (cond ((member a '("--check" "--archive-done" + "--convert-subtasks" "--sync-child-priority" "--check-child-priority")) t) diff --git a/claude-templates/.ai/scripts/wrap-org-table.el b/claude-templates/.ai/scripts/wrap-org-table.el index ddbea65..173e44d 100644 --- a/claude-templates/.ai/scripts/wrap-org-table.el +++ b/claude-templates/.ai/scripts/wrap-org-table.el @@ -228,22 +228,40 @@ continuation lines merge back into their logical row before re-wrapping." ;;; file layer (defun wot-process-file (file &optional budget) - "Reformat every org table in FILE in place to BUDGET width." + "Reformat every org table in FILE in place to BUDGET width. +Pipe-led lines inside #+begin_/#+end_ blocks (example, src, quote, …) are +content, not tables — ASCII art in an example block once got mangled into a +bordered table — so block regions are skipped verbatim." (with-temp-buffer (insert-file-contents file) (goto-char (point-min)) - (while (re-search-forward "^[ \t]*|" nil t) - (let ((start (line-beginning-position))) - (while (and (not (eobp)) - (save-excursion (beginning-of-line) - (looking-at "[ \t]*|"))) + (let ((in-block nil)) ; the open block's type, e.g. "example" — nil outside + (while (not (eobp)) + (cond + ;; Only the matching #+end_<type> closes a block: an example block + ;; often quotes literal #+begin_src/#+end_src lines, and a boolean + ;; flag would let that inner literal end-marker re-expose the rest + ;; of the block to reformatting. + ((and (not in-block) + (looking-at "^[ \t]*#\\+begin_\\([^ \t\n]+\\)")) + (setq in-block (downcase (match-string 1))) (forward-line 1)) - (let* ((end (point)) - (table (buffer-substring-no-properties start end)) - (reformatted (wot-reformat-table-string table budget))) - (delete-region start end) - (goto-char start) - (insert reformatted)))) + ((and in-block + (looking-at-p (format "^[ \t]*#\\+end_%s\\([ \t]\\|$\\)" + (regexp-quote in-block)))) + (setq in-block nil) + (forward-line 1)) + ((and (not in-block) (looking-at-p "^[ \t]*|")) + (let ((start (point))) + (while (and (not (eobp)) (looking-at-p "^[ \t]*|")) + (forward-line 1)) + (let* ((end (point)) + (table (buffer-substring-no-properties start end)) + (reformatted (wot-reformat-table-string table budget))) + (delete-region start end) + (goto-char start) + (insert reformatted)))) + (t (forward-line 1))))) (write-region (point-min) (point-max) file))) ;;; --------------------------------------------------------------------------- @@ -289,7 +307,21 @@ so the ERT suite can `require' this file without firing the CLI dispatch." (t (file-readable-p a)))) command-line-args-left))) -(when (and noninteractive (wot--cli-invocation-p)) +(defun wot--entry-script-p () + "Non-nil when wrap-org-table.el itself was named on the command line. +lint-org.el `require's this file, and a load-triggered dispatch would run +the table reformatter over lint-org's file arguments — that's how a lint +invocation once reformatted the files it was only supposed to report on. +Only dispatch when a -l/--load argument names this very file." + (and load-file-name + (cl-loop for (flag arg) on command-line-args + thereis (and (member flag '("-l" "--load")) + (stringp arg) + (file-exists-p arg) + (string= (file-truename (expand-file-name arg)) + (file-truename load-file-name)))))) + +(when (and noninteractive (wot--entry-script-p) (wot--cli-invocation-p)) (wot-main)) (provide 'wrap-org-table) |
