From bed27626bc00fb05fbd367813c56f00c4ba9cace Mon Sep 17 00:00:00 2001 From: Allen Date: Thu, 12 Mar 2026 09:53:50 -0500 Subject: [PATCH 01/57] feat(skills): add git-worktree-tidy and test-ping skills - git-worktree-tidy: routine hygiene for bare-repo + worktree layouts (fetch, prune stale branches/worktrees, ff-only update important branches) - test-ping: minimal skill stub for test CLI ping command - canton-nodes codex override: stripped-down version without broken symlink --- .claude/skills/git-worktree-tidy/SKILL.md | 127 +++++++++++++++++++ .claude/skills/test-ping | 1 + codex-overrides/skills/canton-nodes/SKILL.md | 113 +++++++++++++++++ 3 files changed, 241 insertions(+) create mode 100644 .claude/skills/git-worktree-tidy/SKILL.md create mode 120000 .claude/skills/test-ping create mode 100644 codex-overrides/skills/canton-nodes/SKILL.md diff --git a/.claude/skills/git-worktree-tidy/SKILL.md b/.claude/skills/git-worktree-tidy/SKILL.md new file mode 100644 index 0000000..361f903 --- /dev/null +++ b/.claude/skills/git-worktree-tidy/SKILL.md @@ -0,0 +1,127 @@ +--- +name: git-worktree-tidy +description: Fetch latest from origin, prune remote-tracking refs, delete stale local branches and worktrees, and fast-forward important branches. Use when tidying up a worktree-based repo layout. +metadata: + short-description: Worktree hygiene cleanup +--- + +# git-worktree-tidy + +Routine hygiene for bare-repo + worktree layouts. Fetches origin, prunes +gone branches and orphaned worktrees, and fast-forwards important local +branches. + +## When to use + +User asks to "fetch prune", "clean up stale branches/worktrees", or +"update main/dev to latest" in a worktree-based repo. + +## Hard Rules + +- All destructive actions (worktree remove, branch delete) require user + confirmation. Present the full list and wait. +- Never force-delete a worktree with uncommitted changes without explicit + approval. Flag dirty worktrees separately. +- Use `--ff-only` when updating branches. If ff-only fails, stop and ask. +- Operate from the `.bare` directory (or repo root) for branch/worktree + management commands. + +## Workflow + +### 1) Locate the bare root + +Determine the bare repo directory: +- If cwd contains `.bare/`, use it +- Otherwise: `git rev-parse --git-common-dir` + +All branch and worktree management commands run from this directory. + +### 2) Fetch + prune + +```bash +git fetch --prune origin +``` + +Report what was pruned (deleted remote-tracking branches, updated refs). + +### 3) Discover stale branches + +```bash +git branch -vv | grep ': gone]' +``` + +Collect branch names whose upstream is gone. + +### 4) Discover stale worktrees + +```bash +git worktree list +git worktree prune --dry-run +``` + +Cross-reference worktrees against the gone-branch list. Check each stale +worktree for dirty state: + +```bash +cd && git status --short +``` + +Categorize: +- **Clean + gone**: safe to remove +- **Dirty + gone**: flag for user review +- **Prunable metadata**: orphaned worktree entries (directory already gone) + +### 5) Confirm deletions + +Present a summary table: + +``` +Stale worktrees to remove: + () [clean] + () [dirty — N uncommitted changes] + +Stale branches to delete: + + +Prunable worktree metadata: + +``` + +Wait for user confirmation before proceeding. + +### 6) Remove stale worktrees + +For each confirmed worktree: + +```bash +git worktree remove +``` + +If removal fails (dirty), report and skip unless user approved force. + +### 7) Delete stale branches + +```bash +git branch -D ... +``` + +### 8) Prune worktree metadata + +```bash +git worktree prune -v +``` + +### 9) Update important branches + +Identify which branches have dedicated worktrees for `main`, `dev`, or +other important branches (user may specify). For each: + +```bash +cd && git pull --ff-only origin +``` + +If ff-only fails, report the divergence and ask for guidance. + +### 10) Final status + +Show a summary: what was removed, what was updated, any items skipped. diff --git a/.claude/skills/test-ping b/.claude/skills/test-ping new file mode 120000 index 0000000..52e8805 --- /dev/null +++ b/.claude/skills/test-ping @@ -0,0 +1 @@ +../../../../../.agents/skills/test-ping \ No newline at end of file diff --git a/codex-overrides/skills/canton-nodes/SKILL.md b/codex-overrides/skills/canton-nodes/SKILL.md new file mode 100644 index 0000000..e7fbcdc --- /dev/null +++ b/codex-overrides/skills/canton-nodes/SKILL.md @@ -0,0 +1,113 @@ +--- +name: canton-nodes +description: Canton validator node reference data. Use for participant IDs, database names, port availability, and architecture context. +--- + +# Canton Validator Nodes + +Reference data for Send's Canton validators. For connection commands, use the `sinfra` CLI. + +## Quick Access + +```bash +sinfra hosts --filter testnet # List testnet hosts +sinfra psql canton-testnet-docker --exec # Connect to postgres (with with-secrets) +sinfra grpc canton-testnet-docker health # Health check +``` + +See the `sinfra` skill for full CLI documentation. + +## Participant Info + +| Environment | UID | User | +|-------------|-----|------| +| Devnet | `send-dev-1::122033c9...` | - | +| Testnet | `send-test-cantonwallet-1::1220f760...` | `tn-validator-waxuq421oyl8wdbbj3gwizlkycqpfsyl@clients` | +| Mainnet | `send-cantonwallet-1::1220f1b0...` | `cantonwallet_validator@clients` | + +## Port Availability + +| Port | Service | Devnet | Testnet | Mainnet | +|------|---------|--------|---------|---------| +| 5001 | Ledger API | closed | open | open | +| 5002 | Admin API | open | open | open | +| 5003 | Validator HTTP | closed | open | open | +| 7575 | JSON API | open | open | open | +| 8080 | Scan API | closed | open | open | +| 8090 | External Admin | closed | open | open | +| 8091 | External Ledger | closed | open | open | +| 45432 | PQS Postgres | - | - | open | + +## Postgres Databases + +| Environment | Host | Databases | +|-------------|------|-----------| +| Devnet | canton-devnet-docker | `participant-1`, `validator` | +| Testnet | canton-testnet-docker | `participant-0`, `participant-1`, `validator` | +| Mainnet | canton-mainnet-docker | `participant-3`, `participant-4`, `validator` | +| Mainnet PQS | canton-mainnet-docker:45432 | `pqs-app-provider-4` (via pqs-postgres) | + +## API URLs + +**Testnet:** +``` +http://canton-testnet-docker.tail6be6de.ts.net:{5001,5002,5003,7575,8080} +``` + +**Mainnet:** +``` +http://canton-mainnet-docker.tail6be6de.ts.net:{5001,5002,5003,7575,8080} +``` + +**Kubernetes (in-cluster):** +``` +http://canton-testnet-proxy.tailscale.svc.cluster.local:{5001,5002,5003,8080} +http://canton-mainnet-proxy.tailscale.svc.cluster.local:{5001,5002,5003,8080,45432} +``` + +### Scan API (port 8080) + +Nginx caching reverse proxy round-robining across 13 SV scan endpoints. GET cached 30s, POST cached 5s. Returns `X-Cache-Status` header (MISS/HIT). + +```bash +# Health check +curl http://canton-mainnet-docker.tail6be6de.ts.net:8080/healthz + +# DSO info +curl http://canton-mainnet-docker.tail6be6de.ts.net:8080/api/scan/v0/dso + +# From K8s pod +curl http://canton-mainnet-proxy.tailscale.svc.cluster.local:8080/api/scan/v0/dso +``` + +Config on server: `/data/canton/{testnet,mainnet}/compose-scan-proxy.yaml` + +## Public Endpoints (Cloudflare Tunnels) + +| Endpoint | Env | API | Backend | +|----------|-----|-----|---------| +| `grpc-ta.cantonwallet.com` | testnet | Admin | `envoy-proxy:8090` | +| `grpc-tl.cantonwallet.com` | testnet | Ledger | `envoy-proxy:8091` | +| `json-api-testnet.cantonwallet.com` | testnet | JSON API | `participant:7575` | +| `grpc-ma.cantonwallet.com` | mainnet | Admin | `envoy-proxy:8090` | +| `grpc-ml.cantonwallet.com` | mainnet | Ledger | `envoy-proxy:8091` | +| `json-api-mainnet.cantonwallet.com` | mainnet | JSON API | `participant:7575` | +| `grpc-da.cantonwallet.com` | devnet | Admin | `envoy-proxy:8090` | +| `grpc-dl.cantonwallet.com` | devnet | Ledger | `envoy-proxy:8091` | + +gRPC convention: `grpc-{t|m|d}{a|l}.cantonwallet.com`. No validator gRPC routes via CF. + +All routes authenticated via CF Access service tokens. gRPC routes use Envoy for gRPC-Web conversion. JSON API credentials are in 1Password `api-gateway-secrets` items (`CF_ACCESS_CLIENT_ID`, `CF_ACCESS_CLIENT_SECRET`). gRPC credentials are in `grpc-tunnel` items. + +DNS and tunnel config: `terraform/infra/dns-cantonwallet.tf` + +## Architecture + +- Canton environments run as Docker containers with Tailscale sidecars +- Each environment exposes services on a unique Tailscale FQDN (`canton-testnet-docker`, etc.) +- Host machines (`send-canton01`, `send-canton02`) run multiple environment containers + +## Related + +- CLI: `sinfra` skill +- Tailscale egress: `kubernetes/infrastructure/swiss/tailscale/egress-canton.yaml` From 8f7ce6975eb87367df45cecba3ad555a79ef46ee Mon Sep 17 00:00:00 2001 From: Allen Date: Thu, 12 Mar 2026 10:01:27 -0500 Subject: [PATCH 02/57] chore(skills): remove test-ping leftover --- .claude/skills/test-ping | 1 - 1 file changed, 1 deletion(-) delete mode 120000 .claude/skills/test-ping diff --git a/.claude/skills/test-ping b/.claude/skills/test-ping deleted file mode 120000 index 52e8805..0000000 --- a/.claude/skills/test-ping +++ /dev/null @@ -1 +0,0 @@ -../../../../../.agents/skills/test-ping \ No newline at end of file From bcc0c8929d1a6c156e9fb0d3b088b0f5af0a6f94 Mon Sep 17 00:00:00 2001 From: Allen Date: Mon, 16 Mar 2026 10:55:33 -0500 Subject: [PATCH 03/57] feat(skills): replace tmux skill with zmx - Add zmx skill with session management patterns using zmx run, zmx history, zmx wait, and zmx list - Update tilt and tiltup skills to use zmx instead of tmux - Update CLAUDE.md skill combo reference (tilt + zmx) - Remove tmux skill (zmx has no windows/panes concept; uses separate sessions with project prefix instead) --- .claude/skills/tilt/SKILL.md | 23 ++-- .claude/skills/tiltup/SKILL.md | 28 ++--- .claude/skills/tmux/SKILL.md | 216 --------------------------------- .claude/skills/zmx/SKILL.md | 199 ++++++++++++++++++++++++++++++ CLAUDE.md | 2 +- 5 files changed, 223 insertions(+), 245 deletions(-) delete mode 100644 .claude/skills/tmux/SKILL.md create mode 100644 .claude/skills/zmx/SKILL.md diff --git a/.claude/skills/tilt/SKILL.md b/.claude/skills/tilt/SKILL.md index f829bb8..c01d436 100644 --- a/.claude/skills/tilt/SKILL.md +++ b/.claude/skills/tilt/SKILL.md @@ -62,24 +62,21 @@ tilt down # Stop and clean up ## Running tilt up -**tmux session rules** (mandatory — see `tmux` skill for full patterns): +**zmx session rules** (mandatory — see `zmx` skill for full patterns): -- **MUST** check `tmux has-session` before `tmux new-session` — never create duplicate sessions +- **MUST** check `zmx list --short` before creating sessions — never create duplicates - **MUST** derive session name from git root — never hardcode -- **MUST** add a window to an existing session — never create a parallel session -- **MUST** use `send-keys` — never pass inline commands to `new-session` +- **MUST** use `zmx run` — never attach from agent context ```bash -SESSION=$(basename $(git rev-parse --show-toplevel 2>/dev/null) || basename $PWD) - -if ! tmux has-session -t "$SESSION" 2>/dev/null; then - tmux new-session -d -s "$SESSION" -n tilt - tmux send-keys -t "$SESSION:tilt" 'tilt up' Enter -elif ! tmux list-windows -t "$SESSION" -F '#{window_name}' | grep -q "^tilt$"; then - tmux new-window -t "$SESSION" -n tilt - tmux send-keys -t "$SESSION:tilt" 'tilt up' Enter +PROJECT=$(basename "$(git rev-parse --show-toplevel 2>/dev/null)" || basename "$PWD") +SESSION="${PROJECT}-tilt" + +if zmx list --short 2>/dev/null | grep -q "^${SESSION}$"; then + echo "Tilt session already exists: $SESSION" else - echo "Tilt window already exists in session: $SESSION" + zmx run "$SESSION" 'tilt up' + echo "Started tilt in zmx session: $SESSION" fi ``` diff --git a/.claude/skills/tiltup/SKILL.md b/.claude/skills/tiltup/SKILL.md index 9cf9919..7c05170 100644 --- a/.claude/skills/tiltup/SKILL.md +++ b/.claude/skills/tiltup/SKILL.md @@ -1,6 +1,6 @@ --- name: tiltup -description: Start Tilt dev environment in tmux, monitor bootstrap to healthy state, fix Tiltfile bugs without hard-coding or fallbacks. Use when starting tilt, debugging Tiltfile errors, or bootstrapping a dev environment. +description: Start Tilt dev environment in zmx, monitor bootstrap to healthy state, fix Tiltfile bugs without hard-coding or fallbacks. Use when starting tilt, debugging Tiltfile errors, or bootstrapping a dev environment. --- # Tilt Up @@ -40,8 +40,8 @@ Restart only for: Tilt version upgrades, port/host config changes, crashes, clus 1. Check if tilt is already running: ```bash - SESSION=$(basename $(git rev-parse --show-toplevel 2>/dev/null) || basename $PWD) - tmux list-windows -t "$SESSION" -F '#{window_name}' 2>/dev/null | grep -q "^tilt$" + PROJECT=$(basename "$(git rev-parse --show-toplevel 2>/dev/null)" || basename "$PWD") + zmx list --short 2>/dev/null | grep -q "^${PROJECT}-tilt$" ``` If running, check health via `tilt get uiresources -o json` and skip to Step 3. @@ -52,20 +52,18 @@ Restart only for: Tilt version upgrades, port/host config changes, crashes, clus 3. Check for k3d cluster or Docker prerequisites. -### Step 2: Start Tilt in tmux +### Step 2: Start Tilt in zmx -Follow the `tmux` skill patterns: +Follow the `zmx` skill patterns: ```bash -SESSION=$(basename $(git rev-parse --show-toplevel 2>/dev/null) || basename $PWD) - -if ! tmux has-session -t "$SESSION" 2>/dev/null; then - tmux new-session -d -s "$SESSION" -n tilt - tmux send-keys -t "$SESSION:tilt" 'tilt up' Enter -elif ! tmux list-windows -t "$SESSION" -F '#{window_name}' | grep -q "^tilt$"; then - tmux new-window -t "$SESSION" -n tilt - tmux send-keys -t "$SESSION:tilt" 'tilt up' Enter +PROJECT=$(basename "$(git rev-parse --show-toplevel 2>/dev/null)" || basename "$PWD") +SESSION="${PROJECT}-tilt" + +if zmx list --short 2>/dev/null | grep -q "^${SESSION}$"; then + echo "Tilt session already exists: $SESSION" else - echo "Tilt window already exists in session: $SESSION" + zmx run "$SESSION" 'tilt up' + echo "Started tilt in zmx session: $SESSION" fi ``` @@ -103,7 +101,7 @@ After 3 fix iterations on the same resource without progress: ## Tilt Status: **Resources**: X/Y ok -**Session**: tmux $SESSION:tilt +**Session**: zmx $SESSION ### Errors (if any) - : diff --git a/.claude/skills/tmux/SKILL.md b/.claude/skills/tmux/SKILL.md deleted file mode 100644 index f9e84fc..0000000 --- a/.claude/skills/tmux/SKILL.md +++ /dev/null @@ -1,216 +0,0 @@ ---- -name: tmux -description: Patterns for running long-lived processes in tmux. Use when starting dev servers, watchers, tilt, or any process expected to outlive the conversation. ---- - -# tmux Process Management - -## Session Reuse Rules - -These are **hard requirements**, not suggestions: - -- **MUST** check `tmux has-session` before ever calling `tmux new-session` -- **MUST** derive session name from `git rev-parse --show-toplevel`, never hardcode -- **MUST** add windows to an existing project session, never create a parallel session -- **MUST** use `send-keys` to run commands, never pass inline commands to `new-session` -- **NEVER** create a new session if one already exists for the current project - -One project = one tmux session. Multiple processes = multiple windows within that session. - -## Interactive Shell Requirement - -**Use send-keys pattern for reliable shell initialization.** Creating a session spawns an interactive shell automatically. Use `send-keys` to run commands within that shell, ensuring PATH, direnv, and other initialization runs properly. - -```bash -# WRONG - inline command bypasses shell init, breaks PATH/direnv -tmux new-session -d -s "$SESSION" -n main 'tilt up' - -# CORRECT - check for session, then use send-keys in interactive shell -if ! tmux has-session -t "$SESSION" 2>/dev/null; then - tmux new-session -d -s "$SESSION" -n main -fi -tmux send-keys -t "$SESSION:main" 'tilt up' Enter -``` - -## Session Naming Convention - -Always derive session name from the project: - -```bash -SESSION=$(basename $(git rev-parse --show-toplevel 2>/dev/null) || basename $PWD) -``` - -For multiple processes in one project, use windows not separate sessions: -- Session: `myapp` -- Windows: `server`, `tests`, `logs` - -## Starting Processes - -### Single Process - -```bash -SESSION=$(basename $(git rev-parse --show-toplevel 2>/dev/null) || basename $PWD) - -if ! tmux has-session -t "$SESSION" 2>/dev/null; then - tmux new-session -d -s "$SESSION" -n main - tmux send-keys -t "$SESSION:main" '' Enter -else - echo "Session $SESSION already exists" -fi -``` - -### Adding a Window to Existing Session - -```bash -SESSION=$(basename $(git rev-parse --show-toplevel 2>/dev/null) || basename $PWD) - -# Add a new window if it doesn't exist -if ! tmux list-windows -t "$SESSION" -F '#{window_name}' | grep -q "^server$"; then - tmux new-window -t "$SESSION" -n server - tmux send-keys -t "$SESSION:server" 'npm run dev' Enter -else - echo "Window 'server' already exists" -fi -``` - -### Multiple Processes (Windows) - -```bash -SESSION=$(basename $(git rev-parse --show-toplevel 2>/dev/null) || basename $PWD) - -# Create session if needed, then add windows -if ! tmux has-session -t "$SESSION" 2>/dev/null; then - tmux new-session -d -s "$SESSION" -n server - tmux send-keys -t "$SESSION:server" 'npm run dev' Enter -fi - -# Add more windows (idempotent) -for win in tests logs; do - if ! tmux list-windows -t "$SESSION" -F '#{window_name}' | grep -q "^${win}$"; then - tmux new-window -t "$SESSION" -n "$win" - fi -done -tmux send-keys -t "$SESSION:tests" 'npm run test:watch' Enter -tmux send-keys -t "$SESSION:logs" 'tail -f logs/app.log' Enter -``` - -## Monitoring Output - -```bash -SESSION=$(basename $(git rev-parse --show-toplevel 2>/dev/null) || basename $PWD) - -# Last 50 lines from first window -tmux capture-pane -p -t "$SESSION" -S -50 - -# From specific window -tmux capture-pane -p -t "$SESSION:server" -S -50 - -# Check for errors -tmux capture-pane -p -t "$SESSION" -S -100 | rg -i "error|fail|exception" - -# Check for ready indicators -tmux capture-pane -p -t "$SESSION:server" -S -50 | rg -i "listening|ready|started" -``` - -## Lifecycle Management - -```bash -SESSION=$(basename $(git rev-parse --show-toplevel 2>/dev/null) || basename $PWD) - -# List all sessions (see what exists) -tmux ls - -# List windows in current session -tmux list-windows -t "$SESSION" - -# Kill only this project's session -tmux kill-session -t "$SESSION" - -# Kill specific window -tmux kill-window -t "$SESSION:tests" - -# Send keys to a window (e.g., Ctrl+C to stop) -tmux send-keys -t "$SESSION:server" C-c -``` - -## Isolation Rules - -- **Never** use `tmux kill-server` -- **Never** kill sessions not matching current project -- **Always** derive session name from git root or pwd -- **Always** verify session name before kill operations -- Other Claude Code instances may have their own sessions running - -## When to Use tmux - -| Scenario | Use tmux? | -|----------|-----------| -| `tilt up` | Yes, always | -| Dev server (`npm run dev`, `rails s`) | Yes | -| File watcher (`npm run watch`) | Yes | -| Test watcher (`npm run test:watch`) | Yes | -| Database server | Yes | -| One-shot build (`npm run build`) | No | -| Quick command (<10s) | No | -| Need stdout directly in conversation | No | - -## Checking Process Status - -```bash -SESSION=$(basename $(git rev-parse --show-toplevel 2>/dev/null) || basename $PWD) - -# Check session exists -tmux has-session -t "$SESSION" 2>/dev/null && echo "session exists" || echo "no session" - -# List windows and their status -tmux list-windows -t "$SESSION" -F '#{window_name}: #{pane_current_command}' - -# Check if specific window exists -tmux list-windows -t "$SESSION" -F '#{window_name}' | grep -q "^server$" && echo "server window exists" -``` - -## Restarting a Process - -```bash -SESSION=$(basename $(git rev-parse --show-toplevel 2>/dev/null) || basename $PWD) - -# Send Ctrl+C then restart command -tmux send-keys -t "$SESSION:server" C-c -sleep 1 -tmux send-keys -t "$SESSION:server" 'npm run dev' Enter -``` - -## Common Patterns - -### Start dev server if not running - -```bash -SESSION=$(basename $(git rev-parse --show-toplevel 2>/dev/null) || basename $PWD) - -if ! tmux has-session -t "$SESSION" 2>/dev/null; then - tmux new-session -d -s "$SESSION" -n server - tmux send-keys -t "$SESSION:server" 'npm run dev' Enter - echo "Started dev server in tmux session: $SESSION" -elif ! tmux list-windows -t "$SESSION" -F '#{window_name}' | grep -q "^server$"; then - tmux new-window -t "$SESSION" -n server - tmux send-keys -t "$SESSION:server" 'npm run dev' Enter - echo "Added server window to session: $SESSION" -else - echo "Server already running in session: $SESSION" -fi -``` - -### Wait for server ready - -```bash -SESSION=$(basename $(git rev-parse --show-toplevel 2>/dev/null) || basename $PWD) - -# Poll for ready message -for i in {1..30}; do - if tmux capture-pane -p -t "$SESSION:server" -S -20 | rg -q "listening|ready"; then - echo "Server ready" - break - fi - sleep 1 -done -``` diff --git a/.claude/skills/zmx/SKILL.md b/.claude/skills/zmx/SKILL.md new file mode 100644 index 0000000..c88877a --- /dev/null +++ b/.claude/skills/zmx/SKILL.md @@ -0,0 +1,199 @@ +--- +name: zmx +description: Patterns for running long-lived processes in zmx. Use when starting dev servers, watchers, tilt, or any process expected to outlive the conversation. +--- + +# zmx Process Management + +## Session Rules + +These are **hard requirements**, not suggestions: + +- **MUST** check `zmx list --short` before creating sessions to avoid duplicates +- **MUST** derive session name from `git rev-parse --show-toplevel`, never hardcode +- **MUST** use `zmx run` to send commands without attaching (agent-friendly) +- **MUST** use separate sessions with a common prefix for multiple processes in one project +- **NEVER** attach to sessions from agent context — use `zmx run` and `zmx history` only + +One project = one session prefix. Multiple processes = multiple sessions sharing the prefix. + +## Session Naming Convention + +Derive session prefix from the project: + +```bash +PROJECT=$(basename "$(git rev-parse --show-toplevel 2>/dev/null)" || basename "$PWD") +``` + +For multiple processes in one project, use prefixed session names: +- `myapp-server` +- `myapp-tests` +- `myapp-tilt` + +## Starting Processes + +### Single Process + +```bash +PROJECT=$(basename "$(git rev-parse --show-toplevel 2>/dev/null)" || basename "$PWD") +SESSION="${PROJECT}-main" + +if zmx list --short 2>/dev/null | grep -q "^${SESSION}$"; then + echo "Session $SESSION already exists" +else + zmx run "$SESSION" '' + echo "Started $SESSION" +fi +``` + +### Multiple Processes + +```bash +PROJECT=$(basename "$(git rev-parse --show-toplevel 2>/dev/null)" || basename "$PWD") + +# Start each process in its own session (idempotent) +for name_cmd in "server:npm run dev" "tests:npm run test:watch" "logs:tail -f logs/app.log"; do + name="${name_cmd%%:*}" + cmd="${name_cmd#*:}" + SESSION="${PROJECT}-${name}" + if ! zmx list --short 2>/dev/null | grep -q "^${SESSION}$"; then + zmx run "$SESSION" "$cmd" + echo "Started $SESSION" + else + echo "Session $SESSION already exists" + fi +done +``` + +### Sending Commands to an Existing Session + +```bash +# Run a command in a session (creates session if needed) +zmx run "$SESSION" 'cat README.md' + +# Pipe command via stdin +echo "ls -lah" | zmx r "$SESSION" +``` + +## Monitoring Output + +```bash +PROJECT=$(basename "$(git rev-parse --show-toplevel 2>/dev/null)" || basename "$PWD") + +# Full scrollback from a session +zmx history "${PROJECT}-server" + +# Last 50 lines +zmx history "${PROJECT}-server" | tail -50 + +# Check for errors +zmx history "${PROJECT}-server" | rg -i "error|fail|exception" + +# Check for ready indicators +zmx history "${PROJECT}-server" | rg -i "listening|ready|started" +``` + +## Waiting for Completion + +```bash +# Block until a session's task finishes +zmx wait "${PROJECT}-tests" + +# Wait for multiple sessions +zmx wait "${PROJECT}-build" "${PROJECT}-lint" +``` + +## Lifecycle Management + +```bash +PROJECT=$(basename "$(git rev-parse --show-toplevel 2>/dev/null)" || basename "$PWD") + +# List all sessions +zmx list + +# List session names only +zmx list --short + +# Kill a specific session +zmx kill "${PROJECT}-server" + +# Kill all project sessions +zmx list --short 2>/dev/null | grep "^${PROJECT}-" | while read -r s; do + zmx kill "$s" +done +``` + +## Isolation Rules + +- **Never** kill sessions not matching current project prefix +- **Always** derive session name from git root or pwd +- **Always** verify session name before kill operations +- Other Claude Code instances may have their own sessions running + +## When to Use zmx + +| Scenario | Use zmx? | +|----------|----------| +| `tilt up` | Yes, always | +| Dev server (`npm run dev`, `rails s`) | Yes | +| File watcher (`npm run watch`) | Yes | +| Test watcher (`npm run test:watch`) | Yes | +| Database server | Yes | +| One-shot build (`npm run build`) | No | +| Quick command (<10s) | No | +| Need stdout directly in conversation | No | + +## Checking Session Status + +```bash +PROJECT=$(basename "$(git rev-parse --show-toplevel 2>/dev/null)" || basename "$PWD") + +# Check session exists +zmx list --short 2>/dev/null | grep -q "^${PROJECT}-tilt$" && echo "session exists" || echo "no session" + +# List all project sessions +zmx list --short 2>/dev/null | grep "^${PROJECT}-" +``` + +## Common Patterns + +### Start dev server if not running + +```bash +PROJECT=$(basename "$(git rev-parse --show-toplevel 2>/dev/null)" || basename "$PWD") +SESSION="${PROJECT}-server" + +if zmx list --short 2>/dev/null | grep -q "^${SESSION}$"; then + echo "Server already running in session: $SESSION" +else + zmx run "$SESSION" 'npm run dev' + echo "Started dev server in zmx session: $SESSION" +fi +``` + +### Wait for server ready + +```bash +PROJECT=$(basename "$(git rev-parse --show-toplevel 2>/dev/null)" || basename "$PWD") +SESSION="${PROJECT}-server" + +# Poll for ready message +for i in {1..30}; do + if zmx history "$SESSION" 2>/dev/null | tail -20 | rg -q "listening|ready"; then + echo "Server ready" + break + fi + sleep 1 +done +``` + +### Run tests and wait for result + +```bash +PROJECT=$(basename "$(git rev-parse --show-toplevel 2>/dev/null)" || basename "$PWD") +SESSION="${PROJECT}-tests" + +zmx run "$SESSION" 'go test ./...' +zmx wait "$SESSION" +zmx history "$SESSION" | tail -20 +``` diff --git a/CLAUDE.md b/CLAUDE.md index 03baf5a..fdecabe 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -102,7 +102,7 @@ Load all applicable skills together when contexts overlap: - **Python + Tilt**: Python services in a Tilt-managed dev environment - **Go + Tilt**: Go services in a Tilt-managed dev environment - **testing-best-practices + [language]**: Load testing skill alongside the project's language skill when designing tests from specs -- **tilt + tmux**: Always load both when running `tilt up` or any long-lived process in tmux +- **tilt + zmx**: Always load both when running `tilt up` or any long-lived process in zmx - **tilt + tiltup**: Always load both when starting tilt or fixing Tiltfile errors - **spec-best-practices + specalign**: Load both when reviewing or updating existing specs against implementation - **spec-best-practices + testing-best-practices**: Load both when deriving test strategy from a spec From cbd30e0c1224e113e7db289ee02e2d211a1047c6 Mon Sep 17 00:00:00 2001 From: Allen Date: Mon, 16 Mar 2026 11:10:09 -0500 Subject: [PATCH 04/57] feat(statusline): show zmx session name Read ZMX_SESSION env var and display as gray `zmx:` segment between the git status and model gauge sections. Hidden when not inside a zmx session. --- statusline/src/main.zig | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/statusline/src/main.zig b/statusline/src/main.zig index fac8496..92b8aab 100644 --- a/statusline/src/main.zig +++ b/statusline/src/main.zig @@ -1018,6 +1018,13 @@ pub fn main() !void { } } + // zmx session indicator + if (std.posix.getenv("ZMX_SESSION")) |zmx_session| { + if (zmx_session.len > 0) { + try writer.print(" {s}zmx:{s}{s}", .{ colors.gray, zmx_session, colors.reset }); + } + } + // Add model display with gauge if (input.model) |model| { if (model.display_name) |name| { From 2dc1fbb6140fbe53fa508eb69a3c509418353444 Mon Sep 17 00:00:00 2001 From: Allen Date: Mon, 16 Mar 2026 11:39:49 -0500 Subject: [PATCH 05/57] refactor(skills): align zmx/tilt/tiltup with prompting best practices - Descriptions lead with "Use when..." for discovery - Remove MUST/NEVER over-prompting (Claude 4.6 overtriggers on aggressive language); use normal phrasing with context/motivation - Deduplicate PROJECT= boilerplate (define once, reference after) - Cut redundant "Common Patterns" section that restated earlier examples - tilt skill: inline zmx reference instead of restating rules --- .claude/skills/tilt/SKILL.md | 8 +- .claude/skills/tiltup/SKILL.md | 2 +- .claude/skills/zmx/SKILL.md | 148 ++++++++------------------------- 3 files changed, 36 insertions(+), 122 deletions(-) diff --git a/.claude/skills/tilt/SKILL.md b/.claude/skills/tilt/SKILL.md index c01d436..d0c85d2 100644 --- a/.claude/skills/tilt/SKILL.md +++ b/.claude/skills/tilt/SKILL.md @@ -1,6 +1,6 @@ --- name: tilt -description: Queries Tilt resource status, logs, and manages dev environments. Use when checking deployment health, investigating errors, reading logs, or working with Tiltfiles. +description: Use when checking deployment health, investigating errors, reading logs, or working with Tiltfiles. Queries Tilt resource status, logs, and manages dev environments. --- # Tilt @@ -62,11 +62,7 @@ tilt down # Stop and clean up ## Running tilt up -**zmx session rules** (mandatory — see `zmx` skill for full patterns): - -- **MUST** check `zmx list --short` before creating sessions — never create duplicates -- **MUST** derive session name from git root — never hardcode -- **MUST** use `zmx run` — never attach from agent context +Follow `zmx` skill patterns — check for existing sessions, derive name from git root, use `zmx run` (not attach): ```bash PROJECT=$(basename "$(git rev-parse --show-toplevel 2>/dev/null)" || basename "$PWD") diff --git a/.claude/skills/tiltup/SKILL.md b/.claude/skills/tiltup/SKILL.md index 7c05170..73a99ee 100644 --- a/.claude/skills/tiltup/SKILL.md +++ b/.claude/skills/tiltup/SKILL.md @@ -1,6 +1,6 @@ --- name: tiltup -description: Start Tilt dev environment in zmx, monitor bootstrap to healthy state, fix Tiltfile bugs without hard-coding or fallbacks. Use when starting tilt, debugging Tiltfile errors, or bootstrapping a dev environment. +description: Use when starting tilt, debugging Tiltfile errors, or bootstrapping a dev environment. Starts Tilt in zmx, monitors bootstrap to healthy state, fixes Tiltfile bugs without hard-coding or fallbacks. --- # Tilt Up diff --git a/.claude/skills/zmx/SKILL.md b/.claude/skills/zmx/SKILL.md index c88877a..d98b436 100644 --- a/.claude/skills/zmx/SKILL.md +++ b/.claude/skills/zmx/SKILL.md @@ -1,121 +1,84 @@ --- name: zmx -description: Patterns for running long-lived processes in zmx. Use when starting dev servers, watchers, tilt, or any process expected to outlive the conversation. +description: Use when starting dev servers, watchers, tilt, or any process expected to outlive the conversation. Provides zmx session management patterns for long-lived processes. --- # zmx Process Management ## Session Rules -These are **hard requirements**, not suggestions: - -- **MUST** check `zmx list --short` before creating sessions to avoid duplicates -- **MUST** derive session name from `git rev-parse --show-toplevel`, never hardcode -- **MUST** use `zmx run` to send commands without attaching (agent-friendly) -- **MUST** use separate sessions with a common prefix for multiple processes in one project -- **NEVER** attach to sessions from agent context — use `zmx run` and `zmx history` only +- Check `zmx list --short` before creating sessions — duplicates cause port conflicts and confusing output +- Derive session name from `git rev-parse --show-toplevel` — hardcoded names collide when multiple agent instances run concurrently +- Use `zmx run` to send commands without attaching — `zmx attach` blocks the agent's shell and makes it unresponsive +- Use separate sessions with a common project prefix for multiple processes One project = one session prefix. Multiple processes = multiple sessions sharing the prefix. -## Session Naming Convention - -Derive session prefix from the project: +## Session Naming ```bash PROJECT=$(basename "$(git rev-parse --show-toplevel 2>/dev/null)" || basename "$PWD") ``` -For multiple processes in one project, use prefixed session names: -- `myapp-server` -- `myapp-tests` -- `myapp-tilt` +All subsequent examples assume `PROJECT` is set. Session names follow `${PROJECT}-`: +- `myapp-server`, `myapp-tests`, `myapp-tilt` ## Starting Processes -### Single Process - ```bash -PROJECT=$(basename "$(git rev-parse --show-toplevel 2>/dev/null)" || basename "$PWD") -SESSION="${PROJECT}-main" +SESSION="${PROJECT}-server" -if zmx list --short 2>/dev/null | grep -q "^${SESSION}$"; then - echo "Session $SESSION already exists" -else - zmx run "$SESSION" '' - echo "Started $SESSION" +# Idempotent: skip if already running +if ! zmx list --short 2>/dev/null | grep -q "^${SESSION}$"; then + zmx run "$SESSION" 'npm run dev' fi ``` -### Multiple Processes +For multiple processes, loop over name:command pairs: ```bash -PROJECT=$(basename "$(git rev-parse --show-toplevel 2>/dev/null)" || basename "$PWD") - -# Start each process in its own session (idempotent) -for name_cmd in "server:npm run dev" "tests:npm run test:watch" "logs:tail -f logs/app.log"; do +for name_cmd in "server:npm run dev" "tests:npm run test:watch"; do name="${name_cmd%%:*}" cmd="${name_cmd#*:}" SESSION="${PROJECT}-${name}" if ! zmx list --short 2>/dev/null | grep -q "^${SESSION}$"; then zmx run "$SESSION" "$cmd" - echo "Started $SESSION" - else - echo "Session $SESSION already exists" fi done ``` -### Sending Commands to an Existing Session +## Sending Commands ```bash # Run a command in a session (creates session if needed) -zmx run "$SESSION" 'cat README.md' +zmx run "${PROJECT}-main" 'cat README.md' -# Pipe command via stdin -echo "ls -lah" | zmx r "$SESSION" +# Pipe via stdin +echo "ls -lah" | zmx r "${PROJECT}-main" ``` ## Monitoring Output ```bash -PROJECT=$(basename "$(git rev-parse --show-toplevel 2>/dev/null)" || basename "$PWD") - -# Full scrollback from a session -zmx history "${PROJECT}-server" - -# Last 50 lines -zmx history "${PROJECT}-server" | tail -50 - -# Check for errors -zmx history "${PROJECT}-server" | rg -i "error|fail|exception" - -# Check for ready indicators -zmx history "${PROJECT}-server" | rg -i "listening|ready|started" +zmx history "${PROJECT}-server" # full scrollback +zmx history "${PROJECT}-server" | tail -50 # last 50 lines +zmx history "${PROJECT}-server" | rg -i "error|fail" # check for errors +zmx history "${PROJECT}-server" | rg -i "listening|ready" # check for ready ``` ## Waiting for Completion ```bash -# Block until a session's task finishes -zmx wait "${PROJECT}-tests" - -# Wait for multiple sessions -zmx wait "${PROJECT}-build" "${PROJECT}-lint" +zmx wait "${PROJECT}-tests" # block until done +zmx wait "${PROJECT}-build" "${PROJECT}-lint" # wait for multiple ``` -## Lifecycle Management +## Lifecycle ```bash -PROJECT=$(basename "$(git rev-parse --show-toplevel 2>/dev/null)" || basename "$PWD") - -# List all sessions -zmx list - -# List session names only -zmx list --short - -# Kill a specific session -zmx kill "${PROJECT}-server" +zmx list # all sessions +zmx list --short # names only +zmx kill "${PROJECT}-server" # kill one session # Kill all project sessions zmx list --short 2>/dev/null | grep "^${PROJECT}-" | while read -r s; do @@ -123,12 +86,10 @@ zmx list --short 2>/dev/null | grep "^${PROJECT}-" | while read -r s; do done ``` -## Isolation Rules +## Isolation -- **Never** kill sessions not matching current project prefix -- **Always** derive session name from git root or pwd -- **Always** verify session name before kill operations -- Other Claude Code instances may have their own sessions running +- Only kill sessions matching the current project prefix — other agent instances may have their own sessions running +- Always verify the session name before kill operations ## When to Use zmx @@ -143,57 +104,14 @@ done | Quick command (<10s) | No | | Need stdout directly in conversation | No | -## Checking Session Status - -```bash -PROJECT=$(basename "$(git rev-parse --show-toplevel 2>/dev/null)" || basename "$PWD") - -# Check session exists -zmx list --short 2>/dev/null | grep -q "^${PROJECT}-tilt$" && echo "session exists" || echo "no session" - -# List all project sessions -zmx list --short 2>/dev/null | grep "^${PROJECT}-" -``` - -## Common Patterns - -### Start dev server if not running - -```bash -PROJECT=$(basename "$(git rev-parse --show-toplevel 2>/dev/null)" || basename "$PWD") -SESSION="${PROJECT}-server" - -if zmx list --short 2>/dev/null | grep -q "^${SESSION}$"; then - echo "Server already running in session: $SESSION" -else - zmx run "$SESSION" 'npm run dev' - echo "Started dev server in zmx session: $SESSION" -fi -``` - -### Wait for server ready +## Polling for Readiness ```bash -PROJECT=$(basename "$(git rev-parse --show-toplevel 2>/dev/null)" || basename "$PWD") -SESSION="${PROJECT}-server" - -# Poll for ready message for i in {1..30}; do - if zmx history "$SESSION" 2>/dev/null | tail -20 | rg -q "listening|ready"; then + if zmx history "${PROJECT}-server" 2>/dev/null | tail -20 | rg -q "listening|ready"; then echo "Server ready" break fi sleep 1 done ``` - -### Run tests and wait for result - -```bash -PROJECT=$(basename "$(git rev-parse --show-toplevel 2>/dev/null)" || basename "$PWD") -SESSION="${PROJECT}-tests" - -zmx run "$SESSION" 'go test ./...' -zmx wait "$SESSION" -zmx history "$SESSION" | tail -20 -``` From c9cba20eb6a5ad75f02cbdc854b560619213af2c Mon Sep 17 00:00:00 2001 From: Allen Date: Tue, 17 Mar 2026 14:15:21 -0500 Subject: [PATCH 06/57] feat(statusline): show idle timestamp when agent stops MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add 💤HH:MM indicator to statusline showing when the agent last went idle. Uses Stop/UserPromptSubmit hooks to write and clear per-session marker files, with 7-day auto-cleanup of orphaned files. --- settings/settings.json | 25 ++++++++++++++++++- statusline/src/main.zig | 53 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 77 insertions(+), 1 deletion(-) diff --git a/settings/settings.json b/settings/settings.json index 671f50b..5bfe08e 100644 --- a/settings/settings.json +++ b/settings/settings.json @@ -93,7 +93,30 @@ "~/.claude/handoffs" ] }, - "hooks": {}, + "hooks": { + "Stop": [ + { + "hooks": [ + { + "type": "command", + "command": "sid=$(jq -r '.session_id // empty' 2>/dev/null); [ -n \"$sid\" ] && date '+%H:%M' > ~/.claude/.idle-since-\"$sid\"; find ~/.claude -maxdepth 1 -name '.idle-since-*' -mtime +7 -delete 2>/dev/null; echo '{}'", + "timeout": 5 + } + ] + } + ], + "UserPromptSubmit": [ + { + "hooks": [ + { + "type": "command", + "command": "sid=$(jq -r '.session_id // empty' 2>/dev/null); [ -n \"$sid\" ] && rm -f ~/.claude/.idle-since-\"$sid\"; echo '{}'", + "timeout": 5 + } + ] + } + ] + }, "statusLine": { "type": "command", "command": "statusline" diff --git a/statusline/src/main.zig b/statusline/src/main.zig index 92b8aab..9bd25c3 100644 --- a/statusline/src/main.zig +++ b/statusline/src/main.zig @@ -534,6 +534,31 @@ fn formatLinesChanged(input: StatuslineInput, writer: anytype) !bool { return true; } +/// Read idle-since file for this session and write the indicator directly. +/// Reads and formats in one call to avoid returning a dangling stack slice. +/// Returns true if indicator was written, false if not idle or file missing. +fn formatIdleSince(writer: anytype, session_id: ?[]const u8) !bool { + const sid = session_id orelse return false; + if (sid.len == 0) return false; + const home = std.posix.getenv("HOME") orelse return false; + var path_buf: [512]u8 = undefined; + const path = std.fmt.bufPrint(&path_buf, "{s}/.claude/.idle-since-{s}", .{ home, sid }) catch return false; + + const file = std.fs.cwd().openFile(path, .{}) catch return false; + defer file.close(); + + // File contains a short time string like "14:45\n" + var buf: [32]u8 = undefined; + const bytes_read = file.read(&buf) catch return false; + if (bytes_read == 0) return false; + + const trimmed = std.mem.trim(u8, buf[0..bytes_read], " \t\n\r"); + if (trimmed.len == 0) return false; + + try writer.print(" 💤{s}{s}{s}", .{ colors.light_gray, trimmed, colors.reset }); + return true; +} + /// Get the last segment of a path (e.g., "/foo/bar/baz" -> "baz") fn getLastPathSegment(path: []const u8) []const u8 { if (path.len == 0) return path; @@ -1086,6 +1111,9 @@ pub fn main() !void { } } + // Idle-since indicator (visible only when agent is waiting for input) + _ = try formatIdleSince(writer, input.session_id); + // Output the complete statusline at once const output = output_stream.getWritten(); @@ -1961,3 +1989,28 @@ test "parseCodexReviewStateFromContent with empty content" { const state = parseCodexReviewStateFromContent(""); try std.testing.expect(!state.active); } + +test "formatIdleSince returns false without session_id" { + var buf: [128]u8 = undefined; + var stream = std.io.fixedBufferStream(&buf); + const writer = stream.writer(); + + const result_null = try formatIdleSince(writer, null); + try std.testing.expect(!result_null); + try std.testing.expectEqual(@as(usize, 0), stream.getWritten().len); + + const result_empty = try formatIdleSince(writer, ""); + try std.testing.expect(!result_empty); + try std.testing.expectEqual(@as(usize, 0), stream.getWritten().len); +} + +test "formatIdleSince returns false for missing file" { + var buf: [128]u8 = undefined; + var stream = std.io.fixedBufferStream(&buf); + const writer = stream.writer(); + + // Nonexistent session ID -> file won't exist -> returns false + const result = try formatIdleSince(writer, "nonexistent-session-id-12345"); + try std.testing.expect(!result); + try std.testing.expectEqual(@as(usize, 0), stream.getWritten().len); +} From 66b1293be793098c49d25126ec8e6400dbfae22b Mon Sep 17 00:00:00 2001 From: Allen Date: Tue, 17 Mar 2026 15:38:08 -0500 Subject: [PATCH 07/57] feat(plugins): double default iteration and review cycle limits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ralph and Codex reviewer plugins defaulted to too few cycles, causing loops to hit caps before completing complex tasks. - ralph command: 15→30 max-iterations, 10→20 max-reviews - ralph-loop plugin: 50→30 max-iterations (aligned with ralph command) - codex-reviewer plugin: 10→20 max-cycles (command + hook fallback) --- commands/ralph.md | 8 ++++---- plugins/codex-reviewer/commands/review.md | 12 ++++++------ .../codex-reviewer/hooks/codex-reviewer-stop-hook.ts | 2 +- plugins/ralph-reviewed/commands/ralph-loop.md | 2 +- 4 files changed, 12 insertions(+), 12 deletions(-) diff --git a/commands/ralph.md b/commands/ralph.md index faae96c..4805996 100644 --- a/commands/ralph.md +++ b/commands/ralph.md @@ -16,8 +16,8 @@ Split arguments into two groups: - **LOOP_FLAGS**: Any `--max-iterations`, `--max-reviews`, `--completion-promise`, `--no-review`, `--debug` flags (passed to ralph-loop) Default loop flags if not specified: -- `--max-iterations 15` -- `--max-reviews 10` +- `--max-iterations 30` +- `--max-reviews 20` - `--completion-promise "COMPLETE"` ## Workflow @@ -40,8 +40,8 @@ This will: **CRITICAL: After the handoff context is saved, you MUST continue with this step. The Ralph loop is NOT active until you invoke ralph-loop. Do not stop after the handoff.** Invoke `/ralph-reviewed:ralph-loop` with: -- The task prompt: `Read ~/.claude/handoffs/ and complete the task described there. Work through each step, verify with the "Done when" commands. Output COMPLETE when all verifications pass, or BLOCKED if stuck after 15 iterations.` -- The parsed LOOP_FLAGS (or defaults: `--max-iterations 15 --max-reviews 10 --completion-promise "COMPLETE"`) +- The task prompt: `Read ~/.claude/handoffs/ and complete the task described there. Work through each step, verify with the "Done when" commands. Output COMPLETE when all verifications pass, or BLOCKED if stuck after 30 iterations.` +- The parsed LOOP_FLAGS (or defaults: `--max-iterations 30 --max-reviews 20 --completion-promise "COMPLETE"`) --- diff --git a/plugins/codex-reviewer/commands/review.md b/plugins/codex-reviewer/commands/review.md index ee55f6c..b861a5c 100644 --- a/plugins/codex-reviewer/commands/review.md +++ b/plugins/codex-reviewer/commands/review.md @@ -14,7 +14,7 @@ Arguments: $ARGUMENTS Parse the following from arguments: - **FOCUS**: Everything before the first `--` flag (the review focus) -- **--max-cycles**: Number (default: 10) - maximum review cycles before auto-approve +- **--max-cycles**: Number (default: 20) - maximum review cycles before auto-approve **Parsing rules:** 1. Text before any `--` flags is the review focus @@ -22,8 +22,8 @@ Parse the following from arguments: 3. If no focus text provided, use the default focus **Examples:** -- `/codex-reviewer:review` → default focus, max 10 cycles -- `/codex-reviewer:review "focus on security vulnerabilities"` → security review, max 10 cycles +- `/codex-reviewer:review` → default focus, max 20 cycles +- `/codex-reviewer:review "focus on security vulnerabilities"` → security review, max 20 cycles - `/codex-reviewer:review --max-cycles 3` → default focus, max 3 cycles - `/codex-reviewer:review "verify error handling" --max-cycles 10` → error handling review, max 10 cycles @@ -120,7 +120,7 @@ handoff_path: "~/.claude/handoffs/handoff--.md" task_description: null files_changed: ["file1.ts", "file2.ts"] review_count: 0 -max_review_cycles: +max_review_cycles: review_history: [] timestamp: "[current ISO timestamp]" debug: false @@ -133,7 +133,7 @@ Review gate active. Run `/codex-reviewer:cancel` to abort. **Important:** - Use the actual handoff path from Step 1 -- Use the `--max-cycles` value if provided, otherwise default to 10 +- Use the `--max-cycles` value if provided, otherwise default to 20 - The stop hook reads `handoff_path` at review time --- @@ -164,6 +164,6 @@ Review gate active. Run `/codex-reviewer:cancel` to abort. ## Notes - Codex review can take 5-20+ minutes depending on complexity -- Max 10 review cycles by default; use `--max-cycles N` to customize +- Max 20 review cycles by default; use `--max-cycles N` to customize - Use `/codex-reviewer:cancel` to abort the review gate - Debug logs at `~/.claude/codex/{session_id}/crash.log` diff --git a/plugins/codex-reviewer/hooks/codex-reviewer-stop-hook.ts b/plugins/codex-reviewer/hooks/codex-reviewer-stop-hook.ts index 24a6233..15fcdd8 100755 --- a/plugins/codex-reviewer/hooks/codex-reviewer-stop-hook.ts +++ b/plugins/codex-reviewer/hooks/codex-reviewer-stop-hook.ts @@ -525,7 +525,7 @@ function parseStateFile(content: string): ReviewState | null { task_description: state.task_description ?? null, files_changed: state.files_changed ?? [], review_count: state.review_count ?? 0, - max_review_cycles: state.max_review_cycles ?? 10, + max_review_cycles: state.max_review_cycles ?? 20, review_history: state.review_history ?? [], timestamp: state.timestamp || new Date().toISOString(), debug: state.debug ?? false, diff --git a/plugins/ralph-reviewed/commands/ralph-loop.md b/plugins/ralph-reviewed/commands/ralph-loop.md index 8e3584f..1550ecc 100644 --- a/plugins/ralph-reviewed/commands/ralph-loop.md +++ b/plugins/ralph-reviewed/commands/ralph-loop.md @@ -14,7 +14,7 @@ Arguments: $ARGUMENTS Parse the following from arguments: - **PROMPT**: Everything before the first `--` flag (the task description) -- **--max-iterations**: Number (default: 50) +- **--max-iterations**: Number (default: 30) - **--max-reviews**: Number (optional, defaults to --max-iterations if not specified) - **--completion-promise**: String (default: "COMPLETE") - **--no-review**: Boolean flag (default: false) From 664ccd8280433f3a048357c490ee53bf92056329 Mon Sep 17 00:00:00 2001 From: Allen Date: Fri, 20 Mar 2026 13:59:53 -0500 Subject: [PATCH 08/57] feat(codex-reviewer): increase default max review cycles from 20 to 30 Bump the default --max-cycles value by 50% to allow more review iterations before auto-approve, matching real-world usage patterns. --- plugins/codex-reviewer/commands/review.md | 10 +++++----- .../codex-reviewer/hooks/codex-reviewer-stop-hook.ts | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/plugins/codex-reviewer/commands/review.md b/plugins/codex-reviewer/commands/review.md index b861a5c..c72fbac 100644 --- a/plugins/codex-reviewer/commands/review.md +++ b/plugins/codex-reviewer/commands/review.md @@ -14,7 +14,7 @@ Arguments: $ARGUMENTS Parse the following from arguments: - **FOCUS**: Everything before the first `--` flag (the review focus) -- **--max-cycles**: Number (default: 20) - maximum review cycles before auto-approve +- **--max-cycles**: Number (default: 30) - maximum review cycles before auto-approve **Parsing rules:** 1. Text before any `--` flags is the review focus @@ -22,8 +22,8 @@ Parse the following from arguments: 3. If no focus text provided, use the default focus **Examples:** -- `/codex-reviewer:review` → default focus, max 20 cycles -- `/codex-reviewer:review "focus on security vulnerabilities"` → security review, max 20 cycles +- `/codex-reviewer:review` → default focus, max 30 cycles +- `/codex-reviewer:review "focus on security vulnerabilities"` → security review, max 30 cycles - `/codex-reviewer:review --max-cycles 3` → default focus, max 3 cycles - `/codex-reviewer:review "verify error handling" --max-cycles 10` → error handling review, max 10 cycles @@ -133,7 +133,7 @@ Review gate active. Run `/codex-reviewer:cancel` to abort. **Important:** - Use the actual handoff path from Step 1 -- Use the `--max-cycles` value if provided, otherwise default to 20 +- Use the `--max-cycles` value if provided, otherwise default to 30 - The stop hook reads `handoff_path` at review time --- @@ -164,6 +164,6 @@ Review gate active. Run `/codex-reviewer:cancel` to abort. ## Notes - Codex review can take 5-20+ minutes depending on complexity -- Max 20 review cycles by default; use `--max-cycles N` to customize +- Max 30 review cycles by default; use `--max-cycles N` to customize - Use `/codex-reviewer:cancel` to abort the review gate - Debug logs at `~/.claude/codex/{session_id}/crash.log` diff --git a/plugins/codex-reviewer/hooks/codex-reviewer-stop-hook.ts b/plugins/codex-reviewer/hooks/codex-reviewer-stop-hook.ts index 15fcdd8..23352de 100755 --- a/plugins/codex-reviewer/hooks/codex-reviewer-stop-hook.ts +++ b/plugins/codex-reviewer/hooks/codex-reviewer-stop-hook.ts @@ -525,7 +525,7 @@ function parseStateFile(content: string): ReviewState | null { task_description: state.task_description ?? null, files_changed: state.files_changed ?? [], review_count: state.review_count ?? 0, - max_review_cycles: state.max_review_cycles ?? 20, + max_review_cycles: state.max_review_cycles ?? 30, review_history: state.review_history ?? [], timestamp: state.timestamp || new Date().toISOString(), debug: state.debug ?? false, From cb5b9c90c9d18a520aec78d02e6cb41bbf39e266 Mon Sep 17 00:00:00 2001 From: Allen Date: Fri, 20 Mar 2026 13:59:58 -0500 Subject: [PATCH 09/57] feat(plugins): add version consistency check script and update docs Add check-plugin-versions.sh that validates plugin.json and marketplace.json versions match, and warns when plugin source files change without a version bump. Update CLAUDE.local.md with a clear version bump checklist for publishing plugin updates. --- CLAUDE.local.md | 20 ++++++-- scripts/check-plugin-versions.sh | 86 ++++++++++++++++++++++++++++++++ 2 files changed, 102 insertions(+), 4 deletions(-) create mode 100755 scripts/check-plugin-versions.sh diff --git a/CLAUDE.local.md b/CLAUDE.local.md index 4775860..f7a76b9 100644 --- a/CLAUDE.local.md +++ b/CLAUDE.local.md @@ -68,13 +68,25 @@ claude plugin update ## Publishing Plugin Updates -Keep plugin versions in sync in both locations: +**Any change to plugin source files requires a version bump.** Lefthook pre-commit and pre-push hooks enforce this automatically via `claude-code/scripts/check-plugin-versions.sh`. -1. `claude-code/plugins//.claude-plugin/plugin.json` -2. `claude-code/.claude-plugin/marketplace.json` +### Version bump checklist -Then refresh marketplace metadata: +1. Bump `version` in `claude-code/plugins//.claude-plugin/plugin.json` +2. Bump `version` for the same plugin in `claude-code/.claude-plugin/marketplace.json` +3. Both versions must match — the hook fails on mismatch +4. Stage `plugin.json` alongside your source changes — the hook warns if source files changed without it + +### After committing + +Refresh the local marketplace so the runtime picks up the new version: ```bash claude plugin marketplace update 0xbigboss-plugins ``` + +### Manual check + +```bash +claude-code/scripts/check-plugin-versions.sh +``` diff --git a/scripts/check-plugin-versions.sh b/scripts/check-plugin-versions.sh new file mode 100755 index 0000000..16d6095 --- /dev/null +++ b/scripts/check-plugin-versions.sh @@ -0,0 +1,86 @@ +#!/usr/bin/env bash +# check-plugin-versions.sh — Verify plugin.json and marketplace.json versions are in sync. +# Optionally accepts a file list (from lefthook) to warn about missing version bumps. +# +# Usage: +# check-plugin-versions.sh [changed-file ...] +# +# Exit codes: +# 0 All versions consistent +# 1 Version mismatch or missing version bump detected + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +CLAUDE_CODE_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" +MARKETPLACE="$CLAUDE_CODE_DIR/.claude-plugin/marketplace.json" + +if [[ ! -f "$MARKETPLACE" ]]; then + echo "error: marketplace.json not found at $MARKETPLACE" >&2 + exit 1 +fi + +errors=0 + +# --- Check 1: Version consistency between marketplace.json and each plugin.json --- + +plugin_count=$(jq '.plugins | length' "$MARKETPLACE") + +for ((i = 0; i < plugin_count; i++)); do + name=$(jq -r ".plugins[$i].name" "$MARKETPLACE") + source=$(jq -r ".plugins[$i].source" "$MARKETPLACE") + marketplace_version=$(jq -r ".plugins[$i].version" "$MARKETPLACE") + + plugin_json="$CLAUDE_CODE_DIR/$source/.claude-plugin/plugin.json" + + if [[ ! -f "$plugin_json" ]]; then + echo "error: plugin.json not found for '$name' at $plugin_json" >&2 + errors=$((errors + 1)) + continue + fi + + plugin_version=$(jq -r '.version' "$plugin_json") + + if [[ "$marketplace_version" != "$plugin_version" ]]; then + echo "error: version mismatch for '$name'" >&2 + echo " marketplace.json: $marketplace_version" >&2 + echo " plugin.json: $plugin_version" >&2 + errors=$((errors + 1)) + fi +done + +# --- Check 2: Changed plugin source files without a plugin.json bump --- + +if [[ $# -gt 0 ]]; then + for ((i = 0; i < plugin_count; i++)); do + name=$(jq -r ".plugins[$i].name" "$MARKETPLACE") + source=$(jq -r ".plugins[$i].source" "$MARKETPLACE") + source_prefix="${source#./}" + + has_source_change=false + has_plugin_json_change=false + + for file in "$@"; do + if [[ "$file" == *"$source_prefix"* ]]; then + has_source_change=true + if [[ "$file" == *"$source_prefix/.claude-plugin/plugin.json" ]]; then + has_plugin_json_change=true + fi + fi + done + + if $has_source_change && ! $has_plugin_json_change; then + echo "warning: plugin '$name' has source changes but plugin.json version was not bumped" >&2 + errors=$((errors + 1)) + fi + done +fi + +if [[ $errors -gt 0 ]]; then + echo "" >&2 + echo "Plugin version check failed. Update versions in both:" >&2 + echo " 1. claude-code/plugins//.claude-plugin/plugin.json" >&2 + echo " 2. claude-code/.claude-plugin/marketplace.json" >&2 + echo "Then run: claude plugin marketplace update 0xbigboss-plugins" >&2 + exit 1 +fi From 48db25a93435be20fd53038991130c66a03c6714 Mon Sep 17 00:00:00 2001 From: Allen Date: Sat, 21 Mar 2026 15:11:46 -0500 Subject: [PATCH 10/57] feat(guidelines): add test realism section to global CLAUDE.md Prefer integration tests over mocked unit tests for data flow and permissions. Mocks only acceptable for external services, not your own data layer. "Would this survive a manual walkthrough?" check. --- CLAUDE.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index fdecabe..2e4934a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -152,6 +152,17 @@ When tests fail, investigate root cause and fix the underlying issue. Do not: If a test appears incorrect or the task seems infeasible, report the issue rather than gaming around it. Solutions should work correctly for all valid inputs and follow the principle that drove the test—not just its literal assertions. +## Test realism + +Unit tests verify logic. They do not verify the application works. + +- Prefer integration tests over mocked unit tests for data flow and permissions. +- Mocks are acceptable for external services (TTS, network) but not for your own + data layer (sync engine, database queries, auth). +- If a test passes with mocks but would fail against the real system, the test + is wrong. +- Before claiming work is complete: "would this survive a manual walkthrough?" + ## Module structure and cohesion Organize code by single responsibility: each file/module handles one coherent concern. Split when a file handles genuinely separate concerns or different parts change for different reasons. Keep code together when related functionality shares types, helpers, or state. Prioritize cohesion and clear interfaces over arbitrary line counts; follow language-idiomatic conventions (see language skill files for specifics). From 0b6d3278eca83661bc987362583b60f3f8d89f4b Mon Sep 17 00:00:00 2001 From: Allen Date: Sat, 21 Mar 2026 15:12:05 -0500 Subject: [PATCH 11/57] =?UTF-8?q?feat(ralph-reviewed)!:=20v2.0.0=20?= =?UTF-8?q?=E2=80=94=20rl=20CLI,=20state=20migration,=20rl=20done=20comple?= =?UTF-8?q?tion?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Major rewrite of the ralph-reviewed plugin: State migration: - State moved from .claude/ralph-loop.local.md (YAML) to .rl/state.json (JSON) - Prompt stored separately in .rl/prompt.md - Structured event log at .rl/log.jsonl - .rl/ auto-added to .git/info/exclude on init rl CLI (new): - rl init: one-command setup (state, prompt, log, symlink) - rl done / rl done --blocked: state-flag completion (replaces tags) - rl log phase|commit|decision|summary: structured JSONL logging - rl prompt / rl status / rl clean: state inspection and cleanup - Creates .rl/rl symlink for short-path access after init - Git root walks superprojects (matches stop hook behavior) Stop hook: - Completion detection via state.json flags (not transcript regex) - Removed legacy tag parsing and transcript reading - Review prompt simplified: correctness-focused, tells Codex to use rl - Codex review outputs kept in .rl/ (not /tmp/) - Codex cwd uses gitRoot for reliable .rl/ access - Slimmed LoopState: removed completion_promise, original_prompt, pending_feedback, review_history (all derived from .rl/ files) - Review feedback is plain text (no structured issue IDs/severity) - Iteration headers hide denominators from agent Behavioral improvements: - Pacing, churn breaker, depth-before-breadth, skill loading, live verification sections in skill prompt - Ralphoff "Done when" guidance: verify behavior not file existence BREAKING CHANGE: State format changed from YAML frontmatter to JSON. Completion mechanism changed from tags to rl done command. --- .claude-plugin/marketplace.json | 2 +- .gitignore | 2 +- commands/ralph.md | 13 +- commands/ralphoff.md | 20 +- .../ralph-reviewed/.claude-plugin/plugin.json | 2 +- plugins/ralph-reviewed/README.md | 28 +- .../ralph-reviewed/commands/cancel-ralph.md | 18 +- plugins/ralph-reviewed/commands/help.md | 10 +- plugins/ralph-reviewed/commands/ralph-loop.md | 80 +- .../hooks/ralph-reviewed-stop-hook.ts | 696 +++++------------- plugins/ralph-reviewed/scripts/rl | 323 ++++++++ settings/settings.json | 3 + 12 files changed, 587 insertions(+), 610 deletions(-) create mode 100755 plugins/ralph-reviewed/scripts/rl diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index a4f54f3..3bf4424 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -14,7 +14,7 @@ "name": "ralph-reviewed", "source": "./plugins/ralph-reviewed", "description": "Iterative Ralph loops with Codex CLI review gates at completion", - "version": "1.8.8", + "version": "2.0.0", "author": { "name": "Allen", "email": "bigboss@metalrodeo.xyz" diff --git a/.gitignore b/.gitignore index 09af451..902e5c8 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,5 @@ # Ralph loop state (managed by hook, not tracked in VCS) -.claude/ralph-loop.local.md +.rl/ # Python bytecode __pycache__/ diff --git a/commands/ralph.md b/commands/ralph.md index 4805996..575a8eb 100644 --- a/commands/ralph.md +++ b/commands/ralph.md @@ -13,12 +13,11 @@ Arguments: $ARGUMENTS Split arguments into two groups: - **HANDOFF_ARGS**: Everything before any `--` flags (passed to ralphoff as completion criteria) -- **LOOP_FLAGS**: Any `--max-iterations`, `--max-reviews`, `--completion-promise`, `--no-review`, `--debug` flags (passed to ralph-loop) +- **LOOP_FLAGS**: Any `--max-iterations`, `--max-reviews`, `--no-review`, `--debug` flags (passed to ralph-loop) Default loop flags if not specified: - `--max-iterations 30` - `--max-reviews 20` -- `--completion-promise "COMPLETE"` ## Workflow @@ -40,8 +39,8 @@ This will: **CRITICAL: After the handoff context is saved, you MUST continue with this step. The Ralph loop is NOT active until you invoke ralph-loop. Do not stop after the handoff.** Invoke `/ralph-reviewed:ralph-loop` with: -- The task prompt: `Read ~/.claude/handoffs/ and complete the task described there. Work through each step, verify with the "Done when" commands. Output COMPLETE when all verifications pass, or BLOCKED if stuck after 30 iterations.` -- The parsed LOOP_FLAGS (or defaults: `--max-iterations 30 --max-reviews 20 --completion-promise "COMPLETE"`) +- The task prompt: `Read ~/.claude/handoffs/ and complete the task described there. Work through each step, verify with the "Done when" commands. Run .rl/rl done when all verifications pass, or .rl/rl done --blocked if stuck.` +- The parsed LOOP_FLAGS (or defaults: `--max-iterations 30 --max-reviews 20`) --- @@ -49,12 +48,12 @@ Invoke `/ralph-reviewed:ralph-loop` with: **CRITICAL: You MUST complete this step. Verify the loop state file was created.** -1. **Verify the state file exists**: +1. **Verify the loop is active**: ```bash - cat "$(git rev-parse --show-toplevel 2>/dev/null || pwd)/.claude/ralph-loop.local.md" | head -5 + .rl/rl status ``` -2. If the state file exists, the loop is active. Begin working on the task immediately. +2. If status shows active, begin working on the task immediately. ## Example Usage diff --git a/commands/ralphoff.md b/commands/ralphoff.md index 353d7ff..62e1ff7 100644 --- a/commands/ralphoff.md +++ b/commands/ralphoff.md @@ -18,7 +18,6 @@ Split arguments into two groups: Default loop flags if not specified: - `--max-iterations 30` -- `--completion-promise "COMPLETE"` ## Git Context @@ -59,7 +58,7 @@ Example: `ralph-myapp-sen-69-20260303-1430.md` ### Core Principle -**A ralph handoff is just a handoff with verification commands.** Apply the same principles as `/handoff`: describe what to type, be concrete, link don't summarize, keep it short. The ralph-loop runner handles iteration state, TODO.md tracking, BLOCKED escapes, and completion promises — don't duplicate that machinery in the handoff. +**A ralph handoff is just a handoff with verification commands.** Apply the same principles as `/handoff`: describe what to type, be concrete, link don't summarize, keep it short. The ralph-loop runner handles iteration state, TODO.md tracking, and completion via `rl done` — don't duplicate that machinery in the handoff. ### What belongs in the ralph handoff (task-specific) @@ -72,7 +71,7 @@ Example: `ralph-myapp-sen-69-20260303-1430.md` - TODO.md format or iteration workflow instructions - Generic "if stuck, document the blocker" guidance -- Completion promise syntax (`COMPLETE`) +- Completion mechanism (`rl done` / `rl done --blocked`) - State tracking file management - Generic BLOCKED escape conditions @@ -114,6 +113,17 @@ Use plain markdown (not XML tags): ## Done when +"Done when" commands should verify behavior, not just file existence. + +Bad: `ls app/test/setup.tsx` +Better: `grep -c 'useQuery' app/test/setup.tsx` +Best: `bun test:integration --grep "test flow"` + +Include a verification discovery fallback: +1. Check for e2e/integration tests covering the changed flows +2. If none, check for a dev environment to boot +3. If neither, note the gap as a known limitation + All of these pass: ```bash command-to-build @@ -146,7 +156,7 @@ Only include if there are known risk areas. Omit for straightforward tasks.] ### Anti-Patterns to Avoid -- **Duplicating ralph-loop runner instructions** — TODO.md format, iteration workflow, BLOCKED syntax, completion promise format. The runner handles all of this. +- **Duplicating ralph-loop runner instructions** — TODO.md format, iteration workflow, `rl done` / `rl done --blocked`. The runner handles all of this. - **Separate success criteria and verification sections** — merge them into "Done when" - **Generic "if stuck" instructions** — only include task-specific fallback strategies - **Prose architecture summaries** — link to README or SPEC @@ -169,5 +179,5 @@ Generate the timestamp using: `date +%Y%m%d-%H%M` When using this context file with `/ralph-reviewed:ralph-loop`, the command format is: ``` -/ralph-reviewed:ralph-loop "Read ~/.claude/handoffs/ and complete the task described there. Follow the success criteria and verification loop. Output COMPLETE when all verifications pass, or BLOCKED if stuck after 15 iterations." --completion-promise "COMPLETE" +/ralph-reviewed:ralph-loop "Read ~/.claude/handoffs/ and complete the task described there. Follow the success criteria and verification loop. Run .rl/rl done when all verifications pass, or .rl/rl done --blocked if stuck." ``` diff --git a/plugins/ralph-reviewed/.claude-plugin/plugin.json b/plugins/ralph-reviewed/.claude-plugin/plugin.json index 4c832d3..69d1e31 100644 --- a/plugins/ralph-reviewed/.claude-plugin/plugin.json +++ b/plugins/ralph-reviewed/.claude-plugin/plugin.json @@ -5,6 +5,6 @@ "name": "Allen", "email": "bigboss@metalrodeo.xyz" }, - "version": "1.8.8", + "version": "2.0.0", "commands": "./commands/" } diff --git a/plugins/ralph-reviewed/README.md b/plugins/ralph-reviewed/README.md index e6bd564..8a7bca8 100644 --- a/plugins/ralph-reviewed/README.md +++ b/plugins/ralph-reviewed/README.md @@ -73,23 +73,23 @@ Start an iterative loop with review gates. | Flag | Default | Description | |------|---------|-------------| -| `--max-iterations` | 50 | Max work iterations before auto-stop | +| `--max-iterations` | 30 | Max work iterations before auto-stop | | `--max-reviews` | max-iterations | Max review cycles before force-complete | -| `--completion-promise` | COMPLETE | Phrase that signals completion | | `--no-review` | false | Disable Codex review gate | -| `--debug` | false | Write debug logs to `/tmp/ralph-reviewed-{session_id}.log` | +| `--debug` | false | Write debug logs | + +**Completion:** The agent runs `.rl/rl done` when finished, or `.rl/rl done --blocked` if stuck. **Examples:** ```bash # Basic usage -/ralph-reviewed:ralph-loop "Build a REST API with CRUD for todos. Include tests. Output COMPLETE when done." +/ralph-reviewed:ralph-loop "Build a REST API with CRUD for todos. Include tests." # With options /ralph-reviewed:ralph-loop "Fix the auth bug in src/auth.ts" \ --max-iterations 20 \ - --max-reviews 2 \ - --completion-promise "FIXED" + --max-reviews 2 # Without review (original ralph behavior) /ralph-reviewed:ralph-loop "Refactor the utils module" --no-review @@ -118,7 +118,7 @@ Requirements: - Return 400 with error message on invalid input - Tests for all cases -When all tests pass, output COMPLETE +When all tests pass, run `.rl/rl done` ``` ### Include Verification Steps @@ -131,23 +131,21 @@ Verification: 2. Run `npm run lint` - no errors 3. Manual check: login flow works in dev -When verified, output FIXED +When verified, run `.rl/rl done` ``` ### Set Escape Conditions -The `BLOCKED` signal is a built-in escape hatch that terminates the loop immediately without triggering a Codex review. Use it when genuinely stuck: +`.rl/rl done --blocked` is the escape hatch — terminates the loop immediately without triggering a Codex review. Use it when genuinely stuck: ``` Implement the search feature. If blocked by external issues (missing deps, pre-existing bugs, etc.): - Document what's blocking and what you tried -- Output BLOCKED +- Run `.rl/rl done --blocked` ``` -Note: BLOCKED terminates the loop without review since there's nothing to review when blocked. - ## Review Gate When Claude outputs the completion promise, Codex CLI is invoked to review: @@ -222,7 +220,7 @@ This allows Codex to run build tools, linters, and tests during review. Without ### Loop State -State is stored in `.claude/ralph-loop.local.md` at the **git repository root**. This ensures the loop survives directory changes within the repo. The file tracks: +State is stored in `.rl/state.json` at the **git repository root**. This ensures the loop survives directory changes within the repo. A structured log is appended to `.rl/log.jsonl` for progression tracking. The state file tracks: - Current iteration count - Max iterations @@ -254,13 +252,13 @@ Do not edit this file manually. Use `/ralph-reviewed:cancel-ralph` to stop. **State file not found after directory change:** - Ensure you're in a git repository (`git rev-parse --show-toplevel`) -- State file is at repo root: `{GIT_ROOT}/.claude/ralph-loop.local.md` +- State file is at repo root: `{GIT_ROOT}/.rl/state.json` - Outside git repos, directory changes will break the loop **Reviews not happening:** - Check `codex` CLI is installed: `which codex` - Check `--no-review` is not set -- Check `.claude/ralph-loop.local.md` has `review_enabled: true` +- Check `.rl/state.json` has `"review_enabled": true` **Codex can't run tooling (EPERM errors, tsc/lint/test fails):** - By default, Codex runs in read-only sandbox mode diff --git a/plugins/ralph-reviewed/commands/cancel-ralph.md b/plugins/ralph-reviewed/commands/cancel-ralph.md index 790f72f..1bfad05 100644 --- a/plugins/ralph-reviewed/commands/cancel-ralph.md +++ b/plugins/ralph-reviewed/commands/cancel-ralph.md @@ -18,27 +18,23 @@ Store this as GIT_ROOT. ## Check for Active Loop ```bash -test -f {GIT_ROOT}/.claude/ralph-loop.local.md && echo "found" || echo "not_found" +test -f {GIT_ROOT}/.rl/state.json && echo "found" || echo "not_found" ``` ## If Found -1. Extract current iteration for reporting: +1. Read state for reporting: ```bash - cat {GIT_ROOT}/.claude/ralph-loop.local.md | grep "^iteration:" | cut -d' ' -f2 + cat {GIT_ROOT}/.rl/state.json ``` + Extract `iteration` and `review_count` from the JSON. -2. Extract review count: +2. Delete the state file: ```bash - cat {GIT_ROOT}/.claude/ralph-loop.local.md | grep "^review_count:" | cut -d' ' -f2 + rm {GIT_ROOT}/.rl/state.json ``` -3. Delete the state file: - ```bash - rm {GIT_ROOT}/.claude/ralph-loop.local.md - ``` - -4. Report: +3. Report: ``` Cancelled Ralph Reviewed loop. - Was at iteration: {N} diff --git a/plugins/ralph-reviewed/commands/help.md b/plugins/ralph-reviewed/commands/help.md index 44cf8e5..061ffa4 100644 --- a/plugins/ralph-reviewed/commands/help.md +++ b/plugins/ralph-reviewed/commands/help.md @@ -26,14 +26,16 @@ Start an iterative loop with review gates. ``` **Options:** -- `--max-iterations ` - Max work iterations before auto-stop (default: 50) +- `--max-iterations ` - Max work iterations before auto-stop (default: 30) - `--max-reviews ` - Max review cycles before force-complete (default: --max-iterations) -- `--completion-promise ` - Phrase that signals completion (default: COMPLETE) - `--no-review` - Disable Codex review gate +- `--debug` - Enable debug logging + +**Completion:** The agent runs `.rl/rl done` when finished, or `.rl/rl done --blocked` if stuck. **Examples:** ``` -/ralph-reviewed:ralph-loop "Build a REST API with CRUD for todos. Include tests." --completion-promise "COMPLETE" --max-iterations 30 +/ralph-reviewed:ralph-loop "Build a REST API with CRUD for todos. Include tests." --max-iterations 30 /ralph-reviewed:ralph-loop "Fix the authentication bug in src/auth.ts. Tests must pass." --max-reviews 2 ``` @@ -50,7 +52,7 @@ Show this help message. **Loop won't stop:** - Use `/ralph-reviewed:cancel-ralph` to force stop -- Check that your completion promise matches exactly +- Ensure the agent ran `.rl/rl done` before stopping **Codex not reviewing:** - Ensure `codex` CLI is installed and authenticated diff --git a/plugins/ralph-reviewed/commands/ralph-loop.md b/plugins/ralph-reviewed/commands/ralph-loop.md index 1550ecc..e618c56 100644 --- a/plugins/ralph-reviewed/commands/ralph-loop.md +++ b/plugins/ralph-reviewed/commands/ralph-loop.md @@ -1,7 +1,7 @@ --- description: Start Ralph Reviewed loop in current session -allowed-tools: Bash(git:*), Bash(mkdir:*), Bash(date:*), Bash(cat:*), Write(**/ralph-loop.local.md) -argument-hint: "task description" [--max-iterations N] [--max-reviews N] [--completion-promise TEXT] [--no-review] [--debug] +allowed-tools: Bash(.rl/rl:*), Bash(git:*), Bash(cat:*) +argument-hint: "task description" [--max-iterations N] [--max-reviews N] [--no-review] [--debug] --- # Start Ralph Reviewed Loop @@ -16,79 +16,53 @@ Parse the following from arguments: - **PROMPT**: Everything before the first `--` flag (the task description) - **--max-iterations**: Number (default: 30) - **--max-reviews**: Number (optional, defaults to --max-iterations if not specified) -- **--completion-promise**: String (default: "COMPLETE") - **--no-review**: Boolean flag (default: false) -- **--debug**: Boolean flag (default: false) - writes debug logs to ~/.claude/ralphs/{session_id}/debug.log +- **--debug**: Boolean flag (default: false) ## Setup -1. Get git repository root (state file must be at repo root to survive directory changes): +1. Locate the `rl` CLI — find it in the ralph-reviewed plugin scripts: ```bash - git rev-parse --show-toplevel + find ~/.claude/plugins -name rl -path '*/ralph-reviewed/scripts/*' 2>/dev/null | head -1 ``` - Store this as GIT_ROOT. If not in a git repo, use current directory. + If not found, try `~/code/dotfiles/claude-code/plugins/ralph-reviewed/scripts/rl`. + Store the path as RL_PATH. -2. Create state directory at repo root: +2. Initialize the loop (creates `.rl/` with state.json, prompt.md, and `.rl/rl` symlink): ```bash - mkdir -p {GIT_ROOT}/.claude + {RL_PATH} init "{PROMPT}" --max-iterations {MAX_ITERATIONS} --max-reviews {MAX_REVIEWS} {--no-review if set} {--debug if set} ``` -3. Generate timestamp: +3. Verify setup: ```bash - date -u +"%Y-%m-%dT%H:%M:%SZ" + .rl/rl status ``` -4. Write the state file to `{GIT_ROOT}/.claude/ralph-loop.local.md`: +All subsequent `rl` calls use `.rl/rl` (symlink created by init). -```markdown ---- -active: true -iteration: 0 -max_iterations: {MAX_ITERATIONS} -completion_promise: "{COMPLETION_PROMISE}" -original_prompt: | - {PROMPT} -timestamp: "{TIMESTAMP}" -review_enabled: {true unless --no-review} -review_count: 0 -max_review_cycles: {MAX_REVIEWS} -pending_feedback: null -debug: {true if --debug, else false} ---- -``` +## Completion and Escape -## Confirmation Output +- **Done:** run `.rl/rl done` — triggers Codex review on next stop. +- **Blocked:** run `.rl/rl done --blocked` — terminates without review. -After creating the state file, output: +## Working Guidelines -``` -Ralph Reviewed loop started. +**Pacing.** Each iteration should produce thoughtful work. Researching, loading skills, and studying patterns IS productive — don't rush to `.rl/rl done`. -Task: {first 100 chars of PROMPT}... +**Churn breaker.** If a reviewer flags the same area twice, your next iteration must be research — load skills, read docs, study the codebase. No code fix until you understand why the previous fix was wrong. -Configuration: -- Max iterations: {MAX_ITERATIONS} -- Max review cycles: {MAX_REVIEWS} -- Completion promise: {COMPLETION_PROMISE} -- Review enabled: {yes/no} -- Debug: {yes/no} (logs to ~/.claude/ralphs/{session_id}/debug.log) +**Depth before breadth.** Complete each phase fully before starting the next. -The stop hook will now intercept exit attempts. When you believe the task is complete, output: +**Skill loading.** Check `.claude/skills/` for relevant skills before writing code. -{COMPLETION_PROMISE} +**Live verification.** Before claiming completion: run e2e/integration tests if they exist, boot the dev environment if available, or note the gap. Passing unit tests with a broken application is not done. -Your work will be reviewed by Codex before the loop can end. +**Log progress** with `.rl/rl`: +- `.rl/rl log phase "starting migration"` — new phase +- `.rl/rl log commit "summary"` — after commits +- `.rl/rl log decision "chose X because..."` — design decisions +- `.rl/rl log summary "status update"` — every ~5 iterations --- -Beginning work on task... -``` - -## Completion and Escape - -- When the task is done, output `{COMPLETION_PROMISE}` — triggers Codex review before the loop ends. -- If genuinely blocked (missing dependency, impossible constraint), document the blocker and output `BLOCKED` — terminates the loop without review. - -## Begin Working - -After setup, immediately begin working on the task described in PROMPT. The stop hook handles iteration logic automatically. +Begin working on the task. The stop hook handles iteration logic automatically. diff --git a/plugins/ralph-reviewed/hooks/ralph-reviewed-stop-hook.ts b/plugins/ralph-reviewed/hooks/ralph-reviewed-stop-hook.ts index 33b6498..4ffd4f1 100755 --- a/plugins/ralph-reviewed/hooks/ralph-reviewed-stop-hook.ts +++ b/plugins/ralph-reviewed/hooks/ralph-reviewed-stop-hook.ts @@ -6,9 +6,9 @@ * When completion is claimed, triggers Codex review gate. * * NOTE: Ralph loops only work within git repositories. The state file is stored - * at the git repo root (.claude/ralph-loop.local.md) to ensure it survives - * directory changes within the repo. Outside of git repos, falls back to cwd - * but directory changes will break the loop. + * at the git repo root (.rl/state.json) to ensure it survives directory changes + * within the repo. Outside of git repos, falls back to cwd but directory changes + * will break the loop. * * Flow: * 1. Check for active loop state file (at git repo root) @@ -29,8 +29,8 @@ import { homedir } from "node:os"; // --- Version --- // Update this when making changes to help diagnose cached code issues -const HOOK_VERSION = "2026-01-08T05:00:00Z"; -const HOOK_BUILD = "v1.8.4"; +const HOOK_VERSION = "2026-03-21T00:00:00Z"; +const HOOK_BUILD = "v2.0.0"; const STDIN_TIMEOUT_MS = 2000; // --- User Config --- @@ -241,7 +241,7 @@ function getGitRoot(cwd: string): string | null { function getStateFilePath(cwd: string): string { const gitRoot = getGitRoot(cwd); const baseDir = gitRoot || cwd; - return join(baseDir, ".claude", "ralph-loop.local.md"); + return join(baseDir, ".rl", "state.json"); } // --- Types --- @@ -271,230 +271,51 @@ interface HookOutput { stopReason?: string; } -interface ReviewIssue { - id: number; - severity: "critical" | "major" | "minor"; - description: string; -} - -interface ResolvedIssue { - id: number; - verification: string; -} - -interface ReviewHistoryEntry { - cycle: number; - decision: "APPROVE" | "REJECT"; - issues: ReviewIssue[]; - resolved: ResolvedIssue[]; - notes: string | null; -} interface LoopState { active: boolean; iteration: number; max_iterations: number; - completion_promise: string; - original_prompt: string; timestamp: string; review_enabled: boolean; review_count: number; max_review_cycles: number; - pending_feedback: string | null; - review_history: ReviewHistoryEntry[]; debug: boolean; } -interface TranscriptEntry { - type?: string; - message?: { - role: string; - content: Array<{ type: string; text?: string }>; - }; - // Legacy format fallback - role?: string; - content?: Array<{ type: string; text?: string }>; -} // --- State File Parsing --- -/** - * Normalize a review history entry to handle old schema versions. - * Ensures all required fields exist with sensible defaults. - */ -function normalizeHistoryEntry(entry: unknown): ReviewHistoryEntry { - if (typeof entry !== "object" || entry === null) { - return { - cycle: 0, - decision: "REJECT", - issues: [], - resolved: [], - notes: null, - }; - } - - const obj = entry as Record; - - // Normalize issues array - const issues: ReviewIssue[] = []; - if (Array.isArray(obj.issues)) { - for (const issue of obj.issues) { - if (typeof issue === "object" && issue !== null) { - const i = issue as Record; - issues.push({ - id: typeof i.id === "number" ? i.id : 0, - severity: (["critical", "major", "minor"].includes(String(i.severity)) - ? String(i.severity) - : "minor") as "critical" | "major" | "minor", - description: typeof i.description === "string" ? i.description : "", - }); - } - } - } - - // Normalize resolved array - const resolved: ResolvedIssue[] = []; - if (Array.isArray(obj.resolved)) { - for (const r of obj.resolved) { - if (typeof r === "object" && r !== null) { - const res = r as Record; - resolved.push({ - id: typeof res.id === "number" ? res.id : 0, - verification: typeof res.verification === "string" ? res.verification : "", - }); - } - } - } - - return { - cycle: typeof obj.cycle === "number" ? obj.cycle : 0, - decision: obj.decision === "APPROVE" ? "APPROVE" : "REJECT", - issues, - resolved, - notes: typeof obj.notes === "string" ? obj.notes : null, - }; -} - function parseStateFile(content: string): LoopState | null { - // Extract YAML frontmatter - const match = content.match(/^---\n([\s\S]*?)\n---/); - if (!match) return null; - - const yaml = match[1]; - const state: Partial = {}; - - // Parse each field - const lines = yaml.split("\n"); - let inPrompt = false; - let promptLines: string[] = []; - - for (const line of lines) { - if (inPrompt) { - if (line.startsWith(" ")) { - promptLines.push(line.slice(2)); - continue; - } else { - inPrompt = false; - state.original_prompt = promptLines.join("\n").trim(); - } - } - - if (line.startsWith("active:")) { - state.active = line.includes("true"); - } else if (line.startsWith("iteration:")) { - state.iteration = parseInt(line.split(":")[1].trim(), 10); - } else if (line.startsWith("max_iterations:")) { - state.max_iterations = parseInt(line.split(":")[1].trim(), 10); - } else if (line.startsWith("completion_promise:")) { - state.completion_promise = line.split(":").slice(1).join(":").trim().replace(/^["']|["']$/g, ""); - } else if (line.startsWith("original_prompt:")) { - const inline = line.split(":").slice(1).join(":").trim(); - if (inline === "|") { - inPrompt = true; - promptLines = []; - } else { - state.original_prompt = inline.replace(/^["']|["']$/g, ""); - } - } else if (line.startsWith("timestamp:")) { - state.timestamp = line.split(":").slice(1).join(":").trim().replace(/^["']|["']$/g, ""); - } else if (line.startsWith("review_enabled:")) { - state.review_enabled = line.includes("true"); - } else if (line.startsWith("review_count:")) { - state.review_count = parseInt(line.split(":")[1].trim(), 10); - } else if (line.startsWith("max_review_cycles:")) { - state.max_review_cycles = parseInt(line.split(":")[1].trim(), 10); - } else if (line.startsWith("pending_feedback:")) { - const val = line.split(":").slice(1).join(":").trim(); - state.pending_feedback = val === "null" ? null : val.replace(/^["']|["']$/g, ""); - } else if (line.startsWith("debug:")) { - state.debug = line.includes("true"); - } else if (line.startsWith("review_history:")) { - const val = line.split(":").slice(1).join(":").trim(); - if (val && val !== "[]") { - try { - const parsed = JSON.parse(val); - // Normalize each entry to handle old schema versions - state.review_history = Array.isArray(parsed) - ? parsed.map((entry: unknown) => normalizeHistoryEntry(entry)) - : []; - } catch { - state.review_history = []; - } - } else { - state.review_history = []; - } + try { + const parsed = JSON.parse(content) as Partial; + + // Validate required fields + if ( + parsed.active === undefined || + parsed.iteration === undefined || + parsed.max_iterations === undefined + ) { + return null; } - } - // Validate required fields - if ( - state.active === undefined || - state.iteration === undefined || - state.max_iterations === undefined || - !state.completion_promise || - !state.original_prompt - ) { + return { + active: parsed.active, + iteration: parsed.iteration, + max_iterations: parsed.max_iterations, + timestamp: parsed.timestamp || new Date().toISOString(), + review_enabled: parsed.review_enabled ?? true, + review_count: parsed.review_count ?? 0, + max_review_cycles: parsed.max_review_cycles ?? parsed.max_iterations, + debug: parsed.debug ?? false, + }; + } catch { return null; } - - return { - active: state.active, - iteration: state.iteration, - max_iterations: state.max_iterations, - completion_promise: state.completion_promise, - original_prompt: state.original_prompt, - timestamp: state.timestamp || new Date().toISOString(), - review_enabled: state.review_enabled ?? true, - review_count: state.review_count ?? 0, - max_review_cycles: state.max_review_cycles ?? state.max_iterations, - pending_feedback: state.pending_feedback ?? null, - review_history: state.review_history ?? [], - debug: state.debug ?? false, - }; } function serializeState(state: LoopState): string { - const promptIndented = state.original_prompt - .split("\n") - .map((line) => ` ${line}`) - .join("\n"); - - return `--- -active: ${state.active} -iteration: ${state.iteration} -max_iterations: ${state.max_iterations} -completion_promise: "${state.completion_promise}" -original_prompt: | -${promptIndented} -timestamp: "${state.timestamp}" -review_enabled: ${state.review_enabled} -review_count: ${state.review_count} -max_review_cycles: ${state.max_review_cycles} -pending_feedback: ${state.pending_feedback ? `"${state.pending_feedback.replace(/"/g, '\\"')}"` : "null"} -review_history: ${JSON.stringify(state.review_history)} -debug: ${state.debug} ---- -`; + return JSON.stringify(state, null, 2) + "\n"; } // --- State File Cleanup --- @@ -512,31 +333,66 @@ function cleanupStateFile(stateFilePath: string): void { } } -// --- Transcript Parsing --- +// --- JSONL Log --- + +type LogEntry = Record & { + ts: string; + type: "review" | "phase" | "commit" | "decision" | "summary"; +}; + +/** + * Get the log file path from the state file path. + * State is at .rl/state.json, log is at .rl/log.jsonl. + */ +function getLogFilePath(stateFile: string): string { + return join(stateFile, "..", "log.jsonl"); +} + +function appendLog(stateFile: string, entry: LogEntry): void { + try { + // Ensure .rl/ directory exists + const dir = join(stateFile, ".."); + mkdirSync(dir, { recursive: true }); + const logPath = getLogFilePath(stateFile); + appendFileSync(logPath, JSON.stringify(entry) + "\n"); + } catch (e) { + crash(`Failed to append to log.jsonl`, e); + } +} + +// --- Prompt and Review History from .rl/ --- -function getLastAssistantMessage(transcriptPath: string): string | null { - if (!existsSync(transcriptPath)) return null; +/** + * Read the original prompt from .rl/prompt.md. + */ +function readPrompt(stateFile: string): string | null { + try { + const promptPath = join(stateFile, "..", "prompt.md"); + if (!existsSync(promptPath)) return null; + return readFileSync(promptPath, "utf-8").trim(); + } catch { + return null; + } +} +/** + * Get feedback from the last review if it was a rejection. + * Scans backwards — only needs the last review entry. + */ +function getLastRejectFeedback(stateFile: string): string | null { try { - const content = readFileSync(transcriptPath, "utf-8"); - const lines = content.trim().split("\n").filter(Boolean); + const logFilePath = getLogFilePath(stateFile); + if (!existsSync(logFilePath)) return null; + const content = readFileSync(logFilePath, "utf-8").trim(); + if (!content) return null; - // Find last assistant message (iterate backwards) + const lines = content.split("\n"); for (let i = lines.length - 1; i >= 0; i--) { try { - const entry: TranscriptEntry = JSON.parse(lines[i]); - - // Handle new format: { type: "assistant", message: { role, content } } - const role = entry.message?.role || entry.role; - const msgContent = entry.message?.content || entry.content; - - if (role === "assistant" && Array.isArray(msgContent)) { - const textParts = msgContent - .filter((c) => c.type === "text" && c.text) - .map((c) => c.text) - .join("\n"); - if (textParts) return textParts; - } + const parsed = JSON.parse(lines[i]); + if (parsed.type !== "review") continue; + if (parsed.decision !== "reject") return null; + return typeof parsed.feedback === "string" ? parsed.feedback : null; } catch { continue; } @@ -544,7 +400,6 @@ function getLastAssistantMessage(transcriptPath: string): string | null { } catch { return null; } - return null; } @@ -552,56 +407,11 @@ function getLastAssistantMessage(transcriptPath: string): string | null { interface ReviewResult { approved: boolean; - issues: ReviewIssue[]; - resolved: ResolvedIssue[]; - notes: string | null; -} - -function formatIssuesForDisplay(issues: ReviewIssue[]): string { - return issues - .map((issue) => ` - [ISSUE-${issue.id}] ${issue.severity}: ${issue.description}`) - .join("\n"); -} - -function formatResolvedForDisplay(resolved: ResolvedIssue[]): string { - return resolved - .map((r) => ` - [ISSUE-${r.id}] ✓ ${r.verification}`) - .join("\n"); -} - -function buildReviewHistorySection(history: ReviewHistoryEntry[]): string { - if (history.length === 0) return ""; - - const sections = history.map((entry) => { - const parts: string[] = [`### Cycle ${entry.cycle}: ${entry.decision}`]; - - if (entry.resolved.length > 0) { - parts.push(`**Resolved:**\n${formatResolvedForDisplay(entry.resolved)}`); - } - - if (entry.issues.length > 0) { - parts.push(`**Issues:**\n${formatIssuesForDisplay(entry.issues)}`); - } - - if (entry.notes) { - parts.push(`**Notes:** ${entry.notes}`); - } - - return parts.join("\n"); - }); - - return `## Previous Reviews - -${sections.join("\n\n")} - -`; + feedback: string; } function callCodexReview( - originalPrompt: string, - reviewHistory: ReviewHistoryEntry[], reviewCount: number, - maxReviews: number, cwd: string ): ReviewResult { crash(`callCodexReview() started - reviewCount=${reviewCount}, cwd=${cwd}`); @@ -611,70 +421,51 @@ function callCodexReview( if (whichResult.status !== 0) { crash("Codex CLI not found, approving by default"); debug("Codex CLI not found, approving by default"); - return { approved: true, issues: [], resolved: [], notes: null }; + return { approved: true, feedback: "" }; } crash(`Codex found at: ${whichResult.stdout?.trim()}`); - // Build review history section - const historySection = buildReviewHistorySection(reviewHistory); - - // Build review prompt with formal issue format + // Build review prompt const reviewPrompt = `# Code Review -Review work completed by Claude in an iterative loop. Claude claims the task is complete. - -## Assignment -${originalPrompt} - -## Git Context -**Working Directory**: \`pwd\` -**Repository**: \`basename "$(git rev-parse --show-toplevel 2>/dev/null || pwd)"\` -**Branch**: \`git branch --show-current 2>/dev/null || echo "detached/unknown"\` -**Uncommitted changes**: \`git diff --stat 2>/dev/null || echo "None"\` -**Staged changes**: \`git diff --cached --stat 2>/dev/null || echo "None"\` -**Recent commits (last 4 hours)**: \`git log --oneline -5 --since="4 hours ago" 2>/dev/null || echo "None"\` - -${historySection}## Review Process -1. Understand the task (read referenced files as needed) -2. Review git changes (\`git diff\`, \`git diff --cached\`, \`git log\`, etc.) -3. Run verification commands from success criteria if applicable -4. Check ALL requirements - be thorough, not superficial - -## Output Format - -If approved: -\`\`\` -APPROVE -Optional notes for the record -\`\`\` - -If issues found: -\`\`\` -REJECT - -[ISSUE-1] How you verified this previous issue is now fixed - - -[ISSUE-1] severity: Description of the issue -[ISSUE-2] severity: Description of another issue - -Optional notes visible to future review cycles -\`\`\` - -- Severity levels: \`critical\` (blocking), \`major\` (significant), \`minor\` (nice to fix) -- Issue IDs must be unique across all cycles - continue numbering from previous reviews (don't restart at ISSUE-1) -- \`\` section: List any previous issues you verified as fixed (omit if none or first review) -- \`\` section: Optional, visible to future review cycles -- Be thorough - report ALL issues found - -Review ${reviewCount + 1}/${maxReviews}.`; - - // Use unique file paths based on timestamp to avoid collisions +An agent worked on a task in an iterative loop and claims it's done. Review the work. + +## Context + +The \`.rl/\` directory contains loop state and tools. Start here: +- \`.rl/rl prompt\` — read the original task assignment +- \`.rl/rl status\` — check loop state +- \`cat .rl/log.jsonl\` — read the event log (phases, commits, decisions the agent made) +- \`.rl/rl log decision "your review notes"\` — log your own findings + +## How to Review + +1. Read the task with \`.rl/rl prompt\` +2. Read the event log to understand what the agent did and why +3. Review the actual code — read changed files, check both committed and uncommitted work +4. If the task includes verification commands or tests, run them +5. Judge: does this implementation satisfy the original request? + +Review the code, not the process. The agent may not have committed everything — that's fine. What matters is whether the work is correct and complete relative to the task. + +\`.rl/\` is loop infrastructure. Do not flag it. + +## Verdict + +End your response with exactly one of: +- \`APPROVE\` — the work satisfies the task +- \`REJECT\` — something is broken or a requirement is unmet + +If rejecting, explain what's wrong and what needs to change. Be specific and actionable. + +Review ${reviewCount + 1}.`; + + // Write review output to .rl/ directory const uniqueId = Date.now(); - const outputFile = `/tmp/codex-review-output-${uniqueId}.txt`; + const rlDirPath = stateFilePath ? join(stateFilePath, "..") : "/tmp"; + const outputFile = join(rlDirPath, `codex-review-${uniqueId}.txt`); crash(`Calling Codex with output file: ${outputFile}`); - crash(`Review prompt length: ${reviewPrompt.length} chars`); try { // Build args dynamically from user config @@ -684,20 +475,15 @@ Review ${reviewCount + 1}/${maxReviews}.`; "-", // read prompt from stdin ]; - // Sandbox/approval settings (bypass_sandbox overrides both) if (codexConfig.bypass_sandbox) { codexArgs.push("--dangerously-bypass-approvals-and-sandbox"); } else { codexArgs.push("--sandbox", codexConfig.sandbox || "read-only"); - // Use -c config override style (exec doesn't have -a flag) codexArgs.push("-c", `approval_policy="${codexConfig.approval_policy || "never"}"`); } - // Output file (extra_args could override, but parsing would break) codexArgs.push("-o", outputFile); - // Extra user-provided args (validated as string array, appended last) - // Note: These can override earlier flags if user intends to customize behavior if (Array.isArray(codexConfig.extra_args)) { for (const arg of codexConfig.extra_args) { if (typeof arg === "string") { @@ -706,112 +492,57 @@ Review ${reviewCount + 1}/${maxReviews}.`; } } - // Convert timeout from seconds to milliseconds const timeoutMs = (codexConfig.timeout_seconds || 1200) * 1000; - crash(`Codex config: ${JSON.stringify(codexConfig)}`); - crash(`Codex args: ${JSON.stringify(codexArgs)}`); - crash(`Codex timeout: ${timeoutMs}ms (${codexConfig.timeout_seconds || 1200}s)`); + crash(`Codex args: ${JSON.stringify(codexArgs)}, timeout: ${timeoutMs}ms`); - // NOTE: This timeout must be less than plugin.json hook timeout (1800s) const result = spawnSync("codex", codexArgs, { cwd, encoding: "utf-8", timeout: timeoutMs, maxBuffer: 16 * 1024 * 1024, - input: reviewPrompt, // pass prompt via stdin + input: reviewPrompt, }); - crash(`Codex returned - status: ${result.status}, signal: ${result.signal}, error: ${result.error}`); - if (result.stderr) { - crash(`Codex stderr: ${result.stderr.slice(0, 500)}`); - } - debug(`[ralph-reviewed] Codex exit code: ${result.status}, stderr: ${result.stderr?.slice(0, 200)}`); + crash(`Codex returned - status: ${result.status}, signal: ${result.signal}`); + if (result.stderr) crash(`Codex stderr: ${result.stderr.slice(0, 500)}`); - // Read output from file and clean up + // Read output let output = ""; if (existsSync(outputFile)) { output = readFileSync(outputFile, "utf-8"); - crash(`Codex output file contents: ${output.slice(0, 500)}`); - debug(`[ralph-reviewed] Codex output: ${output.slice(0, 500)}`); - try { unlinkSync(outputFile); } catch { /* ignore cleanup errors */ } + crash(`Codex output: ${output.slice(0, 500)}`); } else { crash("No Codex output file created"); - debug(`[ralph-reviewed] No output file created`); } - // Parse verdict from the END of output to avoid matching echoed examples - // Find the last ... tag in the output + // Parse verdict — last tag wins const reviewMatches = [...output.matchAll(/\s*(APPROVE|REJECT)\s*<\/review>/gi)]; - const lastReviewMatch = reviewMatches.length > 0 ? reviewMatches[reviewMatches.length - 1] : null; - const verdict = lastReviewMatch ? lastReviewMatch[1].toUpperCase() : null; + const verdict = reviewMatches.length > 0 + ? reviewMatches[reviewMatches.length - 1][1].toUpperCase() + : null; - crash(`Verdict parsing: found ${reviewMatches.length} review tags, verdict=${verdict}`); + crash(`Verdict: ${verdict}`); - // Parse notes (present in both APPROVE and REJECT) - also use last match - const notesMatches = [...output.matchAll(/([\s\S]*?)<\/notes>/gi)]; - const lastNotesMatch = notesMatches.length > 0 ? notesMatches[notesMatches.length - 1] : null; - const notes = lastNotesMatch ? lastNotesMatch[1].trim() : null; - - // Parse response based on extracted verdict if (verdict === "APPROVE") { - crash("Codex approved"); - return { approved: true, issues: [], resolved: [], notes }; + return { approved: true, feedback: output }; } if (verdict === "REJECT") { - // Parse issues - use last block - const issues: ReviewIssue[] = []; - const issuesMatches = [...output.matchAll(/([\s\S]*?)<\/issues>/gi)]; - const lastIssuesMatch = issuesMatches.length > 0 ? issuesMatches[issuesMatches.length - 1] : null; - if (lastIssuesMatch) { - // Use [\s\S]+? for multi-line descriptions, terminated by next issue or end - const issuePattern = /\[ISSUE-(\d+)\]\s*(critical|major|minor):\s*([\s\S]+?)(?=\[ISSUE-|\s*$)/gi; - let match; - while ((match = issuePattern.exec(lastIssuesMatch[1])) !== null) { - issues.push({ - id: parseInt(match[1], 10), - severity: match[2].toLowerCase() as "critical" | "major" | "minor", - description: match[3].trim(), - }); - } - } - - // Parse resolved - use last block - const resolved: ResolvedIssue[] = []; - const resolvedMatches = [...output.matchAll(/([\s\S]*?)<\/resolved>/gi)]; - const lastResolvedMatch = resolvedMatches.length > 0 ? resolvedMatches[resolvedMatches.length - 1] : null; - if (lastResolvedMatch) { - // Use [\s\S]+? for multi-line verifications - const resolvedPattern = /\[ISSUE-(\d+)\]\s*([\s\S]+?)(?=\[ISSUE-|\s*$)/gi; - let match; - while ((match = resolvedPattern.exec(lastResolvedMatch[1])) !== null) { - resolved.push({ - id: parseInt(match[1], 10), - verification: match[2].trim(), - }); - } - } - - // Handle REJECT with no parsed issues - auto-approve with warning to avoid deadlock - if (issues.length === 0) { - crash("REJECT verdict but no issues parsed - auto-approving with warning to avoid deadlock"); - debug("[ralph-reviewed] WARNING: Codex rejected but no issues could be parsed. Auto-approving to avoid deadlock."); - return { approved: true, issues: [], resolved: [], notes: notes ? `[AUTO-APPROVED: REJECT with unparseable issues] ${notes}` : "[AUTO-APPROVED: REJECT with unparseable issues]" }; - } - - crash(`Codex rejected with ${issues.length} issues, ${resolved.length} resolved`); - return { approved: false, issues, resolved, notes }; + // Extract feedback — everything after the last REJECT tag + const lastTag = output.lastIndexOf(""); + const feedback = lastTag >= 0 + ? output.slice(0, lastTag).trim() + : output.trim(); + return { approved: false, feedback }; } - // Unclear response - default to approve - crash("Unclear Codex response (no APPROVE/REJECT verdict found), approving by default"); - debug("Unclear Codex response, approving by default"); - return { approved: true, issues: [], resolved: [], notes: null }; + // No clear verdict — approve by default + crash("No APPROVE/REJECT found, approving by default"); + return { approved: true, feedback: output }; } catch (e) { - crash("Codex review call threw exception", e); - debug(`Codex review failed: ${e}, approving by default`); - return { approved: true, issues: [], resolved: [], notes: null }; + crash("Codex review failed", e); + return { approved: true, feedback: "" }; } } @@ -957,26 +688,14 @@ async function main() { debug(`[ralph-reviewed] Debug enabled via state file`); } - // Get last assistant message - const transcriptPath = input.transcript_path || ""; - const lastMessage = transcriptPath ? getLastAssistantMessage(transcriptPath) : null; - - // Debug logging - debug(`[ralph-reviewed] Iteration: ${state.iteration}, Transcript: ${transcriptPath || "none"}`); - debug(`[ralph-reviewed] Last message (truncated): ${lastMessage?.slice(-200) || "null"}`); - - // Check for completion promise - const promisePattern = new RegExp( - `\\s*${state.completion_promise}\\s*`, - "i" - ); - const completionClaimed = lastMessage && promisePattern.test(lastMessage); - debug(`[ralph-reviewed] Promise pattern: ${promisePattern}, Claimed: ${completionClaimed}`); + // Check for completion/blocked via state flags (set by `rl done`) + // Re-read state to pick up flags set during this iteration + const freshContent = readFileSync(stateFilePath, "utf-8"); + const freshState = JSON.parse(freshContent) as Record; + const completionClaimed = freshState.completion_claimed === true; + const blockedClaimed = freshState.blocked_claimed === true; - // Check for BLOCKED signal (special termination without review) - const blockedPattern = /\s*BLOCKED\s*<\/promise>/i; - const blockedClaimed = lastMessage && blockedPattern.test(lastMessage); - debug(`[ralph-reviewed] Blocked pattern check: ${blockedClaimed}`); + debug(`[ralph-reviewed] Iteration: ${state.iteration}, done: ${completionClaimed}, blocked: ${blockedClaimed}`); if (blockedClaimed) { // BLOCKED is a special termination signal - exit without Codex review @@ -986,7 +705,7 @@ async function main() { output({ systemMessage: `# Ralph Loop: BLOCKED -**Iteration:** ${state.iteration}/${state.max_iterations} +**Iteration:** ${state.iteration} Task reported as blocked. Loop terminated without review.` }); @@ -1005,7 +724,7 @@ Task reported as blocked. Loop terminated without review.` output({ systemMessage: `# Ralph Loop: Max Iterations Reached -**Iteration:** ${state.iteration}/${state.max_iterations} +**Iteration:** ${state.iteration} Loop ended without completion claim. Review the work and consider restarting if needed.` }); @@ -1016,17 +735,16 @@ Loop ended without completion claim. Review the work and consider restarting if writeFileSync(stateFilePath, serializeState(state)); // Build continuation prompt - let prompt = `# Ralph Loop - Iteration ${state.iteration}/${state.max_iterations}\n\n`; + const originalPrompt = readPrompt(stateFilePath) || "(no prompt found)"; + let prompt = `# Ralph Loop \u2014 Iteration ${state.iteration}\n\n`; - if (state.pending_feedback) { - prompt += `## Review Feedback from Previous Attempt\n\n${state.pending_feedback}\n\nAddress the above feedback.\n\n---\n\n`; - // Clear pending feedback after injecting - state.pending_feedback = null; - writeFileSync(stateFilePath, serializeState(state)); + const pendingFeedback = getLastRejectFeedback(stateFilePath); + if (pendingFeedback) { + prompt += `## Review Feedback from Previous Attempt\n\n${pendingFeedback}\n\nAddress the above feedback.\n\n---\n\n`; } - prompt += state.original_prompt; - prompt += `\n\nWhen complete, output: ${state.completion_promise}`; + prompt += originalPrompt; + prompt += `\n\nWhen complete, run: .rl/rl done`; output({ decision: "block", reason: prompt }); return; @@ -1064,108 +782,62 @@ Codex requires a git repository to run. The current directory is not inside a gi // Perform Codex review debug(`[ralph-reviewed] Calling Codex for review...`); + const reviewPromptText = readPrompt(stateFilePath) || "(no prompt found)"; + const reviewResult = callCodexReview( - state.original_prompt, - state.review_history, state.review_count, - state.max_review_cycles, - cwd + gitRoot || cwd ); - debug(`[ralph-reviewed] Review result: approved=${reviewResult.approved}, issues=${reviewResult.issues.length}`); + debug(`[ralph-reviewed] Review result: approved=${reviewResult.approved}`); - // Record this review in history - const historyEntry: ReviewHistoryEntry = { + // Log review to .rl/log.jsonl + appendLog(stateFilePath, { + ts: new Date().toISOString(), + type: "review", cycle: state.review_count + 1, - decision: reviewResult.approved ? "APPROVE" : "REJECT", - issues: reviewResult.issues, - resolved: reviewResult.resolved, - notes: reviewResult.notes, - }; - state.review_history.push(historyEntry); + decision: reviewResult.approved ? "approve" : "reject", + feedback: reviewResult.feedback, + }); if (reviewResult.approved) { - // Approved - allow exit debug(`[ralph-reviewed] Codex approved! Exiting loop.`); cleanupStateFile(stateFilePath); - - // Build approval summary for user visibility - const notesLine = reviewResult.notes ? `\n**Reviewer notes:** ${reviewResult.notes}` : ""; - const approvalMessage = `# Ralph Loop: Codex APPROVED - -**Iteration:** ${state.iteration}/${state.max_iterations} -**Review cycle:** ${state.review_count + 1}/${state.max_review_cycles}${notesLine} - -The review gate has been cleared. Task completed successfully.`; - - output({ systemMessage: approvalMessage }); + output({ + systemMessage: `# Ralph Loop: Codex APPROVED\n\n**Iteration:** ${state.iteration} | **Review cycle:** ${state.review_count + 1}\n\nReview gate cleared.` + }); return; } - // Rejected - check review count + // Rejected state.review_count++; if (state.review_count >= state.max_review_cycles) { - // Max reviews reached - allow exit with warning - debug( - `[ralph-reviewed] Max review cycles (${state.max_review_cycles}) reached. Issues: ${reviewResult.issues.length}` - ); + debug(`[ralph-reviewed] Max review cycles (${state.max_review_cycles}) reached.`); cleanupStateFile(stateFilePath); - - // Build summary with remaining issues - const remainingIssues = reviewResult.issues.length > 0 - ? reviewResult.issues.map(i => `- [ISSUE-${i.id}] ${i.severity}: ${i.description}`).join("\n") - : "(no issues parsed)"; - output({ - systemMessage: `# Ralph Loop: Max Review Cycles Reached - -**Iteration:** ${state.iteration}/${state.max_iterations} -**Review cycle:** ${state.review_count}/${state.max_review_cycles} - -**Unresolved issues:** -${remainingIssues} - -Loop ended without Codex approval. Review remaining issues manually.` + systemMessage: `# Ralph Loop: Max Review Cycles Reached\n\n**Iteration:** ${state.iteration} | **Review cycle:** ${state.review_count}\n\nLoop ended without approval. Review feedback manually.` }); return; } - // Format issues for Claude's feedback - const issuesList = reviewResult.issues - .map((issue) => `- [ISSUE-${issue.id}] ${issue.severity}: ${issue.description}`) - .join("\n"); - - const resolvedList = reviewResult.resolved.length > 0 - ? `\n\n**Resolved from previous cycle:**\n${reviewResult.resolved.map((r) => `- [ISSUE-${r.id}] ✓ ${r.verification}`).join("\n")}` - : ""; - - const notesSection = reviewResult.notes - ? `\n\n**Reviewer notes:** ${reviewResult.notes}` - : ""; - - // Store formatted feedback for state - state.pending_feedback = issuesList; - state.iteration++; // Increment iteration for the feedback round + state.iteration++; writeFileSync(stateFilePath, serializeState(state)); - // Build prompt with structured feedback - const feedbackPrompt = `# Ralph Loop - Iteration ${state.iteration}/${state.max_iterations} + // Feed back the reviewer's feedback + original prompt + const feedbackPrompt = `# Ralph Loop \u2014 Iteration ${state.iteration} -## Review Feedback (Cycle ${state.review_count}/${state.max_review_cycles}) +## Review Feedback (Cycle ${state.review_count}) -Your previous completion was reviewed and requires changes. -${resolvedList} +${reviewResult.feedback} -**Open Issues:** -${issuesList} -${notesSection} +--- -Address ALL open issues above, then output ${state.completion_promise} when truly complete. +Fix the issues above, then run \`.rl/rl done\` when complete. --- -${state.original_prompt}`; +${reviewPromptText}`; output({ decision: "block", reason: feedbackPrompt }); } catch (e) { diff --git a/plugins/ralph-reviewed/scripts/rl b/plugins/ralph-reviewed/scripts/rl new file mode 100755 index 0000000..135ffdb --- /dev/null +++ b/plugins/ralph-reviewed/scripts/rl @@ -0,0 +1,323 @@ +#!/usr/bin/env bun +/** + * rl — Ralph Loop CLI for setup, logging, and status. + * + * Usage: + * rl init "" [--max-iterations N] [--max-reviews N] [--no-review] [--debug] + * rl done [--blocked] + * rl prompt + * rl status + * rl log phase + * rl log commit + * rl log decision + * rl log summary + * rl clean + */ + +import { appendFileSync, readFileSync, writeFileSync, mkdirSync, existsSync, symlinkSync, realpathSync, rmSync } from "node:fs"; +import { spawnSync } from "node:child_process"; +import { join, resolve } from "node:path"; + +// --- Git root resolution (matches stop hook — walks through superprojects) --- + +function getGitRoot(): string { + try { + let dir = process.cwd(); + while (true) { + const superResult = spawnSync("git", ["rev-parse", "--show-superproject-working-tree"], { + cwd: dir, encoding: "utf-8", timeout: 5000, + }); + const superproject = superResult.status === 0 ? superResult.stdout.trim() : ""; + if (!superproject) { + const rootResult = spawnSync("git", ["rev-parse", "--show-toplevel"], { + cwd: dir, encoding: "utf-8", timeout: 5000, + }); + if (rootResult.status === 0 && rootResult.stdout) return rootResult.stdout.trim(); + return process.cwd(); + } + dir = superproject; + } + } catch { + return process.cwd(); + } +} + +// --- Path helpers --- + +function rlDir(): string { + return join(getGitRoot(), ".rl"); +} + +function ensureRlDir(): void { + mkdirSync(rlDir(), { recursive: true }); +} + +function logPath(): string { + return join(rlDir(), "log.jsonl"); +} + +function statePath(): string { + return join(rlDir(), "state.json"); +} + +function promptPath(): string { + return join(rlDir(), "prompt.md"); +} + +// --- Resolve the real script path (follows symlinks) --- + +function realScriptPath(): string { + try { + // import.meta.path gives the resolved path in Bun + return (import.meta as { path?: string }).path || realpathSync(process.argv[1]); + } catch { + return resolve(process.argv[1]); + } +} + +// --- Commands --- + +function init(args: string[]): void { + // Parse flags from the end, prompt is everything before flags + let maxIterations = 30; + let maxReviews: number | null = null; + let noReview = false; + let debug = false; + const promptParts: string[] = []; + + for (let i = 0; i < args.length; i++) { + const arg = args[i]; + if (arg === "--max-iterations" && i + 1 < args.length) { + maxIterations = parseInt(args[++i], 10); + } else if (arg === "--max-reviews" && i + 1 < args.length) { + maxReviews = parseInt(args[++i], 10); + } else if (arg === "--no-review") { + noReview = true; + } else if (arg === "--debug") { + debug = true; + } else { + promptParts.push(arg); + } + } + + const prompt = promptParts.join(" "); + if (!prompt) { + console.error("usage: rl init \"\" [--max-iterations N] [--max-reviews N] [--no-review] [--debug]"); + process.exit(1); + } + + const dir = rlDir(); + ensureRlDir(); + + const ts = new Date().toISOString().replace(/\.\d{3}Z$/, "Z"); + const effectiveMaxReviews = maxReviews ?? maxIterations; + + // Write state.json + const state = { + active: true, + iteration: 0, + max_iterations: maxIterations, + timestamp: ts, + review_enabled: !noReview, + review_count: 0, + max_review_cycles: effectiveMaxReviews, + debug, + }; + writeFileSync(statePath(), JSON.stringify(state, null, 2) + "\n"); + + // Write prompt.md + writeFileSync(promptPath(), prompt + "\n"); + + // Reset log.jsonl (new session — don't carry over stale entries) + writeFileSync(logPath(), ""); + + // Add .rl/ to .git/info/exclude if in a git repo + try { + const gitRoot = getGitRoot(); + const excludePath = join(gitRoot, ".git", "info", "exclude"); + if (existsSync(excludePath)) { + const content = readFileSync(excludePath, "utf-8"); + if (!content.includes(".rl/")) { + appendFileSync(excludePath, "\n# Ralph loop state\n.rl/\n"); + } + } + } catch { + // Not critical + } + + // Create .rl/rl symlink to this script + const symlinkPath = join(dir, "rl"); + const scriptPath = realScriptPath(); + try { + if (!existsSync(symlinkPath)) { + symlinkSync(scriptPath, symlinkPath); + } + } catch { + // Symlink may fail on some systems; not critical + } + + console.log(`ralph loop initialized (${maxIterations} iterations, review: ${!noReview})`); +} + +function showPrompt(): void { + const path = promptPath(); + if (!existsSync(path)) { + console.error("no prompt found (.rl/prompt.md missing)"); + process.exit(1); + } + console.log(readFileSync(path, "utf-8").trim()); +} + +function appendLogEntry(entry: Record): void { + if (!existsSync(statePath())) { + console.error("no active ralph loop (run rl init first)"); + process.exit(1); + } + entry.ts = new Date().toISOString(); + appendFileSync(logPath(), JSON.stringify(entry) + "\n"); + console.log(`logged ${entry.type}: ${entry.note || entry.summary || entry.phase || ""}`); +} + +function logPhase(args: string[]): void { + const note = args.join(" "); + if (!note) { + console.error("usage: rl log phase "); + process.exit(1); + } + appendLogEntry({ type: "phase", phase: note, note }); +} + +function logCommit(args: string[]): void { + const sha = args[0]; + const summary = args.slice(1).join(" "); + if (!sha || !summary) { + console.error("usage: rl log commit "); + process.exit(1); + } + appendLogEntry({ type: "commit", sha, summary }); +} + +function logDecision(args: string[]): void { + const note = args.join(" "); + if (!note) { + console.error("usage: rl log decision "); + process.exit(1); + } + appendLogEntry({ type: "decision", note }); +} + +function logSummary(args: string[]): void { + const note = args.join(" "); + if (!note) { + console.error("usage: rl log summary "); + process.exit(1); + } + appendLogEntry({ type: "summary", note }); +} + +function done(args: string[]): void { + const path = statePath(); + if (!existsSync(path)) { + console.error("no active ralph loop"); + process.exit(1); + } + try { + const state = JSON.parse(readFileSync(path, "utf-8")); + if (args.includes("--blocked")) { + state.blocked_claimed = true; + delete state.completion_claimed; + writeFileSync(path, JSON.stringify(state, null, 2) + "\n"); + console.log("marked as BLOCKED — loop will terminate without review"); + } else { + state.completion_claimed = true; + delete state.blocked_claimed; + writeFileSync(path, JSON.stringify(state, null, 2) + "\n"); + const msg = state.review_enabled + ? "marked as COMPLETE — stop hook will trigger review gate" + : "marked as COMPLETE — loop will exit (review disabled)"; + console.log(msg); + } + } catch { + console.error("failed to update state.json"); + process.exit(1); + } +} + +function clean(): void { + const dir = rlDir(); + if (!existsSync(dir)) { + console.log("nothing to clean (.rl/ not found)"); + return; + } + rmSync(dir, { recursive: true, force: true }); + console.log("removed .rl/"); +} + +function status(): void { + const path = statePath(); + if (!existsSync(path)) { + console.log("no active ralph loop"); + process.exit(0); + } + try { + const state = JSON.parse(readFileSync(path, "utf-8")); + const parts: string[] = []; + parts.push(`iteration: ${state.iteration ?? "?"}`); + parts.push(`reviews: ${state.review_count ?? 0}`); + parts.push(`active: ${state.active ?? false}`); + parts.push(`review: ${state.review_enabled ? "on" : "off"}`); + console.log(parts.join(" ")); + } catch { + console.error("failed to read state.json"); + process.exit(1); + } +} + +// --- Main --- + +const args = process.argv.slice(2); +const command = args[0]; + +switch (command) { + case "init": + init(args.slice(1)); + break; + case "prompt": + showPrompt(); + break; + case "done": + done(args.slice(1)); + break; + case "clean": + clean(); + break; + case "status": + status(); + break; + case "log": { + const subcommand = args[1]; + const rest = args.slice(2); + switch (subcommand) { + case "phase": + logPhase(rest); + break; + case "commit": + logCommit(rest); + break; + case "decision": + logDecision(rest); + break; + case "summary": + logSummary(rest); + break; + default: + console.error(`unknown log type: ${subcommand}`); + console.error("usage: rl log "); + process.exit(1); + } + break; + } + default: + console.error("usage: rl [args...]"); + process.exit(1); +} diff --git a/settings/settings.json b/settings/settings.json index 5bfe08e..13e4640 100644 --- a/settings/settings.json +++ b/settings/settings.json @@ -136,6 +136,9 @@ "silo@0xBigBoss-silo": true, "send-infra@send-infra-plugins": true }, + "env": { + "BASH_ENV": "/Users/allen/code/dotfiles/claude-code/hooks/direnv-bash-env" + }, "alwaysThinkingEnabled": true, "feedbackSurveyState": { "lastShownTime": 1754076652911 From 5f1fcf09696bcfef7d001c32e2dcd833e73c30f1 Mon Sep 17 00:00:00 2001 From: Allen Date: Sat, 21 Mar 2026 15:12:13 -0500 Subject: [PATCH 12/57] feat(statusline): parse ralph state from .rl/state.json (JSON) Update statusline to read ralph loop state from the new .rl/state.json path using std.json instead of YAML frontmatter parsing. Tests updated to use JSON fixtures. Statusline still shows N/M denominators for human visibility (only hidden from the agent in the loop). --- statusline/CLAUDE.md | 2 +- statusline/src/main.zig | 103 ++++++++++++---------------------------- 2 files changed, 32 insertions(+), 73 deletions(-) diff --git a/statusline/CLAUDE.md b/statusline/CLAUDE.md index b04241d..e36816e 100644 --- a/statusline/CLAUDE.md +++ b/statusline/CLAUDE.md @@ -30,7 +30,7 @@ zig build -Doptimize=ReleaseFast - Git metadata is read from the current workspace directory. - Review gate state files are read from: - - `{git_root}/.claude/ralph-loop.local.md` + - `{git_root}/.rl/state.json` - `{git_root}/.claude/codex-review.local.md` ## Guardrails diff --git a/statusline/src/main.zig b/statusline/src/main.zig index 9bd25c3..0280d92 100644 --- a/statusline/src/main.zig +++ b/statusline/src/main.zig @@ -799,56 +799,48 @@ fn parseYamlInt(line: []const u8, key: []const u8) ?u32 { return std.fmt.parseInt(u32, value, 10) catch null; } -/// Parse Ralph state from file content string (YAML frontmatter) +/// Parse Ralph state from JSON content string /// Exposed for testing; returns default RalphState if parsing fails -/// Note: Only reads fields at the top of frontmatter; large fields like -/// review_history are ignored, so we don't need the full file content. fn parseRalphStateFromContent(content: []const u8) RalphState { var state = RalphState{}; - // Must start with --- - if (!std.mem.startsWith(u8, content, "---")) return state; - const after_first = content[3..]; - // Skip newline after first --- - const start_idx: usize = if (after_first.len > 0 and after_first[0] == '\n') 1 else 0; + // Use std.json to parse the JSON state file + const JsonState = struct { + active: ?bool = null, + iteration: ?u32 = null, + max_iterations: ?u32 = null, + review_enabled: ?bool = null, + review_count: ?u32 = null, + max_review_cycles: ?u32 = null, + }; - // Find closing --- if present, otherwise parse what we have - // (state files can be large due to review_history, but our fields are at the top) - const frontmatter = if (std.mem.indexOf(u8, after_first[start_idx..], "\n---")) |end_idx| - after_first[start_idx..][0..end_idx] - else - after_first[start_idx..]; + const parsed = std.json.parseFromSlice(JsonState, std.heap.page_allocator, content, .{ + .ignore_unknown_fields = true, + }) catch return state; + defer parsed.deinit(); - // Parse lines until we hit closing delimiter or exhaust content - var lines = std.mem.splitScalar(u8, frontmatter, '\n'); - while (lines.next()) |line| { - const trimmed = std.mem.trim(u8, line, " \t\r"); - // Stop if we hit the closing delimiter - if (std.mem.eql(u8, trimmed, "---")) break; - if (parseYamlBool(trimmed, "active:")) |v| state.active = v; - if (parseYamlInt(trimmed, "iteration:")) |v| state.iteration = v; - if (parseYamlInt(trimmed, "max_iterations:")) |v| state.max_iterations = v; - if (parseYamlBool(trimmed, "review_enabled:")) |v| state.review_enabled = v; - if (parseYamlInt(trimmed, "review_count:")) |v| state.review_count = v; - if (parseYamlInt(trimmed, "max_review_cycles:")) |v| state.max_review_cycles = v; - } + const v = parsed.value; + if (v.active) |a| state.active = a; + if (v.iteration) |i| state.iteration = i; + if (v.max_iterations) |m| state.max_iterations = m; + if (v.review_enabled) |r| state.review_enabled = r; + if (v.review_count) |r| state.review_count = r; + if (v.max_review_cycles) |m| state.max_review_cycles = m; return state; } /// Parse Ralph loop state from state file at git root fn parseRalphState(allocator: Allocator, git_root: []const u8) RalphState { - // Construct path: {git_root}/.claude/ralph-loop.local.md - const path = std.fmt.allocPrint(allocator, "{s}/.claude/ralph-loop.local.md", .{git_root}) catch return RalphState{}; + // Construct path: {git_root}/.rl/state.json + const path = std.fmt.allocPrint(allocator, "{s}/.rl/state.json", .{git_root}) catch return RalphState{}; defer allocator.free(path); - // Read only first 2KB - our fields (active, iteration, etc.) are at the top - // review_history can grow to 8KB+ but comes after our fields - // Using fixed buffer avoids allocation and handles any file size + // Read first 4KB - JSON state file should be well under this const file = std.fs.cwd().openFile(path, .{}) catch return RalphState{}; defer file.close(); - var buf: [2048]u8 = undefined; + var buf: [4096]u8 = undefined; const bytes_read = file.read(&buf) catch return RalphState{}; if (bytes_read == 0) return RalphState{}; @@ -1787,17 +1779,9 @@ test "parseYamlInt function" { try std.testing.expect(parseYamlInt("other: 50", "iteration:") == null); } -test "parseRalphStateFromContent with valid frontmatter" { +test "parseRalphStateFromContent with valid JSON" { const content = - \\--- - \\active: true - \\iteration: 5 - \\max_iterations: 30 - \\review_enabled: true - \\review_count: 2 - \\max_review_cycles: 10 - \\--- - \\# Some markdown content + \\{"active":true,"iteration":5,"max_iterations":30,"review_enabled":true,"review_count":2,"max_review_cycles":10} ; const state = parseRalphStateFromContent(content); @@ -1811,10 +1795,7 @@ test "parseRalphStateFromContent with valid frontmatter" { test "parseRalphStateFromContent with partial fields" { const content = - \\--- - \\active: true - \\iteration: 3 - \\--- + \\{"active":true,"iteration":3} ; const state = parseRalphStateFromContent(content); @@ -1827,8 +1808,8 @@ test "parseRalphStateFromContent with partial fields" { try std.testing.expectEqual(@as(u32, 10), state.max_review_cycles); } -test "parseRalphStateFromContent with no frontmatter" { - const content = "# Just markdown, no frontmatter"; +test "parseRalphStateFromContent with invalid JSON" { + const content = "# Just markdown, not JSON"; const state = parseRalphStateFromContent(content); // Should return defaults @@ -1836,22 +1817,6 @@ test "parseRalphStateFromContent with no frontmatter" { try std.testing.expectEqual(@as(u32, 0), state.iteration); } -test "parseRalphStateFromContent with unclosed frontmatter" { - // Now we parse what we have even without closing delimiter - // (supports truncated reads of large state files) - const content = - \\--- - \\active: true - \\iteration: 5 - \\# Missing closing delimiter - ; - - const state = parseRalphStateFromContent(content); - // Should parse available fields even without closing --- - try std.testing.expect(state.active); - try std.testing.expectEqual(@as(u32, 5), state.iteration); -} - test "parseRalphStateFromContent with empty content" { const state = parseRalphStateFromContent(""); try std.testing.expect(!state.active); @@ -1859,13 +1824,7 @@ test "parseRalphStateFromContent with empty content" { test "parseRalphStateFromContent with extra fields ignored" { const content = - \\--- - \\active: true - \\iteration: 7 - \\unknown_field: some_value - \\completion_promise: "COMPLETE" - \\timestamp: "2025-01-01T00:00:00Z" - \\--- + \\{"active":true,"iteration":7,"unknown_field":"some_value","completion_promise":"COMPLETE","timestamp":"2025-01-01T00:00:00Z"} ; const state = parseRalphStateFromContent(content); From 484f4723f56ac6cc85df2c55703091324aa99354 Mon Sep 17 00:00:00 2001 From: Allen Date: Sat, 21 Mar 2026 15:12:20 -0500 Subject: [PATCH 13/57] test(ralph-reviewed): add QA checklist and automated test harness QA.md covers full e2e verification: rl init, agent work, rl done, Codex review gate, feedback cycle, edge cases. qa.sh resets the test repo and launches claude interactively with the QA prompt. --- plugins/ralph-reviewed/QA.md | 114 +++++++++++++++++++++++++++++++++++ plugins/ralph-reviewed/qa.sh | 31 ++++++++++ 2 files changed, 145 insertions(+) create mode 100644 plugins/ralph-reviewed/QA.md create mode 100755 plugins/ralph-reviewed/qa.sh diff --git a/plugins/ralph-reviewed/QA.md b/plugins/ralph-reviewed/QA.md new file mode 100644 index 0000000..2234e20 --- /dev/null +++ b/plugins/ralph-reviewed/QA.md @@ -0,0 +1,114 @@ +# Ralph Reviewed QA + +End-to-end test of the ralph-reviewed plugin v2.0.0. Run this from a test repo to verify the full loop works — `rl` CLI, stop hook, Codex review gate, feedback cycle. + +## Prerequisites + +- Test repo at `/tmp/ralph-test` with a broken `math.ts` (see setup below) +- Plugin loaded via `--plugin-dir ~/code/dotfiles/claude-code/plugins/ralph-reviewed` +- `codex` CLI installed and authenticated (for review gate tests) + +## Setup + +If the test repo doesn't exist, create it: + +```bash +rm -rf /tmp/ralph-test && mkdir -p /tmp/ralph-test && cd /tmp/ralph-test && git init -q && echo '# test' > README.md && git add -A && git commit -q -m "init" +cat > /tmp/ralph-test/math.ts <<'EOF' +// TODO: implement add, subtract, multiply, divide +export function add(a: number, b: number): number { + return 0; // broken +} + +export function divide(a: number, b: number): number { + return a / b; // no zero check +} +EOF +git -C /tmp/ralph-test add -A && git -C /tmp/ralph-test commit -q -m "add broken math module" +``` + +## Test: Full loop with review + +``` +/ralph-reviewed:ralph-loop "Fix math.ts: 1) add() should return a+b, 2) add subtract(a,b) and multiply(a,b), 3) divide() needs to throw on division by zero. Commit each fix separately. Use .rl/rl log for each phase." --max-iterations 10 --max-reviews 3 +``` + +### What to verify during the loop + +**rl init:** +- [ ] `rl init` is found and runs (check `find` or fallback path) +- [ ] `.rl/state.json` exists with lean schema: `active`, `iteration`, `max_iterations`, `timestamp`, `review_enabled`, `review_count`, `max_review_cycles`, `debug` — no `completion_promise`, `original_prompt`, `pending_feedback`, `review_history` +- [ ] `.rl/prompt.md` exists with the task text +- [ ] `.rl/rl` symlink exists and works (`rl status` returns output) +- [ ] `.rl/` is in `.git/info/exclude` + +**Agent work:** +- [ ] Agent uses `.rl/rl log phase` for each phase +- [ ] Agent uses `.rl/rl log commit` after each commit +- [ ] Agent uses `.rl/rl done` to signal completion (not bare `COMPLETE` text) +- [ ] Iteration headers from the stop hook show no denominators (e.g. `Iteration 1` not `Iteration 1/10`) + +**Codex review gate (if review enabled):** +- [ ] Stop hook triggers Codex review after `.rl/rl done` +- [ ] Review output saved to `.rl/codex-review-*.txt` (not deleted) +- [ ] If rejected: feedback is plain text, fed back to agent, agent gets another iteration +- [ ] If approved: loop exits cleanly with approval message +- [ ] Reviewer does NOT flag `.rl/` directory, process compliance, or git hygiene +- [ ] Reviewer focuses on code correctness and task requirements + +**After loop ends:** +- [ ] `state.json` is deleted (cleanup on approve/max/blocked) +- [ ] `prompt.md`, `log.jsonl`, review outputs persist in `.rl/` +- [ ] `.rl/rl clean` removes the entire `.rl/` directory + +### What to watch for (bugs and edge cases) + +**rl CLI:** +- Does `rl init` fail if `.rl/` already exists from a previous run? +- Does `rl done` fail if `state.json` is missing? +- Does `rl log` fail if `.rl/` doesn't exist yet? +- Does the `.rl/rl` symlink survive across iterations? + +**Stop hook:** +- Does the hook correctly detect `completion_claimed` from fresh state.json re-read? +- Does the hook clear `completion_claimed` on the next iteration write (via `serializeState` which doesn't include the flag)? +- Does the `blocked_claimed` flag work the same way? +- If the agent runs `.rl/rl done` but then the review rejects, does the next iteration NOT think completion is still claimed? +- What happens if max iterations and max reviews are both hit simultaneously? + +**Codex review:** +- Does the reviewer actually run `.rl/rl prompt` to read the task? +- Does the reviewer use `cat .rl/log.jsonl` for context? +- Does the reviewer log its own findings via `.rl/rl log decision`? +- Is the review output in `.rl/` (not `/tmp/`)? +- Does the review prompt avoid triggering pedantic behavior (process compliance, delivery flow, git hygiene)? + +**Feedback cycle:** +- Is the reviewer's free-text feedback passed through to the agent cleanly? +- Does the agent get the original prompt re-injected on rejection? +- Does the `getLastRejectFeedback` function correctly find the last reject entry in log.jsonl? + +## Test: No-review mode + +``` +/ralph-reviewed:ralph-loop "Fix add() in math.ts to return a+b. Add subtract and multiply. Commit." --max-iterations 5 --no-review +``` + +- [ ] Loop runs without Codex +- [ ] `.rl/rl done` exits the loop immediately (no review gate) +- [ ] `.rl/rl done --blocked` exits with BLOCKED message + +## Test: rl clean + +After any test: + +```bash +.rl/rl clean +ls .rl/ # should fail — directory gone +``` + +## Cleanup + +```bash +rm -rf /tmp/ralph-test +``` diff --git a/plugins/ralph-reviewed/qa.sh b/plugins/ralph-reviewed/qa.sh new file mode 100755 index 0000000..898eb7a --- /dev/null +++ b/plugins/ralph-reviewed/qa.sh @@ -0,0 +1,31 @@ +#!/usr/bin/env bash +# Reset test environment and run QA suite for ralph-reviewed plugin +set -euo pipefail + +PLUGIN_DIR="$(cd "$(dirname "$0")" && pwd)" +TEST_DIR="/tmp/ralph-test" + +# Reset test repo +rm -rf "$TEST_DIR" +mkdir -p "$TEST_DIR" +cd "$TEST_DIR" +git init -q +echo '# test' > README.md +git add -A && git commit -q -m "init" + +cat > math.ts <<'EOF' +// TODO: implement add, subtract, multiply, divide +export function add(a: number, b: number): number { + return 0; // broken +} + +export function divide(a: number, b: number): number { + return a / b; // no zero check +} +EOF +git add -A && git commit -q -m "add broken math module" + +echo "test repo ready at $TEST_DIR" + +# Start claude with QA prompt +exec claude --plugin-dir "$PLUGIN_DIR" "Read $PLUGIN_DIR/QA.md and execute the full QA suite. Report any bugs, edge cases, or unexpected behavior." From b8a65cc7e086d741598aa22df8d60feae5f4792e Mon Sep 17 00:00:00 2001 From: Allen Date: Sat, 21 Mar 2026 15:15:34 -0500 Subject: [PATCH 14/57] feat(hooks): add direnv BASH_ENV loader for Claude Code Sources direnv environment in non-interactive bash commands so tools spawned by Claude Code inherit the correct PATH and env vars. Referenced by settings.json BASH_ENV config. --- hooks/direnv-bash-env | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100755 hooks/direnv-bash-env diff --git a/hooks/direnv-bash-env b/hooks/direnv-bash-env new file mode 100755 index 0000000..846a5e9 --- /dev/null +++ b/hooks/direnv-bash-env @@ -0,0 +1,21 @@ +#!/usr/bin/env bash +# BASH_ENV script for Claude Code: loads direnv environment automatically. +# Bash sources this file before executing non-interactive commands (bash -c "..."). +# Paired with a cd() override so directory changes mid-command also trigger direnv. + +# Guard against recursion: direnv spawns bash to evaluate .envrc, +# which would source BASH_ENV again, causing an infinite loop. +if [ -n "$_DIRENV_BASH_ENV_ACTIVE" ]; then + return 0 2>/dev/null || exit 0 +fi +export _DIRENV_BASH_ENV_ACTIVE=1 + +# Load direnv for the initial working directory +eval "$(direnv export bash 2>/dev/null)" || true + +# Override directory-changing builtins so direnv reloads on any dir change +_direnv_reload() { eval "$(direnv export bash 2>/dev/null)" || true; } + +cd() { builtin cd "$@" || return $?; _direnv_reload; } +pushd() { builtin pushd "$@" || return $?; _direnv_reload; } +popd() { builtin popd "$@" || return $?; _direnv_reload; } From 054b333a1ad986aafa3d9b11bc28d57eb0574199 Mon Sep 17 00:00:00 2001 From: Allen Date: Sat, 21 Mar 2026 15:38:55 -0500 Subject: [PATCH 15/57] feat: migrate to standalone rl CLI (@0xbigboss/rl) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace embedded scripts/rl with the standalone @0xbigboss/rl package. Stop hook now delegates state, prompt, and log operations to the rl CLI via spawnSync instead of reimplementing them. - Delete scripts/rl (323 lines) - Refactor stop hook: remove parseStateFile, serializeState, readPrompt, appendLog, LogEntry, LoopState (872 → 654 lines) - Update ralph-loop.md: discover rl on PATH or via bunx - Update cancel-ralph.md: use rl status --json - Update QA.md: reference installed rl, add hook integration checks - Bump to v3.0.0 --- plugins/ralph-reviewed/QA.md | 24 +- .../ralph-reviewed/commands/cancel-ralph.md | 24 +- plugins/ralph-reviewed/commands/ralph-loop.md | 11 +- .../hooks/ralph-reviewed-stop-hook.ts | 508 +++++------------- plugins/ralph-reviewed/scripts/rl | 323 ----------- 5 files changed, 173 insertions(+), 717 deletions(-) delete mode 100755 plugins/ralph-reviewed/scripts/rl diff --git a/plugins/ralph-reviewed/QA.md b/plugins/ralph-reviewed/QA.md index 2234e20..9c8f774 100644 --- a/plugins/ralph-reviewed/QA.md +++ b/plugins/ralph-reviewed/QA.md @@ -1,11 +1,12 @@ # Ralph Reviewed QA -End-to-end test of the ralph-reviewed plugin v2.0.0. Run this from a test repo to verify the full loop works — `rl` CLI, stop hook, Codex review gate, feedback cycle. +End-to-end test of the ralph-reviewed plugin v3.0.0. Run this from a test repo to verify the full loop works — `rl` CLI, stop hook, Codex review gate, feedback cycle. ## Prerequisites - Test repo at `/tmp/ralph-test` with a broken `math.ts` (see setup below) - Plugin loaded via `--plugin-dir ~/code/dotfiles/claude-code/plugins/ralph-reviewed` +- `rl` CLI installed (`npm i -g @0xbigboss/rl` or `bunx @0xbigboss/rl`) - `codex` CLI installed and authenticated (for review gate tests) ## Setup @@ -35,8 +36,11 @@ git -C /tmp/ralph-test add -A && git -C /tmp/ralph-test commit -q -m "add broken ### What to verify during the loop +**rl discovery:** +- [ ] `rl` is found on PATH (via `command -v rl`) or falls back to `bunx @0xbigboss/rl` +- [ ] No references to `find ~/.claude/plugins` or embedded script paths + **rl init:** -- [ ] `rl init` is found and runs (check `find` or fallback path) - [ ] `.rl/state.json` exists with lean schema: `active`, `iteration`, `max_iterations`, `timestamp`, `review_enabled`, `review_count`, `max_review_cycles`, `debug` — no `completion_promise`, `original_prompt`, `pending_feedback`, `review_history` - [ ] `.rl/prompt.md` exists with the task text - [ ] `.rl/rl` symlink exists and works (`rl status` returns output) @@ -48,6 +52,12 @@ git -C /tmp/ralph-test add -A && git -C /tmp/ralph-test commit -q -m "add broken - [ ] Agent uses `.rl/rl done` to signal completion (not bare `COMPLETE` text) - [ ] Iteration headers from the stop hook show no denominators (e.g. `Iteration 1` not `Iteration 1/10`) +**Stop hook uses rl CLI:** +- [ ] Stop hook reads state via `rl status --json` (no local `parseStateFile`) +- [ ] Stop hook reads prompt via `rl prompt --json` (no local `readPrompt`) +- [ ] Stop hook updates state via `rl state set` (no local `serializeState`) +- [ ] Stop hook logs reviews via `rl log review` (no local `appendLog`) + **Codex review gate (if review enabled):** - [ ] Stop hook triggers Codex review after `.rl/rl done` - [ ] Review output saved to `.rl/codex-review-*.txt` (not deleted) @@ -59,7 +69,7 @@ git -C /tmp/ralph-test add -A && git -C /tmp/ralph-test commit -q -m "add broken **After loop ends:** - [ ] `state.json` is deleted (cleanup on approve/max/blocked) - [ ] `prompt.md`, `log.jsonl`, review outputs persist in `.rl/` -- [ ] `.rl/rl clean` removes the entire `.rl/` directory +- [ ] `rl clean` removes the entire `.rl/` directory ### What to watch for (bugs and edge cases) @@ -70,8 +80,8 @@ git -C /tmp/ralph-test add -A && git -C /tmp/ralph-test commit -q -m "add broken - Does the `.rl/rl` symlink survive across iterations? **Stop hook:** -- Does the hook correctly detect `completion_claimed` from fresh state.json re-read? -- Does the hook clear `completion_claimed` on the next iteration write (via `serializeState` which doesn't include the flag)? +- Does the hook correctly detect `completion_claimed` from `rl status --json`? +- Does the hook clear `completion_claimed` via `rl state set` on the next iteration? - Does the `blocked_claimed` flag work the same way? - If the agent runs `.rl/rl done` but then the review rejects, does the next iteration NOT think completion is still claimed? - What happens if max iterations and max reviews are both hit simultaneously? @@ -86,7 +96,7 @@ git -C /tmp/ralph-test add -A && git -C /tmp/ralph-test commit -q -m "add broken **Feedback cycle:** - Is the reviewer's free-text feedback passed through to the agent cleanly? - Does the agent get the original prompt re-injected on rejection? -- Does the `getLastRejectFeedback` function correctly find the last reject entry in log.jsonl? +- Does `getLastRejectFeedback` correctly find the last reject entry in log.jsonl? ## Test: No-review mode @@ -103,7 +113,7 @@ git -C /tmp/ralph-test add -A && git -C /tmp/ralph-test commit -q -m "add broken After any test: ```bash -.rl/rl clean +rl clean ls .rl/ # should fail — directory gone ``` diff --git a/plugins/ralph-reviewed/commands/cancel-ralph.md b/plugins/ralph-reviewed/commands/cancel-ralph.md index 1bfad05..bd8ef22 100644 --- a/plugins/ralph-reviewed/commands/cancel-ralph.md +++ b/plugins/ralph-reviewed/commands/cancel-ralph.md @@ -1,37 +1,25 @@ --- description: Cancel active Ralph Reviewed loop -allowed-tools: Bash(git:*), Bash(cat:*), Bash(rm:*), Bash(test:*) +allowed-tools: Bash(rl:*), Bash(.rl/rl:*), Bash(git:*), Bash(rm:*), Bash(test:*) --- # Cancel Ralph Reviewed Loop Stop an active Ralph loop immediately. -## Find State File - -Get git repository root (state file is at repo root): -```bash -git rev-parse --show-toplevel 2>/dev/null || pwd -``` -Store this as GIT_ROOT. - ## Check for Active Loop ```bash -test -f {GIT_ROOT}/.rl/state.json && echo "found" || echo "not_found" +rl status --json 2>/dev/null || .rl/rl status --json 2>/dev/null || echo '{"error": "no loop"}' ``` -## If Found +## If Active -1. Read state for reporting: - ```bash - cat {GIT_ROOT}/.rl/state.json - ``` - Extract `iteration` and `review_count` from the JSON. +1. Note the iteration and review count from the status output. -2. Delete the state file: +2. Delete the state file to end the loop: ```bash - rm {GIT_ROOT}/.rl/state.json + rm "$(git rev-parse --show-toplevel 2>/dev/null || pwd)/.rl/state.json" ``` 3. Report: diff --git a/plugins/ralph-reviewed/commands/ralph-loop.md b/plugins/ralph-reviewed/commands/ralph-loop.md index e618c56..02c6404 100644 --- a/plugins/ralph-reviewed/commands/ralph-loop.md +++ b/plugins/ralph-reviewed/commands/ralph-loop.md @@ -1,6 +1,6 @@ --- description: Start Ralph Reviewed loop in current session -allowed-tools: Bash(.rl/rl:*), Bash(git:*), Bash(cat:*) +allowed-tools: Bash(rl:*), Bash(.rl/rl:*), Bash(git:*), Bash(cat:*) argument-hint: "task description" [--max-iterations N] [--max-reviews N] [--no-review] [--debug] --- @@ -21,16 +21,15 @@ Parse the following from arguments: ## Setup -1. Locate the `rl` CLI — find it in the ralph-reviewed plugin scripts: +1. Locate the `rl` CLI — check if installed globally, fall back to bunx: ```bash - find ~/.claude/plugins -name rl -path '*/ralph-reviewed/scripts/*' 2>/dev/null | head -1 + command -v rl >/dev/null 2>&1 && echo "rl" || echo "bunx @0xbigboss/rl" ``` - If not found, try `~/code/dotfiles/claude-code/plugins/ralph-reviewed/scripts/rl`. - Store the path as RL_PATH. + Store the result as RL_CMD. 2. Initialize the loop (creates `.rl/` with state.json, prompt.md, and `.rl/rl` symlink): ```bash - {RL_PATH} init "{PROMPT}" --max-iterations {MAX_ITERATIONS} --max-reviews {MAX_REVIEWS} {--no-review if set} {--debug if set} + {RL_CMD} init "{PROMPT}" --max-iterations {MAX_ITERATIONS} --max-reviews {MAX_REVIEWS} {--no-review if set} {--debug if set} ``` 3. Verify setup: diff --git a/plugins/ralph-reviewed/hooks/ralph-reviewed-stop-hook.ts b/plugins/ralph-reviewed/hooks/ralph-reviewed-stop-hook.ts index 4ffd4f1..7668d06 100755 --- a/plugins/ralph-reviewed/hooks/ralph-reviewed-stop-hook.ts +++ b/plugins/ralph-reviewed/hooks/ralph-reviewed-stop-hook.ts @@ -5,16 +5,15 @@ * Intercepts exit attempts during an active Ralph loop. * When completion is claimed, triggers Codex review gate. * - * NOTE: Ralph loops only work within git repositories. The state file is stored - * at the git repo root (.rl/state.json) to ensure it survives directory changes - * within the repo. Outside of git repos, falls back to cwd but directory changes - * will break the loop. + * State, prompt, and log operations are delegated to the `rl` CLI + * (@0xbigboss/rl). The hook only handles stdin parsing, Codex review, + * and the block/allow decision. * * Flow: * 1. Check for active loop state file (at git repo root) * 2. If no loop, allow exit - * 3. Extract last assistant message from transcript - * 4. Check for completion promise + * 3. Read state via `rl status --json` + * 4. Check for completion/blocked flags * - Not found: increment iteration, block exit, re-feed prompt * - Found: trigger review gate * 5. Review gate: @@ -23,26 +22,24 @@ * - REJECT: inject feedback, block exit, continue */ -import { readFileSync, writeFileSync, existsSync, appendFileSync, unlinkSync, mkdirSync } from "node:fs"; -import { execSync, spawnSync } from "node:child_process"; +import { readFileSync, existsSync, appendFileSync, unlinkSync, mkdirSync } from "node:fs"; +import { spawnSync } from "node:child_process"; import { homedir } from "node:os"; +import { join } from "node:path"; // --- Version --- -// Update this when making changes to help diagnose cached code issues -const HOOK_VERSION = "2026-03-21T00:00:00Z"; -const HOOK_BUILD = "v2.0.0"; +const HOOK_VERSION = "2026-03-21T14:00:00Z"; +const HOOK_BUILD = "v3.0.0"; const STDIN_TIMEOUT_MS = 2000; // --- User Config --- -// User preferences stored in ~/.claude/codex.json -// Legacy fallback: ~/.claude/ralphs/config.json interface CodexConfig { sandbox?: "read-only" | "workspace-write" | "danger-full-access"; approval_policy?: "untrusted" | "on-failure" | "on-request" | "never"; bypass_sandbox?: boolean; extra_args?: string[]; - timeout_seconds?: number; // Timeout for Codex CLI call (default: 1200 = 20 min) + timeout_seconds?: number; } interface UserConfig { @@ -55,21 +52,18 @@ const DEFAULT_CONFIG: UserConfig = { approval_policy: "never", bypass_sandbox: false, extra_args: [], - timeout_seconds: 1200, // 20 minutes + timeout_seconds: 1200, }, }; let userConfig: UserConfig = DEFAULT_CONFIG; // --- Crash Reporting --- -// Session-specific logs stored in ~/.claude/ralphs/{session_id}/ -// Pre-session logs go to ~/.claude/ralphs/startup.log const ralphsDir = `${homedir()}/.claude/ralphs`; let sessionId = "unknown"; -let sessionLogDir = ralphsDir; // Updated when session ID is known -let crashLogPath = `${ralphsDir}/startup.log`; // Before we know session ID +let sessionLogDir = ralphsDir; +let crashLogPath = `${ralphsDir}/startup.log`; -// Ensure base ralphs directory exists try { mkdirSync(ralphsDir, { recursive: true }); } catch { /* ignore */ } @@ -77,9 +71,7 @@ try { // --- Config Loading --- function loadUserConfig(): UserConfig { - // Standard location: ~/.claude/codex.json const standardPath = `${homedir()}/.claude/codex.json`; - // Legacy fallback: ~/.claude/ralphs/config.json const legacyPath = `${ralphsDir}/config.json`; for (const configPath of [standardPath, legacyPath]) { @@ -87,7 +79,6 @@ function loadUserConfig(): UserConfig { if (existsSync(configPath)) { const content = readFileSync(configPath, "utf-8"); const parsed = JSON.parse(content) as Partial; - // Merge with defaults return { codex: { ...DEFAULT_CONFIG.codex, @@ -96,7 +87,6 @@ function loadUserConfig(): UserConfig { }; } } catch (e) { - // Log but don't fail - use defaults try { appendFileSync(`${ralphsDir}/startup.log`, `[${new Date().toISOString()}] Failed to load config from ${configPath}: ${e}\n`); } catch { /* ignore */ } @@ -105,7 +95,6 @@ function loadUserConfig(): UserConfig { return DEFAULT_CONFIG; } -// Load config at startup userConfig = loadUserConfig(); function crash(msg: string, error?: unknown) { @@ -122,7 +111,6 @@ function crash(msg: string, error?: unknown) { try { appendFileSync(crashLogPath, line); } catch { - // Last resort: stderr console.error(line); } } @@ -130,103 +118,65 @@ function crash(msg: string, error?: unknown) { function setSessionId(id: string) { sessionId = id; sessionLogDir = `${ralphsDir}/${id}`; - - // Create session-specific directory try { mkdirSync(sessionLogDir, { recursive: true }); } catch { /* ignore */ } - crashLogPath = `${sessionLogDir}/crash.log`; } -// Log startup immediately to help diagnose "operation aborted" errors crash(`Hook starting - version: ${HOOK_BUILD} (${HOOK_VERSION}), PID: ${process.pid}`); // Global error handlers +let stateFilePath: string | null = null; + process.on("uncaughtException", (err) => { crash("Uncaught exception", err); - // Clean up state file to avoid re-triggering loop if (stateFilePath) { - try { - unlinkSync(stateFilePath); - crash(`Cleaned up state file on uncaught exception: ${stateFilePath}`); - } catch { /* ignore cleanup errors */ } + try { unlinkSync(stateFilePath); crash(`Cleaned up state file on uncaught exception: ${stateFilePath}`); } catch { /* ignore */ } } - // Output empty JSON to avoid trapping user console.log(JSON.stringify({})); process.exit(1); }); process.on("unhandledRejection", (reason) => { crash("Unhandled rejection", reason); - // Clean up state file to avoid re-triggering loop if (stateFilePath) { - try { - unlinkSync(stateFilePath); - crash(`Cleaned up state file on unhandled rejection: ${stateFilePath}`); - } catch { /* ignore cleanup errors */ } + try { unlinkSync(stateFilePath); crash(`Cleaned up state file on unhandled rejection: ${stateFilePath}`); } catch { /* ignore */ } } - // Output empty JSON to avoid trapping user console.log(JSON.stringify({})); process.exit(1); }); -let debugLogPath = `${ralphsDir}/debug.log`; // Updated with session ID later +let debugLogPath = `${ralphsDir}/debug.log`; let debugEnabled = process.env.RALPH_DEBUG === "1"; -let stateFilePath: string | null = null; // Set in main() for error handler access function debug(msg: string) { - // Always log to crash log for traceability const timestamp = new Date().toISOString(); const line = `[${timestamp}] [${sessionId}] ${msg}\n`; - - // Always append to session crash log (for debugging crashes) try { appendFileSync(crashLogPath, `[DEBUG] ${line}`); } catch { /* ignore */ } - - // Only write to debug log if debug mode is enabled if (!debugEnabled) return; appendFileSync(debugLogPath, line); } -import { join } from "node:path"; // --- Git Utilities --- -/** - * Get the true root git repository, walking up through submodules. - * If cwd is inside a submodule, returns the top-level parent repo. - * Returns null if not in a git repo or git command fails. - */ function getGitRoot(cwd: string): string | null { try { let dir = cwd; - - // Walk up through submodule hierarchy to find true root while (true) { - // Check if we're in a submodule (has a parent superproject) const superResult = spawnSync("git", ["rev-parse", "--show-superproject-working-tree"], { - cwd: dir, - encoding: "utf-8", - timeout: 5000, + cwd: dir, encoding: "utf-8", timeout: 5000, }); - const superproject = superResult.status === 0 ? superResult.stdout.trim() : ""; - if (!superproject) { - // No parent superproject - this is the true root (or we're not in a submodule) const rootResult = spawnSync("git", ["rev-parse", "--show-toplevel"], { - cwd: dir, - encoding: "utf-8", - timeout: 5000, + cwd: dir, encoding: "utf-8", timeout: 5000, }); - if (rootResult.status === 0 && rootResult.stdout) { - return rootResult.stdout.trim(); - } + if (rootResult.status === 0 && rootResult.stdout) return rootResult.stdout.trim(); return null; } - - // Move up to the parent repo and check again (handles nested submodules) dir = superproject; } } catch { @@ -234,154 +184,63 @@ function getGitRoot(cwd: string): string | null { } } -/** - * Determine the state file path. - * Uses git repo root if available, otherwise falls back to cwd. - */ function getStateFilePath(cwd: string): string { const gitRoot = getGitRoot(cwd); - const baseDir = gitRoot || cwd; - return join(baseDir, ".rl", "state.json"); -} - -// --- Types --- - -interface HookInput { - session_id: string; - transcript_path?: string; - cwd?: string; - permission_mode?: string; - hook_event_name: "Stop"; - stop_hook_active?: boolean; -} - -/** - * Hook output schema for Claude Code stop hooks. - * See: https://code.claude.com/docs/en/hooks.md - * - * - decision: "block" prevents stopping (omit to allow) - * - reason: Message shown to Claude when blocking - * - systemMessage: Optional message shown to user regardless of decision - */ -interface HookOutput { - decision?: "block"; - reason?: string; - systemMessage?: string; - continue?: boolean; - stopReason?: string; + return join(gitRoot || cwd, ".rl", "state.json"); } +// --- rl CLI integration --- -interface LoopState { - active: boolean; - iteration: number; - max_iterations: number; - timestamp: string; - review_enabled: boolean; - review_count: number; - max_review_cycles: number; - debug: boolean; +function callRl(args: string[], cwd: string): { ok: boolean; stdout: string } { + const result = spawnSync("rl", args, { cwd, encoding: "utf-8", timeout: 10000 }); + if (result.status !== 0) { + crash(`rl ${args.join(" ")} failed: ${result.stderr || result.stdout}`); + return { ok: false, stdout: result.stdout || "" }; + } + return { ok: true, stdout: result.stdout || "" }; } - -// --- State File Parsing --- - -function parseStateFile(content: string): LoopState | null { +function rlStatusJson(cwd: string): Record | null { + const result = callRl(["status", "--json"], cwd); + if (!result.ok) return null; try { - const parsed = JSON.parse(content) as Partial; - - // Validate required fields - if ( - parsed.active === undefined || - parsed.iteration === undefined || - parsed.max_iterations === undefined - ) { - return null; - } - - return { - active: parsed.active, - iteration: parsed.iteration, - max_iterations: parsed.max_iterations, - timestamp: parsed.timestamp || new Date().toISOString(), - review_enabled: parsed.review_enabled ?? true, - review_count: parsed.review_count ?? 0, - max_review_cycles: parsed.max_review_cycles ?? parsed.max_iterations, - debug: parsed.debug ?? false, - }; + return JSON.parse(result.stdout) as Record; } catch { + crash(`Failed to parse rl status output: ${result.stdout.slice(0, 200)}`); return null; } } -function serializeState(state: LoopState): string { - return JSON.stringify(state, null, 2) + "\n"; -} - -// --- State File Cleanup --- - -function cleanupStateFile(stateFilePath: string): void { +function rlPrompt(cwd: string): string | null { + const result = callRl(["prompt", "--json"], cwd); + if (!result.ok) return null; try { - if (existsSync(stateFilePath)) { - unlinkSync(stateFilePath); - crash(`State file deleted: ${stateFilePath}`); - debug(`[ralph-reviewed] Cleaned up state file: ${stateFilePath}`); - } - } catch (e) { - crash(`Failed to delete state file: ${stateFilePath}`, e); - debug(`[ralph-reviewed] Failed to cleanup state file: ${e}`); + const parsed = JSON.parse(result.stdout); + return typeof parsed === "string" ? parsed : null; + } catch { + return result.stdout.trim() || null; } } -// --- JSONL Log --- - -type LogEntry = Record & { - ts: string; - type: "review" | "phase" | "commit" | "decision" | "summary"; -}; - -/** - * Get the log file path from the state file path. - * State is at .rl/state.json, log is at .rl/log.jsonl. - */ -function getLogFilePath(stateFile: string): string { - return join(stateFile, "..", "log.jsonl"); -} +// --- State File Cleanup --- -function appendLog(stateFile: string, entry: LogEntry): void { +function cleanupStateFile(path: string): void { try { - // Ensure .rl/ directory exists - const dir = join(stateFile, ".."); - mkdirSync(dir, { recursive: true }); - const logPath = getLogFilePath(stateFile); - appendFileSync(logPath, JSON.stringify(entry) + "\n"); + if (existsSync(path)) { + unlinkSync(path); + crash(`State file deleted: ${path}`); + debug(`[ralph-reviewed] Cleaned up state file: ${path}`); + } } catch (e) { - crash(`Failed to append to log.jsonl`, e); + crash(`Failed to delete state file: ${path}`, e); } } -// --- Prompt and Review History from .rl/ --- +// --- Last Reject Feedback --- -/** - * Read the original prompt from .rl/prompt.md. - */ -function readPrompt(stateFile: string): string | null { +function getLastRejectFeedback(rlDir: string): string | null { try { - const promptPath = join(stateFile, "..", "prompt.md"); - if (!existsSync(promptPath)) return null; - return readFileSync(promptPath, "utf-8").trim(); - } catch { - return null; - } -} - -/** - * Get feedback from the last review if it was a rejection. - * Scans backwards — only needs the last review entry. - */ -function getLastRejectFeedback(stateFile: string): string | null { - try { - const logFilePath = getLogFilePath(stateFile); + const logFilePath = join(rlDir, "log.jsonl"); if (!existsSync(logFilePath)) return null; const content = readFileSync(logFilePath, "utf-8").trim(); if (!content) return null; @@ -393,39 +252,47 @@ function getLastRejectFeedback(stateFile: string): string | null { if (parsed.type !== "review") continue; if (parsed.decision !== "reject") return null; return typeof parsed.feedback === "string" ? parsed.feedback : null; - } catch { - continue; - } + } catch { continue; } } - } catch { - return null; - } + } catch { /* ignore */ } return null; } -// --- Codex Review --- +// --- Types --- + +interface HookInput { + session_id: string; + transcript_path?: string; + cwd?: string; + permission_mode?: string; + hook_event_name: "Stop"; + stop_hook_active?: boolean; +} + +interface HookOutput { + decision?: "block"; + reason?: string; + systemMessage?: string; + continue?: boolean; + stopReason?: string; +} interface ReviewResult { approved: boolean; feedback: string; } -function callCodexReview( - reviewCount: number, - cwd: string -): ReviewResult { +// --- Codex Review --- + +function callCodexReview(reviewCount: number, cwd: string): ReviewResult { crash(`callCodexReview() started - reviewCount=${reviewCount}, cwd=${cwd}`); - // Check if codex is available const whichResult = spawnSync("which", ["codex"], { encoding: "utf-8" }); if (whichResult.status !== 0) { crash("Codex CLI not found, approving by default"); - debug("Codex CLI not found, approving by default"); return { approved: true, feedback: "" }; } - crash(`Codex found at: ${whichResult.stdout?.trim()}`); - // Build review prompt const reviewPrompt = `# Code Review An agent worked on a task in an iterative loop and claims it's done. Review the work. @@ -460,20 +327,13 @@ If rejecting, explain what's wrong and what needs to change. Be specific and act Review ${reviewCount + 1}.`; - // Write review output to .rl/ directory const uniqueId = Date.now(); const rlDirPath = stateFilePath ? join(stateFilePath, "..") : "/tmp"; const outputFile = join(rlDirPath, `codex-review-${uniqueId}.txt`); - crash(`Calling Codex with output file: ${outputFile}`); - try { - // Build args dynamically from user config const codexConfig = userConfig.codex || DEFAULT_CONFIG.codex!; - const codexArgs: string[] = [ - "exec", - "-", // read prompt from stdin - ]; + const codexArgs: string[] = ["exec", "-"]; if (codexConfig.bypass_sandbox) { codexArgs.push("--dangerously-bypass-approvals-and-sandbox"); @@ -486,60 +346,44 @@ Review ${reviewCount + 1}.`; if (Array.isArray(codexConfig.extra_args)) { for (const arg of codexConfig.extra_args) { - if (typeof arg === "string") { - codexArgs.push(arg); - } + if (typeof arg === "string") codexArgs.push(arg); } } const timeoutMs = (codexConfig.timeout_seconds || 1200) * 1000; - crash(`Codex args: ${JSON.stringify(codexArgs)}, timeout: ${timeoutMs}ms`); const result = spawnSync("codex", codexArgs, { - cwd, - encoding: "utf-8", - timeout: timeoutMs, - maxBuffer: 16 * 1024 * 1024, - input: reviewPrompt, + cwd, encoding: "utf-8", timeout: timeoutMs, maxBuffer: 16 * 1024 * 1024, input: reviewPrompt, }); crash(`Codex returned - status: ${result.status}, signal: ${result.signal}`); if (result.stderr) crash(`Codex stderr: ${result.stderr.slice(0, 500)}`); - // Read output - let output = ""; + let codexOutput = ""; if (existsSync(outputFile)) { - output = readFileSync(outputFile, "utf-8"); - crash(`Codex output: ${output.slice(0, 500)}`); + codexOutput = readFileSync(outputFile, "utf-8"); + crash(`Codex output: ${codexOutput.slice(0, 500)}`); } else { crash("No Codex output file created"); } - // Parse verdict — last tag wins - const reviewMatches = [...output.matchAll(/\s*(APPROVE|REJECT)\s*<\/review>/gi)]; + const reviewMatches = [...codexOutput.matchAll(/\s*(APPROVE|REJECT)\s*<\/review>/gi)]; const verdict = reviewMatches.length > 0 ? reviewMatches[reviewMatches.length - 1][1].toUpperCase() : null; crash(`Verdict: ${verdict}`); - if (verdict === "APPROVE") { - return { approved: true, feedback: output }; - } - + if (verdict === "APPROVE") return { approved: true, feedback: codexOutput }; if (verdict === "REJECT") { - // Extract feedback — everything after the last REJECT tag - const lastTag = output.lastIndexOf(""); - const feedback = lastTag >= 0 - ? output.slice(0, lastTag).trim() - : output.trim(); + const lastTag = codexOutput.lastIndexOf(""); + const feedback = lastTag >= 0 ? codexOutput.slice(0, lastTag).trim() : codexOutput.trim(); return { approved: false, feedback }; } - // No clear verdict — approve by default crash("No APPROVE/REJECT found, approving by default"); - return { approved: true, feedback: output }; + return { approved: true, feedback: codexOutput }; } catch (e) { crash("Codex review failed", e); return { approved: true, feedback: "" }; @@ -570,29 +414,12 @@ async function readStdin(): Promise { resolved = true; cleanup(); resolve(data); - } catch { - // keep reading - } - }; - - const onData = (chunk: string | Buffer) => { - data += chunk.toString(); - tryResolve(); - }; - - const onEnd = () => { - if (resolved) return; - resolved = true; - cleanup(); - resolve(data); + } catch { /* keep reading */ } }; - const onError = (err: Error) => { - if (resolved) return; - resolved = true; - cleanup(); - reject(err); - }; + const onData = (chunk: string | Buffer) => { data += chunk.toString(); tryResolve(); }; + const onEnd = () => { if (resolved) return; resolved = true; cleanup(); resolve(data); }; + const onError = (err: Error) => { if (resolved) return; resolved = true; cleanup(); reject(err); }; const timer = setTimeout(() => { if (resolved) return; @@ -636,109 +463,94 @@ async function main() { input = JSON.parse(trimmed); } catch (parseErr) { crash("Failed to parse input JSON", parseErr); - crash(`Raw input was: ${trimmed.slice(0, 500)}`); throw parseErr; } } - // Switch to session-specific crash log immediately setSessionId(input.session_id || "unknown"); const cwd = input.cwd || process.env.CLAUDE_PROJECT_DIR || process.cwd(); - crash(`Input parsed: session_id=${input.session_id}, cwd=${cwd}, event=${input.hook_event_name}`); + crash(`Input parsed: session_id=${input.session_id}, cwd=${cwd}`); - // Use git repo root for state file to handle directory changes within repo const gitRoot = getGitRoot(cwd); stateFilePath = getStateFilePath(cwd); - - // Set session-specific file paths debugLogPath = `${sessionLogDir}/debug.log`; - crash(`State file: ${stateFilePath}, Git root: ${gitRoot || "none"}, cwd: ${cwd}, logs: ${sessionLogDir}`); + crash(`State file: ${stateFilePath}, Git root: ${gitRoot || "none"}, cwd: ${cwd}`); - // Check for active loop + // Fast gate: no state file means no active loop if (!existsSync(stateFilePath)) { crash("No state file found, approving exit"); output({}); return; } - crash("State file exists, reading..."); - // Parse state - const stateContent = readFileSync(stateFilePath, "utf-8"); - const state = parseStateFile(stateContent); + // Read full state via rl CLI (single call, cached for the hook invocation) + const rlCwd = gitRoot || cwd; + const state = rlStatusJson(rlCwd); if (!state) { - // Corrupt state file - clean up and exit - crash("Failed to parse state file, cleaning up"); + crash("Failed to read state via rl, cleaning up"); cleanupStateFile(stateFilePath); output({}); return; } if (!state.active) { - // Loop was deactivated - clean up stale file and exit crash("Loop inactive, cleaning up stale state file"); cleanupStateFile(stateFilePath); output({}); return; } - // Enable debug if set in state if (state.debug) { debugEnabled = true; debug(`[ralph-reviewed] Debug enabled via state file`); } - // Check for completion/blocked via state flags (set by `rl done`) - // Re-read state to pick up flags set during this iteration - const freshContent = readFileSync(stateFilePath, "utf-8"); - const freshState = JSON.parse(freshContent) as Record; - const completionClaimed = freshState.completion_claimed === true; - const blockedClaimed = freshState.blocked_claimed === true; + const iteration = state.iteration as number; + const maxIterations = state.max_iterations as number; + const reviewEnabled = state.review_enabled as boolean; + const reviewCount = state.review_count as number; + const maxReviewCycles = state.max_review_cycles as number; + const completionClaimed = state.completion_claimed === true; + const blockedClaimed = state.blocked_claimed === true; - debug(`[ralph-reviewed] Iteration: ${state.iteration}, done: ${completionClaimed}, blocked: ${blockedClaimed}`); + debug(`[ralph-reviewed] Iteration: ${iteration}, done: ${completionClaimed}, blocked: ${blockedClaimed}`); if (blockedClaimed) { - // BLOCKED is a special termination signal - exit without Codex review crash("BLOCKED claimed - terminating loop without review"); - debug(`[ralph-reviewed] BLOCKED signal received. Terminating loop without review.`); cleanupStateFile(stateFilePath); output({ - systemMessage: `# Ralph Loop: BLOCKED - -**Iteration:** ${state.iteration} - -Task reported as blocked. Loop terminated without review.` + systemMessage: `# Ralph Loop: BLOCKED\n\n**Iteration:** ${iteration}\n\nTask reported as blocked. Loop terminated without review.` }); return; } if (!completionClaimed) { // Normal iteration - no completion claimed - state.iteration++; + const nextIteration = iteration + 1; - // Check max iterations - if (state.iteration >= state.max_iterations) { - // Max iterations reached - allow exit - debug(`[ralph-reviewed] Max iterations (${state.max_iterations}) reached, exiting loop`); + if (nextIteration >= maxIterations) { + debug(`[ralph-reviewed] Max iterations (${maxIterations}) reached, exiting loop`); cleanupStateFile(stateFilePath); - output({ - systemMessage: `# Ralph Loop: Max Iterations Reached - -**Iteration:** ${state.iteration} - -Loop ended without completion claim. Review the work and consider restarting if needed.` + output({ + systemMessage: `# Ralph Loop: Max Iterations Reached\n\n**Iteration:** ${nextIteration}\n\nLoop ended without completion claim. Review the work and consider restarting if needed.` }); return; } - // Update state file - writeFileSync(stateFilePath, serializeState(state)); + // Update iteration via rl + callRl(["state", "set", "iteration", String(nextIteration)], rlCwd); + + // Clear any stale completion/blocked flags + callRl(["state", "set", "completion_claimed", "false"], rlCwd); + callRl(["state", "set", "blocked_claimed", "false"], rlCwd); // Build continuation prompt - const originalPrompt = readPrompt(stateFilePath) || "(no prompt found)"; - let prompt = `# Ralph Loop \u2014 Iteration ${state.iteration}\n\n`; + const originalPrompt = rlPrompt(rlCwd) || "(no prompt found)"; + let prompt = `# Ralph Loop \u2014 Iteration ${nextIteration}\n\n`; - const pendingFeedback = getLastRejectFeedback(stateFilePath); + const rlDir = join(rlCwd, ".rl"); + const pendingFeedback = getLastRejectFeedback(rlDir); if (pendingFeedback) { prompt += `## Review Feedback from Previous Attempt\n\n${pendingFeedback}\n\nAddress the above feedback.\n\n---\n\n`; } @@ -753,87 +565,67 @@ Loop ended without completion claim. Review the work and consider restarting if // Completion claimed - enter review gate debug(`[ralph-reviewed] Completion claimed! Entering review gate...`); - if (!state.review_enabled) { - // Reviews disabled - allow exit + if (!reviewEnabled) { debug(`[ralph-reviewed] Reviews disabled, approving exit`); cleanupStateFile(stateFilePath); output({}); return; } - // Require git repository - Codex needs a trusted directory if (!gitRoot) { crash("Not in a git repository - BLOCKING (Codex requires git repo)"); output({ decision: "block", - reason: `# Review Gate Error: Not a Git Repository - -Codex requires a git repository to run. The current directory is not inside a git repo. - -**Current directory:** \`${cwd}\` - -**To fix:** Initialize a git repository with \`git init\`, or move the project into an existing git repo. - -**To escape this loop:** Run \`/ralph-reviewed:cancel-ralph\` to remove the loop, then exit normally.` + reason: `# Review Gate Error: Not a Git Repository\n\nCodex requires a git repository to run.\n\n**Current directory:** \`${cwd}\`\n\n**To fix:** Initialize a git repository with \`git init\`.\n\n**To escape this loop:** Run \`/ralph-reviewed:cancel-ralph\` to remove the loop, then exit normally.` }); return; } - // Perform Codex review debug(`[ralph-reviewed] Calling Codex for review...`); - const reviewPromptText = readPrompt(stateFilePath) || "(no prompt found)"; - - const reviewResult = callCodexReview( - state.review_count, - gitRoot || cwd - ); + const reviewResult = callCodexReview(reviewCount, gitRoot); debug(`[ralph-reviewed] Review result: approved=${reviewResult.approved}`); - // Log review to .rl/log.jsonl - appendLog(stateFilePath, { - ts: new Date().toISOString(), - type: "review", - cycle: state.review_count + 1, - decision: reviewResult.approved ? "approve" : "reject", - feedback: reviewResult.feedback, - }); + // Log review via rl + callRl(["log", "review", "--decision", reviewResult.approved ? "approve" : "reject", "--feedback", reviewResult.feedback], rlCwd); if (reviewResult.approved) { debug(`[ralph-reviewed] Codex approved! Exiting loop.`); cleanupStateFile(stateFilePath); output({ - systemMessage: `# Ralph Loop: Codex APPROVED\n\n**Iteration:** ${state.iteration} | **Review cycle:** ${state.review_count + 1}\n\nReview gate cleared.` + systemMessage: `# Ralph Loop: Codex APPROVED\n\n**Iteration:** ${iteration} | **Review cycle:** ${reviewCount + 1}\n\nReview gate cleared.` }); return; } // Rejected - state.review_count++; + const newReviewCount = reviewCount + 1; - if (state.review_count >= state.max_review_cycles) { - debug(`[ralph-reviewed] Max review cycles (${state.max_review_cycles}) reached.`); + if (newReviewCount >= maxReviewCycles) { + debug(`[ralph-reviewed] Max review cycles (${maxReviewCycles}) reached.`); cleanupStateFile(stateFilePath); output({ - systemMessage: `# Ralph Loop: Max Review Cycles Reached\n\n**Iteration:** ${state.iteration} | **Review cycle:** ${state.review_count}\n\nLoop ended without approval. Review feedback manually.` + systemMessage: `# Ralph Loop: Max Review Cycles Reached\n\n**Iteration:** ${iteration} | **Review cycle:** ${newReviewCount}\n\nLoop ended without approval. Review feedback manually.` }); return; } - state.iteration++; - writeFileSync(stateFilePath, serializeState(state)); + const nextIteration = iteration + 1; + callRl(["state", "set", "iteration", String(nextIteration)], rlCwd); + callRl(["state", "set", "review_count", String(newReviewCount)], rlCwd); + callRl(["state", "set", "completion_claimed", "false"], rlCwd); - // Feed back the reviewer's feedback + original prompt - const feedbackPrompt = `# Ralph Loop \u2014 Iteration ${state.iteration} + const reviewPromptText = rlPrompt(rlCwd) || "(no prompt found)"; + const feedbackPrompt = `# Ralph Loop \u2014 Iteration ${nextIteration} -## Review Feedback (Cycle ${state.review_count}) +## Review Feedback (Cycle ${newReviewCount}) -${reviewResult.feedback} +Your previous completion was reviewed and requires changes. ---- +${reviewResult.feedback} -Fix the issues above, then run \`.rl/rl done\` when complete. +Address ALL open issues above, then output COMPLETE when truly complete. --- @@ -842,15 +634,9 @@ ${reviewPromptText}`; output({ decision: "block", reason: feedbackPrompt }); } catch (e) { crash("main() caught exception", e); - debug(`Stop hook error: ${e}`); - // Clean up state file to avoid re-triggering loop if (stateFilePath) { - try { - unlinkSync(stateFilePath); - crash(`Cleaned up state file on main() exception: ${stateFilePath}`); - } catch { /* ignore cleanup errors */ } + try { unlinkSync(stateFilePath); crash(`Cleaned up state file on main() exception: ${stateFilePath}`); } catch { /* ignore */ } } - // On error, allow exit to avoid trapping user output({}); } crash("main() exiting normally"); @@ -859,12 +645,8 @@ ${reviewPromptText}`; crash("About to call main()"); main().catch((e) => { crash("main() promise rejected", e); - // Clean up state file to avoid re-triggering loop if (stateFilePath) { - try { - unlinkSync(stateFilePath); - crash(`Cleaned up state file on main() rejection: ${stateFilePath}`); - } catch { /* ignore cleanup errors */ } + try { unlinkSync(stateFilePath); crash(`Cleaned up state file on main() rejection: ${stateFilePath}`); } catch { /* ignore */ } } console.log(JSON.stringify({})); process.exit(1); diff --git a/plugins/ralph-reviewed/scripts/rl b/plugins/ralph-reviewed/scripts/rl deleted file mode 100755 index 135ffdb..0000000 --- a/plugins/ralph-reviewed/scripts/rl +++ /dev/null @@ -1,323 +0,0 @@ -#!/usr/bin/env bun -/** - * rl — Ralph Loop CLI for setup, logging, and status. - * - * Usage: - * rl init "" [--max-iterations N] [--max-reviews N] [--no-review] [--debug] - * rl done [--blocked] - * rl prompt - * rl status - * rl log phase - * rl log commit - * rl log decision - * rl log summary - * rl clean - */ - -import { appendFileSync, readFileSync, writeFileSync, mkdirSync, existsSync, symlinkSync, realpathSync, rmSync } from "node:fs"; -import { spawnSync } from "node:child_process"; -import { join, resolve } from "node:path"; - -// --- Git root resolution (matches stop hook — walks through superprojects) --- - -function getGitRoot(): string { - try { - let dir = process.cwd(); - while (true) { - const superResult = spawnSync("git", ["rev-parse", "--show-superproject-working-tree"], { - cwd: dir, encoding: "utf-8", timeout: 5000, - }); - const superproject = superResult.status === 0 ? superResult.stdout.trim() : ""; - if (!superproject) { - const rootResult = spawnSync("git", ["rev-parse", "--show-toplevel"], { - cwd: dir, encoding: "utf-8", timeout: 5000, - }); - if (rootResult.status === 0 && rootResult.stdout) return rootResult.stdout.trim(); - return process.cwd(); - } - dir = superproject; - } - } catch { - return process.cwd(); - } -} - -// --- Path helpers --- - -function rlDir(): string { - return join(getGitRoot(), ".rl"); -} - -function ensureRlDir(): void { - mkdirSync(rlDir(), { recursive: true }); -} - -function logPath(): string { - return join(rlDir(), "log.jsonl"); -} - -function statePath(): string { - return join(rlDir(), "state.json"); -} - -function promptPath(): string { - return join(rlDir(), "prompt.md"); -} - -// --- Resolve the real script path (follows symlinks) --- - -function realScriptPath(): string { - try { - // import.meta.path gives the resolved path in Bun - return (import.meta as { path?: string }).path || realpathSync(process.argv[1]); - } catch { - return resolve(process.argv[1]); - } -} - -// --- Commands --- - -function init(args: string[]): void { - // Parse flags from the end, prompt is everything before flags - let maxIterations = 30; - let maxReviews: number | null = null; - let noReview = false; - let debug = false; - const promptParts: string[] = []; - - for (let i = 0; i < args.length; i++) { - const arg = args[i]; - if (arg === "--max-iterations" && i + 1 < args.length) { - maxIterations = parseInt(args[++i], 10); - } else if (arg === "--max-reviews" && i + 1 < args.length) { - maxReviews = parseInt(args[++i], 10); - } else if (arg === "--no-review") { - noReview = true; - } else if (arg === "--debug") { - debug = true; - } else { - promptParts.push(arg); - } - } - - const prompt = promptParts.join(" "); - if (!prompt) { - console.error("usage: rl init \"\" [--max-iterations N] [--max-reviews N] [--no-review] [--debug]"); - process.exit(1); - } - - const dir = rlDir(); - ensureRlDir(); - - const ts = new Date().toISOString().replace(/\.\d{3}Z$/, "Z"); - const effectiveMaxReviews = maxReviews ?? maxIterations; - - // Write state.json - const state = { - active: true, - iteration: 0, - max_iterations: maxIterations, - timestamp: ts, - review_enabled: !noReview, - review_count: 0, - max_review_cycles: effectiveMaxReviews, - debug, - }; - writeFileSync(statePath(), JSON.stringify(state, null, 2) + "\n"); - - // Write prompt.md - writeFileSync(promptPath(), prompt + "\n"); - - // Reset log.jsonl (new session — don't carry over stale entries) - writeFileSync(logPath(), ""); - - // Add .rl/ to .git/info/exclude if in a git repo - try { - const gitRoot = getGitRoot(); - const excludePath = join(gitRoot, ".git", "info", "exclude"); - if (existsSync(excludePath)) { - const content = readFileSync(excludePath, "utf-8"); - if (!content.includes(".rl/")) { - appendFileSync(excludePath, "\n# Ralph loop state\n.rl/\n"); - } - } - } catch { - // Not critical - } - - // Create .rl/rl symlink to this script - const symlinkPath = join(dir, "rl"); - const scriptPath = realScriptPath(); - try { - if (!existsSync(symlinkPath)) { - symlinkSync(scriptPath, symlinkPath); - } - } catch { - // Symlink may fail on some systems; not critical - } - - console.log(`ralph loop initialized (${maxIterations} iterations, review: ${!noReview})`); -} - -function showPrompt(): void { - const path = promptPath(); - if (!existsSync(path)) { - console.error("no prompt found (.rl/prompt.md missing)"); - process.exit(1); - } - console.log(readFileSync(path, "utf-8").trim()); -} - -function appendLogEntry(entry: Record): void { - if (!existsSync(statePath())) { - console.error("no active ralph loop (run rl init first)"); - process.exit(1); - } - entry.ts = new Date().toISOString(); - appendFileSync(logPath(), JSON.stringify(entry) + "\n"); - console.log(`logged ${entry.type}: ${entry.note || entry.summary || entry.phase || ""}`); -} - -function logPhase(args: string[]): void { - const note = args.join(" "); - if (!note) { - console.error("usage: rl log phase "); - process.exit(1); - } - appendLogEntry({ type: "phase", phase: note, note }); -} - -function logCommit(args: string[]): void { - const sha = args[0]; - const summary = args.slice(1).join(" "); - if (!sha || !summary) { - console.error("usage: rl log commit "); - process.exit(1); - } - appendLogEntry({ type: "commit", sha, summary }); -} - -function logDecision(args: string[]): void { - const note = args.join(" "); - if (!note) { - console.error("usage: rl log decision "); - process.exit(1); - } - appendLogEntry({ type: "decision", note }); -} - -function logSummary(args: string[]): void { - const note = args.join(" "); - if (!note) { - console.error("usage: rl log summary "); - process.exit(1); - } - appendLogEntry({ type: "summary", note }); -} - -function done(args: string[]): void { - const path = statePath(); - if (!existsSync(path)) { - console.error("no active ralph loop"); - process.exit(1); - } - try { - const state = JSON.parse(readFileSync(path, "utf-8")); - if (args.includes("--blocked")) { - state.blocked_claimed = true; - delete state.completion_claimed; - writeFileSync(path, JSON.stringify(state, null, 2) + "\n"); - console.log("marked as BLOCKED — loop will terminate without review"); - } else { - state.completion_claimed = true; - delete state.blocked_claimed; - writeFileSync(path, JSON.stringify(state, null, 2) + "\n"); - const msg = state.review_enabled - ? "marked as COMPLETE — stop hook will trigger review gate" - : "marked as COMPLETE — loop will exit (review disabled)"; - console.log(msg); - } - } catch { - console.error("failed to update state.json"); - process.exit(1); - } -} - -function clean(): void { - const dir = rlDir(); - if (!existsSync(dir)) { - console.log("nothing to clean (.rl/ not found)"); - return; - } - rmSync(dir, { recursive: true, force: true }); - console.log("removed .rl/"); -} - -function status(): void { - const path = statePath(); - if (!existsSync(path)) { - console.log("no active ralph loop"); - process.exit(0); - } - try { - const state = JSON.parse(readFileSync(path, "utf-8")); - const parts: string[] = []; - parts.push(`iteration: ${state.iteration ?? "?"}`); - parts.push(`reviews: ${state.review_count ?? 0}`); - parts.push(`active: ${state.active ?? false}`); - parts.push(`review: ${state.review_enabled ? "on" : "off"}`); - console.log(parts.join(" ")); - } catch { - console.error("failed to read state.json"); - process.exit(1); - } -} - -// --- Main --- - -const args = process.argv.slice(2); -const command = args[0]; - -switch (command) { - case "init": - init(args.slice(1)); - break; - case "prompt": - showPrompt(); - break; - case "done": - done(args.slice(1)); - break; - case "clean": - clean(); - break; - case "status": - status(); - break; - case "log": { - const subcommand = args[1]; - const rest = args.slice(2); - switch (subcommand) { - case "phase": - logPhase(rest); - break; - case "commit": - logCommit(rest); - break; - case "decision": - logDecision(rest); - break; - case "summary": - logSummary(rest); - break; - default: - console.error(`unknown log type: ${subcommand}`); - console.error("usage: rl log "); - process.exit(1); - } - break; - } - default: - console.error("usage: rl [args...]"); - process.exit(1); -} From e717aa385b8a82ac2d43c74b7d846bd6b6c33829 Mon Sep 17 00:00:00 2001 From: Allen Date: Sat, 21 Mar 2026 15:51:57 -0500 Subject: [PATCH 16/57] fix: rl discovery, reject prompt, version bump - Stop hook: find rl via PATH, then .rl/rl wrapper fallback - Stop hook: reject prompt says ".rl/rl done" not "" - ralph-loop.md: add bunx/npx/command to allowed-tools - Bump plugin.json to v3.0.0 --- .../ralph-reviewed/.claude-plugin/plugin.json | 2 +- plugins/ralph-reviewed/commands/ralph-loop.md | 2 +- .../hooks/ralph-reviewed-stop-hook.ts | 31 +++++++++++++++++-- 3 files changed, 30 insertions(+), 5 deletions(-) diff --git a/plugins/ralph-reviewed/.claude-plugin/plugin.json b/plugins/ralph-reviewed/.claude-plugin/plugin.json index 69d1e31..cd82f74 100644 --- a/plugins/ralph-reviewed/.claude-plugin/plugin.json +++ b/plugins/ralph-reviewed/.claude-plugin/plugin.json @@ -5,6 +5,6 @@ "name": "Allen", "email": "bigboss@metalrodeo.xyz" }, - "version": "2.0.0", + "version": "3.0.0", "commands": "./commands/" } diff --git a/plugins/ralph-reviewed/commands/ralph-loop.md b/plugins/ralph-reviewed/commands/ralph-loop.md index 02c6404..c5a15f8 100644 --- a/plugins/ralph-reviewed/commands/ralph-loop.md +++ b/plugins/ralph-reviewed/commands/ralph-loop.md @@ -1,6 +1,6 @@ --- description: Start Ralph Reviewed loop in current session -allowed-tools: Bash(rl:*), Bash(.rl/rl:*), Bash(git:*), Bash(cat:*) +allowed-tools: Bash(rl:*), Bash(.rl/rl:*), Bash(bunx:*), Bash(npx:*), Bash(git:*), Bash(cat:*), Bash(command:*) argument-hint: "task description" [--max-iterations N] [--max-reviews N] [--no-review] [--debug] --- diff --git a/plugins/ralph-reviewed/hooks/ralph-reviewed-stop-hook.ts b/plugins/ralph-reviewed/hooks/ralph-reviewed-stop-hook.ts index 7668d06..b41833c 100755 --- a/plugins/ralph-reviewed/hooks/ralph-reviewed-stop-hook.ts +++ b/plugins/ralph-reviewed/hooks/ralph-reviewed-stop-hook.ts @@ -191,10 +191,35 @@ function getStateFilePath(cwd: string): string { // --- rl CLI integration --- +let rlBinary: string | null = null; + +function findRl(cwd: string): string { + if (rlBinary) return rlBinary; + + // 1. Check if rl is on PATH + const which = spawnSync("which", ["rl"], { encoding: "utf-8" }); + if (which.status === 0 && which.stdout.trim()) { + rlBinary = "rl"; + return rlBinary; + } + + // 2. Use .rl/rl wrapper (created by rl init with absolute path fallback) + const wrapper = join(cwd, ".rl", "rl"); + if (existsSync(wrapper)) { + rlBinary = wrapper; + return rlBinary; + } + + // 3. Last resort: try rl anyway (may fail) + rlBinary = "rl"; + return rlBinary; +} + function callRl(args: string[], cwd: string): { ok: boolean; stdout: string } { - const result = spawnSync("rl", args, { cwd, encoding: "utf-8", timeout: 10000 }); + const rl = findRl(cwd); + const result = spawnSync(rl, args, { cwd, encoding: "utf-8", timeout: 10000 }); if (result.status !== 0) { - crash(`rl ${args.join(" ")} failed: ${result.stderr || result.stdout}`); + crash(`${rl} ${args.join(" ")} failed: ${result.stderr || result.stdout}`); return { ok: false, stdout: result.stdout || "" }; } return { ok: true, stdout: result.stdout || "" }; @@ -625,7 +650,7 @@ Your previous completion was reviewed and requires changes. ${reviewResult.feedback} -Address ALL open issues above, then output COMPLETE when truly complete. +Address ALL open issues above, then run \`.rl/rl done\` when truly complete. --- From 7ffafd5fe9799b52512471988d97b6d4f43caba1 Mon Sep 17 00:00:00 2001 From: Allen Date: Sat, 21 Mar 2026 15:59:35 -0500 Subject: [PATCH 17/57] docs: update QA install instructions for GitHub Packages registry --- plugins/ralph-reviewed/QA.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/ralph-reviewed/QA.md b/plugins/ralph-reviewed/QA.md index 9c8f774..b28ea1e 100644 --- a/plugins/ralph-reviewed/QA.md +++ b/plugins/ralph-reviewed/QA.md @@ -6,7 +6,7 @@ End-to-end test of the ralph-reviewed plugin v3.0.0. Run this from a test repo t - Test repo at `/tmp/ralph-test` with a broken `math.ts` (see setup below) - Plugin loaded via `--plugin-dir ~/code/dotfiles/claude-code/plugins/ralph-reviewed` -- `rl` CLI installed (`npm i -g @0xbigboss/rl` or `bunx @0xbigboss/rl`) +- `rl` CLI installed (see [github.com/0xbigboss/rl](https://github.com/0xbigboss/rl) — `npm i -g @0xbigboss/rl --registry https://npm.pkg.github.com`) - `codex` CLI installed and authenticated (for review gate tests) ## Setup From 5cff6640e0d0b89c302fec04337f9caee13a2d63 Mon Sep 17 00:00:00 2001 From: Allen Date: Sat, 21 Mar 2026 20:28:51 -0500 Subject: [PATCH 18/57] feat: auto-install rl CLI from source on first use MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ralph-loop.md now builds rl from github.com/0xbigboss/rl via bun build --compile if not already on PATH. Zero manual setup needed — just run /ralph-reviewed:ralph-loop. --- plugins/ralph-reviewed/QA.md | 2 +- plugins/ralph-reviewed/commands/ralph-loop.md | 26 ++++++++++++++----- 2 files changed, 20 insertions(+), 8 deletions(-) diff --git a/plugins/ralph-reviewed/QA.md b/plugins/ralph-reviewed/QA.md index b28ea1e..8ba820b 100644 --- a/plugins/ralph-reviewed/QA.md +++ b/plugins/ralph-reviewed/QA.md @@ -6,7 +6,7 @@ End-to-end test of the ralph-reviewed plugin v3.0.0. Run this from a test repo t - Test repo at `/tmp/ralph-test` with a broken `math.ts` (see setup below) - Plugin loaded via `--plugin-dir ~/code/dotfiles/claude-code/plugins/ralph-reviewed` -- `rl` CLI installed (see [github.com/0xbigboss/rl](https://github.com/0xbigboss/rl) — `npm i -g @0xbigboss/rl --registry https://npm.pkg.github.com`) +- `rl` CLI on PATH (auto-installed by `/ralph-reviewed:ralph-loop` on first use, or manually: `git clone https://github.com/0xbigboss/rl /tmp/rl && cd /tmp/rl && bun install && bun build src/cli.ts --compile --outfile ~/.local/bin/rl`) - `codex` CLI installed and authenticated (for review gate tests) ## Setup diff --git a/plugins/ralph-reviewed/commands/ralph-loop.md b/plugins/ralph-reviewed/commands/ralph-loop.md index c5a15f8..3a922b6 100644 --- a/plugins/ralph-reviewed/commands/ralph-loop.md +++ b/plugins/ralph-reviewed/commands/ralph-loop.md @@ -1,6 +1,6 @@ --- description: Start Ralph Reviewed loop in current session -allowed-tools: Bash(rl:*), Bash(.rl/rl:*), Bash(bunx:*), Bash(npx:*), Bash(git:*), Bash(cat:*), Bash(command:*) +allowed-tools: Bash(rl:*), Bash(.rl/rl:*), Bash(bun:*), Bash(git:*), Bash(cat:*), Bash(command:*), Bash(mkdir:*) argument-hint: "task description" [--max-iterations N] [--max-reviews N] [--no-review] [--debug] --- @@ -21,15 +21,27 @@ Parse the following from arguments: ## Setup -1. Locate the `rl` CLI — check if installed globally, fall back to bunx: +1. Ensure the `rl` CLI is installed. If not on PATH, build from source: ```bash - command -v rl >/dev/null 2>&1 && echo "rl" || echo "bunx @0xbigboss/rl" + command -v rl >/dev/null 2>&1 || ( + echo "Installing rl CLI..." && + git clone https://github.com/0xbigboss/rl /tmp/rl-build 2>/dev/null && + cd /tmp/rl-build && + bun install --frozen-lockfile && + mkdir -p ~/.local/bin && + bun build src/cli.ts --compile --outfile ~/.local/bin/rl && + rm -rf /tmp/rl-build && + echo "rl installed to ~/.local/bin/rl" + ) + ``` + Verify it's available: + ```bash + rl --version ``` - Store the result as RL_CMD. -2. Initialize the loop (creates `.rl/` with state.json, prompt.md, and `.rl/rl` symlink): +2. Initialize the loop (creates `.rl/` with state.json, prompt.md, and `.rl/rl` wrapper): ```bash - {RL_CMD} init "{PROMPT}" --max-iterations {MAX_ITERATIONS} --max-reviews {MAX_REVIEWS} {--no-review if set} {--debug if set} + rl init "{PROMPT}" --max-iterations {MAX_ITERATIONS} --max-reviews {MAX_REVIEWS} {--no-review if set} {--debug if set} ``` 3. Verify setup: @@ -37,7 +49,7 @@ Parse the following from arguments: .rl/rl status ``` -All subsequent `rl` calls use `.rl/rl` (symlink created by init). +All subsequent `rl` calls use `.rl/rl` (wrapper created by init). ## Completion and Escape From c92db1ca3fa8aa4b58a7fe529630b0f79118ab2f Mon Sep 17 00:00:00 2001 From: Allen Date: Sat, 21 Mar 2026 23:17:05 -0500 Subject: [PATCH 19/57] docs(guidelines): add liberal intent-commenting directive Override default "comment only when non-obvious" behavior with an explicit preference for more comments that explain why, not what. --- CLAUDE.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index 2e4934a..426befa 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -129,6 +129,10 @@ Skills provide error handling conventions, code quality patterns, type-first dev - Use brief bullets when it improves scanability; paths in backticks; code fences only when helpful. - Technical documentation in third person; instructions in second person; avoid first person. +## Code comments + +Comment liberally. Every comment must explain intent, rationale, or non-obvious constraints — never restate what the code does. Good comments answer "why this approach?" and "what would break if this changed?" Bad comments narrate syntax. Prefer more comments over fewer; when in doubt, comment. + ## Error handling and completeness - **Errors must be handled or returned to callers**; every error requires explicit handling at every level of the stack (universal principle across all languages). - Fail loudly with clear messages on missing data or unsupported cases (silent failures compound into system-wide issues). From 00f93cf73d7c00d421d41b5134fda5a4e0834a2b Mon Sep 17 00:00:00 2001 From: Allen Date: Mon, 23 Mar 2026 21:36:58 -0500 Subject: [PATCH 20/57] refactor: move handoffs from ~/.claude/handoffs to ~/.handoffs Claude Code has internal protection on ~/.claude that blocks writes even with explicit permissions and additionalDirectories configured. Moving handoffs outside the protected zone eliminates approval prompts. --- .gitmodules | 3 +++ claude-code | 1 + commands/handoff.md | 10 +++++----- commands/ralph.md | 4 ++-- commands/ralphoff.md | 12 ++++++------ plugins/codex-reviewer/commands/review.md | 8 ++++---- settings/settings.json | 5 +++-- 7 files changed, 24 insertions(+), 19 deletions(-) create mode 160000 claude-code diff --git a/.gitmodules b/.gitmodules index 09ebfd0..d1a6b70 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,3 +1,6 @@ [submodule "analytics"] path = analytics url = git@github.com:0xbigboss/claude-code-analytics.git +[submodule "claude-code"] + path = claude-code + url = https://github.com/0xBigBoss/claude-code.git diff --git a/claude-code b/claude-code new file mode 160000 index 0000000..c92db1c --- /dev/null +++ b/claude-code @@ -0,0 +1 @@ +Subproject commit c92db1ca3fa8aa4b58a7fe529630b0f79118ab2f diff --git a/commands/handoff.md b/commands/handoff.md index 72ec974..62e8177 100644 --- a/commands/handoff.md +++ b/commands/handoff.md @@ -1,5 +1,5 @@ --- -allowed-tools: Bash(git:*), Bash(pwd:*), Bash(cat:*), Bash(basename:*), Bash(mkdir:*), Bash(date:*), Write(~/.claude/handoffs/**), Read(~/.claude/handoffs/**) +allowed-tools: Bash(git:*), Bash(pwd:*), Bash(cat:*), Bash(basename:*), Bash(mkdir:*), Bash(date:*), Write(~/.handoffs/**), Read(~/.handoffs/**) argument-hint: [optional focus area or additional notes] description: Generate concise handoff summary with context --- @@ -37,7 +37,7 @@ $ARGUMENTS ## Task -Write a handoff prompt to `~/.claude/handoffs/handoff---.md` where: +Write a handoff prompt to `~/.handoffs/handoff---.md` where: - `` is the repository name (or directory basename if not a git repo) - `` is derived from the branch name, or use `main` if not in a git repo - `` is the current date/time as `YYYYMMDD-HHMM` (e.g., `20260303-1430`) @@ -128,10 +128,10 @@ Omit if no special constraints beyond normal development.] ### Output Method -1. Ensure directory exists: `mkdir -p ~/.claude/handoffs` +1. Ensure directory exists: `mkdir -p ~/.handoffs` -2. Write the handoff prompt to `~/.claude/handoffs/handoff---.md` +2. Write the handoff prompt to `~/.handoffs/handoff---.md` 3. Generate the timestamp using: `date +%Y%m%d-%H%M` -4. Confirm with the path: "Handoff saved to `~/.claude/handoffs/`" +4. Confirm with the path: "Handoff saved to `~/.handoffs/`" diff --git a/commands/ralph.md b/commands/ralph.md index 575a8eb..9bb3fba 100644 --- a/commands/ralph.md +++ b/commands/ralph.md @@ -27,7 +27,7 @@ Invoke the `/ralphoff` skill with HANDOFF_ARGS. This will: - Analyze the current session context -- Write a context file to `~/.claude/handoffs/ralph---.md` +- Write a context file to `~/.handoffs/ralph---.md` - Prepare the task description with success criteria and verification loops **Important**: Note the exact filename created (e.g., `ralph-myrepo-feature-x-20260303-1430.md`). @@ -39,7 +39,7 @@ This will: **CRITICAL: After the handoff context is saved, you MUST continue with this step. The Ralph loop is NOT active until you invoke ralph-loop. Do not stop after the handoff.** Invoke `/ralph-reviewed:ralph-loop` with: -- The task prompt: `Read ~/.claude/handoffs/ and complete the task described there. Work through each step, verify with the "Done when" commands. Run .rl/rl done when all verifications pass, or .rl/rl done --blocked if stuck.` +- The task prompt: `Read ~/.handoffs/ and complete the task described there. Work through each step, verify with the "Done when" commands. Run .rl/rl done when all verifications pass, or .rl/rl done --blocked if stuck.` - The parsed LOOP_FLAGS (or defaults: `--max-iterations 30 --max-reviews 20`) --- diff --git a/commands/ralphoff.md b/commands/ralphoff.md index 62e1ff7..fba348e 100644 --- a/commands/ralphoff.md +++ b/commands/ralphoff.md @@ -1,5 +1,5 @@ --- -allowed-tools: Bash(git:*), Bash(pwd:*), Bash(cat:*), Bash(basename:*), Bash(mkdir:*), Bash(date:*), Write(~/.claude/handoffs/**), Read(~/.claude/handoffs/**) +allowed-tools: Bash(git:*), Bash(pwd:*), Bash(cat:*), Bash(basename:*), Bash(mkdir:*), Bash(date:*), Write(~/.handoffs/**), Read(~/.handoffs/**) argument-hint: [completion criteria] [--max-iterations N] [--max-reviews N] [--no-review] [--debug] description: Generate Ralph-loop-ready handoff prompt --- @@ -49,7 +49,7 @@ HANDOFF_ARGS (the completion criteria portion of $ARGUMENTS, excluding any `--` ## Task -Write a Ralph-loop context file to `~/.claude/handoffs/ralph---.md` where: +Write a Ralph-loop context file to `~/.handoffs/ralph---.md` where: - `` is the repository name - `` is derived from the branch name - `` is the current date/time as `YYYYMMDD-HHMM` @@ -164,11 +164,11 @@ Only include if there are known risk areas. Omit for straightforward tasks.] ### Output Method -1. Ensure directory exists: `mkdir -p ~/.claude/handoffs` +1. Ensure directory exists: `mkdir -p ~/.handoffs` -2. Write the Ralph-loop context file to `~/.claude/handoffs/ralph---.md` +2. Write the Ralph-loop context file to `~/.handoffs/ralph---.md` -3. Confirm with the path: "Ralph-loop context saved to `~/.claude/handoffs/`" +3. Confirm with the path: "Ralph-loop context saved to `~/.handoffs/`" ### Timestamp Generation @@ -179,5 +179,5 @@ Generate the timestamp using: `date +%Y%m%d-%H%M` When using this context file with `/ralph-reviewed:ralph-loop`, the command format is: ``` -/ralph-reviewed:ralph-loop "Read ~/.claude/handoffs/ and complete the task described there. Follow the success criteria and verification loop. Run .rl/rl done when all verifications pass, or .rl/rl done --blocked if stuck." +/ralph-reviewed:ralph-loop "Read ~/.handoffs/ and complete the task described there. Follow the success criteria and verification loop. Run .rl/rl done when all verifications pass, or .rl/rl done --blocked if stuck." ``` diff --git a/plugins/codex-reviewer/commands/review.md b/plugins/codex-reviewer/commands/review.md index c72fbac..f639182 100644 --- a/plugins/codex-reviewer/commands/review.md +++ b/plugins/codex-reviewer/commands/review.md @@ -1,7 +1,7 @@ --- description: Start a Codex review gate - generates handoff context for the reviewer argument-hint: ["review focus"] [--max-cycles N] -allowed-tools: Bash(git:*), Bash(pwd:*), Bash(cat:*), Bash(head:*), Bash(grep:*), Bash(basename:*), Bash(mkdir:*), Bash(date:*), Write(**/.claude/codex-review.local.md), Read(~/.claude/handoffs/**), Read(**/.claude/codex-review.local.md) +allowed-tools: Bash(git:*), Bash(pwd:*), Bash(cat:*), Bash(head:*), Bash(grep:*), Bash(basename:*), Bash(mkdir:*), Bash(date:*), Write(**/.claude/codex-review.local.md), Read(~/.handoffs/**), Read(**/.claude/codex-review.local.md) --- # Start Codex Review Gate @@ -57,7 +57,7 @@ The review gate is already active. **ALLOWED when gate is active:** - Regenerate the handoff file (`/handoff`) to capture your latest work -- The handoff file at `~/.claude/handoffs/` can be updated freely +- The handoff file at `~/.handoffs/` can be updated freely **FORBIDDEN when gate is active:** - Writing to the state file (`.claude/codex-review.local.md`) @@ -92,7 +92,7 @@ Invoke the `/handoff` skill with the review focus. **Default focus (no arguments):** > Generate a handoff for a code reviewer who will verify the changes made in this session. Focus on what was changed, why, and how to verify correctness. -The handoff will be written to `~/.claude/handoffs/handoff--.md`. +The handoff will be written to `~/.handoffs/handoff--.md`. --- @@ -116,7 +116,7 @@ The handoff will be written to `~/.claude/handoffs/handoff--.md ```yaml --- active: true -handoff_path: "~/.claude/handoffs/handoff--.md" +handoff_path: "~/.handoffs/handoff--.md" task_description: null files_changed: ["file1.ts", "file2.ts"] review_count: 0 diff --git a/settings/settings.json b/settings/settings.json index 13e4640..7819939 100644 --- a/settings/settings.json +++ b/settings/settings.json @@ -6,7 +6,8 @@ }, "permissions": { "allow": [ - "Edit(~/.claude/handoffs/**)", + "Edit(~/.handoffs/**)", + "Write(~/.handoffs/**)", "Skill(typescript-best-practices)", "Skill(react-best-practices)", @@ -90,7 +91,7 @@ "Bash(wc:*)" ], "additionalDirectories": [ - "~/.claude/handoffs" + "~/.handoffs" ] }, "hooks": { From 98341aaf6168948ff7aaaa5223dd4f00ec6c23ff Mon Sep 17 00:00:00 2001 From: 0xBigBoss <95193764+0xBigBoss@users.noreply.github.com> Date: Mon, 23 Mar 2026 22:11:00 -0500 Subject: [PATCH 21/57] fix: remove accidental self-referencing submodule entry Commit 00f93cf introduced a claude-code submodule pointer inside the claude-code repo itself. Remove the bogus gitlink and .gitmodules entry. --- .gitmodules | 3 --- claude-code | 1 - 2 files changed, 4 deletions(-) delete mode 160000 claude-code diff --git a/.gitmodules b/.gitmodules index d1a6b70..09ebfd0 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,6 +1,3 @@ [submodule "analytics"] path = analytics url = git@github.com:0xbigboss/claude-code-analytics.git -[submodule "claude-code"] - path = claude-code - url = https://github.com/0xBigBoss/claude-code.git diff --git a/claude-code b/claude-code deleted file mode 160000 index c92db1c..0000000 --- a/claude-code +++ /dev/null @@ -1 +0,0 @@ -Subproject commit c92db1ca3fa8aa4b58a7fe529630b0f79118ab2f From 307f17ffd708dd0acebf5c6ef8f16969c2e17923 Mon Sep 17 00:00:00 2001 From: 0xBigBoss <95193764+0xBigBoss@users.noreply.github.com> Date: Mon, 23 Mar 2026 22:18:01 -0500 Subject: [PATCH 22/57] feat(skills): add rl CLI skill definitions Skill wrappers for rl-clean, rl-done, rl-init, rl-log, rl-prompt, rl-state, and rl-status commands. --- .claude/skills/rl-clean | 1 + .claude/skills/rl-done | 1 + .claude/skills/rl-init | 1 + .claude/skills/rl-log | 1 + .claude/skills/rl-prompt | 1 + .claude/skills/rl-state | 1 + .claude/skills/rl-status | 1 + 7 files changed, 7 insertions(+) create mode 120000 .claude/skills/rl-clean create mode 120000 .claude/skills/rl-done create mode 120000 .claude/skills/rl-init create mode 120000 .claude/skills/rl-log create mode 120000 .claude/skills/rl-prompt create mode 120000 .claude/skills/rl-state create mode 120000 .claude/skills/rl-status diff --git a/.claude/skills/rl-clean b/.claude/skills/rl-clean new file mode 120000 index 0000000..6aedce1 --- /dev/null +++ b/.claude/skills/rl-clean @@ -0,0 +1 @@ +../../../../../.agents/skills/rl-clean \ No newline at end of file diff --git a/.claude/skills/rl-done b/.claude/skills/rl-done new file mode 120000 index 0000000..830c829 --- /dev/null +++ b/.claude/skills/rl-done @@ -0,0 +1 @@ +../../../../../.agents/skills/rl-done \ No newline at end of file diff --git a/.claude/skills/rl-init b/.claude/skills/rl-init new file mode 120000 index 0000000..37200c8 --- /dev/null +++ b/.claude/skills/rl-init @@ -0,0 +1 @@ +../../../../../.agents/skills/rl-init \ No newline at end of file diff --git a/.claude/skills/rl-log b/.claude/skills/rl-log new file mode 120000 index 0000000..1dbc5de --- /dev/null +++ b/.claude/skills/rl-log @@ -0,0 +1 @@ +../../../../../.agents/skills/rl-log \ No newline at end of file diff --git a/.claude/skills/rl-prompt b/.claude/skills/rl-prompt new file mode 120000 index 0000000..c78a742 --- /dev/null +++ b/.claude/skills/rl-prompt @@ -0,0 +1 @@ +../../../../../.agents/skills/rl-prompt \ No newline at end of file diff --git a/.claude/skills/rl-state b/.claude/skills/rl-state new file mode 120000 index 0000000..3a18549 --- /dev/null +++ b/.claude/skills/rl-state @@ -0,0 +1 @@ +../../../../../.agents/skills/rl-state \ No newline at end of file diff --git a/.claude/skills/rl-status b/.claude/skills/rl-status new file mode 120000 index 0000000..0ecceec --- /dev/null +++ b/.claude/skills/rl-status @@ -0,0 +1 @@ +../../../../../.agents/skills/rl-status \ No newline at end of file From f4a05e4c67f94b74c65081466109da757c5eb28a Mon Sep 17 00:00:00 2001 From: 0xBigBoss <95193764+0xBigBoss@users.noreply.github.com> Date: Tue, 24 Mar 2026 11:58:58 -0500 Subject: [PATCH 23/57] feat(skills): add /improve skill for structured improvement analysis Standalone skill that replaces the manual "knowing what you know now, what are some improvements?" prompt with structured, grounded output. Analyzes across six dimensions (correctness, simplicity, security, tests, performance, DX) and produces [IMP-N] format suggestions that integrate with ralph loops and handoffs. --- .claude/skills/improve/SKILL.md | 119 ++++++++++++++++++++++++++++++++ 1 file changed, 119 insertions(+) create mode 100644 .claude/skills/improve/SKILL.md diff --git a/.claude/skills/improve/SKILL.md b/.claude/skills/improve/SKILL.md new file mode 100644 index 0000000..f5dd77c --- /dev/null +++ b/.claude/skills/improve/SKILL.md @@ -0,0 +1,119 @@ +--- +name: improve +description: Structured improvement analysis grounded in session observations. Use after completing a task, before claiming done, before handoff, or when the user asks "what are some improvements?" Surfaces concrete suggestions across correctness, simplicity, security, tests, performance, and DX — prioritized by impact, each citing what was actually observed. +--- + +# Improve + +Structured improvement pass grounded in what was observed during this session. + +## Principles (Always Active) + +- Every suggestion must cite a specific observation — file path, error seen, pattern noticed, or behavior verified. "I saw X" beats "common practice suggests Y." +- Distinguish grounded suggestions (observed evidence) from speculative ones (general knowledge). Mark speculative suggestions explicitly. +- Prioritize by impact to the user, not by ease of description. +- Do not suggest improvements that would change public API contracts, auth boundaries, or data schemas without flagging as high-risk. +- Simplicity is a feature. Do not suggest adding complexity unless it prevents a concrete problem observed in this session. + +## When to Use + +- User asks "knowing what you know now, what are some improvements?" or similar +- Before claiming done in a ralph loop (self-check) +- Before generating a handoff +- After completing a significant chunk of work +- When reviewing code that was just written or modified + +## When Not to Use + +- At the start of a session before meaningful work has been done +- When the user explicitly asks to skip review and ship +- For architectural redesigns (use spec/plan flow instead) + +## Dimensions + +Analyze across these dimensions. Skip any dimension with nothing grounded to say. + +### Correctness +Error handling gaps, unhandled edge cases, type safety issues, race conditions, missing validation at system boundaries. + +### Simplicity +Unnecessary indirection, dead code, naming that obscures intent, over-engineered abstractions, complexity that doesn't earn its keep. Reference the `simplify` skill for implementation. + +### Security +Auth boundary gaps, input validation holes, secret handling issues, OWASP concerns observed in touched code. + +### Test Coverage +Untested code paths observed during the session, missing test layers (unit/integration/e2e), fixture realism gaps, assertions that don't verify meaningful behavior. + +### Performance +Bottlenecks observed during the session (slow queries, N+1 patterns, unnecessary recomputation). Only flag what was actually observed, not hypothetical. + +### Developer Experience +API ergonomics issues, missing documentation for non-obvious behavior, CLI friction, configuration that should be extracted. + +## Workflow + +1. **Gather context**: Review what happened this session — files changed, commands run, errors encountered, tests written, patterns established. + +2. **Analyze each dimension**: For each, check whether session observations surface anything concrete. Skip dimensions with nothing to say. + +3. **Structure each suggestion**: + +``` +[IMP-N] effort/gate: summary + Observation: what was seen (file:line, error message, test gap, etc.) + Suggestion: concrete change + Grounded: yes/no +``` + +Where: +- `effort`: `trivial` (< 5 min) | `small` (< 30 min) | `medium` (hours) +- `gate`: which delivery gate this strengthens: `TDD` | `DEV` | `E2E` | `REVIEW` | `CI` + +4. **Sort by impact** (highest first), not by dimension. + +5. **Present as actionable list**. If in a ralph loop, address trivial/small improvements inline before claiming done. Log medium improvements as decisions for the user to prioritize. + +## Integration with Ralph Loops + +When running inside a ralph loop (`.rl/state.json` exists): + +- Run the improvement pass before `.rl/rl done` +- Address `trivial` improvements inline — just fix them +- Address `small` improvements if they're within the current milestone scope +- Log `medium` improvements: `.rl/rl log decision "IMP-N deferred: [reason]"` +- Do not expand scope beyond the task's milestones for medium improvements + +## Integration with Handoffs + +When generating a handoff, include unaddressed improvements in the `` section. Use the same `[IMP-N]` format so the receiving agent can reference them. + +## Output Format + +```markdown +## Improvements + +N suggestions (X grounded, Y speculative) + +[IMP-1] trivial/DEV: Extract magic number to config constant + Observation: `src/server.ts:42` uses hardcoded port 3000, but other services read from env + Suggestion: Read from `PORT` env var with 3000 as default + Grounded: yes + +[IMP-2] small/TDD: Add boundary test for empty input + Observation: `processItems([])` path untested — saw it handle non-empty arrays only in test suite + Suggestion: Add test case for empty array input in `process-items.test.ts` + Grounded: yes + +[IMP-3] medium/E2E: Add timeout handling for external API calls + Observation: Catalog search took 1.2s avg in profiling — no timeout configured + Suggestion: Add configurable timeout with bounded retry + Grounded: yes +``` + +## Red Flags + +- Suggestions without observations — if you can't point to something specific, don't suggest it +- More than 10 suggestions — focus on the top 5-7 by impact +- Suggestions that expand scope beyond what was touched — stay within the blast radius of the current work +- Generic "add more tests" without specifying which paths are untested From 8ac13669ad79b94e6236361ccfd18a3789d7b6df Mon Sep 17 00:00:00 2001 From: 0xBigBoss <95193764+0xBigBoss@users.noreply.github.com> Date: Tue, 24 Mar 2026 14:25:53 -0500 Subject: [PATCH 24/57] chore: remove deprecated /ralph and /ralphoff commands Replaced by /rl:ralph plugin command. --- commands/ralph.md | 71 ----------------- commands/ralphoff.md | 183 ------------------------------------------- 2 files changed, 254 deletions(-) delete mode 100644 commands/ralph.md delete mode 100644 commands/ralphoff.md diff --git a/commands/ralph.md b/commands/ralph.md deleted file mode 100644 index 9bb3fba..0000000 --- a/commands/ralph.md +++ /dev/null @@ -1,71 +0,0 @@ ---- -description: Generate handoff and start Ralph loop in one command -argument-hint: [completion criteria] [--max-iterations N] [--max-reviews N] [--no-review] [--debug] ---- - -# Ralph: Handoff + Loop Combined - -Chain `/ralphoff` and `/ralph-reviewed:ralph-loop` into a single workflow. - -## Parse Arguments - -Arguments: $ARGUMENTS - -Split arguments into two groups: -- **HANDOFF_ARGS**: Everything before any `--` flags (passed to ralphoff as completion criteria) -- **LOOP_FLAGS**: Any `--max-iterations`, `--max-reviews`, `--no-review`, `--debug` flags (passed to ralph-loop) - -Default loop flags if not specified: -- `--max-iterations 30` -- `--max-reviews 20` - -## Workflow - -### Step 1: Generate Handoff Context - -Invoke the `/ralphoff` skill with HANDOFF_ARGS. - -This will: -- Analyze the current session context -- Write a context file to `~/.handoffs/ralph---.md` -- Prepare the task description with success criteria and verification loops - -**Important**: Note the exact filename created (e.g., `ralph-myrepo-feature-x-20260303-1430.md`). - ---- - -### MANDATORY: Step 2 - Start Ralph Loop - -**CRITICAL: After the handoff context is saved, you MUST continue with this step. The Ralph loop is NOT active until you invoke ralph-loop. Do not stop after the handoff.** - -Invoke `/ralph-reviewed:ralph-loop` with: -- The task prompt: `Read ~/.handoffs/ and complete the task described there. Work through each step, verify with the "Done when" commands. Run .rl/rl done when all verifications pass, or .rl/rl done --blocked if stuck.` -- The parsed LOOP_FLAGS (or defaults: `--max-iterations 30 --max-reviews 20`) - ---- - -### MANDATORY: Step 3 - Verify Loop Started - -**CRITICAL: You MUST complete this step. Verify the loop state file was created.** - -1. **Verify the loop is active**: - ```bash - .rl/rl status - ``` - -2. If status shows active, begin working on the task immediately. - -## Example Usage - -``` -/ralph # Use session context, default settings -/ralph fix all type errors # With specific completion criteria -/ralph --max-iterations 50 # Override iteration limit -/ralph --max-reviews 3 # Limit review cycles -/ralph complete the refactor --no-review # Skip Codex reviews -/ralph implement feature --debug # Enable debug logging -``` - -## Output - -After starting the loop, work begins immediately on the task from the handoff context. diff --git a/commands/ralphoff.md b/commands/ralphoff.md deleted file mode 100644 index fba348e..0000000 --- a/commands/ralphoff.md +++ /dev/null @@ -1,183 +0,0 @@ ---- -allowed-tools: Bash(git:*), Bash(pwd:*), Bash(cat:*), Bash(basename:*), Bash(mkdir:*), Bash(date:*), Write(~/.handoffs/**), Read(~/.handoffs/**) -argument-hint: [completion criteria] [--max-iterations N] [--max-reviews N] [--no-review] [--debug] -description: Generate Ralph-loop-ready handoff prompt ---- - -# Generate Ralph Loop Handoff Prompt - -Generate a prompt for handing off work to a Ralph Reviewed loop (`/ralph-reviewed:ralph-loop`). The receiving session runs in an iterative self-improvement loop with Codex review gates. The prompt must be self-contained, include clear success criteria, and support automatic verification. - -## Parse Arguments - -Arguments: $ARGUMENTS - -Split arguments into two groups: -- **HANDOFF_ARGS**: Everything before any `--` flags (used as completion criteria) -- **LOOP_FLAGS**: Any `--max-iterations`, `--max-reviews`, `--no-review`, `--debug` flags (passed to ralph-loop) - -Default loop flags if not specified: -- `--max-iterations 30` - -## Git Context - -**Working Directory**: !`pwd` - -**Repository**: !`git rev-parse --show-toplevel 2>/dev/null || echo "Not a git repository"` - -**Branch**: !`git branch --show-current 2>/dev/null || echo "detached/unknown"` - -**Uncommitted changes**: !`git diff --stat 2>/dev/null || echo "None"` - -**Staged changes**: !`git diff --cached --stat 2>/dev/null || echo "None"` - -**Recent commits (last 4 hours)**: !`git log --oneline -5 --since="4 hours ago" 2>/dev/null || echo "None"` - -## Session Context - -Review the conversation history from this session to understand: -- What task was requested and why -- What approach was taken -- Decisions made or tradeoffs discussed -- Current state: what's done, in progress, or blocked -- What verification exists (tests, linters, type checks, builds) -- Known issues or incomplete items - -## Additional Focus / Completion Criteria - -HANDOFF_ARGS (the completion criteria portion of $ARGUMENTS, excluding any `--` flags) - -## Task - -Write a Ralph-loop context file to `~/.handoffs/ralph---.md` where: -- `` is the repository name -- `` is derived from the branch name -- `` is the current date/time as `YYYYMMDD-HHMM` - -Example: `ralph-myapp-sen-69-20260303-1430.md` - -### Core Principle - -**A ralph handoff is just a handoff with verification commands.** Apply the same principles as `/handoff`: describe what to type, be concrete, link don't summarize, keep it short. The ralph-loop runner handles iteration state, TODO.md tracking, and completion via `rl done` — don't duplicate that machinery in the handoff. - -### What belongs in the ralph handoff (task-specific) - -- What to build/fix/implement — concrete steps with file paths and function names -- How to verify it's done — exact shell commands -- Task-specific gotchas and fallback strategies -- Constraints and scope boundaries - -### What does NOT belong (handled by ralph-loop runner) - -- TODO.md format or iteration workflow instructions -- Generic "if stuck, document the blocker" guidance -- Completion mechanism (`rl done` / `rl done --blocked`) -- State tracking file management -- Generic BLOCKED escape conditions - -### Prompting Guidelines - -- **Be concrete over comprehensive** — file paths, function names, shell commands, specific values -- **Link, don't summarize** — "See `SPEC.md` for requirements" beats paraphrasing the spec -- **Include constraints** — "Only modify files under `src/`" and "Do NOT modify `packages/core/`" -- **Merge criteria and verification** — don't list success criteria separately from verification commands. One section: "Done when these commands all pass." -- **Front-load the task** — the agent should know what to do after reading the first 10 lines -- **Keep it proportional** — single-phase tasks: 60-100 lines. Multi-phase (3+): up to 200. Over 200 means you're probably summarizing things the agent can read themselves. - -### Output Structure - -Use plain markdown (not XML tags): - -```markdown -# [1-line task summary] - -[2-4 sentences: what exists, why, key decisions already made] - -## What to do - -### 1. [First concrete task] -[Details: file paths, function names, expected behavior, code snippets if helpful] - -### 2. [Second concrete task] -[Details] - -### 3. [Continue as needed] - -## Key files - -- `path/to/file.ts` — what it does / why it matters - -## Spec - -[OPTIONAL — link to spec file, don't repeat its contents] - -## Done when - -"Done when" commands should verify behavior, not just file existence. - -Bad: `ls app/test/setup.tsx` -Better: `grep -c 'useQuery' app/test/setup.tsx` -Best: `bun test:integration --grep "test flow"` - -Include a verification discovery fallback: -1. Check for e2e/integration tests covering the changed flows -2. If none, check for a dev environment to boot -3. If neither, note the gap as a known limitation - -All of these pass: -```bash -command-to-build -command-to-test -command-to-check-scope -``` -[Plus any non-command criteria like "E2E tests exist for each waitFor state"] - -## Gotchas - -[OPTIONAL — things that will trip you up: -- "Build requires `DEVELOPER_DIR=...` prefix" -- "Pre-existing changes in git stash — pop after committing" -Keep to 2-5 bullets. Omit if nothing non-obvious.] - -## Constraints - -[OPTIONAL — hard boundaries: -- "Only modify files under `apps/gui-swift/`" -- "Do not modify `packages/core/`" -Omit if no special constraints.] - -## Fallbacks - -[OPTIONAL — task-specific escape hatches for if a phase is genuinely blocked: -- "Phase 6: If native screenshot module won't compile, keep JS-based capture" -- "If sandbox blocks module cache writes, pass `-Xcc -fmodules-cache-path=/tmp/mc`" -Only include if there are known risk areas. Omit for straightforward tasks.] -``` - -### Anti-Patterns to Avoid - -- **Duplicating ralph-loop runner instructions** — TODO.md format, iteration workflow, `rl done` / `rl done --blocked`. The runner handles all of this. -- **Separate success criteria and verification sections** — merge them into "Done when" -- **Generic "if stuck" instructions** — only include task-specific fallback strategies -- **Prose architecture summaries** — link to README or SPEC -- **Over 200 lines** — if it's this long, split into smaller tasks or link to existing docs - -### Output Method - -1. Ensure directory exists: `mkdir -p ~/.handoffs` - -2. Write the Ralph-loop context file to `~/.handoffs/ralph---.md` - -3. Confirm with the path: "Ralph-loop context saved to `~/.handoffs/`" - -### Timestamp Generation - -Generate the timestamp using: `date +%Y%m%d-%H%M` - -### Wrapper Command Format - -When using this context file with `/ralph-reviewed:ralph-loop`, the command format is: - -``` -/ralph-reviewed:ralph-loop "Read ~/.handoffs/ and complete the task described there. Follow the success criteria and verification loop. Run .rl/rl done when all verifications pass, or .rl/rl done --blocked if stuck." -``` From 4d831aeded82163c00f8bb16d2034e357687063f Mon Sep 17 00:00:00 2001 From: 0xBigBoss <95193764+0xBigBoss@users.noreply.github.com> Date: Sat, 28 Mar 2026 22:36:11 -0500 Subject: [PATCH 25/57] refactor(guidelines): condense CLAUDE.md from 188 to 118 lines MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Simplify ADF flow to reflect actual agent ownership: agent owns SPEC → PLAN → TDD → DEV → E2E; review, CI, and merge are human decisions. Remove CI/REVIEW/MERGE gates, multi-skill combinations matrix, and verbose explanations while preserving all behavioral rules. Restore file-pattern triggers in skills table for Codex compatibility. --- CLAUDE.md | 176 ++++++++++++++++-------------------------------------- 1 file changed, 53 insertions(+), 123 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 426befa..a592a19 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -8,54 +8,42 @@ Applies to agents. Follow these directives as system-level behavior. ## Core principles - Explore relevant code before proposing changes; understand context first. -- Work idiomatically and safely; align with project conventions and architecture (contributions integrate seamlessly). -- Keep changes minimal and focused; implement only what is requested or clearly necessary (avoid unrequested features, refactoring, or flexibility). -- Treat `## Agentic delivery flow (canonical)` as the process of record; if deviating, record a waiver with rationale. -- Fail fast with visible evidence; validate understanding with minimal repros/tests (quick feedback prevents wasted effort). -- Use available tools/documentation before coding; verify assumptions (evidence-based development catches errors early). +- Work idiomatically and safely; align with project conventions and architecture. +- Keep changes minimal and focused; implement only what is requested or clearly necessary. +- Fail fast with visible evidence; validate understanding with minimal repros/tests. +- Use available tools/documentation before coding; verify assumptions. - Verify changes with project tooling (tests, linters, builds) before claiming done. -- Document project context inline when needed; complete implementations or fail explicitly with descriptive errors (partial work masks bugs). -- Security: require explicit authorization before accessing secrets/keychains. +- Complete implementations or fail explicitly with descriptive errors; partial work masks bugs. - Extract configuration immediately; magic numbers, URLs, ports, timeouts, and feature flags belong in config, not code. -## Agentic delivery flow (canonical) -- Flow: `SPEC -> PLAN -> TDD -> DEV -> E2E -> REVIEW -> CI -> MERGE`. -- Command discovery order (`DEV`/`E2E`): repo task runner/scripts -> repo docs -> project defaults (`tilt up`, `silo up`) -> ask user. -- High-risk changes (approval required in `PLAN`): schema/data migrations, auth/security boundaries, public API/contract changes, infra/deploy/runtime config. -- Low-risk skip path: docs/comments/non-runtime changes may use `SPEC -> PLAN -> REVIEW -> CI -> MERGE`. -- Traceability rule: every change maps `REQ-*` -> tests -> commit/PR. -- Handoff contract (`REVIEW`, required): assumptions, changed files, commands run, results, unresolved risks. -- Phase gates: - - `SPEC` gate: IDs, invariants, non-goals, acceptance criteria present; risk tags required when high-risk items exist. Load `spec-best-practices` skill. File must be named `SPEC.md`, colocated with the code it describes. - - `PLAN` gate: task graph with files/types/tests and explicit risk classification. - - `TDD` gate: failing tests first; required test layers added (unit/integration/contract/property/regression as applicable). - - `DEV` gate: local environment boots; deterministic health checks pass (readiness, migrations, seed data, key APIs, timeout budgets). - - `E2E` gate: happy path and failure modes pass (timeouts, retries, auth edge cases, partial outages). - - `CI` gate: bounded auto-repair retries; flake policy enforced (retry cap + quarantine rule); fail hard on policy/security violations. - - `MERGE` gate: all required gates pass, or waiver recorded with rationale. +## Agentic delivery flow + +Agent owns `SPEC → PLAN → TDD → DEV → E2E`. Stop here. Review, CI, and merge are human decisions. + +- Command discovery order: repo task runner/scripts → repo docs → project defaults (`tilt up`, `silo up`) → ask user. +- High-risk changes (approval required in PLAN): schema/data migrations, auth/security boundaries, public API/contract changes, infra/deploy/runtime config. +- Low-risk skip path: docs/comments/non-runtime changes may use `SPEC → PLAN → DEV`. +- Traceability: every change maps REQ-* → tests → commit. +- If deviating from this flow, record a waiver with rationale. +- Gates: + - SPEC: IDs, invariants, non-goals, acceptance criteria. Risk tags when high-risk items exist. Load `spec-best-practices`. File named `SPEC.md`, colocated. + - PLAN: task graph with files/types/tests and risk classification. + - TDD: failing tests first. + - DEV: local environment boots; health checks pass. + - E2E: happy path and failure modes pass against live dev environment. ## Secret handling Treat secret safety as a hard requirement. -- Assume all chat content, tool inputs, and tool outputs are persisted in internal history; do not place secret values in them. +- Assume all chat content, tool inputs, and tool outputs are persisted; do not place secret values in them. - Never ask for or accept secrets in plain text via chat. - Never echo, print, or log secret values to stdout/stderr. - Never pass secrets as command arguments (`--token ...`) or inline env assignments (`TOKEN=... cmd`). -- Never write secrets to disk (`.env`, temp files, scripts, fixtures, shell profiles) unless explicitly authorized for an approved secure store. -- Use secret references and secure one-way piping from a secret manager directly into commands that read from stdin. -- Prefer commands that support `--*-stdin`; if a tool only accepts argv/env/file plaintext, stop and ask for an approved secure alternative. -- Redact suspected secrets immediately if they appear in output and notify the user. - -Preferred patterns: -- `op read | ` -- ` | docker login --password-stdin` - -Forbidden patterns: -- ` --token "$SECRET"` -- `export SECRET=...` -- `echo "secret" > .env` -- Any command that reveals a secret in chat, logs, or command output +- Never write secrets to disk unless explicitly authorized for an approved secure store. +- Pipe from secret manager to stdin: `op read | `. +- If a tool only accepts argv/env/file plaintext, stop and ask for an approved alternative. +- Redact suspected secrets immediately if they appear in output. ## Type-first development - Define types, interfaces, and data models before implementing logic. @@ -64,64 +52,29 @@ Forbidden patterns: - Schema changes drive implementation; if the types are right, the code follows. ## Functional style -- Prefer immutability: `const`, `frozen`, `readonly` types; mutate only when necessary for performance. -- Write pure functions; isolate side effects at system boundaries (I/O, network, state updates). -- Use `map`/`filter`/`reduce` and comprehensions over imperative loops where readable. -- Compose small functions over large stateful procedures; prefer pipelines over in-place mutation. -- Avoid shared mutable state; pass data explicitly rather than relying on side effects. +- Prefer immutability and pure functions; isolate side effects at system boundaries. +- Compose small functions; prefer pipelines over in-place mutation. ## Skills -Load all relevant best-practices skills immediately as your first action when working with supported languages or tools. Do not wait for the user to request skills. When multiple contexts apply, load multiple skills in parallel. +Load relevant best-practices skills immediately when working with supported languages or tools. Load multiple when contexts overlap (e.g., typescript + react for `.tsx` files). Do not wait for the user to request skills. | Context | Skill | |---------|-------| -| Python: `.py`, `pyproject.toml`, `requirements.txt` | python-best-practices | -| TypeScript: `.ts`, `.tsx`, `tsconfig.json` | typescript-best-practices | -| Electrobun: `electrobun.config.ts`, `electrobun/bun`, `electrobun/view`, Electrobun CLI commands | electrobun-best-practices | -| React: `.tsx`, `.jsx`, `@react` imports | react-best-practices | -| Go: `.go`, `go.mod` | go-best-practices | -| Zig: `.zig`, `build.zig`, `build.zig.zon` | zig-best-practices | -| Playwright: `.spec.ts`, `.test.ts` with `@playwright/test` | playwright-best-practices | -| Tilt: `Tiltfile`, tilt commands | tilt | -| Tamagui: `tamagui.config.ts`, `@tamagui` imports | tamagui-best-practices | -| Atlas: `atlas.hcl`, `.hcl` schema files, Atlas CLI commands | atlas-best-practices | -| Spec authoring: creating, reviewing, or updating `SPEC.md` files | spec-best-practices | -| Spec-derived test design: `*.spec.md`, `spec/*.md`, `SPEC.md` when designing tests | testing-best-practices | -| Spec alignment: spec file + implementation in context | specalign | -| Git: commits, branches, PRs, history rewriting | git-best-practices | - -### Multi-skill combinations - -Load all applicable skills together when contexts overlap: -- **TypeScript + React**: All React components (`.tsx`, `.jsx`) - always load both skills together -- **TypeScript + Electrobun**: Electrobun desktop apps (`electrobun.config.ts`, `electrobun/bun`, `electrobun/view`) - always load both skills together -- **TypeScript + React + Playwright**: React component E2E tests with `@playwright/test` -- **TypeScript + React + Tamagui**: React Native/web components with `@tamagui` imports -- **TypeScript + Playwright**: Non-React test files with `@playwright/test` imports -- **Python + Tilt**: Python services in a Tilt-managed dev environment -- **Go + Tilt**: Go services in a Tilt-managed dev environment -- **testing-best-practices + [language]**: Load testing skill alongside the project's language skill when designing tests from specs -- **tilt + zmx**: Always load both when running `tilt up` or any long-lived process in zmx -- **tilt + tiltup**: Always load both when starting tilt or fixing Tiltfile errors -- **spec-best-practices + specalign**: Load both when reviewing or updating existing specs against implementation -- **spec-best-practices + testing-best-practices**: Load both when deriving test strategy from a spec -- **spec-best-practices + /specout**: Load skill before running specout interview -- **specalign + testing-best-practices**: Load both when a spec file and its implementation are in context -- **e2e + playwright-best-practices**: Load both when running or fixing Playwright e2e tests -- **e2e + specalign**: Load both when e2e failures may indicate spec drift -- **git-best-practices + /commit**: Load skill when using the commit command or making any commits -- **git-best-practices + /rewrite-history**: Load skill when rewriting branch history before PR - -### When to invoke skills - -Invoke skills proactively: -- Reading code: understand expected patterns before analyzing -- Writing or modifying code: apply correct conventions during implementation -- Reviewing or debugging: identify violations against established patterns -- Exploring unfamiliar code: load the language skill to interpret idioms correctly - -Skills provide error handling conventions, code quality patterns, type-first development guidance, and review standards specific to each language or tool. +| Python (`.py`, `pyproject.toml`) | python-best-practices | +| TypeScript (`.ts`, `.tsx`, `tsconfig.json`) | typescript-best-practices | +| Electrobun (`electrobun.config.ts`, `electrobun/*`) | electrobun-best-practices | +| React (`.tsx`, `.jsx`, `@react` imports) | react-best-practices | +| Go (`.go`, `go.mod`) | go-best-practices | +| Zig (`.zig`, `build.zig`) | zig-best-practices | +| Playwright (`.spec.ts`, `.test.ts` with `@playwright/test`) | playwright-best-practices | +| Tilt (`Tiltfile`, tilt commands) | tilt | +| Tamagui (`tamagui.config.ts`, `@tamagui` imports) | tamagui-best-practices | +| Atlas (`atlas.hcl`, `.hcl` schema, Atlas CLI) | atlas-best-practices | +| SPEC.md authoring | spec-best-practices | +| Test design from specs | testing-best-practices | +| Spec vs implementation drift | specalign | +| Git operations | git-best-practices | ## Communication style - Concise teammate tone; plain text without emojis; brevity over perfect grammar. @@ -131,55 +84,32 @@ Skills provide error handling conventions, code quality patterns, type-first dev ## Code comments -Comment liberally. Every comment must explain intent, rationale, or non-obvious constraints — never restate what the code does. Good comments answer "why this approach?" and "what would break if this changed?" Bad comments narrate syntax. Prefer more comments over fewer; when in doubt, comment. +Comment liberally. Every comment must explain intent, rationale, or non-obvious constraints — never restate what the code does. Good comments answer "why this approach?" and "what would break if this changed?" -## Error handling and completeness -- **Errors must be handled or returned to callers**; every error requires explicit handling at every level of the stack (universal principle across all languages). -- Fail loudly with clear messages on missing data or unsupported cases (silent failures compound into system-wide issues). -- Propagate errors up the call stack; transform exceptions into meaningful results or rethrow. +## Error handling +- Errors must be handled or returned to callers at every level of the stack. +- Fail loudly with clear messages; silent failures compound into system-wide issues. - Handle edge cases explicitly (empty inputs, nil/null, default branches). - -## Idempotency and resilience -- Check state before changes; skip if already correct; prefer declarative over imperative. - External calls need explicit timeouts; retries must be bounded with backoff. ## Test integrity -Tests verify correctness—they do not define the solution. Implement general-purpose solutions that solve the actual problem, not code that merely satisfies test cases. - -When tests fail, investigate root cause and fix the underlying issue. Do not: -- Hard-code values matching test assertions -- Add conditionals detecting test scenarios -- Weaken or remove assertions to avoid failures -- Change test expectations to match broken behavior -- Create workarounds or helper scripts that bypass the real problem - -If a test appears incorrect or the task seems infeasible, report the issue rather than gaming around it. Solutions should work correctly for all valid inputs and follow the principle that drove the test—not just its literal assertions. +Tests verify correctness — they do not define the solution. When tests fail, investigate root cause and fix the underlying issue. Do not hard-code values, weaken assertions, or game around tests. If a test appears incorrect, report the issue. ## Test realism - -Unit tests verify logic. They do not verify the application works. - - Prefer integration tests over mocked unit tests for data flow and permissions. -- Mocks are acceptable for external services (TTS, network) but not for your own - data layer (sync engine, database queries, auth). -- If a test passes with mocks but would fail against the real system, the test - is wrong. -- Before claiming work is complete: "would this survive a manual walkthrough?" - -## Module structure and cohesion - -Organize code by single responsibility: each file/module handles one coherent concern. Split when a file handles genuinely separate concerns or different parts change for different reasons. Keep code together when related functionality shares types, helpers, or state. Prioritize cohesion and clear interfaces over arbitrary line counts; follow language-idiomatic conventions (see language skill files for specifics). +- Mocks are acceptable for external services but not for your own data layer. +- If a test passes with mocks but would fail against the real system, the test is wrong. +- Before claiming done: "would this survive a manual walkthrough?" -## Refactoring rules +## Refactoring - Update all callers when changing interfaces; clean breaks over backward-compatibility shims. -- Fail on unexpected inputs; support legacy formats only when explicitly specified. - Prefer clean, complete migrations over gradual transitions. -- Commit to one implementation and delete superseded code; trust version control for history. +- Commit to one implementation and delete superseded code; trust version control. ## Implementation checklist - Functions implemented or explicitly error. -- TODOs accompanied by failing stubs that surface the incomplete work. +- TODOs accompanied by failing stubs. - Solutions work for all valid inputs; avoid hard-coded values that only satisfy test cases. - All paths handled; external calls checked for errors/timeouts. - Edge cases covered; switch/default cases present. From aba377965c167e3a7deacb398cbaa4f22e5beefe Mon Sep 17 00:00:00 2001 From: 0xBigBoss <95193764+0xBigBoss@users.noreply.github.com> Date: Sat, 28 Mar 2026 23:27:32 -0500 Subject: [PATCH 26/57] chore(skills): remove deprecated and unused skills Delete 4 skills no longer needed: - data-driven-testing: deprecated, replaced by testing-best-practices - extract-transcripts: replaced by /recall - codex: no longer used - specalign: Opus handles spec alignment naturally via ADF --- .claude/skills/codex/SKILL.md | 163 ----- .claude/skills/data-driven-testing/SKILL.md | 20 - .claude/skills/extract-transcripts/PLAN.md | 196 ------ .claude/skills/extract-transcripts/SKILL.md | 103 --- .../extract_codex_transcript.py | 211 ------ .../extract-transcripts/extract_transcript.py | 296 --------- .../extract-transcripts/transcript_index.py | 602 ------------------ .claude/skills/specalign/SKILL.md | 112 ---- 8 files changed, 1703 deletions(-) delete mode 100644 .claude/skills/codex/SKILL.md delete mode 100644 .claude/skills/data-driven-testing/SKILL.md delete mode 100644 .claude/skills/extract-transcripts/PLAN.md delete mode 100644 .claude/skills/extract-transcripts/SKILL.md delete mode 100644 .claude/skills/extract-transcripts/extract_codex_transcript.py delete mode 100755 .claude/skills/extract-transcripts/extract_transcript.py delete mode 100755 .claude/skills/extract-transcripts/transcript_index.py delete mode 100644 .claude/skills/specalign/SKILL.md diff --git a/.claude/skills/codex/SKILL.md b/.claude/skills/codex/SKILL.md deleted file mode 100644 index 7f8e81b..0000000 --- a/.claude/skills/codex/SKILL.md +++ /dev/null @@ -1,163 +0,0 @@ ---- -name: codex -description: Hand off a task to Codex CLI for autonomous execution. Use when a task would benefit from a capable subagent to implement, fix, investigate, or review code. Codex has full codebase access and can make changes. -argument-hint: [--model ] [--sandbox ] -disable-model-invocation: false -allowed-tools: Bash(codex:*), Bash(git:*), Bash(pwd:*), Bash(mkdir:*), Bash(cat:*), Bash(head:*), Bash(tail:*), Bash(wc:*), Read, Grep, Glob ---- - -# Codex Subagent - -Session ID: ${CLAUDE_SESSION_ID} -Output: `~/.claude/codex/${CLAUDE_SESSION_ID}/` - -## Arguments - -Task: $ARGUMENTS - -**Optional flags** (only if user explicitly requests): -- `--model `: `gpt-5.2-codex` (default), `gpt-5.2`, `gpt-5-mini`, `o3` -- `--sandbox `: `read-only`, `workspace-write`, `danger-full-access` - -Omit flags to use user's config defaults. - -## Context Depth - -**Minimal**: Specific file/function → git state only -**Medium**: Feature/multi-file → add recent changes -**Full**: Investigation/unclear → add session summary - -## Gather Context - -**Always:** -```bash -pwd && git rev-parse --show-toplevel 2>/dev/null || echo "Not a git repo" -git branch --show-current 2>/dev/null && git status --short 2>/dev/null | head -20 -``` - -**Medium/Full:** -```bash -git diff --stat 2>/dev/null | tail -20 -git log --oneline -5 --since="4 hours ago" 2>/dev/null -``` - -**Full only:** Session summary (work done, decisions, blockers) - -## Prompt Structure - -Use CTCO format (Context → Task → Constraints → Output): - -``` - -Working directory: {cwd} -Repository: {repo_name} -Branch: {branch} -{git_status} -{recent_changes if medium/full} -{session_summary if full} - - - -{task from arguments} - - - -- Implement EXACTLY what is requested -- Read files before changing -- Run tests/linters to validate -- State interpretation if ambiguous - - - -Summary (≤5 bullets): -- **What changed**: Files and changes -- **Where**: file:line references -- **Validation**: Tests/linters run -- **Risks**: Edge cases to watch -- **Next steps**: Follow-up or "None" - -``` - -## Execute - -Setup: -```bash -mkdir -p ~/.claude/codex/${CLAUDE_SESSION_ID} -git rev-parse --show-toplevel 2>/dev/null && IN_GIT=true || IN_GIT=false -``` - -Run: -```bash -codex exec --json \ - -o ~/.claude/codex/${CLAUDE_SESSION_ID}/summary-{timestamp}.txt \ - {--skip-git-repo-check if not in git} \ - {--full-auto OR --sandbox } \ - {-m if requested} \ - - <<'CODEX_PROMPT' -{prompt} -CODEX_PROMPT > ~/.claude/codex/${CLAUDE_SESSION_ID}/progress-{timestamp}.jsonl -``` - -**Flags:** -- Not in git: add `--skip-git-repo-check` -- Default: `--full-auto` (workspace-write + auto-approval) -- User-requested: `--sandbox ` or `-m ` - -### Background vs Foreground - -**Background tasks** (>30 seconds expected): -- Multi-file changes, investigations, tests, feature work -- Use `run_in_background: true` → returns `task_id` - -**Foreground tasks** (<30 seconds): -- Single-line fixes, simple queries - -### Monitoring Background Tasks - -**Token-efficient approach:** -1. Use `TaskOutput(task_id, block=true)` to wait for completion -2. Ignore TaskOutput's content (stdout redirected to progress file) -3. Read summary file directly: `cat ~/.claude/codex/${CLAUDE_SESSION_ID}/summary-*.txt` - -The summary file contains only Codex's final message (token-efficient). - -**Progress checks** (if needed before completion): -- `TaskOutput(task_id, block=false)` - check if still running -- `tail -n 3 ~/.claude/codex/${CLAUDE_SESSION_ID}/progress-*.jsonl` - last 3 events only - -**Do NOT** read entire progress files or use `tail -f`. - -## Report Result - -Read summary: -```bash -cat ~/.claude/codex/${CLAUDE_SESSION_ID}/summary-*.txt -``` - -Report format (≤5 bullets): -``` -**Status:** {success/error/partial} -**Changed:** {files and changes} -**Validation:** {tests/linters} -**Risks:** {if any} -**Next:** {follow-up or "None"} -``` - -## Examples - -```bash -# Simple fix -/codex fix the null pointer in utils/parser.ts line 42 - -# Feature work -/codex add rate limiting to the /api/submit endpoint - -# Investigation (background) -/codex investigate why the CI build fails on arm64 - -# Model override -/codex --model o3 design a caching strategy - -# Read-only -/codex --sandbox read-only review the auth implementation -``` diff --git a/.claude/skills/data-driven-testing/SKILL.md b/.claude/skills/data-driven-testing/SKILL.md deleted file mode 100644 index f437110..0000000 --- a/.claude/skills/data-driven-testing/SKILL.md +++ /dev/null @@ -1,20 +0,0 @@ ---- -name: data-driven-testing -description: "DEPRECATED: Use testing-best-practices instead. This skill has been retired." ---- - -## Deprecated - -This skill has been replaced by **testing-best-practices**. - -Use `testing-best-practices` for all test design, test case generation, and test strategy work. - -### What changed - -- Test layering policy (unit / integration / e2e) replaces the unit-only DDT focus. -- Markdown tables replace the rigid canonical JSON test-case schema. -- Output is strategy + matrix + implementation plan, not JSON blocks. -- Added: hard rules against fabricated fixtures and invented source locations. -- Added: e2e execution guidance (preflight, async polling, flake handling). -- Added: CI lane guidance (PR smoke vs nightly full). -- Auth-state reuse and idempotent/state-tolerant e2e are first-class concerns. diff --git a/.claude/skills/extract-transcripts/PLAN.md b/.claude/skills/extract-transcripts/PLAN.md deleted file mode 100644 index 84b689f..0000000 --- a/.claude/skills/extract-transcripts/PLAN.md +++ /dev/null @@ -1,196 +0,0 @@ -# Transcript Analytics with DuckDB - Implementation Plan - -## Overview - -Extend the existing transcript extraction tools with a DuckDB-based index for querying past Claude Code and Codex CLI sessions at scale. - -## Schema Design (v2) - -```sql --- sessions table: file_path is the unique key (not session_id) -CREATE TABLE sessions ( - file_path TEXT PRIMARY KEY, -- unique identifier (filename handles subagent collision) - session_id TEXT, -- original session_id (for reference, not unique) - source TEXT NOT NULL, -- 'claude_code' | 'codex' - started_at TIMESTAMP, - ended_at TIMESTAMP, - duration_seconds INTEGER, - model TEXT, - cwd TEXT, - git_branch TEXT, - git_repo TEXT, -- derived from cwd - message_count INTEGER, - tool_count INTEGER, - file_mtime REAL, -- for incremental indexing - file_size INTEGER, -- for change detection - indexed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP -); - --- messages table -CREATE TABLE messages ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - file_path TEXT NOT NULL REFERENCES sessions(file_path), - message_idx INTEGER NOT NULL, - role TEXT NOT NULL, -- 'user' | 'assistant' - content TEXT, - timestamp TIMESTAMP, - has_thinking BOOLEAN DEFAULT FALSE, - UNIQUE(file_path, message_idx) -); - --- tool_calls table (simplified - no success tracking) -CREATE TABLE tool_calls ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - file_path TEXT NOT NULL REFERENCES sessions(file_path), - message_idx INTEGER, -- nullable: Codex function_call events lack message context - tool_name TEXT NOT NULL - -- NOTE: succeeded/input_summary removed - not derivable from current parsing -); - --- Full-text search index (DuckDB native) --- DuckDB doesn't have fts5; use LIKE/ILIKE for simple search or: --- Option 1: Use DuckDB's full-text search extension (duckdb_fts) --- Option 2: Use PRAGMA create_fts_index (experimental) --- For Phase 1, use simple ILIKE queries; add FTS extension in later phase -CREATE INDEX idx_messages_content ON messages(content); -``` - -### Design Decisions - -1. **`file_path` as primary key**: The existing `extract_transcript.py` explicitly uses filename (not session_id) as the unique identifier because session_id can be shared across subagents (see line 130-131). This schema follows that pattern. - -2. **No `tool_calls.succeeded`**: Current extractors only capture `tool_use` blocks. `tool_result` blocks are skipped in Codex parsing (line 86-87 of `extract_codex_transcript.py`) and not correlated in Claude parsing. Adding success tracking would require new extraction logic. - -3. **No `messages.token_count`**: Current extractors don't capture usage/token data. Would require parsing additional fields from session JSONL. - -4. **Session linking deferred**: No parent/subagent metadata exists in current session format. Tree construction would require heuristics or new metadata. - -5. **`tool_calls.message_idx` nullable**: Claude Code tool_use blocks are nested in assistant messages (so message_idx is available), but Codex function_call events are standalone entries without message context (see `extract_codex_transcript.py:67-69`). Making this nullable allows both sources to populate the schema. - -6. **FTS via ILIKE for Phase 1**: DuckDB doesn't support SQLite's fts5 syntax. Phase 1 uses simple `ILIKE` queries on an indexed column. The `duckdb_fts` extension can be added later for better performance. - ---- - -## Incremental Indexing Strategy - -Per-file tracking stored in DuckDB (no separate JSON file): - -```python -def should_reindex(file_path: Path, db: DuckDB) -> bool: - """Check if file needs reindexing.""" - current_mtime = file_path.stat().st_mtime - current_size = file_path.stat().st_size - - result = db.execute(""" - SELECT file_mtime, file_size FROM sessions - WHERE file_path = ? - """, [str(file_path)]).fetchone() - - if result is None: - return True # New file - - stored_mtime, stored_size = result - return current_mtime != stored_mtime or current_size != stored_size - -def reindex_file(file_path: Path, db: DuckDB): - """Delete old data and reindex file.""" - db.execute("DELETE FROM tool_calls WHERE file_path = ?", [str(file_path)]) - db.execute("DELETE FROM messages WHERE file_path = ?", [str(file_path)]) - db.execute("DELETE FROM sessions WHERE file_path = ?", [str(file_path)]) - # ... parse and insert fresh data - -def delete_session(file_path: str, db: DuckDB): - """Remove all data for a session file.""" - db.execute("DELETE FROM tool_calls WHERE file_path = ?", [file_path]) - db.execute("DELETE FROM messages WHERE file_path = ?", [file_path]) - db.execute("DELETE FROM sessions WHERE file_path = ?", [file_path]) - -def cleanup_deleted_files(db: DuckDB): - """Remove entries for files that no longer exist.""" - indexed_files = db.execute("SELECT file_path FROM sessions").fetchall() - for (file_path,) in indexed_files: - if not Path(file_path).exists(): - delete_session(file_path, db) # Just delete, don't reindex -``` - -### Handles - -| Scenario | Detection | Action | -|----------|-----------|--------| -| New file | Not in DB | Full index | -| Modified file | mtime or size changed | Delete + reindex | -| Deleted file | Path no longer exists | Delete from DB | -| Append-only growth | Size increased | Delete + reindex | - ---- - -## CLI Commands - -```bash -# Index/reindex sessions -transcript index # Incremental index of all sessions -transcript index --full # Force full reindex -transcript index --path # Index specific directory - -# Search -transcript search "error handling" # FTS across message content -transcript search "error" --cwd ~/myproject # Filter by project - -# List sessions -transcript recent # Last 10 sessions -transcript recent --project myapp # Filter by cwd containing "myapp" -transcript recent --since 7d # Last 7 days - -# Analytics -transcript tools # Top 10 tools by usage -transcript tools --top 20 # Top 20 -transcript stats # Session counts, durations, model breakdown - -# View session -transcript show # Full transcript -transcript show --summary # Summary only -``` - ---- - -## Directory Structure - -``` -~/.claude/transcript-index/ -└── sessions.duckdb # Single database file with all tables + FTS -``` - ---- - -## Implementation Phases - -### Phase 1: Core indexing -- DuckDB schema creation -- Parse Claude Code JSONL → sessions/messages/tool_calls tables -- Incremental indexing with mtime/size tracking -- Basic CLI: `index`, `recent`, `search` - -### Phase 2: Codex support -- Add Codex session parsing -- Unified schema handles both sources via `source` column - -### Phase 3: Analytics -- `tools` command with aggregations -- `stats` command for usage patterns -- Time-series queries - -### Phase 4: Future considerations -- Session linking heuristics (if metadata becomes available) -- Token counting (if extraction adds usage parsing) -- Semantic search via embeddings - ---- - -## Out of Scope (with rationale) - -| Feature | Reason | Reference | -|---------|--------|-----------| -| `tool_calls.succeeded` | Requires `tool_result` parsing not in current extractors | `extract_codex_transcript.py:86-87` | -| `messages.token_count` | Not captured by current extraction | `extract_transcript.py:108-125` | -| Parent/subagent linking | No metadata available in session format | `extract_transcript.py:93-100` | -| Real-time updates | Batch indexing only; run `transcript index` as needed | Design choice | diff --git a/.claude/skills/extract-transcripts/SKILL.md b/.claude/skills/extract-transcripts/SKILL.md deleted file mode 100644 index 2b7ed5a..0000000 --- a/.claude/skills/extract-transcripts/SKILL.md +++ /dev/null @@ -1,103 +0,0 @@ ---- -name: extract-transcripts -description: Extract readable transcripts from Claude Code and Codex CLI session JSONL files ---- - -# Extract Transcripts - -Extracts readable markdown transcripts from Claude Code and Codex CLI session JSONL files. - -## Scripts - -### Claude Code Sessions - -```bash -# Extract a single session -uv run ~/.claude/skills/extract-transcripts/extract_transcript.py - -# With tool calls and thinking blocks -uv run ~/.claude/skills/extract-transcripts/extract_transcript.py --include-tools --include-thinking - -# Extract all sessions from a directory -uv run ~/.claude/skills/extract-transcripts/extract_transcript.py --all - -# Output to file -uv run ~/.claude/skills/extract-transcripts/extract_transcript.py -o output.md - -# Summary only (quick overview) -uv run ~/.claude/skills/extract-transcripts/extract_transcript.py --summary - -# Skip empty/warmup-only sessions -uv run ~/.claude/skills/extract-transcripts/extract_transcript.py --all --skip-empty -``` - -**Options:** -- `--include-tools`: Include tool calls and results -- `--include-thinking`: Include Claude's thinking blocks -- `--all`: Process all .jsonl files in directory -- `-o, --output`: Output file path (default: stdout) -- `--summary`: Only output brief summary -- `--skip-empty`: Skip empty and warmup-only sessions -- `--min-messages N`: Minimum messages for --skip-empty (default: 2) - -### Codex CLI Sessions - -```bash -# Extract a Codex session -uv run ~/.claude/skills/extract-transcripts/extract_codex_transcript.py - -# Extract from Codex history file -uv run ~/.claude/skills/extract-transcripts/extract_codex_transcript.py ~/.codex/history.jsonl --history -``` - -## Session File Locations - -### Claude Code -- Sessions: `~/.claude/projects//.jsonl` - -### Codex CLI -- Sessions: `~/.codex/sessions//rollout.jsonl` -- History: `~/.codex/history.jsonl` - -## DuckDB-Based Transcript Index - -For querying across many sessions, use the DuckDB-based indexer: - -```bash -# Index all sessions (incremental - only new/changed files) -uv run ~/.claude/skills/extract-transcripts/transcript_index.py index - -# Force full reindex -uv run ~/.claude/skills/extract-transcripts/transcript_index.py index --full - -# Limit number of files to process -uv run ~/.claude/skills/extract-transcripts/transcript_index.py index --limit 10 - -# List recent sessions -uv run ~/.claude/skills/extract-transcripts/transcript_index.py recent -uv run ~/.claude/skills/extract-transcripts/transcript_index.py recent --limit 20 -uv run ~/.claude/skills/extract-transcripts/transcript_index.py recent --project myapp -uv run ~/.claude/skills/extract-transcripts/transcript_index.py recent --since 7d - -# Search across sessions -uv run ~/.claude/skills/extract-transcripts/transcript_index.py search "error handling" -uv run ~/.claude/skills/extract-transcripts/transcript_index.py search "query" --cwd ~/myproject - -# Show a session transcript -uv run ~/.claude/skills/extract-transcripts/transcript_index.py show -uv run ~/.claude/skills/extract-transcripts/transcript_index.py show --summary -``` - -**Requirements:** uv (dependencies auto-installed via inline script metadata) - -**Database location:** `~/.claude/transcript-index/sessions.duckdb` - -## Output Format - -Transcripts are formatted as markdown with: -- Session metadata (date, duration, model, working directory, git branch) -- User messages prefixed with `## User` -- Assistant responses prefixed with `## Assistant` -- Tool calls in code blocks (if --include-tools) -- Thinking in blockquotes (if --include-thinking) -- Tool usage summary for Codex sessions diff --git a/.claude/skills/extract-transcripts/extract_codex_transcript.py b/.claude/skills/extract-transcripts/extract_codex_transcript.py deleted file mode 100644 index 824cce7..0000000 --- a/.claude/skills/extract-transcripts/extract_codex_transcript.py +++ /dev/null @@ -1,211 +0,0 @@ -#!/usr/bin/env python3 -"""Extract readable transcripts from Codex CLI session JSONL files.""" - -import json -import sys -import os -from datetime import datetime -from pathlib import Path - - -def parse_timestamp(ts: str) -> datetime: - """Parse ISO timestamp.""" - return datetime.fromisoformat(ts.replace('Z', '+00:00')) - - -def process_codex_session(filepath: Path) -> str: - """Process a Codex session file and return formatted transcript.""" - output = [] - session_meta = None - messages = [] - tool_calls = [] - - with open(filepath, 'r') as f: - for line in f: - line = line.strip() - if not line: - continue - try: - entry = json.loads(line) - except json.JSONDecodeError: - continue - - entry_type = entry.get('type') - - if entry_type == 'session_meta': - payload = entry.get('payload', {}) - session_meta = { - 'id': payload.get('id', 'unknown'), - 'timestamp': payload.get('timestamp'), - 'cwd': payload.get('cwd'), - 'cli_version': payload.get('cli_version'), - 'git': payload.get('git', {}), - } - elif entry_type == 'event_msg': - # Codex wraps messages in event_msg payloads - payload = entry.get('payload', {}) - msg_type = payload.get('type') - - if msg_type == 'user_message': - text = payload.get('message', '') - if text: - messages.append({ - 'role': 'user', - 'text': text, - 'tools': [], - 'timestamp': entry.get('timestamp') - }) - elif msg_type == 'agent_message': - text = payload.get('message', '') - if text: - messages.append({ - 'role': 'assistant', - 'text': text, - 'tools': [], - 'timestamp': entry.get('timestamp') - }) - elif msg_type == 'function_call': - name = payload.get('name', 'unknown') - tool_calls.append({'name': name}) - elif entry_type == 'message': - # Legacy format support - payload = entry.get('payload', {}) - role = payload.get('role', 'unknown') - content = payload.get('content', []) - - # Extract text from content - text_parts = [] - for item in content: - if isinstance(item, dict): - if item.get('type') == 'text': - text_parts.append(item.get('text', '')) - elif item.get('type') == 'tool_use': - tool_calls.append({ - 'name': item.get('name'), - }) - elif item.get('type') == 'tool_result': - pass # Skip tool results for brevity - elif isinstance(item, str): - text_parts.append(item) - - if text_parts: - messages.append({ - 'role': role, - 'text': '\n'.join(text_parts), - 'tools': [], - 'timestamp': entry.get('timestamp') - }) - - # Build output - output.append(f"# Codex Session: {filepath.stem}") - output.append("") - - if session_meta: - if session_meta.get('timestamp'): - try: - ts = parse_timestamp(session_meta['timestamp']) - output.append(f"**Date:** {ts.strftime('%Y-%m-%d %H:%M')}") - except: - pass - if session_meta.get('cwd'): - output.append(f"**Working Directory:** {session_meta['cwd']}") - if session_meta.get('cli_version'): - output.append(f"**Codex Version:** {session_meta['cli_version']}") - git = session_meta.get('git', {}) - if git.get('branch'): - output.append(f"**Git Branch:** {git['branch']}") - if git.get('commit_hash'): - output.append(f"**Commit:** {git['commit_hash'][:8]}") - - output.append("") - user_count = len([m for m in messages if m['role'] == 'user']) - assistant_count = len([m for m in messages if m['role'] == 'assistant']) - output.append(f"**Messages:** {user_count} user, {assistant_count} assistant, {len(tool_calls)} tool calls") - output.append("") - output.append("---") - output.append("") - - # Output messages - for msg in messages: - role_header = "## User" if msg['role'] == 'user' else "## Assistant" - output.append(role_header) - output.append("") - - if msg['text']: - # Truncate very long messages - text = msg['text'] - if len(text) > 2000: - text = text[:2000] + "\n\n... (truncated)" - output.append(text) - output.append("") - - # Append tool summary if any - if tool_calls: - output.append("## Tools Used") - output.append("") - tool_names = {} - for t in tool_calls: - name = t.get('name', 'unknown') - tool_names[name] = tool_names.get(name, 0) + 1 - for name, count in sorted(tool_names.items(), key=lambda x: -x[1])[:10]: - output.append(f"- `{name}`: {count}") - output.append("") - - return '\n'.join(output) - - -def process_history_entry(entry: dict) -> str: - """Format a single history entry.""" - session_id = entry.get('session_id', 'unknown')[:8] - ts = entry.get('ts', 0) - text = entry.get('text', '') - - # Format timestamp - try: - dt = datetime.fromtimestamp(ts) - date_str = dt.strftime('%Y-%m-%d %H:%M') - except: - date_str = 'unknown' - - output = [] - output.append(f"## Session {session_id} ({date_str})") - output.append("") - - # Truncate very long prompts - if len(text) > 3000: - text = text[:3000] + "\n\n... (truncated)" - - output.append(text) - output.append("") - output.append("---") - output.append("") - - return '\n'.join(output) - - -def main(): - if len(sys.argv) < 2: - print("Usage: extract_codex_transcript.py [--history]") - sys.exit(1) - - filepath = Path(sys.argv[1]) - is_history = '--history' in sys.argv - - if is_history: - # Process history.jsonl format - output = ["# Codex History Entries", "", "---", ""] - with open(filepath, 'r') as f: - for line in f: - try: - entry = json.loads(line.strip()) - output.append(process_history_entry(entry)) - except json.JSONDecodeError: - continue - print('\n'.join(output)) - else: - # Process session rollout format - print(process_codex_session(filepath)) - - -if __name__ == '__main__': - main() diff --git a/.claude/skills/extract-transcripts/extract_transcript.py b/.claude/skills/extract-transcripts/extract_transcript.py deleted file mode 100755 index 743f42d..0000000 --- a/.claude/skills/extract-transcripts/extract_transcript.py +++ /dev/null @@ -1,296 +0,0 @@ -#!/usr/bin/env python3 -"""Extract readable transcripts from Claude Code session JSONL files.""" - -import json -import sys -import os -import argparse -from datetime import datetime -from pathlib import Path -from typing import Optional, TextIO - - -def parse_timestamp(ts: str) -> datetime: - """Parse ISO timestamp.""" - return datetime.fromisoformat(ts.replace('Z', '+00:00')) - - -def format_duration(start: datetime, end: datetime) -> str: - """Format duration between two timestamps.""" - delta = end - start - hours, remainder = divmod(int(delta.total_seconds()), 3600) - minutes, seconds = divmod(remainder, 60) - if hours > 0: - return f"{hours}h {minutes}m {seconds}s" - elif minutes > 0: - return f"{minutes}m {seconds}s" - return f"{seconds}s" - - -def extract_text_content(content) -> str: - """Extract text from message content (handles both string and array formats).""" - if isinstance(content, str): - return content - if isinstance(content, list): - texts = [] - for block in content: - if isinstance(block, dict): - if block.get('type') == 'text': - texts.append(block.get('text', '')) - return '\n'.join(texts) - return '' - - -def extract_thinking(content) -> Optional[str]: - """Extract thinking from message content.""" - if isinstance(content, list): - for block in content: - if isinstance(block, dict) and block.get('type') == 'thinking': - return block.get('thinking', '') - return None - - -def extract_tool_calls(content) -> list: - """Extract tool calls from message content.""" - tools = [] - if isinstance(content, list): - for block in content: - if isinstance(block, dict) and block.get('type') == 'tool_use': - tools.append({ - 'name': block.get('name', 'unknown'), - 'input': block.get('input', {}) - }) - return tools - - -def process_session(filepath: Path, include_tools: bool = False, - include_thinking: bool = False, summary_only: bool = False) -> str: - """Process a single session file and return formatted transcript.""" - messages = [] - metadata = {} - first_ts = None - last_ts = None - - with open(filepath, 'r') as f: - for line in f: - line = line.strip() - if not line: - continue - try: - entry = json.loads(line) - except json.JSONDecodeError: - continue - - entry_type = entry.get('type') - timestamp = entry.get('timestamp') - - if timestamp: - ts = parse_timestamp(timestamp) - if first_ts is None: - first_ts = ts - last_ts = ts - - # Extract session metadata - if entry_type == 'user' and not metadata: - metadata = { - 'sessionId': entry.get('sessionId', 'unknown'), - 'version': entry.get('version', 'unknown'), - 'cwd': entry.get('cwd', 'unknown'), - 'gitBranch': entry.get('gitBranch', 'unknown'), - } - - # Extract model from assistant messages - if entry_type == 'assistant': - msg = entry.get('message', {}) - if 'model' in msg and 'model' not in metadata: - metadata['model'] = msg['model'] - - # Process user and assistant messages - if entry_type in ('user', 'assistant'): - msg = entry.get('message', {}) - role = msg.get('role', entry_type) - content = msg.get('content', '') - - text = extract_text_content(content) - thinking = extract_thinking(content) if include_thinking else None - tools = extract_tool_calls(content) if include_tools else [] - - if text or thinking or tools: - messages.append({ - 'role': role, - 'text': text, - 'thinking': thinking, - 'tools': tools, - 'timestamp': timestamp - }) - - # Build output - output = [] - - # Header - use filename to ensure uniqueness (session_id can be shared by subagents) - file_id = filepath.stem - output.append(f"# Session: {file_id}") - output.append("") - - if first_ts and last_ts: - output.append(f"**Date:** {first_ts.strftime('%Y-%m-%d %H:%M')}") - output.append(f"**Duration:** {format_duration(first_ts, last_ts)}") - - if metadata.get('model'): - output.append(f"**Model:** {metadata['model']}") - if metadata.get('cwd'): - output.append(f"**Working Directory:** {metadata['cwd']}") - if metadata.get('gitBranch'): - output.append(f"**Git Branch:** {metadata['gitBranch']}") - - output.append("") - output.append("---") - output.append("") - - if summary_only: - user_count = sum(1 for m in messages if m['role'] == 'user') - assistant_count = sum(1 for m in messages if m['role'] == 'assistant') - tool_count = sum(len(m['tools']) for m in messages) - - output.append(f"**Messages:** {user_count} user, {assistant_count} assistant") - output.append(f"**Tool calls:** {tool_count}") - - # First user message preview - find first substantive prompt - for m in messages: - if m['role'] == 'user' and m['text']: - text = m['text'].strip() - # Skip very short prompts (likely just "Warmup" or partial) - if len(text) < 20: - continue - preview = text[:500].replace('\n', ' ') - if len(text) > 500: - preview += '...' - output.append(f"\n**First prompt:** {preview}") - break - else: - # No substantive prompt found - output.append(f"\n**First prompt:** (no substantive prompt found)") - - return '\n'.join(output) - - # Full transcript - for msg in messages: - role_header = "## User" if msg['role'] == 'user' else "## Assistant" - output.append(role_header) - output.append("") - - if msg['thinking']: - output.append("> **Thinking:**") - for line in msg['thinking'].split('\n'): - output.append(f"> {line}") - output.append("") - - if msg['text']: - output.append(msg['text']) - output.append("") - - if msg['tools']: - for tool in msg['tools']: - output.append(f"**Tool:** `{tool['name']}`") - input_str = json.dumps(tool['input'], indent=2) - if len(input_str) > 500: - input_str = input_str[:500] + '\n ...(truncated)' - output.append(f"```json\n{input_str}\n```") - output.append("") - - return '\n'.join(output) - - -def has_substantive_content(filepath: Path, min_messages: int = 2) -> bool: - """Check if session has substantive content (not just warmups or empty).""" - user_count = 0 - assistant_count = 0 - has_real_content = False - - with open(filepath, 'r') as f: - for line in f: - line = line.strip() - if not line: - continue - try: - entry = json.loads(line) - except json.JSONDecodeError: - continue - - entry_type = entry.get('type') - if entry_type == 'user': - msg = entry.get('message', {}) - content = msg.get('content', '') - text = content if isinstance(content, str) else '' - if isinstance(content, list): - text = ' '.join(b.get('text', '') for b in content if isinstance(b, dict)) - # Skip warmup-only sessions - if text.strip().lower() not in ('warmup', ''): - has_real_content = True - user_count += 1 - elif entry_type == 'assistant': - assistant_count += 1 - - return has_real_content and (user_count + assistant_count) >= min_messages - - -def main(): - parser = argparse.ArgumentParser(description='Extract transcripts from Claude Code sessions') - parser.add_argument('path', help='Session file or directory') - parser.add_argument('--include-tools', action='store_true', help='Include tool calls') - parser.add_argument('--include-thinking', action='store_true', help='Include thinking blocks') - parser.add_argument('--all', action='store_true', help='Process all .jsonl files in directory') - parser.add_argument('-o', '--output', help='Output file (default: stdout)') - parser.add_argument('--summary', action='store_true', help='Only output summary') - parser.add_argument('--skip-empty', action='store_true', help='Skip empty and warmup-only sessions') - parser.add_argument('--min-messages', type=int, default=2, help='Minimum messages for --skip-empty (default: 2)') - - args = parser.parse_args() - - path = Path(args.path) - - if args.all and path.is_dir(): - files = sorted(path.glob('*.jsonl'), key=lambda p: p.stat().st_mtime) - elif path.is_file(): - files = [path] - else: - print(f"Error: {path} not found or invalid", file=sys.stderr) - sys.exit(1) - - # Filter out empty/warmup sessions if requested - if args.skip_empty: - files = [f for f in files if has_substantive_content(f, args.min_messages)] - - output_file: Optional[TextIO] = None - if args.output: - output_file = open(args.output, 'w') - - seen_sessions = set() - try: - for filepath in files: - # Track unique sessions by session ID to avoid duplicates - session_id = filepath.stem - if session_id in seen_sessions: - continue - seen_sessions.add(session_id) - - transcript = process_session( - filepath, - include_tools=args.include_tools, - include_thinking=args.include_thinking, - summary_only=args.summary - ) - - if output_file: - output_file.write(transcript) - output_file.write('\n\n---\n\n') - else: - print(transcript) - print('\n---\n') - finally: - if output_file: - output_file.close() - - -if __name__ == '__main__': - main() diff --git a/.claude/skills/extract-transcripts/transcript_index.py b/.claude/skills/extract-transcripts/transcript_index.py deleted file mode 100755 index 60bb81e..0000000 --- a/.claude/skills/extract-transcripts/transcript_index.py +++ /dev/null @@ -1,602 +0,0 @@ -#!/usr/bin/env -S uv run -# /// script -# requires-python = ">=3.10" -# dependencies = ["duckdb"] -# /// -"""DuckDB-based indexer for Claude Code session transcripts.""" - -import argparse -import json -import os -import sys -from datetime import datetime, timedelta -from pathlib import Path -from typing import Optional - -import duckdb - - -# Default paths -DEFAULT_DB_PATH = Path.home() / ".claude" / "transcript-index" / "sessions.duckdb" -# Check both possible session locations -DEFAULT_SESSIONS_PATHS = [ - Path.home() / "Library" / "Application Support" / "Claude" / "sessions", # macOS - Path.home() / ".claude" / "projects", # Claude Code CLI projects - Path.home() / ".config" / "claude" / "sessions", # Linux -] - -# Schema - matches PLAN.md -SCHEMA = """ --- sessions table: file_path is the unique key (not session_id) -CREATE TABLE IF NOT EXISTS sessions ( - file_path TEXT PRIMARY KEY, - session_id TEXT, - source TEXT NOT NULL, - started_at TIMESTAMP, - ended_at TIMESTAMP, - duration_seconds INTEGER, - model TEXT, - cwd TEXT, - git_branch TEXT, - git_repo TEXT, - message_count INTEGER, - tool_count INTEGER, - file_mtime DOUBLE, - file_size BIGINT, - indexed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP -); - --- messages table with id and foreign key reference -CREATE SEQUENCE IF NOT EXISTS messages_id_seq; -CREATE TABLE IF NOT EXISTS messages ( - id INTEGER DEFAULT nextval('messages_id_seq') PRIMARY KEY, - file_path TEXT NOT NULL REFERENCES sessions(file_path), - message_idx INTEGER NOT NULL, - role TEXT NOT NULL, - content TEXT, - timestamp TIMESTAMP, - has_thinking BOOLEAN DEFAULT FALSE, - UNIQUE(file_path, message_idx) -); - --- tool_calls table with id and foreign key reference -CREATE SEQUENCE IF NOT EXISTS tool_calls_id_seq; -CREATE TABLE IF NOT EXISTS tool_calls ( - id INTEGER DEFAULT nextval('tool_calls_id_seq') PRIMARY KEY, - file_path TEXT NOT NULL REFERENCES sessions(file_path), - message_idx INTEGER, - tool_name TEXT NOT NULL -); - --- Indexes for search and lookup --- Note: No index on messages.content - ILIKE search works without it and --- avoids DuckDB's ART index key size limit (122KB) for large message content -CREATE INDEX IF NOT EXISTS idx_messages_file_path ON messages(file_path); -CREATE INDEX IF NOT EXISTS idx_tool_calls_file_path ON tool_calls(file_path); -""" - - -def parse_timestamp(ts: str) -> datetime: - """Parse ISO timestamp.""" - return datetime.fromisoformat(ts.replace('Z', '+00:00')) - - -def extract_text_content(content) -> str: - """Extract text from message content.""" - if isinstance(content, str): - return content - if isinstance(content, list): - texts = [] - for block in content: - if isinstance(block, dict) and block.get('type') == 'text': - texts.append(block.get('text', '')) - return '\n'.join(texts) - return '' - - -def extract_tool_calls(content) -> list: - """Extract tool calls from message content.""" - tools = [] - if isinstance(content, list): - for block in content: - if isinstance(block, dict) and block.get('type') == 'tool_use': - tools.append(block.get('name', 'unknown')) - return tools - - -def has_thinking(content) -> bool: - """Check if content has thinking blocks.""" - if isinstance(content, list): - for block in content: - if isinstance(block, dict) and block.get('type') == 'thinking': - return True - return False - - -def parse_session_file(filepath: Path) -> dict: - """Parse a Claude Code session JSONL file.""" - messages = [] - tool_calls = [] - metadata = {} - first_ts = None - last_ts = None - message_idx = 0 - - with open(filepath, 'r') as f: - for line in f: - line = line.strip() - if not line: - continue - try: - entry = json.loads(line) - except json.JSONDecodeError: - continue - - entry_type = entry.get('type') - timestamp = entry.get('timestamp') - - if timestamp: - try: - ts = parse_timestamp(timestamp) - if first_ts is None: - first_ts = ts - last_ts = ts - except (ValueError, TypeError): - pass - - # Extract session metadata from first user entry - if entry_type == 'user' and not metadata: - metadata = { - 'session_id': entry.get('sessionId', 'unknown'), - 'cwd': entry.get('cwd'), - 'git_branch': entry.get('gitBranch'), - } - - # Extract model from assistant messages - if entry_type == 'assistant': - msg = entry.get('message', {}) - if 'model' in msg and 'model' not in metadata: - metadata['model'] = msg['model'] - - # Process user and assistant messages - if entry_type in ('user', 'assistant'): - msg = entry.get('message', {}) - role = msg.get('role', entry_type) - content = msg.get('content', '') - - text = extract_text_content(content) - tools = extract_tool_calls(content) - thinking = has_thinking(content) - - if text or tools: - messages.append({ - 'message_idx': message_idx, - 'role': role, - 'content': text, - 'timestamp': timestamp, - 'has_thinking': thinking, - }) - - for tool_name in tools: - tool_calls.append({ - 'message_idx': message_idx, - 'tool_name': tool_name, - }) - - message_idx += 1 - - # Calculate duration - duration_seconds = None - if first_ts and last_ts: - duration_seconds = int((last_ts - first_ts).total_seconds()) - - # Derive git_repo from cwd - git_repo = None - if metadata.get('cwd'): - git_repo = Path(metadata['cwd']).name - - return { - 'session_id': metadata.get('session_id'), - 'source': 'claude_code', - 'started_at': first_ts, - 'ended_at': last_ts, - 'duration_seconds': duration_seconds, - 'model': metadata.get('model'), - 'cwd': metadata.get('cwd'), - 'git_branch': metadata.get('git_branch'), - 'git_repo': git_repo, - 'messages': messages, - 'tool_calls': tool_calls, - } - - -def should_reindex(file_path: Path, con: duckdb.DuckDBPyConnection) -> bool: - """Check if file needs reindexing.""" - try: - stat = file_path.stat() - current_mtime = stat.st_mtime - current_size = stat.st_size - except OSError: - return False - - result = con.execute(""" - SELECT file_mtime, file_size FROM sessions - WHERE file_path = ? - """, [str(file_path)]).fetchone() - - if result is None: - return True # New file - - stored_mtime, stored_size = result - return current_mtime != stored_mtime or current_size != stored_size - - -def delete_session(file_path: str, con: duckdb.DuckDBPyConnection): - """Remove all data for a session file.""" - con.execute("DELETE FROM tool_calls WHERE file_path = ?", [file_path]) - con.execute("DELETE FROM messages WHERE file_path = ?", [file_path]) - con.execute("DELETE FROM sessions WHERE file_path = ?", [file_path]) - - -def index_file(file_path: Path, con: duckdb.DuckDBPyConnection) -> bool: - """Index a single session file. Returns True if indexed.""" - if not should_reindex(file_path, con): - return False - - # Delete existing data - delete_session(str(file_path), con) - - # Parse the file - data = parse_session_file(file_path) - - # Get file stats - stat = file_path.stat() - - # Insert session - con.execute(""" - INSERT INTO sessions ( - file_path, session_id, source, started_at, ended_at, - duration_seconds, model, cwd, git_branch, git_repo, - message_count, tool_count, file_mtime, file_size - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - """, [ - str(file_path), - data['session_id'], - data['source'], - data['started_at'], - data['ended_at'], - data['duration_seconds'], - data['model'], - data['cwd'], - data['git_branch'], - data['git_repo'], - len(data['messages']), - len(data['tool_calls']), - stat.st_mtime, - stat.st_size, - ]) - - # Insert messages - for msg in data['messages']: - con.execute(""" - INSERT INTO messages (file_path, message_idx, role, content, timestamp, has_thinking) - VALUES (?, ?, ?, ?, ?, ?) - """, [ - str(file_path), - msg['message_idx'], - msg['role'], - msg['content'], - msg['timestamp'], - msg['has_thinking'], - ]) - - # Insert tool calls - for tool in data['tool_calls']: - con.execute(""" - INSERT INTO tool_calls (file_path, message_idx, tool_name) - VALUES (?, ?, ?) - """, [ - str(file_path), - tool['message_idx'], - tool['tool_name'], - ]) - - return True - - -def cleanup_deleted_files(con: duckdb.DuckDBPyConnection) -> int: - """Remove entries for files that no longer exist.""" - indexed_files = con.execute("SELECT file_path FROM sessions").fetchall() - deleted = 0 - for (file_path,) in indexed_files: - if not Path(file_path).exists(): - delete_session(file_path, con) - deleted += 1 - return deleted - - -def cmd_index(args, con: duckdb.DuckDBPyConnection): - """Index command handler.""" - if args.path: - # User-specified path - expand ~ and check existence - sessions_path = Path(args.path).expanduser() - if not sessions_path.exists(): - print(f"Error: Sessions directory not found: {sessions_path}", file=sys.stderr) - sys.exit(1) - sessions_paths = [sessions_path] - else: - # Use default paths - check all that exist - sessions_paths = [p for p in DEFAULT_SESSIONS_PATHS if p.exists()] - if not sessions_paths: - print("Error: No sessions directory found. Checked:", file=sys.stderr) - for p in DEFAULT_SESSIONS_PATHS: - print(f" - {p}", file=sys.stderr) - sys.exit(1) - - # Get all JSONL files from all paths (recursively for project directories) - all_files = [] - for sessions_path in sessions_paths: - all_files.extend(sessions_path.glob('**/*.jsonl')) - files = sorted(all_files, key=lambda p: p.stat().st_mtime, reverse=True) - - if args.limit: - files = files[:args.limit] - - if args.full: - # Force full reindex - delete all data first - con.execute("DELETE FROM tool_calls") - con.execute("DELETE FROM messages") - con.execute("DELETE FROM sessions") - print("Full reindex: cleared existing data") - - indexed = 0 - skipped = 0 - for filepath in files: - if index_file(filepath, con): - indexed += 1 - if not args.quiet: - print(f"Indexed: {filepath.name}") - else: - skipped += 1 - - # Cleanup deleted files - deleted = cleanup_deleted_files(con) - - print(f"\nSummary: {indexed} indexed, {skipped} skipped (unchanged), {deleted} removed (deleted files)") - - -def cmd_recent(args, con: duckdb.DuckDBPyConnection): - """Recent sessions command handler.""" - limit = args.limit or 10 - - query = "SELECT file_path, session_id, started_at, duration_seconds, model, cwd, git_branch, message_count, tool_count FROM sessions" - params = [] - - conditions = [] - if args.project: - conditions.append("cwd ILIKE ?") - params.append(f"%{args.project}%") - - if args.since: - # Parse duration like "7d", "24h" - since = args.since.lower() - try: - if since.endswith('d'): - days = int(since[:-1]) - cutoff = datetime.now() - timedelta(days=days) - elif since.endswith('h'): - hours = int(since[:-1]) - cutoff = datetime.now() - timedelta(hours=hours) - else: - print(f"Invalid --since format: {args.since}. Use '7d' or '24h'", file=sys.stderr) - sys.exit(1) - except ValueError: - print(f"Invalid --since value: {args.since}. Use format like '7d' or '24h'", file=sys.stderr) - sys.exit(1) - conditions.append("started_at >= ?") - params.append(cutoff) - - if conditions: - query += " WHERE " + " AND ".join(conditions) - - query += " ORDER BY started_at DESC LIMIT ?" - params.append(limit) - - results = con.execute(query, params).fetchall() - - if not results: - print("No sessions found.") - return - - for row in results: - file_path, session_id, started_at, duration, model, cwd, git_branch, msg_count, tool_count = row - duration_str = f"{duration // 60}m {duration % 60}s" if duration else "?" - date_str = started_at.strftime('%Y-%m-%d %H:%M') if started_at else "?" - cwd_short = Path(cwd).name if cwd else "?" - - print(f"{date_str} | {duration_str:>8} | {msg_count:>3} msgs | {tool_count:>4} tools | {cwd_short}") - print(f" {file_path}") - print() - - -def cmd_search(args, con: duckdb.DuckDBPyConnection): - """Search command handler.""" - query_text = args.query - limit = args.limit or 20 - - query = """ - SELECT DISTINCT s.file_path, s.started_at, s.cwd, s.git_branch, - m.content, m.role - FROM messages m - JOIN sessions s ON m.file_path = s.file_path - WHERE m.content ILIKE ? - """ - params = [f"%{query_text}%"] - - if args.cwd: - query += " AND s.cwd ILIKE ?" - params.append(f"%{args.cwd}%") - - query += " ORDER BY s.started_at DESC LIMIT ?" - params.append(limit) - - results = con.execute(query, params).fetchall() - - if not results: - print(f"No matches for '{query_text}'") - return - - current_file = None - for row in results: - file_path, started_at, cwd, git_branch, content, role = row - - if file_path != current_file: - current_file = file_path - date_str = started_at.strftime('%Y-%m-%d %H:%M') if started_at else "?" - cwd_short = Path(cwd).name if cwd else "?" - print(f"\n{'='*60}") - print(f"{date_str} | {cwd_short} | {git_branch or '?'}") - print(f" {file_path}") - - # Show context around match - content_lower = content.lower() - query_lower = query_text.lower() - idx = content_lower.find(query_lower) - if idx >= 0: - start = max(0, idx - 50) - end = min(len(content), idx + len(query_text) + 50) - snippet = content[start:end].replace('\n', ' ') - if start > 0: - snippet = "..." + snippet - if end < len(content): - snippet = snippet + "..." - print(f" [{role}] {snippet}") - - -def cmd_show(args, con: duckdb.DuckDBPyConnection): - """Show session command handler.""" - file_path = args.file_path - - # Check if session exists - session = con.execute(""" - SELECT file_path, session_id, started_at, ended_at, duration_seconds, - model, cwd, git_branch, message_count, tool_count - FROM sessions WHERE file_path = ? - """, [file_path]).fetchone() - - if not session: - print(f"Session not found: {file_path}", file=sys.stderr) - sys.exit(1) - - file_path, session_id, started_at, ended_at, duration, model, cwd, git_branch, msg_count, tool_count = session - - print(f"# Session: {Path(file_path).stem}") - print() - if started_at: - print(f"**Date:** {started_at.strftime('%Y-%m-%d %H:%M')}") - if duration: - hours, remainder = divmod(duration, 3600) - minutes, seconds = divmod(remainder, 60) - if hours > 0: - print(f"**Duration:** {hours}h {minutes}m {seconds}s") - elif minutes > 0: - print(f"**Duration:** {minutes}m {seconds}s") - else: - print(f"**Duration:** {seconds}s") - if model: - print(f"**Model:** {model}") - if cwd: - print(f"**Working Directory:** {cwd}") - if git_branch: - print(f"**Git Branch:** {git_branch}") - print(f"**Messages:** {msg_count}") - print(f"**Tool Calls:** {tool_count}") - print() - print("---") - print() - - if args.summary: - # Get first user message as preview - first_msg = con.execute(""" - SELECT content FROM messages - WHERE file_path = ? AND role = 'user' AND LENGTH(content) > 20 - ORDER BY message_idx LIMIT 1 - """, [file_path]).fetchone() - - if first_msg: - preview = first_msg[0][:500].replace('\n', ' ') - if len(first_msg[0]) > 500: - preview += "..." - print(f"**First prompt:** {preview}") - return - - # Full transcript - messages = con.execute(""" - SELECT message_idx, role, content, has_thinking - FROM messages WHERE file_path = ? - ORDER BY message_idx - """, [file_path]).fetchall() - - for msg_idx, role, content, thinking in messages: - role_header = "## User" if role == 'user' else "## Assistant" - print(role_header) - print() - if content: - print(content) - print() - - -def main(): - parser = argparse.ArgumentParser(description='DuckDB-based transcript indexer') - parser.add_argument('--db', type=str, help=f'Database path (default: {DEFAULT_DB_PATH})') - - subparsers = parser.add_subparsers(dest='command', required=True) - - # index command - index_parser = subparsers.add_parser('index', help='Index session files') - index_parser.add_argument('--path', type=str, help='Sessions directory (default: auto-detect)') - index_parser.add_argument('--full', action='store_true', help='Force full reindex') - index_parser.add_argument('--limit', type=int, help='Limit number of files to process') - index_parser.add_argument('--quiet', '-q', action='store_true', help='Quiet mode') - - # recent command - recent_parser = subparsers.add_parser('recent', help='List recent sessions') - recent_parser.add_argument('--limit', '-n', type=int, default=10, help='Number of sessions') - recent_parser.add_argument('--project', type=str, help='Filter by project (cwd contains)') - recent_parser.add_argument('--since', type=str, help='Filter by time (e.g., 7d, 24h)') - - # search command - search_parser = subparsers.add_parser('search', help='Search sessions') - search_parser.add_argument('query', type=str, help='Search query') - search_parser.add_argument('--cwd', type=str, help='Filter by working directory') - search_parser.add_argument('--limit', '-n', type=int, default=20, help='Max results') - - # show command - show_parser = subparsers.add_parser('show', help='Show session transcript') - show_parser.add_argument('file_path', type=str, help='Session file path') - show_parser.add_argument('--summary', action='store_true', help='Summary only') - - args = parser.parse_args() - - # Setup database - db_path = Path(args.db) if args.db else DEFAULT_DB_PATH - db_path.parent.mkdir(parents=True, exist_ok=True) - - con = duckdb.connect(str(db_path)) - con.execute(SCHEMA) - - # Dispatch command - if args.command == 'index': - cmd_index(args, con) - elif args.command == 'recent': - cmd_recent(args, con) - elif args.command == 'search': - cmd_search(args, con) - elif args.command == 'show': - cmd_show(args, con) - - con.close() - - -if __name__ == '__main__': - main() diff --git a/.claude/skills/specalign/SKILL.md b/.claude/skills/specalign/SKILL.md deleted file mode 100644 index f0f5574..0000000 --- a/.claude/skills/specalign/SKILL.md +++ /dev/null @@ -1,112 +0,0 @@ ---- -name: specalign -description: Align spec files with implementation. Detects drift between spec and code, surfaces discrepancies, user decides whether to update spec or code. Use when both a spec file and its implementation are in context. ---- - -# Spec Alignment - -## Principles (Always Active) - -These apply whenever a spec file and its corresponding implementation are both in context: - -### Spec and Code Must Agree - -- A spec describes intended behavior; code implements it. When they disagree, one is wrong. -- Never silently tolerate drift - surface it immediately when noticed. -- The user decides which is the source of truth for each discrepancy. Do not assume. - -### Drift Categories - -- **Type drift**: spec defines fields/types that don't match the implementation -- **Behavior drift**: spec describes logic the code doesn't follow -- **Missing implementation**: spec defines something with no corresponding code -- **Missing spec**: code implements behavior not described in the spec -- **Constraint drift**: spec states invariants the code doesn't enforce -- **Error handling drift**: spec defines error cases the code doesn't handle (or vice versa) - -### Mutation Policy - -- Do not edit spec files unless the user explicitly chooses "update spec" for a discrepancy. -- Do not change implementation logic unless the user explicitly chooses "update code". -- When updating code, run lint/typecheck after changes. -- When updating spec, preserve formatting and structure of unrelated sections. - -### Bidirectional Awareness - -When reading code, check if a spec exists and note divergences. -When reading a spec, check if the implementation matches and note divergences. -This awareness should be passive - flag drift in your responses without interrupting the user's primary task, unless the drift is directly relevant. - -## Workflow (When Explicitly Aligning) - -### Step 1: Locate the Spec - -A spec file is required. Search for `SPEC.md` at colocated paths: -- `SPEC.md` (root, `apps/*/`, `packages/*/`, `src/lib/*/`) -- Supporting files linked from a `SPEC.md` TOC (e.g., `commands.spec.md`) -- Legacy patterns as fallback: `*.spec.md`, `*-spec.md`, `spec/*.md` - -If multiple specs exist, ask which to align. If none exist, stop - this workflow requires an existing spec. - -Read the spec file completely. - -### Step 2: Map Spec to Code - -For each spec section, identify the corresponding implementation: - -| Spec Section | Source File(s) | Status | -|---|---|---| -| `## Types` | `src/types.ts:10-40` | aligned / drifted / missing-impl / missing-spec | - -Read each mapped source file before assessing. - -### Step 3: Present Discrepancies - -For each discrepancy: - -``` -### DRIFT-01: - -**Spec says** (spec-file.md:L42): -> - -**Code does** (src/module.ts:L87): -> - -**Impact**: -``` - -Number with stable IDs (`DRIFT-NN`). Batch related discrepancies that share a root cause. - -### Step 4: User Decision - -For each discrepancy, ask: - -- **Update spec** - the code is correct, update the spec to match -- **Update code** - the spec is correct, update the code to match -- **Skip** - defer this discrepancy - -### Step 5: Apply Changes - -**Spec updates:** -- Edit the spec file with corrected text -- Preserve formatting and structure of unrelated sections - -**Code updates:** -- Fix the implementation to match the spec -- Run lint/typecheck after changes -- If non-trivial, outline the change and confirm before editing -- If unit tests exist for the affected code, run them -- If unit tests don't exist and the spec defines testable behavior, flag it - -### Step 6: Summary - -``` -## Spec Alignment: - -**Discrepancies found**: N -**Resolved**: X (spec: A, code: B, skipped: C) - -### Remaining -- DRIFT-04: (skipped) -``` From de9a3f40f91ec96dfcd2217ecd7615399fa3eb47 Mon Sep 17 00:00:00 2001 From: 0xBigBoss <95193764+0xBigBoss@users.noreply.github.com> Date: Sat, 28 Mar 2026 23:28:07 -0500 Subject: [PATCH 27/57] refactor(skills): condense skills and fix descriptions per Anthropic best practices MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Apply skill authoring guidelines across all 36 remaining skills: - Fix all descriptions to "Use when..." format (triggering conditions only, no workflow summaries that Claude shortcuts past) - Remove CLAUDE.md overlap from language skills (type-first workflow, functional patterns, error handling, config sections) - Split heavy skills into short SKILL.md + reference files: react-best-practices (1737→404 words), playwright (1655→386), git (1293→938 with git-examples.md) - Condense zig (1811→436), tamagui (1324→529), orbstack (1114→566), canton-network-repos (1281→503), spec (921→508), testing (1023→721), e2e (687→562) - Add CLAUDE.md cross-references in language skills Total context savings: ~8,500 words eliminated from skill loading. --- .claude/skills/axe-ios-simulator/SKILL.md | 2 +- .claude/skills/canton-network-repos/SKILL.md | 311 ++-------- .claude/skills/e2e/SKILL.md | 59 +- .claude/skills/git-best-practices/SKILL.md | 108 +--- .../skills/git-best-practices/git-examples.md | 99 +++ .claude/skills/git-rebase-sync/SKILL.md | 2 +- .claude/skills/go-best-practices/SKILL.md | 169 +----- .claude/skills/improve/SKILL.md | 2 +- .claude/skills/ios-device-screenshot/SKILL.md | 2 +- .claude/skills/nix-best-practices/SKILL.md | 2 +- .claude/skills/op-cli/SKILL.md | 2 +- .claude/skills/openai-image-gen/SKILL.md | 2 +- .../skills/orbstack-best-practices/SKILL.md | 336 ++--------- .../skills/playwright-best-practices/SKILL.md | 492 +-------------- .../playwright-patterns.md | 422 +++++++++++++ .claude/skills/python-best-practices/SKILL.md | 187 +----- .claude/skills/react-best-practices/SKILL.md | 571 +----------------- .../react-best-practices/react-patterns.md | 516 ++++++++++++++++ .claude/skills/spec-best-practices/SKILL.md | 137 +---- .../skills/tamagui-best-practices/SKILL.md | 389 ++---------- .../skills/testing-best-practices/SKILL.md | 74 +-- .../skills/typescript-best-practices/SKILL.md | 176 +----- .claude/skills/zig-best-practices/SKILL.md | 467 +------------- 23 files changed, 1386 insertions(+), 3141 deletions(-) create mode 100644 .claude/skills/git-best-practices/git-examples.md create mode 100644 .claude/skills/playwright-best-practices/playwright-patterns.md create mode 100644 .claude/skills/react-best-practices/react-patterns.md diff --git a/.claude/skills/axe-ios-simulator/SKILL.md b/.claude/skills/axe-ios-simulator/SKILL.md index 83fcc10..d31dbc2 100644 --- a/.claude/skills/axe-ios-simulator/SKILL.md +++ b/.claude/skills/axe-ios-simulator/SKILL.md @@ -1,6 +1,6 @@ --- name: axe-ios-simulator -description: iOS Simulator automation using AXe CLI for touch gestures, text input, hardware buttons, screenshots, video recording, and accessibility inspection. Use when automating iOS Simulator interactions, writing UI tests, capturing screenshots/video, or inspecting accessibility elements. Triggers on iOS Simulator automation, AXe CLI usage, simulator tap/swipe/gesture commands, or accessibility testing tasks. +description: Use when automating iOS Simulator interactions, capturing screenshots/video, or inspecting accessibility via AXe CLI. --- # AXe iOS Simulator Automation diff --git a/.claude/skills/canton-network-repos/SKILL.md b/.claude/skills/canton-network-repos/SKILL.md index 2d7e506..9429744 100644 --- a/.claude/skills/canton-network-repos/SKILL.md +++ b/.claude/skills/canton-network-repos/SKILL.md @@ -1,306 +1,95 @@ --- name: canton-network-repos -description: Canton Network, DAML, and Splice repository knowledge. Use when working with Canton participants, DAML smart contracts, Splice applications, LF version compatibility, or package ID mismatches. Triggers on Canton, DAML, Splice, decentralized-canton-sync, or LF version queries. +description: Use when working with Canton Network participants, DAML smart contracts, Splice applications, or debugging LF version and package ID issues. --- -# Canton Network Open-Source Repositories - -This skill provides comprehensive knowledge about the Canton Network open-source ecosystem, repository relationships, and build processes. - -## Activation - -Use this skill when: -- Working with Canton Network, DAML, or Splice repositories -- Investigating version compatibility issues -- Understanding enterprise vs community differences -- Debugging LF version or package ID mismatches -- Building Canton participants or Splice applications +# Canton Network Repositories ## Repository Hierarchy ``` -┌─────────────────────────────────────────────────────────────────┐ -│ Splice Version (e.g., 0.5.4) │ -│ github.com/digital-asset/decentralized-canton-sync │ -│ Applications: Validator, SV, Wallet, Scan, Amulet (CC) │ -└─────────────────────────┬───────────────────────────────────────┘ - │ depends on - ▼ -┌─────────────────────────────────────────────────────────────────┐ -│ Canton Version (e.g., 3.4.9) │ -│ github.com/digital-asset/canton │ -│ Runtime: Participant, Sequencer, Mediator, Admin API │ -└─────────────────────────┬───────────────────────────────────────┘ - │ depends on - ▼ -┌─────────────────────────────────────────────────────────────────┐ -│ DAML SDK (e.g., 3.4.9) │ -│ github.com/digital-asset/daml │ -│ Compiler: damlc, LF Engine, Ledger API, stdlib, protobuf │ -└─────────────────────────────────────────────────────────────────┘ +Splice (e.g., 0.5.4) github.com/digital-asset/decentralized-canton-sync + └─ depends on +Canton (e.g., 3.4.9) github.com/digital-asset/canton + └─ depends on +DAML SDK (e.g., 3.4.9) github.com/digital-asset/daml ``` -## Repository Details - -### 1. DAML SDK (`github.com/digital-asset/daml`) - -**Purpose**: Smart contract language, compiler, and runtime libraries. - -**Key Directories**: -``` -daml/ -├── sdk/ -│ ├── compiler/damlc/ # Haskell compiler source -│ │ └── lib/DA/Cli/Options.hs # --target version validation -│ ├── daml-lf/ -│ │ ├── language/ # LF version definitions (Scala) -│ │ ├── engine/ # LF execution engine -│ │ └── archive/ # DALF protobuf format -│ └── canton/ # Canton runtime (submodule) -├── ledger-api/ # gRPC API definitions -└── VERSION # SDK version string -``` +## Version Mapping -**LF Version Definitions** (`LanguageVersion.scala` at v3.4.9): -```scala -// V2 versions defined -val List(v2_1, v2_2, v2_dev) = AllV2 // Line 51 - v2_2 IS defined +| Splice | Canton | DAML SDK | Protocol | LF Default | LF Available | +|--------|--------|----------|----------|------------|--------------| +| 0.5.4 | 3.4.9 | 3.4.9 | PV34 | 2.1* | 2.2 (verified) | +| 0.5.3 | 3.4.8 | 3.4.8 | PV34 | 2.1* | 2.2 | +| 0.4.x | 3.3.x | 3.3.x | PV33 | 2.1 | 2.1 | -// Version ranges -case Major.V2 => VersionRange(v2_1, v2_2) // Line 171 - StableVersions includes v2_2 -def AllVersions = VersionRange(v2_1, v2_dev) +*Open-source Splice 0.5.4 ships with SDK snapshot `3.3.0-snapshot.20250502` (pre-dates LF 2.2). LF 2.2 was added to the SDK on 2025-10-03. Updating to SDK 3.4.9 enables LF 2.2 builds. -// Features at v2_2: -val flatArchive = v2_2 -val kindInterning = flatArchive -val exprInterning = flatArchive -val explicitPkgImports = v2_2 -val unsafeFromInterfaceRemoved = v2_2 -``` +## Key Configuration Files -**Note**: v2_2 IS in SDK v3.4.9 source. Older snapshots may not include it. +| Purpose | Repo | File | +|---------|------|------| +| LF version definitions | daml | `sdk/daml-lf/language/.../LanguageVersion.scala` | +| damlc target validation | daml | `sdk/compiler/damlc/lib/DA/Cli/Options.hs` | +| Canton version | canton | `VERSION` | +| Built-in DARs | canton | `community/common/src/main/daml/` | +| Splice LF config | splice | `project/CantonDependencies.scala` | +| Package targets | splice | `daml/*/daml.yaml` | +| Docker builds | splice | `cluster/images/*/Dockerfile` | -**damlc Target Validation** (`Options.hs`): -```haskell -lfVersionOpt :: Parser LF.Version --- Validates against LF.supportedOutputVersions --- Error: "Unknown Daml-LF version: X" if not in list +**Splice LF config** (`project/CantonDependencies.scala`): +```scala +val daml_language_versions = Seq("2.1") // ← LF target; change to "2.2" for upgrade +val daml_compiler_version = sys.env("DAML_COMPILER_VERSION") ``` -### 2. Canton (`github.com/digital-asset/canton`) +## Package ID Derivation -**Purpose**: Distributed ledger runtime implementing the Canton Protocol. +Package IDs are cryptographic hashes of: source content + LF version (`--target`) + SDK/stdlib version + dependency package IDs. -**Key Directories**: -``` -canton/ -├── community/ # Open-source Canton -│ ├── app/ # CantonCommunityApp entry point -│ ├── participant/ # Participant node implementation -│ ├── domain/ # Embedded domain (sequencer/mediator) -│ └── common/src/main/daml/ # Built-in DAML packages -│ └── AdminWorkflows/ # Ping, party replication DARs -├── daml/ # DAML SDK submodule -├── daml_dependencies.json # LF library versions -├── VERSION # Canton version -└── version.sbt # SBT version config -``` +**Changing LF version = different package IDs = incompatible packages.** Canton validates that upgraded packages use equal or newer LF version; mixing LF versions on the same ledger causes validation failures. -**Built-in DARs** (embedded in JAR): -- `canton-builtin-admin-workflow-ping.dar` -- `canton-builtin-admin-workflow-party-replication-alpha.dar` -- `CantonExamples.dar` +## Enterprise vs Community Canton -**Enterprise vs Community**: | Feature | Enterprise | Community | |---------|------------|-----------| -| Main class | CantonEnterpriseApp | CantonCommunityApp | | Transaction processing | Parallel | Sequential | -| Pruning | Available | Limited | | Database | PostgreSQL, Oracle | PostgreSQL only | | HA Domain | Supported | Embedded only | +| Pruning | Full | Limited | -### 3. Splice (`github.com/digital-asset/decentralized-canton-sync`) - -**Purpose**: Decentralized synchronizer governance, Amulet (Canton Coin), and network applications. - -**Key Directories**: -``` -decentralized-canton-sync/ -├── project/ -│ ├── CantonDependencies.scala # Version config, LF versions -│ └── DamlPlugin.scala # DAR build logic -├── daml/ -│ ├── splice-amulet/ # Canton Coin token contracts -│ ├── splice-wallet/ # Wallet contracts -│ ├── splice-dso-governance/ # DSO governance -│ └── */daml.yaml # Package configs with --target -├── apps/ -│ ├── sv/ # Super Validator app -│ ├── validator/ # Validator app -│ ├── wallet/ # Wallet backend -│ └── scan/ # Payment scan service -├── cluster/images/ # Docker image builds -│ └── canton-community/ # Community participant image -└── daml-compiler-sources.json # Compiler version reference -``` - -**Critical Configuration** (`CantonDependencies.scala`): -```scala -object CantonDependencies { - val version: String = "3.4.9" - val daml_language_versions = Seq("2.1") // ← LF target version - val daml_libraries_version = version - val daml_compiler_version = sys.env("DAML_COMPILER_VERSION") -} -``` - -**Package Target** (`daml/splice-amulet/daml.yaml`): -```yaml -sdk-version: 3.3.0-snapshot.20250502.13767.0.v2fc6c7e2 -build-options: - - --target=2.1 # Explicit LF 2.1 target -``` - -## Version Mapping - -| Splice | Canton | DAML SDK | Protocol | LF (Default) | LF (With SDK 3.4.9) | -|--------|--------|----------|----------|--------------|---------------------| -| 0.5.4 | 3.4.9 | 3.4.9 | PV34 | 2.1* | 2.2 (verified) | -| 0.5.3 | 3.4.8 | 3.4.8 | PV34 | 2.1* | 2.2 | -| 0.4.x | 3.3.x | 3.3.x | PV33 | 2.1 | 2.1 | - -*Open-source Splice 0.5.4 ships with SDK snapshot `3.3.0-snapshot.20250502` which predates LF 2.2. - -**Root Cause (Verified)**: The public Splice release uses an SDK snapshot from **May 2, 2025**, but LF 2.2 was added to the SDK on **October 3, 2025**. Updating to SDK 3.4.9 enables LF 2.2 builds. +## Build Commands -**Key insight**: LF 2.2 is fully available in open-source SDK v3.4.9. The Splice project simply needs to be updated to use the newer SDK. - -## LF Version Implications - -### Package ID Derivation -Package IDs are cryptographic hashes derived from: -1. Package source content -2. **LF version used** (`--target`) -3. SDK/stdlib versions -4. Dependency package IDs - -**Changing LF version = Different package IDs = Incompatible packages** - -### Upgrade Validation -Canton validates package upgrades: -- Upgraded packages must use equal or newer LF version -- LF 2.1 package cannot "upgrade" to LF 2.2 package (different IDs) -- Mixing LF versions on same ledger causes validation failures - -## Building from Open-Source - -### Community Canton Participant ```bash -cd canton -sbt "community/app/assembly" +# Community Canton participant +cd canton && sbt "community/app/assembly" # Output: community/app/target/scala-2.13/canton-community.jar -``` -### Splice Applications -```bash -cd decentralized-canton-sync -sbt compile # Requires DAML_COMPILER_VERSION env var +# Splice applications (requires DAML_COMPILER_VERSION env var) +cd decentralized-canton-sync && sbt compile ``` -### Building with LF 2.2 (Verified Working) - -LF 2.2 is available in SDK v3.4.9. The following steps have been **verified to work**: - -1. Edit `project/CantonDependencies.scala`: - ```scala - val daml_language_versions = Seq("2.2") - ``` - -2. Update `nix/daml-compiler-sources.json`: - ```json - { "version": "3.4.9" } - ``` - -3. Update all `daml/*/daml.yaml` files: - ```yaml - sdk-version: 3.4.9 - build-options: - - --target=2.2 - ``` - -4. Remove invalid warning flags (not present in SDK 3.4.9): - ```bash - # Remove -Wno-ledger-time-is-alpha from all daml.yaml files - ``` - -5. Build packages: - ```bash - cd decentralized-canton-sync - nix-shell -p daml-sdk --run "daml build -p daml/splice-util" - nix-shell -p daml-sdk --run "daml build -p daml/splice-amulet" - ``` - -**Verified**: splice-util and splice-amulet build successfully with LF 2.2 and SDK 3.4.9. - -## Fully Open-Source LF 2.2 Build (Verified) +## Upgrading to LF 2.2 (Verified with SDK 3.4.9) -Both Splice and Canton can be built with LF 2.2 from entirely open-source code: +1. `project/CantonDependencies.scala`: `val daml_language_versions = Seq("2.2")` +2. `nix/daml-compiler-sources.json`: `{ "version": "3.4.9" }` +3. All `daml/*/daml.yaml`: set `sdk-version: 3.4.9` and `--target=2.2` +4. Remove `-Wno-ledger-time-is-alpha` from all `daml.yaml` files (not in SDK 3.4.9) +5. Build: `daml build -p daml/splice-util && daml build -p daml/splice-amulet` -### Canton Built-in DARs - -Update Canton's daml.yaml files: -```bash -cd canton/community -# Update all daml.yaml files to sdk-version: 3.4.9 and --target=2.2 -perl -pi -e 's/sdk-version: 3\.3\.0-snapshot\.[^\n]*/sdk-version: 3.4.9/g' **/daml.yaml -perl -pi -e 's/--target=2\.1/--target=2.2/g' **/daml.yaml -``` - -Rebuild Canton: -```bash -sbt "canton-community-app/assembly" -``` - -### Verified Results (2025-12-24) - -Community-built DARs have **identical package IDs** to enterprise: -- `canton-builtin-admin-workflow-ping-3.4.9-fbeb863dab36da66d99...` - -This confirms full compatibility with enterprise deployments. - -## Key Files Reference - -| Purpose | Repository | File | -|---------|------------|------| -| LF versions (Scala) | daml | `sdk/daml-lf/language/.../LanguageVersion.scala` | -| damlc validation | daml | `sdk/compiler/damlc/lib/DA/Cli/Options.hs` | -| Canton version | canton | `VERSION` | -| Canton DARs | canton | `community/common/src/main/daml/` | -| Splice LF config | splice | `project/CantonDependencies.scala` | -| Package targets | splice | `daml/*/daml.yaml` | -| Docker builds | splice | `cluster/images/*/Dockerfile` | +Community-built DARs have identical package IDs to enterprise at the same LF version (verified 2025-12-24). ## Troubleshooting -### "Unknown Daml-LF version: 2.2" -- **Cause**: damlc binary doesn't support 2.2 in `supportedOutputVersions` -- **Check**: `daml damlc --help` for supported targets -- **Fix**: Use SDK version that includes 2.2, or use 2.1 +**"Unknown Daml-LF version: 2.2"**: damlc binary doesn't support 2.2. Check `daml damlc --help` for supported targets; upgrade to SDK 3.4.9. -### Package ID Mismatch -- **Cause**: Different LF versions between builds -- **Check**: `unzip -p package.dar META-INF/MANIFEST.MF | grep Sdk-Version` -- **Fix**: Ensure consistent `--target` across all builds +**Package ID mismatch**: different `--target` values between builds. Check: `unzip -p package.dar META-INF/MANIFEST.MF | grep Sdk-Version` -### Upgrade Validation Failed -- **Cause**: Trying to swap enterprise (LF 2.2) with community (LF 2.1) packages -- **Fix**: Use DAR injection to maintain LF 2.2 compatibility +**Upgrade validation failed**: swapping enterprise (LF 2.2) with community (LF 2.1) packages. Use DAR injection to maintain LF 2.2 compatibility. -## External References +## References - [DAML SDK Releases](https://github.com/digital-asset/daml/releases) - [Canton Releases](https://github.com/digital-asset/canton/releases) -- [Splice Documentation](https://docs.dev.sync.global/) +- [Splice Docs](https://docs.dev.sync.global/) - [DAML-LF Governance](https://github.com/digital-asset/daml/blob/main/daml-lf/governance.rst) -- [Canton Network Docs](https://docs.digitalasset.com/) diff --git a/.claude/skills/e2e/SKILL.md b/.claude/skills/e2e/SKILL.md index 66303eb..36d0519 100644 --- a/.claude/skills/e2e/SKILL.md +++ b/.claude/skills/e2e/SKILL.md @@ -1,29 +1,20 @@ --- name: e2e -description: Run e2e tests, fix flake and outdated tests, identify bugs against spec. Use when running e2e tests, debugging test failures, or fixing flaky tests. Never changes source code logic or API without spec backing. +description: Use when running e2e tests, debugging test failures, or fixing flaky tests. Covers failure taxonomy, fix rules, and workflow. Never changes source code logic or API without spec backing. --- # E2E Testing -## Principles (Always Active) - -These apply whenever working with e2e tests, test failures, or test flakiness: - -### Failure Taxonomy +## Failure Taxonomy Every e2e failure is exactly one of: **A. Flaky** (test infrastructure issue) -- Race conditions, timing-dependent assertions -- Stale selectors after UI changes -- Missing waits, incorrect wait targets -- Network timing, mock setup ordering +- Race conditions, timing-dependent assertions, stale selectors, missing waits - Symptom: passes on retry, fails intermittently **B. Outdated** (test no longer matches implementation) -- Test asserts old behavior that was intentionally changed -- Selectors reference removed/renamed elements -- API contract changed, test wasn't updated +- Test asserts old behavior that was intentionally changed; selectors reference removed elements - Symptom: consistent failure, app works correctly **C. Bug** (implementation doesn't match spec) @@ -31,7 +22,7 @@ Every e2e failure is exactly one of: - **Only classify as bug when a spec exists to validate against** - If no spec exists, classify as "unverified failure" and report to the user -### Fix Rules by Category +## Fix Rules by Category **Flaky fixes:** - Replace `waitForTimeout` with auto-waiting locators @@ -39,7 +30,6 @@ Every e2e failure is exactly one of: - Fix race conditions with `expect()` web-first assertions - Fix mock/route setup ordering (before navigation) - **Never add arbitrary delays** - fix the underlying wait -- **Never weaken assertions** to make flaky tests pass - **Never add retry loops around assertions** - use the framework's built-in retry **Outdated fixes:** @@ -50,24 +40,16 @@ Every e2e failure is exactly one of: **Bug fixes:** - Quote the spec section that defines expected behavior - Fix the source code to match the spec -- **Unit tests MUST exist** before the fix is complete - - If unit tests exist, run them to confirm - - If unit tests don't exist, write them first (TDD) +- **Unit tests MUST exist** before the fix is complete — write them first if missing (TDD) - **Never change e2e assertions** to match buggy code - **Never change API contracts or interfaces** without spec backing - If no spec exists, ask the user: bug or outdated test? -### Source Code Boundary - -E2e test fixes must not change: -- Application logic or business rules -- API contracts, request/response shapes -- Database schemas or migrations -- Configuration defaults +## Source Code Boundary -The only exception: bug fixes where a spec explicitly defines the correct behavior and unit tests cover the fix. +E2e test fixes must not change application logic, API contracts, database schemas, or configuration defaults. The only exception: bug fixes where a spec explicitly defines the correct behavior and unit tests cover the fix. -## Workflow (When Explicitly Running E2E) +## Workflow ### Step 1: Discover Test Infrastructure @@ -78,8 +60,6 @@ The only exception: bug fixes where a spec explicitly defines the correct behavi ### Step 2: Run Tests -Run with minimal reporter to avoid context overflow: - ```bash # Playwright yarn playwright test --reporter=line @@ -88,12 +68,6 @@ yarn playwright test --reporter=line yarn test:e2e ``` -If a filter is specified, apply it: -```bash -yarn playwright test --reporter=line -g "transfer" -yarn test:e2e -- --grep "transfer" -``` - Parse failures into: | Test | File | Error | Category | @@ -102,23 +76,14 @@ Parse failures into: ### Step 3: Categorize -For each failure: -1. Read the test file -2. Read the source code it exercises -3. Check for a corresponding spec file -4. Assign category: flaky, outdated, bug, or unverified +For each failure: read the test file, read the source code it exercises, check for a corresponding spec file, assign category (flaky / outdated / bug / unverified). ### Step 4: Fix by Category -Apply fixes following the Principles above, in order: -1. **Flaky** - fix test infrastructure issues first (unblocks other tests) -2. **Outdated** - update stale assertions -3. **Bug** - fix with spec + unit test gate +Apply fixes in order: flaky first (unblocks other tests), then outdated, then bug. ### Step 5: Re-run and Report -After all fixes, re-run the suite: - ``` ## E2E Results @@ -136,3 +101,5 @@ After all fixes, re-run the suite: ### Unit Tests Added - `src/transfer.test.ts` - amount validation edge cases (covers BUG fix) ``` + +See `testing-best-practices` for async handling, flake classification, and preflight check patterns. diff --git a/.claude/skills/git-best-practices/SKILL.md b/.claude/skills/git-best-practices/SKILL.md index 13c04df..7b4acd6 100644 --- a/.claude/skills/git-best-practices/SKILL.md +++ b/.claude/skills/git-best-practices/SKILL.md @@ -1,6 +1,6 @@ --- name: git-best-practices -description: Git workflow patterns for commits, branching, PRs, and history management across heterogeneous repositories. Use when creating commits, managing branches, opening pull requests, or rewriting history. Do not use for non-git implementation tasks or repo-specific release policy decisions without repository documentation. +description: Use when creating commits, managing branches, opening PRs, or rewriting history. Not for non-git implementation tasks or repo-specific release policy decisions. --- # Git Best Practices @@ -16,17 +16,15 @@ When this skill is loaded, follow these directives for all git operations: ## Agent Git Workflow -Follow this sequence when performing git operations: - -1. **Check state** — run `git status` and `git diff HEAD`; output: working tree and unstaged/staged delta -2. **Discover branches** — identify and store default/current/(optional) production branch names (see Branch Discovery) +1. **Check state** — run `git status` and `git diff HEAD` +2. **Discover branches** — identify default/current/(optional) production branch names (see Branch Discovery) 3. **Stage by name** — `git add path/to/file` for each file; verify with `git status` 4. **Write a conventional commit** — `type(scope): description` with optional body -5. **Push safely** — use regular push by default; use `git push --force-with-lease origin {branch}` only for rewritten history and only after user confirmation +5. **Push safely** — regular push by default; `git push --force-with-lease origin {branch}` only for rewritten history after user confirmation ### Checkpoint Commits -Agents may create WIP checkpoint commits during long-running tasks. These are development artifacts, cleaned up before PR. +Agents may create WIP checkpoint commits during long-running tasks, cleaned up before PR. - Prefix with `wip:` or use standard conventional commit format - Keep changes logically grouped even in WIP state @@ -82,84 +80,7 @@ Add a body when: - Multi-part changes benefit from a bullet list - External context is needed (links, issue references, root cause) -### Examples - - - - -Single-line fix, no body needed: - -``` -fix(shell): restore Alt+F terminal navigation -``` - - - -Non-obvious fix with body explaining root cause: - -``` -fix(shell): use HOMEBREW_PREFIX to avoid path_helper breaking plugins in login shells - -macOS path_helper reorders PATH in login shells, putting /usr/local/bin -before /opt/homebrew/bin. This caused `brew --prefix` to resolve the stale -Intel Homebrew, so fzf, zsh-autosuggestions, and zsh-syntax-highlighting -all silently failed to load in Ghostty (which spawns login shells). - -Use the HOMEBREW_PREFIX env var (set by brew shellenv in .zshenv) instead -of calling `brew --prefix` — it survives path_helper and is faster. -``` - - - -Feature with bullet-list body for multi-part changes: - -``` -feat(install): add claude bootstrap runtime management - -- migrate Claude defaults to declarative files under claude/defaults -- add claude-bootstrap check/fix/uninstall with backup-first migration -- stop stowing full claude/codex runtime trees and tighten drift checks -``` - - - -Monorepo commit with ticket reference in branch and scope: - -``` -fix(pool-party): handle stale settlement state on reconnect - -PoolSettlement contract stays in pending state when the participant -disconnects mid-settlement. Check settlement timestamp and expire -stale entries on reconnect. - -Fixes SEND-718 -``` - - - -Submodule update with downstream commit info: - -``` -chore(submodule): update claude-code - -Bump claude-code to 88d0c75 (feat(skills): add tiltup, specalign, and e2e skills). -``` - -For trivial bumps, `bump` or `bump claude-code submodule` is acceptable. - - - -Breaking change using `!` suffix: - -``` -refactor(api)!: change auth endpoint response format - -The /auth/token endpoint now returns { access_token, expires_in } -instead of { token, expiry }. All clients must update their parsers. -``` - - - +See git-examples.md for commit message examples. ## Branch Discovery @@ -176,22 +97,7 @@ git branch --show-current git branch -r --list 'origin/main' 'origin/master' 'origin/production' ``` -**Fallback when `gh` is unavailable or the repo has no remote:** - -```bash -# Infer default branch from local refs -git symbolic-ref refs/remotes/origin/HEAD 2>/dev/null | sed 's@^refs/remotes/origin/@@' - -# Last resort: check local branches and fail loudly if unknown -if git rev-parse --verify main >/dev/null 2>&1; then - echo main -elif git rev-parse --verify master >/dev/null 2>&1; then - echo master -else - echo "ERROR: unable to determine default branch (main/master not found)." >&2 - exit 1 -fi -``` +If `gh` is unavailable or the repo has no remote, see the fallback commands in git-examples.md. Store the discovered branch name and reference it throughout. Use the actual branch name in all subsequent commands. diff --git a/.claude/skills/git-best-practices/git-examples.md b/.claude/skills/git-best-practices/git-examples.md new file mode 100644 index 0000000..d9aa7a7 --- /dev/null +++ b/.claude/skills/git-best-practices/git-examples.md @@ -0,0 +1,99 @@ +# Git Examples Reference + +## Commit Message Examples + + + + +Single-line fix, no body needed: + +``` +fix(shell): restore Alt+F terminal navigation +``` + + + +Non-obvious fix with body explaining root cause: + +``` +fix(shell): use HOMEBREW_PREFIX to avoid path_helper breaking plugins in login shells + +macOS path_helper reorders PATH in login shells, putting /usr/local/bin +before /opt/homebrew/bin. This caused `brew --prefix` to resolve the stale +Intel Homebrew, so fzf, zsh-autosuggestions, and zsh-syntax-highlighting +all silently failed to load in Ghostty (which spawns login shells). + +Use the HOMEBREW_PREFIX env var (set by brew shellenv in .zshenv) instead +of calling `brew --prefix` — it survives path_helper and is faster. +``` + + + +Feature with bullet-list body for multi-part changes: + +``` +feat(install): add claude bootstrap runtime management + +- migrate Claude defaults to declarative files under claude/defaults +- add claude-bootstrap check/fix/uninstall with backup-first migration +- stop stowing full claude/codex runtime trees and tighten drift checks +``` + + + +Monorepo commit with ticket reference in branch and scope: + +``` +fix(pool-party): handle stale settlement state on reconnect + +PoolSettlement contract stays in pending state when the participant +disconnects mid-settlement. Check settlement timestamp and expire +stale entries on reconnect. + +Fixes SEND-718 +``` + + + +Submodule update with downstream commit info: + +``` +chore(submodule): update claude-code + +Bump claude-code to 88d0c75 (feat(skills): add tiltup, specalign, and e2e skills). +``` + +For trivial bumps, `bump` or `bump claude-code submodule` is acceptable. + + + +Breaking change using `!` suffix: + +``` +refactor(api)!: change auth endpoint response format + +The /auth/token endpoint now returns { access_token, expires_in } +instead of { token, expiry }. All clients must update their parsers. +``` + + + + +## Branch Discovery Fallback + +Use when `gh` is unavailable or the repo has no remote: + +```bash +# Infer default branch from local refs +git symbolic-ref refs/remotes/origin/HEAD 2>/dev/null | sed 's@^refs/remotes/origin/@@' + +# Last resort: check local branches and fail loudly if unknown +if git rev-parse --verify main >/dev/null 2>&1; then + echo main +elif git rev-parse --verify master >/dev/null 2>&1; then + echo master +else + echo "ERROR: unable to determine default branch (main/master not found)." >&2 + exit 1 +fi +``` diff --git a/.claude/skills/git-rebase-sync/SKILL.md b/.claude/skills/git-rebase-sync/SKILL.md index 5fa9af7..896cdef 100644 --- a/.claude/skills/git-rebase-sync/SKILL.md +++ b/.claude/skills/git-rebase-sync/SKILL.md @@ -1,6 +1,6 @@ --- name: git-rebase-sync -description: Sync a feature branch onto the latest origin base branch via git rebase, with safety rails, deliberate conflict resolution, and safe force-with-lease pushing. +description: Use when syncing a feature branch onto the latest origin base branch via git rebase. metadata: short-description: Rebase branch sync --- diff --git a/.claude/skills/go-best-practices/SKILL.md b/.claude/skills/go-best-practices/SKILL.md index 12d407b..b4d2fab 100644 --- a/.claude/skills/go-best-practices/SKILL.md +++ b/.claude/skills/go-best-practices/SKILL.md @@ -1,44 +1,16 @@ --- name: go-best-practices -description: Provides Go patterns for type-first development with custom types, interfaces, functional options, and error handling. Must use when reading or writing Go files. +description: Use when reading or writing Go files (.go, go.mod). --- # Go Best Practices -## Type-First Development +Follows type-first, functional, and error handling patterns from CLAUDE.md. This skill covers language-specific idioms only. -Types define the contract before implementation. Follow this workflow: - -1. **Define data structures** - structs and interfaces first -2. **Define function signatures** - parameters, return types, and error conditions -3. **Implement to satisfy types** - let the compiler guide completeness -4. **Validate at boundaries** - check inputs where data enters the system - -### Make Illegal States Unrepresentable +## Make Illegal States Unrepresentable Use Go's type system to prevent invalid states at compile time. -**Structs for domain models:** -```go -// Define the data model first -type User struct { - ID UserID - Email string - Name string - CreatedAt time.Time -} - -type CreateUserRequest struct { - Email string - Name string -} - -// Functions follow from the types -func CreateUser(req CreateUserRequest) (*User, error) { - // implementation -} -``` - **Custom types for domain primitives:** ```go // Distinct types prevent mixing up IDs @@ -49,10 +21,6 @@ func GetUser(id UserID) (*User, error) { // Compiler prevents passing OrderID here } -func NewUserID(raw string) UserID { - return UserID(raw) -} - // Methods attach behavior to the type func (id UserID) String() string { return string(id) @@ -62,22 +30,18 @@ func (id UserID) String() string { **Interfaces for behavior contracts:** ```go // Define what you need, not what you have -type Reader interface { - Read(p []byte) (n int, err error) -} - type UserRepository interface { GetByID(ctx context.Context, id UserID) (*User, error) Save(ctx context.Context, user *User) error } // Accept interfaces, return structs -func ProcessInput(r Reader) ([]byte, error) { +func ProcessInput(r io.Reader) ([]byte, error) { return io.ReadAll(r) } ``` -**Enums with iota:** +**Enums with iota and exhaustive switch:** ```go type Status int @@ -87,20 +51,6 @@ const ( StatusPending ) -func (s Status) String() string { - switch s { - case StatusActive: - return "active" - case StatusInactive: - return "inactive" - case StatusPending: - return "pending" - default: - return fmt.Sprintf("Status(%d)", s) - } -} - -// Exhaustive handling in switch func ProcessStatus(s Status) (string, error) { switch s { case StatusActive: @@ -120,28 +70,16 @@ func ProcessStatus(s Status) (string, error) { type ServerOption func(*Server) func WithPort(port int) ServerOption { - return func(s *Server) { - s.port = port - } -} - -func WithTimeout(d time.Duration) ServerOption { - return func(s *Server) { - s.timeout = d - } + return func(s *Server) { s.port = port } } func NewServer(opts ...ServerOption) *Server { - s := &Server{ - port: 8080, // sensible defaults - timeout: 30 * time.Second, - } + s := &Server{port: 8080, timeout: 30 * time.Second} for _, opt := range opts { opt(s) } return s } - // Usage: NewServer(WithPort(3000), WithTimeout(time.Minute)) ``` @@ -153,65 +91,25 @@ type Timestamps struct { } type User struct { - Timestamps // embedded - User has CreatedAt, UpdatedAt + Timestamps // User gains CreatedAt, UpdatedAt ID UserID Email string } ``` -## Module Structure - -Prefer smaller files within packages: one type or concern per file. Split when a file handles multiple unrelated types or exceeds ~300 lines. Keep tests in `_test.go` files alongside implementation. Package boundaries define the API; internal organization is flexible. - -## Functional Patterns - -- Use value receivers when methods don't mutate state; reserve pointer receivers for mutation. -- Avoid package-level mutable variables; pass dependencies explicitly via function parameters. -- Return new structs/slices rather than mutating inputs; makes data flow explicit. -- Use closures and higher-order functions where they simplify code (e.g., `sort.Slice`, iterators). +## Go-Specific Error Handling -## Instructions - -- Return errors with context using `fmt.Errorf` and `%w` for wrapping. This preserves the error chain for debugging. -- Every function returns a value or an error; unimplemented paths return descriptive errors. Explicit failures are debuggable. -- Handle all branches in `switch` statements; include a `default` case that returns an error. Exhaustive handling prevents silent bugs. -- Pass `context.Context` to external calls with explicit timeouts. Runaway requests cause cascading failures. -- Reserve `panic` for truly unrecoverable situations; prefer returning errors. Panics crash the program. -- Add or update table-driven tests for new logic; cover edge cases (empty input, nil, boundaries). - -## Examples - -Explicit failure for unimplemented logic: -```go -func buildWidget(widgetType string) (*Widget, error) { - return nil, fmt.Errorf("buildWidget not implemented for type: %s", widgetType) -} -``` - -Wrap errors with context to preserve the chain: +Wrap errors with `%w` to preserve the chain for `errors.Is` / `errors.As`: ```go out, err := client.Do(ctx, req) if err != nil { return nil, fmt.Errorf("fetch widget failed: %w", err) } -return out, nil ``` -Exhaustive switch with default error: -```go -func processStatus(status string) (string, error) { - switch status { - case "active": - return "processing", nil - case "inactive": - return "skipped", nil - default: - return "", fmt.Errorf("unhandled status: %s", status) - } -} -``` +## Structured Logging -Structured logging with slog: +Use `log/slog` with structured key-value pairs: ```go import "log/slog" @@ -224,46 +122,3 @@ func createWidget(name string) (*Widget, error) { return widget, nil } ``` - -## Configuration - -- Load config from environment variables at startup; validate required values before use. Missing config should cause immediate exit. -- Define a Config struct as single source of truth; avoid `os.Getenv` scattered throughout code. -- Use sensible defaults for development; require explicit values for production secrets. - -### Examples - -Typed config struct: -```go -type Config struct { - Port int - DatabaseURL string - APIKey string - Env string -} - -func LoadConfig() (*Config, error) { - dbURL := os.Getenv("DATABASE_URL") - if dbURL == "" { - return nil, fmt.Errorf("DATABASE_URL is required") - } - apiKey := os.Getenv("API_KEY") - if apiKey == "" { - return nil, fmt.Errorf("API_KEY is required") - } - port := 3000 - if p := os.Getenv("PORT"); p != "" { - var err error - port, err = strconv.Atoi(p) - if err != nil { - return nil, fmt.Errorf("invalid PORT: %w", err) - } - } - return &Config{ - Port: port, - DatabaseURL: dbURL, - APIKey: apiKey, - Env: getEnvOrDefault("ENV", "development"), - }, nil -} -``` diff --git a/.claude/skills/improve/SKILL.md b/.claude/skills/improve/SKILL.md index f5dd77c..26ee7fd 100644 --- a/.claude/skills/improve/SKILL.md +++ b/.claude/skills/improve/SKILL.md @@ -1,6 +1,6 @@ --- name: improve -description: Structured improvement analysis grounded in session observations. Use after completing a task, before claiming done, before handoff, or when the user asks "what are some improvements?" Surfaces concrete suggestions across correctness, simplicity, security, tests, performance, and DX — prioritized by impact, each citing what was actually observed. +description: Use after completing a task, before claiming done, before handoff, or when asked "what are some improvements?" Surfaces concrete suggestions grounded in session observations. --- # Improve diff --git a/.claude/skills/ios-device-screenshot/SKILL.md b/.claude/skills/ios-device-screenshot/SKILL.md index 7350307..681a892 100644 --- a/.claude/skills/ios-device-screenshot/SKILL.md +++ b/.claude/skills/ios-device-screenshot/SKILL.md @@ -1,6 +1,6 @@ --- name: ios-device-screenshot -description: Take screenshots from physical iOS devices connected via USB using pymobiledevice3. Use when capturing screenshots from real iPhones/iPads (not simulators), debugging on-device, or needing high-fidelity device captures. Triggers on physical iOS device screenshots, pymobiledevice3 usage, or USB-connected device capture tasks. +description: Use when capturing screenshots from physical iOS devices connected via USB using pymobiledevice3. --- # iOS Device Screenshot diff --git a/.claude/skills/nix-best-practices/SKILL.md b/.claude/skills/nix-best-practices/SKILL.md index 44866c1..afc659c 100644 --- a/.claude/skills/nix-best-practices/SKILL.md +++ b/.claude/skills/nix-best-practices/SKILL.md @@ -1,6 +1,6 @@ --- name: nix-best-practices -description: Nix patterns for flakes, overlays, unfree handling, and binary overlays. Use when working with flake.nix or shell.nix. +description: Use when working with Nix flakes, overlays, shell.nix, or flake.nix files. --- # Nix Best Practices diff --git a/.claude/skills/op-cli/SKILL.md b/.claude/skills/op-cli/SKILL.md index ba5e111..b75e15a 100644 --- a/.claude/skills/op-cli/SKILL.md +++ b/.claude/skills/op-cli/SKILL.md @@ -1,6 +1,6 @@ --- name: op-cli -description: Secure 1Password CLI patterns for reading secrets, discovering vaults/items, and piping credentials to other tools. Use when reading from 1Password, rotating secrets, or piping credentials to wrangler/kubectl/etc. Triggers on op CLI, 1Password, secret rotation, or credential piping tasks. +description: Use when reading from 1Password, discovering vaults/items, rotating secrets, or piping credentials to other tools via op CLI. --- # 1Password CLI (`op`) — Secure Handling diff --git a/.claude/skills/openai-image-gen/SKILL.md b/.claude/skills/openai-image-gen/SKILL.md index 855eee8..518f7c6 100644 --- a/.claude/skills/openai-image-gen/SKILL.md +++ b/.claude/skills/openai-image-gen/SKILL.md @@ -1,6 +1,6 @@ --- name: openai-image-gen -description: Generate images using OpenAI's DALL-E 3 API. Use when needing to create graphics, icons, backgrounds, or any visual assets. Requires OPENAI_API_KEY in environment. +description: Use when generating images, graphics, icons, or visual assets via OpenAI DALL-E 3 API. Requires OPENAI_API_KEY. --- # OpenAI Image Generation (DALL-E 3) diff --git a/.claude/skills/orbstack-best-practices/SKILL.md b/.claude/skills/orbstack-best-practices/SKILL.md index 4f1e9c0..e38c531 100644 --- a/.claude/skills/orbstack-best-practices/SKILL.md +++ b/.claude/skills/orbstack-best-practices/SKILL.md @@ -1,192 +1,78 @@ --- name: orbstack-best-practices -description: Patterns for OrbStack Linux VMs and Docker on macOS. Covers orbctl/orb commands, machine lifecycle, cloud-init, networking, file sharing, and SSH access. Must use when working with OrbStack, orbctl commands, or Linux VMs on macOS. +description: Use when working with OrbStack Linux VMs, Docker on macOS, orbctl commands, or orb machine lifecycle. --- # OrbStack Best Practices -OrbStack is a fast, lightweight Docker and Linux VM runtime for macOS. Replaces Docker Desktop with better performance and seamless macOS integration. +OrbStack is a fast Docker and Linux VM runtime for macOS. Replaces Docker Desktop with better performance and seamless macOS integration. ## Core Commands ```bash -# Start/stop -orb # Start + open default machine shell -orb start # Start OrbStack -orb stop # Stop OrbStack +# Machine lifecycle +orb list # List machines +orb create ubuntu:noble myvm # Create (distro:version name) +orb create --arch amd64 ubuntu x86vm # x86 emulation via Rosetta +orb create ubuntu myvm -c cloud.yml # With cloud-init +orb start/stop/restart/delete myvm +orb default myvm # Set default machine -# Machine management -orb list # List machines -orb create ubuntu # Create with latest version -orb create ubuntu:jammy myvm # Specific version + name -orb create --arch amd64 ubuntu intel # x86 on Apple Silicon -orb delete myvm # Delete machine - -# Shell access -orb # Default machine shell -orb -m myvm # Specific machine -orb -u root # As root -orb -m myvm -u root # Combined - -# Run commands -orb uname -a # Run in default machine -orb -m myvm ./script.sh # Run in specific machine +# Shell and exec +orb # Shell into default machine +orb -m myvm -u root # Specific machine + user +orb -m myvm ./script.sh # Run command in machine # File transfer -orb push ~/local.txt # Copy to Linux -orb pull ~/remote.txt # Copy from Linux -orb push -m vm ~/f.txt /dest/ # Push to specific machine/path +orb push ~/local.txt # Copy to default machine home +orb pull ~/remote.txt # Copy from default machine +orb push -m vm ~/f.txt /tmp/ # Specific machine + path # Docker/K8s orb restart docker # Restart Docker engine orb logs docker # Docker engine logs -orb start k8s # Start Kubernetes -orb delete k8s # Delete K8s cluster +orb start k8s / orb delete k8s # Config -orb config set memory_mib 8192 # Set memory limit -orb config docker # Edit daemon.json +orb config set memory_mib 8192 +orb config set cpu 4 +orb config set rosetta true +orb config set network_proxy http://proxy:8080 ``` ## Key Paths -| Path | Description | -|------|-------------| -| `~/OrbStack//` | Linux files from macOS | -| `~/OrbStack/docker/volumes/` | Docker volumes from macOS | -| `/mnt/mac/Users/...` | macOS files from Linux | -| `/mnt/machines//` | Other machines from Linux | -| `~/.orbstack/ssh/id_ed25519` | SSH private key | -| `~/.orbstack/config/docker.json` | Docker daemon config | +| Location | Path | +|----------|------| +| Linux files from macOS | `~/OrbStack//` | +| Docker volumes from macOS | `~/OrbStack/docker/volumes/` | +| macOS files from Linux | `/mnt/mac/Users/...` (also at same path directly) | +| Other machines from Linux | `/mnt/machines//` | +| SSH key | `~/.orbstack/ssh/id_ed25519` | +| Docker daemon config | `~/.orbstack/config/docker.json` | -## DNS Names +## Networking (OrbStack-Specific) -| Pattern | Description | +Servers in Linux machines are **automatically available on `localhost`** on macOS — no port mapping required. + +**DNS names:** +| Pattern | Resolves to | |---------|-------------| -| `.orb.local` | Linux machine | +| `.orb.local` | Linux VM | | `.orb.local` | Docker container | | `..orb.local` | Compose service | -| `host.orb.internal` | macOS from Linux machine | -| `host.docker.internal` | macOS from container | -| `docker.orb.internal` | Docker from Linux machine | - -## Machine Lifecycle - -### Creation - -```bash -orb create ubuntu # Latest Ubuntu -orb create ubuntu:noble devbox # Ubuntu 24.04 named "devbox" -orb create --arch amd64 debian x86vm # x86 emulation via Rosetta -orb create --set-password ubuntu pwvm # With password set -orb create ubuntu myvm -c cloud.yml # With cloud-init -``` - -Supported distros: Alma, Alpine, Arch, CentOS, Debian, Devuan, Fedora, Gentoo, Kali, NixOS, openSUSE, Oracle, Rocky, Ubuntu, Void - -### Lifecycle - -```bash -orb start myvm # Start stopped machine -orb stop myvm # Stop machine -orb restart myvm # Restart -orb delete myvm # Delete permanently -orb default myvm # Set as default machine -orb logs myvm # View boot logs -``` - -## Cloud-Init - -Create machines with automated provisioning: - -```bash -orb create ubuntu myvm -c user-data.yml -``` - -Example `user-data.yml`: - -```yaml -#cloud-config -packages: - - git - - vim - - docker.io - -users: - - name: dev - groups: sudo, docker - shell: /bin/bash - sudo: ALL=(ALL) NOPASSWD:ALL - -runcmd: - - systemctl enable docker - - systemctl start docker -``` - -Debug cloud-init: - -```bash -orb logs myvm # Boot logs from macOS -orb -m myvm cloud-init status --long # Status inside machine -orb -m myvm cat /var/log/cloud-init-output.log -``` - -## Networking +| `host.orb.internal` | macOS host (from Linux machine) | +| `host.docker.internal` | macOS host (from container) | -### Port Access +All `.orb.local` domains get **zero-config HTTPS** automatically. -Servers in Linux machines are automatically on `localhost`: +Custom container domain: `docker run -l dev.orbstack.domains=myapp.local nginx` -```bash -# In Linux: python3 -m http.server 8000 -# From macOS: curl localhost:8000 or curl myvm.orb.local:8000 -``` - -### Connecting from Linux to macOS - -```bash -# From Linux machine -curl host.orb.internal:3000 - -# From Docker container -curl host.docker.internal:3000 -``` - -### VPN/Proxy - -- Fully VPN-compatible with automatic DNS handling -- Follows macOS proxy settings automatically -- Custom proxy: `orb config set network_proxy http://proxy:8080` -- Disable: `orb config set network_proxy none` - -## File Sharing - -### macOS Files from Linux - -```bash -# Same paths work -cat /Users/allen/file.txt -cat /mnt/mac/Users/allen/file.txt # Explicit prefix -``` - -### Linux Files from macOS - -```bash -ls ~/OrbStack/myvm/home/user/ -ls ~/OrbStack/docker/volumes/myvolume/ -``` - -### Transfer Commands - -```bash -orb push ~/local.txt # To default machine home -orb pull ~/remote.txt # From default machine -orb push -m vm ~/f.txt /tmp/ # To specific path -``` +VPN-compatible; follows macOS proxy settings automatically. ## SSH Access -Built-in multiplexed SSH server (no per-machine setup): +Single multiplexed SSH server — no per-machine setup needed: ```bash ssh orb # Default machine @@ -194,144 +80,34 @@ ssh myvm@orb # Specific machine ssh user@myvm@orb # Specific user + machine ``` -### IDE Setup - -**VS Code**: Install "Remote - SSH" extension, connect to `orb` or `myvm@orb` - -**JetBrains**: Host `localhost`, Port `32222`, Key `~/.orbstack/ssh/id_ed25519` - -### Ansible - -```ini -[servers] -myvm@orb ansible_user=ubuntu -``` - -SSH agent forwarding is automatic. +IDE config: VS Code "Remote - SSH" → `orb` or `myvm@orb`. JetBrains: host `localhost`, port `32222`, key `~/.orbstack/ssh/id_ed25519`. SSH agent forwarding is automatic. -## Docker Integration +## Docker Differences from Docker Desktop -### Container Domains +- Container domains resolve without port mapping (`web.orb.local` instead of `localhost:8080`). +- Prefer named volumes over bind mounts — data stays in Linux, no cross-filesystem overhead. +- x86 images on Apple Silicon: `docker run --platform linux/amd64 ubuntu` or `export DOCKER_DEFAULT_PLATFORM=linux/amd64`. +- SSH agent in containers: `-v /run/host-services/ssh-auth.sock:/agent.sock -e SSH_AUTH_SOCK=/agent.sock`. +- Kubernetes: all service types accessible from macOS without `kubectl port-forward`; `cluster.local` DNS works directly. -```bash -docker run --name web nginx -# Access: http://web.orb.local (no port needed for web servers) - -# Compose: ..orb.local -``` - -### HTTPS - -Zero-config HTTPS for all `.orb.local` domains: - -```bash -curl https://mycontainer.orb.local -``` - -### Custom Domains - -```bash -docker run -l dev.orbstack.domains=myapp.local nginx -``` - -### Host Networking - -```bash -docker run --net=host nginx -# localhost works both directions -``` - -### x86 Emulation - -```bash -docker run --platform linux/amd64 ubuntu -export DOCKER_DEFAULT_PLATFORM=linux/amd64 # Default to x86 -``` - -### SSH Agent in Containers - -```bash -docker run -v /run/host-services/ssh-auth.sock:/agent.sock \ - -e SSH_AUTH_SOCK=/agent.sock alpine -``` - -### Volumes vs Bind Mounts - -Prefer volumes for performance (data stays in Linux): - -```bash -docker run -v mydata:/data alpine # Volume (fast) -docker run -v ~/code:/code alpine # Bind mount (slower) -``` - -## Kubernetes - -```bash -orb start k8s # Start cluster -kubectl get nodes # kubectl included -``` - -All service types accessible from macOS without port-forward: +## macOS Commands from Linux ```bash -curl myservice.default.svc.cluster.local # cluster.local works -curl 192.168.194.20 # Pod IPs work -curl myservice.k8s.orb.local # LoadBalancer wildcard +mac open https://example.com # Open in macOS browser +mac notify "Build done" # macOS notification +ORBENV=AWS_PROFILE:EDITOR orb ./deploy.sh # Forward env vars ``` -Local images available immediately (use non-`latest` tag or `imagePullPolicy: IfNotPresent`). - ## Troubleshooting ```bash orb report # Generate diagnostic report orb logs myvm # Machine boot logs -orb logs docker # Docker engine logs -orb restart docker # Restart Docker +orb restart docker # Restart Docker engine orb reset # Factory reset (deletes everything) +docker context use orbstack # Fix "cannot connect to Docker daemon" ``` -**Cannot connect to Docker daemon**: Start OrbStack with `orb start`, or fix context with `docker context use orbstack` - -**Machine not starting**: Check `orb logs myvm`, try `orb restart myvm` - -**Rosetta x86 error**: Install x86 libc: -```bash -sudo dpkg --add-architecture amd64 -sudo apt update && sudo apt install libc6:amd64 -``` - -## Configuration - -```bash -orb config set rosetta true # Enable x86 emulation -orb config set memory_mib 8192 # Memory limit (MiB) -orb config set cpu 4 # CPU limit (cores) -orb config set network_proxy auto # Proxy (auto/none/url) -``` - -Docker daemon config at `~/.orbstack/config/docker.json`: - -```json -{ - "insecure-registries": ["registry.local:5000"], - "registry-mirrors": ["https://mirror.gcr.io"] -} -``` - -Apply with `orb restart docker`. - -## macOS Commands from Linux - -```bash -mac open https://example.com # Open URL in macOS browser -mac uname -a # Run macOS command -mac link brew # Link command for reuse -mac notify "Build done" # Send notification -``` - -Forward env vars: +**Rosetta x86 error**: `sudo dpkg --add-architecture amd64 && sudo apt install libc6:amd64` -```bash -ORBENV=AWS_PROFILE:EDITOR orb ./deploy.sh -``` +Cloud-init debug: `orb -m myvm cloud-init status --long` or `orb -m myvm cat /var/log/cloud-init-output.log` diff --git a/.claude/skills/playwright-best-practices/SKILL.md b/.claude/skills/playwright-best-practices/SKILL.md index fb5a724..87cede7 100644 --- a/.claude/skills/playwright-best-practices/SKILL.md +++ b/.claude/skills/playwright-best-practices/SKILL.md @@ -1,6 +1,6 @@ --- name: playwright-best-practices -description: Provides Playwright test patterns for resilient locators, Page Object Models, fixtures, web-first assertions, and network mocking. Must use when writing or modifying Playwright tests (.spec.ts, .test.ts files with @playwright/test imports). +description: Use when writing or modifying Playwright tests (.spec.ts, .test.ts with @playwright/test imports). --- # Playwright Best Practices @@ -9,488 +9,50 @@ description: Provides Playwright test patterns for resilient locators, Page Obje When running Playwright tests from Claude Code or any CLI agent, always use minimal reporters to prevent verbose output from consuming the context window. -**Use `--reporter=line` or `--reporter=dot` for CLI test runs:** - -```bash -# REQUIRED: Use minimal reporter to prevent context overflow -npx playwright test --reporter=line -npx playwright test --reporter=dot - -# BAD: Default reporter generates thousands of lines, floods context -npx playwright test -``` - -Configure `playwright.config.ts` to use minimal reporters by default when `CI` or `CLAUDE` env vars are set: - -```ts -reporter: process.env.CI || process.env.CLAUDE - ? [['line'], ['html', { open: 'never' }]] - : 'list', -``` +**Use `--reporter=line` or `--reporter=dot` for CLI test runs.** Configure `playwright.config.ts` to default to minimal reporters when `CI` or `CLAUDE` env vars are set — see `playwright-patterns.md` for the config snippet. ## Locator Priority (Most to Least Resilient) Always prefer user-facing attributes: -1. `page.getByRole('button', { name: 'Submit' })` - accessibility roles -2. `page.getByLabel('Email')` - form control labels -3. `page.getByPlaceholder('Search...')` - input placeholders -4. `page.getByText('Welcome')` - visible text (non-interactive) -5. `page.getByAltText('Logo')` - image alt text -6. `page.getByTitle('Settings')` - title attributes -7. `page.getByTestId('submit-btn')` - explicit test contracts -8. CSS/XPath - last resort, avoid - -```ts -// BAD: Brittle selectors tied to implementation -page.locator('button.btn-primary.submit-form') -page.locator('//div[@class="container"]/form/button') -page.locator('#app > div:nth-child(2) > button') - -// GOOD: User-facing, resilient locators -page.getByRole('button', { name: 'Submit' }) -page.getByLabel('Password') -``` - -### Chaining and Filtering - -```ts -// Scope within a region -const card = page.getByRole('listitem').filter({ hasText: 'Product A' }); -await card.getByRole('button', { name: 'Add to cart' }).click(); - -// Filter by child locator -const row = page.getByRole('row').filter({ - has: page.getByRole('cell', { name: 'John' }) -}); - -// Combine conditions -const visibleSubmit = page.getByRole('button', { name: 'Submit' }).and(page.locator(':visible')); -const primaryOrSecondary = page.getByRole('button', { name: 'Save' }).or(page.getByRole('button', { name: 'Update' })); -``` - -### Strictness - -Locators throw if multiple elements match. Use `first()`, `last()`, `nth()` only when intentional: - -```ts -// Throws if multiple buttons match -await page.getByRole('button', { name: 'Delete' }).click(); - -// Explicit selection when needed -await page.getByRole('listitem').first().click(); -await page.getByRole('row').nth(2).getByRole('button').click(); -``` - -## Web-First Assertions - -Use async assertions that auto-wait and retry: - -```ts -// BAD: No auto-wait, flaky -expect(await page.getByText('Success').isVisible()).toBe(true); - -// GOOD: Auto-waits up to timeout -await expect(page.getByText('Success')).toBeVisible(); -await expect(page.getByRole('button')).toBeEnabled(); -await expect(page.getByTestId('status')).toHaveText('Submitted'); -await expect(page).toHaveURL(/dashboard/); -await expect(page).toHaveTitle('Dashboard'); - -// Collections -await expect(page.getByRole('listitem')).toHaveCount(5); -await expect(page.getByRole('listitem')).toHaveText(['Item 1', 'Item 2', 'Item 3']); - -// Soft assertions (continue on failure, report all) -await expect.soft(locator).toBeVisible(); -await expect.soft(locator).toHaveText('Expected'); -// Test continues, failures compiled at end -``` - -## Page Object Model - -Encapsulate page interactions. Define locators as readonly properties in constructor. - -```ts -// pages/base.page.ts -import { type Page, type Locator, expect } from '@playwright/test'; -import debug from 'debug'; - -export abstract class BasePage { - protected readonly log: debug.Debugger; - - constructor( - protected readonly page: Page, - protected readonly timeout = 30_000 - ) { - this.log = debug(`test:page:${this.constructor.name}`); - } - - protected async safeClick(locator: Locator, description?: string) { - this.log('clicking: %s', description ?? locator); - await expect(locator).toBeVisible({ timeout: this.timeout }); - await expect(locator).toBeEnabled({ timeout: this.timeout }); - await locator.click(); - } - - protected async safeFill(locator: Locator, value: string) { - await expect(locator).toBeVisible({ timeout: this.timeout }); - await locator.fill(value); - } - - abstract isLoaded(): Promise; -} -``` - -```ts -// pages/login.page.ts -import { type Locator, type Page, expect } from '@playwright/test'; -import { BasePage } from './base.page'; - -export class LoginPage extends BasePage { - readonly emailInput: Locator; - readonly passwordInput: Locator; - readonly submitButton: Locator; - readonly errorMessage: Locator; - - constructor(page: Page) { - super(page); - this.emailInput = page.getByLabel('Email'); - this.passwordInput = page.getByLabel('Password'); - this.submitButton = page.getByRole('button', { name: 'Sign in' }); - this.errorMessage = page.getByRole('alert'); - } - - async goto() { - await this.page.goto('/login'); - await this.isLoaded(); - } - - async isLoaded() { - await expect(this.emailInput).toBeVisible(); - } - - async login(email: string, password: string) { - await this.safeFill(this.emailInput, email); - await this.safeFill(this.passwordInput, password); - await this.safeClick(this.submitButton, 'Sign in button'); - } - - async expectError(message: string) { - await expect(this.errorMessage).toHaveText(message); - } -} -``` - -## Fixtures - -Prefer fixtures over beforeEach/afterEach. Fixtures encapsulate setup + teardown, run on-demand, and compose with dependencies. - -```ts -// fixtures/index.ts -import { test as base, expect } from '@playwright/test'; -import { LoginPage } from '../pages/login.page'; -import { DashboardPage } from '../pages/dashboard.page'; - -type TestFixtures = { - loginPage: LoginPage; - dashboardPage: DashboardPage; -}; - -export const test = base.extend({ - loginPage: async ({ page }, use) => { - const loginPage = new LoginPage(page); - await loginPage.goto(); - await use(loginPage); - }, - - dashboardPage: async ({ page }, use) => { - await use(new DashboardPage(page)); - }, -}); - -export { expect }; -``` - -### Worker-Scoped Fixtures - -Use for expensive setup shared across tests (database connections, authenticated users): +1. `page.getByRole('button', { name: 'Submit' })` — accessibility roles +2. `page.getByLabel('Email')` — form control labels +3. `page.getByPlaceholder('Search...')` — input placeholders +4. `page.getByText('Welcome')` — visible text (non-interactive) +5. `page.getByAltText('Logo')` — image alt text +6. `page.getByTitle('Settings')` — title attributes +7. `page.getByTestId('submit-btn')` — explicit test contracts +8. CSS/XPath — last resort, avoid -```ts -// fixtures/auth.fixture.ts -import { test as base } from '@playwright/test'; +## Core Rules -type WorkerFixtures = { - authenticatedUser: { token: string; userId: string }; -}; - -export const test = base.extend<{}, WorkerFixtures>({ - authenticatedUser: [async ({}, use) => { - // Expensive setup - runs once per worker - const user = await createTestUser(); - const token = await authenticateUser(user); - - await use({ token, userId: user.id }); - - // Cleanup after all tests in worker - await deleteTestUser(user.id); - }, { scope: 'worker' }], -}); -``` - -### Automatic Fixtures - -Run for every test without explicit declaration: - -```ts -export const test = base.extend<{ autoLog: void }>({ - autoLog: [async ({ page }, use) => { - page.on('console', msg => console.log(`[browser] ${msg.text()}`)); - await use(); - }, { auto: true }], -}); -``` - -## Authentication - -Save authenticated state to reuse. Never log in via UI in every test. - -```ts -// auth.setup.ts -import { test as setup, expect } from '@playwright/test'; - -const authFile = 'playwright/.auth/user.json'; - -setup('authenticate', async ({ page }) => { - await page.goto('/login'); - await page.getByLabel('Email').fill(process.env.TEST_USER_EMAIL!); - await page.getByLabel('Password').fill(process.env.TEST_USER_PASSWORD!); - await page.getByRole('button', { name: 'Sign in' }).click(); - await page.waitForURL('/dashboard'); - await page.context().storageState({ path: authFile }); -}); -``` - -```ts -// playwright.config.ts -export default defineConfig({ - projects: [ - { name: 'setup', testMatch: /.*\.setup\.ts/ }, - { - name: 'chromium', - use: { - ...devices['Desktop Chrome'], - storageState: 'playwright/.auth/user.json', - }, - dependencies: ['setup'], - }, - ], -}); -``` - -### API Authentication (Faster) - -```ts -setup('authenticate via API', async ({ request }) => { - const response = await request.post('/api/auth/login', { - data: { email: process.env.TEST_USER_EMAIL, password: process.env.TEST_USER_PASSWORD }, - }); - expect(response.ok()).toBeTruthy(); - await request.storageState({ path: authFile }); -}); -``` - -## Network Mocking - -Set up routes before navigation. - -```ts -test('displays mocked data', async ({ page }) => { - await page.route('**/api/users', route => route.fulfill({ - json: [{ id: 1, name: 'Test User' }], - })); - - await page.goto('/users'); - await expect(page.getByText('Test User')).toBeVisible(); -}); - -// Modify real response -test('injects item into response', async ({ page }) => { - await page.route('**/api/items', async route => { - const response = await route.fetch(); - const json = await response.json(); - json.push({ id: 999, name: 'Injected' }); - await route.fulfill({ response, json }); - }); - await page.goto('/items'); -}); - -// HAR recording -test('uses recorded responses', async ({ page }) => { - await page.routeFromHAR('./fixtures/api.har', { - url: '**/api/**', - update: false, // true to record - }); - await page.goto('/'); -}); -``` - -## Test Isolation - -Each test gets fresh browser context. Never share state between tests. - -```ts -// BAD: Tests depend on each other -let userId: string; -test('create user', async ({ request }) => { - userId = (await (await request.post('/api/users', { data: { name: 'Test' } })).json()).id; -}); -test('delete user', async ({ request }) => { - await request.delete(`/api/users/${userId}`); // Depends on previous! -}); - -// GOOD: Each test creates its own data -test('can delete created user', async ({ request }) => { - const { id } = await (await request.post('/api/users', { data: { name: 'Test' } })).json(); - const deleteResponse = await request.delete(`/api/users/${id}`); - expect(deleteResponse.ok()).toBeTruthy(); -}); -``` - -## Configuration - -```ts -// playwright.config.ts -import { defineConfig, devices } from '@playwright/test'; - -export default defineConfig({ - testDir: './tests', - fullyParallel: true, - forbidOnly: !!process.env.CI, - retries: process.env.CI ? 2 : 0, - workers: process.env.CI ? 1 : undefined, - // Use minimal reporter in CI/agent contexts to prevent context overflow - reporter: process.env.CI || process.env.CLAUDE - ? [['line'], ['html', { open: 'never' }]] - : 'list', - - use: { - baseURL: process.env.BASE_URL ?? 'http://localhost:3000', - trace: 'on-first-retry', - screenshot: 'only-on-failure', - video: 'on-first-retry', - }, - - projects: [ - { name: 'setup', testMatch: /.*\.setup\.ts/ }, - { - name: 'chromium', - use: { ...devices['Desktop Chrome'] }, - dependencies: ['setup'], - }, - { - name: 'firefox', - use: { ...devices['Desktop Firefox'] }, - dependencies: ['setup'], - }, - { - name: 'webkit', - use: { ...devices['Desktop Safari'] }, - dependencies: ['setup'], - }, - ], - - webServer: { - command: 'npm run start', - url: 'http://localhost:3000', - reuseExistingServer: !process.env.CI, - }, -}); -``` - -## Project Structure - -``` -tests/ - fixtures/ # Custom fixtures (extend base test) - pages/ # Page Object Models - helpers/ # Utility functions (API clients, data factories) - auth.setup.ts # Authentication setup project - *.spec.ts # Test files -playwright/ - .auth/ # Auth state storage (gitignored) -playwright.config.ts -``` - -Organize tests by feature or user journey. Colocate page objects with tests when possible. - -## Helpers (Separate from Pages) - -```ts -// helpers/user.helper.ts -import type { Page } from '@playwright/test'; -import debug from 'debug'; - -const log = debug('test:helper:user'); - -export class UserHelper { - constructor(private page: Page) {} - - async createUser(data: { name: string; email: string }) { - log('creating user: %s', data.email); - const response = await this.page.request.post('/api/users', { data }); - return response.json(); - } - - async deleteUser(id: string) { - log('deleting user: %s', id); - await this.page.request.delete(`/api/users/${id}`); - } -} - -// helpers/data.factory.ts -export function createTestUser(overrides: Partial = {}): User { - return { - id: crypto.randomUUID(), - email: `test-${Date.now()}@example.com`, - name: 'Test User', - ...overrides, - }; -} -``` - -## Debugging - -```bash -npx playwright test --debug # Step through with inspector -npx playwright test --trace on # Record trace for all tests -npx playwright test --ui # Interactive UI mode -npx playwright codegen localhost:3000 # Generate locators interactively -npx playwright show-report # View HTML report -``` - -Enable debug logs: `DEBUG=test:* npx playwright test` +- **Web-first assertions**: always `await expect(locator).toBeVisible()`, never `expect(await locator.isVisible()).toBe(true)` — web-first matchers auto-wait and retry +- **Test isolation**: each test creates its own data; never share state between tests +- **Auth state reuse**: save authenticated state via setup project + `storageState`; never log in via UI in every test +- **Fixtures over beforeEach**: fixtures encapsulate setup + teardown, run on-demand, and compose ## Anti-Patterns -- `page.waitForTimeout(ms)` - use auto-waiting locators instead -- `page.locator('.class')` - use role/label/testid -- XPath selectors - fragile, use user-facing attributes -- Shared state between tests - each test creates own data -- UI login in every test - use setup project + storageState -- Manual assertions without await - use web-first assertions -- Hardcoded waits - rely on Playwright's auto-waiting -- Default reporter in CI/agent - use `--reporter=line` or `--reporter=dot` to prevent context overflow +- `page.waitForTimeout(ms)` — use auto-waiting locators instead +- `page.locator('.class')` — use role/label/testid +- XPath selectors — fragile, use user-facing attributes +- Shared state between tests — each test creates own data +- UI login in every test — use setup project + storageState +- Manual assertions without await — use web-first assertions +- Hardcoded waits — rely on Playwright's auto-waiting +- Default reporter in CI/agent — use `--reporter=line` or `--reporter=dot` ## Checklist - [ ] Locators use role/label/testid, not CSS classes or XPath - [ ] All assertions use `await expect()` web-first matchers - [ ] Page objects define locators in constructor -- [ ] No `page.waitForTimeout()` - use auto-waiting -- [ ] Tests isolated - no shared state +- [ ] No `page.waitForTimeout()` — use auto-waiting +- [ ] Tests isolated — no shared state - [ ] Auth state reused via setup project - [ ] Network mocks set up before navigation - [ ] Test data created per-test or via fixtures - [ ] Debug logging added for complex flows - [ ] Minimal reporter (`line`/`dot`) used in CI/agent contexts + +See `playwright-patterns.md` for Page Object Model, fixtures, network mocking, and configuration examples. diff --git a/.claude/skills/playwright-best-practices/playwright-patterns.md b/.claude/skills/playwright-best-practices/playwright-patterns.md new file mode 100644 index 0000000..718558e --- /dev/null +++ b/.claude/skills/playwright-best-practices/playwright-patterns.md @@ -0,0 +1,422 @@ +# Playwright Patterns Reference + +Code examples for patterns summarized in `SKILL.md`. Load this file when you need to see or produce a concrete implementation. + +## Locator Chaining and Filtering + +```ts +// Scope within a region +const card = page.getByRole('listitem').filter({ hasText: 'Product A' }); +await card.getByRole('button', { name: 'Add to cart' }).click(); + +// Filter by child locator +const row = page.getByRole('row').filter({ + has: page.getByRole('cell', { name: 'John' }) +}); + +// Combine conditions +const visibleSubmit = page.getByRole('button', { name: 'Submit' }).and(page.locator(':visible')); +const primaryOrSecondary = page.getByRole('button', { name: 'Save' }).or(page.getByRole('button', { name: 'Update' })); +``` + +### Strictness + +Locators throw if multiple elements match. Use `first()`, `last()`, `nth()` only when intentional: + +```ts +// Throws if multiple buttons match — forces you to be precise +await page.getByRole('button', { name: 'Delete' }).click(); + +// Explicit selection when multiple matches are expected +await page.getByRole('listitem').first().click(); +await page.getByRole('row').nth(2).getByRole('button').click(); +``` + +## Web-First Assertions + +```ts +// BAD: No auto-wait, flaky +expect(await page.getByText('Success').isVisible()).toBe(true); + +// GOOD: Auto-waits up to timeout +await expect(page.getByText('Success')).toBeVisible(); +await expect(page.getByRole('button')).toBeEnabled(); +await expect(page.getByTestId('status')).toHaveText('Submitted'); +await expect(page).toHaveURL(/dashboard/); +await expect(page).toHaveTitle('Dashboard'); + +// Collections +await expect(page.getByRole('listitem')).toHaveCount(5); +await expect(page.getByRole('listitem')).toHaveText(['Item 1', 'Item 2', 'Item 3']); + +// Soft assertions — continue on failure, report all at end +await expect.soft(locator).toBeVisible(); +await expect.soft(locator).toHaveText('Expected'); +``` + +## Page Object Model + +Encapsulate page interactions. Define locators as readonly properties in constructor so they are computed once and reused. + +```ts +// pages/base.page.ts +import { type Page, type Locator, expect } from '@playwright/test'; +import debug from 'debug'; + +export abstract class BasePage { + protected readonly log: debug.Debugger; + + constructor( + protected readonly page: Page, + protected readonly timeout = 30_000 + ) { + this.log = debug(`test:page:${this.constructor.name}`); + } + + protected async safeClick(locator: Locator, description?: string) { + this.log('clicking: %s', description ?? locator); + await expect(locator).toBeVisible({ timeout: this.timeout }); + await expect(locator).toBeEnabled({ timeout: this.timeout }); + await locator.click(); + } + + protected async safeFill(locator: Locator, value: string) { + await expect(locator).toBeVisible({ timeout: this.timeout }); + await locator.fill(value); + } + + abstract isLoaded(): Promise; +} +``` + +```ts +// pages/login.page.ts +import { type Locator, type Page, expect } from '@playwright/test'; +import { BasePage } from './base.page'; + +export class LoginPage extends BasePage { + readonly emailInput: Locator; + readonly passwordInput: Locator; + readonly submitButton: Locator; + readonly errorMessage: Locator; + + constructor(page: Page) { + super(page); + this.emailInput = page.getByLabel('Email'); + this.passwordInput = page.getByLabel('Password'); + this.submitButton = page.getByRole('button', { name: 'Sign in' }); + this.errorMessage = page.getByRole('alert'); + } + + async goto() { + await this.page.goto('/login'); + await this.isLoaded(); + } + + async isLoaded() { + await expect(this.emailInput).toBeVisible(); + } + + async login(email: string, password: string) { + await this.safeFill(this.emailInput, email); + await this.safeFill(this.passwordInput, password); + await this.safeClick(this.submitButton, 'Sign in button'); + } + + async expectError(message: string) { + await expect(this.errorMessage).toHaveText(message); + } +} +``` + +## Fixtures + +Prefer fixtures over `beforeEach`/`afterEach`. Fixtures encapsulate setup + teardown, run on-demand, and compose with dependencies. + +```ts +// fixtures/index.ts +import { test as base, expect } from '@playwright/test'; +import { LoginPage } from '../pages/login.page'; +import { DashboardPage } from '../pages/dashboard.page'; + +type TestFixtures = { + loginPage: LoginPage; + dashboardPage: DashboardPage; +}; + +export const test = base.extend({ + loginPage: async ({ page }, use) => { + const loginPage = new LoginPage(page); + await loginPage.goto(); + await use(loginPage); + }, + + dashboardPage: async ({ page }, use) => { + await use(new DashboardPage(page)); + }, +}); + +export { expect }; +``` + +### Worker-Scoped Fixtures + +Use for expensive setup shared across tests (database connections, authenticated users). Runs once per worker, not once per test. + +```ts +// fixtures/auth.fixture.ts +import { test as base } from '@playwright/test'; + +type WorkerFixtures = { + authenticatedUser: { token: string; userId: string }; +}; + +export const test = base.extend<{}, WorkerFixtures>({ + authenticatedUser: [async ({}, use) => { + const user = await createTestUser(); + const token = await authenticateUser(user); + + await use({ token, userId: user.id }); + + // Cleanup after all tests in worker complete + await deleteTestUser(user.id); + }, { scope: 'worker' }], +}); +``` + +### Automatic Fixtures + +Run for every test without explicit declaration in the test body: + +```ts +export const test = base.extend<{ autoLog: void }>({ + autoLog: [async ({ page }, use) => { + page.on('console', msg => console.log(`[browser] ${msg.text()}`)); + await use(); + }, { auto: true }], +}); +``` + +## Authentication + +Save authenticated state to reuse across tests. Never log in via UI in every test — it's slow and fragile. + +```ts +// auth.setup.ts +import { test as setup, expect } from '@playwright/test'; + +const authFile = 'playwright/.auth/user.json'; + +setup('authenticate', async ({ page }) => { + await page.goto('/login'); + await page.getByLabel('Email').fill(process.env.TEST_USER_EMAIL!); + await page.getByLabel('Password').fill(process.env.TEST_USER_PASSWORD!); + await page.getByRole('button', { name: 'Sign in' }).click(); + await page.waitForURL('/dashboard'); + await page.context().storageState({ path: authFile }); +}); +``` + +```ts +// playwright.config.ts — wire up setup project +export default defineConfig({ + projects: [ + { name: 'setup', testMatch: /.*\.setup\.ts/ }, + { + name: 'chromium', + use: { + ...devices['Desktop Chrome'], + storageState: 'playwright/.auth/user.json', + }, + dependencies: ['setup'], + }, + ], +}); +``` + +### API Authentication (Faster) + +Bypass the UI entirely when your API supports it: + +```ts +setup('authenticate via API', async ({ request }) => { + const response = await request.post('/api/auth/login', { + data: { email: process.env.TEST_USER_EMAIL, password: process.env.TEST_USER_PASSWORD }, + }); + expect(response.ok()).toBeTruthy(); + await request.storageState({ path: authFile }); +}); +``` + +## Network Mocking + +Always set up routes before navigation — routes must be registered before the page makes requests. + +```ts +test('displays mocked data', async ({ page }) => { + await page.route('**/api/users', route => route.fulfill({ + json: [{ id: 1, name: 'Test User' }], + })); + + await page.goto('/users'); + await expect(page.getByText('Test User')).toBeVisible(); +}); + +// Modify real response — fetch it then augment before fulfilling +test('injects item into response', async ({ page }) => { + await page.route('**/api/items', async route => { + const response = await route.fetch(); + const json = await response.json(); + json.push({ id: 999, name: 'Injected' }); + await route.fulfill({ response, json }); + }); + await page.goto('/items'); +}); + +// HAR recording — record once, replay in CI +test('uses recorded responses', async ({ page }) => { + await page.routeFromHAR('./fixtures/api.har', { + url: '**/api/**', + update: false, // set true to re-record + }); + await page.goto('/'); +}); +``` + +## Test Isolation + +Each test gets a fresh browser context. Never share mutable state between tests. + +```ts +// BAD: Tests depend on each other — order-sensitive, fragile +let userId: string; +test('create user', async ({ request }) => { + userId = (await (await request.post('/api/users', { data: { name: 'Test' } })).json()).id; +}); +test('delete user', async ({ request }) => { + await request.delete(`/api/users/${userId}`); // Breaks if run alone +}); + +// GOOD: Each test is self-contained +test('can delete created user', async ({ request }) => { + const { id } = await (await request.post('/api/users', { data: { name: 'Test' } })).json(); + const deleteResponse = await request.delete(`/api/users/${id}`); + expect(deleteResponse.ok()).toBeTruthy(); +}); +``` + +## Configuration + +```ts +// playwright.config.ts +import { defineConfig, devices } from '@playwright/test'; + +export default defineConfig({ + testDir: './tests', + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + workers: process.env.CI ? 1 : undefined, + // Minimal reporter prevents context overflow in CI/agent contexts + reporter: process.env.CI || process.env.CLAUDE + ? [['line'], ['html', { open: 'never' }]] + : 'list', + + use: { + baseURL: process.env.BASE_URL ?? 'http://localhost:3000', + trace: 'on-first-retry', + screenshot: 'only-on-failure', + video: 'on-first-retry', + }, + + projects: [ + { name: 'setup', testMatch: /.*\.setup\.ts/ }, + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + dependencies: ['setup'], + }, + { + name: 'firefox', + use: { ...devices['Desktop Firefox'] }, + dependencies: ['setup'], + }, + { + name: 'webkit', + use: { ...devices['Desktop Safari'] }, + dependencies: ['setup'], + }, + ], + + webServer: { + command: 'npm run start', + url: 'http://localhost:3000', + reuseExistingServer: !process.env.CI, + }, +}); +``` + +## Project Structure + +``` +tests/ + fixtures/ # Custom fixtures (extend base test) + pages/ # Page Object Models + helpers/ # Utility functions (API clients, data factories) + auth.setup.ts # Authentication setup project + *.spec.ts # Test files +playwright/ + .auth/ # Auth state storage (gitignored) +playwright.config.ts +``` + +Organize tests by feature or user journey. Colocate page objects with tests when possible. + +## Helpers + +Keep helpers separate from page objects. Page objects model UI structure; helpers handle data setup and cross-cutting concerns. + +```ts +// helpers/user.helper.ts +import type { Page } from '@playwright/test'; +import debug from 'debug'; + +const log = debug('test:helper:user'); + +export class UserHelper { + constructor(private page: Page) {} + + async createUser(data: { name: string; email: string }) { + log('creating user: %s', data.email); + const response = await this.page.request.post('/api/users', { data }); + return response.json(); + } + + async deleteUser(id: string) { + log('deleting user: %s', id); + await this.page.request.delete(`/api/users/${id}`); + } +} + +// helpers/data.factory.ts +export function createTestUser(overrides: Partial = {}): User { + return { + id: crypto.randomUUID(), + email: `test-${Date.now()}@example.com`, + name: 'Test User', + ...overrides, + }; +} +``` + +## Debugging + +```bash +npx playwright test --debug # Step through with inspector +npx playwright test --trace on # Record trace for all tests +npx playwright test --ui # Interactive UI mode +npx playwright codegen localhost:3000 # Generate locators interactively +npx playwright show-report # View HTML report +``` + +Enable debug logs: `DEBUG=test:* npx playwright test` diff --git a/.claude/skills/python-best-practices/SKILL.md b/.claude/skills/python-best-practices/SKILL.md index a7533a4..46ae9b8 100644 --- a/.claude/skills/python-best-practices/SKILL.md +++ b/.claude/skills/python-best-practices/SKILL.md @@ -1,24 +1,17 @@ --- name: python-best-practices -description: Provides Python patterns for type-first development with dataclasses, discriminated unions, NewType, and Protocol. Must use when reading or writing Python files. +description: Use when reading or writing Python files (.py, pyproject.toml, requirements.txt). --- # Python Best Practices -## Type-First Development +Follows type-first, functional, and error handling patterns from CLAUDE.md. This skill covers language-specific idioms only. -Types define the contract before implementation. Follow this workflow: - -1. **Define data models** - dataclasses, Pydantic models, or TypedDict first -2. **Define function signatures** - parameter and return type hints -3. **Implement to satisfy types** - let the type checker guide completeness -4. **Validate at boundaries** - runtime checks where data enters the system - -### Make Illegal States Unrepresentable +## Make Illegal States Unrepresentable Use Python's type system to prevent invalid states at type-check time. -**Dataclasses for structured data:** +**Frozen dataclasses for immutable domain models:** ```python from dataclasses import dataclass from datetime import datetime @@ -30,12 +23,7 @@ class User: name: str created_at: datetime -@dataclass(frozen=True) -class CreateUser: - email: str - name: str - -# Frozen dataclasses are immutable - no accidental mutation +# Frozen dataclasses are immutable — no accidental mutation ``` **Discriminated unions with Literal:** @@ -43,14 +31,6 @@ class CreateUser: from dataclasses import dataclass from typing import Literal -@dataclass -class Idle: - status: Literal["idle"] = "idle" - -@dataclass -class Loading: - status: Literal["loading"] = "loading" - @dataclass class Success: status: Literal["success"] = "success" @@ -61,14 +41,10 @@ class Failure: status: Literal["error"] = "error" error: Exception -RequestState = Idle | Loading | Success | Failure +RequestState = Success | Failure def handle_state(state: RequestState) -> None: match state: - case Idle(): - pass - case Loading(): - show_spinner() case Success(data=data): render(data) case Failure(error=err): @@ -85,29 +61,6 @@ OrderId = NewType("OrderId", str) def get_user(user_id: UserId) -> User: # Type checker prevents passing OrderId here ... - -def create_user_id(raw: str) -> UserId: - return UserId(raw) -``` - -**Enums for constrained values:** -```python -from enum import Enum, auto - -class Role(Enum): - ADMIN = auto() - USER = auto() - GUEST = auto() - -def check_permission(role: Role) -> bool: - match role: - case Role.ADMIN: - return True - case Role.USER: - return limited_check() - case Role.GUEST: - return False - # Type checker warns if case is missing ``` **Protocol for structural typing:** @@ -118,57 +71,13 @@ class Readable(Protocol): def read(self, n: int = -1) -> bytes: ... def process_input(source: Readable) -> bytes: - # Accepts any object with a read() method + # Accepts any object with a read() method — no inheritance required return source.read() ``` -**TypedDict for external data shapes:** -```python -from typing import TypedDict, Required, NotRequired - -class UserResponse(TypedDict): - id: Required[str] - email: Required[str] - name: Required[str] - avatar_url: NotRequired[str] - -def parse_user(data: dict) -> UserResponse: - # Runtime validation needed - TypedDict is structural - return UserResponse( - id=data["id"], - email=data["email"], - name=data["name"], - ) -``` - -## Module Structure - -Prefer smaller, focused files: one class or closely related set of functions per module. Split when a file handles multiple concerns or exceeds ~300 lines. Use `__init__.py` to expose public API; keep implementation details in private modules (`_internal.py`). Colocate tests in `tests/` mirroring the source structure. - -## Functional Patterns +## Python-Specific Error Handling -- Use list/dict/set comprehensions and generator expressions over explicit loops. -- Prefer `@dataclass(frozen=True)` for immutable data; avoid mutable default arguments. -- Use `functools.partial` for partial application; compose small functions over large classes. -- Avoid class-level mutable state; prefer pure functions that take inputs and return outputs. - -## Instructions - -- Raise descriptive exceptions for unsupported cases; every code path returns a value or raises. This makes failures debuggable and prevents silent corruption. -- Propagate exceptions with context using `from err`; catching requires re-raising or returning a meaningful result. Swallowed exceptions hide root causes. -- Handle edge cases explicitly: empty inputs, `None`, boundary values. Include `else` clauses in conditionals where appropriate. -- Use context managers for I/O; prefer `pathlib` and explicit encodings. Resource leaks cause production issues. -- Add or adjust unit tests when touching logic; prefer minimal repros that isolate the failure. - -## Examples - -Explicit failure for unimplemented logic: -```python -def build_widget(widget_type: str) -> Widget: - raise NotImplementedError(f"build_widget not implemented for type: {widget_type}") -``` - -Propagate with context to preserve the original traceback: +Chain exceptions with `from err` to preserve the original traceback: ```python try: data = json.loads(raw) @@ -176,19 +85,9 @@ except json.JSONDecodeError as err: raise ValueError(f"invalid JSON payload: {err}") from err ``` -Exhaustive match with explicit default: -```python -def process_status(status: str) -> str: - match status: - case "active": - return "processing" - case "inactive": - return "skipped" - case _: - raise ValueError(f"unhandled status: {status}") -``` +## Structured Logging -Debug-level tracing with namespaced logger: +Use a module-level logger with `%s` formatting (deferred string interpolation): ```python import logging @@ -201,70 +100,22 @@ def create_widget(name: str) -> Widget: return widget ``` -## Configuration - -- Load config from environment variables at startup; validate required values before use. Missing config should fail immediately. -- Define a config dataclass or Pydantic model as single source of truth; avoid `os.getenv` scattered throughout code. -- Use sensible defaults for development; require explicit values for production secrets. - -### Examples - -Typed config with dataclass: -```python -import os -from dataclasses import dataclass - -@dataclass(frozen=True) -class Config: - port: int = 3000 - database_url: str = "" - api_key: str = "" - env: str = "development" - - @classmethod - def from_env(cls) -> "Config": - database_url = os.environ.get("DATABASE_URL", "") - if not database_url: - raise ValueError("DATABASE_URL is required") - return cls( - port=int(os.environ.get("PORT", "3000")), - database_url=database_url, - api_key=os.environ["API_KEY"], # required, will raise if missing - env=os.environ.get("ENV", "development"), - ) - -config = Config.from_env() -``` - ## Optional: ty -For fast type checking, consider [ty](https://docs.astral.sh/ty/) from Astral (creators of ruff and uv). Written in Rust, it's significantly faster than mypy or pyright. +For fast type checking, consider [ty](https://docs.astral.sh/ty/) from Astral (creators of ruff and uv). Written in Rust, significantly faster than mypy or pyright. -**Installation and usage:** ```bash -# Run directly with uvx (no install needed) -uvx ty check - -# Check specific files -uvx ty check src/main.py - -# Install permanently -uv tool install ty +uvx ty check # run directly, no install needed +uvx ty check src/ # check specific path ``` -**Key features:** -- Automatic virtual environment detection (via `VIRTUAL_ENV` or `.venv`) -- Project discovery from `pyproject.toml` -- Fast incremental checking -- Compatible with standard Python type hints - -**Configuration in `pyproject.toml`:** ```toml +# pyproject.toml [tool.ty] python-version = "3.12" ``` -**When to use ty vs alternatives:** -- `ty` - fastest, good for CI and large codebases (early stage, rapidly evolving) -- `pyright` - most complete type inference, VS Code integration -- `mypy` - mature, extensive plugin ecosystem +When to choose: +- `ty` — fastest, good for CI and large codebases (early stage, rapidly evolving) +- `pyright` — most complete type inference, VS Code integration +- `mypy` — mature, extensive plugin ecosystem diff --git a/.claude/skills/react-best-practices/SKILL.md b/.claude/skills/react-best-practices/SKILL.md index 1d8fc2e..4f9fa36 100644 --- a/.claude/skills/react-best-practices/SKILL.md +++ b/.claude/skills/react-best-practices/SKILL.md @@ -1,6 +1,6 @@ --- name: react-best-practices -description: Provides React patterns for hooks, effects, refs, and component design. Covers escape hatches, anti-patterns, and correct effect usage. Must use when reading or writing React components (.tsx, .jsx files with React imports). +description: Use when reading or writing React components (.tsx, .jsx files with React imports). --- # React Best Practices @@ -13,558 +13,47 @@ When working with React, always load both this skill and `typescript-best-practi Effects let you "step outside" React to synchronize with external systems. **Most component logic should NOT use Effects.** Before writing an Effect, ask: "Is there a way to do this without an Effect?" -## When to Use Effects - -Effects are for synchronizing with **external systems**: -- Subscribing to browser APIs (WebSocket, IntersectionObserver, resize) -- Connecting to third-party libraries not written in React -- Setting up/cleaning up event listeners on window/document -- Fetching data on mount (though prefer React Query or framework data fetching) -- Controlling non-React DOM elements (video players, maps, modals) - -## When NOT to Use Effects - -### Derived State (Calculate During Render) - -```tsx -// BAD: Effect for derived state -const [firstName, setFirstName] = useState('Taylor'); -const [lastName, setLastName] = useState('Swift'); -const [fullName, setFullName] = useState(''); -useEffect(() => { - setFullName(firstName + ' ' + lastName); -}, [firstName, lastName]); - -// GOOD: Calculate during render -const [firstName, setFirstName] = useState('Taylor'); -const [lastName, setLastName] = useState('Swift'); -const fullName = firstName + ' ' + lastName; -``` - -### Expensive Calculations (Use useMemo) - -```tsx -// BAD: Effect for caching -const [visibleTodos, setVisibleTodos] = useState([]); -useEffect(() => { - setVisibleTodos(getFilteredTodos(todos, filter)); -}, [todos, filter]); - -// GOOD: useMemo for expensive calculations -const visibleTodos = useMemo( - () => getFilteredTodos(todos, filter), - [todos, filter] -); -``` - -### Resetting State on Prop Change (Use key) - -```tsx -// BAD: Effect to reset state -function ProfilePage({ userId }) { - const [comment, setComment] = useState(''); - useEffect(() => { - setComment(''); - }, [userId]); - // ... -} - -// GOOD: Use key to reset component state -function ProfilePage({ userId }) { - return ; -} - -function Profile({ userId }) { - const [comment, setComment] = useState(''); // Resets automatically - // ... -} -``` - -### User Event Handling (Use Event Handlers) - -```tsx -// BAD: Event-specific logic in Effect -function ProductPage({ product, addToCart }) { - useEffect(() => { - if (product.isInCart) { - showNotification(`Added ${product.name} to cart`); - } - }, [product]); - // ... -} - -// GOOD: Logic in event handler -function ProductPage({ product, addToCart }) { - function buyProduct() { - addToCart(product); - showNotification(`Added ${product.name} to cart`); - } - // ... -} -``` - -### Notifying Parent of State Changes - -```tsx -// BAD: Effect to notify parent -function Toggle({ onChange }) { - const [isOn, setIsOn] = useState(false); - useEffect(() => { - onChange(isOn); - }, [isOn, onChange]); - // ... -} - -// GOOD: Update both in event handler -function Toggle({ onChange }) { - const [isOn, setIsOn] = useState(false); - function updateToggle(nextIsOn) { - setIsOn(nextIsOn); - onChange(nextIsOn); - } - // ... -} - -// BEST: Fully controlled component -function Toggle({ isOn, onChange }) { - function handleClick() { - onChange(!isOn); - } - // ... -} -``` - -### Chains of Effects - -```tsx -// BAD: Effect chain -useEffect(() => { - if (card !== null && card.gold) { - setGoldCardCount(c => c + 1); - } -}, [card]); - -useEffect(() => { - if (goldCardCount > 3) { - setRound(r => r + 1); - setGoldCardCount(0); - } -}, [goldCardCount]); - -// GOOD: Calculate derived state, update in event handler -const isGameOver = round > 5; - -function handlePlaceCard(nextCard) { - setCard(nextCard); - if (nextCard.gold) { - if (goldCardCount < 3) { - setGoldCardCount(goldCardCount + 1); - } else { - setGoldCardCount(0); - setRound(round + 1); - } - } -} -``` - -## Effect Dependencies - -### Never Suppress the Linter - -```tsx -// BAD: Suppressing linter hides bugs -useEffect(() => { - const id = setInterval(() => { - setCount(count + increment); - }, 1000); - return () => clearInterval(id); - // eslint-disable-next-line react-hooks/exhaustive-deps -}, []); - -// GOOD: Fix the code, not the linter -useEffect(() => { - const id = setInterval(() => { - setCount(c => c + increment); - }, 1000); - return () => clearInterval(id); -}, [increment]); -``` - -### Use Updater Functions to Remove State Dependencies - -```tsx -// BAD: messages in dependencies causes reconnection on every message -useEffect(() => { - connection.on('message', (msg) => { - setMessages([...messages, msg]); - }); - // ... -}, [messages]); // Reconnects on every message! - -// GOOD: Updater function removes dependency -useEffect(() => { - connection.on('message', (msg) => { - setMessages(msgs => [...msgs, msg]); - }); - // ... -}, []); // No messages dependency needed -``` - -### Move Objects/Functions Inside Effects - -```tsx -// BAD: Object created each render triggers Effect -function ChatRoom({ roomId }) { - const options = { serverUrl, roomId }; // New object each render - useEffect(() => { - const connection = createConnection(options); - connection.connect(); - return () => connection.disconnect(); - }, [options]); // Reconnects every render! -} - -// GOOD: Create object inside Effect -function ChatRoom({ roomId }) { - useEffect(() => { - const options = { serverUrl, roomId }; - const connection = createConnection(options); - connection.connect(); - return () => connection.disconnect(); - }, [roomId, serverUrl]); // Only reconnects when values change -} -``` - -### useEffectEvent for Non-Reactive Logic - -```tsx -// BAD: theme change reconnects chat -function ChatRoom({ roomId, theme }) { - useEffect(() => { - const connection = createConnection(serverUrl, roomId); - connection.on('connected', () => { - showNotification('Connected!', theme); - }); - connection.connect(); - return () => connection.disconnect(); - }, [roomId, theme]); // Reconnects on theme change! -} - -// GOOD: useEffectEvent for non-reactive logic -function ChatRoom({ roomId, theme }) { - const onConnected = useEffectEvent(() => { - showNotification('Connected!', theme); - }); - - useEffect(() => { - const connection = createConnection(serverUrl, roomId); - connection.on('connected', () => { - onConnected(); - }); - connection.connect(); - return () => connection.disconnect(); - }, [roomId]); // theme no longer causes reconnection -} -``` - -### Wrap Callback Props with useEffectEvent - -```tsx -// BAD: Callback prop in dependencies -function ChatRoom({ roomId, onReceiveMessage }) { - useEffect(() => { - connection.on('message', onReceiveMessage); - // ... - }, [roomId, onReceiveMessage]); // Reconnects if parent re-renders -} - -// GOOD: Wrap callback in useEffectEvent -function ChatRoom({ roomId, onReceiveMessage }) { - const onMessage = useEffectEvent(onReceiveMessage); - - useEffect(() => { - connection.on('message', onMessage); - // ... - }, [roomId]); // Stable dependency list -} -``` - -## Effect Cleanup - -### Always Clean Up Subscriptions - -```tsx -useEffect(() => { - const connection = createConnection(serverUrl, roomId); - connection.connect(); - return () => connection.disconnect(); // REQUIRED -}, [roomId]); - -useEffect(() => { - function handleScroll(e) { - console.log(window.scrollY); - } - window.addEventListener('scroll', handleScroll); - return () => window.removeEventListener('scroll', handleScroll); // REQUIRED -}, []); -``` - -### Data Fetching with Ignore Flag - -```tsx -useEffect(() => { - let ignore = false; +## Decision Tree - async function fetchData() { - const result = await fetchTodos(userId); - if (!ignore) { - setTodos(result); - } - } - - fetchData(); - - return () => { - ignore = true; // Prevents stale data from old requests - }; -}, [userId]); -``` +1. **Need to respond to user interaction?** Use event handler +2. **Need computed value from props/state?** Calculate during render +3. **Need cached expensive calculation?** Use `useMemo` +4. **Need to reset state on prop change?** Use `key` prop +5. **Need to synchronize with external system?** Use Effect with cleanup +6. **Need non-reactive code in Effect?** Use `useEffectEvent` +7. **Need mutable value that doesn't trigger render?** Use ref -### Development Double-Fire Is Intentional +## When to Use Effects -React remounts components in development to verify cleanup works. If you see effects firing twice, don't try to prevent it with refs: +Synchronizing with **external systems**: browser APIs (WebSocket, IntersectionObserver), third-party non-React libraries, window/document event listeners, non-React DOM elements (video, maps). -```tsx -// BAD: Hiding the symptom -const didInit = useRef(false); -useEffect(() => { - if (didInit.current) return; - didInit.current = true; - // ... -}, []); +## When NOT to Use Effects -// GOOD: Fix the cleanup -useEffect(() => { - const connection = createConnection(); - connection.connect(); - return () => connection.disconnect(); // Proper cleanup -}, []); -``` +- Derived state — calculate during render +- Expensive calculations — use `useMemo` +- Resetting state on prop change — use `key` prop +- Responding to user events — use event handlers +- Notifying parent of state changes — update both in the same event handler +- Chains of effects — calculate derived state and update in one event handler ## Refs -### Use Refs for Values That Don't Affect Rendering - -```tsx -// GOOD: Ref for timeout ID (doesn't affect UI) -const timeoutRef = useRef(null); - -function handleClick() { - clearTimeout(timeoutRef.current); - timeoutRef.current = setTimeout(() => { - // ... - }, 1000); -} - -// BAD: Using ref for displayed value -const countRef = useRef(0); -countRef.current++; // UI won't update! -``` - -### Never Read/Write ref.current During Render - -```tsx -// BAD: Reading ref during render -function MyComponent() { - const ref = useRef(0); - ref.current++; // Mutating during render! - return
{ref.current}
; // Reading during render! -} - -// GOOD: Read/write refs in event handlers and effects -function MyComponent() { - const ref = useRef(0); - - function handleClick() { - ref.current++; // OK in event handler - } - - useEffect(() => { - ref.current = someValue; // OK in effect - }, [someValue]); -} -``` - -### Ref Callbacks for Dynamic Lists - -```tsx -// BAD: Can't call useRef in a loop -{items.map((item) => { - const ref = useRef(null); // Rule violation! - return
  • ; -})} - -// GOOD: Ref callback with Map -const itemsRef = useRef(new Map()); - -{items.map((item) => ( -
  • { - if (node) { - itemsRef.current.set(item.id, node); - } else { - itemsRef.current.delete(item.id); - } - }} - /> -))} -``` - -### useImperativeHandle for Controlled Exposure - -```tsx -// Limit what parent can access -function MyInput({ ref }) { - const realInputRef = useRef(null); - - useImperativeHandle(ref, () => ({ - focus() { - realInputRef.current.focus(); - }, - // Parent can ONLY call focus(), not access full DOM node - })); - - return ; -} -``` +- Use for values that don't affect rendering (timer IDs, DOM node references) +- Never read or write `ref.current` during render; only in event handlers and effects +- Use ref callbacks (not `useRef` in loops) for dynamic lists +- Use `useImperativeHandle` to limit what parent can access ## Custom Hooks -### Hooks Share Logic, Not State - -```tsx -// Each call gets independent state -function StatusBar() { - const isOnline = useOnlineStatus(); // Own state -} - -function SaveButton() { - const isOnline = useOnlineStatus(); // Separate state instance -} -``` - -### Name Hooks useXxx Only If They Use Hooks - -```tsx -// BAD: useXxx but doesn't use hooks -function useSorted(items) { - return items.slice().sort(); -} - -// GOOD: Regular function -function getSorted(items) { - return items.slice().sort(); -} - -// GOOD: Uses hooks, so prefix with use -function useAuth() { - return useContext(AuthContext); -} -``` - -### Avoid "Lifecycle" Hooks - -```tsx -// BAD: Custom lifecycle hooks -function useMount(fn) { - useEffect(() => { - fn(); - }, []); // Missing dependency, linter can't catch it -} - -// GOOD: Use useEffect directly -useEffect(() => { - doSomething(); -}, [doSomething]); -``` - -### Keep Custom Hooks Focused - -```tsx -// GOOD: Focused, concrete use cases -useChatRoom({ serverUrl, roomId }); -useOnlineStatus(); -useFormInput(initialValue); - -// BAD: Generic, abstract hooks -useMount(fn); -useEffectOnce(fn); -useUpdateEffect(fn); -``` +- Share logic, not state — each call gets an independent state instance +- Name `useXxx` only if it actually calls other hooks; otherwise use a regular function +- Avoid lifecycle hooks (`useMount`, `useEffectOnce`) — use `useEffect` directly so the linter catches missing deps +- Keep focused on a single concrete use case ## Component Patterns -### Controlled vs Uncontrolled - -```tsx -// Uncontrolled: component owns state -function SearchInput() { - const [query, setQuery] = useState(''); - return setQuery(e.target.value)} />; -} - -// Controlled: parent owns state -function SearchInput({ query, onQueryChange }) { - return onQueryChange(e.target.value)} />; -} -``` +- Controlled: parent owns state; uncontrolled: component owns state +- Prefer composition with `children` over prop drilling; use Context only for truly global state +- Use `flushSync` when you need to read the DOM synchronously after a state update -### Prefer Composition Over Prop Drilling - -```tsx -// BAD: Prop drilling - - -
    - -
    -
    -
    - -// GOOD: Composition with children - - -
    } /> - - - -// GOOD: Context for truly global state - - - -``` - -### flushSync for Synchronous DOM Updates - -```tsx -// When you need to read DOM immediately after state update -import { flushSync } from 'react-dom'; - -function handleAdd() { - flushSync(() => { - setTodos([...todos, newTodo]); - }); - // DOM is now updated, safe to read - listRef.current.lastChild.scrollIntoView(); -} -``` - -## Summary: Decision Tree - -1. **Need to respond to user interaction?** Use event handler -2. **Need computed value from props/state?** Calculate during render -3. **Need cached expensive calculation?** Use useMemo -4. **Need to reset state on prop change?** Use key prop -5. **Need to synchronize with external system?** Use Effect with cleanup -6. **Need non-reactive code in Effect?** Use useEffectEvent -7. **Need mutable value that doesn't trigger render?** Use ref +See `react-patterns.md` for code examples and detailed patterns. diff --git a/.claude/skills/react-best-practices/react-patterns.md b/.claude/skills/react-best-practices/react-patterns.md new file mode 100644 index 0000000..43e44e2 --- /dev/null +++ b/.claude/skills/react-best-practices/react-patterns.md @@ -0,0 +1,516 @@ +# React Patterns Reference + +Code examples for patterns summarized in `SKILL.md`. Load this file when you need to see or produce a concrete implementation. + +## Effect Anti-Patterns + +### Derived State (Calculate During Render) + +```tsx +// BAD: Effect for derived state +const [firstName, setFirstName] = useState('Taylor'); +const [lastName, setLastName] = useState('Swift'); +const [fullName, setFullName] = useState(''); +useEffect(() => { + setFullName(firstName + ' ' + lastName); +}, [firstName, lastName]); + +// GOOD: Calculate during render +const [firstName, setFirstName] = useState('Taylor'); +const [lastName, setLastName] = useState('Swift'); +const fullName = firstName + ' ' + lastName; +``` + +### Expensive Calculations (Use useMemo) + +```tsx +// BAD: Effect for caching +const [visibleTodos, setVisibleTodos] = useState([]); +useEffect(() => { + setVisibleTodos(getFilteredTodos(todos, filter)); +}, [todos, filter]); + +// GOOD: useMemo for expensive calculations +const visibleTodos = useMemo( + () => getFilteredTodos(todos, filter), + [todos, filter] +); +``` + +### Resetting State on Prop Change (Use key) + +```tsx +// BAD: Effect to reset state +function ProfilePage({ userId }) { + const [comment, setComment] = useState(''); + useEffect(() => { + setComment(''); + }, [userId]); +} + +// GOOD: Use key to reset component state +function ProfilePage({ userId }) { + return ; +} + +function Profile({ userId }) { + const [comment, setComment] = useState(''); // Resets automatically when key changes +} +``` + +### User Event Handling (Use Event Handlers) + +```tsx +// BAD: Event-specific logic in Effect +function ProductPage({ product, addToCart }) { + useEffect(() => { + if (product.isInCart) { + showNotification(`Added ${product.name} to cart`); + } + }, [product]); +} + +// GOOD: Logic in event handler +function ProductPage({ product, addToCart }) { + function buyProduct() { + addToCart(product); + showNotification(`Added ${product.name} to cart`); + } +} +``` + +### Notifying Parent of State Changes + +```tsx +// BAD: Effect to notify parent +function Toggle({ onChange }) { + const [isOn, setIsOn] = useState(false); + useEffect(() => { + onChange(isOn); + }, [isOn, onChange]); +} + +// GOOD: Update both in event handler +function Toggle({ onChange }) { + const [isOn, setIsOn] = useState(false); + function updateToggle(nextIsOn) { + setIsOn(nextIsOn); + onChange(nextIsOn); + } +} + +// BEST: Fully controlled component +function Toggle({ isOn, onChange }) { + function handleClick() { + onChange(!isOn); + } +} +``` + +### Chains of Effects + +```tsx +// BAD: Effect chain — each effect re-renders before the next fires +useEffect(() => { + if (card !== null && card.gold) { + setGoldCardCount(c => c + 1); + } +}, [card]); + +useEffect(() => { + if (goldCardCount > 3) { + setRound(r => r + 1); + setGoldCardCount(0); + } +}, [goldCardCount]); + +// GOOD: Calculate derived state, update everything in one event handler +const isGameOver = round > 5; + +function handlePlaceCard(nextCard) { + setCard(nextCard); + if (nextCard.gold) { + if (goldCardCount < 3) { + setGoldCardCount(goldCardCount + 1); + } else { + setGoldCardCount(0); + setRound(round + 1); + } + } +} +``` + +## Effect Dependencies + +### Never Suppress the Linter + +```tsx +// BAD: Suppressing linter hides bugs +useEffect(() => { + const id = setInterval(() => { + setCount(count + increment); + }, 1000); + return () => clearInterval(id); + // eslint-disable-next-line react-hooks/exhaustive-deps +}, []); + +// GOOD: Fix the code, not the linter +useEffect(() => { + const id = setInterval(() => { + setCount(c => c + increment); + }, 1000); + return () => clearInterval(id); +}, [increment]); +``` + +### Use Updater Functions to Remove State Dependencies + +```tsx +// BAD: messages in dependencies causes reconnection on every message +useEffect(() => { + connection.on('message', (msg) => { + setMessages([...messages, msg]); + }); +}, [messages]); // Reconnects on every message! + +// GOOD: Updater function removes the dependency +useEffect(() => { + connection.on('message', (msg) => { + setMessages(msgs => [...msgs, msg]); + }); +}, []); // No messages dependency needed +``` + +### Move Objects/Functions Inside Effects + +```tsx +// BAD: Object created each render triggers Effect +function ChatRoom({ roomId }) { + const options = { serverUrl, roomId }; // New object each render + useEffect(() => { + const connection = createConnection(options); + connection.connect(); + return () => connection.disconnect(); + }, [options]); // Reconnects every render! +} + +// GOOD: Create object inside Effect +function ChatRoom({ roomId }) { + useEffect(() => { + const options = { serverUrl, roomId }; + const connection = createConnection(options); + connection.connect(); + return () => connection.disconnect(); + }, [roomId, serverUrl]); // Only reconnects when values change +} +``` + +### useEffectEvent for Non-Reactive Logic + +```tsx +// BAD: theme change reconnects chat +function ChatRoom({ roomId, theme }) { + useEffect(() => { + const connection = createConnection(serverUrl, roomId); + connection.on('connected', () => { + showNotification('Connected!', theme); + }); + connection.connect(); + return () => connection.disconnect(); + }, [roomId, theme]); // Reconnects on theme change! +} + +// GOOD: useEffectEvent for non-reactive logic +function ChatRoom({ roomId, theme }) { + const onConnected = useEffectEvent(() => { + showNotification('Connected!', theme); + }); + + useEffect(() => { + const connection = createConnection(serverUrl, roomId); + connection.on('connected', () => { + onConnected(); + }); + connection.connect(); + return () => connection.disconnect(); + }, [roomId]); // theme no longer causes reconnection +} +``` + +### Wrap Callback Props with useEffectEvent + +```tsx +// BAD: Callback prop in dependencies reconnects if parent re-renders +function ChatRoom({ roomId, onReceiveMessage }) { + useEffect(() => { + connection.on('message', onReceiveMessage); + }, [roomId, onReceiveMessage]); +} + +// GOOD: Wrap callback in useEffectEvent +function ChatRoom({ roomId, onReceiveMessage }) { + const onMessage = useEffectEvent(onReceiveMessage); + + useEffect(() => { + connection.on('message', onMessage); + }, [roomId]); // Stable dependency list +} +``` + +## Effect Cleanup + +### Always Clean Up Subscriptions + +```tsx +useEffect(() => { + const connection = createConnection(serverUrl, roomId); + connection.connect(); + return () => connection.disconnect(); // REQUIRED +}, [roomId]); + +useEffect(() => { + function handleScroll(e) { + console.log(window.scrollY); + } + window.addEventListener('scroll', handleScroll); + return () => window.removeEventListener('scroll', handleScroll); // REQUIRED +}, []); +``` + +### Data Fetching with Ignore Flag + +```tsx +useEffect(() => { + let ignore = false; + + async function fetchData() { + const result = await fetchTodos(userId); + if (!ignore) { + setTodos(result); + } + } + + fetchData(); + + return () => { + ignore = true; // Prevents stale data from superseded requests + }; +}, [userId]); +``` + +### Development Double-Fire Is Intentional + +React remounts components in development to verify cleanup works. If effects fire twice, fix the cleanup — don't suppress the double-fire: + +```tsx +// BAD: Hiding the symptom +const didInit = useRef(false); +useEffect(() => { + if (didInit.current) return; + didInit.current = true; + // ... +}, []); + +// GOOD: Fix the cleanup so remounting is safe +useEffect(() => { + const connection = createConnection(); + connection.connect(); + return () => connection.disconnect(); +}, []); +``` + +## Ref Patterns + +### Use Refs for Values That Don't Affect Rendering + +```tsx +// GOOD: Ref for timeout ID (doesn't affect UI) +const timeoutRef = useRef(null); + +function handleClick() { + clearTimeout(timeoutRef.current); + timeoutRef.current = setTimeout(() => { + // ... + }, 1000); +} + +// BAD: Using ref for displayed value — UI won't update +const countRef = useRef(0); +countRef.current++; +``` + +### Never Read/Write ref.current During Render + +```tsx +// BAD: Reading/writing ref during render +function MyComponent() { + const ref = useRef(0); + ref.current++; // Mutating during render! + return
    {ref.current}
    ; // Reading during render! +} + +// GOOD: Read/write refs in event handlers and effects +function MyComponent() { + const ref = useRef(0); + + function handleClick() { + ref.current++; // OK in event handler + } + + useEffect(() => { + ref.current = someValue; // OK in effect + }, [someValue]); +} +``` + +### Ref Callbacks for Dynamic Lists + +```tsx +// BAD: Can't call useRef in a loop +{items.map((item) => { + const ref = useRef(null); // Rules of Hooks violation! + return
  • ; +})} + +// GOOD: Ref callback with Map +const itemsRef = useRef(new Map()); + +{items.map((item) => ( +
  • { + if (node) { + itemsRef.current.set(item.id, node); + } else { + itemsRef.current.delete(item.id); + } + }} + /> +))} +``` + +### useImperativeHandle for Controlled Exposure + +```tsx +// Limit what parent can access through a ref — expose only the API surface you intend +function MyInput({ ref }) { + const realInputRef = useRef(null); + + useImperativeHandle(ref, () => ({ + focus() { + realInputRef.current.focus(); + }, + // Parent can ONLY call focus(), not access the full DOM node + })); + + return ; +} +``` + +## Custom Hook Patterns + +### Hooks Share Logic, Not State + +```tsx +// Each call gets independent state — these are two separate online status subscriptions +function StatusBar() { + const isOnline = useOnlineStatus(); +} + +function SaveButton() { + const isOnline = useOnlineStatus(); +} +``` + +### Name Hooks useXxx Only If They Use Hooks + +```tsx +// BAD: useXxx prefix but doesn't call any hooks +function useSorted(items) { + return items.slice().sort(); +} + +// GOOD: Regular function +function getSorted(items) { + return items.slice().sort(); +} + +// GOOD: Uses hooks, so prefix with use +function useAuth() { + return useContext(AuthContext); +} +``` + +### Avoid "Lifecycle" Hooks + +```tsx +// BAD: Custom lifecycle hooks prevent linter from catching missing dependencies +function useMount(fn) { + useEffect(() => { + fn(); + }, []); // fn is missing from dependencies — linter can't catch it +} + +// GOOD: Use useEffect directly +useEffect(() => { + doSomething(); +}, [doSomething]); +``` + +## Component Patterns + +### Controlled vs Uncontrolled + +```tsx +// Uncontrolled: component owns state +function SearchInput() { + const [query, setQuery] = useState(''); + return setQuery(e.target.value)} />; +} + +// Controlled: parent owns state — more composable, easier to test +function SearchInput({ query, onQueryChange }) { + return onQueryChange(e.target.value)} />; +} +``` + +### Prefer Composition Over Prop Drilling + +```tsx +// BAD: Prop drilling through intermediate components that don't use the value + + +
    + +
    +
    +
    + +// GOOD: Pass the rendered element, not raw data + + +
    } /> + + + +// GOOD: Context for truly global state (auth, theme, locale) + + + +``` + +### flushSync for Synchronous DOM Updates + +```tsx +// When you need to read the DOM immediately after a state update +// (e.g., scroll to a newly added list item before the next paint) +import { flushSync } from 'react-dom'; + +function handleAdd() { + flushSync(() => { + setTodos([...todos, newTodo]); + }); + // DOM is now updated synchronously — safe to read layout + listRef.current.lastChild.scrollIntoView(); +} +``` diff --git a/.claude/skills/spec-best-practices/SKILL.md b/.claude/skills/spec-best-practices/SKILL.md index 22650bd..2383367 100644 --- a/.claude/skills/spec-best-practices/SKILL.md +++ b/.claude/skills/spec-best-practices/SKILL.md @@ -1,154 +1,61 @@ --- name: spec-best-practices -description: Spec authoring conventions for naming, placement, structure, and lifecycle. Use when creating, reviewing, or updating SPEC.md files, running /specout, or entering the ADF SPEC gate. +description: Use when creating, reviewing, or updating SPEC.md files, running /specout, or entering the ADF SPEC gate. --- -## When to activate - -Engage when: -- Creating a new spec (greenfield or retroactive) -- Reviewing or updating an existing `SPEC.md` -- Entering the ADF `SPEC` gate -- Running `/specout` -- An agent proposes a spec file with the wrong name or location - ## Naming -Always `SPEC.md`. No exceptions for the primary spec file. +Always `SPEC.md`. No exceptions for the primary spec file. Not `feature.spec.md`, not `SPEC-feature.md`. -- Not `feature.spec.md`, not `thing-spec.md`, not `SPEC-feature.md` -- The file name is always exactly `SPEC.md` - -Supporting documents linked from a `SPEC.md` TOC may use descriptive names (e.g., `commands.spec.md`, `config-and-state.spec.md`), but only when the root or package `SPEC.md` exists and links to them. +Supporting documents linked from a `SPEC.md` TOC may use descriptive names (e.g., `commands.spec.md`), but only when a root `SPEC.md` exists and links to them. ## Placement -Specs are colocated with the code they describe. - -### Standard layout - -``` -repo/ - SPEC.md # root spec: project-level scope - apps/foo/SPEC.md # app-level spec - packages/bar/SPEC.md # package-level spec - src/lib/module/SPEC.md # module-level spec (non-monorepo) -``` - -### Rules +Specs are colocated with the code they describe: root `SPEC.md` for project scope, `apps/foo/SPEC.md` for app scope, `packages/bar/SPEC.md` for package scope. -- Root `SPEC.md` covers the project/repo scope: problem, solution, domain model, cross-cutting requirements. -- Package/app/module `SPEC.md` files cover the behavior of that unit. -- Avoid `spec/`, `docs/specs/`, and `docs/plans/` directories by default. Prefer colocated `SPEC.md` files and adjacent supporting docs. -- Plan documents are ephemeral. If a plan captures durable decisions, absorb them into the relevant `SPEC.md` and delete the plan doc. - -### When a spec gets long - -Add a TOC to the `SPEC.md` linking to adjacent supporting files: - -```markdown -## Specifications - -- [Commands](./commands.spec.md) -- [Config and State](./config-and-state.spec.md) -- [Error Handling](./errors-and-observability.spec.md) -``` - -Supporting files live alongside the `SPEC.md` that references them, not in a subdirectory. Exception: large single-binary projects with many cross-cutting spec topics may use a `spec/` directory with a contracts index when the domain is complex enough that colocated `SPEC.md` trees would be awkward. Treat this as an explicit exception, not the default layout. +- Avoid `spec/`, `docs/specs/`, and `docs/plans/` directories. Prefer colocated `SPEC.md` files. +- Plan documents are ephemeral. Absorb durable decisions into the relevant `SPEC.md` and delete the plan doc. +- When a spec gets long, add a TOC linking to adjacent supporting files (`./commands.spec.md`, etc.). Supporting files live alongside the `SPEC.md`, not in a subdirectory. ## Content -Specs are freeform markdown. No rigid template, no YAML frontmatter, no required section ordering. The following elements must be present, arranged in whatever order suits the domain. - -### Required elements +Specs are freeform markdown. No rigid template, no YAML frontmatter, no required section ordering. These elements must be present: **Problem and solution** -- narrative context for why this system/feature exists. Lead with the problem. -**Domain model** -- types, relationships, data flow. Required for new systems. For retroactive specs, derive from inspected code. +**Domain model** -- types, relationships, data flow. For retroactive specs, derive from inspected code. -**Requirements with `REQ-*` IDs** -- every behavioral requirement gets a stable identifier. -- Format: `REQ-{DOMAIN}-{NNN}` (e.g., `REQ-AUTH-001`, `REQ-SYNC-003`) -- Domain prefix matches the module/package scope -- Append-only; never renumber -- Each requirement is testable and traceable +**Requirements with `REQ-*` IDs** -- every behavioral requirement gets a stable identifier. Format: `REQ-{DOMAIN}-{NNN}` (e.g., `REQ-AUTH-001`). Append-only; never renumber. Each requirement is testable and traceable. -**Invariants** -- conditions that must always hold. State inline with requirements or in a dedicated section. +**Invariants** -- conditions that must always hold. -**Non-goals** -- explicit scope boundary. What this spec intentionally does not cover. Prevents scope creep and sets expectations for reviewers. +**Non-goals** -- explicit scope boundary. What this spec intentionally does not cover. -**Acceptance criteria** -- checklistable verification items. Use markdown checklists, not prose. +**Acceptance criteria** -- markdown checklist, not prose: ```markdown -## Acceptance Criteria - - [ ] Auth endpoint returns JWT with tier claim - [ ] Rate limiter rejects >100 req/min per IP -- [ ] Drift scan completes in <5s for repos with <1000 managed files ``` -### Conditional elements - -**Risk tags** -- flag high-risk items (schema migrations, auth changes, public API contracts, infra changes). Include them when those risks exist or when the ADF `PLAN` gate requires approval. - -**Test traceability** -- maps `REQ-*` IDs to test file:line references. Added during or after the TDD/DEV phase, not at initial authoring. - -```markdown -## Test Traceability - -| Requirement | Test | -|-------------|------| -| REQ-AUTH-001 | src/auth/auth.test.ts:42 | -| REQ-SYNC-003 | src/sync/sync.test.ts:87 | -``` +**Risk tags** (conditional) -- flag high-risk items (schema migrations, auth changes, public API contracts, infra changes) when those risks exist or the ADF `PLAN` gate requires approval. -**`[Normative]` / `[Informative]` section labels** -- use when multiple specs cross-reference each other and precision matters about which sections define binding contracts vs. provide examples. +**Test traceability** (conditional) -- `REQ-*` to test file:line mapping. Added during/after TDD, not at initial authoring. ## Authoring rules -### Evidence-based - -Read code before writing spec content. Do not invent behavior, signatures, or file paths. For retroactive specs, derive requirements from the actual implementation. +**Evidence-based**: read code before writing spec content. Do not invent behavior, signatures, or file paths. For retroactive specs, derive requirements from the actual implementation. -### Retroactive specs are first-class +**Retroactive specs are first-class**: documenting existing behavior is valid and encouraged. Read the implementation, extract requirements from actual behavior, note inconsistencies as open items (not silent omissions), map traceability to existing tests. -Documenting existing behavior in a `SPEC.md` is valid and encouraged. Retroactive specs follow the same structure and naming rules. When writing retroactively: -1. Read the implementation thoroughly -2. Extract requirements from actual behavior -3. Note any discovered inconsistencies as open items, not silent omissions -4. Map test traceability to existing tests +**Mutation policy**: do not edit a spec without explicit user direction. When drift is found, surface it immediately using `specalign` patterns. Never silently tolerate or fix drift — the user decides whether to update spec or code. -### Mutation policy - -- Do not edit a spec without explicit user direction -- When drift is found between spec and code, surface it immediately (use `specalign` patterns) -- Never silently tolerate drift; never silently fix it -- The user decides whether to update spec or code for each discrepancy - -### Spec vs. plan - -Specs describe **what** the system does and **why**. Plans describe **how** and **when** to build it. Plans are ephemeral work artifacts; specs are durable project documentation. - -If a plan doc contains decisions that should outlive the implementation sprint, those decisions belong in the spec. Delete the plan doc after absorption. +**Spec vs. plan**: specs describe what and why; plans describe how and when. Plans are ephemeral. Absorb durable decisions into the spec; delete the plan doc. ## Lifecycle -### Creation (SPEC gate) - -The ADF `SPEC` gate requires: IDs, invariants, non-goals, acceptance criteria, and risk tags when high-risk items exist. - -When entering the SPEC gate: -1. Determine placement: which `SPEC.md` file should this go in? -2. If the file exists, read it and identify gaps -3. If the file doesn't exist, create it at the correct colocated path -4. Ensure all required elements are present before passing the gate - -### Maintenance - -- Update spec when behavior changes (spec leads code changes; code leads retroactive spec updates) -- Append new `REQ-*` IDs; never renumber existing ones -- Add test traceability as tests are written -- Run `specalign` when both spec and implementation are in context +**Creation (SPEC gate)**: see ADF SPEC gate in CLAUDE.md for gate requirements. Determine placement, read existing file and identify gaps (or create at the correct colocated path), ensure all required elements are present. -### Retirement +**Maintenance**: update spec when behavior changes; append new `REQ-*` IDs, never renumber; add test traceability as tests are written; run `specalign` when spec and implementation are both in context. -When a feature is removed, remove or archive its `SPEC.md`. Do not leave stale specs that describe deleted behavior. +**Retirement**: when a feature is removed, remove or archive its `SPEC.md`. Do not leave stale specs describing deleted behavior. diff --git a/.claude/skills/tamagui-best-practices/SKILL.md b/.claude/skills/tamagui-best-practices/SKILL.md index 05fd349..fd104ff 100644 --- a/.claude/skills/tamagui-best-practices/SKILL.md +++ b/.claude/skills/tamagui-best-practices/SKILL.md @@ -1,393 +1,112 @@ --- name: tamagui-best-practices -description: Provides Tamagui patterns for config v4, compiler optimization, styled context, and cross-platform styling. Must use when working with Tamagui projects (tamagui.config.ts, @tamagui imports). +description: Use when working with Tamagui projects (tamagui.config.ts, @tamagui imports). --- -This skill provides patterns for Tamagui v1.x that go beyond fundamentals. It focuses on Config v4, compiler optimization, compound components, and common mistakes. +# Tamagui Best Practices -## Mandatory Context Loading +Tamagui v1.x patterns beyond fundamentals: Config v4, compiler optimization, compound components, and gotchas. -When working with these components, read the corresponding pattern file BEFORE writing code: +## Reference Files — Read Before Writing Code -| Component Type | Required Reading | Cross-Skills | -|---------------|------------------|--------------| -| Dialog, Sheet, modal overlays | @DIALOG_PATTERNS.md | | -| Form, Input, Label, validation | @FORM_PATTERNS.md | `typescript-best-practices` (zod) | -| Animations, transitions | @ANIMATION_PATTERNS.md | | -| Popover, Tooltip, Select | @OVERLAY_PATTERNS.md | | -| Compiler optimization | @COMPILER_PATTERNS.md | | -| Design tokens, theming | @DESIGN_SYSTEM.md | | +| Context | File | What it covers | +|---------|------|----------------| +| Dialog, Sheet, modal overlays | @DIALOG_PATTERNS.md | Adapt component, accessibility | +| Form, Input, Label, validation | @FORM_PATTERNS.md | zod integration | +| Animations, transitions | @ANIMATION_PATTERNS.md | drivers, enterStyle/exitStyle | +| Popover, Tooltip, Select | @OVERLAY_PATTERNS.md | overlay primitives | +| Compiler optimization | @COMPILER_PATTERNS.md | what the compiler can/cannot flatten | +| Design tokens, theming | @DESIGN_SYSTEM.md | palette, token structure | -## Config v4 Quick Start +## Config v4 -Use `@tamagui/config/v4` for simplified setup: +Minimal setup with `@tamagui/config/v4`. Add `styleCompat: 'react-native'` for new projects to align `flexBasis` with React Native behavior: ```tsx -// tamagui.config.ts import { defaultConfig } from '@tamagui/config/v4' import { createTamagui } from 'tamagui' -export const config = createTamagui(defaultConfig) - -type CustomConfig = typeof config - -declare module 'tamagui' { - interface TamaguiCustomConfig extends CustomConfig {} -} -``` - -**Recommended setting** for new projects (aligns flexBasis to React Native): -```tsx export const config = createTamagui({ ...defaultConfig, - settings: { - ...defaultConfig.settings, - styleCompat: 'react-native', - }, + settings: { ...defaultConfig.settings, styleCompat: 'react-native' }, }) -``` - -### createThemes Pattern - -For custom themes, use `createThemes` with palette/accent/childrenThemes: -```tsx -import { createThemes, defaultComponentThemes } from '@tamagui/config/v4' - -const generatedThemes = createThemes({ - componentThemes: defaultComponentThemes, - base: { - palette: { - dark: ['#050505', '#151515', /* ...12 colors */ '#fff'], - light: ['#fff', '#f8f8f8', /* ...12 colors */ '#000'], - }, - extra: { - light: { ...Colors.blue, shadowColor: 'rgba(0,0,0,0.04)' }, - dark: { ...Colors.blueDark, shadowColor: 'rgba(0,0,0,0.2)' }, - }, - }, - accent: { - palette: { dark: lightPalette, light: darkPalette }, // inverted - }, - childrenThemes: { - blue: { palette: { dark: Object.values(Colors.blueDark), light: Object.values(Colors.blue) } }, - red: { /* ... */ }, - green: { /* ... */ }, - }, -}) +declare module 'tamagui' { + interface TamaguiCustomConfig extends typeof config {} +} ``` -## Token and Theme Syntax - -### $ Prefix Rules - -- **Props**: Use `$` prefix for token references: `` -- **Theme keys**: Access without `$` in theme definitions: `{ color: palette[11] }` -- **Token access in variants**: Use `tokens.size[name]` pattern +For custom themes use `createThemes` with `palette`/`accent`/`childrenThemes` — see @DESIGN_SYSTEM.md. -### Variant Spread Operators +## Compiler Optimization Rules -Special spread operators map token categories to variant values: +- Use `styled()` variants instead of inline conditionals — dynamic values break flattening. +- Avoid `style={{ ... }}` with variables; use variant props instead. +- The `context` pattern (createStyledContext) disables compiler flattening — use for higher-level components (Button, Card), not primitives. ```tsx -const Button = styled(View, { +// BAD — breaks compiler + + +// GOOD — use variants +const Box = styled(View, { variants: { - size: { - // Maps size tokens: $1, $2, $true, etc. - '...size': (size, { tokens }) => ({ - height: tokens.size[size] ?? size, - borderRadius: tokens.radius[size] ?? size, - gap: tokens.space[size]?.val * 0.2, - }), - }, - textSize: { - // Maps fontSize tokens - '...fontSize': (name, { font }) => ({ - fontSize: font?.size[name], - }), - }, - } as const, + dark: { true: { backgroundColor: '$gray1' }, false: { backgroundColor: '$gray12' } }, + }, }) ``` -**Important**: Use `as const` on variants object until TypeScript supports inferred const generics. +## styled() vs Inline -## Compound Components with createStyledContext +- `styled()`: reusable components, variant-driven behavior, compiler-optimizable primitives. +- Inline props: one-off layout adjustments on already-styled components. +- Always use `as const` on `variants` objects (TypeScript limitation until inferred const generics). -For compound APIs like ``: +## Key Gotchas +**Prop order determines override priority** — props after a spread cannot be overridden by callers: ```tsx -import { - SizeTokens, - View, - Text, - createStyledContext, - styled, - withStaticProperties, -} from '@tamagui/core' - -// 1. Create context with shared variant types -export const ButtonContext = createStyledContext<{ size: SizeTokens }>({ - size: '$medium', -}) - -// 2. Create frame with context -export const ButtonFrame = styled(View, { - name: 'Button', - context: ButtonContext, - variants: { - size: { - '...size': (name, { tokens }) => ({ - height: tokens.size[name], - borderRadius: tokens.radius[name], - gap: tokens.space[name].val * 0.2, - }), - }, - } as const, - defaultVariants: { - size: '$medium', - }, -}) - -// 3. Create text with same context (variants auto-sync) -export const ButtonText = styled(Text, { - name: 'ButtonText', - context: ButtonContext, - variants: { - size: { - '...fontSize': (name, { font }) => ({ - fontSize: font?.size[name], - }), - }, - } as const, -}) - -// 4. Compose with withStaticProperties -export const Button = withStaticProperties(ButtonFrame, { - Props: ButtonContext.Provider, - Text: ButtonText, -}) +// width is locked; backgroundColor can be overridden + ``` -**Usage**: +**Variant order matters** — later props win: ```tsx - - -// Or override defaults from above: - - - + // scale = 3 (scale listed first) + // scale = 2 (huge overrides, comes first in variants) ``` -**Note**: `context` pattern does not work with compiler flattening. Use for higher-level components (Button, Card), not primitives (Stack, Text). - -## styleable() for Wrapper Components - -When wrapping a styled component in a functional component, use `.styleable()` to preserve variant inheritance: - +**Use `.styleable()` when wrapping styled components** — preserves variant inheritance: ```tsx -const StyledText = styled(Text) - -// WITHOUT styleable - BROKEN variant inheritance -const BrokenWrapper = (props) => - -// WITH styleable - CORRECT const CorrectWrapper = StyledText.styleable((props, ref) => ( )) - -// Now this works: -const StyledCorrectWrapper = styled(CorrectWrapper, { - variants: { - bold: { true: { fontWeight: 'bold' } }, - }, -}) -``` - -### Adding Extra Props - -Pass generic type argument for additional props: - -```tsx -type ExtraProps = { icon?: React.ReactNode } - -const IconText = StyledText.styleable((props, ref) => { - const { icon, ...rest } = props - return ( - - {icon} - - - ) -}) ``` -## accept Prop for Custom Components - -Enable token/theme resolution on non-standard props: - -```tsx -// For SVG fill/stroke that should accept theme colors -const StyledSVG = styled(SVG, {}, { - accept: { fill: 'color', stroke: 'color' } as const, -}) - -// Usage: - -// For style objects (like ScrollView's contentContainerStyle) -const MyScrollView = styled(ScrollView, {}, { - accept: { contentContainerStyle: 'style' } as const, -}) - -// Usage: -``` - -**Important**: Use `as const` on the accept object. - -## Prop Order Matters - -In `styled()`, prop order determines override priority: - -```tsx -// backgroundColor can be overridden by props -const Overridable = (props) => ( - -) -// width CANNOT be overridden (comes after spread) - -// Variant order matters too: - // scale = 3 (scale comes first) - // scale = 2 (huge overrides) -``` - -## Anti-Patterns - -### Dynamic Styles Break Optimization - -```tsx -// BAD - breaks compiler optimization - - - -// GOOD - use variants -const Box = styled(View, { - variants: { - dark: { true: { backgroundColor: '$gray1' }, false: { backgroundColor: '$gray12' } }, - }, -}) - -``` - -### Inline Functions - +**`accept` prop for non-standard token resolution** (SVG fill/stroke, contentContainerStyle): ```tsx -// BAD - new function every render - handlePress(id)} /> - -// GOOD - stable reference -const handlePressCallback = useCallback(() => handlePress(id), [id]) - +const StyledSVG = styled(SVG, {}, { accept: { fill: 'color', stroke: 'color' } as const }) ``` -### Wrong Import Paths - -```tsx -// These are different packages with different contents: -import { View } from 'tamagui' // Full UI kit -import { View } from '@tamagui/core' // Core only (smaller) -import { Button } from '@tamagui/button' // Individual component - -// Pick one approach and be consistent -``` +**Import consistency** — `tamagui`, `@tamagui/core`, and `@tamagui/button` are different packages; pick one approach per project. -### Mixing RN StyleSheet with Tamagui +**Never mix RN StyleSheet with Tamagui** — StyleSheet values don't resolve tokens. -```tsx -// BAD - StyleSheet values don't resolve tokens -const styles = StyleSheet.create({ box: { padding: 20 } }) - +**Platform branching for Dialog/Sheet** — use `Adapt` instead of `Platform.OS` checks (see @DIALOG_PATTERNS.md). -// GOOD - all Tamagui - -``` +## Quick Reference -### Platform.OS Branching for Dialog/Sheet +**Config v4 shorthands**: `bg` backgroundColor, `p` padding, `m` margin, `w` width, `h` height, `br` borderRadius -```tsx -// BAD - manual platform branching -if (Platform.OS === 'web') { - return ... -} -return ... +**Media breakpoints**: `$xs` 660px, `$sm` 800px, `$md` 1020px, `$lg` 1280px, `$xl` 1420px -// GOOD - use Adapt (see @DIALOG_PATTERNS.md) - - ... - - - - -``` +**Animation drivers**: `css` (web, default), `react-native-reanimated` (native, required) -## Fetching Current Documentation +**Token `$` prefix**: use in props (`color="$color"`), omit in theme definitions (`{ color: palette[11] }`) -For latest API details, fetch markdown docs directly: +## Fetching Current Docs ```bash -# Core docs curl -sL "https://tamagui.dev/docs/core/configuration.md" -curl -sL "https://tamagui.dev/docs/core/styled.md" -curl -sL "https://tamagui.dev/docs/core/variants.md" -curl -sL "https://tamagui.dev/docs/core/animations.md" - -# Component docs -curl -sL "https://tamagui.dev/ui/sheet.md" -curl -sL "https://tamagui.dev/ui/dialog.md" -curl -sL "https://tamagui.dev/ui/select.md" - -# Full docs index -curl -sL "https://tamagui.dev/llms.txt" +curl -sL "https://tamagui.dev/llms.txt" # full index ``` - -For HTML pages, use the web-fetch skill with appropriate selectors. - -## Quick Reference - -### Config v4 Shorthands (Tailwind-aligned) - -| Shorthand | Property | -|-----------|----------| -| `bg` | backgroundColor | -| `p` | padding | -| `m` | margin | -| `w` | width | -| `h` | height | -| `br` | borderRadius | - -### Media Query Breakpoints - -| Token | Default | Server Default | -|-------|---------|----------------| -| `$xs` | 660px | true | -| `$sm` | 800px | false | -| `$md` | 1020px | false | -| `$lg` | 1280px | false | -| `$xl` | 1420px | false | - -### Animation Drivers - -| Driver | Platform | Use Case | -|--------|----------|----------| -| `css` | Web | Default, best performance | -| `react-native-reanimated` | Native | Required for native animations | - -## Additional Pattern Files - -- @DIALOG_PATTERNS.md - Dialog, Sheet, Adapt, accessibility -- @FORM_PATTERNS.md - Form, Input, Label, validation with zod -- @ANIMATION_PATTERNS.md - Animation drivers, enterStyle/exitStyle -- @OVERLAY_PATTERNS.md - Popover, Tooltip, Select -- @COMPILER_PATTERNS.md - Compiler optimization details -- @DESIGN_SYSTEM.md - Design tokens and theming diff --git a/.claude/skills/testing-best-practices/SKILL.md b/.claude/skills/testing-best-practices/SKILL.md index 6d53c2e..e6b382f 100644 --- a/.claude/skills/testing-best-practices/SKILL.md +++ b/.claude/skills/testing-best-practices/SKILL.md @@ -1,23 +1,8 @@ --- name: testing-best-practices -description: Test layering, execution, and CI guidance across unit, integration, and e2e. Use when designing tests, writing test cases, or planning test strategy for a module. +description: Use when designing tests, writing test cases, or planning test strategy for a module. Covers unit, integration, and e2e layering. --- -## When to activate - -Engage when: -- Working with spec files (`*.spec.md`, `SPEC.md`, `spec/*.md`) -- Designing test cases or test strategy for a module -- Writing or reviewing unit, integration, or e2e tests -- After `/specout` completes -- Planning CI test lanes - -## Mutation policy - -- Default: analyze code and produce test strategy, matrix, and implementation plan. -- Do not edit spec files unless the user explicitly requests spec maintenance. -- When this skill conflicts with system/project rules, follow system/project rules. - ## Test layering policy ### Unit tests @@ -27,7 +12,6 @@ Purpose: verify individual functions and invariants in isolation. - **Data-driven**: parameterized tables covering happy path, boundary, error, and edge cases. - **Property-based**: fuzz invariants that must hold across all inputs (e.g., idempotency, sort stability, roundtrip serialization). - Derive cases from the module's public API surface: input types/constraints, output shape, error modes, invariants. -- Cover categories per function: happy path, boundary values, error cases, edge cases, invariants. ### Integration / contract tests @@ -45,7 +29,6 @@ Purpose: verify real user workflows through the full stack. - No mocks; exercise real services, databases, and APIs. - Happy-path workflows only; save edge cases for lower layers. -- Fast: each test should complete within a reasonable timeout. - **State-tolerant**: never assume a clean slate; tolerate and work with prior state. - **Idempotent**: safe to run repeatedly without cleanup between runs. - **Flow-oriented**: validate real data paths end-to-end rather than isolated assertions. @@ -56,8 +39,6 @@ Purpose: verify real user workflows through the full stack. - **No fabricated fixtures.** Derive test data from actual schemas, types, or seed data in the repo. - **No test-only hacks in product code.** No `if (process.env.TEST)` branches, no test-specific exports, no test backdoors. - **E2E must not rely on clean slate.** Tests must tolerate pre-existing data, prior test runs, and shared environments. -- **Never weaken assertions to make tests pass.** Fix the underlying issue. -- **Never hard-code values matching test assertions.** Implement general-purpose logic. ## Execution guidance @@ -95,56 +76,16 @@ Before generating test cases: - Confirm scope from the user request and inspected code context; if ambiguous, state assumptions and proceed conservatively. - For each function: input types/constraints, output shape, error modes, invariants. - Probe for state dependencies and ordering constraints between functions. -- Decide granularity from context: unit-level (individual functions) vs integration-level (compositions). ## Output format -Keep outputs actionable and concise. Use markdown, not rigid JSON schemas. - -### Test strategy - -Brief summary of what to test and at which layer: - -```markdown -## Test Strategy - -- **Unit**: [functions/modules], data-driven + property-based for [invariants] -- **Integration**: [API contracts], auth scoping, error envelopes -- **E2E**: [workflows], happy-path flows against real services -``` - -### Test matrix - -Tabular case listing per function or flow: - -```markdown -## Test Matrix - -### `functionName` - -| ID | Category | Name | Input | Expected | -|----|----------|------|-------|----------| -| HP-01 | happy_path | basic uppercase | "hello" | "HELLO" | -| BV-01 | boundary | empty string | "" | "" | -| ERR-01 | error | null input | null | INVALID_ARGUMENT | -| EDGE-01 | edge | unicode combining | "cafe\u0301" | "CAFE\u0301" | -``` - -Case ID scheme: `{CATEGORY}-{NN}` (HP, BV, ERR, EDGE). Append-only; never renumber. - -### Implementation plan +Use markdown. Produce three sections: -Ordered steps to write and run the tests: +**Test Strategy** -- one bullet per layer (unit/integration/e2e) naming the functions/flows and their coverage type. -```markdown -## Implementation Plan +**Test Matrix** -- table per function: columns `ID | Category | Name | Input | Expected`. Case ID scheme: `{CATEGORY}-{NN}` (HP, BV, ERR, EDGE). Append-only; never renumber. -1. Add factory for [fixture] using seeded data -2. Write parameterized unit tests for [function] (X cases) -3. Write integration test for [API endpoint] auth + error contract -4. Write e2e flow for [workflow] with preflight checks -5. Run suite: `[command]` -``` +**Implementation Plan** -- ordered steps: fixtures, unit tests, integration tests, e2e flows, run command. ## CI guidance @@ -156,10 +97,7 @@ Ordered steps to write and run the tests: ### Nightly full lane -- Full unit + integration + e2e suite. -- Include property-based tests with higher iteration counts. -- Idempotency verification: run critical setup paths twice, assert no side effects on second run. -- Flake detection: flag tests that pass on retry but failed initially. +Full unit + integration + e2e suite with higher property-based iteration counts. Flag tests that pass on retry but failed initially. ## Workflow diff --git a/.claude/skills/typescript-best-practices/SKILL.md b/.claude/skills/typescript-best-practices/SKILL.md index 703db2b..7d1a488 100644 --- a/.claude/skills/typescript-best-practices/SKILL.md +++ b/.claude/skills/typescript-best-practices/SKILL.md @@ -1,24 +1,17 @@ --- name: typescript-best-practices -description: Provides TypeScript patterns for type-first development, making illegal states unrepresentable, exhaustive handling, and runtime validation. Must use when reading or writing TypeScript/JavaScript files. +description: Use when reading or writing TypeScript or JavaScript files (.ts, .tsx, .js, tsconfig.json). --- # TypeScript Best Practices +Follows type-first, functional, and error handling patterns from CLAUDE.md. This skill covers language-specific idioms only. + ## Pair with React Best Practices When working with React components (`.tsx`, `.jsx` files or `@react` imports), always load `react-best-practices` alongside this skill. This skill covers TypeScript fundamentals; React-specific patterns (effects, hooks, refs, component design) are in the dedicated React skill. -## Type-First Development - -Types define the contract before implementation. Follow this workflow: - -1. **Define the data model** - types, interfaces, and schemas first -2. **Define function signatures** - input/output types before logic -3. **Implement to satisfy types** - let the compiler guide completeness -4. **Validate at boundaries** - runtime checks where data enters the system - -### Make Illegal States Unrepresentable +## Make Illegal States Unrepresentable Use the type system to prevent invalid states at compile time. @@ -46,10 +39,6 @@ type OrderId = string & { readonly __brand: 'OrderId' }; // Compiler prevents passing OrderId where UserId expected function getUser(id: UserId): Promise { /* ... */ } - -function createUserId(id: string): UserId { - return id as UserId; -} ``` **Const assertions for literal unions:** @@ -63,58 +52,11 @@ function isValidRole(role: string): role is Role { } ``` -**Required vs optional fields - be explicit:** -```ts -// Creation: some fields required -type CreateUser = { - email: string; - name: string; -}; - -// Update: all fields optional -type UpdateUser = Partial; - -// Database row: all fields present -type User = CreateUser & { - id: UserId; - createdAt: Date; -}; -``` - -## Module Structure - -Prefer smaller, focused files: one component, hook, or utility per file. Split when a file handles multiple concerns or exceeds ~200 lines. Colocate tests with implementation (`foo.test.ts` alongside `foo.ts`). Group related files by feature rather than by type. - -## Functional Patterns - -- Prefer `const` over `let`; use `readonly` and `Readonly` for immutable data. -- Use `array.map/filter/reduce` over `for` loops; chain transformations in pipelines. -- Write pure functions for business logic; isolate side effects in dedicated modules. -- Avoid mutating function parameters; return new objects/arrays instead. - -## Instructions - -- Enable `strict` mode; model data with interfaces and types. Strong typing catches bugs at compile time. -- Every code path returns a value or throws; use exhaustive `switch` with `never` checks in default. Unhandled cases become compile errors. -- Propagate errors with context; catching requires re-throwing or returning a meaningful result. Hidden failures delay debugging. -- Handle edge cases explicitly: empty arrays, null/undefined inputs, boundary values. Defensive checks prevent runtime surprises. -- Use `await` for async calls; wrap external calls with contextual error messages. Unhandled rejections crash Node processes. -- Add or update focused tests when changing logic; test behavior, not implementation details. - -## Examples - -Explicit failure for unimplemented logic: -```ts -export function buildWidget(widgetType: string): never { - throw new Error(`buildWidget not implemented for type: ${widgetType}`); -} -``` - -Exhaustive switch with never check: +**Exhaustive switch with never check:** ```ts type Status = "active" | "inactive"; -export function processStatus(status: Status): string { +function processStatus(status: Status): string { switch (status) { case "active": return "processing"; @@ -128,42 +70,13 @@ export function processStatus(status: Status): string { } ``` -Wrap external calls with context: -```ts -export async function fetchWidget(id: string): Promise { - const response = await fetch(`/api/widgets/${id}`); - if (!response.ok) { - throw new Error(`fetch widget ${id} failed: ${response.status}`); - } - return response.json(); -} -``` - -Debug logging with namespaced logger: -```ts -import debug from "debug"; - -const log = debug("myapp:widgets"); - -export function createWidget(name: string): Widget { - log("creating widget: %s", name); - const widget = { id: crypto.randomUUID(), name }; - log("created widget: %s", widget.id); - return widget; -} -``` - ## Runtime Validation with Zod - Define schemas as single source of truth; infer TypeScript types with `z.infer<>`. Avoid duplicating types and schemas. - Use `safeParse` for user input where failure is expected; use `parse` at trust boundaries where invalid data is a bug. - Compose schemas with `.extend()`, `.pick()`, `.omit()`, `.merge()` for DRY definitions. - Add `.transform()` for data normalization at parse time (trim strings, parse dates). -- Include descriptive error messages; use `.refine()` for custom validation logic. -### Examples - -Schema as source of truth with type inference: ```ts import { z } from "zod"; @@ -175,74 +88,22 @@ const UserSchema = z.object({ }); type User = z.infer; -``` - -Return parse results to callers (never swallow errors): -```ts -import { z, SafeParseReturnType } from "zod"; - -export function parseUserInput(raw: unknown): SafeParseReturnType { - return UserSchema.safeParse(raw); -} - -// Caller handles both success and error: -const result = parseUserInput(formData); -if (!result.success) { - setErrors(result.error.flatten().fieldErrors); - return; -} -await submitUser(result.data); -``` -Strict parsing at trust boundaries: -```ts +// Strict parsing at trust boundaries — throws if API contract violated export async function fetchUser(id: string): Promise { const response = await fetch(`/api/users/${id}`); if (!response.ok) { throw new Error(`fetch user ${id} failed: ${response.status}`); } - const data = await response.json(); - return UserSchema.parse(data); // throws if API contract violated + return UserSchema.parse(await response.json()); } -``` - -Schema composition: -```ts -const CreateUserSchema = UserSchema.omit({ id: true, createdAt: true }); -const UpdateUserSchema = CreateUserSchema.partial(); -const UserWithPostsSchema = UserSchema.extend({ - posts: z.array(PostSchema), -}); -``` -## Configuration - -- Load config from environment variables at startup; validate with Zod before use. Invalid config should crash immediately. -- Define a typed config object as single source of truth; avoid accessing `process.env` throughout the codebase. -- Use sensible defaults for development; require explicit values for production secrets. - -### Examples - -Typed config with Zod validation: -```ts -import { z } from "zod"; - -const ConfigSchema = z.object({ - PORT: z.coerce.number().default(3000), - DATABASE_URL: z.string().url(), - API_KEY: z.string().min(1), - NODE_ENV: z.enum(["development", "production", "test"]).default("development"), -}); - -export const config = ConfigSchema.parse(process.env); -``` - -Access config values (not process.env directly): -```ts -import { config } from "./config"; - -const server = app.listen(config.PORT); -const db = connect(config.DATABASE_URL); +// Caller handles both success and error from user input +const result = UserSchema.safeParse(formData); +if (!result.success) { + setErrors(result.error.flatten().fieldErrors); + return; +} ``` ## Optional: type-fest @@ -252,19 +113,12 @@ For advanced type utilities beyond TypeScript builtins, consider [type-fest](htt - `Opaque` - cleaner branded types than manual `& { __brand }` pattern - `PartialDeep` - recursive partial for nested objects - `ReadonlyDeep` - recursive readonly for immutable data -- `LiteralUnion` - literals with autocomplete + string fallback - `SetRequired` / `SetOptional` - targeted field modifications - `Simplify` - flatten complex intersection types in IDE tooltips ```ts -import type { Opaque, PartialDeep, SetRequired } from 'type-fest'; +import type { Opaque, PartialDeep } from 'type-fest'; -// Branded type (cleaner than manual approach) type UserId = Opaque; - -// Deep partial for patch operations type UserPatch = PartialDeep; - -// Make specific fields required -type UserWithEmail = SetRequired, 'email'>; ``` diff --git a/.claude/skills/zig-best-practices/SKILL.md b/.claude/skills/zig-best-practices/SKILL.md index a222f43..e747d77 100644 --- a/.claude/skills/zig-best-practices/SKILL.md +++ b/.claude/skills/zig-best-practices/SKILL.md @@ -1,497 +1,92 @@ --- name: zig-best-practices -description: Provides Zig patterns for type-first development with tagged unions, explicit error sets, comptime validation, and memory management. Must use when reading or writing Zig files. +description: Use when reading or writing Zig files (.zig, build.zig, build.zig.zon). --- # Zig Best Practices -## Type-First Development +Follows type-first, functional, and error handling patterns from CLAUDE.md. This skill covers Zig-specific idioms only. -Types define the contract before implementation. Follow this workflow: +## Type System Patterns -1. **Define data structures** - structs, unions, and error sets first -2. **Define function signatures** - parameters, return types, and error unions -3. **Implement to satisfy types** - let the compiler guide completeness -4. **Validate at comptime** - catch invalid configurations during compilation - -### Make Illegal States Unrepresentable - -Use Zig's type system to prevent invalid states at compile time. - -**Tagged unions for mutually exclusive states:** +**Tagged unions for mutually exclusive states** — prevents invalid combinations that a struct with multiple nullable fields would allow: ```zig -// Good: only valid combinations possible const RequestState = union(enum) { idle, loading, success: []const u8, failure: anyerror, }; - -fn handleState(state: RequestState) void { - switch (state) { - .idle => {}, - .loading => showSpinner(), - .success => |data| render(data), - .failure => |err| showError(err), - } -} - -// Bad: allows invalid combinations -const RequestState = struct { - loading: bool, - data: ?[]const u8, - err: ?anyerror, -}; ``` -**Explicit error sets for failure modes:** +**Explicit error sets** — documents exactly what can fail; `anyerror` hides failure modes: ```zig -// Good: documents exactly what can fail -const ParseError = error{ - InvalidSyntax, - UnexpectedToken, - EndOfInput, -}; - -fn parse(input: []const u8) ParseError!Ast { - // implementation -} - -// Bad: anyerror hides failure modes -fn parse(input: []const u8) anyerror!Ast { - // implementation -} +const ParseError = error{ InvalidSyntax, UnexpectedToken, EndOfInput }; +fn parse(input: []const u8) ParseError!Ast { ... } ``` -**Distinct types for domain concepts:** +**Distinct types for domain IDs** — compiler prevents mixing up different ID types: ```zig -// Prevent mixing up IDs of different types const UserId = enum(u64) { _ }; const OrderId = enum(u64) { _ }; - -fn getUser(id: UserId) !User { - // Compiler prevents passing OrderId here -} - -fn createUserId(raw: u64) UserId { - return @enumFromInt(raw); -} ``` -**Comptime validation for invariants:** +**Comptime validation** — catch invalid configurations at compile time, not runtime: ```zig fn Buffer(comptime size: usize) type { - if (size == 0) { - @compileError("buffer size must be greater than 0"); - } - if (size > 1024 * 1024) { - @compileError("buffer size exceeds 1MB limit"); - } - return struct { - data: [size]u8 = undefined, - len: usize = 0, - }; -} -``` - -**Non-exhaustive enums for extensibility:** -```zig -// External enum that may gain variants -const Status = enum(u8) { - active = 1, - inactive = 2, - pending = 3, - _, -}; - -fn processStatus(status: Status) !void { - switch (status) { - .active => {}, - .inactive => {}, - .pending => {}, - _ => return error.UnknownStatus, - } + if (size == 0) @compileError("buffer size must be greater than 0"); + return struct { data: [size]u8 = undefined, len: usize = 0 }; } ``` -## Module Structure - -Larger cohesive files are idiomatic in Zig. Keep related code together: tests alongside implementation, comptime generics at file scope, public/private controlled by `pub`. Split only when a file handles genuinely separate concerns. The standard library demonstrates this pattern with files like `std/mem.zig` containing 2000+ lines of cohesive memory operations. - -## Instructions - -- Return errors with context using error unions (`!T`); every function returns a value or an error. Explicit error sets document failure modes. -- Use `errdefer` for cleanup on error paths; use `defer` for unconditional cleanup. This prevents resource leaks without try-finally boilerplate. -- Handle all branches in `switch` statements; include an `else` clause that returns an error or uses `unreachable` for truly impossible cases. -- Pass allocators explicitly to functions requiring dynamic memory; prefer `std.testing.allocator` in tests for leak detection. -- Prefer `const` over `var`; prefer slices over raw pointers for bounds safety. Immutability signals intent and enables optimizations. -- Avoid `anytype`; prefer explicit `comptime T: type` parameters. Explicit types document intent and produce clearer error messages. -- Use `std.log.scoped` for namespaced logging; define a module-level `log` constant for consistent scope across the file. -- Add or update tests for new logic; use `std.testing.allocator` to catch memory leaks automatically. - -## Examples - -Explicit failure for unimplemented logic: -```zig -fn buildWidget(widget_type: []const u8) !Widget { - return error.NotImplemented; -} -``` +## Memory Management -Propagate errors with try: -```zig -fn readConfig(path: []const u8) !Config { - const file = try std.fs.cwd().openFile(path, .{}); - defer file.close(); - const contents = try file.readToEndAlloc(allocator, max_size); - return parseConfig(contents); -} -``` +- Pass allocators explicitly to every function that allocates; no global allocator state. +- Place `defer resource.deinit()` immediately after acquisition — keeps cleanup co-located with creation. +- Use `errdefer` for cleanup on error paths; `defer` for unconditional cleanup. +- Use arena allocators for batch/temporary work; they free everything at once. +- Use `std.testing.allocator` in tests — reports leaks with stack traces. -Resource cleanup with errdefer: ```zig fn createResource(allocator: std.mem.Allocator) !*Resource { const resource = try allocator.create(Resource); - errdefer allocator.destroy(resource); - + errdefer allocator.destroy(resource); // runs only on error resource.* = try initializeResource(); return resource; } ``` -Exhaustive switch with explicit default: -```zig -fn processStatus(status: Status) ![]const u8 { - return switch (status) { - .active => "processing", - .inactive => "skipped", - _ => error.UnhandledStatus, - }; -} -``` - -Testing with memory leak detection: -```zig -const std = @import("std"); +## Key Conventions -test "widget creation" { - const allocator = std.testing.allocator; - var list: std.ArrayListUnmanaged(u32) = .empty; - defer list.deinit(allocator); - - try list.append(allocator, 42); - try std.testing.expectEqual(1, list.items.len); -} -``` - -## Memory Management - -- Pass allocators explicitly; never use global state for allocation. Functions declare their allocation needs in parameters. -- Use `defer` immediately after acquiring a resource. Place cleanup logic next to acquisition for clarity. -- Prefer arena allocators for temporary allocations; they free everything at once when the arena is destroyed. -- Use `std.testing.allocator` in tests; it reports leaks with stack traces showing allocation origins. - -### Examples - -Allocator as explicit parameter: -```zig -fn processData(allocator: std.mem.Allocator, input: []const u8) ![]u8 { - const result = try allocator.alloc(u8, input.len * 2); - errdefer allocator.free(result); - - // process input into result - return result; -} -``` - -Arena allocator for batch operations: -```zig -fn processBatch(items: []const Item) !void { - var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); - defer arena.deinit(); - const allocator = arena.allocator(); - - for (items) |item| { - const processed = try processItem(allocator, item); - try outputResult(processed); - } - // All allocations freed when arena deinits -} -``` - -## Logging - -- Use `std.log.scoped` to create namespaced loggers; each module should define its own scoped logger for filtering. -- Define a module-level `const log` at the top of the file; use it consistently throughout the module. -- Use appropriate log levels: `err` for failures, `warn` for suspicious conditions, `info` for state changes, `debug` for tracing. - -### Examples - -Scoped logger for a module: -```zig -const std = @import("std"); -const log = std.log.scoped(.widgets); - -pub fn createWidget(name: []const u8) !Widget { - log.debug("creating widget: {s}", .{name}); - const widget = try allocateWidget(name); - log.debug("created widget id={d}", .{widget.id}); - return widget; -} - -pub fn deleteWidget(id: u32) void { - log.info("deleting widget id={d}", .{id}); - // cleanup -} -``` - -Multiple scopes in a codebase: -```zig -// In src/db.zig -const log = std.log.scoped(.db); - -// In src/http.zig -const log = std.log.scoped(.http); - -// In src/auth.zig -const log = std.log.scoped(.auth); -``` - -## Comptime Patterns - -- Use `comptime` parameters for generic functions; type information is available at compile time with zero runtime cost. -- Prefer compile-time validation over runtime checks when possible. Catch errors during compilation rather than in production. -- Use `@compileError` for invalid configurations that should fail the build. - -### Examples - -Generic function with comptime type: -```zig -fn max(comptime T: type, a: T, b: T) T { - return if (a > b) a else b; -} -``` - -Compile-time validation: -```zig -fn createBuffer(comptime size: usize) [size]u8 { - if (size == 0) { - @compileError("buffer size must be greater than 0"); - } - return [_]u8{0} ** size; -} -``` - -## Avoiding anytype - -- Prefer `comptime T: type` over `anytype`; explicit type parameters document expected constraints and produce clearer errors. -- Use `anytype` only when the function genuinely accepts any type (like `std.debug.print`) or for callbacks/closures. -- When using `anytype`, add a doc comment describing the expected interface or constraints. - -### Examples - -Prefer explicit comptime type (good): -```zig -fn sum(comptime T: type, items: []const T) T { - var total: T = 0; - for (items) |item| { - total += item; - } - return total; -} -``` - -Avoid anytype when type is known (bad): -```zig -// Unclear what types are valid; error messages will be confusing -fn sum(items: anytype) @TypeOf(items[0]) { - // ... -} -``` - -Acceptable anytype for callbacks: -```zig -/// Calls `callback` for each item. Callback must accept (T) and return void. -fn forEach(comptime T: type, items: []const T, callback: anytype) void { - for (items) |item| { - callback(item); - } -} -``` - -Using @TypeOf when anytype is necessary: -```zig -fn debugPrint(value: anytype) void { - const T = @TypeOf(value); - if (@typeInfo(T) == .Pointer) { - std.debug.print("ptr: {*}\n", .{value}); - } else { - std.debug.print("val: {}\n", .{value}); - } -} -``` - -## Error Handling Patterns - -- Define specific error sets for functions; avoid `anyerror` when possible. Specific errors document failure modes. -- Use `catch` with a block for error recovery or logging; use `catch unreachable` only when errors are truly impossible. -- Merge error sets with `||` when combining operations that can fail in different ways. - -### Examples - -Specific error set: -```zig -const ConfigError = error{ - FileNotFound, - ParseError, - InvalidFormat, -}; - -fn loadConfig(path: []const u8) ConfigError!Config { - // implementation -} -``` - -Error handling with catch block: -```zig -const value = operation() catch |err| { - std.log.err("operation failed: {}", .{err}); - return error.OperationFailed; -}; -``` - -## Configuration - -- Load config from environment variables at startup; validate required values before use. Missing config should cause a clean exit with a descriptive message. -- Define a Config struct as single source of truth; avoid `std.posix.getenv` scattered throughout code. -- Use sensible defaults for development; require explicit values for production secrets. - -### Examples - -Typed config struct: -```zig -const std = @import("std"); - -pub const Config = struct { - port: u16, - database_url: []const u8, - api_key: []const u8, - env: []const u8, -}; - -pub fn loadConfig() !Config { - const db_url = std.posix.getenv("DATABASE_URL") orelse - return error.MissingDatabaseUrl; - const api_key = std.posix.getenv("API_KEY") orelse - return error.MissingApiKey; - const port_str = std.posix.getenv("PORT") orelse "3000"; - const port = std.fmt.parseInt(u16, port_str, 10) catch - return error.InvalidPort; - - return .{ - .port = port, - .database_url = db_url, - .api_key = api_key, - .env = std.posix.getenv("ENV") orelse "development", - }; -} -``` - -## Optionals - -- Use `orelse` to provide default values for optionals; use `.?` only when null is a program error. -- Prefer `if (optional) |value|` pattern for safe unwrapping with access to the value. - -### Examples - -Safe optional handling: -```zig -fn findWidget(id: u32) ?*Widget { - // lookup implementation -} - -fn processWidget(id: u32) !void { - const widget = findWidget(id) orelse return error.WidgetNotFound; - try widget.process(); -} -``` - -Optional with if unwrapping: -```zig -if (maybeValue) |value| { - try processValue(value); -} else { - std.log.warn("no value present", .{}); -} -``` +- Prefer `const` over `var`; prefer slices over raw pointers. +- Prefer `comptime T: type` over `anytype`; explicit types produce clearer errors. Use `anytype` only for genuinely polymorphic cases (callbacks, `std.debug.print`-style). +- Exhaustive `switch`: include an `else` returning an error or `unreachable` for truly impossible cases. +- Use `std.log.scoped(.module_name)` for namespaced logging; define a module-level `const log` constant. +- Larger cohesive files are idiomatic — tests alongside implementation, comptime generics at file scope. ## Advanced Topics -Reference these guides for specialized patterns: - -- **Building custom containers** (queues, stacks, trees): See [GENERICS.md](GENERICS.md) -- **Interfacing with C libraries** (raylib, SDL, curl, system APIs): See [C-INTEROP.md](C-INTEROP.md) +- **Generic containers** (queues, stacks, trees): See [GENERICS.md](GENERICS.md) +- **C library interop** (raylib, SDL, curl): See [C-INTEROP.md](C-INTEROP.md) - **Debugging memory leaks** (GPA, stack traces): See [DEBUGGING.md](DEBUGGING.md) ## Tooling -### zigdoc - Documentation Lookup - -CLI tool for browsing Zig std library and project dependency docs. - -**Install:** -```bash -git clone https://github.com/rockorager/zigdoc -cd zigdoc -zig build install -Doptimize=ReleaseFast --prefix $HOME/.local -``` - -**Usage:** +**zigdoc** — browse std library and dependency docs: ```bash -zigdoc std.ArrayList # std lib symbol -zigdoc std.mem.Allocator # nested symbol -zigdoc vaxis.Window # project dependency (if in build.zig) +zigdoc std.mem.Allocator # std lib symbol +zigdoc vaxis.Window # project dependency zigdoc @init # create AGENTS.md with API patterns ``` -### ziglint - Static Analysis - -Linter for Zig source code enforcing coding standards. - -**Install:** +**ziglint** — static analysis with `.ziglint.zon` config: ```bash -git clone https://github.com/rockorager/ziglint -cd ziglint -zig build install -Doptimize=ReleaseFast --prefix $HOME/.local -``` - -**Usage:** -```bash -ziglint # lint current directory (uses .ziglint.zon if present) -ziglint src build.zig # lint specific paths +ziglint # lint current directory ziglint --ignore Z001 # suppress specific rule ``` -**Configuration (`.ziglint.zon`):** -```zig -.{ - .paths = .{ "src", "build.zig" }, - .rules = .{ - .Z001 = .{ .enabled = false }, - .Z024 = .{ .max_length = 80 }, - }, -} -``` - -**Inline suppression:** -```zig -fn MyBadName() void {} // ziglint-ignore: Z001 -``` - ## References - Language Reference: https://ziglang.org/documentation/0.15.2/ - Standard Library: https://ziglang.org/documentation/0.15.2/std/ -- Code Samples: https://ziglang.org/learn/samples/ - Zig Guide: https://zig.guide/ From 28c08d5683d24413db20194409fae1cafa40a911 Mon Sep 17 00:00:00 2001 From: 0xBigBoss <95193764+0xBigBoss@users.noreply.github.com> Date: Wed, 8 Apr 2026 20:46:08 -0500 Subject: [PATCH 28/57] docs(agents): document .envrc autoload behavior Add Shell environment section clarifying that direnv hooks in ~/.zshenv auto-export .envrc on every cd/pushd/popd, so agents should not manually run `direnv allow`, `source .envrc`, or `eval "$(direnv export ...)"`. Saves tokens and prevents the agent from re-inventing workarounds when a var looks missing. --- CLAUDE.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index a592a19..31904b3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,6 +6,11 @@ Applies to agents. Follow these directives as system-level behavior. - Default to analysis/plan/recommend; edit files or run mutating commands only when explicitly requested or clearly implied. Ask when ambiguous. - Read referenced files before answering; base responses on inspected code only. +## Shell environment +- `.envrc` files are auto-loaded by direnv via `~/.zshenv`; each `cd` (including `pushd`/`popd`) re-exports the target directory's env before the command runs. +- Do not run `direnv allow`, `source .envrc`, or `eval "$(direnv export ...)"` — the hook already did it. Just `cd` and use the vars. +- If an expected `.envrc` var is missing, the `.envrc` is blocked (unallowed) or the file is absent; read it before inventing workarounds. + ## Core principles - Explore relevant code before proposing changes; understand context first. - Work idiomatically and safely; align with project conventions and architecture. From a8960eb421571c709cead4eec1605323c3353d3b Mon Sep 17 00:00:00 2001 From: 0xBigBoss <95193764+0xBigBoss@users.noreply.github.com> Date: Sat, 11 Apr 2026 19:55:32 -0500 Subject: [PATCH 29/57] docs(skills): add SPEC for delegate-implementer v1 Design for a Claude Code skill that lets Opus delegate bounded implementation tasks to Codex via `codex exec` while keeping the existing `rl review` gate as the audit. Approach 1 (skill-first proving ground), forward-compatible with promotion to a first-class `implement` worker role in rl-broker. --- .claude/skills/delegate-implementer/SPEC.md | 223 ++++++++++++++++++++ 1 file changed, 223 insertions(+) create mode 100644 .claude/skills/delegate-implementer/SPEC.md diff --git a/.claude/skills/delegate-implementer/SPEC.md b/.claude/skills/delegate-implementer/SPEC.md new file mode 100644 index 0000000..18118f9 --- /dev/null +++ b/.claude/skills/delegate-implementer/SPEC.md @@ -0,0 +1,223 @@ +# delegate-implementer skill — design + +**Date:** 2026-04-11 +**Status:** Design approved, ready for planning +**Author:** Opus + Allen (brainstorm session) +**Scope:** v1 of a Claude Code skill that lets Opus delegate bounded implementation tasks to Codex (gpt-5.4) while acting as driver/reviewer. Provider-agnostic shape, Codex-only adapter wired in v1. + +## Summary + +Today Claude Code runs Opus as the worker inside `rl ralph` loops while Codex serves as the reviewer. On large projects that framing burns Opus tokens on mechanical implementation work that Codex can do well when given a tight instruction packet. This spec defines a skill — `delegate-implementer` — that inverts the relationship for selected milestones: Opus writes a delegation packet, Codex implements it in a detached `codex exec` worker, Opus reads a structured result, and the existing `rl review` gate audits the diff with fresh context. The skill is a proving ground (Approach 1) for a later promotion to a first-class `implement` worker role in `rl-broker` (Approach 2). Event shapes and packet format are designed to lift directly into `rl-broker` when that promotion happens. + +## Goals + +- **G1** Let Opus delegate a well-scoped milestone to Codex without leaving the Claude Code session, protecting Opus's context by reading only structured results. +- **G2** Give Opus live, low-noise visibility into Codex's progress via agent-emitted phase events, not log parsing. +- **G3** Keep the reviewer (existing `rl review`) blind to delegation origin so the audit remains unbiased. +- **G4** Design the event schema, packet format, and result schema so promotion to an `rl-broker` `implement` worker is a code move, not a format translation. +- **G5** Be provider-agnostic in shape (interface + adapter pattern), wired only for Codex in v1. +- **G6** Respect yolo-mode-by-default while allowing per-packet downgrade. + +## Non-Goals + +- **NG1** No second provider implementation in v1. Claude and Gemini adapters are stubs documenting the interface. +- **NG2** No changes to `rl-broker`, `rl ralph`, or `.rl/events.jsonl`. The skill runs entirely outside the broker. +- **NG3** No parallel delegations. One packet in flight at a time. Multi-job queueing belongs in the broker promotion. +- **NG4** No automatic retry. Failed delegations are surfaced to Opus, which decides whether to amend and re-dispatch or escalate. +- **NG5** No slash-command entry. Skill is invoked via the Skill tool, typically from inside an active ralph iteration. +- **NG6** No mock provider or recorded-response test harness. Provider behavior is the thing under validation. + +## Requirements + +### Architecture + +- **REQ-DI-001** Three roles must be distinct: Driver (Opus in the Claude Code session), Implementer (Codex via `codex exec`), Reviewer (existing `rl review` worker). The skill coordinates the first two; the existing ralph stop-hook triggers the third unchanged. +- **REQ-DI-002** Implementer runs in a detached background process spawned via `Bash(run_in_background=true)`. Driver never blocks waiting on it. +- **REQ-DI-003** Implementer and Reviewer must never share conversation context. Reviewer receives only the committed diff, not the packet or the implementer's result JSON. +- **REQ-DI-004** The skill owns four on-disk artifacts per job, all under `.rl/`: + - `.rl/packets/.md` — delegation packet (written by Driver) + - `.rl/results/.json` — structured result (written by Implementer) + - `.rl/impl-log/.log` — raw stdout/stderr (not watched by Driver) + - `.rl/impl-events.jsonl` — lifecycle + progress event stream (watched by Driver) + +### Delegation packet + +- **REQ-DI-010** Packets are markdown files with YAML frontmatter. The body is prose instructions the Implementer reads; the frontmatter is parsed only by the wrapper. +- **REQ-DI-011** Frontmatter schema is documented in `schemas/packet.schema.json` and exemplified in `references/packet-authoring.md`. Drift between example and schema is a CI failure. +- **REQ-DI-012** Frontmatter required fields: `packet_id`, `created_at`, `provider`, `provider_args`, `spec_refs`, `plan_refs`, `scope`, `acceptance`. +- **REQ-DI-013** `scope` defines three file-pattern lists: `allowed_write`, `read_only_reference`, and an implicit deny-everything-else. The wrapper does not enforce these — the provider's sandbox does, combined with the prose instructions in the body. +- **REQ-DI-014** `provider_args.sandbox` accepts `yolo | workspace-write | read-only`. Skill default is `yolo`. +- **REQ-DI-015** Sandbox precedence from highest to lowest: environment variable `DELEGATE_CODEX_SANDBOX` → packet frontmatter → skill default. Env var is the top of the chain so that a session-level safety downgrade ("this session: no yolo") cannot be silently undone by a packet. Within that ordering, a packet can only restrict further; it can never escalate above the effective env-or-default level. The wrapper computes the effective level as `min(env, packet, default)` where the ordering is `read-only < workspace-write < yolo`, and rejects any packet whose declared level is higher than the env var with a clear error. +- **REQ-DI-016** Packets must include a `## Progress reporting` section with the standard `delegate-emit` call examples. The skill prose generates this section automatically when Opus composes a packet. + +### Result schema + +- **REQ-DI-020** Result files conform to `schemas/impl-result.schema.json`, enforced at runtime via `codex exec --output-schema`. +- **REQ-DI-021** Required fields: `status` (`complete | partial | blocked`), `summary` (≤800 chars), `files_changed` (array of paths). +- **REQ-DI-022** Optional fields: `commits`, `acceptance` (with per-check status), `blockers`, `handoff_notes` (≤1000 chars). +- **REQ-DI-023** All string fields have maximum length caps. Worst-case result JSON is ~3KB regardless of work volume. This is the Driver's primary context-protection mechanism. +- **REQ-DI-024** `additionalProperties: false` at every object level. The Implementer cannot smuggle extra fields past the schema. +- **REQ-DI-025** The `acceptance` array carries the Implementer's self-reported check results. The Driver is responsible for re-running any checks it considers load-bearing before trusting `status: complete`. + +### Wrapper and helper scripts + +- **REQ-DI-030** `scripts/delegate-codex-impl.sh` is the single entry point that runs one `codex exec` invocation. Target length ≤50 lines. Exceeding 50 lines is the signal to promote to Approach 2 (broker worker role). +- **REQ-DI-031** Wrapper parses packet frontmatter via `yq`, computes codex flags, sets `DELEGATE_JOB_ID` and prepends the skill's `scripts/` directory to `PATH` before invoking codex, so the Implementer can call `delegate-emit` without knowing install paths. +- **REQ-DI-032** Wrapper strips the YAML frontmatter before piping the packet body to `codex exec` on stdin. The Implementer never sees the frontmatter. +- **REQ-DI-033** Under normal operation the wrapper emits four events total: three lifecycle events in order (`implement-queued`, `implement-spawned`, `implement-started`) followed by exactly one terminal event — either `implement-completed` with a `verdict` field populated from the result file, or `implement-failed` with `error` and `duration_ms`. Progress events (`implement-progress`) are emitted separately by the Implementer via `delegate-emit` and are not counted in the wrapper's event budget. +- **REQ-DI-034** Wrapper has no pidfile, no cancellation handler, no retry logic. Those responsibilities live in the Driver (in-session judgment) or the future broker. +- **REQ-DI-035** `scripts/delegate-emit.sh` is called by the Implementer during execution to emit `implement-progress` events. Target length ≤25 lines. +- **REQ-DI-036** `delegate-emit` validates `--phase` against the allowlist `planning | implementing | testing | finalizing`. Invalid phase is a non-zero exit that the Implementer sees. +- **REQ-DI-037** `delegate-emit` caps `--message` at 120 characters (truncated, not rejected). Enforces INV-DI-10 at write time. +- **REQ-DI-038** `delegate-emit` requires `DELEGATE_JOB_ID` in the environment and fails fast if unset. Standalone invocation (without the wrapper) is impossible. + +### Events and Monitor + +- **REQ-DI-040** Events go to `.rl/impl-events.jsonl` in the project root, one JSON object per line, each line appended atomically via a single `>>` redirect from `jq -nc`. +- **REQ-DI-041** Event shape is byte-compatible with `rl`'s canonical `RlEvent` discriminated union in `src/events.ts`: each event has `ts` (ISO8601), `type` (discriminator), `job_id`, and type-specific fields. Promotion to Approach 2 is achieved by adding `implement-*` variants to the union and pointing the Wrapper at `.rl/events.jsonl` instead of `.rl/impl-events.jsonl`. +- **REQ-DI-042** Event type vocabulary for v1: `implement-queued`, `implement-spawned`, `implement-started`, `implement-progress`, `implement-completed`, `implement-failed`. No `implement-cancelled` in v1 (no cancellation). +- **REQ-DI-043** Monitor filter subscribed by the Driver matches `implement-(started|progress|completed|failed)`. Queued and spawned events are visible only via file inspection, not pushed to the Driver's notification stream. +- **REQ-DI-044** Monitor `timeout_ms` is 3600000 (60 minutes). This is a catastrophic safety net. Drift detection is a separate in-session judgment rule, not a Monitor capability. + +### Progress reporting contract (Implementer-side) + +- **REQ-DI-050** The Implementer emits exactly one `delegate-emit` call at each phase transition. Tool-call-level events are explicitly forbidden by the packet instructions. +- **REQ-DI-051** Phase vocabulary is exactly four: `planning`, `implementing`, `testing`, `finalizing`. Any additional granularity must be expressed via the `--message` text, not new phases. +- **REQ-DI-052** The Implementer emits `delegate-emit --phase X` *before* entering phase X, not after completing the previous one. A packet that completes without any progress events is considered malformed from a reporting standpoint (the work may still be valid). + +### Drift detection and cancellation (Driver-side) + +- **REQ-DI-060** Drift rule: if no `implement-progress` event arrives for 5 consecutive minutes and the job has not terminated, the Driver treats the job as drifted. The rule lives in skill prose, not in the wrapper or Monitor — it is an in-session judgment. +- **REQ-DI-061** On drift detection, the Driver's options are: (a) wait one additional cycle if the last phase was `testing` (long test runs are expected), (b) cancel via `pkill -f "codex exec.*"`, or (c) escalate to the human. v1 does not prescribe which of (a)/(b)/(c) is correct — the skill prose describes the tradeoffs and leaves the choice to Opus. +- **REQ-DI-062** There is no automatic cancellation. The Driver must decide. + +### Integration with `rl ralph` + +- **REQ-DI-070** The skill is invoked from inside an active `rl ralph` iteration, after `.rl/task.md` has been crystallized. It does not initialize its own loop state. +- **REQ-DI-071** Delegation heuristic: delegate when the scope is a self-contained milestone in `PLAN.md` (or `.rl/task.md`) that a fresh-context Codex session can execute from the packet alone. If Opus cannot picture how to write the packet without paragraphs of caveats, the task is not a delegation candidate — Opus implements directly. +- **REQ-DI-072** After the Implementer completes and Opus reads the result, the ralph iteration proceeds normally — stop-hook fires, `rl review` is triggered, the reviewer audits the diff. The skill does not call `rl review` itself. +- **REQ-DI-073** The reviewer must not be told the diff came from a delegated worker. No annotations in commit messages identifying the source, no handoff notes pasted into review context. Audit integrity depends on the reviewer's fresh context. + +### Provider interface + +- **REQ-DI-080** A provider adapter lives in `references/providers/.md` and must document four things: invocation pattern, sandbox-level mapping, result-contract support (can it honor `--output-schema`?), and dependency check. +- **REQ-DI-081** v1 ships a complete Codex adapter (`codex.md`) and stubs for Claude (`claude.md`) and Gemini (`gemini.md`). +- **REQ-DI-082** A stub adapter documents the template and explicitly states "not yet wired in v1" in its opening line. The wrapper will refuse to run a packet with `provider: claude` or `provider: gemini` with a clear error message. +- **REQ-DI-083** Adding a second provider in a future version requires: (a) filling in the stub `.md` file, (b) adding a provider-specific wrapper script alongside `delegate-codex-impl.sh`, (c) the main wrapper dispatches to the provider-specific script based on the packet's `provider` field. No changes to `delegate-emit`, the event schema, or the result schema are required — those are provider-agnostic by design. + +### Dependencies + +- **REQ-DI-090** Hard runtime dependencies: `codex` (CLI), `yq`, `jq`, `git`. Missing any of these causes the wrapper to exit non-zero with a clear message before emitting any events. +- **REQ-DI-091** The skill's SKILL.md instructs Opus to verify these dependencies exist at invocation time, before writing the first packet. + +## Invariants + +- **INV-DI-01** Raw provider stdout, token deltas, reasoning summaries, and tool-call traces MUST NOT appear in `.rl/impl-events.jsonl`. Only structured lifecycle and progress events with bounded message fields. Mirrors rl's `INV-10`. +- **INV-DI-02** Every event file line is one complete JSON object with `ts`, `type`, and `job_id`. Partial lines are never observed (atomic append). +- **INV-DI-03** The Implementer cannot emit events without the wrapper's environment (enforced by `DELEGATE_JOB_ID` check in `delegate-emit`). Rogue event injection from outside a running job is impossible by construction. +- **INV-DI-04** The result JSON cannot exceed ~3KB under normal operation due to per-field caps in the schema. Opus context cost per delegation is bounded. +- **INV-DI-05** The reviewer's context contains no reference to the delegation. Audit integrity is preserved as an invariant of the integration, not a best-effort convention. + +## Acceptance Criteria + +### Skill artifacts exist and validate + +- [ ] `SKILL.md` exists with frontmatter matching the Claude Code skill format, and the description triggers on delegation tasks without false positives on general implementation work +- [ ] `scripts/delegate-codex-impl.sh` is ≤50 lines and passes `shellcheck` with zero warnings +- [ ] `scripts/delegate-emit.sh` is ≤25 lines and passes `shellcheck` with zero warnings +- [ ] `schemas/impl-result.schema.json` compiles with `ajv compile` without errors +- [ ] `schemas/packet.schema.json` validates the example packet in `references/packet-authoring.md` + +### Smoke test passes + +- [ ] Running the wrapper with a trivial packet (create `/tmp/delegate-smoke.txt` with fixed content) produces a result JSON with `status: complete` and the file path in `files_changed` +- [ ] The same smoke run emits at least one `implement-started`, one `implement-progress`, and one `implement-completed` event in `.rl/impl-events.jsonl` +- [ ] The target file exists with the expected content after the smoke run +- [ ] The wrapper exits 0 on the smoke run + +### Unit tests pass + +- [ ] `delegate-emit` rejects an invalid `--phase` with non-zero exit and writes no event +- [ ] `delegate-emit` rejects a missing `DELEGATE_JOB_ID` with non-zero exit +- [ ] `delegate-emit` truncates messages over 120 characters cleanly (no partial unicode, no crash) +- [ ] Wrapper rejects a packet whose `provider_args.sandbox` is higher than `DELEGATE_CODEX_SANDBOX` with a clear error and non-zero exit +- [ ] Wrapper exits with a clear error if any of `codex`, `yq`, `jq`, `git` is missing from `PATH` + +### Dogfood validation + +- [ ] The skill successfully drives the rl-broker `implement` worker extension work as a separate brainstorm → plan → execute cycle, Codex produces a working extension, and the existing `rl review` gate approves the diff. If this succeeds, skill v1 is considered proven. If it fails, the failure mode is the input to a follow-up design cycle — no retro-fixes to v1 itself without that new cycle. + +## File Layout + +``` +/ +├── SKILL.md +├── scripts/ +│ ├── delegate-codex-impl.sh +│ └── delegate-emit.sh +├── schemas/ +│ ├── packet.schema.json +│ └── impl-result.schema.json +└── references/ + ├── event-shape.md + ├── packet-authoring.md + ├── drift-detection.md + └── providers/ + ├── codex.md + ├── claude.md + └── gemini.md +``` + +Runtime artifacts under the project being worked on: + +``` +/.rl/ +├── packets/.md +├── results/.json +├── impl-log/.log +├── impl-events.jsonl +└── events.jsonl # existing rl canonical, untouched +``` + +## Approach 2 promotion path + +When the skill graduates to a first-class broker worker, the mechanical steps are: + +1. Add `ImplementQueuedEvent`, `ImplementSpawnedEvent`, `ImplementStartedEvent`, `ImplementProgressEvent`, `ImplementCompletedEvent`, `ImplementFailedEvent`, and optionally `ImplementCancelledEvent` to `RlEvent` in `rl/src/events.ts`. Extend `KNOWN_EVENT_TYPES` accordingly. A new REQ-RL-* entry authorizes the union change. +2. Extend `WorkerQueuedEvent.kind` from `'review'` to `'review' | 'implement'`. +3. Create `rl/src/worker/impl-worker.ts` mirroring `review-worker.ts`, port the wrapper's codex invocation into TypeScript. +4. Add a new CLI command `rl implement ` or a `--implementer` flag on `rl ralph`. +5. Point the skill's wrapper at the new broker command; the skill scripts become thin shims during the transition, then are deleted when confidence is high. +6. Event emission moves from `delegate-emit` + wrapper shell to the broker's `emitEvent` TypeScript path. Write destination moves from `.rl/impl-events.jsonl` to `.rl/events.jsonl`. The Driver's Monitor filter updates to target the canonical file. + +None of these steps require changing the packet format, result schema, or provider interface. Those are stable from v1 by design. + +## Open questions for the implementation plan + +These are explicitly deferred to the planning phase and listed here so they don't get lost: + +- **OQ-1** Exact `SKILL.md` description text — needs to trigger Opus's delegation reflex without matching every "implement X" request. +- **OQ-2** Exact `shellcheck` configuration (which checks to enable/disable for the wrapper scripts). +- **OQ-3** Whether `delegate-emit` should ship a completion installer that symlinks it into `~/.local/bin` for humans who want to call it directly in test scripts, or whether test scripts should invoke it via its full path. +- **OQ-4** Whether the smoke test runs in CI (needs codex available) or only locally on the maintainer's machine. +- **OQ-5** Where the skill source lives in the dotfiles tree — likely under `claude-code/.claude/skills/delegate-implementer/` following the repo's existing convention, but confirming in the plan. + +## Testing Strategy + +Four layers, in order of cost: + +1. **Shellcheck on every script** (cheap, CI). +2. **JSON Schema compile check** via `ajv` (cheap, CI). +3. **Unit tests on `delegate-emit`** via `bats` or plain bash (cheap, CI). +4. **Wrapper smoke test** running a real `codex exec` invocation against a trivial packet (local-only in v1; needs codex auth). +5. **Dogfood validation** — the skill drives the rl-broker extension build. This is the real signal. + +No mock provider. No recorded-response replay. Provider behavior is the thing under validation, and faking it is worse than not testing. + +## Security and Safety Considerations + +- **S-1** Yolo sandbox mode is the default at the user's explicit request (`rl ralph` context, trusted workspace). This is not a vulnerability — it's a deliberate configuration choice documented in REQ-DI-014. +- **S-2** The wrapper runs inside the user's existing shell and git context. No privilege escalation, no network exposure beyond what `codex exec` itself performs. +- **S-3** `delegate-emit` cannot be tricked into writing malformed events (phase allowlist, message truncation, `DELEGATE_JOB_ID` requirement). +- **S-4** The result schema's `additionalProperties: false` plus per-field length caps prevent the Implementer from exfiltrating large blobs through the result path into Driver context. +- **S-5** The packet body is written to `.rl/packets/.md` and is not marked sensitive. Packets may reference spec files but should not include secrets; this is Opus's responsibility when composing them, documented in `references/packet-authoring.md`. From 3c3ebbf3d5efe6e2fa747c31dade8371663d3178 Mon Sep 17 00:00:00 2001 From: 0xBigBoss <95193764+0xBigBoss@users.noreply.github.com> Date: Sun, 12 Apr 2026 20:05:45 -0500 Subject: [PATCH 30/57] feat(statusline): align rl loop segment with rl 1.0 state schema MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The rl 1.0 rewrite moved verdict state into .rl/state.json (v3 schema) with strategy-typed loops, async review workers, and verdict/in-flight fields. The statusline's old segment still reads the pre-1.0 files and no longer reflects what the loop is actually doing. Changes: - Strategy-aware leading glyph: ralph (🔁), review (🧪), research (🔬). - Verdict/in-flight tail glyph: ⏳ > ✅ > ❌ > none, with in-flight-wins precedence (running worker supersedes any prior verdict by construction). - research strategy hides the review counter and renders ★{best_metric_value} when present (3 decimal places). - Drop dead CodexReviewState code + tests; rl 1.0 does not write .claude/codex-review.local.md. - Thread allocator through parseRalphStateFromContent (was using std.heap.page_allocator directly). - glyphs namespace alongside colors for grepability. - --debug mode logs schema version drift (version != 3) to /tmp/statusline-debug.log without affecting rendering. Adds SPEC.md as a retroactive specification covering both preserved behavior (path/git/model/gauge) and the rl 1.0 alignment requirements. Tests: 50/50 passing (+11 new, -8 removed CodexReviewState tests). Manual smoke-tested against 7 synthetic fixtures covering each strategy/verdict/in-flight/metric/legacy permutation. --- statusline/SPEC.md | 185 +++++++++++++ statusline/src/main.zig | 596 +++++++++++++++++++++++++--------------- 2 files changed, 563 insertions(+), 218 deletions(-) create mode 100644 statusline/SPEC.md diff --git a/statusline/SPEC.md b/statusline/SPEC.md new file mode 100644 index 0000000..a1db6da --- /dev/null +++ b/statusline/SPEC.md @@ -0,0 +1,185 @@ +# Statusline SPEC + +Retroactive specification for the Zig statusline renderer in `claude-code/statusline/`. Authored 2026-04-12 from the existing implementation in `src/main.zig` plus rl 1.0 alignment work. + +## Problem + +Claude Code spawns a status-line process on every render tick. The renderer must: + +- Turn a single JSON blob on stdin into a single terminal-formatted line on stdout. +- Be fast enough to feel instantaneous on every agent turn (target: < 50 ms wall). +- Surface the information the operator actually glances at mid-loop: where am I (path + branch), what's the agent doing (model, context gauge, cost, time), and what's the loop doing (rl iteration, review verdict / in-flight state). +- Never crash the rendering pipeline. A bad input, a missing file, or a dead subprocess degrades to a safe fallback (`~`), not a broken prompt. + +The rl CLI's 1.0 rewrite moved verdict state from a standalone `.claude/codex-review.local.md` file into `.rl/state.json`, introduced strategy-typed loops (`ralph | review | research`), and added async review workers with `review_in_flight_job_id` / `review_verdict` / `review_verdict_sha` fields. The statusline's old segment still reads the pre-1.0 files and no longer reflects what the loop is actually doing. This spec captures both the preserved behavior and the rl 1.0 alignment. + +## Non-goals + +- Not a general-purpose prompt engine. The segment set is fixed; configuration lives in code. +- Not a persistent daemon. One process per render; no background polling. +- Not authoritative for any data source. Every data read is best-effort and may return null / empty. +- Not responsible for the rl loop's decision logic. The Stop hook in `rl` owns verdict gating; the statusline only visualizes state. +- No network I/O of any kind. +- No schema migration for stale rl state files. Graceful degrade in `--debug` mode; do not try to repair. + +## Domain model + +``` +stdin (JSON StatuslineInput) + │ + ▼ +┌──────────────────────┐ ┌────────────────────────┐ +│ parseFromSlice │ fail │ fallback: "~\n" to │ +│ (ignore_unknown…) ├──────▶│ stdout, exit 0 │ +└──────┬───────────────┘ └────────────────────────┘ + │ ok + ▼ +┌──────────────────────────────────────────────────────┐ +│ Segment pipeline (writes into a 1 KiB output buffer) │ +│ │ +│ path + branch + git-status │ +│ rl loop segment (from .rl/state.json) │ +│ zmx session (from $ZMX_SESSION) │ +│ model + gauge + usage + cost + duration + lines │ +│ idle-since (from ~/.claude/.idle-since-*)│ +└──────┬───────────────────────────────────────────────┘ + │ + ▼ + stdout (one line) +``` + +### Key types (source: `src/main.zig`) + +- `StatuslineInput` — the stdin contract. Fields are all optional. `context_window.current_usage` (Claude Code v2.0.70+) is the preferred token-count source; transcript parsing is the fallback. +- `ContextUsage` — `{ percentage, total_tokens }`. Renders a 5-char, 40-step eighth-block gauge with an RGB gradient (green → yellow → red). +- `ModelType` — `opus | sonnet | haiku | unknown`. Drives the model glyph (`🎭📜🍃?`). +- `GitStatus` — `{ added, modified, deleted, untracked }`. Parsed from `git status --porcelain`. +- `RalphState` (pre-1.0) — `{ active, iteration, max_iterations, review_enabled, review_count, max_review_cycles }`. Read from `{git_root}/.rl/state.json`. +- `CodexReviewState` (deprecated; removed in rl 1.0 alignment) — YAML frontmatter reader for `{git_root}/.claude/codex-review.local.md`. + +### rl 1.0 state schema (source: `~/0xbigboss/rl/SPEC.md:387-418`) + +```typescript +interface LoopState { + version: 3 + strategy: 'ralph' | 'review' | 'research' + active: boolean + iteration: number + max_iterations: number + timestamp: string + + review_enabled: boolean + review_count: number + max_review_cycles: number + + review_verdict: 'approve' | 'reject' | null + review_verdict_sha: string | null + review_verdict_ts: string | null + review_verdict_job_id: string | null + review_in_flight_job_id: string | null + + metric_name?: string + metric_direction?: 'minimize' | 'maximize' + best_metric_value?: number + best_metric_commit?: string + + completion_claimed?: boolean + blocked_claimed?: boolean + debug: boolean +} +``` + +The statusline reads a subset: everything needed to render one segment, nothing more. + +## Invariants + +- **I-1 Single-line output.** Exactly one newline, at the end. No mid-line newlines. +- **I-2 Crash-free.** Any error in any segment must be swallowed into "skip that segment" or, at worst, into the `~\n` fallback. A return code of 0 is always produced (subject to OS limits). +- **I-3 Sub-process budget.** All `git` subprocess calls run against the workspace `current_dir`. No arbitrary shell. No network. Timeouts are implicit (Claude Code kills slow renders). +- **I-4 Read-only.** The statusline never writes to state files, rl files, or the repo. Only writes are to `/tmp/statusline-debug.log` when `--debug` is set. +- **I-5 File reads are bounded.** Every file read caps the byte count (4 KiB for `.rl/state.json`, 512 KiB tail for transcripts). +- **I-6 Unknown fields are ignored.** All JSON parses use `ignore_unknown_fields = true`. Schema additions upstream must not break the statusline. +- **I-7 Empty segments are hidden.** A segment that has nothing interesting to say emits zero bytes (not even a leading space). + +## Requirements + +### Input + +- **REQ-SL-001**: The statusline reads one JSON `StatuslineInput` document from stdin. Fields are all optional. Unknown fields are ignored. +- **REQ-SL-002**: If stdin JSON fails to parse, emit `~\n` (cyan) to stdout and exit 0. Log the parse error to `/tmp/statusline-debug.log` when `--debug` is set. +- **REQ-SL-003**: `--debug` command-line flag enables writing the raw input, rendered output, and any diagnostics to `/tmp/statusline-debug.log` (append-only). No other command-line flags exist. + +### Workspace segment + +- **REQ-SL-010**: When `workspace.current_dir` is missing, emit `~` and skip all workspace-dependent segments (git, rl). +- **REQ-SL-011**: When `current_dir` is present, render the path via `formatPathShort` — home-relative, abbreviating intermediate segments on long paths, last segment full. +- **REQ-SL-012**: When `current_dir` is inside a git repo, detect this via `git rev-parse --is-inside-work-tree` and enable git-dependent segments. +- **REQ-SL-013**: When the git branch equals the last path segment, color the last path segment green and skip the `[branch]` display. Otherwise render `[branch]` (abbreviated via `abbreviateBranch`). +- **REQ-SL-014**: Abbreviation rules for branches: Linear-issue pattern (`PREFIX-NNNN[-suffix]`) truncates to `PREFIX-NNNN`. Other branches get the per-segment `abbreviateSegment` treatment (first letter per hyphen-separated token, `0x`-prefixed tokens keep three chars). +- **REQ-SL-015**: Git status indicators (`+N ~N -N ?N`) render inside the same bracket pair as the branch when any are non-zero. + +### rl loop segment (pre-1.0 — captured for baseline) + +- **REQ-SL-020** (pre-1.0): Read `{git_root}/.rl/state.json` as JSON (first 4 KiB) into `RalphState` with `ignore_unknown_fields`. On any failure return the default (inactive) state. +- **REQ-SL-021** (pre-1.0): When `state.active == false`, emit nothing. +- **REQ-SL-022** (pre-1.0): When active, emit ` 🔄 {color}{iteration}/{max_iterations}{reset}` where color follows `progressColor` (green <50%, yellow <80%, red ≥80%). +- **REQ-SL-023** (pre-1.0): When `review_enabled`, additionally emit ` 🔍 {color}{review_count}/{max_review_cycles}{reset}` using the same color rule. +- **REQ-SL-024** (deprecated, removed in rl 1.0 alignment): Read `{git_root}/.claude/codex-review.local.md` YAML frontmatter for a standalone `🔎` Codex review segment. + +### rl loop segment (rl 1.0 — NEW) + +- **REQ-SL-030**: The statusline reads `.rl/state.json` v3 and recognizes the additional fields `strategy`, `review_verdict`, `review_verdict_sha`, `review_verdict_job_id`, `review_in_flight_job_id`, `metric_name`, `metric_direction`, `best_metric_value`. Missing fields default to null/0 and do not break rendering (REQ-SL-001 / I-6). +- **REQ-SL-031**: When `state.active == false`, the rl segment emits nothing regardless of other fields. +- **REQ-SL-032** (strategy glyph): When `state.active == true`, the leading glyph is selected from `strategy`: + - `ralph` → `🔁` + - `review` → `🧪` + - `research` → `🔬` + - missing/unknown → `🔁` (legacy fallback, matches pre-1.0 default) +- **REQ-SL-033** (iteration counter): For `ralph` and `review` strategies, render ` {glyph} {color}{iteration}/{max_iterations}{reset}` using `progressColor`. +- **REQ-SL-034** (review counter): For `ralph` and `review` strategies, when `review_enabled == true`, additionally render ` 🔍 {color}{review_count}/{max_review_cycles}{reset}`. When `review_enabled == false` the review sub-segment is omitted. +- **REQ-SL-035** (state glyph): For `ralph` and `review` strategies with `review_enabled == true`, a terminal state glyph is appended after the review counter, chosen by precedence: + 1. `review_in_flight_job_id != null` → ` ⏳` (in-flight beats verdict; a running worker invalidates any prior verdict by construction) + 2. `review_verdict == "approve"` → ` ✅` + 3. `review_verdict == "reject"` → ` ❌` + 4. otherwise → nothing +- **REQ-SL-036** (research rendering): For `research` strategy: + - Render ` 🔬 {color}{iteration}/{max_iterations}{reset}` (same color rule). + - Review sub-segment and state glyph are hidden (research loops do not gate on reviews). + - When `best_metric_value != null`, additionally render ` ★{value}` using 3 significant digits. Metric name is omitted to preserve space — the operator's task prompt supplies context. +- **REQ-SL-037** (staleness): Verdict staleness (`review_verdict_sha != HEAD`) is NOT surfaced on the statusline. The rl Stop hook owns that decision; the statusline's goal is a glanceable snapshot of stored state, not a correctness oracle. +- **REQ-SL-038** (schema version): The statusline parses `state.version` and, when `--debug` is set and `version` is present but `!= 3`, appends a single diagnostic line to `/tmp/statusline-debug.log`. No visual indication is emitted (I-7 preserves quiet degradation). +- **REQ-SL-039** (allocator hygiene): `parseRalphStateFromContent` accepts an `Allocator` parameter and uses it for all `std.json.parseFromSlice` allocations. No calls to `std.heap.page_allocator` inside parsing code. + +### Other segments (captured for traceability) + +- **REQ-SL-050**: `ZMX_SESSION` env var, when non-empty, renders as ` zmx:{value}` in gray. +- **REQ-SL-051**: Model segment (`{gauge} {emoji}`) is emitted when `input.model.display_name` is present. +- **REQ-SL-052**: Context usage prefers `context_window.current_usage` (v2.0.70+). Falls back to parsing the transcript's last assistant message (max 100 lines / 512 KiB tail scan). Effective context size is 77.5% of `context_window_size` (22.5% autocompact reserve). Returns 0% when unavailable. +- **REQ-SL-053**: Cost (`${usd}`), duration (`Nh|Nm|<1m`), and lines-changed (`+N/-N` in green/red) render when their source fields are present and non-zero. Rounding rules: `<$1 .2f`, `<$10 .1f`, `≥$10 integer`. +- **REQ-SL-054**: Idle-since indicator (`💤{time}`) reads `~/.claude/.idle-since-{session_id}` (max 32 bytes) and is only shown when the file exists. + +## Acceptance criteria + +rl 1.0 alignment (this change set): + +- [x] `SPEC.md` exists colocated at `claude-code/statusline/SPEC.md`. +- [ ] `CodexReviewState` struct, `parseCodexReviewState*` functions, and all `CodexReviewState` tests are removed. +- [ ] `RalphState` gains optional `strategy`, `review_verdict`, `review_verdict_sha`, `review_in_flight_job_id`, `best_metric_value` fields, plus any others required for rendering above. +- [ ] `parseRalphStateFromContent(allocator, content)` signature threads the allocator; no `std.heap.page_allocator` in parse path. +- [ ] `glyphs` namespace declared alongside `colors`; strategy, state, and metric glyph literals live there. +- [ ] Strategy-aware `RalphState.format` renders per REQ-SL-032…036. +- [ ] Tests cover: strategy glyph selection; verdict state glyph precedence including in-flight-wins-over-verdict; research metric rendering; research segment hides review counter; schema version debug-log branch; allocator-threading test uses `std.testing.allocator`. +- [ ] `zig build test` passes. +- [ ] `zig build` (default) produces a working binary that renders all three strategies correctly against hand-crafted `.rl/state.json` fixtures. +- [ ] All existing tests remain green (no behavior change to path/git/model/gauge/cost segments). + +## Risk tags + +- **LOW — code-only change, read-only.** No schema migration, no auth, no infra. Blast radius is the statusline renderer. +- **LOW — reversible.** Dead code removal is recoverable via git. +- No high-risk tags apply. + +## Open items + +- The `blocked_claimed` / `completion_claimed` flags from rl 1.0 are not surfaced. If `/rl:done` leaves `active == true` while setting these, the statusline will continue rendering the iteration segment. Revisit if the rl contract actually does this; otherwise treat as a non-goal (the Stop hook clears `active` on done). +- Iteration-runtime indicator (`+Nm` derived from `iteration_start_ms`) is deferred (IMP-7) until concrete "stuck iteration" pain is observed. diff --git a/statusline/src/main.zig b/statusline/src/main.zig index 0280d92..7978a6b 100644 --- a/statusline/src/main.zig +++ b/statusline/src/main.zig @@ -22,6 +22,22 @@ const colors = struct { const bg_reset = "\x1b[49m"; // Reset background only }; +/// Statusline-segment glyphs grouped for grepability +const glyphs = struct { + // Strategy glyphs — rl loop type indicator + const ralph = "🔁"; + const review = "🧪"; + const research = "🔬"; + // Review sub-counter (counts confirmed-reject verdicts vs max_review_cycles) + const counter = "🔍"; + // Verdict / in-flight state glyphs + const in_flight = "⏳"; + const approve = "✅"; + const reject = "❌"; + // Research metric (★{best_metric_value}) + const metric = "★"; +}; + /// Current context usage token counts - added in v2.0.70 /// Provides accurate per-message token counts for context window calculation const CurrentUsage = struct { @@ -218,63 +234,96 @@ fn progressColor(current: u32, max: u32) []const u8 { } } -/// Ralph Reviewed loop state +/// rl loop strategy — matches `strategy` field in .rl/state.json v3 +const Strategy = enum { + ralph, + review, + research, + unknown, + + fn fromString(s: []const u8) Strategy { + if (std.mem.eql(u8, s, "ralph")) return .ralph; + if (std.mem.eql(u8, s, "review")) return .review; + if (std.mem.eql(u8, s, "research")) return .research; + return .unknown; + } + + fn glyph(self: Strategy) []const u8 { + return switch (self) { + // Legacy fallback: older state files without strategy were ralph-style loops + .ralph, .unknown => glyphs.ralph, + .review => glyphs.review, + .research => glyphs.research, + }; + } +}; + +/// Derived verdict state for the rl segment tail glyph. +/// Precedence (resolved at parse time): in_flight > approve > reject > none. +/// Rationale: a running review worker supersedes any stored verdict from a prior round +/// by construction — the worker clears `review_in_flight_job_id` only after writing the +/// new verdict (see ~/0xbigboss/rl/SPEC.md REQ-RL-093). +const Verdict = enum { + none, + in_flight, + approve, + reject, +}; + +/// rl loop state — read from .rl/state.json (v3 schema, rl 1.0+) const RalphState = struct { active: bool = false, + strategy: Strategy = .unknown, iteration: u32 = 0, max_iterations: u32 = 50, review_enabled: bool = false, review_count: u32 = 0, max_review_cycles: u32 = 10, - - /// Format Ralph status for statusline display - /// Returns true if something was written + verdict: Verdict = .none, + best_metric_value: ?f64 = null, + /// Schema version from the `version` field, if present. Consumers use this for + /// debug-mode drift detection only; rendering never branches on it. + version: ?u32 = null, + + /// Format rl loop status for statusline display. + /// Returns true if something was written. fn format(self: RalphState, writer: anytype) !bool { if (!self.active) return false; - // Iteration display: 🔄 N/M - const iter_color = progressColor(self.iteration, self.max_iterations); - try writer.print(" 🔄 {s}{d}/{d}{s}", .{ - iter_color, + // Strategy glyph + iteration counter + try writer.print(" {s} {s}{d}/{d}{s}", .{ + self.strategy.glyph(), + progressColor(self.iteration, self.max_iterations), self.iteration, self.max_iterations, colors.reset, }); - // Review display: 🔍 N/M (only if enabled) + // Research strategy: no review gating; optionally show best metric value + if (self.strategy == .research) { + if (self.best_metric_value) |v| { + try writer.print(" {s}{d:.3}", .{ glyphs.metric, v }); + } + return true; + } + + // Ralph / review strategies: review sub-counter + verdict tail glyph if (self.review_enabled) { - const rev_color = progressColor(self.review_count, self.max_review_cycles); - try writer.print(" 🔍 {s}{d}/{d}{s}", .{ - rev_color, + try writer.print(" {s} {s}{d}/{d}{s}", .{ + glyphs.counter, + progressColor(self.review_count, self.max_review_cycles), self.review_count, self.max_review_cycles, colors.reset, }); - } - return true; - } -}; - -/// Codex Reviewer state (standalone review gate, not part of Ralph loop) -const CodexReviewState = struct { - active: bool = false, - review_count: u32 = 0, - max_review_cycles: u32 = 10, - - /// Format Codex review status for statusline display - /// Returns true if something was written - fn format(self: CodexReviewState, writer: anytype) !bool { - if (!self.active) return false; - - // Review display: 🔎 N/M (left-tilted magnifying glass for Codex, distinct from Ralph's 🔍) - const rev_color = progressColor(self.review_count, self.max_review_cycles); - try writer.print(" 🔎 {s}{d}/{d}{s}", .{ - rev_color, - self.review_count, - self.max_review_cycles, - colors.reset, - }); + switch (self.verdict) { + .none => {}, + .in_flight => try writer.print(" {s}", .{glyphs.in_flight}), + .approve => try writer.print(" {s}", .{glyphs.approve}), + .reject => try writer.print(" {s}", .{glyphs.reject}), + } + } return true; } @@ -799,44 +848,70 @@ fn parseYamlInt(line: []const u8, key: []const u8) ?u32 { return std.fmt.parseInt(u32, value, 10) catch null; } -/// Parse Ralph state from JSON content string -/// Exposed for testing; returns default RalphState if parsing fails -fn parseRalphStateFromContent(content: []const u8) RalphState { +/// Parse rl loop state from JSON content string. +/// Exposed for testing. Returns default (inactive) RalphState if parsing fails. +/// The caller-supplied `allocator` is used for all JSON allocations. +fn parseRalphStateFromContent(allocator: Allocator, content: []const u8) RalphState { var state = RalphState{}; - // Use std.json to parse the JSON state file + // Mirror of .rl/state.json v3 — all fields optional so unknown/absent keys + // degrade gracefully. See ~/0xbigboss/rl/SPEC.md:387 for the authoritative schema. const JsonState = struct { + version: ?u32 = null, + strategy: ?[]const u8 = null, active: ?bool = null, iteration: ?u32 = null, max_iterations: ?u32 = null, review_enabled: ?bool = null, review_count: ?u32 = null, max_review_cycles: ?u32 = null, + review_verdict: ?[]const u8 = null, + review_in_flight_job_id: ?[]const u8 = null, + best_metric_value: ?f64 = null, }; - const parsed = std.json.parseFromSlice(JsonState, std.heap.page_allocator, content, .{ + const parsed = std.json.parseFromSlice(JsonState, allocator, content, .{ .ignore_unknown_fields = true, }) catch return state; defer parsed.deinit(); const v = parsed.value; + if (v.version) |ver| state.version = ver; if (v.active) |a| state.active = a; if (v.iteration) |i| state.iteration = i; if (v.max_iterations) |m| state.max_iterations = m; if (v.review_enabled) |r| state.review_enabled = r; if (v.review_count) |r| state.review_count = r; if (v.max_review_cycles) |m| state.max_review_cycles = m; + if (v.best_metric_value) |b| state.best_metric_value = b; + if (v.strategy) |s| state.strategy = Strategy.fromString(s); + + // Verdict precedence: a running worker always supersedes a stored verdict + // (the worker clears review_in_flight_job_id only after writing the new verdict, + // so a non-null job id means the prior verdict is stale-by-construction). + if (v.review_in_flight_job_id) |id| { + if (id.len > 0) state.verdict = .in_flight; + } + if (state.verdict == .none) { + if (v.review_verdict) |verdict| { + if (std.mem.eql(u8, verdict, "approve")) { + state.verdict = .approve; + } else if (std.mem.eql(u8, verdict, "reject")) { + state.verdict = .reject; + } + } + } return state; } -/// Parse Ralph loop state from state file at git root +/// Parse rl loop state from .rl/state.json at git root. +/// Returns default (inactive) state on any I/O or parse failure. fn parseRalphState(allocator: Allocator, git_root: []const u8) RalphState { - // Construct path: {git_root}/.rl/state.json const path = std.fmt.allocPrint(allocator, "{s}/.rl/state.json", .{git_root}) catch return RalphState{}; defer allocator.free(path); - // Read first 4KB - JSON state file should be well under this + // 4 KiB cap: observed state.json files are <1 KiB; 4 KiB gives 4x headroom. const file = std.fs.cwd().openFile(path, .{}) catch return RalphState{}; defer file.close(); @@ -844,54 +919,22 @@ fn parseRalphState(allocator: Allocator, git_root: []const u8) RalphState { const bytes_read = file.read(&buf) catch return RalphState{}; if (bytes_read == 0) return RalphState{}; - return parseRalphStateFromContent(buf[0..bytes_read]); -} - -/// Parse Codex review state from file content string (YAML frontmatter) -/// Exposed for testing; returns default CodexReviewState if parsing fails -fn parseCodexReviewStateFromContent(content: []const u8) CodexReviewState { - var state = CodexReviewState{}; - - // Must start with --- - if (!std.mem.startsWith(u8, content, "---")) return state; - const after_first = content[3..]; - // Skip newline after first --- - const start_idx: usize = if (after_first.len > 0 and after_first[0] == '\n') 1 else 0; - - // Find closing --- if present, otherwise parse what we have - const frontmatter = if (std.mem.indexOf(u8, after_first[start_idx..], "\n---")) |end_idx| - after_first[start_idx..][0..end_idx] - else - after_first[start_idx..]; - - // Parse lines until we hit closing delimiter or exhaust content - var lines = std.mem.splitScalar(u8, frontmatter, '\n'); - while (lines.next()) |line| { - const trimmed = std.mem.trim(u8, line, " \t\r"); - if (std.mem.eql(u8, trimmed, "---")) break; - if (parseYamlBool(trimmed, "active:")) |v| state.active = v; - if (parseYamlInt(trimmed, "review_count:")) |v| state.review_count = v; - if (parseYamlInt(trimmed, "max_review_cycles:")) |v| state.max_review_cycles = v; - } - - return state; + return parseRalphStateFromContent(allocator, buf[0..bytes_read]); } -/// Parse Codex review state from state file at git root -fn parseCodexReviewState(allocator: Allocator, git_root: []const u8) CodexReviewState { - // Construct path: {git_root}/.claude/codex-review.local.md - const path = std.fmt.allocPrint(allocator, "{s}/.claude/codex-review.local.md", .{git_root}) catch return CodexReviewState{}; - defer allocator.free(path); - - // Read only first 2KB - our fields (active, review_count, etc.) are at the top - const file = std.fs.cwd().openFile(path, .{}) catch return CodexReviewState{}; +/// Append a single timestamped line to /tmp/statusline-debug.log. +/// Best-effort: all failure modes are swallowed so debug logging never affects rendering. +fn appendDebugLog(comptime fmt: []const u8, args: anytype) void { + const file = std.fs.cwd().createFile("/tmp/statusline-debug.log", .{ .truncate = false }) catch return; defer file.close(); - - var buf: [2048]u8 = undefined; - const bytes_read = file.read(&buf) catch return CodexReviewState{}; - if (bytes_read == 0) return CodexReviewState{}; - - return parseCodexReviewStateFromContent(buf[0..bytes_read]); + file.seekFromEnd(0) catch return; + var file_buffer: [512]u8 = undefined; + var file_writer = file.writerStreaming(&file_buffer); + const writer = &file_writer.interface; + const timestamp = std.time.timestamp(); + writer.print("[{d}] ", .{timestamp}) catch return; + writer.print(fmt ++ "\n", args) catch return; + writer.flush() catch return; } pub fn main() !void { @@ -1019,18 +1062,25 @@ pub fn main() !void { try writer.print("{s}", .{colors.reset}); } - // Add Ralph loop and Codex review status if active (only in git repos) + // Add rl loop segment if active (only in git repos) if (is_git) { if (try getGitRoot(allocator, current_dir.?)) |git_root| { defer allocator.free(git_root); - // Ralph loop status (iterations + optional review count) const ralph_state = parseRalphState(allocator, git_root); - _ = try ralph_state.format(writer); - // Codex review status (standalone review gate) - const codex_state = parseCodexReviewState(allocator, git_root); - _ = try codex_state.format(writer); + // Debug-mode drift detector (REQ-SL-038): warn when the state file + // carries a schema version we don't know about. Render still proceeds + // with legacy defaults — never fail a render on drift. + if (debug_mode) { + if (ralph_state.version) |v| { + if (v != 3) { + appendDebugLog("rl state schema version {d} != 3 — rendering with legacy defaults", .{v}); + } + } + } + + _ = try ralph_state.format(writer); } } } @@ -1715,7 +1765,7 @@ test "RalphState format inactive returns false" { try std.testing.expectEqual(@as(usize, 0), stream.getWritten().len); } -test "RalphState format active shows iteration" { +test "RalphState format active shows iteration with default ralph glyph" { const state = RalphState{ .active = true, .iteration = 3, @@ -1729,18 +1779,23 @@ test "RalphState format active shows iteration" { const result = try state.format(writer); try std.testing.expect(result); const output = stream.getWritten(); - // Should contain the loop emoji and iteration count - try std.testing.expect(std.mem.indexOf(u8, output, "🔄") != null); + // Default strategy (.unknown) renders the ralph glyph + try std.testing.expect(std.mem.indexOf(u8, output, glyphs.ralph) != null); try std.testing.expect(std.mem.indexOf(u8, output, "3/50") != null); - // Should contain green color (3/50 = 6% < 50%) + // 3/50 = 6% → green try std.testing.expect(std.mem.indexOf(u8, output, colors.green) != null); - // Should NOT contain review emoji - try std.testing.expect(std.mem.indexOf(u8, output, "🔍") == null); + // Review sub-counter is suppressed when review_enabled = false + try std.testing.expect(std.mem.indexOf(u8, output, glyphs.counter) == null); + // No verdict glyph either + try std.testing.expect(std.mem.indexOf(u8, output, glyphs.in_flight) == null); + try std.testing.expect(std.mem.indexOf(u8, output, glyphs.approve) == null); + try std.testing.expect(std.mem.indexOf(u8, output, glyphs.reject) == null); } -test "RalphState format active with reviews" { +test "RalphState format active with reviews, no verdict" { const state = RalphState{ .active = true, + .strategy = .ralph, .iteration = 5, .max_iterations = 30, .review_enabled = true, @@ -1754,13 +1809,160 @@ test "RalphState format active with reviews" { const result = try state.format(writer); try std.testing.expect(result); const output = stream.getWritten(); - // Should contain both emojis and counts - try std.testing.expect(std.mem.indexOf(u8, output, "🔄") != null); + try std.testing.expect(std.mem.indexOf(u8, output, glyphs.ralph) != null); try std.testing.expect(std.mem.indexOf(u8, output, "5/30") != null); - try std.testing.expect(std.mem.indexOf(u8, output, "🔍") != null); + try std.testing.expect(std.mem.indexOf(u8, output, glyphs.counter) != null); try std.testing.expect(std.mem.indexOf(u8, output, "2/5") != null); - // Should contain green for iteration (5/30 = 16%) and yellow for review (2/5 = 40%) try std.testing.expect(std.mem.indexOf(u8, output, colors.green) != null); + // No verdict glyph when verdict == .none + try std.testing.expect(std.mem.indexOf(u8, output, glyphs.approve) == null); + try std.testing.expect(std.mem.indexOf(u8, output, glyphs.reject) == null); + try std.testing.expect(std.mem.indexOf(u8, output, glyphs.in_flight) == null); +} + +test "RalphState strategy glyphs" { + const cases = [_]struct { s: Strategy, expected: []const u8 }{ + .{ .s = .ralph, .expected = glyphs.ralph }, + .{ .s = .review, .expected = glyphs.review }, + .{ .s = .research, .expected = glyphs.research }, + .{ .s = .unknown, .expected = glyphs.ralph }, // legacy fallback + }; + for (cases) |tc| { + try std.testing.expectEqualStrings(tc.expected, tc.s.glyph()); + } +} + +test "Strategy.fromString maps known values" { + try std.testing.expectEqual(Strategy.ralph, Strategy.fromString("ralph")); + try std.testing.expectEqual(Strategy.review, Strategy.fromString("review")); + try std.testing.expectEqual(Strategy.research, Strategy.fromString("research")); + try std.testing.expectEqual(Strategy.unknown, Strategy.fromString("nonsense")); + try std.testing.expectEqual(Strategy.unknown, Strategy.fromString("")); +} + +test "RalphState format review strategy with approve verdict" { + const state = RalphState{ + .active = true, + .strategy = .review, + .iteration = 1, + .max_iterations = 30, + .review_enabled = true, + .review_count = 0, + .max_review_cycles = 30, + .verdict = .approve, + }; + var buf: [256]u8 = undefined; + var stream = std.io.fixedBufferStream(&buf); + const writer = stream.writer(); + _ = try state.format(writer); + const output = stream.getWritten(); + try std.testing.expect(std.mem.indexOf(u8, output, glyphs.review) != null); + try std.testing.expect(std.mem.indexOf(u8, output, glyphs.approve) != null); + try std.testing.expect(std.mem.indexOf(u8, output, glyphs.reject) == null); +} + +test "RalphState format review strategy with reject verdict" { + const state = RalphState{ + .active = true, + .strategy = .review, + .iteration = 2, + .max_iterations = 30, + .review_enabled = true, + .review_count = 1, + .max_review_cycles = 30, + .verdict = .reject, + }; + var buf: [256]u8 = undefined; + var stream = std.io.fixedBufferStream(&buf); + const writer = stream.writer(); + _ = try state.format(writer); + const output = stream.getWritten(); + try std.testing.expect(std.mem.indexOf(u8, output, glyphs.reject) != null); + try std.testing.expect(std.mem.indexOf(u8, output, glyphs.approve) == null); +} + +test "RalphState format in-flight glyph when worker running" { + const state = RalphState{ + .active = true, + .strategy = .review, + .iteration = 1, + .max_iterations = 30, + .review_enabled = true, + .review_count = 0, + .max_review_cycles = 30, + .verdict = .in_flight, + }; + var buf: [256]u8 = undefined; + var stream = std.io.fixedBufferStream(&buf); + const writer = stream.writer(); + _ = try state.format(writer); + const output = stream.getWritten(); + try std.testing.expect(std.mem.indexOf(u8, output, glyphs.in_flight) != null); + // Precedence: in-flight wins over any stored verdict (verified at parse time) + try std.testing.expect(std.mem.indexOf(u8, output, glyphs.approve) == null); +} + +test "RalphState format review strategy suppresses verdict when review_enabled is false" { + // A review-strategy loop with review_enabled explicitly false should not render + // the review sub-counter or a verdict glyph. + const state = RalphState{ + .active = true, + .strategy = .review, + .iteration = 1, + .max_iterations = 30, + .review_enabled = false, + .verdict = .approve, + }; + var buf: [256]u8 = undefined; + var stream = std.io.fixedBufferStream(&buf); + const writer = stream.writer(); + _ = try state.format(writer); + const output = stream.getWritten(); + try std.testing.expect(std.mem.indexOf(u8, output, glyphs.review) != null); + try std.testing.expect(std.mem.indexOf(u8, output, glyphs.counter) == null); + try std.testing.expect(std.mem.indexOf(u8, output, glyphs.approve) == null); +} + +test "RalphState format research strategy without metric" { + const state = RalphState{ + .active = true, + .strategy = .research, + .iteration = 5, + .max_iterations = 30, + .review_enabled = true, // should be ignored for research + .review_count = 3, + .max_review_cycles = 10, + .verdict = .approve, // should be ignored for research + }; + var buf: [256]u8 = undefined; + var stream = std.io.fixedBufferStream(&buf); + const writer = stream.writer(); + _ = try state.format(writer); + const output = stream.getWritten(); + try std.testing.expect(std.mem.indexOf(u8, output, glyphs.research) != null); + try std.testing.expect(std.mem.indexOf(u8, output, "5/30") != null); + // Research hides review counter, verdict glyphs, and metric + try std.testing.expect(std.mem.indexOf(u8, output, glyphs.counter) == null); + try std.testing.expect(std.mem.indexOf(u8, output, glyphs.approve) == null); + try std.testing.expect(std.mem.indexOf(u8, output, glyphs.metric) == null); +} + +test "RalphState format research strategy with best metric value" { + const state = RalphState{ + .active = true, + .strategy = .research, + .iteration = 5, + .max_iterations = 30, + .best_metric_value = 0.823, + }; + var buf: [256]u8 = undefined; + var stream = std.io.fixedBufferStream(&buf); + const writer = stream.writer(); + _ = try state.format(writer); + const output = stream.getWritten(); + try std.testing.expect(std.mem.indexOf(u8, output, glyphs.research) != null); + try std.testing.expect(std.mem.indexOf(u8, output, glyphs.metric) != null); + try std.testing.expect(std.mem.indexOf(u8, output, "0.823") != null); } test "parseYamlBool function" { @@ -1779,174 +1981,132 @@ test "parseYamlInt function" { try std.testing.expect(parseYamlInt("other: 50", "iteration:") == null); } -test "parseRalphStateFromContent with valid JSON" { +test "parseRalphStateFromContent with valid v3 JSON" { + const allocator = std.testing.allocator; const content = - \\{"active":true,"iteration":5,"max_iterations":30,"review_enabled":true,"review_count":2,"max_review_cycles":10} + \\{"version":3,"strategy":"review","active":true,"iteration":5,"max_iterations":30,"review_enabled":true,"review_count":2,"max_review_cycles":10} ; - const state = parseRalphStateFromContent(content); + const state = parseRalphStateFromContent(allocator, content); try std.testing.expect(state.active); try std.testing.expectEqual(@as(u32, 5), state.iteration); try std.testing.expectEqual(@as(u32, 30), state.max_iterations); try std.testing.expect(state.review_enabled); try std.testing.expectEqual(@as(u32, 2), state.review_count); try std.testing.expectEqual(@as(u32, 10), state.max_review_cycles); + try std.testing.expectEqual(Strategy.review, state.strategy); + try std.testing.expectEqual(@as(?u32, 3), state.version); + try std.testing.expectEqual(Verdict.none, state.verdict); } test "parseRalphStateFromContent with partial fields" { + const allocator = std.testing.allocator; const content = \\{"active":true,"iteration":3} ; - const state = parseRalphStateFromContent(content); + const state = parseRalphStateFromContent(allocator, content); try std.testing.expect(state.active); try std.testing.expectEqual(@as(u32, 3), state.iteration); - // Defaults should be used for missing fields + // Defaults for missing fields try std.testing.expectEqual(@as(u32, 50), state.max_iterations); try std.testing.expect(!state.review_enabled); try std.testing.expectEqual(@as(u32, 0), state.review_count); try std.testing.expectEqual(@as(u32, 10), state.max_review_cycles); + try std.testing.expectEqual(Strategy.unknown, state.strategy); + try std.testing.expectEqual(@as(?u32, null), state.version); } test "parseRalphStateFromContent with invalid JSON" { - const content = "# Just markdown, not JSON"; - - const state = parseRalphStateFromContent(content); - // Should return defaults + const allocator = std.testing.allocator; + const state = parseRalphStateFromContent(allocator, "# Just markdown, not JSON"); try std.testing.expect(!state.active); try std.testing.expectEqual(@as(u32, 0), state.iteration); } test "parseRalphStateFromContent with empty content" { - const state = parseRalphStateFromContent(""); + const allocator = std.testing.allocator; + const state = parseRalphStateFromContent(allocator, ""); try std.testing.expect(!state.active); } -test "parseRalphStateFromContent with extra fields ignored" { +test "parseRalphStateFromContent ignores extra fields" { + const allocator = std.testing.allocator; const content = \\{"active":true,"iteration":7,"unknown_field":"some_value","completion_promise":"COMPLETE","timestamp":"2025-01-01T00:00:00Z"} ; - const state = parseRalphStateFromContent(content); + const state = parseRalphStateFromContent(allocator, content); try std.testing.expect(state.active); try std.testing.expectEqual(@as(u32, 7), state.iteration); - // Should not crash on unknown fields } -test "CodexReviewState default values" { - const state = CodexReviewState{}; - try std.testing.expect(!state.active); - try std.testing.expectEqual(@as(u32, 0), state.review_count); - try std.testing.expectEqual(@as(u32, 10), state.max_review_cycles); -} - -test "CodexReviewState format inactive returns false" { - const state = CodexReviewState{ .active = false }; - var buf: [256]u8 = undefined; - var stream = std.io.fixedBufferStream(&buf); - const writer = stream.writer(); - - const result = try state.format(writer); - try std.testing.expect(!result); - try std.testing.expectEqual(@as(usize, 0), stream.getWritten().len); +test "parseRalphStateFromContent resolves approve verdict" { + const allocator = std.testing.allocator; + const content = + \\{"version":3,"strategy":"review","active":true,"iteration":1,"max_iterations":30, + \\"review_enabled":true,"review_count":0,"max_review_cycles":30, + \\"review_verdict":"approve","review_verdict_sha":"deadbeef", + \\"review_in_flight_job_id":null} + ; + const state = parseRalphStateFromContent(allocator, content); + try std.testing.expectEqual(Verdict.approve, state.verdict); } -test "CodexReviewState format active shows review count" { - const state = CodexReviewState{ - .active = true, - .review_count = 2, - .max_review_cycles = 5, - }; - var buf: [256]u8 = undefined; - var stream = std.io.fixedBufferStream(&buf); - const writer = stream.writer(); - - const result = try state.format(writer); - try std.testing.expect(result); - const output = stream.getWritten(); - // Should contain the magnifying glass emoji and review count - try std.testing.expect(std.mem.indexOf(u8, output, "🔎") != null); - try std.testing.expect(std.mem.indexOf(u8, output, "2/5") != null); - // Should contain green color (2/5 = 40% < 50%) - try std.testing.expect(std.mem.indexOf(u8, output, colors.green) != null); +test "parseRalphStateFromContent resolves reject verdict" { + const allocator = std.testing.allocator; + const content = + \\{"version":3,"strategy":"review","active":true, + \\"review_verdict":"reject","review_in_flight_job_id":null} + ; + const state = parseRalphStateFromContent(allocator, content); + try std.testing.expectEqual(Verdict.reject, state.verdict); } -test "CodexReviewState format with high review count shows yellow" { - const state = CodexReviewState{ - .active = true, - .review_count = 3, - .max_review_cycles = 5, - }; - var buf: [256]u8 = undefined; - var stream = std.io.fixedBufferStream(&buf); - const writer = stream.writer(); - - const result = try state.format(writer); - try std.testing.expect(result); - const output = stream.getWritten(); - // 3/5 = 60% should be yellow (50-80% range) - try std.testing.expect(std.mem.indexOf(u8, output, colors.yellow) != null); +test "parseRalphStateFromContent in-flight beats stored verdict" { + // Real-world race: a new review worker started while a prior verdict is still on disk. + // The worker clears review_in_flight_job_id only after writing the new verdict, + // so in-flight always wins. + const allocator = std.testing.allocator; + const content = + \\{"version":3,"strategy":"review","active":true, + \\"review_verdict":"approve","review_verdict_sha":"deadbeef", + \\"review_in_flight_job_id":"review-1776040657173-2j38kp"} + ; + const state = parseRalphStateFromContent(allocator, content); + try std.testing.expectEqual(Verdict.in_flight, state.verdict); } -test "CodexReviewState format with critical review count shows red" { - const state = CodexReviewState{ - .active = true, - .review_count = 4, - .max_review_cycles = 5, - }; - var buf: [256]u8 = undefined; - var stream = std.io.fixedBufferStream(&buf); - const writer = stream.writer(); - - const result = try state.format(writer); - try std.testing.expect(result); - const output = stream.getWritten(); - // 4/5 = 80% should be red (80-100% range) - try std.testing.expect(std.mem.indexOf(u8, output, colors.red) != null); +test "parseRalphStateFromContent treats empty in-flight job id as not in flight" { + const allocator = std.testing.allocator; + const content = + \\{"active":true,"review_verdict":"approve","review_in_flight_job_id":""} + ; + const state = parseRalphStateFromContent(allocator, content); + try std.testing.expectEqual(Verdict.approve, state.verdict); } -test "parseCodexReviewStateFromContent with valid frontmatter" { +test "parseRalphStateFromContent with research metric" { + const allocator = std.testing.allocator; const content = - \\--- - \\active: true - \\review_count: 3 - \\max_review_cycles: 10 - \\--- - \\# Some markdown content + \\{"version":3,"strategy":"research","active":true,"iteration":5,"max_iterations":30, + \\"metric_name":"accuracy","metric_direction":"maximize","best_metric_value":0.8231} ; - - const state = parseCodexReviewStateFromContent(content); - try std.testing.expect(state.active); - try std.testing.expectEqual(@as(u32, 3), state.review_count); - try std.testing.expectEqual(@as(u32, 10), state.max_review_cycles); + const state = parseRalphStateFromContent(allocator, content); + try std.testing.expectEqual(Strategy.research, state.strategy); + try std.testing.expect(state.best_metric_value != null); + try std.testing.expectApproxEqAbs(@as(f64, 0.8231), state.best_metric_value.?, 0.0001); } -test "parseCodexReviewStateFromContent with partial fields" { +test "parseRalphStateFromContent captures stale schema version" { + const allocator = std.testing.allocator; const content = - \\--- - \\active: true - \\review_count: 2 - \\--- + \\{"version":2,"active":true,"iteration":1} ; - - const state = parseCodexReviewStateFromContent(content); + const state = parseRalphStateFromContent(allocator, content); + // Parse still succeeds — drift detection happens in the debug-log branch, not the render path. try std.testing.expect(state.active); - try std.testing.expectEqual(@as(u32, 2), state.review_count); - // Default should be used for missing max_review_cycles - try std.testing.expectEqual(@as(u32, 10), state.max_review_cycles); -} - -test "parseCodexReviewStateFromContent with no frontmatter" { - const content = "# Just markdown, no frontmatter"; - - const state = parseCodexReviewStateFromContent(content); - try std.testing.expect(!state.active); - try std.testing.expectEqual(@as(u32, 0), state.review_count); -} - -test "parseCodexReviewStateFromContent with empty content" { - const state = parseCodexReviewStateFromContent(""); - try std.testing.expect(!state.active); + try std.testing.expectEqual(@as(?u32, 2), state.version); } test "formatIdleSince returns false without session_id" { From 4414f9115a91b465bf6280755b1a1937d8dc2894 Mon Sep 17 00:00:00 2001 From: 0xBigBoss <95193764+0xBigBoss@users.noreply.github.com> Date: Mon, 13 Apr 2026 04:24:12 -0500 Subject: [PATCH 31/57] feat(statusline): strategy-aware rl 1.1 renderer with orphan-safe verdict gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first-cut rl 1.0 alignment (commit 3ecca04) projected state.json fields directly onto the status line. Testing against live rl 1.1 loops exposed the gaps: verdicts rendered even when stale against HEAD, in-flight markers rendered even when the worker was orphaned, iteration counters rendered even for review strategies (which never advance iteration), and terminal-state flags were ignored entirely so blocked loops looked active. This commit rewrites the rl segment to mirror the decision logic of rl's own Stop hook (~/0xbigboss/rl/src/strategies/{ralph,review,research}.ts). Every rendering rule is now traceable to a specific branch of rl's state machine, locked in by per-strategy fixture tests. REQ-SL-060..069 in SPEC.md are the durable record; the highlights: - Strategy-dispatched layout (REQ-SL-061): * ralph → 🔁 iter/max 🔍 rev/cap verdict? age? (iter is meaningful here) * review → 🧪 🔍 rev/cap verdict? age? (iter is dead — hidden) * research → 🔬 iter/max ★arrow value? age? (no reviews) - Terminal-state prefix (REQ-SL-062): 🚧 blocked_claimed / 🏁 completion_claimed, precedence blocked > completion. Surfaces the "active: true + blocked_claimed: true" anomaly we hit in ~/0xbigboss/rl yesterday at a glance. - Orphan-aware verdict resolution (REQ-SL-063): readJobStatus opens .rl/jobs/{id}.json and checks the status field; only queued|running counts as in-flight. Orphan markers (missing/terminal job file) fall through to the staleness check, which itself suppresses the verdict glyph when review_verdict_sha != git HEAD. Collapses "no verdict", "stale verdict", and "orphaned in-flight" into the same "no signal you can trust" visual — which is the semantically useful bucket. - Research metric with direction arrow (REQ-SL-064): ★↑0.823 (maximize) / ★↓0.045 (minimize) / ★1.500 (direction unknown). - Loop age from iteration_start_ms (REQ-SL-065): +Ns / +Nm / +NhMm / +Nd with color grade (green <1h, yellow <4h, red ≥4h). Note: emitIterationEnd is dead code in rl 1.1, so this reads "loop age since init" not "current iteration age" — still useful as a "has this loop been open too long" signal. Per-strategy fixture tests (REQ-SL-068) assert the exact render for each stateUpdates branch in the rl strategy files. When rl adds a new branch or changes a branch's field shape, the tests will fail against the old assertions and force a statusline update — the coupling becomes an explicit contract rather than a silent assumption. Implementation notes: - RalphState is now fully by-value. String fields (verdict_sha, in_flight_job_id) live in fixed-size stack buffers on the struct, so the state is safe to use after its parse-time allocator is torn down. copyIntoFixedBuf handles overflow by treating the field as absent. - New helpers: readJobStatus, parseJobStatusFromContent, getGitHead, formatLoopAge, copyIntoFixedBuf. - Removed: first-cut REQ-SL-037 (staleness-ignored), REQ-SL-033 unconditional iteration counter, REQ-SL-035 parse-time verdict precedence. - Verified: 78 tests green, release build clean, manual smoke against 21 synthetic fixtures covering every new code path. --- statusline/SPEC.md | 149 +++++- statusline/src/main.zig | 1043 ++++++++++++++++++++++++++++++--------- 2 files changed, 943 insertions(+), 249 deletions(-) diff --git a/statusline/SPEC.md b/statusline/SPEC.md index a1db6da..6c52286 100644 --- a/statusline/SPEC.md +++ b/statusline/SPEC.md @@ -126,7 +126,9 @@ The statusline reads a subset: everything needed to render one segment, nothing - **REQ-SL-023** (pre-1.0): When `review_enabled`, additionally emit ` 🔍 {color}{review_count}/{max_review_cycles}{reset}` using the same color rule. - **REQ-SL-024** (deprecated, removed in rl 1.0 alignment): Read `{git_root}/.claude/codex-review.local.md` YAML frontmatter for a standalone `🔎` Codex review segment. -### rl loop segment (rl 1.0 — NEW) +### rl loop segment (rl 1.0 — initial cut, superseded by REQ-SL-060s) + +These requirements shipped with the first rl 1.0 alignment commit and are retained for traceability. REQ-SL-033, REQ-SL-035, REQ-SL-036, and REQ-SL-037 are superseded by the strategy-aware design in the next section. REQ-SL-030…032, 034, 038, 039 carry forward unchanged. - **REQ-SL-030**: The statusline reads `.rl/state.json` v3 and recognizes the additional fields `strategy`, `review_verdict`, `review_verdict_sha`, `review_verdict_job_id`, `review_in_flight_job_id`, `metric_name`, `metric_direction`, `best_metric_value`. Missing fields default to null/0 and do not break rendering (REQ-SL-001 / I-6). - **REQ-SL-031**: When `state.active == false`, the rl segment emits nothing regardless of other fields. @@ -135,21 +137,109 @@ The statusline reads a subset: everything needed to render one segment, nothing - `review` → `🧪` - `research` → `🔬` - missing/unknown → `🔁` (legacy fallback, matches pre-1.0 default) -- **REQ-SL-033** (iteration counter): For `ralph` and `review` strategies, render ` {glyph} {color}{iteration}/{max_iterations}{reset}` using `progressColor`. -- **REQ-SL-034** (review counter): For `ralph` and `review` strategies, when `review_enabled == true`, additionally render ` 🔍 {color}{review_count}/{max_review_cycles}{reset}`. When `review_enabled == false` the review sub-segment is omitted. -- **REQ-SL-035** (state glyph): For `ralph` and `review` strategies with `review_enabled == true`, a terminal state glyph is appended after the review counter, chosen by precedence: - 1. `review_in_flight_job_id != null` → ` ⏳` (in-flight beats verdict; a running worker invalidates any prior verdict by construction) - 2. `review_verdict == "approve"` → ` ✅` - 3. `review_verdict == "reject"` → ` ❌` - 4. otherwise → nothing -- **REQ-SL-036** (research rendering): For `research` strategy: - - Render ` 🔬 {color}{iteration}/{max_iterations}{reset}` (same color rule). - - Review sub-segment and state glyph are hidden (research loops do not gate on reviews). - - When `best_metric_value != null`, additionally render ` ★{value}` using 3 significant digits. Metric name is omitted to preserve space — the operator's task prompt supplies context. -- **REQ-SL-037** (staleness): Verdict staleness (`review_verdict_sha != HEAD`) is NOT surfaced on the statusline. The rl Stop hook owns that decision; the statusline's goal is a glanceable snapshot of stored state, not a correctness oracle. +- **REQ-SL-033** (SUPERSEDED by REQ-SL-061): Unconditional iteration counter for ralph + review. +- **REQ-SL-034** (review counter): For `ralph` and `review` strategies, when `review_enabled == true`, render ` 🔍 {color}{review_count}/{max_review_cycles}{reset}` using `progressColor`. When `review_enabled == false` the review sub-segment is omitted. +- **REQ-SL-035** (SUPERSEDED by REQ-SL-063): Verdict state glyph precedence without orphan detection or staleness. +- **REQ-SL-036** (SUPERSEDED by REQ-SL-064): Research rendering without metric direction arrow. +- **REQ-SL-037** (SUPERSEDED by REQ-SL-063): Staleness hidden from statusline. Reversed because stale verdicts silently lied about the state of HEAD (observed in `~/0xbigboss/rl` and `…/famo-classifier-alignment` on 2026-04-13). - **REQ-SL-038** (schema version): The statusline parses `state.version` and, when `--debug` is set and `version` is present but `!= 3`, appends a single diagnostic line to `/tmp/statusline-debug.log`. No visual indication is emitted (I-7 preserves quiet degradation). - **REQ-SL-039** (allocator hygiene): `parseRalphStateFromContent` accepts an `Allocator` parameter and uses it for all `std.json.parseFromSlice` allocations. No calls to `std.heap.page_allocator` inside parsing code. +### rl loop segment (rl 1.1 — strategy-aware, orphan-aware) + +Authored 2026-04-13 after reading the rl 1.1.0 strategy decision functions in `~/0xbigboss/rl/src/strategies/{ralph,review,research}.ts`. Grounded in the observation that `iteration` / `review_count` / `iteration_start_*` have different semantics per strategy, and that the statusline must mirror rl's own decision logic to stay meaningful. Field schema verified unchanged in 1.1 (`schemas.ts:74-110`) — 1.1 only adds the impl-worker track which does not appear in `state.json`. + +#### Counter semantics — truth table derived from rl source + +| Strategy | `iteration` mutated by hook? | `review_count` mutated? | `iteration_start_*` mutated? | +|---|---|---|---| +| `ralph` | yes: every Stop (`ralph.ts:139`) + every confirmed reject (`ralph.ts:245`) | yes: confirmed reject only (`ralph.ts:256`) | no — `emitIterationEnd` is declared (`shared.ts:803`) but never called anywhere in the source tree | +| `review` | **no** — no branch of `review.ts:decide` touches `iteration` | yes: confirmed reject only (`review.ts:150`) | no — same | +| `research` | yes: every Stop (`research.ts:114`, `research.ts:130`) | n/a | no — same | + +Consequence: `iteration_start_ms` encodes "rl init time", not "current iteration start". The statusline treats it as **loop age**. + +#### Requirements + +- **REQ-SL-060** (fields parsed): The statusline additionally parses `completion_claimed`, `blocked_claimed`, `metric_direction`, `iteration_start_ms`. All are optional; parse failures return defaults and never break rendering. + +- **REQ-SL-061** (strategy-dispatched layout): The rl segment dispatches on `strategy`: + + | Strategy | Layout | + |---|---| + | `ralph` / unknown | `[prefix]? 🔁 {iter_counter} {review_counter}? {verdict_state}? {age}?` | + | `review` | `[prefix]? 🧪 🔍 {review_counter} {verdict_state}? {age}?` (no iteration counter — `iteration` is never touched by the review hook, so displaying it is permanently misleading) | + | `research` | `[prefix]? 🔬 {iter_counter} {metric}? {age}?` (no review counter or verdict glyph — research loops do not gate on reviews) | + + Where: + - `iter_counter` = ` {progressColor}{iteration}/{max_iterations}{reset}` + - `review_counter` = ` {progressColor}{review_count}/{max_review_cycles}{reset}` (ralph: preceded by ` 🔍 ` glyph; review: glyph already leads the layout) + - `progressColor` is unchanged: green <50%, yellow <80%, red ≥80%. + +- **REQ-SL-062** (terminal-state prefix): When the loop is in a waiting/winding-down state, a terminal-prefix glyph is emitted before the strategy glyph. Precedence: `blocked_claimed > completion_claimed`. + + | Condition | Prefix | Meaning | + |---|---|---| + | `blocked_claimed == true` | ` 🚧` | Loop marked blocked; next Stop will cleanup. This catches the "`active: true` + `blocked_claimed: true`" anomaly observed in `~/0xbigboss/rl/.rl/state.json` on 2026-04-13. | + | `completion_claimed == true` | ` 🏁` | Agent has claimed completion. For ralph: waiting for verdict. For research: waiting for user's `rl done --keep/--discard`. | + | neither | *no prefix* | Normal running state. | + +- **REQ-SL-063** (verdict state glyph — orphan-aware + staleness-aware): For `ralph` and `review` strategies with `review_enabled == true`, a single trailing verdict glyph is emitted AFTER the review counter, resolved at parse time by the following decision procedure (mirrors `ralph.ts:185-200` / `review.ts:86-98`): + + ``` + resolveVerdictState(state, git_head): + if state.review_in_flight_job_id is non-null: + job_status = readJobStatus(git_root, review_in_flight_job_id) + if job_status in {"queued", "running"}: + return ⏳ # worker actually running + # else: orphan marker — fall through as if null + if state.review_verdict == "approve" + and state.review_verdict_sha != null + and state.review_verdict_sha == git_head: + return ✅ + if state.review_verdict == "reject" + and state.review_verdict_sha != null + and state.review_verdict_sha == git_head: + return ❌ + return blank + ``` + + This collapses "no verdict", "stale verdict", and "orphaned in-flight marker" into the same blank-glyph bucket — all three mean "no verdict you can trust for your current HEAD". The orphan branch prevents the false-positive ⏳ we hit on 2026-04-13 when the stop hook wrote an in-flight id but the worker never spawned. + + Reading the job file costs one bounded file open + small JSON parse; `git rev-parse HEAD` costs one subprocess call already amortized against the existing git calls in the path/branch segment. + +- **REQ-SL-064** (research metric with direction arrow): For `research` strategy, when `best_metric_value != null`, render ` ★{arrow}{value}` where `arrow` is `↑` if `metric_direction == "maximize"`, `↓` if `metric_direction == "minimize"`, empty string otherwise. `value` uses 3 decimal places (`{d:.3}`). Metric name is omitted — the operator's task prompt already establishes what metric is being optimized. + +- **REQ-SL-065** (loop age): When `iteration_start_ms` is present and the loop is active, render ` +{age}` after the metric/verdict segment, where `age` is the wall-clock delta from `iteration_start_ms` to now, formatted compactly: + + - `<60s` → `{N}s` + - `<60m` → `{N}m` + - `<24h` → `{N}h` or `{N}h{M}m` when `M > 0` + - `≥24h` → `{N}d` (rounded down) + + Color graded by age: green `<1h`, yellow `1h–4h`, red `≥4h`. Rationale: `iteration_start_ms` is only written at `rl init` in 1.1 (the per-iteration advance path is dead code), so this signal represents "loop age since init" — a "this loop has been open a long time" indicator for spotting forgotten or stuck loops. + +- **REQ-SL-066** (job-file reader): `readJobStatus(allocator, git_root, job_id)` reads `{git_root}/.rl/jobs/{job_id}.json`, caps the read at 4 KiB, parses `status` from the top-level object. Returns one of `queued | running | completed | failed | cancelled | missing`. File not found, parse failure, or an unexpected status string all map to `missing` — the caller treats `missing` identically to a terminal status (orphan marker). + +- **REQ-SL-067** (git HEAD reader): `getGitHead(allocator, dir)` runs `git rev-parse HEAD` in `dir` and returns the trimmed sha. Any failure returns an empty string; callers treat empty as "HEAD unknown" and the staleness check fails open (verdict glyph is NOT suppressed on an unknowable HEAD — we'd rather show a potentially stale ✅/❌ than hide an actionable signal due to a git glitch). + +- **REQ-SL-068** (strategy coupling as contract): Because the statusline mirrors the rl hook's decision logic, test fixtures must cover the exact state shapes produced by each `stateUpdates` block in the rl 1.1 strategy files: + + | rl source | State shape | Expected render | + |---|---|---| + | `ralph.ts:152-160` (iterate) | iteration++, completion_claimed=false | ` 🔁 N/max` | + | `ralph.ts:218-223` (approve) | verdict=approve, sha=HEAD | ` 🏁 🔁 N/max 🔍 K/cap ✅ +age` (if completion_claimed was set in the transition) | + | `ralph.ts:252-265` (reject) | iteration++, review_count++, verdict cleared | ` 🔁 (N+1)/max 🔍 (K+1)/cap` (no verdict glyph — worker cleared it) | + | `ralph.ts:187-194` (in-flight) | review_in_flight_job_id set, job running | ` 🔁 N/max 🔍 K/cap ⏳` | + | `review.ts:162-173` (enqueue) | review_in_flight_job_id set, job running | ` 🧪 🔍 K/cap ⏳` | + | `review.ts:144-158` (reject-iterate) | review_count++, verdict cleared | ` 🧪 🔍 (K+1)/cap` (no verdict glyph) | + | `research.ts:125-135` (iterate) | iteration++ | ` 🔬 N/max (★±value)? +age` | + | `research.ts:79-87` (blocked) | blocked_claimed=true | ` 🚧 🔬 N/max …` | + + When rl adds a new branch or changes an existing `stateUpdates` block, the corresponding fixture must be updated. That turns the implicit coupling into an explicit contract. + +- **REQ-SL-069** (glyph namespace additions): `glyphs` gains `completion` (`🏁`), `blocked` (`🚧`), `arrow_up` (`↑`), `arrow_down` (`↓`). Existing glyphs unchanged. + ### Other segments (captured for traceability) - **REQ-SL-050**: `ZMX_SESSION` env var, when non-empty, renders as ` zmx:{value}` in gray. @@ -160,18 +250,31 @@ The statusline reads a subset: everything needed to render one segment, nothing ## Acceptance criteria -rl 1.0 alignment (this change set): +rl 1.0 alignment (first cut — 2026-04-12): - [x] `SPEC.md` exists colocated at `claude-code/statusline/SPEC.md`. -- [ ] `CodexReviewState` struct, `parseCodexReviewState*` functions, and all `CodexReviewState` tests are removed. -- [ ] `RalphState` gains optional `strategy`, `review_verdict`, `review_verdict_sha`, `review_in_flight_job_id`, `best_metric_value` fields, plus any others required for rendering above. -- [ ] `parseRalphStateFromContent(allocator, content)` signature threads the allocator; no `std.heap.page_allocator` in parse path. -- [ ] `glyphs` namespace declared alongside `colors`; strategy, state, and metric glyph literals live there. -- [ ] Strategy-aware `RalphState.format` renders per REQ-SL-032…036. -- [ ] Tests cover: strategy glyph selection; verdict state glyph precedence including in-flight-wins-over-verdict; research metric rendering; research segment hides review counter; schema version debug-log branch; allocator-threading test uses `std.testing.allocator`. -- [ ] `zig build test` passes. -- [ ] `zig build` (default) produces a working binary that renders all three strategies correctly against hand-crafted `.rl/state.json` fixtures. -- [ ] All existing tests remain green (no behavior change to path/git/model/gauge/cost segments). +- [x] `CodexReviewState` struct, parse functions, and tests removed. +- [x] `RalphState` gained `strategy`, `review_verdict`, `review_in_flight_job_id`, `best_metric_value`, `version`. +- [x] `parseRalphStateFromContent` threads the allocator. +- [x] `glyphs` namespace. +- [x] Strategy-aware `format` (REQ-SL-032, REQ-SL-034). +- [x] 50/50 tests passing. + +rl 1.1 strategy-aware renderer (this change set — 2026-04-13): + +- [ ] `RalphState` gains `completion_claimed`, `blocked_claimed`, `metric_direction`, `iteration_start_ms`, `review_verdict_sha` fields. +- [ ] `glyphs` namespace gains `completion`, `blocked`, `arrow_up`, `arrow_down`. +- [ ] `readJobStatus(allocator, git_root, job_id)` reads `.rl/jobs/{id}.json` and returns the job status string (REQ-SL-066). +- [ ] `getGitHead(allocator, dir)` runs `git rev-parse HEAD` once per render (REQ-SL-067). +- [ ] `RalphState.format` dispatches on strategy per REQ-SL-061; ralph/review/research layouts differ as specified. +- [ ] Terminal-state prefix emitted per REQ-SL-062 (`🚧` blocked, `🏁` completion). +- [ ] Verdict state resolution mirrors rl hook: orphan-aware in-flight + HEAD-sha staleness check (REQ-SL-063). +- [ ] Research metric renders with direction arrow per REQ-SL-064. +- [ ] Loop age renders from `iteration_start_ms` with color grading per REQ-SL-065. +- [ ] Per-strategy fixture tests cover every `stateUpdates` branch listed in REQ-SL-068. +- [ ] `zig build test` passes with at least 60 tests total. +- [ ] Live smoke passes against current `~/0xbigboss/rl` loop and `…/famo-classifier-alignment` loop — rendered segment matches what would be expected given each loop's live `state.json` + HEAD. +- [ ] All existing non-rl tests remain green (no regression to path/git/model/gauge/cost/idle segments). ## Risk tags diff --git a/statusline/src/main.zig b/statusline/src/main.zig index 7978a6b..00b61b7 100644 --- a/statusline/src/main.zig +++ b/statusline/src/main.zig @@ -34,8 +34,13 @@ const glyphs = struct { const in_flight = "⏳"; const approve = "✅"; const reject = "❌"; - // Research metric (★{best_metric_value}) + // Research metric (★{best_metric_value}) with optional direction arrow const metric = "★"; + const arrow_up = "↑"; + const arrow_down = "↓"; + // Terminal-state prefixes — loop is winding down, not actively iterating + const completion = "🏁"; // completion_claimed: agent claims done, awaiting verdict/user + const blocked = "🚧"; // blocked_claimed: loop marked blocked, next Stop cleans up }; /// Current context usage token counts - added in v2.0.70 @@ -234,7 +239,8 @@ fn progressColor(current: u32, max: u32) []const u8 { } } -/// rl loop strategy — matches `strategy` field in .rl/state.json v3 +/// rl loop strategy — matches `strategy` field in .rl/state.json v3. +/// Each variant has distinct counter semantics that drive dispatch in `RalphState.format`. const Strategy = enum { ralph, review, @@ -250,7 +256,7 @@ const Strategy = enum { fn glyph(self: Strategy) []const u8 { return switch (self) { - // Legacy fallback: older state files without strategy were ralph-style loops + // Legacy fallback: state files without strategy are treated as ralph-style .ralph, .unknown => glyphs.ralph, .review => glyphs.review, .research => glyphs.research, @@ -258,19 +264,60 @@ const Strategy = enum { } }; -/// Derived verdict state for the rl segment tail glyph. -/// Precedence (resolved at parse time): in_flight > approve > reject > none. -/// Rationale: a running review worker supersedes any stored verdict from a prior round -/// by construction — the worker clears `review_in_flight_job_id` only after writing the -/// new verdict (see ~/0xbigboss/rl/SPEC.md REQ-RL-093). -const Verdict = enum { +/// Direction for a research-strategy metric (minimize/maximize). +/// Parsed from the `metric_direction` field; drives the ★-arrow glyph. +const MetricDirection = enum { + none, + maximize, + minimize, + + fn fromString(s: []const u8) MetricDirection { + if (std.mem.eql(u8, s, "maximize")) return .maximize; + if (std.mem.eql(u8, s, "minimize")) return .minimize; + return .none; + } +}; + +/// Derived verdict state for the rl segment's trailing glyph. +/// Unlike the first-cut design, this is NOT resolved at parse time — it requires +/// both the current git HEAD and optionally a job-file status check, so it's +/// resolved in `format` where those inputs are in scope. +const VerdictState = enum { none, in_flight, approve, reject, }; -/// rl loop state — read from .rl/state.json (v3 schema, rl 1.0+) +/// Status of a background job as read from `.rl/jobs/{id}.json`. +/// `missing` covers "file not found", "parse failed", and "unknown status string". +const JobStatus = enum { + missing, + queued, + running, + completed, + failed, + cancelled, + + fn fromString(s: []const u8) JobStatus { + if (std.mem.eql(u8, s, "queued")) return .queued; + if (std.mem.eql(u8, s, "running")) return .running; + if (std.mem.eql(u8, s, "completed")) return .completed; + if (std.mem.eql(u8, s, "failed")) return .failed; + if (std.mem.eql(u8, s, "cancelled")) return .cancelled; + return .missing; + } +}; + +/// rl loop state — projected from .rl/state.json v3 (rl 1.0+). Optional fields are +/// null when the state file omits them; defaults match rl's init values where known. +/// See ~/0xbigboss/rl/src/schemas.ts LoopStateV3Schema for the authoritative shape. +/// +/// String-valued fields (`review_verdict_sha`, `review_in_flight_job_id`) are stored +/// as fixed-size stack buffers so a RalphState is entirely by-value and carries no +/// allocator lifetime. Max length 64 bytes comfortably fits a git sha (40 hex) and +/// an rl job id ("review-{ms}-{6char}" ~= 28 bytes). Overflowing strings are treated +/// as if the field were absent. const RalphState = struct { active: bool = false, strategy: Strategy = .unknown, @@ -279,35 +326,124 @@ const RalphState = struct { review_enabled: bool = false, review_count: u32 = 0, max_review_cycles: u32 = 10, - verdict: Verdict = .none, + // Verdict contract (rl 1.0) + review_verdict_raw: VerdictRaw = .none, + review_verdict_sha_buf: [64]u8 = undefined, + review_verdict_sha_len: u8 = 0, + review_in_flight_job_id_buf: [64]u8 = undefined, + review_in_flight_job_id_len: u8 = 0, + // Research fields best_metric_value: ?f64 = null, + metric_direction: MetricDirection = .none, + // Terminal-state flags + completion_claimed: bool = false, + blocked_claimed: bool = false, + // Iteration audit (used for loop-age rendering) + iteration_start_ms: ?i64 = null, /// Schema version from the `version` field, if present. Consumers use this for /// debug-mode drift detection only; rendering never branches on it. version: ?u32 = null, - /// Format rl loop status for statusline display. - /// Returns true if something was written. - fn format(self: RalphState, writer: anytype) !bool { + fn verdictSha(self: *const RalphState) ?[]const u8 { + if (self.review_verdict_sha_len == 0) return null; + return self.review_verdict_sha_buf[0..self.review_verdict_sha_len]; + } + + fn inFlightJobId(self: *const RalphState) ?[]const u8 { + if (self.review_in_flight_job_id_len == 0) return null; + return self.review_in_flight_job_id_buf[0..self.review_in_flight_job_id_len]; + } + + /// Format rl loop segment for statusline display (strategy-dispatched). + /// - `git_head`: output of `git rev-parse HEAD`, or empty for "HEAD unknown" + /// - `git_root`: workspace root; used to read job files for orphan detection + /// - `allocator`: scratch allocator for path construction and job-file reads + /// - `now_ms`: current wall-clock time in milliseconds (monotonic-ish; `std.time.milliTimestamp()`) + /// Returns true when any output was written. + fn format( + self: RalphState, + writer: anytype, + allocator: Allocator, + git_root: []const u8, + git_head: []const u8, + now_ms: i64, + ) !bool { if (!self.active) return false; - // Strategy glyph + iteration counter - try writer.print(" {s} {s}{d}/{d}{s}", .{ - self.strategy.glyph(), + // Terminal-state prefix (REQ-SL-062): blocked wins over completion + if (self.blocked_claimed) { + try writer.print(" {s}", .{glyphs.blocked}); + } else if (self.completion_claimed) { + try writer.print(" {s}", .{glyphs.completion}); + } + + // Strategy glyph always leads the counter block + try writer.print(" {s}", .{self.strategy.glyph()}); + + switch (self.strategy) { + .ralph, .unknown => try self.formatRalphCounters(writer), + .review => try self.formatReviewCounters(writer), + .research => try self.formatResearchCounters(writer), + } + + // Verdict / in-flight glyph (ralph + review only, and only when review_enabled) + if ((self.strategy == .ralph or self.strategy == .unknown or self.strategy == .review) and self.review_enabled) { + const verdict_state = self.resolveVerdictState(allocator, git_root, git_head); + switch (verdict_state) { + .none => {}, + .in_flight => try writer.print(" {s}", .{glyphs.in_flight}), + .approve => try writer.print(" {s}", .{glyphs.approve}), + .reject => try writer.print(" {s}", .{glyphs.reject}), + } + } + + // Research metric with optional direction arrow (REQ-SL-064) + if (self.strategy == .research) { + if (self.best_metric_value) |v| { + const arrow: []const u8 = switch (self.metric_direction) { + .maximize => glyphs.arrow_up, + .minimize => glyphs.arrow_down, + .none => "", + }; + try writer.print(" {s}{s}{d:.3}", .{ glyphs.metric, arrow, v }); + } + } + + // Loop age from iteration_start_ms (REQ-SL-065) + if (self.iteration_start_ms) |start_ms| { + if (now_ms > start_ms) { + try formatLoopAge(writer, now_ms - start_ms); + } + } + + return true; + } + + /// Ralph layout: iteration/max_iterations as the primary counter, optional review counter. + /// Rationale: `iteration` is advanced by `ralph.ts:139` on every Stop and `ralph.ts:245` on every + /// reject, so it's the meaningful "how far through my loop am I" signal the original author intended. + fn formatRalphCounters(self: RalphState, writer: anytype) !void { + try writer.print(" {s}{d}/{d}{s}", .{ progressColor(self.iteration, self.max_iterations), self.iteration, self.max_iterations, colors.reset, }); - - // Research strategy: no review gating; optionally show best metric value - if (self.strategy == .research) { - if (self.best_metric_value) |v| { - try writer.print(" {s}{d:.3}", .{ glyphs.metric, v }); - } - return true; + if (self.review_enabled) { + try writer.print(" {s} {s}{d}/{d}{s}", .{ + glyphs.counter, + progressColor(self.review_count, self.max_review_cycles), + self.review_count, + self.max_review_cycles, + colors.reset, + }); } + } - // Ralph / review strategies: review sub-counter + verdict tail glyph + /// Review layout: review_count/max_review_cycles ONLY. The review strategy's Stop hook + /// (~/0xbigboss/rl/src/strategies/review.ts) never touches `iteration`, so rendering it + /// would always read 0 (or whatever init set) — permanent dead signal. Hide it. + fn formatReviewCounters(self: RalphState, writer: anytype) !void { if (self.review_enabled) { try writer.print(" {s} {s}{d}/{d}{s}", .{ glyphs.counter, @@ -316,19 +452,60 @@ const RalphState = struct { self.max_review_cycles, colors.reset, }); + } + } - switch (self.verdict) { - .none => {}, - .in_flight => try writer.print(" {s}", .{glyphs.in_flight}), - .approve => try writer.print(" {s}", .{glyphs.approve}), - .reject => try writer.print(" {s}", .{glyphs.reject}), - } + /// Research layout: iteration/max_iterations counts experiments. No review counter + /// (research loops don't gate on review). Metric and arrow render after counters in `format`. + fn formatResearchCounters(self: RalphState, writer: anytype) !void { + try writer.print(" {s}{d}/{d}{s}", .{ + progressColor(self.iteration, self.max_iterations), + self.iteration, + self.max_iterations, + colors.reset, + }); + } + + /// Resolve the trailing verdict glyph per REQ-SL-063. Mirrors the rl Stop hook's + /// decision procedure so what the statusline shows matches what the hook will do next. + /// + /// Priority: + /// 1. in_flight_job_id set AND job status is queued/running → .in_flight + /// (orphan markers where the job file is missing or terminal fall through) + /// 2. verdict == approve AND verdict_sha matches HEAD → .approve + /// 3. verdict == reject AND verdict_sha matches HEAD → .reject + /// 4. otherwise → .none (stale, orphan, null, or HEAD-unknown all collapse here) + fn resolveVerdictState( + self: *const RalphState, + allocator: Allocator, + git_root: []const u8, + git_head: []const u8, + ) VerdictState { + if (self.inFlightJobId()) |job_id| { + const status = readJobStatus(allocator, git_root, job_id); + if (status == .queued or status == .running) return .in_flight; + // Orphan marker (missing/completed/failed/cancelled) — fall through } - return true; + // Staleness check: we only honor verdicts whose sha matches current HEAD. + // An empty git_head means `git rev-parse HEAD` failed — fail OPEN and + // render the stored verdict anyway, since hiding an actionable signal + // because of a git glitch is worse than showing a potentially stale one. + const verdict_sha = self.verdictSha() orelse return .none; + if (git_head.len > 0 and !std.mem.eql(u8, verdict_sha, git_head)) return .none; + + return switch (self.review_verdict_raw) { + .none => .none, + .approve => .approve, + .reject => .reject, + }; } }; +/// Raw verdict string parsed from state.json. Kept separate from VerdictState because +/// the displayable state requires cross-checking sha + in-flight job status. +const VerdictRaw = enum { none, approve, reject }; + /// Git file status representation const GitStatus = struct { added: u32 = 0, @@ -848,14 +1025,24 @@ fn parseYamlInt(line: []const u8, key: []const u8) ?u32 { return std.fmt.parseInt(u32, value, 10) catch null; } +/// Copy a string slice into a fixed-size stack buffer, returning the bytes written. +/// If the source exceeds the buffer, returns 0 (treated as "absent" by the accessors). +fn copyIntoFixedBuf(dest: []u8, src: []const u8) u8 { + if (src.len == 0 or src.len > dest.len) return 0; + @memcpy(dest[0..src.len], src); + return @intCast(src.len); +} + /// Parse rl loop state from JSON content string. /// Exposed for testing. Returns default (inactive) RalphState if parsing fails. -/// The caller-supplied `allocator` is used for all JSON allocations. +/// The returned state is entirely by-value — all string fields are copied into +/// stack buffers on the struct, so the result is safe to use after the allocator +/// is freed. fn parseRalphStateFromContent(allocator: Allocator, content: []const u8) RalphState { var state = RalphState{}; - // Mirror of .rl/state.json v3 — all fields optional so unknown/absent keys - // degrade gracefully. See ~/0xbigboss/rl/SPEC.md:387 for the authoritative schema. + // Mirror of .rl/state.json v3. See ~/0xbigboss/rl/src/schemas.ts LoopStateV3Schema. + // All fields optional so unknown/absent keys degrade gracefully (I-6). const JsonState = struct { version: ?u32 = null, strategy: ?[]const u8 = null, @@ -866,8 +1053,13 @@ fn parseRalphStateFromContent(allocator: Allocator, content: []const u8) RalphSt review_count: ?u32 = null, max_review_cycles: ?u32 = null, review_verdict: ?[]const u8 = null, + review_verdict_sha: ?[]const u8 = null, review_in_flight_job_id: ?[]const u8 = null, best_metric_value: ?f64 = null, + metric_direction: ?[]const u8 = null, + completion_claimed: ?bool = null, + blocked_claimed: ?bool = null, + iteration_start_ms: ?i64 = null, }; const parsed = std.json.parseFromSlice(JsonState, allocator, content, .{ @@ -884,21 +1076,23 @@ fn parseRalphStateFromContent(allocator: Allocator, content: []const u8) RalphSt if (v.review_count) |r| state.review_count = r; if (v.max_review_cycles) |m| state.max_review_cycles = m; if (v.best_metric_value) |b| state.best_metric_value = b; + if (v.completion_claimed) |c| state.completion_claimed = c; + if (v.blocked_claimed) |b| state.blocked_claimed = b; + if (v.iteration_start_ms) |ms| state.iteration_start_ms = ms; if (v.strategy) |s| state.strategy = Strategy.fromString(s); + if (v.metric_direction) |d| state.metric_direction = MetricDirection.fromString(d); - // Verdict precedence: a running worker always supersedes a stored verdict - // (the worker clears review_in_flight_job_id only after writing the new verdict, - // so a non-null job id means the prior verdict is stale-by-construction). + if (v.review_verdict_sha) |sha| { + state.review_verdict_sha_len = copyIntoFixedBuf(&state.review_verdict_sha_buf, sha); + } if (v.review_in_flight_job_id) |id| { - if (id.len > 0) state.verdict = .in_flight; + state.review_in_flight_job_id_len = copyIntoFixedBuf(&state.review_in_flight_job_id_buf, id); } - if (state.verdict == .none) { - if (v.review_verdict) |verdict| { - if (std.mem.eql(u8, verdict, "approve")) { - state.verdict = .approve; - } else if (std.mem.eql(u8, verdict, "reject")) { - state.verdict = .reject; - } + if (v.review_verdict) |verdict| { + if (std.mem.eql(u8, verdict, "approve")) { + state.review_verdict_raw = .approve; + } else if (std.mem.eql(u8, verdict, "reject")) { + state.review_verdict_raw = .reject; } } @@ -907,11 +1101,12 @@ fn parseRalphStateFromContent(allocator: Allocator, content: []const u8) RalphSt /// Parse rl loop state from .rl/state.json at git root. /// Returns default (inactive) state on any I/O or parse failure. +/// The returned RalphState is by-value and safe to use independently of the allocator. fn parseRalphState(allocator: Allocator, git_root: []const u8) RalphState { const path = std.fmt.allocPrint(allocator, "{s}/.rl/state.json", .{git_root}) catch return RalphState{}; defer allocator.free(path); - // 4 KiB cap: observed state.json files are <1 KiB; 4 KiB gives 4x headroom. + // 4 KiB cap: observed v3 state.json files are ~600 bytes; 4 KiB gives >6x headroom. const file = std.fs.cwd().openFile(path, .{}) catch return RalphState{}; defer file.close(); @@ -922,6 +1117,80 @@ fn parseRalphState(allocator: Allocator, git_root: []const u8) RalphState { return parseRalphStateFromContent(allocator, buf[0..bytes_read]); } +/// Read a background job's status field from `.rl/jobs/{job_id}.json`. +/// Returns `.missing` on any failure (file not found, parse error, unexpected string, +/// oversize file). Used for orphan detection on `review_in_flight_job_id`. +/// Cost: one allocPrint, one openFile, one 4 KiB read, one JSON parse. +fn readJobStatus(allocator: Allocator, git_root: []const u8, job_id: []const u8) JobStatus { + const path = std.fmt.allocPrint(allocator, "{s}/.rl/jobs/{s}.json", .{ git_root, job_id }) catch return .missing; + defer allocator.free(path); + + const file = std.fs.cwd().openFile(path, .{}) catch return .missing; + defer file.close(); + + var buf: [4096]u8 = undefined; + const bytes_read = file.read(&buf) catch return .missing; + if (bytes_read == 0) return .missing; + + return parseJobStatusFromContent(allocator, buf[0..bytes_read]); +} + +/// Exposed for testing. Parses the `status` field from a job JSON blob. +fn parseJobStatusFromContent(allocator: Allocator, content: []const u8) JobStatus { + const JobFile = struct { status: ?[]const u8 = null }; + const parsed = std.json.parseFromSlice(JobFile, allocator, content, .{ + .ignore_unknown_fields = true, + }) catch return .missing; + defer parsed.deinit(); + + const status_str = parsed.value.status orelse return .missing; + return JobStatus.fromString(status_str); +} + +/// Run `git rev-parse HEAD` in `dir`. Returns empty string on any failure. +/// Caller receives an allocator-owned slice; free with `allocator.free` or rely on arena. +/// Callers treat empty-string as "HEAD unknown" — the verdict staleness check fails open. +fn getGitHead(allocator: Allocator, dir: []const u8) []const u8 { + var buf: [256]u8 = undefined; + var fba = std.heap.FixedBufferAllocator.init(&buf); + const temp_alloc = fba.allocator(); + const cmd = temp_alloc.dupeZ(u8, "git rev-parse HEAD") catch return ""; + return execCommand(allocator, cmd, dir) catch ""; +} + +/// Format a loop age (in ms) as a compact ` +{N}{s|m|h|d}` string with color grading. +/// Color thresholds: green <1h, yellow <4h, red ≥4h. +fn formatLoopAge(writer: anytype, age_ms: i64) !void { + if (age_ms < 0) return; + const total_s: u64 = @intCast(@divTrunc(age_ms, 1000)); + + const color: []const u8 = blk: { + if (total_s < 60 * 60) break :blk colors.green; + if (total_s < 4 * 60 * 60) break :blk colors.yellow; + break :blk colors.red; + }; + + try writer.print(" {s}+", .{color}); + + if (total_s < 60) { + try writer.print("{d}s", .{total_s}); + } else if (total_s < 60 * 60) { + try writer.print("{d}m", .{total_s / 60}); + } else if (total_s < 24 * 60 * 60) { + const hours = total_s / 3600; + const minutes = (total_s % 3600) / 60; + if (minutes == 0) { + try writer.print("{d}h", .{hours}); + } else { + try writer.print("{d}h{d}m", .{ hours, minutes }); + } + } else { + try writer.print("{d}d", .{total_s / (24 * 60 * 60)}); + } + + try writer.print("{s}", .{colors.reset}); +} + /// Append a single timestamped line to /tmp/statusline-debug.log. /// Best-effort: all failure modes are swallowed so debug logging never affects rendering. fn appendDebugLog(comptime fmt: []const u8, args: anytype) void { @@ -1080,7 +1349,10 @@ pub fn main() !void { } } - _ = try ralph_state.format(writer); + // git HEAD for verdict staleness check (REQ-SL-067). Empty on failure → fail-open. + const git_head = getGitHead(allocator, current_dir.?); + const now_ms = std.time.milliTimestamp(); + _ = try ralph_state.format(writer, allocator, git_root, git_head, now_ms); } } } @@ -1754,217 +2026,513 @@ test "RalphState progressColor thresholds" { try std.testing.expectEqualStrings(colors.green, progressColor(0, 0)); } -test "RalphState format inactive returns false" { - const state = RalphState{ .active = false }; - var buf: [256]u8 = undefined; - var stream = std.io.fixedBufferStream(&buf); +// --- rl segment rendering tests --- +// +// Shared test harness: renderRalphState wraps format() with a stack buffer so test +// cases stay tight. A non-git-repo path and empty git_head are used so the +// orphan-detection + staleness-check code paths run without touching the filesystem. + +fn renderRalphState(state: *const RalphState, buf: []u8, now_ms: i64) ![]const u8 { + var stream = std.io.fixedBufferStream(buf); const writer = stream.writer(); + _ = try state.*.format(writer, std.testing.allocator, "/nonexistent-root", "", now_ms); + return stream.getWritten(); +} - const result = try state.format(writer); - try std.testing.expect(!result); +test "RalphState format inactive returns false and writes nothing" { + const state = RalphState{ .active = false }; + var stream_buf: [256]u8 = undefined; + var stream = std.io.fixedBufferStream(&stream_buf); + const writer = stream.writer(); + const wrote = try state.format(writer, std.testing.allocator, "/nonexistent", "", 0); + try std.testing.expect(!wrote); try std.testing.expectEqual(@as(usize, 0), stream.getWritten().len); } -test "RalphState format active shows iteration with default ralph glyph" { - const state = RalphState{ +test "Strategy.fromString maps known values" { + try std.testing.expectEqual(Strategy.ralph, Strategy.fromString("ralph")); + try std.testing.expectEqual(Strategy.review, Strategy.fromString("review")); + try std.testing.expectEqual(Strategy.research, Strategy.fromString("research")); + try std.testing.expectEqual(Strategy.unknown, Strategy.fromString("nonsense")); + try std.testing.expectEqual(Strategy.unknown, Strategy.fromString("")); +} + +test "Strategy glyph mapping" { + try std.testing.expectEqualStrings(glyphs.ralph, Strategy.ralph.glyph()); + try std.testing.expectEqualStrings(glyphs.review, Strategy.review.glyph()); + try std.testing.expectEqualStrings(glyphs.research, Strategy.research.glyph()); + try std.testing.expectEqualStrings(glyphs.ralph, Strategy.unknown.glyph()); // legacy fallback +} + +test "JobStatus.fromString maps known values" { + try std.testing.expectEqual(JobStatus.queued, JobStatus.fromString("queued")); + try std.testing.expectEqual(JobStatus.running, JobStatus.fromString("running")); + try std.testing.expectEqual(JobStatus.completed, JobStatus.fromString("completed")); + try std.testing.expectEqual(JobStatus.failed, JobStatus.fromString("failed")); + try std.testing.expectEqual(JobStatus.cancelled, JobStatus.fromString("cancelled")); + try std.testing.expectEqual(JobStatus.missing, JobStatus.fromString("weird")); + try std.testing.expectEqual(JobStatus.missing, JobStatus.fromString("")); +} + +test "parseJobStatusFromContent with running job" { + const content = + \\{"id":"review-123-abc","kind":"review","status":"running","pid":1234} + ; + try std.testing.expectEqual(JobStatus.running, parseJobStatusFromContent(std.testing.allocator, content)); +} + +test "parseJobStatusFromContent with completed job" { + const content = "{\"status\":\"completed\"}"; + try std.testing.expectEqual(JobStatus.completed, parseJobStatusFromContent(std.testing.allocator, content)); +} + +test "parseJobStatusFromContent with missing status field" { + const content = "{\"id\":\"review-123\",\"kind\":\"review\"}"; + try std.testing.expectEqual(JobStatus.missing, parseJobStatusFromContent(std.testing.allocator, content)); +} + +test "parseJobStatusFromContent with garbage" { + try std.testing.expectEqual(JobStatus.missing, parseJobStatusFromContent(std.testing.allocator, "not json")); + try std.testing.expectEqual(JobStatus.missing, parseJobStatusFromContent(std.testing.allocator, "")); +} + +test "RalphState ralph iterate branch (ralph.ts:152-160)" { + // Matches ralph.ts:152 stateUpdates: { iteration: nextIteration, completion_claimed: false } + var state = RalphState{ .active = true, + .strategy = .ralph, .iteration = 3, .max_iterations = 50, - .review_enabled = false, + .review_enabled = true, + .review_count = 0, + .max_review_cycles = 10, }; var buf: [256]u8 = undefined; - var stream = std.io.fixedBufferStream(&buf); - const writer = stream.writer(); - - const result = try state.format(writer); - try std.testing.expect(result); - const output = stream.getWritten(); - // Default strategy (.unknown) renders the ralph glyph + const output = try renderRalphState(&state, &buf, 0); try std.testing.expect(std.mem.indexOf(u8, output, glyphs.ralph) != null); try std.testing.expect(std.mem.indexOf(u8, output, "3/50") != null); - // 3/50 = 6% → green - try std.testing.expect(std.mem.indexOf(u8, output, colors.green) != null); - // Review sub-counter is suppressed when review_enabled = false - try std.testing.expect(std.mem.indexOf(u8, output, glyphs.counter) == null); - // No verdict glyph either - try std.testing.expect(std.mem.indexOf(u8, output, glyphs.in_flight) == null); + try std.testing.expect(std.mem.indexOf(u8, output, glyphs.counter) != null); + try std.testing.expect(std.mem.indexOf(u8, output, "0/10") != null); + // No verdict glyph — fresh iterate has no verdict yet try std.testing.expect(std.mem.indexOf(u8, output, glyphs.approve) == null); try std.testing.expect(std.mem.indexOf(u8, output, glyphs.reject) == null); } -test "RalphState format active with reviews, no verdict" { - const state = RalphState{ +test "RalphState ralph without review_enabled hides review counter" { + var state = RalphState{ .active = true, .strategy = .ralph, .iteration = 5, .max_iterations = 30, - .review_enabled = true, - .review_count = 2, - .max_review_cycles = 5, + .review_enabled = false, }; var buf: [256]u8 = undefined; - var stream = std.io.fixedBufferStream(&buf); - const writer = stream.writer(); + const output = try renderRalphState(&state, &buf, 0); + try std.testing.expect(std.mem.indexOf(u8, output, glyphs.ralph) != null); + try std.testing.expect(std.mem.indexOf(u8, output, "5/30") != null); + try std.testing.expect(std.mem.indexOf(u8, output, glyphs.counter) == null); +} - const result = try state.format(writer); - try std.testing.expect(result); +test "RalphState ralph fresh approve verdict (ralph.ts:218-223)" { + var state = RalphState{ + .active = true, + .strategy = .ralph, + .iteration = 5, + .max_iterations = 30, + .review_enabled = true, + .review_count = 1, + .max_review_cycles = 10, + .review_verdict_raw = .approve, + .completion_claimed = true, // ralph.ts branch 4b runs only after completion claim + }; + state.review_verdict_sha_len = copyIntoFixedBuf(&state.review_verdict_sha_buf, "deadbeef"); + + // Using git_head = "deadbeef" so staleness check passes. We can't go through + // renderRalphState since it hardcodes empty git_head; call format directly. + var stream_buf: [256]u8 = undefined; + var stream = std.io.fixedBufferStream(&stream_buf); + _ = try state.format(stream.writer(), std.testing.allocator, "/nonexistent", "deadbeef", 0); const output = stream.getWritten(); + + // Completion prefix + strategy glyph + counters + verdict glyph + try std.testing.expect(std.mem.indexOf(u8, output, glyphs.completion) != null); try std.testing.expect(std.mem.indexOf(u8, output, glyphs.ralph) != null); try std.testing.expect(std.mem.indexOf(u8, output, "5/30") != null); - try std.testing.expect(std.mem.indexOf(u8, output, glyphs.counter) != null); - try std.testing.expect(std.mem.indexOf(u8, output, "2/5") != null); - try std.testing.expect(std.mem.indexOf(u8, output, colors.green) != null); - // No verdict glyph when verdict == .none - try std.testing.expect(std.mem.indexOf(u8, output, glyphs.approve) == null); + try std.testing.expect(std.mem.indexOf(u8, output, "1/10") != null); + try std.testing.expect(std.mem.indexOf(u8, output, glyphs.approve) != null); +} + +test "RalphState ralph reject-iterate branch (ralph.ts:252-265)" { + // ralph.ts reject branch bumps iteration++, review_count++, and CLEARS the verdict + // fields. So the statusline should show incremented counters and NO verdict glyph. + var state = RalphState{ + .active = true, + .strategy = .ralph, + .iteration = 6, // was 5, now 6 + .max_iterations = 30, + .review_enabled = true, + .review_count = 2, // was 1, now 2 + .max_review_cycles = 10, + .review_verdict_raw = .none, // cleared by worker + }; + // review_verdict_sha also cleared + var buf: [256]u8 = undefined; + const output = try renderRalphState(&state, &buf, 0); + try std.testing.expect(std.mem.indexOf(u8, output, "6/30") != null); + try std.testing.expect(std.mem.indexOf(u8, output, "2/10") != null); try std.testing.expect(std.mem.indexOf(u8, output, glyphs.reject) == null); - try std.testing.expect(std.mem.indexOf(u8, output, glyphs.in_flight) == null); + try std.testing.expect(std.mem.indexOf(u8, output, glyphs.approve) == null); } -test "RalphState strategy glyphs" { - const cases = [_]struct { s: Strategy, expected: []const u8 }{ - .{ .s = .ralph, .expected = glyphs.ralph }, - .{ .s = .review, .expected = glyphs.review }, - .{ .s = .research, .expected = glyphs.research }, - .{ .s = .unknown, .expected = glyphs.ralph }, // legacy fallback +test "RalphState review strategy layout omits iteration counter" { + // review.ts never mutates state.iteration — displaying it would be misleading. + var state = RalphState{ + .active = true, + .strategy = .review, + .iteration = 0, // permanently 0 in review strategy + .max_iterations = 30, + .review_enabled = true, + .review_count = 3, + .max_review_cycles = 30, }; - for (cases) |tc| { - try std.testing.expectEqualStrings(tc.expected, tc.s.glyph()); - } + var buf: [256]u8 = undefined; + const output = try renderRalphState(&state, &buf, 0); + try std.testing.expect(std.mem.indexOf(u8, output, glyphs.review) != null); + try std.testing.expect(std.mem.indexOf(u8, output, glyphs.counter) != null); + try std.testing.expect(std.mem.indexOf(u8, output, "3/30") != null); + // Must NOT contain "0/30" (the iteration counter) + // Note: review_count is "3/30", max_review_cycles matches max_iterations here — so the + // only way "0/30" could appear is from a rendered iteration counter. + try std.testing.expect(std.mem.indexOf(u8, output, "0/30") == null); +} + +test "RalphState review queueing-review branch (review.ts:162-173)" { + // review.ts enqueue branch sets review_in_flight_job_id but does NOT clear the + // prior verdict. The statusline's in-flight check (via readJobStatus) would try + // to open a job file that doesn't exist in this test; the orphan path returns + // `missing` and the staleness check then fails — so no verdict glyph should render. + var state = RalphState{ + .active = true, + .strategy = .review, + .review_enabled = true, + .review_count = 1, + .max_review_cycles = 30, + .review_verdict_raw = .reject, // prior round's reject + }; + state.review_verdict_sha_len = copyIntoFixedBuf(&state.review_verdict_sha_buf, "abc12345"); + state.review_in_flight_job_id_len = copyIntoFixedBuf(&state.review_in_flight_job_id_buf, "review-123-xyz"); + // git_head matches verdict_sha. The orphan check runs first: the job file under + // /nonexistent-root/.rl/jobs/ doesn't exist → readJobStatus returns .missing → + // in_flight branch falls through → staleness check passes → render ❌. + var stream_buf: [256]u8 = undefined; + var stream = std.io.fixedBufferStream(&stream_buf); + _ = try state.format(stream.writer(), std.testing.allocator, "/nonexistent-root", "abc12345", 0); + const output = stream.getWritten(); + try std.testing.expect(std.mem.indexOf(u8, output, glyphs.reject) != null); + try std.testing.expect(std.mem.indexOf(u8, output, glyphs.in_flight) == null); } -test "Strategy.fromString maps known values" { - try std.testing.expectEqual(Strategy.ralph, Strategy.fromString("ralph")); - try std.testing.expectEqual(Strategy.review, Strategy.fromString("review")); - try std.testing.expectEqual(Strategy.research, Strategy.fromString("research")); - try std.testing.expectEqual(Strategy.unknown, Strategy.fromString("nonsense")); - try std.testing.expectEqual(Strategy.unknown, Strategy.fromString("")); +test "RalphState review reject-iterate clears verdict (review.ts:148-155)" { + // After a confirmed reject, the rl hook writes review_count++ AND clears the verdict + // fields. Next render should show no verdict glyph. + var state = RalphState{ + .active = true, + .strategy = .review, + .review_enabled = true, + .review_count = 2, + .max_review_cycles = 30, + .review_verdict_raw = .none, + }; + var buf: [256]u8 = undefined; + const output = try renderRalphState(&state, &buf, 0); + try std.testing.expect(std.mem.indexOf(u8, output, "2/30") != null); + try std.testing.expect(std.mem.indexOf(u8, output, glyphs.reject) == null); + try std.testing.expect(std.mem.indexOf(u8, output, glyphs.approve) == null); } -test "RalphState format review strategy with approve verdict" { - const state = RalphState{ +test "RalphState review fresh approve with matching HEAD" { + var state = RalphState{ .active = true, .strategy = .review, - .iteration = 1, - .max_iterations = 30, .review_enabled = true, .review_count = 0, .max_review_cycles = 30, - .verdict = .approve, + .review_verdict_raw = .approve, }; - var buf: [256]u8 = undefined; - var stream = std.io.fixedBufferStream(&buf); - const writer = stream.writer(); - _ = try state.format(writer); + state.review_verdict_sha_len = copyIntoFixedBuf(&state.review_verdict_sha_buf, "cafebabe"); + var stream_buf: [256]u8 = undefined; + var stream = std.io.fixedBufferStream(&stream_buf); + _ = try state.format(stream.writer(), std.testing.allocator, "/nonexistent-root", "cafebabe", 0); const output = stream.getWritten(); - try std.testing.expect(std.mem.indexOf(u8, output, glyphs.review) != null); try std.testing.expect(std.mem.indexOf(u8, output, glyphs.approve) != null); - try std.testing.expect(std.mem.indexOf(u8, output, glyphs.reject) == null); } -test "RalphState format review strategy with reject verdict" { - const state = RalphState{ +test "RalphState verdict suppressed when HEAD drifts from verdict_sha (staleness)" { + var state = RalphState{ .active = true, .strategy = .review, - .iteration = 2, - .max_iterations = 30, .review_enabled = true, - .review_count = 1, + .review_count = 0, .max_review_cycles = 30, - .verdict = .reject, + .review_verdict_raw = .reject, }; - var buf: [256]u8 = undefined; - var stream = std.io.fixedBufferStream(&buf); - const writer = stream.writer(); - _ = try state.format(writer); + state.review_verdict_sha_len = copyIntoFixedBuf(&state.review_verdict_sha_buf, "oldsha1234"); + var stream_buf: [256]u8 = undefined; + var stream = std.io.fixedBufferStream(&stream_buf); + // git_head != verdict_sha → stale → suppress verdict glyph + _ = try state.format(stream.writer(), std.testing.allocator, "/nonexistent-root", "newsha5678", 0); const output = stream.getWritten(); - try std.testing.expect(std.mem.indexOf(u8, output, glyphs.reject) != null); + try std.testing.expect(std.mem.indexOf(u8, output, glyphs.reject) == null); try std.testing.expect(std.mem.indexOf(u8, output, glyphs.approve) == null); } -test "RalphState format in-flight glyph when worker running" { - const state = RalphState{ +test "RalphState verdict renders when git_head is empty (fail-open on unknown HEAD)" { + var state = RalphState{ .active = true, .strategy = .review, - .iteration = 1, - .max_iterations = 30, .review_enabled = true, - .review_count = 0, - .max_review_cycles = 30, - .verdict = .in_flight, + .review_verdict_raw = .approve, }; + state.review_verdict_sha_len = copyIntoFixedBuf(&state.review_verdict_sha_buf, "somesha"); var buf: [256]u8 = undefined; - var stream = std.io.fixedBufferStream(&buf); - const writer = stream.writer(); - _ = try state.format(writer); + // renderRalphState passes empty git_head — fail-open means verdict still renders + const output = try renderRalphState(&state, &buf, 0); + try std.testing.expect(std.mem.indexOf(u8, output, glyphs.approve) != null); +} + +test "RalphState in-flight with orphan job file falls through to verdict" { + // review_in_flight_job_id set but the job file doesn't exist → orphan → fall through + // to verdict check. Matching sha → render verdict glyph. + var state = RalphState{ + .active = true, + .strategy = .review, + .review_enabled = true, + .review_verdict_raw = .approve, + }; + state.review_verdict_sha_len = copyIntoFixedBuf(&state.review_verdict_sha_buf, "headsha"); + state.review_in_flight_job_id_len = copyIntoFixedBuf(&state.review_in_flight_job_id_buf, "review-orphan-xx"); + var stream_buf: [256]u8 = undefined; + var stream = std.io.fixedBufferStream(&stream_buf); + _ = try state.format(stream.writer(), std.testing.allocator, "/nonexistent-root", "headsha", 0); const output = stream.getWritten(); - try std.testing.expect(std.mem.indexOf(u8, output, glyphs.in_flight) != null); - // Precedence: in-flight wins over any stored verdict (verified at parse time) - try std.testing.expect(std.mem.indexOf(u8, output, glyphs.approve) == null); + // Orphan → fall through → staleness passes → ✅ renders + try std.testing.expect(std.mem.indexOf(u8, output, glyphs.approve) != null); + try std.testing.expect(std.mem.indexOf(u8, output, glyphs.in_flight) == null); } -test "RalphState format review strategy suppresses verdict when review_enabled is false" { - // A review-strategy loop with review_enabled explicitly false should not render - // the review sub-counter or a verdict glyph. - const state = RalphState{ +test "RalphState terminal-state prefix: blocked_claimed" { + var state = RalphState{ .active = true, .strategy = .review, - .iteration = 1, + .review_enabled = true, + .review_count = 0, + .max_review_cycles = 30, + .blocked_claimed = true, + }; + var buf: [256]u8 = undefined; + const output = try renderRalphState(&state, &buf, 0); + try std.testing.expect(std.mem.indexOf(u8, output, glyphs.blocked) != null); + try std.testing.expect(std.mem.indexOf(u8, output, glyphs.completion) == null); +} + +test "RalphState terminal-state prefix: completion_claimed" { + var state = RalphState{ + .active = true, + .strategy = .ralph, + .iteration = 5, .max_iterations = 30, - .review_enabled = false, - .verdict = .approve, + .completion_claimed = true, }; var buf: [256]u8 = undefined; - var stream = std.io.fixedBufferStream(&buf); - const writer = stream.writer(); - _ = try state.format(writer); - const output = stream.getWritten(); - try std.testing.expect(std.mem.indexOf(u8, output, glyphs.review) != null); - try std.testing.expect(std.mem.indexOf(u8, output, glyphs.counter) == null); - try std.testing.expect(std.mem.indexOf(u8, output, glyphs.approve) == null); + const output = try renderRalphState(&state, &buf, 0); + try std.testing.expect(std.mem.indexOf(u8, output, glyphs.completion) != null); + try std.testing.expect(std.mem.indexOf(u8, output, glyphs.blocked) == null); +} + +test "RalphState terminal-state prefix: blocked beats completion" { + var state = RalphState{ + .active = true, + .strategy = .review, + .review_enabled = true, + .completion_claimed = true, + .blocked_claimed = true, + }; + var buf: [256]u8 = undefined; + const output = try renderRalphState(&state, &buf, 0); + try std.testing.expect(std.mem.indexOf(u8, output, glyphs.blocked) != null); + try std.testing.expect(std.mem.indexOf(u8, output, glyphs.completion) == null); } -test "RalphState format research strategy without metric" { - const state = RalphState{ +test "RalphState research strategy without metric (research.ts:125-135)" { + var state = RalphState{ .active = true, .strategy = .research, .iteration = 5, .max_iterations = 30, - .review_enabled = true, // should be ignored for research + // research ignores review fields even if set + .review_enabled = true, .review_count = 3, .max_review_cycles = 10, - .verdict = .approve, // should be ignored for research + .review_verdict_raw = .approve, }; var buf: [256]u8 = undefined; - var stream = std.io.fixedBufferStream(&buf); - const writer = stream.writer(); - _ = try state.format(writer); - const output = stream.getWritten(); + const output = try renderRalphState(&state, &buf, 0); try std.testing.expect(std.mem.indexOf(u8, output, glyphs.research) != null); try std.testing.expect(std.mem.indexOf(u8, output, "5/30") != null); - // Research hides review counter, verdict glyphs, and metric + // Research hides review counter, verdict glyphs, and (without metric) the star try std.testing.expect(std.mem.indexOf(u8, output, glyphs.counter) == null); try std.testing.expect(std.mem.indexOf(u8, output, glyphs.approve) == null); try std.testing.expect(std.mem.indexOf(u8, output, glyphs.metric) == null); } -test "RalphState format research strategy with best metric value" { - const state = RalphState{ +test "RalphState research with maximize metric renders up-arrow" { + var state = RalphState{ .active = true, .strategy = .research, - .iteration = 5, + .iteration = 12, .max_iterations = 30, .best_metric_value = 0.823, + .metric_direction = .maximize, }; var buf: [256]u8 = undefined; - var stream = std.io.fixedBufferStream(&buf); - const writer = stream.writer(); - _ = try state.format(writer); - const output = stream.getWritten(); + const output = try renderRalphState(&state, &buf, 0); try std.testing.expect(std.mem.indexOf(u8, output, glyphs.research) != null); try std.testing.expect(std.mem.indexOf(u8, output, glyphs.metric) != null); + try std.testing.expect(std.mem.indexOf(u8, output, glyphs.arrow_up) != null); try std.testing.expect(std.mem.indexOf(u8, output, "0.823") != null); } +test "RalphState research with minimize metric renders down-arrow" { + var state = RalphState{ + .active = true, + .strategy = .research, + .iteration = 8, + .max_iterations = 30, + .best_metric_value = 0.045, + .metric_direction = .minimize, + }; + var buf: [256]u8 = undefined; + const output = try renderRalphState(&state, &buf, 0); + try std.testing.expect(std.mem.indexOf(u8, output, glyphs.arrow_down) != null); + try std.testing.expect(std.mem.indexOf(u8, output, "0.045") != null); +} + +test "RalphState research with unknown direction omits arrow" { + var state = RalphState{ + .active = true, + .strategy = .research, + .best_metric_value = 1.5, + .metric_direction = .none, + }; + var buf: [256]u8 = undefined; + const output = try renderRalphState(&state, &buf, 0); + try std.testing.expect(std.mem.indexOf(u8, output, glyphs.metric) != null); + try std.testing.expect(std.mem.indexOf(u8, output, glyphs.arrow_up) == null); + try std.testing.expect(std.mem.indexOf(u8, output, glyphs.arrow_down) == null); + try std.testing.expect(std.mem.indexOf(u8, output, "1.500") != null); +} + +test "RalphState loop age: seconds" { + var state = RalphState{ + .active = true, + .strategy = .ralph, + .iteration = 1, + .max_iterations = 30, + .iteration_start_ms = 1000, + }; + var buf: [256]u8 = undefined; + const output = try renderRalphState(&state, &buf, 31000); // 30s later + try std.testing.expect(std.mem.indexOf(u8, output, "+30s") != null); + try std.testing.expect(std.mem.indexOf(u8, output, colors.green) != null); +} + +test "RalphState loop age: minutes in green range" { + var state = RalphState{ + .active = true, + .strategy = .ralph, + .iteration_start_ms = 0, + }; + var buf: [256]u8 = undefined; + const output = try renderRalphState(&state, &buf, 45 * 60 * 1000); // 45m + try std.testing.expect(std.mem.indexOf(u8, output, "+45m") != null); + try std.testing.expect(std.mem.indexOf(u8, output, colors.green) != null); +} + +test "RalphState loop age: hours in yellow range" { + var state = RalphState{ + .active = true, + .strategy = .ralph, + .iteration_start_ms = 0, + }; + var buf: [256]u8 = undefined; + const output = try renderRalphState(&state, &buf, 2 * 60 * 60 * 1000 + 15 * 60 * 1000); // 2h15m + try std.testing.expect(std.mem.indexOf(u8, output, "+2h15m") != null); + try std.testing.expect(std.mem.indexOf(u8, output, colors.yellow) != null); +} + +test "RalphState loop age: hours with zero-minute suffix" { + var state = RalphState{ + .active = true, + .strategy = .ralph, + .iteration_start_ms = 0, + }; + var buf: [256]u8 = undefined; + const output = try renderRalphState(&state, &buf, 3 * 60 * 60 * 1000); // exactly 3h + try std.testing.expect(std.mem.indexOf(u8, output, "+3h") != null); + // Should NOT contain "+3h0m" + try std.testing.expect(std.mem.indexOf(u8, output, "+3h0m") == null); +} + +test "RalphState loop age: days in red range" { + var state = RalphState{ + .active = true, + .strategy = .ralph, + .iteration_start_ms = 0, + }; + var buf: [256]u8 = undefined; + const output = try renderRalphState(&state, &buf, 3 * 24 * 60 * 60 * 1000); // 3d + try std.testing.expect(std.mem.indexOf(u8, output, "+3d") != null); + try std.testing.expect(std.mem.indexOf(u8, output, colors.red) != null); +} + +test "RalphState loop age: absent when iteration_start_ms is null" { + var state = RalphState{ + .active = true, + .strategy = .ralph, + .iteration_start_ms = null, + }; + var buf: [256]u8 = undefined; + const output = try renderRalphState(&state, &buf, 1_000_000); + // No "+" prefix followed by digit/h/m/s/d + try std.testing.expect(std.mem.indexOf(u8, output, "+") == null); +} + +test "RalphState loop age: absent when now_ms <= iteration_start_ms" { + // Clock skew edge case — don't render a negative age. + var state = RalphState{ + .active = true, + .strategy = .ralph, + .iteration_start_ms = 5000, + }; + var buf: [256]u8 = undefined; + const output = try renderRalphState(&state, &buf, 3000); + try std.testing.expect(std.mem.indexOf(u8, output, "+") == null); +} + +test "copyIntoFixedBuf overflow returns zero" { + var buf: [8]u8 = undefined; + try std.testing.expectEqual(@as(u8, 0), copyIntoFixedBuf(&buf, "this_is_longer_than_eight")); + try std.testing.expectEqual(@as(u8, 0), copyIntoFixedBuf(&buf, "")); + try std.testing.expectEqual(@as(u8, 3), copyIntoFixedBuf(&buf, "abc")); + try std.testing.expectEqualStrings("abc", buf[0..3]); +} + +test "RalphState accessors return null for empty buffers" { + const state = RalphState{}; + try std.testing.expect(state.verdictSha() == null); + try std.testing.expect(state.inFlightJobId() == null); +} + test "parseYamlBool function" { try std.testing.expectEqual(true, parseYamlBool("active: true", "active:")); try std.testing.expectEqual(false, parseYamlBool("active: false", "active:")); @@ -1984,9 +2552,9 @@ test "parseYamlInt function" { test "parseRalphStateFromContent with valid v3 JSON" { const allocator = std.testing.allocator; const content = - \\{"version":3,"strategy":"review","active":true,"iteration":5,"max_iterations":30,"review_enabled":true,"review_count":2,"max_review_cycles":10} + \\{"version":3,"strategy":"review","active":true,"iteration":5,"max_iterations":30, + \\"review_enabled":true,"review_count":2,"max_review_cycles":10} ; - const state = parseRalphStateFromContent(allocator, content); try std.testing.expect(state.active); try std.testing.expectEqual(@as(u32, 5), state.iteration); @@ -1996,117 +2564,140 @@ test "parseRalphStateFromContent with valid v3 JSON" { try std.testing.expectEqual(@as(u32, 10), state.max_review_cycles); try std.testing.expectEqual(Strategy.review, state.strategy); try std.testing.expectEqual(@as(?u32, 3), state.version); - try std.testing.expectEqual(Verdict.none, state.verdict); + try std.testing.expectEqual(VerdictRaw.none, state.review_verdict_raw); } -test "parseRalphStateFromContent with partial fields" { +test "parseRalphStateFromContent with partial fields falls back to defaults" { const allocator = std.testing.allocator; - const content = - \\{"active":true,"iteration":3} - ; - - const state = parseRalphStateFromContent(allocator, content); + const state = parseRalphStateFromContent(allocator, "{\"active\":true,\"iteration\":3}"); try std.testing.expect(state.active); try std.testing.expectEqual(@as(u32, 3), state.iteration); - // Defaults for missing fields try std.testing.expectEqual(@as(u32, 50), state.max_iterations); try std.testing.expect(!state.review_enabled); - try std.testing.expectEqual(@as(u32, 0), state.review_count); - try std.testing.expectEqual(@as(u32, 10), state.max_review_cycles); try std.testing.expectEqual(Strategy.unknown, state.strategy); try std.testing.expectEqual(@as(?u32, null), state.version); + try std.testing.expectEqual(MetricDirection.none, state.metric_direction); + try std.testing.expect(!state.completion_claimed); + try std.testing.expect(!state.blocked_claimed); + try std.testing.expect(state.verdictSha() == null); + try std.testing.expect(state.inFlightJobId() == null); } -test "parseRalphStateFromContent with invalid JSON" { - const allocator = std.testing.allocator; - const state = parseRalphStateFromContent(allocator, "# Just markdown, not JSON"); +test "parseRalphStateFromContent with invalid JSON returns defaults" { + const state = parseRalphStateFromContent(std.testing.allocator, "# not JSON"); try std.testing.expect(!state.active); - try std.testing.expectEqual(@as(u32, 0), state.iteration); } -test "parseRalphStateFromContent with empty content" { - const allocator = std.testing.allocator; - const state = parseRalphStateFromContent(allocator, ""); +test "parseRalphStateFromContent with empty content returns defaults" { + const state = parseRalphStateFromContent(std.testing.allocator, ""); try std.testing.expect(!state.active); } -test "parseRalphStateFromContent ignores extra fields" { - const allocator = std.testing.allocator; +test "parseRalphStateFromContent ignores unknown fields" { const content = - \\{"active":true,"iteration":7,"unknown_field":"some_value","completion_promise":"COMPLETE","timestamp":"2025-01-01T00:00:00Z"} + \\{"active":true,"iteration":7,"unknown_field":"x","completion_promise":"COMPLETE"} ; - - const state = parseRalphStateFromContent(allocator, content); + const state = parseRalphStateFromContent(std.testing.allocator, content); try std.testing.expect(state.active); try std.testing.expectEqual(@as(u32, 7), state.iteration); } -test "parseRalphStateFromContent resolves approve verdict" { - const allocator = std.testing.allocator; +test "parseRalphStateFromContent parses approve verdict + sha" { const content = - \\{"version":3,"strategy":"review","active":true,"iteration":1,"max_iterations":30, - \\"review_enabled":true,"review_count":0,"max_review_cycles":30, + \\{"version":3,"strategy":"review","active":true, \\"review_verdict":"approve","review_verdict_sha":"deadbeef", \\"review_in_flight_job_id":null} ; - const state = parseRalphStateFromContent(allocator, content); - try std.testing.expectEqual(Verdict.approve, state.verdict); + const state = parseRalphStateFromContent(std.testing.allocator, content); + try std.testing.expectEqual(VerdictRaw.approve, state.review_verdict_raw); + try std.testing.expect(state.verdictSha() != null); + try std.testing.expectEqualStrings("deadbeef", state.verdictSha().?); + try std.testing.expect(state.inFlightJobId() == null); } -test "parseRalphStateFromContent resolves reject verdict" { - const allocator = std.testing.allocator; +test "parseRalphStateFromContent parses reject verdict" { const content = - \\{"version":3,"strategy":"review","active":true, - \\"review_verdict":"reject","review_in_flight_job_id":null} + \\{"version":3,"active":true,"review_verdict":"reject","review_verdict_sha":"abc"} ; - const state = parseRalphStateFromContent(allocator, content); - try std.testing.expectEqual(Verdict.reject, state.verdict); + const state = parseRalphStateFromContent(std.testing.allocator, content); + try std.testing.expectEqual(VerdictRaw.reject, state.review_verdict_raw); } -test "parseRalphStateFromContent in-flight beats stored verdict" { - // Real-world race: a new review worker started while a prior verdict is still on disk. - // The worker clears review_in_flight_job_id only after writing the new verdict, - // so in-flight always wins. - const allocator = std.testing.allocator; +test "parseRalphStateFromContent parses in-flight job id" { const content = - \\{"version":3,"strategy":"review","active":true, - \\"review_verdict":"approve","review_verdict_sha":"deadbeef", - \\"review_in_flight_job_id":"review-1776040657173-2j38kp"} + \\{"version":3,"active":true,"review_in_flight_job_id":"review-123-abc"} ; - const state = parseRalphStateFromContent(allocator, content); - try std.testing.expectEqual(Verdict.in_flight, state.verdict); + const state = parseRalphStateFromContent(std.testing.allocator, content); + try std.testing.expect(state.inFlightJobId() != null); + try std.testing.expectEqualStrings("review-123-abc", state.inFlightJobId().?); } -test "parseRalphStateFromContent treats empty in-flight job id as not in flight" { - const allocator = std.testing.allocator; - const content = - \\{"active":true,"review_verdict":"approve","review_in_flight_job_id":""} - ; - const state = parseRalphStateFromContent(allocator, content); - try std.testing.expectEqual(Verdict.approve, state.verdict); +test "parseRalphStateFromContent treats empty in-flight job id as absent" { + const content = "{\"active\":true,\"review_in_flight_job_id\":\"\"}"; + const state = parseRalphStateFromContent(std.testing.allocator, content); + try std.testing.expect(state.inFlightJobId() == null); } -test "parseRalphStateFromContent with research metric" { - const allocator = std.testing.allocator; +test "parseRalphStateFromContent parses research metric + direction" { const content = \\{"version":3,"strategy":"research","active":true,"iteration":5,"max_iterations":30, \\"metric_name":"accuracy","metric_direction":"maximize","best_metric_value":0.8231} ; - const state = parseRalphStateFromContent(allocator, content); + const state = parseRalphStateFromContent(std.testing.allocator, content); try std.testing.expectEqual(Strategy.research, state.strategy); try std.testing.expect(state.best_metric_value != null); try std.testing.expectApproxEqAbs(@as(f64, 0.8231), state.best_metric_value.?, 0.0001); + try std.testing.expectEqual(MetricDirection.maximize, state.metric_direction); +} + +test "parseRalphStateFromContent parses minimize direction" { + const content = "{\"strategy\":\"research\",\"metric_direction\":\"minimize\"}"; + const state = parseRalphStateFromContent(std.testing.allocator, content); + try std.testing.expectEqual(MetricDirection.minimize, state.metric_direction); +} + +test "parseRalphStateFromContent parses terminal-state flags" { + const content = "{\"active\":true,\"completion_claimed\":true,\"blocked_claimed\":true}"; + const state = parseRalphStateFromContent(std.testing.allocator, content); + try std.testing.expect(state.completion_claimed); + try std.testing.expect(state.blocked_claimed); +} + +test "parseRalphStateFromContent parses iteration_start_ms" { + const content = "{\"active\":true,\"iteration_start_ms\":1776040656906}"; + const state = parseRalphStateFromContent(std.testing.allocator, content); + try std.testing.expect(state.iteration_start_ms != null); + try std.testing.expectEqual(@as(i64, 1776040656906), state.iteration_start_ms.?); } test "parseRalphStateFromContent captures stale schema version" { - const allocator = std.testing.allocator; + const content = "{\"version\":2,\"active\":true,\"iteration\":1}"; + const state = parseRalphStateFromContent(std.testing.allocator, content); + try std.testing.expect(state.active); + try std.testing.expectEqual(@as(?u32, 2), state.version); +} + +test "parseRalphStateFromContent handles real-world state.json shape" { + // Actual payload observed in ~/0xbigboss/rl/.rl/state.json on 2026-04-13. const content = - \\{"version":2,"active":true,"iteration":1} + \\{"version":3,"strategy":"review","active":true,"iteration":0,"max_iterations":30, + \\"timestamp":"2026-04-13T01:07:58Z","review_enabled":true,"review_count":0, + \\"max_review_cycles":30,"debug":false,"review_verdict":"reject", + \\"review_verdict_sha":"8bc2c48697d39eb0488b64ddd00f7a0a3bcdcd64", + \\"review_verdict_ts":"2026-04-13T01:09:44.749Z", + \\"review_verdict_job_id":"review-1776042481651-abccbr", + \\"review_in_flight_job_id":null,"iteration_start_ms":1776042478932, + \\"iteration_start_sha":"8bc2c48","blocked_claimed":true} ; - const state = parseRalphStateFromContent(allocator, content); - // Parse still succeeds — drift detection happens in the debug-log branch, not the render path. + const state = parseRalphStateFromContent(std.testing.allocator, content); try std.testing.expect(state.active); - try std.testing.expectEqual(@as(?u32, 2), state.version); + try std.testing.expectEqual(Strategy.review, state.strategy); + try std.testing.expectEqual(VerdictRaw.reject, state.review_verdict_raw); + try std.testing.expect(state.blocked_claimed); + try std.testing.expect(state.verdictSha() != null); + try std.testing.expectEqualStrings("8bc2c48697d39eb0488b64ddd00f7a0a3bcdcd64", state.verdictSha().?); + try std.testing.expect(state.inFlightJobId() == null); + try std.testing.expect(state.iteration_start_ms != null); } test "formatIdleSince returns false without session_id" { From ccf93295caabc1a6cbb9e90fc852ae8997142399 Mon Sep 17 00:00:00 2001 From: 0xBigBoss <95193764+0xBigBoss@users.noreply.github.com> Date: Mon, 13 Apr 2026 05:41:55 -0500 Subject: [PATCH 32/57] =?UTF-8?q?feat(statusline):=20add=20impl-worker=20i?= =?UTF-8?q?ndicator=20(=F0=9F=94=A8)=20for=20rl=201.1=20parallel=20jobs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Observed: `rl implement start` spawns a detached worker against a packet with no active rl loop — the famo-gas-floor-ratchet workspace on 2026-04-13 had a running `.rl/jobs/impl-famo-gas-phase-1-schema-repo-tzzyst.json` and no state.json. The prior statusline cut surfaced nothing: the rl segment is gated on state.json's `active` flag, so impl workers were invisible. REQ-SL-070 adds a standalone `hasRunningImplJob` check that scans `.rl/jobs/` directly. Runs independent of state.json; renders `🔨` at the tail of the rl segment area whenever at least one `impl-*.json` reports status `queued` or `running`. Sibling of RalphState.format — called from main() unconditionally after it, so the glyph appears with or without an active loop. Implementation notes: - `hasRunningImplJob`: opens `.rl/jobs/` as iterable dir, bounded to 100 entries, short-circuits on the first running impl job. Filters by filename (must start with `impl-`, end with `.json`) to avoid conflating with review-kind jobs — the rl segment already tracks those via review_in_flight_job_id. - `formatImplWorker`: thin writer wrapper returning bool for consistency with the other segment functions. Fail-closed on every filesystem or parse error — treats anything unexpected as "no worker running". - `parseJobStatusFromContent` reused unchanged (already used for orphan detection on review jobs). Test coverage (+9 new, 87/87 total): - missing .rl/jobs/ → no glyph - running impl job → glyph - queued impl job → glyph - completed/failed/cancelled → no glyph - review-kind running job → no glyph (filtered out) - mixed: one of many running → glyph (short-circuit) - wrong filename prefix / extension → no glyph - formatImplWorker: emits glyph when running, writes nothing otherwise Rendering examples: 🧪 🔍 2/30 ⏳ 🔨 +1h # loop + impl worker parallel ~/foo/bar [main] 🔨 # no loop, just impl 🔁 5/30 🔍 0/10 🔨 +30m # ralph + impl --- statusline/SPEC.md | 31 ++++++- statusline/src/main.zig | 180 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 210 insertions(+), 1 deletion(-) diff --git a/statusline/SPEC.md b/statusline/SPEC.md index 6c52286..9a027f4 100644 --- a/statusline/SPEC.md +++ b/statusline/SPEC.md @@ -238,7 +238,35 @@ Consequence: `iteration_start_ms` encodes "rl init time", not "current iteration When rl adds a new branch or changes an existing `stateUpdates` block, the corresponding fixture must be updated. That turns the implicit coupling into an explicit contract. -- **REQ-SL-069** (glyph namespace additions): `glyphs` gains `completion` (`🏁`), `blocked` (`🚧`), `arrow_up` (`↑`), `arrow_down` (`↓`). Existing glyphs unchanged. +- **REQ-SL-069** (glyph namespace additions): `glyphs` gains `completion` (`🏁`), `blocked` (`🚧`), `arrow_up` (`↑`), `arrow_down` (`↓`), `impl` (`🔨`). Existing glyphs unchanged. + +- **REQ-SL-070** (impl-worker visibility): `rl implement start` spawns a detached worker that does NOT touch `state.json` and does NOT require an active rl loop — observed against `~/0xbigboss/0xsend/canton-monorepo.worktrees/famo-gas-floor-ratchet` on 2026-04-13, where an impl worker was running against `.rl/jobs/impl-famo-gas-phase-1-schema-repo-tzzyst.json` with no state.json present. The statusline emits a trailing ` 🔨` whenever `hasRunningImplJob(allocator, git_root)` returns true. + + Detection procedure (`hasRunningImplJob`): + 1. Open `{git_root}/.rl/jobs/` as an iterable directory. Missing directory → return false. + 2. Iterate entries with a hard cap of 100 (prevents pathological cost in workspaces with many historical jobs). + 3. For each entry whose name starts with `impl-` AND ends with `.json`: + - Read up to 4 KiB from the file + - Parse the `status` field via `parseJobStatusFromContent` + - If status is `queued` or `running` → return true (short-circuit) + 4. If no running impl job found → return false. + + The impl glyph renders at the tail of the rl segment area, emitted by a sibling function (`formatImplWorker`) called after `RalphState.format` — deliberately independent of loop state so an impl worker is visible even when no loop is initialized. When the rl segment also renders, the glyphs concatenate naturally: + + ``` + # rl 1.1 loop + impl worker running + 🧪 🔍 2/30 ⏳ 🔨 +1h + + # no state.json, just an impl worker + ~/foo/bar [main] 🔨 + + # ralph loop + impl worker + 🔁 5/30 🔍 0/10 🔨 +30m + ``` + + Review-kind jobs (`review-*.json`) are explicitly ignored by the prefix filter — the rl segment already tracks review in-flight via `review_in_flight_job_id`. The impl glyph is strictly for the parallel impl-worker track introduced in rl 1.1. + +- **REQ-SL-071** (acceptance: impl glyph renders without state.json): The acceptance contract for REQ-SL-070 explicitly requires that the glyph renders in workspaces that have `.rl/jobs/impl-*.json` but NO `.rl/state.json`. Fixture tests cover this scenario. ### Other segments (captured for traceability) @@ -275,6 +303,7 @@ rl 1.1 strategy-aware renderer (this change set — 2026-04-13): - [ ] `zig build test` passes with at least 60 tests total. - [ ] Live smoke passes against current `~/0xbigboss/rl` loop and `…/famo-classifier-alignment` loop — rendered segment matches what would be expected given each loop's live `state.json` + HEAD. - [ ] All existing non-rl tests remain green (no regression to path/git/model/gauge/cost/idle segments). +- [x] Impl-worker visibility (REQ-SL-070): `hasRunningImplJob` scans `.rl/jobs/` for `impl-*.json` with status queued/running; renders `🔨` glyph independently of state.json. Tests cover: missing dir, queued/running/completed/failed/cancelled, review-kind job filter, non-json filter, prefix filter. ## Risk tags diff --git a/statusline/src/main.zig b/statusline/src/main.zig index 00b61b7..5c0948c 100644 --- a/statusline/src/main.zig +++ b/statusline/src/main.zig @@ -41,6 +41,8 @@ const glyphs = struct { // Terminal-state prefixes — loop is winding down, not actively iterating const completion = "🏁"; // completion_claimed: agent claims done, awaiting verdict/user const blocked = "🚧"; // blocked_claimed: loop marked blocked, next Stop cleans up + // Impl worker (rl 1.1): background `rl implement start` job, independent of loop state + const impl = "🔨"; }; /// Current context usage token counts - added in v2.0.70 @@ -1158,6 +1160,54 @@ fn getGitHead(allocator: Allocator, dir: []const u8) []const u8 { return execCommand(allocator, cmd, dir) catch ""; } +/// Scan `.rl/jobs/` for a currently-running `rl implement` worker. Returns true +/// as soon as any file named `impl-*.json` reports status `queued` or `running`. +/// +/// Implementation independent of state.json: `rl implement start` spawns a worker +/// even when no rl loop is initialized, so the impl indicator must render on job +/// presence alone. Iteration is bounded (max 100 entries) to keep the hot path +/// predictable for long-running workspaces with many historical jobs. +/// +/// Fail-closed on any filesystem or parse error: treat anything unexpected as +/// "no running impl worker" rather than surfacing a misleading glyph. +fn hasRunningImplJob(allocator: Allocator, git_root: []const u8) bool { + const dir_path = std.fmt.allocPrint(allocator, "{s}/.rl/jobs", .{git_root}) catch return false; + defer allocator.free(dir_path); + + var dir = std.fs.cwd().openDir(dir_path, .{ .iterate = true }) catch return false; + defer dir.close(); + + var iter = dir.iterate(); + var scanned: u32 = 0; + while (iter.next() catch null) |entry| { + if (scanned >= 100) break; // bounded scan + scanned += 1; + + if (entry.kind != .file) continue; + if (!std.mem.startsWith(u8, entry.name, "impl-")) continue; + if (!std.mem.endsWith(u8, entry.name, ".json")) continue; + + const file = dir.openFile(entry.name, .{}) catch continue; + defer file.close(); + + var buf: [4096]u8 = undefined; + const bytes_read = file.read(&buf) catch continue; + if (bytes_read == 0) continue; + + const status = parseJobStatusFromContent(allocator, buf[0..bytes_read]); + if (status == .queued or status == .running) return true; + } + return false; +} + +/// Emit the impl-worker indicator (` 🔨`) when at least one `rl implement` worker +/// is running in this workspace. Returns true if anything was written. +fn formatImplWorker(writer: anytype, allocator: Allocator, git_root: []const u8) !bool { + if (!hasRunningImplJob(allocator, git_root)) return false; + try writer.print(" {s}", .{glyphs.impl}); + return true; +} + /// Format a loop age (in ms) as a compact ` +{N}{s|m|h|d}` string with color grading. /// Color thresholds: green <1h, yellow <4h, red ≥4h. fn formatLoopAge(writer: anytype, age_ms: i64) !void { @@ -1353,6 +1403,11 @@ pub fn main() !void { const git_head = getGitHead(allocator, current_dir.?); const now_ms = std.time.milliTimestamp(); _ = try ralph_state.format(writer, allocator, git_root, git_head, now_ms); + + // Impl-worker indicator (REQ-SL-070). Orthogonal to the rl segment — + // `rl implement start` spawns a worker even when no loop is active, + // so this check happens regardless of ralph_state.active. + _ = try formatImplWorker(writer, allocator, git_root); } } } @@ -2533,6 +2588,131 @@ test "RalphState accessors return null for empty buffers" { try std.testing.expect(state.inFlightJobId() == null); } +// --- Impl-worker segment tests --- +// +// These exercise the filesystem-aware helper by constructing real temp directories +// with fake job files. Fixture layout: tmpdir/.rl/jobs/.json + +fn makeImplFixture(root: std.fs.Dir, subdir: []const u8) !std.fs.Dir { + root.makeDir(subdir) catch {}; + var d = try root.openDir(subdir, .{}); + errdefer d.close(); + d.makePath(".rl/jobs") catch {}; + return d; +} + +fn writeFixtureJob(fixture: std.fs.Dir, name: []const u8, content: []const u8) !void { + const path = try std.fmt.allocPrint(std.testing.allocator, ".rl/jobs/{s}", .{name}); + defer std.testing.allocator.free(path); + const file = try fixture.createFile(path, .{}); + defer file.close(); + try file.writeAll(content); +} + +test "hasRunningImplJob returns false when .rl/jobs/ is missing" { + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + const path = try tmp.dir.realpathAlloc(std.testing.allocator, "."); + defer std.testing.allocator.free(path); + try std.testing.expect(!hasRunningImplJob(std.testing.allocator, path)); +} + +test "hasRunningImplJob detects running impl job" { + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + try tmp.dir.makePath(".rl/jobs"); + try writeFixtureJob(tmp.dir, "impl-packet-abc.json", "{\"kind\":\"implement\",\"status\":\"running\"}"); + const path = try tmp.dir.realpathAlloc(std.testing.allocator, "."); + defer std.testing.allocator.free(path); + try std.testing.expect(hasRunningImplJob(std.testing.allocator, path)); +} + +test "hasRunningImplJob detects queued impl job" { + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + try tmp.dir.makePath(".rl/jobs"); + try writeFixtureJob(tmp.dir, "impl-queued.json", "{\"status\":\"queued\"}"); + const path = try tmp.dir.realpathAlloc(std.testing.allocator, "."); + defer std.testing.allocator.free(path); + try std.testing.expect(hasRunningImplJob(std.testing.allocator, path)); +} + +test "hasRunningImplJob ignores completed impl jobs" { + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + try tmp.dir.makePath(".rl/jobs"); + try writeFixtureJob(tmp.dir, "impl-done.json", "{\"status\":\"completed\"}"); + try writeFixtureJob(tmp.dir, "impl-fail.json", "{\"status\":\"failed\"}"); + try writeFixtureJob(tmp.dir, "impl-cancel.json", "{\"status\":\"cancelled\"}"); + const path = try tmp.dir.realpathAlloc(std.testing.allocator, "."); + defer std.testing.allocator.free(path); + try std.testing.expect(!hasRunningImplJob(std.testing.allocator, path)); +} + +test "hasRunningImplJob ignores review jobs" { + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + try tmp.dir.makePath(".rl/jobs"); + // A running review job should NOT trigger the impl glyph. + try writeFixtureJob(tmp.dir, "review-123-abc.json", "{\"status\":\"running\"}"); + const path = try tmp.dir.realpathAlloc(std.testing.allocator, "."); + defer std.testing.allocator.free(path); + try std.testing.expect(!hasRunningImplJob(std.testing.allocator, path)); +} + +test "hasRunningImplJob short-circuits on first running job" { + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + try tmp.dir.makePath(".rl/jobs"); + // Mixed — at least one running impl should return true. + try writeFixtureJob(tmp.dir, "impl-done-1.json", "{\"status\":\"completed\"}"); + try writeFixtureJob(tmp.dir, "impl-running-2.json", "{\"status\":\"running\"}"); + try writeFixtureJob(tmp.dir, "impl-done-3.json", "{\"status\":\"failed\"}"); + const path = try tmp.dir.realpathAlloc(std.testing.allocator, "."); + defer std.testing.allocator.free(path); + try std.testing.expect(hasRunningImplJob(std.testing.allocator, path)); +} + +test "hasRunningImplJob ignores non-json files and non-impl prefixes" { + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + try tmp.dir.makePath(".rl/jobs"); + // Files that should be skipped: + try writeFixtureJob(tmp.dir, "impl-running.txt", "{\"status\":\"running\"}"); // wrong extension + try writeFixtureJob(tmp.dir, "not-impl.json", "{\"status\":\"running\"}"); // wrong prefix + const path = try tmp.dir.realpathAlloc(std.testing.allocator, "."); + defer std.testing.allocator.free(path); + try std.testing.expect(!hasRunningImplJob(std.testing.allocator, path)); +} + +test "formatImplWorker emits glyph when running job exists" { + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + try tmp.dir.makePath(".rl/jobs"); + try writeFixtureJob(tmp.dir, "impl-xyz.json", "{\"status\":\"running\"}"); + const path = try tmp.dir.realpathAlloc(std.testing.allocator, "."); + defer std.testing.allocator.free(path); + + var buf: [128]u8 = undefined; + var stream = std.io.fixedBufferStream(&buf); + const wrote = try formatImplWorker(stream.writer(), std.testing.allocator, path); + try std.testing.expect(wrote); + try std.testing.expect(std.mem.indexOf(u8, stream.getWritten(), glyphs.impl) != null); +} + +test "formatImplWorker writes nothing when no impl workers" { + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + const path = try tmp.dir.realpathAlloc(std.testing.allocator, "."); + defer std.testing.allocator.free(path); + + var buf: [128]u8 = undefined; + var stream = std.io.fixedBufferStream(&buf); + const wrote = try formatImplWorker(stream.writer(), std.testing.allocator, path); + try std.testing.expect(!wrote); + try std.testing.expectEqual(@as(usize, 0), stream.getWritten().len); +} + test "parseYamlBool function" { try std.testing.expectEqual(true, parseYamlBool("active: true", "active:")); try std.testing.expectEqual(false, parseYamlBool("active: false", "active:")); From fef685e3acef4cc828afa5f4c92216f29b008457 Mon Sep 17 00:00:00 2001 From: 0xBigBoss <95193764+0xBigBoss@users.noreply.github.com> Date: Mon, 13 Apr 2026 06:15:05 -0500 Subject: [PATCH 33/57] chore(skills): drop references to removed specalign skill The specalign skill no longer exists. Remove the row from the CLAUDE.md skills table and reword two spec-best-practices passages that pointed at it to describe the drift-detection behavior directly. --- .claude/skills/spec-best-practices/SKILL.md | 4 ++-- CLAUDE.md | 1 - 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/.claude/skills/spec-best-practices/SKILL.md b/.claude/skills/spec-best-practices/SKILL.md index 2383367..8f6bebf 100644 --- a/.claude/skills/spec-best-practices/SKILL.md +++ b/.claude/skills/spec-best-practices/SKILL.md @@ -48,7 +48,7 @@ Specs are freeform markdown. No rigid template, no YAML frontmatter, no required **Retroactive specs are first-class**: documenting existing behavior is valid and encouraged. Read the implementation, extract requirements from actual behavior, note inconsistencies as open items (not silent omissions), map traceability to existing tests. -**Mutation policy**: do not edit a spec without explicit user direction. When drift is found, surface it immediately using `specalign` patterns. Never silently tolerate or fix drift — the user decides whether to update spec or code. +**Mutation policy**: do not edit a spec without explicit user direction. When spec/implementation drift is found, surface it immediately. Never silently tolerate or fix drift — the user decides whether to update spec or code. **Spec vs. plan**: specs describe what and why; plans describe how and when. Plans are ephemeral. Absorb durable decisions into the spec; delete the plan doc. @@ -56,6 +56,6 @@ Specs are freeform markdown. No rigid template, no YAML frontmatter, no required **Creation (SPEC gate)**: see ADF SPEC gate in CLAUDE.md for gate requirements. Determine placement, read existing file and identify gaps (or create at the correct colocated path), ensure all required elements are present. -**Maintenance**: update spec when behavior changes; append new `REQ-*` IDs, never renumber; add test traceability as tests are written; run `specalign` when spec and implementation are both in context. +**Maintenance**: update spec when behavior changes; append new `REQ-*` IDs, never renumber; add test traceability as tests are written; cross-check spec against implementation whenever both are in context and surface drift. **Retirement**: when a feature is removed, remove or archive its `SPEC.md`. Do not leave stale specs describing deleted behavior. diff --git a/CLAUDE.md b/CLAUDE.md index 31904b3..0ea3f26 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -78,7 +78,6 @@ Load relevant best-practices skills immediately when working with supported lang | Atlas (`atlas.hcl`, `.hcl` schema, Atlas CLI) | atlas-best-practices | | SPEC.md authoring | spec-best-practices | | Test design from specs | testing-best-practices | -| Spec vs implementation drift | specalign | | Git operations | git-best-practices | ## Communication style From af26cb4ca238d5a3cfccf5da4355772c629fe197 Mon Sep 17 00:00:00 2001 From: 0xBigBoss <95193764+0xBigBoss@users.noreply.github.com> Date: Thu, 16 Apr 2026 15:26:27 -0500 Subject: [PATCH 34/57] chore(skills): add devctl, rl-cancel, rl-review, test-ping links --- .claude/skills/devctl-auth | 1 + .claude/skills/devctl-connect | 1 + .claude/skills/devctl-disconnect | 1 + .claude/skills/devctl-down | 1 + .claude/skills/devctl-env | 1 + .claude/skills/devctl-forward | 1 + .claude/skills/devctl-heartbeat | 1 + .claude/skills/devctl-hetzner | 1 + .claude/skills/devctl-init | 1 + .claude/skills/devctl-kubeconfig | 1 + .claude/skills/devctl-reaper | 1 + .claude/skills/devctl-ssh | 1 + .claude/skills/devctl-status | 1 + .claude/skills/devctl-sync | 1 + .claude/skills/devctl-up | 1 + .claude/skills/rl-cancel | 1 + .claude/skills/rl-review | 1 + .claude/skills/test-ping | 1 + 18 files changed, 18 insertions(+) create mode 120000 .claude/skills/devctl-auth create mode 120000 .claude/skills/devctl-connect create mode 120000 .claude/skills/devctl-disconnect create mode 120000 .claude/skills/devctl-down create mode 120000 .claude/skills/devctl-env create mode 120000 .claude/skills/devctl-forward create mode 120000 .claude/skills/devctl-heartbeat create mode 120000 .claude/skills/devctl-hetzner create mode 120000 .claude/skills/devctl-init create mode 120000 .claude/skills/devctl-kubeconfig create mode 120000 .claude/skills/devctl-reaper create mode 120000 .claude/skills/devctl-ssh create mode 120000 .claude/skills/devctl-status create mode 120000 .claude/skills/devctl-sync create mode 120000 .claude/skills/devctl-up create mode 120000 .claude/skills/rl-cancel create mode 120000 .claude/skills/rl-review create mode 120000 .claude/skills/test-ping diff --git a/.claude/skills/devctl-auth b/.claude/skills/devctl-auth new file mode 120000 index 0000000..64c5e0e --- /dev/null +++ b/.claude/skills/devctl-auth @@ -0,0 +1 @@ +../../../../../.agents/skills/devctl-auth \ No newline at end of file diff --git a/.claude/skills/devctl-connect b/.claude/skills/devctl-connect new file mode 120000 index 0000000..063271a --- /dev/null +++ b/.claude/skills/devctl-connect @@ -0,0 +1 @@ +../../../../../.agents/skills/devctl-connect \ No newline at end of file diff --git a/.claude/skills/devctl-disconnect b/.claude/skills/devctl-disconnect new file mode 120000 index 0000000..a40d42f --- /dev/null +++ b/.claude/skills/devctl-disconnect @@ -0,0 +1 @@ +../../../../../.agents/skills/devctl-disconnect \ No newline at end of file diff --git a/.claude/skills/devctl-down b/.claude/skills/devctl-down new file mode 120000 index 0000000..d81f4a6 --- /dev/null +++ b/.claude/skills/devctl-down @@ -0,0 +1 @@ +../../../../../.agents/skills/devctl-down \ No newline at end of file diff --git a/.claude/skills/devctl-env b/.claude/skills/devctl-env new file mode 120000 index 0000000..bda77ef --- /dev/null +++ b/.claude/skills/devctl-env @@ -0,0 +1 @@ +../../../../../.agents/skills/devctl-env \ No newline at end of file diff --git a/.claude/skills/devctl-forward b/.claude/skills/devctl-forward new file mode 120000 index 0000000..4a6debf --- /dev/null +++ b/.claude/skills/devctl-forward @@ -0,0 +1 @@ +../../../../../.agents/skills/devctl-forward \ No newline at end of file diff --git a/.claude/skills/devctl-heartbeat b/.claude/skills/devctl-heartbeat new file mode 120000 index 0000000..658fafc --- /dev/null +++ b/.claude/skills/devctl-heartbeat @@ -0,0 +1 @@ +../../../../../.agents/skills/devctl-heartbeat \ No newline at end of file diff --git a/.claude/skills/devctl-hetzner b/.claude/skills/devctl-hetzner new file mode 120000 index 0000000..4b1ee70 --- /dev/null +++ b/.claude/skills/devctl-hetzner @@ -0,0 +1 @@ +../../../../../.agents/skills/devctl-hetzner \ No newline at end of file diff --git a/.claude/skills/devctl-init b/.claude/skills/devctl-init new file mode 120000 index 0000000..4f5afb6 --- /dev/null +++ b/.claude/skills/devctl-init @@ -0,0 +1 @@ +../../../../../.agents/skills/devctl-init \ No newline at end of file diff --git a/.claude/skills/devctl-kubeconfig b/.claude/skills/devctl-kubeconfig new file mode 120000 index 0000000..e7393e2 --- /dev/null +++ b/.claude/skills/devctl-kubeconfig @@ -0,0 +1 @@ +../../../../../.agents/skills/devctl-kubeconfig \ No newline at end of file diff --git a/.claude/skills/devctl-reaper b/.claude/skills/devctl-reaper new file mode 120000 index 0000000..be65bce --- /dev/null +++ b/.claude/skills/devctl-reaper @@ -0,0 +1 @@ +../../../../../.agents/skills/devctl-reaper \ No newline at end of file diff --git a/.claude/skills/devctl-ssh b/.claude/skills/devctl-ssh new file mode 120000 index 0000000..93214a5 --- /dev/null +++ b/.claude/skills/devctl-ssh @@ -0,0 +1 @@ +../../../../../.agents/skills/devctl-ssh \ No newline at end of file diff --git a/.claude/skills/devctl-status b/.claude/skills/devctl-status new file mode 120000 index 0000000..9c85fa7 --- /dev/null +++ b/.claude/skills/devctl-status @@ -0,0 +1 @@ +../../../../../.agents/skills/devctl-status \ No newline at end of file diff --git a/.claude/skills/devctl-sync b/.claude/skills/devctl-sync new file mode 120000 index 0000000..051d565 --- /dev/null +++ b/.claude/skills/devctl-sync @@ -0,0 +1 @@ +../../../../../.agents/skills/devctl-sync \ No newline at end of file diff --git a/.claude/skills/devctl-up b/.claude/skills/devctl-up new file mode 120000 index 0000000..af23f46 --- /dev/null +++ b/.claude/skills/devctl-up @@ -0,0 +1 @@ +../../../../../.agents/skills/devctl-up \ No newline at end of file diff --git a/.claude/skills/rl-cancel b/.claude/skills/rl-cancel new file mode 120000 index 0000000..3bf43ad --- /dev/null +++ b/.claude/skills/rl-cancel @@ -0,0 +1 @@ +../../../../../.agents/skills/rl-cancel \ No newline at end of file diff --git a/.claude/skills/rl-review b/.claude/skills/rl-review new file mode 120000 index 0000000..e9605d2 --- /dev/null +++ b/.claude/skills/rl-review @@ -0,0 +1 @@ +../../../../../.agents/skills/rl-review \ No newline at end of file diff --git a/.claude/skills/test-ping b/.claude/skills/test-ping new file mode 120000 index 0000000..52e8805 --- /dev/null +++ b/.claude/skills/test-ping @@ -0,0 +1 @@ +../../../../../.agents/skills/test-ping \ No newline at end of file From 2dd0f55b04b5180ed9e8aa859535b0aad82c16b1 Mon Sep 17 00:00:00 2001 From: 0xBigBoss <95193764+0xBigBoss@users.noreply.github.com> Date: Mon, 20 Apr 2026 09:36:07 -0500 Subject: [PATCH 35/57] refactor(statusline): delegate rl segment to `rl statusline` (rl 1.10+) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the ~1300-line rl-state schema mirror in src/main.zig with a shell-out to `rl statusline --format text --cwd [--git-head ]`. rl 1.10 (0xsend/rl#6) now owns the renderer, strategy dispatch, verdict staleness, and impl-worker glyph — one source of truth instead of a drifting mirror. - src/main.zig: 2906 → 1584 lines. Delete RalphState, Strategy, MetricDirection, VerdictState, JobStatus, VerdictRaw and their parse/format/test helpers. Introduce renderRlStatusline with a PATH probe and graceful fail-open on missing/failing rl. - SPEC.md: mark REQ-SL-020..024 / 030..039 / 060..071 SUPERSEDED in place (retain for traceability) and add REQ-SL-080..083 for the delegated contract. - CLAUDE.md: update data-source note to point at `rl statusline`. All 24 remaining tests pass; `zig build` clean. --- statusline/CLAUDE.md | 4 +- statusline/SPEC.md | 195 ++---- statusline/src/main.zig | 1442 ++------------------------------------- 3 files changed, 108 insertions(+), 1533 deletions(-) diff --git a/statusline/CLAUDE.md b/statusline/CLAUDE.md index e36816e..82df062 100644 --- a/statusline/CLAUDE.md +++ b/statusline/CLAUDE.md @@ -29,9 +29,7 @@ zig build -Doptimize=ReleaseFast ## Data Sources - Git metadata is read from the current workspace directory. -- Review gate state files are read from: - - `{git_root}/.rl/state.json` - - `{git_root}/.claude/codex-review.local.md` +- Review/loop state is rendered by `rl statusline` (PATH dependency); the statusline does not read `.rl/state.json` directly. ## Guardrails diff --git a/statusline/SPEC.md b/statusline/SPEC.md index 9a027f4..5557b65 100644 --- a/statusline/SPEC.md +++ b/statusline/SPEC.md @@ -11,7 +11,7 @@ Claude Code spawns a status-line process on every render tick. The renderer must - Surface the information the operator actually glances at mid-loop: where am I (path + branch), what's the agent doing (model, context gauge, cost, time), and what's the loop doing (rl iteration, review verdict / in-flight state). - Never crash the rendering pipeline. A bad input, a missing file, or a dead subprocess degrades to a safe fallback (`~`), not a broken prompt. -The rl CLI's 1.0 rewrite moved verdict state from a standalone `.claude/codex-review.local.md` file into `.rl/state.json`, introduced strategy-typed loops (`ralph | review | research`), and added async review workers with `review_in_flight_job_id` / `review_verdict` / `review_verdict_sha` fields. The statusline's old segment still reads the pre-1.0 files and no longer reflects what the loop is actually doing. This spec captures both the preserved behavior and the rl 1.0 alignment. +The rl CLI's 1.10 release added `rl statusline`, a first-class one-line renderer for loop state. The statusline now delegates that segment to the CLI instead of mirroring `.rl/state.json` or `.rl/jobs/*`. This spec retains the older direct-read requirements for traceability, but the active contract is the delegated 1.10+ path. ## Non-goals @@ -20,7 +20,7 @@ The rl CLI's 1.0 rewrite moved verdict state from a standalone `.claude/codex-re - Not authoritative for any data source. Every data read is best-effort and may return null / empty. - Not responsible for the rl loop's decision logic. The Stop hook in `rl` owns verdict gating; the statusline only visualizes state. - No network I/O of any kind. -- No schema migration for stale rl state files. Graceful degrade in `--debug` mode; do not try to repair. +- No schema migration or repair logic for rl loop state. `rl statusline` owns schema evolution; this renderer only shells out and renders stdout. ## Domain model @@ -38,7 +38,7 @@ stdin (JSON StatuslineInput) │ Segment pipeline (writes into a 1 KiB output buffer) │ │ │ │ path + branch + git-status │ -│ rl loop segment (from .rl/state.json) │ +│ rl loop segment (from `rl statusline`) │ │ zmx session (from $ZMX_SESSION) │ │ model + gauge + usage + cost + duration + lines │ │ idle-since (from ~/.claude/.idle-since-*)│ @@ -54,8 +54,6 @@ stdin (JSON StatuslineInput) - `ContextUsage` — `{ percentage, total_tokens }`. Renders a 5-char, 40-step eighth-block gauge with an RGB gradient (green → yellow → red). - `ModelType` — `opus | sonnet | haiku | unknown`. Drives the model glyph (`🎭📜🍃?`). - `GitStatus` — `{ added, modified, deleted, untracked }`. Parsed from `git status --porcelain`. -- `RalphState` (pre-1.0) — `{ active, iteration, max_iterations, review_enabled, review_count, max_review_cycles }`. Read from `{git_root}/.rl/state.json`. -- `CodexReviewState` (deprecated; removed in rl 1.0 alignment) — YAML frontmatter reader for `{git_root}/.claude/codex-review.local.md`. ### rl 1.0 state schema (source: `~/0xbigboss/rl/SPEC.md:387-418`) @@ -89,7 +87,7 @@ interface LoopState { } ``` -The statusline reads a subset: everything needed to render one segment, nothing more. +Historical context only. The statusline no longer parses this schema directly; `rl statusline` owns it. ## Invariants @@ -97,7 +95,7 @@ The statusline reads a subset: everything needed to render one segment, nothing - **I-2 Crash-free.** Any error in any segment must be swallowed into "skip that segment" or, at worst, into the `~\n` fallback. A return code of 0 is always produced (subject to OS limits). - **I-3 Sub-process budget.** All `git` subprocess calls run against the workspace `current_dir`. No arbitrary shell. No network. Timeouts are implicit (Claude Code kills slow renders). - **I-4 Read-only.** The statusline never writes to state files, rl files, or the repo. Only writes are to `/tmp/statusline-debug.log` when `--debug` is set. -- **I-5 File reads are bounded.** Every file read caps the byte count (4 KiB for `.rl/state.json`, 512 KiB tail for transcripts). +- **I-5 File reads are bounded.** Every direct file read caps the byte count (512 KiB tail for transcripts). The delegated rl subprocess caps captured stdout at 1 KiB. - **I-6 Unknown fields are ignored.** All JSON parses use `ignore_unknown_fields = true`. Schema additions upstream must not break the statusline. - **I-7 Empty segments are hidden.** A segment that has nothing interesting to say emits zero bytes (not even a leading space). @@ -120,153 +118,46 @@ The statusline reads a subset: everything needed to render one segment, nothing ### rl loop segment (pre-1.0 — captured for baseline) -- **REQ-SL-020** (pre-1.0): Read `{git_root}/.rl/state.json` as JSON (first 4 KiB) into `RalphState` with `ignore_unknown_fields`. On any failure return the default (inactive) state. -- **REQ-SL-021** (pre-1.0): When `state.active == false`, emit nothing. -- **REQ-SL-022** (pre-1.0): When active, emit ` 🔄 {color}{iteration}/{max_iterations}{reset}` where color follows `progressColor` (green <50%, yellow <80%, red ≥80%). -- **REQ-SL-023** (pre-1.0): When `review_enabled`, additionally emit ` 🔍 {color}{review_count}/{max_review_cycles}{reset}` using the same color rule. -- **REQ-SL-024** (deprecated, removed in rl 1.0 alignment): Read `{git_root}/.claude/codex-review.local.md` YAML frontmatter for a standalone `🔎` Codex review segment. +- **REQ-SL-020** (SUPERSEDED by REQ-SL-080; pre-1.0): Read `{git_root}/.rl/state.json` as JSON (first 4 KiB) into `RalphState`. +- **REQ-SL-021** (SUPERSEDED by REQ-SL-080; pre-1.0): When `state.active == false`, emit nothing. +- **REQ-SL-022** (SUPERSEDED by REQ-SL-080; pre-1.0): Render an iteration counter from mirrored loop state. +- **REQ-SL-023** (SUPERSEDED by REQ-SL-080; pre-1.0): Render a review counter from mirrored loop state when reviews are enabled. +- **REQ-SL-024** (SUPERSEDED by REQ-SL-080; pre-1.0): Read `{git_root}/.claude/codex-review.local.md` for a standalone review segment. ### rl loop segment (rl 1.0 — initial cut, superseded by REQ-SL-060s) -These requirements shipped with the first rl 1.0 alignment commit and are retained for traceability. REQ-SL-033, REQ-SL-035, REQ-SL-036, and REQ-SL-037 are superseded by the strategy-aware design in the next section. REQ-SL-030…032, 034, 038, 039 carry forward unchanged. - -- **REQ-SL-030**: The statusline reads `.rl/state.json` v3 and recognizes the additional fields `strategy`, `review_verdict`, `review_verdict_sha`, `review_verdict_job_id`, `review_in_flight_job_id`, `metric_name`, `metric_direction`, `best_metric_value`. Missing fields default to null/0 and do not break rendering (REQ-SL-001 / I-6). -- **REQ-SL-031**: When `state.active == false`, the rl segment emits nothing regardless of other fields. -- **REQ-SL-032** (strategy glyph): When `state.active == true`, the leading glyph is selected from `strategy`: - - `ralph` → `🔁` - - `review` → `🧪` - - `research` → `🔬` - - missing/unknown → `🔁` (legacy fallback, matches pre-1.0 default) -- **REQ-SL-033** (SUPERSEDED by REQ-SL-061): Unconditional iteration counter for ralph + review. -- **REQ-SL-034** (review counter): For `ralph` and `review` strategies, when `review_enabled == true`, render ` 🔍 {color}{review_count}/{max_review_cycles}{reset}` using `progressColor`. When `review_enabled == false` the review sub-segment is omitted. -- **REQ-SL-035** (SUPERSEDED by REQ-SL-063): Verdict state glyph precedence without orphan detection or staleness. -- **REQ-SL-036** (SUPERSEDED by REQ-SL-064): Research rendering without metric direction arrow. -- **REQ-SL-037** (SUPERSEDED by REQ-SL-063): Staleness hidden from statusline. Reversed because stale verdicts silently lied about the state of HEAD (observed in `~/0xbigboss/rl` and `…/famo-classifier-alignment` on 2026-04-13). -- **REQ-SL-038** (schema version): The statusline parses `state.version` and, when `--debug` is set and `version` is present but `!= 3`, appends a single diagnostic line to `/tmp/statusline-debug.log`. No visual indication is emitted (I-7 preserves quiet degradation). -- **REQ-SL-039** (allocator hygiene): `parseRalphStateFromContent` accepts an `Allocator` parameter and uses it for all `std.json.parseFromSlice` allocations. No calls to `std.heap.page_allocator` inside parsing code. +- **REQ-SL-030** (SUPERSEDED by REQ-SL-080): Parse `.rl/state.json` v3 fields needed for the rl segment. +- **REQ-SL-031** (SUPERSEDED by REQ-SL-080): Hide the rl segment when `state.active == false`. +- **REQ-SL-032** (SUPERSEDED by REQ-SL-080): Dispatch the leading glyph from `strategy`. +- **REQ-SL-033** (SUPERSEDED by REQ-SL-080): Render an unconditional iteration counter for ralph + review. +- **REQ-SL-034** (SUPERSEDED by REQ-SL-080): Render a review counter for `ralph` / `review` when `review_enabled == true`. +- **REQ-SL-035** (SUPERSEDED by REQ-SL-080): Render a verdict state glyph from mirrored state. +- **REQ-SL-036** (SUPERSEDED by REQ-SL-080): Render research metrics from mirrored state. +- **REQ-SL-037** (SUPERSEDED by REQ-SL-080): Apply HEAD-staleness behavior inside the statusline. +- **REQ-SL-038** (SUPERSEDED by REQ-SL-080): Parse `state.version` and emit debug drift diagnostics. +- **REQ-SL-039** (SUPERSEDED by REQ-SL-080): Thread an allocator through rl-state JSON parsing helpers. ### rl loop segment (rl 1.1 — strategy-aware, orphan-aware) -Authored 2026-04-13 after reading the rl 1.1.0 strategy decision functions in `~/0xbigboss/rl/src/strategies/{ralph,review,research}.ts`. Grounded in the observation that `iteration` / `review_count` / `iteration_start_*` have different semantics per strategy, and that the statusline must mirror rl's own decision logic to stay meaningful. Field schema verified unchanged in 1.1 (`schemas.ts:74-110`) — 1.1 only adds the impl-worker track which does not appear in `state.json`. - -#### Counter semantics — truth table derived from rl source - -| Strategy | `iteration` mutated by hook? | `review_count` mutated? | `iteration_start_*` mutated? | -|---|---|---|---| -| `ralph` | yes: every Stop (`ralph.ts:139`) + every confirmed reject (`ralph.ts:245`) | yes: confirmed reject only (`ralph.ts:256`) | no — `emitIterationEnd` is declared (`shared.ts:803`) but never called anywhere in the source tree | -| `review` | **no** — no branch of `review.ts:decide` touches `iteration` | yes: confirmed reject only (`review.ts:150`) | no — same | -| `research` | yes: every Stop (`research.ts:114`, `research.ts:130`) | n/a | no — same | - -Consequence: `iteration_start_ms` encodes "rl init time", not "current iteration start". The statusline treats it as **loop age**. - -#### Requirements - -- **REQ-SL-060** (fields parsed): The statusline additionally parses `completion_claimed`, `blocked_claimed`, `metric_direction`, `iteration_start_ms`. All are optional; parse failures return defaults and never break rendering. - -- **REQ-SL-061** (strategy-dispatched layout): The rl segment dispatches on `strategy`: - - | Strategy | Layout | - |---|---| - | `ralph` / unknown | `[prefix]? 🔁 {iter_counter} {review_counter}? {verdict_state}? {age}?` | - | `review` | `[prefix]? 🧪 🔍 {review_counter} {verdict_state}? {age}?` (no iteration counter — `iteration` is never touched by the review hook, so displaying it is permanently misleading) | - | `research` | `[prefix]? 🔬 {iter_counter} {metric}? {age}?` (no review counter or verdict glyph — research loops do not gate on reviews) | - - Where: - - `iter_counter` = ` {progressColor}{iteration}/{max_iterations}{reset}` - - `review_counter` = ` {progressColor}{review_count}/{max_review_cycles}{reset}` (ralph: preceded by ` 🔍 ` glyph; review: glyph already leads the layout) - - `progressColor` is unchanged: green <50%, yellow <80%, red ≥80%. - -- **REQ-SL-062** (terminal-state prefix): When the loop is in a waiting/winding-down state, a terminal-prefix glyph is emitted before the strategy glyph. Precedence: `blocked_claimed > completion_claimed`. - - | Condition | Prefix | Meaning | - |---|---|---| - | `blocked_claimed == true` | ` 🚧` | Loop marked blocked; next Stop will cleanup. This catches the "`active: true` + `blocked_claimed: true`" anomaly observed in `~/0xbigboss/rl/.rl/state.json` on 2026-04-13. | - | `completion_claimed == true` | ` 🏁` | Agent has claimed completion. For ralph: waiting for verdict. For research: waiting for user's `rl done --keep/--discard`. | - | neither | *no prefix* | Normal running state. | - -- **REQ-SL-063** (verdict state glyph — orphan-aware + staleness-aware): For `ralph` and `review` strategies with `review_enabled == true`, a single trailing verdict glyph is emitted AFTER the review counter, resolved at parse time by the following decision procedure (mirrors `ralph.ts:185-200` / `review.ts:86-98`): - - ``` - resolveVerdictState(state, git_head): - if state.review_in_flight_job_id is non-null: - job_status = readJobStatus(git_root, review_in_flight_job_id) - if job_status in {"queued", "running"}: - return ⏳ # worker actually running - # else: orphan marker — fall through as if null - if state.review_verdict == "approve" - and state.review_verdict_sha != null - and state.review_verdict_sha == git_head: - return ✅ - if state.review_verdict == "reject" - and state.review_verdict_sha != null - and state.review_verdict_sha == git_head: - return ❌ - return blank - ``` - - This collapses "no verdict", "stale verdict", and "orphaned in-flight marker" into the same blank-glyph bucket — all three mean "no verdict you can trust for your current HEAD". The orphan branch prevents the false-positive ⏳ we hit on 2026-04-13 when the stop hook wrote an in-flight id but the worker never spawned. - - Reading the job file costs one bounded file open + small JSON parse; `git rev-parse HEAD` costs one subprocess call already amortized against the existing git calls in the path/branch segment. - -- **REQ-SL-064** (research metric with direction arrow): For `research` strategy, when `best_metric_value != null`, render ` ★{arrow}{value}` where `arrow` is `↑` if `metric_direction == "maximize"`, `↓` if `metric_direction == "minimize"`, empty string otherwise. `value` uses 3 decimal places (`{d:.3}`). Metric name is omitted — the operator's task prompt already establishes what metric is being optimized. - -- **REQ-SL-065** (loop age): When `iteration_start_ms` is present and the loop is active, render ` +{age}` after the metric/verdict segment, where `age` is the wall-clock delta from `iteration_start_ms` to now, formatted compactly: - - - `<60s` → `{N}s` - - `<60m` → `{N}m` - - `<24h` → `{N}h` or `{N}h{M}m` when `M > 0` - - `≥24h` → `{N}d` (rounded down) - - Color graded by age: green `<1h`, yellow `1h–4h`, red `≥4h`. Rationale: `iteration_start_ms` is only written at `rl init` in 1.1 (the per-iteration advance path is dead code), so this signal represents "loop age since init" — a "this loop has been open a long time" indicator for spotting forgotten or stuck loops. - -- **REQ-SL-066** (job-file reader): `readJobStatus(allocator, git_root, job_id)` reads `{git_root}/.rl/jobs/{job_id}.json`, caps the read at 4 KiB, parses `status` from the top-level object. Returns one of `queued | running | completed | failed | cancelled | missing`. File not found, parse failure, or an unexpected status string all map to `missing` — the caller treats `missing` identically to a terminal status (orphan marker). - -- **REQ-SL-067** (git HEAD reader): `getGitHead(allocator, dir)` runs `git rev-parse HEAD` in `dir` and returns the trimmed sha. Any failure returns an empty string; callers treat empty as "HEAD unknown" and the staleness check fails open (verdict glyph is NOT suppressed on an unknowable HEAD — we'd rather show a potentially stale ✅/❌ than hide an actionable signal due to a git glitch). - -- **REQ-SL-068** (strategy coupling as contract): Because the statusline mirrors the rl hook's decision logic, test fixtures must cover the exact state shapes produced by each `stateUpdates` block in the rl 1.1 strategy files: - - | rl source | State shape | Expected render | - |---|---|---| - | `ralph.ts:152-160` (iterate) | iteration++, completion_claimed=false | ` 🔁 N/max` | - | `ralph.ts:218-223` (approve) | verdict=approve, sha=HEAD | ` 🏁 🔁 N/max 🔍 K/cap ✅ +age` (if completion_claimed was set in the transition) | - | `ralph.ts:252-265` (reject) | iteration++, review_count++, verdict cleared | ` 🔁 (N+1)/max 🔍 (K+1)/cap` (no verdict glyph — worker cleared it) | - | `ralph.ts:187-194` (in-flight) | review_in_flight_job_id set, job running | ` 🔁 N/max 🔍 K/cap ⏳` | - | `review.ts:162-173` (enqueue) | review_in_flight_job_id set, job running | ` 🧪 🔍 K/cap ⏳` | - | `review.ts:144-158` (reject-iterate) | review_count++, verdict cleared | ` 🧪 🔍 (K+1)/cap` (no verdict glyph) | - | `research.ts:125-135` (iterate) | iteration++ | ` 🔬 N/max (★±value)? +age` | - | `research.ts:79-87` (blocked) | blocked_claimed=true | ` 🚧 🔬 N/max …` | - - When rl adds a new branch or changes an existing `stateUpdates` block, the corresponding fixture must be updated. That turns the implicit coupling into an explicit contract. - -- **REQ-SL-069** (glyph namespace additions): `glyphs` gains `completion` (`🏁`), `blocked` (`🚧`), `arrow_up` (`↑`), `arrow_down` (`↓`), `impl` (`🔨`). Existing glyphs unchanged. - -- **REQ-SL-070** (impl-worker visibility): `rl implement start` spawns a detached worker that does NOT touch `state.json` and does NOT require an active rl loop — observed against `~/0xbigboss/0xsend/canton-monorepo.worktrees/famo-gas-floor-ratchet` on 2026-04-13, where an impl worker was running against `.rl/jobs/impl-famo-gas-phase-1-schema-repo-tzzyst.json` with no state.json present. The statusline emits a trailing ` 🔨` whenever `hasRunningImplJob(allocator, git_root)` returns true. - - Detection procedure (`hasRunningImplJob`): - 1. Open `{git_root}/.rl/jobs/` as an iterable directory. Missing directory → return false. - 2. Iterate entries with a hard cap of 100 (prevents pathological cost in workspaces with many historical jobs). - 3. For each entry whose name starts with `impl-` AND ends with `.json`: - - Read up to 4 KiB from the file - - Parse the `status` field via `parseJobStatusFromContent` - - If status is `queued` or `running` → return true (short-circuit) - 4. If no running impl job found → return false. - - The impl glyph renders at the tail of the rl segment area, emitted by a sibling function (`formatImplWorker`) called after `RalphState.format` — deliberately independent of loop state so an impl worker is visible even when no loop is initialized. When the rl segment also renders, the glyphs concatenate naturally: - - ``` - # rl 1.1 loop + impl worker running - 🧪 🔍 2/30 ⏳ 🔨 +1h - - # no state.json, just an impl worker - ~/foo/bar [main] 🔨 - - # ralph loop + impl worker - 🔁 5/30 🔍 0/10 🔨 +30m - ``` - - Review-kind jobs (`review-*.json`) are explicitly ignored by the prefix filter — the rl segment already tracks review in-flight via `review_in_flight_job_id`. The impl glyph is strictly for the parallel impl-worker track introduced in rl 1.1. - -- **REQ-SL-071** (acceptance: impl glyph renders without state.json): The acceptance contract for REQ-SL-070 explicitly requires that the glyph renders in workspaces that have `.rl/jobs/impl-*.json` but NO `.rl/state.json`. Fixture tests cover this scenario. +- **REQ-SL-060** (SUPERSEDED by REQ-SL-080): Parse additional loop-state fields (`completion_claimed`, `blocked_claimed`, `metric_direction`, `iteration_start_ms`). +- **REQ-SL-061** (SUPERSEDED by REQ-SL-080): Dispatch layout by `strategy`. +- **REQ-SL-062** (SUPERSEDED by REQ-SL-080): Render terminal-state prefixes from mirrored loop flags. +- **REQ-SL-063** (SUPERSEDED by REQ-SL-080): Resolve verdict glyphs by reading `.rl/jobs/*` and comparing `review_verdict_sha` to `git HEAD`. +- **REQ-SL-064** (SUPERSEDED by REQ-SL-080): Render research metrics with direction arrows. +- **REQ-SL-065** (SUPERSEDED by REQ-SL-080): Render loop age from `iteration_start_ms`. +- **REQ-SL-066** (SUPERSEDED by REQ-SL-080): Read `{git_root}/.rl/jobs/{job_id}.json` to derive job state. +- **REQ-SL-067** (SUPERSEDED by REQ-SL-080): Probe `git HEAD` for verdict staleness checks. +- **REQ-SL-068** (SUPERSEDED by REQ-SL-080): Maintain strategy-coupled fixture coverage for mirrored rl logic. +- **REQ-SL-069** (SUPERSEDED by REQ-SL-080): Maintain rl-specific glyph constants in `src/main.zig`. +- **REQ-SL-070** (SUPERSEDED by REQ-SL-080): Detect impl workers by scanning `.rl/jobs/` directly. +- **REQ-SL-071** (SUPERSEDED by REQ-SL-080): Accept workspaces that show an impl glyph without `.rl/state.json`. + +### rl loop segment (rl 1.10+ — delegated to rl statusline) + +- **REQ-SL-080** (delegation): The rl loop segment is produced by shelling out to `rl statusline --format text --cwd [--git-head ]`. The statusline emits the subprocess's stdout verbatim, prefixed by a single space. No parsing, no post-processing, no knowledge of `.rl/state.json` or `.rl/jobs/*` remains in the statusline codebase. Source of truth: `rl` CLI owns the schema, the renderer, and the strategy dispatch. Reference: 0xsend/rl#6. +- **REQ-SL-081** (fail-open): Missing `rl` binary (PATH lookup failure), spawn failure, non-zero exit, or stderr output MUST all collapse to "emit nothing" (invariants I-2, I-7). No crash, no fallback glyph, no log spam outside `--debug` mode. +- **REQ-SL-082** (subprocess budget): The rl segment adds exactly one subprocess call per render. Stdout read is capped at 1 KiB. No additional file reads from `.rl/` remain in `src/main.zig`. The git-root discovery and HEAD probe (already present for the path/branch segment) are reused, not duplicated. +- **REQ-SL-083** (tests): The new rl segment is intentionally left uncovered by design. A fake-PATH integration test would require test-only subprocess environment plumbing larger than the helper itself; the project relies on `zig build`, `zig build test`, and live smoke against the real `rl statusline` contract instead. ### Other segments (captured for traceability) @@ -305,6 +196,14 @@ rl 1.1 strategy-aware renderer (this change set — 2026-04-13): - [ ] All existing non-rl tests remain green (no regression to path/git/model/gauge/cost/idle segments). - [x] Impl-worker visibility (REQ-SL-070): `hasRunningImplJob` scans `.rl/jobs/` for `impl-*.json` with status queued/running; renders `🔨` glyph independently of state.json. Tests cover: missing dir, queued/running/completed/failed/cancelled, review-kind job filter, non-json filter, prefix filter. +rl 1.10+ delegation (this change set — 2026-04-20): + +- [x] `src/main.zig` loses RalphState, Strategy, MetricDirection, VerdictState, JobStatus, VerdictRaw + their parse/format helpers. +- [x] `renderRlStatusline` shells out to `rl statusline` with PATH probe and graceful fail-open. +- [x] SPEC REQ-SL-020..024, REQ-SL-030..039, REQ-SL-060..071 marked SUPERSEDED by REQ-SL-080. +- [x] New REQ-SL-080..083 describe the delegated contract. +- [x] `zig build test` passes; line count in `src/main.zig` drops by >= 1000 lines. + ## Risk tags - **LOW — code-only change, read-only.** No schema migration, no auth, no infra. Blast radius is the statusline renderer. diff --git a/statusline/src/main.zig b/statusline/src/main.zig index 5c0948c..5666b43 100644 --- a/statusline/src/main.zig +++ b/statusline/src/main.zig @@ -22,29 +22,6 @@ const colors = struct { const bg_reset = "\x1b[49m"; // Reset background only }; -/// Statusline-segment glyphs grouped for grepability -const glyphs = struct { - // Strategy glyphs — rl loop type indicator - const ralph = "🔁"; - const review = "🧪"; - const research = "🔬"; - // Review sub-counter (counts confirmed-reject verdicts vs max_review_cycles) - const counter = "🔍"; - // Verdict / in-flight state glyphs - const in_flight = "⏳"; - const approve = "✅"; - const reject = "❌"; - // Research metric (★{best_metric_value}) with optional direction arrow - const metric = "★"; - const arrow_up = "↑"; - const arrow_down = "↓"; - // Terminal-state prefixes — loop is winding down, not actively iterating - const completion = "🏁"; // completion_claimed: agent claims done, awaiting verdict/user - const blocked = "🚧"; // blocked_claimed: loop marked blocked, next Stop cleans up - // Impl worker (rl 1.1): background `rl implement start` job, independent of loop state - const impl = "🔨"; -}; - /// Current context usage token counts - added in v2.0.70 /// Provides accurate per-message token counts for context window calculation const CurrentUsage = struct { @@ -226,288 +203,6 @@ const ContextUsage = struct { } }; -/// Get progress color with discrete thresholds (matching colors.green/yellow/red): -/// 0-50% = green, 50-80% = yellow, 80-100% = red -fn progressColor(current: u32, max: u32) []const u8 { - if (max == 0) return colors.green; - const pct = @min(100.0, (@as(f64, @floatFromInt(current)) / @as(f64, @floatFromInt(max))) * 100.0); - - if (pct < 50.0) { - return colors.green; - } else if (pct < 80.0) { - return colors.yellow; - } else { - return colors.red; - } -} - -/// rl loop strategy — matches `strategy` field in .rl/state.json v3. -/// Each variant has distinct counter semantics that drive dispatch in `RalphState.format`. -const Strategy = enum { - ralph, - review, - research, - unknown, - - fn fromString(s: []const u8) Strategy { - if (std.mem.eql(u8, s, "ralph")) return .ralph; - if (std.mem.eql(u8, s, "review")) return .review; - if (std.mem.eql(u8, s, "research")) return .research; - return .unknown; - } - - fn glyph(self: Strategy) []const u8 { - return switch (self) { - // Legacy fallback: state files without strategy are treated as ralph-style - .ralph, .unknown => glyphs.ralph, - .review => glyphs.review, - .research => glyphs.research, - }; - } -}; - -/// Direction for a research-strategy metric (minimize/maximize). -/// Parsed from the `metric_direction` field; drives the ★-arrow glyph. -const MetricDirection = enum { - none, - maximize, - minimize, - - fn fromString(s: []const u8) MetricDirection { - if (std.mem.eql(u8, s, "maximize")) return .maximize; - if (std.mem.eql(u8, s, "minimize")) return .minimize; - return .none; - } -}; - -/// Derived verdict state for the rl segment's trailing glyph. -/// Unlike the first-cut design, this is NOT resolved at parse time — it requires -/// both the current git HEAD and optionally a job-file status check, so it's -/// resolved in `format` where those inputs are in scope. -const VerdictState = enum { - none, - in_flight, - approve, - reject, -}; - -/// Status of a background job as read from `.rl/jobs/{id}.json`. -/// `missing` covers "file not found", "parse failed", and "unknown status string". -const JobStatus = enum { - missing, - queued, - running, - completed, - failed, - cancelled, - - fn fromString(s: []const u8) JobStatus { - if (std.mem.eql(u8, s, "queued")) return .queued; - if (std.mem.eql(u8, s, "running")) return .running; - if (std.mem.eql(u8, s, "completed")) return .completed; - if (std.mem.eql(u8, s, "failed")) return .failed; - if (std.mem.eql(u8, s, "cancelled")) return .cancelled; - return .missing; - } -}; - -/// rl loop state — projected from .rl/state.json v3 (rl 1.0+). Optional fields are -/// null when the state file omits them; defaults match rl's init values where known. -/// See ~/0xbigboss/rl/src/schemas.ts LoopStateV3Schema for the authoritative shape. -/// -/// String-valued fields (`review_verdict_sha`, `review_in_flight_job_id`) are stored -/// as fixed-size stack buffers so a RalphState is entirely by-value and carries no -/// allocator lifetime. Max length 64 bytes comfortably fits a git sha (40 hex) and -/// an rl job id ("review-{ms}-{6char}" ~= 28 bytes). Overflowing strings are treated -/// as if the field were absent. -const RalphState = struct { - active: bool = false, - strategy: Strategy = .unknown, - iteration: u32 = 0, - max_iterations: u32 = 50, - review_enabled: bool = false, - review_count: u32 = 0, - max_review_cycles: u32 = 10, - // Verdict contract (rl 1.0) - review_verdict_raw: VerdictRaw = .none, - review_verdict_sha_buf: [64]u8 = undefined, - review_verdict_sha_len: u8 = 0, - review_in_flight_job_id_buf: [64]u8 = undefined, - review_in_flight_job_id_len: u8 = 0, - // Research fields - best_metric_value: ?f64 = null, - metric_direction: MetricDirection = .none, - // Terminal-state flags - completion_claimed: bool = false, - blocked_claimed: bool = false, - // Iteration audit (used for loop-age rendering) - iteration_start_ms: ?i64 = null, - /// Schema version from the `version` field, if present. Consumers use this for - /// debug-mode drift detection only; rendering never branches on it. - version: ?u32 = null, - - fn verdictSha(self: *const RalphState) ?[]const u8 { - if (self.review_verdict_sha_len == 0) return null; - return self.review_verdict_sha_buf[0..self.review_verdict_sha_len]; - } - - fn inFlightJobId(self: *const RalphState) ?[]const u8 { - if (self.review_in_flight_job_id_len == 0) return null; - return self.review_in_flight_job_id_buf[0..self.review_in_flight_job_id_len]; - } - - /// Format rl loop segment for statusline display (strategy-dispatched). - /// - `git_head`: output of `git rev-parse HEAD`, or empty for "HEAD unknown" - /// - `git_root`: workspace root; used to read job files for orphan detection - /// - `allocator`: scratch allocator for path construction and job-file reads - /// - `now_ms`: current wall-clock time in milliseconds (monotonic-ish; `std.time.milliTimestamp()`) - /// Returns true when any output was written. - fn format( - self: RalphState, - writer: anytype, - allocator: Allocator, - git_root: []const u8, - git_head: []const u8, - now_ms: i64, - ) !bool { - if (!self.active) return false; - - // Terminal-state prefix (REQ-SL-062): blocked wins over completion - if (self.blocked_claimed) { - try writer.print(" {s}", .{glyphs.blocked}); - } else if (self.completion_claimed) { - try writer.print(" {s}", .{glyphs.completion}); - } - - // Strategy glyph always leads the counter block - try writer.print(" {s}", .{self.strategy.glyph()}); - - switch (self.strategy) { - .ralph, .unknown => try self.formatRalphCounters(writer), - .review => try self.formatReviewCounters(writer), - .research => try self.formatResearchCounters(writer), - } - - // Verdict / in-flight glyph (ralph + review only, and only when review_enabled) - if ((self.strategy == .ralph or self.strategy == .unknown or self.strategy == .review) and self.review_enabled) { - const verdict_state = self.resolveVerdictState(allocator, git_root, git_head); - switch (verdict_state) { - .none => {}, - .in_flight => try writer.print(" {s}", .{glyphs.in_flight}), - .approve => try writer.print(" {s}", .{glyphs.approve}), - .reject => try writer.print(" {s}", .{glyphs.reject}), - } - } - - // Research metric with optional direction arrow (REQ-SL-064) - if (self.strategy == .research) { - if (self.best_metric_value) |v| { - const arrow: []const u8 = switch (self.metric_direction) { - .maximize => glyphs.arrow_up, - .minimize => glyphs.arrow_down, - .none => "", - }; - try writer.print(" {s}{s}{d:.3}", .{ glyphs.metric, arrow, v }); - } - } - - // Loop age from iteration_start_ms (REQ-SL-065) - if (self.iteration_start_ms) |start_ms| { - if (now_ms > start_ms) { - try formatLoopAge(writer, now_ms - start_ms); - } - } - - return true; - } - - /// Ralph layout: iteration/max_iterations as the primary counter, optional review counter. - /// Rationale: `iteration` is advanced by `ralph.ts:139` on every Stop and `ralph.ts:245` on every - /// reject, so it's the meaningful "how far through my loop am I" signal the original author intended. - fn formatRalphCounters(self: RalphState, writer: anytype) !void { - try writer.print(" {s}{d}/{d}{s}", .{ - progressColor(self.iteration, self.max_iterations), - self.iteration, - self.max_iterations, - colors.reset, - }); - if (self.review_enabled) { - try writer.print(" {s} {s}{d}/{d}{s}", .{ - glyphs.counter, - progressColor(self.review_count, self.max_review_cycles), - self.review_count, - self.max_review_cycles, - colors.reset, - }); - } - } - - /// Review layout: review_count/max_review_cycles ONLY. The review strategy's Stop hook - /// (~/0xbigboss/rl/src/strategies/review.ts) never touches `iteration`, so rendering it - /// would always read 0 (or whatever init set) — permanent dead signal. Hide it. - fn formatReviewCounters(self: RalphState, writer: anytype) !void { - if (self.review_enabled) { - try writer.print(" {s} {s}{d}/{d}{s}", .{ - glyphs.counter, - progressColor(self.review_count, self.max_review_cycles), - self.review_count, - self.max_review_cycles, - colors.reset, - }); - } - } - - /// Research layout: iteration/max_iterations counts experiments. No review counter - /// (research loops don't gate on review). Metric and arrow render after counters in `format`. - fn formatResearchCounters(self: RalphState, writer: anytype) !void { - try writer.print(" {s}{d}/{d}{s}", .{ - progressColor(self.iteration, self.max_iterations), - self.iteration, - self.max_iterations, - colors.reset, - }); - } - - /// Resolve the trailing verdict glyph per REQ-SL-063. Mirrors the rl Stop hook's - /// decision procedure so what the statusline shows matches what the hook will do next. - /// - /// Priority: - /// 1. in_flight_job_id set AND job status is queued/running → .in_flight - /// (orphan markers where the job file is missing or terminal fall through) - /// 2. verdict == approve AND verdict_sha matches HEAD → .approve - /// 3. verdict == reject AND verdict_sha matches HEAD → .reject - /// 4. otherwise → .none (stale, orphan, null, or HEAD-unknown all collapse here) - fn resolveVerdictState( - self: *const RalphState, - allocator: Allocator, - git_root: []const u8, - git_head: []const u8, - ) VerdictState { - if (self.inFlightJobId()) |job_id| { - const status = readJobStatus(allocator, git_root, job_id); - if (status == .queued or status == .running) return .in_flight; - // Orphan marker (missing/completed/failed/cancelled) — fall through - } - - // Staleness check: we only honor verdicts whose sha matches current HEAD. - // An empty git_head means `git rev-parse HEAD` failed — fail OPEN and - // render the stored verdict anyway, since hiding an actionable signal - // because of a git glitch is worse than showing a potentially stale one. - const verdict_sha = self.verdictSha() orelse return .none; - if (git_head.len > 0 and !std.mem.eql(u8, verdict_sha, git_head)) return .none; - - return switch (self.review_verdict_raw) { - .none => .none, - .approve => .approve, - .reject => .reject, - }; - } -}; - -/// Raw verdict string parsed from state.json. Kept separate from VerdictState because -/// the displayable state requires cross-checking sha + in-flight job status. -const VerdictRaw = enum { none, approve, reject }; - /// Git file status representation const GitStatus = struct { added: u32 = 0, @@ -1011,147 +706,9 @@ fn getGitRoot(allocator: Allocator, dir: []const u8) !?[]const u8 { return result; } -/// Parse a YAML boolean value from a line -fn parseYamlBool(line: []const u8, key: []const u8) ?bool { - if (!std.mem.startsWith(u8, line, key)) return null; - const value = std.mem.trim(u8, line[key.len..], " \t"); - if (std.mem.eql(u8, value, "true")) return true; - if (std.mem.eql(u8, value, "false")) return false; - return null; -} - -/// Parse a YAML integer value from a line -fn parseYamlInt(line: []const u8, key: []const u8) ?u32 { - if (!std.mem.startsWith(u8, line, key)) return null; - const value = std.mem.trim(u8, line[key.len..], " \t"); - return std.fmt.parseInt(u32, value, 10) catch null; -} - -/// Copy a string slice into a fixed-size stack buffer, returning the bytes written. -/// If the source exceeds the buffer, returns 0 (treated as "absent" by the accessors). -fn copyIntoFixedBuf(dest: []u8, src: []const u8) u8 { - if (src.len == 0 or src.len > dest.len) return 0; - @memcpy(dest[0..src.len], src); - return @intCast(src.len); -} - -/// Parse rl loop state from JSON content string. -/// Exposed for testing. Returns default (inactive) RalphState if parsing fails. -/// The returned state is entirely by-value — all string fields are copied into -/// stack buffers on the struct, so the result is safe to use after the allocator -/// is freed. -fn parseRalphStateFromContent(allocator: Allocator, content: []const u8) RalphState { - var state = RalphState{}; - - // Mirror of .rl/state.json v3. See ~/0xbigboss/rl/src/schemas.ts LoopStateV3Schema. - // All fields optional so unknown/absent keys degrade gracefully (I-6). - const JsonState = struct { - version: ?u32 = null, - strategy: ?[]const u8 = null, - active: ?bool = null, - iteration: ?u32 = null, - max_iterations: ?u32 = null, - review_enabled: ?bool = null, - review_count: ?u32 = null, - max_review_cycles: ?u32 = null, - review_verdict: ?[]const u8 = null, - review_verdict_sha: ?[]const u8 = null, - review_in_flight_job_id: ?[]const u8 = null, - best_metric_value: ?f64 = null, - metric_direction: ?[]const u8 = null, - completion_claimed: ?bool = null, - blocked_claimed: ?bool = null, - iteration_start_ms: ?i64 = null, - }; - - const parsed = std.json.parseFromSlice(JsonState, allocator, content, .{ - .ignore_unknown_fields = true, - }) catch return state; - defer parsed.deinit(); - - const v = parsed.value; - if (v.version) |ver| state.version = ver; - if (v.active) |a| state.active = a; - if (v.iteration) |i| state.iteration = i; - if (v.max_iterations) |m| state.max_iterations = m; - if (v.review_enabled) |r| state.review_enabled = r; - if (v.review_count) |r| state.review_count = r; - if (v.max_review_cycles) |m| state.max_review_cycles = m; - if (v.best_metric_value) |b| state.best_metric_value = b; - if (v.completion_claimed) |c| state.completion_claimed = c; - if (v.blocked_claimed) |b| state.blocked_claimed = b; - if (v.iteration_start_ms) |ms| state.iteration_start_ms = ms; - if (v.strategy) |s| state.strategy = Strategy.fromString(s); - if (v.metric_direction) |d| state.metric_direction = MetricDirection.fromString(d); - - if (v.review_verdict_sha) |sha| { - state.review_verdict_sha_len = copyIntoFixedBuf(&state.review_verdict_sha_buf, sha); - } - if (v.review_in_flight_job_id) |id| { - state.review_in_flight_job_id_len = copyIntoFixedBuf(&state.review_in_flight_job_id_buf, id); - } - if (v.review_verdict) |verdict| { - if (std.mem.eql(u8, verdict, "approve")) { - state.review_verdict_raw = .approve; - } else if (std.mem.eql(u8, verdict, "reject")) { - state.review_verdict_raw = .reject; - } - } - - return state; -} - -/// Parse rl loop state from .rl/state.json at git root. -/// Returns default (inactive) state on any I/O or parse failure. -/// The returned RalphState is by-value and safe to use independently of the allocator. -fn parseRalphState(allocator: Allocator, git_root: []const u8) RalphState { - const path = std.fmt.allocPrint(allocator, "{s}/.rl/state.json", .{git_root}) catch return RalphState{}; - defer allocator.free(path); - - // 4 KiB cap: observed v3 state.json files are ~600 bytes; 4 KiB gives >6x headroom. - const file = std.fs.cwd().openFile(path, .{}) catch return RalphState{}; - defer file.close(); - - var buf: [4096]u8 = undefined; - const bytes_read = file.read(&buf) catch return RalphState{}; - if (bytes_read == 0) return RalphState{}; - - return parseRalphStateFromContent(allocator, buf[0..bytes_read]); -} - -/// Read a background job's status field from `.rl/jobs/{job_id}.json`. -/// Returns `.missing` on any failure (file not found, parse error, unexpected string, -/// oversize file). Used for orphan detection on `review_in_flight_job_id`. -/// Cost: one allocPrint, one openFile, one 4 KiB read, one JSON parse. -fn readJobStatus(allocator: Allocator, git_root: []const u8, job_id: []const u8) JobStatus { - const path = std.fmt.allocPrint(allocator, "{s}/.rl/jobs/{s}.json", .{ git_root, job_id }) catch return .missing; - defer allocator.free(path); - - const file = std.fs.cwd().openFile(path, .{}) catch return .missing; - defer file.close(); - - var buf: [4096]u8 = undefined; - const bytes_read = file.read(&buf) catch return .missing; - if (bytes_read == 0) return .missing; - - return parseJobStatusFromContent(allocator, buf[0..bytes_read]); -} - -/// Exposed for testing. Parses the `status` field from a job JSON blob. -fn parseJobStatusFromContent(allocator: Allocator, content: []const u8) JobStatus { - const JobFile = struct { status: ?[]const u8 = null }; - const parsed = std.json.parseFromSlice(JobFile, allocator, content, .{ - .ignore_unknown_fields = true, - }) catch return .missing; - defer parsed.deinit(); - - const status_str = parsed.value.status orelse return .missing; - return JobStatus.fromString(status_str); -} - /// Run `git rev-parse HEAD` in `dir`. Returns empty string on any failure. /// Caller receives an allocator-owned slice; free with `allocator.free` or rely on arena. -/// Callers treat empty-string as "HEAD unknown" — the verdict staleness check fails open. +/// Callers treat empty-string as "HEAD unknown" and omit `--git-head`. fn getGitHead(allocator: Allocator, dir: []const u8) []const u8 { var buf: [256]u8 = undefined; var fba = std.heap.FixedBufferAllocator.init(&buf); @@ -1160,100 +717,74 @@ fn getGitHead(allocator: Allocator, dir: []const u8) []const u8 { return execCommand(allocator, cmd, dir) catch ""; } -/// Scan `.rl/jobs/` for a currently-running `rl implement` worker. Returns true -/// as soon as any file named `impl-*.json` reports status `queued` or `running`. +/// Delegate the rl loop segment to `rl statusline`. /// -/// Implementation independent of state.json: `rl implement start` spawns a worker -/// even when no rl loop is initialized, so the impl indicator must render on job -/// presence alone. Iteration is bounded (max 100 entries) to keep the hot path -/// predictable for long-running workspaces with many historical jobs. -/// -/// Fail-closed on any filesystem or parse error: treat anything unexpected as -/// "no running impl worker" rather than surfacing a misleading glyph. -fn hasRunningImplJob(allocator: Allocator, git_root: []const u8) bool { - const dir_path = std.fmt.allocPrint(allocator, "{s}/.rl/jobs", .{git_root}) catch return false; - defer allocator.free(dir_path); - - var dir = std.fs.cwd().openDir(dir_path, .{ .iterate = true }) catch return false; - defer dir.close(); - - var iter = dir.iterate(); - var scanned: u32 = 0; - while (iter.next() catch null) |entry| { - if (scanned >= 100) break; // bounded scan - scanned += 1; - - if (entry.kind != .file) continue; - if (!std.mem.startsWith(u8, entry.name, "impl-")) continue; - if (!std.mem.endsWith(u8, entry.name, ".json")) continue; - - const file = dir.openFile(entry.name, .{}) catch continue; - defer file.close(); - - var buf: [4096]u8 = undefined; - const bytes_read = file.read(&buf) catch continue; - if (bytes_read == 0) continue; - - const status = parseJobStatusFromContent(allocator, buf[0..bytes_read]); - if (status == .queued or status == .running) return true; +/// Covered by live smoke rather than a fake-PATH unit test: adding test-only PATH +/// injection plumbing would create more surface area than this helper itself. The +/// contract is verified against the real `rl` CLI, and the renderer stays fail-open. +fn renderRlStatusline( + allocator: Allocator, + writer: anytype, + git_root: []const u8, + git_head: []const u8, +) !void { + var argv_buf: [8][]const u8 = undefined; + var argc: usize = 0; + argv_buf[argc] = "rl"; + argc += 1; + argv_buf[argc] = "statusline"; + argc += 1; + argv_buf[argc] = "--format"; + argc += 1; + argv_buf[argc] = "text"; + argc += 1; + argv_buf[argc] = "--cwd"; + argc += 1; + argv_buf[argc] = git_root; + argc += 1; + if (git_head.len > 0) { + argv_buf[argc] = "--git-head"; + argc += 1; + argv_buf[argc] = git_head; + argc += 1; } - return false; -} -/// Emit the impl-worker indicator (` 🔨`) when at least one `rl implement` worker -/// is running in this workspace. Returns true if anything was written. -fn formatImplWorker(writer: anytype, allocator: Allocator, git_root: []const u8) !bool { - if (!hasRunningImplJob(allocator, git_root)) return false; - try writer.print(" {s}", .{glyphs.impl}); - return true; -} - -/// Format a loop age (in ms) as a compact ` +{N}{s|m|h|d}` string with color grading. -/// Color thresholds: green <1h, yellow <4h, red ≥4h. -fn formatLoopAge(writer: anytype, age_ms: i64) !void { - if (age_ms < 0) return; - const total_s: u64 = @intCast(@divTrunc(age_ms, 1000)); + var child = std.process.Child.init(argv_buf[0..argc], allocator); + child.stdin_behavior = .Ignore; + child.stdout_behavior = .Pipe; + child.stderr_behavior = .Ignore; + child.spawn() catch |err| switch (err) { + error.FileNotFound => return, + else => return, + }; + errdefer _ = child.kill() catch {}; - const color: []const u8 = blk: { - if (total_s < 60 * 60) break :blk colors.green; - if (total_s < 4 * 60 * 60) break :blk colors.yellow; - break :blk colors.red; + const stdout = child.stdout orelse { + _ = child.wait() catch {}; + return; }; - try writer.print(" {s}+", .{color}); - - if (total_s < 60) { - try writer.print("{d}s", .{total_s}); - } else if (total_s < 60 * 60) { - try writer.print("{d}m", .{total_s / 60}); - } else if (total_s < 24 * 60 * 60) { - const hours = total_s / 3600; - const minutes = (total_s % 3600) / 60; - if (minutes == 0) { - try writer.print("{d}h", .{hours}); - } else { - try writer.print("{d}h{d}m", .{ hours, minutes }); + var stdout_buf: [1024]u8 = undefined; + var stdout_len: usize = 0; + while (stdout_len < stdout_buf.len) { + const bytes_read = stdout.read(stdout_buf[stdout_len..]) catch break; + if (bytes_read == 0) break; + stdout_len += bytes_read; + } + + if (stdout_len == stdout_buf.len) { + var discard_buf: [256]u8 = undefined; + while (true) { + const bytes_read = stdout.read(&discard_buf) catch break; + if (bytes_read == 0) break; } - } else { - try writer.print("{d}d", .{total_s / (24 * 60 * 60)}); } - try writer.print("{s}", .{colors.reset}); -} + _ = child.wait() catch {}; + if (stdout_len == 0) return; -/// Append a single timestamped line to /tmp/statusline-debug.log. -/// Best-effort: all failure modes are swallowed so debug logging never affects rendering. -fn appendDebugLog(comptime fmt: []const u8, args: anytype) void { - const file = std.fs.cwd().createFile("/tmp/statusline-debug.log", .{ .truncate = false }) catch return; - defer file.close(); - file.seekFromEnd(0) catch return; - var file_buffer: [512]u8 = undefined; - var file_writer = file.writerStreaming(&file_buffer); - const writer = &file_writer.interface; - const timestamp = std.time.timestamp(); - writer.print("[{d}] ", .{timestamp}) catch return; - writer.print(fmt ++ "\n", args) catch return; - writer.flush() catch return; + try writer.writeByte(' '); + try writer.writeAll(stdout_buf[0..stdout_len]); } pub fn main() !void { @@ -1385,29 +916,8 @@ pub fn main() !void { if (is_git) { if (try getGitRoot(allocator, current_dir.?)) |git_root| { defer allocator.free(git_root); - - const ralph_state = parseRalphState(allocator, git_root); - - // Debug-mode drift detector (REQ-SL-038): warn when the state file - // carries a schema version we don't know about. Render still proceeds - // with legacy defaults — never fail a render on drift. - if (debug_mode) { - if (ralph_state.version) |v| { - if (v != 3) { - appendDebugLog("rl state schema version {d} != 3 — rendering with legacy defaults", .{v}); - } - } - } - - // git HEAD for verdict staleness check (REQ-SL-067). Empty on failure → fail-open. const git_head = getGitHead(allocator, current_dir.?); - const now_ms = std.time.milliTimestamp(); - _ = try ralph_state.format(writer, allocator, git_root, git_head, now_ms); - - // Impl-worker indicator (REQ-SL-070). Orthogonal to the rl segment — - // `rl implement start` spawns a worker even when no loop is active, - // so this check happens regardless of ralph_state.active. - _ = try formatImplWorker(writer, allocator, git_root); + try renderRlStatusline(allocator, writer, git_root, git_head); } } } @@ -2048,838 +1558,6 @@ test "current_usage field fallback when missing" { try std.testing.expect(parsed.value.context_window.?.current_usage == null); } -test "RalphState default values" { - const state = RalphState{}; - try std.testing.expect(!state.active); - try std.testing.expectEqual(@as(u32, 0), state.iteration); - try std.testing.expectEqual(@as(u32, 50), state.max_iterations); - try std.testing.expect(!state.review_enabled); - try std.testing.expectEqual(@as(u32, 0), state.review_count); - try std.testing.expectEqual(@as(u32, 10), state.max_review_cycles); -} - -test "RalphState progressColor thresholds" { - // 0% = green (0-50% range) - try std.testing.expectEqualStrings(colors.green, progressColor(0, 100)); - - // 49% = still green (0-50% range) - try std.testing.expectEqualStrings(colors.green, progressColor(49, 100)); - - // 50% = yellow (50-80% range) - try std.testing.expectEqualStrings(colors.yellow, progressColor(50, 100)); - - // 79% = still yellow (50-80% range) - try std.testing.expectEqualStrings(colors.yellow, progressColor(79, 100)); - - // 80% = red (80-100% range) - try std.testing.expectEqualStrings(colors.red, progressColor(80, 100)); - - // 100% = red (80-100% range) - try std.testing.expectEqualStrings(colors.red, progressColor(100, 100)); - - // Edge case: max = 0 returns green - try std.testing.expectEqualStrings(colors.green, progressColor(0, 0)); -} - -// --- rl segment rendering tests --- -// -// Shared test harness: renderRalphState wraps format() with a stack buffer so test -// cases stay tight. A non-git-repo path and empty git_head are used so the -// orphan-detection + staleness-check code paths run without touching the filesystem. - -fn renderRalphState(state: *const RalphState, buf: []u8, now_ms: i64) ![]const u8 { - var stream = std.io.fixedBufferStream(buf); - const writer = stream.writer(); - _ = try state.*.format(writer, std.testing.allocator, "/nonexistent-root", "", now_ms); - return stream.getWritten(); -} - -test "RalphState format inactive returns false and writes nothing" { - const state = RalphState{ .active = false }; - var stream_buf: [256]u8 = undefined; - var stream = std.io.fixedBufferStream(&stream_buf); - const writer = stream.writer(); - const wrote = try state.format(writer, std.testing.allocator, "/nonexistent", "", 0); - try std.testing.expect(!wrote); - try std.testing.expectEqual(@as(usize, 0), stream.getWritten().len); -} - -test "Strategy.fromString maps known values" { - try std.testing.expectEqual(Strategy.ralph, Strategy.fromString("ralph")); - try std.testing.expectEqual(Strategy.review, Strategy.fromString("review")); - try std.testing.expectEqual(Strategy.research, Strategy.fromString("research")); - try std.testing.expectEqual(Strategy.unknown, Strategy.fromString("nonsense")); - try std.testing.expectEqual(Strategy.unknown, Strategy.fromString("")); -} - -test "Strategy glyph mapping" { - try std.testing.expectEqualStrings(glyphs.ralph, Strategy.ralph.glyph()); - try std.testing.expectEqualStrings(glyphs.review, Strategy.review.glyph()); - try std.testing.expectEqualStrings(glyphs.research, Strategy.research.glyph()); - try std.testing.expectEqualStrings(glyphs.ralph, Strategy.unknown.glyph()); // legacy fallback -} - -test "JobStatus.fromString maps known values" { - try std.testing.expectEqual(JobStatus.queued, JobStatus.fromString("queued")); - try std.testing.expectEqual(JobStatus.running, JobStatus.fromString("running")); - try std.testing.expectEqual(JobStatus.completed, JobStatus.fromString("completed")); - try std.testing.expectEqual(JobStatus.failed, JobStatus.fromString("failed")); - try std.testing.expectEqual(JobStatus.cancelled, JobStatus.fromString("cancelled")); - try std.testing.expectEqual(JobStatus.missing, JobStatus.fromString("weird")); - try std.testing.expectEqual(JobStatus.missing, JobStatus.fromString("")); -} - -test "parseJobStatusFromContent with running job" { - const content = - \\{"id":"review-123-abc","kind":"review","status":"running","pid":1234} - ; - try std.testing.expectEqual(JobStatus.running, parseJobStatusFromContent(std.testing.allocator, content)); -} - -test "parseJobStatusFromContent with completed job" { - const content = "{\"status\":\"completed\"}"; - try std.testing.expectEqual(JobStatus.completed, parseJobStatusFromContent(std.testing.allocator, content)); -} - -test "parseJobStatusFromContent with missing status field" { - const content = "{\"id\":\"review-123\",\"kind\":\"review\"}"; - try std.testing.expectEqual(JobStatus.missing, parseJobStatusFromContent(std.testing.allocator, content)); -} - -test "parseJobStatusFromContent with garbage" { - try std.testing.expectEqual(JobStatus.missing, parseJobStatusFromContent(std.testing.allocator, "not json")); - try std.testing.expectEqual(JobStatus.missing, parseJobStatusFromContent(std.testing.allocator, "")); -} - -test "RalphState ralph iterate branch (ralph.ts:152-160)" { - // Matches ralph.ts:152 stateUpdates: { iteration: nextIteration, completion_claimed: false } - var state = RalphState{ - .active = true, - .strategy = .ralph, - .iteration = 3, - .max_iterations = 50, - .review_enabled = true, - .review_count = 0, - .max_review_cycles = 10, - }; - var buf: [256]u8 = undefined; - const output = try renderRalphState(&state, &buf, 0); - try std.testing.expect(std.mem.indexOf(u8, output, glyphs.ralph) != null); - try std.testing.expect(std.mem.indexOf(u8, output, "3/50") != null); - try std.testing.expect(std.mem.indexOf(u8, output, glyphs.counter) != null); - try std.testing.expect(std.mem.indexOf(u8, output, "0/10") != null); - // No verdict glyph — fresh iterate has no verdict yet - try std.testing.expect(std.mem.indexOf(u8, output, glyphs.approve) == null); - try std.testing.expect(std.mem.indexOf(u8, output, glyphs.reject) == null); -} - -test "RalphState ralph without review_enabled hides review counter" { - var state = RalphState{ - .active = true, - .strategy = .ralph, - .iteration = 5, - .max_iterations = 30, - .review_enabled = false, - }; - var buf: [256]u8 = undefined; - const output = try renderRalphState(&state, &buf, 0); - try std.testing.expect(std.mem.indexOf(u8, output, glyphs.ralph) != null); - try std.testing.expect(std.mem.indexOf(u8, output, "5/30") != null); - try std.testing.expect(std.mem.indexOf(u8, output, glyphs.counter) == null); -} - -test "RalphState ralph fresh approve verdict (ralph.ts:218-223)" { - var state = RalphState{ - .active = true, - .strategy = .ralph, - .iteration = 5, - .max_iterations = 30, - .review_enabled = true, - .review_count = 1, - .max_review_cycles = 10, - .review_verdict_raw = .approve, - .completion_claimed = true, // ralph.ts branch 4b runs only after completion claim - }; - state.review_verdict_sha_len = copyIntoFixedBuf(&state.review_verdict_sha_buf, "deadbeef"); - - // Using git_head = "deadbeef" so staleness check passes. We can't go through - // renderRalphState since it hardcodes empty git_head; call format directly. - var stream_buf: [256]u8 = undefined; - var stream = std.io.fixedBufferStream(&stream_buf); - _ = try state.format(stream.writer(), std.testing.allocator, "/nonexistent", "deadbeef", 0); - const output = stream.getWritten(); - - // Completion prefix + strategy glyph + counters + verdict glyph - try std.testing.expect(std.mem.indexOf(u8, output, glyphs.completion) != null); - try std.testing.expect(std.mem.indexOf(u8, output, glyphs.ralph) != null); - try std.testing.expect(std.mem.indexOf(u8, output, "5/30") != null); - try std.testing.expect(std.mem.indexOf(u8, output, "1/10") != null); - try std.testing.expect(std.mem.indexOf(u8, output, glyphs.approve) != null); -} - -test "RalphState ralph reject-iterate branch (ralph.ts:252-265)" { - // ralph.ts reject branch bumps iteration++, review_count++, and CLEARS the verdict - // fields. So the statusline should show incremented counters and NO verdict glyph. - var state = RalphState{ - .active = true, - .strategy = .ralph, - .iteration = 6, // was 5, now 6 - .max_iterations = 30, - .review_enabled = true, - .review_count = 2, // was 1, now 2 - .max_review_cycles = 10, - .review_verdict_raw = .none, // cleared by worker - }; - // review_verdict_sha also cleared - var buf: [256]u8 = undefined; - const output = try renderRalphState(&state, &buf, 0); - try std.testing.expect(std.mem.indexOf(u8, output, "6/30") != null); - try std.testing.expect(std.mem.indexOf(u8, output, "2/10") != null); - try std.testing.expect(std.mem.indexOf(u8, output, glyphs.reject) == null); - try std.testing.expect(std.mem.indexOf(u8, output, glyphs.approve) == null); -} - -test "RalphState review strategy layout omits iteration counter" { - // review.ts never mutates state.iteration — displaying it would be misleading. - var state = RalphState{ - .active = true, - .strategy = .review, - .iteration = 0, // permanently 0 in review strategy - .max_iterations = 30, - .review_enabled = true, - .review_count = 3, - .max_review_cycles = 30, - }; - var buf: [256]u8 = undefined; - const output = try renderRalphState(&state, &buf, 0); - try std.testing.expect(std.mem.indexOf(u8, output, glyphs.review) != null); - try std.testing.expect(std.mem.indexOf(u8, output, glyphs.counter) != null); - try std.testing.expect(std.mem.indexOf(u8, output, "3/30") != null); - // Must NOT contain "0/30" (the iteration counter) - // Note: review_count is "3/30", max_review_cycles matches max_iterations here — so the - // only way "0/30" could appear is from a rendered iteration counter. - try std.testing.expect(std.mem.indexOf(u8, output, "0/30") == null); -} - -test "RalphState review queueing-review branch (review.ts:162-173)" { - // review.ts enqueue branch sets review_in_flight_job_id but does NOT clear the - // prior verdict. The statusline's in-flight check (via readJobStatus) would try - // to open a job file that doesn't exist in this test; the orphan path returns - // `missing` and the staleness check then fails — so no verdict glyph should render. - var state = RalphState{ - .active = true, - .strategy = .review, - .review_enabled = true, - .review_count = 1, - .max_review_cycles = 30, - .review_verdict_raw = .reject, // prior round's reject - }; - state.review_verdict_sha_len = copyIntoFixedBuf(&state.review_verdict_sha_buf, "abc12345"); - state.review_in_flight_job_id_len = copyIntoFixedBuf(&state.review_in_flight_job_id_buf, "review-123-xyz"); - // git_head matches verdict_sha. The orphan check runs first: the job file under - // /nonexistent-root/.rl/jobs/ doesn't exist → readJobStatus returns .missing → - // in_flight branch falls through → staleness check passes → render ❌. - var stream_buf: [256]u8 = undefined; - var stream = std.io.fixedBufferStream(&stream_buf); - _ = try state.format(stream.writer(), std.testing.allocator, "/nonexistent-root", "abc12345", 0); - const output = stream.getWritten(); - try std.testing.expect(std.mem.indexOf(u8, output, glyphs.reject) != null); - try std.testing.expect(std.mem.indexOf(u8, output, glyphs.in_flight) == null); -} - -test "RalphState review reject-iterate clears verdict (review.ts:148-155)" { - // After a confirmed reject, the rl hook writes review_count++ AND clears the verdict - // fields. Next render should show no verdict glyph. - var state = RalphState{ - .active = true, - .strategy = .review, - .review_enabled = true, - .review_count = 2, - .max_review_cycles = 30, - .review_verdict_raw = .none, - }; - var buf: [256]u8 = undefined; - const output = try renderRalphState(&state, &buf, 0); - try std.testing.expect(std.mem.indexOf(u8, output, "2/30") != null); - try std.testing.expect(std.mem.indexOf(u8, output, glyphs.reject) == null); - try std.testing.expect(std.mem.indexOf(u8, output, glyphs.approve) == null); -} - -test "RalphState review fresh approve with matching HEAD" { - var state = RalphState{ - .active = true, - .strategy = .review, - .review_enabled = true, - .review_count = 0, - .max_review_cycles = 30, - .review_verdict_raw = .approve, - }; - state.review_verdict_sha_len = copyIntoFixedBuf(&state.review_verdict_sha_buf, "cafebabe"); - var stream_buf: [256]u8 = undefined; - var stream = std.io.fixedBufferStream(&stream_buf); - _ = try state.format(stream.writer(), std.testing.allocator, "/nonexistent-root", "cafebabe", 0); - const output = stream.getWritten(); - try std.testing.expect(std.mem.indexOf(u8, output, glyphs.approve) != null); -} - -test "RalphState verdict suppressed when HEAD drifts from verdict_sha (staleness)" { - var state = RalphState{ - .active = true, - .strategy = .review, - .review_enabled = true, - .review_count = 0, - .max_review_cycles = 30, - .review_verdict_raw = .reject, - }; - state.review_verdict_sha_len = copyIntoFixedBuf(&state.review_verdict_sha_buf, "oldsha1234"); - var stream_buf: [256]u8 = undefined; - var stream = std.io.fixedBufferStream(&stream_buf); - // git_head != verdict_sha → stale → suppress verdict glyph - _ = try state.format(stream.writer(), std.testing.allocator, "/nonexistent-root", "newsha5678", 0); - const output = stream.getWritten(); - try std.testing.expect(std.mem.indexOf(u8, output, glyphs.reject) == null); - try std.testing.expect(std.mem.indexOf(u8, output, glyphs.approve) == null); -} - -test "RalphState verdict renders when git_head is empty (fail-open on unknown HEAD)" { - var state = RalphState{ - .active = true, - .strategy = .review, - .review_enabled = true, - .review_verdict_raw = .approve, - }; - state.review_verdict_sha_len = copyIntoFixedBuf(&state.review_verdict_sha_buf, "somesha"); - var buf: [256]u8 = undefined; - // renderRalphState passes empty git_head — fail-open means verdict still renders - const output = try renderRalphState(&state, &buf, 0); - try std.testing.expect(std.mem.indexOf(u8, output, glyphs.approve) != null); -} - -test "RalphState in-flight with orphan job file falls through to verdict" { - // review_in_flight_job_id set but the job file doesn't exist → orphan → fall through - // to verdict check. Matching sha → render verdict glyph. - var state = RalphState{ - .active = true, - .strategy = .review, - .review_enabled = true, - .review_verdict_raw = .approve, - }; - state.review_verdict_sha_len = copyIntoFixedBuf(&state.review_verdict_sha_buf, "headsha"); - state.review_in_flight_job_id_len = copyIntoFixedBuf(&state.review_in_flight_job_id_buf, "review-orphan-xx"); - var stream_buf: [256]u8 = undefined; - var stream = std.io.fixedBufferStream(&stream_buf); - _ = try state.format(stream.writer(), std.testing.allocator, "/nonexistent-root", "headsha", 0); - const output = stream.getWritten(); - // Orphan → fall through → staleness passes → ✅ renders - try std.testing.expect(std.mem.indexOf(u8, output, glyphs.approve) != null); - try std.testing.expect(std.mem.indexOf(u8, output, glyphs.in_flight) == null); -} - -test "RalphState terminal-state prefix: blocked_claimed" { - var state = RalphState{ - .active = true, - .strategy = .review, - .review_enabled = true, - .review_count = 0, - .max_review_cycles = 30, - .blocked_claimed = true, - }; - var buf: [256]u8 = undefined; - const output = try renderRalphState(&state, &buf, 0); - try std.testing.expect(std.mem.indexOf(u8, output, glyphs.blocked) != null); - try std.testing.expect(std.mem.indexOf(u8, output, glyphs.completion) == null); -} - -test "RalphState terminal-state prefix: completion_claimed" { - var state = RalphState{ - .active = true, - .strategy = .ralph, - .iteration = 5, - .max_iterations = 30, - .completion_claimed = true, - }; - var buf: [256]u8 = undefined; - const output = try renderRalphState(&state, &buf, 0); - try std.testing.expect(std.mem.indexOf(u8, output, glyphs.completion) != null); - try std.testing.expect(std.mem.indexOf(u8, output, glyphs.blocked) == null); -} - -test "RalphState terminal-state prefix: blocked beats completion" { - var state = RalphState{ - .active = true, - .strategy = .review, - .review_enabled = true, - .completion_claimed = true, - .blocked_claimed = true, - }; - var buf: [256]u8 = undefined; - const output = try renderRalphState(&state, &buf, 0); - try std.testing.expect(std.mem.indexOf(u8, output, glyphs.blocked) != null); - try std.testing.expect(std.mem.indexOf(u8, output, glyphs.completion) == null); -} - -test "RalphState research strategy without metric (research.ts:125-135)" { - var state = RalphState{ - .active = true, - .strategy = .research, - .iteration = 5, - .max_iterations = 30, - // research ignores review fields even if set - .review_enabled = true, - .review_count = 3, - .max_review_cycles = 10, - .review_verdict_raw = .approve, - }; - var buf: [256]u8 = undefined; - const output = try renderRalphState(&state, &buf, 0); - try std.testing.expect(std.mem.indexOf(u8, output, glyphs.research) != null); - try std.testing.expect(std.mem.indexOf(u8, output, "5/30") != null); - // Research hides review counter, verdict glyphs, and (without metric) the star - try std.testing.expect(std.mem.indexOf(u8, output, glyphs.counter) == null); - try std.testing.expect(std.mem.indexOf(u8, output, glyphs.approve) == null); - try std.testing.expect(std.mem.indexOf(u8, output, glyphs.metric) == null); -} - -test "RalphState research with maximize metric renders up-arrow" { - var state = RalphState{ - .active = true, - .strategy = .research, - .iteration = 12, - .max_iterations = 30, - .best_metric_value = 0.823, - .metric_direction = .maximize, - }; - var buf: [256]u8 = undefined; - const output = try renderRalphState(&state, &buf, 0); - try std.testing.expect(std.mem.indexOf(u8, output, glyphs.research) != null); - try std.testing.expect(std.mem.indexOf(u8, output, glyphs.metric) != null); - try std.testing.expect(std.mem.indexOf(u8, output, glyphs.arrow_up) != null); - try std.testing.expect(std.mem.indexOf(u8, output, "0.823") != null); -} - -test "RalphState research with minimize metric renders down-arrow" { - var state = RalphState{ - .active = true, - .strategy = .research, - .iteration = 8, - .max_iterations = 30, - .best_metric_value = 0.045, - .metric_direction = .minimize, - }; - var buf: [256]u8 = undefined; - const output = try renderRalphState(&state, &buf, 0); - try std.testing.expect(std.mem.indexOf(u8, output, glyphs.arrow_down) != null); - try std.testing.expect(std.mem.indexOf(u8, output, "0.045") != null); -} - -test "RalphState research with unknown direction omits arrow" { - var state = RalphState{ - .active = true, - .strategy = .research, - .best_metric_value = 1.5, - .metric_direction = .none, - }; - var buf: [256]u8 = undefined; - const output = try renderRalphState(&state, &buf, 0); - try std.testing.expect(std.mem.indexOf(u8, output, glyphs.metric) != null); - try std.testing.expect(std.mem.indexOf(u8, output, glyphs.arrow_up) == null); - try std.testing.expect(std.mem.indexOf(u8, output, glyphs.arrow_down) == null); - try std.testing.expect(std.mem.indexOf(u8, output, "1.500") != null); -} - -test "RalphState loop age: seconds" { - var state = RalphState{ - .active = true, - .strategy = .ralph, - .iteration = 1, - .max_iterations = 30, - .iteration_start_ms = 1000, - }; - var buf: [256]u8 = undefined; - const output = try renderRalphState(&state, &buf, 31000); // 30s later - try std.testing.expect(std.mem.indexOf(u8, output, "+30s") != null); - try std.testing.expect(std.mem.indexOf(u8, output, colors.green) != null); -} - -test "RalphState loop age: minutes in green range" { - var state = RalphState{ - .active = true, - .strategy = .ralph, - .iteration_start_ms = 0, - }; - var buf: [256]u8 = undefined; - const output = try renderRalphState(&state, &buf, 45 * 60 * 1000); // 45m - try std.testing.expect(std.mem.indexOf(u8, output, "+45m") != null); - try std.testing.expect(std.mem.indexOf(u8, output, colors.green) != null); -} - -test "RalphState loop age: hours in yellow range" { - var state = RalphState{ - .active = true, - .strategy = .ralph, - .iteration_start_ms = 0, - }; - var buf: [256]u8 = undefined; - const output = try renderRalphState(&state, &buf, 2 * 60 * 60 * 1000 + 15 * 60 * 1000); // 2h15m - try std.testing.expect(std.mem.indexOf(u8, output, "+2h15m") != null); - try std.testing.expect(std.mem.indexOf(u8, output, colors.yellow) != null); -} - -test "RalphState loop age: hours with zero-minute suffix" { - var state = RalphState{ - .active = true, - .strategy = .ralph, - .iteration_start_ms = 0, - }; - var buf: [256]u8 = undefined; - const output = try renderRalphState(&state, &buf, 3 * 60 * 60 * 1000); // exactly 3h - try std.testing.expect(std.mem.indexOf(u8, output, "+3h") != null); - // Should NOT contain "+3h0m" - try std.testing.expect(std.mem.indexOf(u8, output, "+3h0m") == null); -} - -test "RalphState loop age: days in red range" { - var state = RalphState{ - .active = true, - .strategy = .ralph, - .iteration_start_ms = 0, - }; - var buf: [256]u8 = undefined; - const output = try renderRalphState(&state, &buf, 3 * 24 * 60 * 60 * 1000); // 3d - try std.testing.expect(std.mem.indexOf(u8, output, "+3d") != null); - try std.testing.expect(std.mem.indexOf(u8, output, colors.red) != null); -} - -test "RalphState loop age: absent when iteration_start_ms is null" { - var state = RalphState{ - .active = true, - .strategy = .ralph, - .iteration_start_ms = null, - }; - var buf: [256]u8 = undefined; - const output = try renderRalphState(&state, &buf, 1_000_000); - // No "+" prefix followed by digit/h/m/s/d - try std.testing.expect(std.mem.indexOf(u8, output, "+") == null); -} - -test "RalphState loop age: absent when now_ms <= iteration_start_ms" { - // Clock skew edge case — don't render a negative age. - var state = RalphState{ - .active = true, - .strategy = .ralph, - .iteration_start_ms = 5000, - }; - var buf: [256]u8 = undefined; - const output = try renderRalphState(&state, &buf, 3000); - try std.testing.expect(std.mem.indexOf(u8, output, "+") == null); -} - -test "copyIntoFixedBuf overflow returns zero" { - var buf: [8]u8 = undefined; - try std.testing.expectEqual(@as(u8, 0), copyIntoFixedBuf(&buf, "this_is_longer_than_eight")); - try std.testing.expectEqual(@as(u8, 0), copyIntoFixedBuf(&buf, "")); - try std.testing.expectEqual(@as(u8, 3), copyIntoFixedBuf(&buf, "abc")); - try std.testing.expectEqualStrings("abc", buf[0..3]); -} - -test "RalphState accessors return null for empty buffers" { - const state = RalphState{}; - try std.testing.expect(state.verdictSha() == null); - try std.testing.expect(state.inFlightJobId() == null); -} - -// --- Impl-worker segment tests --- -// -// These exercise the filesystem-aware helper by constructing real temp directories -// with fake job files. Fixture layout: tmpdir/.rl/jobs/.json - -fn makeImplFixture(root: std.fs.Dir, subdir: []const u8) !std.fs.Dir { - root.makeDir(subdir) catch {}; - var d = try root.openDir(subdir, .{}); - errdefer d.close(); - d.makePath(".rl/jobs") catch {}; - return d; -} - -fn writeFixtureJob(fixture: std.fs.Dir, name: []const u8, content: []const u8) !void { - const path = try std.fmt.allocPrint(std.testing.allocator, ".rl/jobs/{s}", .{name}); - defer std.testing.allocator.free(path); - const file = try fixture.createFile(path, .{}); - defer file.close(); - try file.writeAll(content); -} - -test "hasRunningImplJob returns false when .rl/jobs/ is missing" { - var tmp = std.testing.tmpDir(.{}); - defer tmp.cleanup(); - const path = try tmp.dir.realpathAlloc(std.testing.allocator, "."); - defer std.testing.allocator.free(path); - try std.testing.expect(!hasRunningImplJob(std.testing.allocator, path)); -} - -test "hasRunningImplJob detects running impl job" { - var tmp = std.testing.tmpDir(.{}); - defer tmp.cleanup(); - try tmp.dir.makePath(".rl/jobs"); - try writeFixtureJob(tmp.dir, "impl-packet-abc.json", "{\"kind\":\"implement\",\"status\":\"running\"}"); - const path = try tmp.dir.realpathAlloc(std.testing.allocator, "."); - defer std.testing.allocator.free(path); - try std.testing.expect(hasRunningImplJob(std.testing.allocator, path)); -} - -test "hasRunningImplJob detects queued impl job" { - var tmp = std.testing.tmpDir(.{}); - defer tmp.cleanup(); - try tmp.dir.makePath(".rl/jobs"); - try writeFixtureJob(tmp.dir, "impl-queued.json", "{\"status\":\"queued\"}"); - const path = try tmp.dir.realpathAlloc(std.testing.allocator, "."); - defer std.testing.allocator.free(path); - try std.testing.expect(hasRunningImplJob(std.testing.allocator, path)); -} - -test "hasRunningImplJob ignores completed impl jobs" { - var tmp = std.testing.tmpDir(.{}); - defer tmp.cleanup(); - try tmp.dir.makePath(".rl/jobs"); - try writeFixtureJob(tmp.dir, "impl-done.json", "{\"status\":\"completed\"}"); - try writeFixtureJob(tmp.dir, "impl-fail.json", "{\"status\":\"failed\"}"); - try writeFixtureJob(tmp.dir, "impl-cancel.json", "{\"status\":\"cancelled\"}"); - const path = try tmp.dir.realpathAlloc(std.testing.allocator, "."); - defer std.testing.allocator.free(path); - try std.testing.expect(!hasRunningImplJob(std.testing.allocator, path)); -} - -test "hasRunningImplJob ignores review jobs" { - var tmp = std.testing.tmpDir(.{}); - defer tmp.cleanup(); - try tmp.dir.makePath(".rl/jobs"); - // A running review job should NOT trigger the impl glyph. - try writeFixtureJob(tmp.dir, "review-123-abc.json", "{\"status\":\"running\"}"); - const path = try tmp.dir.realpathAlloc(std.testing.allocator, "."); - defer std.testing.allocator.free(path); - try std.testing.expect(!hasRunningImplJob(std.testing.allocator, path)); -} - -test "hasRunningImplJob short-circuits on first running job" { - var tmp = std.testing.tmpDir(.{}); - defer tmp.cleanup(); - try tmp.dir.makePath(".rl/jobs"); - // Mixed — at least one running impl should return true. - try writeFixtureJob(tmp.dir, "impl-done-1.json", "{\"status\":\"completed\"}"); - try writeFixtureJob(tmp.dir, "impl-running-2.json", "{\"status\":\"running\"}"); - try writeFixtureJob(tmp.dir, "impl-done-3.json", "{\"status\":\"failed\"}"); - const path = try tmp.dir.realpathAlloc(std.testing.allocator, "."); - defer std.testing.allocator.free(path); - try std.testing.expect(hasRunningImplJob(std.testing.allocator, path)); -} - -test "hasRunningImplJob ignores non-json files and non-impl prefixes" { - var tmp = std.testing.tmpDir(.{}); - defer tmp.cleanup(); - try tmp.dir.makePath(".rl/jobs"); - // Files that should be skipped: - try writeFixtureJob(tmp.dir, "impl-running.txt", "{\"status\":\"running\"}"); // wrong extension - try writeFixtureJob(tmp.dir, "not-impl.json", "{\"status\":\"running\"}"); // wrong prefix - const path = try tmp.dir.realpathAlloc(std.testing.allocator, "."); - defer std.testing.allocator.free(path); - try std.testing.expect(!hasRunningImplJob(std.testing.allocator, path)); -} - -test "formatImplWorker emits glyph when running job exists" { - var tmp = std.testing.tmpDir(.{}); - defer tmp.cleanup(); - try tmp.dir.makePath(".rl/jobs"); - try writeFixtureJob(tmp.dir, "impl-xyz.json", "{\"status\":\"running\"}"); - const path = try tmp.dir.realpathAlloc(std.testing.allocator, "."); - defer std.testing.allocator.free(path); - - var buf: [128]u8 = undefined; - var stream = std.io.fixedBufferStream(&buf); - const wrote = try formatImplWorker(stream.writer(), std.testing.allocator, path); - try std.testing.expect(wrote); - try std.testing.expect(std.mem.indexOf(u8, stream.getWritten(), glyphs.impl) != null); -} - -test "formatImplWorker writes nothing when no impl workers" { - var tmp = std.testing.tmpDir(.{}); - defer tmp.cleanup(); - const path = try tmp.dir.realpathAlloc(std.testing.allocator, "."); - defer std.testing.allocator.free(path); - - var buf: [128]u8 = undefined; - var stream = std.io.fixedBufferStream(&buf); - const wrote = try formatImplWorker(stream.writer(), std.testing.allocator, path); - try std.testing.expect(!wrote); - try std.testing.expectEqual(@as(usize, 0), stream.getWritten().len); -} - -test "parseYamlBool function" { - try std.testing.expectEqual(true, parseYamlBool("active: true", "active:")); - try std.testing.expectEqual(false, parseYamlBool("active: false", "active:")); - try std.testing.expect(parseYamlBool("active: maybe", "active:") == null); - try std.testing.expect(parseYamlBool("other: true", "active:") == null); - try std.testing.expect(parseYamlBool("review_enabled: true", "review_enabled:") == true); -} - -test "parseYamlInt function" { - try std.testing.expectEqual(@as(u32, 50), parseYamlInt("max_iterations: 50", "max_iterations:").?); - try std.testing.expectEqual(@as(u32, 0), parseYamlInt("iteration: 0", "iteration:").?); - try std.testing.expectEqual(@as(u32, 123), parseYamlInt("count: 123", "count:").?); - try std.testing.expect(parseYamlInt("iteration: abc", "iteration:") == null); - try std.testing.expect(parseYamlInt("other: 50", "iteration:") == null); -} - -test "parseRalphStateFromContent with valid v3 JSON" { - const allocator = std.testing.allocator; - const content = - \\{"version":3,"strategy":"review","active":true,"iteration":5,"max_iterations":30, - \\"review_enabled":true,"review_count":2,"max_review_cycles":10} - ; - const state = parseRalphStateFromContent(allocator, content); - try std.testing.expect(state.active); - try std.testing.expectEqual(@as(u32, 5), state.iteration); - try std.testing.expectEqual(@as(u32, 30), state.max_iterations); - try std.testing.expect(state.review_enabled); - try std.testing.expectEqual(@as(u32, 2), state.review_count); - try std.testing.expectEqual(@as(u32, 10), state.max_review_cycles); - try std.testing.expectEqual(Strategy.review, state.strategy); - try std.testing.expectEqual(@as(?u32, 3), state.version); - try std.testing.expectEqual(VerdictRaw.none, state.review_verdict_raw); -} - -test "parseRalphStateFromContent with partial fields falls back to defaults" { - const allocator = std.testing.allocator; - const state = parseRalphStateFromContent(allocator, "{\"active\":true,\"iteration\":3}"); - try std.testing.expect(state.active); - try std.testing.expectEqual(@as(u32, 3), state.iteration); - try std.testing.expectEqual(@as(u32, 50), state.max_iterations); - try std.testing.expect(!state.review_enabled); - try std.testing.expectEqual(Strategy.unknown, state.strategy); - try std.testing.expectEqual(@as(?u32, null), state.version); - try std.testing.expectEqual(MetricDirection.none, state.metric_direction); - try std.testing.expect(!state.completion_claimed); - try std.testing.expect(!state.blocked_claimed); - try std.testing.expect(state.verdictSha() == null); - try std.testing.expect(state.inFlightJobId() == null); -} - -test "parseRalphStateFromContent with invalid JSON returns defaults" { - const state = parseRalphStateFromContent(std.testing.allocator, "# not JSON"); - try std.testing.expect(!state.active); -} - -test "parseRalphStateFromContent with empty content returns defaults" { - const state = parseRalphStateFromContent(std.testing.allocator, ""); - try std.testing.expect(!state.active); -} - -test "parseRalphStateFromContent ignores unknown fields" { - const content = - \\{"active":true,"iteration":7,"unknown_field":"x","completion_promise":"COMPLETE"} - ; - const state = parseRalphStateFromContent(std.testing.allocator, content); - try std.testing.expect(state.active); - try std.testing.expectEqual(@as(u32, 7), state.iteration); -} - -test "parseRalphStateFromContent parses approve verdict + sha" { - const content = - \\{"version":3,"strategy":"review","active":true, - \\"review_verdict":"approve","review_verdict_sha":"deadbeef", - \\"review_in_flight_job_id":null} - ; - const state = parseRalphStateFromContent(std.testing.allocator, content); - try std.testing.expectEqual(VerdictRaw.approve, state.review_verdict_raw); - try std.testing.expect(state.verdictSha() != null); - try std.testing.expectEqualStrings("deadbeef", state.verdictSha().?); - try std.testing.expect(state.inFlightJobId() == null); -} - -test "parseRalphStateFromContent parses reject verdict" { - const content = - \\{"version":3,"active":true,"review_verdict":"reject","review_verdict_sha":"abc"} - ; - const state = parseRalphStateFromContent(std.testing.allocator, content); - try std.testing.expectEqual(VerdictRaw.reject, state.review_verdict_raw); -} - -test "parseRalphStateFromContent parses in-flight job id" { - const content = - \\{"version":3,"active":true,"review_in_flight_job_id":"review-123-abc"} - ; - const state = parseRalphStateFromContent(std.testing.allocator, content); - try std.testing.expect(state.inFlightJobId() != null); - try std.testing.expectEqualStrings("review-123-abc", state.inFlightJobId().?); -} - -test "parseRalphStateFromContent treats empty in-flight job id as absent" { - const content = "{\"active\":true,\"review_in_flight_job_id\":\"\"}"; - const state = parseRalphStateFromContent(std.testing.allocator, content); - try std.testing.expect(state.inFlightJobId() == null); -} - -test "parseRalphStateFromContent parses research metric + direction" { - const content = - \\{"version":3,"strategy":"research","active":true,"iteration":5,"max_iterations":30, - \\"metric_name":"accuracy","metric_direction":"maximize","best_metric_value":0.8231} - ; - const state = parseRalphStateFromContent(std.testing.allocator, content); - try std.testing.expectEqual(Strategy.research, state.strategy); - try std.testing.expect(state.best_metric_value != null); - try std.testing.expectApproxEqAbs(@as(f64, 0.8231), state.best_metric_value.?, 0.0001); - try std.testing.expectEqual(MetricDirection.maximize, state.metric_direction); -} - -test "parseRalphStateFromContent parses minimize direction" { - const content = "{\"strategy\":\"research\",\"metric_direction\":\"minimize\"}"; - const state = parseRalphStateFromContent(std.testing.allocator, content); - try std.testing.expectEqual(MetricDirection.minimize, state.metric_direction); -} - -test "parseRalphStateFromContent parses terminal-state flags" { - const content = "{\"active\":true,\"completion_claimed\":true,\"blocked_claimed\":true}"; - const state = parseRalphStateFromContent(std.testing.allocator, content); - try std.testing.expect(state.completion_claimed); - try std.testing.expect(state.blocked_claimed); -} - -test "parseRalphStateFromContent parses iteration_start_ms" { - const content = "{\"active\":true,\"iteration_start_ms\":1776040656906}"; - const state = parseRalphStateFromContent(std.testing.allocator, content); - try std.testing.expect(state.iteration_start_ms != null); - try std.testing.expectEqual(@as(i64, 1776040656906), state.iteration_start_ms.?); -} - -test "parseRalphStateFromContent captures stale schema version" { - const content = "{\"version\":2,\"active\":true,\"iteration\":1}"; - const state = parseRalphStateFromContent(std.testing.allocator, content); - try std.testing.expect(state.active); - try std.testing.expectEqual(@as(?u32, 2), state.version); -} - -test "parseRalphStateFromContent handles real-world state.json shape" { - // Actual payload observed in ~/0xbigboss/rl/.rl/state.json on 2026-04-13. - const content = - \\{"version":3,"strategy":"review","active":true,"iteration":0,"max_iterations":30, - \\"timestamp":"2026-04-13T01:07:58Z","review_enabled":true,"review_count":0, - \\"max_review_cycles":30,"debug":false,"review_verdict":"reject", - \\"review_verdict_sha":"8bc2c48697d39eb0488b64ddd00f7a0a3bcdcd64", - \\"review_verdict_ts":"2026-04-13T01:09:44.749Z", - \\"review_verdict_job_id":"review-1776042481651-abccbr", - \\"review_in_flight_job_id":null,"iteration_start_ms":1776042478932, - \\"iteration_start_sha":"8bc2c48","blocked_claimed":true} - ; - const state = parseRalphStateFromContent(std.testing.allocator, content); - try std.testing.expect(state.active); - try std.testing.expectEqual(Strategy.review, state.strategy); - try std.testing.expectEqual(VerdictRaw.reject, state.review_verdict_raw); - try std.testing.expect(state.blocked_claimed); - try std.testing.expect(state.verdictSha() != null); - try std.testing.expectEqualStrings("8bc2c48697d39eb0488b64ddd00f7a0a3bcdcd64", state.verdictSha().?); - try std.testing.expect(state.inFlightJobId() == null); - try std.testing.expect(state.iteration_start_ms != null); -} - test "formatIdleSince returns false without session_id" { var buf: [128]u8 = undefined; var stream = std.io.fixedBufferStream(&buf); From 82ddc9598dc14b34f54e2a62a25035f7575da9ec Mon Sep 17 00:00:00 2001 From: Allen Date: Sat, 18 Apr 2026 00:43:43 +0000 Subject: [PATCH 36/57] chore(skills): add SPEC claim-calibration rule Recorded the dogfood lesson from the 2026-04-18 rl reject cycles: match absolutist SPEC language to what the check actually enforces, or the reviewer will build a sub-60s counterexample. Guidance lives alongside Evidence-based under Authoring rules. --- .claude/skills/spec-best-practices/SKILL.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.claude/skills/spec-best-practices/SKILL.md b/.claude/skills/spec-best-practices/SKILL.md index 8f6bebf..3e403bd 100644 --- a/.claude/skills/spec-best-practices/SKILL.md +++ b/.claude/skills/spec-best-practices/SKILL.md @@ -46,6 +46,8 @@ Specs are freeform markdown. No rigid template, no YAML frontmatter, no required **Evidence-based**: read code before writing spec content. Do not invent behavior, signatures, or file paths. For retroactive specs, derive requirements from the actual implementation. +**Calibrate claim strength to enforcement**: match absolutist words ("unrepresentable", "cannot", "structurally caught", "any regression trips the check") to what the check actually proves. If a reviewer can construct a counterexample in under 60 seconds (comment decoy, string literal, shadowed binding, computed path), the SPEC is overclaiming. Either tighten the check, narrow the claim (e.g., "closes the literal-spawn regression; comment/string/shadow-binding cases are covered by the companion test"), or record the gap as an open item. Conservative phrasing backed by evidence beats strong phrasing that invites reject cycles. + **Retroactive specs are first-class**: documenting existing behavior is valid and encouraged. Read the implementation, extract requirements from actual behavior, note inconsistencies as open items (not silent omissions), map traceability to existing tests. **Mutation policy**: do not edit a spec without explicit user direction. When spec/implementation drift is found, surface it immediately. Never silently tolerate or fix drift — the user decides whether to update spec or code. From b390a4d098c8158401f3ded1b60397e10df54d9c Mon Sep 17 00:00:00 2001 From: 0xBigBoss <95193764+0xBigBoss@users.noreply.github.com> Date: Sat, 25 Apr 2026 18:21:27 -0500 Subject: [PATCH 37/57] chore(settings): set theme to auto --- settings/settings.json | 1 + 1 file changed, 1 insertion(+) diff --git a/settings/settings.json b/settings/settings.json index 7819939..52dc287 100644 --- a/settings/settings.json +++ b/settings/settings.json @@ -4,6 +4,7 @@ "commit": "", "pr": "" }, + "theme": "auto", "permissions": { "allow": [ "Edit(~/.handoffs/**)", From 6c776276bbe14dbf91d19407823c5aac1fd6797c Mon Sep 17 00:00:00 2001 From: 0xBigBoss <95193764+0xBigBoss@users.noreply.github.com> Date: Sat, 25 Apr 2026 19:07:25 -0500 Subject: [PATCH 38/57] chore(settings): adopt installed plugin set; promote effort=xhigh + skipDangerousModePermissionPrompt Plugin set now mirrors what's actually installed (drops codex-reviewer and ralph-reviewed; adds the official-marketplace + workflow plugins added via UI, plus swift-lsp and gh-pulse promoted from settings.local.json). Without this, claude-settings-merge --fix silently overwrote UI-installed plugins on every sync. Also moves effortLevel=xhigh and skipDangerousModePermissionPrompt=true into the baseline since they're durable per-user prefs used everywhere. --- settings/settings.json | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/settings/settings.json b/settings/settings.json index 52dc287..2419405 100644 --- a/settings/settings.json +++ b/settings/settings.json @@ -132,16 +132,30 @@ "gopls-lsp@claude-plugins-official": true, "jdtls-lsp@claude-plugins-official": true, "clangd-lsp@claude-plugins-official": true, - "codex-reviewer@0xbigboss-plugins": true, - "ralph-reviewed@0xbigboss-plugins": true, + "swift-lsp@claude-plugins-official": true, "recall@0xbigboss-recall": true, "silo@0xBigBoss-silo": true, - "send-infra@send-infra-plugins": true + "send-infra@send-infra-plugins": true, + "gh-pulse@gh-pulse-marketplace": true, + "rl@0xsend-rl": true, + "superpowers@claude-plugins-official": true, + "code-review@claude-plugins-official": true, + "skill-creator@claude-plugins-official": true, + "claude-md-management@claude-plugins-official": true, + "claude-code-setup@claude-plugins-official": true, + "agent-sdk-dev@claude-plugins-official": true, + "plugin-dev@claude-plugins-official": true, + "posthog@claude-plugins-official": true, + "data-engineering@claude-plugins-official": true, + "cloudflare@claude-plugins-official": true, + "expo@claude-plugins-official": true }, "env": { "BASH_ENV": "/Users/allen/code/dotfiles/claude-code/hooks/direnv-bash-env" }, "alwaysThinkingEnabled": true, + "effortLevel": "xhigh", + "skipDangerousModePermissionPrompt": true, "feedbackSurveyState": { "lastShownTime": 1754076652911 } From 8969a83e04266205c8d84794f0311acfb333397b Mon Sep 17 00:00:00 2001 From: 0xBigBoss <95193764+0xBigBoss@users.noreply.github.com> Date: Sat, 25 Apr 2026 19:20:40 -0500 Subject: [PATCH 39/57] docs: mention claude-settings-merge --diff mode --- CLAUDE.local.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CLAUDE.local.md b/CLAUDE.local.md index f7a76b9..ec8a380 100644 --- a/CLAUDE.local.md +++ b/CLAUDE.local.md @@ -28,6 +28,7 @@ bin/bin/claude-bootstrap --check # 2) Validate merged settings bin/bin/claude-settings-merge --check +bin/bin/claude-settings-merge --diff # show what --fix would change # 3) Validate Claude->Codex sync (commands + skills) claude-code/scripts/sync-codex.sh --check From b093e013cfc6cd2282eba53a13bb6b6b3c23da1e Mon Sep 17 00:00:00 2001 From: 0xBigBoss <95193764+0xBigBoss@users.noreply.github.com> Date: Sat, 25 Apr 2026 19:58:56 -0500 Subject: [PATCH 40/57] chore(settings): add extraKnownMarketplaces; consolidate plugin source of truth settings.json now declares both marketplaces (extraKnownMarketplaces) and which plugins are on (enabledPlugins). Claude Code auto-registers the marketplaces on startup; install.sh derives the install list from this file. bin/bin/claude-settings-merge --check is expected to report DRIFT on hosts until bin/bin/claude-settings-merge --fix propagates the new field. --- CLAUDE.local.md | 2 +- README.md | 2 ++ settings/settings.json | 10 ++++++++++ 3 files changed, 13 insertions(+), 1 deletion(-) diff --git a/CLAUDE.local.md b/CLAUDE.local.md index ec8a380..f90ce09 100644 --- a/CLAUDE.local.md +++ b/CLAUDE.local.md @@ -43,7 +43,7 @@ claude-code/scripts/sync-codex.sh - New command: add `*.md` to `claude-code/commands/` and run `sync-codex.sh`. - New skill: add `claude-code/.claude/skills//SKILL.md` and run `sync-codex.sh`. -- Plugin/marketplace defaults: update `claude/defaults/plugins.txt` or `claude/defaults/marketplaces.txt` in the parent repo. +- Plugin and marketplace state: update `claude-code/settings/settings.json`. `enabledPlugins` controls which plugins are turned on, `extraKnownMarketplaces` declares where to fetch them, and `install.sh --claude` derives its idempotent install loop from that file. ## Review Checklist diff --git a/README.md b/README.md index 7e60e26..0ed267d 100644 --- a/README.md +++ b/README.md @@ -107,6 +107,8 @@ Language and tool-specific best practices loaded automatically based on file con - Local overrides: `~/.claude/settings.local.json` - Generated runtime file: `~/.claude/settings.json` (generated by `bin/bin/claude-settings-merge --fix`) - Merge behavior: object keys merge recursively; `permissions.allow` and `permissions.additionalDirectories` append local entries without removing baseline entries; other arrays are replaced by local values. +- Plugin state lives in the baseline settings: `enabledPlugins` controls which plugins are turned on, and `extraKnownMarketplaces` declares where Claude Code fetches them. +- `install.sh --claude` reads those settings and runs `claude plugin install` for each enabled plugin; the install loop is idempotent. ## Instruction Source of Truth diff --git a/settings/settings.json b/settings/settings.json index 2419405..7076c21 100644 --- a/settings/settings.json +++ b/settings/settings.json @@ -123,6 +123,16 @@ "type": "command", "command": "statusline" }, + "extraKnownMarketplaces": { + "claude-plugins-official": { "source": { "source": "github", "repo": "anthropics/claude-plugins-official" } }, + "0xsend-marketplace": { "source": { "source": "git", "url": "https://github.com/0xsend/claude-code-plugins.git" } }, + "0xbigboss-recall": { "source": { "source": "github", "repo": "0xbigboss/recall" } }, + "0xBigBoss-silo": { "source": { "source": "github", "repo": "0xBigBoss/silo" } }, + "send-infra-plugins": { "source": { "source": "github", "repo": "0xsend/infra" } }, + "gh-pulse-marketplace": { "source": { "source": "github", "repo": "0xbigboss/gh-pulse" } }, + "0xsend-rl": { "source": { "source": "github", "repo": "0xsend/rl" } }, + "linear-cli-marketplace": { "source": { "source": "github", "repo": "0xBigBoss/linear-cli" } } + }, "enabledPlugins": { "linear-cli@linear-cli-marketplace": true, "canton-skills@0xsend-marketplace": true, From 8037354cdd9d73cf60df141b6372b0c6972aefe3 Mon Sep 17 00:00:00 2001 From: 0xBigBoss <95193764+0xBigBoss@users.noreply.github.com> Date: Thu, 30 Apr 2026 23:09:45 -0500 Subject: [PATCH 41/57] docs(skills): flag boolean-prop composition smell in react skill Adds guidance to treat tree-switching boolean props as a composition smell, prefer compound components with provider-scoped state, and lift provider boundaries when external controls need shared access. --- .claude/skills/react-best-practices/SKILL.md | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/.claude/skills/react-best-practices/SKILL.md b/.claude/skills/react-best-practices/SKILL.md index 4f9fa36..0093af6 100644 --- a/.claude/skills/react-best-practices/SKILL.md +++ b/.claude/skills/react-best-practices/SKILL.md @@ -53,7 +53,12 @@ Synchronizing with **external systems**: browser APIs (WebSocket, IntersectionOb ## Component Patterns - Controlled: parent owns state; uncontrolled: component owns state -- Prefer composition with `children` over prop drilling; use Context only for truly global state +- Prefer composition with `children` over prop drilling +- Treat boolean props that switch large component trees (`isEditing`, `isThread`, `hideAttachments`) as a composition smell; prefer separate composed components for distinct use cases +- For complex reusable UI, prefer compound components with provider-scoped state/actions over monolithic components with many optional props +- Use Context for scoped component families as well as truly global state, when it defines a local interface consumed by descendants +- Render JSX directly for UI variation; avoid config-array mini-frameworks unless the config is real domain data +- Lift the provider boundary when sibling or external controls need access to the same state/actions - Use `flushSync` when you need to read the DOM synchronously after a state update See `react-patterns.md` for code examples and detailed patterns. From a88db8a8471d94e4002fe5639cb1d143299dfbf2 Mon Sep 17 00:00:00 2001 From: 0xBigBoss <95193764+0xBigBoss@users.noreply.github.com> Date: Fri, 8 May 2026 13:54:37 -0400 Subject: [PATCH 42/57] chore(skills): drop 25 broken devctl/rl/test-ping symlinks These symlinks pointed at ~/.agents/skills/ which is empty, so they resolved to nothing. The rl:* plugin skills (rl:delegate, rl:rl, etc.) have superseded the local rl-* helpers. Removes 25 dead entries from the skill listing to free description budget. --- .claude/skills/devctl-auth | 1 - .claude/skills/devctl-connect | 1 - .claude/skills/devctl-disconnect | 1 - .claude/skills/devctl-down | 1 - .claude/skills/devctl-env | 1 - .claude/skills/devctl-forward | 1 - .claude/skills/devctl-heartbeat | 1 - .claude/skills/devctl-hetzner | 1 - .claude/skills/devctl-init | 1 - .claude/skills/devctl-kubeconfig | 1 - .claude/skills/devctl-reaper | 1 - .claude/skills/devctl-ssh | 1 - .claude/skills/devctl-status | 1 - .claude/skills/devctl-sync | 1 - .claude/skills/devctl-up | 1 - .claude/skills/rl-cancel | 1 - .claude/skills/rl-clean | 1 - .claude/skills/rl-done | 1 - .claude/skills/rl-init | 1 - .claude/skills/rl-log | 1 - .claude/skills/rl-prompt | 1 - .claude/skills/rl-review | 1 - .claude/skills/rl-state | 1 - .claude/skills/rl-status | 1 - .claude/skills/test-ping | 1 - 25 files changed, 25 deletions(-) delete mode 120000 .claude/skills/devctl-auth delete mode 120000 .claude/skills/devctl-connect delete mode 120000 .claude/skills/devctl-disconnect delete mode 120000 .claude/skills/devctl-down delete mode 120000 .claude/skills/devctl-env delete mode 120000 .claude/skills/devctl-forward delete mode 120000 .claude/skills/devctl-heartbeat delete mode 120000 .claude/skills/devctl-hetzner delete mode 120000 .claude/skills/devctl-init delete mode 120000 .claude/skills/devctl-kubeconfig delete mode 120000 .claude/skills/devctl-reaper delete mode 120000 .claude/skills/devctl-ssh delete mode 120000 .claude/skills/devctl-status delete mode 120000 .claude/skills/devctl-sync delete mode 120000 .claude/skills/devctl-up delete mode 120000 .claude/skills/rl-cancel delete mode 120000 .claude/skills/rl-clean delete mode 120000 .claude/skills/rl-done delete mode 120000 .claude/skills/rl-init delete mode 120000 .claude/skills/rl-log delete mode 120000 .claude/skills/rl-prompt delete mode 120000 .claude/skills/rl-review delete mode 120000 .claude/skills/rl-state delete mode 120000 .claude/skills/rl-status delete mode 120000 .claude/skills/test-ping diff --git a/.claude/skills/devctl-auth b/.claude/skills/devctl-auth deleted file mode 120000 index 64c5e0e..0000000 --- a/.claude/skills/devctl-auth +++ /dev/null @@ -1 +0,0 @@ -../../../../../.agents/skills/devctl-auth \ No newline at end of file diff --git a/.claude/skills/devctl-connect b/.claude/skills/devctl-connect deleted file mode 120000 index 063271a..0000000 --- a/.claude/skills/devctl-connect +++ /dev/null @@ -1 +0,0 @@ -../../../../../.agents/skills/devctl-connect \ No newline at end of file diff --git a/.claude/skills/devctl-disconnect b/.claude/skills/devctl-disconnect deleted file mode 120000 index a40d42f..0000000 --- a/.claude/skills/devctl-disconnect +++ /dev/null @@ -1 +0,0 @@ -../../../../../.agents/skills/devctl-disconnect \ No newline at end of file diff --git a/.claude/skills/devctl-down b/.claude/skills/devctl-down deleted file mode 120000 index d81f4a6..0000000 --- a/.claude/skills/devctl-down +++ /dev/null @@ -1 +0,0 @@ -../../../../../.agents/skills/devctl-down \ No newline at end of file diff --git a/.claude/skills/devctl-env b/.claude/skills/devctl-env deleted file mode 120000 index bda77ef..0000000 --- a/.claude/skills/devctl-env +++ /dev/null @@ -1 +0,0 @@ -../../../../../.agents/skills/devctl-env \ No newline at end of file diff --git a/.claude/skills/devctl-forward b/.claude/skills/devctl-forward deleted file mode 120000 index 4a6debf..0000000 --- a/.claude/skills/devctl-forward +++ /dev/null @@ -1 +0,0 @@ -../../../../../.agents/skills/devctl-forward \ No newline at end of file diff --git a/.claude/skills/devctl-heartbeat b/.claude/skills/devctl-heartbeat deleted file mode 120000 index 658fafc..0000000 --- a/.claude/skills/devctl-heartbeat +++ /dev/null @@ -1 +0,0 @@ -../../../../../.agents/skills/devctl-heartbeat \ No newline at end of file diff --git a/.claude/skills/devctl-hetzner b/.claude/skills/devctl-hetzner deleted file mode 120000 index 4b1ee70..0000000 --- a/.claude/skills/devctl-hetzner +++ /dev/null @@ -1 +0,0 @@ -../../../../../.agents/skills/devctl-hetzner \ No newline at end of file diff --git a/.claude/skills/devctl-init b/.claude/skills/devctl-init deleted file mode 120000 index 4f5afb6..0000000 --- a/.claude/skills/devctl-init +++ /dev/null @@ -1 +0,0 @@ -../../../../../.agents/skills/devctl-init \ No newline at end of file diff --git a/.claude/skills/devctl-kubeconfig b/.claude/skills/devctl-kubeconfig deleted file mode 120000 index e7393e2..0000000 --- a/.claude/skills/devctl-kubeconfig +++ /dev/null @@ -1 +0,0 @@ -../../../../../.agents/skills/devctl-kubeconfig \ No newline at end of file diff --git a/.claude/skills/devctl-reaper b/.claude/skills/devctl-reaper deleted file mode 120000 index be65bce..0000000 --- a/.claude/skills/devctl-reaper +++ /dev/null @@ -1 +0,0 @@ -../../../../../.agents/skills/devctl-reaper \ No newline at end of file diff --git a/.claude/skills/devctl-ssh b/.claude/skills/devctl-ssh deleted file mode 120000 index 93214a5..0000000 --- a/.claude/skills/devctl-ssh +++ /dev/null @@ -1 +0,0 @@ -../../../../../.agents/skills/devctl-ssh \ No newline at end of file diff --git a/.claude/skills/devctl-status b/.claude/skills/devctl-status deleted file mode 120000 index 9c85fa7..0000000 --- a/.claude/skills/devctl-status +++ /dev/null @@ -1 +0,0 @@ -../../../../../.agents/skills/devctl-status \ No newline at end of file diff --git a/.claude/skills/devctl-sync b/.claude/skills/devctl-sync deleted file mode 120000 index 051d565..0000000 --- a/.claude/skills/devctl-sync +++ /dev/null @@ -1 +0,0 @@ -../../../../../.agents/skills/devctl-sync \ No newline at end of file diff --git a/.claude/skills/devctl-up b/.claude/skills/devctl-up deleted file mode 120000 index af23f46..0000000 --- a/.claude/skills/devctl-up +++ /dev/null @@ -1 +0,0 @@ -../../../../../.agents/skills/devctl-up \ No newline at end of file diff --git a/.claude/skills/rl-cancel b/.claude/skills/rl-cancel deleted file mode 120000 index 3bf43ad..0000000 --- a/.claude/skills/rl-cancel +++ /dev/null @@ -1 +0,0 @@ -../../../../../.agents/skills/rl-cancel \ No newline at end of file diff --git a/.claude/skills/rl-clean b/.claude/skills/rl-clean deleted file mode 120000 index 6aedce1..0000000 --- a/.claude/skills/rl-clean +++ /dev/null @@ -1 +0,0 @@ -../../../../../.agents/skills/rl-clean \ No newline at end of file diff --git a/.claude/skills/rl-done b/.claude/skills/rl-done deleted file mode 120000 index 830c829..0000000 --- a/.claude/skills/rl-done +++ /dev/null @@ -1 +0,0 @@ -../../../../../.agents/skills/rl-done \ No newline at end of file diff --git a/.claude/skills/rl-init b/.claude/skills/rl-init deleted file mode 120000 index 37200c8..0000000 --- a/.claude/skills/rl-init +++ /dev/null @@ -1 +0,0 @@ -../../../../../.agents/skills/rl-init \ No newline at end of file diff --git a/.claude/skills/rl-log b/.claude/skills/rl-log deleted file mode 120000 index 1dbc5de..0000000 --- a/.claude/skills/rl-log +++ /dev/null @@ -1 +0,0 @@ -../../../../../.agents/skills/rl-log \ No newline at end of file diff --git a/.claude/skills/rl-prompt b/.claude/skills/rl-prompt deleted file mode 120000 index c78a742..0000000 --- a/.claude/skills/rl-prompt +++ /dev/null @@ -1 +0,0 @@ -../../../../../.agents/skills/rl-prompt \ No newline at end of file diff --git a/.claude/skills/rl-review b/.claude/skills/rl-review deleted file mode 120000 index e9605d2..0000000 --- a/.claude/skills/rl-review +++ /dev/null @@ -1 +0,0 @@ -../../../../../.agents/skills/rl-review \ No newline at end of file diff --git a/.claude/skills/rl-state b/.claude/skills/rl-state deleted file mode 120000 index 3a18549..0000000 --- a/.claude/skills/rl-state +++ /dev/null @@ -1 +0,0 @@ -../../../../../.agents/skills/rl-state \ No newline at end of file diff --git a/.claude/skills/rl-status b/.claude/skills/rl-status deleted file mode 120000 index 0ecceec..0000000 --- a/.claude/skills/rl-status +++ /dev/null @@ -1 +0,0 @@ -../../../../../.agents/skills/rl-status \ No newline at end of file diff --git a/.claude/skills/test-ping b/.claude/skills/test-ping deleted file mode 120000 index 52e8805..0000000 --- a/.claude/skills/test-ping +++ /dev/null @@ -1 +0,0 @@ -../../../../../.agents/skills/test-ping \ No newline at end of file From 413d349c6f526a7748a24342e3377ead1849f888 Mon Sep 17 00:00:00 2001 From: 0xBigBoss <95193764+0xBigBoss@users.noreply.github.com> Date: Fri, 8 May 2026 14:03:25 -0400 Subject: [PATCH 43/57] chore(skills): drop 3 unused skills - delegate-implementer: dead spec; v1 was built on feat/delegate-implementer but only SPEC.md ever landed on master. Functionally superseded by the rl: plugin (rl:delegate, rl:implement, rl:rl). - ios-device-screenshot: 1 actual load across all sessions; obsolete. - openai-image-gen: 4 loads, ad-hoc gothic-cathedral image experiments; not part of any recurring workflow. Frees description budget in the skill listing. --- .claude/skills/delegate-implementer/SPEC.md | 223 ------------------ .claude/skills/ios-device-screenshot/SKILL.md | 137 ----------- .claude/skills/openai-image-gen/SKILL.md | 125 ---------- .../skills/openai-image-gen/batch-gothic.sh | 66 ------ .claude/skills/openai-image-gen/generate.sh | 70 ------ 5 files changed, 621 deletions(-) delete mode 100644 .claude/skills/delegate-implementer/SPEC.md delete mode 100644 .claude/skills/ios-device-screenshot/SKILL.md delete mode 100644 .claude/skills/openai-image-gen/SKILL.md delete mode 100755 .claude/skills/openai-image-gen/batch-gothic.sh delete mode 100755 .claude/skills/openai-image-gen/generate.sh diff --git a/.claude/skills/delegate-implementer/SPEC.md b/.claude/skills/delegate-implementer/SPEC.md deleted file mode 100644 index 18118f9..0000000 --- a/.claude/skills/delegate-implementer/SPEC.md +++ /dev/null @@ -1,223 +0,0 @@ -# delegate-implementer skill — design - -**Date:** 2026-04-11 -**Status:** Design approved, ready for planning -**Author:** Opus + Allen (brainstorm session) -**Scope:** v1 of a Claude Code skill that lets Opus delegate bounded implementation tasks to Codex (gpt-5.4) while acting as driver/reviewer. Provider-agnostic shape, Codex-only adapter wired in v1. - -## Summary - -Today Claude Code runs Opus as the worker inside `rl ralph` loops while Codex serves as the reviewer. On large projects that framing burns Opus tokens on mechanical implementation work that Codex can do well when given a tight instruction packet. This spec defines a skill — `delegate-implementer` — that inverts the relationship for selected milestones: Opus writes a delegation packet, Codex implements it in a detached `codex exec` worker, Opus reads a structured result, and the existing `rl review` gate audits the diff with fresh context. The skill is a proving ground (Approach 1) for a later promotion to a first-class `implement` worker role in `rl-broker` (Approach 2). Event shapes and packet format are designed to lift directly into `rl-broker` when that promotion happens. - -## Goals - -- **G1** Let Opus delegate a well-scoped milestone to Codex without leaving the Claude Code session, protecting Opus's context by reading only structured results. -- **G2** Give Opus live, low-noise visibility into Codex's progress via agent-emitted phase events, not log parsing. -- **G3** Keep the reviewer (existing `rl review`) blind to delegation origin so the audit remains unbiased. -- **G4** Design the event schema, packet format, and result schema so promotion to an `rl-broker` `implement` worker is a code move, not a format translation. -- **G5** Be provider-agnostic in shape (interface + adapter pattern), wired only for Codex in v1. -- **G6** Respect yolo-mode-by-default while allowing per-packet downgrade. - -## Non-Goals - -- **NG1** No second provider implementation in v1. Claude and Gemini adapters are stubs documenting the interface. -- **NG2** No changes to `rl-broker`, `rl ralph`, or `.rl/events.jsonl`. The skill runs entirely outside the broker. -- **NG3** No parallel delegations. One packet in flight at a time. Multi-job queueing belongs in the broker promotion. -- **NG4** No automatic retry. Failed delegations are surfaced to Opus, which decides whether to amend and re-dispatch or escalate. -- **NG5** No slash-command entry. Skill is invoked via the Skill tool, typically from inside an active ralph iteration. -- **NG6** No mock provider or recorded-response test harness. Provider behavior is the thing under validation. - -## Requirements - -### Architecture - -- **REQ-DI-001** Three roles must be distinct: Driver (Opus in the Claude Code session), Implementer (Codex via `codex exec`), Reviewer (existing `rl review` worker). The skill coordinates the first two; the existing ralph stop-hook triggers the third unchanged. -- **REQ-DI-002** Implementer runs in a detached background process spawned via `Bash(run_in_background=true)`. Driver never blocks waiting on it. -- **REQ-DI-003** Implementer and Reviewer must never share conversation context. Reviewer receives only the committed diff, not the packet or the implementer's result JSON. -- **REQ-DI-004** The skill owns four on-disk artifacts per job, all under `.rl/`: - - `.rl/packets/.md` — delegation packet (written by Driver) - - `.rl/results/.json` — structured result (written by Implementer) - - `.rl/impl-log/.log` — raw stdout/stderr (not watched by Driver) - - `.rl/impl-events.jsonl` — lifecycle + progress event stream (watched by Driver) - -### Delegation packet - -- **REQ-DI-010** Packets are markdown files with YAML frontmatter. The body is prose instructions the Implementer reads; the frontmatter is parsed only by the wrapper. -- **REQ-DI-011** Frontmatter schema is documented in `schemas/packet.schema.json` and exemplified in `references/packet-authoring.md`. Drift between example and schema is a CI failure. -- **REQ-DI-012** Frontmatter required fields: `packet_id`, `created_at`, `provider`, `provider_args`, `spec_refs`, `plan_refs`, `scope`, `acceptance`. -- **REQ-DI-013** `scope` defines three file-pattern lists: `allowed_write`, `read_only_reference`, and an implicit deny-everything-else. The wrapper does not enforce these — the provider's sandbox does, combined with the prose instructions in the body. -- **REQ-DI-014** `provider_args.sandbox` accepts `yolo | workspace-write | read-only`. Skill default is `yolo`. -- **REQ-DI-015** Sandbox precedence from highest to lowest: environment variable `DELEGATE_CODEX_SANDBOX` → packet frontmatter → skill default. Env var is the top of the chain so that a session-level safety downgrade ("this session: no yolo") cannot be silently undone by a packet. Within that ordering, a packet can only restrict further; it can never escalate above the effective env-or-default level. The wrapper computes the effective level as `min(env, packet, default)` where the ordering is `read-only < workspace-write < yolo`, and rejects any packet whose declared level is higher than the env var with a clear error. -- **REQ-DI-016** Packets must include a `## Progress reporting` section with the standard `delegate-emit` call examples. The skill prose generates this section automatically when Opus composes a packet. - -### Result schema - -- **REQ-DI-020** Result files conform to `schemas/impl-result.schema.json`, enforced at runtime via `codex exec --output-schema`. -- **REQ-DI-021** Required fields: `status` (`complete | partial | blocked`), `summary` (≤800 chars), `files_changed` (array of paths). -- **REQ-DI-022** Optional fields: `commits`, `acceptance` (with per-check status), `blockers`, `handoff_notes` (≤1000 chars). -- **REQ-DI-023** All string fields have maximum length caps. Worst-case result JSON is ~3KB regardless of work volume. This is the Driver's primary context-protection mechanism. -- **REQ-DI-024** `additionalProperties: false` at every object level. The Implementer cannot smuggle extra fields past the schema. -- **REQ-DI-025** The `acceptance` array carries the Implementer's self-reported check results. The Driver is responsible for re-running any checks it considers load-bearing before trusting `status: complete`. - -### Wrapper and helper scripts - -- **REQ-DI-030** `scripts/delegate-codex-impl.sh` is the single entry point that runs one `codex exec` invocation. Target length ≤50 lines. Exceeding 50 lines is the signal to promote to Approach 2 (broker worker role). -- **REQ-DI-031** Wrapper parses packet frontmatter via `yq`, computes codex flags, sets `DELEGATE_JOB_ID` and prepends the skill's `scripts/` directory to `PATH` before invoking codex, so the Implementer can call `delegate-emit` without knowing install paths. -- **REQ-DI-032** Wrapper strips the YAML frontmatter before piping the packet body to `codex exec` on stdin. The Implementer never sees the frontmatter. -- **REQ-DI-033** Under normal operation the wrapper emits four events total: three lifecycle events in order (`implement-queued`, `implement-spawned`, `implement-started`) followed by exactly one terminal event — either `implement-completed` with a `verdict` field populated from the result file, or `implement-failed` with `error` and `duration_ms`. Progress events (`implement-progress`) are emitted separately by the Implementer via `delegate-emit` and are not counted in the wrapper's event budget. -- **REQ-DI-034** Wrapper has no pidfile, no cancellation handler, no retry logic. Those responsibilities live in the Driver (in-session judgment) or the future broker. -- **REQ-DI-035** `scripts/delegate-emit.sh` is called by the Implementer during execution to emit `implement-progress` events. Target length ≤25 lines. -- **REQ-DI-036** `delegate-emit` validates `--phase` against the allowlist `planning | implementing | testing | finalizing`. Invalid phase is a non-zero exit that the Implementer sees. -- **REQ-DI-037** `delegate-emit` caps `--message` at 120 characters (truncated, not rejected). Enforces INV-DI-10 at write time. -- **REQ-DI-038** `delegate-emit` requires `DELEGATE_JOB_ID` in the environment and fails fast if unset. Standalone invocation (without the wrapper) is impossible. - -### Events and Monitor - -- **REQ-DI-040** Events go to `.rl/impl-events.jsonl` in the project root, one JSON object per line, each line appended atomically via a single `>>` redirect from `jq -nc`. -- **REQ-DI-041** Event shape is byte-compatible with `rl`'s canonical `RlEvent` discriminated union in `src/events.ts`: each event has `ts` (ISO8601), `type` (discriminator), `job_id`, and type-specific fields. Promotion to Approach 2 is achieved by adding `implement-*` variants to the union and pointing the Wrapper at `.rl/events.jsonl` instead of `.rl/impl-events.jsonl`. -- **REQ-DI-042** Event type vocabulary for v1: `implement-queued`, `implement-spawned`, `implement-started`, `implement-progress`, `implement-completed`, `implement-failed`. No `implement-cancelled` in v1 (no cancellation). -- **REQ-DI-043** Monitor filter subscribed by the Driver matches `implement-(started|progress|completed|failed)`. Queued and spawned events are visible only via file inspection, not pushed to the Driver's notification stream. -- **REQ-DI-044** Monitor `timeout_ms` is 3600000 (60 minutes). This is a catastrophic safety net. Drift detection is a separate in-session judgment rule, not a Monitor capability. - -### Progress reporting contract (Implementer-side) - -- **REQ-DI-050** The Implementer emits exactly one `delegate-emit` call at each phase transition. Tool-call-level events are explicitly forbidden by the packet instructions. -- **REQ-DI-051** Phase vocabulary is exactly four: `planning`, `implementing`, `testing`, `finalizing`. Any additional granularity must be expressed via the `--message` text, not new phases. -- **REQ-DI-052** The Implementer emits `delegate-emit --phase X` *before* entering phase X, not after completing the previous one. A packet that completes without any progress events is considered malformed from a reporting standpoint (the work may still be valid). - -### Drift detection and cancellation (Driver-side) - -- **REQ-DI-060** Drift rule: if no `implement-progress` event arrives for 5 consecutive minutes and the job has not terminated, the Driver treats the job as drifted. The rule lives in skill prose, not in the wrapper or Monitor — it is an in-session judgment. -- **REQ-DI-061** On drift detection, the Driver's options are: (a) wait one additional cycle if the last phase was `testing` (long test runs are expected), (b) cancel via `pkill -f "codex exec.*"`, or (c) escalate to the human. v1 does not prescribe which of (a)/(b)/(c) is correct — the skill prose describes the tradeoffs and leaves the choice to Opus. -- **REQ-DI-062** There is no automatic cancellation. The Driver must decide. - -### Integration with `rl ralph` - -- **REQ-DI-070** The skill is invoked from inside an active `rl ralph` iteration, after `.rl/task.md` has been crystallized. It does not initialize its own loop state. -- **REQ-DI-071** Delegation heuristic: delegate when the scope is a self-contained milestone in `PLAN.md` (or `.rl/task.md`) that a fresh-context Codex session can execute from the packet alone. If Opus cannot picture how to write the packet without paragraphs of caveats, the task is not a delegation candidate — Opus implements directly. -- **REQ-DI-072** After the Implementer completes and Opus reads the result, the ralph iteration proceeds normally — stop-hook fires, `rl review` is triggered, the reviewer audits the diff. The skill does not call `rl review` itself. -- **REQ-DI-073** The reviewer must not be told the diff came from a delegated worker. No annotations in commit messages identifying the source, no handoff notes pasted into review context. Audit integrity depends on the reviewer's fresh context. - -### Provider interface - -- **REQ-DI-080** A provider adapter lives in `references/providers/.md` and must document four things: invocation pattern, sandbox-level mapping, result-contract support (can it honor `--output-schema`?), and dependency check. -- **REQ-DI-081** v1 ships a complete Codex adapter (`codex.md`) and stubs for Claude (`claude.md`) and Gemini (`gemini.md`). -- **REQ-DI-082** A stub adapter documents the template and explicitly states "not yet wired in v1" in its opening line. The wrapper will refuse to run a packet with `provider: claude` or `provider: gemini` with a clear error message. -- **REQ-DI-083** Adding a second provider in a future version requires: (a) filling in the stub `.md` file, (b) adding a provider-specific wrapper script alongside `delegate-codex-impl.sh`, (c) the main wrapper dispatches to the provider-specific script based on the packet's `provider` field. No changes to `delegate-emit`, the event schema, or the result schema are required — those are provider-agnostic by design. - -### Dependencies - -- **REQ-DI-090** Hard runtime dependencies: `codex` (CLI), `yq`, `jq`, `git`. Missing any of these causes the wrapper to exit non-zero with a clear message before emitting any events. -- **REQ-DI-091** The skill's SKILL.md instructs Opus to verify these dependencies exist at invocation time, before writing the first packet. - -## Invariants - -- **INV-DI-01** Raw provider stdout, token deltas, reasoning summaries, and tool-call traces MUST NOT appear in `.rl/impl-events.jsonl`. Only structured lifecycle and progress events with bounded message fields. Mirrors rl's `INV-10`. -- **INV-DI-02** Every event file line is one complete JSON object with `ts`, `type`, and `job_id`. Partial lines are never observed (atomic append). -- **INV-DI-03** The Implementer cannot emit events without the wrapper's environment (enforced by `DELEGATE_JOB_ID` check in `delegate-emit`). Rogue event injection from outside a running job is impossible by construction. -- **INV-DI-04** The result JSON cannot exceed ~3KB under normal operation due to per-field caps in the schema. Opus context cost per delegation is bounded. -- **INV-DI-05** The reviewer's context contains no reference to the delegation. Audit integrity is preserved as an invariant of the integration, not a best-effort convention. - -## Acceptance Criteria - -### Skill artifacts exist and validate - -- [ ] `SKILL.md` exists with frontmatter matching the Claude Code skill format, and the description triggers on delegation tasks without false positives on general implementation work -- [ ] `scripts/delegate-codex-impl.sh` is ≤50 lines and passes `shellcheck` with zero warnings -- [ ] `scripts/delegate-emit.sh` is ≤25 lines and passes `shellcheck` with zero warnings -- [ ] `schemas/impl-result.schema.json` compiles with `ajv compile` without errors -- [ ] `schemas/packet.schema.json` validates the example packet in `references/packet-authoring.md` - -### Smoke test passes - -- [ ] Running the wrapper with a trivial packet (create `/tmp/delegate-smoke.txt` with fixed content) produces a result JSON with `status: complete` and the file path in `files_changed` -- [ ] The same smoke run emits at least one `implement-started`, one `implement-progress`, and one `implement-completed` event in `.rl/impl-events.jsonl` -- [ ] The target file exists with the expected content after the smoke run -- [ ] The wrapper exits 0 on the smoke run - -### Unit tests pass - -- [ ] `delegate-emit` rejects an invalid `--phase` with non-zero exit and writes no event -- [ ] `delegate-emit` rejects a missing `DELEGATE_JOB_ID` with non-zero exit -- [ ] `delegate-emit` truncates messages over 120 characters cleanly (no partial unicode, no crash) -- [ ] Wrapper rejects a packet whose `provider_args.sandbox` is higher than `DELEGATE_CODEX_SANDBOX` with a clear error and non-zero exit -- [ ] Wrapper exits with a clear error if any of `codex`, `yq`, `jq`, `git` is missing from `PATH` - -### Dogfood validation - -- [ ] The skill successfully drives the rl-broker `implement` worker extension work as a separate brainstorm → plan → execute cycle, Codex produces a working extension, and the existing `rl review` gate approves the diff. If this succeeds, skill v1 is considered proven. If it fails, the failure mode is the input to a follow-up design cycle — no retro-fixes to v1 itself without that new cycle. - -## File Layout - -``` -/ -├── SKILL.md -├── scripts/ -│ ├── delegate-codex-impl.sh -│ └── delegate-emit.sh -├── schemas/ -│ ├── packet.schema.json -│ └── impl-result.schema.json -└── references/ - ├── event-shape.md - ├── packet-authoring.md - ├── drift-detection.md - └── providers/ - ├── codex.md - ├── claude.md - └── gemini.md -``` - -Runtime artifacts under the project being worked on: - -``` -/.rl/ -├── packets/.md -├── results/.json -├── impl-log/.log -├── impl-events.jsonl -└── events.jsonl # existing rl canonical, untouched -``` - -## Approach 2 promotion path - -When the skill graduates to a first-class broker worker, the mechanical steps are: - -1. Add `ImplementQueuedEvent`, `ImplementSpawnedEvent`, `ImplementStartedEvent`, `ImplementProgressEvent`, `ImplementCompletedEvent`, `ImplementFailedEvent`, and optionally `ImplementCancelledEvent` to `RlEvent` in `rl/src/events.ts`. Extend `KNOWN_EVENT_TYPES` accordingly. A new REQ-RL-* entry authorizes the union change. -2. Extend `WorkerQueuedEvent.kind` from `'review'` to `'review' | 'implement'`. -3. Create `rl/src/worker/impl-worker.ts` mirroring `review-worker.ts`, port the wrapper's codex invocation into TypeScript. -4. Add a new CLI command `rl implement ` or a `--implementer` flag on `rl ralph`. -5. Point the skill's wrapper at the new broker command; the skill scripts become thin shims during the transition, then are deleted when confidence is high. -6. Event emission moves from `delegate-emit` + wrapper shell to the broker's `emitEvent` TypeScript path. Write destination moves from `.rl/impl-events.jsonl` to `.rl/events.jsonl`. The Driver's Monitor filter updates to target the canonical file. - -None of these steps require changing the packet format, result schema, or provider interface. Those are stable from v1 by design. - -## Open questions for the implementation plan - -These are explicitly deferred to the planning phase and listed here so they don't get lost: - -- **OQ-1** Exact `SKILL.md` description text — needs to trigger Opus's delegation reflex without matching every "implement X" request. -- **OQ-2** Exact `shellcheck` configuration (which checks to enable/disable for the wrapper scripts). -- **OQ-3** Whether `delegate-emit` should ship a completion installer that symlinks it into `~/.local/bin` for humans who want to call it directly in test scripts, or whether test scripts should invoke it via its full path. -- **OQ-4** Whether the smoke test runs in CI (needs codex available) or only locally on the maintainer's machine. -- **OQ-5** Where the skill source lives in the dotfiles tree — likely under `claude-code/.claude/skills/delegate-implementer/` following the repo's existing convention, but confirming in the plan. - -## Testing Strategy - -Four layers, in order of cost: - -1. **Shellcheck on every script** (cheap, CI). -2. **JSON Schema compile check** via `ajv` (cheap, CI). -3. **Unit tests on `delegate-emit`** via `bats` or plain bash (cheap, CI). -4. **Wrapper smoke test** running a real `codex exec` invocation against a trivial packet (local-only in v1; needs codex auth). -5. **Dogfood validation** — the skill drives the rl-broker extension build. This is the real signal. - -No mock provider. No recorded-response replay. Provider behavior is the thing under validation, and faking it is worse than not testing. - -## Security and Safety Considerations - -- **S-1** Yolo sandbox mode is the default at the user's explicit request (`rl ralph` context, trusted workspace). This is not a vulnerability — it's a deliberate configuration choice documented in REQ-DI-014. -- **S-2** The wrapper runs inside the user's existing shell and git context. No privilege escalation, no network exposure beyond what `codex exec` itself performs. -- **S-3** `delegate-emit` cannot be tricked into writing malformed events (phase allowlist, message truncation, `DELEGATE_JOB_ID` requirement). -- **S-4** The result schema's `additionalProperties: false` plus per-field length caps prevent the Implementer from exfiltrating large blobs through the result path into Driver context. -- **S-5** The packet body is written to `.rl/packets/.md` and is not marked sensitive. Packets may reference spec files but should not include secrets; this is Opus's responsibility when composing them, documented in `references/packet-authoring.md`. diff --git a/.claude/skills/ios-device-screenshot/SKILL.md b/.claude/skills/ios-device-screenshot/SKILL.md deleted file mode 100644 index 681a892..0000000 --- a/.claude/skills/ios-device-screenshot/SKILL.md +++ /dev/null @@ -1,137 +0,0 @@ ---- -name: ios-device-screenshot -description: Use when capturing screenshots from physical iOS devices connected via USB using pymobiledevice3. ---- - -# iOS Device Screenshot - -Take screenshots from physical iOS devices connected via USB using pymobiledevice3. - -## Installation - -```bash -# Install pymobiledevice3 using uv (recommended) -uv tool install pymobiledevice3 - -# Or with pipx -pipx install pymobiledevice3 -``` - -## Prerequisites - -1. **Physical iOS device** connected via USB -2. **Developer Mode enabled** on the device (Settings > Privacy & Security > Developer Mode) -3. **Device trusted** - approve "Trust This Computer" prompt on device - -## Usage - -### For iOS 17+ (including iOS 26+) - -iOS 17+ requires a tunneld daemon running with root privileges: - -```bash -# Terminal 1: Start tunneld (requires sudo, runs continuously) -sudo pymobiledevice3 remote tunneld - -# Terminal 2: Take screenshot via DVT (Developer Tools) -pymobiledevice3 developer dvt screenshot --tunnel "" /path/to/screenshot.png -``` - -The `--tunnel ""` flag tells it to use the tunneld for device communication. - -### For iOS 16 and earlier - -```bash -pymobiledevice3 developer screenshot /path/to/screenshot.png -``` - -## Quick Reference - -```bash -# List connected devices -xcrun devicectl list devices - -# Check iOS version -ideviceinfo -k ProductVersion - -# Check if developer image is mounted -ideviceimagemounter list - -# Auto-mount developer image if needed -pymobiledevice3 mounter auto-mount --tunnel "" - -# Take screenshot (iOS 17+) - single device -pymobiledevice3 developer dvt screenshot --tunnel "" ~/Desktop/screenshot.png - -# Take screenshot with specific device UDID (required for multiple devices) -# First get UDIDs from: xcrun devicectl list devices -# Pass UDID directly to --tunnel (NOT --udid flag which doesn't work with tunneld) -pymobiledevice3 developer dvt screenshot --tunnel "00008101-001E05A41144001E" ~/Desktop/screenshot.png -``` - -**Important:** When multiple devices are connected, pass the UDID directly to `--tunnel` (not `--udid`). The `--tunnel ""` syntax prompts for interactive selection which fails in non-interactive shells. - -## Troubleshooting - -### "InvalidServiceError" or "Failed to start service" - -1. Ensure tunneld is running: `ps aux | grep tunneld` -2. If not running: `sudo pymobiledevice3 remote tunneld` -3. Use the DVT command instead of regular screenshot: `developer dvt screenshot` instead of `developer screenshot` - -### "DeveloperDiskImage not mounted" - -```bash -pymobiledevice3 mounter auto-mount --tunnel "" -``` - -### Multiple devices connected - -Specify the target device with `--udid`: - -```bash -# List devices first -xcrun devicectl list devices - -# Use specific UDID -pymobiledevice3 developer dvt screenshot --tunnel "" --udid "40182233-00C8-51ED-8C68-174E14E4B4C9" /tmp/screenshot.png -``` - -## Key Discovery - -For iOS 17+, the regular `pymobiledevice3 developer screenshot` command often fails even with tunneld running. The solution is to use the DVT (Developer Tools) variant: - -```bash -# This fails on iOS 17+: -pymobiledevice3 developer screenshot --tunnel "" /tmp/screenshot.png - -# This works on iOS 17+: -pymobiledevice3 developer dvt screenshot --tunnel "" /tmp/screenshot.png -``` - -## Integration Example - -```bash -#!/bin/bash -# Take iOS device screenshot and open it - -OUTPUT="/tmp/ios-screenshot-$(date +%Y%m%d-%H%M%S).png" - -# Check if tunneld is running, start if not -if ! pgrep -f "pymobiledevice3 remote tunneld" > /dev/null; then - echo "Starting tunneld (requires sudo)..." - sudo pymobiledevice3 remote tunneld & - sleep 3 -fi - -# Take screenshot -pymobiledevice3 developer dvt screenshot --tunnel "" "$OUTPUT" - -if [ -f "$OUTPUT" ]; then - echo "Screenshot saved to: $OUTPUT" - open "$OUTPUT" # macOS: open in Preview -else - echo "Failed to capture screenshot" - exit 1 -fi -``` diff --git a/.claude/skills/openai-image-gen/SKILL.md b/.claude/skills/openai-image-gen/SKILL.md deleted file mode 100644 index 518f7c6..0000000 --- a/.claude/skills/openai-image-gen/SKILL.md +++ /dev/null @@ -1,125 +0,0 @@ ---- -name: openai-image-gen -description: Use when generating images, graphics, icons, or visual assets via OpenAI DALL-E 3 API. Requires OPENAI_API_KEY. ---- - -# OpenAI Image Generation (DALL-E 3) - -Generate images using OpenAI's DALL-E 3 API via command line. - -## Quick Usage - -```bash -# Generate an image with a prompt -~/.claude/skills/openai-image-gen/generate.sh "your prompt here" output.png - -# Generate with specific size -~/.claude/skills/openai-image-gen/generate.sh "your prompt here" output.png 1792x1024 - -# Generate with quality setting -~/.claude/skills/openai-image-gen/generate.sh "your prompt here" output.png 1024x1024 hd -``` - -## Parameters - -| Parameter | Options | Default | Description | -|-----------|---------|---------|-------------| -| prompt | any text | required | The image description | -| output | filepath | required | Where to save the image | -| size | 1024x1024, 1792x1024, 1024x1792 | 1024x1024 | Image dimensions | -| quality | standard, hd | standard | Image quality (hd = more detail) | - -## Size Guide - -- **1024x1024** - Square, good for icons, avatars, general use -- **1792x1024** - Landscape/wide, good for headers, banners, hero images -- **1024x1792** - Portrait/tall, good for mobile backgrounds, vertical banners - -## Gothic Cathedral Presets - -Pre-made prompts for the 0xbigboss.github.io site: - -### Backgrounds - -```bash -# Dark stone texture -~/.claude/skills/openai-image-gen/generate.sh "Dark cathedral stone wall texture, seamless tileable pattern, deep charcoal gray with subtle purple undertones, weathered medieval masonry, dramatic shadows, gothic architecture, 4k texture, dark moody atmosphere" stone-bg.png 1024x1024 - -# Vaulted ceiling -~/.claude/skills/openai-image-gen/generate.sh "Gothic cathedral ribbed vault ceiling view from below, deep blue-black with gold leaf accent lines on ribs, dramatic perspective, medieval architecture, dim candlelit glow, ornate stone carvings fading into darkness, atmospheric fog, 4k" vault-ceiling.png 1792x1024 -``` - -### Hero Images - -```bash -# Homepage - Light rays -~/.claude/skills/openai-image-gen/generate.sh "Divine light rays streaming through gothic cathedral rose window, deep purple and blue stained glass, golden light beams cutting through darkness, dust particles floating in light, medieval stone interior, dramatic chiaroscuro, cinematic lighting, 4k" hero-home.png 1792x1024 hd - -# Projects - Craftsman workshop -~/.claude/skills/openai-image-gen/generate.sh "Medieval craftsman's workshop, golden tools on dark wood workbench, gothic arched window in background, warm candlelight, scrolls and blueprints, brass instruments, artisan craftsmanship aesthetic, dramatic shadows, cinematic still life" hero-projects.png 1792x1024 hd - -# Posts - Scriptorium -~/.claude/skills/openai-image-gen/generate.sh "Ancient scriptorium desk with illuminated manuscript, quill pen and gold ink pot, gothic window with blue light, leather-bound journals stacked, medieval monastery aesthetic, dramatic rim lighting, warm golden candlelight against cool window light" hero-posts.png 1792x1024 hd - -# Contact - Cathedral door -~/.claude/skills/openai-image-gen/generate.sh "Gothic cathedral door slightly ajar with divine light streaming through crack, ornate iron hinges and handles, carved stone archway frame, welcoming yet mysterious, invitation to enter, dramatic lighting, medieval aesthetic" hero-contact.png 1792x1024 hd -``` - -### Stained Glass Windows - -```bash -# Projects window -~/.claude/skills/openai-image-gen/generate.sh "Gothic stained glass window design, geometric pattern, deep purple and blue glass with gold leading, hammer and gear symbols, craftsman iconography, backlit with divine rays, ornate pointed arch frame, dark background, digital art" window-projects.png 1024x1024 hd - -# Posts window -~/.claude/skills/openai-image-gen/generate.sh "Gothic stained glass window design, open book and quill symbols, deep blue and purple glass with gold leading, medieval manuscript aesthetic, backlit glow, pointed arch frame, ornate tracery pattern, dark background, digital art" window-posts.png 1024x1024 hd - -# Contact window -~/.claude/skills/openai-image-gen/generate.sh "Gothic stained glass window design, dove and reaching hand symbols, deep purple and gold glass, connection iconography, divine light streaming through, pointed arch frame, ornate leading pattern, dark background, digital art" window-contact.png 1024x1024 hd -``` - -### Decorative Elements - -```bash -# Logo monogram -~/.claude/skills/openai-image-gen/generate.sh "Medieval illuminated manuscript style monogram letters AE, gold leaf with deep blue and purple accents, ornate flourishes and Celtic knotwork, gothic calligraphy style, intricate detail, dark background, luxury heraldic aesthetic" logo-ae.png 1024x1024 hd - -# Divider -~/.claude/skills/openai-image-gen/generate.sh "Medieval ornate horizontal divider, gold filigree on dark background, gothic scrollwork pattern, symmetrical design, thin elegant line with central medallion, Celtic knotwork accents, digital art, isolated element" divider.png 1792x1024 - -# Light ray overlay -~/.claude/skills/openai-image-gen/generate.sh "Divine light rays streaming from top, volumetric god rays, dust particles floating, golden warm light on pure black background, cathedral lighting effect, subtle and ethereal, digital art" light-overlay.png 1792x1024 -``` - -## Batch Generation - -Generate all Gothic presets at once: - -```bash -cd ~/path/to/your/project/public/images -~/.claude/skills/openai-image-gen/batch-gothic.sh -``` - -## Tips for Better Results - -1. **Be specific** - More detail = better results -2. **Include style keywords** - "4k", "cinematic", "digital art", "photorealistic" -3. **Specify lighting** - "dramatic shadows", "rim lighting", "candlelit" -4. **Mention perspective** - "from below", "bird's eye view", "close-up" -5. **Use quality=hd** for hero images and important assets - -## Troubleshooting - -**"Invalid API key"** - Check `echo $OPENAI_API_KEY` is set - -**"Content policy violation"** - Rephrase prompt to avoid flagged content - -**Image looks wrong** - DALL-E interprets prompts creatively; try multiple generations or refine prompt - -## Cost Reference - -DALL-E 3 pricing (as of 2024): -- Standard 1024x1024: ~$0.04/image -- Standard 1792x1024 or 1024x1792: ~$0.08/image -- HD 1024x1024: ~$0.08/image -- HD 1792x1024 or 1024x1792: ~$0.12/image diff --git a/.claude/skills/openai-image-gen/batch-gothic.sh b/.claude/skills/openai-image-gen/batch-gothic.sh deleted file mode 100755 index 4251d90..0000000 --- a/.claude/skills/openai-image-gen/batch-gothic.sh +++ /dev/null @@ -1,66 +0,0 @@ -#!/bin/bash - -# Batch generate all Gothic cathedral assets for 0xbigboss.github.io -# Run from within your project's public/images directory - -set -e - -SCRIPT_DIR="$(dirname "$0")" -GEN="$SCRIPT_DIR/generate.sh" - -echo "=== Gothic Cathedral Asset Generator ===" -echo "This will generate multiple images using DALL-E 3" -echo "Estimated cost: ~$1.00-1.50" -echo "" - -read -p "Continue? (y/n) " -n 1 -r -echo -if [[ ! $REPLY =~ ^[Yy]$ ]]; then - exit 1 -fi - -mkdir -p backgrounds heroes windows decorative - -echo "" -echo "=== Generating Backgrounds ===" - -"$GEN" "Dark cathedral stone wall texture, seamless tileable pattern, deep charcoal gray with subtle purple undertones, weathered medieval masonry, dramatic shadows between stones, gothic architecture, 4k texture, dark moody atmosphere, no visible mortar lines" backgrounds/stone-texture.png 1024x1024 - -"$GEN" "Gothic cathedral ribbed vault ceiling view from below, deep blue-black with gold leaf accent lines on ribs, dramatic perspective, medieval architecture, dim candlelit glow, ornate stone carvings fading into darkness, atmospheric fog, 4k wallpaper" backgrounds/vault-ceiling.png 1792x1024 hd - -echo "" -echo "=== Generating Hero Images ===" - -"$GEN" "Divine light rays streaming through gothic cathedral rose window, deep purple and blue stained glass, golden light beams cutting through darkness, dust particles floating in light, medieval stone interior, dramatic chiaroscuro, cinematic lighting, 4k, no people" heroes/home.png 1792x1024 hd - -"$GEN" "Medieval craftsman's workshop, golden tools on dark wood workbench, gothic arched window in background with blue light, warm candlelight illuminating workspace, scrolls and blueprints scattered, brass instruments and gears, artisan craftsmanship aesthetic, dramatic shadows, cinematic still life, no people" heroes/projects.png 1792x1024 hd - -"$GEN" "Ancient scriptorium desk with illuminated manuscript open, quill pen resting in gold ink pot, gothic window casting blue light, leather-bound journals stacked neatly, medieval monastery aesthetic, dramatic rim lighting, warm golden candlelight contrasting cool window light, no people" heroes/posts.png 1792x1024 hd - -"$GEN" "Gothic cathedral door slightly ajar with divine golden light streaming through the crack, ornate wrought iron hinges and handles, intricately carved stone archway frame with gothic tracery, welcoming yet mysterious atmosphere, invitation to enter, dramatic cinematic lighting, medieval aesthetic" heroes/contact.png 1792x1024 hd - -echo "" -echo "=== Generating Stained Glass Windows ===" - -"$GEN" "Gothic stained glass window design, geometric sacred pattern, deep purple and blue glass pieces with gold leading lines, hammer and gear symbols representing craftsmanship, backlit with divine golden rays, ornate pointed arch frame, isolated on pure black background, digital art illustration" windows/projects.png 1024x1024 hd - -"$GEN" "Gothic stained glass window design, open book and quill pen symbols in center, deep blue and purple glass with intricate gold leading, medieval manuscript aesthetic, soft backlit glow, pointed gothic arch frame, ornate tracery pattern surrounding, isolated on pure black background, digital art" windows/posts.png 1024x1024 hd - -"$GEN" "Gothic stained glass window design, white dove and reaching hand symbols representing connection, deep purple and gold glass pieces, divine light streaming through, pointed gothic arch frame, ornate geometric leading pattern, isolated on pure black background, digital art illustration" windows/contact.png 1024x1024 hd - -echo "" -echo "=== Generating Decorative Elements ===" - -"$GEN" "Medieval illuminated manuscript style monogram combining letters A and E intertwined, ornate gold leaf with deep blue and purple accents, flourishes and Celtic knotwork borders, gothic calligraphy style, intricate hand-drawn detail, pure black background, luxury heraldic aesthetic, logo design" decorative/monogram-ae.png 1024x1024 hd - -"$GEN" "Medieval ornate horizontal divider bar, intricate gold filigree scrollwork on pure black background, gothic pattern with symmetrical design, thin elegant line with central diamond medallion, Celtic knotwork accents on ends, digital art, isolated decorative element, PNG style" decorative/divider.png 1792x1024 - -"$GEN" "Volumetric divine light rays streaming from top of frame downward, god rays with floating dust particles, warm golden light on pure black background, cathedral window lighting effect, subtle and ethereal atmosphere, overlay texture, digital art" decorative/light-rays.png 1792x1024 - -echo "" -echo "=== Complete! ===" -echo "" -echo "Generated assets:" -find . -name "*.png" -newer "$0" 2>/dev/null | sort -echo "" -echo "Total images: $(find . -name "*.png" -newer "$0" 2>/dev/null | wc -l | tr -d ' ')" diff --git a/.claude/skills/openai-image-gen/generate.sh b/.claude/skills/openai-image-gen/generate.sh deleted file mode 100755 index bc48bc9..0000000 --- a/.claude/skills/openai-image-gen/generate.sh +++ /dev/null @@ -1,70 +0,0 @@ -#!/bin/bash - -# OpenAI DALL-E 3 Image Generation Script -# Usage: generate.sh "prompt" output.png [size] [quality] - -set -e - -PROMPT="$1" -OUTPUT="$2" -SIZE="${3:-1024x1024}" -QUALITY="${4:-standard}" - -if [ -z "$PROMPT" ] || [ -z "$OUTPUT" ]; then - echo "Usage: generate.sh \"prompt\" output.png [size] [quality]" - echo "" - echo "Sizes: 1024x1024 (default), 1792x1024, 1024x1792" - echo "Quality: standard (default), hd" - exit 1 -fi - -if [ -z "$OPENAI_API_KEY" ]; then - echo "Error: OPENAI_API_KEY environment variable not set" - exit 1 -fi - -echo "Generating image..." -echo " Prompt: ${PROMPT:0:80}..." -echo " Size: $SIZE" -echo " Quality: $QUALITY" -echo " Output: $OUTPUT" - -# Call OpenAI API -RESPONSE=$(curl -s https://api.openai.com/v1/images/generations \ - -H "Content-Type: application/json" \ - -H "Authorization: Bearer $OPENAI_API_KEY" \ - -d "{ - \"model\": \"dall-e-3\", - \"prompt\": $(echo "$PROMPT" | jq -Rs .), - \"n\": 1, - \"size\": \"$SIZE\", - \"quality\": \"$QUALITY\", - \"response_format\": \"url\" - }") - -# Check for errors -ERROR=$(echo "$RESPONSE" | jq -r '.error.message // empty') -if [ -n "$ERROR" ]; then - echo "Error: $ERROR" - exit 1 -fi - -# Extract URL and download -IMAGE_URL=$(echo "$RESPONSE" | jq -r '.data[0].url') -REVISED_PROMPT=$(echo "$RESPONSE" | jq -r '.data[0].revised_prompt // empty') - -if [ -z "$IMAGE_URL" ] || [ "$IMAGE_URL" = "null" ]; then - echo "Error: No image URL in response" - echo "$RESPONSE" - exit 1 -fi - -echo "Downloading image..." -curl -s "$IMAGE_URL" -o "$OUTPUT" - -echo "Done! Saved to: $OUTPUT" -if [ -n "$REVISED_PROMPT" ]; then - echo "" - echo "DALL-E revised prompt:" - echo " $REVISED_PROMPT" -fi From 7dd332348feec09e01fe1e522ed9f7fd5a087360 Mon Sep 17 00:00:00 2001 From: 0xBigBoss <95193764+0xBigBoss@users.noreply.github.com> Date: Fri, 8 May 2026 14:34:11 -0400 Subject: [PATCH 44/57] chore(settings): resync baseline with live machine state Trim enabledPlugins to the active set (canton-skills, recall, send-infra, rl, canton-keycloak-skills, typescript-lsp), add the canton-keycloak-providers marketplace, and surface defaultMode=auto plus skipAutoPermissionPrompt that had drifted in ~/.claude. --- settings/settings.json | 28 ++++++---------------------- 1 file changed, 6 insertions(+), 22 deletions(-) diff --git a/settings/settings.json b/settings/settings.json index 7076c21..b033e23 100644 --- a/settings/settings.json +++ b/settings/settings.json @@ -91,6 +91,7 @@ "Bash(date:*)", "Bash(wc:*)" ], + "defaultMode": "auto", "additionalDirectories": [ "~/.handoffs" ] @@ -131,34 +132,16 @@ "send-infra-plugins": { "source": { "source": "github", "repo": "0xsend/infra" } }, "gh-pulse-marketplace": { "source": { "source": "github", "repo": "0xbigboss/gh-pulse" } }, "0xsend-rl": { "source": { "source": "github", "repo": "0xsend/rl" } }, - "linear-cli-marketplace": { "source": { "source": "github", "repo": "0xBigBoss/linear-cli" } } + "linear-cli-marketplace": { "source": { "source": "github", "repo": "0xBigBoss/linear-cli" } }, + "canton-keycloak-providers": { "source": { "source": "git", "url": "https://github.com/0xsend/canton-keycloak-providers.git" } } }, "enabledPlugins": { - "linear-cli@linear-cli-marketplace": true, "canton-skills@0xsend-marketplace": true, - "pr-review-toolkit@claude-plugins-official": true, - "typescript-lsp@claude-plugins-official": true, - "pyright-lsp@claude-plugins-official": true, - "gopls-lsp@claude-plugins-official": true, - "jdtls-lsp@claude-plugins-official": true, - "clangd-lsp@claude-plugins-official": true, - "swift-lsp@claude-plugins-official": true, "recall@0xbigboss-recall": true, - "silo@0xBigBoss-silo": true, "send-infra@send-infra-plugins": true, - "gh-pulse@gh-pulse-marketplace": true, "rl@0xsend-rl": true, - "superpowers@claude-plugins-official": true, - "code-review@claude-plugins-official": true, - "skill-creator@claude-plugins-official": true, - "claude-md-management@claude-plugins-official": true, - "claude-code-setup@claude-plugins-official": true, - "agent-sdk-dev@claude-plugins-official": true, - "plugin-dev@claude-plugins-official": true, - "posthog@claude-plugins-official": true, - "data-engineering@claude-plugins-official": true, - "cloudflare@claude-plugins-official": true, - "expo@claude-plugins-official": true + "canton-keycloak-skills@canton-keycloak-providers": true, + "typescript-lsp@claude-plugins-official": true }, "env": { "BASH_ENV": "/Users/allen/code/dotfiles/claude-code/hooks/direnv-bash-env" @@ -166,6 +149,7 @@ "alwaysThinkingEnabled": true, "effortLevel": "xhigh", "skipDangerousModePermissionPrompt": true, + "skipAutoPermissionPrompt": true, "feedbackSurveyState": { "lastShownTime": 1754076652911 } From b20503639a32fc8b1a36c80dd849115f7aad5bca Mon Sep 17 00:00:00 2001 From: 0xBigBoss <95193764+0xBigBoss@users.noreply.github.com> Date: Sun, 17 May 2026 05:48:51 -0500 Subject: [PATCH 45/57] chore: gitignore local engagement-algos skill symlink .claude/skills/engagement-algos is a symlink to a working directory outside this repo; keep it loose on this host. --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index 902e5c8..67d0d3c 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,6 @@ __pycache__/ # Local commands (not ready for sharing) commands/blog-context.md + +# Local skills (not ready for sharing) +.claude/skills/engagement-algos From 3d3cd8fa8f50199b95bdd02ea6d7c718016a4d4a Mon Sep 17 00:00:00 2001 From: 0xBigBoss <95193764+0xBigBoss@users.noreply.github.com> Date: Sun, 17 May 2026 13:19:52 -0500 Subject: [PATCH 46/57] chore(plugins): rename recall marketplace to alleneubank-recall Upstream 0xbigboss/recall was transferred to alleneubank/recall and the marketplace renamed accordingly. The plugin name stays `recall`, but the @-suffix tracking the marketplace must update or `claude plugin install` fails with "Plugin recall not found in marketplace 0xbigboss-recall". --- settings/settings.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/settings/settings.json b/settings/settings.json index b033e23..ffcc8a3 100644 --- a/settings/settings.json +++ b/settings/settings.json @@ -127,7 +127,7 @@ "extraKnownMarketplaces": { "claude-plugins-official": { "source": { "source": "github", "repo": "anthropics/claude-plugins-official" } }, "0xsend-marketplace": { "source": { "source": "git", "url": "https://github.com/0xsend/claude-code-plugins.git" } }, - "0xbigboss-recall": { "source": { "source": "github", "repo": "0xbigboss/recall" } }, + "alleneubank-recall": { "source": { "source": "github", "repo": "alleneubank/recall" } }, "0xBigBoss-silo": { "source": { "source": "github", "repo": "0xBigBoss/silo" } }, "send-infra-plugins": { "source": { "source": "github", "repo": "0xsend/infra" } }, "gh-pulse-marketplace": { "source": { "source": "github", "repo": "0xbigboss/gh-pulse" } }, @@ -137,7 +137,7 @@ }, "enabledPlugins": { "canton-skills@0xsend-marketplace": true, - "recall@0xbigboss-recall": true, + "recall@alleneubank-recall": true, "send-infra@send-infra-plugins": true, "rl@0xsend-rl": true, "canton-keycloak-skills@canton-keycloak-providers": true, From 8aba74b92290d6c24f2b26dde71f4593568f90cc Mon Sep 17 00:00:00 2001 From: 0xBigBoss <95193764+0xBigBoss@users.noreply.github.com> Date: Mon, 18 May 2026 12:11:02 -0500 Subject: [PATCH 47/57] chore(settings): absorb tui=fullscreen into baseline Pin Claude Code's TUI to fullscreen (alternate-screen) so the preference survives across machines instead of living only in the live ~/.claude/settings.json. --- settings/settings.json | 1 + 1 file changed, 1 insertion(+) diff --git a/settings/settings.json b/settings/settings.json index ffcc8a3..7b7f5b0 100644 --- a/settings/settings.json +++ b/settings/settings.json @@ -5,6 +5,7 @@ "pr": "" }, "theme": "auto", + "tui": "fullscreen", "permissions": { "allow": [ "Edit(~/.handoffs/**)", From 876d8695692d07743e5fb265b892ca3709919cc4 Mon Sep 17 00:00:00 2001 From: Allen Date: Thu, 21 May 2026 13:39:28 +0000 Subject: [PATCH 48/57] docs(skills): split tilt error check from pending and surface buildHistory reason Errors were getting buried in 20+ pending lines; separate the queries so failures surface first and include buildHistory[0].error for the why. Adds a re-poll note since in_progress can flip to error. --- .claude/skills/tilt/SKILL.md | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/.claude/skills/tilt/SKILL.md b/.claude/skills/tilt/SKILL.md index d0c85d2..6d5d13f 100644 --- a/.claude/skills/tilt/SKILL.md +++ b/.claude/skills/tilt/SKILL.md @@ -7,16 +7,21 @@ description: Use when checking deployment health, investigating errors, reading ## First Action: Check for Errors -Before investigating issues or verifying deployments, check resource health: +Before investigating issues or verifying deployments, check resource health. Run **errors first**, separately from pending/in-progress — otherwise real failures get buried in 20+ pending lines: ```bash -# Find errors and pending resources (primary health check) -tilt get uiresources -o json | jq -r '.items[] | select(.status.runtimeStatus == "error" or .status.updateStatus == "error" or .status.updateStatus == "pending") | "\(.metadata.name): runtime=\(.status.runtimeStatus) update=\(.status.updateStatus)"' +# 1. Errors only — surface the buildHistory[0].error so you see WHY, not just THAT +tilt get uiresources -o json | jq -r '.items[] | select(.status.runtimeStatus == "error" or .status.updateStatus == "error") | "\(.metadata.name): runtime=\(.status.runtimeStatus) update=\(.status.updateStatus)\n reason: \((.status.buildHistory[0].error // "(no buildHistory error; check tilt logs)") | gsub("\n"; " ") | .[0:240])"' -# Quick status overview +# 2. In-progress and pending — informational; an in-progress build may flip to error any moment +tilt get uiresources -o json | jq -r '.items[] | select(.status.updateStatus == "in_progress" or .status.updateStatus == "pending" or .status.runtimeStatus == "pending") | "\(.metadata.name): runtime=\(.status.runtimeStatus) update=\(.status.updateStatus)"' + +# 3. Quick status overview tilt get uiresources -o json | jq '[.items[].status.updateStatus] | group_by(.) | map({status: .[0], count: length})' ``` +If a resource is `in_progress` when you check, **re-poll** before declaring it healthy — it can transition straight to `error` with a populated `buildHistory[0].error`. The `updateStatus` field reflects only the *current* build attempt; the last error always lives in `buildHistory[0].error` even when `updateStatus` is `none` or `not_applicable`. + ## Non-Default Ports When Tilt runs on a non-default port, add `--port`: From ee9e8c7ba6bee3f5295be6d97d150f0675560328 Mon Sep 17 00:00:00 2001 From: Allen Date: Thu, 21 May 2026 16:23:45 +0000 Subject: [PATCH 49/57] docs: standalone-friendly README and install path Rewrite README.md to make this repo presentable for open-source consumers who don't have the surrounding private dotfiles tree. Inventory every directory (commands, agents, all 25 skills, plugins, statusline, scripts, settings, hooks, analytics) with accurate descriptions, document the plugin marketplace install path, and add an explicit footnote about the optional dotfiles-tree relationship. install.sh now also symlinks commands/, agents/, and .claude/skills/ into ~/.claude/, and prefers the parent dotfiles' codex/codex.json when present but falls back to a new in-repo codex.json sample so standalone clones no longer fail under set -e. Remove the stale TODO.md (DuckDB indexing work, all items completed) and untrack CLAUDE.local.md / AGENTS.local.md so personal notes stay private on disk via .gitignore. Drop a personal ~/dotfiles path from scripts/README.md. --- .gitignore | 4 + AGENTS.local.md | 1 - CLAUDE.local.md | 93 ---------------- README.md | 267 ++++++++++++++++++++++++++++------------------ TODO.md | 27 ----- codex.json | 11 ++ install.sh | 20 +++- scripts/README.md | 5 +- 8 files changed, 198 insertions(+), 230 deletions(-) delete mode 120000 AGENTS.local.md delete mode 100644 CLAUDE.local.md delete mode 100644 TODO.md create mode 100644 codex.json diff --git a/.gitignore b/.gitignore index 67d0d3c..f477167 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,7 @@ +# Personal/local-only notes (not shared via this repo) +CLAUDE.local.md +AGENTS.local.md + # Ralph loop state (managed by hook, not tracked in VCS) .rl/ diff --git a/AGENTS.local.md b/AGENTS.local.md deleted file mode 120000 index 261173f..0000000 --- a/AGENTS.local.md +++ /dev/null @@ -1 +0,0 @@ -CLAUDE.local.md \ No newline at end of file diff --git a/CLAUDE.local.md b/CLAUDE.local.md deleted file mode 100644 index f90ce09..0000000 --- a/CLAUDE.local.md +++ /dev/null @@ -1,93 +0,0 @@ -# Claude Code Development Repository (Local Notes) - -Local operational notes for maintaining this Claude Code config repo. - -## Scope - -This repo is the source of truth for Claude Code runtime assets (commands, agents, skills, hooks, settings, statusline) that are projected into `~/.claude` and synced to Codex. - -## Work From Source Paths - -- Edit files in this repository, not in `~/.claude/*`. -- Runtime links are managed by `bin/bin/claude-bootstrap`. -- Generated runtime settings are managed by `bin/bin/claude-settings-merge`. - -## Key Paths - -- Commands: `claude-code/commands/` -- Agents: `claude-code/agents/` -- Skills: `claude-code/.claude/skills/` -- Hooks and scripts: `claude-code/hooks/`, `claude-code/scripts/` -- Settings baseline: `claude-code/settings/settings.json` - -## Canonical Sync/Apply Flow - -```bash -# 1) Validate managed links and runtime files -bin/bin/claude-bootstrap --check - -# 2) Validate merged settings -bin/bin/claude-settings-merge --check -bin/bin/claude-settings-merge --diff # show what --fix would change - -# 3) Validate Claude->Codex sync (commands + skills) -claude-code/scripts/sync-codex.sh --check - -# Apply -bin/bin/claude-bootstrap --fix -bin/bin/claude-settings-merge --fix -claude-code/scripts/sync-codex.sh -``` - -## When Adding New Assets - -- New command: add `*.md` to `claude-code/commands/` and run `sync-codex.sh`. -- New skill: add `claude-code/.claude/skills//SKILL.md` and run `sync-codex.sh`. -- Plugin and marketplace state: update `claude-code/settings/settings.json`. `enabledPlugins` controls which plugins are turned on, `extraKnownMarketplaces` declares where to fetch them, and `install.sh --claude` derives its idempotent install loop from that file. - -## Review Checklist - -Before finishing changes, run: - -```bash -# From dotfiles repo root -./install.sh --claude --check -bin/bin/claude-bootstrap --check -bin/bin/claude-settings-merge --check -claude-code/scripts/sync-codex.sh --check -``` - -## Plugin Troubleshooting - -If a plugin appears installed but hooks do not run, check orphan markers: - -```bash -find ~/.claude/plugins/cache -name ".orphaned_at" -rm ~/.claude/plugins/cache/0xbigboss-plugins//*/.orphaned_at -claude plugin update -``` - -## Publishing Plugin Updates - -**Any change to plugin source files requires a version bump.** Lefthook pre-commit and pre-push hooks enforce this automatically via `claude-code/scripts/check-plugin-versions.sh`. - -### Version bump checklist - -1. Bump `version` in `claude-code/plugins//.claude-plugin/plugin.json` -2. Bump `version` for the same plugin in `claude-code/.claude-plugin/marketplace.json` -3. Both versions must match — the hook fails on mismatch -4. Stage `plugin.json` alongside your source changes — the hook warns if source files changed without it - -### After committing - -Refresh the local marketplace so the runtime picks up the new version: - -```bash -claude plugin marketplace update 0xbigboss-plugins -``` - -### Manual check - -```bash -claude-code/scripts/check-plugin-versions.sh -``` diff --git a/README.md b/README.md index 0ed267d..cac4792 100644 --- a/README.md +++ b/README.md @@ -1,83 +1,67 @@ # Claude Code Configuration -Personal configuration for [Claude Code](https://docs.anthropic.com/en/docs/claude-code) providing development guidelines, slash commands, custom agents, and language-specific skills. +Personal configuration for [Claude Code](https://docs.anthropic.com/en/docs/claude-code): agent guidelines, slash commands, subagents, language-specific skills, plugins, a Zig statusline, and a settings baseline. Designed to be usable as a standalone repo or as a submodule of a larger dotfiles tree. -## What's Included +## Quick Start -``` -claude-code/ -├── CLAUDE.md # Shared runtime agent instructions (synced to ~/.claude/CLAUDE.md and ~/.codex/AGENTS.md) -├── CLAUDE.local.md # Local operational notes for this repo (not the runtime instruction source) -├── commands/ # Slash commands -│ ├── fix-issue.md # /fix-issue - locate and fix issues -│ ├── git-commit.md # /git-commit - conventional commit workflow -│ ├── handoff.md # /handoff - generate session handoff prompts -│ └── rewrite-history.md # /rewrite-history - clean up branch commits -├── agents/ # Custom subagents -│ ├── code-reviewer.md # Review code for quality and security -│ ├── debugger.md # Root cause analysis for failures -│ ├── refactorer.md # Clean refactoring with complete migrations -│ └── test-writer.md # Write tests that verify correctness -├── .claude/ -│ └── skills/ # Language and tool best practices -│ ├── python-best-practices/ -│ ├── typescript-best-practices/ -│ ├── react-best-practices/ -│ ├── go-best-practices/ -│ ├── zig-best-practices/ -│ ├── playwright-best-practices/ -│ ├── tamagui-best-practices/ -│ ├── tilt/ -│ ├── web-fetch/ -│ ├── axe-ios-simulator/ -│ └── zig-docs/ -├── scripts/ # Utility scripts -│ ├── install-symlinks.sh # Installation helper -│ └── sync-codex.sh # Sync/check Claude commands + skills into Codex -├── codex-overrides/ -│ └── skills/ # Codex-only skill overrides applied after upstream sync -├── settings/ # Settings configurations -├── statusline/ # Statusline configurations -└── analytics/ # Usage analytics (submodule) -``` - -## Installation - -### Via Stow (Recommended) - -This repo is a submodule of a dotfiles repository using GNU Stow: +Clone the repo anywhere, then run the installer to symlink runtime assets into `~/.claude/`: ```bash -cd ~/code/dotfiles -stow -v -R -t ~ claude +git clone https://github.com/alleneubank/claude-code.git ~/code/claude-code +cd ~/code/claude-code +./install.sh # apply symlinks +./install.sh --check # report drift without changing anything ``` -The `claude` stow package symlinks to this repo's contents. +The installer is idempotent. It links: -### Manual Symlinks +- `CLAUDE.md` → `~/.claude/CLAUDE.md` (agent guidelines) +- `commands/` → `~/.claude/commands/` (slash commands) +- `agents/` → `~/.claude/agents/` (subagents) +- `.claude/skills/` → `~/.claude/skills/` (best-practices skills) +- `codex.json` → `~/.claude/codex.json` (Codex CLI config for the `codex-reviewer` plugin) + +Settings, statusline binary, and plugins are opt-in — see the sections below. + +## Repository Layout -```bash -mkdir -p ~/.claude -ln -sf "$(pwd)/CLAUDE.md" ~/.claude/CLAUDE.md -ln -sf "$(pwd)/commands" ~/.claude/commands -ln -sf "$(pwd)/agents" ~/.claude/agents -ln -sf "$(pwd)/.claude/skills" ~/.claude/skills +``` +claude-code/ +├── CLAUDE.md # Agent guidelines (linked into ~/.claude/CLAUDE.md) +├── codex.json # Codex CLI defaults (linked into ~/.claude/codex.json) +├── install.sh # Idempotent symlink installer +├── commands/ # Slash commands +├── agents/ # Subagents +├── .claude/skills/ # Best-practices skills (auto-load by file context) +├── .claude-plugin/ # Marketplace manifest for the bundled plugins +├── plugins/ # Source for plugins published via marketplace.json +│ ├── codex-reviewer/ # Codex CLI review gate +│ └── ralph-reviewed/ # Iterative loop with Codex review +├── codex-overrides/skills/ # Codex-only skill overrides applied during sync +├── settings/settings.json # Settings baseline (copy or merge into ~/.claude/settings.json) +├── statusline/ # Zig statusline (build separately, see below) +├── hooks/direnv-bash-env # BASH_ENV shim that loads direnv on subshell cd +├── scripts/ # Usage tracking, codex sync, plugin version checks +└── analytics/ # Submodule: claude-code-analytics (transcript analyzer) ``` ## Commands -Invoke with `/command-name` in Claude Code: +Invoked with `/` in Claude Code. Sources live in `commands/`. | Command | Description | |---------|-------------| -| `/fix-issue ` | Find and fix an issue by ID with tests and PR description | -| `/git-commit` | Review changes and create conventional commits | -| `/handoff` | Generate a self-contained handoff prompt for another agent | -| `/rewrite-history` | Rewrite branch with clean, narrative commit history | +| `/commit` | Stage, draft, and create a conventional git commit from the current diff | +| `/fix-issue ` | Find and fix an issue end-to-end with tests and a PR description | +| `/greenline` | Tilt bootstrap then iterative e2e baseline improvement with categorized fixes | +| `/handoff` | Generate a self-contained handoff prompt for another agent (writes to `~/.handoffs/`) | +| `/rewrite-history` | Rewrite the current branch with a clean narrative commit history | +| `/specout` | Interview-driven workflow to fill gaps in a `SPEC.md` | +| `/triage` | Interactive Linear issue triage with codebase verification | -## Agents +## Subagents -Custom subagents for focused tasks. Claude Code delegates to these automatically when appropriate: +Custom subagents in `agents/`. Claude Code delegates automatically when their description matches the task. | Agent | Purpose | |-------|---------| @@ -88,65 +72,144 @@ Custom subagents for focused tasks. Claude Code delegates to these automatically ## Skills -Language and tool-specific best practices loaded automatically based on file context: +Best-practices skills in `.claude/skills/` are auto-loaded when the file context matches their triggers. The full set: + +| Skill | Use when | +|-------|----------| +| `atlas-best-practices` | Editing `atlas.hcl`, schema HCL/SQL, or planning Atlas migrations | +| `axe-ios-simulator` | Driving the iOS Simulator via AXe (screenshots, accessibility, automation) | +| `canton-network-repos` | Working with Canton Network participants, Daml, or Splice | +| `e2e` | Running, debugging, or fixing e2e tests | +| `electrobun-best-practices` | Building Electrobun desktop apps | +| `git-best-practices` | Commits, branches, PRs, rewriting history | +| `git-rebase-sync` | Rebasing a feature branch onto its base | +| `git-worktree-tidy` | Pruning stale local branches and worktrees | +| `go-best-practices` | Reading or writing `.go` files | +| `improve` | Surfacing concrete improvements after a task | +| `nix-best-practices` | Editing flakes, overlays, `shell.nix`, `flake.nix` | +| `op-cli` | Reading secrets from 1Password via `op` | +| `orbstack-best-practices` | OrbStack Linux VMs and Docker on macOS | +| `playwright-best-practices` | Writing or modifying Playwright tests | +| `python-best-practices` | Reading or writing Python files | +| `react-best-practices` | Reading or writing React components | +| `spec-best-practices` | Creating, reviewing, or updating `SPEC.md` | +| `tamagui-best-practices` | Working in Tamagui projects | +| `testing-best-practices` | Designing tests and planning test strategy | +| `tilt` | Reading Tilt status, logs, working with Tiltfiles | +| `tiltup` | Bootstrapping Tilt to a healthy state | +| `typescript-best-practices` | Reading or writing TypeScript / JavaScript files | +| `web-fetch` | Fetching web content as clean markdown | +| `zig-best-practices` | Reading or writing Zig files | +| `zmx` | Managing long-lived dev processes via `zmx` | + +## Plugins + +This repo also doubles as a Claude Code plugin marketplace via `.claude-plugin/marketplace.json`. Two plugins ship from it: + +| Plugin | Description | +|--------|-------------| +| `ralph-reviewed` | Iterative Ralph loop with a Codex CLI review gate at completion | +| `codex-reviewer` | Standalone Codex-powered code review gate | + +### Install as a marketplace + +```bash +# Inside Claude Code: +/plugin marketplace add alleneubank/claude-code +/plugin install ralph-reviewed@0xbigboss-plugins +/plugin install codex-reviewer@0xbigboss-plugins +``` + +Or install from a local checkout: -| Context | Skill | -|---------|-------| -| Python (`.py`, `pyproject.toml`) | python-best-practices | -| TypeScript (`.ts`, `.tsx`) | typescript-best-practices | -| React (`.tsx`, `.jsx`, `@react` imports) | react-best-practices | -| Go (`.go`, `go.mod`) | go-best-practices | -| Zig (`.zig`, `build.zig`) | zig-best-practices | -| Playwright (`@playwright/test`) | playwright-best-practices | -| Tamagui (`@tamagui` imports) | tamagui-best-practices | -| Tilt (`Tiltfile`) | tilt | +```bash +/plugin marketplace add ~/code/claude-code +``` -## Runtime Settings +Both plugins read `~/.claude/codex.json` for sandbox/approval/timeout settings — `install.sh` puts a default copy there. See `plugins/ralph-reviewed/README.md` for the full schema. -- Baseline settings: `claude-code/settings/settings.json` -- Local overrides: `~/.claude/settings.local.json` -- Generated runtime file: `~/.claude/settings.json` (generated by `bin/bin/claude-settings-merge --fix`) -- Merge behavior: object keys merge recursively; `permissions.allow` and `permissions.additionalDirectories` append local entries without removing baseline entries; other arrays are replaced by local values. -- Plugin state lives in the baseline settings: `enabledPlugins` controls which plugins are turned on, and `extraKnownMarketplaces` declares where Claude Code fetches them. -- `install.sh --claude` reads those settings and runs `claude plugin install` for each enabled plugin; the install loop is idempotent. +## Settings Baseline -## Instruction Source of Truth +`settings/settings.json` is an opinionated baseline you can adopt as-is or use as a reference: -- Keep shared agent behavior in `claude-code/CLAUDE.md`. -- Keep local repo notes and personal operational guidance in `claude-code/CLAUDE.local.md`. -- Do not move local-only content into `claude-code/CLAUDE.md`; that file syncs to both `~/.claude/CLAUDE.md` and `~/.codex/AGENTS.md`. +- `permissions.allow` pre-approves common read-only and safe-write Bash patterns (git, npm/bun/yarn, zig, go, tilt, kubectl, docker, gh) plus a few skills. +- `extraKnownMarketplaces` declares plugin marketplaces; `enabledPlugins` controls which are turned on. +- `statusLine.command` invokes the Zig statusline binary (see below). +- `env.BASH_ENV` points at `hooks/direnv-bash-env` so subshells reload direnv on `cd`. -## Codex Asset Sync +This file ships with author-specific marketplace pointers (e.g. private Send/Canton repos) and a hard-coded `BASH_ENV` path. If you adopt it, edit those fields to match your machine. The simplest path is to copy it once and treat `~/.claude/settings.json` as yours: ```bash -# Drift check only (exit 0 clean, 2 drift, 1 error) -claude-code/scripts/sync-codex.sh --check +cp settings/settings.json ~/.claude/settings.json +``` -# Apply sync and prune stale managed entries (commands + skills) -claude-code/scripts/sync-codex.sh +The author personally generates `~/.claude/settings.json` via a private merge helper in the surrounding dotfiles tree (baseline + `~/.claude/settings.local.json` overrides). That helper is not required to use this repo — direct copy works fine. + +## Statusline + +A small Zig statusline lives in `statusline/`. It reads JSON on stdin and prints one formatted status line. + +```bash +cd statusline +zig build -Doptimize=ReleaseFast +# binary lands at statusline/zig-out/bin/statusline +``` + +Point Claude Code at it by setting `statusLine.command` in `~/.claude/settings.json` (the baseline already does this). Minimum Zig: `0.15.1`. See `statusline/CLAUDE.md` and `statusline/SPEC.md` for build, runtime contract, and design notes. + +## Scripts + +`scripts/` contains optional utilities: + +| Script | Purpose | +|--------|---------| +| `setup-usage-tracking.py` | Writes hook overrides into `~/.claude/settings.local.json` to log every session/tool/command | +| `usage-tracker.py` | The hook handler that records events to SQLite + JSONL | +| `analyze-usage.py` | Summaries, time-windowed reports, exports | +| `usage-dashboard.py` | Live terminal dashboard over the SQLite store | +| `sync-codex.sh` | Mirrors `commands/` + `.claude/skills/` into `~/.codex/` (prompts + skills), applying `codex-overrides/` on top | +| `check-plugin-versions.sh` | Pre-commit guardrail: plugin source changes must bump `plugin.json` and `marketplace.json` versions in lockstep | +| `install-symlinks.sh` | Older partial symlinker (kept for compatibility; `install.sh` is the canonical entrypoint) | + +See `scripts/README.md` for the usage-tracking specifics. + +### Codex sync (optional) + +If you also use the Codex CLI and want the same skills/commands available there: + +```bash +scripts/sync-codex.sh --check # report drift (exit 0 clean, 2 drift, 1 error) +scripts/sync-codex.sh # apply ``` -Codex skill sync is curated: +Codex skill resolution order during sync: +1. User skills from `.claude/skills/` +2. Plugin skills (with selective drops per `scripts/sync-codex.skill-policy.tsv`) +3. Codex-only overrides from `codex-overrides/skills/` (win on name collision) + +## Analytics -- Upstream Claude user skills sync first -- Plugin skills sync next, with selective drops from `claude-code/scripts/sync-codex.skill-policy.tsv` -- Codex-only overrides in `claude-code/codex-overrides/skills/` sync last and win on name collision +`analytics/` is a [submodule](https://github.com/0xbigboss/claude-code-analytics) that analyzes Claude Code transcripts (skill usage, token consumption, tool patterns). + +```bash +git submodule update --init analytics +cd analytics && uv sync +uv run cc-analytics skills +``` + +## Instruction Source of Truth -## Core Principles +- Shared agent guidelines live in `CLAUDE.md` at the repo root. The installer links this into `~/.claude/CLAUDE.md`. +- Personal local notes belong in `~/.claude/CLAUDE.local.md` (or this repo's `CLAUDE.local.md`, which is gitignored). Do not move local-only content into the tracked `CLAUDE.md` — it is meant to be shareable. -The `CLAUDE.md` guidelines emphasize: +## Relationship to a Larger Dotfiles Tree -- **Type-first development**: Define types before implementing logic; make illegal states unrepresentable -- **Functional style**: Prefer immutability, pure functions, and explicit data flow -- **Minimal changes**: Implement only what's requested; avoid unrequested features or refactoring -- **Error handling**: Handle or return errors at every level; fail loudly with clear messages -- **Test integrity**: Tests verify correctness, not just satisfy assertions -- **Clean refactoring**: Update all callers atomically; delete superseded code completely +The author keeps this repo as a submodule of a private dotfiles tree. When present, that tree's `codex/codex.json` is preferred by `install.sh` over the in-repo sample, and a private `claude-bootstrap` / `claude-settings-merge` pair manages additional symlinks and the settings merge. Neither is required to use this repo standalone — everything in this README assumes no parent tree. ## Author -Created by Allen Eubank (Big Boss) +Allen Eubank ([@alleneubank](https://github.com/alleneubank)) ## License -Licensed under the Apache License, Version 2.0. See [LICENSE](LICENSE) for details. +Licensed under the Apache License, Version 2.0. See [LICENSE](LICENSE). diff --git a/TODO.md b/TODO.md deleted file mode 100644 index 19facc2..0000000 --- a/TODO.md +++ /dev/null @@ -1,27 +0,0 @@ -# TODO - Fix DuckDB Indexing Limit - -## Completed -- [x] Identified issue: idx_messages_content index causes ART key size limit error (iteration 1) -- [x] Removed content index - ILIKE search works without it (iteration 1) -- [x] Deleted existing database and reindexed all sessions (iteration 1) -- [x] Verified all 4915 sessions indexed without errors (iteration 1) -- [x] Verified search command works (iteration 1) -- [x] Verified recent command works (iteration 1) -- [x] Removed .claude/ralph-loop.local.md from VCS (iteration 2) -- [x] Removed __pycache__ from VCS (iteration 2) -- [x] Added .gitignore with proper ignore rules (iteration 2) - -## In Progress -- None - -## Pending -- None - -## Blocked -- None - -## Notes -- DuckDB's ART index has a 122KB key size limit -- Some message content exceeds this limit (e.g., 3.4MB messages) -- Solution: Remove the content column index since ILIKE search doesn't require it -- Alternative would be truncating content, but removing the unused index is cleaner diff --git a/codex.json b/codex.json new file mode 100644 index 0000000..ee43e15 --- /dev/null +++ b/codex.json @@ -0,0 +1,11 @@ +{ + "$schema": "./codex.schema.json", + "_description": "Default Codex CLI configuration used by the codex-reviewer plugin. Symlinked to ~/.claude/codex.json by install.sh. Override locally by editing ~/.claude/codex.json directly or by replacing the symlink with a private copy.", + "codex": { + "sandbox": "read-only", + "approval_policy": "never", + "bypass_sandbox": false, + "extra_args": [], + "timeout_seconds": 1200 + } +} diff --git a/install.sh b/install.sh index 0e1e322..575de77 100755 --- a/install.sh +++ b/install.sh @@ -1,7 +1,8 @@ #!/usr/bin/env bash # -# Claude Code configuration installer -# Creates direct symlinks from ~/.claude/ to this repository +# Claude Code configuration installer. +# Links this repo's CLAUDE.md, commands/, agents/, skills/, and codex.json into +# ~/.claude/ so Claude Code picks them up. Idempotent and safe to re-run. # # Usage: # ./install.sh # Install symlinks @@ -110,8 +111,19 @@ main() { # CLAUDE.md - user-level instructions (open-source, editable) ensure_symlink "$SCRIPT_DIR/CLAUDE.md" "$HOME/.claude/CLAUDE.md" - # codex.json - centralized Codex CLI configuration for all plugins - ensure_symlink "$SCRIPT_DIR/../codex/codex.json" "$HOME/.claude/codex.json" + # commands/, agents/, skills/ - assets discoverable by Claude Code + ensure_symlink "$SCRIPT_DIR/commands" "$HOME/.claude/commands" + ensure_symlink "$SCRIPT_DIR/agents" "$HOME/.claude/agents" + ensure_symlink "$SCRIPT_DIR/.claude/skills" "$HOME/.claude/skills" + + # codex.json - Codex CLI config consumed by the codex-reviewer plugin. + # Prefer a parent dotfiles tree (../codex/codex.json) when present; otherwise + # fall back to the in-repo sample so standalone installs still work. + local codex_source="$SCRIPT_DIR/codex.json" + if [[ -e "$SCRIPT_DIR/../codex/codex.json" ]]; then + codex_source="$SCRIPT_DIR/../codex/codex.json" + fi + ensure_symlink "$codex_source" "$HOME/.claude/codex.json" # Legacy compatibility cleanup: baseline now loads directly from repository path. remove_legacy_settings_base || true diff --git a/scripts/README.md b/scripts/README.md index e543aba..b1cd29d 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -65,9 +65,8 @@ Edit `~/.claude/scripts/usage-tracker.py` to: ## Troubleshooting 1. **Hooks not working?** - - Check `~/dotfiles/claude-code/settings/settings.json` exists - - Check `~/.claude/settings.json` was regenerated - - Restart Claude Code + - Check that `~/.claude/settings.json` contains the hook entries (rerun `setup-usage-tracking.py` if not) + - Restart Claude Code so it re-reads settings - Run with `--debug` flag 2. **No data showing?** From 0419f75ccbf4c400ec04ffc71fdb3a9c6b479488 Mon Sep 17 00:00:00 2001 From: Allen Date: Thu, 21 May 2026 16:24:01 +0000 Subject: [PATCH 50/57] fix(plugins): clamp Codex timeout and filter paired output flags When the codex-reviewer and ralph-reviewed Stop hooks invoke `codex exec`, they pass `-o ` so the verdict can be parsed back from a known path, then append the user's `extra_args`. Two latent bugs surfaced after the docs commit started promoting the shared ~/.claude/codex.json knobs to standalone users: 1. The previous filter dropped `-o` / `--output*` tokens individually but not the value that followed a bare flag. With extra_args: ["-o", "/tmp/other"], the filter left "/tmp/other" as a stray positional that Codex treated as an extra prompt argument. The new index-walk filter consumes both halves of paired flags (-o, --output, --output-last-message) and drops the inline --flag=value variants. Applied to both hooks. 2. ralph-reviewed honored `timeout_seconds` raw. A configured value at or above the 1800s Stop hook timeout let Claude kill the hook before it could parse Codex's output and surface a verdict. Now clamped to [60, 1680] (matching the existing codex-reviewer clamp), leaving a 120s buffer below the hook ceiling. Bumps ralph-reviewed to 3.0.2 and codex-reviewer to 1.6.10 in lockstep across each plugin.json and the root marketplace.json. The ralph-reviewed troubleshooting README is updated to describe the new clamp range rather than telling users to "increase to 1800". --- .claude-plugin/marketplace.json | 4 +- .../codex-reviewer/.claude-plugin/plugin.json | 2 +- .../hooks/codex-reviewer-stop-hook.ts | 19 ++++++-- .../ralph-reviewed/.claude-plugin/plugin.json | 2 +- plugins/ralph-reviewed/README.md | 4 +- .../hooks/ralph-reviewed-stop-hook.ts | 44 +++++++++++++++++-- 6 files changed, 61 insertions(+), 14 deletions(-) diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 3bf4424..c312112 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -14,7 +14,7 @@ "name": "ralph-reviewed", "source": "./plugins/ralph-reviewed", "description": "Iterative Ralph loops with Codex CLI review gates at completion", - "version": "2.0.0", + "version": "3.0.2", "author": { "name": "Allen", "email": "bigboss@metalrodeo.xyz" @@ -26,7 +26,7 @@ "name": "codex-reviewer", "source": "./plugins/codex-reviewer", "description": "Codex-powered code review gate for Claude Code", - "version": "1.6.9", + "version": "1.6.10", "author": { "name": "Allen", "email": "bigboss@metalrodeo.xyz" diff --git a/plugins/codex-reviewer/.claude-plugin/plugin.json b/plugins/codex-reviewer/.claude-plugin/plugin.json index 4d830c4..531a5c5 100644 --- a/plugins/codex-reviewer/.claude-plugin/plugin.json +++ b/plugins/codex-reviewer/.claude-plugin/plugin.json @@ -5,6 +5,6 @@ "name": "Allen", "email": "bigboss@metalrodeo.xyz" }, - "version": "1.6.9", + "version": "1.6.10", "commands": "./commands/" } diff --git a/plugins/codex-reviewer/hooks/codex-reviewer-stop-hook.ts b/plugins/codex-reviewer/hooks/codex-reviewer-stop-hook.ts index 23352de..35af430 100755 --- a/plugins/codex-reviewer/hooks/codex-reviewer-stop-hook.ts +++ b/plugins/codex-reviewer/hooks/codex-reviewer-stop-hook.ts @@ -736,12 +736,23 @@ Review ${reviewCount + 1}/${maxReviews}. Remember: your response MUST contain Date: Thu, 21 May 2026 16:45:44 -0500 Subject: [PATCH 51/57] feat(skills): add ios-device-toolkit, broader iOS USB workflow Reintroduces and broadens the dropped ios-device-screenshot skill (413d349) into a full pymobiledevice3 toolkit. Covers capture (screenshot, syslog, crash, pcap), apps + sandbox file I/O, diagnostics + sysmon, port forwarding, WebInspector/CDP, and device info. Grounded against pymobiledevice3 7.0.7 CLI surface. SKILL.md + 3 reference docs (capture, apps-files, diagnostics-perf). --- .claude/skills/ios-device-toolkit/SKILL.md | 112 ++++++++++ .../references/apps-files.md | 140 +++++++++++++ .../ios-device-toolkit/references/capture.md | 136 +++++++++++++ .../references/diagnostics-perf.md | 192 ++++++++++++++++++ 4 files changed, 580 insertions(+) create mode 100644 .claude/skills/ios-device-toolkit/SKILL.md create mode 100644 .claude/skills/ios-device-toolkit/references/apps-files.md create mode 100644 .claude/skills/ios-device-toolkit/references/capture.md create mode 100644 .claude/skills/ios-device-toolkit/references/diagnostics-perf.md diff --git a/.claude/skills/ios-device-toolkit/SKILL.md b/.claude/skills/ios-device-toolkit/SKILL.md new file mode 100644 index 0000000..7f4b0d0 --- /dev/null +++ b/.claude/skills/ios-device-toolkit/SKILL.md @@ -0,0 +1,112 @@ +--- +name: ios-device-toolkit +description: Use when interacting with USB-attached iOS devices via pymobiledevice3 — screenshots, syslog, crash reports, app install/launch/pull, file transfer, sysmon perf, TCP port forwarding, WebInspector/CDP, and device diagnostics. +--- + +# iOS Device Toolkit + +Drive a physical iOS device over USB with `pymobiledevice3`. This skill covers the high-value workflows; deep reference per area is in `references/`. + +- Capture, logs, crashes → `references/capture.md` +- Apps, app sandbox files, AFC media, debugserver → `references/apps-files.md` +- Device info, perf, springboard, webinspector, tunneling → `references/diagnostics-perf.md` + +## Install + +```bash +# uv (preferred) or pipx +uv tool install pymobiledevice3 +pipx install pymobiledevice3 + +pymobiledevice3 version # confirm; tested at 7.0.7 +``` + +## Prerequisites + +1. **Physical iOS device** attached via USB (or Wi-Fi-paired through usbmuxd). +2. **Developer Mode** enabled on device (Settings → Privacy & Security → Developer Mode). Verify with `pymobiledevice3 mounter query-developer-mode-status`. +3. **Trusted** computer — accept "Trust This Computer" prompt on the device after first connect. +4. **iOS 17+ only**: a long-running `tunneld` daemon is required for any developer-mode service (screenshots, DVT instruments, debugserver, pcap-with-process, etc.). Start it once per boot: + ```bash + sudo pymobiledevice3 remote tunneld + ``` + The CLI auto-retries developer commands through tunneld; you no longer need `--tunnel ""` on every call in 7.x, though it still works. + +## Device selection + +For a single attached device, no flag is needed. For multiple, scope every call with one of: + +- `--udid ` — usbmux-discovered device +- `--tunnel ` — tunneld-discovered device (developer services on iOS 17+) +- `PYMOBILEDEVICE3_UDID=` / `PYMOBILEDEVICE3_TUNNEL=` env vars — set once, all subsequent invocations inherit + +Discover UDIDs: + +```bash +pymobiledevice3 usbmux list # USB + Wi-Fi devices known to usbmuxd +xcrun devicectl list devices # Apple's own listing (macOS) +pymobiledevice3 lockdown info | head # full lockdown dump for the default device +``` + +Caveat: `--tunnel ""` (empty string) prompts interactively. In non-interactive shells, pass the UDID directly. + +## Quick reference + +```bash +# --- Device info --- +pymobiledevice3 usbmux list +pymobiledevice3 lockdown info +pymobiledevice3 diagnostics battery +pymobiledevice3 mounter list + +# --- Capture --- +# Screenshot (iOS 17+, DVT path — works where the deprecated developer screenshot fails) +pymobiledevice3 developer dvt screenshot ~/Desktop/shot.png +# Live syslog +pymobiledevice3 syslog live +# Crash reports +pymobiledevice3 crash ls +pymobiledevice3 crash pull ~/ios-crashes +# Packet capture (filter by process is optional) +pymobiledevice3 pcap --out trace.pcap --process Safari + +# --- Apps --- +pymobiledevice3 apps list --user # only user-installed +pymobiledevice3 apps install ./MyApp.ipa +pymobiledevice3 apps uninstall com.example.MyApp +pymobiledevice3 developer dvt launch com.example.MyApp +pymobiledevice3 developer dvt process-id-for-bundle-id com.example.MyApp +pymobiledevice3 developer dvt kill + +# --- Files (app sandbox + media) --- +pymobiledevice3 apps pull com.example.MyApp Documents/log.txt ./log.txt +pymobiledevice3 apps push com.example.MyApp ./seed.json Documents/seed.json +pymobiledevice3 afc pull DCIM/100APPLE/IMG_0001.HEIC ./ +pymobiledevice3 afc ls -r DCIM + +# --- Perf --- +pymobiledevice3 developer dvt sysmon system # one-shot system stats +pymobiledevice3 developer dvt sysmon process # per-process loop +pymobiledevice3 developer dvt proclist # PID + start times +pymobiledevice3 developer dvt energy [...] + +# --- Networking / port forwarding --- +pymobiledevice3 usbmux forward 8080 8080 # local:8080 -> device:8080 +pymobiledevice3 webinspector opened-tabs # requires Safari Web Inspector enabled on device +pymobiledevice3 webinspector cdp # CDP server for WebView debugging +``` + +## Common gotchas + +- **"InvalidServiceError" / "Failed to start service" on iOS 17+** → tunneld is not running. Start `sudo pymobiledevice3 remote tunneld` and retry. Confirm with `ps aux | grep tunneld`. +- **"DeveloperDiskImage not mounted"** → `pymobiledevice3 mounter auto-mount`. +- **Deprecated screenshot API hangs on iOS 17+** → use the DVT variant: `developer dvt screenshot` (not `developer screenshot`). +- **`--tunnel ""` hangs in scripts** → empty string means interactive picker; pass the UDID explicitly. +- **Permissions on Linux** → usbmuxd typically needs to be running; on macOS it's built in. +- **Pairing lost after iOS update** → `pymobiledevice3 lockdown unpair && pymobiledevice3 lockdown pair`. + +## Scripting tips + +- Set `PYMOBILEDEVICE3_UDID` (and `PYMOBILEDEVICE3_TUNNEL` for developer services) once at the top of a script to avoid repeating flags. +- For capture-and-open recipes (screenshot + `open`, syslog tail + `grep`, etc.), wrap in a shell function rather than re-typing the full path each time. +- Most subcommands accept `--rsd HOST PORT` as an alternative to `--tunnel`, useful when you already have an RSD address from a manual `pymobiledevice3 remote start-tunnel`. diff --git a/.claude/skills/ios-device-toolkit/references/apps-files.md b/.claude/skills/ios-device-toolkit/references/apps-files.md new file mode 100644 index 0000000..c6cd7b9 --- /dev/null +++ b/.claude/skills/ios-device-toolkit/references/apps-files.md @@ -0,0 +1,140 @@ +# Apps and Files + +App lifecycle (list, install, launch, kill), app sandbox file I/O, AFC media access, and debugserver. Most app-lifecycle operations and the app-sandbox AFC require a tunneld session on iOS 17+. + +## Listing and querying apps + +```bash +# All apps (system + user) +pymobiledevice3 apps list + +# Filter flags +pymobiledevice3 apps list --user # only user-installed +pymobiledevice3 apps list --system # only system-installed +pymobiledevice3 apps list --hidden # include hidden + +# Metadata for a specific bundle ID (returns dict: version, signing, paths, entitlements) +pymobiledevice3 apps query com.example.MyApp + +# Via DVT — different shape; useful when you want the same set the Instruments target picker sees +pymobiledevice3 developer dvt applist +``` + +## Install / uninstall + +`apps install` accepts `.ipa`, an unpacked `.app` bundle, or `.ipcc` carrier bundles. The IPA must be signed for the target device's profile (development or enterprise) — App Store signed builds will be refused. + +```bash +pymobiledevice3 apps install ./build/MyApp.ipa +pymobiledevice3 apps uninstall com.example.MyApp +``` + +## Launch, kill, signal + +These go through DVT. On iOS 17+ you'll want a tunneld session running. + +```bash +# Launch (returns PID) +pymobiledevice3 developer dvt launch com.example.MyApp + +# Resolve a running bundle ID to its PID (empty if not running) +pymobiledevice3 developer dvt process-id-for-bundle-id com.example.MyApp + +# Kill by PID +pymobiledevice3 developer dvt kill 1234 + +# Kill by name substring +pymobiledevice3 developer dvt pkill MyApp + +# Send arbitrary signal (e.g. SIGSTOP=17, SIGCONT=19) +pymobiledevice3 developer dvt signal 1234 --signal-name SIGSTOP +``` + +`memlimitoff` raises a specific PID's jetsam limit — useful for diagnosing low-memory kills during instrumentation: + +```bash +pymobiledevice3 developer dvt memlimitoff 1234 +``` + +## App sandbox files (the common case) + +`pymobiledevice3 apps {pull,push,rm,afc}` go directly into a specific app's container — no need to know the on-device path layout. The container exposes `Documents/`, `Library/`, `tmp/`, `SystemData/`, etc. + +```bash +# Pull a single file +pymobiledevice3 apps pull com.example.MyApp Documents/app.sqlite ./app.sqlite + +# Push (overwrites if present) +pymobiledevice3 apps push com.example.MyApp ./seed.json Documents/seed.json + +# Remove +pymobiledevice3 apps rm com.example.MyApp Documents/stale.log + +# Interactive shell into the container — useful for exploration +pymobiledevice3 apps afc com.example.MyApp + +# Restrict to Documents only (UIFileSharingEnabled apps) +pymobiledevice3 apps afc com.example.MyApp --documents +``` + +App must support file sharing (`UIFileSharingEnabled` in Info.plist) for non-developer builds; development builds expose the full container regardless. + +## AFC: device media root + +Plain `afc` mounts `/var/mobile/Media` — photos, ringtones, downloads, etc. No tunneld needed (it's a lockdown service). + +```bash +pymobiledevice3 afc ls +pymobiledevice3 afc ls -r DCIM +pymobiledevice3 afc pull DCIM/100APPLE/IMG_0001.HEIC ./ +pymobiledevice3 afc push ./song.m4a iTunes_Control/Music/ +pymobiledevice3 afc shell # interactive +``` + +## debugserver (LLDB attach) + +For attaching `lldb` to a running app or launching one under the debugger. iOS 17+ uses RSD (so tunneld); older releases go over usbmux. + +```bash +# Show which apps debugserver can target +pymobiledevice3 developer debugserver applist + +# Start the server and print the LLDB connect string +pymobiledevice3 developer debugserver start-server + +# In another terminal, paste the printed connect string into lldb: +# (lldb) process connect connect://[FE80::...]:PORT +# (lldb) process attach --pid 1234 +``` + +The `lldb` subcommand automates this for an Xcode project: + +```bash +pymobiledevice3 developer debugserver lldb /path/to/MyApp.xcodeproj +``` + +## XCUITest + +`xcuitest` lets you drive a UI test runner against a built `.app` without Xcode. Mostly relevant for headless CI work. + +```bash +pymobiledevice3 developer dvt xcuitest --bundle-id com.example.MyAppUITests.xctrunner ./test-runner.app +``` + +Refer to upstream docs for the full argument set — semantics evolve with iOS releases. + +## Profile / provisioning + +Install configuration profiles (MDM payloads, Wi-Fi, certs) and provisioning profiles. Profile install requires the user to approve from Settings → General → VPN & Device Management. + +```bash +# Configuration profiles +pymobiledevice3 profile list +pymobiledevice3 profile install ./MyConfig.mobileconfig +pymobiledevice3 profile remove + +# Provisioning profiles (for sideloaded dev builds) +pymobiledevice3 provision list +pymobiledevice3 provision install ./Dev.mobileprovision +pymobiledevice3 provision remove +``` diff --git a/.claude/skills/ios-device-toolkit/references/capture.md b/.claude/skills/ios-device-toolkit/references/capture.md new file mode 100644 index 0000000..f4bd013 --- /dev/null +++ b/.claude/skills/ios-device-toolkit/references/capture.md @@ -0,0 +1,136 @@ +# Capture: screenshots, screen content, logs, crashes, traffic + +Reference for the read-only capture surface of `pymobiledevice3`. See `SKILL.md` for prerequisites (Developer Mode + tunneld for iOS 17+). + +## Screenshots + +The modern, iOS 17+-friendly path is the DVT variant — it goes through the developer instrumentation channel and works where the legacy `developer screenshot` (marked Deprecated in the CLI) silently fails. + +```bash +# Recommended +pymobiledevice3 developer dvt screenshot ~/Desktop/shot.png + +# Specific device when multiple are attached +PYMOBILEDEVICE3_TUNNEL=00008101-001E05A41144001E \ + pymobiledevice3 developer dvt screenshot ~/Desktop/shot.png + +# Legacy (older iOS / fallback only) +pymobiledevice3 developer screenshot ~/Desktop/shot.png +``` + +Timestamped capture-and-open one-liner (macOS): + +```bash +OUT="$HOME/Desktop/ios-$(date +%Y%m%d-%H%M%S).png" +pymobiledevice3 developer dvt screenshot "$OUT" && open "$OUT" +``` + +No native screen-recording subcommand exists in `pymobiledevice3` today; for video, use QuickTime's "Movie Recording → device as camera" over USB on macOS. + +## Springboard / wallpaper / icon captures + +These don't need tunneld — they're plain lockdown services. + +```bash +# Save an app's icon PNG +pymobiledevice3 springboard icon com.example.MyApp ./icon.png + +# Save the homescreen wallpaper PNG +pymobiledevice3 springboard wallpaper-home-screen ./wallpaper.png + +# Current orientation (portrait/landscape*) +pymobiledevice3 springboard orientation +``` + +## Live syslog + +`syslog live` streams the device's unified log over usbmux. Cheap and stable; first stop when diagnosing crashes that aren't producing crash reports yet, or when watching app lifecycle. + +```bash +# Stream forever; ctrl-c to stop +pymobiledevice3 syslog live + +# Filter at the shell — pymobiledevice3 doesn't take predicates +pymobiledevice3 syslog live | grep -i 'MyApp\|fault\|error' + +# Capture to a .logarchive for later inspection with `log show` / Console.app +pymobiledevice3 syslog collect ~/ios-logs +``` + +For richer logs (more fields, includes oslog metadata) the DVT-backed variant exists but is flaky: + +```bash +pymobiledevice3 developer dvt oslog +``` + +Prefer `syslog live` unless you specifically need oslog fields. + +## Crash reports + +The CrashReporter service surfaces both fresh and historical reports. Reports include `.ips` (newer) and `.crash` (older) formats. Symbolicate with Xcode if you need readable stacks. + +```bash +# Flush queued reports from the on-device mover into CrashReports/ +pymobiledevice3 crash flush + +# List +pymobiledevice3 crash ls + +# Pull all crashes to a local directory +pymobiledevice3 crash pull ~/ios-crashes + +# Watch for new reports as they're generated +pymobiledevice3 crash watch + +# Capture a full sysdiagnose (requires holding volume buttons on device — user gesture) +pymobiledevice3 crash sysdiagnose ~/sysdiagnose +``` + +`crash shell` opens an interactive AFC shell into the crash directory if you want to navigate before pulling. + +## Packet capture + +The `pcap` service taps interface-level traffic on the device. Output is a standard `.pcap` you can open in Wireshark. + +```bash +# Capture everything to a file +pymobiledevice3 pcap --out trace.pcap + +# Limit by packet count +pymobiledevice3 pcap --out trace.pcap --count 500 + +# Filter to a single process — invaluable for diagnosing a misbehaving app's traffic +pymobiledevice3 pcap --out safari.pcap --process Safari + +# Filter to an interface (e.g. en0 Wi-Fi) +pymobiledevice3 pcap --out wifi.pcap --interface en0 +``` + +Note: TLS payloads are encrypted at the wire. To see decrypted HTTP/HTTPS, install a CA on the device and route through an MITM proxy (mitmproxy/Charles) — `pcap` alone won't help. + +## Location simulation + +Spoof GPS for the entire device (not just an app). Useful for region-gated features and map QA. + +```bash +# Set a fixed coordinate +pymobiledevice3 developer simulate-location set 37.7749 -122.4194 + +# Clear and return to real GPS +pymobiledevice3 developer simulate-location clear + +# Replay a GPX route (e.g. exported from Maps or recorded with a fitness app) +pymobiledevice3 developer simulate-location play ./route.gpx +``` + +`simulate-location` also exists under `developer dvt` with the same semantics; prefer the top-level one. + +## HAR logging (network from app perspective) + +`har` enables network logging at the CFNetwork layer for a target app — closer to "developer tools network tab" than to `pcap`. Output is HAR JSON. + +```bash +pymobiledevice3 developer dvt har --process com.example.MyApp --out app.har +``` + +Caveat: only HTTPS/HTTP traffic going through CFNetwork shows up; sockets and lower-level APIs do not. diff --git a/.claude/skills/ios-device-toolkit/references/diagnostics-perf.md b/.claude/skills/ios-device-toolkit/references/diagnostics-perf.md new file mode 100644 index 0000000..ebba4bc --- /dev/null +++ b/.claude/skills/ios-device-toolkit/references/diagnostics-perf.md @@ -0,0 +1,192 @@ +# Diagnostics, Performance, and Networking + +Reference for device introspection (lockdown, diagnostics, mounter), live performance metrics (sysmon, processes, energy), SpringBoard control, WebInspector/CDP, and port forwarding. + +## Device info + +`lockdown` returns the canonical device record — everything Apple's MobileLockdown daemon exposes. Most of this is unauthenticated (no developer mode needed). + +```bash +# Full dump (long, paginate) +pymobiledevice3 lockdown info | less + +# A single field by domain + key +pymobiledevice3 lockdown get --domain com.apple.disk_usage --key TotalDataAvailable +pymobiledevice3 lockdown get --key ProductVersion # iOS version +pymobiledevice3 lockdown get --key DeviceName + +# Get/set device name +pymobiledevice3 lockdown device-name # read +pymobiledevice3 lockdown device-name "Test iPhone" # write + +# Locale and language +pymobiledevice3 lockdown locale +pymobiledevice3 lockdown language +``` + +The MobileGestalt corpus (huge key/value store backing most Settings fields) is reachable via diagnostics: + +```bash +pymobiledevice3 diagnostics mg # all known keys +pymobiledevice3 diagnostics mg DeviceColor RegionInfo +``` + +`bonjour` can discover Wi-Fi-reachable devices without USB: + +```bash +pymobiledevice3 bonjour browse +``` + +## Power, battery, hardware diagnostics + +```bash +# Battery health, cycle count, design vs current capacity +pymobiledevice3 diagnostics battery + +# IORegistry dump — every hardware service the kernel exposes (huge) +pymobiledevice3 diagnostics ioregistry + +# General diagnostics info +pymobiledevice3 diagnostics info +``` + +Power assertions prevent screen sleep / display dim while running e.g. long instrumentation captures: + +```bash +pymobiledevice3 power-assertion --type PreventUserIdleSystemSleep +# (runs until ctrl-c) +``` + +Reboot / shutdown / sleep are blunt: + +```bash +pymobiledevice3 diagnostics restart +pymobiledevice3 diagnostics shutdown +pymobiledevice3 diagnostics sleep +``` + +## DeveloperDiskImage state + +```bash +# Is developer mode on? +pymobiledevice3 mounter query-developer-mode-status + +# What images are mounted? +pymobiledevice3 mounter list + +# Auto-mount the correct image for the running iOS version +pymobiledevice3 mounter auto-mount + +# Manual mount/unmount +pymobiledevice3 mounter mount-developer ... +pymobiledevice3 mounter umount-developer +``` + +`auto-mount` is what you want 95% of the time after a fresh iOS update — Xcode would normally do this on first connect, but pymobiledevice3 can do it headless. + +## Live system performance (sysmon) + +`developer dvt sysmon` is the live equivalent of Activity Monitor. Requires tunneld on iOS 17+. + +```bash +# One-shot system stats: CPU%, mem pressure, thermal state, uptime +pymobiledevice3 developer dvt sysmon system + +# Per-process polling loop — top-like view of CPU/memory per PID +pymobiledevice3 developer dvt sysmon process +``` + +For PID enumeration and runtime checks: + +```bash +pymobiledevice3 developer dvt proclist # PIDs + names + start times +pymobiledevice3 developer dvt is-running-pid 1234 +pymobiledevice3 processes # alternate (diagnosticsd-backed) listing +``` + +Energy monitoring (Instruments' Energy Log equivalent) needs explicit PIDs: + +```bash +pymobiledevice3 developer dvt energy 1234 5678 +``` + +Graphics / FPS sampling and notification monitoring: + +```bash +pymobiledevice3 developer dvt graphics # FPS, GPU%, draw call rates +pymobiledevice3 developer dvt notifications # memory + app lifecycle events +``` + +## Network / port forwarding + +`usbmux forward` is the most useful piece for development: tunnel a TCP port from your host through usbmuxd to the device. Local-only, no tunneld needed. Survives ctrl-c only if you background it. + +```bash +# Forward localhost:8080 -> device:8080 (e.g. a dev server inside an app) +pymobiledevice3 usbmux forward 8080 8080 + +# Different local port to avoid collisions +pymobiledevice3 usbmux forward 9090 8080 + +# Target a specific device by UDID +pymobiledevice3 usbmux forward --udid 00008101-... 8080 8080 +``` + +For developer-service tunnels (e.g. to talk RemoteXPC directly): + +```bash +# Print the RSD HOST:PORT for use with --rsd +pymobiledevice3 remote start-tunnel +``` + +## WebInspector (Safari + WKWebView debugging) + +Requires Safari → Settings → Advanced → "Web Inspector" enabled on the device, and a Mac with the matching Safari Develop menu enabled. `cdp` exposes a Chrome DevTools Protocol endpoint, which is useful for headless WebView automation (Playwright, Puppeteer-style harnesses). + +```bash +# See what's debuggable +pymobiledevice3 webinspector opened-tabs + +# Open a URL in Safari +pymobiledevice3 webinspector launch https://example.com + +# JavaScript REPL bound to a remote target +pymobiledevice3 webinspector js-shell + +# Start a CDP server (default port printed on stdout) — point Chrome DevTools or CDP clients at it +pymobiledevice3 webinspector cdp +``` + +`webinspector shell` drops you into an IPython shell with a `WebView` handle for ad-hoc scripting. + +## Notifications + +The Darwin notification proxy lets you observe or post system-wide notifications. Useful for triggering app-side handlers (e.g. wallpaper changed, low memory). + +```bash +# Observe notifications by name +pymobiledevice3 notifications observe com.apple.UIKit.userActivityActive + +# Post a notification (limited — most names require entitlements) +pymobiledevice3 notifications post com.example.MyNotification +``` + +## Force device conditions (DVT) + +Simulate constrained network, low battery, or thermal pressure for testing — same conditions Xcode's "Devices and Simulators → Conditions" exposes. + +```bash +# Network conditioning (predefined profiles) +pymobiledevice3 developer developer ... +``` + +The `developer developer` subcommand handles conditions; see `pymobiledevice3 developer developer --help` for the current profile names — Apple changes them between iOS releases. + +## Arbitration + +When multiple tools (Xcode, Instruments, automation harnesses) compete for the device, mark/unmark in-use to avoid collisions: + +```bash +pymobiledevice3 developer arbitration check-in +pymobiledevice3 developer arbitration check-out +``` From 2ef752c086061389272c8942b0bc42a25db912dd Mon Sep 17 00:00:00 2001 From: 0xBigBoss <95193764+0xBigBoss@users.noreply.github.com> Date: Fri, 22 May 2026 12:32:16 -0500 Subject: [PATCH 52/57] chore(settings): drop redundant env.BASH_ENV (handled portably by .zshenv) The hardcoded /Users/allen/... path only worked on Mac. shell/.zshenv already exports BASH_ENV via $HOME on every zsh process and Claude Code inherits it through its bash subprocesses, so the settings.json field was Mac-only redundancy. --- settings/settings.json | 3 --- 1 file changed, 3 deletions(-) diff --git a/settings/settings.json b/settings/settings.json index 7b7f5b0..e8a962e 100644 --- a/settings/settings.json +++ b/settings/settings.json @@ -144,9 +144,6 @@ "canton-keycloak-skills@canton-keycloak-providers": true, "typescript-lsp@claude-plugins-official": true }, - "env": { - "BASH_ENV": "/Users/allen/code/dotfiles/claude-code/hooks/direnv-bash-env" - }, "alwaysThinkingEnabled": true, "effortLevel": "xhigh", "skipDangerousModePermissionPrompt": true, From f85dbe8e9dd9f80a6b820e8b3b134a495bfa3f5e Mon Sep 17 00:00:00 2001 From: 0xBigBoss <95193764+0xBigBoss@users.noreply.github.com> Date: Sat, 23 May 2026 16:16:59 -0500 Subject: [PATCH 53/57] feat(commands): add /brb for AFK autonomous work Adds a slash command that signals the user is stepping away. Tells Claude to keep working but proactively skip any command that needs 1Password/SSH/Touch ID/sudo/browser-dialog approval, then invoke /handoff at the natural stopping point so the user can resume on return. Marked disable-model-invocation so only the user can trigger it. --- commands/brb.md | 75 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 75 insertions(+) create mode 100644 commands/brb.md diff --git a/commands/brb.md b/commands/brb.md new file mode 100644 index 0000000..829a67f --- /dev/null +++ b/commands/brb.md @@ -0,0 +1,75 @@ +--- +argument-hint: "[optional eta or focus]" +description: Use when the user signals they're stepping away (brb, afk, heading out) — keep working autonomously, skip anything that needs interactive approval, handoff when done or blocked. +disable-model-invocation: true +--- + +# BRB Mode + +The user is stepping away and **cannot approve interactive prompts** (1Password, SSH agent, Touch ID, sudo, browser dialogs). Keep working autonomously, then leave a `/handoff` so they can pick up when back. + +Treat this as a standing behavioral mode for the rest of the session. Exit the mode when the user sends any new message, unless they explicitly extend it ("still afk, keep going"). A new prompt is the signal they're back. + +The rules below apply to **every** tool invocation in this session — bash commands, MCP tool calls, sub-agent delegations, browser automation. Do not silently exempt a category just because it isn't enumerated. + +## Extra context from the user + +$ARGUMENTS + +## Rules while AFK + +### Keep doing +- Edit files, read files, grep, run tests, run builds, run typecheckers +- `git add`, `git status`, `git diff`, `git log`, `git branch` +- `git commit` — the user does not sign commits, so this won't prompt. Make multiple small commits at natural checkpoints; easier to review later. +- `gh pr view`, `gh pr diff`, `gh issue view`, `gh run view`, anything `gh` read-only (uses HTTPS token, no SSH) +- `gh pr create`, `gh pr comment`, `gh issue comment` — fine, HTTPS token-based +- Background tasks that don't need approval +- Sub-agents (Explore, code-reviewer, etc.) — **subagents start in a fresh context and won't see this skill**. When delegating during /brb, prepend the AFK constraint to the delegation prompt, e.g.: `User is AFK. Do not run any command that requires 1Password / SSH agent / Touch ID / sudo approval. If you hit one, note it and stop.` + +### Do NOT do +Any command that triggers 1Password / SSH agent / Touch ID / interactive prompt. Skip these proactively — don't attempt and time out. + +- `git push`, `git push --force` — SSH agent approval +- `git fetch`, `git pull` — SSH agent approval (remotes are SSH) +- `op read`, `op item get`, `op signin`, anything `op` that touches a vault +- `ssh-add`, `ssh ` for new connections needing keys +- `gh auth login`, `gh auth refresh`, `gh auth setup-git` +- `npm login`, `npm publish`, `yarn publish`, `cargo publish` +- `sudo ` if the sudo timestamp isn't already cached +- Browser dialogs / `alert()` / `confirm()` via Claude-in-Chrome — these freeze the session +- Any new MCP `authenticate` flow + +### If something needs approval anyway +Don't retry. Note it in the handoff queue and move on if other work is available. If approval-blocked work is the *only* remaining task, halt and write the handoff now. + +### If you need a secret that's normally in 1Password +Don't fetch it. Note in the handoff that the next step needs `` and how to retrieve it (e.g., `op read "op://Private/foo/credential"`). + +## Definition of done + +Keep working until one of: +1. **All local checks pass and only the push remains** — preferred outcome. Stage and commit everything; the handoff lists the exact `git push` to run. +2. **A decision blocker** — ambiguous requirement, two viable approaches, missing context only the user has. +3. **All work is blocked behind an approval gate** that you've already deferred. + +Do not stop early just because the queue is "mostly" done. AFK is a chance to work the queue. + +## Wrapping up + +When you hit a stopping point: + +1. Make sure all work is committed locally. Don't leave large uncommitted diffs — the user can `git push` everything at once when back. +2. Invoke `/handoff` with focus set to: `AFK session — what's done, what's deferred behind approval gates, what (if anything) needs a decision` +3. After `/handoff` writes the file, post a short chat summary: number of unpushed commits, the exact `git push` line, and any deferred-approval items. + + +Handoff saved to ~/.handoffs/handoff-myapp-feat-xyz-20260523-1830.md + +3 unpushed commits on `feat/xyz`. When back: + git push + +Deferred (needed approval): + - `op read "op://Private/stripe/api-key"` for the integration test + - `gh auth refresh` (token expires today per gh status) + From c7bd15e99919c8a1cffc8eefcf8452d4648a717a Mon Sep 17 00:00:00 2001 From: 0xBigBoss <95193764+0xBigBoss@users.noreply.github.com> Date: Fri, 29 May 2026 11:00:59 -0500 Subject: [PATCH 54/57] chore(settings): enable autoUpdate for recall plugin Captures the auto-update flag the Claude Code upgrade wrote into the runtime marketplace entry so it survives reinstalls instead of reading as drift. --- settings/settings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/settings/settings.json b/settings/settings.json index e8a962e..c6b2630 100644 --- a/settings/settings.json +++ b/settings/settings.json @@ -128,7 +128,7 @@ "extraKnownMarketplaces": { "claude-plugins-official": { "source": { "source": "github", "repo": "anthropics/claude-plugins-official" } }, "0xsend-marketplace": { "source": { "source": "git", "url": "https://github.com/0xsend/claude-code-plugins.git" } }, - "alleneubank-recall": { "source": { "source": "github", "repo": "alleneubank/recall" } }, + "alleneubank-recall": { "autoUpdate": true, "source": { "source": "github", "repo": "alleneubank/recall" } }, "0xBigBoss-silo": { "source": { "source": "github", "repo": "0xBigBoss/silo" } }, "send-infra-plugins": { "source": { "source": "github", "repo": "0xsend/infra" } }, "gh-pulse-marketplace": { "source": { "source": "github", "repo": "0xbigboss/gh-pulse" } }, From e0ee74802e60a2a8b15709ebecbd671976d77bea Mon Sep 17 00:00:00 2001 From: Allen Date: Fri, 29 May 2026 17:25:58 +0000 Subject: [PATCH 55/57] feat(git-worktree-tidy): verify ship status before deleting gone branches A deleted upstream does not prove the work shipped: squash- and rebase-merges land changes under a new SHA, so git rev-list/ancestry report a fully-merged branch as having unmerged commits. Relying on that risks force-deleting branches that actually shipped (false positive) or, worse, trusting it the other way. - Add step 3a: verify each gone-upstream branch against the forge's PR merge state (gh pr list --head; confirm tip == merged PR head), and classify Merged (verified) vs At risk. - Add a hard rule against inferring merged status from rev-list/ancestry. - Surface ship status in the confirmation table so deletions are confirmed with the evidence visible. --- .claude/skills/git-worktree-tidy/SKILL.md | 44 +++++++++++++++++++++-- 1 file changed, 41 insertions(+), 3 deletions(-) diff --git a/.claude/skills/git-worktree-tidy/SKILL.md b/.claude/skills/git-worktree-tidy/SKILL.md index 361f903..3eb6ae4 100644 --- a/.claude/skills/git-worktree-tidy/SKILL.md +++ b/.claude/skills/git-worktree-tidy/SKILL.md @@ -25,6 +25,11 @@ User asks to "fetch prune", "clean up stale branches/worktrees", or - Use `--ff-only` when updating branches. If ff-only fails, stop and ask. - Operate from the `.bare` directory (or repo root) for branch/worktree management commands. +- Never infer "merged/shipped" from `git rev-list origin/main..` or + `git merge-base --is-ancestor` alone. Squash- and rebase-merges land the + work under a new SHA, so a fully-shipped branch still shows commits "not in + main" and a non-ancestor tip. Verify ship status against the forge's PR + merge state (step 3a) before classifying a gone branch as unmerged. ## Workflow @@ -52,6 +57,37 @@ git branch -vv | grep ': gone]' Collect branch names whose upstream is gone. +### 3a) Verify ship status (gone upstream ≠ merged) + +A deleted upstream ("gone") does NOT prove the work shipped, and git ancestry +is unreliable here: squash- and rebase-merges land the work under a new SHA, so +a fully-shipped branch still shows commits "not in main" and a non-ancestor tip. +Verify against the forge before deleting — gone-but-unmerged branches are the +only ones that lose real work. + +For each gone-upstream branch (GitHub example; substitute your forge CLI): + +```bash +# Was there a merged PR from this head? +gh pr list --state all --head \ + --json number,state,mergedAt,mergeCommit \ + --jq '.[] | "#\(.number) \(.state) merged=\(.mergedAt // "no")"' + +# If merged, confirm nothing was added to the branch AFTER the merge: +# the local tip should equal the PR head at merge. +gh pr view --json commits --jq '.commits[-1].oid' # vs: git rev-parse +``` + +Classify each gone branch: +- **Merged (verified)** — a MERGED PR exists AND its head == local tip → shipped, + safe to delete. +- **At risk** — no merged PR, or the tip has commits dated after the merge + (`git show -s --format=%ci `) → genuine unshipped work. Flag it; do not + delete without explicit approval. + +If `gh`/the forge CLI is unavailable, say so and treat unverifiable branches as +**at risk** rather than assuming merged. + ### 4) Discover stale worktrees ```bash @@ -80,14 +116,16 @@ Stale worktrees to remove: () [clean] () [dirty — N uncommitted changes] -Stale branches to delete: - +Stale branches: + [merged — PR #N, safe to delete] + [AT RISK — no merged PR / commits after merge; review first] Prunable worktree metadata: ``` -Wait for user confirmation before proceeding. +Call out the **at risk** branches explicitly so the user is deciding with the +ship status in front of them. Wait for user confirmation before proceeding. ### 6) Remove stale worktrees From 617768a0bce9109e8edef15410b37359cd8adc99 Mon Sep 17 00:00:00 2001 From: 0xBigBoss <95193764+0xBigBoss@users.noreply.github.com> Date: Fri, 29 May 2026 14:25:23 -0500 Subject: [PATCH 56/57] fix(skills): default git-rebase-sync to --update-refs Plain `git rebase origin/{base}` rewrites the range and orphans any local branch pointing into it, silently breaking stacked feature branches (and their open PRs). Make `--update-refs` the documented default so stacked branches follow the rewrite. - git-rebase-sync: add stacked-branch detection step (5), default the rebase command to --update-refs and note rebase.updateRefs config, verify moved refs post-rebase, and force-push each moved stack member individually (--update-refs moves local refs only; worktree-checked-out refs are skipped). - git-best-practices: add a "Rebasing a Stack" note near Force Push. Closes #4 --- .claude/skills/git-best-practices/SKILL.md | 4 ++ .claude/skills/git-rebase-sync/SKILL.md | 45 +++++++++++++++++----- 2 files changed, 40 insertions(+), 9 deletions(-) diff --git a/.claude/skills/git-best-practices/SKILL.md b/.claude/skills/git-best-practices/SKILL.md index 7b4acd6..e0944fd 100644 --- a/.claude/skills/git-best-practices/SKILL.md +++ b/.claude/skills/git-best-practices/SKILL.md @@ -47,6 +47,10 @@ git push --force-with-lease origin feat/my-branch Always confirm with the user before any force push, regardless of branch. +### Rebasing a Stack + +When rebasing a branch that other branches are stacked on (e.g. phased `NN-description` chains), use `git rebase --update-refs` so the stacked branches follow the rewrite instead of being orphaned on the old commits. `--update-refs` moves **local** refs only — each moved branch that also exists on the remote still needs its own `--force-with-lease` push (after confirmation), and any branch checked out in another worktree is skipped. For the full conflict-resolution and safety workflow, use the `git-rebase-sync` skill. + ## Conventional Commits Format: `type(scope): description` diff --git a/.claude/skills/git-rebase-sync/SKILL.md b/.claude/skills/git-rebase-sync/SKILL.md index 896cdef..38d3586 100644 --- a/.claude/skills/git-rebase-sync/SKILL.md +++ b/.claude/skills/git-rebase-sync/SKILL.md @@ -47,14 +47,35 @@ Use this skill when you need to sync a feature branch onto the latest `origin/{b - `git rev-list --count --merges origin/{base_branch}..HEAD` - If merge commits exist, ask whether to preserve them (`--rebase-merges`) or flatten them (plain rebase). -### 5) Run the rebase (requires confirmation) +### 5) Detect stacked branches (will they follow the rebase?) +Other local branches may point at intermediate commits in the range being rewritten (`origin/{base_branch}..HEAD`) — common with phased `NN-description` stacks. A plain rebase rewrites those commits and **orphans** the stacked branches on the old, pre-rebase commits (along with every open PR built on them). `--update-refs` (the default in step 6) moves them onto the rewritten commits instead. Enumerate them so you can report what the rebase will carry along: + +```bash +# Branches pointing into the range being rewritten — these get orphaned by a +# plain rebase and require --update-refs to follow the rewrite. +cur=$(git branch --show-current) +git for-each-ref --format='%(refname:short)' refs/heads | while read -r br; do + [ "$br" = "$cur" ] && continue + if git merge-base --is-ancestor "$br" HEAD \ + && ! git merge-base --is-ancestor "$br" origin/{base_branch}; then + echo "stacked: $br ($(git rev-parse --short "$br"))" + fi +done +``` + +- Report the detected stacked branches to me before rebasing. +- A branch that is **checked out in another worktree** is skipped by `--update-refs` (git will not move a checked-out branch); flag these so I can verify them manually after the rebase. + +### 6) Run the rebase (requires confirmation) +- Default to `--update-refs` so any stacked branch pointing into the rewritten range follows the rewrite instead of being orphaned. - Print the exact command you intend to run, then wait for confirmation: - - Typical: - - `git rebase origin/{base_branch}` + - Default: + - `git rebase --update-refs origin/{base_branch}` - With merge preservation: - - `git rebase --rebase-merges origin/{base_branch}` + - `git rebase --rebase-merges --update-refs origin/{base_branch}` +- To make this the permanent default without the flag: `git config --global rebase.updateRefs true`. -### 6) Conflict handling loop +### 7) Conflict handling loop When conflicts happen: 1. Collect context: - `git status` @@ -79,15 +100,21 @@ Helpful commands during conflicts: - See the commit being replayed: `git show` - If you need to back out: `git rebase --abort` (this is safe and should be preferred over destructive resets) -### 7) Post-rebase verification +### 8) Post-rebase verification - Show the new commit range: - `git log --oneline --decorate origin/{base_branch}..HEAD` +- Confirm each stacked branch from step 5 moved onto the rewritten range. `git rebase --update-refs` prints an "Updated the following refs with --update-refs" summary; the moved branch refs should also appear as decorations on the new commits in the log above. Note any that did **not** move (e.g. checked out in another worktree) for manual handling. - Run appropriate repo checks (tests, typecheck, lint) if available. -### 8) Push updated branch (requires confirmation) +### 9) Push updated branch(es) (requires confirmation) - If the branch already exists on origin, rebasing rewrites history, so pushing requires force-with-lease. -- Print the exact command and wait for confirmation: - - `git push --force-with-lease origin HEAD:{branch_name}` +- `--update-refs` only moves **local** refs. A normal `git push` updates the current branch only — each stacked branch (from step 5) that also exists on origin must be force-pushed **individually**. +- Print the exact command(s) and wait for confirmation: + - Current branch: + - `git push --force-with-lease origin HEAD:{branch_name}` + - Each moved stacked branch that exists on origin (repeat per branch): + - `git push --force-with-lease origin {stacked_branch}` +- Any branch skipped because it is checked out in another worktree was not moved — verify it before pushing. ## Recovery - If something goes wrong, use `{backup_ref}` to restore the pre-rebase state. From 026e08858616214dbab4e614ea9837eb7ad693a2 Mon Sep 17 00:00:00 2001 From: Roee L Date: Thu, 4 Jun 2026 11:04:17 +0300 Subject: [PATCH 57/57] feat: expose typescript-best-practices skill in marketplace Adds the existing .claude/skills/typescript-best-practices skill to .claude-plugin/marketplace.json so it can be installed from any Claude Code marketplace as an external reference: /plugin install typescript-best-practices --- .claude-plugin/marketplace.json | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index c312112..6a338b9 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -10,6 +10,18 @@ "pluginRoot": "./plugins" }, "plugins": [ + { + "name": "typescript-best-practices", + "source": "./.claude/skills/typescript-best-practices", + "description": "Use when reading or writing TypeScript or JavaScript files (.ts, .tsx, .js, tsconfig.json)", + "version": "1.0.0", + "author": { + "name": "Allen", + "email": "bigboss@metalrodeo.xyz" + }, + "keywords": ["typescript", "javascript", "best-practices"], + "category": "languages" + }, { "name": "ralph-reviewed", "source": "./plugins/ralph-reviewed",