From 4c8e8dd3897b17c9eb03a109d6d9d081ac874750 Mon Sep 17 00:00:00 2001 From: sario Date: Wed, 4 Nov 2026 08:42:27 +0100 Subject: [PATCH 01/16] =?UTF-8?q?chore:=20v4.1.0=20=E2=80=94=20sync=20work?= =?UTF-8?q?flow=20wrappers=20with=20superpowers=20v7.0.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - brainstorm: handle size outcomes; return control before writing-plans - plan: stop at method choice; split Spec pointer and Review Focus per service - work: --method subagent|native; per-plan SDD workspace path - review: route 'Declined to judge' items to the user - check-dependencies: honor CLAUDE_CONFIG_DIR/symlinks, warn on superpowers < 7.0.0 - CI: checkout@v5, setup-python@v6 Co-Authored-By: Claude Opus 5.5 --- .claude-plugin/plugin.json | 2 +- .github/workflows/validate.yml | 4 ++-- CHANGELOG.md | 27 +++++++++++++++++++++++++++ README.md | 10 +++++----- commands/workflows/brainstorm.md | 21 +++++++++++++++++---- commands/workflows/plan.md | 14 ++++++++++---- commands/workflows/review.md | 3 +++ commands/workflows/work.md | 23 +++++++++++++++++------ scripts/check-dependencies.sh | 26 ++++++++++++++++++++++++-- 9 files changed, 106 insertions(+), 24 deletions(-) diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 63ef735..31421d1 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "py-micro-superpowers", - "version": "4.0.0", + "version": "4.1.0", "description": "Superpowers for Python microservices \u2014 Django, FastAPI, pytest, PostgreSQL review agents and skills built on the superpowers plugin. 25 agents, 17 commands, 21 skills, 1 MCP server.", "hooks": { "SessionStart": [ diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index f9efe89..a6a481d 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -9,8 +9,8 @@ jobs: validate: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 + - uses: actions/checkout@v5 + - uses: actions/setup-python@v6 with: python-version: "3.12" - name: Structural validation diff --git a/CHANGELOG.md b/CHANGELOG.md index f8a0b5f..7f80048 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,33 @@ All notable changes to the compound-engineering-feat-python plugin will be docum The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [4.1.0] - 2026-11-04 + +### Changed +- **Synced with superpowers v7.0.0** (was v6.1.1). superpowers v7.0.0 or later is now required. +- `/workflows:brainstorm` handles brainstorming's new sizing. For a quick task or a + small change no spec is written, so the per-service spec split and the + `/workflows:plan` suggestion are skipped. For a project, brainstorming now hands + control back to the wrapper instead of invoking `writing-plans` itself, so the + focus gate and the per-service split still run. `` is passed to the + builder-check subagent. +- `/workflows:plan` stops after the user reviews the plan and chooses an execution + method. It no longer jumps straight into execution and around the Python quality + gate. The per-service split now also carries the `**Spec:**` pointer (rewritten to + the service's spec) and gives each `## Review Focus` item to the service that owns + it. +- `/workflows:work` supports both upstream executors through a new + `--method subagent|native` flag (Native = the rebuilt `executing-plans`). The + per-plan workspace path is documented as `.superpowers/sdd//`. The + test command must run the project's full suite. +- `/workflows:review` sends the reviewers' "Declined to judge" items to the user for + a decision instead of filing them as todos. +- CI: `actions/checkout@v5`, `actions/setup-python@v6`. + +### Fixed +- `scripts/check-dependencies.sh` now honors `CLAUDE_CONFIG_DIR` and symlinked plugin + directories, and warns when the installed superpowers version is older than 7.0.0. + ## [4.0.0] - 2026-07-21 ### Changed (BREAKING) diff --git a/README.md b/README.md index 4805d5f..f48c8b2 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@

Superpowers for Python microservices.

- Version 4.0.0 + Version 4.1.0 25 agents 17 commands 21 skills @@ -40,7 +40,7 @@ For monorepos, every command detects your services (`*/pyproject.toml`), scopes ## Installation -Requires the `superpowers` plugin (process primitives): +Requires the `superpowers` plugin **v7.0.0 or later** (process primitives). The session-start check warns if it is missing or older: ``` /plugin marketplace add anthropics/claude-plugins-official @@ -91,7 +91,7 @@ Commands use the `workflows:` prefix to avoid collisions with Claude Code built- |---------|---------| | `/workflows:brainstorm` | Explore requirements before planning (delegates to `superpowers:brainstorming`) | | `/workflows:plan` | Generate implementation plans (delegates to `superpowers:writing-plans`) | -| `/workflows:work` | Execute a plan with Python quality gates (delegates to `superpowers:subagent-driven-development` / `superpowers:executing-plans`) | +| `/workflows:work` | Execute a plan with Python quality gates (`--method subagent` → `superpowers:subagent-driven-development`, `--method native` → `superpowers:executing-plans`) | | `/workflows:review` | Multi-agent Python code review (delegates to `superpowers:requesting-code-review`) | Each workflow command: @@ -285,7 +285,7 @@ All workflow commands accept `--services` for automated invocation (skips all in `/workflows:work` is the engine that turns plans into shipped code: 1. **Load context** — project workflow skills + microservice focus gate (execution order for multi-service plans) -2. **Delegate execution** — `superpowers:subagent-driven-development` runs the task loop with per-task commits and reviews +2. **Delegate execution** — Subagent-driven (`superpowers:subagent-driven-development`: fresh implementer + reviewer per task) or Native (`superpowers:executing-plans`: this session implements, one whole-branch review). Pick with `--method`, or reuse the choice made at the end of `/workflows:plan` 3. **Python quality gate (mandatory)** — full pytest suite + ruff + mypy + pre-commit hooks must all pass; loops until clean 4. **Ship** — commit via the `commit` skill, push, open a PR that includes a Post-Deploy Monitoring & Validation plan @@ -299,7 +299,7 @@ Never ships with failing tests, never skips hooks. See [CHANGELOG.md](CHANGELOG.md) for detailed version history. -**Current version: 4.0.0** — renamed from `compound-engineering-feat-python`; the repository URL is unchanged. +**Current version: 4.1.0** — synced with superpowers v7.0.0. ## Contributing diff --git a/commands/workflows/brainstorm.md b/commands/workflows/brainstorm.md index 50d59be..7f37df4 100644 --- a/commands/workflows/brainstorm.md +++ b/commands/workflows/brainstorm.md @@ -45,14 +45,26 @@ Invoke the `superpowers:brainstorming` skill. Pass: - The user's feature description from `` as the topic. - The `` block returned from Step 2 (so the superpowers skill includes it in any sub-agent prompts it spawns). - A note that any Python `workflow_skills` listed above are already loaded in the conversation as additional context. +- An instruction to include the `` block in the builder-check subagent prompt it dispatches. +- **An instruction to return control to this wrapper once the design is approved, instead of invoking `superpowers:writing-plans`.** This wrapper owns the plan handoff so `/workflows:plan` can run the focus gate and the per-service split. -The superpowers skill drives the rest of the conversation (clarifying questions, approach exploration, design presentation, spec writing) and returns when the spec is written to the default path. Control then returns to this wrapper for Step 4. +The superpowers skill drives the rest of the conversation: it draws out intent, plays back what it heard, sizes the work, and (for a project) writes the design and runs the builder check. It returns once the user approves the description. Control then returns to this wrapper. + +### Size outcome + +`superpowers:brainstorming` (v7+) picks one of three sizes: + +| Size | What exists afterwards | This wrapper then | +|------|------------------------|-------------------| +| Quick, clear task | Nothing written; the task is done | Skip Step 4. Report what was done. | +| Small change | Description agreed in chat | Skip Step 4. Restate the agreed description; the user builds it through the normal workflow. | +| Project | Approved spec at `docs/superpowers/specs/YYYY-MM-DD--design.md` | Run Step 4 (multi-service only), then suggest `/workflows:plan`. | ## Step 4: Multi-service spec post-processing (multi-service only) `superpowers:brainstorming` always writes ONE spec to the default path: `docs/superpowers/specs/YYYY-MM-DD--design.md`. It does not know about per-service paths. -If Step 2 returned a non-empty service list, **after the superpowers skill returns**, this wrapper post-processes the spec: +If Step 2 returned a non-empty service list **and** brainstorming wrote a spec, **after the superpowers skill returns**, this wrapper post-processes the spec: 1. Read the spec at `docs/superpowers/specs/YYYY-MM-DD--design.md`. 2. For each focused service, write a copy to `{service}/docs/superpowers/specs/YYYY-MM-DD--design.md` (create the directory with `mkdir -p` first). @@ -74,6 +86,7 @@ In single-service mode (Step 2 returned empty service list), this entire step is After the superpowers skill returns, summarize: -- Spec path(s) written. +- The size brainstorming chose. +- Spec path(s) written (project size only). - The focused services (if multi-service mode). -- Suggested next step: `/workflows:plan `. +- Suggested next step for project size: `/workflows:plan `. For a small change, no plan step is needed. diff --git a/commands/workflows/plan.md b/commands/workflows/plan.md index 62498f6..0201a87 100644 --- a/commands/workflows/plan.md +++ b/commands/workflows/plan.md @@ -45,8 +45,10 @@ Invoke the `superpowers:writing-plans` skill. Pass: - The input (spec path or feature description) as the planning topic. - The `` block from Step 2. - A note that loaded Python `workflow_skills` are available in conversation. +- **An instruction to stop after the user has reviewed the saved plan and chosen an execution method (Subagent-driven or Native), and NOT to invoke `superpowers:subagent-driven-development` or `superpowers:executing-plans`.** Execution goes through `/workflows:work`, which owns the Python quality gate. Record the chosen method for the Output step. +- **Pipeline mode:** skip the plan-review prompt and the method question; record Subagent-driven. -The superpowers skill drives plan writing: file structure, task decomposition, bite-sized steps, self-review. It returns when the plan is written to its default path `docs/superpowers/plans/YYYY-MM-DD-.md`. Control then returns to this wrapper for Step 4. +The superpowers skill drives plan writing: the `**Spec:**` pointer, `## Global Constraints`, `## Review Focus`, task decomposition with `**Interfaces:**` blocks, and self-review. It saves the plan to `docs/superpowers/plans/YYYY-MM-DD-.md`. Control then returns to this wrapper for Step 4. ## Step 4: Multi-service plan post-processing (multi-service only) @@ -69,12 +71,16 @@ If Step 2 returned a non-empty service list, **after the superpowers skill retur Determine dependencies from the `` block's `dependencies:` section. Ask the user to confirm or adjust the graph before writing the final plan. -3. **Per-service plan files** — for each focused service, write a copy of the plan to `{service}/docs/superpowers/plans/YYYY-MM-DD-.md` (create the directory with `mkdir -p` first). Each per-service plan contains the tasks scoped to that service, the acceptance criteria, and the file changes for that service only. Copy the plan header — including the `## Global Constraints` section and each task's `**Interfaces:**` block (both part of the superpowers plan format) — verbatim into every per-service plan; task implementers only ever see their own plan file, so cross-service signatures must not be lost in the split. +3. **Per-service plan files** — for each focused service, write a copy of the plan to `{service}/docs/superpowers/plans/YYYY-MM-DD-.md` (create the directory with `mkdir -p` first). Each per-service plan contains the tasks scoped to that service, the acceptance criteria, and the file changes for that service only. Task implementers only ever see their own plan file, so carry the shared header into every per-service plan: + - **`**Spec:**` pointer** — rewrite it to that service's spec copy (`{service}/docs/superpowers/specs/...`) when one exists; otherwise keep the original path. + - **`## Global Constraints`** — copy verbatim. + - **`**Interfaces:**` blocks** — keep each task's block verbatim, so cross-service signatures are not lost in the split. + - **`## Review Focus`** — give each item to the service whose code it exercises, together with the test that pins it. Items that span services stay in the root overview. -4. **Root overview** — keep the root-level plan at `docs/superpowers/plans/YYYY-MM-DD-.md`. **Rewrite** its body to contain only: the dependency graph, execution order, links to the per-service plans (from sub-step 3), and any cross-cutting concerns. The detailed per-service tasks now live in the per-service files — they should NOT be duplicated in the root. +4. **Root overview** — keep the root-level plan at `docs/superpowers/plans/YYYY-MM-DD-.md`. **Rewrite** its body to contain only: the dependency graph, execution order, links to the per-service plans (from sub-step 3), cross-cutting concerns, and cross-service Review Focus items. The detailed per-service tasks now live in the per-service files — they should NOT be duplicated in the root. In single-service mode (Step 2 returned empty service list), this entire step is skipped — the root plan is the final artifact. ## Output -Confirm: "Plan written to ``" (and per-service locations if applicable). Suggest next step: `/workflows:work `. +Confirm: "Plan written to ``" (and per-service locations if applicable). Suggest the next step with the method the user chose: `/workflows:work --method subagent` or `--method native`. diff --git a/commands/workflows/review.md b/commands/workflows/review.md index e7f7319..9f978a8 100644 --- a/commands/workflows/review.md +++ b/commands/workflows/review.md @@ -104,6 +104,8 @@ Invoke the `superpowers:requesting-code-review` skill. Pass: The superpowers skill handles parallel agent execution and findings collection. After it returns, **by default** use the `file-todos` skill to create one todo file per finding under `todos/`, using the standard naming `{issue_id}-pending-{priority}-{description}.md`. +**Declined to judge** — superpowers reviewers list behavior they declined to rule on. Do not file these as P1/P2/P3 todos. Present them to the user as a separate list and ask which ones to treat as findings; file todos only for those. + **Exception** — if the user invoked the command with `--no-todos`, or if the user explicitly requested an inline summary during the conversation, surface findings as a categorized list (P1/P2/P3) without writing files. ## Step 9: Browser Test Offer (Optional) @@ -121,5 +123,6 @@ If the user accepts, spawn a sub-agent to run `/test-browser` for the PR. The su Summarize: - PR/branch reviewed. - Number of P1/P2/P3 findings. +- "Declined to judge" items awaiting the user's decision (if any). - Path to todos/ (if file-todos was used). - Next-step recommendation (e.g., "Address P1 findings before merging"). diff --git a/commands/workflows/work.md b/commands/workflows/work.md index 74e60b4..b09642f 100644 --- a/commands/workflows/work.md +++ b/commands/workflows/work.md @@ -1,12 +1,12 @@ --- name: workflows:work description: Execute a plan via superpowers, gated by a Python-specific quality bar (pytest + ruff + mypy + pre-commit), with multi-service execution ordering preserved -argument-hint: "[plan file path]" +argument-hint: "[plan file path] [--method subagent|native] [--services svc-a,svc-b]" --- # Work (Python) -This command is a wrapper around `superpowers:executing-plans`. It loads Python-specific context, runs the microservice focus gate, optionally honors per-service execution ordering, delegates plan execution, then enforces a Python quality gate before any PR is created. +This command is a wrapper around the two superpowers executors, `superpowers:subagent-driven-development` (Subagent-driven) and `superpowers:executing-plans` (Native). It loads Python-specific context, runs the microservice focus gate, optionally honors per-service execution ordering, delegates plan execution, then enforces a Python quality gate before any PR is created. ## Input Document @@ -16,7 +16,7 @@ If empty, ask the user for a plan path (typically under `docs/superpowers/plans/ ## Prerequisites -If the `superpowers:executing-plans` skill is not available, halt and instruct the user: +If the `superpowers:subagent-driven-development` or `superpowers:executing-plans` skill is not available, halt and instruct the user: > "This command requires the `superpowers` plugin. Install it with: > `/plugin marketplace add anthropics/claude-plugins-official` @@ -63,16 +63,27 @@ Options: [Confirm] [Force parallel] [Force sequential] [Adjust order] ## Step 3: Delegate plan execution to superpowers -Choose the execution skill per current superpowers guidance: **prefer `superpowers:subagent-driven-development` when subagents are available** (they are in Claude Code); use `superpowers:executing-plans` only when subagents are unavailable or the plan header explicitly requests it. SDD keeps its working artifacts in `.superpowers/sdd/` at the repo root — leave that directory alone; it is how an interrupted run resumes. Pass: +Pick the execution method, first match wins: + +1. `--method subagent` or `--method native` in `$ARGUMENTS`. +2. The method the user chose when `/workflows:plan` finished (if it is in this conversation). +3. Otherwise ask via `AskUserQuestion`. In pipeline mode, use Subagent-driven without asking. + +| Method | Skill | Tradeoff | +|--------|-------|----------| +| Subagent-driven | `superpowers:subagent-driven-development` | Fresh implementer and reviewer per task, then a whole-branch review. Most thorough; costs a fresh context per task and per review. | +| Native | `superpowers:executing-plans` | This session implements every task, then one fresh reviewer checks the whole branch. Cheapest and fastest; no independent review until the end. | + +Both executors keep a per-plan workspace at `.superpowers/sdd//` (ledger, briefs, test log). Leave it alone; it is how an interrupted run resumes, and the executor deletes it after a clean final review. Per-service plans share a basename, and superpowers gives each one its own workspace. Pass: - The plan path from ``. - The `` block. - The execution order from Step 2 (so the skill processes tasks in the right wave order in multi-service mode). -- The test command to use per service: +- The test command to use per service (SDD puts it in each task brief; Native runs it through `task-done`). It must run the project's whole suite, not only the task's test file: - Single-service: `poetry run pytest` (or whatever the project's CLAUDE.md specifies) - Multi-service: `cd {service} && poetry run pytest` for each service -The superpowers skill handles the task loop, per-task commits, and incremental progress. **Instruct the skill to NOT invoke `superpowers:finishing-a-development-branch` at the end** — this wrapper owns the finishing flow (Steps 4 and 5) because it adds Python-specific value (quality gate before commit, Post-Deploy Monitoring section in PR). Control returns to this wrapper after the task loop completes (or escalates). +The superpowers skill handles the task loop, per-task commits, the ledger, and the final whole-branch review. **Instruct the skill (either method) to NOT invoke `superpowers:finishing-a-development-branch` at the end** — this wrapper owns the finishing flow (Steps 4 and 5) because it adds Python-specific value (quality gate before commit, Post-Deploy Monitoring section in PR). Control returns to this wrapper after the task loop completes (or escalates). ## Step 4: Python Quality Gate (MANDATORY — blocks Step 5) diff --git a/scripts/check-dependencies.sh b/scripts/check-dependencies.sh index 9010879..39c426d 100755 --- a/scripts/check-dependencies.sh +++ b/scripts/check-dependencies.sh @@ -14,12 +14,29 @@ if ! command -v rclone &> /dev/null; then fi # Check superpowers plugin (REQUIRED — provides workflow primitives) -if ! find ~/.claude/plugins -type d -name "superpowers" 2>/dev/null | grep -q .; then +MIN_SUPERPOWERS="7.0.0" +plugins_dir="${CLAUDE_CONFIG_DIR:-$HOME/.claude}/plugins" +outdated_superpowers="" +if ! find "$plugins_dir/" -type d -name "superpowers" 2>/dev/null | grep -q .; then missing+=("superpowers") +else + # Newest installed version = highest semver-named dir under cache/*/superpowers/ + sp_version=$(find "$plugins_dir"/cache/*/superpowers -mindepth 1 -maxdepth 1 -type d 2>/dev/null \ + | sed 's#.*/##' | grep -E '^[0-9]+\.[0-9]+\.[0-9]+$' | sort -V | tail -n1) + if [ -n "$sp_version" ] && \ + [ "$(printf '%s\n%s\n' "$MIN_SUPERPOWERS" "$sp_version" | sort -V | head -n1)" != "$MIN_SUPERPOWERS" ]; then + outdated_superpowers="$sp_version" + fi +fi + +# If nothing is missing or outdated, exit silently +if [ ${#missing[@]} -eq 0 ] && [ -z "$outdated_superpowers" ]; then + exit 0 fi -# If nothing is missing, exit silently if [ ${#missing[@]} -eq 0 ]; then + echo "[py-micro-superpowers] superpowers $outdated_superpowers is installed, but the workflow commands assume $MIN_SUPERPOWERS+ behavior. + Update with: /plugin update superpowers" >&2 exit 0 fi @@ -45,6 +62,11 @@ if [[ " ${missing[*]} " =~ " superpowers " ]]; then /plugin install superpowers@claude-plugins-official" fi +if [ -n "$outdated_superpowers" ]; then + warning="$warning + - [REQUIRED] superpowers $outdated_superpowers is older than $MIN_SUPERPOWERS — update with: /plugin update superpowers" +fi + warning="$warning Run the install commands above. [REQUIRED] dependencies must be installed for the workflow commands to work." From 8cc92dc6d7d5f30a9e655a0ee0cdfad2f662b3c3 Mon Sep 17 00:00:00 2001 From: sario Date: Wed, 4 Nov 2026 08:50:56 +0100 Subject: [PATCH 02/16] =?UTF-8?q?feat:=20v4.2.0=20=E2=80=94=20demo=20monor?= =?UTF-8?q?epo=20and=20sandbox=20script=20for=20local=20testing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - examples/monorepo: lib-common, orders, payments (FastAPI + Poetry), walkthrough README - scripts/sandbox.sh: throwaway git copy of the demo, optional poetry install - CI: example job runs pytest, ruff, mypy on the demo - fix marketplace.json version (was stuck at 4.0.0) Co-Authored-By: Claude Opus 5.5 --- .claude-plugin/marketplace.json | 2 +- .claude-plugin/plugin.json | 2 +- .github/workflows/validate.yml | 18 ++++++ CHANGELOG.md | 14 +++++ README.md | 15 ++++- examples/monorepo/.gitignore | 6 ++ examples/monorepo/.pre-commit-config.yaml | 10 ++++ examples/monorepo/README.md | 39 ++++++++++++ .../lib-common/lib_common/__init__.py | 3 + .../monorepo/lib-common/lib_common/models.py | 21 +++++++ .../monorepo/lib-common/lib_common/py.typed | 0 examples/monorepo/lib-common/pyproject.toml | 32 ++++++++++ .../monorepo/lib-common/tests/test_models.py | 21 +++++++ examples/monorepo/orders/orders/__init__.py | 0 examples/monorepo/orders/orders/app.py | 34 +++++++++++ examples/monorepo/orders/pyproject.toml | 38 ++++++++++++ examples/monorepo/orders/tests/test_orders.py | 28 +++++++++ .../monorepo/payments/payments/__init__.py | 0 examples/monorepo/payments/payments/app.py | 39 ++++++++++++ .../payments/payments/orders_client.py | 26 ++++++++ examples/monorepo/payments/pyproject.toml | 37 ++++++++++++ .../monorepo/payments/tests/test_payments.py | 49 +++++++++++++++ .../monorepo/py-micro-superpowers.local.md | 15 +++++ scripts/sandbox.sh | 59 +++++++++++++++++++ 24 files changed, 504 insertions(+), 4 deletions(-) create mode 100644 examples/monorepo/.gitignore create mode 100644 examples/monorepo/.pre-commit-config.yaml create mode 100644 examples/monorepo/README.md create mode 100644 examples/monorepo/lib-common/lib_common/__init__.py create mode 100644 examples/monorepo/lib-common/lib_common/models.py create mode 100644 examples/monorepo/lib-common/lib_common/py.typed create mode 100644 examples/monorepo/lib-common/pyproject.toml create mode 100644 examples/monorepo/lib-common/tests/test_models.py create mode 100644 examples/monorepo/orders/orders/__init__.py create mode 100644 examples/monorepo/orders/orders/app.py create mode 100644 examples/monorepo/orders/pyproject.toml create mode 100644 examples/monorepo/orders/tests/test_orders.py create mode 100644 examples/monorepo/payments/payments/__init__.py create mode 100644 examples/monorepo/payments/payments/app.py create mode 100644 examples/monorepo/payments/payments/orders_client.py create mode 100644 examples/monorepo/payments/pyproject.toml create mode 100644 examples/monorepo/payments/tests/test_payments.py create mode 100644 examples/monorepo/py-micro-superpowers.local.md create mode 100755 scripts/sandbox.sh diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index b5da163..e3c8630 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -9,7 +9,7 @@ "name": "py-micro-superpowers", "source": "./", "description": "Superpowers for Python microservices. 25 agents, 17 commands, 21 skills, 1 MCP server.", - "version": "4.0.0", + "version": "4.2.0", "author": { "name": "BaronVonSario" } diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 31421d1..d415ea0 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "py-micro-superpowers", - "version": "4.1.0", + "version": "4.2.0", "description": "Superpowers for Python microservices \u2014 Django, FastAPI, pytest, PostgreSQL review agents and skills built on the superpowers plugin. 25 agents, 17 commands, 21 skills, 1 MCP server.", "hooks": { "SessionStart": [ diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index a6a481d..e600512 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -15,3 +15,21 @@ jobs: python-version: "3.12" - name: Structural validation run: bash scripts/validate.sh + + example: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + - uses: actions/setup-python@v6 + with: + python-version: "3.12" + - name: Install Poetry + run: pipx install poetry + - name: Test demo monorepo (pytest + ruff + mypy) + working-directory: examples/monorepo + run: | + for s in lib-common orders payments; do + echo "::group::$s" + (cd "$s" && poetry install -q && poetry run pytest -q && poetry run ruff check . && poetry run mypy .) || exit 1 + echo "::endgroup::" + done diff --git a/CHANGELOG.md b/CHANGELOG.md index 7f80048..de5498e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,20 @@ All notable changes to the compound-engineering-feat-python plugin will be docum The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [4.2.0] - 2026-11-04 + +### Added +- `examples/monorepo/`: a demo with three small FastAPI services (`lib-common`, + `orders`, `payments`) and a walkthrough README for testing the workflow commands + end to end. +- `scripts/sandbox.sh`: copies the demo into a throwaway git repo (optionally + `--install` runs `poetry install` per service) and prints the + `claude --plugin-dir` command to load this checkout. +- CI job `example`: runs the demo's pytest, ruff and mypy on every push. + +### Fixed +- `.claude-plugin/marketplace.json` version was stuck at 4.0.0. + ## [4.1.0] - 2026-11-04 ### Changed diff --git a/README.md b/README.md index f48c8b2..6148123 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@

Superpowers for Python microservices.

- Version 4.1.0 + Version 4.2.0 25 agents 17 commands 21 skills @@ -291,6 +291,17 @@ All workflow commands accept `--services` for automated invocation (skips all in Never ships with failing tests, never skips hooks. +## Try It Locally + +[`examples/monorepo/`](examples/monorepo/README.md) holds three tiny FastAPI services (`lib-common`, `orders`, `payments`). Spin up a throwaway copy as its own git repo and load this checkout of the plugin: + +```bash +./scripts/sandbox.sh --install +cd && claude --plugin-dir +``` + +The example README walks through brainstorm → plan → work → review on a cross-service feature and says what to check at each step. CI runs the example's pytest, ruff and mypy so it stays green. + ## Development `scripts/validate.sh` runs the structural checks CI enforces on every push: frontmatter integrity, reference-link resolution, script compilation, stale-reference guards, and component-count consistency. @@ -299,7 +310,7 @@ Never ships with failing tests, never skips hooks. See [CHANGELOG.md](CHANGELOG.md) for detailed version history. -**Current version: 4.1.0** — synced with superpowers v7.0.0. +**Current version: 4.2.0** — adds a demo monorepo for trying the plugin locally. ## Contributing diff --git a/examples/monorepo/.gitignore b/examples/monorepo/.gitignore new file mode 100644 index 0000000..3545cdf --- /dev/null +++ b/examples/monorepo/.gitignore @@ -0,0 +1,6 @@ +__pycache__/ +*.pyc +.venv/ +.pytest_cache/ +.mypy_cache/ +.ruff_cache/ diff --git a/examples/monorepo/.pre-commit-config.yaml b/examples/monorepo/.pre-commit-config.yaml new file mode 100644 index 0000000..93c4024 --- /dev/null +++ b/examples/monorepo/.pre-commit-config.yaml @@ -0,0 +1,10 @@ +# Local hooks only: no network fetch, uses the ruff installed in each service's venv. +repos: + - repo: local + hooks: + - id: ruff + name: ruff + entry: bash -c 'for s in lib-common orders payments; do (cd "$s" && poetry run ruff check .) || exit 1; done' + language: system + pass_filenames: false + types: [python] diff --git a/examples/monorepo/README.md b/examples/monorepo/README.md new file mode 100644 index 0000000..2fd7c33 --- /dev/null +++ b/examples/monorepo/README.md @@ -0,0 +1,39 @@ +# Demo monorepo + +Three tiny FastAPI services for trying py-micro-superpowers end to end. They keep all state in memory, so no database is needed. + +| Service | Role | Depends on | +|---------|------|------------| +| `lib-common` | Shared Pydantic models (`Money`, `OrderDTO`, `OrderStatus`) | — | +| `orders` | Create and read orders, mark them paid | `lib-common` | +| `payments` | Capture a payment for an order (calls `orders` over HTTP) | `lib-common`, `orders` | + +## Setup + +Don't work in this folder directly. The workflow commands create branches and commits, and here they would land in the plugin's own repository. Make a throwaway copy instead: + +```bash +./scripts/sandbox.sh --install # from the plugin repo root +cd && claude --plugin-dir +``` + +Requirements: Poetry 2.x, Python 3.12+, `pre-commit`, and the `superpowers` plugin (v7.0.0 or later) installed in Claude Code. + +## Walkthrough + +The suggested feature touches all three services: **"Add refunds: payments can refund a captured payment, and the order moves to a `refunded` status."** + +| # | Run | Check | +|---|-----|-------| +| 1 | `/workflows:brainstorm add refunds across orders and payments` | The focus gate lists all three services. Brainstorming sizes it as a project, then hands control back instead of starting plan writing. Spec copies land in `{service}/docs/superpowers/specs/`, and the root copy is deleted. | +| 2 | `/workflows:plan ` | Per-service plans under `{service}/docs/superpowers/plans/`, each with its own `**Spec:**` link and its own Review Focus items. A `services:` dependency graph sits in the root plan's frontmatter. It stops at the Subagent-driven / Native question without executing. | +| 3 | `/workflows:work --method native` | Waves run in order (`lib-common` first). It stops before superpowers' finishing step, then runs the Python quality gate (pytest, ruff, mypy, pre-commit). | +| 4 | `/workflows:review` | The roster from `py-micro-superpowers.local.md` runs, and "Declined to judge" items come to you rather than to `todos/`. | + +For a quicker check of the size outcomes, try `/workflows:brainstorm rename OrderDTO.item to OrderDTO.sku`. It should be sized as a small change and write no spec. + +## Checks by hand + +```bash +for s in lib-common orders payments; do (cd $s && poetry run pytest -q && poetry run ruff check . && poetry run mypy .); done +``` diff --git a/examples/monorepo/lib-common/lib_common/__init__.py b/examples/monorepo/lib-common/lib_common/__init__.py new file mode 100644 index 0000000..6ac95b8 --- /dev/null +++ b/examples/monorepo/lib-common/lib_common/__init__.py @@ -0,0 +1,3 @@ +from lib_common.models import Money, OrderDTO, OrderStatus + +__all__ = ["Money", "OrderDTO", "OrderStatus"] diff --git a/examples/monorepo/lib-common/lib_common/models.py b/examples/monorepo/lib-common/lib_common/models.py new file mode 100644 index 0000000..681eb4e --- /dev/null +++ b/examples/monorepo/lib-common/lib_common/models.py @@ -0,0 +1,21 @@ +from decimal import Decimal +from enum import StrEnum + +from pydantic import BaseModel, Field + + +class OrderStatus(StrEnum): + PENDING = "pending" + PAID = "paid" + + +class Money(BaseModel): + amount: Decimal = Field(gt=0, decimal_places=2) + currency: str = Field(pattern=r"^[A-Z]{3}$") + + +class OrderDTO(BaseModel): + id: int + item: str + total: Money + status: OrderStatus = OrderStatus.PENDING diff --git a/examples/monorepo/lib-common/lib_common/py.typed b/examples/monorepo/lib-common/lib_common/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/examples/monorepo/lib-common/pyproject.toml b/examples/monorepo/lib-common/pyproject.toml new file mode 100644 index 0000000..cb486fa --- /dev/null +++ b/examples/monorepo/lib-common/pyproject.toml @@ -0,0 +1,32 @@ +[project] +name = "lib-common" +version = "0.1.0" +description = "Shared DTOs for the demo services" +requires-python = ">=3.12" +dependencies = ["pydantic>=2.9"] + +[tool.poetry] +packages = [{ include = "lib_common" }] + +[tool.poetry.group.dev.dependencies] +pytest = ">=8.3" +ruff = ">=0.8" +mypy = ">=1.13" + +[build-system] +requires = ["poetry-core>=2.0"] +build-backend = "poetry.core.masonry.api" + +[tool.mypy] +strict = true +plugins = ["pydantic.mypy"] + +[tool.ruff] +line-length = 110 +target-version = "py312" + +[tool.ruff.lint] +select = ["E", "F", "I", "UP", "B"] + +[tool.ruff.lint.isort] +known-first-party = ["lib_common", "orders", "payments"] diff --git a/examples/monorepo/lib-common/tests/test_models.py b/examples/monorepo/lib-common/tests/test_models.py new file mode 100644 index 0000000..61b78b6 --- /dev/null +++ b/examples/monorepo/lib-common/tests/test_models.py @@ -0,0 +1,21 @@ +from decimal import Decimal + +import pytest +from pydantic import ValidationError + +from lib_common import Money, OrderDTO, OrderStatus + + +def test_money_rejects_non_positive_amount() -> None: + with pytest.raises(ValidationError): + Money(amount=Decimal("0"), currency="EUR") + + +def test_money_rejects_lowercase_currency() -> None: + with pytest.raises(ValidationError): + Money(amount=Decimal("1.00"), currency="eur") + + +def test_order_defaults_to_pending() -> None: + order = OrderDTO(id=1, item="book", total=Money(amount=Decimal("9.99"), currency="EUR")) + assert order.status is OrderStatus.PENDING diff --git a/examples/monorepo/orders/orders/__init__.py b/examples/monorepo/orders/orders/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/examples/monorepo/orders/orders/app.py b/examples/monorepo/orders/orders/app.py new file mode 100644 index 0000000..cc124e1 --- /dev/null +++ b/examples/monorepo/orders/orders/app.py @@ -0,0 +1,34 @@ +from fastapi import FastAPI, HTTPException +from pydantic import BaseModel + +from lib_common import Money, OrderDTO, OrderStatus + +app = FastAPI(title="orders") + +_orders: dict[int, OrderDTO] = {} + + +class CreateOrder(BaseModel): + item: str + total: Money + + +@app.post("/orders", status_code=201) +def create_order(body: CreateOrder) -> OrderDTO: + order = OrderDTO(id=len(_orders) + 1, item=body.item, total=body.total) + _orders[order.id] = order + return order + + +@app.get("/orders/{order_id}") +def get_order(order_id: int) -> OrderDTO: + if order_id not in _orders: + raise HTTPException(status_code=404, detail="order not found") + return _orders[order_id] + + +@app.post("/orders/{order_id}/mark-paid") +def mark_paid(order_id: int) -> OrderDTO: + order = get_order(order_id) + order.status = OrderStatus.PAID + return order diff --git a/examples/monorepo/orders/pyproject.toml b/examples/monorepo/orders/pyproject.toml new file mode 100644 index 0000000..c46515d --- /dev/null +++ b/examples/monorepo/orders/pyproject.toml @@ -0,0 +1,38 @@ +[project] +name = "orders" +version = "0.1.0" +description = "Order management service (demo)" +requires-python = ">=3.12" +dependencies = ["fastapi>=0.115", "lib-common"] + +[tool.poetry] +package-mode = false + +# Source for the lib-common entry in [project].dependencies +[tool.poetry.dependencies] +lib-common = { path = "../lib-common", develop = true } + +[tool.poetry.group.dev.dependencies] +pytest = ">=8.3" +httpx = ">=0.28" +ruff = ">=0.8" +mypy = ">=1.13" + +[tool.pytest.ini_options] +pythonpath = ["."] +# Starlette's TestClient now nudges toward an `httpx2` package; keep httpx until that is vetted. +filterwarnings = ["ignore:Using `httpx` with `starlette.testclient` is deprecated"] + +[tool.mypy] +strict = true +plugins = ["pydantic.mypy"] + +[tool.ruff] +line-length = 110 +target-version = "py312" + +[tool.ruff.lint] +select = ["E", "F", "I", "UP", "B"] + +[tool.ruff.lint.isort] +known-first-party = ["lib_common", "orders", "payments"] diff --git a/examples/monorepo/orders/tests/test_orders.py b/examples/monorepo/orders/tests/test_orders.py new file mode 100644 index 0000000..c45e1ca --- /dev/null +++ b/examples/monorepo/orders/tests/test_orders.py @@ -0,0 +1,28 @@ +from fastapi.testclient import TestClient + +from orders.app import app + +client = TestClient(app) + + +def _create() -> dict[str, object]: + resp = client.post("/orders", json={"item": "book", "total": {"amount": "9.99", "currency": "EUR"}}) + assert resp.status_code == 201 + body: dict[str, object] = resp.json() + return body + + +def test_create_and_get_order() -> None: + order = _create() + resp = client.get(f"/orders/{order['id']}") + assert resp.json()["status"] == "pending" + + +def test_mark_paid() -> None: + order = _create() + resp = client.post(f"/orders/{order['id']}/mark-paid") + assert resp.json()["status"] == "paid" + + +def test_unknown_order_is_404() -> None: + assert client.get("/orders/999999").status_code == 404 diff --git a/examples/monorepo/payments/payments/__init__.py b/examples/monorepo/payments/payments/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/examples/monorepo/payments/payments/app.py b/examples/monorepo/payments/payments/app.py new file mode 100644 index 0000000..0ce5140 --- /dev/null +++ b/examples/monorepo/payments/payments/app.py @@ -0,0 +1,39 @@ +from typing import Annotated + +from fastapi import Depends, FastAPI, HTTPException +from pydantic import BaseModel + +from lib_common import Money, OrderStatus +from payments.orders_client import HttpOrdersClient, OrdersClient + +app = FastAPI(title="payments") + + +def get_orders_client() -> OrdersClient: + return HttpOrdersClient() + + +class PaymentRequest(BaseModel): + order_id: int + amount: Money + + +class PaymentResult(BaseModel): + order_id: int + status: str + + +@app.post("/payments", status_code=201) +def pay( + body: PaymentRequest, + orders: Annotated[OrdersClient, Depends(get_orders_client)], +) -> PaymentResult: + order = orders.get_order(body.order_id) + if order is None: + raise HTTPException(status_code=404, detail="order not found") + if order.status is OrderStatus.PAID: + raise HTTPException(status_code=409, detail="order already paid") + if body.amount != order.total: + raise HTTPException(status_code=422, detail="amount does not match order total") + orders.mark_paid(order.id) + return PaymentResult(order_id=order.id, status="captured") diff --git a/examples/monorepo/payments/payments/orders_client.py b/examples/monorepo/payments/payments/orders_client.py new file mode 100644 index 0000000..55855b6 --- /dev/null +++ b/examples/monorepo/payments/payments/orders_client.py @@ -0,0 +1,26 @@ +import os +from typing import Protocol + +import httpx + +from lib_common import OrderDTO + + +class OrdersClient(Protocol): + def get_order(self, order_id: int) -> OrderDTO | None: ... + def mark_paid(self, order_id: int) -> None: ... + + +class HttpOrdersClient: + def __init__(self, base_url: str | None = None) -> None: + self._base_url = base_url or os.environ.get("ORDERS_URL", "http://localhost:8001") + + def get_order(self, order_id: int) -> OrderDTO | None: + resp = httpx.get(f"{self._base_url}/orders/{order_id}") + if resp.status_code == 404: + return None + resp.raise_for_status() + return OrderDTO.model_validate(resp.json()) + + def mark_paid(self, order_id: int) -> None: + httpx.post(f"{self._base_url}/orders/{order_id}/mark-paid").raise_for_status() diff --git a/examples/monorepo/payments/pyproject.toml b/examples/monorepo/payments/pyproject.toml new file mode 100644 index 0000000..8f88bb4 --- /dev/null +++ b/examples/monorepo/payments/pyproject.toml @@ -0,0 +1,37 @@ +[project] +name = "payments" +version = "0.1.0" +description = "Payment processing service (demo); calls the orders service over HTTP" +requires-python = ">=3.12" +dependencies = ["fastapi>=0.115", "httpx>=0.28", "lib-common"] + +[tool.poetry] +package-mode = false + +# Source for the lib-common entry in [project].dependencies +[tool.poetry.dependencies] +lib-common = { path = "../lib-common", develop = true } + +[tool.poetry.group.dev.dependencies] +pytest = ">=8.3" +ruff = ">=0.8" +mypy = ">=1.13" + +[tool.pytest.ini_options] +pythonpath = ["."] +# Starlette's TestClient now nudges toward an `httpx2` package; keep httpx until that is vetted. +filterwarnings = ["ignore:Using `httpx` with `starlette.testclient` is deprecated"] + +[tool.mypy] +strict = true +plugins = ["pydantic.mypy"] + +[tool.ruff] +line-length = 110 +target-version = "py312" + +[tool.ruff.lint] +select = ["E", "F", "I", "UP", "B"] + +[tool.ruff.lint.isort] +known-first-party = ["lib_common", "orders", "payments"] diff --git a/examples/monorepo/payments/tests/test_payments.py b/examples/monorepo/payments/tests/test_payments.py new file mode 100644 index 0000000..3711ad0 --- /dev/null +++ b/examples/monorepo/payments/tests/test_payments.py @@ -0,0 +1,49 @@ +from collections.abc import Iterator +from decimal import Decimal + +import pytest +from fastapi.testclient import TestClient + +from lib_common import Money, OrderDTO, OrderStatus +from payments.app import app, get_orders_client + + +class FakeOrders: + def __init__(self) -> None: + self.orders = {1: OrderDTO(id=1, item="book", total=Money(amount=Decimal("9.99"), currency="EUR"))} + + def get_order(self, order_id: int) -> OrderDTO | None: + return self.orders.get(order_id) + + def mark_paid(self, order_id: int) -> None: + self.orders[order_id].status = OrderStatus.PAID + + +@pytest.fixture +def client() -> Iterator[TestClient]: + fake = FakeOrders() + app.dependency_overrides[get_orders_client] = lambda: fake + yield TestClient(app) + app.dependency_overrides.clear() + + +def _pay(client: TestClient, order_id: int = 1, amount: str = "9.99") -> int: + body = {"order_id": order_id, "amount": {"amount": amount, "currency": "EUR"}} + return int(client.post("/payments", json=body).status_code) + + +def test_pay_captures_matching_amount(client: TestClient) -> None: + assert _pay(client) == 201 + + +def test_pay_twice_is_conflict(client: TestClient) -> None: + _pay(client) + assert _pay(client) == 409 + + +def test_wrong_amount_is_rejected(client: TestClient) -> None: + assert _pay(client, amount="1.00") == 422 + + +def test_unknown_order_is_404(client: TestClient) -> None: + assert _pay(client, order_id=42) == 404 diff --git a/examples/monorepo/py-micro-superpowers.local.md b/examples/monorepo/py-micro-superpowers.local.md new file mode 100644 index 0000000..1cff84c --- /dev/null +++ b/examples/monorepo/py-micro-superpowers.local.md @@ -0,0 +1,15 @@ +--- +review_agents: [python-quality-reviewer, pytest-reviewer, python-lint, code-simplicity-reviewer, security-sentinel, performance-oracle, fastapi-reviewer] +plan_review_agents: [python-quality-reviewer, pytest-reviewer, python-lint, code-simplicity-reviewer] +frameworks: [fastapi, pydantic] +database: [] +workflow_skills: + brainstorm: [fastapi-patterns] + plan: [fastapi-patterns] + work: [fastapi-patterns, pytest-patterns] + review: [fastapi-patterns, pytest-patterns] +--- + +# Review Context + +Demo monorepo for testing py-micro-superpowers. Services keep state in memory on purpose; do not flag the missing database. diff --git a/scripts/sandbox.sh b/scripts/sandbox.sh new file mode 100755 index 0000000..157a34a --- /dev/null +++ b/scripts/sandbox.sh @@ -0,0 +1,59 @@ +#!/usr/bin/env bash +# Create a throwaway copy of examples/monorepo as its own git repo, for trying the +# plugin locally without touching this repository's history. +# +# Usage: ./scripts/sandbox.sh [target-dir] [--install] +# target-dir where to create the sandbox (default: a new temp dir) +# --install run `poetry install` in every service (needed by /workflows:work) +set -euo pipefail + +plugin_root="$(cd "$(dirname "$0")/.." && pwd)" +template="$plugin_root/examples/monorepo" +target="" +install=0 + +for arg in "$@"; do + case "$arg" in + --install) install=1 ;; + -h|--help) sed -n '2,8p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;; + *) target="$arg" ;; + esac +done + +if [ -z "$target" ]; then + target="$(mktemp -d "${TMPDIR:-/tmp}/pms-sandbox.XXXXXX")" +elif [ -e "$target" ] && [ -n "$(ls -A "$target" 2>/dev/null)" ]; then + echo "error: $target exists and is not empty" >&2 + exit 1 +fi + +mkdir -p "$target" +cp -R "$template/." "$target/" +find "$target" \( -name .venv -o -name __pycache__ -o -name '.*_cache' \) -prune -exec rm -rf {} + + +cd "$target" +git init -q -b main +git add -A +git -c user.name="sandbox" -c user.email="sandbox@localhost" commit -q -m "chore: initial demo monorepo" + +if [ "$install" -eq 1 ]; then + for svc in lib-common orders payments; do + echo "poetry install: $svc" + (cd "$svc" && poetry install -q) + done +fi + +if ! command -v pre-commit >/dev/null 2>&1; then + echo "note: pre-commit is not installed; /workflows:work's quality gate runs it." >&2 + echo " Install with: uv tool install pre-commit (or pipx install pre-commit)" >&2 +fi + +cat <= 7.0.0) installed at user scope. +Walkthrough: $template/README.md +MSG From b3f2631ff5c689b82fde8aa059ca0d3afffb51aa Mon Sep 17 00:00:00 2001 From: sario Date: Thu, 5 Nov 2026 13:36:49 +0100 Subject: [PATCH 03/16] docs: services-pane design and implementation plan Co-Authored-By: Claude Opus 5.5 --- .../plans/2026-11-05-services-pane.md | 218 ++++++++++++++++++ .../specs/2026-11-05-services-pane-design.md | 103 +++++++++ 2 files changed, 321 insertions(+) create mode 100644 docs/superpowers/plans/2026-11-05-services-pane.md create mode 100644 docs/superpowers/specs/2026-11-05-services-pane-design.md diff --git a/docs/superpowers/plans/2026-11-05-services-pane.md b/docs/superpowers/plans/2026-11-05-services-pane.md new file mode 100644 index 0000000..2e2a54b --- /dev/null +++ b/docs/superpowers/plans/2026-11-05-services-pane.md @@ -0,0 +1,218 @@ +# Services Pane Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Ship a separate `services-pane` mod in this repo that shows each monorepo service's planned vs actual changes, run order, progress, test status, scope and todos, with per-service Start work and a final Ship. Also make the `/workflows:*` changes it relies on. + +**Architecture:** Pure parsing modules (plan markdown, ledger, git output, pytest output, waves) are plain TypeScript with no `$` and are unit-tested. One `snapshot.ts` gathers inputs through `$.fs` / `$.process` and builds a `Snapshot`. `register.tsx` keeps it in `$.state` atoms, refreshes after tool calls, and draws the pane. The main plugin gets four markdown-only workflow changes. + +**Tech Stack:** Claude Code mod API (early access, build 2.1.318; the authority is `.claude-plugin/types/claude-code/index.d.ts` once loaded, or `/tmp/claude-1000/bundled-skills/2.1.318/705fccc1f5ebffa20a8989c5331c10d8/plugin-authoring/types/claude-code.d.ts`), TypeScript/TSX, `claude-code/testing`, `claude plugin validate|test`. + +**Spec:** `docs/superpowers/specs/2026-11-05-services-pane-design.md` + +## Global Constraints + +- Mod folder `mods/services-pane/`; manifest name `services-pane`, version `0.1.0`; `"types": "./types/index.d.ts"`; `hooks/hooks.json` = `{ "modules": ["./register.tsx"] }`. +- Pane id `services-pane`, title `Services`; slash command `/services` (toggle). +- No Node, DOM or npm imports. Only `$` (`$.fs`, `$.process`, `$.prompt`, `$.ui`, `$.command`, `$.clock`) and `claude-code` exports. +- Read-only git only: `rev-parse`, `merge-base`, `diff --numstat`, `ls-files --others --exclude-standard`, `worktree list --porcelain`. The mod never runs tests, never commits, never edits files. +- Refresh after a tool call completes (never before it runs). Coalesce refreshes to at most one per 2 s. +- The main plugin must not reference or depend on the mod. +- Repo release rules (`CLAUDE.md`): bump `.claude-plugin/plugin.json`, CHANGELOG, README. `./scripts/validate.sh` must pass. +- State atoms carry a `shape` tag (e.g. `'snapshot-1'`) so older stored values are declined after a reload. + +## Review Focus + +1. **No plan with `services:` exists:** the pane still lists services with actual changes, shows no waves and offers no Start/Ship. (Task 6 test) +2. **Not a git repo, or no `origin/HEAD`/`main`/`master`:** actual changes show `git unavailable` per row instead of crashing the render. (Task 4 + Task 6 tests) +3. **Plan names a service that has no folder or no per-service plan file:** the row shows `no plan`, and Start work is absent. (Task 2 + Task 6 tests) +4. **Dependency cycle in `services:`:** cycle members are treated as independent (wave 1), as `work.md` does, and nothing loops. (Task 3 test) +5. **Single-service repo (no `*/pyproject.toml`):** the pane says `No services found (single-service repo).` (Task 6 test) + +--- + +### Task 1: Workflow changes in the main plugin + +**Files:** +- Modify: `commands/workflows/plan.md` (Step 4) +- Modify: `commands/workflows/work.md` (frontmatter `argument-hint`, Step 2, Step 3, Step 4/5 boundary, Step 5.4) + +**Interfaces:** +- Produces (contract the mod reads): + - Root plan frontmatter: `services:` map + `status: active|completed`. + - Per-service plan frontmatter: `status: active|completed`, and **no** `services:` key. + - CLI: `/workflows:work [--no-ship]`. A root plan whose per-service plans are all `status: completed` runs ship-only. + +- [ ] **Step 1: Edit `plan.md` Step 4.** Root plan frontmatter gets `status: active` next to `services:`. Each per-service plan gets frontmatter with only `status: active`. Add one sentence: "Do not copy the `services:` section into per-service plans: a per-service run must not pick up the whole dependency graph." +- [ ] **Step 2: Edit `work.md`.** + - `argument-hint` adds `[--no-ship]`. + - New Step 2 rule: when the input plan is a root plan (has `services:`) and every listed `{service}/docs/superpowers/plans/` has `status: completed`, skip Steps 2–3 (ship-only) and go to Step 4. + - Step 4 end: "If `--no-ship` was passed, stop here and report; do not run Step 5." + - Step 5.4 becomes: set `status: completed` on the input plan **and** on every per-service plan whose tasks this run completed. +- [ ] **Step 3: Verify.** Run `./scripts/validate.sh` → `Validation passed: 25 agents, 17 commands, 21 skills.` Run `grep -n "no-ship\|ship-only\|status: completed" commands/workflows/work.md` → at least 3 hits. Run `grep -n "Do not copy the \`services:\`" commands/workflows/plan.md` → 1 hit. +- [ ] **Step 4: Commit** `feat(workflows): --no-ship, ship-only root runs, per-service plan status`. + +### Task 2: Mod scaffold + plan parsing + +**Files:** +- Create: `mods/services-pane/.claude-plugin/plugin.json`, `mods/services-pane/hooks/hooks.json` +- Create: `mods/services-pane/types/index.d.ts` +- Create: `mods/services-pane/hooks/plan.ts` +- Create: `mods/services-pane/hooks/register.tsx` (stub: `export const register: Register = () => {}`) +- Test: `mods/services-pane/tests/plan.test.ts` + +**Interfaces:** +- Produces in `types/index.d.ts` (exported, then declared under `interface PluginState { 'services-pane': { snapshot: Shaped; selected: Shaped; pinnedPlan: Shaped; tests: Shaped>; edits: Shaped> } }`): + - `type DependsOn = { service: string; reason?: string }` + - `type PlanTask = { n: number; title: string; files: string[] }` + - `type PlanInfo = { status: 'active' | 'completed' | null; services: Record | null; tasks: PlanTask[] }` + - `type FileChange = { path: string; added: number; removed: number; isNew: boolean }` + - `type TestRun = { passed: boolean; summary: string; at: number }` + - `type ServiceRow = { name: string; inPlan: boolean; planPath: string | null; wave: number | null; waitingOn: string[]; tasks: PlanTask[]; done: number; total: number; isDone: boolean; changes: FileChange[] | null; outOfScope: boolean; todos: number }` (`changes: null` = git unavailable) + - `type Snapshot = { checkout: string; branch: string | null; rootPlan: string | null; plans: string[]; services: ServiceRow[]; refreshedAt: number }` +- Produces in `hooks/plan.ts`: + - `parsePlan(markdown: string): PlanInfo` + - `pickCurrentPlan(candidates: { path: string; mtimeMs: number; info: PlanInfo }[]): string | null` + - `servicePlanPath(service: string, rootPlanPath: string): string`, giving `${service}/docs/superpowers/plans/${basename(rootPlanPath)}` + +- [ ] **Step 1: Write failing tests** in `tests/plan.test.ts`: + - `parsePlan` on frontmatter `services:\n orders:\n depends_on: []\n payments:\n depends_on:\n - service: orders\n reason: "calls orders"\nstatus: active` gives `services = { orders: [], payments: [{ service: 'orders', reason: 'calls orders' }] }` and `status = 'active'`. + - Body `### Task 1: Add REFUNDED\n**Files:**\n- Create: \`orders/a.py\`\n- Modify: \`orders/b.py:10-20\`\n- Test: \`orders/tests/t.py\`\n### Task 2: Refund endpoint` gives `tasks = [{ n: 1, title: 'Add REFUNDED', files: ['orders/a.py', 'orders/b.py', 'orders/tests/t.py'] }, { n: 2, title: 'Refund endpoint', files: [] }]` (the line range is stripped). + - No frontmatter gives `{ status: null, services: null, tasks: [] }`. Malformed YAML under `services:` gives `services: null`, not a throw. + - `pickCurrentPlan` ignores candidates with `services: null`, prefers the later `YYYY-MM-DD-` filename prefix, and breaks ties on `mtimeMs`. An empty list gives `null`. + - `servicePlanPath('orders', 'docs/superpowers/plans/2026-11-05-refunds.md')` gives `'orders/docs/superpowers/plans/2026-11-05-refunds.md'`. +- [ ] **Step 2: Run** `claude plugin test mods/services-pane` → the plan tests FAIL (not defined). +- [ ] **Step 3: Implement** `plan.ts` and the scaffold files. YAML is hand-parsed for exactly the shapes above (indent-based). No library. +- [ ] **Step 4: Run** `claude plugin test mods/services-pane` → PASS. Run `claude plugin validate mods/services-pane` → no errors. +- [ ] **Step 5: Commit** `feat(services-pane): scaffold mod and plan parser`. + +### Task 3: Ledger progress and waves + +**Files:** +- Create: `mods/services-pane/hooks/ledger.ts`, `mods/services-pane/hooks/waves.ts` +- Test: `mods/services-pane/tests/ledger.test.ts`, `mods/services-pane/tests/waves.test.ts` + +**Interfaces:** +- Consumes: `DependsOn`, `PlanInfo` (Task 2). +- Produces: + - `completedTasks(progressMd: string): Set`: numbers N from lines matching `^Task (\d+): complete\b`. + - `matchWorkspace(markers: { dir: string; planPath: string }[], plan: string): string | null`: the workspace dir whose `plan-path` marker equals `plan` (repo-relative, trimmed). + - `computeWaves(services: Record): Record`: wave 1 for no deps. Cycle members and deps on unknown services count as wave 1. + - `waitingOn(service: string, services: Record, isDone: (s: string) => boolean): string[]` + +- [ ] **Step 1: Write failing tests:** + - `completedTasks('# plan: x.md\nTask 1: complete (commits a1..b2, review clean)\nTask 3: complete (commits c..d, tests: pytest → 3 passed)\nTask 2: started')` gives `{1, 3}`. + - `matchWorkspace([{ dir: 'a', planPath: 'orders/docs/superpowers/plans/p.md' }, { dir: 'b', planPath: 'payments/docs/superpowers/plans/p.md\n' }], 'payments/docs/superpowers/plans/p.md')` gives `'b'`. + - `computeWaves({ 'lib-common': [], orders: [{ service: 'lib-common' }], payments: [{ service: 'orders' }] })` gives `{ 'lib-common': 1, orders: 2, payments: 3 }`. + - Cycle `{ a: [{ service: 'b' }], b: [{ service: 'a' }], c: [] }` gives all `1`, and the call returns (no hang). + - `waitingOn('payments', …, s => s === 'lib-common')` gives `['orders']`. +- [ ] **Step 2: Run** `claude plugin test mods/services-pane` → new tests FAIL. +- [ ] **Step 3: Implement** both modules. +- [ ] **Step 4: Run** → PASS. +- [ ] **Step 5: Commit** `feat(services-pane): ledger progress and wave computation`. + +### Task 4: Git changes and watched checkout + +**Files:** +- Create: `mods/services-pane/hooks/git.ts` +- Test: `mods/services-pane/tests/git.test.ts` + +**Interfaces:** +- Consumes: `FileChange` (Task 2). +- Produces: + - `parseNumstat(out: string): FileChange[]`: `added\tremoved\tpath`. Binary `-\t-` counts as 0/0. + - `groupByTopFolder(changes: FileChange[]): Record`: root-level files go under `''`. + - `parseWorktrees(porcelain: string): string[]`: the `worktree ` lines. + - `outOfScope(folder: string, services: string[], planServices: string[] | null): boolean`: true only when `folder` is a service, `planServices` is non-null, and the folder isn't in it. + +- [ ] **Step 1: Write failing tests:** + - `parseNumstat('3\t1\torders/app.py\n-\t-\tlogo.png\n')` gives `[{ path: 'orders/app.py', added: 3, removed: 1, isNew: false }, { path: 'logo.png', added: 0, removed: 0, isNew: false }]`. + - `groupByTopFolder` sends `orders/app.py` → `orders` and `README.md` → `''`. + - `parseWorktrees('worktree /r\nHEAD abc\nbranch refs/heads/main\n\nworktree /r/.wt/feat\nHEAD def\n')` gives `['/r', '/r/.wt/feat']`. + - `outOfScope('docs', ['orders','payments'], ['orders'])` is `false` (not a service). `outOfScope('payments', ['orders','payments'], ['orders'])` is `true`. `outOfScope('payments', …, null)` is `false`. + - `parseNumstat('')` gives `[]`. +- [ ] **Step 2: Run** → FAIL. +- [ ] **Step 3: Implement** `git.ts`. +- [ ] **Step 4: Run** → PASS. +- [ ] **Step 5: Commit** `feat(services-pane): git numstat, worktree and scope helpers`. + +### Task 5: Observed test status + +**Files:** +- Create: `mods/services-pane/hooks/pytest.ts` +- Test: `mods/services-pane/tests/pytest.test.ts` + +**Interfaces:** +- Consumes: `TestRun` (Task 2). +- Produces: + - `pytestService(command: string, cwdRel: string, services: string[]): string | null`: null unless `command` contains the word `pytest`. Service = first `cd

` target's top folder if it's a service, else `cwdRel`'s top folder if that's a service. + - `parsePytest(output: string, at: number): TestRun | null`: uses the last line matching `=+ (.+) in [\d.]+s` or `(\d+ (passed|failed|error)[^\n]*)`. `passed` = summary contains `passed` and neither `failed` nor `error`. Null when no summary line exists. + - `isStale(run: TestRun, lastEditAt: number | undefined): boolean`, giving `lastEditAt !== undefined && lastEditAt > run.at`. + +- [ ] **Step 1: Write failing tests:** + - `pytestService('cd orders && poetry run pytest -q', '', ['orders','payments'])` gives `'orders'`. `pytestService('ls', '', …)` gives `null`. `pytestService('poetry run pytest', 'payments', …)` gives `'payments'`. + - `parsePytest('...\n===== 3 passed in 0.18s =====', 5)` gives `{ passed: true, summary: '3 passed', at: 5 }`. `'1 failed, 2 passed in 0.2s'` gives `passed: false`. `'no tests ran'` gives `null`. + - `isStale({ passed: true, summary: '', at: 10 }, 11)` is `true`; with `undefined` it is `false`. +- [ ] **Step 2: Run** → FAIL. +- [ ] **Step 3: Implement** `pytest.ts`. +- [ ] **Step 4: Run** → PASS. +- [ ] **Step 5: Commit** `feat(services-pane): observe pytest runs per service`. + +### Task 6: Snapshot, pane, actions, status line + +**Files:** +- Create: `mods/services-pane/hooks/snapshot.ts`, `mods/services-pane/hooks/view.tsx` +- Modify: `mods/services-pane/hooks/register.tsx` +- Test: `mods/services-pane/tests/pane.test.tsx` + +**Interfaces:** +- Consumes: everything from Tasks 2–5. +- Produces: + - `buildSnapshot($: EngineInterface, cwd: string): Promise` in `snapshot.ts`. Steps: + 1. Services = top-level dirs of `cwd` containing `pyproject.toml`. + 2. Current plan = `pinnedPlan` if still present, else `pickCurrentPlan` over `docs/superpowers/plans/*.md`. `plans` lists every root plan with `services:`, newest first. + 3. Watched checkout = the worktree (from `git worktree list --porcelain`) whose `.superpowers/sdd/*/plan-path` matches the root plan or a per-service plan, else `cwd`. + 4. In that checkout: base = first of `git merge-base HEAD origin/HEAD`, `…main`, `…master` that exits 0. Changes = `git diff --numstat ` + untracked (`isNew: true`, 0/0). Any git failure leaves `changes: null`. + 5. Per service: plan info, progress (ledger via `matchWorkspace` when present, else `status: completed` → total, else 0), `isDone = status === 'completed'`, waves, `waitingOn`, scope, todos = count of `todos/*-pending-*.md` whose text contains `${name}/`. + - `register.tsx`: + - `session.start`: register `/services`, then refresh. + - `command.run {command:'services'}`: toggle the pane via `$.ui.open` / close. + - `tool.call`: after `await next(e)`, for `Edit|Write|NotebookEdit` record `edits[service] = Date.now()`. For `Bash`, record `tests[service]` from `pytestService` + `parsePytest`. Then schedule a refresh (coalesced, 2 s). + - `ui.render {component:'Pane', requestId:'services-pane'}`: draw via `view.tsx`. + - `$.ui.status(...)` after each refresh, e.g. `3 services · orders 1/4 · tests ✗ payments`; `undefined` when there is no root plan. + - Actions (Buttons): see the steps below. + +- [ ] **Step 1: Write failing tests** in `pane.test.tsx` (pattern: the bundled `examples/pane.test.tsx`; stand in for `$.fs` / `$.process` with hooks on their events, seeding the `examples/monorepo` layout with a root plan `docs/superpowers/plans/2026-11-05-refunds.md` that has `services:` lib-common→[], orders→[lib-common], payments→[orders], with `lib-common`'s per-service plan `status: completed`): + - Rows render in wave order. `lib-common` shows `3/3 ✓`; `payments` shows `waiting on orders`. + - Pressing `orders` then **Start work** submits exactly `/workflows:work orders/docs/superpowers/plans/2026-11-05-refunds.md --services orders --no-ship` (assert via a `prompt.submit` hook). + - `payments` has no Start work button (it's waiting). **Ship** is absent until all three per-service plans are `status: completed`, then submits `/workflows:work docs/superpowers/plans/2026-11-05-refunds.md`. + - With two root plans, **Switch plan** sets `pinnedPlan` to the next entry of `snapshot.plans`, and the rows re-render for that plan. With one or zero plans the button is absent. + - **Open plan** submits `Show me orders/docs/superpowers/plans/2026-11-05-refunds.md`. **Show diff** submits `Summarize the changes in orders/ on this branch.` + - A changed file under a service not in `services:` shows `⚠`. + - A Bash `cd orders && poetry run pytest` with output `1 failed, 2 passed in 0.2s` shows `tests ✗` on `orders`. After a later Edit to `orders/x.py`, it shows `tests ✗ (stale)`. + - Review Focus 1: no plan files → rows render with changes, no `Wave` headers, no Start/Ship. + - Review Focus 2: every `git` call exits 128 → each row shows `git unavailable`. + - Review Focus 3: plan lists `refunds-svc` with no folder → row `refunds-svc no plan`, no Start work. + - Review Focus 5: no `*/pyproject.toml` → text `No services found (single-service repo).` +- [ ] **Step 2: Run** `claude plugin test mods/services-pane` → FAIL. +- [ ] **Step 3: Implement** `snapshot.ts`, `view.tsx`, `register.tsx`. Header line: `branch: · watching `. Start work never adds `--method`: the pane cannot know the plan-time choice, so `/workflows:work` asks as usual (consistent with the spec's "when known"). +- [ ] **Step 4: Run** `claude plugin test mods/services-pane` → all PASS. Run `claude plugin validate mods/services-pane` → no errors. Run `tsc -p mods/services-pane` (once the engine has laid types) → no errors. +- [ ] **Step 5: Commit** `feat(services-pane): live pane with per-service actions and status line`. + +### Task 7: Packaging, docs, release + +**Files:** +- Modify: `.claude-plugin/marketplace.json` (add a second entry), `.claude-plugin/plugin.json` (4.2.0 → 4.3.0), `CHANGELOG.md`, `README.md`, `scripts/validate.sh` +- Create: `mods/services-pane/README.md` + +**Interfaces:** +- Consumes: Task 1 workflow contract; mod from Tasks 2–6. + +- [ ] **Step 1: marketplace.json:** add `{ "name": "services-pane", "source": "./mods/services-pane", "description": "Live pane of monorepo services: planned vs actual changes, waves, progress, tests; start work per service.", "version": "0.1.0", "author": { "name": "BaronVonSario" } }`. Set the main entry's version to `4.3.0`. +- [ ] **Step 2: validate.sh:** add a check that each `mods/*/.claude-plugin/plugin.json` parses as JSON and has `name` and `version`, and that `mods/*/hooks/hooks.json` names an existing module. When `claude` is on PATH, also run `claude plugin validate` per mod (skip silently otherwise, since CI has no `claude`). +- [ ] **Step 3: Docs:** + - `mods/services-pane/README.md`: install with `/plugin install services-pane@py-micro-superpowers`, `/services`, what each column means, early-access note. + - Root README: a "Services pane (optional)" section, plus the `--no-ship` flag in the `/workflows:work` row. + - CHANGELOG `[4.3.0]`: Added (services-pane mod, `--no-ship`, ship-only run, per-service plan status); Changed (per-service plans no longer copy `services:`). +- [ ] **Step 4: Verify.** `./scripts/validate.sh` → passes (component counts unchanged: 25/17/21). `claude plugin test mods/services-pane` → PASS. Run `./scripts/sandbox.sh --install` and load with `--plugin-dir /mods/services-pane --plugin-dir `; `/services` opens the pane and lists `lib-common`, `orders`, `payments`. +- [ ] **Step 5: Commit** `feat: v4.3.0 — services-pane mod`. diff --git a/docs/superpowers/specs/2026-11-05-services-pane-design.md b/docs/superpowers/specs/2026-11-05-services-pane-design.md new file mode 100644 index 0000000..1fbc5a1 --- /dev/null +++ b/docs/superpowers/specs/2026-11-05-services-pane-design.md @@ -0,0 +1,103 @@ +# Services Pane — design + +**Status:** draft for approval (builder check done) · 2026-11-05 + +## Intent + +When work spans several services in a monorepo, the person running py-micro-superpowers has to keep in their head which services are in scope, what the plan says will change in each, what has actually changed so far, and what can start next. The Services Pane is a Claude Code **mod** (a pane beside the chat) that shows this at a glance and lets them start the planned work for one service with a click. + +Success: during a multi-service `/workflows:plan` → `/workflows:work` run, the person never has to open plan files or run `git diff` to know where each service stands. + +## Goals + +- List the monorepo's services. +- Per service, show **planned** changes (from its plan file) and **actual** changes (from git) side by side. +- Show run order (waves) and which services are waiting on others. +- Start a service's planned work from the pane. +- Extra signals: plan progress, last test result, out-of-scope edits, open review todos. + +## Non-goals + +- Running tests, git commands or workflows by itself. It only observes, except when the person clicks an action. +- Editing plans or todos. +- Anything outside a Claude Code terminal session (desktop/mobile surfaces may render it, but terminal is the target). +- Replacing the workflow commands. It is a view and a launcher for them. + +## Anti-goals (failure even if it works) + +- A change in the early-access mod API breaks `/workflows:*`. The pane must be removable or broken without affecting the main plugin. +- The pane slows the session noticeably or floods the transcript. +- It shows stale or wrong state with confidence (for example, "tests passing" from a run three edits ago with no hint of age). + +## Packaging (decided by the person) + +- A **separate plugin in this repository**, e.g. `mods/services-pane/`, with its own `.claude-plugin/plugin.json`, `hooks/hooks.json` (`{ "modules": ["./register.tsx"] }`) and hooks module. +- Listed as a second entry in this repo's `.claude-plugin/marketplace.json`, so it installs independently: `/plugin install services-pane@py-micro-superpowers`. +- The main plugin does not depend on it. The repo's release rules apply (CHANGELOG, README, version bumps). `scripts/validate.sh` must keep passing, and must not count the mod's files as main-plugin components. + +## Data sources (my calls, agreed in conversation) + +| Shown | Source | +|-------|--------| +| Services | Top-level folders containing `pyproject.toml` (same rule as `microservice-focus`: `Glob("*/pyproject.toml")`). | +| Current work | Newest root plan under `docs/superpowers/plans/` whose YAML frontmatter has a `services:` section (the format `/workflows:plan` writes). The person can switch to another plan from the pane. With no such plan, the pane still lists services and actual changes. | +| Planned changes | Each service's plan `{service}/docs/superpowers/plans/.md`: task titles, checkbox state (`- [x]` / `- [ ]`), and files each task names. | +| Watched checkout *(decided by the person)* | The worktree where the current plan's work is happening (found from the ledger's workspace or `git worktree list`), else the session's folder. The pane header names the watched checkout. All git, ledger and test reads use it. | +| Actual changes | `git diff` against the branch's merge-base with the default branch, plus uncommitted changes, grouped by top-level folder: files and +/− lines. | +| Run order | Waves from the root plan's `services:` → `depends_on`. A service is "waiting on X" until X is **done** (see Done signal). | +| Done signal *(decided by the person)* | A service is done when its per-service plan's frontmatter has `status: completed`. `/workflows:work` writes this (see Main-plugin changes). | +| Progress | While a run is live: `Task : complete` lines in that plan's ledger (`.superpowers/sdd//progress.md`) out of the plan's task count. After the ledger is deleted: `status: completed` → N/N, otherwise 0/N. Plan checkboxes are per step and nobody ticks them, so they are not a progress source. | +| Test status | Observed only: when Claude runs pytest (Bash tool) with the working directory or `cd` inside a service, record pass/fail and time from the result. Show age ("2 min ago") and mark it stale once files in that service change after the run. | +| Out-of-scope warning | Actual changes in a **service folder** that is not in the current plan's `services:` get a ⚠. Non-service folders (`docs/`, `todos/`, root files) are never flagged. *(my call, from the builder check)* | +| Ledger ↔ service mapping | Per-service plans share a basename, so superpowers names their workspaces ``, `-2`, …. Match each workspace to its plan through the workspace's `plan-path` marker, never by directory name. *(my call, from the builder check)* | +| Review findings | Count of `todos/*-pending-*.md` files that mention the service's folder name. | + +## Interface (my calls) + +- **Pane** titled "Services", opened by a `/services` command (toggle). Layout as sketched: + + ``` + ┌ Services ─────────────── branch: feat/refunds ┐ + │ Wave 1 │ + │ ● lib-common plan 3/3 ✓ +42 −3 2 files │ + │ Wave 2 │ + │ ▶ orders plan 1/4 +18 −0 1 file │ + │ ○ payments plan 0/5 waiting on orders │ + │ │ + │ orders › tasks │ + │ ✓ Add REFUNDED to OrderStatus │ + │ · POST /orders/{id}/refund │ + │ [Start work] [Open plan] [Show diff] │ + └───────────────────────────────────────────────┘ + ``` + Selecting a service expands its tasks and files below the list. +- **Actions** on the selected service: + - **Start work**: submits `/workflows:work --services --no-ship` as if typed, adding `--method ` when the method chosen at plan time is known. Disabled while the service is "waiting" or done. + - **Ship** *(decided by the person)*: shown once every service in the plan is done. It submits `/workflows:work ` in ship-only mode (see Main-plugin changes), which opens one PR for the whole branch. + - **Open plan**: asks Claude to show the plan file. + - **Show diff**: asks Claude to summarize that service's changes. +- **Status line**: one line when there is a current plan, e.g. `3 services · orders 1/4 · tests ✗ payments`. +- **Refresh**: after file-editing tool calls and Bash calls finish, and when the pane opens. Not on a polling timer faster than a few seconds. + +## Main-plugin changes (needed by the person's decisions) + +These change `commands/workflows/plan.md` and `work.md`. They are useful without the pane, and the main plugin still does not depend on it: + +1. **`/workflows:work --no-ship`**: run tasks and the Python quality gate (Step 4), then stop before Step 5. No PR. +2. **Ship-only run**: `/workflows:work ` when every per-service plan is `status: completed` skips execution and goes straight to the quality gate and Step 5 (one PR). +3. **`status: completed` on per-service plans**: when a run finishes, set it on each per-service plan it completed, not only on the input plan. +4. **Per-service plans carry no `services:` frontmatter** (`plan.md` Step 4), so a per-service run cannot pick up the whole dependency graph and run every wave. + +## Constraints + +- Mod API is early access (Claude Code build 2.1.318 at time of writing). The type file shipped with the engine is the authority. +- The mod has no Node or DOM. Filesystem and process access go through the engine interface (`$.fs`, `$.process`). +- A pane opened unasked needs about 110+ terminal columns. When the person runs `/services`, it opens at any width. +- Must not add noticeable latency to tool calls. Expensive reads (git diff) happen after the call completes, not before. + +## Left to the builder + +- Exact hooks, state shape, and component tree. +- How to parse plan markdown robustly (task headings, checkboxes, file lists). +- Debouncing and caching. +- Test files (`claude plugin test`), and whether CI runs them. From 5e2384371fe02f18969909fdbc28f2b0788b0650 Mon Sep 17 00:00:00 2001 From: sario Date: Thu, 5 Nov 2026 13:37:54 +0100 Subject: [PATCH 04/16] feat(workflows): --no-ship, ship-only root runs, per-service plan status Co-Authored-By: Claude Opus 5.5 --- commands/workflows/plan.md | 5 +++-- commands/workflows/work.md | 8 ++++++-- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/commands/workflows/plan.md b/commands/workflows/plan.md index 0201a87..aed81e0 100644 --- a/commands/workflows/plan.md +++ b/commands/workflows/plan.md @@ -57,9 +57,10 @@ The superpowers skill drives plan writing: the `**Spec:**` pointer, `## Global C If Step 2 returned a non-empty service list, **after the superpowers skill returns**, this wrapper post-processes the plan: 1. Read the plan at `docs/superpowers/plans/YYYY-MM-DD-.md`. -2. Add or update the YAML frontmatter to include a `services:` section so `/workflows:work` can execute in dependency order: +2. Add or update the YAML frontmatter to include `status: active` and a `services:` section so `/workflows:work` can execute in dependency order: ```yaml + status: active services: service-a: depends_on: [] @@ -71,7 +72,7 @@ If Step 2 returned a non-empty service list, **after the superpowers skill retur Determine dependencies from the `` block's `dependencies:` section. Ask the user to confirm or adjust the graph before writing the final plan. -3. **Per-service plan files** — for each focused service, write a copy of the plan to `{service}/docs/superpowers/plans/YYYY-MM-DD-.md` (create the directory with `mkdir -p` first). Each per-service plan contains the tasks scoped to that service, the acceptance criteria, and the file changes for that service only. Task implementers only ever see their own plan file, so carry the shared header into every per-service plan: +3. **Per-service plan files** — for each focused service, write a copy of the plan to `{service}/docs/superpowers/plans/YYYY-MM-DD-.md` (create the directory with `mkdir -p` first). Each per-service plan gets YAML frontmatter containing only `status: active`. Do not copy the `services:` section into per-service plans: a per-service run must not pick up the whole dependency graph. Each per-service plan contains the tasks scoped to that service, the acceptance criteria, and the file changes for that service only. Task implementers only ever see their own plan file, so carry the shared header into every per-service plan: - **`**Spec:**` pointer** — rewrite it to that service's spec copy (`{service}/docs/superpowers/specs/...`) when one exists; otherwise keep the original path. - **`## Global Constraints`** — copy verbatim. - **`**Interfaces:**` blocks** — keep each task's block verbatim, so cross-service signatures are not lost in the split. diff --git a/commands/workflows/work.md b/commands/workflows/work.md index b09642f..b77aa98 100644 --- a/commands/workflows/work.md +++ b/commands/workflows/work.md @@ -1,7 +1,7 @@ --- name: workflows:work description: Execute a plan via superpowers, gated by a Python-specific quality bar (pytest + ruff + mypy + pre-commit), with multi-service execution ordering preserved -argument-hint: "[plan file path] [--method subagent|native] [--services svc-a,svc-b]" +argument-hint: "[plan file path] [--method subagent|native] [--services svc-a,svc-b] [--no-ship]" --- # Work (Python) @@ -28,6 +28,8 @@ Read `py-micro-superpowers.local.md` (if it doesn't exist but a legacy `compound ## Step 2: Microservice Focus + Execution Order +**Ship-only check (first):** if the input plan is a root plan (its frontmatter has `services:`) and every listed `{service}/docs/superpowers/plans/` has `status: completed`, there is nothing left to execute. Skip the rest of Step 2 and Step 3 (ship-only run) and go to Step 4. + Invoke the `microservice-focus` skill. It will: - Parse `--services` from `$ARGUMENTS` if present. @@ -128,6 +130,8 @@ Step 5 cannot start until all checks in this step pass. This is not advisory. If truly no production/runtime impact: include the section with `No additional operational monitoring required: `. +5. **`--no-ship` boundary** — If `--no-ship` was passed, stop here and report; do not run Step 5. + ## Step 5: Ship It Prerequisite: Step 4 quality gate passed. If not, return to Step 4. @@ -182,6 +186,6 @@ EOF )" ``` -4. **Update plan status** — if the input plan has YAML frontmatter with a `status` field, update it from `active` → `completed`. +4. **Update plan status** — set `status: completed` (from `active`) in the frontmatter of the input plan **and** of every per-service plan whose tasks this run completed. 5. **Notify user** — summarize what shipped, link to PR, note any follow-ups. From 536dbd20c899e976d3c23c8dbd5e39d930744b4a Mon Sep 17 00:00:00 2001 From: sario Date: Thu, 5 Nov 2026 13:39:04 +0100 Subject: [PATCH 05/16] fix(workflows): mark per-service plans done at the quality gate; focus list for ship-only Co-Authored-By: Claude Opus 5.5 --- commands/workflows/work.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/commands/workflows/work.md b/commands/workflows/work.md index b77aa98..bd50245 100644 --- a/commands/workflows/work.md +++ b/commands/workflows/work.md @@ -28,7 +28,7 @@ Read `py-micro-superpowers.local.md` (if it doesn't exist but a legacy `compound ## Step 2: Microservice Focus + Execution Order -**Ship-only check (first):** if the input plan is a root plan (its frontmatter has `services:`) and every listed `{service}/docs/superpowers/plans/` has `status: completed`, there is nothing left to execute. Skip the rest of Step 2 and Step 3 (ship-only run) and go to Step 4. +**Ship-only check (first):** if the input plan is a root plan (its frontmatter has `services:`) and every listed `{service}/docs/superpowers/plans/` has `status: completed`, there is nothing left to execute. Skip the rest of Step 2 and Step 3 (ship-only run) and go to Step 4. On this path the focused services are the root plan's `services:` keys: build the `` block from them and their `depends_on`, and use it for Step 4. Invoke the `microservice-focus` skill. It will: @@ -130,7 +130,9 @@ Step 5 cannot start until all checks in this step pass. This is not advisory. If truly no production/runtime impact: include the section with `No additional operational monitoring required: `. -5. **`--no-ship` boundary** — If `--no-ship` was passed, stop here and report; do not run Step 5. +5. **Mark per-service plans done** — once the checks above pass, set `status: completed` (from `active`) in the frontmatter of every per-service plan whose tasks this run completed. This happens before the `--no-ship` stop, so it applies to normal and `--no-ship` runs. + +6. **`--no-ship` boundary** — If `--no-ship` was passed, stop here and report; do not run Step 5. ## Step 5: Ship It @@ -186,6 +188,6 @@ EOF )" ``` -4. **Update plan status** — set `status: completed` (from `active`) in the frontmatter of the input plan **and** of every per-service plan whose tasks this run completed. +4. **Update plan status** — set `status: completed` (from `active`) in the frontmatter of the input plan. Per-service plans were already marked at the Step 4 quality gate. 5. **Notify user** — summarize what shipped, link to PR, note any follow-ups. From 9f8d016f33cfb1ce206009cdb316b3b4b5e915e9 Mon Sep 17 00:00:00 2001 From: sario Date: Thu, 5 Nov 2026 13:40:32 +0100 Subject: [PATCH 06/16] feat(services-pane): scaffold mod and plan parser Co-Authored-By: Claude Opus 5.5 --- mods/services-pane/.claude-plugin/plugin.json | 6 + mods/services-pane/hooks/hooks.json | 1 + mods/services-pane/hooks/plan.ts | 125 ++++++++++++++++++ mods/services-pane/hooks/register.tsx | 3 + mods/services-pane/tests/plan.test.ts | 70 ++++++++++ mods/services-pane/types/index.d.ts | 44 ++++++ 6 files changed, 249 insertions(+) create mode 100644 mods/services-pane/.claude-plugin/plugin.json create mode 100644 mods/services-pane/hooks/hooks.json create mode 100644 mods/services-pane/hooks/plan.ts create mode 100644 mods/services-pane/hooks/register.tsx create mode 100644 mods/services-pane/tests/plan.test.ts create mode 100644 mods/services-pane/types/index.d.ts diff --git a/mods/services-pane/.claude-plugin/plugin.json b/mods/services-pane/.claude-plugin/plugin.json new file mode 100644 index 0000000..4d69254 --- /dev/null +++ b/mods/services-pane/.claude-plugin/plugin.json @@ -0,0 +1,6 @@ +{ + "name": "services-pane", + "version": "0.1.0", + "description": "Live pane of monorepo services: planned vs actual changes, waves, progress, tests; start work per service.", + "types": "./types/index.d.ts" +} diff --git a/mods/services-pane/hooks/hooks.json b/mods/services-pane/hooks/hooks.json new file mode 100644 index 0000000..5a63f64 --- /dev/null +++ b/mods/services-pane/hooks/hooks.json @@ -0,0 +1 @@ +{ "modules": ["./register.tsx"] } diff --git a/mods/services-pane/hooks/plan.ts b/mods/services-pane/hooks/plan.ts new file mode 100644 index 0000000..aa34084 --- /dev/null +++ b/mods/services-pane/hooks/plan.ts @@ -0,0 +1,125 @@ +import type { DependsOn, PlanInfo, PlanTask } from '../types/index' + +const indentOf = (line: string): number => line.length - line.trimStart().length + +const unquote = (s: string): string => { + const t = s.trim() + const q = t[0] + return t.length >= 2 && (q === '"' || q === "'") && t[t.length - 1] === q ? t.slice(1, -1) : t +} + +/** Parses the `services:` block; returns null on any shape it does not know. */ +const parseServices = (block: string[]): Record | null => { + const lines = block.filter(l => l.trim() !== '') + const out: Record = {} + let i = 0 + while (i < lines.length) { + const head = /^(\s+)([^\s:#-][^:]*):\s*$/.exec(lines[i]) + if (!head) return null + const name = head[2].trim() + const base = head[1].length + const deps: DependsOn[] = [] + out[name] = deps + i++ + let sawDepends = false + while (i < lines.length && indentOf(lines[i]) > base) { + const dl = /^\s+depends_on:\s*(\[\s*\])?\s*$/.exec(lines[i]) + if (!dl || sawDepends) return null + sawDepends = true + const depBase = indentOf(lines[i]) + i++ + if (dl[1]) continue + let cur: DependsOn | null = null + while (i < lines.length && indentOf(lines[i]) > depBase) { + const item = /^\s+-\s+service:\s*(\S.*)$/.exec(lines[i]) + const reason = /^\s+reason:\s*(\S.*)$/.exec(lines[i]) + if (item) { + cur = { service: unquote(item[1]) } + deps.push(cur) + } else if (reason && cur) { + cur.reason = unquote(reason[1]) + } else return null + i++ + } + } + } + return out +} + +const parseFrontmatter = (lines: string[]): Pick => { + const result: Pick = { status: null, services: null } + if (lines[0]?.trim() !== '---') return result + const end = lines.findIndex((l, i) => i > 0 && l.trim() === '---') + const fm = end === -1 ? lines.slice(1) : lines.slice(1, end) + for (let i = 0; i < fm.length; i++) { + const line = fm[i] + const status = /^status:\s*(\S+)\s*$/.exec(line) + if (status) { + const v = unquote(status[1]) + if (v === 'active' || v === 'completed') result.status = v + } else if (/^services:\s*(\{\s*\})?\s*$/.test(line)) { + if (/\{/.test(line)) { + result.services = {} + continue + } + let j = i + 1 + while (j < fm.length && (fm[j].trim() === '' || indentOf(fm[j]) > 0)) j++ + result.services = parseServices(fm.slice(i + 1, j)) + i = j - 1 + } + } + return result +} + +const parseTasks = (lines: string[]): PlanTask[] => { + const tasks: PlanTask[] = [] + let cur: PlanTask | null = null + let inFiles = false + for (const line of lines) { + const head = /^###\s+Task\s+(\d+):\s*(.+?)\s*$/.exec(line) + if (head) { + cur = { n: Number(head[1]), title: head[2], files: [] } + tasks.push(cur) + inFiles = false + continue + } + if (!cur) continue + if (/^\*\*Files:\*\*/.test(line)) { + inFiles = true + continue + } + if (!inFiles) continue + const file = /^\s*-\s+(?:Create|Modify|Test):\s*(.*)$/.exec(line) + if (file) { + for (const m of file[1].matchAll(/`([^`]+)`/g)) { + cur.files.push(m[1].replace(/:\d+(?:-\d+)?$/, '')) + } + } else if (line.trim() !== '') inFiles = false + } + return tasks +} + +export const parsePlan = (markdown: string): PlanInfo => { + const lines = markdown.split(/\r?\n/) + return { ...parseFrontmatter(lines), tasks: parseTasks(lines) } +} + +const basename = (p: string): string => p.slice(p.lastIndexOf('/') + 1) +const datePrefix = (p: string): string => /^(\d{4}-\d{2}-\d{2})-/.exec(basename(p))?.[1] ?? '' + +/** The current root plan: the latest dated plan that declares `services:`; mtime breaks ties. */ +export const pickCurrentPlan = ( + candidates: { path: string; mtimeMs: number; info: PlanInfo }[], +): string | null => { + const usable = candidates.filter(c => c.info.services !== null) + if (usable.length === 0) return null + usable.sort((a, b) => { + const da = datePrefix(a.path) + const db = datePrefix(b.path) + return da === db ? b.mtimeMs - a.mtimeMs : da < db ? 1 : -1 + }) + return usable[0].path +} + +export const servicePlanPath = (service: string, rootPlanPath: string): string => + `${service}/docs/superpowers/plans/${basename(rootPlanPath)}` diff --git a/mods/services-pane/hooks/register.tsx b/mods/services-pane/hooks/register.tsx new file mode 100644 index 0000000..8ad3388 --- /dev/null +++ b/mods/services-pane/hooks/register.tsx @@ -0,0 +1,3 @@ +import type { Register } from 'claude-code' + +export const register: Register = () => {} diff --git a/mods/services-pane/tests/plan.test.ts b/mods/services-pane/tests/plan.test.ts new file mode 100644 index 0000000..8421e6e --- /dev/null +++ b/mods/services-pane/tests/plan.test.ts @@ -0,0 +1,70 @@ +import { expect, test } from 'claude-code/testing' +import { parsePlan, pickCurrentPlan, servicePlanPath } from '../hooks/plan' + +const info = (services: Record | null) => ({ status: null, services, tasks: [] }) + +test('parsePlan reads services and status from frontmatter', async () => { + const md = [ + '---', + 'services:', + ' orders:', + ' depends_on: []', + ' payments:', + ' depends_on:', + ' - service: orders', + ' reason: "calls orders"', + 'status: active', + '---', + '# Plan', + ].join('\n') + const p = parsePlan(md) + expect(p.services).toEqual({ orders: [], payments: [{ service: 'orders', reason: 'calls orders' }] }) + expect(p.status).toBe('active') +}) + +test('parsePlan reads tasks and strips line ranges', async () => { + const md = [ + '### Task 1: Add REFUNDED', + '**Files:**', + '- Create: `orders/a.py`', + '- Modify: `orders/b.py:10-20`', + '- Test: `orders/tests/t.py`', + '### Task 2: Refund endpoint', + ].join('\n') + expect(parsePlan(md).tasks).toEqual([ + { n: 1, title: 'Add REFUNDED', files: ['orders/a.py', 'orders/b.py', 'orders/tests/t.py'] }, + { n: 2, title: 'Refund endpoint', files: [] }, + ]) +}) + +test('parsePlan without frontmatter is empty; malformed services is null', async () => { + expect(parsePlan('# just text')).toEqual({ status: null, services: null, tasks: [] }) + const bad = '---\nservices:\n orders\n : [oops\n - x\nstatus: completed\n---\n' + const p = parsePlan(bad) + expect(p.services).toBe(null) + expect(p.status).toBe('completed') +}) + +test('pickCurrentPlan prefers later date prefix, then mtime, ignoring null services', async () => { + expect(pickCurrentPlan([])).toBe(null) + expect(pickCurrentPlan([{ path: 'p/2026-11-06-x.md', mtimeMs: 1, info: info(null) }])).toBe(null) + expect( + pickCurrentPlan([ + { path: 'p/2026-11-05-a.md', mtimeMs: 900, info: info({}) }, + { path: 'p/2026-11-06-b.md', mtimeMs: 100, info: info({}) }, + { path: 'p/2026-11-07-c.md', mtimeMs: 999, info: info(null) }, + ]), + ).toBe('p/2026-11-06-b.md') + expect( + pickCurrentPlan([ + { path: 'p/2026-11-05-a.md', mtimeMs: 100, info: info({}) }, + { path: 'p/2026-11-05-b.md', mtimeMs: 200, info: info({}) }, + ]), + ).toBe('p/2026-11-05-b.md') +}) + +test('servicePlanPath nests the root plan basename under the service', async () => { + expect(servicePlanPath('orders', 'docs/superpowers/plans/2026-11-05-refunds.md')).toBe( + 'orders/docs/superpowers/plans/2026-11-05-refunds.md', + ) +}) diff --git a/mods/services-pane/types/index.d.ts b/mods/services-pane/types/index.d.ts new file mode 100644 index 0000000..e619d2d --- /dev/null +++ b/mods/services-pane/types/index.d.ts @@ -0,0 +1,44 @@ +export type DependsOn = { service: string; reason?: string } +export type PlanTask = { n: number; title: string; files: string[] } +export type PlanInfo = { + status: 'active' | 'completed' | null + services: Record | null + tasks: PlanTask[] +} +export type FileChange = { path: string; added: number; removed: number; isNew: boolean } +export type TestRun = { passed: boolean; summary: string; at: number } +export type ServiceRow = { + name: string + inPlan: boolean + planPath: string | null + wave: number | null + waitingOn: string[] + tasks: PlanTask[] + done: number + total: number + isDone: boolean + /** null = git unavailable */ + changes: FileChange[] | null + outOfScope: boolean + todos: number +} +export type Snapshot = { + checkout: string + branch: string | null + rootPlan: string | null + plans: string[] + services: ServiceRow[] + refreshedAt: number +} + +declare module 'claude-code' { + interface PluginState { + 'services-pane': { + snapshot: Shaped + selected: Shaped + pinnedPlan: Shaped + tests: Shaped> + edits: Shaped> + } + } +} From 73ba1d96606a5c5251c7342fdc4f4be63bb7ca94 Mon Sep 17 00:00:00 2001 From: sario Date: Thu, 5 Nov 2026 13:42:01 +0100 Subject: [PATCH 07/16] feat(services-pane): ledger progress and wave computation Co-Authored-By: Claude Opus 5.5 --- mods/services-pane/hooks/ledger.ts | 18 +++++++++++ mods/services-pane/hooks/waves.ts | 41 +++++++++++++++++++++++++ mods/services-pane/tests/ledger.test.ts | 17 ++++++++++ mods/services-pane/tests/waves.test.ts | 24 +++++++++++++++ 4 files changed, 100 insertions(+) create mode 100644 mods/services-pane/hooks/ledger.ts create mode 100644 mods/services-pane/hooks/waves.ts create mode 100644 mods/services-pane/tests/ledger.test.ts create mode 100644 mods/services-pane/tests/waves.test.ts diff --git a/mods/services-pane/hooks/ledger.ts b/mods/services-pane/hooks/ledger.ts new file mode 100644 index 0000000..ed46d95 --- /dev/null +++ b/mods/services-pane/hooks/ledger.ts @@ -0,0 +1,18 @@ +/** Task numbers marked complete in a superpowers progress ledger. */ +export const completedTasks = (progressMd: string): Set => { + const done = new Set() + for (const line of progressMd.split('\n')) { + const m = /^Task (\d+): complete\b/.exec(line) + if (m) done.add(Number(m[1])) + } + return done +} + +/** The workspace dir whose `plan-path` marker equals `plan` (repo-relative), else null. */ +export const matchWorkspace = ( + markers: { dir: string; planPath: string }[], + plan: string, +): string | null => { + const want = plan.trim() + return markers.find((m) => m.planPath.trim() === want)?.dir ?? null +} diff --git a/mods/services-pane/hooks/waves.ts b/mods/services-pane/hooks/waves.ts new file mode 100644 index 0000000..b428634 --- /dev/null +++ b/mods/services-pane/hooks/waves.ts @@ -0,0 +1,41 @@ +import type { DependsOn } from '../types/index' + +type Services = Record + +const depsOf = (services: Services, s: string): string[] => + (services[s] ?? []).map((d) => d.service).filter((d) => d in services) + +/** True when `s` can reach itself through known dependencies. */ +const inCycle = (services: Services, s: string): boolean => { + const seen = new Set() + const stack = depsOf(services, s) + while (stack.length) { + const cur = stack.pop() as string + if (cur === s) return true + if (seen.has(cur)) continue + seen.add(cur) + stack.push(...depsOf(services, cur)) + } + return false +} + +/** Wave 1 for no deps; cycle members and unknown deps are treated as independent (wave 1). */ +export const computeWaves = (services: Services): Record => { + const cyclic = new Set(Object.keys(services).filter((s) => inCycle(services, s))) + const waves: Record = {} + const wave = (s: string): number => { + if (s in waves) return waves[s] + if (cyclic.has(s)) return (waves[s] = 1) + const deps = depsOf(services, s) + return (waves[s] = deps.length ? 1 + Math.max(...deps.map(wave)) : 1) + } + for (const s of Object.keys(services)) wave(s) + return waves +} + +/** Direct dependencies of `service` that are not yet done. */ +export const waitingOn = ( + service: string, + services: Services, + isDone: (s: string) => boolean, +): string[] => (services[service] ?? []).map((d) => d.service).filter((d) => !isDone(d)) diff --git a/mods/services-pane/tests/ledger.test.ts b/mods/services-pane/tests/ledger.test.ts new file mode 100644 index 0000000..435a704 --- /dev/null +++ b/mods/services-pane/tests/ledger.test.ts @@ -0,0 +1,17 @@ +import { expect, test } from 'claude-code/testing' +import { completedTasks, matchWorkspace } from '../hooks/ledger' + +test('completedTasks collects only complete lines', async () => { + const md = + '# plan: x.md\nTask 1: complete (commits a1..b2, review clean)\nTask 3: complete (commits c..d, tests: pytest → 3 passed)\nTask 2: started' + expect(completedTasks(md)).toEqual(new Set([1, 3])) +}) + +test('matchWorkspace matches trimmed plan-path markers', async () => { + const markers = [ + { dir: 'a', planPath: 'orders/docs/superpowers/plans/p.md' }, + { dir: 'b', planPath: 'payments/docs/superpowers/plans/p.md\n' }, + ] + expect(matchWorkspace(markers, 'payments/docs/superpowers/plans/p.md')).toBe('b') + expect(matchWorkspace(markers, 'nope.md')).toBeNull() +}) diff --git a/mods/services-pane/tests/waves.test.ts b/mods/services-pane/tests/waves.test.ts new file mode 100644 index 0000000..ed62317 --- /dev/null +++ b/mods/services-pane/tests/waves.test.ts @@ -0,0 +1,24 @@ +import { expect, test } from 'claude-code/testing' +import { computeWaves, waitingOn } from '../hooks/waves' + +const chain = { + 'lib-common': [], + orders: [{ service: 'lib-common' }], + payments: [{ service: 'orders' }], +} + +test('computeWaves layers a dependency chain', async () => { + expect(computeWaves(chain)).toEqual({ 'lib-common': 1, orders: 2, payments: 3 }) +}) + +test('computeWaves treats cycle members as wave 1 and returns', async () => { + expect(computeWaves({ a: [{ service: 'b' }], b: [{ service: 'a' }], c: [] })).toEqual({ a: 1, b: 1, c: 1 }) +}) + +test('computeWaves ignores unknown dependencies', async () => { + expect(computeWaves({ a: [{ service: 'ghost' }] })).toEqual({ a: 1 }) +}) + +test('waitingOn lists direct deps that are not done', async () => { + expect(waitingOn('payments', chain, (s) => s === 'lib-common')).toEqual(['orders']) +}) From 3dc5d2e1ecae221c05f413291506c92f0c7b57af Mon Sep 17 00:00:00 2001 From: sario Date: Thu, 5 Nov 2026 13:43:31 +0100 Subject: [PATCH 08/16] feat(services-pane): git numstat, worktree and scope helpers Co-Authored-By: Claude Opus 5.5 --- mods/services-pane/hooks/git.ts | 37 ++++++++++++++++++++++++++++ mods/services-pane/tests/git.test.ts | 35 ++++++++++++++++++++++++++ 2 files changed, 72 insertions(+) create mode 100644 mods/services-pane/hooks/git.ts create mode 100644 mods/services-pane/tests/git.test.ts diff --git a/mods/services-pane/hooks/git.ts b/mods/services-pane/hooks/git.ts new file mode 100644 index 0000000..93c590c --- /dev/null +++ b/mods/services-pane/hooks/git.ts @@ -0,0 +1,37 @@ +import type { FileChange } from '../types/index' + +/** Parses `git diff --numstat` output; binary files (`-\t-`) count as 0/0. */ +export const parseNumstat = (out: string): FileChange[] => { + const changes: FileChange[] = [] + for (const line of out.split('\n')) { + if (line.trim() === '') continue + const [added, removed, ...rest] = line.split('\t') + const path = rest.join('\t') + if (path === '') continue + changes.push({ path, added: Number(added) || 0, removed: Number(removed) || 0, isNew: false }) + } + return changes +} + +/** Groups changes by first path segment; root-level files go under `''`. */ +export const groupByTopFolder = (changes: FileChange[]): Record => { + const groups: Record = {} + for (const change of changes) { + const slash = change.path.indexOf('/') + const folder = slash === -1 ? '' : change.path.slice(0, slash) + if (!Object.hasOwn(groups, folder)) groups[folder] = [] + groups[folder].push(change) + } + return groups +} + +/** Returns the paths on the `worktree ` lines of `git worktree list --porcelain`. */ +export const parseWorktrees = (porcelain: string): string[] => + porcelain + .split('\n') + .filter(l => l.startsWith('worktree ')) + .map(l => l.slice('worktree '.length).trim()) + +/** True only for a service folder that a known plan leaves out. */ +export const outOfScope = (folder: string, services: string[], planServices: string[] | null): boolean => + planServices !== null && services.includes(folder) && !planServices.includes(folder) diff --git a/mods/services-pane/tests/git.test.ts b/mods/services-pane/tests/git.test.ts new file mode 100644 index 0000000..8e1b86b --- /dev/null +++ b/mods/services-pane/tests/git.test.ts @@ -0,0 +1,35 @@ +import { expect, test } from 'claude-code/testing' +import { groupByTopFolder, outOfScope, parseNumstat, parseWorktrees } from '../hooks/git' + +test('parseNumstat reads counts and treats binary as 0/0', async () => { + expect(parseNumstat('3\t1\torders/app.py\n-\t-\tlogo.png\n')).toEqual([ + { path: 'orders/app.py', added: 3, removed: 1, isNew: false }, + { path: 'logo.png', added: 0, removed: 0, isNew: false }, + ]) +}) + +test('parseNumstat returns [] for empty output', async () => { + expect(parseNumstat('')).toEqual([]) +}) + +test('groupByTopFolder groups by first segment, root files under empty key', async () => { + const g = groupByTopFolder([ + { path: 'orders/app.py', added: 1, removed: 0, isNew: false }, + { path: 'README.md', added: 1, removed: 0, isNew: false }, + ]) + expect(g.orders.map(c => c.path)).toEqual(['orders/app.py']) + expect(g[''].map(c => c.path)).toEqual(['README.md']) +}) + +test('parseWorktrees returns worktree paths', async () => { + const out = 'worktree /r\nHEAD abc\nbranch refs/heads/main\n\nworktree /r/.wt/feat\nHEAD def\n' + expect(parseWorktrees(out)).toEqual(['/r', '/r/.wt/feat']) +}) + +test('outOfScope is true only for a service missing from a non-null plan', async () => { + const svcs = ['orders', 'payments'] + expect(outOfScope('docs', svcs, ['orders'])).toBe(false) + expect(outOfScope('payments', svcs, ['orders'])).toBe(true) + expect(outOfScope('payments', svcs, null)).toBe(false) + expect(outOfScope('orders', svcs, ['orders'])).toBe(false) +}) From 53295bbf5bab9689572f3ba11178f87ae33d163f Mon Sep 17 00:00:00 2001 From: sario Date: Thu, 5 Nov 2026 13:44:41 +0100 Subject: [PATCH 09/16] feat(services-pane): observe pytest runs per service Co-Authored-By: Claude Opus 5.5 --- mods/services-pane/hooks/pytest.ts | 37 +++++++++++++++++++++++ mods/services-pane/tests/pytest.test.ts | 39 +++++++++++++++++++++++++ 2 files changed, 76 insertions(+) create mode 100644 mods/services-pane/hooks/pytest.ts create mode 100644 mods/services-pane/tests/pytest.test.ts diff --git a/mods/services-pane/hooks/pytest.ts b/mods/services-pane/hooks/pytest.ts new file mode 100644 index 0000000..15c412f --- /dev/null +++ b/mods/services-pane/hooks/pytest.ts @@ -0,0 +1,37 @@ +import type { TestRun } from '../types/index' + +const PYTEST_WORD = /(? path.replace(/^(\.\/)+/, '').split('/')[0] ?? '' + +/** Service a pytest command ran in: first `cd` target's top folder, else the cwd's; null if not pytest or no service. */ +export const pytestService = (command: string, cwdRel: string, services: string[]): string | null => { + if (!PYTEST_WORD.test(command)) return null + const cd = CD_TARGET.exec(command) + const target = cd ? (cd[1] ?? cd[2] ?? cd[3]) : undefined + if (target !== undefined) { + const top = topFolder(target) + if (services.includes(top)) return top + } + const cwdTop = topFolder(cwdRel) + return services.includes(cwdTop) ? cwdTop : null +} + +/** Parses the last pytest summary line in `output`; null when there is none. */ +export const parsePytest = (output: string, at: number): TestRun | null => { + let summary: string | null = null + for (const line of output.split('\n')) { + const match = BANNER_SUMMARY.exec(line) ?? PLAIN_SUMMARY.exec(line) + if (match) summary = match[1].trim() + } + if (summary === null) return null + const passed = summary.includes('passed') && !summary.includes('failed') && !summary.includes('error') + return { passed, summary, at } +} + +/** A run is stale when its service was edited after it ran. */ +export const isStale = (run: TestRun, lastEditAt: number | undefined): boolean => + lastEditAt !== undefined && lastEditAt > run.at diff --git a/mods/services-pane/tests/pytest.test.ts b/mods/services-pane/tests/pytest.test.ts new file mode 100644 index 0000000..3f7bf05 --- /dev/null +++ b/mods/services-pane/tests/pytest.test.ts @@ -0,0 +1,39 @@ +import { expect, test } from 'claude-code/testing' +import { isStale, parsePytest, pytestService } from '../hooks/pytest' + +const services = ['orders', 'payments'] + +test('pytestService prefers the first cd target that is a service', async () => { + expect(pytestService('cd orders && poetry run pytest -q', '', services)).toBe('orders') + expect(pytestService('cd ./payments/app && python -m pytest', '', services)).toBe('payments') +}) + +test('pytestService falls back to the cwd top folder', async () => { + expect(pytestService('poetry run pytest', 'payments', services)).toBe('payments') + expect(pytestService('poetry run pytest', 'payments/app', services)).toBe('payments') +}) + +test('pytestService is null for non-pytest commands or unknown services', async () => { + expect(pytestService('ls', '', services)).toBeNull() + expect(pytestService('pip install pytest-cov', 'orders', services)).toBeNull() + expect(pytestService('pytest', '', services)).toBeNull() + expect(pytestService('pytest', 'docs', services)).toBeNull() +}) + +test('parsePytest reads the final summary line', async () => { + expect(parsePytest('...\n===== 3 passed in 0.18s =====', 5)).toEqual({ passed: true, summary: '3 passed', at: 5 }) + expect(parsePytest('1 failed, 2 passed in 0.2s', 7)?.passed).toBe(false) + expect(parsePytest('=== 1 failed, 2 passed in 0.2s ===\n', 7)?.summary).toBe('1 failed, 2 passed') + expect(parsePytest('=== 2 passed, 1 error in 0.2s ===', 1)?.passed).toBe(false) +}) + +test('parsePytest returns null without a summary', async () => { + expect(parsePytest('no tests ran', 1)).toBeNull() + expect(parsePytest('', 1)).toBeNull() +}) + +test('isStale compares last edit to the run time', async () => { + expect(isStale({ passed: true, summary: '', at: 10 }, 11)).toBe(true) + expect(isStale({ passed: true, summary: '', at: 10 }, 10)).toBe(false) + expect(isStale({ passed: true, summary: '', at: 10 }, undefined)).toBe(false) +}) From 44da4b008b5a739e63b2ceace96317574cb27e88 Mon Sep 17 00:00:00 2001 From: sario Date: Thu, 5 Nov 2026 13:45:41 +0100 Subject: [PATCH 10/16] fix(services-pane): xfailed counts as a passing run Co-Authored-By: Claude Opus 5.5 --- mods/services-pane/hooks/pytest.ts | 2 +- mods/services-pane/tests/pytest.test.ts | 6 ++++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/mods/services-pane/hooks/pytest.ts b/mods/services-pane/hooks/pytest.ts index 15c412f..e38caef 100644 --- a/mods/services-pane/hooks/pytest.ts +++ b/mods/services-pane/hooks/pytest.ts @@ -28,7 +28,7 @@ export const parsePytest = (output: string, at: number): TestRun | null => { if (match) summary = match[1].trim() } if (summary === null) return null - const passed = summary.includes('passed') && !summary.includes('failed') && !summary.includes('error') + const passed = /\bpassed\b/.test(summary) && !/\bfailed\b/.test(summary) && !/\berrors?\b/.test(summary) return { passed, summary, at } } diff --git a/mods/services-pane/tests/pytest.test.ts b/mods/services-pane/tests/pytest.test.ts index 3f7bf05..b3f4ad2 100644 --- a/mods/services-pane/tests/pytest.test.ts +++ b/mods/services-pane/tests/pytest.test.ts @@ -27,6 +27,12 @@ test('parsePytest reads the final summary line', async () => { expect(parsePytest('=== 2 passed, 1 error in 0.2s ===', 1)?.passed).toBe(false) }) +test('parsePytest treats xfailed and xpassed as passing', async () => { + expect(parsePytest('3 passed, 1 xfailed in 0.2s', 1)?.passed).toBe(true) + expect(parsePytest('2 passed, 1 xpassed in 0.1s', 1)?.passed).toBe(true) + expect(parsePytest('1 failed, 2 passed, 1 xfailed in 0.1s', 1)?.passed).toBe(false) +}) + test('parsePytest returns null without a summary', async () => { expect(parsePytest('no tests ran', 1)).toBeNull() expect(parsePytest('', 1)).toBeNull() From 77348d8b81196e1e51ce88f7dbca9a487e2c19e7 Mon Sep 17 00:00:00 2001 From: sario Date: Thu, 5 Nov 2026 13:55:34 +0100 Subject: [PATCH 11/16] feat(services-pane): live pane with per-service actions and status line Co-Authored-By: Claude Opus 5.5 --- mods/services-pane/hooks/plan.ts | 24 +- mods/services-pane/hooks/register.tsx | 114 ++++++- mods/services-pane/hooks/snapshot.ts | 209 +++++++++++++ mods/services-pane/hooks/state.ts | 53 ++++ mods/services-pane/hooks/view.tsx | 193 ++++++++++++ mods/services-pane/hooks/waves.ts | 11 +- mods/services-pane/tests/pane.test.tsx | 402 +++++++++++++++++++++++++ mods/services-pane/tests/plan.test.ts | 13 +- mods/services-pane/tests/waves.test.ts | 9 + mods/services-pane/types/index.d.ts | 2 + 10 files changed, 1013 insertions(+), 17 deletions(-) create mode 100644 mods/services-pane/hooks/snapshot.ts create mode 100644 mods/services-pane/hooks/state.ts create mode 100644 mods/services-pane/hooks/view.tsx create mode 100644 mods/services-pane/tests/pane.test.tsx diff --git a/mods/services-pane/hooks/plan.ts b/mods/services-pane/hooks/plan.ts index aa34084..c978830 100644 --- a/mods/services-pane/hooks/plan.ts +++ b/mods/services-pane/hooks/plan.ts @@ -107,19 +107,23 @@ export const parsePlan = (markdown: string): PlanInfo => { const basename = (p: string): string => p.slice(p.lastIndexOf('/') + 1) const datePrefix = (p: string): string => /^(\d{4}-\d{2}-\d{2})-/.exec(basename(p))?.[1] ?? '' +/** Root plans that declare `services:`, newest first: later date prefix, then later mtime. */ +export const rankPlans = ( + candidates: { path: string; mtimeMs: number; info: PlanInfo }[], +): string[] => + candidates + .filter(c => c.info.services !== null) + .sort((a, b) => { + const da = datePrefix(a.path) + const db = datePrefix(b.path) + return da === db ? b.mtimeMs - a.mtimeMs : da < db ? 1 : -1 + }) + .map(c => c.path) + /** The current root plan: the latest dated plan that declares `services:`; mtime breaks ties. */ export const pickCurrentPlan = ( candidates: { path: string; mtimeMs: number; info: PlanInfo }[], -): string | null => { - const usable = candidates.filter(c => c.info.services !== null) - if (usable.length === 0) return null - usable.sort((a, b) => { - const da = datePrefix(a.path) - const db = datePrefix(b.path) - return da === db ? b.mtimeMs - a.mtimeMs : da < db ? 1 : -1 - }) - return usable[0].path -} +): string | null => rankPlans(candidates)[0] ?? null export const servicePlanPath = (service: string, rootPlanPath: string): string => `${service}/docs/superpowers/plans/${basename(rootPlanPath)}` diff --git a/mods/services-pane/hooks/register.tsx b/mods/services-pane/hooks/register.tsx index 8ad3388..490a5f0 100644 --- a/mods/services-pane/hooks/register.tsx +++ b/mods/services-pane/hooks/register.tsx @@ -1,3 +1,113 @@ -import type { Register } from 'claude-code' +import type { EngineInterface, Register, Timer, ToolCallInput, ToolCallResult } from 'claude-code' -export const register: Register = () => {} +import { parsePytest, pytestService } from './pytest' +import { buildSnapshot, resolvePath } from './snapshot' +import { readSnapshot, readTests, recordEdit, recordTest, writeSnapshot } from './state' +import { drawPane, statusLine } from './view' + +const PANE = 'services-pane' +const TITLE = 'Services' +const REFRESH_MS = 2000 +const OBSERVED = new Set(['Edit', 'Write', 'NotebookEdit', 'Bash']) + +let pending: Timer | null = null + +async function refresh($: EngineInterface): Promise { + try { + const snap = await buildSnapshot($, await $.session.cwd()) + await writeSnapshot($, snap) + $.ui.status(statusLine(snap, await readTests($))) + } catch (err) { + $.ui.log(`services-pane: refresh failed: ${err instanceof Error ? err.message : String(err)}`, { to: 'debug' }) + } +} + +/** At most one refresh per REFRESH_MS: the first call in a window schedules it, the rest ride along. */ +function scheduleRefresh($: EngineInterface): void { + if (pending !== null) return + pending = $.clock.after(REFRESH_MS, () => { + pending = null + void refresh($) + }) +} + +/** The service a path (absolute, or relative to the repo) falls in, or null. */ +const serviceOf = (path: string, roots: string[], services: string[]): string | null => { + let rel = path + for (const root of roots) { + if (path.startsWith(`${root}/`)) { + rel = path.slice(root.length + 1) + break + } + } + if (rel.startsWith('/')) return null + const top = rel.replace(/^(\.\/)+/, '').split('/')[0] ?? '' + return services.includes(top) ? top : null +} + +/** What the call printed: core's model-facing text, else the Bash record's streams. */ +const outputOf = (ran: ToolCallResult): string => { + if (ran.deny !== undefined) return '' + if (typeof ran.text === 'string') return ran.text + const result: unknown = ran.result + if (typeof result === 'string') return result + if (typeof result === 'object' && result !== null) { + const { stdout, stderr } = result as { stdout?: unknown; stderr?: unknown } + return [stdout, stderr].filter((s): s is string => typeof s === 'string').join('\n') + } + return '' +} + +async function observe($: EngineInterface, e: ToolCallInput, ran: ToolCallResult): Promise { + const snap = await readSnapshot($) + if (snap === null) return + const services = snap.services.map(r => r.name) + const now = await $.clock.now() + if (e.tool === 'Bash') { + const service = pytestService(e.command, '', services) + const run = service === null ? null : parsePytest(outputOf(ran), now) + if (service !== null && run !== null) await recordTest($, service, run) + return + } + const path = e.tool === 'NotebookEdit' ? e.notebook_path : e.tool === 'Edit' || e.tool === 'Write' ? e.file_path : null + if (path === null) return + const cwd = await $.session.cwd() + const service = serviceOf(path, [resolvePath(cwd, snap.checkout), cwd], services) + if (service !== null) await recordEdit($, service, now) +} + +export const register: Register = on => { + on('session.start', async ($, e, next) => { + await $.command.register({ + name: 'services', + description: 'Toggle the Services pane: monorepo services, waves, planned vs actual changes, tests', + }) + void refresh($) + return next(e) + }) + + on('command.run', { command: 'services' }, async $ => { + if ((await $.ui.panes()).some(p => p.id === PANE)) { + await $.ui.close({ id: PANE }) + return { text: 'Services pane closed.' } + } + await $.ui.open({ id: PANE, title: TITLE }) + await refresh($) + return { text: 'Services pane opened.' } + }) + + // Observe only after the tool ran; never delay or change the call itself. + on('tool.call', async ($, e, next) => { + const ran = await next(e) + if (!OBSERVED.has(String(e.tool)) || ran.deny !== undefined) return ran + try { + await observe($, e, ran) + } catch { + // observing is best effort + } + scheduleRefresh($) + return ran + }).catch(($, e, next) => next(e)) + + on('ui.render', { component: 'Pane', requestId: PANE }, ($, e) => drawPane($, e, () => refresh($))) +} diff --git a/mods/services-pane/hooks/snapshot.ts b/mods/services-pane/hooks/snapshot.ts new file mode 100644 index 0000000..524bd1a --- /dev/null +++ b/mods/services-pane/hooks/snapshot.ts @@ -0,0 +1,209 @@ +import type { EngineInterface } from 'claude-code' + +import type { DependsOn, FileChange, PlanInfo, ServiceRow, Snapshot } from '../types/index' +import { groupByTopFolder, outOfScope, parseNumstat, parseWorktrees } from './git' +import { completedTasks, matchWorkspace } from './ledger' +import { parsePlan, rankPlans, servicePlanPath } from './plan' +import { readPinnedPlan } from './state' +import { computeWaves, waitingOn } from './waves' + +const PLANS_DIR = 'docs/superpowers/plans' +const SDD_DIR = '.superpowers/sdd' + +async function readText($: EngineInterface, path: string): Promise { + try { + return await $.fs.read(path) + } catch { + return null + } +} + +async function list($: EngineInterface, path: string) { + try { + return await $.fs.list(path) + } catch { + return [] + } +} + +/** Runs a read-only git command; null on a non-zero exit or when git cannot run. */ +async function git($: EngineInterface, cwd: string, ...args: string[]): Promise { + try { + const r = await $.process.run(['git', ...args], { cwd }) + return r.exitCode === 0 ? r.stdout : null + } catch { + return null + } +} + +async function discoverServices($: EngineInterface, cwd: string): Promise { + const found: string[] = [] + for (const entry of await list($, cwd)) { + if (entry.kind !== 'dir' || entry.name.startsWith('.')) continue + try { + if (await $.fs.exists(`${cwd}/${entry.name}/pyproject.toml`)) found.push(entry.name) + } catch { + // unreadable folder: not a service + } + } + return found.sort() +} + +async function readRootPlans($: EngineInterface, cwd: string) { + const out: { path: string; mtimeMs: number; info: PlanInfo }[] = [] + for (const entry of await list($, `${cwd}/${PLANS_DIR}`)) { + if (entry.kind !== 'file' || !entry.name.endsWith('.md')) continue + const path = `${PLANS_DIR}/${entry.name}` + const text = await readText($, `${cwd}/${path}`) + if (text !== null) out.push({ path, mtimeMs: entry.mtimeMs, info: parsePlan(text) }) + } + return out +} + +/** Every `.superpowers/sdd//plan-path` marker under `root`. */ +async function readMarkers($: EngineInterface, root: string) { + const markers: { dir: string; planPath: string }[] = [] + for (const entry of await list($, `${root}/${SDD_DIR}`)) { + if (entry.kind !== 'dir') continue + const planPath = await readText($, `${root}/${SDD_DIR}/${entry.name}/plan-path`) + if (planPath !== null) markers.push({ dir: entry.name, planPath: planPath.trim() }) + } + return markers +} + +/** The worktree whose ledger names one of `plans`, else `cwd`. */ +async function watchedCheckout($: EngineInterface, cwd: string, plans: string[]): Promise { + if (plans.length === 0) return cwd + const porcelain = await git($, cwd, 'worktree', 'list', '--porcelain') + const worktrees = porcelain === null ? [cwd] : parseWorktrees(porcelain) + for (const wt of worktrees) { + const markers = await readMarkers($, wt) + if (markers.some(m => plans.includes(m.planPath))) return wt + } + return cwd +} + +/** Committed-since-base plus uncommitted and untracked changes; null when git cannot say. */ +async function actualChanges($: EngineInterface, checkout: string): Promise { + let base: string | null = null + for (const ref of ['origin/HEAD', 'main', 'master']) { + base = (await git($, checkout, 'merge-base', 'HEAD', ref))?.trim() || null + if (base !== null) break + } + if (base === null) return null + const diff = await git($, checkout, 'diff', '--numstat', '--no-renames', base) + const untracked = await git($, checkout, 'ls-files', '--others', '--exclude-standard') + if (diff === null || untracked === null) return null + const fresh = untracked + .split('\n') + .filter(p => p.trim() !== '') + .map(path => ({ path, added: 0, removed: 0, isNew: true })) + return [...parseNumstat(diff), ...fresh] +} + +async function countTodos($: EngineInterface, checkout: string): Promise { + const texts: string[] = [] + for (const entry of await list($, `${checkout}/todos`)) { + if (entry.kind !== 'file' || !/-pending-.*\.md$/.test(entry.name)) continue + const text = await readText($, `${checkout}/todos/${entry.name}`) + if (text !== null) texts.push(text) + } + return texts +} + +/** Relative path from `from` to `to`, both absolute; `.` when equal. */ +export const relativePath = (from: string, to: string): string => { + const a = from.split('/').filter(Boolean) + const b = to.split('/').filter(Boolean) + let i = 0 + while (i < a.length && i < b.length && a[i] === b[i]) i++ + const parts = [...a.slice(i).map(() => '..'), ...b.slice(i)] + return parts.length === 0 ? '.' : parts.join('/') +} + +/** `rel` (as `relativePath` wrote it) resolved against the absolute `base`. */ +export const resolvePath = (base: string, rel: string): string => { + const parts = base.split('/').filter(Boolean) + for (const seg of rel.split('/')) { + if (seg === '..') parts.pop() + else if (seg !== '.' && seg !== '') parts.push(seg) + } + return `/${parts.join('/')}` +} + +export async function buildSnapshot($: EngineInterface, cwd: string): Promise { + const discovered = await discoverServices($, cwd) + const candidates = await readRootPlans($, cwd) + const plans = rankPlans(candidates) + const pinned = await readPinnedPlan($) + const rootPlan = pinned !== null && plans.includes(pinned) ? pinned : (plans[0] ?? null) + const planServices: Record | null = + rootPlan === null ? null : (candidates.find(c => c.path === rootPlan)?.info.services ?? null) + const planNames = planServices === null ? [] : Object.keys(planServices) + + const watchedPlans = rootPlan === null ? [] : [rootPlan, ...planNames.map(s => servicePlanPath(s, rootPlan))] + const checkout = await watchedCheckout($, cwd, watchedPlans) + const branch = (await git($, checkout, 'rev-parse', '--abbrev-ref', 'HEAD'))?.trim() || null + const changes = discovered.length === 0 ? null : await actualChanges($, checkout) + const groups = changes === null ? null : groupByTopFolder(changes) + const markers = await readMarkers($, checkout) + const todoTexts = await countTodos($, checkout) + const waves = planServices === null ? {} : computeWaves(planServices) + + const rowFor = async (name: string): Promise => { + const inPlan = planNames.includes(name) + let planPath: string | null = null + let info: PlanInfo | null = null + if (inPlan && rootPlan !== null) { + const path = servicePlanPath(name, rootPlan) + const text = (await readText($, `${checkout}/${path}`)) ?? (await readText($, `${cwd}/${path}`)) + if (text !== null) { + planPath = path + info = parsePlan(text) + } + } + const tasks = info?.tasks ?? [] + const isDone = info?.status === 'completed' + const workspace = planPath === null ? null : matchWorkspace(markers, planPath) + const ledger = workspace === null ? null : await readText($, `${checkout}/${SDD_DIR}/${workspace}/progress.md`) + const numbers = tasks.map(t => t.n) + const logged = ledger === null ? null : completedTasks(ledger) + const doneTasks = logged !== null ? numbers.filter(n => logged.has(n)) : isDone ? numbers : [] + return { + name, + inPlan, + planPath, + wave: inPlan && Object.hasOwn(waves, name) ? waves[name] : null, + waitingOn: [], + tasks, + done: doneTasks.length, + doneTasks, + total: tasks.length, + isDone, + changes: groups === null ? null : Object.hasOwn(groups, name) ? groups[name] : [], + outOfScope: + groups !== null && Object.hasOwn(groups, name) && outOfScope(name, discovered, planServices === null ? null : planNames), + todos: todoTexts.filter(t => t.includes(`${name}/`)).length, + } + } + + const services: ServiceRow[] = [] + if (discovered.length > 0) { + const planned = [...planNames].sort((a, b) => (waves[a] ?? 0) - (waves[b] ?? 0)) + for (const name of planned) services.push(await rowFor(name)) + for (const name of discovered) if (!planNames.includes(name)) services.push(await rowFor(name)) + if (planServices !== null) { + const done = new Set(services.filter(r => r.isDone).map(r => r.name)) + for (const row of services) if (row.inPlan) row.waitingOn = waitingOn(row.name, planServices, s => done.has(s)) + } + } + + return { + checkout: relativePath(cwd, checkout), + branch, + rootPlan, + plans, + services, + refreshedAt: await $.clock.now(), + } +} diff --git a/mods/services-pane/hooks/state.ts b/mods/services-pane/hooks/state.ts new file mode 100644 index 0000000..2046929 --- /dev/null +++ b/mods/services-pane/hooks/state.ts @@ -0,0 +1,53 @@ +import { atom, read, update } from 'claude-code' +import type { EngineInterface } from 'claude-code' + +import type { Snapshot, TestRun } from '../types/index' + +// The engine's scan lists state only from atoms written in the file that reads or +// writes them, so every state access goes through this file. +// Bump a tag when its value's shape changes: it declines what older code stored. +const snapshot = atom({ plugin: 'services-pane', key: 'snapshot' } as const, null, { shape: 'snapshot-1' }) +const selected = atom({ plugin: 'services-pane', key: 'selected' } as const, null, { shape: 'selected-1' }) +const pinnedPlan = atom({ plugin: 'services-pane', key: 'pinnedPlan' } as const, null, { shape: 'pinned-1' }) +const tests = atom({ plugin: 'services-pane', key: 'tests' } as const, {}, { shape: 'tests-1' }) +const edits = atom({ plugin: 'services-pane', key: 'edits' } as const, {}, { shape: 'edits-1' }) + +export async function readSnapshot($: EngineInterface): Promise { + return read($, snapshot) +} + +export async function writeSnapshot($: EngineInterface, value: Snapshot): Promise { + await update($, snapshot, () => value) +} + +export async function readSelected($: EngineInterface): Promise { + return read($, selected) +} + +export async function select($: EngineInterface, service: string): Promise { + await update($, selected, () => service) +} + +export async function readPinnedPlan($: EngineInterface): Promise { + return read($, pinnedPlan) +} + +export async function pinPlan($: EngineInterface, plan: string): Promise { + await update($, pinnedPlan, () => plan) +} + +export async function readTests($: EngineInterface): Promise> { + return read($, tests) +} + +export async function recordTest($: EngineInterface, service: string, run: TestRun): Promise { + await update($, tests, runs => ({ ...runs, [service]: run })) +} + +export async function readEdits($: EngineInterface): Promise> { + return read($, edits) +} + +export async function recordEdit($: EngineInterface, service: string, at: number): Promise { + await update($, edits, times => ({ ...times, [service]: at })) +} diff --git a/mods/services-pane/hooks/view.tsx b/mods/services-pane/hooks/view.tsx new file mode 100644 index 0000000..b9db9aa --- /dev/null +++ b/mods/services-pane/hooks/view.tsx @@ -0,0 +1,193 @@ +import type { EngineInterface, RenderElement, RenderInput } from 'claude-code' + +import type { ServiceRow, Snapshot, TestRun } from '../types/index' +import { isStale } from './pytest' +import { pinPlan, readEdits, readSelected, readSnapshot, readTests, select } from './state' + +const MAX_CHANGED_FILES = 20 + +const basename = (p: string): string => p.slice(p.lastIndexOf('/') + 1) +const plural = (n: number, word: string): string => `${n} ${word}${n === 1 ? '' : 's'}` + +const changesLabel = (row: ServiceRow): string | null => { + if (row.changes === null) return 'git unavailable' + if (row.changes.length === 0) return null + const added = row.changes.reduce((sum, c) => sum + c.added, 0) + const removed = row.changes.reduce((sum, c) => sum + c.removed, 0) + return `+${added} −${removed} ${plural(row.changes.length, 'file')}` +} + +const testsLabel = (run: TestRun | undefined, lastEditAt: number | undefined): string | null => + run === undefined ? null : `tests ${run.passed ? '✓' : '✗'}${isStale(run, lastEditAt) ? ' (stale)' : ''}` + +/** True when Start work applies: a per-service plan, nothing to wait on, not done. */ +export const canStart = (row: ServiceRow): boolean => row.planPath !== null && row.waitingOn.length === 0 && !row.isDone + +/** True when every service the plan names is done. */ +export const canShip = (snap: Snapshot): boolean => { + const planned = snap.services.filter(r => r.inPlan) + return snap.rootPlan !== null && planned.length > 0 && planned.every(r => r.isDone) +} + +/** The status line, e.g. `3 services · orders 1/4 · tests ✗ payments`; undefined without a root plan. */ +export const statusLine = (snap: Snapshot | null, tests: Record): string | undefined => { + if (snap === null || snap.rootPlan === null) return undefined + const planned = snap.services.filter(r => r.inPlan) + const parts = [plural(planned.length, 'service')] + if (canShip(snap)) parts.push('ready to ship') + else { + const active = planned.filter(canStart).map(r => `${r.name} ${r.done}/${r.total}`) + if (active.length > 0) parts.push(active.join(', ')) + } + const failing = snap.services.filter(r => Object.hasOwn(tests, r.name) && !tests[r.name].passed).map(r => r.name) + if (failing.length > 0) parts.push(`tests ✗ ${failing.join(', ')}`) + return parts.join(' · ') +} + +const rowText = ( + row: ServiceRow, + width: number, + isSelected: boolean, + tests: Record, + edits: Record, +): string => { + const mark = isSelected ? '▶' : row.isDone ? '●' : '○' + const parts = [`${mark} ${row.name.padEnd(width)}`] + if (row.inPlan) parts.push(row.planPath === null ? 'no plan' : `plan ${row.done}/${row.total}${row.isDone ? ' ✓' : ''}`) + const changes = changesLabel(row) + if (changes !== null) parts.push(changes) + if (row.outOfScope) parts.push('⚠ not in plan') + if (row.waitingOn.length > 0) parts.push(`waiting on ${row.waitingOn.join(', ')}`) + const test = testsLabel( + Object.hasOwn(tests, row.name) ? tests[row.name] : undefined, + Object.hasOwn(edits, row.name) ? edits[row.name] : undefined, + ) + if (test !== null) parts.push(test) + if (row.todos > 0) parts.push(plural(row.todos, 'todo')) + return parts.join(' ') +} + +/** Asks Claude in the person's words. */ +function submit($: EngineInterface, text: string) { + return $.prompt.submit({ text, asUser: true }) +} + +/** Runs `/workflows:work ` as if typed; a plugin may not submit a slash line as a prompt. */ +function work($: EngineInterface, args: string) { + return $.command.run({ command: 'workflows:work', args }) +} + +/** Draws the Services pane from the stored snapshot; `refresh` rebuilds it (after Switch plan). */ +export async function drawPane($: EngineInterface, e: RenderInput<'Pane'>, refresh: () => Promise) { + const { Box, Text, Button } = $.ui.resolve(e) + const snap = await readSnapshot($) + if (snap === null) return Loading services… + if (snap.services.length === 0) return No services found (single-service repo). + + const selected = await readSelected($) + const tests = await readTests($) + const edits = await readEdits($) + const width = Math.max(...snap.services.map(r => r.name.length)) + const current = snap.services.find(r => r.name === selected) ?? null + + const rowButton = (row: ServiceRow) => ( + + ) + + const sections: RenderElement[] = [] + if (snap.rootPlan === null) sections.push(...snap.services.map(rowButton)) + else { + let wave: number | null = null + for (const row of snap.services.filter(r => r.inPlan)) { + if (row.wave !== wave) { + wave = row.wave + sections.push({`Wave ${wave ?? '?'}`}) + } + sections.push(rowButton(row)) + } + const others = snap.services.filter(r => !r.inPlan) + if (others.length > 0) sections.push(Not in plan, ...others.map(rowButton)) + } + + const details: RenderElement[] = [] + if (current !== null) { + details.push( , {`${current.name} › tasks`}) + if (current.planPath === null) details.push( no per-service plan) + for (const task of current.tasks) { + details.push({` ${current.doneTasks.includes(task.n) ? '✓' : '·'} ${task.title}`}) + for (const file of task.files) details.push({` ${file}`}) + } + if (current.changes !== null && current.changes.length > 0) { + details.push({`${current.name} › changes`}) + for (const c of current.changes.slice(0, MAX_CHANGED_FILES)) { + details.push({` ${c.isNew ? 'new ' : `+${c.added} −${c.removed}`} ${c.path}`}) + } + if (current.changes.length > MAX_CHANGED_FILES) { + details.push({` … ${current.changes.length - MAX_CHANGED_FILES} more`}) + } + } + const run = Object.hasOwn(tests, current.name) ? tests[current.name] : undefined + if (run !== undefined) { + const lastEdit = Object.hasOwn(edits, current.name) ? edits[current.name] : undefined + details.push({`${testsLabel(run, lastEdit)} · ${run.summary}`}) + } + } + + const actions: RenderElement[] = [] + if (current !== null && current.planPath !== null && canStart(current)) { + const planPath = current.planPath + const name = current.name + actions.push( + ) @@ -131,7 +144,7 @@ export async function drawPane($: EngineInterface, e: RenderInput<'Pane'>, refre const run = Object.hasOwn(tests, current.name) ? tests[current.name] : undefined if (run !== undefined) { const lastEdit = Object.hasOwn(edits, current.name) ? edits[current.name] : undefined - details.push({`${testsLabel(run, lastEdit)} · ${run.summary}`}) + details.push({`${testsLabel(run, lastEdit, now)} · ${run.summary}`}) } } diff --git a/mods/services-pane/tests/pane.test.tsx b/mods/services-pane/tests/pane.test.tsx index 4bf9826..091bd3b 100644 --- a/mods/services-pane/tests/pane.test.tsx +++ b/mods/services-pane/tests/pane.test.tsx @@ -294,6 +294,25 @@ test('an observed pytest run shows on its service and goes stale after an edit', await ui.unmount() }) +test('a test result shows its age', async ($, on) => { + const clock = mock.clock(on, { now: 10_000 }) + world(on, monorepo()) + await $.session.start({ cwd: ROOT, surface: 'terminal', isInteractive: true }) + await clock.settle() + await $.tool.call({ tool: 'Bash', command: 'cd orders && poetry run pytest' }) + + const ageAt = async (ms: number) => { + await clock.set(10_000 + ms) + const ui = await $.ui.mount({ ...PANE, surface: 'terminal' }) + const text = (await ui.find({ key: 'orders' }))?.text + await ui.unmount() + return text + } + expect(await ageAt(30_000)).toContain('tests ✗ · just now') + expect(await ageAt(150_000)).toContain('tests ✗ · 2 min ago') + expect(await ageAt(2 * 3600_000)).toContain('tests ✗ · 2 h ago') +}) + test('tool calls coalesce into one refresh per two seconds', async ($, on) => { const clock = mock.clock(on) const w = world(on, monorepo()) From 0cdf9ae8b844a42eaef1660bdb35f473404ad050 Mon Sep 17 00:00:00 2001 From: sario Date: Thu, 5 Nov 2026 14:00:33 +0100 Subject: [PATCH 13/16] =?UTF-8?q?feat:=20v4.3.0=20=E2=80=94=20services-pan?= =?UTF-8?q?e=20mod?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- .claude-plugin/marketplace.json | 11 ++++- .claude-plugin/plugin.json | 2 +- CHANGELOG.md | 21 ++++++++++ README.md | 14 ++++++- mods/services-pane/.claude-plugin/plugin.json | 5 ++- mods/services-pane/README.md | 42 +++++++++++++++++++ scripts/validate.sh | 22 ++++++++++ 7 files changed, 112 insertions(+), 5 deletions(-) create mode 100644 mods/services-pane/README.md diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index e3c8630..27c8c73 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -9,7 +9,16 @@ "name": "py-micro-superpowers", "source": "./", "description": "Superpowers for Python microservices. 25 agents, 17 commands, 21 skills, 1 MCP server.", - "version": "4.2.0", + "version": "4.3.0", + "author": { + "name": "BaronVonSario" + } + }, + { + "name": "services-pane", + "source": "./mods/services-pane", + "description": "Live pane of monorepo services: planned vs actual changes, waves, progress, tests; start work per service.", + "version": "0.1.0", "author": { "name": "BaronVonSario" } diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index d415ea0..fa9dfdc 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "py-micro-superpowers", - "version": "4.2.0", + "version": "4.3.0", "description": "Superpowers for Python microservices \u2014 Django, FastAPI, pytest, PostgreSQL review agents and skills built on the superpowers plugin. 25 agents, 17 commands, 21 skills, 1 MCP server.", "hooks": { "SessionStart": [ diff --git a/CHANGELOG.md b/CHANGELOG.md index de5498e..4a1fb3b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,27 @@ All notable changes to the compound-engineering-feat-python plugin will be docum The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [4.3.0] - 2026-11-05 + +### Added +- `mods/services-pane/`: optional, separately installable mod (early-access mod API) + that shows monorepo services in a pane: planned vs actual changes, waves, plan + progress, last test result, with Start work and Ship actions. Listed as a second + entry in `.claude-plugin/marketplace.json`; install with + `/plugin install services-pane@py-micro-superpowers`. +- `/workflows:work --no-ship`: run tasks and the Python quality gate, then stop + before the PR. +- Ship-only run: `/workflows:work ` when every per-service plan is + `status: completed` skips execution and goes straight to the quality gate and one PR. +- `/workflows:work` sets `status: completed` on each per-service plan it finishes + once the quality gate passes. +- `scripts/validate.sh` checks mod manifests and hooks, and runs + `claude plugin validate` per mod when `claude` is installed. + +### Changed +- Per-service plans written by `/workflows:plan` no longer copy the `services:` + frontmatter, so a per-service run cannot pick up the whole dependency graph. + ## [4.2.0] - 2026-11-04 ### Added diff --git a/README.md b/README.md index 6148123..060e15f 100644 --- a/README.md +++ b/README.md @@ -91,7 +91,7 @@ Commands use the `workflows:` prefix to avoid collisions with Claude Code built- |---------|---------| | `/workflows:brainstorm` | Explore requirements before planning (delegates to `superpowers:brainstorming`) | | `/workflows:plan` | Generate implementation plans (delegates to `superpowers:writing-plans`) | -| `/workflows:work` | Execute a plan with Python quality gates (`--method subagent` → `superpowers:subagent-driven-development`, `--method native` → `superpowers:executing-plans`) | +| `/workflows:work` | Execute a plan with Python quality gates (`--method subagent` → `superpowers:subagent-driven-development`, `--method native` → `superpowers:executing-plans`; `--no-ship` stops after the quality gate, before the PR) | | `/workflows:review` | Multi-agent Python code review (delegates to `superpowers:requesting-code-review`) | Each workflow command: @@ -291,6 +291,16 @@ All workflow commands accept `--services` for automated invocation (skips all in Never ships with failing tests, never skips hooks. +## Services pane (optional) + +`mods/services-pane/` is a separate, optional plugin in this repository: a live pane beside the chat that shows each service's planned vs actual changes, run waves, plan progress and last test result, with buttons to start work per service. The main plugin does not depend on it. Early access: it uses Claude Code's mod API, which may change. + +``` +/plugin install services-pane@py-micro-superpowers +``` + +Open it with `/services`. See [mods/services-pane/README.md](mods/services-pane/README.md). + ## Try It Locally [`examples/monorepo/`](examples/monorepo/README.md) holds three tiny FastAPI services (`lib-common`, `orders`, `payments`). Spin up a throwaway copy as its own git repo and load this checkout of the plugin: @@ -310,7 +320,7 @@ The example README walks through brainstorm → plan → work → review on a cr See [CHANGELOG.md](CHANGELOG.md) for detailed version history. -**Current version: 4.2.0** — adds a demo monorepo for trying the plugin locally. +**Current version: 4.3.0** — adds the optional `services-pane` mod and `--no-ship` for `/workflows:work`. ## Contributing diff --git a/mods/services-pane/.claude-plugin/plugin.json b/mods/services-pane/.claude-plugin/plugin.json index 4d69254..ed8c217 100644 --- a/mods/services-pane/.claude-plugin/plugin.json +++ b/mods/services-pane/.claude-plugin/plugin.json @@ -2,5 +2,8 @@ "name": "services-pane", "version": "0.1.0", "description": "Live pane of monorepo services: planned vs actual changes, waves, progress, tests; start work per service.", - "types": "./types/index.d.ts" + "types": "./types/index.d.ts", + "author": { + "name": "BaronVonSario" + } } diff --git a/mods/services-pane/README.md b/mods/services-pane/README.md new file mode 100644 index 0000000..7ac55b4 --- /dev/null +++ b/mods/services-pane/README.md @@ -0,0 +1,42 @@ +# services-pane + +An optional mod for [py-micro-superpowers](../../README.md): a pane beside the chat that shows where each service in a monorepo stands during a multi-service `/workflows:plan` → `/workflows:work` run. + +> **Early access.** This mod uses Claude Code's mod API, which is still changing. The main plugin does not depend on it; removing or breaking the mod never affects `/workflows:*`. + +## Install + +``` +/plugin install services-pane@py-micro-superpowers +``` + +Open or close the pane with `/services`. A one-line summary also appears in the status line while a plan with `services:` is current (for example `3 services · orders 1/4 · tests ✗ payments`). + +## What the pane shows + +Services are the top-level folders that contain `pyproject.toml`. The current plan is the newest root plan in `docs/superpowers/plans/` with a `services:` section; use **Switch plan** to pick another. The header names the branch and the watched checkout (the worktree where the plan's work is happening, else the session folder). + +| Column | Meaning | +|--------|---------| +| `Wave N` | Run order from the plan's `depends_on`. Services in a cycle are treated as independent (wave 1). | +| `●` / `○` / `▶` | Done / not done / selected. | +| `plan 1/4` | Tasks complete out of tasks in the per-service plan. `✓` once the plan has `status: completed`. `no plan` when the service has no per-service plan file. | +| `+18 −0 1 file` | Actual changes from git: merge-base with the default branch plus uncommitted, per service. `git unavailable` outside a git repo. | +| `⚠ not in plan` | The service folder has changes but is not in the current plan's `services:`. | +| `waiting on X` | Dependencies not yet done. | +| `tests ✓ · 2 min ago` | Last pytest run Claude made in that service, with its age. `(stale)` is added once files in the service change after the run. Observed only; the mod never runs tests. | + +Select a service to see its tasks and changed files below the list. + +## Actions + +- **Start work**: submits `/workflows:work --services --no-ship`, the same as typing it. Never passes `--method`. Hidden while the service is waiting, done, or has no plan. +- **Ship**: shown once every service in the plan is done. Submits `/workflows:work `, which opens one PR for the branch. +- **Open plan**: asks Claude to show the plan file. +- **Show diff**: asks Claude to summarize that service's changes. + +Start work and Ship go through the engine's command API. + +## Limits + +Read-only git (`rev-parse`, `merge-base`, `diff --numstat`, `ls-files`, `worktree list`). It never commits, edits files or runs tests. Without a plan that has `services:`, the pane lists services and actual changes but shows no waves and no actions. In a single-service repo it says `No services found (single-service repo).` diff --git a/scripts/validate.sh b/scripts/validate.sh index 9927633..a765b65 100755 --- a/scripts/validate.sh +++ b/scripts/validate.sh @@ -75,6 +75,28 @@ for pair in "$agents_n agents" "$commands_n commands" "$skills_n skills"; do echo "$desc" | grep -q "${pair}" || err "plugin.json description does not say '${pair}' (filesystem truth)" done +# 8. Mods under mods/: manifest parses and names an existing hooks module +for manifest in mods/*/.claude-plugin/plugin.json; do + [ -f "$manifest" ] || continue + mod="$(dirname "$(dirname "$manifest")")" + python3 - "$manifest" <<'PY' || err "$manifest: invalid JSON or missing name/version" +import json, sys +d = json.load(open(sys.argv[1])) +assert d.get("name") and d.get("version") +PY + hooks="$mod/hooks/hooks.json" + if [ -f "$hooks" ]; then + while read -r m; do + [ -n "$m" ] || continue + [ -f "$mod/hooks/$m" ] || err "$hooks: module '$m' does not exist" + done < <(python3 -c 'import json,sys; [print(m) for m in json.load(open(sys.argv[1])).get("modules", [])]' "$hooks" 2>/dev/null || { err "$hooks: invalid JSON"; }) + fi + # CI has no `claude`; skip silently there + if command -v claude >/dev/null 2>&1; then + claude plugin validate "$mod" >/dev/null 2>&1 || err "claude plugin validate failed for $mod" + fi +done + if [ "$fail" -ne 0 ]; then echo; echo "Validation FAILED." >&2; exit 1 fi From 6bbb46921ec45fbf4da3c0d5102acb7fe7984eec Mon Sep 17 00:00:00 2001 From: sario Date: Thu, 5 Nov 2026 14:01:55 +0100 Subject: [PATCH 14/16] fix(validate): fail the build on invalid mod hooks.json Co-Authored-By: Claude Opus 5.5 --- scripts/validate.sh | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/scripts/validate.sh b/scripts/validate.sh index a765b65..3683cbb 100755 --- a/scripts/validate.sh +++ b/scripts/validate.sh @@ -86,10 +86,12 @@ assert d.get("name") and d.get("version") PY hooks="$mod/hooks/hooks.json" if [ -f "$hooks" ]; then + mods_list=$(python3 -c 'import json,sys; [print(m) for m in json.load(open(sys.argv[1])).get("modules", [])]' "$hooks" 2>/dev/null) \ + || { err "$hooks: invalid JSON"; mods_list=""; } while read -r m; do [ -n "$m" ] || continue [ -f "$mod/hooks/$m" ] || err "$hooks: module '$m' does not exist" - done < <(python3 -c 'import json,sys; [print(m) for m in json.load(open(sys.argv[1])).get("modules", [])]' "$hooks" 2>/dev/null || { err "$hooks: invalid JSON"; }) + done <<<"$mods_list" fi # CI has no `claude`; skip silently there if command -v claude >/dev/null 2>&1; then From 2ffbef10a2783d7dbf0861f70a420d05ecdaa5d5 Mon Sep 17 00:00:00 2001 From: sario Date: Thu, 5 Nov 2026 14:10:12 +0100 Subject: [PATCH 15/16] =?UTF-8?q?fix(services-pane):=20final-review=20fixe?= =?UTF-8?q?s=20=E2=80=94=20staleness,=20refresh=20safety,=20button=20error?= =?UTF-8?q?s,=20idle=20age?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- mods/services-pane/README.md | 4 +- mods/services-pane/hooks/pytest.ts | 23 ++- mods/services-pane/hooks/register.tsx | 70 +++++--- mods/services-pane/hooks/snapshot.ts | 29 ++- mods/services-pane/hooks/state.ts | 2 +- mods/services-pane/hooks/view.tsx | 73 +++++--- mods/services-pane/tests/pane.test.tsx | 227 +++++++++++++++++++++--- mods/services-pane/tests/pytest.test.ts | 6 + mods/services-pane/types/index.d.ts | 4 + 9 files changed, 368 insertions(+), 70 deletions(-) diff --git a/mods/services-pane/README.md b/mods/services-pane/README.md index 7ac55b4..9140327 100644 --- a/mods/services-pane/README.md +++ b/mods/services-pane/README.md @@ -24,7 +24,7 @@ Services are the top-level folders that contain `pyproject.toml`. The current pl | `+18 −0 1 file` | Actual changes from git: merge-base with the default branch plus uncommitted, per service. `git unavailable` outside a git repo. | | `⚠ not in plan` | The service folder has changes but is not in the current plan's `services:`. | | `waiting on X` | Dependencies not yet done. | -| `tests ✓ · 2 min ago` | Last pytest run Claude made in that service, with its age. `(stale)` is added once files in the service change after the run. Observed only; the mod never runs tests. | +| `tests ✓ · 2 min ago` | Last pytest run Claude made in that service, with its age (refreshed every 60 s while the pane is open). `(stale)` is added once files in the service change after the run, by any means: an edit Claude makes, or a file rewritten by a formatter, pre-commit hook or another tool (seen through the changed files' modification times). The status line marks a stale failing run the same way. Observed only; the mod never runs tests. | Select a service to see its tasks and changed files below the list. @@ -35,7 +35,7 @@ Select a service to see its tasks and changed files below the list. - **Open plan**: asks Claude to show the plan file. - **Show diff**: asks Claude to summarize that service's changes. -Start work and Ship go through the engine's command API. +Start work and Ship go through the engine's command API, and are hidden when `/workflows:work` is not available in the session (for example when the main plugin is not installed). A press that fails shows a short toast. ## Limits diff --git a/mods/services-pane/hooks/pytest.ts b/mods/services-pane/hooks/pytest.ts index e38caef..f4391d2 100644 --- a/mods/services-pane/hooks/pytest.ts +++ b/mods/services-pane/hooks/pytest.ts @@ -7,13 +7,28 @@ const PLAIN_SUMMARY = /(\d+ (?:passed|failed|error)[^\n]*)/ const topFolder = (path: string): string => path.replace(/^(\.\/)+/, '').split('/')[0] ?? '' -/** Service a pytest command ran in: first `cd` target's top folder, else the cwd's; null if not pytest or no service. */ -export const pytestService = (command: string, cwdRel: string, services: string[]): string | null => { +/** `path` relative to the first of `roots` it lies under; a relative path as is; null for an absolute path outside them. */ +export const relativeTo = (path: string, roots: string[]): string | null => { + for (const root of roots) { + if (path === root) return '' + if (path.startsWith(`${root}/`)) return path.slice(root.length + 1) + } + return path.startsWith('/') ? null : path +} + +/** Service a pytest command ran in: first `cd` target's top folder (absolute targets under `roots`), else the cwd's. */ +export const pytestService = ( + command: string, + cwdRel: string, + services: string[], + roots: string[] = [], +): string | null => { if (!PYTEST_WORD.test(command)) return null const cd = CD_TARGET.exec(command) const target = cd ? (cd[1] ?? cd[2] ?? cd[3]) : undefined - if (target !== undefined) { - const top = topFolder(target) + const rel = target === undefined ? null : relativeTo(target, roots) + if (rel !== null) { + const top = topFolder(rel) if (services.includes(top)) return top } const cwdTop = topFolder(cwdRel) diff --git a/mods/services-pane/hooks/register.tsx b/mods/services-pane/hooks/register.tsx index 490a5f0..e024dd4 100644 --- a/mods/services-pane/hooks/register.tsx +++ b/mods/services-pane/hooks/register.tsx @@ -1,8 +1,8 @@ import type { EngineInterface, Register, Timer, ToolCallInput, ToolCallResult } from 'claude-code' -import { parsePytest, pytestService } from './pytest' +import { parsePytest, pytestService, relativeTo } from './pytest' import { buildSnapshot, resolvePath } from './snapshot' -import { readSnapshot, readTests, recordEdit, recordTest, writeSnapshot } from './state' +import { readEdits, readSnapshot, readTests, recordEdit, recordTest, writeSnapshot } from './state' import { drawPane, statusLine } from './view' const PANE = 'services-pane' @@ -10,37 +10,56 @@ const TITLE = 'Services' const REFRESH_MS = 2000 const OBSERVED = new Set(['Edit', 'Write', 'NotebookEdit', 'Bash']) -let pending: Timer | null = null +const AGE_TICK_MS = 60_000 +/** A pending refresh older than this was refused or lost: schedule a new one. */ +const PENDING_LOST_MS = 3 * REFRESH_MS + +let pendingSince: number | null = null +let ticker: Timer | null = null +let buildSeq = 0 async function refresh($: EngineInterface): Promise { + const mine = ++buildSeq try { const snap = await buildSnapshot($, await $.session.cwd()) + // A build that started later owns the state; this older one drops its result. + if (mine !== buildSeq) return await writeSnapshot($, snap) - $.ui.status(statusLine(snap, await readTests($))) + $.ui.status(statusLine(snap, await readTests($), await readEdits($))) } catch (err) { $.ui.log(`services-pane: refresh failed: ${err instanceof Error ? err.message : String(err)}`, { to: 'debug' }) } } -/** At most one refresh per REFRESH_MS: the first call in a window schedules it, the rest ride along. */ -function scheduleRefresh($: EngineInterface): void { - if (pending !== null) return - pending = $.clock.after(REFRESH_MS, () => { - pending = null +/** + * At most one refresh per REFRESH_MS: the first call in a window schedules it, the rest ride along. + * A timer that never fired (refused by a hook) is replaced once it is PENDING_LOST_MS old. + */ +async function scheduleRefresh($: EngineInterface): Promise { + const now = await $.clock.now() + if (pendingSince !== null && now - pendingSince < PENDING_LOST_MS) return + const since = now + pendingSince = since + $.clock.after(REFRESH_MS, () => { + if (pendingSince === since) pendingSince = null void refresh($) }) } +/** Redraws the pane once a minute while it is open, so test ages move while idle. */ +function startTicker($: EngineInterface): void { + if (ticker === null) ticker = $.clock.every(AGE_TICK_MS, () => $.ui.invalidate('ui.render')) +} + +function stopTicker(): void { + ticker?.cancel() + ticker = null +} + /** The service a path (absolute, or relative to the repo) falls in, or null. */ const serviceOf = (path: string, roots: string[], services: string[]): string | null => { - let rel = path - for (const root of roots) { - if (path.startsWith(`${root}/`)) { - rel = path.slice(root.length + 1) - break - } - } - if (rel.startsWith('/')) return null + const rel = relativeTo(path, roots) + if (rel === null) return null const top = rel.replace(/^(\.\/)+/, '').split('/')[0] ?? '' return services.includes(top) ? top : null } @@ -63,16 +82,17 @@ async function observe($: EngineInterface, e: ToolCallInput, ran: ToolCallResult if (snap === null) return const services = snap.services.map(r => r.name) const now = await $.clock.now() + const cwd = await $.session.cwd() + const roots = [resolvePath(cwd, snap.checkout), cwd] if (e.tool === 'Bash') { - const service = pytestService(e.command, '', services) + const service = pytestService(e.command, '', services, roots) const run = service === null ? null : parsePytest(outputOf(ran), now) if (service !== null && run !== null) await recordTest($, service, run) return } const path = e.tool === 'NotebookEdit' ? e.notebook_path : e.tool === 'Edit' || e.tool === 'Write' ? e.file_path : null if (path === null) return - const cwd = await $.session.cwd() - const service = serviceOf(path, [resolvePath(cwd, snap.checkout), cwd], services) + const service = serviceOf(path, roots, services) if (service !== null) await recordEdit($, service, now) } @@ -88,10 +108,12 @@ export const register: Register = on => { on('command.run', { command: 'services' }, async $ => { if ((await $.ui.panes()).some(p => p.id === PANE)) { + stopTicker() await $.ui.close({ id: PANE }) return { text: 'Services pane closed.' } } await $.ui.open({ id: PANE, title: TITLE }) + startTicker($) await refresh($) return { text: 'Services pane opened.' } }) @@ -105,9 +127,15 @@ export const register: Register = on => { } catch { // observing is best effort } - scheduleRefresh($) + await scheduleRefresh($) return ran }).catch(($, e, next) => next(e)) + // The person's close mark or key closes the pane too: stop the age ticker with it. + on('ui.close', { id: PANE }, ($, e, next) => { + stopTicker() + return next(e) + }) + on('ui.render', { component: 'Pane', requestId: PANE }, ($, e) => drawPane($, e, () => refresh($))) } diff --git a/mods/services-pane/hooks/snapshot.ts b/mods/services-pane/hooks/snapshot.ts index 524bd1a..223ebed 100644 --- a/mods/services-pane/hooks/snapshot.ts +++ b/mods/services-pane/hooks/snapshot.ts @@ -111,6 +111,30 @@ async function countTodos($: EngineInterface, checkout: string): Promise { + let newest: number | null = null + for (const c of changes) { + try { + const { mtimeMs } = await $.fs.stat(`${checkout}/${c.path}`) + if (newest === null || mtimeMs > newest) newest = mtimeMs + } catch { + // deleted or unreadable: it has no mtime to offer + } + } + return newest +} + +/** The registered name of `/workflows:work` (bare, else plugin-prefixed), or null. */ +async function findWorkCommand($: EngineInterface): Promise { + try { + const names = (await $.command.list()).map(c => c.name) + return names.find(n => n === 'workflows:work') ?? names.find(n => n.endsWith(':workflows:work')) ?? null + } catch { + return null + } +} + /** Relative path from `from` to `to`, both absolute; `.` when equal. */ export const relativePath = (from: string, to: string): string => { const a = from.split('/').filter(Boolean) @@ -169,6 +193,7 @@ export async function buildSnapshot($: EngineInterface, cwd: string): Promise t.n) const logged = ledger === null ? null : completedTasks(ledger) const doneTasks = logged !== null ? numbers.filter(n => logged.has(n)) : isDone ? numbers : [] + const changed = groups === null ? null : Object.hasOwn(groups, name) ? groups[name] : [] return { name, inPlan, @@ -180,9 +205,10 @@ export async function buildSnapshot($: EngineInterface, cwd: string): Promise t.includes(`${name}/`)).length, } } @@ -203,6 +229,7 @@ export async function buildSnapshot($: EngineInterface, cwd: string): Promise { return minutes < 60 ? `${minutes} min ago` : `${Math.floor(minutes / 60)} h ago` } -const testsLabel = (run: TestRun | undefined, lastEditAt: number | undefined, now: number): string | null => +/** When files in the service last changed, by an edit Claude made or any other means (mtime). */ +const changedAt = (row: ServiceRow, edits: Record): number | undefined => { + const times = [Object.hasOwn(edits, row.name) ? edits[row.name] : null, row.lastChangeAt].filter( + (t): t is number => t !== null, + ) + return times.length === 0 ? undefined : Math.max(...times) +} + +const testsLabel = (run: TestRun | undefined, lastChangeAt: number | undefined, now: number): string | null => run === undefined ? null - : `tests ${run.passed ? '✓' : '✗'}${isStale(run, lastEditAt) ? ' (stale)' : ''} · ${ageLabel(run.at, now)}` + : `tests ${run.passed ? '✓' : '✗'}${isStale(run, lastChangeAt) ? ' (stale)' : ''} · ${ageLabel(run.at, now)}` /** True when Start work applies: a per-service plan, nothing to wait on, not done. */ export const canStart = (row: ServiceRow): boolean => row.planPath !== null && row.waitingOn.length === 0 && !row.isDone @@ -39,7 +47,11 @@ export const canShip = (snap: Snapshot): boolean => { } /** The status line, e.g. `3 services · orders 1/4 · tests ✗ payments`; undefined without a root plan. */ -export const statusLine = (snap: Snapshot | null, tests: Record): string | undefined => { +export const statusLine = ( + snap: Snapshot | null, + tests: Record, + edits: Record, +): string | undefined => { if (snap === null || snap.rootPlan === null) return undefined const planned = snap.services.filter(r => r.inPlan) const parts = [plural(planned.length, 'service')] @@ -48,7 +60,9 @@ export const statusLine = (snap: Snapshot | null, tests: Record const active = planned.filter(canStart).map(r => `${r.name} ${r.done}/${r.total}`) if (active.length > 0) parts.push(active.join(', ')) } - const failing = snap.services.filter(r => Object.hasOwn(tests, r.name) && !tests[r.name].passed).map(r => r.name) + const failing = snap.services + .filter(r => Object.hasOwn(tests, r.name) && !tests[r.name].passed) + .map(r => (isStale(tests[r.name], changedAt(r, edits)) ? `${r.name} (stale)` : r.name)) if (failing.length > 0) parts.push(`tests ✗ ${failing.join(', ')}`) return parts.join(' · ') } @@ -70,7 +84,7 @@ const rowText = ( if (row.waitingOn.length > 0) parts.push(`waiting on ${row.waitingOn.join(', ')}`) const test = testsLabel( Object.hasOwn(tests, row.name) ? tests[row.name] : undefined, - Object.hasOwn(edits, row.name) ? edits[row.name] : undefined, + changedAt(row, edits), now, ) if (test !== null) parts.push(test) @@ -83,9 +97,18 @@ function submit($: EngineInterface, text: string) { return $.prompt.submit({ text, asUser: true }) } -/** Runs `/workflows:work ` as if typed; a plugin may not submit a slash line as a prompt. */ -function work($: EngineInterface, args: string) { - return $.command.run({ command: 'workflows:work', args }) +/** Runs `/workflows:work ` as if typed, under the name it is registered by; a plugin may not submit a slash line as a prompt. */ +function work($: EngineInterface, command: string, args: string) { + return $.command.run({ command, args }) +} + +/** Runs a press's action; a failure becomes a short toast instead of an unhandled rejection. */ +async function attempt($: EngineInterface, label: string, action: () => Promise): Promise { + try { + await action() + } catch (err) { + $.ui.toast(`services-pane: ${label} failed: ${err instanceof Error ? err.message : String(err)}`) + } } /** Draws the Services pane from the stored snapshot; `refresh` rebuilds it (after Switch plan). */ @@ -98,7 +121,7 @@ export async function drawPane($: EngineInterface, e: RenderInput<'Pane'>, refre const selected = await readSelected($) const tests = await readTests($) const edits = await readEdits($) - // Redrawn on every refresh, which keeps the age current without a timer of its own. + // Redrawn on every refresh and, while the pane is open, once a minute (register.tsx). const now = await $.clock.now() const width = Math.max(...snap.services.map(r => r.name.length)) const current = snap.services.find(r => r.name === selected) ?? null @@ -143,13 +166,13 @@ export async function drawPane($: EngineInterface, e: RenderInput<'Pane'>, refre } const run = Object.hasOwn(tests, current.name) ? tests[current.name] : undefined if (run !== undefined) { - const lastEdit = Object.hasOwn(edits, current.name) ? edits[current.name] : undefined - details.push({`${testsLabel(run, lastEdit, now)} · ${run.summary}`}) + details.push({`${testsLabel(run, changedAt(current, edits), now)} · ${run.summary}`}) } } const actions: RenderElement[] = [] - if (current !== null && current.planPath !== null && canStart(current)) { + const workCommand = snap.workCommand + if (workCommand !== null && current !== null && current.planPath !== null && canStart(current)) { const planPath = current.planPath const name = current.name actions.push( @@ -157,22 +180,28 @@ export async function drawPane($: EngineInterface, e: RenderInput<'Pane'>, refre key="start" variant="primary" label="Start work" - onPress={() => work($, `${planPath} --services ${name} --no-ship`)} + onPress={() => attempt($, 'Start work', () => work($, workCommand, `${planPath} --services ${name} --no-ship`))} />, ) } - if (canShip(snap) && snap.rootPlan !== null) { + if (workCommand !== null && canShip(snap) && snap.rootPlan !== null) { const rootPlan = snap.rootPlan - actions.push(