diff --git a/AGENTS.md b/AGENTS.md index 061453e2b..00216f50e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -70,10 +70,34 @@ You are working in the Codev project itself, with multiple development protocols - **ASPIR**: Autonomous SPIR (no human gates on spec/plan) - `codev/protocols/aspir/protocol.md` - **AIR**: Autonomous Implement & Review for small features - `codev/protocols/air/protocol.md` - **BUGFIX**: Bug fixes from GitHub issues - `codev/protocols/bugfix/protocol.md` +- **PIR**: Plan / Implement / Review — issue-driven with two pre-PR human gates (plan-approval, dev-approval) plus a post-PR `pr` gate. Lighter than SPIR; stronger than BUGFIX/AIR. Useful when a change needs design review before coding OR pre-PR testing of running code (e.g., mobile / UI / cross-platform). See `codev/protocols/pir/protocol.md`. - **EXPERIMENT**: Disciplined experimentation - `codev/protocols/experiment/protocol.md` - **MAINTAIN**: Codebase maintenance (code hygiene + documentation sync) - `codev/protocols/maintain/protocol.md` - **RESEARCH**: Multi-agent research with 3-way investigation, synthesis, and critique - `codev/protocols/research/protocol.md` +### File Resolution (How Codev Finds Protocols and Templates) + +Codev resolves protocol files, prompts, agent definitions, and roles through a four-tier lookup (highest priority first): + +1. `.codev/` — user override (project-local customization) +2. `codev/` — project-local copy (customized and checked in) +3. Runtime cache +4. **Installed package skeleton** — ships with `@cluesmith/codev` (the default for every standard protocol) + +**The absence of `codev/protocols//` on disk is not a missing reference** — it's the normal case for any protocol you haven't customized. The protocol resolves from the installed package's skeleton at runtime. Only protocols you want to customize need to live in your repo's `codev/protocols/`. + +**Implication for `codev update` and CLAUDE.md / AGENTS.md merges:** when an updated template references a protocol (e.g., PIR), do NOT drop the reference because `codev/protocols//` is absent locally. The protocol resolves via the package skeleton, and dropping the reference removes the protocol from the user's available-protocol list while it's still callable from the CLI. + +### Protocol Verification (When You Don't Recognize a Protocol Name) + +If the user mentions a protocol name you don't immediately recognize, verify against the CLI before responding: + +```bash +afx spawn --protocol --help +``` + +This succeeds if the protocol is registered (including via the skeleton fallback in tier 4 of the resolution chain) and errors helpfully otherwise. The CLI is the source of truth — defer to it when in doubt. + Key locations: - Protocol details: `codev/protocols/` (Choose appropriate protocol) - **Project tracking**: GitHub Issues (source of truth for all projects) @@ -144,6 +168,26 @@ validated: [gemini, codex, claude] **AIR uses GitHub Issues as source of truth.** Two phases: Implement → Review. See `codev/protocols/air/protocol.md`. +### Use PIR for (engineer-judged — based on the nature of the work, not its size): + +Pick PIR when ONE or BOTH of the following apply to a GitHub-issue-driven change: + +**1. The approach needs review before coding starts**: +- Root cause is ambiguous; multiple valid fixes exist +- Area is unfamiliar or high-blast-radius (shared utilities, auth, migrations, public APIs) +- Design-sensitive (affects conventions, patterns, architecture) +- Cheaper to redirect at plan time than at PR time + +**2. The implementation needs to be TESTED before a PR is created** (PR diff alone is insufficient): +- Mobile app changes (needs device testing on Android, iOS, possibly web) +- UI / UX changes (visual inspection, interaction flow, accessibility) +- Hardware-adjacent behavior (sensors, camera, permissions, notifications) +- Integration with external services that don't mock cleanly (OAuth, payments, analytics) +- User-journey changes that need a full-flow exercise +- Performance-sensitive changes that need profiling on the running app + +**PIR uses GitHub Issues as source of truth.** Three phases: Plan (gated by `plan-approval`) → Implement (gated by `dev-approval`) → Review (PR + CMAP-2 at PR, then gated by `pr` for merge synchronization — matching SPIR's pr-gate pattern but with no post-merge verify phase). Plan and review artifacts live in `codev/plans/` and `codev/reviews/` on the builder branch, ship to main with the merge. Review file is shaped identically to SPIR's (Summary + Architecture Updates + Lessons Learned + supporting sections) so `codev/reviews/` stays semantically consistent across protocols. Lighter than SPIR (no spec phase — the issue body is the implicit spec; consult footprint matches BUGFIX/AIR's "one consult at PR" pattern). Stronger than BUGFIX/AIR (two human gates pre-PR — the human reviews the running worktree at the `dev-approval` gate, not the PR diff post-creation). CMAP at the PR is a **single advisory pass** (`max_iterations: 1`) — no iterate-until-APPROVE loop; a `REQUEST_CHANGES` is escalated to the human at the `pr` gate, not auto-re-reviewed. The CMAP-2 footprint is a design invariant: porch's model precedence is *config > protocol*, so a project-wide `porch.consultation.models` (e.g. a SPIR-tuned 3-model list) silently inflates PIR — leave it unset or scope it per-protocol to preserve the BUGFIX/AIR-parity cost. See `codev/protocols/pir/protocol.md`. + ### Use SPIR for (new features): - Creating a **new feature from scratch** (no existing spec to amend) - New protocols or protocol variants @@ -286,7 +330,7 @@ When configured, each builder worktree (`.builders//`) becomes runnable — "worktree": { "symlinks": ["..."], // glob patterns of files to symlink from root into each new worktree "postSpawn": ["..."], // shell commands run inside each new worktree after createWorktree - "devCommand": "..." // consumed by `afx dev ` + "devCommand": "..." // consumed by `afx dev ` } } ``` @@ -301,10 +345,13 @@ When configured, each builder worktree (`.builders//`) becomes runnable — ```bash afx dev # start the dev server in 's worktree -afx dev --stop # stop the currently running dev PTY +afx dev main # start the dev server in the MAIN workspace (Codev-managed) +afx dev --stop # stop the currently running dev PTY (builder or main) ``` -Only one dev PTY runs at a time (by design — see "URLs are load-bearing" below). Running `afx dev` while another builder's dev is up prompts for swap. Same-builder requests print the existing terminal URL and exit. +Only one dev PTY runs at a time (by design — see "URLs are load-bearing" below), across **{main + all builders}**. `main` is a reserved target: it runs `worktree.devCommand` in the main checkout as a Codev-managed, swappable PTY, symmetric with builders. Starting any target while another is up prompts for swap (`afx dev ` while `main` runs, or vice-versa); same-target requests print the existing terminal URL and exit. Like builder dev, main dev is a **non-persistent** PTY — a Tower restart (`pnpm -w run local-install`, crash) kills it; re-run to restart. + +**Launch main dev via `afx dev main`, not a bare `pnpm dev`.** A manually-run `pnpm dev` at the repo root is invisible to Codev (the deliberate "never kill what it didn't spawn" policy) — start a builder dev while it holds the ports and the builder dev silently fails to bind, or worse serves main's code under the worktree URL. `afx dev main` makes it a managed PTY that swap-detection can cleanly stop first. This only helps if you use it *consistently*; a hand-started `pnpm dev` stays unmanaged. ### VSCode @@ -317,11 +364,16 @@ The same actions are available via right-click on any builder row in the Codev s - **Codev: Run Dev Server** — reads `worktree.devCommand` from `.codev/config.json`, asks Tower to spawn a dev PTY in the builder's worktree, and opens it as a VSCode terminal tab named `Codev: (dev)`. If another builder's dev is already running, you get a modal asking whether to swap. - **Codev: Stop Dev Server** — kills the running dev PTY and closes its tab. +The Codev sidebar's **Workspace** view also carries a dev server for *whatever folder this VSCode window is rooted at* (it is not "main"-specific): + +- **Start Dev Server** — runs `worktree.devCommand` for the current workspace. Target is resolved from the open folder: the main checkout → `main`; a `.builders//` worktree opened as its own window (e.g. via *Open Worktree as Workspace*) → that builder. Same single-slot swap model as builder dev (prompts if another dev is running). The row tooltip names the resolved target. +- **Stop Dev Server** — stops this workspace's dev server; the row appears only while it is running. Scoped to the resolved target — it does not touch other devs. + The three commands are also available from the command palette (Cmd+Shift+P). No default keybindings; bind via `keybindings.json` if you use them often. ### URLs are load-bearing -The dev PTY uses **the same ports and URLs as main** intentionally. OAuth callbacks, CORS allowlists, cookie scoping, CSP `connect-src`, webhook URLs are all keyed off origin — running the worktree on a different port would break them. Consequence: stop main's `pnpm dev` before `afx dev`. If you don't, the spawned dev fails at bind time with its own `EADDRINUSE`. +The dev PTY uses **the same ports and URLs as main** intentionally. OAuth callbacks, CORS allowlists, cookie scoping, CSP `connect-src`, webhook URLs are all keyed off origin — running the worktree on a different port would break them. Consequence: stop main's `pnpm dev` before `afx dev`. If you don't, the spawned dev fails at bind time with its own `EADDRINUSE`. Prefer `afx dev main` (or the Workspace view's *Start Dev Server* row) over a hand-run `pnpm dev` so Codev owns the PTY and swap-detection can stop it for you automatically. ### Cleanup semantics diff --git a/CLAUDE.md b/CLAUDE.md index 061453e2b..00216f50e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -70,10 +70,34 @@ You are working in the Codev project itself, with multiple development protocols - **ASPIR**: Autonomous SPIR (no human gates on spec/plan) - `codev/protocols/aspir/protocol.md` - **AIR**: Autonomous Implement & Review for small features - `codev/protocols/air/protocol.md` - **BUGFIX**: Bug fixes from GitHub issues - `codev/protocols/bugfix/protocol.md` +- **PIR**: Plan / Implement / Review — issue-driven with two pre-PR human gates (plan-approval, dev-approval) plus a post-PR `pr` gate. Lighter than SPIR; stronger than BUGFIX/AIR. Useful when a change needs design review before coding OR pre-PR testing of running code (e.g., mobile / UI / cross-platform). See `codev/protocols/pir/protocol.md`. - **EXPERIMENT**: Disciplined experimentation - `codev/protocols/experiment/protocol.md` - **MAINTAIN**: Codebase maintenance (code hygiene + documentation sync) - `codev/protocols/maintain/protocol.md` - **RESEARCH**: Multi-agent research with 3-way investigation, synthesis, and critique - `codev/protocols/research/protocol.md` +### File Resolution (How Codev Finds Protocols and Templates) + +Codev resolves protocol files, prompts, agent definitions, and roles through a four-tier lookup (highest priority first): + +1. `.codev/` — user override (project-local customization) +2. `codev/` — project-local copy (customized and checked in) +3. Runtime cache +4. **Installed package skeleton** — ships with `@cluesmith/codev` (the default for every standard protocol) + +**The absence of `codev/protocols//` on disk is not a missing reference** — it's the normal case for any protocol you haven't customized. The protocol resolves from the installed package's skeleton at runtime. Only protocols you want to customize need to live in your repo's `codev/protocols/`. + +**Implication for `codev update` and CLAUDE.md / AGENTS.md merges:** when an updated template references a protocol (e.g., PIR), do NOT drop the reference because `codev/protocols//` is absent locally. The protocol resolves via the package skeleton, and dropping the reference removes the protocol from the user's available-protocol list while it's still callable from the CLI. + +### Protocol Verification (When You Don't Recognize a Protocol Name) + +If the user mentions a protocol name you don't immediately recognize, verify against the CLI before responding: + +```bash +afx spawn --protocol --help +``` + +This succeeds if the protocol is registered (including via the skeleton fallback in tier 4 of the resolution chain) and errors helpfully otherwise. The CLI is the source of truth — defer to it when in doubt. + Key locations: - Protocol details: `codev/protocols/` (Choose appropriate protocol) - **Project tracking**: GitHub Issues (source of truth for all projects) @@ -144,6 +168,26 @@ validated: [gemini, codex, claude] **AIR uses GitHub Issues as source of truth.** Two phases: Implement → Review. See `codev/protocols/air/protocol.md`. +### Use PIR for (engineer-judged — based on the nature of the work, not its size): + +Pick PIR when ONE or BOTH of the following apply to a GitHub-issue-driven change: + +**1. The approach needs review before coding starts**: +- Root cause is ambiguous; multiple valid fixes exist +- Area is unfamiliar or high-blast-radius (shared utilities, auth, migrations, public APIs) +- Design-sensitive (affects conventions, patterns, architecture) +- Cheaper to redirect at plan time than at PR time + +**2. The implementation needs to be TESTED before a PR is created** (PR diff alone is insufficient): +- Mobile app changes (needs device testing on Android, iOS, possibly web) +- UI / UX changes (visual inspection, interaction flow, accessibility) +- Hardware-adjacent behavior (sensors, camera, permissions, notifications) +- Integration with external services that don't mock cleanly (OAuth, payments, analytics) +- User-journey changes that need a full-flow exercise +- Performance-sensitive changes that need profiling on the running app + +**PIR uses GitHub Issues as source of truth.** Three phases: Plan (gated by `plan-approval`) → Implement (gated by `dev-approval`) → Review (PR + CMAP-2 at PR, then gated by `pr` for merge synchronization — matching SPIR's pr-gate pattern but with no post-merge verify phase). Plan and review artifacts live in `codev/plans/` and `codev/reviews/` on the builder branch, ship to main with the merge. Review file is shaped identically to SPIR's (Summary + Architecture Updates + Lessons Learned + supporting sections) so `codev/reviews/` stays semantically consistent across protocols. Lighter than SPIR (no spec phase — the issue body is the implicit spec; consult footprint matches BUGFIX/AIR's "one consult at PR" pattern). Stronger than BUGFIX/AIR (two human gates pre-PR — the human reviews the running worktree at the `dev-approval` gate, not the PR diff post-creation). CMAP at the PR is a **single advisory pass** (`max_iterations: 1`) — no iterate-until-APPROVE loop; a `REQUEST_CHANGES` is escalated to the human at the `pr` gate, not auto-re-reviewed. The CMAP-2 footprint is a design invariant: porch's model precedence is *config > protocol*, so a project-wide `porch.consultation.models` (e.g. a SPIR-tuned 3-model list) silently inflates PIR — leave it unset or scope it per-protocol to preserve the BUGFIX/AIR-parity cost. See `codev/protocols/pir/protocol.md`. + ### Use SPIR for (new features): - Creating a **new feature from scratch** (no existing spec to amend) - New protocols or protocol variants @@ -286,7 +330,7 @@ When configured, each builder worktree (`.builders//`) becomes runnable — "worktree": { "symlinks": ["..."], // glob patterns of files to symlink from root into each new worktree "postSpawn": ["..."], // shell commands run inside each new worktree after createWorktree - "devCommand": "..." // consumed by `afx dev ` + "devCommand": "..." // consumed by `afx dev ` } } ``` @@ -301,10 +345,13 @@ When configured, each builder worktree (`.builders//`) becomes runnable — ```bash afx dev # start the dev server in 's worktree -afx dev --stop # stop the currently running dev PTY +afx dev main # start the dev server in the MAIN workspace (Codev-managed) +afx dev --stop # stop the currently running dev PTY (builder or main) ``` -Only one dev PTY runs at a time (by design — see "URLs are load-bearing" below). Running `afx dev` while another builder's dev is up prompts for swap. Same-builder requests print the existing terminal URL and exit. +Only one dev PTY runs at a time (by design — see "URLs are load-bearing" below), across **{main + all builders}**. `main` is a reserved target: it runs `worktree.devCommand` in the main checkout as a Codev-managed, swappable PTY, symmetric with builders. Starting any target while another is up prompts for swap (`afx dev ` while `main` runs, or vice-versa); same-target requests print the existing terminal URL and exit. Like builder dev, main dev is a **non-persistent** PTY — a Tower restart (`pnpm -w run local-install`, crash) kills it; re-run to restart. + +**Launch main dev via `afx dev main`, not a bare `pnpm dev`.** A manually-run `pnpm dev` at the repo root is invisible to Codev (the deliberate "never kill what it didn't spawn" policy) — start a builder dev while it holds the ports and the builder dev silently fails to bind, or worse serves main's code under the worktree URL. `afx dev main` makes it a managed PTY that swap-detection can cleanly stop first. This only helps if you use it *consistently*; a hand-started `pnpm dev` stays unmanaged. ### VSCode @@ -317,11 +364,16 @@ The same actions are available via right-click on any builder row in the Codev s - **Codev: Run Dev Server** — reads `worktree.devCommand` from `.codev/config.json`, asks Tower to spawn a dev PTY in the builder's worktree, and opens it as a VSCode terminal tab named `Codev: (dev)`. If another builder's dev is already running, you get a modal asking whether to swap. - **Codev: Stop Dev Server** — kills the running dev PTY and closes its tab. +The Codev sidebar's **Workspace** view also carries a dev server for *whatever folder this VSCode window is rooted at* (it is not "main"-specific): + +- **Start Dev Server** — runs `worktree.devCommand` for the current workspace. Target is resolved from the open folder: the main checkout → `main`; a `.builders//` worktree opened as its own window (e.g. via *Open Worktree as Workspace*) → that builder. Same single-slot swap model as builder dev (prompts if another dev is running). The row tooltip names the resolved target. +- **Stop Dev Server** — stops this workspace's dev server; the row appears only while it is running. Scoped to the resolved target — it does not touch other devs. + The three commands are also available from the command palette (Cmd+Shift+P). No default keybindings; bind via `keybindings.json` if you use them often. ### URLs are load-bearing -The dev PTY uses **the same ports and URLs as main** intentionally. OAuth callbacks, CORS allowlists, cookie scoping, CSP `connect-src`, webhook URLs are all keyed off origin — running the worktree on a different port would break them. Consequence: stop main's `pnpm dev` before `afx dev`. If you don't, the spawned dev fails at bind time with its own `EADDRINUSE`. +The dev PTY uses **the same ports and URLs as main** intentionally. OAuth callbacks, CORS allowlists, cookie scoping, CSP `connect-src`, webhook URLs are all keyed off origin — running the worktree on a different port would break them. Consequence: stop main's `pnpm dev` before `afx dev`. If you don't, the spawned dev fails at bind time with its own `EADDRINUSE`. Prefer `afx dev main` (or the Workspace view's *Start Dev Server* row) over a hand-run `pnpm dev` so Codev owns the PTY and swap-detection can stop it for you automatically. ### Cleanup semantics diff --git a/codev-skeleton/protocols/pir/builder-prompt.md b/codev-skeleton/protocols/pir/builder-prompt.md new file mode 100644 index 000000000..81a926977 --- /dev/null +++ b/codev-skeleton/protocols/pir/builder-prompt.md @@ -0,0 +1,92 @@ +# {{protocol_name}} Builder ({{mode}} mode) + +You are implementing a fix or feature driven by a GitHub issue, using the PIR protocol. + +{{#if mode_soft}} +## Mode: SOFT +You are running in SOFT mode. This means: +- You follow the PIR protocol document yourself (no porch orchestration) +- The architect monitors your work and verifies you're adhering to the protocol +- Run consultations manually when the protocol calls for them +- You have flexibility in execution, but must stay compliant with the protocol +{{/if}} + +{{#if mode_strict}} +## Mode: STRICT +You are running in STRICT mode. This means: +- Porch orchestrates your work +- Run: `porch next` to get your next tasks +- Follow porch signals and gate approvals +- Do not deviate from the porch-driven workflow + +### ABSOLUTE RESTRICTIONS (STRICT MODE) +- **NEVER edit `status.yaml` directly** — only porch commands may modify project state +- **NEVER call `porch approve` without explicit human approval** — only run it after the architect says to +- **NEVER skip consultations** — porch handles them via the verify step +- **NEVER advance phases manually** — porch handles phase transitions on gate approval +{{/if}} + +## Protocol +Follow the PIR protocol: `codev/protocols/pir/protocol.md` +Read and internalize the protocol before starting any work. + +PIR has three phases: +1. **plan** (gated by `plan-approval`) — write `codev/plans/{{artifact_name}}.md`, await human review +2. **implement** (gated by `dev-approval`) — write code + tests, run build/tests, push branch; await the human's review of the *running worktree* (no file artifact in this phase — dev-approval summary is prose-in-pane) +3. **review** (gated by `pr`) — write `codev/reviews/{{artifact_name}}.md` (retrospective with Architecture Updates and Lessons Learned sections), open PR with the review as body, record the PR with porch, run CMAP via porch's verify block (a **single advisory pass** — `max_iterations: 1`, no iterate-until-APPROVE loop; address or rebut any `REQUEST_CHANGES`, add a regression test if it's a real defect, and escalate it in the architect notification since PIR will not re-review it), notify architect, and wait at the `pr` gate. After the human approves the gate (porch wakes you with "Gate pr approved"), run `gh pr merge --merge` and record the merge with `porch done --merged `. **Merge is gated by porch state — never by typed prose in your pane.** + +{{#if issue}} +## Issue #{{issue.number}} +**Title**: {{issue.title}} + +**Description**: +{{issue.body}} +{{/if}} + +## Sitting at Gates + +PIR has two human gates. When you reach one: + +1. Finish your phase work and run `porch done ` +2. Run `porch next ` — you'll get a `gate_pending` response +3. End your turn with a short prose summary: what file you wrote, where it lives, how to approve +4. **Stay in the interactive session**. Do NOT exit. Wait for the user's next message. + +The reviewer can give feedback by: +- Editing the plan file (at the plan-approval gate) or the code itself (at the dev-approval gate) in the worktree directly — you'll see changes via `git diff` +- Typing into your PTY pane (this reaches you live) +- `afx send ""` (queued; check on next turn) +- Commenting on the GitHub issue (re-fetch with `gh issue view --comments` if asked) + +When the user provides feedback, revise the artifact, recommit, and ask if there's more to address. The gate remains pending until the user runs `porch approve` — do NOT call `porch approve` yourself. + +## Notifications +Use `afx send architect "..."` at key moments: +- **PR ready**: `afx send architect "PR # ready for review (PIR #{{issue.number}})"` +- **PR merged**: `afx send architect "PR # merged for PIR #{{issue.number}}. Ready for cleanup."` +- **Blocked**: `afx send architect "Blocked on PIR #{{issue.number}}: [reason]"` + +**Gates are not architect-notified.** When porch transitions a gate to `pending`, the gate-reached message (including the `porch approve --a-human-explicitly-approved-this` invocation) appears in YOUR pane as part of your normal output. That's the universal notification surface — visible whether the user is in VSCode, tmux, plain Terminal, or any other host. The user reads it directly from your pane (or runs `porch pending` from a shell) and approves themselves; the architect can't approve gates, so notifying it would be informational noise. + +## Handling Flaky Tests + +If you encounter **pre-existing flaky tests** (intermittent failures unrelated to your changes): +1. **DO NOT** edit `status.yaml` to bypass checks +2. **DO NOT** skip porch checks or use any workaround to avoid the failure +3. **DO** mark the test as skipped with a clear annotation (e.g., `it.skip('...') // FLAKY: skipped pending investigation`) +4. **DO** document each skipped flaky test in the review file under a `## Flaky Tests` section +5. Commit the skip and continue with your work + +## Resumption After Crash + +If your Claude session crashes mid-flow, Tower's `while true` loop will relaunch you with the same prompt. On startup: + +1. Run `porch next {{project_id}}` to learn what phase you're in +2. If `gate_pending`: read the latest plan file (plan-approval) or `git diff main` (dev-approval) plus any new GitHub issue comments; check `afx send` queue. Decide whether to revise or just announce you're back. +3. Otherwise: pick up where you left off + +## Getting Started + +1. Read the PIR protocol document (`codev/protocols/pir/protocol.md`) +2. Run `porch next {{project_id}}` to see what to do next +3. Begin work diff --git a/codev-skeleton/protocols/pir/consult-types/impl-review.md b/codev-skeleton/protocols/pir/consult-types/impl-review.md new file mode 100644 index 000000000..380bceee9 --- /dev/null +++ b/codev-skeleton/protocols/pir/consult-types/impl-review.md @@ -0,0 +1,78 @@ +# Implementation Review Prompt (PIR) + +## Context + +You are reviewing the implementation of a PIR protocol project before it reaches the `dev-approval` human gate. A builder has implemented the approved plan and written a dev-approval summary. Your job is to verify the implementation matches the plan and is ready for human review. + +## CRITICAL: Verify Before Flagging + +Before requesting changes for missing configuration, incorrect patterns, or framework issues: +1. **Check `package.json`** for actual dependency versions — framework conventions change between major versions +2. **Read the actual config files** (or confirm their deliberate absence) before flagging missing configs +3. **Do not assume** your training data reflects the version in use — verify against project files +4. If "Previous Iteration Context" is provided, read it carefully before re-raising concerns that were already disputed + +## Focus Areas + +1. **Plan Adherence** + - Does the implementation fulfill the approved plan? + - Are all "Files to Change" actually changed? + - Are the changes scoped to what the plan described, or has scope crept? + +2. **Code Quality** + - Is the code readable and maintainable? + - Are there obvious bugs? + - Are error cases handled appropriately? + - Is the change minimal — no unnecessary refactoring or unrelated tidy-ups? + +3. **Test Coverage** + - Are the tests adequate for the changes? + - Do tests cover both the main path and the edge cases the plan called out? + - For a bug fix: is there a regression test that would fail without the fix? + +4. **Review File Quality** + - Does `codev/reviews/-.md` exist and follow the template? + - Does it accurately describe what changed? + - Is "Things to Look At" honest about tricky spots? + - Is "How to Test Locally" specific enough that the human reviewer can act on it? + +5. **PIR-Specific Concerns** + - For UI / mobile / cross-platform changes: does the review file explain platform-specific behavior the human should verify? + - For changes with external integrations: are the integration points documented? + +## Verdict Format + +After your review, provide your verdict in exactly this format: + +``` +--- +VERDICT: [APPROVE | REQUEST_CHANGES | COMMENT] +SUMMARY: [One-line summary of your assessment] +CONFIDENCE: [HIGH | MEDIUM | LOW] +--- +KEY_ISSUES: +- [Issue 1 or "None"] +- [Issue 2] +... +``` + +**Verdict meanings:** +- `APPROVE`: Ready for human at the `dev-approval` gate +- `REQUEST_CHANGES`: Issues that must be fixed before reaching the human +- `COMMENT`: Minor suggestions, can proceed but note feedback + +## Scope + +- **DO** review the implementation against the approved plan +- **DO** flag missing regression tests for bug fixes +- **DO** flag obvious bugs, code smells, security issues +- **DO NOT** redesign the approach — that was settled at `plan-approval` +- **DO NOT** demand changes outside the plan's scope +- **DO NOT** request architecture-level refactors unless the change introduces a clear new problem + +## Notes + +- This is a pre-gate review; the human is the final authority +- Focus on "is this ready for someone else to test in a browser / simulator" +- If referencing line numbers, use `file:line` format +- The builder needs actionable feedback to iterate diff --git a/codev-skeleton/protocols/pir/consult-types/pr-review.md b/codev-skeleton/protocols/pir/consult-types/pr-review.md new file mode 100644 index 000000000..0eb5fb1d0 --- /dev/null +++ b/codev-skeleton/protocols/pir/consult-types/pr-review.md @@ -0,0 +1,65 @@ +# PR Review Prompt (PIR) + +## Context + +You are performing the CMAP-2 review of a PIR protocol PR. The builder has implemented an approved plan, the human has approved the `dev-approval` gate (meaning a human has run the code locally and tested it), and the PR has been opened. This is a single advisory pass (`max_iterations: 1`) — your verdict is surfaced to the human at the `pr` gate, who is the sole remaining reviewer; it is not auto-re-reviewed. + +## Focus Areas + +1. **Completeness** + - Is the PR body the review file content + `Fixes #`? + - Are all commits properly formatted (`[PIR #] ...`)? + - Does the diff match what the review file describes? + +2. **Test Status** + - Do all tests pass on the branch? + - Is test coverage adequate for the change? + - Are there skipped or flaky tests documented? + +3. **Code Quality** + - Any debug code left in? + - Any TODO comments that should be resolved? + - Any `// REVIEW:` markers that weren't addressed? + +4. **Branch Hygiene** + - Is the branch up to date with main? (If not, suggest a rebase.) + - Are commits atomic and well-described? + - Is the change diff a reasonable size for the issue scope? + +5. **Issue Linkage** + - Does the PR body contain `Fixes #` (or `Refs #` for partial fixes)? + - Without this, GitHub won't auto-close the issue on merge + +## Verdict Format + +After your review, provide your verdict in exactly this format: + +``` +--- +VERDICT: [APPROVE | REQUEST_CHANGES | COMMENT] +SUMMARY: [One-line summary of your assessment] +CONFIDENCE: [HIGH | MEDIUM | LOW] +--- +KEY_ISSUES: +- [Issue 1 or "None"] +- [Issue 2] +... +``` + +**Verdict meanings:** +- `APPROVE`: Ready to merge +- `REQUEST_CHANGES`: Issues to fix before merging +- `COMMENT`: Minor items, can merge but note feedback + +## Scope + +- **DO** flag missing `Fixes #` lines +- **DO** flag obvious problems the human reviewer at the gate might have missed +- **DO NOT** redesign the approach — that was settled at `plan-approval` and validated at `dev-approval` +- **DO NOT** demand changes the human reviewer already accepted at the `dev-approval` gate (the human ran the code and approved it; you didn't) + +## Notes + +- The human at the `dev-approval` gate is the primary reviewer for behavior; you are the secondary reviewer for hygiene and edge cases +- Focus on "what would an integration reviewer catch that the gate reviewer missed" +- If referencing line numbers, use `file:line` format diff --git a/codev-skeleton/protocols/pir/prompts/implement.md b/codev-skeleton/protocols/pir/prompts/implement.md new file mode 100644 index 000000000..0d8ff9e5b --- /dev/null +++ b/codev-skeleton/protocols/pir/prompts/implement.md @@ -0,0 +1,156 @@ +# IMPLEMENT Phase Prompt + +You are executing the **IMPLEMENT** phase of the PIR protocol. + +## Your Goal + +Implement the approved plan, write tests, and pause at the `dev-approval` gate so the human can verify behavior by running the worktree locally. No file artifact is produced in this phase — the retrospective (review file) is written in the next phase, after the human has approved the running code. + +## Context + +- **Project ID**: {{project_id}} +- **Issue Number**: #{{issue.number}} +- **Issue Title**: {{issue.title}} +- **Plan File**: `codev/plans/{{artifact_name}}.md` (already approved) + +## Resumption Check (do this FIRST) + +Run `porch next {{project_id}}`. If the response is `gate_pending` on `dev-approval`, the code is already written and you're awaiting review. In that case: + +1. Check for feedback: + - `git diff main` — has the reviewer made any direct edits to your code? + - `gh issue view {{issue.number}} --comments` + - `afx send` queue messages +2. If feedback requires code changes: make them, re-run build + tests, recommit. +3. If feedback is a discussion question: respond and stay in the session. + +Otherwise (`tasks` response — first run), continue below. + +## Process + +### 1. Re-Read the Plan + +```bash +cat codev/plans/{{artifact_name}}.md +``` + +The plan is your authoritative scope. Stick to it. If you discover the plan is wrong while implementing, stop and signal `BLOCKED` rather than silently deviating. + +### 2. Implement the Code + +Follow the plan's "Files to Change" section. Apply the changes. + +**Code quality standards:** +- Self-documenting code (clear names, obvious structure) +- No commented-out code +- No debug prints +- Explicit error handling +- Follow project style guide + +**Commit cadence:** commit each logical unit separately. Use the format: + +``` +[PIR #{{issue.number}}] +``` + +**Never use `git add .` or `git add -A`.** Stage files explicitly: + +```bash +git add path/to/changed-file.ts +git commit -m "[PIR #{{issue.number}}] ..." +``` + +### 3. Write Tests + +The plan's "Test Plan" section lists what to verify. Write the corresponding tests: + +- Unit tests for new functions +- Integration tests for cross-component flows +- Regression tests for any bug fixes + +**Test quality:** +- Test behavior, not implementation +- Avoid overmocking — only mock external dependencies (APIs, databases, file systems) +- Use real implementations for internal module boundaries + +### 4. Verify Everything Works + +```bash +npm run build # or project equivalent +npm test # or project equivalent +``` + +Both MUST pass before signaling phase complete. If a test is flaky (intermittent failure unrelated to your changes), skip it with annotation — you'll document each skipped test in the review file in the next phase. + +**Flaky ≠ pre-existing unrelated failure.** If the suite surfaces a *deterministic* failure your diff did not cause (e.g., a stale test broken by another team's earlier refactor, or a type error in an unrelated package), that is **out of scope** — do not fix it, skip it, or quarantine it to force a green. Note it for the review file's Lessons Learned and proceed. Porch's gate `checks` are narrow structural assertions, not a full-suite proof; making an unrelated red go green is scope creep, not diligence. + +### 5. Push Your Branch + +```bash +git push -u origin "$(git branch --show-current)" +``` + +So the reviewer can pull / inspect from elsewhere if they want. + +### 6. Signal Phase Complete + +```bash +porch done {{project_id}} +porch next {{project_id}} +``` + +PIR's `implement` phase has no AI consult — the `dev-approval` gate becomes pending immediately, and the human is the sole reviewer of the running code. (CMAP-2 runs later, in the `review` phase, after the human approves the gate and the PR is opened.) + +### 7. End Your Turn With a Code-Review Summary (Prose, Not a File) + +When the gate goes pending, output a short prose summary in the pane to orient the human reviewer. This is **not** a committed file — it's a transient message to help them inspect the change. Structure: + +> **What changed**: 2–3 sentence summary. +> +> **Files**: `git diff --stat main` style list — paths and +/-. +> +> **Test results**: `npm run build` ✓, `npm test` ✓ (X tests, Y new). +> +> **Things to look at**: tricky spots, platform-specific behavior, anything you want the reviewer to focus on. +> +> **How to test locally**: VSCode → right-click builder → **Run Dev Server**, or `afx dev pir-{{project_id}}`. View diff via VSCode → **View Diff** (auto-detects the repo's default branch). +> +> Ready for review — type feedback here, or approve with `porch approve {{project_id}} dev-approval --a-human-explicitly-approved-this` (Cmd+K G in VSCode). + +Then **stay in the interactive session**. Do not exit. Wait for the user's next message. + +(Optional: if your team prefers an issue-thread record, you can also post a one-line comment on the GitHub issue pointing reviewers at the worktree branch. The summary itself stays in the pane — don't duplicate it as a committed file. That's the next phase's job, and that file will be a proper retrospective with arch + lessons updates, not a transient dev-approval note.) + +## Signals + +``` +PHASE_COMPLETE # Implementation + tests done; dev-approval gate becomes pending +BLOCKED:reason # Cannot proceed +``` + +## What NOT to Do + +- **Don't write `codev/reviews/-.md` in this phase** — it's the next phase's artifact, with a different shape (retrospective with arch + lessons updates) +- Don't add features not in the plan — scope creep is a `BLOCKED` signal, not a free expansion +- Don't run `porch approve` yourself +- Don't push to main — only to your builder branch +- Don't squash commits — let the merge commit preserve history +- Don't use `git add .` or `git add -A` +- Don't open the PR yet — that's the `review` phase +- Don't exit the interactive session at the gate + +## Handling Feedback at the Gate + +The reviewer will run your code via `afx dev` and test it. They may: + +- Approve immediately → porch advances to the `review` phase +- Type feedback in the pane / send via `afx send` / edit code in the worktree / comment on the issue +- Ask clarifying questions about specific files or behaviors + +When they provide feedback: + +1. If it's code feedback: make the change, run build + tests, recommit +2. If it's a discussion question: answer it in the pane +3. Don't re-run `porch done` unless porch's `verify` block needs to re-validate — porch will tell you + +The gate stays pending until the human approves. diff --git a/codev-skeleton/protocols/pir/prompts/plan.md b/codev-skeleton/protocols/pir/prompts/plan.md new file mode 100644 index 000000000..a40cdee2d --- /dev/null +++ b/codev-skeleton/protocols/pir/prompts/plan.md @@ -0,0 +1,133 @@ +# PLAN Phase Prompt + +You are executing the **PLAN** phase of the PIR protocol. + +## Your Goal + +Read the GitHub issue, investigate the codebase, and write a plan to `codev/plans/{{artifact_name}}.md`. The plan is reviewed by a human at the `plan-approval` gate before any code is written. + +## Context + +- **Project ID**: {{project_id}} +- **Issue Number**: #{{issue.number}} +- **Issue Title**: {{issue.title}} +- **Artifact**: `codev/plans/{{artifact_name}}.md` + +## Resumption Check (do this FIRST) + +Run `porch next {{project_id}}`. If the response is `gate_pending`, you have already drafted the plan and are awaiting review. In that case: + +1. Read your current plan file: `cat codev/plans/{{artifact_name}}.md` +2. Check for new feedback that may have arrived while you were idle: + - `git diff HEAD~1 codev/plans/{{artifact_name}}.md` — the reviewer may have edited the file directly + - `gh issue view {{issue.number}} --comments` — check for new comments + - Read any `afx send` queue messages +3. If feedback exists, revise the plan and recommit. If not, end the turn with a short "still awaiting review" message and stay in the interactive session. + +Otherwise (`tasks` response — this is your first run), continue with the steps below. + +## Process + +### 1. Read the Issue + +```bash +gh issue view {{issue.number}} +``` + +Understand what's being asked. For a bug, identify the symptom. For a feature, identify the desired outcome. + +### 2. Investigate the Codebase + +- Use Glob / Grep / Read to find the relevant code +- For a bug: trace the failure path, identify the root cause +- For a feature: find the existing patterns and integration points +- Note any existing utilities, components, or conventions you should reuse + +### 3. Write the Plan + +Create `codev/plans/{{artifact_name}}.md` where `` is a short kebab-case description of the change. Use this structure: + +```markdown +# PIR Plan: + +## Understanding + +What the issue is asking for, in your own words. For a bug, include the root cause you identified — back it up with file:line references. + +## Proposed Change + +The approach you intend to take. Be specific. If there are multiple valid approaches, pick one and explain why. + +## Files to Change + +Concrete file paths. Use `file:line` format for specific edits where possible. + +- `path/to/file.ts:42-55` — what changes +- `path/to/new-file.ts` — new file, what it does + +## Risks & Alternatives Considered + +- Risk: what could go wrong; mitigation +- Alternative: ; why rejected + +## Test Plan + +How to verify this works once implemented. The reviewer will use this at the `dev-approval` gate to test the running worktree. + +- Unit test: +- Manual: +- Cross-platform: +``` + +### 4. Commit and Push + +```bash +git add codev/plans/{{artifact_name}}.md +git commit -m "[PIR #{{issue.number}}] Plan draft" +git push -u origin "$(git branch --show-current)" +``` + +**Never use `git add .` or `git add -A`.** + +### 5. Signal Phase Complete + +```bash +porch done {{project_id}} +porch next {{project_id}} +``` + +`porch next` will respond with `gate_pending` on the `plan-approval` gate. Porch automatically notifies the architect. + +### 6. End Your Turn With a Prose Summary + +Output something like: + +> Plan written to `codev/plans/{{artifact_name}}.md` and committed. Ready for review — type any feedback here, edit the plan file directly in VSCode, or approve with `porch approve {{project_id}} plan-approval --a-human-explicitly-approved-this` (Cmd+K G in VSCode). + +Then **stay in the interactive session**. Do not exit. Wait for the user's next message. + +## Signals + +``` +PHASE_COMPLETE # Plan drafted (informational; the real signal is porch done) +BLOCKED:reason # Cannot proceed +``` + +## What NOT to Do + +- Don't write code — that's the implement phase +- Don't run `porch approve` yourself — only the human can approve the gate +- Don't post the plan content as a GitHub issue comment — the plan lives in the file, not the issue thread. A one-line pointer comment on the issue is fine if you think it helps the discussion. +- Don't use `git add .` or `git add -A` +- Don't exit the interactive session at the gate + +## Handling Feedback + +When the reviewer provides feedback (typed in pane, file-edit, `afx send`, or issue comment): + +1. Re-read the plan file (the user may have edited it) +2. Apply the requested changes to your plan +3. Recommit: `git add codev/plans/{{artifact_name}}.md && git commit -m "[PIR #{{issue.number}}] Plan revised"` +4. Push +5. Output a short "Revised — see commit X" message +6. Wait for next input — the gate remains pending until the human approves diff --git a/codev-skeleton/protocols/pir/prompts/review.md b/codev-skeleton/protocols/pir/prompts/review.md new file mode 100644 index 000000000..a5153902b --- /dev/null +++ b/codev-skeleton/protocols/pir/prompts/review.md @@ -0,0 +1,261 @@ +# REVIEW Phase Prompt + +You are executing the **REVIEW** phase of the PIR protocol. + +## Your Goal + +Write a retrospective at `codev/reviews/{{artifact_name}}.md` including **Summary**, **Architecture Updates**, and **Lessons Learned Updates** sections. Push, open a PR using the review file as the PR body, record the PR with porch, then signal completion — **porch runs CMAP-2 (Gemini + Codex) once** via the verify block. CMAP is a *single advisory pass* (`max_iterations: 1`): its verdicts are surfaced to the human at the `pr` gate, **not** an iterate-until-APPROVE loop. After the single pass the `pr` gate fires regardless of verdict; you notify the architect (leading with any REQUEST_CHANGES) and wait at the gate while the human merges on GitHub. + +The retrospective ships with the merged PR — it's durable team knowledge, searchable in `codev/reviews/` on `main`. + +## Context + +- **Project ID**: {{project_id}} +- **Issue Number**: #{{issue.number}} +- **Plan File**: `codev/plans/{{artifact_name}}.md` +- **Review File**: `codev/reviews/{{artifact_name}}.md` (you will write this) + +## Prerequisites + +- The `dev-approval` gate has been approved (you're here because `porch next` advanced you) +- Your branch contains the implementation commits +- Build and tests pass + +## Process + +### 1. Write the Review File + +Create `codev/reviews/{{artifact_name}}.md` with these sections: + +```markdown +# PIR Review: + +Fixes #{{issue.number}} + +## Summary + +2–3 sentence overview of what was implemented and why. The reader is someone scanning `codev/reviews/` six months from now trying to understand what this PR did. + +## Files Changed + +Output of `git diff --stat main`, formatted as a list: + +- `path/to/file.ts` (+12 / -3) +- `path/to/new-file.ts` (+45 / -0) + +## Commits + +Output of `git log main..HEAD --oneline`: + +- `` [PIR #{{issue.number}}] First change +- `` [PIR #{{issue.number}}] Second change + +## Test Results + +- `npm run build`: ✓ pass +- `npm test`: ✓ pass (X tests, Y new) +- Manual verification: + +## Architecture Updates + +What changed in `codev/resources/arch.md` (or why no changes were needed). If this PR introduced or modified an architectural pattern, document it here AND update `arch.md` in this same commit. If no architectural updates are needed (typical for small fixes), write a single line explaining why: "No arch changes — this PR fixes a typo without affecting module boundaries." + +Use the `update-arch-docs` skill if available (`.claude/skills/update-arch-docs/SKILL.md`) — it encodes the discipline for what NOT to include in arch docs. + +## Lessons Learned Updates + +What durable engineering wisdom emerged from this PR (or why no lessons were captured). Same pattern as Architecture Updates: if something is worth recording in `codev/resources/lessons-learned.md`, update both files in this commit. If not, explain why: "No lessons captured — change was mechanical." + +## Things to Look At During PR Review + +Tricky spots the PR reviewer should focus on. Honest — if a section was hard to get right, flag it. + +## How to Test Locally + +For reviewers pulling the branch: + +- **View diff**: VSCode sidebar → right-click builder pir-{{project_id}} → **Review Diff** (auto-detects the repo's default branch) +- **Run dev server**: VSCode sidebar → **Run Dev Server**, or `afx dev pir-{{project_id}}` +- **What to verify**: + +## Flaky Tests (if any) + +List any tests you skipped due to pre-existing flakiness, with file:line refs and a one-line rationale each. Omit this section if none. +``` + +### 2. Update Architecture / Lessons Docs (if applicable) + +If your "Architecture Updates" or "Lessons Learned Updates" section calls out real changes, update `codev/resources/arch.md` and/or `codev/resources/lessons-learned.md` accordingly. Use the `update-arch-docs` skill if it's available. + +If neither doc needs updating, your review file's sections still need to explain why — the porch `checks` block enforces section presence. + +### 3. Commit the Review File (and arch / lessons updates) + +```bash +git add codev/reviews/{{artifact_name}}.md +# Add arch.md / lessons-learned.md only if you changed them +git add codev/resources/arch.md # only if changed +git add codev/resources/lessons-learned.md # only if changed +git commit -m "[PIR #{{issue.number}}] Review + retrospective" +git push +``` + +### 4. Open the PR + +```bash +PR_TITLE="" +BRANCH="$(git branch --show-current)" + +gh pr create \ + --base main \ + --head "$BRANCH" \ + --title "$PR_TITLE" \ + --body-file codev/reviews/{{artifact_name}}.md +``` + +**Verify the PR body contains `Fixes #{{issue.number}}`** (it should — the review file has it at the top). If somehow missing, edit and re-apply: + +```bash +gh pr edit --body-file codev/reviews/{{artifact_name}}.md +``` + +**Exception**: if this PR only partially addresses the issue, use `Refs #{{issue.number}}` instead — the issue stays open until a follow-up PR closes it. + +### 4a. Record the PR with Porch + +Immediately after creating the PR, tell porch about it so `status.yaml` carries the PR number and branch. This is a metadata-only call — it does NOT advance the phase or trigger CMAP: + +```bash +porch done {{project_id}} --pr --branch "$(git branch --show-current)" +``` + +Without this, porch's `history:` for the project stays empty and downstream tooling (status views, analytics, audit trails) can't link the porch project to its GitHub PR. + +### 5. Signal Completion to Porch (porch runs CMAP-2) + +```bash +porch done {{project_id}} +``` + +Porch will: +1. Run the `pr_exists` / `review_has_arch_updates` / `review_has_lessons_updates` checks. +2. **Execute CMAP-2 (Gemini + Codex) automatically** via the protocol's `verify` block. Outputs land in `codev/projects/{{project_id}}-/{{project_id}}-gemini.txt` and `-codex.txt`. +3. CMAP runs **exactly once** (`max_iterations: 1`). Whatever the verdicts, porch records them in `status.yaml` and advances to the `pr` gate — there is **no automated re-review pass and no "stays in the review phase" loop**. `APPROVE` and `REQUEST_CHANGES` differ only in what you must surface to the human (steps 6–7), not in whether the gate fires. The output of `porch done` surfaces the verdicts. + +> **Why CMAP-2, not CMAP-3?** PIR's design parallels BUGFIX/AIR's consult footprint. The human already approved the *running* implementation at the `dev-approval` gate; CMAP at PR is a pre-merge hygiene + code-quality pass, not a functional review. + +### 6. Handle a REQUEST_CHANGES Verdict (single-pass — no automated re-review) + +PIR's CMAP is one advisory pass (`max_iterations: 1`). If a reviewer returns `REQUEST_CHANGES`, porch does **not** loop or re-run CMAP — it records the verdict and proceeds to the `pr` gate. There is no iter-2. The correctness backstop for a CMAP-flagged issue is therefore **(a)** your fix + a regression test and **(b)** the human's `pr`-gate review — *not* an independent model re-review. Treat that as load-bearing: a substantive finding you "address and rebut" gets no second AI opinion. + +For any `REQUEST_CHANGES`: + +1. Read the finding in full (`codev/projects/{{project_id}}-*/{{project_id}}-.txt`). +2. **Assess it honestly:** + - **Real defect** (correctness / cancellation / security / data-loss): fix it in code, add a regression test that fails without the fix, commit + push (the PR updates automatically — no new `gh pr create`). Then document the finding, your fix, and the pinning test in the review file's **"Things to Look At During PR Review"** section. + - **False positive / out of scope**: write a brief rebuttal in that same section explaining why no change is warranted. +3. Do **not** re-run `porch done` expecting another CMAP — `max_iterations: 1` means it will not re-review. Proceed to step 7. + +Whether you fixed it or rebutted it, a `REQUEST_CHANGES` that PIR will never re-check **must be escalated to the human at the `pr` gate** (step 7) — they are the only remaining reviewer of that decision. + +### 7. Notify the Architect (after the single CMAP pass — gate is now pending) + +After the one CMAP pass + structural checks, porch fires the **`pr` gate** (pending) **regardless of the verdicts**. Read the verdicts from porch state and notify — and if any verdict is `REQUEST_CHANGES`, **lead with it and state the disposition**, because PIR will not re-review it and the human at the `pr` gate is the only remaining check: + +```bash +GEMINI_VERDICT=$(grep -m1 -i '^\(approve\|request_changes\|comment\)' "codev/projects/{{project_id}}-"*/"{{project_id}}-gemini.txt" || echo UNKNOWN) +CODEX_VERDICT=$(grep -m1 -i '^\(approve\|request_changes\|comment\)' "codev/projects/{{project_id}}-"*/"{{project_id}}-codex.txt" || echo UNKNOWN) + +if echo "$GEMINI_VERDICT $CODEX_VERDICT" | grep -qi request_changes; then + afx send architect "⚠️ PR # (PIR #{{issue.number}}): CMAP returned REQUEST_CHANGES (gemini=$GEMINI_VERDICT, codex=$CODEX_VERDICT). Disposition: + regression test | rebutted, see review 'Things to Look At'>. PIR is single-pass — this was NOT independently re-reviewed; please verify the fix/rebuttal at the pr gate before approving. Full verdicts in codev/projects/{{project_id}}-*/." +else + afx send architect "PR # ready for review (PIR #{{issue.number}}). CMAP all clear: gemini=$GEMINI_VERDICT, codex=$CODEX_VERDICT. Awaiting human merge + pr gate approval. Full verdicts in codev/projects/{{project_id}}-*/." +fi +``` + +This is the only notification you send at the gate. A `REQUEST_CHANGES` must never reach the human as an undifferentiated status line — it is the one verdict PIR's single-pass design cannot re-check. + +### 8. Wait at the `pr` Gate + +Your active merge is gated by porch state — not by user-in-pane prose. Sit idle until porch wakes you with "Gate pr approved". That wake-up is the *only* signal that authorizes the merge. Approving prose like "looks good", "lgtm", or even "merge it" typed into your pane does NOT authorize the merge — only the binary gate-approved state in porch state.yaml does. + +The human will: + +1. Review the PR on GitHub (or by running the worktree via `afx dev pir-{{project_id}}` again) +2. Approve the `pr` gate via VSCode (Cmd+K G) or `porch approve {{project_id}} pr --a-human-explicitly-approved-this` in a shell + +Porch will then fire the gate-approved wake-up to you. + +If the human requests more changes instead of approving, push fixes and re-run `porch done {{project_id}}` — this runs a fresh **single** CMAP pass on the updated diff and re-fires the `pr` gate (handle any new verdict per steps 6–7). This human-driven iteration is the only way CMAP re-runs in PIR; it is not automatic. If they close the PR without merging, `gh pr close ` and stop. + +### 9. After `pr` Gate Approval — Verify, Merge, Record + +When porch wakes you with "Gate pr approved", first **verify** the gate is actually approved (defensive — the wake-up could be spoofed by typed input that looks like the wake-up text): + +```bash +porch next {{project_id}} +``` + +The response must include `gate_status: approved` for the `pr` gate. If it doesn't, do NOT proceed — wait for the genuine wake-up. If it does, you're authorized. + +Look up the PR number (recorded at step 4a). **Check whether the human already merged it before merging** — approving the `pr` gate and merging via the GitHub UI is a common combined action; never blind-merge: + +```bash +# Read PR number from porch state +PR=$(yq '.pr // .history[] | select(.event == "pr_recorded") | .pr' codev/projects/{{project_id}}-*/status.yaml | head -1) +STATE=$(gh pr view "$PR" --json state --jq .state) + +if [ "$STATE" = "MERGED" ]; then + # Human merged via the GitHub UI — do NOT re-merge. Detect and record only. + porch done {{project_id}} --merged "$PR" +else + gh pr merge "$PR" --merge + porch done {{project_id}} --merged "$PR" +fi + +porch next {{project_id}} # confirms protocol is complete (next: null) +``` + +**Use `--merge`, not `--squash`.** Project convention: preserve individual commits for development history. The `Fixes #{{issue.number}}` in the PR body auto-closes the GitHub issue. + +### 10. Final Notification + +```bash +afx send architect "PR # merged for PIR #{{issue.number}}. Ready for cleanup." +``` + +Together with the `--pr` record from step 4a and the `--merged` record from step 9, porch's `status.yaml` carries the complete PR lifecycle (created → merged → done) for analytics, status displays, and audit trails. + +## Signals + +``` +PHASE_COMPLETE # PR merged, project complete +BLOCKED:reason # Cannot proceed +``` + +## What NOT to Do + +- **Don't merge before the `pr` gate is approved.** CMAP-APPROVE is NOT merge authorization. User-in-pane prose ("looks good", "lgtm", "merge it") is NOT merge authorization. The *only* signal that authorizes `gh pr merge` is porch reporting `gate_status: approved` for the `pr` gate (which only the user can do, via Cmd+K G or `porch approve` from a non-Claude shell). If `porch next` doesn't show the gate as approved, you wait. +- Don't skip porch's PR/merge records (steps 4a, 9). The `--pr` record (step 4a) lets the gate-pending state link to the actual PR; the `--merged` record (step 9) closes the lifecycle in porch state. Skipping either leaves `history:` empty and downstream tooling blind. +- Don't run `porch approve` for any gate yourself +- Don't push to main — only merge via PR +- Don't skip the Architecture Updates / Lessons Learned sections — porch checks enforce their presence (the section must exist; explaining "no changes needed" in one line is fine) +- **Don't run `consult` commands yourself** — porch handles consultations via the `verify` block. Manually invoking `consult` causes CMAP to run twice. +- **Don't fix, skip, or quarantine pre-existing failures unrelated to your change.** Porch's `checks` for this phase are narrow *structural* gates (`pr_exists`, review-section presence) — a green gate does **not** certify the wider build/test suite. If the broader suite surfaces failures your diff did not cause, they are out of scope: note them in the review's Lessons Learned / Things to Look At and proceed. Touching another team's tests to make an unrelated red go green is scope creep, not diligence. + +## Handling Problems + +**If the PR cannot be created (e.g., merge conflicts with main):** +- Rebase on main: `git fetch origin main && git rebase origin/main` +- Resolve conflicts (do NOT use destructive shortcuts) +- Force-push with lease: `git push --force-with-lease` +- Re-run `gh pr create` + +**If porch's CMAP consults fail (e.g., model unavailable):** +- `porch done` will report the failure. Inspect `codev/projects/{{project_id}}-*/{{project_id}}-.txt` for the failure details. +- Re-run `porch done {{project_id}}` once — porch will retry the consult. +- If the model is persistently unavailable, notify the architect and ask whether to proceed without that model's verdict. They may direct you to skip via a manual override. + +**If the architect doesn't respond within a reasonable window:** +- Send one follow-up via `afx send architect "..."` after a few hours +- Do not auto-merge diff --git a/codev-skeleton/protocols/pir/protocol.json b/codev-skeleton/protocols/pir/protocol.json new file mode 100644 index 000000000..e2812d036 --- /dev/null +++ b/codev-skeleton/protocols/pir/protocol.json @@ -0,0 +1,125 @@ +{ + "$schema": "../../protocol-schema.json", + "name": "pir", + "alias": "plan-implement-review", + "version": "1.0.0", + "description": "PIR: Plan → Implement → Review with build-verify cycles and two pre-PR human gates (plan-approval, dev-approval), for GitHub-issue-driven work.", + "input": { + "type": "github-issue", + "required": true, + "default_for": ["--issue", "-i"] + }, + "hooks": { + "pre-spawn": { + "collision-check": true, + "comment-on-issue": "On it! Working on this with the PIR protocol (plan + dev-approval gates before PR)." + } + }, + "phases": [ + { + "id": "plan", + "name": "Plan", + "description": "Read the issue, investigate the codebase, write codev/plans/-.md, await plan-approval", + "type": "build_verify", + "build": { + "prompt": "plan.md", + "artifact": "codev/plans/${PROJECT_TITLE}.md" + }, + "verify": null, + "max_iterations": 1, + "on_complete": { + "commit": true, + "push": true + }, + "checks": { + "plan_exists": "test -f codev/plans/${PROJECT_TITLE}.md" + }, + "gate": "plan-approval", + "next": "implement" + }, + { + "id": "implement", + "name": "Implement", + "description": "Write code + tests, run build/test checks, await dev-approval on the running worktree", + "type": "build_verify", + "build": { + "prompt": "implement.md", + "artifact": "src/**/*.{ts,tsx,js,jsx,py,go,rs}" + }, + "verify": null, + "max_iterations": 1, + "on_complete": { + "commit": true, + "push": true + }, + "checks": { + "build": { + "command": "npm run build", + "on_fail": "retry", + "max_retries": 2 + }, + "tests": { + "command": "npm test", + "on_fail": "retry", + "max_retries": 2 + } + }, + "gate": "dev-approval", + "next": "review" + }, + { + "id": "review", + "name": "Review", + "description": "Write retrospective (arch + lessons), push, open PR with review as body, run CMAP, then wait at pr gate while the human merges on GitHub", + "type": "build_verify", + "build": { + "prompt": "review.md", + "artifact": "codev/reviews/${PROJECT_TITLE}.md" + }, + "verify": { + "type": "impl", + "models": ["gemini", "codex"], + "parallel": true + }, + "max_iterations": 1, + "on_complete": { + "commit": true, + "push": true + }, + "checks": { + "pr_exists": { + "command": "gh pr list --state all --head \"$(git branch --show-current)\" --json number --jq 'length' | grep -q '^[1-9]'", + "description": "PR must be created before signaling completion" + }, + "review_has_arch_updates": { + "command": "grep -q '## Architecture Updates' codev/reviews/${PROJECT_TITLE}.md", + "description": "Review must include '## Architecture Updates' section (what changed in arch.md, or why no changes needed)" + }, + "review_has_lessons_updates": { + "command": "grep -q '## Lessons Learned Updates' codev/reviews/${PROJECT_TITLE}.md", + "description": "Review must include '## Lessons Learned Updates' section (what changed in lessons-learned.md, or why no changes needed)" + } + }, + "gate": "pr", + "next": null + } + ], + "signals": { + "PHASE_COMPLETE": { + "description": "Signal current build phase is complete", + "transitions_to": "next_phase" + }, + "BLOCKED": { + "description": "Signal implementation is blocked", + "requires": "reason" + } + }, + "defaults": { + "mode": "strict", + "max_iterations": 1, + "consultation": { + "enabled": true, + "parallel": true + } + } +} diff --git a/codev-skeleton/protocols/pir/protocol.md b/codev-skeleton/protocols/pir/protocol.md new file mode 100644 index 000000000..537a1de9c --- /dev/null +++ b/codev-skeleton/protocols/pir/protocol.md @@ -0,0 +1,204 @@ +# PIR Protocol + +> **Plan → Implement → Review** for GitHub-issue-driven work that needs human review of *either* the approach (before code is written) *or* the implementation (before a PR exists), or both. Lighter than SPIR/ASPIR (no `specify` phase — the GitHub issue is the implicit spec) with the human dev-approval moved earlier (pre-PR instead of post-PR). Stronger than BUGFIX/AIR (two human gates before the PR). + +## When to Use PIR + +Pick PIR when working from a GitHub Issue and ONE or BOTH of the following apply — based on the *nature* of the change, not its size: + +### 1. The approach needs review before coding starts +- Root cause is ambiguous; multiple valid fixes exist +- Area is unfamiliar or high-blast-radius (shared utilities, auth, migrations, public APIs) +- Design-sensitive (affects conventions, patterns, architecture) +- Cheaper to redirect at plan time than at PR time + +### 2. The implementation needs to be tested before a PR is created +The PR diff alone is insufficient; the reviewer must *run* the code: +- Mobile app changes (needs device testing on Android, iOS, possibly web) +- UI / UX changes (visual inspection, interaction flow, accessibility) +- Hardware-adjacent behavior (sensors, camera, permissions, notifications) +- Integration with external services that don't mock cleanly (OAuth, payments, analytics) +- User-journey changes that need a full-flow exercise +- Performance-sensitive changes that need profiling on the running app + +### Use SPIR / ASPIR / BUGFIX / AIR instead when +- **SPIR / ASPIR**: the change is complex enough to warrant careful specification, multi-agent consultation at every phase, and the full spec → plan → implement → review ceremony with file artifacts. The driving issue is incidental — what matters is that the design work deserves a formal spec and the implementation deserves consult-driven review at each phase +- **BUGFIX**: small bug fix, no design review needed, diff-on-PR review is enough +- **AIR**: small feature from an issue, autonomous, diff-on-PR review is enough + +## How PIR Differs from SPIR + +PIR is structurally *SPIR minus the `specify` phase*, with the human dev-approval moved earlier (pre-PR instead of post-PR). + +| Aspect | SPIR | PIR | +|---|---|---| +| Phases | specify → plan → implement → review → verify | plan → implement → review | +| Spec artifact | `codev/specs/-.md` | GitHub Issue body (implicit spec) | +| Plan artifact | `codev/plans/-.md` | Same — committed on builder branch | +| Review artifact | `codev/reviews/-.md` (Summary + Architecture Updates + Lessons Learned, becomes PR body) | **Same shape** — `codev/reviews/-.md` with the same sections, also becomes PR body | +| Human gates | spec-approval, plan-approval, pr, verify-approval | plan-approval, dev-approval, pr | +| Where code is reviewed by the human | On the PR (post-creation) — read the diff | Pre-PR (at the `dev-approval` gate) — read the diff **and run the worktree locally** | + +The review file always includes Summary, Architecture Updates, and Lessons Learned sections so `codev/reviews/` stays semantically consistent across all protocols. PIR's lightness comes from skipping the `specify` phase (the issue body is the spec), not from cutting corners on the retrospective. + +The `dev-approval` gate is what makes PIR genuinely different: the human gates the *running implementation* via the worktree before the PR exists, instead of gating the PR after creation. + +## Phases + +``` +plan → implement → review +``` + +### Plan (gated by `plan-approval`) + +The builder: +1. Reads the GitHub issue and investigates the codebase +2. Writes `codev/plans/-.md` with: Understanding / Proposed change / Files to change / Risks & alternatives / Test plan +3. Commits the plan on the builder branch and pushes +4. Runs `porch done` and `porch next` — the `plan-approval` gate becomes pending +5. Sits at the interactive prompt waiting for review + +**Reviewer paths** (all equivalent): +- Open `codev/plans/-.md` in the worktree, read and / or edit directly, save +- Type feedback into the builder's PTY pane — the builder is alive in interactive mode +- `afx send ""` +- Comment on the GitHub issue (sidecar discussion) + +When satisfied, approve via VSCode's "Approve Gate" command (Cmd+K G) or: + +```bash +porch approve plan-approval --a-human-explicitly-approved-this +``` + +### Implement (gated by `dev-approval`) + +The builder: +1. Reads the approved plan file +2. Writes code and tests; runs build + tests via the `checks` block +3. *No AI consult on this phase* — the human at the `dev-approval` gate is the sole reviewer of the running code. Matches BUGFIX / AIR's pattern of "no consult on implementation, one consult at PR creation". +4. Pushes the branch +5. Runs `porch done` and `porch next` — the `dev-approval` gate becomes pending +6. Outputs a **prose** dev-approval summary in the PTY pane (Summary / Files / Test results / Things to look at / How to test locally). This is a transient message to orient the human reviewer — **not a committed file**. The retrospective file is written in the next phase, after the human approves the running code. +7. Sits at the interactive prompt + +**The reviewer's killer move**: run the worktree locally. + +- VSCode: right-click the builder in the Codev sidebar → **Run Dev Server** (spawns `afx dev ` via Tower) +- CLI: `afx dev ` + +The dev server uses **the same ports and URLs as main** intentionally (OAuth callbacks, CORS, cookie scoping all depend on consistent origins). Only one dev env runs at a time; stop main's `pnpm dev` before starting the worktree's, or use VSCode's **Stop Dev Server** to swap. + +Reviewer tests the change on real devices / browsers / simulators. When satisfied, approves via Cmd+K G or: + +```bash +porch approve dev-approval --a-human-explicitly-approved-this +``` + +### Review (gated by `pr`) + +The builder: +1. Writes `codev/reviews/-.md` with **Summary**, **Architecture Updates**, **Lessons Learned Updates**, plus the supporting sections (Files Changed, Commits, Test Results, Things to Look At, How to Test Locally). +2. Updates `codev/resources/arch.md` and/or `codev/resources/lessons-learned.md` if real changes need recording. If not, the review file's sections state "no changes needed" with a one-line explanation (the porch `checks` block enforces section presence, not content). +3. Commits the review file (and arch / lessons updates if any) and pushes +4. Opens a PR with `gh pr create`; PR body is the review file content + `Fixes #`. Records the PR with `porch done --pr --branch `. +5. Runs `porch done ` — porch's `verify` block runs CMAP-2 (Gemini + Codex, type=impl) as a **single advisory pass** (`max_iterations: 1`); CMAP outputs land in `codev/projects/-*/`. There is no iterate-until-APPROVE loop: whatever the verdicts, porch records them and advances to the `pr` gate. A `REQUEST_CHANGES` is not auto-re-reviewed — the builder addresses or rebuts it, adds a regression test if it's a real defect, and escalates it in the architect notification so the human verifies it at the `pr` gate. Outcomes are not auto-appended to the PR body; reviewers with the worktree read them from the projects dir. +6. The `pr` gate fires (pending) regardless of verdict. Builder notifies the architect once — leading with any `REQUEST_CHANGES` and its disposition (since PIR will not re-review it) rather than burying it in a flat status line. +7. Builder waits at the `pr` gate. The human reviews the PR on GitHub, then approves the `pr` gate (Cmd+K G or `porch approve pr --a-human-explicitly-approved-this`). Porch wakes the builder. +8. Builder verifies the gate is genuinely approved via `porch next` (defensive — typed prose can't trigger this branch, only real porch state does), then runs `gh pr merge --merge`, records via `porch done --merged `, and sends the cleanup-ready notification. Protocol complete (`next: null`). + +## Gates + +PIR uses porch's existing gate machinery. Gate names are opaque strings; no porch engine changes are needed. + +- **`plan-approval`** — pre-PR. Human reads the plan file (committed on the builder branch) and approves before any code is written. Gates are keyed by `(project_id, gate_name)` so the name is safe to share with other protocols. +- **`dev-approval`** — pre-PR. The human reviews the *running* worktree (via `afx dev`) before any PR exists. This is PIR's distinctive gate. +- **`pr`** — post-PR. Gates the merge step. The human reviews the PR on GitHub and approves this gate; porch wakes the builder, which then runs `gh pr merge`. The gate exists so the merge trigger is structured porch state (binary approved/not), not free-text prose typed into the builder's pane. Eliminates the self-merge bug class: builders can't infer authorization from ambiguous user input. + +When a gate becomes pending, porch broadcasts `overview-changed` via SSE. The VSCode Builders tree picks up the blocked state and renders it with a bell icon; a toast surfaces the new gate-pending event. Architect notification is *not* automatic — gates surface via the toast/sidebar (for IDE users) or by checking the builder pane / `porch pending` (for CLI users). The builder's job at any gate is to write the artifact, commit, signal completion, and wait — never to invoke `porch approve` itself (Claude refuses the `--a-human-explicitly-approved-this` flag by design). + +## Rejection / Feedback Model + +There is no formal `porch reject` command. Rejection works via the feedback-iterate pattern: + +1. Reviewer provides feedback (edit the plan file in VSCode, type in the builder pane, `afx send`, or issue comment) +2. Builder reads the feedback on its next turn, revises the artifact, recommits +3. The gate remains pending — porch doesn't advance until the human runs `porch approve` + +The same pattern works at both gates. + +## Builder Session Lifetime + +The builder is a long-running interactive Claude Code session in a PTY pane managed by Tower. The session is launched as `claude ""` (no `--print`) inside a `while true` restart loop. That form starts an interactive Claude REPL with the prompt as the first user message; after Claude finishes the prompted work it sits at the input prompt awaiting next user input. The outer `while true` loop only fires if Claude crashes — it is a crash-recovery safety net, not the gate-wait mechanism. + +This means typed input in the builder pane reaches the live Claude session immediately, exactly like any other interactive Claude Code conversation. There is no "session ended at gate" state to worry about under normal operation. + +## Configuration + +PIR uses the same `.codev/config.json` configuration as other protocols. The `worktree` block (from Issue 689) enables the at-gate dev-server review flow: + +```json +{ + "worktree": { + "symlinks": [".env.local", "packages/*/.env"], + "postSpawn": ["pnpm install --frozen-lockfile"], + "devCommand": "pnpm dev" + } +} +``` + +Without `worktree.devCommand`, `afx dev` won't work and the `dev-approval` gate degenerates to a diff-read — at which point you should probably use AIR or BUGFIX instead. + +## Multi-Agent Consultation + +- **plan**: human-only review. No AI consultation. +- **implement**: no AI consult — the human at the `dev-approval` gate is the sole reviewer of the running code. +- **review**: CMAP-2 (Gemini + Codex, type=impl) after the PR is opened, as a **single advisory pass** (`max_iterations: 1`). Same pattern as BUGFIX / AIR's PR-creation consult. + +CMAP at the PR is a single pass — there is **no iterate-until-APPROVE loop**. A `REQUEST_CHANGES` does not block or re-trigger CMAP; the builder addresses or rebuts it and escalates it to the human at the `pr` gate, who is the sole remaining reviewer of any resulting fix (CMAP does not re-check it). + +Net: PIR runs **two model calls per protocol run**, matching its BUGFIX/AIR peers — its distinguishing features are the two human gates (`plan-approval`, `dev-approval`), not AI-consult density. + +**The 2-model footprint is a design invariant.** porch's model precedence is *config > protocol* (`porch.consultation.models` in `.codev/config.json` overrides a protocol's declared `verify.models`). A project-wide setting tuned for SPIR (gemini + codex + claude) will silently inflate PIR to a 3-model pass, breaking the BUGFIX/AIR-parity cost story. To keep PIR at CMAP-2, leave `porch.consultation.models` unset or scope it per-protocol. + +To disable consultation entirely, say "without multi-agent consultation" when starting work. + +## Signals + +PIR uses the standard porch signal vocabulary: + +``` +PHASE_COMPLETE # Current phase build complete +BLOCKED:reason # Cannot proceed +``` + +Signals are informational for log readability. The state machine is driven by `porch done` and `porch next` CLI calls inside the builder turn. + +## Commit Messages + +Commits during PIR phases use the issue-driven format: + +``` +[PIR #] Plan draft +[PIR #] Implement avatar masking +[PIR #] Add Android-side regression test +``` + +The PR title follows the project's existing PR convention. + +## Branch Naming + +``` +builder/pir- +``` + +Example: `builder/pir-842` for a PIR spawn against GitHub issue #842. + +## File Locations + +``` +codev/plans/-.md # written in plan phase, on builder branch +codev/reviews/-.md # written in review phase (post-dev-approval-approval), on builder branch; becomes PR body +codev/projects/-/status.yaml # porch state, managed automatically +``` + +The plan and review files ship to `main` with the merged PR — durable, searchable, git-versioned. The review file includes Summary + Architecture Updates + Lessons Learned + supporting sections, so `codev/reviews/` stays semantically consistent across protocols. diff --git a/codev-skeleton/templates/AGENTS.md b/codev-skeleton/templates/AGENTS.md index d9dbe7953..037ff7eb8 100644 --- a/codev-skeleton/templates/AGENTS.md +++ b/codev-skeleton/templates/AGENTS.md @@ -12,10 +12,34 @@ This project uses **Codev** for AI-assisted development. - **ASPIR**: Autonomous SPIR — no human gates on spec/plan (`codev/protocols/aspir/protocol.md`) - **AIR**: Autonomous Implement & Review for small features (`codev/protocols/air/protocol.md`) - **BUGFIX**: Bug fixes from GitHub issues (`codev/protocols/bugfix/protocol.md`) +- **PIR**: Plan / Implement / Review — issue-driven with three human gates (plan-approval, dev-approval, pr) (`codev/protocols/pir/protocol.md`) - **EXPERIMENT**: Disciplined experimentation (`codev/protocols/experiment/protocol.md`) - **MAINTAIN**: Codebase maintenance (`codev/protocols/maintain/protocol.md`) - **RESEARCH**: Multi-agent research with 3-way investigation, synthesis, and critique (`codev/protocols/research/protocol.md`) +## File Resolution (How Codev Finds Protocols and Templates) + +Codev resolves protocol files, prompts, agent definitions, and roles through a four-tier lookup (highest priority first): + +1. `.codev/` — user override (project-local customization) +2. `codev/` — project-local copy (customized and checked in) +3. Runtime cache +4. **Installed package skeleton** — ships with `@cluesmith/codev` (the default for every standard protocol) + +**The absence of `codev/protocols//` on disk is not a missing reference** — it's the normal case for any protocol you haven't customized. The protocol resolves from the installed package's skeleton at runtime. Only protocols you want to customize need to live in your repo's `codev/protocols/`. + +**Implication for `codev update` and CLAUDE.md / AGENTS.md merges:** when an updated template references a protocol, do NOT drop the reference because `codev/protocols//` is absent locally. The protocol resolves via the package skeleton, and dropping the reference removes it from your available-protocol list while it's still callable from the CLI. + +## Protocol Verification (When You Don't Recognize a Protocol Name) + +If the user mentions a protocol name you don't immediately recognize, verify against the CLI before responding: + +```bash +afx spawn --protocol --help +``` + +This succeeds if the protocol is registered (including via the skeleton fallback in tier 4 of the resolution chain) and errors helpfully otherwise. The CLI is the source of truth — defer to it when in doubt. + ## Key Locations - **Specs**: `codev/specs/` - Feature specifications (WHAT to build) diff --git a/codev-skeleton/templates/CLAUDE.md b/codev-skeleton/templates/CLAUDE.md index 3aa624b09..cb1f25a5a 100644 --- a/codev-skeleton/templates/CLAUDE.md +++ b/codev-skeleton/templates/CLAUDE.md @@ -10,10 +10,34 @@ This project uses **Codev** for AI-assisted development. - **ASPIR**: Autonomous SPIR — no human gates on spec/plan (`codev/protocols/aspir/protocol.md`) - **AIR**: Autonomous Implement & Review for small features (`codev/protocols/air/protocol.md`) - **BUGFIX**: Bug fixes from GitHub issues (`codev/protocols/bugfix/protocol.md`) +- **PIR**: Plan / Implement / Review — issue-driven with three human gates (plan-approval, dev-approval, pr) (`codev/protocols/pir/protocol.md`) - **EXPERIMENT**: Disciplined experimentation (`codev/protocols/experiment/protocol.md`) - **MAINTAIN**: Codebase maintenance (`codev/protocols/maintain/protocol.md`) - **RESEARCH**: Multi-agent research with 3-way investigation, synthesis, and critique (`codev/protocols/research/protocol.md`) +## File Resolution (How Codev Finds Protocols and Templates) + +Codev resolves protocol files, prompts, agent definitions, and roles through a four-tier lookup (highest priority first): + +1. `.codev/` — user override (project-local customization) +2. `codev/` — project-local copy (customized and checked in) +3. Runtime cache +4. **Installed package skeleton** — ships with `@cluesmith/codev` (the default for every standard protocol) + +**The absence of `codev/protocols//` on disk is not a missing reference** — it's the normal case for any protocol you haven't customized. The protocol resolves from the installed package's skeleton at runtime. Only protocols you want to customize need to live in your repo's `codev/protocols/`. + +**Implication for `codev update` and CLAUDE.md / AGENTS.md merges:** when an updated template references a protocol, do NOT drop the reference because `codev/protocols//` is absent locally. The protocol resolves via the package skeleton, and dropping the reference removes it from your available-protocol list while it's still callable from the CLI. + +## Protocol Verification (When You Don't Recognize a Protocol Name) + +If the user mentions a protocol name you don't immediately recognize, verify against the CLI before responding: + +```bash +afx spawn --protocol --help +``` + +This succeeds if the protocol is registered (including via the skeleton fallback in tier 4 of the resolution chain) and errors helpfully otherwise. The CLI is the source of truth — defer to it when in doubt. + ## Key Locations - **Specs**: `codev/specs/` - Feature specifications (WHAT to build) diff --git a/codev/protocols/pir/builder-prompt.md b/codev/protocols/pir/builder-prompt.md new file mode 100644 index 000000000..81a926977 --- /dev/null +++ b/codev/protocols/pir/builder-prompt.md @@ -0,0 +1,92 @@ +# {{protocol_name}} Builder ({{mode}} mode) + +You are implementing a fix or feature driven by a GitHub issue, using the PIR protocol. + +{{#if mode_soft}} +## Mode: SOFT +You are running in SOFT mode. This means: +- You follow the PIR protocol document yourself (no porch orchestration) +- The architect monitors your work and verifies you're adhering to the protocol +- Run consultations manually when the protocol calls for them +- You have flexibility in execution, but must stay compliant with the protocol +{{/if}} + +{{#if mode_strict}} +## Mode: STRICT +You are running in STRICT mode. This means: +- Porch orchestrates your work +- Run: `porch next` to get your next tasks +- Follow porch signals and gate approvals +- Do not deviate from the porch-driven workflow + +### ABSOLUTE RESTRICTIONS (STRICT MODE) +- **NEVER edit `status.yaml` directly** — only porch commands may modify project state +- **NEVER call `porch approve` without explicit human approval** — only run it after the architect says to +- **NEVER skip consultations** — porch handles them via the verify step +- **NEVER advance phases manually** — porch handles phase transitions on gate approval +{{/if}} + +## Protocol +Follow the PIR protocol: `codev/protocols/pir/protocol.md` +Read and internalize the protocol before starting any work. + +PIR has three phases: +1. **plan** (gated by `plan-approval`) — write `codev/plans/{{artifact_name}}.md`, await human review +2. **implement** (gated by `dev-approval`) — write code + tests, run build/tests, push branch; await the human's review of the *running worktree* (no file artifact in this phase — dev-approval summary is prose-in-pane) +3. **review** (gated by `pr`) — write `codev/reviews/{{artifact_name}}.md` (retrospective with Architecture Updates and Lessons Learned sections), open PR with the review as body, record the PR with porch, run CMAP via porch's verify block (a **single advisory pass** — `max_iterations: 1`, no iterate-until-APPROVE loop; address or rebut any `REQUEST_CHANGES`, add a regression test if it's a real defect, and escalate it in the architect notification since PIR will not re-review it), notify architect, and wait at the `pr` gate. After the human approves the gate (porch wakes you with "Gate pr approved"), run `gh pr merge --merge` and record the merge with `porch done --merged `. **Merge is gated by porch state — never by typed prose in your pane.** + +{{#if issue}} +## Issue #{{issue.number}} +**Title**: {{issue.title}} + +**Description**: +{{issue.body}} +{{/if}} + +## Sitting at Gates + +PIR has two human gates. When you reach one: + +1. Finish your phase work and run `porch done ` +2. Run `porch next ` — you'll get a `gate_pending` response +3. End your turn with a short prose summary: what file you wrote, where it lives, how to approve +4. **Stay in the interactive session**. Do NOT exit. Wait for the user's next message. + +The reviewer can give feedback by: +- Editing the plan file (at the plan-approval gate) or the code itself (at the dev-approval gate) in the worktree directly — you'll see changes via `git diff` +- Typing into your PTY pane (this reaches you live) +- `afx send ""` (queued; check on next turn) +- Commenting on the GitHub issue (re-fetch with `gh issue view --comments` if asked) + +When the user provides feedback, revise the artifact, recommit, and ask if there's more to address. The gate remains pending until the user runs `porch approve` — do NOT call `porch approve` yourself. + +## Notifications +Use `afx send architect "..."` at key moments: +- **PR ready**: `afx send architect "PR # ready for review (PIR #{{issue.number}})"` +- **PR merged**: `afx send architect "PR # merged for PIR #{{issue.number}}. Ready for cleanup."` +- **Blocked**: `afx send architect "Blocked on PIR #{{issue.number}}: [reason]"` + +**Gates are not architect-notified.** When porch transitions a gate to `pending`, the gate-reached message (including the `porch approve --a-human-explicitly-approved-this` invocation) appears in YOUR pane as part of your normal output. That's the universal notification surface — visible whether the user is in VSCode, tmux, plain Terminal, or any other host. The user reads it directly from your pane (or runs `porch pending` from a shell) and approves themselves; the architect can't approve gates, so notifying it would be informational noise. + +## Handling Flaky Tests + +If you encounter **pre-existing flaky tests** (intermittent failures unrelated to your changes): +1. **DO NOT** edit `status.yaml` to bypass checks +2. **DO NOT** skip porch checks or use any workaround to avoid the failure +3. **DO** mark the test as skipped with a clear annotation (e.g., `it.skip('...') // FLAKY: skipped pending investigation`) +4. **DO** document each skipped flaky test in the review file under a `## Flaky Tests` section +5. Commit the skip and continue with your work + +## Resumption After Crash + +If your Claude session crashes mid-flow, Tower's `while true` loop will relaunch you with the same prompt. On startup: + +1. Run `porch next {{project_id}}` to learn what phase you're in +2. If `gate_pending`: read the latest plan file (plan-approval) or `git diff main` (dev-approval) plus any new GitHub issue comments; check `afx send` queue. Decide whether to revise or just announce you're back. +3. Otherwise: pick up where you left off + +## Getting Started + +1. Read the PIR protocol document (`codev/protocols/pir/protocol.md`) +2. Run `porch next {{project_id}}` to see what to do next +3. Begin work diff --git a/codev/protocols/pir/consult-types/impl-review.md b/codev/protocols/pir/consult-types/impl-review.md new file mode 100644 index 000000000..380bceee9 --- /dev/null +++ b/codev/protocols/pir/consult-types/impl-review.md @@ -0,0 +1,78 @@ +# Implementation Review Prompt (PIR) + +## Context + +You are reviewing the implementation of a PIR protocol project before it reaches the `dev-approval` human gate. A builder has implemented the approved plan and written a dev-approval summary. Your job is to verify the implementation matches the plan and is ready for human review. + +## CRITICAL: Verify Before Flagging + +Before requesting changes for missing configuration, incorrect patterns, or framework issues: +1. **Check `package.json`** for actual dependency versions — framework conventions change between major versions +2. **Read the actual config files** (or confirm their deliberate absence) before flagging missing configs +3. **Do not assume** your training data reflects the version in use — verify against project files +4. If "Previous Iteration Context" is provided, read it carefully before re-raising concerns that were already disputed + +## Focus Areas + +1. **Plan Adherence** + - Does the implementation fulfill the approved plan? + - Are all "Files to Change" actually changed? + - Are the changes scoped to what the plan described, or has scope crept? + +2. **Code Quality** + - Is the code readable and maintainable? + - Are there obvious bugs? + - Are error cases handled appropriately? + - Is the change minimal — no unnecessary refactoring or unrelated tidy-ups? + +3. **Test Coverage** + - Are the tests adequate for the changes? + - Do tests cover both the main path and the edge cases the plan called out? + - For a bug fix: is there a regression test that would fail without the fix? + +4. **Review File Quality** + - Does `codev/reviews/-.md` exist and follow the template? + - Does it accurately describe what changed? + - Is "Things to Look At" honest about tricky spots? + - Is "How to Test Locally" specific enough that the human reviewer can act on it? + +5. **PIR-Specific Concerns** + - For UI / mobile / cross-platform changes: does the review file explain platform-specific behavior the human should verify? + - For changes with external integrations: are the integration points documented? + +## Verdict Format + +After your review, provide your verdict in exactly this format: + +``` +--- +VERDICT: [APPROVE | REQUEST_CHANGES | COMMENT] +SUMMARY: [One-line summary of your assessment] +CONFIDENCE: [HIGH | MEDIUM | LOW] +--- +KEY_ISSUES: +- [Issue 1 or "None"] +- [Issue 2] +... +``` + +**Verdict meanings:** +- `APPROVE`: Ready for human at the `dev-approval` gate +- `REQUEST_CHANGES`: Issues that must be fixed before reaching the human +- `COMMENT`: Minor suggestions, can proceed but note feedback + +## Scope + +- **DO** review the implementation against the approved plan +- **DO** flag missing regression tests for bug fixes +- **DO** flag obvious bugs, code smells, security issues +- **DO NOT** redesign the approach — that was settled at `plan-approval` +- **DO NOT** demand changes outside the plan's scope +- **DO NOT** request architecture-level refactors unless the change introduces a clear new problem + +## Notes + +- This is a pre-gate review; the human is the final authority +- Focus on "is this ready for someone else to test in a browser / simulator" +- If referencing line numbers, use `file:line` format +- The builder needs actionable feedback to iterate diff --git a/codev/protocols/pir/consult-types/pr-review.md b/codev/protocols/pir/consult-types/pr-review.md new file mode 100644 index 000000000..0eb5fb1d0 --- /dev/null +++ b/codev/protocols/pir/consult-types/pr-review.md @@ -0,0 +1,65 @@ +# PR Review Prompt (PIR) + +## Context + +You are performing the CMAP-2 review of a PIR protocol PR. The builder has implemented an approved plan, the human has approved the `dev-approval` gate (meaning a human has run the code locally and tested it), and the PR has been opened. This is a single advisory pass (`max_iterations: 1`) — your verdict is surfaced to the human at the `pr` gate, who is the sole remaining reviewer; it is not auto-re-reviewed. + +## Focus Areas + +1. **Completeness** + - Is the PR body the review file content + `Fixes #`? + - Are all commits properly formatted (`[PIR #] ...`)? + - Does the diff match what the review file describes? + +2. **Test Status** + - Do all tests pass on the branch? + - Is test coverage adequate for the change? + - Are there skipped or flaky tests documented? + +3. **Code Quality** + - Any debug code left in? + - Any TODO comments that should be resolved? + - Any `// REVIEW:` markers that weren't addressed? + +4. **Branch Hygiene** + - Is the branch up to date with main? (If not, suggest a rebase.) + - Are commits atomic and well-described? + - Is the change diff a reasonable size for the issue scope? + +5. **Issue Linkage** + - Does the PR body contain `Fixes #` (or `Refs #` for partial fixes)? + - Without this, GitHub won't auto-close the issue on merge + +## Verdict Format + +After your review, provide your verdict in exactly this format: + +``` +--- +VERDICT: [APPROVE | REQUEST_CHANGES | COMMENT] +SUMMARY: [One-line summary of your assessment] +CONFIDENCE: [HIGH | MEDIUM | LOW] +--- +KEY_ISSUES: +- [Issue 1 or "None"] +- [Issue 2] +... +``` + +**Verdict meanings:** +- `APPROVE`: Ready to merge +- `REQUEST_CHANGES`: Issues to fix before merging +- `COMMENT`: Minor items, can merge but note feedback + +## Scope + +- **DO** flag missing `Fixes #` lines +- **DO** flag obvious problems the human reviewer at the gate might have missed +- **DO NOT** redesign the approach — that was settled at `plan-approval` and validated at `dev-approval` +- **DO NOT** demand changes the human reviewer already accepted at the `dev-approval` gate (the human ran the code and approved it; you didn't) + +## Notes + +- The human at the `dev-approval` gate is the primary reviewer for behavior; you are the secondary reviewer for hygiene and edge cases +- Focus on "what would an integration reviewer catch that the gate reviewer missed" +- If referencing line numbers, use `file:line` format diff --git a/codev/protocols/pir/prompts/implement.md b/codev/protocols/pir/prompts/implement.md new file mode 100644 index 000000000..0d8ff9e5b --- /dev/null +++ b/codev/protocols/pir/prompts/implement.md @@ -0,0 +1,156 @@ +# IMPLEMENT Phase Prompt + +You are executing the **IMPLEMENT** phase of the PIR protocol. + +## Your Goal + +Implement the approved plan, write tests, and pause at the `dev-approval` gate so the human can verify behavior by running the worktree locally. No file artifact is produced in this phase — the retrospective (review file) is written in the next phase, after the human has approved the running code. + +## Context + +- **Project ID**: {{project_id}} +- **Issue Number**: #{{issue.number}} +- **Issue Title**: {{issue.title}} +- **Plan File**: `codev/plans/{{artifact_name}}.md` (already approved) + +## Resumption Check (do this FIRST) + +Run `porch next {{project_id}}`. If the response is `gate_pending` on `dev-approval`, the code is already written and you're awaiting review. In that case: + +1. Check for feedback: + - `git diff main` — has the reviewer made any direct edits to your code? + - `gh issue view {{issue.number}} --comments` + - `afx send` queue messages +2. If feedback requires code changes: make them, re-run build + tests, recommit. +3. If feedback is a discussion question: respond and stay in the session. + +Otherwise (`tasks` response — first run), continue below. + +## Process + +### 1. Re-Read the Plan + +```bash +cat codev/plans/{{artifact_name}}.md +``` + +The plan is your authoritative scope. Stick to it. If you discover the plan is wrong while implementing, stop and signal `BLOCKED` rather than silently deviating. + +### 2. Implement the Code + +Follow the plan's "Files to Change" section. Apply the changes. + +**Code quality standards:** +- Self-documenting code (clear names, obvious structure) +- No commented-out code +- No debug prints +- Explicit error handling +- Follow project style guide + +**Commit cadence:** commit each logical unit separately. Use the format: + +``` +[PIR #{{issue.number}}] +``` + +**Never use `git add .` or `git add -A`.** Stage files explicitly: + +```bash +git add path/to/changed-file.ts +git commit -m "[PIR #{{issue.number}}] ..." +``` + +### 3. Write Tests + +The plan's "Test Plan" section lists what to verify. Write the corresponding tests: + +- Unit tests for new functions +- Integration tests for cross-component flows +- Regression tests for any bug fixes + +**Test quality:** +- Test behavior, not implementation +- Avoid overmocking — only mock external dependencies (APIs, databases, file systems) +- Use real implementations for internal module boundaries + +### 4. Verify Everything Works + +```bash +npm run build # or project equivalent +npm test # or project equivalent +``` + +Both MUST pass before signaling phase complete. If a test is flaky (intermittent failure unrelated to your changes), skip it with annotation — you'll document each skipped test in the review file in the next phase. + +**Flaky ≠ pre-existing unrelated failure.** If the suite surfaces a *deterministic* failure your diff did not cause (e.g., a stale test broken by another team's earlier refactor, or a type error in an unrelated package), that is **out of scope** — do not fix it, skip it, or quarantine it to force a green. Note it for the review file's Lessons Learned and proceed. Porch's gate `checks` are narrow structural assertions, not a full-suite proof; making an unrelated red go green is scope creep, not diligence. + +### 5. Push Your Branch + +```bash +git push -u origin "$(git branch --show-current)" +``` + +So the reviewer can pull / inspect from elsewhere if they want. + +### 6. Signal Phase Complete + +```bash +porch done {{project_id}} +porch next {{project_id}} +``` + +PIR's `implement` phase has no AI consult — the `dev-approval` gate becomes pending immediately, and the human is the sole reviewer of the running code. (CMAP-2 runs later, in the `review` phase, after the human approves the gate and the PR is opened.) + +### 7. End Your Turn With a Code-Review Summary (Prose, Not a File) + +When the gate goes pending, output a short prose summary in the pane to orient the human reviewer. This is **not** a committed file — it's a transient message to help them inspect the change. Structure: + +> **What changed**: 2–3 sentence summary. +> +> **Files**: `git diff --stat main` style list — paths and +/-. +> +> **Test results**: `npm run build` ✓, `npm test` ✓ (X tests, Y new). +> +> **Things to look at**: tricky spots, platform-specific behavior, anything you want the reviewer to focus on. +> +> **How to test locally**: VSCode → right-click builder → **Run Dev Server**, or `afx dev pir-{{project_id}}`. View diff via VSCode → **View Diff** (auto-detects the repo's default branch). +> +> Ready for review — type feedback here, or approve with `porch approve {{project_id}} dev-approval --a-human-explicitly-approved-this` (Cmd+K G in VSCode). + +Then **stay in the interactive session**. Do not exit. Wait for the user's next message. + +(Optional: if your team prefers an issue-thread record, you can also post a one-line comment on the GitHub issue pointing reviewers at the worktree branch. The summary itself stays in the pane — don't duplicate it as a committed file. That's the next phase's job, and that file will be a proper retrospective with arch + lessons updates, not a transient dev-approval note.) + +## Signals + +``` +PHASE_COMPLETE # Implementation + tests done; dev-approval gate becomes pending +BLOCKED:reason # Cannot proceed +``` + +## What NOT to Do + +- **Don't write `codev/reviews/-.md` in this phase** — it's the next phase's artifact, with a different shape (retrospective with arch + lessons updates) +- Don't add features not in the plan — scope creep is a `BLOCKED` signal, not a free expansion +- Don't run `porch approve` yourself +- Don't push to main — only to your builder branch +- Don't squash commits — let the merge commit preserve history +- Don't use `git add .` or `git add -A` +- Don't open the PR yet — that's the `review` phase +- Don't exit the interactive session at the gate + +## Handling Feedback at the Gate + +The reviewer will run your code via `afx dev` and test it. They may: + +- Approve immediately → porch advances to the `review` phase +- Type feedback in the pane / send via `afx send` / edit code in the worktree / comment on the issue +- Ask clarifying questions about specific files or behaviors + +When they provide feedback: + +1. If it's code feedback: make the change, run build + tests, recommit +2. If it's a discussion question: answer it in the pane +3. Don't re-run `porch done` unless porch's `verify` block needs to re-validate — porch will tell you + +The gate stays pending until the human approves. diff --git a/codev/protocols/pir/prompts/plan.md b/codev/protocols/pir/prompts/plan.md new file mode 100644 index 000000000..a40cdee2d --- /dev/null +++ b/codev/protocols/pir/prompts/plan.md @@ -0,0 +1,133 @@ +# PLAN Phase Prompt + +You are executing the **PLAN** phase of the PIR protocol. + +## Your Goal + +Read the GitHub issue, investigate the codebase, and write a plan to `codev/plans/{{artifact_name}}.md`. The plan is reviewed by a human at the `plan-approval` gate before any code is written. + +## Context + +- **Project ID**: {{project_id}} +- **Issue Number**: #{{issue.number}} +- **Issue Title**: {{issue.title}} +- **Artifact**: `codev/plans/{{artifact_name}}.md` + +## Resumption Check (do this FIRST) + +Run `porch next {{project_id}}`. If the response is `gate_pending`, you have already drafted the plan and are awaiting review. In that case: + +1. Read your current plan file: `cat codev/plans/{{artifact_name}}.md` +2. Check for new feedback that may have arrived while you were idle: + - `git diff HEAD~1 codev/plans/{{artifact_name}}.md` — the reviewer may have edited the file directly + - `gh issue view {{issue.number}} --comments` — check for new comments + - Read any `afx send` queue messages +3. If feedback exists, revise the plan and recommit. If not, end the turn with a short "still awaiting review" message and stay in the interactive session. + +Otherwise (`tasks` response — this is your first run), continue with the steps below. + +## Process + +### 1. Read the Issue + +```bash +gh issue view {{issue.number}} +``` + +Understand what's being asked. For a bug, identify the symptom. For a feature, identify the desired outcome. + +### 2. Investigate the Codebase + +- Use Glob / Grep / Read to find the relevant code +- For a bug: trace the failure path, identify the root cause +- For a feature: find the existing patterns and integration points +- Note any existing utilities, components, or conventions you should reuse + +### 3. Write the Plan + +Create `codev/plans/{{artifact_name}}.md` where `` is a short kebab-case description of the change. Use this structure: + +```markdown +# PIR Plan: + +## Understanding + +What the issue is asking for, in your own words. For a bug, include the root cause you identified — back it up with file:line references. + +## Proposed Change + +The approach you intend to take. Be specific. If there are multiple valid approaches, pick one and explain why. + +## Files to Change + +Concrete file paths. Use `file:line` format for specific edits where possible. + +- `path/to/file.ts:42-55` — what changes +- `path/to/new-file.ts` — new file, what it does + +## Risks & Alternatives Considered + +- Risk: what could go wrong; mitigation +- Alternative: ; why rejected + +## Test Plan + +How to verify this works once implemented. The reviewer will use this at the `dev-approval` gate to test the running worktree. + +- Unit test: +- Manual: +- Cross-platform: +``` + +### 4. Commit and Push + +```bash +git add codev/plans/{{artifact_name}}.md +git commit -m "[PIR #{{issue.number}}] Plan draft" +git push -u origin "$(git branch --show-current)" +``` + +**Never use `git add .` or `git add -A`.** + +### 5. Signal Phase Complete + +```bash +porch done {{project_id}} +porch next {{project_id}} +``` + +`porch next` will respond with `gate_pending` on the `plan-approval` gate. Porch automatically notifies the architect. + +### 6. End Your Turn With a Prose Summary + +Output something like: + +> Plan written to `codev/plans/{{artifact_name}}.md` and committed. Ready for review — type any feedback here, edit the plan file directly in VSCode, or approve with `porch approve {{project_id}} plan-approval --a-human-explicitly-approved-this` (Cmd+K G in VSCode). + +Then **stay in the interactive session**. Do not exit. Wait for the user's next message. + +## Signals + +``` +PHASE_COMPLETE # Plan drafted (informational; the real signal is porch done) +BLOCKED:reason # Cannot proceed +``` + +## What NOT to Do + +- Don't write code — that's the implement phase +- Don't run `porch approve` yourself — only the human can approve the gate +- Don't post the plan content as a GitHub issue comment — the plan lives in the file, not the issue thread. A one-line pointer comment on the issue is fine if you think it helps the discussion. +- Don't use `git add .` or `git add -A` +- Don't exit the interactive session at the gate + +## Handling Feedback + +When the reviewer provides feedback (typed in pane, file-edit, `afx send`, or issue comment): + +1. Re-read the plan file (the user may have edited it) +2. Apply the requested changes to your plan +3. Recommit: `git add codev/plans/{{artifact_name}}.md && git commit -m "[PIR #{{issue.number}}] Plan revised"` +4. Push +5. Output a short "Revised — see commit X" message +6. Wait for next input — the gate remains pending until the human approves diff --git a/codev/protocols/pir/prompts/review.md b/codev/protocols/pir/prompts/review.md new file mode 100644 index 000000000..a5153902b --- /dev/null +++ b/codev/protocols/pir/prompts/review.md @@ -0,0 +1,261 @@ +# REVIEW Phase Prompt + +You are executing the **REVIEW** phase of the PIR protocol. + +## Your Goal + +Write a retrospective at `codev/reviews/{{artifact_name}}.md` including **Summary**, **Architecture Updates**, and **Lessons Learned Updates** sections. Push, open a PR using the review file as the PR body, record the PR with porch, then signal completion — **porch runs CMAP-2 (Gemini + Codex) once** via the verify block. CMAP is a *single advisory pass* (`max_iterations: 1`): its verdicts are surfaced to the human at the `pr` gate, **not** an iterate-until-APPROVE loop. After the single pass the `pr` gate fires regardless of verdict; you notify the architect (leading with any REQUEST_CHANGES) and wait at the gate while the human merges on GitHub. + +The retrospective ships with the merged PR — it's durable team knowledge, searchable in `codev/reviews/` on `main`. + +## Context + +- **Project ID**: {{project_id}} +- **Issue Number**: #{{issue.number}} +- **Plan File**: `codev/plans/{{artifact_name}}.md` +- **Review File**: `codev/reviews/{{artifact_name}}.md` (you will write this) + +## Prerequisites + +- The `dev-approval` gate has been approved (you're here because `porch next` advanced you) +- Your branch contains the implementation commits +- Build and tests pass + +## Process + +### 1. Write the Review File + +Create `codev/reviews/{{artifact_name}}.md` with these sections: + +```markdown +# PIR Review: + +Fixes #{{issue.number}} + +## Summary + +2–3 sentence overview of what was implemented and why. The reader is someone scanning `codev/reviews/` six months from now trying to understand what this PR did. + +## Files Changed + +Output of `git diff --stat main`, formatted as a list: + +- `path/to/file.ts` (+12 / -3) +- `path/to/new-file.ts` (+45 / -0) + +## Commits + +Output of `git log main..HEAD --oneline`: + +- `` [PIR #{{issue.number}}] First change +- `` [PIR #{{issue.number}}] Second change + +## Test Results + +- `npm run build`: ✓ pass +- `npm test`: ✓ pass (X tests, Y new) +- Manual verification: + +## Architecture Updates + +What changed in `codev/resources/arch.md` (or why no changes were needed). If this PR introduced or modified an architectural pattern, document it here AND update `arch.md` in this same commit. If no architectural updates are needed (typical for small fixes), write a single line explaining why: "No arch changes — this PR fixes a typo without affecting module boundaries." + +Use the `update-arch-docs` skill if available (`.claude/skills/update-arch-docs/SKILL.md`) — it encodes the discipline for what NOT to include in arch docs. + +## Lessons Learned Updates + +What durable engineering wisdom emerged from this PR (or why no lessons were captured). Same pattern as Architecture Updates: if something is worth recording in `codev/resources/lessons-learned.md`, update both files in this commit. If not, explain why: "No lessons captured — change was mechanical." + +## Things to Look At During PR Review + +Tricky spots the PR reviewer should focus on. Honest — if a section was hard to get right, flag it. + +## How to Test Locally + +For reviewers pulling the branch: + +- **View diff**: VSCode sidebar → right-click builder pir-{{project_id}} → **Review Diff** (auto-detects the repo's default branch) +- **Run dev server**: VSCode sidebar → **Run Dev Server**, or `afx dev pir-{{project_id}}` +- **What to verify**: + +## Flaky Tests (if any) + +List any tests you skipped due to pre-existing flakiness, with file:line refs and a one-line rationale each. Omit this section if none. +``` + +### 2. Update Architecture / Lessons Docs (if applicable) + +If your "Architecture Updates" or "Lessons Learned Updates" section calls out real changes, update `codev/resources/arch.md` and/or `codev/resources/lessons-learned.md` accordingly. Use the `update-arch-docs` skill if it's available. + +If neither doc needs updating, your review file's sections still need to explain why — the porch `checks` block enforces section presence. + +### 3. Commit the Review File (and arch / lessons updates) + +```bash +git add codev/reviews/{{artifact_name}}.md +# Add arch.md / lessons-learned.md only if you changed them +git add codev/resources/arch.md # only if changed +git add codev/resources/lessons-learned.md # only if changed +git commit -m "[PIR #{{issue.number}}] Review + retrospective" +git push +``` + +### 4. Open the PR + +```bash +PR_TITLE="" +BRANCH="$(git branch --show-current)" + +gh pr create \ + --base main \ + --head "$BRANCH" \ + --title "$PR_TITLE" \ + --body-file codev/reviews/{{artifact_name}}.md +``` + +**Verify the PR body contains `Fixes #{{issue.number}}`** (it should — the review file has it at the top). If somehow missing, edit and re-apply: + +```bash +gh pr edit --body-file codev/reviews/{{artifact_name}}.md +``` + +**Exception**: if this PR only partially addresses the issue, use `Refs #{{issue.number}}` instead — the issue stays open until a follow-up PR closes it. + +### 4a. Record the PR with Porch + +Immediately after creating the PR, tell porch about it so `status.yaml` carries the PR number and branch. This is a metadata-only call — it does NOT advance the phase or trigger CMAP: + +```bash +porch done {{project_id}} --pr --branch "$(git branch --show-current)" +``` + +Without this, porch's `history:` for the project stays empty and downstream tooling (status views, analytics, audit trails) can't link the porch project to its GitHub PR. + +### 5. Signal Completion to Porch (porch runs CMAP-2) + +```bash +porch done {{project_id}} +``` + +Porch will: +1. Run the `pr_exists` / `review_has_arch_updates` / `review_has_lessons_updates` checks. +2. **Execute CMAP-2 (Gemini + Codex) automatically** via the protocol's `verify` block. Outputs land in `codev/projects/{{project_id}}-/{{project_id}}-gemini.txt` and `-codex.txt`. +3. CMAP runs **exactly once** (`max_iterations: 1`). Whatever the verdicts, porch records them in `status.yaml` and advances to the `pr` gate — there is **no automated re-review pass and no "stays in the review phase" loop**. `APPROVE` and `REQUEST_CHANGES` differ only in what you must surface to the human (steps 6–7), not in whether the gate fires. The output of `porch done` surfaces the verdicts. + +> **Why CMAP-2, not CMAP-3?** PIR's design parallels BUGFIX/AIR's consult footprint. The human already approved the *running* implementation at the `dev-approval` gate; CMAP at PR is a pre-merge hygiene + code-quality pass, not a functional review. + +### 6. Handle a REQUEST_CHANGES Verdict (single-pass — no automated re-review) + +PIR's CMAP is one advisory pass (`max_iterations: 1`). If a reviewer returns `REQUEST_CHANGES`, porch does **not** loop or re-run CMAP — it records the verdict and proceeds to the `pr` gate. There is no iter-2. The correctness backstop for a CMAP-flagged issue is therefore **(a)** your fix + a regression test and **(b)** the human's `pr`-gate review — *not* an independent model re-review. Treat that as load-bearing: a substantive finding you "address and rebut" gets no second AI opinion. + +For any `REQUEST_CHANGES`: + +1. Read the finding in full (`codev/projects/{{project_id}}-*/{{project_id}}-.txt`). +2. **Assess it honestly:** + - **Real defect** (correctness / cancellation / security / data-loss): fix it in code, add a regression test that fails without the fix, commit + push (the PR updates automatically — no new `gh pr create`). Then document the finding, your fix, and the pinning test in the review file's **"Things to Look At During PR Review"** section. + - **False positive / out of scope**: write a brief rebuttal in that same section explaining why no change is warranted. +3. Do **not** re-run `porch done` expecting another CMAP — `max_iterations: 1` means it will not re-review. Proceed to step 7. + +Whether you fixed it or rebutted it, a `REQUEST_CHANGES` that PIR will never re-check **must be escalated to the human at the `pr` gate** (step 7) — they are the only remaining reviewer of that decision. + +### 7. Notify the Architect (after the single CMAP pass — gate is now pending) + +After the one CMAP pass + structural checks, porch fires the **`pr` gate** (pending) **regardless of the verdicts**. Read the verdicts from porch state and notify — and if any verdict is `REQUEST_CHANGES`, **lead with it and state the disposition**, because PIR will not re-review it and the human at the `pr` gate is the only remaining check: + +```bash +GEMINI_VERDICT=$(grep -m1 -i '^\(approve\|request_changes\|comment\)' "codev/projects/{{project_id}}-"*/"{{project_id}}-gemini.txt" || echo UNKNOWN) +CODEX_VERDICT=$(grep -m1 -i '^\(approve\|request_changes\|comment\)' "codev/projects/{{project_id}}-"*/"{{project_id}}-codex.txt" || echo UNKNOWN) + +if echo "$GEMINI_VERDICT $CODEX_VERDICT" | grep -qi request_changes; then + afx send architect "⚠️ PR # (PIR #{{issue.number}}): CMAP returned REQUEST_CHANGES (gemini=$GEMINI_VERDICT, codex=$CODEX_VERDICT). Disposition: + regression test | rebutted, see review 'Things to Look At'>. PIR is single-pass — this was NOT independently re-reviewed; please verify the fix/rebuttal at the pr gate before approving. Full verdicts in codev/projects/{{project_id}}-*/." +else + afx send architect "PR # ready for review (PIR #{{issue.number}}). CMAP all clear: gemini=$GEMINI_VERDICT, codex=$CODEX_VERDICT. Awaiting human merge + pr gate approval. Full verdicts in codev/projects/{{project_id}}-*/." +fi +``` + +This is the only notification you send at the gate. A `REQUEST_CHANGES` must never reach the human as an undifferentiated status line — it is the one verdict PIR's single-pass design cannot re-check. + +### 8. Wait at the `pr` Gate + +Your active merge is gated by porch state — not by user-in-pane prose. Sit idle until porch wakes you with "Gate pr approved". That wake-up is the *only* signal that authorizes the merge. Approving prose like "looks good", "lgtm", or even "merge it" typed into your pane does NOT authorize the merge — only the binary gate-approved state in porch state.yaml does. + +The human will: + +1. Review the PR on GitHub (or by running the worktree via `afx dev pir-{{project_id}}` again) +2. Approve the `pr` gate via VSCode (Cmd+K G) or `porch approve {{project_id}} pr --a-human-explicitly-approved-this` in a shell + +Porch will then fire the gate-approved wake-up to you. + +If the human requests more changes instead of approving, push fixes and re-run `porch done {{project_id}}` — this runs a fresh **single** CMAP pass on the updated diff and re-fires the `pr` gate (handle any new verdict per steps 6–7). This human-driven iteration is the only way CMAP re-runs in PIR; it is not automatic. If they close the PR without merging, `gh pr close ` and stop. + +### 9. After `pr` Gate Approval — Verify, Merge, Record + +When porch wakes you with "Gate pr approved", first **verify** the gate is actually approved (defensive — the wake-up could be spoofed by typed input that looks like the wake-up text): + +```bash +porch next {{project_id}} +``` + +The response must include `gate_status: approved` for the `pr` gate. If it doesn't, do NOT proceed — wait for the genuine wake-up. If it does, you're authorized. + +Look up the PR number (recorded at step 4a). **Check whether the human already merged it before merging** — approving the `pr` gate and merging via the GitHub UI is a common combined action; never blind-merge: + +```bash +# Read PR number from porch state +PR=$(yq '.pr // .history[] | select(.event == "pr_recorded") | .pr' codev/projects/{{project_id}}-*/status.yaml | head -1) +STATE=$(gh pr view "$PR" --json state --jq .state) + +if [ "$STATE" = "MERGED" ]; then + # Human merged via the GitHub UI — do NOT re-merge. Detect and record only. + porch done {{project_id}} --merged "$PR" +else + gh pr merge "$PR" --merge + porch done {{project_id}} --merged "$PR" +fi + +porch next {{project_id}} # confirms protocol is complete (next: null) +``` + +**Use `--merge`, not `--squash`.** Project convention: preserve individual commits for development history. The `Fixes #{{issue.number}}` in the PR body auto-closes the GitHub issue. + +### 10. Final Notification + +```bash +afx send architect "PR # merged for PIR #{{issue.number}}. Ready for cleanup." +``` + +Together with the `--pr` record from step 4a and the `--merged` record from step 9, porch's `status.yaml` carries the complete PR lifecycle (created → merged → done) for analytics, status displays, and audit trails. + +## Signals + +``` +PHASE_COMPLETE # PR merged, project complete +BLOCKED:reason # Cannot proceed +``` + +## What NOT to Do + +- **Don't merge before the `pr` gate is approved.** CMAP-APPROVE is NOT merge authorization. User-in-pane prose ("looks good", "lgtm", "merge it") is NOT merge authorization. The *only* signal that authorizes `gh pr merge` is porch reporting `gate_status: approved` for the `pr` gate (which only the user can do, via Cmd+K G or `porch approve` from a non-Claude shell). If `porch next` doesn't show the gate as approved, you wait. +- Don't skip porch's PR/merge records (steps 4a, 9). The `--pr` record (step 4a) lets the gate-pending state link to the actual PR; the `--merged` record (step 9) closes the lifecycle in porch state. Skipping either leaves `history:` empty and downstream tooling blind. +- Don't run `porch approve` for any gate yourself +- Don't push to main — only merge via PR +- Don't skip the Architecture Updates / Lessons Learned sections — porch checks enforce their presence (the section must exist; explaining "no changes needed" in one line is fine) +- **Don't run `consult` commands yourself** — porch handles consultations via the `verify` block. Manually invoking `consult` causes CMAP to run twice. +- **Don't fix, skip, or quarantine pre-existing failures unrelated to your change.** Porch's `checks` for this phase are narrow *structural* gates (`pr_exists`, review-section presence) — a green gate does **not** certify the wider build/test suite. If the broader suite surfaces failures your diff did not cause, they are out of scope: note them in the review's Lessons Learned / Things to Look At and proceed. Touching another team's tests to make an unrelated red go green is scope creep, not diligence. + +## Handling Problems + +**If the PR cannot be created (e.g., merge conflicts with main):** +- Rebase on main: `git fetch origin main && git rebase origin/main` +- Resolve conflicts (do NOT use destructive shortcuts) +- Force-push with lease: `git push --force-with-lease` +- Re-run `gh pr create` + +**If porch's CMAP consults fail (e.g., model unavailable):** +- `porch done` will report the failure. Inspect `codev/projects/{{project_id}}-*/{{project_id}}-.txt` for the failure details. +- Re-run `porch done {{project_id}}` once — porch will retry the consult. +- If the model is persistently unavailable, notify the architect and ask whether to proceed without that model's verdict. They may direct you to skip via a manual override. + +**If the architect doesn't respond within a reasonable window:** +- Send one follow-up via `afx send architect "..."` after a few hours +- Do not auto-merge diff --git a/codev/protocols/pir/protocol.json b/codev/protocols/pir/protocol.json new file mode 100644 index 000000000..e2812d036 --- /dev/null +++ b/codev/protocols/pir/protocol.json @@ -0,0 +1,125 @@ +{ + "$schema": "../../protocol-schema.json", + "name": "pir", + "alias": "plan-implement-review", + "version": "1.0.0", + "description": "PIR: Plan → Implement → Review with build-verify cycles and two pre-PR human gates (plan-approval, dev-approval), for GitHub-issue-driven work.", + "input": { + "type": "github-issue", + "required": true, + "default_for": ["--issue", "-i"] + }, + "hooks": { + "pre-spawn": { + "collision-check": true, + "comment-on-issue": "On it! Working on this with the PIR protocol (plan + dev-approval gates before PR)." + } + }, + "phases": [ + { + "id": "plan", + "name": "Plan", + "description": "Read the issue, investigate the codebase, write codev/plans/-.md, await plan-approval", + "type": "build_verify", + "build": { + "prompt": "plan.md", + "artifact": "codev/plans/${PROJECT_TITLE}.md" + }, + "verify": null, + "max_iterations": 1, + "on_complete": { + "commit": true, + "push": true + }, + "checks": { + "plan_exists": "test -f codev/plans/${PROJECT_TITLE}.md" + }, + "gate": "plan-approval", + "next": "implement" + }, + { + "id": "implement", + "name": "Implement", + "description": "Write code + tests, run build/test checks, await dev-approval on the running worktree", + "type": "build_verify", + "build": { + "prompt": "implement.md", + "artifact": "src/**/*.{ts,tsx,js,jsx,py,go,rs}" + }, + "verify": null, + "max_iterations": 1, + "on_complete": { + "commit": true, + "push": true + }, + "checks": { + "build": { + "command": "npm run build", + "on_fail": "retry", + "max_retries": 2 + }, + "tests": { + "command": "npm test", + "on_fail": "retry", + "max_retries": 2 + } + }, + "gate": "dev-approval", + "next": "review" + }, + { + "id": "review", + "name": "Review", + "description": "Write retrospective (arch + lessons), push, open PR with review as body, run CMAP, then wait at pr gate while the human merges on GitHub", + "type": "build_verify", + "build": { + "prompt": "review.md", + "artifact": "codev/reviews/${PROJECT_TITLE}.md" + }, + "verify": { + "type": "impl", + "models": ["gemini", "codex"], + "parallel": true + }, + "max_iterations": 1, + "on_complete": { + "commit": true, + "push": true + }, + "checks": { + "pr_exists": { + "command": "gh pr list --state all --head \"$(git branch --show-current)\" --json number --jq 'length' | grep -q '^[1-9]'", + "description": "PR must be created before signaling completion" + }, + "review_has_arch_updates": { + "command": "grep -q '## Architecture Updates' codev/reviews/${PROJECT_TITLE}.md", + "description": "Review must include '## Architecture Updates' section (what changed in arch.md, or why no changes needed)" + }, + "review_has_lessons_updates": { + "command": "grep -q '## Lessons Learned Updates' codev/reviews/${PROJECT_TITLE}.md", + "description": "Review must include '## Lessons Learned Updates' section (what changed in lessons-learned.md, or why no changes needed)" + } + }, + "gate": "pr", + "next": null + } + ], + "signals": { + "PHASE_COMPLETE": { + "description": "Signal current build phase is complete", + "transitions_to": "next_phase" + }, + "BLOCKED": { + "description": "Signal implementation is blocked", + "requires": "reason" + } + }, + "defaults": { + "mode": "strict", + "max_iterations": 1, + "consultation": { + "enabled": true, + "parallel": true + } + } +} diff --git a/codev/protocols/pir/protocol.md b/codev/protocols/pir/protocol.md new file mode 100644 index 000000000..537a1de9c --- /dev/null +++ b/codev/protocols/pir/protocol.md @@ -0,0 +1,204 @@ +# PIR Protocol + +> **Plan → Implement → Review** for GitHub-issue-driven work that needs human review of *either* the approach (before code is written) *or* the implementation (before a PR exists), or both. Lighter than SPIR/ASPIR (no `specify` phase — the GitHub issue is the implicit spec) with the human dev-approval moved earlier (pre-PR instead of post-PR). Stronger than BUGFIX/AIR (two human gates before the PR). + +## When to Use PIR + +Pick PIR when working from a GitHub Issue and ONE or BOTH of the following apply — based on the *nature* of the change, not its size: + +### 1. The approach needs review before coding starts +- Root cause is ambiguous; multiple valid fixes exist +- Area is unfamiliar or high-blast-radius (shared utilities, auth, migrations, public APIs) +- Design-sensitive (affects conventions, patterns, architecture) +- Cheaper to redirect at plan time than at PR time + +### 2. The implementation needs to be tested before a PR is created +The PR diff alone is insufficient; the reviewer must *run* the code: +- Mobile app changes (needs device testing on Android, iOS, possibly web) +- UI / UX changes (visual inspection, interaction flow, accessibility) +- Hardware-adjacent behavior (sensors, camera, permissions, notifications) +- Integration with external services that don't mock cleanly (OAuth, payments, analytics) +- User-journey changes that need a full-flow exercise +- Performance-sensitive changes that need profiling on the running app + +### Use SPIR / ASPIR / BUGFIX / AIR instead when +- **SPIR / ASPIR**: the change is complex enough to warrant careful specification, multi-agent consultation at every phase, and the full spec → plan → implement → review ceremony with file artifacts. The driving issue is incidental — what matters is that the design work deserves a formal spec and the implementation deserves consult-driven review at each phase +- **BUGFIX**: small bug fix, no design review needed, diff-on-PR review is enough +- **AIR**: small feature from an issue, autonomous, diff-on-PR review is enough + +## How PIR Differs from SPIR + +PIR is structurally *SPIR minus the `specify` phase*, with the human dev-approval moved earlier (pre-PR instead of post-PR). + +| Aspect | SPIR | PIR | +|---|---|---| +| Phases | specify → plan → implement → review → verify | plan → implement → review | +| Spec artifact | `codev/specs/-.md` | GitHub Issue body (implicit spec) | +| Plan artifact | `codev/plans/-.md` | Same — committed on builder branch | +| Review artifact | `codev/reviews/-.md` (Summary + Architecture Updates + Lessons Learned, becomes PR body) | **Same shape** — `codev/reviews/-.md` with the same sections, also becomes PR body | +| Human gates | spec-approval, plan-approval, pr, verify-approval | plan-approval, dev-approval, pr | +| Where code is reviewed by the human | On the PR (post-creation) — read the diff | Pre-PR (at the `dev-approval` gate) — read the diff **and run the worktree locally** | + +The review file always includes Summary, Architecture Updates, and Lessons Learned sections so `codev/reviews/` stays semantically consistent across all protocols. PIR's lightness comes from skipping the `specify` phase (the issue body is the spec), not from cutting corners on the retrospective. + +The `dev-approval` gate is what makes PIR genuinely different: the human gates the *running implementation* via the worktree before the PR exists, instead of gating the PR after creation. + +## Phases + +``` +plan → implement → review +``` + +### Plan (gated by `plan-approval`) + +The builder: +1. Reads the GitHub issue and investigates the codebase +2. Writes `codev/plans/-.md` with: Understanding / Proposed change / Files to change / Risks & alternatives / Test plan +3. Commits the plan on the builder branch and pushes +4. Runs `porch done` and `porch next` — the `plan-approval` gate becomes pending +5. Sits at the interactive prompt waiting for review + +**Reviewer paths** (all equivalent): +- Open `codev/plans/-.md` in the worktree, read and / or edit directly, save +- Type feedback into the builder's PTY pane — the builder is alive in interactive mode +- `afx send ""` +- Comment on the GitHub issue (sidecar discussion) + +When satisfied, approve via VSCode's "Approve Gate" command (Cmd+K G) or: + +```bash +porch approve plan-approval --a-human-explicitly-approved-this +``` + +### Implement (gated by `dev-approval`) + +The builder: +1. Reads the approved plan file +2. Writes code and tests; runs build + tests via the `checks` block +3. *No AI consult on this phase* — the human at the `dev-approval` gate is the sole reviewer of the running code. Matches BUGFIX / AIR's pattern of "no consult on implementation, one consult at PR creation". +4. Pushes the branch +5. Runs `porch done` and `porch next` — the `dev-approval` gate becomes pending +6. Outputs a **prose** dev-approval summary in the PTY pane (Summary / Files / Test results / Things to look at / How to test locally). This is a transient message to orient the human reviewer — **not a committed file**. The retrospective file is written in the next phase, after the human approves the running code. +7. Sits at the interactive prompt + +**The reviewer's killer move**: run the worktree locally. + +- VSCode: right-click the builder in the Codev sidebar → **Run Dev Server** (spawns `afx dev ` via Tower) +- CLI: `afx dev ` + +The dev server uses **the same ports and URLs as main** intentionally (OAuth callbacks, CORS, cookie scoping all depend on consistent origins). Only one dev env runs at a time; stop main's `pnpm dev` before starting the worktree's, or use VSCode's **Stop Dev Server** to swap. + +Reviewer tests the change on real devices / browsers / simulators. When satisfied, approves via Cmd+K G or: + +```bash +porch approve dev-approval --a-human-explicitly-approved-this +``` + +### Review (gated by `pr`) + +The builder: +1. Writes `codev/reviews/-.md` with **Summary**, **Architecture Updates**, **Lessons Learned Updates**, plus the supporting sections (Files Changed, Commits, Test Results, Things to Look At, How to Test Locally). +2. Updates `codev/resources/arch.md` and/or `codev/resources/lessons-learned.md` if real changes need recording. If not, the review file's sections state "no changes needed" with a one-line explanation (the porch `checks` block enforces section presence, not content). +3. Commits the review file (and arch / lessons updates if any) and pushes +4. Opens a PR with `gh pr create`; PR body is the review file content + `Fixes #`. Records the PR with `porch done --pr --branch `. +5. Runs `porch done ` — porch's `verify` block runs CMAP-2 (Gemini + Codex, type=impl) as a **single advisory pass** (`max_iterations: 1`); CMAP outputs land in `codev/projects/-*/`. There is no iterate-until-APPROVE loop: whatever the verdicts, porch records them and advances to the `pr` gate. A `REQUEST_CHANGES` is not auto-re-reviewed — the builder addresses or rebuts it, adds a regression test if it's a real defect, and escalates it in the architect notification so the human verifies it at the `pr` gate. Outcomes are not auto-appended to the PR body; reviewers with the worktree read them from the projects dir. +6. The `pr` gate fires (pending) regardless of verdict. Builder notifies the architect once — leading with any `REQUEST_CHANGES` and its disposition (since PIR will not re-review it) rather than burying it in a flat status line. +7. Builder waits at the `pr` gate. The human reviews the PR on GitHub, then approves the `pr` gate (Cmd+K G or `porch approve pr --a-human-explicitly-approved-this`). Porch wakes the builder. +8. Builder verifies the gate is genuinely approved via `porch next` (defensive — typed prose can't trigger this branch, only real porch state does), then runs `gh pr merge --merge`, records via `porch done --merged `, and sends the cleanup-ready notification. Protocol complete (`next: null`). + +## Gates + +PIR uses porch's existing gate machinery. Gate names are opaque strings; no porch engine changes are needed. + +- **`plan-approval`** — pre-PR. Human reads the plan file (committed on the builder branch) and approves before any code is written. Gates are keyed by `(project_id, gate_name)` so the name is safe to share with other protocols. +- **`dev-approval`** — pre-PR. The human reviews the *running* worktree (via `afx dev`) before any PR exists. This is PIR's distinctive gate. +- **`pr`** — post-PR. Gates the merge step. The human reviews the PR on GitHub and approves this gate; porch wakes the builder, which then runs `gh pr merge`. The gate exists so the merge trigger is structured porch state (binary approved/not), not free-text prose typed into the builder's pane. Eliminates the self-merge bug class: builders can't infer authorization from ambiguous user input. + +When a gate becomes pending, porch broadcasts `overview-changed` via SSE. The VSCode Builders tree picks up the blocked state and renders it with a bell icon; a toast surfaces the new gate-pending event. Architect notification is *not* automatic — gates surface via the toast/sidebar (for IDE users) or by checking the builder pane / `porch pending` (for CLI users). The builder's job at any gate is to write the artifact, commit, signal completion, and wait — never to invoke `porch approve` itself (Claude refuses the `--a-human-explicitly-approved-this` flag by design). + +## Rejection / Feedback Model + +There is no formal `porch reject` command. Rejection works via the feedback-iterate pattern: + +1. Reviewer provides feedback (edit the plan file in VSCode, type in the builder pane, `afx send`, or issue comment) +2. Builder reads the feedback on its next turn, revises the artifact, recommits +3. The gate remains pending — porch doesn't advance until the human runs `porch approve` + +The same pattern works at both gates. + +## Builder Session Lifetime + +The builder is a long-running interactive Claude Code session in a PTY pane managed by Tower. The session is launched as `claude ""` (no `--print`) inside a `while true` restart loop. That form starts an interactive Claude REPL with the prompt as the first user message; after Claude finishes the prompted work it sits at the input prompt awaiting next user input. The outer `while true` loop only fires if Claude crashes — it is a crash-recovery safety net, not the gate-wait mechanism. + +This means typed input in the builder pane reaches the live Claude session immediately, exactly like any other interactive Claude Code conversation. There is no "session ended at gate" state to worry about under normal operation. + +## Configuration + +PIR uses the same `.codev/config.json` configuration as other protocols. The `worktree` block (from Issue 689) enables the at-gate dev-server review flow: + +```json +{ + "worktree": { + "symlinks": [".env.local", "packages/*/.env"], + "postSpawn": ["pnpm install --frozen-lockfile"], + "devCommand": "pnpm dev" + } +} +``` + +Without `worktree.devCommand`, `afx dev` won't work and the `dev-approval` gate degenerates to a diff-read — at which point you should probably use AIR or BUGFIX instead. + +## Multi-Agent Consultation + +- **plan**: human-only review. No AI consultation. +- **implement**: no AI consult — the human at the `dev-approval` gate is the sole reviewer of the running code. +- **review**: CMAP-2 (Gemini + Codex, type=impl) after the PR is opened, as a **single advisory pass** (`max_iterations: 1`). Same pattern as BUGFIX / AIR's PR-creation consult. + +CMAP at the PR is a single pass — there is **no iterate-until-APPROVE loop**. A `REQUEST_CHANGES` does not block or re-trigger CMAP; the builder addresses or rebuts it and escalates it to the human at the `pr` gate, who is the sole remaining reviewer of any resulting fix (CMAP does not re-check it). + +Net: PIR runs **two model calls per protocol run**, matching its BUGFIX/AIR peers — its distinguishing features are the two human gates (`plan-approval`, `dev-approval`), not AI-consult density. + +**The 2-model footprint is a design invariant.** porch's model precedence is *config > protocol* (`porch.consultation.models` in `.codev/config.json` overrides a protocol's declared `verify.models`). A project-wide setting tuned for SPIR (gemini + codex + claude) will silently inflate PIR to a 3-model pass, breaking the BUGFIX/AIR-parity cost story. To keep PIR at CMAP-2, leave `porch.consultation.models` unset or scope it per-protocol. + +To disable consultation entirely, say "without multi-agent consultation" when starting work. + +## Signals + +PIR uses the standard porch signal vocabulary: + +``` +PHASE_COMPLETE # Current phase build complete +BLOCKED:reason # Cannot proceed +``` + +Signals are informational for log readability. The state machine is driven by `porch done` and `porch next` CLI calls inside the builder turn. + +## Commit Messages + +Commits during PIR phases use the issue-driven format: + +``` +[PIR #] Plan draft +[PIR #] Implement avatar masking +[PIR #] Add Android-side regression test +``` + +The PR title follows the project's existing PR convention. + +## Branch Naming + +``` +builder/pir- +``` + +Example: `builder/pir-842` for a PIR spawn against GitHub issue #842. + +## File Locations + +``` +codev/plans/-.md # written in plan phase, on builder branch +codev/reviews/-.md # written in review phase (post-dev-approval-approval), on builder branch; becomes PR body +codev/projects/-/status.yaml # porch state, managed automatically +``` + +The plan and review files ship to `main` with the merged PR — durable, searchable, git-versioned. The review file includes Summary + Architecture Updates + Lessons Learned + supporting sections, so `codev/reviews/` stays semantically consistent across protocols. diff --git a/packages/codev/src/agent-farm/__tests__/agent-names.test.ts b/packages/codev/src/agent-farm/__tests__/agent-names.test.ts index 82d4898e1..ebcbeb832 100644 --- a/packages/codev/src/agent-farm/__tests__/agent-names.test.ts +++ b/packages/codev/src/agent-farm/__tests__/agent-names.test.ts @@ -42,6 +42,11 @@ describe('buildAgentName', () => { expect(buildAgentName('bugfix', '269')).toBe('builder-bugfix-269'); }); + it('generates pir builder names', () => { + expect(buildAgentName('pir', '737')).toBe('builder-pir-737'); + expect(buildAgentName('pir', '0042')).toBe('builder-pir-42'); + }); + it('generates task builder names', () => { expect(buildAgentName('task', 'AbCd')).toBe('builder-task-abcd'); expect(buildAgentName('task', 'XyZ1')).toBe('builder-task-xyz1'); diff --git a/packages/codev/src/agent-farm/__tests__/overview.test.ts b/packages/codev/src/agent-farm/__tests__/overview.test.ts index 076327d9d..8010d69de 100644 --- a/packages/codev/src/agent-farm/__tests__/overview.test.ts +++ b/packages/codev/src/agent-farm/__tests__/overview.test.ts @@ -28,12 +28,13 @@ import { // Mocks // ============================================================================ -const { mockFetchPRList, mockFetchIssueList, mockFetchRecentlyClosed, mockFetchMergedPRs, mockLoadProtocol } = vi.hoisted(() => ({ +const { mockFetchPRList, mockFetchIssueList, mockFetchRecentlyClosed, mockFetchMergedPRs, mockLoadProtocol, mockFetchCurrentUser } = vi.hoisted(() => ({ mockFetchPRList: vi.fn(), mockFetchIssueList: vi.fn(), mockFetchRecentlyClosed: vi.fn(), mockFetchMergedPRs: vi.fn(), mockLoadProtocol: vi.fn(), + mockFetchCurrentUser: vi.fn(), })); vi.mock('../../lib/github.js', async (importOriginal) => { @@ -44,6 +45,7 @@ vi.mock('../../lib/github.js', async (importOriginal) => { fetchIssueList: mockFetchIssueList, fetchRecentlyClosed: mockFetchRecentlyClosed, fetchRecentMergedPRs: mockFetchMergedPRs, + fetchCurrentUser: mockFetchCurrentUser, }; }); @@ -126,6 +128,7 @@ describe('overview', () => { mockFetchIssueList.mockResolvedValue([]); mockFetchRecentlyClosed.mockResolvedValue([]); mockFetchMergedPRs.mockResolvedValue([]); + mockFetchCurrentUser.mockResolvedValue('octocat'); }); afterEach(() => { @@ -781,6 +784,14 @@ describe('overview', () => { expect(extractProjectIdFromWorktreeName('bugfix-296-slug')).toBe('bugfix-296'); }); + it('extracts bare numeric ID from PIR worktree (aligns with SPIR convention)', () => { + expect(extractProjectIdFromWorktreeName('pir-1298-fix-foo')).toBe('1298'); + }); + + it('extracts bare numeric ID from PIR worktree with no slug', () => { + expect(extractProjectIdFromWorktreeName('pir-1298')).toBe('1298'); + }); + it('extracts legacy numeric ID', () => { expect(extractProjectIdFromWorktreeName('0110')).toBe('0110'); }); @@ -1385,6 +1396,36 @@ describe('overview', () => { expect(mockFetchPRList).toHaveBeenCalledTimes(1); }); + it('resolves currentUser from the user-identity concept', async () => { + mockFetchCurrentUser.mockResolvedValue('octocat'); + + const cache = new OverviewCache(); + const data = await cache.getOverview(tmpDir); + + expect(data.currentUser).toBe('octocat'); + expect(mockFetchCurrentUser).toHaveBeenCalledWith(tmpDir); + }); + + it('omits currentUser when user-identity resolution fails', async () => { + mockFetchCurrentUser.mockResolvedValue(null); + + const cache = new OverviewCache(); + const data = await cache.getOverview(tmpDir); + + expect(data.currentUser).toBeUndefined(); + expect(data.backlog).toEqual([]); + }); + + it('caches currentUser across getOverview calls', async () => { + mockFetchCurrentUser.mockResolvedValue('octocat'); + + const cache = new OverviewCache(); + await cache.getOverview(tmpDir); + await cache.getOverview(tmpDir); + + expect(mockFetchCurrentUser).toHaveBeenCalledTimes(1); + }); + it('invalidates cache on refresh', async () => { mockFetchPRList.mockResolvedValue([]); mockFetchIssueList.mockResolvedValue([]); diff --git a/packages/codev/src/agent-farm/cli.ts b/packages/codev/src/agent-farm/cli.ts index 6841b12f2..437703c73 100644 --- a/packages/codev/src/agent-farm/cli.ts +++ b/packages/codev/src/agent-farm/cli.ts @@ -237,7 +237,7 @@ export async function runAgentFarm(args: string[]): Promise { .command('spawn') .description('Spawn a new builder') .argument('[identifier]', 'Issue identifier (positional, e.g. 315 or ENG-123)') - .option('--protocol ', 'Protocol to use (spir, aspir, air, bugfix, maintain, experiment)') + .option('--protocol ', 'Protocol to use (spir, aspir, air, bugfix, pir, maintain, experiment)') .option('--task ', 'Spawn builder with a task description') .option('--shell', 'Spawn a bare Claude session') .option('--worktree', 'Spawn worktree session') diff --git a/packages/codev/src/agent-farm/commands/cleanup.ts b/packages/codev/src/agent-farm/commands/cleanup.ts index ddfd7f755..b0056ccac 100644 --- a/packages/codev/src/agent-farm/commands/cleanup.ts +++ b/packages/codev/src/agent-farm/commands/cleanup.ts @@ -393,6 +393,19 @@ async function cleanupBuilder(builder: Builder, force?: boolean, issueNumber?: n } } + // Invalidate Tower's overview cache + broadcast an `overview-changed` + // SSE event. Connected clients (VSCode sidebar, dashboard) subscribe + // to SSE and re-fetch on any event — without this, the just-removed + // builder would linger in their UI until some unrelated SSE event + // happened to trigger an incidental refresh. Best-effort — silently + // no-ops if Tower isn't running. + try { + const client = new TowerClient(); + await client.refreshOverview(); + } catch { + // Tower not running or unreachable — non-fatal. + } + logger.blank(); logger.success(`Builder ${builder.id} cleaned up!`); } diff --git a/packages/codev/src/agent-farm/commands/dev.ts b/packages/codev/src/agent-farm/commands/dev.ts index deb0e1442..db85c7c5d 100644 --- a/packages/codev/src/agent-farm/commands/dev.ts +++ b/packages/codev/src/agent-farm/commands/dev.ts @@ -47,10 +47,19 @@ export async function dev(options: DevOptions): Promise { } if (!options.builderId) { - throw new Error('Usage: afx dev (or --stop)'); + throw new Error('Usage: afx dev (or --stop)'); } - const builder = findBuilderById(options.builderId); + const config = getConfig(); + + // Reserved target: `main` runs the dev server in the main workspace (the + // default checkout), making it a Codev-managed, swappable PTY exactly like + // a builder worktree. Resolved locally — deliberately NOT via + // findBuilderById — so `main` never leaks into afx send/cleanup/status. + const isMain = options.builderId.toLowerCase() === 'main'; + const builder: { id: string; worktree?: string } | null = isMain + ? { id: 'main', worktree: config.workspaceRoot } + : findBuilderById(options.builderId); if (!builder) { throw new Error(`No builder found matching "${options.builderId}". Try \`afx status\`.`); } @@ -58,7 +67,6 @@ export async function dev(options: DevOptions): Promise { throw new Error(`Builder ${builder.id} has no worktree path on record — cannot start dev.`); } - const config = getConfig(); const { devCommand } = getWorktreeConfig(config.workspaceRoot); if (!devCommand) { throw new Error( diff --git a/packages/codev/src/agent-farm/commands/spawn-worktree.ts b/packages/codev/src/agent-farm/commands/spawn-worktree.ts index 0d91054f0..d7a6d96c7 100644 --- a/packages/codev/src/agent-farm/commands/spawn-worktree.ts +++ b/packages/codev/src/agent-farm/commands/spawn-worktree.ts @@ -407,21 +407,37 @@ export function slugify(title: string): string { } /** - * Find an existing bugfix worktree directory for a given issue number. - * Scans the builders directory for directories matching `bugfix-{issueNumber}-*`. - * Returns the directory name if found, or null if no match exists. + * Find an existing issue-driven worktree directory for a given protocol prefix + * and issue number. Scans the builders directory for directories matching + * `--*`. Returns the directory name if found, or + * null if no match exists. + * + * Used by the bugfix / PIR resume path to locate a worktree whose suffix + * (slug) may have changed since the original spawn. */ -export function findExistingBugfixWorktree(buildersDir: string, issueNumber: number | string): string | null { - const prefix = `bugfix-${issueNumber}-`; +export function findExistingIssueWorktree( + buildersDir: string, + protocolPrefix: string, + issueNumber: number | string, +): string | null { + const dirPrefix = `${protocolPrefix}-${issueNumber}-`; try { const entries = readdirSync(buildersDir, { withFileTypes: true }); - const match = entries.find(e => e.isDirectory() && e.name.startsWith(prefix)); + const match = entries.find(e => e.isDirectory() && e.name.startsWith(dirPrefix)); return match ? match.name : null; } catch { return null; } } +/** + * @deprecated Use `findExistingIssueWorktree(buildersDir, 'bugfix', issueNumber)` directly. + * Kept as a thin wrapper for backwards compatibility with existing tests. + */ +export function findExistingBugfixWorktree(buildersDir: string, issueNumber: number | string): string | null { + return findExistingIssueWorktree(buildersDir, 'bugfix', issueNumber); +} + /** * Fetch a GitHub issue via the `issue-view` concept command (fatal on failure). * Delegates to shared github utility but wraps with fatal() for spawn context. diff --git a/packages/codev/src/agent-farm/commands/spawn.ts b/packages/codev/src/agent-farm/commands/spawn.ts index 51eabb603..0f5ed39eb 100644 --- a/packages/codev/src/agent-farm/commands/spawn.ts +++ b/packages/codev/src/agent-farm/commands/spawn.ts @@ -58,7 +58,7 @@ import { fetchGitHubIssue, executePreSpawnHooks, slugify, - findExistingBugfixWorktree, + findExistingIssueWorktree, validateResumeWorktree, createPtySession, startBuilderSession, @@ -207,8 +207,12 @@ function getSpawnMode(options: SpawnOptions): BuilderType { if (options.worktree) return 'worktree'; if (options.issueNumber) { - // Protocol drives mode for issue-based spawns + // Protocol drives the mode for issue-based spawns. Each issue-driven + // protocol has its own mode value and its own dispatch entry — they share + // an implementation through `spawnIssueDrivenBuilder`, not through the + // dispatcher. if (options.protocol === 'bugfix') return 'bugfix'; + if (options.protocol === 'pir') return 'pir'; return 'spec'; } @@ -669,20 +673,34 @@ async function spawnWorktree(options: SpawnOptions, config: Config): Promise { +async function spawnIssueDrivenBuilder( + options: SpawnOptions, + config: Config, + prefix: 'bugfix' | 'pir', +): Promise { + const protocolLabel = prefix === 'pir' ? 'PIR' : 'Bugfix'; const issueNumber = options.issueNumber!; const protocol = await resolveIssueProtocol(options, config); const forgeConfig = loadForgeConfig(config.workspaceRoot); - logger.header(`${options.resume ? 'Resuming' : 'Spawning'} Bugfix Builder for Issue #${issueNumber}`); + logger.header(`${options.resume ? 'Resuming' : 'Spawning'} ${protocolLabel} Builder for Issue #${issueNumber}`); // Fetch issue from GitHub logger.info('Fetching issue from GitHub...'); const issue = await fetchGitHubIssue(issueNumber, { cwd: config.workspaceRoot, forgeConfig }); - const builderId = buildAgentName('bugfix', String(issueNumber)); + const builderId = buildAgentName(prefix, String(issueNumber)); // When resuming, find the existing worktree by issue number pattern // instead of recomputing from the current title (which may have changed). @@ -691,15 +709,15 @@ async function spawnBugfix(options: SpawnOptions, config: Config): Promise let branchName: string; if (options.branch) { branchName = options.branch; - worktreeName = `bugfix-${issueNumber}`; + worktreeName = `${prefix}-${issueNumber}`; } else if (options.resume) { // Migration: try ID-only path first, fall back to old title-based path - const idOnlyName = `bugfix-${issueNumber}`; + const idOnlyName = `${prefix}-${issueNumber}`; const idOnlyPath = resolve(config.buildersDir, idOnlyName); if (existsSync(idOnlyPath)) { worktreeName = idOnlyName; } else { - const existing = findExistingBugfixWorktree(config.buildersDir, issueNumber); + const existing = findExistingIssueWorktree(config.buildersDir, prefix, issueNumber); if (existing) { worktreeName = existing; } else { @@ -708,7 +726,7 @@ async function spawnBugfix(options: SpawnOptions, config: Config): Promise } branchName = `builder/${worktreeName}`; } else { - worktreeName = `bugfix-${issueNumber}`; + worktreeName = `${prefix}-${issueNumber}`; branchName = `builder/${worktreeName}`; } @@ -755,21 +773,26 @@ async function spawnBugfix(options: SpawnOptions, config: Config): Promise await ensureDirectories(config); await checkDependencies(); + // Porch project ID: + // - PIR uses the bare issue number (matches SPIR's convention, so + // artifacts land at codev/{plans,reviews}/-.md). + // - BUGFIX uses - (historical, kept untouched). + // + // Distinct from the worktree dir (.builders/-/), the branch + // (builder/-), and the Tower agent name (builder--). + // All four are namespaced differently — porch state ID lines up with + // artifacts; the other three encode protocol for collision-free worktree + // / branch / agent management. + const porchProjectId = prefix === 'pir' ? String(issueNumber) : `${prefix}-${issueNumber}`; + if (options.resume) { validateResumeWorktree(worktreePath); } else if (options.branch) { await createWorktreeFromBranch(config, branchName, worktreePath, { remote: options.remote }); - // Pre-initialize porch for --branch mode too - const porchProjectId = `bugfix-${issueNumber}`; const slug = slugify(issue.title); await initPorchInWorktree(worktreePath, protocol, porchProjectId, slug); } else { await createWorktree(config, branchName, worktreePath); - - // Pre-initialize porch so the builder doesn't need to figure out project ID. - // Use bugfix-{N} as the porch project ID (not the builder agent name). - // This aligns with porch's CWD-based detection from worktree paths. - const porchProjectId = `bugfix-${issueNumber}`; const slug = slugify(issue.title); await initPorchInWorktree(worktreePath, protocol, porchProjectId, slug); } @@ -777,8 +800,10 @@ async function spawnBugfix(options: SpawnOptions, config: Config): Promise const templateContext: TemplateContext = { protocol_name: protocol.toUpperCase(), mode, mode_soft: mode === 'soft', mode_strict: mode === 'strict', - project_id: builderId, - input_description: `a fix for GitHub Issue #${issueNumber}`, + // The porch project ID — what `porch next/done/approve` expects. + // NOT the builder agent name (which includes the `builder-` prefix). + project_id: porchProjectId, + input_description: `work for GitHub Issue #${issueNumber}`, issue: { number: issueNumber, title: issue.title, body: issue.body || '(No description provided)' }, }; if (options.branch) { @@ -800,13 +825,23 @@ async function spawnBugfix(options: SpawnOptions, config: Config): Promise upsertBuilder({ id: builderId, - name: `Bugfix #${issueNumber}: ${issue.title.substring(0, 40)}${issue.title.length > 40 ? '...' : ''}`, + name: `${protocolLabel} #${issueNumber}: ${issue.title.substring(0, 40)}${issue.title.length > 40 ? '...' : ''}`, status: 'implementing', phase: 'init', - worktree: worktreePath, branch: branchName, type: 'bugfix', issueNumber, terminalId, + worktree: worktreePath, branch: branchName, type: prefix, issueNumber, terminalId, spawnedByArchitect: SPAWNING_ARCHITECT_NAME, }); - logSpawnSuccess(`Bugfix builder for issue #${issueNumber}`, terminalId, mode); + logSpawnSuccess(`${protocolLabel} builder for issue #${issueNumber}`, terminalId, mode); +} + +/** Spawn a BUGFIX builder via the shared issue-driven helper. */ +async function spawnBugfix(options: SpawnOptions, config: Config): Promise { + return spawnIssueDrivenBuilder(options, config, 'bugfix'); +} + +/** Spawn a PIR builder via the shared issue-driven helper. */ +async function spawnPir(options: SpawnOptions, config: Config): Promise { + return spawnIssueDrivenBuilder(options, config, 'pir'); } // ============================================================================= @@ -855,6 +890,7 @@ export async function spawn(options: SpawnOptions): Promise { const handlers: Record Promise> = { spec: () => spawnSpec(options, config), bugfix: () => spawnBugfix(options, config), + pir: () => spawnPir(options, config), task: () => spawnTask(options, config), protocol: () => spawnProtocol(options, config), shell: () => spawnShell(options, config), diff --git a/packages/codev/src/agent-farm/db/index.ts b/packages/codev/src/agent-farm/db/index.ts index 3ef9fb78e..6689309be 100644 --- a/packages/codev/src/agent-farm/db/index.ts +++ b/packages/codev/src/agent-farm/db/index.ts @@ -431,6 +431,42 @@ function ensureLocalDatabase(): Database.Database { db.prepare('INSERT INTO _migrations (version) VALUES (9)').run(); } + // Migration v10: Add 'pir' to builders.type CHECK constraint (Issue 691). + // Reintroduced post main-merge (the original collided with main's v9). + // Drift-robust: derive builders_new from the LIVE builders DDL (only the + // table name + the type CHECK literal are rewritten), so `SELECT *` can + // never column-mismatch regardless of columns earlier migrations added + // (e.g. v9's spawned_by_architect). Idempotent: skips if 'pir' is already + // in the constraint (covers fresh installs and DBs that ran the old v9). + const v10 = db.prepare('SELECT version FROM _migrations WHERE version = 10').get(); + if (!v10) { + const builders = db + .prepare("SELECT sql FROM sqlite_master WHERE type='table' AND name='builders'") + .get() as { sql: string } | undefined; + + if (builders?.sql && !builders.sql.includes("'pir'")) { + const newSql = builders.sql + .replace(/^CREATE TABLE\s+(?:IF NOT EXISTS\s+)?["'`]?builders["'`]?/i, 'CREATE TABLE builders_new') + .replace(/(CHECK\s*\(\s*type\s+IN\s*\([^)]*)\)/i, "$1, 'pir')"); + db.exec(` + ${newSql}; + INSERT INTO builders_new SELECT * FROM builders; + DROP TABLE builders; + ALTER TABLE builders_new RENAME TO builders; + CREATE INDEX IF NOT EXISTS idx_builders_status ON builders(status); + CREATE INDEX IF NOT EXISTS idx_builders_port ON builders(port); + CREATE TRIGGER IF NOT EXISTS builders_updated_at + AFTER UPDATE ON builders + FOR EACH ROW + BEGIN + UPDATE builders SET updated_at = datetime('now') WHERE id = NEW.id; + END; + `); + console.log("[info] Migrated builders table: added 'pir' to type CHECK constraint (v10)"); + } + db.prepare('INSERT INTO _migrations (version) VALUES (10)').run(); + } + return db; } diff --git a/packages/codev/src/agent-farm/db/schema.ts b/packages/codev/src/agent-farm/db/schema.ts index 27810a11b..c8aa4c681 100644 --- a/packages/codev/src/agent-farm/db/schema.ts +++ b/packages/codev/src/agent-farm/db/schema.ts @@ -37,7 +37,7 @@ CREATE TABLE IF NOT EXISTS builders ( worktree TEXT NOT NULL, branch TEXT NOT NULL, type TEXT NOT NULL DEFAULT 'spec' - CHECK(type IN ('spec', 'task', 'protocol', 'shell', 'worktree', 'bugfix')), + CHECK(type IN ('spec', 'task', 'protocol', 'shell', 'worktree', 'bugfix', 'pir')), task_text TEXT, protocol_name TEXT, issue_number TEXT, diff --git a/packages/codev/src/agent-farm/servers/analytics.ts b/packages/codev/src/agent-farm/servers/analytics.ts index 6499534af..d34e64968 100644 --- a/packages/codev/src/agent-farm/servers/analytics.ts +++ b/packages/codev/src/agent-farm/servers/analytics.ts @@ -253,6 +253,7 @@ function computeConsultationMetrics(days: number | undefined, workspacePath: str */ const BRANCH_PROTOCOL_PATTERNS: Array<{ pattern: RegExp; protocol: string }> = [ { pattern: /^builder\/bugfix-/, protocol: 'bugfix' }, + { pattern: /^builder\/pir-/, protocol: 'pir' }, { pattern: /^builder\/spir-/, protocol: 'spir' }, { pattern: /^spir\//, protocol: 'spir' }, { pattern: /^builder\/aspir-/, protocol: 'aspir' }, diff --git a/packages/codev/src/agent-farm/servers/overview.ts b/packages/codev/src/agent-farm/servers/overview.ts index e2061dc57..1ca1c41c7 100644 --- a/packages/codev/src/agent-farm/servers/overview.ts +++ b/packages/codev/src/agent-farm/servers/overview.ts @@ -14,6 +14,7 @@ import { fetchIssueList, fetchRecentlyClosed, fetchRecentMergedPRs, + fetchCurrentUser, parseLinkedIssue, parseLabelDefaults, } from '../../lib/github.js'; @@ -42,7 +43,14 @@ export interface BuilderOverview { protocol: string; planPhases: PlanPhase[]; progress: number; + /** Human-readable label for the gate the builder is blocked on (e.g. "plan review"). */ blocked: string | null; + /** + * Canonical gate name (e.g. "plan-approval") for the gate the builder is + * blocked on. Use this when calling `porch approve` — `blocked` is a + * display label and won't match porch's gate keys. + */ + blockedGate: string | null; blockedSince: string | null; startedAt: string | null; idleMs: number; @@ -93,6 +101,8 @@ export interface OverviewData { pendingPRs: PROverview[]; backlog: BacklogItem[]; recentlyClosed: RecentlyClosedItem[]; + /** Auto-detected forge login of the current user (via the user-identity concept). */ + currentUser?: string; errors?: { prs?: string; issues?: string }; } @@ -331,15 +341,22 @@ function loadProtocolPhases(workspaceRoot: string, protocolName: string): string /** * Detect if a builder is blocked on a gate (requested but not approved). * Returns a human-readable label or null. + * + * The allowlist mirrors the gates emitted by the bundled protocols (SPIR, + * ASPIR, BUGFIX, AIR, PIR). New protocols that introduce new gate names must + * register them here, otherwise their gate-pending state is invisible to + * `OverviewBuilder.blocked` and downstream UIs (VSCode Needs Attention tree, + * VSCode toast, dashboard NeedsAttentionList, status bar counter). */ -export function detectBlocked(parsed: ParsedStatus): string | null { - const gateLabels: Record = { - 'spec-approval': 'spec review', - 'plan-approval': 'plan review', - 'pr': 'PR review', - }; +const GATE_LABELS: Record = { + 'spec-approval': 'spec review', + 'plan-approval': 'plan review', + 'dev-approval': 'dev review', + 'pr': 'PR review', +}; - for (const [gate, label] of Object.entries(gateLabels)) { +export function detectBlocked(parsed: ParsedStatus): string | null { + for (const [gate, label] of Object.entries(GATE_LABELS)) { if (parsed.gates[gate] === 'pending' && parsed.gateRequestedAt[gate]) { return label; } @@ -347,12 +364,28 @@ export function detectBlocked(parsed: ParsedStatus): string | null { return null; } +/** + * Canonical gate name (e.g. "plan-approval") for the gate the builder is + * blocked on. Sibling to `detectBlocked` which returns the display label. + * Returns null if the builder isn't blocked. + */ +export function detectBlockedGate(parsed: ParsedStatus): string | null { + for (const gate of Object.keys(GATE_LABELS)) { + if (parsed.gates[gate] === 'pending' && parsed.gateRequestedAt[gate]) { + return gate; + } + } + return null; +} + /** * Detect when the current blocked gate was first requested. * Returns the ISO timestamp string or null if not blocked. + * + * Keep this list in sync with `detectBlocked`'s `gateLabels` keys. */ export function detectBlockedSince(parsed: ParsedStatus): string | null { - const gateNames = ['spec-approval', 'plan-approval', 'pr']; + const gateNames = ['spec-approval', 'plan-approval', 'dev-approval', 'pr']; for (const gate of gateNames) { if (parsed.gates[gate] === 'pending' && parsed.gateRequestedAt[gate]) { return parsed.gateRequestedAt[gate]; @@ -424,6 +457,10 @@ export function worktreeNameToRoleId(dirName: string): string | null { const bugfixMatch = lower.match(/^bugfix-(\d+)/); if (bugfixMatch) return `builder-bugfix-${Number(bugfixMatch[1])}`; + // PIR: pir-1298-slug → builder-pir-1298 + const pirMatch = lower.match(/^pir-(\d+)/); + if (pirMatch) return `builder-pir-${Number(pirMatch[1])}`; + // Task: task-NAvW → builder-task-navw const taskMatch = lower.match(/^task-([a-z0-9]+)/); if (taskMatch) return `builder-task-${taskMatch[1]}`; @@ -470,6 +507,13 @@ export function extractProjectIdFromWorktreeName(dirName: string): string | null const bugfixMatch = dirName.match(/^bugfix-(\d+)/); if (bugfixMatch) return `bugfix-${bugfixMatch[1]}`; + // PIR: pir-1298-slug → "1298" (porch project ID is just the issue + // number, aligning with SPIR's convention so artifacts land in + // codev/{plans,reviews}/-.md without a protocol prefix). + // Worktree dir keeps the `pir-` prefix for namespace separation. + const pirMatch = dirName.match(/^pir-(\d+)/); + if (pirMatch) return pirMatch[1]; + // Legacy numeric: 0110 or 0110-slug → "0110" const numericMatch = dirName.match(/^(\d+)(?:-|$)/); if (numericMatch) return numericMatch[1]; @@ -514,6 +558,7 @@ export function discoverBuilders(workspaceRoot: string): BuilderOverview[] { planPhases: [], progress: 0, blocked: null, + blockedGate: null, blockedSince: null, startedAt: null, idleMs: 0, @@ -565,6 +610,7 @@ export function discoverBuilders(workspaceRoot: string): BuilderOverview[] { planPhases: parsed.planPhases, progress: calculateProgress(parsed, workspaceRoot), blocked: detectBlocked(parsed), + blockedGate: detectBlockedGate(parsed), blockedSince: detectBlockedSince(parsed), startedAt: parsed.startedAt || null, idleMs: computeIdleMs(parsed), @@ -593,6 +639,7 @@ export function discoverBuilders(workspaceRoot: string): BuilderOverview[] { planPhases: [], progress: 0, blocked: null, + blockedGate: null, blockedSince: null, startedAt: null, idleMs: 0, @@ -678,7 +725,9 @@ export class OverviewCache { private issueCache = new Map(); private closedCache = new Map(); private mergedPRCache = new Map(); + private currentUserCache = new Map(); private readonly TTL = 30_000; + private readonly USER_TTL = 3_600_000; // 1h — GitHub identity is session-stable /** * Build the overview response. Aggregates builder state, PRs, and backlog. @@ -731,12 +780,14 @@ export class OverviewCache { .filter((id): id is string => id !== null), ); - // 2. Fetch PRs, issues, recently closed, and merged PRs in parallel (each is independently cached) - const [prs, issues, closed, mergedPRs] = await Promise.all([ + // 2. Fetch PRs, issues, recently closed, merged PRs, and current user in + // parallel (each is independently cached) + const [prs, issues, closed, mergedPRs, currentUser] = await Promise.all([ this.fetchPRsCached(workspaceRoot), this.fetchIssuesCached(workspaceRoot), this.fetchRecentlyClosedCached(workspaceRoot), this.fetchMergedPRsCached(workspaceRoot), + this.fetchCurrentUserCached(workspaceRoot), ]); // 3. Process PRs @@ -819,6 +870,9 @@ export class OverviewCache { } const result: OverviewData = { builders, pendingPRs, backlog, recentlyClosed }; + if (currentUser) { + result.currentUser = currentUser; + } if (Object.keys(errors).length > 0) { result.errors = errors; } @@ -833,6 +887,7 @@ export class OverviewCache { this.issueCache.clear(); this.closedCache.clear(); this.mergedPRCache.clear(); + this.currentUserCache.clear(); } // =========================================================================== @@ -867,6 +922,26 @@ export class OverviewCache { return data; } + /** + * Resolve the current user's forge login via the `user-identity` concept. + * Long TTL — identity is stable for the lifetime of a Tower session. + * Only successful resolutions are cached, so a transient failure (gh + * logged out, offline) self-heals on the next overview poll. + */ + private async fetchCurrentUserCached(cwd: string): Promise { + const now = Date.now(); + const cached = this.currentUserCache.get(cwd); + if (cached && (now - cached.fetchedAt) < this.USER_TTL) { + return cached.data; + } + + const login = await fetchCurrentUser(cwd); + if (login !== null) { + this.currentUserCache.set(cwd, { data: login, fetchedAt: now }); + } + return login; + } + private async fetchRecentlyClosedCached(cwd: string): Promise { const now = Date.now(); const cached = this.closedCache.get(cwd); diff --git a/packages/codev/src/agent-farm/servers/tower-routes.ts b/packages/codev/src/agent-farm/servers/tower-routes.ts index 44c6ab246..016860958 100644 --- a/packages/codev/src/agent-farm/servers/tower-routes.ts +++ b/packages/codev/src/agent-farm/servers/tower-routes.ts @@ -57,6 +57,7 @@ import { addArchitect, } from './tower-instances.js'; import { OverviewCache } from './overview.js'; +import { fetchIssue } from '../../lib/github.js'; import { computeAnalytics } from './analytics.js'; import { getAllTasks, executeTask, getTaskId } from './tower-cron.js'; import { getGlobalDb } from '../db/index.js'; @@ -145,6 +146,7 @@ const ROUTES: Record = { 'GET /api/terminals': (_req, res) => handleTerminalList(res), 'GET /api/status': (_req, res) => handleStatus(res), 'GET /api/overview': (_req, res, url) => handleOverview(res, url), + 'GET /api/issue': (_req, res, url) => handleIssueView(res, url), 'GET /api/analytics': (_req, res, url) => handleAnalytics(res, url), 'POST /api/overview/refresh': (_req, res, _url, ctx) => handleOverviewRefresh(res, ctx), 'GET /api/events': (req, res, _url, ctx) => handleSSEEvents(req, res, ctx), @@ -766,6 +768,34 @@ async function handleOverview(res: http.ServerResponse, url: URL, workspaceOverr res.end(JSON.stringify(data)); } +async function handleIssueView(res: http.ServerResponse, url: URL): Promise { + // Workspace resolution mirrors handleOverview: ?workspace= param, else + // the first known non-builder workspace path. + let workspaceRoot = url.searchParams.get('workspace'); + if (!workspaceRoot) { + const knownPaths = getKnownWorkspacePaths(); + workspaceRoot = knownPaths.find(p => !p.includes('/.builders/')) || null; + } + + const number = url.searchParams.get('number'); + if (!workspaceRoot || !number) { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Missing workspace or number' })); + return; + } + + // Routes through the `issue-view` forge concept (forge-agnostic). + const issue = await fetchIssue(number, { cwd: workspaceRoot }); + if (!issue) { + res.writeHead(404, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: `Issue #${number} not found or forge unavailable` })); + return; + } + + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(issue)); +} + function handleOverviewRefresh(res: http.ServerResponse, ctx?: RouteContext): void { overviewCache.invalidate(); // Bugfix #388: Broadcast SSE event so all connected dashboard clients diff --git a/packages/codev/src/agent-farm/types.ts b/packages/codev/src/agent-farm/types.ts index 76382526d..d68ef7e82 100644 --- a/packages/codev/src/agent-farm/types.ts +++ b/packages/codev/src/agent-farm/types.ts @@ -2,7 +2,7 @@ * Core types for Agent Farm */ -export type BuilderType = 'spec' | 'task' | 'protocol' | 'shell' | 'worktree' | 'bugfix'; +export type BuilderType = 'spec' | 'task' | 'protocol' | 'shell' | 'worktree' | 'bugfix' | 'pir'; export interface Builder { id: string; diff --git a/packages/codev/src/agent-farm/utils/agent-names.ts b/packages/codev/src/agent-farm/utils/agent-names.ts index 5947adbde..cb7e294fb 100644 --- a/packages/codev/src/agent-farm/utils/agent-names.ts +++ b/packages/codev/src/agent-farm/utils/agent-names.ts @@ -43,6 +43,9 @@ export function buildAgentName(type: BuilderType, id: string, protocol?: string) case 'bugfix': protocolSegment = 'bugfix'; break; + case 'pir': + protocolSegment = 'pir'; + break; case 'task': protocolSegment = 'task'; break; diff --git a/packages/codev/src/cli.ts b/packages/codev/src/cli.ts index cffbda6ad..3fa456639 100644 --- a/packages/codev/src/cli.ts +++ b/packages/codev/src/cli.ts @@ -173,7 +173,7 @@ program .option('-m, --model ', 'Model to use (gemini, codex, claude, hermes, or aliases: pro, gpt, opus)') .option('--prompt ', 'Inline prompt (general mode)') .option('--prompt-file ', 'Prompt file path (general mode)') - .option('--protocol ', 'Protocol name: spir, aspir, air, bugfix, maintain') + .option('--protocol ', 'Protocol name: spir, aspir, air, bugfix, pir, maintain') .option('-t, --type ', 'Review type: spec, plan, impl, pr, phase, integration') .option('--issue ', 'Issue number (required from architect context)') .option('--output ', 'Write consultation output to file (used by porch)') diff --git a/packages/codev/src/commands/porch/__tests__/artifacts.test.ts b/packages/codev/src/commands/porch/__tests__/artifacts.test.ts index 585343403..9d6b12b37 100644 --- a/packages/codev/src/commands/porch/__tests__/artifacts.test.ts +++ b/packages/codev/src/commands/porch/__tests__/artifacts.test.ts @@ -12,6 +12,7 @@ import { CliResolver, getResolver, isPreApprovedContent, + matchesProjectId, } from '../artifacts.js'; // --------------------------------------------------------------------------- @@ -232,3 +233,163 @@ describe('getResolver', () => { expect(() => getResolver(tmpDir)).toThrow('af-config.json is no longer supported'); }); }); + +// --------------------------------------------------------------------------- +// matchesProjectId (Issue 691 — fix prefix-N project ID resolution) +// --------------------------------------------------------------------------- + +describe('matchesProjectId', () => { + describe('numeric project IDs (SPIR / ASPIR / AIR)', () => { + it('matches a file whose leading digits equal the (zero-stripped) ID', () => { + expect(matchesProjectId('0073-feature-name.md', '0073')).toBe(true); + expect(matchesProjectId('0073-feature-name.md', '73')).toBe(true); + expect(matchesProjectId('73-feature-name.md', '0073')).toBe(true); + }); + + it('matches a directory name (no .md suffix)', () => { + expect(matchesProjectId('0073-feature-name', '0073')).toBe(true); + }); + + it('rejects a different numeric ID', () => { + expect(matchesProjectId('0073-feature-name.md', '0074')).toBe(false); + }); + + it('rejects a filename without leading digits', () => { + expect(matchesProjectId('feature-name.md', '0073')).toBe(false); + }); + }); + + describe('prefix-N project IDs (BUGFIX / PIR / future issue-driven)', () => { + it('matches a file whose name starts with --', () => { + expect(matchesProjectId('pir-1099-fix-avatar.md', 'pir-1099')).toBe(true); + expect(matchesProjectId('bugfix-237-stale-cache.md', 'bugfix-237')).toBe(true); + }); + + it('matches a directory name (no .md suffix)', () => { + expect(matchesProjectId('pir-1099-fix-avatar', 'pir-1099')).toBe(true); + }); + + it('matches when filename equals the project ID exactly (no slug)', () => { + expect(matchesProjectId('pir-1099.md', 'pir-1099')).toBe(true); + expect(matchesProjectId('pir-1099', 'pir-1099')).toBe(true); + }); + + it('rejects a different prefix', () => { + expect(matchesProjectId('bugfix-1099-foo.md', 'pir-1099')).toBe(false); + }); + + it('rejects a different number', () => { + expect(matchesProjectId('pir-1099-foo.md', 'pir-1100')).toBe(false); + }); + + it('rejects a filename where the ID is a prefix but not delimited by -', () => { + // "pir-1099foo.md" should NOT match "pir-1099" — the next char must be "-" or end. + expect(matchesProjectId('pir-1099foo.md', 'pir-1099')).toBe(false); + }); + + it('does not confuse numeric and prefixed matching', () => { + // A numeric ID should not match a prefix-N filename. + expect(matchesProjectId('pir-1099-foo.md', '1099')).toBe(false); + expect(matchesProjectId('pir-1099-foo.md', '0073')).toBe(false); + }); + }); +}); + +// --------------------------------------------------------------------------- +// LocalResolver — prefix-N project ID support (Issue 691) +// --------------------------------------------------------------------------- + +describe('LocalResolver — prefix-N project IDs (bugfix)', () => { + // PIR was historically in this group but aligned with SPIR's numeric + // convention in commit dc177c83. These tests exercise the prefix-N path + // that is still load-bearing for BUGFIX (and any future issue-driven + // protocol that opts for a prefix-N ID). + let tmpDir: string; + + beforeEach(() => { + tmpDir = mkTmp(); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('getPlanContent finds a prefix-N plan file (bugfix-style)', () => { + writeFile( + tmpDir, + 'codev/plans/bugfix-237-stale-cache.md', + '# Plan: stale cache\n', + ); + + const resolver = new LocalResolver(tmpDir); + const content = resolver.getPlanContent('bugfix-237', 'stale-cache'); + expect(content).toContain('# Plan: stale cache'); + }); + + it('getReviewContent finds a prefix-N review file (bugfix-style)', () => { + writeFile( + tmpDir, + 'codev/reviews/bugfix-237-stale-cache.md', + '# Review: stale cache\n', + ); + + const resolver = new LocalResolver(tmpDir); + const content = resolver.getReviewContent('bugfix-237', 'stale-cache'); + expect(content).toContain('# Review: stale cache'); + }); + + it('returns null for a missing prefix-N plan', () => { + fs.mkdirSync(path.join(tmpDir, 'codev', 'plans'), { recursive: true }); + + const resolver = new LocalResolver(tmpDir); + expect(resolver.getPlanContent('bugfix-9999', 'nothing-here')).toBeNull(); + }); + + it('does not match a prefix-N plan when looking up a numeric ID with the same digits', () => { + // Regression guard: "bugfix-237-foo.md" must NOT be returned for projectId="237". + writeFile( + tmpDir, + 'codev/plans/bugfix-237-stale.md', + '# Plan: bugfix stale cache\n', + ); + + const resolver = new LocalResolver(tmpDir); + expect(resolver.getPlanContent('237', '')).toBeNull(); + }); + + it('still works for numeric project IDs (SPIR / ASPIR / AIR / PIR)', () => { + writeFile( + tmpDir, + 'codev/plans/0073-user-auth.md', + '# Plan: user auth\n', + ); + + const resolver = new LocalResolver(tmpDir); + expect(resolver.getPlanContent('0073', 'user-auth')).toContain('# Plan: user auth'); + expect(resolver.getPlanContent('73', 'user-auth')).toContain('# Plan: user auth'); + }); + + it('finds a PIR plan file by bare numeric ID (post-dc177c83 convention)', () => { + writeFile( + tmpDir, + 'codev/plans/1298-fix-native-social-login.md', + '# Plan: fix social login\n', + ); + + const resolver = new LocalResolver(tmpDir); + const content = resolver.getPlanContent('1298', 'fix-native-social-login'); + expect(content).toContain('# Plan: fix social login'); + }); + + it('finds a PIR review file by bare numeric ID (post-dc177c83 convention)', () => { + writeFile( + tmpDir, + 'codev/reviews/1298-fix-native-social-login.md', + '# Review: fix social login\n', + ); + + const resolver = new LocalResolver(tmpDir); + const content = resolver.getReviewContent('1298', 'fix-native-social-login'); + expect(content).toContain('# Review: fix social login'); + }); +}); diff --git a/packages/codev/src/commands/porch/__tests__/notify.test.ts b/packages/codev/src/commands/porch/__tests__/notify.test.ts index 57f57e8b1..109f61cf5 100644 --- a/packages/codev/src/commands/porch/__tests__/notify.test.ts +++ b/packages/codev/src/commands/porch/__tests__/notify.test.ts @@ -1,8 +1,9 @@ /** - * Tests for notifyArchitect (Spec 0108) + * Tests for notifyTerminal — the builder wake-up after gate approval. * - * Verifies that porch sends gate notifications via afx send - * and that failures are swallowed (fire-and-forget). + * Architect-bound notifications were removed deliberately; the only caller + * of notifyTerminal today is the gate-approve path, which wakes the builder + * so its idle Claude session advances on the next turn. */ import { describe, it, expect, beforeEach, vi } from 'vitest'; @@ -18,14 +19,13 @@ vi.mock('node:child_process', () => ({ })); import { execFile } from 'node:child_process'; -import { notifyArchitect } from '../notify.js'; +import { notifyTerminal, gateApprovedMessage } from '../notify.js'; const mockExecFile = vi.mocked(execFile); -describe('notifyArchitect', () => { +describe('notifyTerminal', () => { beforeEach(() => { mockExecFile.mockReset(); - // Restore default success-callback implementation after each test mockExecFile.mockImplementation( (_cmd: any, _args: any, _opts: any, cb: any) => { cb(null); @@ -34,37 +34,51 @@ describe('notifyArchitect', () => { ); }); - it('calls execFile with correct arguments', () => { - notifyArchitect('0108', 'spec-approval', '/projects/test'); + it('routes message to the named target via afx send', () => { + notifyTerminal({ + target: 'pir-0108', + message: 'wake up', + worktreeDir: '/projects/test', + }); expect(mockExecFile).toHaveBeenCalledTimes(1); const [cmd, args, opts] = mockExecFile.mock.calls[0]; expect(cmd).toBe(process.execPath); expect(args).toContain('send'); - expect(args).toContain('architect'); + expect(args).toContain('pir-0108'); + expect(args).toContain('wake up'); expect(args).toContain('--raw'); - expect(args).toContain('--no-enter'); expect((opts as { cwd: string }).cwd).toBe('/projects/test'); }); - it('formats message with gate name and project id', () => { - notifyArchitect('0108', 'plan-approval', '/projects/test'); + it('submits as a regular message (no --no-enter)', () => { + notifyTerminal({ + target: 'pir-0108', + message: 'approved', + worktreeDir: '/projects/test', + }); - const message = mockExecFile.mock.calls[0][1]![3]; - expect(message).toContain('GATE: plan-approval (Builder 0108)'); - expect(message).toContain('Builder 0108 is waiting for approval.'); - expect(message).toContain('Run: porch approve 0108 plan-approval'); + const args = mockExecFile.mock.calls[0][1]!; + expect(args).not.toContain('--no-enter'); }); it('sets timeout to 10 seconds', () => { - notifyArchitect('0108', 'spec-approval', '/projects/test'); + notifyTerminal({ + target: 'pir-0108', + message: 'x', + worktreeDir: '/projects/test', + }); const opts = mockExecFile.mock.calls[0][2] as { timeout: number }; expect(opts.timeout).toBe(10_000); }); it('sets cwd to worktreeDir', () => { - notifyArchitect('0108', 'spec-approval', '/my/worktree'); + notifyTerminal({ + target: 'pir-0108', + message: 'x', + worktreeDir: '/my/worktree', + }); const opts = mockExecFile.mock.calls[0][2] as { cwd: string }; expect(opts.cwd).toBe('/my/worktree'); @@ -80,40 +94,33 @@ describe('notifyArchitect', () => { } ); - // Should not throw - expect(() => notifyArchitect('0108', 'spec-approval', '/projects/test')).not.toThrow(); + expect(() => + notifyTerminal({ target: 'pir-0108', message: 'x', worktreeDir: '/p' }) + ).not.toThrow(); expect(consoleSpy).toHaveBeenCalledWith( - expect.stringContaining('Gate notification failed') - ); - - consoleSpy.mockRestore(); - }); - - it('logs error message on failure', () => { - const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); - - mockExecFile.mockImplementation( - (_cmd: any, _args: any, _opts: any, cb: any) => { - cb(new Error('connection refused')); - return undefined as any; - } - ); - - notifyArchitect('0108', 'spec-approval', '/projects/test'); - - expect(consoleSpy).toHaveBeenCalledWith( - '[porch] Gate notification failed: connection refused' + expect.stringContaining('notifyTerminal(pir-0108) failed') ); consoleSpy.mockRestore(); }); it('uses afx binary path ending with bin/afx.js', () => { - notifyArchitect('0108', 'spec-approval', '/projects/test'); + notifyTerminal({ + target: 'pir-0108', + message: 'x', + worktreeDir: '/projects/test', + }); const args = mockExecFile.mock.calls[0][1]!; - // The afx binary should be a resolved path ending with bin/afx.js expect(args[0]).toMatch(/bin\/afx\.js$/); }); }); + +describe('gateApprovedMessage', () => { + it('references the gate and porch next', () => { + const msg = gateApprovedMessage('dev-approval'); + expect(msg).toContain('dev-approval'); + expect(msg).toContain('porch next'); + }); +}); diff --git a/packages/codev/src/commands/porch/__tests__/protocol.test.ts b/packages/codev/src/commands/porch/__tests__/protocol.test.ts index 5bfe9a7e1..2dd02f489 100644 --- a/packages/codev/src/commands/porch/__tests__/protocol.test.ts +++ b/packages/codev/src/commands/porch/__tests__/protocol.test.ts @@ -254,3 +254,103 @@ describe('porch protocol loading', () => { }); }); }); + +/** + * PIR-specific protocol shape tests (Issue 691). + * + * PIR's contract is meaningful enough that we lock it in here: + * plan → gated by 'plan-approval' → next 'implement' + * implement → gated by 'dev-approval' → next 'review' + * review → no gate, terminal (next: null) + * + * The PIR protocol.json itself lives at codev/protocols/pir/ in the repo; + * to keep these tests independent of working-tree state, we synthesize a + * minimal PIR-shaped protocol and verify loadProtocol parses it correctly. + */ +describe('PIR protocol shape', () => { + const testDir = path.join(tmpdir(), `porch-pir-test-${Date.now()}`); + const pirDir = path.join(testDir, 'codev/protocols/pir'); + + const pirProtocol = { + name: 'pir', + alias: 'plan-implement-review', + version: '1.0.0', + description: 'PIR: Plan → Implement → Review for GitHub-issue-driven work with two human gates', + input: { type: 'github-issue', required: true }, + phases: [ + { + id: 'plan', + name: 'Plan', + type: 'build_verify', + gate: 'plan-approval', + next: 'implement', + }, + { + id: 'implement', + name: 'Implement', + type: 'build_verify', + gate: 'dev-approval', + next: 'review', + }, + { + id: 'review', + name: 'Review', + type: 'build_verify', + next: null, + }, + ], + }; + + beforeEach(() => { + fs.mkdirSync(pirDir, { recursive: true }); + fs.writeFileSync( + path.join(pirDir, 'protocol.json'), + JSON.stringify(pirProtocol, null, 2), + ); + }); + + afterEach(() => { + if (fs.existsSync(testDir)) { + fs.rmSync(testDir, { recursive: true }); + } + }); + + it('loads and normalizes the three PIR phases', () => { + const protocol = loadProtocol(testDir, 'pir'); + + expect(protocol.name).toBe('pir'); + expect(protocol.phases).toHaveLength(3); + expect(protocol.phases.map(p => p.id)).toEqual(['plan', 'implement', 'review']); + }); + + it('gates the plan phase on plan-approval and transitions to implement', () => { + const protocol = loadProtocol(testDir, 'pir'); + expect(getPhaseGate(protocol, 'plan')).toBe('plan-approval'); + expect(getNextPhase(protocol, 'plan')?.id).toBe('implement'); + }); + + it('gates the implement phase on dev-approval and transitions to review', () => { + const protocol = loadProtocol(testDir, 'pir'); + expect(getPhaseGate(protocol, 'implement')).toBe('dev-approval'); + expect(getNextPhase(protocol, 'implement')?.id).toBe('review'); + }); + + it('leaves the review phase ungated and terminal', () => { + const protocol = loadProtocol(testDir, 'pir'); + expect(getPhaseGate(protocol, 'review')).toBeNull(); + expect(getNextPhase(protocol, 'review')).toBeNull(); + }); + + it('resolves the plan-implement-review alias', () => { + const protocol = loadProtocol(testDir, 'plan-implement-review'); + expect(protocol.name).toBe('pir'); + }); + + it('treats dev-approval as a valid gate name (no whitelist)', () => { + // Sanity check: porch must accept new gate names purely from data. If a + // whitelist were ever added, this test would break before PIR ships. + const protocol = loadProtocol(testDir, 'pir'); + const gate = getPhaseGate(protocol, 'implement'); + expect(gate).toBe('dev-approval'); + }); +}); diff --git a/packages/codev/src/commands/porch/__tests__/state.test.ts b/packages/codev/src/commands/porch/__tests__/state.test.ts index d7a61e17a..34598d373 100644 --- a/packages/codev/src/commands/porch/__tests__/state.test.ts +++ b/packages/codev/src/commands/porch/__tests__/state.test.ts @@ -420,6 +420,18 @@ updated_at: "${state.updated_at}" expect(detectProjectIdFromCwd('/repo/.builders/spir-042-feature-name')).toBe('042'); }); + it('should detect numeric ID from pir worktree (aligns with SPIR convention)', () => { + expect(detectProjectIdFromCwd('/repo/.builders/pir-1298-fix-foo')).toBe('1298'); + }); + + it('should detect numeric ID from pir worktree without slug', () => { + expect(detectProjectIdFromCwd('/repo/.builders/pir-1298')).toBe('1298'); + }); + + it('should detect numeric ID from pir worktree subdirectory', () => { + expect(detectProjectIdFromCwd('/repo/.builders/pir-1298-fix-foo/src/file.ts')).toBe('1298'); + }); + it('should detect numeric ID from air worktree', () => { expect(detectProjectIdFromCwd('/repo/.builders/air-100-small-feature')).toBe('100'); }); diff --git a/packages/codev/src/commands/porch/__tests__/status-json.test.ts b/packages/codev/src/commands/porch/__tests__/status-json.test.ts new file mode 100644 index 000000000..e89c47167 --- /dev/null +++ b/packages/codev/src/commands/porch/__tests__/status-json.test.ts @@ -0,0 +1,194 @@ +/** + * Tests for `porch status --json` (Issue 691). + * + * The JSON flag produces a single-line JSON object on stdout suitable for + * consumption by the VSCode Needs Attention view (and any other tooling). + * All human-readable console output is suppressed. + * + * Asserted shape (per the function's JSDoc in commands/porch/index.ts): + * + * { + * "id": string, + * "title": string, + * "protocol": string, + * "phase": string, + * "iteration": number, + * "build_complete": boolean, + * "gate": string | null, + * "gate_status": "pending" | "approved" | null, + * "gate_requested_at": string | null, + * "gate_approved_at": string | null + * } + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { tmpdir } from 'node:os'; +import { status } from '../index.js'; +import { writeState, getStatusPath } from '../state.js'; +import type { ProjectState } from '../types.js'; + +function createTestDir(): string { + return path.join( + tmpdir(), + `porch-status-json-test-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, + ); +} + +function setupPirProtocol(testDir: string): void { + const protocolDir = path.join(testDir, 'codev', 'protocols', 'pir'); + fs.mkdirSync(protocolDir, { recursive: true }); + fs.writeFileSync( + path.join(protocolDir, 'protocol.json'), + JSON.stringify({ + name: 'pir', + version: '1.0.0', + phases: [ + { id: 'plan', name: 'Plan', type: 'build_verify', gate: 'plan-approval', next: 'implement' }, + { id: 'implement', name: 'Implement', type: 'build_verify', gate: 'dev-approval', next: 'review' }, + { id: 'review', name: 'Review', type: 'build_verify', next: null }, + ], + }), + ); +} + +function makeState(overrides: Partial = {}): ProjectState { + return { + id: 'pir-842', + title: 'fix-avatar-crop', + protocol: 'pir', + phase: 'plan', + plan_phases: [], + current_plan_phase: null, + gates: {}, + iteration: 1, + build_complete: false, + history: [], + started_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + ...overrides, + }; +} + +describe('porch status --json', () => { + let testDir: string; + let stdoutSpy: ReturnType; + let logSpy: ReturnType; + + beforeEach(() => { + testDir = createTestDir(); + fs.mkdirSync(testDir, { recursive: true }); + setupPirProtocol(testDir); + // Capture process.stdout.write to inspect the JSON output and prevent + // it from leaking into the test runner's output. + stdoutSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true); + // Suppress chalk/console output from any non-JSON code paths so tests + // don't print noise even if the suppression path regresses. + logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + }); + + afterEach(() => { + fs.rmSync(testDir, { recursive: true, force: true }); + vi.restoreAllMocks(); + }); + + function parseStdoutJson(): Record { + // The last write should be the JSON line; previous writes may be empty. + const calls = stdoutSpy.mock.calls + .map(c => (typeof c[0] === 'string' ? c[0] : String(c[0]))) + .filter(s => s.trim().length > 0); + expect(calls.length).toBeGreaterThan(0); + return JSON.parse(calls[calls.length - 1]); + } + + it('emits JSON object with all required fields when gate is pending', async () => { + const state = makeState({ + gates: { + 'plan-approval': { + status: 'pending', + requested_at: '2026-05-12T14:23:00.000Z', + }, + }, + build_complete: true, + }); + writeState(getStatusPath(testDir, 'pir-842', 'fix-avatar-crop'), state); + + await status(testDir, 'pir-842', undefined, { json: true }); + + const out = parseStdoutJson(); + expect(out.id).toBe('pir-842'); + expect(out.title).toBe('fix-avatar-crop'); + expect(out.protocol).toBe('pir'); + expect(out.phase).toBe('plan'); + expect(out.iteration).toBe(1); + expect(out.build_complete).toBe(true); + expect(out.gate).toBe('plan-approval'); + expect(out.gate_status).toBe('pending'); + expect(out.gate_requested_at).toBe('2026-05-12T14:23:00.000Z'); + expect(out.gate_approved_at).toBeNull(); + }); + + it('emits gate_status approved with approved_at timestamp', async () => { + const state = makeState({ + gates: { + 'plan-approval': { + status: 'approved', + requested_at: '2026-05-12T14:23:00.000Z', + approved_at: '2026-05-12T14:35:00.000Z', + }, + }, + }); + writeState(getStatusPath(testDir, 'pir-842', 'fix-avatar-crop'), state); + + await status(testDir, 'pir-842', undefined, { json: true }); + + const out = parseStdoutJson(); + expect(out.gate_status).toBe('approved'); + expect(out.gate_requested_at).toBe('2026-05-12T14:23:00.000Z'); + expect(out.gate_approved_at).toBe('2026-05-12T14:35:00.000Z'); + }); + + it('emits gate null for an ungated phase', async () => { + const state = makeState({ phase: 'review' }); + writeState(getStatusPath(testDir, 'pir-842', 'fix-avatar-crop'), state); + + await status(testDir, 'pir-842', undefined, { json: true }); + + const out = parseStdoutJson(); + expect(out.phase).toBe('review'); + expect(out.gate).toBeNull(); + expect(out.gate_status).toBeNull(); + expect(out.gate_requested_at).toBeNull(); + expect(out.gate_approved_at).toBeNull(); + }); + + it('suppresses human-readable console output in json mode', async () => { + const state = makeState(); + writeState(getStatusPath(testDir, 'pir-842', 'fix-avatar-crop'), state); + + await status(testDir, 'pir-842', undefined, { json: true }); + + // No console.log calls should fire in JSON mode — all output goes via + // process.stdout.write. + expect(logSpy).not.toHaveBeenCalled(); + }); + + it('reports the dev-approval gate correctly when in the implement phase', async () => { + const state = makeState({ + phase: 'implement', + gates: { + 'dev-approval': { status: 'pending', requested_at: '2026-05-12T15:00:00.000Z' }, + }, + build_complete: true, + }); + writeState(getStatusPath(testDir, 'pir-842', 'fix-avatar-crop'), state); + + await status(testDir, 'pir-842', undefined, { json: true }); + + const out = parseStdoutJson(); + expect(out.phase).toBe('implement'); + expect(out.gate).toBe('dev-approval'); + expect(out.gate_status).toBe('pending'); + }); +}); diff --git a/packages/codev/src/commands/porch/artifacts.ts b/packages/codev/src/commands/porch/artifacts.ts index 9844ecd14..7145ec593 100644 --- a/packages/codev/src/commands/porch/artifacts.ts +++ b/packages/codev/src/commands/porch/artifacts.ts @@ -39,6 +39,45 @@ export interface ArtifactResolver { // Shared Helpers // ============================================================================= +/** + * Match a file basename (or directory name) against a porch project ID. + * + * Supports two project-ID formats used in this codebase: + * + * 1. **Numeric** (SPIR, ASPIR, AIR, PIR): e.g. `"0073"` or `"1298"`. Matches + * filenames whose leading digits, zero-stripped, equal the project ID + * (also zero-stripped). Example: `"0073"` matches `"0073-feature.md"` + * and `"73-feature.md"`. + * + * 2. **Prefix-N** (BUGFIX only — historical, kept for back-compat with + * existing on-disk artifacts): e.g. `"bugfix-237"`. Matches filenames + * that equal `-` or start with `--`. Example: + * `"bugfix-237"` matches `"bugfix-237-stale-cache.md"`. + * + * Without this distinction, the previous regex `name.match(/^(\d+)/)` + * silently failed for prefix-N IDs, breaking `plan_exists` and other + * artifact-resolution checks for BUGFIX projects. (PIR exposed the bug + * historically when it also used prefix-N IDs; PIR has since been aligned + * with SPIR's numeric convention — see commit dc177c83.) + */ +export function matchesProjectId(name: string, projectId: string): boolean { + const base = name.replace(/\.md$/, ''); + + // Prefix-N format: `-` (one or more hyphen-separated letter + // segments followed by a numeric segment). Today this catches "bugfix-237"; + // the shape is kept general for any future issue-driven protocol that opts + // for a prefix-N ID. + if (/^[a-z]+(?:-[a-z]+)*-\d+$/i.test(projectId)) { + return base === projectId || base.startsWith(`${projectId}-`); + } + + // Numeric format: zero-stripped equality on leading digits. + const normalizedId = projectId.replace(/^0+/, '') || '0'; + const numMatch = base.match(/^(\d+)/); + if (!numMatch) return false; + return (numMatch[1].replace(/^0+/, '') || '0') === normalizedId; +} + /** * Check if artifact content has pre-approval frontmatter. * Looks for YAML frontmatter with `approved:` and `validated:` fields. @@ -66,15 +105,9 @@ export class LocalResolver implements ArtifactResolver { const specsDir = path.join(this.workspaceRoot, 'codev', 'specs'); if (!fs.existsSync(specsDir)) return null; - const normalizedId = projectId.replace(/^0+/, '') || '0'; try { const files = fs.readdirSync(specsDir); - const specFile = files.find(f => { - if (!f.endsWith('.md')) return false; - const numMatch = f.match(/^(\d+)/); - if (!numMatch) return false; - return (numMatch[1].replace(/^0+/, '') || '0') === normalizedId; - }); + const specFile = files.find(f => f.endsWith('.md') && matchesProjectId(f, projectId)); return specFile ? specFile.replace(/\.md$/, '') : null; } catch { return null; @@ -92,18 +125,13 @@ export class LocalResolver implements ArtifactResolver { } } - getPlanContent(projectId: string, title: string): string | null { + getPlanContent(projectId: string, _title: string): string | null { // Try new location first: codev/projects/-/plan.md const projectsDir = path.join(this.workspaceRoot, 'codev', 'projects'); if (fs.existsSync(projectsDir)) { - const normalizedId = projectId.replace(/^0+/, '') || '0'; try { const dirs = fs.readdirSync(projectsDir); - const projDir = dirs.find(d => { - const numMatch = d.match(/^(\d+)/); - if (!numMatch) return false; - return (numMatch[1].replace(/^0+/, '') || '0') === normalizedId; - }); + const projDir = dirs.find(d => matchesProjectId(d, projectId)); if (projDir) { const planPath = path.join(projectsDir, projDir, 'plan.md'); if (fs.existsSync(planPath)) { @@ -117,15 +145,9 @@ export class LocalResolver implements ArtifactResolver { const plansDir = path.join(this.workspaceRoot, 'codev', 'plans'); if (!fs.existsSync(plansDir)) return null; - const normalizedId = projectId.replace(/^0+/, '') || '0'; try { const files = fs.readdirSync(plansDir); - const planFile = files.find(f => { - if (!f.endsWith('.md')) return false; - const numMatch = f.match(/^(\d+)/); - if (!numMatch) return false; - return (numMatch[1].replace(/^0+/, '') || '0') === normalizedId; - }); + const planFile = files.find(f => f.endsWith('.md') && matchesProjectId(f, projectId)); if (planFile) { return fs.readFileSync(path.join(plansDir, planFile), 'utf-8'); } @@ -138,15 +160,9 @@ export class LocalResolver implements ArtifactResolver { const reviewsDir = path.join(this.workspaceRoot, 'codev', 'reviews'); if (!fs.existsSync(reviewsDir)) return null; - const normalizedId = projectId.replace(/^0+/, '') || '0'; try { const files = fs.readdirSync(reviewsDir); - const reviewFile = files.find(f => { - if (!f.endsWith('.md')) return false; - const numMatch = f.match(/^(\d+)/); - if (!numMatch) return false; - return (numMatch[1].replace(/^0+/, '') || '0') === normalizedId; - }); + const reviewFile = files.find(f => f.endsWith('.md') && matchesProjectId(f, projectId)); if (reviewFile) { return fs.readFileSync(path.join(reviewsDir, reviewFile), 'utf-8'); } @@ -206,12 +222,7 @@ export class CliResolver implements ArtifactResolver { const children = this.listChildren('specs'); if (!children) return null; - const normalizedId = projectId.replace(/^0+/, '') || '0'; - const match = children.find(name => { - const numMatch = name.match(/^(\d+)/); - if (!numMatch) return false; - return (numMatch[1].replace(/^0+/, '') || '0') === normalizedId; - }); + const match = children.find(name => matchesProjectId(name, projectId)); return match || null; } @@ -234,12 +245,14 @@ export class CliResolver implements ArtifactResolver { } hasPreApproval(artifactGlob: string): boolean { - // Determine artifact type from glob path (e.g., "codev/specs/0559-*.md" or "codev/plans/0559-*.md") + // Determine artifact type from glob path (e.g., "codev/specs/0559-*.md" or "codev/plans/bugfix-237-*.md") const typeMatch = artifactGlob.match(/\b(specs|plans|reviews)\b/); - const idMatch = artifactGlob.match(/(?:specs|plans|reviews)\/0*(\d+)/); - if (!idMatch) return false; + // Extract project ID — supports both numeric ("0073", "1298") and prefix-N ("bugfix-237") formats. + const prefixedMatch = artifactGlob.match(/(?:specs|plans|reviews)\/([a-z]+(?:-[a-z]+)*-\d+)/i); + const numericMatch = artifactGlob.match(/(?:specs|plans|reviews)\/0*(\d+)/); + const projectId = prefixedMatch?.[1] ?? numericMatch?.[1]; + if (!projectId) return false; - const projectId = idMatch[1]; let content: string | null = null; const artifactType = typeMatch?.[1] || 'specs'; @@ -262,27 +275,13 @@ export class CliResolver implements ArtifactResolver { private findPlanBaseName(projectId: string): string | null { const children = this.listChildren('plans'); if (!children) return null; - - const normalizedId = projectId.replace(/^0+/, '') || '0'; - const match = children.find(name => { - const numMatch = name.match(/^(\d+)/); - if (!numMatch) return false; - return (numMatch[1].replace(/^0+/, '') || '0') === normalizedId; - }); - return match || null; + return children.find(name => matchesProjectId(name, projectId)) || null; } private findReviewBaseName(projectId: string): string | null { const children = this.listChildren('reviews'); if (!children) return null; - - const normalizedId = projectId.replace(/^0+/, '') || '0'; - const match = children.find(name => { - const numMatch = name.match(/^(\d+)/); - if (!numMatch) return false; - return (numMatch[1].replace(/^0+/, '') || '0') === normalizedId; - }); - return match || null; + return children.find(name => matchesProjectId(name, projectId)) || null; } private listChildren(subPath: string): string[] | null { diff --git a/packages/codev/src/commands/porch/index.ts b/packages/codev/src/commands/porch/index.ts index e448776e7..fc71aff88 100644 --- a/packages/codev/src/commands/porch/index.ts +++ b/packages/codev/src/commands/porch/index.ts @@ -48,6 +48,7 @@ import { type CheckEnv, } from './checks.js'; import { loadCheckOverrides } from './config.js'; +import { notifyTerminal, gateApprovedMessage } from './notify.js'; import { loadConfig } from '../../lib/config.js'; import { version } from '../../version.js'; @@ -121,12 +122,40 @@ function logCheckOverrides( // ============================================================================ /** - * porch status + * porch status [--json] * Shows current state and prescriptive next steps. + * + * `--json`: emits a single-line JSON object with the project's current state + * and gate status, suppressing all human-readable output. Consumed by the + * VSCode Needs Attention view (Issue 691) and any other tooling that needs + * structured access to gate state. + * + * JSON shape: + * { + * "id": string, + * "title": string, + * "protocol": string, + * "phase": string, + * "iteration": number, + * "build_complete": boolean, + * "gate": string | null, + * "gate_status": "pending" | "approved" | null, + * "gate_requested_at": string | null, // ISO timestamp + * "gate_approved_at": string | null // ISO timestamp + * } */ -export async function status(workspaceRoot: string, projectId: string, resolver?: ArtifactResolver): Promise { +export async function status( + workspaceRoot: string, + projectId: string, + resolver?: ArtifactResolver, + options?: { json?: boolean }, +): Promise { const statusPath = findStatusPath(workspaceRoot, projectId); if (!statusPath) { + if (options?.json) { + console.error(`Project ${projectId} not found.`); + process.exit(1); + } throw new Error(`Project ${projectId} not found.\nRun 'porch init' to create a new project.`); } @@ -134,6 +163,25 @@ export async function status(workspaceRoot: string, projectId: string, resolver? const protocol = loadProtocol(workspaceRoot, state.protocol); const phaseConfig = getPhaseConfig(protocol, state.phase); + if (options?.json) { + const gateName = getPhaseGate(protocol, state.phase); + const gateStatus = gateName ? state.gates[gateName] : undefined; + const out = { + id: state.id, + title: state.title, + protocol: state.protocol, + phase: state.phase, + iteration: state.iteration, + build_complete: state.build_complete, + gate: gateName ?? null, + gate_status: gateStatus?.status ?? null, + gate_requested_at: gateStatus?.requested_at ?? null, + gate_approved_at: gateStatus?.approved_at ?? null, + }; + process.stdout.write(JSON.stringify(out) + '\n'); + return; + } + // Header console.log(''); console.log(header(`PROJECT: ${state.id} - ${state.title}`)); @@ -682,6 +730,24 @@ export async function approve( state.gates[gateName].approved_at = new Date().toISOString(); await writeStateAndCommit(statusPath, state, `chore(porch): ${state.id} ${gateName} gate-approved`); + // Wake the builder iff porch was invoked from OUTSIDE the builder's + // worktree. The wake-up wakes an *idle* builder; when the builder is + // the one running `porch approve` (the user typed feedback into the + // builder's pane and the builder ran the command itself), it's already + // active and the message would be echoed back as the builder's next + // input — Claude then "responds" to its own approval message. + // + // workspaceRoot is process.cwd() at CLI invocation. When called from + // inside the worktree it resolves to the same path as artifactRoot. + const calledFromBuilderWorktree = path.resolve(workspaceRoot) === path.resolve(artifactRoot); + if (!calledFromBuilderWorktree) { + notifyTerminal({ + target: state.id, + message: gateApprovedMessage(gateName), + worktreeDir: workspaceRoot, + }); + } + console.log(''); console.log(chalk.green(`Gate ${gateName} approved.`)); @@ -1006,6 +1072,12 @@ export async function cli(args: string[]): Promise { return id; } + // Commands that mutate state. After dispatching, we fire a Tower + // overview-refresh so subscribed clients (VSCode sidebar, dashboard) + // pick up the change immediately instead of waiting for an unrelated + // SSE event to incidentally cause a re-fetch. + const MUTATING_COMMANDS = new Set(['next', 'done', 'gate', 'approve', 'rollback', 'verify', 'init']); + try { switch (command) { case 'pending': @@ -1025,9 +1097,12 @@ export async function cli(args: string[]): Promise { process.exit(1); break; - case 'status': - await status(workspaceRoot, getProjectId(rest[0]), resolver); + case 'status': { + const json = rest.includes('--json'); + const positional = rest.find(a => !a.startsWith('--')); + await status(workspaceRoot, getProjectId(positional), resolver, { json }); break; + } case 'check': await check(workspaceRoot, getProjectId(rest[0]), resolver); @@ -1130,6 +1205,18 @@ export async function cli(args: string[]): Promise { console.log(''); process.exit(command && command !== '--help' && command !== '-h' ? 1 : 0); } + + // After a successful mutating command, broadcast `overview-changed` + // via Tower so VSCode / dashboard refresh without a manual reload. + // Best-effort — silently no-ops if Tower isn't running. + if (command && MUTATING_COMMANDS.has(command)) { + try { + const { TowerClient } = await import('../../agent-farm/lib/tower-client.js'); + await new TowerClient().refreshOverview(); + } catch { + // Tower not running / unreachable — non-fatal. + } + } } catch (err) { console.error(chalk.red(`Error: ${(err as Error).message}`)); process.exit(1); diff --git a/packages/codev/src/commands/porch/notify.ts b/packages/codev/src/commands/porch/notify.ts index 500df9b97..80cbd5f86 100644 --- a/packages/codev/src/commands/porch/notify.ts +++ b/packages/codev/src/commands/porch/notify.ts @@ -1,6 +1,18 @@ /** - * Porch gate notification — sends `afx send architect` when a gate transitions to pending. - * Spec 0108: Push-based gate notifications, replacing the poll-based gate watcher. + * Porch terminal notifications — sends `afx send ` to deliver + * messages into a target terminal as PTY input. + * + * Currently used only to wake the builder after a gate is approved. The + * builder's interactive Claude session sits idle at the gate until it + * receives an input event; without this wake-up it would not call + * `porch next` and advance. + * + * Architect-bound gate notifications were removed deliberately: PIR/SPIR + * gates are explicit human-decision points (review the plan / review the + * worktree), surfaced via the VSCode sidebar tree and toast. The + * architect cannot act on a gate autonomously (approval requires a human + * `--a-human-explicitly-approved-this` flag), so pushing gate state into + * its conversation history only adds noise. */ import { execFile } from 'node:child_process'; @@ -12,27 +24,35 @@ function resolveAfxBinary(): string { return resolve(thisDir, '../../../bin/afx.js'); } +export interface NotifyTerminalOptions { + /** Target terminal — currently always a builder ID (e.g., 'pir-1298'). */ + target: string; + /** Message text to deliver. */ + message: string; + /** Working directory — used by afx to resolve the workspace. */ + worktreeDir: string; +} + +/** Builder-bound wake-up after a gate is approved. */ +export function gateApprovedMessage(gateName: string): string { + return `Gate ${gateName} approved — please run \`porch next\` to advance.`; +} + /** - * Fire-and-forget notification to the architect terminal when a gate becomes pending. - * Uses `afx send architect` via execFile (no shell, no injection risk). + * Fire-and-forget notification to a terminal. + * Uses `afx send ` via execFile (no shell, no injection risk). * Errors are logged but never thrown — notification is best-effort. */ -export function notifyArchitect(projectId: string, gateName: string, worktreeDir: string): void { - const message = [ - `GATE: ${gateName} (Builder ${projectId})`, - `Builder ${projectId} is waiting for approval.`, - `Run: porch approve ${projectId} ${gateName}`, - ].join('\n'); - +export function notifyTerminal(opts: NotifyTerminalOptions): void { const afBinary = resolveAfxBinary(); execFile( process.execPath, - [afBinary, 'send', 'architect', message, '--raw', '--no-enter'], - { cwd: worktreeDir, timeout: 10_000 }, + [afBinary, 'send', opts.target, opts.message, '--raw'], + { cwd: opts.worktreeDir, timeout: 10_000 }, (error) => { if (error) { - console.error(`[porch] Gate notification failed: ${error.message}`); + console.error(`[porch] notifyTerminal(${opts.target}) failed: ${error.message}`); } } ); diff --git a/packages/codev/src/commands/porch/state.ts b/packages/codev/src/commands/porch/state.ts index 13661bb6f..9b97c9109 100644 --- a/packages/codev/src/commands/porch/state.ts +++ b/packages/codev/src/commands/porch/state.ts @@ -305,20 +305,22 @@ export function findStatusPath(workspaceRoot: string, projectId: string): string /** * Detect project ID from the current working directory if inside a builder worktree. * Works from any subdirectory within the worktree. - * Returns the porch project ID (e.g. "bugfix-237" or "0073"), or null if not in a recognized worktree. + * Returns the porch project ID (e.g. "bugfix-237", "1298", or "0073"), or null if not in a recognized worktree. */ export function detectProjectIdFromCwd(cwd: string): string | null { const normalized = path.resolve(cwd).split(path.sep).join('/'); - // Bugfix worktrees: .builders/bugfix-{N}-{slug} (slug is optional for legacy paths) - // Protocol worktrees: .builders/{protocol}-{N}-{slug} (aspir, spir, air) + // bugfix worktrees: .builders/bugfix-{N}-{slug} (slug optional) + // porch project ID is "bugfix-{N}" — historical convention, kept untouched. + // PIR / SPIR / ASPIR / AIR worktrees: .builders/{prefix}-{N}-{slug} (slug optional) + // porch project ID is the bare numeric ID. // Spec worktrees (legacy): .builders/{NNNN} (bare 4-digit ID, no slug) const match = normalized.match( - /\/\.builders\/(bugfix-(\d+)(?:-[^/]*)?|(?:aspir|spir|air)-(\d+)(?:-[^/]*)?|(\d{4}))(\/|$)/, + /\/\.builders\/(bugfix-(\d+)(?:-[^/]*)?|(?:aspir|spir|air|pir)-(\d+)(?:-[^/]*)?|(\d{4}))(\/|$)/, ); if (!match) return null; - // Bugfix worktrees use "bugfix-N" as the porch project ID + // bugfix uses "bugfix-N" as the porch project ID if (match[2]) return `bugfix-${match[2]}`; - // Protocol worktrees (aspir, spir, air) use the bare numeric ID + // Protocol worktrees (aspir, spir, air, pir) use the bare numeric ID if (match[3]) return match[3]; // Spec worktrees use zero-padded numeric IDs return match[4]; diff --git a/packages/codev/src/lib/github.ts b/packages/codev/src/lib/github.ts index 5b913d5ae..2b539e30a 100644 --- a/packages/codev/src/lib/github.ts +++ b/packages/codev/src/lib/github.ts @@ -115,6 +115,25 @@ export async function fetchIssueList( return result as ForgeIssueListItem[] | null; } +/** + * Resolve the current user's forge login. + * Routes through the `user-identity` concept command (default: + * `gh api user --jq .login`). The concept emits a bare string, not JSON, + * so `raw: true` is required. Returns null on failure (e.g. `gh` + * unauthenticated) so callers can degrade gracefully. + */ +export async function fetchCurrentUser( + cwd?: string, + forgeConfig?: ForgeConfig | null, +): Promise { + const result = await executeForgeCommand('user-identity', {}, { + cwd, + forgeConfig, + raw: true, + }); + return typeof result === 'string' && result.trim() ? result.trim() : null; +} + /** * Fetch recently closed issues (last 24 hours). * Routes through the `recently-closed` concept command. @@ -124,7 +143,15 @@ export async function fetchRecentlyClosed( cwd?: string, forgeConfig?: ForgeConfig | null, ): Promise { - const since = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString().split('T')[0]; + // Full ISO-8601 timestamp, NOT a bare date. The concept query is + // `closed:>$CODEV_SINCE_DATE`; GitHub search supports second-precision + // datetime qualifiers and `>` against a precise timestamp is exact + // (verified). A bare `YYYY-MM-DD` was the bug: GitHub's `>` excludes the + // entire sinceDate day, collapsing the 24h window to "since UTC midnight" + // (≈0h just after 00:00Z) and silently hiding genuinely-recent closures. + // Seconds precision (no millis) matches GitHub's documented format. + const since = new Date(Date.now() - 24 * 60 * 60 * 1000) + .toISOString().replace(/\.\d{3}Z$/, 'Z'); const result = await executeForgeCommand('recently-closed', { CODEV_SINCE_DATE: since, }, { @@ -149,7 +176,11 @@ export async function fetchRecentMergedPRs( cwd?: string, forgeConfig?: ForgeConfig | null, ): Promise { - const since = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString().split('T')[0]; + // Full ISO-8601 timestamp (seconds precision), not a bare date — same + // GitHub bare-date `>` day-exclusion bug as fetchRecentlyClosed. + // `merged:>$CODEV_SINCE_DATE` against a precise timestamp is exact. + const since = new Date(Date.now() - 24 * 60 * 60 * 1000) + .toISOString().replace(/\.\d{3}Z$/, 'Z'); const result = await executeForgeCommand('recently-merged', { CODEV_SINCE_DATE: since, }, { diff --git a/packages/codev/src/lib/team-github.ts b/packages/codev/src/lib/team-github.ts index 63c307e55..027d57da8 100644 --- a/packages/codev/src/lib/team-github.ts +++ b/packages/codev/src/lib/team-github.ts @@ -33,11 +33,17 @@ export interface ReviewBlockingEntry { // Keep in sync with the canonical definition in @cluesmith/codev-types. export interface TeamMemberGitHubData { + // node arrays are capped at GitHub search `first` (20) and feed lists / + // review-blocking; the *Count fields are the true totals (search.issueCount). assignedIssues: { number: number; title: string; url: string }[]; + assignedIssuesCount: number; openPRs: { number: number; title: string; url: string }[]; + openPRsCount: number; recentActivity: { mergedPRs: { number: number; title: string; url: string; mergedAt: string }[]; + mergedPRsCount: number; closedIssues: { number: number; title: string; url: string; closedAt: string }[]; + closedIssuesCount: number; }; reviewBlocking: ReviewBlockingEntry[]; } @@ -100,9 +106,11 @@ export function buildTeamGraphQLQuery(members: TeamMember[], owner: string, name const alias = toAlias(m.github); return ` ${alias}_assigned: search(query: "repo:${repo} assignee:${m.github} is:issue is:open", type: ISSUE, first: 20) { + issueCount nodes { ... on Issue { number title url } } } ${alias}_prs: search(query: "repo:${repo} author:${m.github} is:pr is:open", type: ISSUE, first: 20) { + issueCount nodes { ... on PullRequest { number @@ -118,9 +126,11 @@ export function buildTeamGraphQLQuery(members: TeamMember[], owner: string, name } } ${alias}_merged: search(query: "repo:${repo} author:${m.github} is:pr is:merged merged:>=${since}", type: ISSUE, first: 20) { + issueCount nodes { ... on PullRequest { number title url mergedAt } } } ${alias}_closed: search(query: "repo:${repo} assignee:${m.github} is:issue is:closed closed:>=${since}", type: ISSUE, first: 20) { + issueCount nodes { ... on Issue { number title url closedAt } } }`; }) @@ -257,20 +267,27 @@ export function parseTeamGraphQLResponse( if (!isValidGitHubHandle(member.github)) continue; const alias = toAlias(member.github); - const assigned = data[`${alias}_assigned`] as { nodes?: Array<{ number: number; title: string; url: string }> } | undefined; - const prs = data[`${alias}_prs`] as { nodes?: OpenPrNode[] } | undefined; - const merged = data[`${alias}_merged`] as { nodes?: Array<{ number: number; title: string; url: string; mergedAt: string }> } | undefined; - const closed = data[`${alias}_closed`] as { nodes?: Array<{ number: number; title: string; url: string; closedAt: string }> } | undefined; + const assigned = data[`${alias}_assigned`] as { issueCount?: number; nodes?: Array<{ number: number; title: string; url: string }> } | undefined; + const prs = data[`${alias}_prs`] as { issueCount?: number; nodes?: OpenPrNode[] } | undefined; + const merged = data[`${alias}_merged`] as { issueCount?: number; nodes?: Array<{ number: number; title: string; url: string; mergedAt: string }> } | undefined; + const closed = data[`${alias}_closed`] as { issueCount?: number; nodes?: Array<{ number: number; title: string; url: string; closedAt: string }> } | undefined; + const assignedNodes = assigned?.nodes ?? []; const openPrNodes = prs?.nodes ?? []; + const mergedNodes = merged?.nodes ?? []; + const closedNodes = closed?.nodes ?? []; prsByAuthor.set(member.github, openPrNodes); result.set(member.github, { - assignedIssues: (assigned?.nodes ?? []).map(n => ({ number: n.number, title: n.title, url: n.url })), + assignedIssues: assignedNodes.map(n => ({ number: n.number, title: n.title, url: n.url })), + assignedIssuesCount: assigned?.issueCount ?? assignedNodes.length, openPRs: openPrNodes.map(n => ({ number: n.number, title: n.title, url: n.url })), + openPRsCount: prs?.issueCount ?? openPrNodes.length, recentActivity: { - mergedPRs: (merged?.nodes ?? []).map(n => ({ number: n.number, title: n.title, url: n.url, mergedAt: n.mergedAt })), - closedIssues: (closed?.nodes ?? []).map(n => ({ number: n.number, title: n.title, url: n.url, closedAt: n.closedAt })), + mergedPRs: mergedNodes.map(n => ({ number: n.number, title: n.title, url: n.url, mergedAt: n.mergedAt })), + mergedPRsCount: merged?.issueCount ?? mergedNodes.length, + closedIssues: closedNodes.map(n => ({ number: n.number, title: n.title, url: n.url, closedAt: n.closedAt })), + closedIssuesCount: closed?.issueCount ?? closedNodes.length, }, reviewBlocking: [], }); diff --git a/packages/core/src/tower-client.ts b/packages/core/src/tower-client.ts index 6c0807bcb..40efda734 100644 --- a/packages/core/src/tower-client.ts +++ b/packages/core/src/tower-client.ts @@ -7,7 +7,7 @@ * Extracted from packages/codev/src/agent-farm/lib/tower-client.ts */ -import type { DashboardState, OverviewData } from '@cluesmith/codev-types'; +import type { DashboardState, OverviewData, IssueView } from '@cluesmith/codev-types'; import { DEFAULT_TOWER_PORT } from './constants.js'; import { ensureLocalKey } from './auth.js'; @@ -261,6 +261,32 @@ export class TowerClient { return result.ok ? result.data! : null; } + /** + * Fetch a single issue's title/body/state/comments via Tower's + * forge-backed GET /api/issue. Returns null if the issue can't be + * resolved (forge unavailable, bad number) so callers can degrade. + */ + async getIssue(issueNumber: string, workspacePath?: string): Promise { + const params = new URLSearchParams({ number: issueNumber }); + if (workspacePath) { params.set('workspace', workspacePath); } + const result = await this.request(`/api/issue?${params.toString()}`); + return result.ok ? result.data! : null; + } + + /** + * Invalidate Tower's in-memory overview cache and broadcast an + * `overview-changed` SSE event. Subscribed clients (VSCode sidebar, + * dashboard) re-fetch /api/overview on any SSE event, so this is + * what makes them notice out-of-band mutations to builder state — + * e.g., `afx cleanup` invoked from a shell or the architect. Without + * it, the change is invisible to clients until some other SSE event + * happens to fire. Best-effort: returns false if Tower isn't running. + */ + async refreshOverview(): Promise { + const result = await this.request<{ ok: boolean }>('/api/overview/refresh', { method: 'POST' }); + return result.ok; + } + async getWorkspaceState(workspacePath: string): Promise { const encoded = encodeWorkspacePath(workspacePath); const result = await this.request(`/workspace/${encoded}/api/state`); diff --git a/packages/dashboard/src/components/NeedsAttentionList.tsx b/packages/dashboard/src/components/NeedsAttentionList.tsx index 8f669a630..558db9db5 100644 --- a/packages/dashboard/src/components/NeedsAttentionList.tsx +++ b/packages/dashboard/src/components/NeedsAttentionList.tsx @@ -15,6 +15,20 @@ interface AttentionItem { url?: string; } +/** + * Map an OverviewBuilder.blocked label to a CSS class. The labels come from + * `detectBlocked` in packages/codev/src/agent-farm/servers/overview.ts. + * Unknown kinds fall back to the plan styling so the row still renders. + */ +function gateKindClass(blocked: string): string { + switch (blocked) { + case 'spec review': return 'attention-kind--spec'; + case 'plan review': return 'attention-kind--plan'; + case 'code review': return 'attention-kind--code-review'; + default: return 'attention-kind--plan'; + } +} + function timeAgo(dateStr: string): string { const ms = Date.now() - new Date(dateStr).getTime(); const mins = Math.floor(ms / 60000); @@ -52,9 +66,7 @@ function buildItems(prs: OverviewPR[], builders: OverviewBuilder[]): AttentionIt issueOrPR: label, title: b.issueTitle || b.id, kind: b.blocked, - kindClass: b.blocked === 'spec review' - ? 'attention-kind--spec' - : 'attention-kind--plan', + kindClass: gateKindClass(b.blocked), waitingSince: b.blockedSince, }); } diff --git a/packages/dashboard/src/index.css b/packages/dashboard/src/index.css index 9bba21f76..4648fe13f 100644 --- a/packages/dashboard/src/index.css +++ b/packages/dashboard/src/index.css @@ -1282,6 +1282,10 @@ a.attention-row { color: var(--status-error); } +.attention-kind--code-review { + color: var(--status-waiting); +} + .attention-row-age { font-size: 13px; color: var(--text-muted); diff --git a/packages/types/src/api.ts b/packages/types/src/api.ts index 678d05208..6230d8ce8 100644 --- a/packages/types/src/api.ts +++ b/packages/types/src/api.ts @@ -82,7 +82,14 @@ export interface OverviewBuilder { protocol: string; planPhases: Array<{ id: string; title: string; status: string }>; progress: number; + /** Human-readable label for the gate the builder is blocked on (e.g. "plan review"). */ blocked: string | null; + /** + * Canonical gate name (e.g. "plan-approval") for the gate the builder is + * blocked on. Use this when calling `porch approve` — `blocked` is a + * display label and won't match porch's gate keys. + */ + blockedGate: string | null; blockedSince: string | null; startedAt: string | null; idleMs: number; @@ -133,9 +140,29 @@ export interface OverviewData { pendingPRs: OverviewPR[]; backlog: OverviewBacklogItem[]; recentlyClosed: OverviewRecentlyClosed[]; + /** Auto-detected GitHub login of the current user (via the user-identity forge concept). */ + currentUser?: string; errors?: { prs?: string; issues?: string }; } +// --- Issue view (GET /api/issue) --- + +/** + * A single issue as returned by the `issue-view` forge concept and + * surfaced verbatim by Tower's GET /api/issue. Mirrors the server-side + * IssueViewResult (packages/codev/src/lib/forge-contracts.ts). + */ +export interface IssueView { + title: string; + body: string; + state: string; + comments: Array<{ + body: string; + createdAt: string; + author: { login: string }; + }>; +} + // --- Team (GET /workspace/:path/api/team) --- export interface ReviewBlockingEntry { @@ -151,11 +178,18 @@ export interface ReviewBlockingEntry { } export interface TeamMemberGitHubData { + // node arrays are capped at GitHub search `first` (20) and feed lists / + // review-blocking; the *Count fields are the true totals (search.issueCount) + // and must be used for any "N assigned / N open" display. assignedIssues: { number: number; title: string; url: string }[]; + assignedIssuesCount: number; openPRs: { number: number; title: string; url: string }[]; + openPRsCount: number; recentActivity: { mergedPRs: { number: number; title: string; url: string; mergedAt: string }[]; + mergedPRsCount: number; closedIssues: { number: number; title: string; url: string; closedAt: string }[]; + closedIssuesCount: number; }; reviewBlocking: ReviewBlockingEntry[]; } diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 0cc5e500a..ef2b6a5ba 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -23,6 +23,7 @@ export { type OverviewBacklogItem, type OverviewRecentlyClosed, type OverviewData, + type IssueView, type TeamMemberGitHubData, type ReviewBlockingEntry, type TeamApiMember, diff --git a/packages/vscode/CHANGELOG.md b/packages/vscode/CHANGELOG.md index dc2fb22cb..ca6dd9a6c 100644 --- a/packages/vscode/CHANGELOG.md +++ b/packages/vscode/CHANGELOG.md @@ -2,6 +2,37 @@ What's changed in the Codev VS Code extension, version by version, written for the developers who use it. +## [Unreleased] + +### What's new + +- **Codev-managed dev server for the current workspace.** New **Start Dev Server** / **Stop Dev Server** rows in the sidebar's Workspace view run `worktree.devCommand` for whatever folder the window is rooted at — the main checkout (CLI: `afx dev main`) or, if you opened a `.builders//` worktree as its own window, that builder. One dev runs at a time across {main + all builders}; starting another prompts to swap. The two rows are mutually exclusive: **Start** when stopped, **Stop** when running. +- **Dev server terminals open in the bottom panel** (not an editor group). A dev server is a long-running log, not a tab you switch between; `type: 'dev'` terminals now always use the panel regardless of `codev.terminalPosition`. Architect (left group) and builder/shell (right) are unchanged. +- **Builder terminal tabs are labeled by issue** — a builder's tab now reads `Codev: # ` (matching its sidebar row) instead of the internal `builder--` agent name. The title is capped at 25 characters on a whole-word boundary (with `…`); the `#` prefix is kept whole. Falls back to the agent name when overview data or a builder match is unavailable. Architect / Shell / dev tab names are unchanged. +- **Team view is a per-member snapshot.** Expanding a teammate shows `Assigned: N` and `Open PRs: N` count summaries plus `Last 7d: X merged, Y closed` — the full issue/PR lists already live in the Backlog and Pull Requests views. A Refresh button in the Team title bar forces a re-fetch (Team otherwise only updates as a side effect of other Tower activity). +- **A symmetric keyboard-shortcut family** — `Cmd+Alt+C` / `Ctrl+Alt+C` toggles the Codev sidebar (opens & focuses it if hidden, closes it if it's the active view); `Cmd/Ctrl+Alt+R` and `Cmd/Ctrl+Alt+S` start / stop the current workspace's dev server. The existing `Cmd/Ctrl+K` A / D / G (open architect, send message, approve gate) are unchanged. +- **Workspace sidebar gained Spawn Builder and New Shell** rows, next to Open Architect and Open Web Interface. +- **Gate-pending toasts with one-click Approve.** When a builder reaches a human-approval gate, a toast surfaces it with a per-gate action (View Plan / Run Dev / Review) and an **Approve** button. The approval dialog now shows the issue + gate context. Silence with the new `codev.gateToasts.enabled` setting. +- **Inline review comments on plan/spec files** via VSCode's Comments API — leave `REVIEW(@architect):` threads in the editor, not only the text snippet. +- **PIR protocol support** — PIR in the Spawn Builder picker, a View Plan action for PIR builders, and context-menu actions scoped per protocol. +- **Open Worktree in New Window** — opens `.builders//` as its own VSCode window. Replaces the former "Codev: View Diff" (3.0.3), which couldn't reliably render multi-file worktree diffs; a real worktree window gives native SCM + diffs. +- **Builder right-click menu reordered** — primary actions first (open terminal, approve), then worktree actions, then dev actions, grouped so the common ones are at the top. +- **"Needs Attention" is merged into Builders.** Blocked builders are flagged inline in the Builders view (bell icon) instead of a separate section, and gate toasts point at the builder's pane. The standalone Needs Attention view is gone. +- **Live item counts** in the Builders / Pull Requests / Backlog / Recently Closed view titles; Recently Closed gained a refresh button. +- **Backlog is actionable** — "Codev: View Issue" opens an issue in the right pane, assigned-to-me issues are surfaced, and you can click-to-spawn a builder from a backlog issue. +- **Periodic sidebar refresh while visible**, every `codev.overviewRefreshSeconds` (default 60; `0` = event-only, the previous behavior). A shared 30s Tower-side cache throttles GitHub calls across windows. + +### Bug fixes + +- The sidebar survives SSE event bursts without losing state (last-write-wins in the overview cache). +- Builder terminal tabs close automatically on cleanup instead of lingering as dead "Process exited" tabs. +- Tower PTY dimensions sync on terminal open, fixing wrapped/truncated output. +- Gate-pending toast: the issue title is quoted (no longer reads like an error), and the seen-set persists across reloads so a still-blocked builder doesn't re-toast on every window reload. +- State-change actions (Approve Gate, Cleanup Builder, …) now wait for Tower and refresh the sidebar immediately, instead of leaving it briefly stale. +- Approving a gate from the sidebar targets the correct (canonical) gate name and runs `porch approve` in the right working directory — previously it could mis-resolve for renamed/shared gates or non-root worktrees. +- Clicking a backlog issue reuses a single editor group for the preview instead of opening a new split group on every click after the first. +- Team per-member counts (Assigned / Open PRs / the 7-day merged & closed) now show the true totals instead of silently capping at 20 — they read GitHub's search `issueCount` rather than the length of the (20-node-limited) result list. + ## [3.0.4] - 2026-05-13 ### Bug fixes diff --git a/packages/vscode/README.md b/packages/vscode/README.md index 9a9a9c10a..680b66389 100644 --- a/packages/vscode/README.md +++ b/packages/vscode/README.md @@ -4,11 +4,13 @@ Bring Codev's Agent Farm into VS Code — monitor builders, open terminals, appr ## Features -- **Unified Sidebar** — Needs Attention, Builders, Pull Requests, Backlog, Team, and Status in a single pane -- **Native Terminals** — Architect and builder terminals in the editor area with full vertical height +- **Unified Sidebar** — Workspace, Builders, Pull Requests, Backlog, Recently Closed, Team, and Status in a single pane (blocked builders are flagged inline in Builders, with live item counts in the view titles) +- **Native Terminals** — Architect / builder / shell terminals in the editor area with full vertical height; dev servers run in the bottom panel +- **Managed dev servers** — start/stop the dev server for the current workspace or any builder worktree from the sidebar; one runs at a time and swaps on demand +- **Gate review** — a toast with one-click **Approve** when a builder reaches a human-approval gate, plus inline `REVIEW(@architect):` comment threads on plan/spec files - **Live Spawn Notifications** — Get notified (or auto-open a terminal) the moment a new builder starts - **Status Bar** — Connection state, builder count, blocked gates at a glance -- **Command Palette** — Open terminals, send messages, approve gates via keyboard +- **Command Palette & shortcuts** — Open terminals, send messages, approve gates, toggle the sidebar - **Auto-Connect** — Detects Codev workspaces and connects to Tower automatically - **Auto-Start Tower** — Starts Tower if not running (configurable) @@ -32,10 +34,11 @@ Bring Codev's Agent Farm into VS Code — monitor builders, open terminals, appr | Codev | Architect | [#42] [#43] | | (sidebar) | (terminal) | Builder #42 | | | | (terminal) | -| - Attention| | | +| - Workspace| | | | - Builders | Left editor | Right editor | | - PRs | group | group | | - Backlog | | | +| - Recent | | | | - Team | | | | - Status | | | +------------+----------------+----------------+ @@ -48,11 +51,17 @@ Bring Codev's Agent Farm into VS Code — monitor builders, open terminals, appr | Codev: Open Architect Terminal | `Cmd+K, A` | Open the architect terminal in the left editor group | | Codev: Send Message | `Cmd+K, D` | Pick a builder, type a message, send via Tower | | Codev: Approve Gate | `Cmd+K, G` | Approve a blocked builder's gate | +| Toggle Codev Sidebar | `Cmd+Alt+C` / `Ctrl+Alt+C` | Show & focus the Codev sidebar, or close it if it's the active view | | Codev: Open Builder Terminal | | Pick a builder and open its terminal | | Codev: New Shell | | Create a new persistent shell terminal | | Codev: Spawn Builder | | Issue number + protocol + optional branch | | Codev: Cleanup Builder | | Remove a completed builder's worktree | +| Codev: Start / Stop Dev Server (this workspace) | `Cmd/Ctrl+Alt+R` / `Cmd/Ctrl+Alt+S` | Run/stop the dev server for the current workspace (sidebar Workspace rows) | +| Codev: Run / Stop Dev Server | | Run/stop a builder worktree's dev server (builder right-click) | +| Codev: Open Worktree in New Window | | Open `.builders//` as its own VSCode window | +| Codev: Run Worktree Setup | | Re-apply `worktree.symlinks` + `postSpawn` to an existing worktree | | Codev: Refresh Overview | | Manually refresh sidebar data | +| Codev: Refresh Team | | Re-fetch the Team view's GitHub data | | Codev: Connect Tunnel | | Connect cloud tunnel for remote access | | Codev: Disconnect Tunnel | | Disconnect cloud tunnel | | Codev: Cron Tasks | | List, run, enable, or disable cron tasks | @@ -83,3 +92,6 @@ Whenever a new builder starts (e.g. you ran `afx spawn 42`), the extension can o | `codev.autoConnect` | `true` | Connect to Tower on activation | | `codev.autoStartTower` | `true` | Auto-start Tower if not running | | `codev.autoOpenBuilderTerminal` | `notify` | Behavior on builder-spawned events (`off` / `notify` / `auto`) | +| `codev.overviewRefreshSeconds` | `60` | Auto-refresh Builders/PRs/Backlog/Recently Closed every N seconds while the sidebar is visible (`0` = event-only) | +| `codev.gateToasts.enabled` | `true` | Show a toast when a builder reaches a human-approval gate | +| `codev.telemetry` | `false` | No telemetry collected | diff --git a/packages/vscode/package.json b/packages/vscode/package.json index 77565e7ba..aa806d42c 100644 --- a/packages/vscode/package.json +++ b/packages/vscode/package.json @@ -72,7 +72,8 @@ }, { "command": "codev.approveGate", - "title": "Codev: Approve Gate" + "title": "Codev: Approve Gate", + "icon": "$(check)" }, { "command": "codev.cleanupBuilder", @@ -83,6 +84,11 @@ "title": "Codev: Refresh Overview", "icon": "$(refresh)" }, + { + "command": "codev.refreshTeam", + "title": "Codev: Refresh Team", + "icon": "$(refresh)" + }, { "command": "codev.reconnect", "title": "Codev: Reconnect to Tower", @@ -105,8 +111,8 @@ "title": "Codev: Add Review Comment" }, { - "command": "codev.reviewDiff", - "title": "Codev: View Diff" + "command": "codev.openWorktreeWindow", + "title": "Codev: Open Worktree in New Window" }, { "command": "codev.runWorktreeDev", @@ -116,6 +122,14 @@ "command": "codev.stopWorktreeDev", "title": "Codev: Stop Dev Server" }, + { + "command": "codev.runWorkspaceDev", + "title": "Codev: Start Dev Server (this workspace)" + }, + { + "command": "codev.stopWorkspaceDev", + "title": "Codev: Stop Dev Server (this workspace)" + }, { "command": "codev.openBuilderById", "title": "Codev: Open Builder Terminal" @@ -127,6 +141,32 @@ { "command": "codev.runWorktreeSetup", "title": "Codev: Run Worktree Setup" + }, + { + "command": "codev.viewPlanFile", + "title": "Codev: View Plan File" + }, + { + "command": "codev.viewBacklogIssue", + "title": "Codev: View Issue" + }, + { + "command": "codev.openBacklogIssue", + "title": "Codev: Open Issue in Browser" + }, + { + "command": "codev.copyBacklogIssueNumber", + "title": "Codev: Copy Issue Number" + }, + { + "command": "codev.submitReviewComment", + "title": "Submit review comment", + "enablement": "!commentIsEmpty" + }, + { + "command": "codev.deleteReviewComment", + "title": "Delete review comment", + "icon": "$(trash)" } ], "menus": { @@ -135,62 +175,132 @@ "command": "codev.openBuilderById", "when": "false" }, + { + "command": "codev.viewBacklogIssue", + "when": "false" + }, + { + "command": "codev.openBacklogIssue", + "when": "false" + }, + { + "command": "codev.copyBacklogIssueNumber", + "when": "false" + }, { "command": "codev.addReviewComment", "when": "editorLangId == 'markdown'" + }, + { + "command": "codev.submitReviewComment", + "when": "false" + }, + { + "command": "codev.deleteReviewComment", + "when": "false" } ], "view/item/context": [ + { + "command": "codev.approveGate", + "when": "view == codev.builders && viewItem =~ /^blocked-builder-/", + "group": "inline@1" + }, { "command": "codev.openBuilderById", - "when": "view =~ /^codev\\.(builders|needsAttention)$/ && viewItem =~ /^(builder|blocked-builder)$/", - "group": "1_terminal@1" + "when": "view == codev.builders && viewItem =~ /^(builder|blocked-builder)-/", + "group": "1_primary@1" }, { - "command": "codev.openWorktreeFolder", - "when": "view =~ /^codev\\.(builders|needsAttention)$/ && viewItem =~ /^(builder|blocked-builder)$/", - "group": "2_files@1" + "command": "codev.approveGate", + "when": "view == codev.builders && viewItem =~ /^blocked-builder-/", + "group": "1_primary@2" + }, + { + "command": "codev.viewPlanFile", + "when": "view == codev.builders && viewItem =~ /^(builder|blocked-builder)-pir$/", + "group": "1_primary@3" + }, + { + "command": "codev.openWorktreeWindow", + "when": "view == codev.builders && viewItem =~ /^(builder|blocked-builder)-/", + "group": "2_worktree@1" }, { - "command": "codev.reviewDiff", - "when": "view =~ /^codev\\.(builders|needsAttention)$/ && viewItem =~ /^(builder|blocked-builder)$/", - "group": "3_review@1" + "command": "codev.openWorktreeFolder", + "when": "view == codev.builders && viewItem =~ /^(builder|blocked-builder)-/", + "group": "2_worktree@2" }, { "command": "codev.runWorktreeSetup", - "when": "view =~ /^codev\\.(builders|needsAttention)$/ && viewItem =~ /^(builder|blocked-builder)$/", - "group": "4_worktree@1" + "when": "view == codev.builders && viewItem =~ /^(builder|blocked-builder)-/", + "group": "3_dev@1" }, { "command": "codev.runWorktreeDev", - "when": "view =~ /^codev\\.(builders|needsAttention)$/ && viewItem =~ /^(builder|blocked-builder)$/", - "group": "4_worktree@2" + "when": "view == codev.builders && viewItem =~ /^(builder|blocked-builder)-/", + "group": "3_dev@2" }, { "command": "codev.stopWorktreeDev", - "when": "view =~ /^codev\\.(builders|needsAttention)$/ && viewItem =~ /^(builder|blocked-builder)$/", - "group": "4_worktree@3" + "when": "view == codev.builders && viewItem =~ /^(builder|blocked-builder)-/", + "group": "3_dev@3" + }, + { + "command": "codev.runWorkspaceDev", + "when": "view == codev.workspace && viewItem == workspace-dev-start", + "group": "1_primary@1" + }, + { + "command": "codev.stopWorkspaceDev", + "when": "view == codev.workspace && viewItem == workspace-dev-stop", + "group": "1_primary@1" + }, + { + "command": "codev.viewBacklogIssue", + "when": "view == codev.backlog && viewItem == backlog-item", + "group": "1_primary@1" + }, + { + "command": "codev.spawnBuilder", + "when": "view == codev.backlog && viewItem == backlog-item", + "group": "1_primary@2" + }, + { + "command": "codev.openBacklogIssue", + "when": "view == codev.backlog && viewItem == backlog-item", + "group": "1_primary@3" + }, + { + "command": "codev.copyBacklogIssueNumber", + "when": "view == codev.backlog && viewItem == backlog-item", + "group": "1_primary@4" } ], "view/title": [ { "command": "codev.refreshOverview", - "when": "view == codev.needsAttention", + "when": "view == codev.builders", "group": "navigation" }, { "command": "codev.refreshOverview", - "when": "view == codev.builders", + "when": "view == codev.pullRequests", "group": "navigation" }, { "command": "codev.refreshOverview", - "when": "view == codev.pullRequests", + "when": "view == codev.backlog", "group": "navigation" }, { "command": "codev.refreshOverview", - "when": "view == codev.backlog", + "when": "view == codev.recentlyClosed", + "group": "navigation" + }, + { + "command": "codev.refreshTeam", + "when": "view == codev.team", "group": "navigation" }, { @@ -198,6 +308,20 @@ "when": "view == codev.status", "group": "navigation" } + ], + "comments/commentThread/context": [ + { + "command": "codev.submitReviewComment", + "group": "inline", + "when": "commentController == codev-review && commentThreadIsEmpty" + } + ], + "comments/commentThread/title": [ + { + "command": "codev.deleteReviewComment", + "group": "inline@1", + "when": "commentController == codev-review && commentThread == inline-review" + } ] }, "snippets": [ @@ -221,6 +345,28 @@ "command": "codev.approveGate", "key": "ctrl+k g", "mac": "cmd+k g" + }, + { + "command": "workbench.view.extension.codev", + "key": "ctrl+alt+c", + "mac": "cmd+alt+c", + "when": "!sideBarVisible || activeViewlet != 'workbench.view.extension.codev'" + }, + { + "command": "workbench.action.closeSidebar", + "key": "ctrl+alt+c", + "mac": "cmd+alt+c", + "when": "sideBarVisible && activeViewlet == 'workbench.view.extension.codev'" + }, + { + "command": "codev.runWorkspaceDev", + "key": "ctrl+alt+r", + "mac": "cmd+alt+r" + }, + { + "command": "codev.stopWorkspaceDev", + "key": "ctrl+alt+s", + "mac": "cmd+alt+s" } ], "viewsContainers": { @@ -235,7 +381,6 @@ "views": { "codev": [ { "id": "codev.workspace", "name": "Workspace" }, - { "id": "codev.needsAttention", "name": "Needs Attention" }, { "id": "codev.builders", "name": "Builders" }, { "id": "codev.pullRequests", "name": "Pull Requests" }, { "id": "codev.backlog", "name": "Backlog" }, @@ -293,6 +438,17 @@ ], "default": "notify", "description": "Behavior when Tower reports a new builder spawn" + }, + "codev.gateToasts.enabled": { + "type": "boolean", + "default": true, + "description": "Show a VSCode notification toast when a builder reaches a human-approval gate (plan-approval, code-review, etc.)" + }, + "codev.overviewRefreshSeconds": { + "type": "number", + "default": 60, + "minimum": 0, + "markdownDescription": "Auto-refresh the Builders / Pull Requests / Backlog / Recently Closed views every N seconds while the Codev sidebar is visible. `0` disables periodic refresh (event-only, the previous behavior). A shared Tower-side 30s cache throttles GitHub calls across all windows." } } } diff --git a/packages/vscode/src/commands/approve.ts b/packages/vscode/src/commands/approve.ts index dc4c15623..1eb524fe9 100644 --- a/packages/vscode/src/commands/approve.ts +++ b/packages/vscode/src/commands/approve.ts @@ -1,11 +1,64 @@ import * as vscode from 'vscode'; -import { spawn } from 'node:child_process'; +import { execFile } from 'node:child_process'; +import { promisify } from 'node:util'; import type { ConnectionManager } from '../connection-manager.js'; +import type { OverviewCache } from '../views/overview-data.js'; + +const execFileAsync = promisify(execFile); /** - * Codev: Approve Gate — show blocked builders, pick one, approve via porch CLI. + * Per-gate side-button mapping for the approval-confirmation dialog. + * + * Lets the reviewer pop open the natural artifact for one final look + * before committing to approval — without first dismissing the dialog + * and re-triggering the command. + * + * Mirrors `gate-toast.ts`'s GATE_ACTIONS one-for-one so a given gate + * surfaces the same inspection action from either entry point. The maps + * are kept in separate files because the two surfaces have different + * ergonomics (toast at gate-pending fires once; this confirmation fires + * every approval click), but their *contents* must stay in sync. */ -export async function approveGate(connectionManager: ConnectionManager): Promise { +const GATE_SIDE_ACTIONS: Record = { + 'plan-approval': { label: 'View Plan', command: 'codev.viewPlanFile' }, + 'dev-approval': { label: 'Run Dev', command: 'codev.runWorktreeDev' }, +}; + +export interface ApproveGateOptions { + /** + * When true, skip the confirmation dialog and approve directly. Used + * by the gate-pending toast (gate-toast.ts), which is itself the + * context — surfacing a second confirmation would be redundant. + */ + skipConfirmation?: boolean; +} + +/** + * Codev: Approve Gate. + * + * Three invocation paths: + * + * 1. Right-click a blocked-builder row → pass the builder ID directly. + * Skips the quick-pick; auto-detects the gate from b.blockedGate. + * Shows the rich confirmation dialog. + * + * 2. Command palette / Cmd+K G → no builder ID → show quick-pick of all + * blocked builders. Then the rich confirmation dialog. + * + * 3. Gate-pending toast's [Approve] button → builder ID + options + * { skipConfirmation: true }. The toast was the context; approving + * from there commits directly with no second confirmation. + * + * After `porch approve` succeeds, refresh the OverviewCache so the + * sidebar updates immediately rather than waiting for the SSE round-trip + * triggered by porch's overview-refresh broadcast. + */ +export async function approveGate( + connectionManager: ConnectionManager, + cache?: OverviewCache, + builderIdArg?: string, + options?: ApproveGateOptions, +): Promise { const client = connectionManager.getClient(); const workspacePath = connectionManager.getWorkspacePath(); if (!client || !workspacePath || connectionManager.getState() !== 'connected') { @@ -20,21 +73,100 @@ export async function approveGate(connectionManager: ConnectionManager): Promise return; } - const picked = await vscode.window.showQuickPick( - blocked.map(b => ({ - label: `#${b.issueId ?? b.id} ${b.issueTitle ?? ''}`, - description: `blocked on ${b.blocked}`, - id: b.id, - gate: b.blocked!, - })), - { placeHolder: 'Select gate to approve' }, + // We need blockedGate (canonical name like "plan-approval"), not blocked + // (display label like "plan review"). Porch's gate keys are the canonical + // names; the display label is for the human-facing prompts. + let builder: typeof blocked[number] | undefined; + let gate: string; + if (builderIdArg) { + builder = blocked.find(b => b.id === builderIdArg); + if (!builder || !builder.blockedGate) { + vscode.window.showWarningMessage(`Codev: Builder ${builderIdArg} is not blocked at a gate`); + return; + } + gate = builder.blockedGate; + } else { + const candidates = blocked.filter(b => b.blockedGate); + const picked = await vscode.window.showQuickPick( + candidates.map(b => ({ + label: `#${b.issueId ?? b.id} ${b.issueTitle ?? ''}`, + description: `blocked on ${b.blocked}`, + builder: b, + gate: b.blockedGate!, + })), + { placeHolder: 'Select gate to approve' }, + ); + if (!picked) { return; } + builder = picked.builder; + gate = picked.gate; + } + + const id = builder.id; + const issueRef = builder.issueId ? `#${builder.issueId}` : id; + const titlePart = builder.issueTitle ? ` — ${truncate(builder.issueTitle, 60)}` : ''; + // Display label e.g. "plan review" from overview; falls back to the + // canonical gate name if the display label isn't set. + const gateLabel = builder.blocked ?? gate; + + // Fast path: caller already has context (gate-pending toast). Skip the + // confirmation dialog and go straight to porch approve. + if (options?.skipConfirmation) { + await runPorchApprove(workspacePath, id, gate, gateLabel, issueRef); + cache?.refresh(); + return; + } + + // Rich confirmation: modal (centered, blocking) keeps the dialog close + // to where the user just clicked — the ✓ icon in the left sidebar or + // Cmd+K G near the editor — instead of a bottom-right toast that + // forces a diagonal cursor traversal. Approval is a deliberate, + // once-per-gate action; the modal interrupt is appropriate. + const sideAction = GATE_SIDE_ACTIONS[gate]; + const buttons = sideAction ? [sideAction.label, 'Approve'] : ['Approve']; + + const selection = await vscode.window.showInformationMessage( + `Approve ${gateLabel} for ${issueRef}${titlePart}?`, + { modal: true }, + ...buttons, ); - if (!picked) { return; } - - const child = spawn('porch', ['approve', picked.id, picked.gate, '--a-human-explicitly-approved-this'], { - detached: true, - stdio: 'ignore', - }); - child.unref(); - vscode.window.showInformationMessage(`Codev: Approving ${picked.gate} for #${picked.id}`); + + if (!selection) { return; } + + if (selection === 'Approve') { + await runPorchApprove(workspacePath, id, gate, gateLabel, issueRef); + cache?.refresh(); + return; + } + + // Side-button clicked. Invoke the corresponding command with the + // builder ID; the user can re-trigger Approve afterward. + if (sideAction && selection === sideAction.label) { + await vscode.commands.executeCommand(sideAction.command, id); + } +} + +async function runPorchApprove( + workspacePath: string, + id: string, + gate: string, + gateLabel: string, + issueRef: string, +): Promise { + try { + await execFileAsync('porch', [ + 'approve', + id, + gate, + '--a-human-explicitly-approved-this', + ], { cwd: workspacePath }); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + vscode.window.showErrorMessage(`Codev: porch approve failed — ${msg}`); + return; + } + vscode.window.showInformationMessage(`Codev: Approved ${gateLabel} for ${issueRef}`); +} + +function truncate(s: string, max: number): string { + return s.length > max ? `${s.slice(0, max - 1)}…` : s; } diff --git a/packages/vscode/src/commands/cleanup.ts b/packages/vscode/src/commands/cleanup.ts index 2f697e14f..00984530a 100644 --- a/packages/vscode/src/commands/cleanup.ts +++ b/packages/vscode/src/commands/cleanup.ts @@ -1,11 +1,27 @@ import * as vscode from 'vscode'; -import { spawn } from 'node:child_process'; +import { execFile } from 'node:child_process'; +import { promisify } from 'node:util'; import type { ConnectionManager } from '../connection-manager.js'; +import type { OverviewCache } from '../views/overview-data.js'; + +const execFileAsync = promisify(execFile); /** - * Codev: Cleanup Builder — pick builder, run afx cleanup. + * Codev: Cleanup Builder — pick builder, run `afx cleanup`, then refresh the + * sidebar so the removed builder disappears immediately. + * + * Two follow-ups after a successful cleanup: + * + * 1. Show success / error toast based on the actual exit status (the old + * fire-and-forget spawn silently swallowed errors). + * 2. Refresh OverviewCache so the Builders tree drops the removed + * entry without waiting for the next SSE tick. This fixes the + * user-visible bug where a cleaned-up builder lingered. */ -export async function cleanupBuilder(connectionManager: ConnectionManager): Promise { +export async function cleanupBuilder( + connectionManager: ConnectionManager, + cache?: OverviewCache, +): Promise { const client = connectionManager.getClient(); const workspacePath = connectionManager.getWorkspacePath(); if (!client || !workspacePath || connectionManager.getState() !== 'connected') { @@ -30,7 +46,16 @@ export async function cleanupBuilder(connectionManager: ConnectionManager): Prom ); if (!picked) { return; } - const child = spawn('afx', ['cleanup', '-p', picked.id], { detached: true, stdio: 'ignore' }); - child.unref(); - vscode.window.showInformationMessage(`Codev: Cleaning up builder #${picked.id}`); + try { + await execFileAsync('afx', ['cleanup', '-p', picked.id], { cwd: workspacePath }); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + vscode.window.showErrorMessage(`Codev: afx cleanup failed — ${msg}`); + return; + } + + vscode.window.showInformationMessage(`Codev: Cleaned up builder #${picked.id}`); + + // Refresh the cache so the sidebar drops the removed builder immediately. + cache?.refresh(); } diff --git a/packages/vscode/src/commands/dev-shared.ts b/packages/vscode/src/commands/dev-shared.ts new file mode 100644 index 000000000..35d4dca08 --- /dev/null +++ b/packages/vscode/src/commands/dev-shared.ts @@ -0,0 +1,173 @@ +/** + * Shared core for Codev dev-server commands. There is exactly one underlying + * action — "spawn a Tower dev PTY for a {id, cwd} target and open its tab" — + * with two front-ends that differ only in how they resolve the target: + * + * - run-worktree-dev.ts : target picked from Tower's builder overview + * (right-click a Builders row / palette quick-pick). + * - run-workspace-dev.ts: target = whatever folder *this VSCode window* is + * rooted at (main checkout → `main`; a `.builders//` worktree → that + * builder), via resolveWorkspaceDevTarget(). + * + * Swap-sequencing constants mirror the CLI's `afx dev` + * (packages/codev/src/agent-farm/commands/dev.ts). + */ + +import * as vscode from 'vscode'; +import { readFile } from 'node:fs/promises'; +import * as path from 'node:path'; +import type { ConnectionManager } from '../connection-manager.js'; +import type { TerminalManager } from '../terminal-manager.js'; + +export const KILL_WAIT_TIMEOUT_MS = 7000; +export const KILL_POLL_INTERVAL_MS = 200; +export const SWAP_GRACE_MS = 250; + +/** A resolved dev target: which id to run under, where, and how to label it. */ +export interface DevTarget { + /** roleId + swap key + `Dev: ` label suffix (e.g. `main`, `pir-1467`). */ + id: string; + /** Working directory the dev command runs in. */ + cwd: string; + /** Human-facing name for toasts / the terminal tab (e.g. `Workspace`). */ + name: string; +} + +/** + * Resolve the dev target from the folder *this VSCode window* is rooted at. + * + * Path-sniffing against the fixed `/.builders//` layout that + * `afx spawn` creates (the same invariant findProjectRoot / openWorktreeWindow + * already rely on) — deterministic, offline, no Tower round-trip: + * - parent dir is `.builders` → builder worktree, id = basename + * - otherwise → the main checkout, id = `main` + */ +export function resolveWorkspaceDevTarget(workspacePath: string): DevTarget { + if (path.basename(path.dirname(workspacePath)) === '.builders') { + const id = path.basename(workspacePath); + return { id, cwd: workspacePath, name: id }; + } + return { id: 'main', cwd: workspacePath, name: 'Workspace' }; +} + +/** Read `worktree.devCommand` from `.codev/config.json`; null if unset/unreadable. */ +export async function readDevCommand(workspacePath: string): Promise { + const configPath = path.join(workspacePath, '.codev', 'config.json'); + try { + const raw = await readFile(configPath, 'utf-8'); + const parsed = JSON.parse(raw) as { worktree?: { devCommand?: unknown } }; + const cmd = parsed.worktree?.devCommand; + return typeof cmd === 'string' && cmd.length > 0 ? cmd : null; + } catch { + return null; + } +} + +/** Poll `listTerminals` until the killed terminal disappears (or timeout). */ +export async function waitForTerminalGone( + client: NonNullable>, + terminalId: string, +): Promise { + const deadline = Date.now() + KILL_WAIT_TIMEOUT_MS; + while (Date.now() < deadline) { + const terminals = await client.listTerminals(); + if (!terminals.some(t => t.id === terminalId)) { return; } + await new Promise((r) => setTimeout(r, KILL_POLL_INTERVAL_MS)); + } + throw new Error(`Dev terminal ${terminalId} did not exit within ${KILL_WAIT_TIMEOUT_MS}ms`); +} + +/** + * Start (or focus) the dev PTY for a resolved target. Single-dev-slot model: + * one dev at a time across {main + all builders} because they all bind main's + * ports — a different running target prompts for swap. Identical for every + * front-end; only target resolution differs. + */ +export async function startDevForTarget( + connectionManager: ConnectionManager, + terminalManager: TerminalManager, + target: DevTarget, +): Promise { + const client = connectionManager.getClient(); + const workspacePath = connectionManager.getWorkspacePath(); + if (!client || !workspacePath || connectionManager.getState() !== 'connected') { + vscode.window.showErrorMessage('Codev: Not connected to Tower'); + return; + } + + const devCommand = await readDevCommand(workspacePath); + if (!devCommand) { + vscode.window.showErrorMessage( + 'Codev: Configure worktree.devCommand in .codev/config.json to use this action. ' + + 'See "Runnable Worktrees" in CLAUDE.md for stack-specific recipes.', + ); + return; + } + + // Swap detection (per-VSCode-instance; cross-instance is a #690 non-goal). + const existing = terminalManager.listDevTerminals(); + const same = existing.find(d => d.builderId === target.id); + if (same) { + vscode.window.showInformationMessage(`Codev: Dev server is already running for ${target.name}`); + await terminalManager.openDevTerminal(same.terminalId, target.id, target.name, true); + return; + } + if (existing.length > 0) { + const other = existing[0]!; + const choice = await vscode.window.showWarningMessage( + `Stop dev for ${other.builderId} and start ${target.name}?`, + { modal: true }, + 'Yes', 'No', + ); + if (choice !== 'Yes') { return; } + await client.killTerminal(other.terminalId); + terminalManager.closeDevTerminal(other.builderId); + try { + await waitForTerminalGone(client, other.terminalId); + } catch (err) { + vscode.window.showErrorMessage(`Codev: ${(err as Error).message}`); + return; + } + await new Promise((r) => setTimeout(r, SWAP_GRACE_MS)); + } + + const terminal = await client.createTerminal({ + command: '/bin/sh', + args: ['-lc', devCommand], + cwd: target.cwd, + workspacePath, + type: 'dev', + roleId: target.id, + label: `Dev: ${target.id}`, + persistent: false, + }); + if (!terminal) { + vscode.window.showErrorMessage(`Codev: Failed to spawn dev terminal for ${target.name}`); + return; + } + + await terminalManager.openDevTerminal(terminal.id, target.id, target.name, true); + vscode.window.showInformationMessage(`Codev: Dev server started for ${target.name}`); +} + +/** Stop the dev PTY for a single target id (scoped — does not touch others). */ +export async function stopDevForTarget( + connectionManager: ConnectionManager, + terminalManager: TerminalManager, + targetId: string, + name: string, +): Promise { + const client = connectionManager.getClient(); + if (!client || connectionManager.getState() !== 'connected') { + vscode.window.showErrorMessage('Codev: Not connected to Tower'); + return; + } + const found = terminalManager.listDevTerminals().find(d => d.builderId === targetId); + if (!found) { + vscode.window.showInformationMessage(`Codev: No dev server is running for ${name}`); + return; + } + await client.killTerminal(found.terminalId); + terminalManager.closeDevTerminal(targetId); + vscode.window.showInformationMessage(`Codev: Dev server stopped for ${name}`); +} diff --git a/packages/vscode/src/commands/open-worktree-window.ts b/packages/vscode/src/commands/open-worktree-window.ts new file mode 100644 index 000000000..a296ddd5b --- /dev/null +++ b/packages/vscode/src/commands/open-worktree-window.ts @@ -0,0 +1,75 @@ +/** + * Codev: Open Worktree as Workspace — opens a builder's worktree in a new + * editor window. The new window is rooted at `.builders//`, so VSCode's + * built-in Git extension treats it as a primary checkout: the SCM panel + * shows working-tree changes, "View Working Tree Changes" shows committed + * diff vs the default branch, and inline-editor diffs work natively. + * + * Replaces the previous "View Diff" command, which tried to render a + * multi-file diff in the current window via `vscode.changes` + `git:` URIs + * but couldn't, because VSCode's Git extension doesn't auto-discover + * worktrees inside `.gitignore`d subdirectories of the host workspace. + * + * Editor-agnostic: `vscode.openFolder` is a built-in command exposed by + * VSCode, Cursor, Windsurf, and other VSCode-family editors. No CLI + * detection needed. + */ + +import * as vscode from 'vscode'; +import type { ConnectionManager } from '../connection-manager.js'; + +export async function openWorktreeWindow( + connectionManager: ConnectionManager, + builderIdArg: string | undefined, +): Promise { + const client = connectionManager.getClient(); + const workspacePath = connectionManager.getWorkspacePath(); + if (!client || !workspacePath || connectionManager.getState() !== 'connected') { + vscode.window.showErrorMessage('Codev: Not connected to Tower'); + return; + } + + const overview = await client.getOverview(workspacePath); + const builders = overview?.builders ?? []; + if (builders.length === 0) { + vscode.window.showInformationMessage('Codev: No builders available'); + return; + } + + const builder = builderIdArg + ? builders.find(b => b.id === builderIdArg) + : await pickBuilder(builders); + if (!builder) { + if (builderIdArg) { + vscode.window.showErrorMessage(`Codev: No builder found for "${builderIdArg}"`); + } + return; + } + if (!builder.worktreePath) { + vscode.window.showErrorMessage(`Codev: Builder ${builder.id} has no worktree on record`); + return; + } + + await vscode.commands.executeCommand( + 'vscode.openFolder', + vscode.Uri.file(builder.worktreePath), + /* forceNewWindow */ true, + ); +} + +interface BuilderLike { + id: string; + issueId: string | null; + issueTitle: string | null; +} + +async function pickBuilder(builders: T[]): Promise { + const picked = await vscode.window.showQuickPick( + builders.map(b => ({ + label: `#${b.issueId ?? b.id} ${b.issueTitle ?? ''}`, + builder: b, + })), + { placeHolder: 'Select builder to open in new window' }, + ); + return picked?.builder; +} diff --git a/packages/vscode/src/commands/review-diff.ts b/packages/vscode/src/commands/review-diff.ts deleted file mode 100644 index 74b2c74df..000000000 --- a/packages/vscode/src/commands/review-diff.ts +++ /dev/null @@ -1,163 +0,0 @@ -/** - * Codev: View Diff — open `main...HEAD` for a builder's worktree as a - * single multi-file diff editor (matches VSCode's built-in "Working Tree" - * view in the Source Control panel). - * - * Right-click a builder row → "View Diff". Opens ONE tab with a file - * list on the left and a diff that updates as the reviewer clicks each - * file. Handles added / modified / deleted files uniformly via VSCode's - * `vscode.changes` command. - * - * Why this works across worktrees: each `.builders//` is a real git - * worktree linked to the parent repo's `.git`, so all branches live in the - * shared object database. `git -C rev-parse main` resolves the same - * SHA as from the parent repo, and `git:?ref=main` reads from that object - * database regardless of which repo VSCode attributes the file to. - */ - -import * as vscode from 'vscode'; -import { execFile } from 'node:child_process'; -import { promisify } from 'node:util'; -import * as path from 'node:path'; -import type { ConnectionManager } from '../connection-manager.js'; - -const execFileAsync = promisify(execFile); - -export async function reviewDiff( - connectionManager: ConnectionManager, - builderIdArg: string | undefined, -): Promise { - const client = connectionManager.getClient(); - const workspacePath = connectionManager.getWorkspacePath(); - if (!client || !workspacePath || connectionManager.getState() !== 'connected') { - vscode.window.showErrorMessage('Codev: Not connected to Tower'); - return; - } - - // Resolve the builder. Quick-pick fallback for command-palette invocation. - const overview = await client.getOverview(workspacePath); - const builders = overview?.builders ?? []; - if (builders.length === 0) { - vscode.window.showInformationMessage('Codev: No builders to diff'); - return; - } - - const builder = builderIdArg - ? builders.find(b => b.id === builderIdArg) - : await pickBuilder(builders); - if (!builder) { - if (builderIdArg) { - vscode.window.showErrorMessage(`Codev: No builder found for "${builderIdArg}"`); - } - return; - } - if (!builder.worktreePath) { - vscode.window.showErrorMessage(`Codev: Builder ${builder.id} has no worktree on record`); - return; - } - - // Enumerate changed files with status letters so we can handle - // added (A) / deleted (D) files distinctly from modified (M). - let changes: Array<{ status: string; path: string }>; - try { - const { stdout } = await execFileAsync('git', [ - '-C', builder.worktreePath, - 'diff', '--name-status', 'main...HEAD', - ]); - changes = stdout - .split('\n') - .map(s => s.trim()) - .filter(Boolean) - .map(line => { - // Status letter, then tab(s), then the path. For renames/copies the - // line is "R100\told\tnew" — take the new (last) path. - const parts = line.split('\t'); - return { status: parts[0]![0]!, path: parts[parts.length - 1]! }; - }); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - vscode.window.showErrorMessage(`Codev: git diff failed — ${message}`); - return; - } - - if (changes.length === 0) { - vscode.window.showInformationMessage(`Codev: No changes to review yet for ${builder.id}`); - return; - } - - // Build the resources array for vscode.changes. Each entry is a tuple of - // [resourceUri, leftUri, rightUri]: - // - resourceUri: the file as it lives in the worktree (used for icon / - // file-list display) - // - leftUri: main's version (empty for added files) - // - rightUri: worktree's version (empty for deleted files) - // Empty sides are signalled with the `git:` URI at a non-existent ref — - // VSCode's diff editor renders that as a one-sided view. - const resources: Array<[vscode.Uri, vscode.Uri, vscode.Uri]> = changes.map(({ status, path: rel }) => { - const abs = path.join(builder.worktreePath, rel); - const resourceUri = vscode.Uri.file(abs); - const mainUri = toGitUri(abs, rel, 'main'); - const headUri = vscode.Uri.file(abs); - - if (status === 'A') { - // Added: no main version. Left = empty, right = file. - return [resourceUri, emptyGitUri(abs, rel), headUri]; - } - if (status === 'D') { - // Deleted: no worktree version. Left = main, right = empty. - return [resourceUri, mainUri, emptyGitUri(abs, rel)]; - } - // Modified / renamed / copied / unmerged → side-by-side diff - return [resourceUri, mainUri, headUri]; - }); - - await vscode.commands.executeCommand( - 'vscode.changes', - `Reviewing ${builder.id} (main ↔ HEAD)`, - resources, - ); -} - -/** - * Build a `git:` URI VSCode's built-in Git extension resolves against the - * worktree's shared object database. Matches the Git extension's canonical - * `toGitUri` shape: scheme=git, path=fsPath, query=JSON of `{ path, ref }`. - */ -function toGitUri(absPath: string, relPath: string, ref: string): vscode.Uri { - return vscode.Uri.file(absPath).with({ - scheme: 'git', - query: JSON.stringify({ path: relPath, ref }), - }); -} - -/** - * URI that renders as empty content in the diff editor. Used for the - * "missing" side of added/deleted files. We use a `git:` URI at a known - * empty ref (`HEAD~0` resolves but the file doesn't exist for added files; - * the Git extension returns empty content for unresolved paths at a valid - * ref). If that ever becomes flaky, the alternative is a `data:` URI or - * an untitled scheme. - */ -function emptyGitUri(absPath: string, relPath: string): vscode.Uri { - return vscode.Uri.file(absPath).with({ - scheme: 'git', - query: JSON.stringify({ path: relPath, ref: '' }), - }); -} - -interface BuilderLike { - id: string; - issueId: string | null; - issueTitle: string | null; -} - -async function pickBuilder(builders: T[]): Promise { - const picked = await vscode.window.showQuickPick( - builders.map(b => ({ - label: `#${b.issueId ?? b.id} ${b.issueTitle ?? ''}`, - builder: b, - })), - { placeHolder: 'Select builder to diff against main' }, - ); - return picked?.builder; -} diff --git a/packages/vscode/src/commands/run-workspace-dev.ts b/packages/vscode/src/commands/run-workspace-dev.ts new file mode 100644 index 000000000..486abad96 --- /dev/null +++ b/packages/vscode/src/commands/run-workspace-dev.ts @@ -0,0 +1,42 @@ +/** + * Codev: Start / Stop Dev Server (Workspace view) — runs the dev server for + * whatever folder *this VSCode window is rooted at*. Not "main"-specific: + * + * - window opened on the main checkout → target `main`, cwd = root + * - window opened on a `.builders//` → target ``, cwd = worktree + * worktree (e.g. via "Open Worktree as Workspace") + * + * Target resolution is the *only* difference from the builder-row command; + * the actual spawn/swap/stop is the shared core in dev-shared.ts. + */ + +import * as vscode from 'vscode'; +import type { ConnectionManager } from '../connection-manager.js'; +import type { TerminalManager } from '../terminal-manager.js'; +import { resolveWorkspaceDevTarget, startDevForTarget, stopDevForTarget } from './dev-shared.js'; + +export async function runWorkspaceDev( + connectionManager: ConnectionManager, + terminalManager: TerminalManager, +): Promise { + const workspacePath = connectionManager.getWorkspacePath(); + if (!workspacePath) { + vscode.window.showErrorMessage('Codev: Not connected to Tower'); + return; + } + const target = resolveWorkspaceDevTarget(workspacePath); + await startDevForTarget(connectionManager, terminalManager, target); +} + +export async function stopWorkspaceDev( + connectionManager: ConnectionManager, + terminalManager: TerminalManager, +): Promise { + const workspacePath = connectionManager.getWorkspacePath(); + if (!workspacePath) { + vscode.window.showErrorMessage('Codev: Not connected to Tower'); + return; + } + const target = resolveWorkspaceDevTarget(workspacePath); + await stopDevForTarget(connectionManager, terminalManager, target.id, target.name); +} diff --git a/packages/vscode/src/commands/run-worktree-dev.ts b/packages/vscode/src/commands/run-worktree-dev.ts index a4ce9fcf9..9a9d5098e 100644 --- a/packages/vscode/src/commands/run-worktree-dev.ts +++ b/packages/vscode/src/commands/run-worktree-dev.ts @@ -1,25 +1,17 @@ /** - * Codev: Run Dev Server — start a Tower-managed dev PTY for a builder's worktree. - * - * Right-click a builder row → "Run Dev Server". Reads `worktree.devCommand` - * from `.codev/config.json`, asks Tower to spawn the PTY (type='dev', label - * 'Dev: '), and auto-opens it as a VSCode terminal tab. - * - * Mirrors the swap-detection logic from the CLI's `afx dev` (commands/dev.ts) - * but uses a VSCode modal instead of a readline prompt. + * Codev: Run Dev Server — start a Tower-managed dev PTY for a builder's + * worktree. Right-click a builder row (or palette quick-pick) resolves the + * builder from Tower's overview, then delegates to the shared dev core + * (dev-shared.ts), which handles the devCommand read, swap, spawn, and tab + * open — identical to every other dev front-end; only target resolution + * differs (see run-workspace-dev.ts). */ import * as vscode from 'vscode'; -import { readFile } from 'node:fs/promises'; -import * as path from 'node:path'; import { resolveAgentName } from '@cluesmith/codev-core/agent-names'; import type { ConnectionManager } from '../connection-manager.js'; import type { TerminalManager } from '../terminal-manager.js'; - -/** Match the CLI's swap-sequencing values from packages/codev/src/agent-farm/commands/dev.ts */ -const KILL_WAIT_TIMEOUT_MS = 7000; -const KILL_POLL_INTERVAL_MS = 200; -const SWAP_GRACE_MS = 250; +import { startDevForTarget } from './dev-shared.js'; export async function runWorktreeDev( connectionManager: ConnectionManager, @@ -54,102 +46,23 @@ export async function runWorktreeDev( return; } - // Read worktree.devCommand from .codev/config.json - const devCommand = await readDevCommand(workspacePath); - if (!devCommand) { - vscode.window.showErrorMessage( - 'Codev: Configure worktree.devCommand in .codev/config.json to use this action. ' + - 'See "Runnable Worktrees" in CLAUDE.md for stack-specific recipes.', - ); - return; - } - - // Look up the human-friendly builder name from workspace state — same - // source and matching strategy the builder tab uses (openBuilderByRoleOrId - // in terminal-manager.ts). OverviewBuilder.id and Builder.id can differ in - // shape (one may include the worktree slug, the other not), so we use - // resolveAgentName for the tail-match fallback rather than strict ===. + // Human-friendly builder name — same source and matching strategy the + // builder tab uses (openBuilderByRoleOrId in terminal-manager.ts). + // OverviewBuilder.id and Builder.id can differ in shape, so resolveAgentName + // does the tail-match fallback rather than strict ===. const workspaceState = await client.getWorkspaceState(workspacePath); const { builder: namedBuilder } = resolveAgentName(builder.id, workspaceState?.builders ?? []); const builderName = namedBuilder?.name ?? builder.id; - // Swap detection. Source of truth for "what dev terminals exist" is - // TerminalManager's local map — Tower's label filter would be brittle - // and wouldn't catch terminals across VSCode instances anyway (a #690 - // non-goal). For now we assume one VSCode instance per workspace. - const existing = terminalManager.listDevTerminals(); - const sameBuilder = existing.find(d => d.builderId === builder.id); - if (sameBuilder) { - vscode.window.showInformationMessage(`Codev: Dev server is already running for ${builderName}`); - await terminalManager.openDevTerminal(sameBuilder.terminalId, builder.id, builderName, true); - return; - } - if (existing.length > 0) { - const other = existing[0]!; - const choice = await vscode.window.showWarningMessage( - `Stop dev for ${other.builderId} and start for ${builderName}?`, - { modal: true }, - 'Yes', 'No', - ); - if (choice !== 'Yes') { return; } - await client.killTerminal(other.terminalId); - terminalManager.closeDevTerminal(other.builderId); - try { - await waitForTerminalGone(client, other.terminalId); - } catch (err) { - vscode.window.showErrorMessage(`Codev: ${(err as Error).message}`); - return; - } - await new Promise((r) => setTimeout(r, SWAP_GRACE_MS)); - } - - // Spawn the new dev PTY - const terminal = await client.createTerminal({ - command: '/bin/sh', - args: ['-lc', devCommand], + await startDevForTarget(connectionManager, terminalManager, { + id: builder.id, cwd: builder.worktreePath, - workspacePath, - type: 'dev', - roleId: builder.id, - label: `Dev: ${builder.id}`, - persistent: false, + name: builderName, }); - if (!terminal) { - vscode.window.showErrorMessage(`Codev: Failed to spawn dev terminal for ${builderName}`); - return; - } - - await terminalManager.openDevTerminal(terminal.id, builder.id, builderName, true); - vscode.window.showInformationMessage(`Codev: Dev server started for ${builderName}`); } // ─── Helpers ──────────────────────────────────────────────────────────── -async function readDevCommand(workspacePath: string): Promise { - const configPath = path.join(workspacePath, '.codev', 'config.json'); - try { - const raw = await readFile(configPath, 'utf-8'); - const parsed = JSON.parse(raw) as { worktree?: { devCommand?: unknown } }; - const cmd = parsed.worktree?.devCommand; - return typeof cmd === 'string' && cmd.length > 0 ? cmd : null; - } catch { - return null; - } -} - -async function waitForTerminalGone( - client: NonNullable>, - terminalId: string, -): Promise { - const deadline = Date.now() + KILL_WAIT_TIMEOUT_MS; - while (Date.now() < deadline) { - const terminals = await client.listTerminals(); - if (!terminals.some(t => t.id === terminalId)) { return; } - await new Promise((r) => setTimeout(r, KILL_POLL_INTERVAL_MS)); - } - throw new Error(`Dev terminal ${terminalId} did not exit within ${KILL_WAIT_TIMEOUT_MS}ms`); -} - interface BuilderLike { id: string; issueId: string | null; diff --git a/packages/vscode/src/commands/send.ts b/packages/vscode/src/commands/send.ts index 850ddb245..4bb0fb4e6 100644 --- a/packages/vscode/src/commands/send.ts +++ b/packages/vscode/src/commands/send.ts @@ -32,9 +32,10 @@ export async function sendMessage(connectionManager: ConnectionManager): Promise if (!message) { return; } const result = await client.sendMessage(picked.id, message, { workspace: workspacePath }); - if (result.ok) { - vscode.window.showInformationMessage(`Codev: Message sent to ${picked.label}`); - } else { + if (!result.ok) { vscode.window.showErrorMessage(`Codev: Failed to send — ${result.error}`); + return; } + + vscode.window.showInformationMessage(`Codev: Message sent to ${picked.label}`); } diff --git a/packages/vscode/src/commands/spawn.ts b/packages/vscode/src/commands/spawn.ts index 46115af97..987ca60bf 100644 --- a/packages/vscode/src/commands/spawn.ts +++ b/packages/vscode/src/commands/spawn.ts @@ -2,29 +2,41 @@ import * as vscode from 'vscode'; import { spawn } from 'node:child_process'; /** - * Codev: Spawn Builder — quick-pick flow for issue + protocol + optional branch. + * Codev: Spawn Builder. + * + * Two entry points: + * - No arg (command palette): full flow — issue input → protocol → optional branch. + * - `issueArg` provided (Backlog row-click / context menu): the issue is + * already known, so jump straight to the protocol pick and spawn. The + * branch prompt is skipped too — starting work on a backlog issue means + * a fresh branch. */ -export async function spawnBuilder(): Promise { - const issueNumber = await vscode.window.showInputBox({ - prompt: 'Issue number', - placeHolder: '42', - }); - if (!issueNumber) { return; } +export async function spawnBuilder(issueArg?: string): Promise { + let issueNumber = issueArg; + if (!issueNumber) { + issueNumber = await vscode.window.showInputBox({ + prompt: 'Issue number', + placeHolder: '42', + }); + if (!issueNumber) { return; } + } const protocol = await vscode.window.showQuickPick( - ['spir', 'aspir', 'air', 'bugfix', 'tick'], + ['spir', 'aspir', 'pir', 'air', 'bugfix', 'tick'], { placeHolder: 'Select protocol' }, ); if (!protocol) { return; } - const branch = await vscode.window.showInputBox({ - prompt: 'Branch name (optional — leave empty for new branch)', - placeHolder: 'feature/my-branch', - }); - const args = ['spawn', issueNumber, '--protocol', protocol]; - if (branch) { - args.push('--branch', branch); + + if (!issueArg) { + const branch = await vscode.window.showInputBox({ + prompt: 'Branch name (optional — leave empty for new branch)', + placeHolder: 'feature/my-branch', + }); + if (branch) { + args.push('--branch', branch); + } } runAfxCommand(args); diff --git a/packages/vscode/src/commands/view-artifact.ts b/packages/vscode/src/commands/view-artifact.ts new file mode 100644 index 000000000..5be34539e --- /dev/null +++ b/packages/vscode/src/commands/view-artifact.ts @@ -0,0 +1,145 @@ +/** + * Codev: View Plan File — open the plan markdown a gated PIR builder is + * waiting on directly in a VSCode editor tab. + * + * Right-click a builder row → "View Plan File". + * + * Strategy: locate `/codev/plans/`, filter to files prefixed + * with the builder ID, and: + * - 0 files → friendly message ("no file yet — the builder hasn't written one") + * - 1 file → open it + * - 2+ files → quick-pick (newer files float to the top) + * + * View Review File was intentionally not added: the review file is the + * PR body in PIR's review phase, so reviewers read it on GitHub when + * it matters. + */ + +import * as vscode from 'vscode'; +import { resolve } from 'node:path'; +import { existsSync, readdirSync, statSync } from 'node:fs'; +import type { ConnectionManager } from '../connection-manager.js'; + +type ArtifactKind = 'plan'; + +const ARTIFACT_SUBDIR: Record = { + plan: 'codev/plans', +}; + +export function viewPlanFile(connectionManager: ConnectionManager, builderIdArg: string | undefined) { + return viewArtifact(connectionManager, builderIdArg, 'plan'); +} + +async function viewArtifact( + connectionManager: ConnectionManager, + builderIdArg: string | undefined, + kind: ArtifactKind, +): Promise { + const client = connectionManager.getClient(); + const workspacePath = connectionManager.getWorkspacePath(); + if (!client || !workspacePath || connectionManager.getState() !== 'connected') { + vscode.window.showErrorMessage('Codev: Not connected to Tower'); + return; + } + + const overview = await client.getOverview(workspacePath); + const builders = overview?.builders ?? []; + if (builders.length === 0) { + vscode.window.showInformationMessage('Codev: No builders available'); + return; + } + + const builder = builderIdArg + ? builders.find(b => b.id === builderIdArg) + : await pickBuilder(builders, kind); + if (!builder) { + if (builderIdArg) { + vscode.window.showErrorMessage(`Codev: No builder found for "${builderIdArg}"`); + } + return; + } + if (!builder.worktreePath) { + vscode.window.showErrorMessage(`Codev: Builder ${builder.id} has no worktree on record`); + return; + } + + const artifactDir = resolve(builder.worktreePath, ARTIFACT_SUBDIR[kind]); + if (!existsSync(artifactDir)) { + vscode.window.showInformationMessage( + `Codev: No ${kind} file yet — ${ARTIFACT_SUBDIR[kind]}/ doesn't exist in the worktree.`, + ); + return; + } + + // Filter to files belonging to THIS builder. Plan/review files are named + // `-.md` (e.g. `1298-fix-foo.md`) where `` matches the + // builder's porch project ID, so the builder ID prefix is the natural + // filter. Other builders' files for the same protocol live in the same + // dir and would otherwise show up in the quick-pick. + const builderPrefix = `${builder.id}-`; + const files = readdirSync(artifactDir) + .filter(f => + f.endsWith('.md') && + (f.startsWith(builderPrefix) || f === `${builder.id}.md`) + ) + .map(f => ({ name: f, path: resolve(artifactDir, f), mtime: safeMtime(resolve(artifactDir, f)) })) + .sort((a, b) => b.mtime - a.mtime); + + if (files.length === 0) { + vscode.window.showInformationMessage( + `Codev: No ${kind} file for builder ${builder.id} yet — the builder hasn't written one.`, + ); + return; + } + + let target: string; + if (files.length === 1) { + target = files[0].path; + } else { + const picked = await vscode.window.showQuickPick( + files.map(f => ({ label: f.name, description: relativeTime(f.mtime), path: f.path })), + { placeHolder: `Select ${kind} file to open` }, + ); + if (!picked) { + return; + } + target = picked.path; + } + + const doc = await vscode.workspace.openTextDocument(vscode.Uri.file(target)); + await vscode.window.showTextDocument(doc, { preview: false }); +} + +function safeMtime(path: string): number { + try { + return statSync(path).mtimeMs; + } catch { + return 0; + } +} + +function relativeTime(mtime: number): string { + if (mtime === 0) { return ''; } + const seconds = Math.floor((Date.now() - mtime) / 1000); + if (seconds < 60) { return `${seconds}s ago`; } + if (seconds < 3600) { return `${Math.floor(seconds / 60)}m ago`; } + if (seconds < 86400) { return `${Math.floor(seconds / 3600)}h ago`; } + return `${Math.floor(seconds / 86400)}d ago`; +} + +interface BuilderLike { + id: string; + issueId: string | null; + issueTitle: string | null; +} + +async function pickBuilder(builders: T[], kind: ArtifactKind): Promise { + const picked = await vscode.window.showQuickPick( + builders.map(b => ({ + label: `#${b.issueId ?? b.id} ${b.issueTitle ?? ''}`, + builder: b, + })), + { placeHolder: `Select builder whose ${kind} file to open` }, + ); + return picked?.builder; +} diff --git a/packages/vscode/src/commands/view-issue.ts b/packages/vscode/src/commands/view-issue.ts new file mode 100644 index 000000000..c3efd84d4 --- /dev/null +++ b/packages/vscode/src/commands/view-issue.ts @@ -0,0 +1,99 @@ +/** + * Codev: View Issue — read the body + comments of a backlog issue inside + * VSCode instead of opening a browser. + * + * Right-click a backlog row → "View Issue". Fetches via Tower's + * forge-backed GET /api/issue (so it stays forge-agnostic and + * tunnel-safe — the extension never shells out to `gh`), renders the + * issue as markdown behind a read-only `codev-issue:` document, and + * opens VSCode's built-in markdown preview. + * + * A TextDocumentContentProvider scheme is read-only by construction, so + * there's no editable scratch buffer left behind (unlike opening an + * untitled document). + */ + +import * as vscode from 'vscode'; +import type { IssueView } from '@cluesmith/codev-types'; +import type { ConnectionManager } from '../connection-manager.js'; + +const SCHEME = 'codev-issue'; + +class IssueContentProvider implements vscode.TextDocumentContentProvider { + private readonly contents = new Map(); + + /** Key is the issue id; value is the rendered markdown. */ + set(issueId: string, markdown: string): void { + this.contents.set(issueId, markdown); + } + + provideTextDocumentContent(uri: vscode.Uri): string { + // URI form: codev-issue:.md → authority/path is `.md` + const issueId = uri.path.replace(/\.md$/, ''); + return this.contents.get(issueId) ?? `# Issue #${issueId}\n\n_Content unavailable._`; + } +} + +const provider = new IssueContentProvider(); + +export function activateIssueView(context: vscode.ExtensionContext): void { + context.subscriptions.push( + vscode.workspace.registerTextDocumentContentProvider(SCHEME, provider), + ); +} + +function renderIssue(issueId: string, issue: IssueView): string { + const lines: string[] = [ + `# #${issueId} ${issue.title}`, + '', + `**State:** ${issue.state}`, + '', + issue.body?.trim() ? issue.body : '_No description._', + ]; + + if (issue.comments.length > 0) { + lines.push('', '---', '', `## Comments (${issue.comments.length})`, ''); + for (const c of issue.comments) { + lines.push(`### @${c.author.login} — ${c.createdAt}`, '', c.body, ''); + } + } + + return lines.join('\n'); +} + +export async function viewBacklogIssue( + connectionManager: ConnectionManager, + issueId: string | undefined, +): Promise { + if (!issueId) { return; } + + const client = connectionManager.getClient(); + const workspacePath = connectionManager.getWorkspacePath(); + if (!client || !workspacePath || connectionManager.getState() !== 'connected') { + vscode.window.showErrorMessage('Codev: Not connected to Tower'); + return; + } + + const issue = await client.getIssue(issueId, workspacePath); + if (!issue) { + vscode.window.showWarningMessage( + `Codev: Could not load issue #${issueId} (forge unavailable?)`, + ); + return; + } + + provider.set(issueId, renderIssue(issueId, issue)); + const uri = vscode.Uri.parse(`${SCHEME}:${issueId}.md`); + // Render as a read-only markdown preview in editor group 2 — the same + // placement model as builder terminals (which target ViewColumn.Two). + // + // `markdown.showPreviewToSide` opens in ViewColumn.Beside, which is + // RELATIVE to the active editor group, not an absolute column. Without + // anchoring, the first click (sidebar focused → group 1 active) lands + // in group 2, but a second click while that preview is focused makes + // "Beside" resolve to group 3, then 4, … — a brand-new group per click. + // Focusing group 1 first pins "Beside" to group 2 every time, so issue + // previews consistently reuse a single group like the builder terminals. + await vscode.commands.executeCommand('workbench.action.focusFirstEditorGroup'); + await vscode.commands.executeCommand('markdown.showPreviewToSide', uri); +} diff --git a/packages/vscode/src/comments/plan-review.ts b/packages/vscode/src/comments/plan-review.ts new file mode 100644 index 000000000..3030237e8 --- /dev/null +++ b/packages/vscode/src/comments/plan-review.ts @@ -0,0 +1,182 @@ +/** + * Codev Plan Review — inline comments on plan/spec files via VSCode's + * Comments API. + * + * Hover any line in `codev/plans/*.md` or `codev/specs/*.md` → a "+" appears + * in the gutter → click → comment input field opens inline → submit → the + * comment is written into the file as `` + * on the next line. + * + * The HTML-comment serialization re-uses the existing REVIEW convention + * (codev/protocols/.../review.md, review-decorations.ts highlighting, + * Codev: Add Review Comment palette command). Builders read REVIEW + * markers when they re-read the plan and address them inline — no new + * storage layer, no separate protocol surface to teach. + * + * Existing inline REVIEW comments in a file render as collapsed comment + * threads when the file opens, so reviewers see prior notes as comment + * UI rather than raw HTML. + */ + +import * as vscode from 'vscode'; + +/** + * Matches the canonical inline form. Capture groups: + * [1] — author (the name inside @...) + * [2] — comment body text + * + * Tolerant of whitespace; mirrors the regex shape used elsewhere + * (review-decorations.ts, snippets/review.json). + */ +const REVIEW_COMMENT_PATTERN = //g; + +const ELIGIBLE_PATH_REGEX = /\/codev\/(plans|specs)\//; + +const CONTROLLER_ID = 'codev-review'; + +/** Tracks threads we created per document URI so we can refresh on edit. */ +const threadsByDoc = new Map(); + +export function activateReviewComments(context: vscode.ExtensionContext): void { + const controller = vscode.comments.createCommentController( + CONTROLLER_ID, + 'Codev Plan Review', + ); + context.subscriptions.push(controller); + + // Where the "+" appears. We accept any line in eligible files. + controller.commentingRangeProvider = { + provideCommentingRanges(document) { + if (!isEligibleDocument(document)) { return []; } + const lastLine = Math.max(0, document.lineCount - 1); + return [new vscode.Range(0, 0, lastLine, 0)]; + }, + }; + + function refreshDoc(document: vscode.TextDocument): void { + if (!isEligibleDocument(document)) { return; } + + // Tear down stale threads so we re-create from the current text. + const key = document.uri.toString(); + const existing = threadsByDoc.get(key) ?? []; + for (const t of existing) { t.dispose(); } + + const text = document.getText(); + const fresh: vscode.CommentThread[] = []; + let match: RegExpExecArray | null; + REVIEW_COMMENT_PATTERN.lastIndex = 0; + while ((match = REVIEW_COMMENT_PATTERN.exec(text)) !== null) { + const startPos = document.positionAt(match.index); + const thread = controller.createCommentThread( + document.uri, + new vscode.Range(startPos, startPos), + [ + { + body: new vscode.MarkdownString(match[2]), + mode: vscode.CommentMode.Preview, + author: { name: match[1] }, + // contextValue is matched in the comment-context menu's `when` + // clause so the delete button only shows on inline-sourced + // comments (not on new in-progress threads). + contextValue: 'inline-review', + }, + ], + ); + thread.collapsibleState = vscode.CommentThreadCollapsibleState.Expanded; + thread.canReply = false; + thread.contextValue = 'inline-review'; + fresh.push(thread); + } + threadsByDoc.set(key, fresh); + } + + // Render for already-open documents. + for (const doc of vscode.workspace.textDocuments) { refreshDoc(doc); } + + // Refresh on open/change/close. + context.subscriptions.push( + vscode.workspace.onDidOpenTextDocument(refreshDoc), + vscode.workspace.onDidChangeTextDocument(e => refreshDoc(e.document)), + vscode.workspace.onDidCloseTextDocument(doc => { + const key = doc.uri.toString(); + const threads = threadsByDoc.get(key); + if (threads) { + for (const t of threads) { t.dispose(); } + threadsByDoc.delete(key); + } + }), + ); + + // Command: submit a new review comment from an empty thread. + context.subscriptions.push( + vscode.commands.registerCommand( + 'codev.submitReviewComment', + async (reply: vscode.CommentReply) => { + await submitReviewComment(reply); + }, + ), + ); + + // Command: delete an inline review comment (removes the `` + // line from the file). + context.subscriptions.push( + vscode.commands.registerCommand( + 'codev.deleteReviewComment', + async (thread: vscode.CommentThread) => { + await deleteReviewCommentByThread(thread); + }, + ), + ); +} + +async function submitReviewComment(reply: vscode.CommentReply): Promise { + const thread = reply.thread; + if (!thread.range) { return; } + const document = await vscode.workspace.openTextDocument(thread.uri); + const line = thread.range.start.line; + const indent = document.lineAt(line).text.match(/^\s*/)?.[0] ?? ''; + // Normalize whitespace — review markers are single-line by convention, + // and review-decorations.ts assumes the marker fits one line. + const body = reply.text.replace(/\s+/g, ' ').trim(); + // Author hardcoded to "architect" to match the existing + // `codev.addReviewComment` palette command (commands/review.ts) and the + // review.json snippet — same on-disk format from every entry point. + const commentLine = `${indent}`; + + const edit = new vscode.WorkspaceEdit(); + edit.insert(thread.uri, new vscode.Position(line + 1, 0), commentLine + '\n'); + await vscode.workspace.applyEdit(edit); + await document.save(); + + // The change event fires refreshDoc, which disposes the in-progress + // thread (it's currently in threadsByDoc as a placeholder) and re-creates + // the canonical thread from the new inline marker. + thread.dispose(); +} + +async function deleteReviewCommentByThread(thread: vscode.CommentThread): Promise { + if (!thread.range) { return; } + const document = await vscode.workspace.openTextDocument(thread.uri); + const line = thread.range.start.line; + if (line >= document.lineCount) { return; } + + // Re-confirm the line is a REVIEW marker (it could have been edited + // out manually between the user clicking the menu and this handler). + const lineText = document.lineAt(line).text; + if (!/^\s*/.test(lineText)) { return; } + + const edit = new vscode.WorkspaceEdit(); + // Delete the whole line including its trailing newline. + const end = line + 1 < document.lineCount + ? new vscode.Position(line + 1, 0) + : new vscode.Position(line, lineText.length); + edit.delete(thread.uri, new vscode.Range(new vscode.Position(line, 0), end)); + await vscode.workspace.applyEdit(edit); + await document.save(); +} + +function isEligibleDocument(doc: vscode.TextDocument): boolean { + if (doc.languageId !== 'markdown') { return false; } + const fsPath = doc.uri.fsPath.replace(/\\/g, '/'); + return ELIGIBLE_PATH_REGEX.test(fsPath); +} diff --git a/packages/vscode/src/extension.ts b/packages/vscode/src/extension.ts index a5122bca5..902fdce09 100644 --- a/packages/vscode/src/extension.ts +++ b/packages/vscode/src/extension.ts @@ -6,18 +6,22 @@ import { spawnBuilder } from './commands/spawn.js'; import { sendMessage } from './commands/send.js'; import { approveGate } from './commands/approve.js'; import { cleanupBuilder } from './commands/cleanup.js'; -import { reviewDiff } from './commands/review-diff.js'; +import { openWorktreeWindow } from './commands/open-worktree-window.js'; import { runWorktreeDev } from './commands/run-worktree-dev.js'; import { stopWorktreeDev } from './commands/stop-worktree-dev.js'; +import { runWorkspaceDev, stopWorkspaceDev } from './commands/run-workspace-dev.js'; import { openWorktreeFolder } from './commands/open-worktree-folder.js'; import { runWorktreeSetup } from './commands/run-worktree-setup.js'; +import { viewPlanFile } from './commands/view-artifact.js'; +import { activateIssueView, viewBacklogIssue } from './commands/view-issue.js'; import { connectTunnel, disconnectTunnel } from './commands/tunnel.js'; import { listCronTasks } from './commands/cron.js'; import { addReviewComment } from './commands/review.js'; +import { activateGateToasts } from './notifications/gate-toast.js'; import { activateReviewDecorations } from './review-decorations.js'; +import { activateReviewComments } from './comments/plan-review.js'; import { BuilderSpawnHandler } from './builder-spawn-handler.js'; import { BuilderTerminalLinkProvider } from './terminal-link-provider.js'; -import { NeedsAttentionProvider } from './views/needs-attention.js'; import { BuildersProvider } from './views/builders.js'; import { PullRequestsProvider } from './views/pull-requests.js'; import { BacklogProvider } from './views/backlog.js'; @@ -26,6 +30,7 @@ import { TeamProvider } from './views/team.js'; import { StatusProvider } from './views/status.js'; import { WorkspaceProvider } from './views/workspace.js'; import { BuilderTreeItem } from './views/builder-tree-item.js'; +import { BacklogTreeItem } from './views/backlog-tree-item.js'; let connectionManager: ConnectionManager | null = null; let terminalManager: TerminalManager | null = null; @@ -45,6 +50,20 @@ function extractBuilderId(arg: vscode.TreeItem | string | undefined): string | u return undefined; } +/** + * Resolve an issue number from a command argument. + * + * Backlog row-click passes the issue id as a string via + * `item.command.arguments`; right-click context-menu invocations pass the + * BacklogTreeItem itself; command-palette invocations pass nothing → + * undefined → spawnBuilder falls back to its full quick-pick flow. + */ +function extractIssueId(arg: vscode.TreeItem | string | undefined): string | undefined { + if (typeof arg === 'string') { return arg; } + if (arg instanceof BacklogTreeItem) { return arg.issueId; } + return undefined; +} + export async function activate(context: vscode.ExtensionContext) { // Output Channel for diagnostics outputChannel = vscode.window.createOutputChannel('Codev'); @@ -84,8 +103,12 @@ export async function activate(context: vscode.ExtensionContext) { } }); + // OverviewCache is created before TerminalManager so it can be injected — + // TerminalManager uses it for friendly builder tab titles (`Codev: # `). + const overviewCache = new OverviewCache(connectionManager); + // Terminal Manager - terminalManager = new TerminalManager(connectionManager, outputChannel, context.extensionUri); + terminalManager = new TerminalManager(connectionManager, outputChannel, context.extensionUri, overviewCache); context.subscriptions.push({ dispose: () => terminalManager?.dispose() }); // Update status bar with builder/gate counts @@ -100,22 +123,129 @@ export async function activate(context: vscode.ExtensionContext) { : `$(server) Codev: ${builderCount} builders`; }; - // Sidebar TreeViews - const overviewCache = new OverviewCache(connectionManager); + // List views show their item count in the title: "Builders (3)". + // createTreeView (not registerTreeDataProvider) is required to get a + // settable .title. When there's no data yet (disconnected/loading) the + // title falls back to the plain base name — no misleading "(0)". + let buildersView: vscode.TreeView<vscode.TreeItem> | undefined; + let pullRequestsView: vscode.TreeView<vscode.TreeItem> | undefined; + let backlogView: vscode.TreeView<vscode.TreeItem> | undefined; + let recentlyClosedView: vscode.TreeView<vscode.TreeItem> | undefined; + const updateListViewTitles = () => { + const data = overviewCache.getData(); + const withCount = (base: string, n: number | undefined) => + typeof n === 'number' ? `${base} (${n})` : base; + if (buildersView) { buildersView.title = withCount('Builders', data?.builders.length); } + if (pullRequestsView) { pullRequestsView.title = withCount('Pull Requests', data?.pendingPRs.length); } + if (backlogView) { backlogView.title = withCount('Backlog', data?.backlog.length); } + if (recentlyClosedView) { recentlyClosedView.title = withCount('Recently Closed', data?.recentlyClosed.length); } + }; + + // Close builder/dev terminal tabs when their builder disappears from Tower + // state. Covers cleanup triggered from the VSCode "Cleanup Builder" command, + // `afx cleanup` on the CLI, or any other removal path — otherwise Tower + // kills the PTY but the VSCode tab lingers as a dead "Process exited" entry. + // Uses a present→absent diff so freshly-spawned builders whose first state + // refresh hasn't landed aren't pre-emptively closed; the inFlight guard + // drops overlapping state fetches so a stale response can't overwrite a + // fresher prevBuilderIds. + let prevBuilderIds: Set<string> | null = null; + let pruneInFlight = false; + const pruneClosedBuilderTerminals = async () => { + if (pruneInFlight) { return; } + if (connectionManager?.getState() !== 'connected') { return; } + const client = connectionManager.getClient(); + const workspacePath = connectionManager.getWorkspacePath(); + if (!client || !workspacePath) { return; } + pruneInFlight = true; + try { + const state = await client.getWorkspaceState(workspacePath); + if (!state?.builders) { return; } + const currIds = new Set(state.builders.map(b => b.id)); + if (prevBuilderIds !== null) { + for (const prev of prevBuilderIds) { + if (!currIds.has(prev)) { + terminalManager?.closeBuilderTerminal(prev); + } + } + } + prevBuilderIds = currIds; + } catch { + // Transient state-fetch failures must not drop prevBuilderIds — + // next successful tick will resync. + } finally { + pruneInFlight = false; + } + }; + + // Sidebar TreeViews (overviewCache created above, before TerminalManager) context.subscriptions.push({ dispose: () => overviewCache.dispose() }); - overviewCache.onDidChange(updateStatusBarCounts); + overviewCache.onDidChange(() => { + updateStatusBarCounts(); + pruneClosedBuilderTerminals(); + updateListViewTitles(); + }); + // List views use createTreeView so their title can carry a live item + // count; the rest stay on registerTreeDataProvider. + buildersView = vscode.window.createTreeView('codev.builders', { treeDataProvider: new BuildersProvider(overviewCache) }); + pullRequestsView = vscode.window.createTreeView('codev.pullRequests', { treeDataProvider: new PullRequestsProvider(overviewCache) }); + backlogView = vscode.window.createTreeView('codev.backlog', { treeDataProvider: new BacklogProvider(overviewCache) }); + recentlyClosedView = vscode.window.createTreeView('codev.recentlyClosed', { treeDataProvider: new RecentlyClosedProvider(overviewCache) }); + const teamProvider = new TeamProvider(connectionManager); context.subscriptions.push( - vscode.window.registerTreeDataProvider('codev.workspace', new WorkspaceProvider(connectionManager)), - vscode.window.registerTreeDataProvider('codev.needsAttention', new NeedsAttentionProvider(overviewCache)), - vscode.window.registerTreeDataProvider('codev.builders', new BuildersProvider(overviewCache)), - vscode.window.registerTreeDataProvider('codev.pullRequests', new PullRequestsProvider(overviewCache)), - vscode.window.registerTreeDataProvider('codev.backlog', new BacklogProvider(overviewCache)), - vscode.window.registerTreeDataProvider('codev.recentlyClosed', new RecentlyClosedProvider(overviewCache)), - vscode.window.registerTreeDataProvider('codev.team', new TeamProvider(connectionManager)), + buildersView, + pullRequestsView, + backlogView, + recentlyClosedView, + vscode.window.registerTreeDataProvider('codev.workspace', new WorkspaceProvider(connectionManager, terminalManager!)), + vscode.window.registerTreeDataProvider('codev.team', teamProvider), vscode.window.registerTreeDataProvider('codev.status', new StatusProvider(connectionManager)), ); + // Periodic overview refresh. VSCode has no timer-based refresh (event-only), + // so an idle workspace never sees externally-merged PRs / new issues. Mirror + // the dashboard's poll idiom: refresh on a cadence while the Codev sidebar is + // visible, paused when it isn't. The shared Tower-side 30s cache throttles gh + // cost across windows; refresh() is last-write-wins so periodic + event-driven + // refreshes coexist without flicker. + const setupPeriodicOverviewRefresh = () => { + let timer: ReturnType<typeof setInterval> | undefined; + const readIntervalSeconds = (): number => { + const s = vscode.workspace.getConfiguration('codev').get<number>('overviewRefreshSeconds', 60); + return typeof s === 'number' && Number.isFinite(s) && s > 0 ? s : 0; + }; + const anyVisible = (): boolean => + !!buildersView?.visible || !!pullRequestsView?.visible + || !!backlogView?.visible || !!recentlyClosedView?.visible; + const stop = () => { + if (timer) { clearInterval(timer); timer = undefined; } + }; + const reconcile = () => { + const seconds = readIntervalSeconds(); + if (seconds === 0 || !anyVisible()) { stop(); return; } + if (!timer) { + timer = setInterval(() => { + if (connectionManager?.getState() === 'connected') { void overviewCache.refresh(); } + }, seconds * 1000); + void overviewCache.refresh(); // resume → immediate refresh + } + }; + + context.subscriptions.push( + buildersView!.onDidChangeVisibility(reconcile), + pullRequestsView!.onDidChangeVisibility(reconcile), + backlogView!.onDidChangeVisibility(reconcile), + recentlyClosedView!.onDidChangeVisibility(reconcile), + vscode.workspace.onDidChangeConfiguration(e => { + if (e.affectsConfiguration('codev.overviewRefreshSeconds')) { stop(); reconcile(); } + }), + { dispose: stop }, + ); + reconcile(); + }; + setupPeriodicOverviewRefresh(); + // Refresh overview on connect + set team visibility connectionManager.onStateChange(async (state) => { if (state === 'connected') { @@ -204,20 +334,42 @@ export async function activate(context: vscode.ExtensionContext) { if (!roleOrId) { return; } await terminalManager?.openBuilderByRoleOrId(roleOrId, true); }), - vscode.commands.registerCommand('codev.spawnBuilder', () => spawnBuilder()), + vscode.commands.registerCommand('codev.spawnBuilder', (arg: vscode.TreeItem | string | undefined) => + spawnBuilder(extractIssueId(arg))), + vscode.commands.registerCommand('codev.openBacklogIssue', (arg: vscode.TreeItem | undefined) => { + if (arg instanceof BacklogTreeItem) { + void vscode.env.openExternal(vscode.Uri.parse(arg.issueUrl)); + } + }), + vscode.commands.registerCommand('codev.copyBacklogIssueNumber', async (arg: vscode.TreeItem | undefined) => { + if (arg instanceof BacklogTreeItem) { + await vscode.env.clipboard.writeText(`#${arg.issueId}`); + vscode.window.showInformationMessage(`Codev: Copied #${arg.issueId}`); + } + }), + vscode.commands.registerCommand('codev.viewBacklogIssue', (arg: vscode.TreeItem | string | undefined) => + viewBacklogIssue(connectionManager!, extractIssueId(arg))), vscode.commands.registerCommand('codev.sendMessage', () => sendMessage(connectionManager!)), - vscode.commands.registerCommand('codev.approveGate', () => approveGate(connectionManager!)), - vscode.commands.registerCommand('codev.cleanupBuilder', () => cleanupBuilder(connectionManager!)), - vscode.commands.registerCommand('codev.reviewDiff', (arg: vscode.TreeItem | string | undefined) => - reviewDiff(connectionManager!, extractBuilderId(arg))), + vscode.commands.registerCommand('codev.approveGate', (arg: vscode.TreeItem | string | undefined, options?: { skipConfirmation?: boolean }) => + approveGate(connectionManager!, overviewCache, extractBuilderId(arg), options)), + vscode.commands.registerCommand('codev.cleanupBuilder', () => cleanupBuilder(connectionManager!, overviewCache)), + vscode.commands.registerCommand('codev.openWorktreeWindow', (arg: vscode.TreeItem | string | undefined) => + openWorktreeWindow(connectionManager!, extractBuilderId(arg))), vscode.commands.registerCommand('codev.runWorktreeDev', (arg: vscode.TreeItem | string | undefined) => runWorktreeDev(connectionManager!, terminalManager!, extractBuilderId(arg))), vscode.commands.registerCommand('codev.stopWorktreeDev', () => stopWorktreeDev(connectionManager!, terminalManager!)), + vscode.commands.registerCommand('codev.runWorkspaceDev', () => + runWorkspaceDev(connectionManager!, terminalManager!)), + vscode.commands.registerCommand('codev.stopWorkspaceDev', () => + stopWorkspaceDev(connectionManager!, terminalManager!)), + vscode.commands.registerCommand('codev.refreshTeam', () => teamProvider.refresh()), vscode.commands.registerCommand('codev.openWorktreeFolder', (arg: vscode.TreeItem | string | undefined) => openWorktreeFolder(connectionManager!, extractBuilderId(arg))), vscode.commands.registerCommand('codev.runWorktreeSetup', (arg: vscode.TreeItem | string | undefined) => runWorktreeSetup(connectionManager!, extractBuilderId(arg))), + vscode.commands.registerCommand('codev.viewPlanFile', (arg: vscode.TreeItem | string | undefined) => + viewPlanFile(connectionManager!, extractBuilderId(arg))), vscode.commands.registerCommand('codev.refreshOverview', () => overviewCache.refresh()), vscode.commands.registerCommand('codev.reconnect', () => connectionManager?.reconnect()), vscode.commands.registerCommand('codev.connectTunnel', () => connectTunnel(connectionManager!)), @@ -226,9 +378,23 @@ export async function activate(context: vscode.ExtensionContext) { vscode.commands.registerCommand('codev.addReviewComment', () => addReviewComment()), ); + // Read-only `codev-issue:` content provider backing the "View Issue" + // backlog action — renders issue body + comments as markdown preview. + activateIssueView(context); + // Review comment decorations activateReviewDecorations(context); + // Inline plan-review comments via VSCode Comments API. Gutter "+" on + // any line in codev/plans/*.md or codev/specs/*.md; submit writes + // `<!-- REVIEW(@architect): ... -->` inline, matching the format + // produced by `codev.addReviewComment` and review.json snippet. + activateReviewComments(context); + + // Toast on new gate-pending — surfaces blocked builders without forcing the + // user to watch the Builders tree. Respects `codev.gateToasts.enabled`. + activateGateToasts(context, overviewCache); + // Auto-open builder terminals on Tower spawn events const builderSpawnHandler = new BuilderSpawnHandler(connectionManager, terminalManager, outputChannel); context.subscriptions.push( diff --git a/packages/vscode/src/notifications/gate-toast.ts b/packages/vscode/src/notifications/gate-toast.ts new file mode 100644 index 000000000..48e94f62b --- /dev/null +++ b/packages/vscode/src/notifications/gate-toast.ts @@ -0,0 +1,143 @@ +import * as vscode from 'vscode'; +import type { OverviewCache } from '../views/overview-data.js'; + +/** + * Toast notifications for newly blocked builders. + * + * Subscribes to OverviewCache changes. Whenever a builder appears in the + * blocked-set for the first time (or its gate name changes), fires an + * `showInformationMessage` toast with two action buttons: + * + * 1. A per-gate "inspection" button from `GATE_ACTIONS`: + * plan-approval → "View Plan" — opens codev/plans/<id>-*.md + * dev-approval → "Run Dev" — starts the worktree's dev PTY + * other gates → "Review" — opens the builder's terminal pane + * 2. "Approve" — commits to porch approve directly via + * `codev.approveGate` with `{ skipConfirmation: true }`. The toast + * is the context; surfacing a second confirmation here would be + * friction without value. + * + * A `(builderId, gateName)` seen-set is kept in module state so we never + * re-toast the same blocked state on subsequent cache ticks. The seen-set + * is pruned when an entry leaves the blocked set (gate approved or builder + * advances) so that re-blocking later (on a different gate) will re-toast. + * + * Respects the `codev.gateToasts.enabled` setting (default: true). Set to + * false to silence; status bar counters and the Builders tree remain + * unaffected. + */ +export function activateGateToasts( + context: vscode.ExtensionContext, + cache: OverviewCache, +): void { + // Track (builderId, gateName) pairs we've already toasted for. Persisted to + // workspaceState so a builder that stays blocked on the same gate doesn't + // re-toast on every window reload / extension reactivation / Tower reconnect + // (the set is otherwise closure state that resets each activation). Pruning + // still removes keys once a builder leaves the blocked set, so a genuine + // re-block later re-toasts. + const SEEN_KEY = 'codev.gateToasts.seen'; + const seen = new Set<string>(context.workspaceState.get<string[]>(SEEN_KEY, [])); + const persist = () => context.workspaceState.update(SEEN_KEY, [...seen]); + + const onChange = () => { + const enabled = vscode.workspace + .getConfiguration('codev') + .get<boolean>('gateToasts.enabled', true); + if (!enabled) { + return; + } + + const data = cache.getData(); + if (!data) { + return; + } + + const currentBlocked = new Set<string>(); + let changed = false; + for (const b of data.builders) { + if (!b.blocked || !b.blockedGate) { + continue; + } + // Track and lookup keys use the CANONICAL gate name (b.blockedGate, + // e.g. "dev-approval"). The display label (b.blocked, e.g. "dev + // review") is for human-facing text only — using it as a lookup key + // breaks GATE_ACTIONS since the map's keys are canonical names. + const key = `${b.id}::${b.blockedGate}`; + currentBlocked.add(key); + if (!seen.has(key)) { + seen.add(key); + changed = true; + showGateToast(b.id, b.blockedGate, b.blocked, b.issueId, b.issueTitle); + } + } + + // Prune entries that are no longer blocked so we re-toast on future blocks. + for (const key of [...seen]) { + if (!currentBlocked.has(key)) { + seen.delete(key); + changed = true; + } + } + + if (changed) { + persist(); + } + }; + + context.subscriptions.push(cache.onDidChange(onChange)); +} + +/** + * Per-gate action mapping for the toast's single button. + * + * plan-approval → "View Plan" opens the plan markdown directly + * dev-approval → "Run Dev" starts the dev PTY for the worktree + * other gates → "Review" opens the builder's terminal pane + * + * The plan/dev mappings match the gate's most-useful artifact (plan-approval + * reviews the plan file; dev-approval tests the running code). Anything + * else falls back to the builder pane — gates without a single obvious + * artifact (spec-approval, code-review, pr) are best handled by typing + * feedback to the interactive Claude that just announced the gate. + */ +const GATE_ACTIONS: Record<string, { label: string; command: string }> = { + 'plan-approval': { label: 'View Plan', command: 'codev.viewPlanFile' }, + 'dev-approval': { label: 'Run Dev', command: 'codev.runWorktreeDev' }, +}; + +function showGateToast( + builderId: string, + gateName: string, // canonical key for GATE_ACTIONS lookup, e.g. "dev-approval" + gateLabel: string, // human-facing label for the toast text, e.g. "dev review" + issueId?: string | number | null, + issueTitle?: string | null, +): void { + const label = issueId ? `#${issueId}` : builderId; + // Quote the issue title so a title that reads like an error + // (e.g. "agent/reset returns 504 GATEWAY_TIMEOUT…") is visibly a subject, + // not a failure the toast is reporting. + const titleSuffix = issueTitle ? ` — “${truncate(issueTitle, 50)}”` : ''; + const message = `Codev: ${label} blocked on ${gateLabel}${titleSuffix}`; + + const action = GATE_ACTIONS[gateName] ?? { label: 'Review', command: 'codev.openBuilderById' }; + + // Two-button toast: [<artifact-specific action>] [Approve]. + // The toast itself is the context — clicking Approve here skips the + // rich confirmation dialog (approve.ts's normal path). The reviewer + // chose to act on the toast directly; surfacing a second confirmation + // would be friction without value. + vscode.window + .showInformationMessage(message, action.label, 'Approve') + .then((selection) => { + if (selection === action.label) { + vscode.commands.executeCommand(action.command, builderId); + } else if (selection === 'Approve') { + vscode.commands.executeCommand('codev.approveGate', builderId, { skipConfirmation: true }); + } + }); +} + +function truncate(s: string, max: number): string { + return s.length > max ? `${s.slice(0, max - 1)}…` : s; +} diff --git a/packages/vscode/src/terminal-adapter.ts b/packages/vscode/src/terminal-adapter.ts index 09bfb0e7a..5f703dc8c 100644 --- a/packages/vscode/src/terminal-adapter.ts +++ b/packages/vscode/src/terminal-adapter.ts @@ -25,6 +25,11 @@ export class CodevPseudoterminal implements vscode.Pseudoterminal { private lastSeq = 0; private replaying = false; private pendingResize: { cols: number; rows: number } | null = null; + // Latest dimensions VSCode has told us about. Seeded from open()'s + // initialDimensions and refreshed on every setDimensions(). Re-sent after + // every WS auth so Tower's PTY isn't stuck at node-pty's 80×24 default + // until the user happens to manually resize the panel (Bugfix #737). + private lastDimensions: { cols: number; rows: number } | null = null; private queuedBytes = 0; private disposed = false; @@ -34,7 +39,10 @@ export class CodevPseudoterminal implements vscode.Pseudoterminal { private outputChannel: vscode.OutputChannel, ) {} - open(_initialDimensions: vscode.TerminalDimensions | undefined): void { + open(initialDimensions: vscode.TerminalDimensions | undefined): void { + if (initialDimensions) { + this.lastDimensions = { cols: initialDimensions.columns, rows: initialDimensions.rows }; + } // Prime the renderer synchronously inside open() — VS Code drops or // mis-orders writes that arrive purely asynchronously after open() // when the terminal becomes the active editor (microsoft/vscode#108298), @@ -64,6 +72,7 @@ export class CodevPseudoterminal implements vscode.Pseudoterminal { } setDimensions(dimensions: vscode.TerminalDimensions): void { + this.lastDimensions = { cols: dimensions.columns, rows: dimensions.rows }; if (this.replaying) { // Defer resize during replay to prevent garbled rendering (Bugfix #625) this.pendingResize = { cols: dimensions.columns, rows: dimensions.rows }; @@ -87,6 +96,14 @@ export class CodevPseudoterminal implements vscode.Pseudoterminal { if (this.authKey) { this.sendControl({ type: 'ping', payload: { auth: this.authKey } }); } + // Sync Tower's PTY to the dimensions VSCode reported. Without this, + // the PTY stays at node-pty's 80×24 default until a manual resize, + // which makes Claude Code's TUI render its input box mid-screen and + // overlap streaming content. Pause/replay messages arrive *after* + // this outbound resize, so the order is auth → resize → replay → resume. + if (this.lastDimensions) { + this.sendResize(this.lastDimensions.cols, this.lastDimensions.rows); + } }); this.ws.on('message', (raw: ArrayBuffer) => { diff --git a/packages/vscode/src/terminal-manager.ts b/packages/vscode/src/terminal-manager.ts index cd8181185..1b0a120e1 100644 --- a/packages/vscode/src/terminal-manager.ts +++ b/packages/vscode/src/terminal-manager.ts @@ -1,6 +1,7 @@ import * as vscode from 'vscode'; import { CodevPseudoterminal } from './terminal-adapter.js'; import type { ConnectionManager } from './connection-manager.js'; +import type { OverviewCache } from './views/overview-data.js'; import { encodeWorkspacePath } from '@cluesmith/codev-core/workspace'; import { resolveAgentName } from '@cluesmith/codev-core/agent-names'; import type { TerminalType } from '@cluesmith/codev-core/tower-client'; @@ -23,14 +24,20 @@ export class TerminalManager { private outputChannel: vscode.OutputChannel; private connectionManager: ConnectionManager; private readonly iconPath: { light: vscode.Uri; dark: vscode.Uri }; + private readonly _onDidChangeDevTerminals = new vscode.EventEmitter<void>(); + /** Fires whenever the set of open dev terminals changes (start/stop/swap/cleanup). */ + readonly onDidChangeDevTerminals = this._onDidChangeDevTerminals.event; + private readonly overviewCache: OverviewCache; constructor( connectionManager: ConnectionManager, outputChannel: vscode.OutputChannel, extensionUri: vscode.Uri, + overviewCache: OverviewCache, ) { this.connectionManager = connectionManager; this.outputChannel = outputChannel; + this.overviewCache = overviewCache; // Theme-aware icon pair. The single-Uri form rendered as solid black on // dark themes (VSCode doesn't resolve currentColor on terminal-tab icons). // codev-light.svg has dark fill (visible on light themes), codev-dark.svg @@ -41,6 +48,46 @@ export class TerminalManager { }; } + /** + * Friendly builder tab title — mirrors the sidebar's `#<issueId> <issueTitle>`, + * prefixed `Codev: `. Falls back to `fallback` (the canonical + * `Codev: <agent-name>`) whenever overview data, a builder match, or an + * issue number is unavailable, so the title is never broken or empty. + * + * Display-only: terminal identity, cleanup, and click-to-focus all key off + * the `builder-<id>` map key and Terminal object — never the tab title — so + * changing this string has no functional side effects. + */ + private friendlyBuilderLabel(builderId: string, fallback: string): string { + const data = this.overviewCache.getData(); + if (!data?.builders?.length) { return fallback; } + // Open paths pass the canonical roleId (`builder-<protocol>-<id>`), but + // OverviewCache builders carry the short id the sidebar uses. resolveAgentName + // matches a *short* target against canonical candidates, so feed it the + // trailing id token, not the full roleId (otherwise it never matches and + // every builder falls back to the agent name). + const shortId = builderId.split('-').pop() ?? builderId; + const { builder } = resolveAgentName(shortId, data.builders); + // Mirror the sidebar exactly: `#${issueId ?? id} ${issueTitle ?? ''}`. + const num = builder?.issueId ?? builder?.id; + if (num === undefined || num === null || num === '') { return fallback; } + // Bound the tab name: `#<id>` stays whole (short, identifying), but the + // issue title is capped — VSCode's default `tabSizing: 'fit'` does not + // ellipsize a lone wide tab, so an unbounded title spans the whole group. + const MAX_TITLE = 25; + const raw = (builder?.issueTitle ?? '').trim(); + let title = raw; + if (raw.length > MAX_TITLE) { + const slice = raw.slice(0, MAX_TITLE); + const lastSpace = slice.lastIndexOf(' '); + // Cut at the last whole word; fall back to a hard cut when the first + // word alone already exceeds the cap (no usable space boundary). + const cut = lastSpace > 0 ? slice.slice(0, lastSpace) : slice.slice(0, MAX_TITLE - 1); + title = `${cut.trimEnd()}…`; + } + return `Codev: #${num}${title ? ` ${title}` : ''}`; + } + /** * Open the architect terminal. `focus` defaults to false so background * paths don't steal focus; click paths pass true. @@ -78,7 +125,7 @@ export class TerminalManager { existing.terminal.dispose(); this.terminals.delete(key); } - await this.openTerminal(terminalId, 'builder', label, key, focus); + await this.openTerminal(terminalId, 'builder', this.friendlyBuilderLabel(builderId, label), key, focus); } /** @@ -142,6 +189,7 @@ export class TerminalManager { // Tab title matches the builder-tab format (`Codev: <name>`) with a // `(dev)` suffix so the pairing is obvious in the tab strip. await this.openTerminal(terminalId, 'dev', `Codev: ${builderName} (dev)`, key, focus); + this._onDidChangeDevTerminals.fire(); } /** @@ -156,6 +204,28 @@ export class TerminalManager { existing.pty.close(); existing.terminal.dispose(); this.terminals.delete(key); + this._onDidChangeDevTerminals.fire(); + } + + /** + * Dispose the VSCode terminal tabs for a builder — both the AI terminal + * and any companion dev-server terminal — after the builder has been + * cleaned up. Tower kills the PTYs as part of cleanup, so without this + * the user sees a stale "Process exited" tab until they close it + * manually. Accepts the canonical builder roleId (e.g. `builder-spir-109`), + * matching the value passed to `openBuilder`. + */ + closeBuilderTerminal(builderId: string): void { + let devClosed = false; + for (const key of [`builder-${builderId}`, `dev-${builderId}`]) { + const existing = this.terminals.get(key); + if (!existing) { continue; } + existing.pty.close(); + existing.terminal.dispose(); + this.terminals.delete(key); + if (key.startsWith('dev-')) { devClosed = true; } + } + if (devClosed) { this._onDidChangeDevTerminals.fire(); } } /** @@ -214,9 +284,19 @@ export class TerminalManager { const authKey = await this.getAuthKey(); const pty = new CodevPseudoterminal(wsUrl, authKey, this.outputChannel); const position = vscode.workspace.getConfiguration('codev').get<string>('terminalPosition', 'editor'); - const location = position === 'editor' - ? { viewColumn: type === 'architect' ? vscode.ViewColumn.One : vscode.ViewColumn.Two } - : vscode.TerminalLocation.Panel; + + // Dev servers are long-running background logs — always the bottom panel, + // regardless of the `codev.terminalPosition` setting (which governs the + // architect/builder/shell terminals: architect → editor group 1, the + // rest → group 2). + let location: vscode.TerminalLocation | vscode.TerminalEditorLocationOptions; + if (type === 'dev' || position !== 'editor') { + location = vscode.TerminalLocation.Panel; + } else if (type === 'architect') { + location = { viewColumn: vscode.ViewColumn.One }; + } else { + location = { viewColumn: vscode.ViewColumn.Two }; + } const terminal = vscode.window.createTerminal({ name, pty, location, iconPath: this.iconPath }); @@ -269,5 +349,6 @@ export class TerminalManager { managed.terminal.dispose(); } this.terminals.clear(); + this._onDidChangeDevTerminals.dispose(); } } diff --git a/packages/vscode/src/views/backlog-tree-item.ts b/packages/vscode/src/views/backlog-tree-item.ts new file mode 100644 index 000000000..7f378c3eb --- /dev/null +++ b/packages/vscode/src/views/backlog-tree-item.ts @@ -0,0 +1,24 @@ +import * as vscode from 'vscode'; + +/** + * TreeItem subclass that carries a backlog issue's id and URL as typed fields. + * + * Why: VSCode passes the tree item itself (not its `command.arguments`) + * to commands invoked from `view/item/context` menus. The backlog + * context-menu commands (codev.spawnBuilder, codev.openBacklogIssue, + * codev.copyBacklogIssueNumber) need to know which issue was + * right-clicked, so BacklogProvider constructs rows with this class and + * the command handlers narrow via `instanceof BacklogTreeItem` to read + * `.issueId` / `.issueUrl` safely. + * + * Used by views/backlog.ts. + */ +export class BacklogTreeItem extends vscode.TreeItem { + constructor( + public readonly issueId: string, + public readonly issueUrl: string, + label: string, + ) { + super(label); + } +} diff --git a/packages/vscode/src/views/backlog.ts b/packages/vscode/src/views/backlog.ts index 57d3051e0..217620a34 100644 --- a/packages/vscode/src/views/backlog.ts +++ b/packages/vscode/src/views/backlog.ts @@ -1,6 +1,19 @@ import * as vscode from 'vscode'; +import type { OverviewBacklogItem } from '@cluesmith/codev-types'; import type { OverviewCache } from './overview-data.js'; +import { BacklogTreeItem } from './backlog-tree-item.js'; +/** + * Backlog view: open GitHub issues with no PR yet. Issues assigned to the + * current user (auto-detected via OverviewData.currentUser) sort to the + * top with an `account` icon; the rest keep `issues`. Order within each + * group preserves Tower's order. Mirrors how BuildersProvider sorts + * blocked-first above active within a single view — no separator rows. + * + * Row click starts work: it invokes codev.spawnBuilder with the issue + * number pre-filled (protocol-pick only). Browser / copy actions live in + * the right-click context menu (see package.json view/item/context). + */ export class BacklogProvider implements vscode.TreeDataProvider<vscode.TreeItem> { private readonly changeEmitter = new vscode.EventEmitter<void>(); readonly onDidChangeTreeData = this.changeEmitter.event; @@ -17,16 +30,25 @@ export class BacklogProvider implements vscode.TreeDataProvider<vscode.TreeItem> const data = this.cache.getData(); if (!data) { return []; } - return data.backlog.map(item => { + const me = data.currentUser?.toLowerCase(); + const isMine = (item: OverviewBacklogItem) => + !!me && !!item.assignees?.some(a => a.toLowerCase() === me); + + const mine = data.backlog.filter(isMine); + const rest = data.backlog.filter(item => !isMine(item)); + + return [...mine, ...rest].map(item => { + const assigned = mine.includes(item); const author = item.author ? ` @${item.author}` : ''; - const ti = new vscode.TreeItem(`#${item.id} ${item.title}${author}`); + const ti = new BacklogTreeItem(item.id, item.url, `#${item.id} ${item.title}${author}`); ti.tooltip = item.url; ti.contextValue = 'backlog-item'; - ti.iconPath = new vscode.ThemeIcon('issues'); + ti.iconPath = new vscode.ThemeIcon(assigned ? 'account' : 'issues'); + if (assigned) { ti.description = 'assigned to you'; } ti.command = { - command: 'vscode.open', - title: 'Open in Browser', - arguments: [vscode.Uri.parse(item.url)], + command: 'codev.viewBacklogIssue', + title: 'View Issue', + arguments: [item.id], }; return ti; }); diff --git a/packages/vscode/src/views/builder-tree-item.ts b/packages/vscode/src/views/builder-tree-item.ts index d8affe0d3..a83105d47 100644 --- a/packages/vscode/src/views/builder-tree-item.ts +++ b/packages/vscode/src/views/builder-tree-item.ts @@ -4,13 +4,14 @@ import * as vscode from 'vscode'; * TreeItem subclass that carries a builder id as a typed field. * * Why: VSCode passes the tree item itself (not its `command.arguments`) - * to commands invoked from `view/item/context` menus. The new #690 - * commands (codev.reviewDiff, codev.runWorktreeDev, codev.stopWorktreeDev) + * to commands invoked from `view/item/context` menus. Builder-scoped + * commands (codev.openWorktreeWindow, codev.runWorktreeDev, + * codev.stopWorktreeDev, codev.viewPlanFile, codev.approveGate, etc.) * need to know which builder was right-clicked, so the views construct * builder rows with this class and the command handlers narrow via * `instanceof BuilderTreeItem` to read `.builderId` safely. * - * Used by views/builders.ts and views/needs-attention.ts. + * Used by views/builders.ts. */ export class BuilderTreeItem extends vscode.TreeItem { constructor( diff --git a/packages/vscode/src/views/builders.ts b/packages/vscode/src/views/builders.ts index bb69163d5..28c4f5ca3 100644 --- a/packages/vscode/src/views/builders.ts +++ b/packages/vscode/src/views/builders.ts @@ -1,7 +1,30 @@ import * as vscode from 'vscode'; +import type { OverviewBuilder } from '@cluesmith/codev-types'; import type { OverviewCache } from './overview-data.js'; import { BuilderTreeItem } from './builder-tree-item.js'; +/** + * Order builders for the Builders tree: blocked first with the longest- + * waiting at the top, then active builders in overview order. Builders + * with no recorded `blockedSince` sort last within the blocked group + * (we don't pretend to know their wait time). + */ +function orderForDisplay(builders: OverviewBuilder[]): OverviewBuilder[] { + const ms = (iso: string | null) => iso ? new Date(iso).getTime() : Infinity; + const blocked = builders + .filter(b => b.blocked) + .sort((a, b) => ms(a.blockedSince) - ms(b.blockedSince)); + const active = builders.filter(b => !b.blocked); + return [...blocked, ...active]; +} + +/** + * Unified Builders view. Blocked builders sort to the top with a bell icon + * and a wait-time suffix; active builders sit below with a play icon. + * Replaces the previous split between a Needs Attention tree (blocked only) + * and a Builders tree (everything) — the duplication caused more noise than + * the at-a-glance triage was worth. + */ export class BuildersProvider implements vscode.TreeDataProvider<vscode.TreeItem> { private readonly changeEmitter = new vscode.EventEmitter<void>(); readonly onDidChangeTreeData = this.changeEmitter.event; @@ -18,13 +41,21 @@ export class BuildersProvider implements vscode.TreeDataProvider<vscode.TreeItem const data = this.cache.getData(); if (!data) { return []; } - return data.builders.map(b => { - const phase = b.blocked ? `[${b.blocked}] blocked` : `[${b.phase}]`; - const item = new BuilderTreeItem(b.id, `#${b.issueId ?? b.id} ${b.issueTitle ?? ''} ${phase}`); + return orderForDisplay(data.builders).map(b => { + const isBlocked = !!b.blocked; + const waitTime = isBlocked && b.blockedSince ? ` [${timeSince(b.blockedSince)}]` : ''; + const phaseLabel = isBlocked + ? `blocked on ${b.blocked}${waitTime}` + : `[${b.phase}]`; + const item = new BuilderTreeItem(b.id, `#${b.issueId ?? b.id} ${b.issueTitle ?? ''} ${phaseLabel}`); item.tooltip = `Protocol: ${b.protocol} | Mode: ${b.mode} | Progress: ${b.progress}%`; - item.contextValue = 'builder'; - item.iconPath = b.blocked - ? new vscode.ThemeIcon('debug-pause', new vscode.ThemeColor('testing.iconFailed')) + // contextValue encodes both blocked-state and protocol so menus can + // scope by either (e.g., inline Approve only on blocked-builder-*). + item.contextValue = isBlocked + ? `blocked-builder-${b.protocol || 'unknown'}` + : `builder-${b.protocol || 'unknown'}`; + item.iconPath = isBlocked + ? new vscode.ThemeIcon('bell', new vscode.ThemeColor('notificationsWarningIcon.foreground')) : new vscode.ThemeIcon('play', new vscode.ThemeColor('testing.iconPassed')); item.command = { command: 'codev.openBuilderById', @@ -35,3 +66,13 @@ export class BuildersProvider implements vscode.TreeDataProvider<vscode.TreeItem }); } } + +function timeSince(isoDate: string): string { + const ms = Date.now() - new Date(isoDate).getTime(); + const minutes = Math.floor(ms / 60000); + if (minutes < 1) { return '<1m'; } + if (minutes < 60) { return `${minutes}m`; } + const hours = Math.floor(minutes / 60); + if (hours < 24) { return `${hours}h`; } + return `${Math.floor(hours / 24)}d`; +} diff --git a/packages/vscode/src/views/needs-attention.ts b/packages/vscode/src/views/needs-attention.ts deleted file mode 100644 index 5c395b060..000000000 --- a/packages/vscode/src/views/needs-attention.ts +++ /dev/null @@ -1,58 +0,0 @@ -import * as vscode from 'vscode'; -import type { OverviewCache } from './overview-data.js'; -import { BuilderTreeItem } from './builder-tree-item.js'; - -export class NeedsAttentionProvider implements vscode.TreeDataProvider<vscode.TreeItem> { - private readonly changeEmitter = new vscode.EventEmitter<void>(); - readonly onDidChangeTreeData = this.changeEmitter.event; - - constructor(private cache: OverviewCache) { - cache.onDidChange(() => this.changeEmitter.fire()); - } - - getTreeItem(element: vscode.TreeItem): vscode.TreeItem { - return element; - } - - getChildren(): vscode.TreeItem[] { - const data = this.cache.getData(); - if (!data) { return []; } - - const items: vscode.TreeItem[] = []; - - // Blocked builders - for (const b of data.builders.filter(b => b.blocked)) { - const waitTime = b.blockedSince - ? `(${timeSince(b.blockedSince)})` - : ''; - const item = new BuilderTreeItem(b.id, `#${b.issueId ?? b.id} — blocked on ${b.blocked} ${waitTime}`); - item.iconPath = new vscode.ThemeIcon('bell', new vscode.ThemeColor('notificationsWarningIcon.foreground')); - item.contextValue = 'blocked-builder'; - item.command = { - command: 'codev.openBuilderById', - title: 'Open Builder Terminal', - arguments: [b.id], - }; - items.push(item); - } - - // PRs needing review - for (const pr of data.pendingPRs.filter(p => p.reviewStatus === 'review_required')) { - const item = new vscode.TreeItem(`PR #${pr.id} — ready for review`); - item.iconPath = new vscode.ThemeIcon('git-pull-request'); - item.contextValue = 'pr-needs-review'; - items.push(item); - } - - return items; - } -} - -function timeSince(isoDate: string): string { - const ms = Date.now() - new Date(isoDate).getTime(); - const minutes = Math.floor(ms / 60000); - if (minutes < 60) { return `${minutes}m`; } - const hours = Math.floor(minutes / 60); - if (hours < 24) { return `${hours}h`; } - return `${Math.floor(hours / 24)}d`; -} diff --git a/packages/vscode/src/views/overview-data.ts b/packages/vscode/src/views/overview-data.ts index cb530ddea..dc100bccd 100644 --- a/packages/vscode/src/views/overview-data.ts +++ b/packages/vscode/src/views/overview-data.ts @@ -8,7 +8,7 @@ import type { ConnectionManager } from '../connection-manager.js'; */ export class OverviewCache { private data: OverviewData | null = null; - private loading = false; + private latestSeq = 0; private readonly changeEmitter = new vscode.EventEmitter<void>(); readonly onDidChange = this.changeEmitter.event; @@ -24,24 +24,35 @@ export class OverviewCache { return this.data; } + /** + * Fetch the latest overview from Tower and notify subscribers. + * + * Last-write-wins via a sequence counter rather than a load gate: every + * call increments `latestSeq` and only commits its result if the seq is + * still current. This guarantees the cache reflects the most-recently + * requested state even when SSE bursts (e.g. `porch done --pr` → + * `porch done --merged` → `afx cleanup`) trigger several refreshes back- + * to-back. A naive `if (loading) return` gate drops requests #2 and #3 + * and freezes the cache on the mid-transition state from request #1 + * until something else triggers an SSE event — the bug this fixes. + * Cost: N rapid events → N parallel `/api/overview` requests; on + * localhost-Tower that's negligible. + */ async refresh(): Promise<void> { - if (this.loading) { return; } - this.loading = true; - - try { - const client = this.connectionManager.getClient(); - if (!client || this.connectionManager.getState() !== 'connected') { - this.data = null; - this.changeEmitter.fire(); - return; - } - - const workspacePath = this.connectionManager.getWorkspacePath(); - this.data = await client.getOverview(workspacePath ?? undefined) ?? null; - } finally { - this.loading = false; + const mySeq = ++this.latestSeq; + const client = this.connectionManager.getClient(); + if (!client || this.connectionManager.getState() !== 'connected') { + if (mySeq !== this.latestSeq) { return; } + this.data = null; this.changeEmitter.fire(); + return; } + + const workspacePath = this.connectionManager.getWorkspacePath(); + const result = await client.getOverview(workspacePath ?? undefined) ?? null; + if (mySeq !== this.latestSeq) { return; } + this.data = result; + this.changeEmitter.fire(); } dispose(): void { diff --git a/packages/vscode/src/views/team.ts b/packages/vscode/src/views/team.ts index 185d80ef2..d39e49bf1 100644 --- a/packages/vscode/src/views/team.ts +++ b/packages/vscode/src/views/team.ts @@ -43,24 +43,24 @@ export class TeamProvider implements vscode.TreeDataProvider<vscode.TreeItem> { const items: vscode.TreeItem[] = []; const ghd = member.github_data; - if (ghd.assignedIssues?.length) { - items.push(...ghd.assignedIssues.map((i: any) => { - const ti = new vscode.TreeItem(`Working on: #${i.number} ${i.title}`); - ti.iconPath = new vscode.ThemeIcon('issues'); - return ti; - })); + // Count-only summaries — the issue/PR lists live in the Backlog / Pull + // Requests views. Use the *Count fields (true totals via GitHub + // search.issueCount); the node arrays are capped at 20 so `.length` + // would silently max out. + if ((ghd.assignedIssuesCount ?? 0) > 0) { + const ti = new vscode.TreeItem(`Assigned: ${ghd.assignedIssuesCount}`); + ti.iconPath = new vscode.ThemeIcon('issues'); + items.push(ti); } - if (ghd.openPRs?.length) { - items.push(...ghd.openPRs.map((p: any) => { - const ti = new vscode.TreeItem(`Open PR: #${p.number} ${p.title}`); - ti.iconPath = new vscode.ThemeIcon('git-pull-request'); - return ti; - })); + if ((ghd.openPRsCount ?? 0) > 0) { + const ti = new vscode.TreeItem(`Open PRs: ${ghd.openPRsCount}`); + ti.iconPath = new vscode.ThemeIcon('git-pull-request'); + items.push(ti); } - const merged = ghd.recentActivity?.mergedPRs?.length ?? 0; - const closed = ghd.recentActivity?.closedIssues?.length ?? 0; + const merged = ghd.recentActivity?.mergedPRsCount ?? 0; + const closed = ghd.recentActivity?.closedIssuesCount ?? 0; if (merged || closed) { const ti = new vscode.TreeItem(`Last 7d: ${merged} merged, ${closed} closed`); ti.iconPath = new vscode.ThemeIcon('graph'); diff --git a/packages/vscode/src/views/workspace.ts b/packages/vscode/src/views/workspace.ts index a41f04812..2622966d0 100644 --- a/packages/vscode/src/views/workspace.ts +++ b/packages/vscode/src/views/workspace.ts @@ -1,19 +1,28 @@ import * as vscode from 'vscode'; import { encodeWorkspacePath } from '@cluesmith/codev-core/workspace'; import type { ConnectionManager } from '../connection-manager.js'; +import type { TerminalManager } from '../terminal-manager.js'; import { getTowerAddress } from '../workspace-detector.js'; +import { resolveWorkspaceDevTarget } from '../commands/dev-shared.js'; /** - * Workspace-level entry points: architect terminal + Tower web dashboard. - * Sits at the top of the Codev sidebar so users can launch either with - * one click instead of hunting in the command palette. + * Workspace-level entry points: architect terminal, Tower web dashboard, + * spawn builder, and new shell. Sits at the top of the Codev sidebar so + * common workspace actions are one click away, not buried in the palette. */ export class WorkspaceProvider implements vscode.TreeDataProvider<vscode.TreeItem> { private readonly changeEmitter = new vscode.EventEmitter<void>(); readonly onDidChangeTreeData = this.changeEmitter.event; - constructor(private connectionManager: ConnectionManager) { + constructor( + private connectionManager: ConnectionManager, + private terminalManager: TerminalManager, + ) { connectionManager.onStateChange(() => this.changeEmitter.fire()); + // Re-render when the dev-terminal set changes (start/stop, a swap that + // killed this workspace's dev, or cleanup) so the conditional "Stop Dev + // Server" row reflects reality across every path. + terminalManager.onDidChangeDevTerminals(() => this.changeEmitter.fire()); } getTreeItem(element: vscode.TreeItem): vscode.TreeItem { @@ -47,6 +56,63 @@ export class WorkspaceProvider implements vscode.TreeDataProvider<vscode.TreeIte items.push(web); } + const spawn = new vscode.TreeItem('Spawn Builder'); + spawn.iconPath = new vscode.ThemeIcon('rocket'); + spawn.tooltip = 'Spawn a new builder for a GitHub issue'; + spawn.contextValue = 'workspace-spawn'; + spawn.command = { + command: 'codev.spawnBuilder', + title: 'Spawn Builder', + }; + items.push(spawn); + + const shell = new vscode.TreeItem('New Shell'); + shell.iconPath = new vscode.ThemeIcon('terminal-new'); + shell.tooltip = 'Open a new shell tab in Tower'; + shell.contextValue = 'workspace-shell'; + shell.command = { + command: 'codev.newShell', + title: 'New Shell', + }; + items.push(shell); + + // Target is whatever folder this VSCode window is rooted at: the main + // checkout → `main`, a `.builders/<id>/` worktree → that builder. The + // label stays generic; the tooltip names the resolved target. + const workspacePath = this.connectionManager.getWorkspacePath(); + const devTarget = workspacePath ? resolveWorkspaceDevTarget(workspacePath) : null; + + // Mutually exclusive: show Start when this workspace's dev is stopped, + // Stop when it's running. The visible control is itself the state + // indicator (play/stop model) — never both, no row-count jitter. + const targetDevRunning = !!devTarget && this.terminalManager + .listDevTerminals() + .some(d => d.builderId === devTarget.id); + + if (targetDevRunning) { + const stopDev = new vscode.TreeItem('Stop Dev Server'); + stopDev.iconPath = new vscode.ThemeIcon('debug-stop'); + stopDev.tooltip = `Stop the dev server for this workspace (target: ${devTarget!.id})`; + stopDev.contextValue = 'workspace-dev-stop'; + stopDev.command = { + command: 'codev.stopWorkspaceDev', + title: 'Stop Dev Server', + }; + items.push(stopDev); + } else { + const startDev = new vscode.TreeItem('Start Dev Server'); + startDev.iconPath = new vscode.ThemeIcon('play'); + startDev.tooltip = devTarget + ? `Run worktree.devCommand for this workspace (target: ${devTarget.id})` + : 'Run worktree.devCommand for this workspace'; + startDev.contextValue = 'workspace-dev-start'; + startDev.command = { + command: 'codev.runWorkspaceDev', + title: 'Start Dev Server', + }; + items.push(startDev); + } + return items; }