From 144539aaa76a1b4166a79a3ae59d00e79148c2f1 Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Mon, 20 Jul 2026 01:23:03 -0400 Subject: [PATCH 01/11] =?UTF-8?q?feat(claude-ops):=20add=20lanes=20skill?= =?UTF-8?q?=20=E2=80=94=20scripted=20background=20loop-lane=20launcher=20(?= =?UTF-8?q?#508)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a `lanes` skill that starts, restarts, stops, and reports loop lanes as named background Claude Code sessions seeded from canonical prompt files, replacing the manual morning refresh (cancel loop, clear, re-paste the canonical prompt) across N lanes. start (default) / restart first `git pull --ff-only` and `claude plugin marketplace update`, then launch each configured lane with `claude --bg -n ` mirroring the lane's model/effort; status prints a per-lane running/stopped table with the live sessionId; stop ends a lane via `claude stop ` (resolved from `claude agents --json` — there is no `claude agents stop` verb). Acts on a session only when its name is a configured lane, so a hand-started session is never touched. Lanes come from a JSON config (--config, else $CLAUDE_OPS_LANES_CONFIG, else /.work/lanes.json). Prompt files are read from a session-local `.work` dir today via a single prompt_dir/resolve_prompt_dir seam that composes with issue #480 (loop-prompt authoring skill) when durable prompt storage lands. Bumps claude-ops 0.14.0 -> 0.15.0 (minor: new feature). Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_011V31qpAHP3jfs76B9d5Rfo --- plugins/claude-ops/.claude-plugin/plugin.json | 4 +- plugins/claude-ops/CHANGELOG.md | 21 + plugins/claude-ops/README.md | 6 +- plugins/claude-ops/skills/lanes/SKILL.md | 110 +++++ .../claude-ops/skills/lanes/context/config.md | 54 +++ .../claude-ops/skills/lanes/evals/evals.json | 66 +++ .../skills/lanes/scripts/lane-launcher.sh | 436 ++++++++++++++++++ .../lanes/scripts/lane-launcher.test.sh | 188 ++++++++ 8 files changed, 881 insertions(+), 4 deletions(-) create mode 100644 plugins/claude-ops/skills/lanes/SKILL.md create mode 100644 plugins/claude-ops/skills/lanes/context/config.md create mode 100644 plugins/claude-ops/skills/lanes/evals/evals.json create mode 100644 plugins/claude-ops/skills/lanes/scripts/lane-launcher.sh create mode 100644 plugins/claude-ops/skills/lanes/scripts/lane-launcher.test.sh diff --git a/plugins/claude-ops/.claude-plugin/plugin.json b/plugins/claude-ops/.claude-plugin/plugin.json index 192954270..2a3d369b4 100644 --- a/plugins/claude-ops/.claude-plugin/plugin.json +++ b/plugins/claude-ops/.claude-plugin/plugin.json @@ -1,8 +1,8 @@ { "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json", "name": "claude-ops", - "version": "0.14.0", - "description": "Claude Code operations toolkit. Six skills: observability (read locally captured telemetry — OTEL store, collector, hook-event JSONL, ccusage — with trend reports and store pruning), known-issues (search known Claude product GitHub bugs, check service health, maintain a persistent tracked-issue registry), changelog (ingest Claude Code changelog entries and integrate them into the current repo), plugins (bring a machine's plugin fleet current on demand — marketplace refresh, effective-scope updates including in-repo project/local installs, new-plugin install per policy, scope-divergence detection and explicit convergence), morning-brief (read-only gh-based operator morning view — queue-label counts, merge-ready PRs, parked decisions with their RECOMMENDED lines, and loop-lane telemetry freshness), and a re-runnable setup action that settles where the known-issues registry lives. Plus a family of seven advisory *-audit telemetry-emitter hooks (API errors, config changes, instruction loads, permission denials, pre-compaction, skill usage, tool failures) that emit the shared hook-telemetry envelope, and a reference sink that maps envelopes into the hook-events.jsonl the observability skill reads.", + "version": "0.15.0", + "description": "Claude Code operations toolkit. Seven skills: observability (read locally captured telemetry — OTEL store, collector, hook-event JSONL, ccusage — with trend reports and store pruning), known-issues (search known Claude product GitHub bugs, check service health, maintain a persistent tracked-issue registry), changelog (ingest Claude Code changelog entries and integrate them into the current repo), plugins (bring a machine's plugin fleet current on demand — marketplace refresh, effective-scope updates including in-repo project/local installs, new-plugin install per policy, scope-divergence detection and explicit convergence), morning-brief (read-only gh-based operator morning view — queue-label counts, merge-ready PRs, parked decisions with their RECOMMENDED lines, and loop-lane telemetry freshness), lanes (start/restart/stop/status loop lanes as named background Claude Code sessions seeded from canonical prompt files, with per-lane model/effort and a repo-pull + marketplace-refresh launch step), and a re-runnable setup action that settles where the known-issues registry lives. Plus a family of seven advisory *-audit telemetry-emitter hooks (API errors, config changes, instruction loads, permission denials, pre-compaction, skill usage, tool failures) that emit the shared hook-telemetry envelope, and a reference sink that maps envelopes into the hook-events.jsonl the observability skill reads.", "author": { "name": "Melodic Software", "email": "info@melodicsoftware.com" diff --git a/plugins/claude-ops/CHANGELOG.md b/plugins/claude-ops/CHANGELOG.md index 613f26ff6..65ca6eaa0 100644 --- a/plugins/claude-ops/CHANGELOG.md +++ b/plugins/claude-ops/CHANGELOG.md @@ -3,6 +3,27 @@ All notable changes to the `claude-ops` plugin are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning. +## [0.15.0] + +### Added + +- **`lanes` skill** — a scripted launcher that starts, restarts, stops, and + reports loop lanes as **named background Claude Code sessions** seeded from + canonical prompt files, replacing the manual morning refresh (cancel loop, + clear, re-paste the canonical prompt) across N lanes. `start` (default) and + `restart` first `git pull --ff-only` and `claude plugin marketplace update`, + then launch each configured lane with `claude --bg -n ` mirroring the + lane's `model`/`effort`; `status` prints a per-lane running/stopped table with + the live sessionId; `stop` ends a lane via `claude stop ` (resolved + from `claude agents --json` — there is no `claude agents stop` verb). Acts on a + session **only** when its name is a configured lane, so a hand-started session + is never touched. Lanes come from a JSON config (`--config`, else + `$CLAUDE_OPS_LANES_CONFIG`, else `/.work/lanes.json`); `--dry-run`, + `--no-pull`, `--no-update`, and `--agents-json` support previewing and offline + reuse. Prompt files are read from a session-local `.work` dir today via the + single `prompt_dir`/`resolve_prompt_dir` seam, which composes with #480 + (loop-prompt authoring skill) when durable prompt storage lands. + ## [0.14.0] ### Added diff --git a/plugins/claude-ops/README.md b/plugins/claude-ops/README.md index 139addd1c..d76685e0b 100644 --- a/plugins/claude-ops/README.md +++ b/plugins/claude-ops/README.md @@ -1,11 +1,12 @@ # claude-ops A Claude Code plugin for running Claude Code well over time — one cohesive -capability across six skills and a family of telemetry-emitter hooks. +capability across seven skills and a family of telemetry-emitter hooks. Observability reads what your sessions actually did, known-issues tracks what upstream has broken, changelog integration keeps your repo current with what upstream has shipped, the plugins skill keeps your own plugin fleet current, -morning-brief prints your read-only operator morning view, a +morning-brief prints your read-only operator morning view, lanes launches and +manages your loop lanes as background sessions, a re-runnable `setup` action settles where the known-issues registry lives, and the `*-audit` hooks feed observability with per-hook execution telemetry Claude Code's native OTEL cannot see. @@ -19,6 +20,7 @@ Claude Code's native OTEL cannot see. | `/claude-ops:changelog` | Ingests Claude Code changelog entries and integrates them into the current repo: `fetch` (read-only display), `diff` (impact triage, no edits), `status` (applied versions from git history), and `apply` (full explore → research → interview → implement pipeline, explicit user intent only). | | `/claude-ops:plugins` | Brings a machine's plugin fleet current on demand: marketplace refresh, updates for the plugins that actually load (including in-repo project/local-scope installs), new-catalog-plugin install per policy, and scope-divergence detection. Actions: `sync` (default, CLI-mediated mutations only), `audit` (read-only dry run), `converge` (the one action that can touch a committed `.claude/settings.json` — previews and confirms per plugin first). | | `/claude-ops:morning-brief` | Prints the read-only, `gh`-based operator morning view for the current repo in one pass: open counts per queue label (`priority: needs-triage`, `status: ready`, `status: needs-decision`, `needs-human`), the gh-native merge-ready PR list (non-draft + `mergeStateStatus=CLEAN`), parked `status: needs-decision` issues with their RECOMMENDED lines, and loop-lane telemetry freshness (per-lane `last-cycle` age + `flags:`). Never mutates anything; the authoritative PR merge gate stays `/source-control:babysit-prs`. | +| `/claude-ops:lanes` | Starts, restarts, stops, and reports loop lanes as named background Claude Code sessions seeded from canonical prompt files. `start` (default) / `restart` pull the repo and refresh the plugin marketplace, then launch each configured lane (`claude --bg -n `) with its per-lane `model`/`effort`; `status` shows per-lane running state and live sessionId; `stop` ends a lane via `claude stop`. Acts only on sessions whose name is a configured lane. Lanes come from a JSON config (`--config`, else `$CLAUDE_OPS_LANES_CONFIG`, else `/.work/lanes.json`); prompt storage is session-local `.work` today and composes with #480 for a durable home. | | `/claude-ops:setup` | `check` (default) reports the effective known-issues-registry and skill-usage-log destinations, their defaults, and path containment; `apply` routes personal option changes through Claude Code's plugin configuration prompt. | ## The audit hooks diff --git a/plugins/claude-ops/skills/lanes/SKILL.md b/plugins/claude-ops/skills/lanes/SKILL.md new file mode 100644 index 000000000..19a1d8ab7 --- /dev/null +++ b/plugins/claude-ops/skills/lanes/SKILL.md @@ -0,0 +1,110 @@ +--- +name: lanes +description: "Start, restart, stop, and check loop lanes as named background Claude Code sessions seeded from canonical prompt files — the scripted replacement for the manual morning refresh (cancel loop, clear, re-paste the canonical prompt) across N lanes on a machine. `start`/`restart` first pull the repo and refresh the plugin marketplace, then launch each configured lane with its per-lane model/effort. Use when: 'launch my lanes', 'restart the loop lanes', 'start the work lanes', 'morning lane refresh', 'stop a lane', 'which lanes are running', 'lane status'. Mutating and operator-initiated; never touches a session whose name is not a configured lane." +argument-hint: "[start|restart|status|stop] [lane...] — start (default); restart/stop accept lane names; --config, --repo, --dry-run, --no-pull, --no-update" +user-invocable: true +disable-model-invocation: true +--- + +## Pre-computed context + +claude CLI: !`command -v claude >/dev/null 2>&1 && echo "present ($(claude --version 2>/dev/null))" || echo "MISSING (required)"` +jq: !`command -v jq >/dev/null 2>&1 && echo "present" || echo "MISSING (required)"` +Repo root: !`git rev-parse --show-toplevel 2>/dev/null || echo "unknown (pass --repo)"` +Lane config: !`c="${CLAUDE_OPS_LANES_CONFIG:-$(git rev-parse --show-toplevel 2>/dev/null || pwd)/.work/lanes.json}"; [[ -f "$c" ]] && echo "$c ($(jq -r '(.lanes//[])|length' "$c" 2>/dev/null) lanes)" || echo "absent ($c) — author one (see context/config.md)"` + +## Variables + +Arguments: `$ARGUMENTS` + +## Purpose + +Running N loop lanes on a machine means a daily ritual: for each lane, cancel its +loop, clear, and re-paste its canonical prompt. This skill collapses that to one +command. `start`/`restart` pull the repo and refresh the plugin marketplace once, +then launch each configured lane as a **named background session** seeded from the +lane's canonical prompt file, mirroring that lane's model/effort onto the launch. +`status`/`stop` read and manage those sessions through the CLI's own +background-session surface. + +**Owns only its own lanes.** `stop`/`restart` act on a session **only** when its +name is a lane in the resolved config — a hand-started session (e.g. an interactive +`work` window, or an unrelated `PR Babysit`) is never stopped by this skill. + +## Run it + +```bash +bash "${CLAUDE_PLUGIN_ROOT}/skills/lanes/scripts/lane-launcher.sh" $ARGUMENTS +``` + +Print the script's output verbatim — it is the deliverable. Preview any mutating +run first with `--dry-run` (prints the exact `claude`/`git` commands, seeds +nothing, kills nothing). + +## Action Router + +Parse `$ARGUMENTS` for the action (first token); remaining tokens are lane names +(targets for `restart`/`stop`; an unknown name is rejected). + +| Action | Mutates | Description | +|---|---|---| +| `start` (default) | Yes | Pull + marketplace update, then launch every configured lane **not already running** | +| `restart [lane...]` | Yes | Pull + marketplace update, then stop-and-relaunch each target lane (all, or named) | +| `status` | No | Per-lane table: model, effort, running/stopped, and the live sessionId | +| `stop [lane...]` | Yes | Stop each running target lane (all, or named) via `claude stop ` | + +Options: `--config FILE`, `--repo DIR`, `--no-pull`, `--no-update`, `--dry-run`, +`--agents-json FILE` (read the session list from a file instead of the live CLI — +offline/scripted reuse). Exit codes: `0` ok · `3` bad argument/config · `4` +prerequisite missing or repo/config unresolved. + +## Lane config + +Lanes are defined in a JSON config, resolved first-hit-wins: +`--config FILE` → `$CLAUDE_OPS_LANES_CONFIG` → `/.work/lanes.json`. Each lane +carries a `name`, a `prompt` file path, and optional `model`/`effort`. The full +schema, resolution rules, and the prompt-storage seam live in +[context/config.md](context/config.md) — read it before authoring a config. + +**Prompt storage is provisional (composes with #480).** Today prompt files live in +a session-local `.work` dir (`prompt_dir`, default `.work`). Issue #480 (loop-prompt +authoring skill) is slated to own durable prompt storage. When it lands, repoint +`prompt_dir` at that home; the launcher resolves the prompt dir in exactly one place +(`resolve_prompt_dir` in the script), which is the single seam to update. + +## Verified CLI surface + +The launcher shells out only to primitives confirmed on this machine's `claude` +(`--help` / real invocation): `claude --bg -n [--model M] [--effort E] +""` (launch a named background session, return immediately), +`claude agents --json` (list sessions: pid, cwd, kind, sessionId, name, status), +`claude stop ` (stop one session; conversation kept, resumable with +`claude attach`), and `claude plugin marketplace update`. There is no +`claude agents stop` verb — stop resolves the sessionId from `agents --json` and +only for a configured lane name. + +## Gotchas + +- **No durable prompt home yet.** `.work` is session-local; a fresh machine/session + has no prompts until they are authored there (or `prompt_dir` is pointed at a + committed dir). This is the #480 dependency, not a bug. +- **Name is the identity.** Lanes are matched by session `name`. Two lanes must not + share a name, and a lane name collision with a hand-started session means `status` + reports it running and `stop` would stop it — keep lane names distinct from ad-hoc + session names. +- **`start` is idempotent-ish, `restart` is not.** `start` skips a lane already + running; `restart` always stops-and-relaunches (discarding the running lane's + in-flight conversation). Use `start` for "bring up whatever is down". +- **A missing/empty prompt file skips that lane** (with an error) rather than + launching an empty session. `status` flags `[prompt MISSING]`. + +## Cross-references + +- `/claude-ops:plugins` — the authoritative, richer plugin-fleet sync (scope + divergence, new-catalog installs). This skill's marketplace refresh is the light + `claude plugin marketplace update` step of a launch, not a substitute. +- `/claude-ops:morning-brief` — reads the loop-lane **telemetry** (per-lane + last-cycle freshness). This skill starts/stops the lanes that emit it. +- #480 (loop-prompt authoring skill) — forward dependency that will own durable + prompt storage. #496 (context economy / restart discipline) — why lanes get + restarted. #502 (telemetry) — the per-lane telemetry the running lanes feed. diff --git a/plugins/claude-ops/skills/lanes/context/config.md b/plugins/claude-ops/skills/lanes/context/config.md new file mode 100644 index 000000000..19480b618 --- /dev/null +++ b/plugins/claude-ops/skills/lanes/context/config.md @@ -0,0 +1,54 @@ +# Lane config contract + +The launcher (`scripts/lane-launcher.sh`) reads a JSON config describing the lanes +to manage. This file is the full contract; the SKILL.md keeps only the summary. + +## Resolution + +First hit wins: + +1. `--config FILE` +2. `$CLAUDE_OPS_LANES_CONFIG` +3. `/.work/lanes.json` (repo = `--repo DIR`, else the git toplevel of the cwd) + +A missing config exits `4`; malformed JSON or a config with no lanes exits `3`. + +## Schema + +```json +{ + "prompt_dir": ".work", + "lanes": [ + { "name": "work", "prompt": "work.md", "model": "opus", "effort": "high" }, + { "name": "work-2", "prompt": "work-2.md", "model": "opus", "effort": "high" }, + { "name": "babysit", "prompt": "babysit.md", "model": "sonnet", "effort": "medium" }, + { "name": "decide", "prompt": "decide.md" } + ] +} +``` + +| Field | Required | Meaning | +|---|---|---| +| `prompt_dir` | no | Base dir for relative `prompt` paths. Default `.work`. Relative values resolve against the repo root; absolute (POSIX `/…` or Windows `C:\…`) are used as-is. | +| `lanes[].name` | yes | The lane's session name — the `--name` value the launcher gives the background session, and the key `status`/`stop` match on. Keep distinct from ad-hoc session names. | +| `lanes[].prompt` | yes | Path to the lane's canonical prompt file. Relative → resolved against `prompt_dir`; absolute → used as-is. The file's full contents seed the session (positional prompt). A missing or empty file skips that lane with an error. | +| `lanes[].model` | no | Passed as `claude --model`. An alias (`opus`, `sonnet`, `fable`) or a full model id. Omit to inherit the machine default. | +| `lanes[].effort` | no | Passed as `claude --effort`. One of `low`, `medium`, `high`, `xhigh`, `max` (validated; a bad value skips the lane). Omit to inherit the default. | + +Lane names are free-form (`work`, `work-2`, `babysit`, `decide`, …); nothing is +hardcoded. The set above mirrors the lanes this repo's telemetry conventions use, +but any names work — `status`/`stop` only ever act on names present in this config. + +## Prompt-storage seam (#480) + +`prompt_dir` defaulting to `.work` reflects today's reality: canonical prompts live +in a session-local `.work` dir, which is **not durable** — a fresh machine or +session starts empty until the prompts are authored there. + +Issue #480 (loop-prompt authoring skill) is slated to **own durable prompt +storage**. This skill deliberately does not build that: it reads prompt files from +wherever `prompt_dir` points today and leaves a single seam for the durable home. + +When #480 lands, the only change here is to repoint `prompt_dir` (per-config) or the +`resolve_prompt_dir` function in the script (the default) at the durable location. +No other part of the launcher knows where prompts live. diff --git a/plugins/claude-ops/skills/lanes/evals/evals.json b/plugins/claude-ops/skills/lanes/evals/evals.json new file mode 100644 index 000000000..30975918b --- /dev/null +++ b/plugins/claude-ops/skills/lanes/evals/evals.json @@ -0,0 +1,66 @@ +{ + "skill_name": "lanes", + "evals": [ + { + "id": 1, + "name": "start-is-default-action", + "prompt": "launch my lanes", + "expected_output": "Routes to the default `start` action: runs lane-launcher.sh, which resolves the lane config (--config, else $CLAUDE_OPS_LANES_CONFIG, else /.work/lanes.json), pulls the repo and refreshes the plugin marketplace, then launches each configured lane NOT already running as a named background session (claude --bg -n [--model] [--effort] ). Prints the script output verbatim.", + "files": [], + "expectations": [ + "Output routes to the start action (lane-launcher.sh with no explicit action), not restart/stop/status", + "Output reflects that start pulls the repo and updates the plugin marketplace before launching", + "Output reflects that start launches only lanes not already running (a running lane is skipped), each seeded from its canonical prompt file with its per-lane model/effort", + "Output does not fabricate lane state — it reflects the config and the live 'claude agents --json' session list" + ] + }, + { + "id": 2, + "name": "restart-stops-then-relaunches", + "prompt": "restart the work lane", + "expected_output": "Routes to `restart work`: validates 'work' is a configured lane, stops it if running (claude stop resolved from claude agents --json), then relaunches it from its canonical prompt file with its model/effort. Restart discards the running lane's in-flight conversation; start would instead skip an already-running lane.", + "files": [], + "expectations": [ + "Output routes to restart scoped to the named lane, not all lanes", + "Output reflects stop-then-relaunch (the running session is stopped first via claude stop, then a fresh background session is launched)", + "Output notes restart always relaunches (unlike start, which skips a running lane)" + ] + }, + { + "id": 3, + "name": "stop-only-touches-configured-lanes", + "prompt": "Will `lanes stop` ever stop a background session I started by hand that is not one of my configured lanes?", + "expected_output": "No. stop (and restart) act on a session ONLY when its name is a lane present in the resolved config. A hand-started session — an unrelated 'PR Babysit', or an interactive window named the same as a lane's ad-hoc twin — is matched by name against the config; names not in the config are never stopped. There is no 'claude agents stop' verb, so stop resolves the sessionId from 'claude agents --json' for a configured lane name only.", + "files": [], + "expectations": [ + "Output states stop only ever targets sessions whose name is a configured lane", + "Output confirms a session whose name is not in the lane config is never stopped", + "Output does not claim a 'claude agents stop' verb exists — stop resolves the sessionId from claude agents --json" + ] + }, + { + "id": 4, + "name": "status-is-read-only", + "prompt": "which lanes are running", + "expected_output": "Routes to `status`: prints a per-lane table (lane, model, effort, running/stopped state, live sessionId) built from the config joined against 'claude agents --json'. It mutates nothing — no pull, no launch, no stop — and flags a lane whose prompt file is missing.", + "files": [], + "expectations": [ + "Output routes to the status action and reports per-lane running/stopped state with the live sessionId", + "Output reflects that status is read-only (no pull, launch, or stop)", + "Output reflects that a lane with a missing prompt file is flagged" + ] + }, + { + "id": 5, + "name": "prompt-storage-defers-to-480", + "prompt": "Where does the launcher read a lane's canonical prompt from, and is that its permanent home?", + "expected_output": "It reads each lane's prompt from the file named by lanes[].prompt, resolved against prompt_dir (default '.work', a session-local dir). That is provisional, NOT the permanent home: issue #480 (loop-prompt authoring skill) is slated to own durable prompt storage. When #480 lands, prompt_dir (or the resolve_prompt_dir seam) is repointed at the durable location; this skill does not build that storage itself.", + "files": [], + "expectations": [ + "Output identifies prompt_dir (default .work) as the current, session-local prompt source", + "Output states this is provisional and that durable prompt storage is issue #480's responsibility, not this skill's", + "Output does not claim this skill builds or owns durable prompt storage" + ] + } + ] +} diff --git a/plugins/claude-ops/skills/lanes/scripts/lane-launcher.sh b/plugins/claude-ops/skills/lanes/scripts/lane-launcher.sh new file mode 100644 index 000000000..2425a9c55 --- /dev/null +++ b/plugins/claude-ops/skills/lanes/scripts/lane-launcher.sh @@ -0,0 +1,436 @@ +#!/usr/bin/env bash +# lane-launcher.sh — start/restart/stop/status loop lanes as background Claude +# Code sessions seeded from canonical prompt files. +# +# The morning refresh ritual — cancel each loop, clear, re-paste its canonical +# prompt across N lanes — collapses to one command. `start`/`restart` pull the +# repo and refresh the plugin marketplace, then launch each configured lane as a +# named background session seeded with the lane's canonical prompt file. +# `status`/`stop` read and manage those sessions through the CLI's own +# background-session surface. +# +# Verified CLI surface (claude 2.1.215 — see the skill's Verification section): +# claude --bg -n [--model M] [--effort E] "" launch, return now +# claude agents --json list sessions +# (pid, cwd, kind, +# sessionId, name, +# status) +# claude stop stop one session +# claude plugin marketplace update refresh catalog(s) +# There is no `claude agents stop` verb: `stop`/`restart` target the sessionId +# that `agents --json` reports, and ONLY for a name present in the lane config — +# so the wrapper can never stop an unrelated session (e.g. a hand-started one). +# +# PROMPT-FILE STORAGE IS PROVISIONAL. Today prompts live in a session-local +# `.work` dir (the config's `prompt_dir`, default `.work`). Issue #480 +# (loop-prompt authoring skill) is slated to own durable prompt storage; when it +# lands, repoint `prompt_dir` at that home — the resolution seam is the single +# `resolve_prompt_dir` function below and nothing else. +# +# Usage: +# lane-launcher.sh [start] pull + update, launch lanes not running +# lane-launcher.sh restart [lane...] stop then start (all lanes, or named) +# lane-launcher.sh status per-lane running state +# lane-launcher.sh stop [lane...] stop running lanes (all, or named) +# +# Options: +# --config FILE lane config JSON (default resolution order below) +# --repo DIR repo root for git pull + default launch cwd +# (default: the git toplevel of the current directory) +# --no-pull skip the git pull step (start / restart) +# --no-update skip the plugin marketplace update step (start / restart) +# --dry-run print the commands that would run; mutate nothing +# --agents-json FILE read the session list from FILE instead of +# `claude agents --json` (offline / scripted / test reuse) +# --help +# +# Config resolution (first hit wins): +# --config FILE → $CLAUDE_OPS_LANES_CONFIG → /.work/lanes.json +# +# Config schema (see context/config.md for the full contract): +# { "prompt_dir": ".work", +# "lanes": [ {"name":"work","prompt":"work.md","model":"opus","effort":"high"} ] } +# prompt_dir optional; base for relative `prompt` paths; default ".work". +# name required; the lane's session name (also the --name value). +# prompt required; path to the canonical prompt file (absolute, or +# relative to prompt_dir). +# model optional; passed as --model. +# effort optional; passed as --effort (low|medium|high|xhigh|max). +# +# Exit codes: +# 0 ok +# 3 invalid argument / malformed config +# 4 prerequisite missing (claude or jq), or repo / config could not be resolved + +set -uo pipefail + +VALID_EFFORTS="low medium high xhigh max" + +ACTION="start" +CONFIG="" +REPO="" +NO_PULL=0 +NO_UPDATE=0 +DRY_RUN=0 +AGENTS_JSON_FILE="" +declare -a TARGET_LANES=() + +# --- Small emitters ----------------------------------------------------------- +err() { printf 'ERROR: %s\n' "$*" >&2; } +info() { printf '%s\n' "$*"; } + +# Print the leading comment header (everything after the shebang up to the first +# non-comment line), stripped of the leading '# '. Robust to header length so a +# reformat never bleeds code into --help. +usage() { awk 'NR==1{next} /^#/{sub(/^# ?/,""); print; next} {exit}' "${BASH_SOURCE[0]}"; } + +# --- Argument parse ----------------------------------------------------------- +# First non-option token is the action; remaining non-option tokens are lane +# names (targets for restart/stop). +parse_args() { + local seen_action=0 + while (($#)); do + case "$1" in + start | restart | status | stop) + if ((seen_action)); then TARGET_LANES+=("$1"); else + ACTION="$1" + seen_action=1 + fi + ;; + --config) + CONFIG="${2:-}" + shift + ;; + --config=*) CONFIG="${1#*=}" ;; + --repo) + REPO="${2:-}" + shift + ;; + --repo=*) REPO="${1#*=}" ;; + --no-pull) NO_PULL=1 ;; + --no-update) NO_UPDATE=1 ;; + --dry-run) DRY_RUN=1 ;; + --agents-json) + AGENTS_JSON_FILE="${2:-}" + shift + ;; + --agents-json=*) AGENTS_JSON_FILE="${1#*=}" ;; + -h | --help) + usage + exit 0 + ;; + --) + shift + while (($#)); do + TARGET_LANES+=("$1") + shift + done + break + ;; + -*) + err "unknown option: $1" + exit 3 + ;; + *) + # A bare token before the action is an unknown action; after it, a lane. + if ((seen_action)); then TARGET_LANES+=("$1"); else + err "unknown action: $1" + exit 3 + fi + ;; + esac + shift + done +} + +# --- Prerequisites ------------------------------------------------------------ +require_jq() { + command -v jq >/dev/null 2>&1 || { + err "jq not found (required)" + exit 4 + } +} + +# `claude` is only needed for real mutating/reading calls. Dry runs and +# fixture-fed (--agents-json) reads must work with no CLI installed. +require_claude() { + ((DRY_RUN)) && return 0 + command -v claude >/dev/null 2>&1 || { + err "claude CLI not found (required)" + exit 4 + } +} + +# --- Repo + config resolution ------------------------------------------------- +resolve_repo() { + if [[ -n "$REPO" ]]; then + [[ -d "$REPO" ]] || { + err "repo not a directory: $REPO" + exit 4 + } + REPO="$(cd "$REPO" && pwd)" + return 0 + fi + REPO="$(git rev-parse --show-toplevel 2>/dev/null)" || + { + err "not inside a git repo; pass --repo DIR" + exit 4 + } +} + +resolve_config() { + if [[ -z "$CONFIG" ]]; then + CONFIG="${CLAUDE_OPS_LANES_CONFIG:-$REPO/.work/lanes.json}" + fi + [[ -f "$CONFIG" ]] || { + err "lane config not found: $CONFIG" + exit 4 + } + jq -e . "$CONFIG" >/dev/null 2>&1 || { + err "lane config is not valid JSON: $CONFIG" + exit 3 + } + local n + n="$(jq -r '(.lanes // []) | length' "$CONFIG")" + [[ "$n" -gt 0 ]] || { + err "lane config has no lanes: $CONFIG" + exit 3 + } +} + +# The one prompt-storage seam. #480 will repoint this at a durable home. +resolve_prompt_dir() { + local d + d="$(jq -r '.prompt_dir // ".work"' "$CONFIG")" + case "$d" in + /* | [A-Za-z]:[\\/]*) printf '%s' "$d" ;; # absolute (POSIX or Windows drive) + *) printf '%s' "$REPO/$d" ;; + esac +} + +# --- Session list (real CLI or fixture) -------------------------------------- +sessions_json() { + if [[ -n "$AGENTS_JSON_FILE" ]]; then + [[ -f "$AGENTS_JSON_FILE" ]] || { + err "agents-json file not found: $AGENTS_JSON_FILE" + exit 4 + } + cat "$AGENTS_JSON_FILE" + else + claude agents --json 2>/dev/null || echo '[]' + fi +} + +# sessionId of a running session with the given name (empty if none). If several +# match, the most recently started wins. +running_session_id() { + local name="$1" + sessions_json | jq -r --arg n "$name" \ + '[ .[] | select(.name == $n) ] | sort_by(.startedAt) | last | .sessionId // empty' +} + +# --- Per-lane field extraction ------------------------------------------------ +lane_field() { jq -r --argjson i "$1" --arg k "$2" '.lanes[$i][$k] // ""' "$CONFIG"; } + +# Absolute path to a lane's prompt file. +lane_prompt_path() { + local raw="$1" pdir="$2" + case "$raw" in + /* | [A-Za-z]:[\\/]*) printf '%s' "$raw" ;; + *) printf '%s' "$pdir/$raw" ;; + esac +} + +# --- Command runner ----------------------------------------------------------- +# Echoes the command; runs it unless --dry-run. +run() { + if ((DRY_RUN)); then + printf 'DRY-RUN:' + printf ' %q' "$@" + printf '\n' + return 0 + fi + "$@" +} + +# --- Lane launch -------------------------------------------------------------- +launch_lane() { + local name="$1" model="$2" effort="$3" prompt_path="$4" + + if [[ ! -f "$prompt_path" ]]; then + err "lane '$name': prompt file not found: $prompt_path — skipped" + return 1 + fi + if [[ ! -s "$prompt_path" ]]; then + err "lane '$name': prompt file is empty: $prompt_path — skipped" + return 1 + fi + if [[ -n "$effort" ]] && [[ " $VALID_EFFORTS " != *" $effort "* ]]; then + err "lane '$name': invalid effort '$effort' (want: $VALID_EFFORTS) — skipped" + return 1 + fi + + local -a cmd=(claude --bg -n "$name") + [[ -n "$model" ]] && cmd+=(--model "$model") + [[ -n "$effort" ]] && cmd+=(--effort "$effort") + + if ((DRY_RUN)); then + # Keep the seeded prompt out of the echoed command — show a size placeholder. + local bytes + bytes="$(wc -c <"$prompt_path" | tr -d ' ')" + printf 'DRY-RUN:' + printf ' %q' "${cmd[@]}" + printf ' %q\n' "" + return 0 + fi + + local prompt + prompt="$(cat "$prompt_path")" + cmd+=("$prompt") + (cd "$REPO" && "${cmd[@]}") +} + +stop_lane_if_running() { + local name="$1" sid + sid="$(running_session_id "$name")" + if [[ -n "$sid" ]]; then + info " stop $name ($sid)" + run claude stop "$sid" + return 0 + fi + return 1 +} + +# --- Refresh step (pull + marketplace update) -------------------------------- +refresh_repo_and_plugins() { + if ((NO_PULL)); then + info "skip git pull (--no-pull)" + else + info "git pull --ff-only ($REPO)" + run git -C "$REPO" pull --ff-only + fi + if ((NO_UPDATE)); then + info "skip plugin marketplace update (--no-update)" + else + info "claude plugin marketplace update" + run claude plugin marketplace update + fi +} + +# --- Lane iteration helper ---------------------------------------------------- +# Runs `callback ` for every lane, or only +# the lanes named in TARGET_LANES. Unknown target names are an error. +for_each_lane() { + local callback="$1" pdir + pdir="$(resolve_prompt_dir)" + local count + count="$(jq -r '.lanes | length' "$CONFIG")" + + # Validate any explicit targets against the config first. + if ((${#TARGET_LANES[@]})); then + local t known + for t in "${TARGET_LANES[@]}"; do + known="$(jq -r --arg n "$t" '[.lanes[].name] | index($n) // "no"' "$CONFIG")" + [[ "$known" != "no" ]] || { + err "unknown lane '$t' (not in $CONFIG)" + exit 3 + } + done + fi + + local i name model effort prompt_path + for ((i = 0; i < count; i++)); do + name="$(lane_field "$i" name)" + [[ -n "$name" ]] || { + err "config lane #$i has no name" + exit 3 + } + if ((${#TARGET_LANES[@]})); then + printf '%s\n' "${TARGET_LANES[@]}" | grep -qxF "$name" || continue + fi + model="$(lane_field "$i" model)" + effort="$(lane_field "$i" effort)" + prompt_path="$(lane_prompt_path "$(lane_field "$i" prompt)" "$pdir")" + "$callback" "$name" "$model" "$effort" "$prompt_path" + done +} + +# --- Actions ------------------------------------------------------------------ +_start_one() { + local name="$1" model="$2" effort="$3" prompt_path="$4" sid + sid="$(running_session_id "$name")" + if [[ -n "$sid" ]]; then + info " skip $name — already running ($sid)" + return 0 + fi + info " start $name${model:+ --model $model}${effort:+ --effort $effort}" + launch_lane "$name" "$model" "$effort" "$prompt_path" +} + +_restart_one() { + local name="$1" model="$2" effort="$3" prompt_path="$4" + stop_lane_if_running "$name" || true + info " start $name${model:+ --model $model}${effort:+ --effort $effort}" + launch_lane "$name" "$model" "$effort" "$prompt_path" +} + +_stop_one() { + local name="$1" + stop_lane_if_running "$name" || info " $name — not running" +} + +_status_one() { + local name="$1" model="$2" effort="$3" prompt_path="$4" sid state="stopped" + sid="$(running_session_id "$name")" + [[ -n "$sid" ]] && state="running" + local pflag="" + [[ -f "$prompt_path" ]] || pflag=" [prompt MISSING]" + printf ' %-12s %-8s %-8s %-8s %s%s\n' \ + "$name" "${model:-–}" "${effort:-–}" "$state" "${sid:-–}" "$pflag" +} + +action_start() { + info "== lanes: start ==" + refresh_repo_and_plugins + info "lanes:" + for_each_lane _start_one +} + +action_restart() { + info "== lanes: restart${TARGET_LANES:+ (${TARGET_LANES[*]})} ==" + refresh_repo_and_plugins + info "lanes:" + for_each_lane _restart_one +} + +action_stop() { + info "== lanes: stop${TARGET_LANES:+ (${TARGET_LANES[*]})} ==" + for_each_lane _stop_one +} + +action_status() { + info "== lanes: status ==" + printf ' %-12s %-8s %-8s %-8s %s\n' "LANE" "MODEL" "EFFORT" "STATE" "SESSION" + for_each_lane _status_one +} + +# --- Main --------------------------------------------------------------------- +main() { + parse_args "$@" + require_jq + require_claude + resolve_repo + resolve_config + case "$ACTION" in + start) action_start ;; + restart) action_restart ;; + status) action_status ;; + stop) action_stop ;; + *) + err "unknown action: $ACTION" + exit 3 + ;; + esac +} + +main "$@" diff --git a/plugins/claude-ops/skills/lanes/scripts/lane-launcher.test.sh b/plugins/claude-ops/skills/lanes/scripts/lane-launcher.test.sh new file mode 100644 index 000000000..6f2db6e8c --- /dev/null +++ b/plugins/claude-ops/skills/lanes/scripts/lane-launcher.test.sh @@ -0,0 +1,188 @@ +#!/usr/bin/env bash +# Regression tests for lane-launcher.sh. +# +# Coverage: +# - config resolution + validation (missing / malformed / empty-lanes) +# - status renders per-lane running/stopped state from an --agents-json fixture +# - start (dry-run): launches only lanes not already running; mirrors +# model/effort onto the command; keeps the prompt body out of the echo +# - start refresh step: pull + marketplace update lines; --no-pull / --no-update +# - restart (dry-run): stop-then-start for a running lane +# - stop (real dispatch, PATH-stub claude): stops only running configured lanes +# - stop / restart of an unknown lane name is rejected (exit 3) +# - missing / empty prompt file and invalid effort are skipped, not launched +# +# Uses a per-suite fixture repo (config + prompt files) and PATH-stub `claude` +# and `git` so no real CLI or network is touched. + +set -uo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +SCRIPT="$SCRIPT_DIR/lane-launcher.sh" +TMP="$(mktemp -d)" +trap 'rm -rf "$TMP"' EXIT + +FAILED=0 +CASE_NUM=0 +pass() { + CASE_NUM=$((CASE_NUM + 1)) + printf 'PASS: [%d] %s\n' "$CASE_NUM" "$1" +} +fail() { + CASE_NUM=$((CASE_NUM + 1)) + printf 'FAIL: [%d] %s\n expected: %q\n got: %q\n' "$CASE_NUM" "$1" "$2" "$3" >&2 + FAILED=$((FAILED + 1)) +} +assert_eq() { if [[ "$3" == "$2" ]]; then pass "$1"; else fail "$1" "$2" "$3"; fi; } +assert_contains() { if [[ "$2" == *"$3"* ]]; then pass "$1"; else fail "$1" "contains: $3" "$2"; fi; } +assert_not_contains() { if [[ "$2" != *"$3"* ]]; then pass "$1"; else fail "$1" "absent: $3" "$2"; fi; } + +# --- Fixture repo ------------------------------------------------------------- +REPO="$TMP/repo" +mkdir -p "$REPO/.work" +cat >"$REPO/.work/lanes.json" <<'JSON' +{ + "prompt_dir": ".work", + "lanes": [ + { "name": "work", "prompt": "work.md", "model": "opus", "effort": "high" }, + { "name": "babysit", "prompt": "babysit.md", "model": "sonnet", "effort": "medium" }, + { "name": "decide", "prompt": "decide.md" } + ] +} +JSON +printf 'You are the work lane.\n' >"$REPO/.work/work.md" +printf 'You are the babysit lane.\n' >"$REPO/.work/babysit.md" +printf 'You are the decide lane.\n' >"$REPO/.work/decide.md" + +CONFIG="$REPO/.work/lanes.json" + +# agents --json fixture: "work" running as a background session, others absent. +AGENTS_RUNNING="$TMP/agents-running.json" +cat >"$AGENTS_RUNNING" <<'JSON' +[ + { "pid": 111, "cwd": "/repo", "kind": "background", "startedAt": 100, + "sessionId": "sid-work-1", "name": "work", "status": "idle" }, + { "pid": 222, "cwd": "/repo", "kind": "interactive", "startedAt": 90, + "sessionId": "sid-other", "name": "PR Babysit", "status": "busy" } +] +JSON +AGENTS_EMPTY="$TMP/agents-empty.json" +echo '[]' >"$AGENTS_EMPTY" + +# --- PATH-stub claude + git (log every invocation) ---------------------------- +STUB_BIN="$TMP/bin" +mkdir -p "$STUB_BIN" +CLAUDE_LOG="$TMP/claude.log" +cat >"$STUB_BIN/claude" <>"$CLAUDE_LOG" +STUB +cat >"$STUB_BIN/git" <>"$CLAUDE_LOG" +STUB +chmod +x "$STUB_BIN/claude" "$STUB_BIN/git" + +run_launcher() { bash "$SCRIPT" "$@"; } + +# ============================================================================ +# Config resolution + validation +# ============================================================================ +out="$(run_launcher status --repo "$REPO" --config "$TMP/nope.json" --agents-json "$AGENTS_EMPTY" 2>&1)" +rc=$? +assert_eq "missing config exits 4" 4 "$rc" +assert_contains "missing config message" "$out" "lane config not found" + +echo '{ not json' >"$TMP/bad.json" +out="$(run_launcher status --repo "$REPO" --config "$TMP/bad.json" --agents-json "$AGENTS_EMPTY" 2>&1)" +rc=$? +assert_eq "malformed config exits 3" 3 "$rc" + +echo '{ "lanes": [] }' >"$TMP/empty.json" +out="$(run_launcher status --repo "$REPO" --config "$TMP/empty.json" --agents-json "$AGENTS_EMPTY" 2>&1)" +rc=$? +assert_eq "empty-lanes config exits 3" 3 "$rc" + +# ============================================================================ +# status +# ============================================================================ +out="$(run_launcher status --repo "$REPO" --config "$CONFIG" --agents-json "$AGENTS_RUNNING" 2>&1)" +assert_contains "status: work is running" "$out" "work" +assert_contains "status: work shows sessionId" "$out" "sid-work-1" +assert_contains "status: work state running" "$out" "running" +assert_contains "status: decide present" "$out" "decide" +assert_contains "status: decide stopped" "$out" "stopped" +assert_not_contains "status ignores non-lane sessions" "$out" "sid-other" + +# ============================================================================ +# start (dry-run) — launch only lanes not already running +# ============================================================================ +out="$(run_launcher start --repo "$REPO" --config "$CONFIG" --agents-json "$AGENTS_RUNNING" --dry-run 2>&1)" +assert_contains "start skips running lane" "$out" "skip work — already running" +assert_contains "start launches babysit" "$out" "claude --bg -n babysit" +assert_contains "start mirrors babysit model" "$out" "--model sonnet" +assert_contains "start mirrors babysit effort" "$out" "--effort medium" +assert_contains "start seeds prompt as placeholder" "$out" "&1)" +assert_contains "--no-pull skips pull" "$out2" "skip git pull" +assert_contains "--no-update skips update" "$out2" "skip plugin marketplace update" +assert_contains "empty agents → all lanes start" "$out2" "claude --bg -n work" + +# ============================================================================ +# restart (dry-run) — stop then start a running lane +# ============================================================================ +out="$(run_launcher restart work --repo "$REPO" --config "$CONFIG" --agents-json "$AGENTS_RUNNING" --dry-run 2>&1)" +assert_contains "restart stops running work" "$out" "stop work (sid-work-1)" +assert_contains "restart relaunches work" "$out" "claude --bg -n work" +assert_not_contains "restart scoped to work" "$out" "claude --bg -n babysit" + +# ============================================================================ +# stop (real dispatch via PATH-stub claude) +# ============================================================================ +: >"$CLAUDE_LOG" +out="$(PATH="$STUB_BIN:$PATH" run_launcher stop --repo "$REPO" --config "$CONFIG" --agents-json "$AGENTS_RUNNING" 2>&1)" +log="$(cat "$CLAUDE_LOG")" +assert_contains "stop dispatches claude stop for running lane" "$log" "stop sid-work-1" +assert_contains "stop reports non-running lanes" "$out" "babysit — not running" +assert_not_contains "stop never targets a non-lane session" "$log" "sid-other" + +# ============================================================================ +# unknown lane rejected +# ============================================================================ +out="$(run_launcher stop bogus --repo "$REPO" --config "$CONFIG" --agents-json "$AGENTS_EMPTY" 2>&1)" +rc=$? +assert_eq "stop unknown lane exits 3" 3 "$rc" +assert_contains "stop unknown lane message" "$out" "unknown lane 'bogus'" + +# ============================================================================ +# missing / empty prompt + invalid effort are skipped, not launched +# ============================================================================ +cat >"$TMP/badprompt.json" <<'JSON' +{ "prompt_dir": ".work", + "lanes": [ + { "name": "gone", "prompt": "missing.md" }, + { "name": "blank", "prompt": "blank.md" }, + { "name": "baddy", "prompt": "work.md", "effort": "turbo" } + ] } +JSON +: >"$REPO/.work/blank.md" +out="$(run_launcher start --repo "$REPO" --config "$TMP/badprompt.json" --agents-json "$AGENTS_EMPTY" --dry-run 2>&1)" +assert_contains "missing prompt file skipped" "$out" "prompt file not found" +assert_contains "empty prompt file skipped" "$out" "prompt file is empty" +assert_contains "invalid effort skipped" "$out" "invalid effort 'turbo'" +assert_not_contains "no launch for bad lanes" "$out" "claude --bg -n baddy" + +# ============================================================================ +echo +if ((FAILED)); then + printf 'lane-launcher.test: FAIL — %d case(s) failed\n' "$FAILED" >&2 + exit 1 +fi +printf 'lane-launcher.test: PASS — %d cases\n' "$CASE_NUM" From be9dc26bd29bc9eb9601101634c56bd910375300 Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Mon, 20 Jul 2026 01:26:42 -0400 Subject: [PATCH 02/11] test(claude-ops): cover the lanes launcher real-dispatch path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a PATH-stubbed (claude + git) non-dry-run `start` case asserting the launcher actually shells out: git pull, marketplace update, and `claude --bg` seeded with the prompt-file body as a single trailing argument — closing the gap left by the dry-run command-construction tests. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_011V31qpAHP3jfs76B9d5Rfo --- .../skills/lanes/scripts/lane-launcher.test.sh | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/plugins/claude-ops/skills/lanes/scripts/lane-launcher.test.sh b/plugins/claude-ops/skills/lanes/scripts/lane-launcher.test.sh index 6f2db6e8c..b363ad38b 100644 --- a/plugins/claude-ops/skills/lanes/scripts/lane-launcher.test.sh +++ b/plugins/claude-ops/skills/lanes/scripts/lane-launcher.test.sh @@ -153,6 +153,20 @@ assert_contains "stop dispatches claude stop for running lane" "$log" "stop sid- assert_contains "stop reports non-running lanes" "$out" "babysit — not running" assert_not_contains "stop never targets a non-lane session" "$log" "sid-other" +# ============================================================================ +# start (REAL dispatch via PATH-stub claude + git) — proves the launch path +# actually shells out: pull, marketplace update, and `claude --bg` seeded with +# the prompt-file BODY as a single trailing argument (not --dry-run). +# ============================================================================ +: >"$CLAUDE_LOG" +out="$(PATH="$STUB_BIN:$PATH" run_launcher start --repo "$REPO" --config "$CONFIG" --agents-json "$AGENTS_EMPTY" 2>&1)" +log="$(cat "$CLAUDE_LOG")" +assert_contains "start really pulls the repo" "$log" "git -C $REPO pull --ff-only" +assert_contains "start really updates the marketplace" "$log" "plugin marketplace update" +assert_contains "start really launches work with model+effort" "$log" "--bg -n work --model opus --effort high" +assert_contains "start seeds the prompt-file body as the trailing arg" "$log" "--effort high You are the work lane." +assert_contains "start really launches babysit" "$log" "--bg -n babysit --model sonnet --effort medium" + # ============================================================================ # unknown lane rejected # ============================================================================ From e0e1e836695b6aa904c22eb88f7e0c79be87e1ad Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Mon, 20 Jul 2026 01:34:00 -0400 Subject: [PATCH 03/11] fix(claude-ops): make lanes tests hermetic and status work offline (CI green) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The skill-quality CI lane runs on a runner with no `claude` installed. The test suite's non-dry, non-stubbed status/stop cases tripped the launcher's `require_claude` gate (exit 4), failing the gate. - Test: resolve `claude`/`git` to the logging stubs for the whole suite so it never depends on an ambient `claude` (verified passing with `claude` absent from PATH). - Script: `status --agents-json` reads a fixture and makes no `claude` call, so it no longer requires the CLI — honoring the flag's documented offline use. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_011V31qpAHP3jfs76B9d5Rfo --- .../claude-ops/skills/lanes/scripts/lane-launcher.sh | 4 ++++ .../skills/lanes/scripts/lane-launcher.test.sh | 10 ++++++++-- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/plugins/claude-ops/skills/lanes/scripts/lane-launcher.sh b/plugins/claude-ops/skills/lanes/scripts/lane-launcher.sh index 2425a9c55..ae2acc36b 100644 --- a/plugins/claude-ops/skills/lanes/scripts/lane-launcher.sh +++ b/plugins/claude-ops/skills/lanes/scripts/lane-launcher.sh @@ -155,6 +155,10 @@ require_jq() { # fixture-fed (--agents-json) reads must work with no CLI installed. require_claude() { ((DRY_RUN)) && return 0 + # A fixture-fed status read (--agents-json) makes no claude call, so it works + # fully offline as documented; only the paths that actually shell out to + # claude (launch / stop / marketplace update, or a live agents list) need it. + [[ "$ACTION" == "status" && -n "$AGENTS_JSON_FILE" ]] && return 0 command -v claude >/dev/null 2>&1 || { err "claude CLI not found (required)" exit 4 diff --git a/plugins/claude-ops/skills/lanes/scripts/lane-launcher.test.sh b/plugins/claude-ops/skills/lanes/scripts/lane-launcher.test.sh index b363ad38b..70616e93b 100644 --- a/plugins/claude-ops/skills/lanes/scripts/lane-launcher.test.sh +++ b/plugins/claude-ops/skills/lanes/scripts/lane-launcher.test.sh @@ -83,6 +83,12 @@ printf 'git %s\n' "\$*" >>"$CLAUDE_LOG" STUB chmod +x "$STUB_BIN/claude" "$STUB_BIN/git" +# Hermetic: the suite must not depend on an ambient `claude` (CI runners have +# none). Every case resolves `claude`/`git` to the logging stubs; cases that +# inspect the log reset it first. Real git is never needed — repos are passed +# via --repo, so resolve_repo never shells out. +export PATH="$STUB_BIN:$PATH" + run_launcher() { bash "$SCRIPT" "$@"; } # ============================================================================ @@ -147,7 +153,7 @@ assert_not_contains "restart scoped to work" "$out" "claude --bg -n babysit" # stop (real dispatch via PATH-stub claude) # ============================================================================ : >"$CLAUDE_LOG" -out="$(PATH="$STUB_BIN:$PATH" run_launcher stop --repo "$REPO" --config "$CONFIG" --agents-json "$AGENTS_RUNNING" 2>&1)" +out="$(run_launcher stop --repo "$REPO" --config "$CONFIG" --agents-json "$AGENTS_RUNNING" 2>&1)" log="$(cat "$CLAUDE_LOG")" assert_contains "stop dispatches claude stop for running lane" "$log" "stop sid-work-1" assert_contains "stop reports non-running lanes" "$out" "babysit — not running" @@ -159,7 +165,7 @@ assert_not_contains "stop never targets a non-lane session" "$log" "sid-other" # the prompt-file BODY as a single trailing argument (not --dry-run). # ============================================================================ : >"$CLAUDE_LOG" -out="$(PATH="$STUB_BIN:$PATH" run_launcher start --repo "$REPO" --config "$CONFIG" --agents-json "$AGENTS_EMPTY" 2>&1)" +out="$(run_launcher start --repo "$REPO" --config "$CONFIG" --agents-json "$AGENTS_EMPTY" 2>&1)" log="$(cat "$CLAUDE_LOG")" assert_contains "start really pulls the repo" "$log" "git -C $REPO pull --ff-only" assert_contains "start really updates the marketplace" "$log" "plugin marketplace update" From 01102de6030c532994cc0e7f7461e3dced249287 Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Mon, 20 Jul 2026 01:40:53 -0400 Subject: [PATCH 04/11] fix(claude-ops): sync root plugin index + mark lanes scripts executable Two CI hygiene/plugin-gate failures on the lanes skill: - Root README.md mirrors each plugin.json description verbatim (enforced by scripts/validate-plugins.sh); update the claude-ops entry to Seven skills including lanes. - The lanes scripts carry a shebang but were committed 100644; set the git index mode to 100755 (executable), matching every sibling skill script. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_011V31qpAHP3jfs76B9d5Rfo --- README.md | 2 +- plugins/claude-ops/skills/lanes/scripts/lane-launcher.sh | 0 plugins/claude-ops/skills/lanes/scripts/lane-launcher.test.sh | 0 3 files changed, 1 insertion(+), 1 deletion(-) mode change 100644 => 100755 plugins/claude-ops/skills/lanes/scripts/lane-launcher.sh mode change 100644 => 100755 plugins/claude-ops/skills/lanes/scripts/lane-launcher.test.sh diff --git a/README.md b/README.md index 2f853bec7..d5d03c86f 100644 --- a/README.md +++ b/README.md @@ -113,7 +113,7 @@ user opts in with `/plugin enable`; an existing install is never flipped by cata - [`playbooks`](plugins/playbooks) — Doctrine and knowledge playbooks as on-demand skills, plus a maintainer-facing update skill. boris — Boris Cherny's Claude Code workflow tips (howborisusesclaudecode.com); skill-authoring — Anthropic's internal skill-authoring playbook; fable-5 — Claude Fable 5's operating doctrine (self-authored, no upstream). The boris and skill-authoring packs vendor a verbatim upstream baseline; /playbooks:update drift-checks and syncs those baselines centrally (maintainers). - [`claude-config`](plugins/claude-config) — Three audit skills for a repo's Claude Code configuration: audit (settings.json / .mcp.json / hooks / plugins / permissions drift), audit-automation-gaps (evidence-gated verdicts on automation gaps), and audit-permission-grants (allow-rule / allowed-tools grants for auto-mode durability and portability). - [`claude-memory`](plugins/claude-memory) — Audits the Claude Code instruction/memory layer — CLAUDE.md, CLAUDE.local.md, .claude/rules/, and auto-memory — against a checklist derived from official Claude Code documentation. A deterministic script-backed spine (MEMORY.md index integrity, orphan always-loaded rules) yields identical findings on identical repo state; judgment-tier checks apply fixed criteria with model reading. Actions: audit (default), fix (per-item approval), update (refresh criteria from current docs), report. -- [`claude-ops`](plugins/claude-ops) — Claude Code operations toolkit. Six skills: observability (read locally captured telemetry — OTEL store, collector, hook-event JSONL, ccusage — with trend reports and store pruning), known-issues (search known Claude product GitHub bugs, check service health, maintain a persistent tracked-issue registry), changelog (ingest Claude Code changelog entries and integrate them into the current repo), plugins (bring a machine's plugin fleet current on demand — marketplace refresh, effective-scope updates including in-repo project/local installs, new-plugin install per policy, scope-divergence detection and explicit convergence), morning-brief (read-only gh-based operator morning view — queue-label counts, merge-ready PRs, parked decisions with their RECOMMENDED lines, and loop-lane telemetry freshness), and a re-runnable setup action that settles where the known-issues registry lives. Plus a family of seven advisory *-audit telemetry-emitter hooks (API errors, config changes, instruction loads, permission denials, pre-compaction, skill usage, tool failures) that emit the shared hook-telemetry envelope, and a reference sink that maps envelopes into the hook-events.jsonl the observability skill reads. +- [`claude-ops`](plugins/claude-ops) — Claude Code operations toolkit. Seven skills: observability (read locally captured telemetry — OTEL store, collector, hook-event JSONL, ccusage — with trend reports and store pruning), known-issues (search known Claude product GitHub bugs, check service health, maintain a persistent tracked-issue registry), changelog (ingest Claude Code changelog entries and integrate them into the current repo), plugins (bring a machine's plugin fleet current on demand — marketplace refresh, effective-scope updates including in-repo project/local installs, new-plugin install per policy, scope-divergence detection and explicit convergence), morning-brief (read-only gh-based operator morning view — queue-label counts, merge-ready PRs, parked decisions with their RECOMMENDED lines, and loop-lane telemetry freshness), lanes (start/restart/stop/status loop lanes as named background Claude Code sessions seeded from canonical prompt files, with per-lane model/effort and a repo-pull + marketplace-refresh launch step), and a re-runnable setup action that settles where the known-issues registry lives. Plus a family of seven advisory *-audit telemetry-emitter hooks (API errors, config changes, instruction loads, permission denials, pre-compaction, skill usage, tool failures) that emit the shared hook-telemetry envelope, and a reference sink that maps envelopes into the hook-events.jsonl the observability skill reads. - [`skill-quality`](plugins/skill-quality) — Skill-authoring QA tooling: a static contract checker that runs seventeen deterministic checks over a Claude Code skill (frontmatter, listing-budget cap, trigger-keyword preservation, line caps, broken internal refs, markdownlint, gotchas surface, evals presence) and a bundled evals.json schema for validation. Runs against any repo's skills directory via the convention-resolution ladder — no baked layout. ### Autonomy diff --git a/plugins/claude-ops/skills/lanes/scripts/lane-launcher.sh b/plugins/claude-ops/skills/lanes/scripts/lane-launcher.sh old mode 100644 new mode 100755 diff --git a/plugins/claude-ops/skills/lanes/scripts/lane-launcher.test.sh b/plugins/claude-ops/skills/lanes/scripts/lane-launcher.test.sh old mode 100644 new mode 100755 From f57d560422f54bc2afd4f9106a924f6eabad94fb Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Mon, 20 Jul 2026 01:45:33 -0400 Subject: [PATCH 05/11] fix(claude-ops): drop issue-number refs from lanes script comments The comment-hygiene gate forbids tracker (issue-number) references in code comments (tracker-ref:issue-reference). Reword the prompt-storage seam comments to describe the forthcoming loop-prompt authoring skill without the issue number; the tracked #480 dependency stays cited in the skill's SKILL.md and context/config.md (markdown is not scanned by that gate). Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_011V31qpAHP3jfs76B9d5Rfo --- plugins/claude-ops/skills/lanes/scripts/lane-launcher.sh | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/plugins/claude-ops/skills/lanes/scripts/lane-launcher.sh b/plugins/claude-ops/skills/lanes/scripts/lane-launcher.sh index ae2acc36b..9ca1ebcc8 100755 --- a/plugins/claude-ops/skills/lanes/scripts/lane-launcher.sh +++ b/plugins/claude-ops/skills/lanes/scripts/lane-launcher.sh @@ -22,8 +22,8 @@ # so the wrapper can never stop an unrelated session (e.g. a hand-started one). # # PROMPT-FILE STORAGE IS PROVISIONAL. Today prompts live in a session-local -# `.work` dir (the config's `prompt_dir`, default `.work`). Issue #480 -# (loop-prompt authoring skill) is slated to own durable prompt storage; when it +# `.work` dir (the config's `prompt_dir`, default `.work`). A forthcoming +# loop-prompt authoring skill is slated to own durable prompt storage; when it # lands, repoint `prompt_dir` at that home — the resolution seam is the single # `resolve_prompt_dir` function below and nothing else. # @@ -202,7 +202,7 @@ resolve_config() { } } -# The one prompt-storage seam. #480 will repoint this at a durable home. +# The one prompt-storage seam — repoint here when a durable prompt home exists. resolve_prompt_dir() { local d d="$(jq -r '.prompt_dir // ".work"' "$CONFIG")" From f889c94d221b5750588739402e805cb644cd3d43 Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Mon, 20 Jul 2026 01:57:06 -0400 Subject: [PATCH 06/11] =?UTF-8?q?fix(claude-ops):=20address=20lanes=20revi?= =?UTF-8?q?ew=20=E2=80=94=20flag=20squashing,=20sort=20field,=20failure=20?= =?UTF-8?q?propagation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolve three MEDIUM review findings plus the low-priority carry-forwards: - M1: option handlers (--config/--repo/--agents-json) now reject consuming a token that looks like another flag (check_optarg), so `--config --dry-run` no longer silently swallows --dry-run as the config path. - M2: verified `startedAt` is genuinely present in real `claude agents --json` (all sessions) and documented it in the verified-surface comment; the most-recent-wins sort_by is correct, not a silent no-op. - M3: per-lane callback failures now accumulate in for_each_lane and propagate through action_start/action_restart to a non-zero script exit, so a lane that fails to (re)launch is visible in $? — the sweep still completes all lanes. Low-priority (all fixed): stop_lane_if_running propagates `claude stop`'s exit code (a failed stop refuses to relaunch, avoiding a duplicate-named session); replaced the `${TARGET_LANES:+…}` element-0 idiom with a `${#TARGET_LANES[@]}` length check (lane_scope); validated the --agents-json fixture once in main — its old lazy guard sat on the left of a pipe (subshell) and could never exit the script; added tests for flag-squash rejection, partial-failure exit codes, stop-failure paths, missing --agents-json file, and `--` end-of-options. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_011V31qpAHP3jfs76B9d5Rfo --- .../skills/lanes/scripts/lane-launcher.sh | 95 ++++++++++++++----- .../lanes/scripts/lane-launcher.test.sh | 55 +++++++++++ 2 files changed, 125 insertions(+), 25 deletions(-) diff --git a/plugins/claude-ops/skills/lanes/scripts/lane-launcher.sh b/plugins/claude-ops/skills/lanes/scripts/lane-launcher.sh index 9ca1ebcc8..127bc1739 100755 --- a/plugins/claude-ops/skills/lanes/scripts/lane-launcher.sh +++ b/plugins/claude-ops/skills/lanes/scripts/lane-launcher.sh @@ -13,6 +13,7 @@ # claude --bg -n [--model M] [--effort E] "" launch, return now # claude agents --json list sessions # (pid, cwd, kind, +# startedAt, # sessionId, name, # status) # claude stop stop one session @@ -79,6 +80,17 @@ declare -a TARGET_LANES=() err() { printf 'ERROR: %s\n' "$*" >&2; } info() { printf '%s\n' "$*"; } +# Guard for a space-separated option that consumes the next token: reject a +# missing value or one that looks like another flag, so `--config --dry-run` +# fails loudly instead of silently swallowing `--dry-run` as the config path. +# Usage: `check_optarg "$1" "${2:-}" || exit 3` (kept out of a subshell so the +# caller's exit actually fires). +check_optarg() { + [[ -n "${2:-}" && "$2" != -* ]] && return 0 + err "option '$1' requires a non-option argument" + return 1 +} + # Print the leading comment header (everything after the shebang up to the first # non-comment line), stripped of the leading '# '. Robust to header length so a # reformat never bleeds code into --help. @@ -98,12 +110,14 @@ parse_args() { fi ;; --config) - CONFIG="${2:-}" + check_optarg "$1" "${2:-}" || exit 3 + CONFIG="$2" shift ;; --config=*) CONFIG="${1#*=}" ;; --repo) - REPO="${2:-}" + check_optarg "$1" "${2:-}" || exit 3 + REPO="$2" shift ;; --repo=*) REPO="${1#*=}" ;; @@ -111,7 +125,8 @@ parse_args() { --no-update) NO_UPDATE=1 ;; --dry-run) DRY_RUN=1 ;; --agents-json) - AGENTS_JSON_FILE="${2:-}" + check_optarg "$1" "${2:-}" || exit 3 + AGENTS_JSON_FILE="$2" shift ;; --agents-json=*) AGENTS_JSON_FILE="${1#*=}" ;; @@ -213,12 +228,11 @@ resolve_prompt_dir() { } # --- Session list (real CLI or fixture) -------------------------------------- +# Existence of the --agents-json fixture is validated once in main (main shell), +# not here: sessions_json runs on the left of a `|`, i.e. a subshell, where an +# `exit` would only kill the subshell and leave the script's status at 0. sessions_json() { if [[ -n "$AGENTS_JSON_FILE" ]]; then - [[ -f "$AGENTS_JSON_FILE" ]] || { - err "agents-json file not found: $AGENTS_JSON_FILE" - exit 4 - } cat "$AGENTS_JSON_FILE" else claude agents --json 2>/dev/null || echo '[]' @@ -294,31 +308,32 @@ launch_lane() { (cd "$REPO" && "${cmd[@]}") } +# Returns: 0 stopped OK · 2 was not running · other = `claude stop` failed +# (its exit code). Callers must distinguish 2 (benign) from a real stop failure. stop_lane_if_running() { local name="$1" sid sid="$(running_session_id "$name")" - if [[ -n "$sid" ]]; then - info " stop $name ($sid)" - run claude stop "$sid" - return 0 - fi - return 1 + [[ -n "$sid" ]] || return 2 + info " stop $name ($sid)" + run claude stop "$sid" } # --- Refresh step (pull + marketplace update) -------------------------------- refresh_repo_and_plugins() { + local rc=0 if ((NO_PULL)); then info "skip git pull (--no-pull)" else info "git pull --ff-only ($REPO)" - run git -C "$REPO" pull --ff-only + run git -C "$REPO" pull --ff-only || rc=1 fi if ((NO_UPDATE)); then info "skip plugin marketplace update (--no-update)" else info "claude plugin marketplace update" - run claude plugin marketplace update + run claude plugin marketplace update || rc=1 fi + return "$rc" } # --- Lane iteration helper ---------------------------------------------------- @@ -342,7 +357,7 @@ for_each_lane() { done fi - local i name model effort prompt_path + local i name model effort prompt_path failures=0 for ((i = 0; i < count; i++)); do name="$(lane_field "$i" name)" [[ -n "$name" ]] || { @@ -355,8 +370,11 @@ for_each_lane() { model="$(lane_field "$i" model)" effort="$(lane_field "$i" effort)" prompt_path="$(lane_prompt_path "$(lane_field "$i" prompt)" "$pdir")" - "$callback" "$name" "$model" "$effort" "$prompt_path" + # A per-lane callback failure must not abort the sweep (other lanes still + # get their turn) but must surface in the aggregate exit status. + "$callback" "$name" "$model" "$effort" "$prompt_path" || failures=1 done + return "$failures" } # --- Actions ------------------------------------------------------------------ @@ -373,14 +391,30 @@ _start_one() { _restart_one() { local name="$1" model="$2" effort="$3" prompt_path="$4" - stop_lane_if_running "$name" || true + stop_lane_if_running "$name" + local s=$? + # A genuine stop failure (not the benign "was not running", 2) means the old + # session may still be alive — relaunching would create a second session under + # the same name, so refuse and surface the failure. + if [[ $s -ne 0 && $s -ne 2 ]]; then + err " $name — stop failed; not relaunching (would duplicate the session name)" + return 1 + fi info " start $name${model:+ --model $model}${effort:+ --effort $effort}" launch_lane "$name" "$model" "$effort" "$prompt_path" } _stop_one() { local name="$1" - stop_lane_if_running "$name" || info " $name — not running" + stop_lane_if_running "$name" + case $? in + 0) : ;; + 2) info " $name — not running" ;; + *) + err " $name — stop failed" + return 1 + ;; + esac } _status_one() { @@ -393,22 +427,29 @@ _status_one() { "$name" "${model:-–}" "${effort:-–}" "$state" "${sid:-–}" "$pflag" } +# scope suffix for the header line, e.g. " (work babysit)" when lanes are named. +lane_scope() { ((${#TARGET_LANES[@]})) && printf ' (%s)' "${TARGET_LANES[*]}"; } + action_start() { + local rc=0 info "== lanes: start ==" - refresh_repo_and_plugins + refresh_repo_and_plugins || rc=1 info "lanes:" - for_each_lane _start_one + for_each_lane _start_one || rc=1 + return "$rc" } action_restart() { - info "== lanes: restart${TARGET_LANES:+ (${TARGET_LANES[*]})} ==" - refresh_repo_and_plugins + local rc=0 + info "== lanes: restart$(lane_scope) ==" + refresh_repo_and_plugins || rc=1 info "lanes:" - for_each_lane _restart_one + for_each_lane _restart_one || rc=1 + return "$rc" } action_stop() { - info "== lanes: stop${TARGET_LANES:+ (${TARGET_LANES[*]})} ==" + info "== lanes: stop$(lane_scope) ==" for_each_lane _stop_one } @@ -425,6 +466,10 @@ main() { require_claude resolve_repo resolve_config + [[ -z "$AGENTS_JSON_FILE" || -f "$AGENTS_JSON_FILE" ]] || { + err "agents-json file not found: $AGENTS_JSON_FILE" + exit 4 + } case "$ACTION" in start) action_start ;; restart) action_restart ;; diff --git a/plugins/claude-ops/skills/lanes/scripts/lane-launcher.test.sh b/plugins/claude-ops/skills/lanes/scripts/lane-launcher.test.sh index 70616e93b..0ec6f8749 100755 --- a/plugins/claude-ops/skills/lanes/scripts/lane-launcher.test.sh +++ b/plugins/claude-ops/skills/lanes/scripts/lane-launcher.test.sh @@ -76,6 +76,9 @@ CLAUDE_LOG="$TMP/claude.log" cat >"$STUB_BIN/claude" <>"$CLAUDE_LOG" +# A test can force a failed \`claude stop\` via STUB_CLAUDE_STOP_RC to exercise +# the stop-failure paths (no relaunch, non-zero exit). +if [[ "\$1" == "stop" ]]; then exit "\${STUB_CLAUDE_STOP_RC:-0}"; fi STUB cat >"$STUB_BIN/git" <&1)" +rc=$? +assert_eq "--config --dry-run rejected (exit 3)" 3 "$rc" +assert_contains "flag-squash message names the option" "$out" "option '--config' requires a non-option argument" +out="$(run_launcher status --repo --agents-json "$AGENTS_EMPTY" 2>&1)" +rc=$? +assert_eq "--repo followed by a flag rejected (exit 3)" 3 "$rc" +out="$(run_launcher status --agents-json --config "$CONFIG" --repo "$REPO" 2>&1)" +rc=$? +assert_eq "--agents-json followed by a flag rejected (exit 3)" 3 "$rc" + +# ============================================================================ +# Medium 3 — partial failure surfaces in the exit status (sweep still completes) +# ============================================================================ +out="$(run_launcher start --repo "$REPO" --config "$TMP/badprompt.json" --agents-json "$AGENTS_EMPTY" 2>&1)" +rc=$? +assert_eq "partial launch failure exits non-zero" 1 "$rc" +out="$(run_launcher start --repo "$REPO" --config "$CONFIG" --agents-json "$AGENTS_EMPTY" 2>&1)" +rc=$? +assert_eq "all-lanes-ok start exits 0" 0 "$rc" + +# ============================================================================ +# Medium 3 / stop exit code — a failed `claude stop` must not relaunch, exits non-zero +# ============================================================================ +: >"$CLAUDE_LOG" +out="$(STUB_CLAUDE_STOP_RC=1 run_launcher restart work --repo "$REPO" --config "$CONFIG" --agents-json "$AGENTS_RUNNING" 2>&1)" +rc=$? +log="$(cat "$CLAUDE_LOG")" +assert_eq "restart with failed stop exits non-zero" 1 "$rc" +assert_contains "restart with failed stop refuses relaunch" "$out" "not relaunching" +assert_not_contains "no relaunch after failed stop" "$log" "--bg -n work" +out="$(STUB_CLAUDE_STOP_RC=1 run_launcher stop work --repo "$REPO" --config "$CONFIG" --agents-json "$AGENTS_RUNNING" 2>&1)" +rc=$? +assert_eq "stop with failed claude stop exits non-zero" 1 "$rc" +assert_contains "stop failure reported" "$out" "work — stop failed" + +# ============================================================================ +# Low-priority carry-forwards — uncovered flag paths +# ============================================================================ +out="$(run_launcher status --repo "$REPO" --config "$CONFIG" --agents-json "$TMP/no-such-agents.json" 2>&1)" +rc=$? +assert_eq "--agents-json missing file exits 4" 4 "$rc" +assert_contains "--agents-json missing file message" "$out" "agents-json file not found" +# `--` ends option parsing: tokens after it are lane names (options must precede it). +out="$(run_launcher stop --repo "$REPO" --config "$CONFIG" --agents-json "$AGENTS_EMPTY" -- babysit 2>&1)" +rc=$? +assert_eq "-- passthrough: known lane after -- is accepted (exit 0)" 0 "$rc" +assert_contains "-- passthrough targets the named lane" "$out" "babysit — not running" + # ============================================================================ echo if ((FAILED)); then From 3240071f755e2563ee7fd54d6eb82917c1c806dc Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Mon, 20 Jul 2026 02:23:53 -0400 Subject: [PATCH 07/11] fix(claude-ops): abort lane-launcher on a failed live session list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `claude agents --json` failures were coerced to `[]` via `|| echo '[]'`, so every configured lane looked stopped: a real `start` would relaunch still-live lanes (duplicate sessions) and `status` would report false "stopped" state. Load the session list once in the main shell (not lazily inside a `$(...)`/pipe subshell, where an `exit` cannot set the script's status) and abort with exit 4 on a genuine live-list failure; `--dry-run` still tolerates it (mutates nothing) to preserve offline-dry-run behaviour. Also documents the load-bearing invariant that `claude agents --json` (no `--all`) already lists active sessions only, so the name-match lookup never sees terminal sessions — do not add `--all` without filtering them. Addresses the Codex P1 "Propagate failures when listing background sessions". The companion P1 "Exclude terminal sessions" is a non-issue on the target CLI (2.1.215): default `--json` excludes completed/terminal background sessions (they require `--all` and carry `state`, not `status`). Co-Authored-By: Claude Sonnet 5 --- .../skills/lanes/scripts/lane-launcher.sh | 51 ++++++++++++++++--- .../lanes/scripts/lane-launcher.test.sh | 31 +++++++++++ 2 files changed, 74 insertions(+), 8 deletions(-) diff --git a/plugins/claude-ops/skills/lanes/scripts/lane-launcher.sh b/plugins/claude-ops/skills/lanes/scripts/lane-launcher.sh index 127bc1739..ce636910e 100755 --- a/plugins/claude-ops/skills/lanes/scripts/lane-launcher.sh +++ b/plugins/claude-ops/skills/lanes/scripts/lane-launcher.sh @@ -228,23 +228,50 @@ resolve_prompt_dir() { } # --- Session list (real CLI or fixture) -------------------------------------- -# Existence of the --agents-json fixture is validated once in main (main shell), -# not here: sessions_json runs on the left of a `|`, i.e. a subshell, where an -# `exit` would only kill the subshell and leave the script's status at 0. -sessions_json() { +# Loaded once, in the main shell, into SESSIONS_JSON — not lazily inside a +# `$(...)`/pipe subshell where an `exit` would only kill the subshell and leave +# the script's status at 0 (the same reason the --agents-json existence check +# lives in main). Loading once also gives every lane a single, consistent +# snapshot instead of re-shelling `claude agents --json` per lane. +# +# A genuine live-list failure must NOT be coerced to an empty list: doing so +# makes every lane look stopped, so a real `start` would relaunch lanes that are +# still alive (duplicate sessions) and `status` would report false "stopped". +# load_sessions therefore fails on a live error and main aborts — except under +# --dry-run, which mutates nothing, where previewing against an empty list is +# harmless and preserves the documented offline-dry-run behaviour. +# +# `claude agents --json` (no --all) lists ACTIVE sessions only — interactive and +# running background; completed/terminal background sessions are excluded by the +# CLI (they surface only under --all, carrying a `state` like "done" rather than +# an active `status`). A name match here is therefore already a live-lane match. +# Do NOT add --all without also filtering terminal sessions out of this lookup. +SESSIONS_JSON="" + +load_sessions() { + local raw if [[ -n "$AGENTS_JSON_FILE" ]]; then - cat "$AGENTS_JSON_FILE" + raw="$(cat "$AGENTS_JSON_FILE")" || return 1 else - claude agents --json 2>/dev/null || echo '[]' + raw="$(claude agents --json)" || { + ((DRY_RUN)) && { + SESSIONS_JSON='[]' + return 0 + } + return 1 + } fi + jq -e 'type == "array"' >/dev/null 2>&1 <<<"$raw" || return 1 + SESSIONS_JSON="$raw" } # sessionId of a running session with the given name (empty if none). If several # match, the most recently started wins. running_session_id() { local name="$1" - sessions_json | jq -r --arg n "$name" \ - '[ .[] | select(.name == $n) ] | sort_by(.startedAt) | last | .sessionId // empty' + jq -r --arg n "$name" \ + '[ .[] | select(.name == $n) ] | sort_by(.startedAt) | last | .sessionId // empty' \ + <<<"$SESSIONS_JSON" } # --- Per-lane field extraction ------------------------------------------------ @@ -470,6 +497,14 @@ main() { err "agents-json file not found: $AGENTS_JSON_FILE" exit 4 } + load_sessions || { + if [[ -n "$AGENTS_JSON_FILE" ]]; then + err "agents-json file is not a JSON array: $AGENTS_JSON_FILE" + else + err "could not list sessions: 'claude agents --json' failed — aborting so start/stop never act on fabricated state" + fi + exit 4 + } case "$ACTION" in start) action_start ;; restart) action_restart ;; diff --git a/plugins/claude-ops/skills/lanes/scripts/lane-launcher.test.sh b/plugins/claude-ops/skills/lanes/scripts/lane-launcher.test.sh index 0ec6f8749..490ed9563 100755 --- a/plugins/claude-ops/skills/lanes/scripts/lane-launcher.test.sh +++ b/plugins/claude-ops/skills/lanes/scripts/lane-launcher.test.sh @@ -76,6 +76,14 @@ CLAUDE_LOG="$TMP/claude.log" cat >"$STUB_BIN/claude" <>"$CLAUDE_LOG" +# A test can drive the live \`claude agents --json\` path (no --agents-json): +# STUB_CLAUDE_AGENTS_RC forces a non-zero exit (transient-failure simulation); +# STUB_CLAUDE_AGENTS_JSON supplies the emitted array (default []). +if [[ "\$1" == "agents" ]]; then + [[ "\${STUB_CLAUDE_AGENTS_RC:-0}" != 0 ]] && exit "\$STUB_CLAUDE_AGENTS_RC" + printf '%s\n' "\${STUB_CLAUDE_AGENTS_JSON:-[]}" + exit 0 +fi # A test can force a failed \`claude stop\` via STUB_CLAUDE_STOP_RC to exercise # the stop-failure paths (no relaunch, non-zero exit). if [[ "\$1" == "stop" ]]; then exit "\${STUB_CLAUDE_STOP_RC:-0}"; fi @@ -254,6 +262,29 @@ rc=$? assert_eq "-- passthrough: known lane after -- is accepted (exit 0)" 0 "$rc" assert_contains "-- passthrough targets the named lane" "$out" "babysit — not running" +# ============================================================================ +# Codex P1 — a failed live `claude agents --json` must abort a mutating action, +# never fabricate an empty list (which would relaunch still-live lanes). These +# omit --agents-json so the live listing path is exercised via the stub. +# ============================================================================ +: >"$CLAUDE_LOG" +out="$(STUB_CLAUDE_AGENTS_RC=1 run_launcher start --repo "$REPO" --config "$CONFIG" 2>&1)" +rc=$? +log="$(cat "$CLAUDE_LOG")" +assert_eq "failed live session list aborts start (exit 4)" 4 "$rc" +assert_contains "failed session list is reported" "$out" "could not list sessions" +assert_not_contains "no lane is launched when the session list failed" "$log" "--bg -n" + +# A successful live list drives the same decisions as a fixture: empty → launch. +out="$(STUB_CLAUDE_AGENTS_JSON='[]' run_launcher start --repo "$REPO" --config "$CONFIG" --dry-run 2>&1)" +assert_contains "live empty session list → lanes launch" "$out" "claude --bg -n work" + +# --dry-run mutates nothing, so a failed live list is tolerated (preview, exit 0) +# — preserving the documented offline-dry-run behaviour. +out="$(STUB_CLAUDE_AGENTS_RC=1 run_launcher start --repo "$REPO" --config "$CONFIG" --dry-run 2>&1)" +rc=$? +assert_eq "dry-run tolerates a failed live list (exit 0)" 0 "$rc" + # ============================================================================ echo if ((FAILED)); then From 6449e3250699a9132a17bc5c2a7b03c59a1b697e Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Mon, 20 Jul 2026 02:27:39 -0400 Subject: [PATCH 08/11] docs(claude-ops): add startedAt to the lanes Verified CLI Surface The M2 fix added `startedAt` to the script header's field list but not to SKILL.md's Verified CLI Surface, which still omitted it. Align the doc and note that `claude agents --json` lists active sessions. Co-Authored-By: Claude Sonnet 5 --- plugins/claude-ops/skills/lanes/SKILL.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/plugins/claude-ops/skills/lanes/SKILL.md b/plugins/claude-ops/skills/lanes/SKILL.md index 19a1d8ab7..72200f094 100644 --- a/plugins/claude-ops/skills/lanes/SKILL.md +++ b/plugins/claude-ops/skills/lanes/SKILL.md @@ -77,7 +77,8 @@ authoring skill) is slated to own durable prompt storage. When it lands, repoint The launcher shells out only to primitives confirmed on this machine's `claude` (`--help` / real invocation): `claude --bg -n [--model M] [--effort E] ""` (launch a named background session, return immediately), -`claude agents --json` (list sessions: pid, cwd, kind, sessionId, name, status), +`claude agents --json` (list active sessions: pid, cwd, kind, startedAt, +sessionId, name, status), `claude stop ` (stop one session; conversation kept, resumable with `claude attach`), and `claude plugin marketplace update`. There is no `claude agents stop` verb — stop resolves the sessionId from `agents --json` and From fd628190aac43fd114fab8835b30ca3101d2ca3f Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Mon, 20 Jul 2026 02:32:07 -0400 Subject: [PATCH 09/11] fix(claude-ops): reject duplicate lane names in lanes config MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lane names are the safety key — stop/restart resolve a sessionId by name against a single per-action snapshot, so a duplicated name would launch two same-named background sessions while stop reaches only the most-recent one, contradicting the documented distinct-names requirement. Validate uniqueness in resolve_config and fail closed (exit 3) instead of acting on an ambiguous lane set. Addresses the Codex P2 "Reject duplicate lane names during config validation". Co-Authored-By: Claude Sonnet 5 --- .../claude-ops/skills/lanes/scripts/lane-launcher.sh | 10 ++++++++++ .../skills/lanes/scripts/lane-launcher.test.sh | 11 +++++++++++ 2 files changed, 21 insertions(+) diff --git a/plugins/claude-ops/skills/lanes/scripts/lane-launcher.sh b/plugins/claude-ops/skills/lanes/scripts/lane-launcher.sh index ce636910e..8900b971a 100755 --- a/plugins/claude-ops/skills/lanes/scripts/lane-launcher.sh +++ b/plugins/claude-ops/skills/lanes/scripts/lane-launcher.sh @@ -215,6 +215,16 @@ resolve_config() { err "lane config has no lanes: $CONFIG" exit 3 } + # Lane names are the safety key: stop/restart resolve a sessionId by name and + # every action snapshots sessions once, so a duplicated name would launch two + # same-named sessions while stop reaches only the most-recent one. Reject it at + # config time rather than silently acting on an ambiguous set. + local dupes + dupes="$(jq -r '[.lanes[].name | select(. != null)] | group_by(.) | map(select(length > 1) | .[0]) | join(", ")' "$CONFIG")" + [[ -z "$dupes" ]] || { + err "lane config has duplicate lane names: $dupes (names must be unique): $CONFIG" + exit 3 + } } # The one prompt-storage seam — repoint here when a durable prompt home exists. diff --git a/plugins/claude-ops/skills/lanes/scripts/lane-launcher.test.sh b/plugins/claude-ops/skills/lanes/scripts/lane-launcher.test.sh index 490ed9563..ce5044abb 100755 --- a/plugins/claude-ops/skills/lanes/scripts/lane-launcher.test.sh +++ b/plugins/claude-ops/skills/lanes/scripts/lane-launcher.test.sh @@ -120,6 +120,17 @@ out="$(run_launcher status --repo "$REPO" --config "$TMP/empty.json" --agents-js rc=$? assert_eq "empty-lanes config exits 3" 3 "$rc" +cat >"$TMP/dupe.json" <<'JSON' +{ "lanes": [ + { "name": "work", "prompt": "work.md" }, + { "name": "work", "prompt": "babysit.md" } +] } +JSON +out="$(run_launcher start --repo "$REPO" --config "$TMP/dupe.json" --agents-json "$AGENTS_EMPTY" --dry-run 2>&1)" +rc=$? +assert_eq "duplicate lane names exit 3" 3 "$rc" +assert_contains "duplicate lane names named in message" "$out" "duplicate lane names: work" + # ============================================================================ # status # ============================================================================ From 68d53b38c15bc32c788ef7236e9d743977c1b081 Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Mon, 20 Jul 2026 02:39:41 -0400 Subject: [PATCH 10/11] fix(claude-ops): match lanes only to background sessions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit running_session_id matched by name alone, so an interactive window sharing a lane name (e.g. a hand-started `work`) was treated as the lane: `stop` would hand that interactive session to `claude stop`, `start` would skip launching the real lane, and `status` would report it running. That contradicts the skill's stated guarantee that an interactive `work` window is never stopped (and the two SKILL.md notes disagreed with each other). Restrict the lookup to `kind == "background"`. Lanes are always launched with `--bg`, so a real lane is by construction a background session — this can only exclude non-lane sessions, never a live lane, so it cannot cause a duplicate launch. Reconcile the SKILL.md "Name is the identity" note accordingly. Addresses the Codex P1 "Restrict lane matches to background sessions". Co-Authored-By: Claude Sonnet 5 --- plugins/claude-ops/skills/lanes/SKILL.md | 9 ++++--- .../skills/lanes/scripts/lane-launcher.sh | 13 ++++++--- .../lanes/scripts/lane-launcher.test.sh | 27 +++++++++++++++++++ 3 files changed, 42 insertions(+), 7 deletions(-) diff --git a/plugins/claude-ops/skills/lanes/SKILL.md b/plugins/claude-ops/skills/lanes/SKILL.md index 72200f094..1d74defab 100644 --- a/plugins/claude-ops/skills/lanes/SKILL.md +++ b/plugins/claude-ops/skills/lanes/SKILL.md @@ -89,10 +89,11 @@ only for a configured lane name. - **No durable prompt home yet.** `.work` is session-local; a fresh machine/session has no prompts until they are authored there (or `prompt_dir` is pointed at a committed dir). This is the #480 dependency, not a bug. -- **Name is the identity.** Lanes are matched by session `name`. Two lanes must not - share a name, and a lane name collision with a hand-started session means `status` - reports it running and `stop` would stop it — keep lane names distinct from ad-hoc - session names. +- **Name is the identity.** Lanes are matched by session `name` **and** `kind: + background` — every lane is launched with `--bg`, so an interactive window sharing + a lane name is never matched or stopped. Two lanes must not share a name; a + hand-started *background* session sharing a lane name would still be treated as + that lane, so keep lane names distinct from ad-hoc background session names. - **`start` is idempotent-ish, `restart` is not.** `start` skips a lane already running; `restart` always stops-and-relaunches (discarding the running lane's in-flight conversation). Use `start` for "bring up whatever is down". diff --git a/plugins/claude-ops/skills/lanes/scripts/lane-launcher.sh b/plugins/claude-ops/skills/lanes/scripts/lane-launcher.sh index 8900b971a..ae20ec17d 100755 --- a/plugins/claude-ops/skills/lanes/scripts/lane-launcher.sh +++ b/plugins/claude-ops/skills/lanes/scripts/lane-launcher.sh @@ -275,12 +275,19 @@ load_sessions() { SESSIONS_JSON="$raw" } -# sessionId of a running session with the given name (empty if none). If several -# match, the most recently started wins. +# sessionId of a running lane session with the given name (empty if none). If +# several match, the most recently started wins. +# +# Restricted to `kind == "background"`: lanes are always launched with `--bg`, so +# a lane is by construction a background session. An interactive window that +# happens to share a lane name (e.g. a hand-started `work`) is therefore never +# matched — never skipped by `start`, never handed to `claude stop`. Since a real +# lane is always background, this can only exclude non-lane sessions, never a live +# lane, so it cannot cause a duplicate launch. running_session_id() { local name="$1" jq -r --arg n "$name" \ - '[ .[] | select(.name == $n) ] | sort_by(.startedAt) | last | .sessionId // empty' \ + '[ .[] | select(.name == $n and .kind == "background") ] | sort_by(.startedAt) | last | .sessionId // empty' \ <<<"$SESSIONS_JSON" } diff --git a/plugins/claude-ops/skills/lanes/scripts/lane-launcher.test.sh b/plugins/claude-ops/skills/lanes/scripts/lane-launcher.test.sh index ce5044abb..724fa60c6 100755 --- a/plugins/claude-ops/skills/lanes/scripts/lane-launcher.test.sh +++ b/plugins/claude-ops/skills/lanes/scripts/lane-launcher.test.sh @@ -69,6 +69,16 @@ JSON AGENTS_EMPTY="$TMP/agents-empty.json" echo '[]' >"$AGENTS_EMPTY" +# An INTERACTIVE session that happens to share a lane name ("work") must never be +# treated as the lane — it is not a `--bg` lane session. +AGENTS_INTERACTIVE_WORK="$TMP/agents-interactive-work.json" +cat >"$AGENTS_INTERACTIVE_WORK" <<'JSON' +[ + { "pid": 333, "cwd": "/repo", "kind": "interactive", "startedAt": 100, + "sessionId": "sid-int-work", "name": "work", "status": "busy" } +] +JSON + # --- PATH-stub claude + git (log every invocation) ---------------------------- STUB_BIN="$TMP/bin" mkdir -p "$STUB_BIN" @@ -273,6 +283,23 @@ rc=$? assert_eq "-- passthrough: known lane after -- is accepted (exit 0)" 0 "$rc" assert_contains "-- passthrough targets the named lane" "$out" "babysit — not running" +# ============================================================================ +# Codex P1 — an interactive session sharing a lane name is not the lane +# (kind must be background). `start` launches the lane, `status` shows stopped, +# `stop` never targets the interactive session. +# ============================================================================ +out="$(run_launcher status --repo "$REPO" --config "$CONFIG" --agents-json "$AGENTS_INTERACTIVE_WORK" 2>&1)" +assert_contains "interactive same-name is not a running lane" "$out" "stopped" +assert_not_contains "interactive same-name sessionId not shown" "$out" "sid-int-work" +out="$(run_launcher start --repo "$REPO" --config "$CONFIG" --agents-json "$AGENTS_INTERACTIVE_WORK" --dry-run 2>&1)" +assert_contains "start launches lane despite interactive namesake" "$out" "claude --bg -n work" +assert_not_contains "start does not skip work for interactive namesake" "$out" "skip work" +: >"$CLAUDE_LOG" +out="$(run_launcher stop work --repo "$REPO" --config "$CONFIG" --agents-json "$AGENTS_INTERACTIVE_WORK" 2>&1)" +log="$(cat "$CLAUDE_LOG")" +assert_contains "stop treats interactive namesake as not running" "$out" "work — not running" +assert_not_contains "stop never targets the interactive namesake" "$log" "sid-int-work" + # ============================================================================ # Codex P1 — a failed live `claude agents --json` must abort a mutating action, # never fabricate an empty list (which would relaunch still-live lanes). These From 18c3465d20feca19b7ee5382d587959648770b03 Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Mon, 20 Jul 2026 03:17:39 -0400 Subject: [PATCH 11/11] fix(claude-ops): preflight lane restart inputs before stopping MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit restart stopped a healthy running lane before launch_lane validated the prompt file and effort, so a deleted/empty prompt or invalid effort took the session down and then failed to relaunch it — leaving the lane down for a recoverable error. Split the prompt/effort checks out of launch_lane into validate_launch_inputs and call it in _restart_one before stop_lane_if_running, so a recoverable input error aborts the restart without touching the live session. launch_lane still re-validates, so the start path is unchanged. New regression test: restarting a running lane with a missing prompt exits non-zero, surfaces the error, and never issues `claude stop` against the live session. Co-Authored-By: Claude Sonnet 5 --- .../skills/lanes/scripts/lane-launcher.sh | 17 ++++++++++++++--- .../skills/lanes/scripts/lane-launcher.test.sh | 18 ++++++++++++++++++ 2 files changed, 32 insertions(+), 3 deletions(-) diff --git a/plugins/claude-ops/skills/lanes/scripts/lane-launcher.sh b/plugins/claude-ops/skills/lanes/scripts/lane-launcher.sh index ae20ec17d..8979fbd56 100755 --- a/plugins/claude-ops/skills/lanes/scripts/lane-launcher.sh +++ b/plugins/claude-ops/skills/lanes/scripts/lane-launcher.sh @@ -316,9 +316,12 @@ run() { } # --- Lane launch -------------------------------------------------------------- -launch_lane() { - local name="$1" model="$2" effort="$3" prompt_path="$4" - +# Validates a lane's launch inputs (prompt present + non-empty, effort allowed); +# returns 1 with a per-lane error on the first failure. Split out from launch_lane +# so restart can preflight these BEFORE stopping a running lane — a recoverable +# prompt/effort error must not take a healthy session down and fail to relaunch it. +validate_launch_inputs() { + local name="$1" effort="$2" prompt_path="$3" if [[ ! -f "$prompt_path" ]]; then err "lane '$name': prompt file not found: $prompt_path — skipped" return 1 @@ -331,6 +334,11 @@ launch_lane() { err "lane '$name': invalid effort '$effort' (want: $VALID_EFFORTS) — skipped" return 1 fi +} + +launch_lane() { + local name="$1" model="$2" effort="$3" prompt_path="$4" + validate_launch_inputs "$name" "$effort" "$prompt_path" || return 1 local -a cmd=(claude --bg -n "$name") [[ -n "$model" ]] && cmd+=(--model "$model") @@ -435,6 +443,9 @@ _start_one() { _restart_one() { local name="$1" model="$2" effort="$3" prompt_path="$4" + # Preflight the launch inputs BEFORE stopping: a recoverable prompt/effort + # error must not take down a healthy running lane we would then fail to relaunch. + validate_launch_inputs "$name" "$effort" "$prompt_path" || return 1 stop_lane_if_running "$name" local s=$? # A genuine stop failure (not the benign "was not running", 2) means the old diff --git a/plugins/claude-ops/skills/lanes/scripts/lane-launcher.test.sh b/plugins/claude-ops/skills/lanes/scripts/lane-launcher.test.sh index 724fa60c6..fe573b069 100755 --- a/plugins/claude-ops/skills/lanes/scripts/lane-launcher.test.sh +++ b/plugins/claude-ops/skills/lanes/scripts/lane-launcher.test.sh @@ -270,6 +270,24 @@ rc=$? assert_eq "stop with failed claude stop exits non-zero" 1 "$rc" assert_contains "stop failure reported" "$out" "work — stop failed" +# ============================================================================ +# Codex P1 — restart must preflight launch inputs BEFORE stopping a running +# lane: a recoverable prompt/effort error must not take the healthy session +# down. `work` is running (sid-work-1) but its prompt file is missing, so the +# stop must never fire and the session must stay up. +# ============================================================================ +cat >"$TMP/restart-badprompt.json" <<'JSON' +{ "prompt_dir": ".work", + "lanes": [ { "name": "work", "prompt": "missing.md" } ] } +JSON +: >"$CLAUDE_LOG" +out="$(run_launcher restart work --repo "$REPO" --config "$TMP/restart-badprompt.json" --agents-json "$AGENTS_RUNNING" 2>&1)" +rc=$? +log="$(cat "$CLAUDE_LOG")" +assert_eq "restart with a bad prompt exits non-zero" 1 "$rc" +assert_contains "restart bad-prompt error surfaced" "$out" "prompt file not found" +assert_not_contains "restart does not stop the healthy running lane" "$log" "stop sid-work-1" + # ============================================================================ # Low-priority carry-forwards — uncovered flag paths # ============================================================================