From 087a2d3dc4ecdaf06956e15f241502b1f0a1f5b4 Mon Sep 17 00:00:00 2001 From: Amr Elsayed Date: Sat, 27 Jun 2026 21:55:10 +1000 Subject: [PATCH 01/29] chore(porch): 1104 init pir --- .../status.yaml | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 codev/projects/1104-vscode-merge-architects-builde/status.yaml diff --git a/codev/projects/1104-vscode-merge-architects-builde/status.yaml b/codev/projects/1104-vscode-merge-architects-builde/status.yaml new file mode 100644 index 000000000..50a5c53db --- /dev/null +++ b/codev/projects/1104-vscode-merge-architects-builde/status.yaml @@ -0,0 +1,18 @@ +id: '1104' +title: vscode-merge-architects-builde +protocol: pir +phase: plan +plan_phases: [] +current_plan_phase: null +gates: + plan-approval: + status: pending + dev-approval: + status: pending + pr: + status: pending +iteration: 1 +build_complete: false +history: [] +started_at: '2026-06-27T11:55:09.921Z' +updated_at: '2026-06-27T11:55:09.922Z' From 4559671ec8d6ced52a9eb255659dbeb55311d7ae Mon Sep 17 00:00:00 2001 From: Amr Elsayed Date: Sat, 27 Jun 2026 22:00:06 +1000 Subject: [PATCH 02/29] [PIR #1104] Plan draft --- .../1104-vscode-merge-architects-builde.md | 220 ++++++++++++++++++ codev/state/pir-1104_thread.md | 26 +++ 2 files changed, 246 insertions(+) create mode 100644 codev/plans/1104-vscode-merge-architects-builde.md create mode 100644 codev/state/pir-1104_thread.md diff --git a/codev/plans/1104-vscode-merge-architects-builde.md b/codev/plans/1104-vscode-merge-architects-builde.md new file mode 100644 index 000000000..7b27895e6 --- /dev/null +++ b/codev/plans/1104-vscode-merge-architects-builde.md @@ -0,0 +1,220 @@ +# PIR Plan: Merge Architects + Builders into a single adaptive "Agents" tree (VSCode) + +## Understanding + +Issue #1104 wants the Codev VSCode sidebar to surface architect→builder ownership +(`spawnedByArchitect`) by replacing the parallel "Architects" and "Builders" views with one +**Agents** tree that is *architect-rooted when there is more than one architect* and collapses +to today's area/phase grouping when there is exactly one. It also rewrites `Codev: Add Architect` +from a direct CLI shell-out into a request routed to the `main` architect. + +### Important correction to the issue's premise + +The issue describes "two parallel trees (Architects + Builders)". **In the VSCode extension that +is not the current state.** There is exactly one builder tree view registered +(`codev.builders` → `BuildersProvider`, `extension.ts:418`) plus a *Workspace > Architects +subsection* living inside the `codev.workspace` view (`WorkspaceProvider.getArchitectChildren`, +`workspace.ts:75,253-293`). There is **no standalone `codev.architects` tree view** to remove — +verified against `package.json` `contributes.views` (only `codev.workspace`, `codev.builders`, +`codev.backlog`, `codev.pullRequests`, `codev.recentlyClosed`, `codev.team`, `codev.status`, +`codev.placeholder`, `codev.devServer`). The "standalone Architects tree" in the issue maps to +the Tower **dashboard**, not the extension. + +Consequence for scope: the work is **(a)** add an adaptive architect tier to the existing +Builders tree and rename it Agents, and **(b)** rewrite Add Architect. The "remove the standalone +Architects tree / repurpose its view id" bullet is a no-op for the extension — the Workspace > +Architects subsection stays exactly as-is (it is already the "workspace configuration" surface the +issue's "Workspace view delineation" section wants to preserve). I will confirm this reading with +the reviewer at the plan gate rather than hunt for a tree that isn't there. + +### Data the design needs, and where it lives today + +- **`OverviewBuilder.spawnedByArchitect`** (`packages/types/src/api.ts:201`) — already on the wire, + populated by the overview server from `state.db.builders.spawned_by_architect` + (`overview.ts:822-828`). This is the builder→architect edge. `null` for legacy / unmatched rows. +- **Architect roster** (names, which is `main`, presence of passive architects like REVIEWER that + never spawn) is **not** in `OverviewData`. It is fetched via + `client.getWorkspaceStatus(workspacePath)` → `terminals.filter(t => t.type === 'architect')`, + each carrying `architectName` (`tower-client.ts:330,34-48`). `WorkspaceProvider` already consumes + this and re-renders on the `architects-updated` SSE envelope (`workspace.ts:44-53`). + +So the Agents tree needs *both* the builder list (overview cache, synchronous) *and* the architect +roster (async workspace-status fetch). The roster is required — deriving architects from +`spawnedByArchitect` alone would silently drop passive architects, which the issue explicitly wants +rendered as interactive leaf rows. + +## Proposed Change + +### 1. Architect-roster source in BuildersProvider — **Option A (recommended)** + +Give `BuildersProvider` an architect roster it refreshes the same way `WorkspaceProvider` does: + +- On construction, subscribe to the `architects-updated` SSE envelope (and reuse the existing + overview-cache `onDidChange`), fetch `getWorkspaceStatus`, cache `architectName[]` (main-first), + and fire `onDidChangeTreeData`. +- `architectCount` = roster length. Drives the adaptive root. + +Rationale: keeps the entire change inside the VSCode extension, mirrors an established, tested +pattern (`WorkspaceProvider.getArchitectChildren`), and changes no wire contract. The roster fetch +is cached (not re-fetched per `getChildren` call), so render stays cheap. + +**Alternative B (considered, not chosen): enrich `/api/overview` with `architects: ArchitectState[]`.** +Cleaner in one respect — a single atomic cache carrying builders + roster, synchronous render, and +the dashboard could reuse it. Rejected for this PIR because it changes the `OverviewData` wire type +and adds roster plumbing into the Tower overview builder (`overview.ts`), pushing a VSCode-scoped +change into `area/tower` and `area/core`. If the reviewer prefers B at the plan gate I will switch; +it is a clean swap of the data source behind the same tree logic. + +### 2. Adaptive root in the tree (`builders.ts`) + +Introduce an **architect tier** as an outer wrapper around the existing grouping strategies, which +stay one-level and unchanged (`builder-grouping.ts` is untouched — honoring the issue's "strategy +interface should stay unchanged"): + +- `rootChildren()` branches on `architectCount`: + - **=== 1** (or 0): today's behaviour exactly — return area/phase group headers (or the flattened + lone-`Uncategorized` rows). This branch is a bit-for-bit regression target. + - **> 1**: return one **architect node** per roster entry (main-first). Each architect node's + `collapsibleState` is `Collapsed` when it owns ≥1 builder, `None` (leaf) when it owns none — the + passive-architect rule. Architect rows are interactive (click → open that architect's terminal + via `codev.openArchitectTerminal`; right-click → message), so a childless REVIEWER stays a usable + leaf. +- A new `getChildren` branch for an architect node returns that architect's builders grouped by the + **existing** active strategy (area or phase) at level 2 — i.e. the level-2 group headers are + produced by delegating to `this.active().group(...)` over only that architect's builders. Level-3 + builder rows and their file-tree children are unchanged. +- Builders are partitioned to architects by `spawnedByArchitect`. Builders with `null` / + unknown-owner are collected under a synthetic **"Unassigned"** architect node at the end (decision + to confirm at gate — see Open Questions; the alternative is hiding them, which would make builders + vanish from the tree, so I lean to an explicit Unassigned bucket). + +### 3. Rollups extend to two tiers (`builder-row.ts`, `builder-tree-item.ts`) + +`rollupGroupState` / `worstBuilderState` / `BUILDER_STATE_GLYPH` already take an arbitrary builder +list, so they extend with no signature change: + +- **Level 1 (architect node):** rollup over *all* builders owned by the architect (sum across its + area/phase groups). Same glyph vocabulary, same worst-of severity, same + `" blocked · waiting · active"` tooltip shape. A new lightweight + `ArchitectGroupTreeItem` (sibling of `BuilderGroupTreeItem`, likely sharing `AreaGroupTreeItem`) + carries the architect label, count, rollup icon, and `contextValue` for the message/open menus. +- **Level 2 (area/phase header):** unchanged `BuilderGroupTreeItem` rollup over that architect's + subset. + +### 4. `description` badge for architect attribution (`builders.ts` / `builder-row.ts`) + +In the single-architect-collapsed view the architect tier is absent, so per the issue the architect +name rides as a dim `description` badge on builder rows **only when `architectCount > 1`** (so +single-architect workspaces stay clean). In the multi-architect tree the architect is already in the +row's ancestry, so the badge is suppressed there to avoid duplication. Net: the badge appears only in +sub-trees where the owning architect is not an ancestor (matching the issue's rule). + +### 5. `getParent` / accordion / auto-reveal (`builders.ts`) + +`getParent` must walk the new chain so `reveal()` (accordion #913, active-file sync #1066) still +works in the multi-architect tree: builder → area/phase group → architect node → root. The +`groupParentByBuilderId` map gains a parallel `architectParentByGroup` (or the builder→group map is +extended to also record the group→architect link). Single-architect mode keeps today's two-level +chain untouched. The accordion (`collapseBuildersExcept`, `AccordionRowIds`) operates on builder +rows and is unaffected by an added ancestor level, but I will add regression coverage. + +### 6. Rename Builders → Agents + +- `package.json` `contributes.views.codev.builders.name`: `"Builders"` → `"Agents"`. +- **View id `codev.builders` is kept** (not renamed to `codev.agents`) to avoid breaking saved view + layouts, `when`-clause references, and the `buildersView` handle wiring in `extension.ts`. Only the + display name changes. (Open Question flags the id-migration alternative for the reviewer.) +- Title/tooltips and the grouping-toggle command titles that say "Builders" updated to "Agents" + where user-facing; internal symbol names (`BuildersProvider`, `buildersView`) left as-is to keep + the diff reviewable (rename is cosmetic and high-churn; can be a follow-up). + +### 7. Add Architect → conversational (`extension.ts:802`, maybe `commands/`) + +Rewrite `codev.addArchitect`: + +1. Resolve the `main` architect's session from `getWorkspaceStatus` (terminals, `type==='architect'`, + `architectName==='main'`). If main is absent or has no live session, show a modal explaining the + action asks main to add, with the CLI fallback (`afx workspace add-architect --name ` / + `afx workspace start`). Refuse — do **not** silently fall back to direct creation. +2. Input box for the new architect name, reusing the existing `validateArchitectName` shared + validator (parity with `afx workspace add-architect`). +3. Dispatch `client.sendMessage('architect:main', "Please add a architect.", { workspace })` + (the `architect:` addressing form `/api/send` already supports). Toast on success. +4. v1 scope per issue: **name-only** (no scope/brief prompt), **no auto-open** of main's terminal — + both listed as deferrable polish in the issue. Open Questions surfaces these for the reviewer. + +The command is reachable from the Agents title-bar `+`, the Workspace > Architects `+`, the +`Cmd+K A` keybinding, and the palette — all already bound to `codev.addArchitect`, so no new +contributions are needed beyond pointing the Agents title `+` at it. + +## Files to Change + +- `packages/vscode/src/views/builders.ts` — adaptive `rootChildren()` (architectCount branch), + architect-node `getChildren` branch, architect partition by `spawnedByArchitect` + Unassigned + bucket, two-tier `getParent`, `description` badge wiring, roster cache + `architects-updated` + subscription. +- `packages/vscode/src/views/builder-tree-item.ts` — new `ArchitectGroupTreeItem` (label, count, + tier-1 rollup icon, `contextValue`, click→`codev.openArchitectTerminal`); leaf vs collapsed state. +- `packages/vscode/src/views/builder-row.ts` — no signature change expected; possibly a small helper + for the architect-attribution `description` string. (Rollup helpers reused as-is.) +- `packages/vscode/src/views/builder-grouping.ts` — **unchanged** (strategy interface stays + one-level); noted explicitly so the reviewer knows the wrapper is the new outer concern. +- `packages/vscode/src/extension.ts` — rewrite `codev.addArchitect` handler (main-resolve → + send-to-main; main-absent → modal+CLI fallback); ensure Agents title `+` points at it. +- `packages/vscode/package.json` — `codev.builders` view `name` → `"Agents"`; menu/title strings + "Builders"→"Agents" where user-facing; Agents title-bar `+` contribution if not already present. +- `packages/vscode/src/__tests__/builder-grouping.test.ts` — multi-architect partition/rollup cases. +- `packages/vscode/src/__tests__/builders-accordion.test.ts` — accordion unaffected by added tier. +- `packages/vscode/src/__tests__/builders-autoreveal.test.ts` — `getParent` three-level chain. +- New: `packages/vscode/src/__tests__/add-architect.test.ts` (or extend an existing handler test) — + main-present → message dispatched; main-absent → modal + CLI fallback; name-validation parity. +- New: a small unit test for the architect-tier partition/rollup pure logic (mirroring + `builder-row.test.ts` style) if the logic is extracted to a vscode-free helper for testability. + +## Risks & Alternatives Considered + +- **Risk: the "remove standalone Architects tree" bullet has no target in VSCode.** Mitigation: + treat it as a no-op, keep Workspace > Architects intact, and confirm the reading at the plan gate. + This is the single most important thing for the reviewer to validate before any code is written. +- **Risk: async roster fetch vs synchronous render.** `rootChildren()` is currently synchronous. + Mitigation: cache the roster in the provider (fetched on SSE/refresh), so `getChildren` reads it + synchronously; never block render on a fetch. `architectCount` defaults to 1 until the first roster + load completes → the tree renders today's behaviour during the brief warm-up, never a broken tree. +- **Risk: builders with `spawnedByArchitect: null` (legacy / unmatched) disappearing.** Mitigation: + explicit "Unassigned" architect bucket in multi-architect mode. (Confirm at gate.) +- **Risk: `getParent` regressions break accordion + active-file reveal.** Mitigation: dedicated + autoreveal test for the three-level chain; single-architect chain kept byte-identical. +- **Alternative — enrich `/api/overview` with the roster (Option B):** cleaner single cache, but + crosses into `area/tower`/`area/core` and changes a wire type. Deferred unless the reviewer prefers + it. +- **Alternative — keep two views, add a badge only (issue's rejected "plain badge"):** doesn't give + the architect-rooted triage view the issue asks for; rejected per the issue's own design discussion. +- **Alternative — rename view id to `codev.agents`:** cleaner naming but risks saved-layout / when- + clause breakage; deferred (Open Questions). + +## Test Plan + +The reviewer exercises the running worktree at the `dev-approval` gate. + +- **Unit (vitest, `pnpm --filter @cluesmith/codev-vscode test` from the worktree):** + - Architect partition: builders bucket to the right architect by `spawnedByArchitect`; null → + Unassigned. + - Tier-1 rollup: worst-of severity + counts sum across an architect's area/phase groups. + - Adaptive root: `architectCount === 1` returns today's group nodes (regression — snapshot of + current output); `> 1` returns architect nodes with correct leaf/collapsed states (passive + architect → leaf). + - `getParent` three-level chain resolves builder → group → architect. + - Add Architect: main-present dispatches `sendMessage('architect:main', ...)`; main-absent shows + modal with CLI fallback; invalid name rejected by shared validator (parity). +- **Manual (VSCode, against a running Tower):** + - Single-architect workspace: Agents tree looks identical to today's Builders tree (area/phase + groups, no architect rows, no architect `description` badge). Toggle area↔phase still works. + - Multi-architect workspace (`afx workspace add-architect --name `): Agents shows architect rows; + builders nest under their owner with area/phase sub-grouping; passive architect renders as a + clickable leaf; rollup glyphs/tooltips correct at both tiers; `description` badge absent (owner is + ancestor). + - Accordion + click-to-open-terminal still work on builder rows under the architect tier. + - Add Architect from the Agents `+`: with main running → message lands in main's terminal; with + main closed → modal points to the CLI fallback. + - Workspace > Architects subsection unchanged and still in sync via `architects-updated`. +- **Cross-platform:** N/A (desktop VSCode extension only). diff --git a/codev/state/pir-1104_thread.md b/codev/state/pir-1104_thread.md new file mode 100644 index 000000000..2b30b73e9 --- /dev/null +++ b/codev/state/pir-1104_thread.md @@ -0,0 +1,26 @@ +# PIR #1104 — merge Architects + Builders into one "Agents" tree (vscode) + +## Plan phase + +Investigated the VSCode sidebar tree code. Key findings: + +- **There is NO standalone "Architects" tree view in VSCode.** Registered views are + `codev.builders` (BuildersProvider) + a *Workspace > Architects subsection* inside + `codev.workspace` (WorkspaceProvider.getArchitectChildren). The issue's "two parallel + trees" framing matches the Tower dashboard, not the extension. So "remove the standalone + Architects tree" is largely a no-op here — the real work is adding an architect tier to + the Builders tree (renamed Agents). Surfaced this in the plan. +- `OverviewBuilder.spawnedByArchitect` exists (api.ts:201), populated from state.db + (overview.ts:822-828). Good — builder→architect ownership is already on the wire. +- `OverviewData` has NO architect roster. Architects come from `getWorkspaceStatus` + (terminals filtered by type==='architect', carrying `architectName`). WorkspaceProvider + already fetches them this way and refreshes on the `architects-updated` SSE event. +- Add Architect command exists today (extension.ts:802) — shells to `client.addArchitect`. + Issue wants it rewritten to dispatch a message to main via `client.sendMessage('architect:main', ...)`. + +Data-source decision for the architect tier (recommended Option A in plan): fetch roster in +BuildersProvider via getWorkspaceStatus + refresh on architects-updated, mirroring +WorkspaceProvider — keeps change VSCode-contained, no OverviewData wire change. Alternative +B (enrich /api/overview with architects) is cleaner single-cache but crosses into area/tower. + +Plan written to codev/plans/1104-vscode-merge-architects-builde.md. Awaiting plan-approval gate. From 522d55c396651d7b1781e978199e68401b56f6f7 Mon Sep 17 00:00:00 2001 From: Amr Elsayed Date: Sat, 27 Jun 2026 22:00:20 +1000 Subject: [PATCH 03/29] chore(porch): 1104 plan-approval gate-requested --- codev/projects/1104-vscode-merge-architects-builde/status.yaml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/codev/projects/1104-vscode-merge-architects-builde/status.yaml b/codev/projects/1104-vscode-merge-architects-builde/status.yaml index 50a5c53db..9174f7dac 100644 --- a/codev/projects/1104-vscode-merge-architects-builde/status.yaml +++ b/codev/projects/1104-vscode-merge-architects-builde/status.yaml @@ -7,6 +7,7 @@ current_plan_phase: null gates: plan-approval: status: pending + requested_at: '2026-06-27T12:00:20.441Z' dev-approval: status: pending pr: @@ -15,4 +16,4 @@ iteration: 1 build_complete: false history: [] started_at: '2026-06-27T11:55:09.921Z' -updated_at: '2026-06-27T11:55:09.922Z' +updated_at: '2026-06-27T12:00:20.442Z' From fc82dc7e00ffdf0a9f693802260163b78db0ff17 Mon Sep 17 00:00:00 2001 From: Amr Elsayed Date: Sat, 27 Jun 2026 22:10:43 +1000 Subject: [PATCH 04/29] =?UTF-8?q?[PIR=20#1104]=20Plan=20revised=20?= =?UTF-8?q?=E2=80=94=20adopt=20Option=20B=20(enrich=20/api/overview=20with?= =?UTF-8?q?=20architects)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../1104-vscode-merge-architects-builde.md | 129 ++++++++++++------ codev/state/pir-1104_thread.md | 13 ++ 2 files changed, 101 insertions(+), 41 deletions(-) diff --git a/codev/plans/1104-vscode-merge-architects-builde.md b/codev/plans/1104-vscode-merge-architects-builde.md index 7b27895e6..fdd22ff1a 100644 --- a/codev/plans/1104-vscode-merge-architects-builde.md +++ b/codev/plans/1104-vscode-merge-architects-builde.md @@ -33,37 +33,60 @@ the reviewer at the plan gate rather than hunt for a tree that isn't there. populated by the overview server from `state.db.builders.spawned_by_architect` (`overview.ts:822-828`). This is the builder→architect edge. `null` for legacy / unmatched rows. - **Architect roster** (names, which is `main`, presence of passive architects like REVIEWER that - never spawn) is **not** in `OverviewData`. It is fetched via - `client.getWorkspaceStatus(workspacePath)` → `terminals.filter(t => t.type === 'architect')`, - each carrying `architectName` (`tower-client.ts:330,34-48`). `WorkspaceProvider` already consumes - this and re-renders on the `architects-updated` SSE envelope (`workspace.ts:44-53`). - -So the Agents tree needs *both* the builder list (overview cache, synchronous) *and* the architect -roster (async workspace-status fetch). The roster is required — deriving architects from -`spawnedByArchitect` alone would silently drop passive architects, which the issue explicitly wants + never spawn) is **not** in `OverviewData` *today*. It is currently surfaced two ways: the + `WorkspaceProvider` fetches it via `client.getWorkspaceStatus(workspacePath)` → + `terminals.filter(t => t.type === 'architect')` and re-renders on the `architects-updated` SSE + envelope (`workspace.ts:44-53,253-293`); and the dashboard-state handler builds it from + `entry.architects` (`tower-routes.ts:1823-1841`). **This plan adds it to `OverviewData`** (see + Proposed Change §1) so the Agents tree reads it synchronously from the overview cache. + +So the Agents tree needs *both* the builder list and the architect roster — and after the §1 +enrichment, both arrive together on the overview cache (synchronous render, no extra fetch). The +roster is required — deriving architects from `spawnedByArchitect` alone would silently drop passive +architects, which the issue explicitly wants rendered as interactive leaf rows. ## Proposed Change -### 1. Architect-roster source in BuildersProvider — **Option A (recommended)** - -Give `BuildersProvider` an architect roster it refreshes the same way `WorkspaceProvider` does: - -- On construction, subscribe to the `architects-updated` SSE envelope (and reuse the existing - overview-cache `onDidChange`), fetch `getWorkspaceStatus`, cache `architectName[]` (main-first), - and fire `onDidChangeTreeData`. -- `architectCount` = roster length. Drives the adaptive root. - -Rationale: keeps the entire change inside the VSCode extension, mirrors an established, tested -pattern (`WorkspaceProvider.getArchitectChildren`), and changes no wire contract. The roster fetch -is cached (not re-fetched per `getChildren` call), so render stays cheap. - -**Alternative B (considered, not chosen): enrich `/api/overview` with `architects: ArchitectState[]`.** -Cleaner in one respect — a single atomic cache carrying builders + roster, synchronous render, and -the dashboard could reuse it. Rejected for this PIR because it changes the `OverviewData` wire type -and adds roster plumbing into the Tower overview builder (`overview.ts`), pushing a VSCode-scoped -change into `area/tower` and `area/core`. If the reviewer prefers B at the plan gate I will switch; -it is a clean swap of the data source behind the same tree logic. +### 1. Architect-roster source — enrich `/api/overview` with `architects: ArchitectState[]` (chosen) + +Put the architect roster on the overview payload so a single atomic cache carries builders + roster, +the tree renders synchronously, and the dashboard can reuse the exact same data (the issue's "both +views read from the same architect roster" becomes literally true — one wire field, one cache). + +Tower side (`tower-routes.ts`): +- `handleOverview` (`tower-routes.ts:864`) already has the roster in hand: `entry.architects` + (a `Map` from `getRehydratedTerminalsEntry`). The `ArchitectState[]` + build logic that the dashboard-state handler uses (`tower-routes.ts:1823-1841`: collect from + `entry.architects`, skip dead sessions, move `main` to index 0) is **extracted into a shared + helper** `collectArchitects(entry, manager): ArchitectState[]` and called from **both** sites — + single source of truth (no inline-literal drift between the two endpoints). +- In `handleOverview`, set `data.architects = collectArchitects(entry, manager)` before serializing. + +Wire type (`packages/types/src/api.ts`): +- Add `architects: ArchitectState[]` to `OverviewData` (required field; `[]` for an empty/ + unreachable roster — never `undefined`, so consumers don't branch). `ArchitectState` already + carries `name`, `terminalId`, `pid`, `persistent` — exactly what the Agents tree needs to render + rows, resolve a terminal for click-to-open, and resolve `main` for Add Architect. + +VSCode side: +- `BuildersProvider` reads `data.architects` straight off `overviewCache.getData()` — **synchronous**, + no extra fetch, no extra subscription. `OverviewCache` already refreshes on *every* SSE event, + and Tower emits `architects-updated` on add/remove (`tower-routes.ts:388-393,447-451`), so the + roster stays live through the existing path. `architectCount = data.architects.length`. + +Note on scope/labels: this enrichment touches `packages/types` (wire contract) and +`packages/codev` (Tower overview handler) in addition to `packages/vscode`. The issue is currently +labeled `area/vscode`; with the overview enrichment the change is genuinely multi-area. I will flag +to the architect that `area/cross-cutting` may be the more accurate label (architect's call — I will +not relabel). + +**Alternative A (considered, not chosen): fetch the roster in `BuildersProvider` via +`getWorkspaceStatus` + an `architects-updated` subscription, mirroring `WorkspaceProvider`.** +Fully VSCode-contained and changes no wire contract, but the roster would be VSCode-only (not +reusable by the dashboard), the fetch is async (warm-up / render-blocking risk), and it +duplicates a roster Tower can hand over for free. Rejected in favor of B per the reviewer's +direction that the enrichment be reusable. ### 2. Adaptive root in the tree (`builders.ts`) @@ -132,10 +155,10 @@ rows and is unaffected by an added ancestor level, but I will add regression cov Rewrite `codev.addArchitect`: -1. Resolve the `main` architect's session from `getWorkspaceStatus` (terminals, `type==='architect'`, - `architectName==='main'`). If main is absent or has no live session, show a modal explaining the - action asks main to add, with the CLI fallback (`afx workspace add-architect --name ` / - `afx workspace start`). Refuse — do **not** silently fall back to direct creation. +1. Resolve the `main` architect from the overview cache (`data.architects.find(a => a.name === 'main')` + — the same enriched roster the tree uses). If main is absent (or has no `terminalId`), show a modal + explaining the action asks main to add, with the CLI fallback (`afx workspace add-architect --name + ` / `afx workspace start`). Refuse — do **not** silently fall back to direct creation. 2. Input box for the new architect name, reusing the existing `validateArchitectName` shared validator (parity with `afx workspace add-architect`). 3. Dispatch `client.sendMessage('architect:main', "Please add a architect.", { workspace })` @@ -149,10 +172,23 @@ contributions are needed beyond pointing the Agents title `+` at it. ## Files to Change +### Tower / wire (Option B enrichment) + +- `packages/types/src/api.ts` — add `architects: ArchitectState[]` to `OverviewData` (required, + defaults to `[]`). +- `packages/codev/src/agent-farm/servers/tower-routes.ts` — extract `collectArchitects(entry, manager): + ArchitectState[]` from the dashboard-state builder (`:1823-1841`), reuse it there and in + `handleOverview` (`:864`) to set `data.architects`. +- `packages/codev/src/agent-farm/__tests__/overview.test.ts` — assert `/api/overview` now carries the + architects roster (main-first, dead-session skip, empty-roster `[]`). + +### VSCode tree + - `packages/vscode/src/views/builders.ts` — adaptive `rootChildren()` (architectCount branch), architect-node `getChildren` branch, architect partition by `spawnedByArchitect` + Unassigned - bucket, two-tier `getParent`, `description` badge wiring, roster cache + `architects-updated` - subscription. + bucket, two-tier `getParent`, `description` badge wiring. Roster read synchronously from + `overviewCache.getData().architects` (no extra subscription — the cache already refreshes on + `architects-updated`). - `packages/vscode/src/views/builder-tree-item.ts` — new `ArchitectGroupTreeItem` (label, count, tier-1 rollup icon, `contextValue`, click→`codev.openArchitectTerminal`); leaf vs collapsed state. - `packages/vscode/src/views/builder-row.ts` — no signature change expected; possibly a small helper @@ -176,17 +212,24 @@ contributions are needed beyond pointing the Agents title `+` at it. - **Risk: the "remove standalone Architects tree" bullet has no target in VSCode.** Mitigation: treat it as a no-op, keep Workspace > Architects intact, and confirm the reading at the plan gate. This is the single most important thing for the reviewer to validate before any code is written. -- **Risk: async roster fetch vs synchronous render.** `rootChildren()` is currently synchronous. - Mitigation: cache the roster in the provider (fetched on SSE/refresh), so `getChildren` reads it - synchronously; never block render on a fetch. `architectCount` defaults to 1 until the first roster - load completes → the tree renders today's behaviour during the brief warm-up, never a broken tree. +- **Risk: roster warm-up before the first overview load.** With Option B the roster is read + synchronously from `overviewCache.getData()`, but that is `null` until the first `/api/overview` + fetch lands. Mitigation: a null/empty roster → `architectCount === 0` → the `=== 1`/0 branch → + today's behaviour. The tree never renders a broken multi-architect shape during warm-up; it just + shows the single-architect layout until the roster arrives (then re-renders on the cache event). +- **Risk: `OverviewData` wire-contract change.** Adding a required `architects` field. Mitigation: + default to `[]` server-side so any consumer that ignores the field is unaffected; the field is + additive (no existing field changes). VSCode reads it defensively (`data.architects ?? []`). +- **Risk: `collectArchitects` extraction altering dashboard-state behaviour.** Mitigation: the + extracted helper must be byte-equivalent to the inlined loop (main-first, dead-session skip); a + dashboard-state regression test (or reuse of the existing overview/state tests) guards it. - **Risk: builders with `spawnedByArchitect: null` (legacy / unmatched) disappearing.** Mitigation: explicit "Unassigned" architect bucket in multi-architect mode. (Confirm at gate.) - **Risk: `getParent` regressions break accordion + active-file reveal.** Mitigation: dedicated autoreveal test for the three-level chain; single-architect chain kept byte-identical. -- **Alternative — enrich `/api/overview` with the roster (Option B):** cleaner single cache, but - crosses into `area/tower`/`area/core` and changes a wire type. Deferred unless the reviewer prefers - it. +- **Alternative — fetch the roster VSCode-side via `getWorkspaceStatus` (Option A):** VSCode-contained, + no wire change, but the roster isn't reusable by the dashboard and the fetch is async. Rejected per + the reviewer's direction to make the enrichment reusable (Option B chosen). - **Alternative — keep two views, add a badge only (issue's rejected "plain badge"):** doesn't give the architect-rooted triage view the issue asks for; rejected per the issue's own design discussion. - **Alternative — rename view id to `codev.agents`:** cleaner naming but risks saved-layout / when- @@ -196,7 +239,11 @@ contributions are needed beyond pointing the Agents title `+` at it. The reviewer exercises the running worktree at the `dev-approval` gate. -- **Unit (vitest, `pnpm --filter @cluesmith/codev-vscode test` from the worktree):** +- **Unit — Tower (`pnpm --filter @cluesmith/codev test` from the worktree):** + - `/api/overview` payload now includes `architects` (main-first; dead sessions skipped; empty + roster → `[]`). + - `collectArchitects` produces the same `ArchitectState[]` the dashboard-state path did (no drift). +- **Unit — VSCode (vitest, `pnpm --filter @cluesmith/codev-vscode test` from the worktree):** - Architect partition: builders bucket to the right architect by `spawnedByArchitect`; null → Unassigned. - Tier-1 rollup: worst-of severity + counts sum across an architect's area/phase groups. diff --git a/codev/state/pir-1104_thread.md b/codev/state/pir-1104_thread.md index 2b30b73e9..4882778ce 100644 --- a/codev/state/pir-1104_thread.md +++ b/codev/state/pir-1104_thread.md @@ -24,3 +24,16 @@ WorkspaceProvider — keeps change VSCode-contained, no OverviewData wire change B (enrich /api/overview with architects) is cleaner single-cache but crosses into area/tower. Plan written to codev/plans/1104-vscode-merge-architects-builde.md. Awaiting plan-approval gate. + +## Plan revision 1 — reviewer chose Option B + +Reviewer directed Option B: enrich `/api/overview` with `architects: ArchitectState[]` so the roster +is reusable (dashboard + extension share one cache). Confirmed it's clean: `handleOverview` +(tower-routes.ts:864) already holds `entry.architects`; extract `collectArchitects(entry, manager)` +from the dashboard-state builder (tower-routes.ts:1823-1841) and reuse at both sites (single source +of truth). Add `architects` to OverviewData (types/api.ts), VSCode reads it synchronously off the +overview cache (no extra subscription — cache already refreshes on architects-updated). Add Architect +main-resolve also reads `data.architects` instead of getWorkspaceStatus. + +Scope note flagged in plan: now spans packages/types + packages/codev + packages/vscode → +area/cross-cutting may fit better than area/vscode (architect's call). Plan revised + recommitted. From bef5d2129aceeaa6c9fa75d9e841988350cad08f Mon Sep 17 00:00:00 2001 From: Amr Elsayed Date: Sat, 27 Jun 2026 22:13:25 +1000 Subject: [PATCH 05/29] [PIR #1104] Record ArchitectState[] parity decision in thread --- codev/state/pir-1104_thread.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/codev/state/pir-1104_thread.md b/codev/state/pir-1104_thread.md index 4882778ce..1a5695ee3 100644 --- a/codev/state/pir-1104_thread.md +++ b/codev/state/pir-1104_thread.md @@ -37,3 +37,7 @@ main-resolve also reads `data.architects` instead of getWorkspaceStatus. Scope note flagged in plan: now spans packages/types + packages/codev + packages/vscode → area/cross-cutting may fit better than area/vscode (architect's call). Plan revised + recommitted. + +Decision: keep `ArchitectState[]` (not a leaner `string[]`) for shape-parity with +DashboardState.architects + the shared `collectArchitects` helper, even though the Agents tree only +reads `name` today. Reviewer confirmed. Plan already reflects this — no change. From 99793afd40e1dec5753baaa0209c4c6a981f22e1 Mon Sep 17 00:00:00 2001 From: Amr Elsayed Date: Sat, 27 Jun 2026 22:13:50 +1000 Subject: [PATCH 06/29] chore(porch): 1104 plan-approval gate-approved --- .../projects/1104-vscode-merge-architects-builde/status.yaml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/codev/projects/1104-vscode-merge-architects-builde/status.yaml b/codev/projects/1104-vscode-merge-architects-builde/status.yaml index 9174f7dac..742878acb 100644 --- a/codev/projects/1104-vscode-merge-architects-builde/status.yaml +++ b/codev/projects/1104-vscode-merge-architects-builde/status.yaml @@ -6,8 +6,9 @@ plan_phases: [] current_plan_phase: null gates: plan-approval: - status: pending + status: approved requested_at: '2026-06-27T12:00:20.441Z' + approved_at: '2026-06-27T12:13:50.827Z' dev-approval: status: pending pr: @@ -16,4 +17,4 @@ iteration: 1 build_complete: false history: [] started_at: '2026-06-27T11:55:09.921Z' -updated_at: '2026-06-27T12:00:20.442Z' +updated_at: '2026-06-27T12:13:50.828Z' From cfd6612d8954b3f47118b03b0f4da6519fa18033 Mon Sep 17 00:00:00 2001 From: Amr Elsayed Date: Sat, 27 Jun 2026 22:14:00 +1000 Subject: [PATCH 07/29] chore(porch): 1104 implement phase-transition --- .../projects/1104-vscode-merge-architects-builde/status.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/codev/projects/1104-vscode-merge-architects-builde/status.yaml b/codev/projects/1104-vscode-merge-architects-builde/status.yaml index 742878acb..d5e5feaaa 100644 --- a/codev/projects/1104-vscode-merge-architects-builde/status.yaml +++ b/codev/projects/1104-vscode-merge-architects-builde/status.yaml @@ -1,7 +1,7 @@ id: '1104' title: vscode-merge-architects-builde protocol: pir -phase: plan +phase: implement plan_phases: [] current_plan_phase: null gates: @@ -17,4 +17,4 @@ iteration: 1 build_complete: false history: [] started_at: '2026-06-27T11:55:09.921Z' -updated_at: '2026-06-27T12:13:50.828Z' +updated_at: '2026-06-27T12:14:00.246Z' From b7e738f21857d1dd2601ceae03d2d32eed80b2df Mon Sep 17 00:00:00 2001 From: Amr Elsayed Date: Sat, 27 Jun 2026 22:37:51 +1000 Subject: [PATCH 08/29] [PIR #1104] Enrich /api/overview with the architect roster (shared collectArchitects) --- .../agent-farm/__tests__/tower-routes.test.ts | 42 ++++++++++++ .../codev/src/agent-farm/servers/overview.ts | 6 +- .../src/agent-farm/servers/tower-routes.ts | 66 ++++++++++++------- .../__tests__/useOverview.stability.test.ts | 1 + .../__tests__/useSSE.reconnect.test.ts | 1 + packages/types/src/api.ts | 12 ++++ 6 files changed, 105 insertions(+), 23 deletions(-) diff --git a/packages/codev/src/agent-farm/__tests__/tower-routes.test.ts b/packages/codev/src/agent-farm/__tests__/tower-routes.test.ts index 71081d9bb..663f0e5d6 100644 --- a/packages/codev/src/agent-farm/__tests__/tower-routes.test.ts +++ b/packages/codev/src/agent-farm/__tests__/tower-routes.test.ts @@ -1022,6 +1022,48 @@ describe('tower-routes', () => { expect(statusCode()).toBe(200); expect(mockOverviewGetOverview).toHaveBeenCalledWith('/my/workspace', expect.any(Set)); }); + + it('enriches the payload with the architect roster, main-first, dead sessions skipped (Issue 1104)', async () => { + // Roster registration order is vscode → main → dead; `main` must surface + // at index 0 and the dead (sessionless) registration must be dropped. + mockGetRehydratedTerminalsEntry.mockResolvedValueOnce({ + architects: new Map([['vscode', 't-vscode'], ['main', 't-main'], ['dead', 't-dead']]), + builders: new Map(), + shells: new Map(), + fileTabs: new Map(), + }); + mockGetTerminalManager.mockReturnValue({ getSession: mockGetSession }); + mockGetSession.mockImplementation((id: string) => + id === 't-dead' ? undefined : { pid: 100, lastDataAt: 0 }); + mockIsSessionPersistent.mockReturnValue(false); + mockOverviewGetOverview.mockResolvedValueOnce({ builders: [], pendingPRs: [], backlog: [] }); + + const req = makeReq('GET', '/api/overview?workspace=/test/workspace'); + const { res, statusCode, body } = makeRes(); + await handleRequest(req, res, makeCtx()); + + expect(statusCode()).toBe(200); + const parsed = JSON.parse(body()); + expect(parsed.architects.map((a: { name: string }) => a.name)).toEqual(['main', 'vscode']); + }); + + it('emits an empty architect roster when the workspace has no architects (Issue 1104)', async () => { + mockGetRehydratedTerminalsEntry.mockResolvedValueOnce({ + architects: new Map(), + builders: new Map(), + shells: new Map(), + fileTabs: new Map(), + }); + mockGetTerminalManager.mockReturnValue({ getSession: mockGetSession }); + mockOverviewGetOverview.mockResolvedValueOnce({ builders: [], pendingPRs: [], backlog: [] }); + + const req = makeReq('GET', '/api/overview?workspace=/test/workspace'); + const { res, statusCode, body } = makeRes(); + await handleRequest(req, res, makeCtx()); + + expect(statusCode()).toBe(200); + expect(JSON.parse(body()).architects).toEqual([]); + }); }); describe('POST /api/overview/refresh', () => { diff --git a/packages/codev/src/agent-farm/servers/overview.ts b/packages/codev/src/agent-farm/servers/overview.ts index fb520ae9f..5907a7fa5 100644 --- a/packages/codev/src/agent-farm/servers/overview.ts +++ b/packages/codev/src/agent-farm/servers/overview.ts @@ -957,7 +957,11 @@ export class OverviewCache { }); } - const result: OverviewData = { builders, pendingPRs, backlog, recentlyClosed }; + // `architects` defaults to `[]` here — the filesystem/git-derived overview + // has no view of the live terminal roster. `handleOverview` (tower-routes.ts) + // injects the real roster via `collectArchitects` before serialization, + // mirroring how it enriches `lastDataAt`. + const result: OverviewData = { builders, pendingPRs, backlog, recentlyClosed, architects: [] }; if (currentUser) { result.currentUser = currentUser; } diff --git a/packages/codev/src/agent-farm/servers/tower-routes.ts b/packages/codev/src/agent-farm/servers/tower-routes.ts index 62dbd5555..2fd439284 100644 --- a/packages/codev/src/agent-farm/servers/tower-routes.ts +++ b/packages/codev/src/agent-farm/servers/tower-routes.ts @@ -30,7 +30,8 @@ import type { PtySessionInfo } from '../../terminal/pty-session.js'; import type { BuilderSpawnedPayload, DashboardState, ArchitectState, TowerVersionInfo } from '@cluesmith/codev-types'; import { getBuilders, setArchitectByName } from '../state.js'; import { DEFAULT_COLS, defaultSessionOptions } from '../../terminal/index.js'; -import type { SSEClient } from './tower-types.js'; +import type { SSEClient, WorkspaceTerminals } from './tower-types.js'; +import type { TerminalManager } from '../../terminal/pty-manager.js'; import { parseJsonBody, isRequestAllowed } from '../utils/server-utils.js'; import { isRateLimited, @@ -861,6 +862,38 @@ async function handleStatus(res: http.ServerResponse): Promise { res.end(JSON.stringify({ instances })); } +/** + * Build the `ArchitectState[]` roster from a workspace's (rehydrated) terminals + * entry: one entry per registered architect whose PtySession is live (stale or + * racing registrations are skipped), with `main` moved to index 0 so consumers + * can rely on `architects[0]` as the default architect. + * + * Single source of truth (Issue 1104) shared by the dashboard-state handler + * (`/api/state`) and the overview handler (`/api/overview`), so the two payloads + * carry an identical roster and can't drift. Extracted verbatim from the + * dashboard-state builder's former inline loop. + */ +function collectArchitects(entry: WorkspaceTerminals, manager: TerminalManager): ArchitectState[] { + const collected: ArchitectState[] = []; + for (const [architectName, terminalId] of entry.architects) { + const session = manager.getSession(terminalId); + if (!session) continue; + collected.push({ + name: architectName, + port: 0, + pid: session.pid || 0, + terminalId, + persistent: isSessionPersistent(terminalId, session), + }); + } + const mainIdx = collected.findIndex(a => a.name === 'main'); + if (mainIdx > 0) { + const [mainEntry] = collected.splice(mainIdx, 1); + collected.unshift(mainEntry); + } + return collected; +} + async function handleOverview(res: http.ServerResponse, url: URL, workspaceOverride?: string, ctx?: RouteContext): Promise { // Accept workspace from: explicit override (workspace-scoped route), ?workspace= param, or first known path. let workspaceRoot = workspaceOverride || url.searchParams.get('workspace'); @@ -909,6 +942,12 @@ async function handleOverview(res: http.ServerResponse, url: URL, workspaceOverr builder.lastDataAt = new Date(ptySession.lastDataAt).toISOString(); } + // Issue 1104: enrich with the live architect roster (main-first) so the + // VSCode Agents tree can render its architect tier and attribution badge + // straight off the overview cache. Same `collectArchitects` helper (and so + // the same roster) the dashboard-state handler uses. + data.architects = collectArchitects(entry, terminalManager); + res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(data)); } @@ -1814,29 +1853,12 @@ async function handleWorkspaceState( teamEnabled: await hasTeam(path.join(workspacePath, 'codev', 'team')), }; - // Spec 761: build the architects collection from entry.architects. - // - Skip entries whose session is unavailable (race / stale registration). - // - Move 'main' to index 0 when present so consumers can rely on - // architects[0] as the default architect. + // Spec 761: build the architects collection from entry.architects (skip dead + // sessions, main-first). Shared with /api/overview via `collectArchitects` + // (Issue 1104) so both payloads carry an identical roster. // Spec 755: the scalar `state.architect` is preserved as a backward-compat // pointer to the same default architect (architects[0] when present). - const collectedArchitects: ArchitectState[] = []; - for (const [architectName, terminalId] of entry.architects) { - const session = manager.getSession(terminalId); - if (!session) continue; - collectedArchitects.push({ - name: architectName, - port: 0, - pid: session.pid || 0, - terminalId, - persistent: isSessionPersistent(terminalId, session), - }); - } - const mainIdx = collectedArchitects.findIndex(a => a.name === 'main'); - if (mainIdx > 0) { - const [mainEntry] = collectedArchitects.splice(mainIdx, 1); - collectedArchitects.unshift(mainEntry); - } + const collectedArchitects = collectArchitects(entry, manager); state.architects = collectedArchitects; state.architect = collectedArchitects[0] ?? null; diff --git a/packages/dashboard/__tests__/useOverview.stability.test.ts b/packages/dashboard/__tests__/useOverview.stability.test.ts index 14a79357b..ad1f5d1af 100644 --- a/packages/dashboard/__tests__/useOverview.stability.test.ts +++ b/packages/dashboard/__tests__/useOverview.stability.test.ts @@ -23,6 +23,7 @@ const makeOverview = (backlogTitle = 'Fix login bug'): OverviewData => ({ }, ], recentlyClosed: [], + architects: [], }); // Mock api module — control what fetchOverview returns per call diff --git a/packages/dashboard/__tests__/useSSE.reconnect.test.ts b/packages/dashboard/__tests__/useSSE.reconnect.test.ts index 87a9fd4c5..cd05d1e44 100644 --- a/packages/dashboard/__tests__/useSSE.reconnect.test.ts +++ b/packages/dashboard/__tests__/useSSE.reconnect.test.ts @@ -50,6 +50,7 @@ const MOCK_OVERVIEW: OverviewData = { pendingPRs: [], backlog: [], recentlyClosed: [], + architects: [], }; function simulateSSEMessage(data: Record = { type: 'connected' }): void { diff --git a/packages/types/src/api.ts b/packages/types/src/api.ts index c2f8d0f50..c890ec58f 100644 --- a/packages/types/src/api.ts +++ b/packages/types/src/api.ts @@ -285,6 +285,18 @@ export interface OverviewData { pendingPRs: OverviewPR[]; backlog: OverviewBacklogItem[]; recentlyClosed: OverviewRecentlyClosed[]; + /** + * Registered architects for the workspace (Issue 1104), main-first. Carries + * the same `ArchitectState[]` shape `DashboardState.architects` exposes, + * built by the shared `collectArchitects` helper from the live terminal + * roster, so the overview payload and the dashboard-state payload never + * drift. Only architects with a live session are listed (stale registrations + * are skipped). `[]` when the workspace has no architects or the roster is + * unavailable — never `undefined`, so consumers don't branch. Lets the VSCode + * Agents tree render its architect tier and the architect-attribution badge + * straight off the overview cache without a second fetch. + */ + architects: ArchitectState[]; /** Auto-detected GitHub login of the current user (via the user-identity forge concept). */ currentUser?: string; errors?: { prs?: string; issues?: string }; From 08346c61abfcc0d311d2247a64d703511f4ea24a Mon Sep 17 00:00:00 2001 From: Amr Elsayed Date: Sat, 27 Jun 2026 22:38:07 +1000 Subject: [PATCH 09/29] [PIR #1104] Add adaptive architect tier to the Agents tree --- .../vscode/src/__tests__/agents-tree.test.ts | 175 ++++++++++++++++ .../src/__tests__/architect-grouping.test.ts | 97 +++++++++ .../vscode/src/views/architect-grouping.ts | 86 ++++++++ .../vscode/src/views/builder-tree-item.ts | 74 +++++++ packages/vscode/src/views/builders.ts | 188 +++++++++++++++++- 5 files changed, 611 insertions(+), 9 deletions(-) create mode 100644 packages/vscode/src/__tests__/agents-tree.test.ts create mode 100644 packages/vscode/src/__tests__/architect-grouping.test.ts create mode 100644 packages/vscode/src/views/architect-grouping.ts diff --git a/packages/vscode/src/__tests__/agents-tree.test.ts b/packages/vscode/src/__tests__/agents-tree.test.ts new file mode 100644 index 000000000..41b02f5e0 --- /dev/null +++ b/packages/vscode/src/__tests__/agents-tree.test.ts @@ -0,0 +1,175 @@ +/** + * Unit tests for the adaptive architect tier of the Agents tree (Issue 1104). + * + * Pins the count-check root (single-architect collapse vs architect-rooted), + * the passive-architect leaf rule, the level-2 area/phase delegation under an + * architect, the Unassigned bucket, and the three-level `getParent` chain that + * keeps `reveal` working. Mocks `vscode` per the established `__tests__` pattern + * (see builders-accordion.test.ts). + */ + +import { describe, it, expect, vi } from 'vitest'; +import type { OverviewBuilder, OverviewData, ArchitectState } from '@cluesmith/codev-types'; + +/** Per-test config overrides the mocked `workspace.getConfiguration` reads. */ +const configValues: Record = {}; + +vi.mock('vscode', () => { + class FakeEventEmitter { + private listeners: Array<(e: T) => void> = []; + readonly event = (listener: (e: T) => void): { dispose: () => void } => { + this.listeners.push(listener); + return { dispose: () => { this.listeners = this.listeners.filter((l) => l !== listener); } }; + }; + fire = vi.fn((e: T) => { this.listeners.forEach((l) => l(e)); }); + } + class TreeItem { + id?: string; + tooltip?: string; + description?: string; + contextValue?: string; + iconPath?: unknown; + command?: unknown; + constructor(public label: string, public collapsibleState?: number) {} + } + class ThemeIcon { constructor(public id: string, public color?: unknown) {} } + class ThemeColor { constructor(public id: string) {} } + return { + EventEmitter: FakeEventEmitter, + TreeItem, + ThemeIcon, + ThemeColor, + TreeItemCollapsibleState: { None: 0, Collapsed: 1, Expanded: 2 }, + workspace: { + getConfiguration: () => ({ + get: (key: string, def: unknown) => (key in configValues ? configValues[key] : def), + }), + }, + }; +}); + +const vscode = await import('vscode'); +const { BuildersProvider } = await import('../views/builders.js'); +const { ArchitectGroupTreeItem, BuilderGroupTreeItem, BuilderTreeItem } = await import('../views/builder-tree-item.js'); + +function builder(overrides: Partial): OverviewBuilder { + return { + id: 'pir-1', issueId: '1', issueTitle: 't', phase: 'implement', protocolPhase: 'implement', + mode: 'strict', gates: {}, worktreePath: '/tmp/wt', roleId: null, protocol: 'pir', + planPhases: [], progress: 0, blocked: null, blockedGate: null, blockedSince: null, + startedAt: null, idleMs: 0, lastDataAt: null, spawnedByArchitect: null, + area: 'Uncategorized', prReady: false, + ...overrides, + } as OverviewBuilder; +} + +function arch(name: string): ArchitectState { + return { name, port: 0, pid: 1, terminalId: `t-${name}`, persistent: false }; +} + +function fakeCache(builders: OverviewBuilder[], architects: ArchitectState[]) { + const data = { builders, backlog: [], pendingPRs: [], recentlyClosed: [], architects } as unknown as OverviewData; + return { getData: () => data, onDidChange: () => ({ dispose() {} }) } as never; +} + +const fakeDiffCache = {} as never; + +function makeProvider(builders: OverviewBuilder[], architects: ArchitectState[]) { + return new BuildersProvider(fakeCache(builders, architects), fakeDiffCache); +} + +describe('Agents tree — adaptive root (#1104)', () => { + it('roots at area/phase groups (no architect tier) when one architect', async () => { + const provider = makeProvider( + [builder({ id: 'a', spawnedByArchitect: 'main' })], + [arch('main')], + ); + const roots = await provider.getChildren(); + expect(roots.some(r => r instanceof ArchitectGroupTreeItem)).toBe(false); + }); + + it('roots at area/phase groups when zero architects (today behaviour)', async () => { + const provider = makeProvider([builder({ id: 'a' })], []); + const roots = await provider.getChildren(); + expect(roots.some(r => r instanceof ArchitectGroupTreeItem)).toBe(false); + }); + + it('roots at architect nodes (main-first) when more than one architect', async () => { + const provider = makeProvider( + [ + builder({ id: 'a', spawnedByArchitect: 'main' }), + builder({ id: 'b', spawnedByArchitect: 'vscode' }), + ], + [arch('vscode'), arch('main')], // collectArchitects returns main-first; emulate that order + ); + const roots = await provider.getChildren(); + expect(roots.every(r => r instanceof ArchitectGroupTreeItem)).toBe(true); + expect((roots as Array<{ architectName: string }>).map(r => r.architectName)).toContain('main'); + expect((roots as Array<{ architectName: string }>).map(r => r.architectName)).toContain('vscode'); + }); + + it('renders a passive architect (zero builders) as a leaf, its owner as collapsible', async () => { + const provider = makeProvider( + [builder({ id: 'a', spawnedByArchitect: 'main' })], + [arch('main'), arch('reviewer')], + ); + const roots = await provider.getChildren() as Array<{ architectName: string; collapsibleState?: number }>; + const main = roots.find(r => r.architectName === 'main')!; + const reviewer = roots.find(r => r.architectName === 'reviewer')!; + expect(main.collapsibleState).toBe(vscode.TreeItemCollapsibleState.Collapsed); + expect(reviewer.collapsibleState).toBe(vscode.TreeItemCollapsibleState.None); + }); + + it('an architect node expands to its own builders, partitioned by owner', async () => { + const provider = makeProvider( + [ + builder({ id: 'a', spawnedByArchitect: 'main' }), + builder({ id: 'b', spawnedByArchitect: 'vscode' }), + builder({ id: 'c', spawnedByArchitect: 'main' }), + ], + [arch('main'), arch('vscode')], + ); + const roots = await provider.getChildren() as Array<{ architectName: string }>; + const mainNode = roots.find(r => r.architectName === 'main'); + const groups = await provider.getChildren(mainNode as never); + const rows: unknown[] = []; + for (const g of groups) { rows.push(...await provider.getChildren(g as never)); } + const ids = (rows as Array<{ builderId: string }>).map(r => r.builderId); + expect(ids.sort()).toEqual(['a', 'c']); + expect(rows.every(r => r instanceof BuilderTreeItem)).toBe(true); + }); + + it('collects an orphan builder under a non-interactive Unassigned node', async () => { + const provider = makeProvider( + [ + builder({ id: 'a', spawnedByArchitect: 'main' }), + builder({ id: 'orphan', spawnedByArchitect: null }), + ], + [arch('main'), arch('vscode')], + ); + const roots = await provider.getChildren() as Array<{ architectName: string; contextValue?: string; command?: unknown }>; + const unassigned = roots.find(r => r.contextValue === 'agent-unassigned'); + expect(unassigned).toBeDefined(); + expect(unassigned!.command).toBeUndefined(); // not a real architect → no open-terminal + }); + + it('walks builder → group → architect via getParent (reveal chain)', async () => { + const provider = makeProvider( + [builder({ id: 'a', spawnedByArchitect: 'main', protocolPhase: 'implement' }), + builder({ id: 'z', spawnedByArchitect: 'vscode' })], + [arch('main'), arch('vscode')], + ); + const roots = await provider.getChildren() as Array<{ architectName: string }>; + const mainNode = roots.find(r => r.architectName === 'main')!; + const groups = await provider.getChildren(mainNode as never); + const group = groups[0]; + const rows = await provider.getChildren(group as never); + const row = rows[0]; + + expect(row).toBeInstanceOf(BuilderTreeItem); + expect(group).toBeInstanceOf(BuilderGroupTreeItem); + expect(await provider.getParent(row)).toBe(group); + expect(await provider.getParent(group)).toBe(mainNode); + expect(await provider.getParent(mainNode as never)).toBeUndefined(); + }); +}); diff --git a/packages/vscode/src/__tests__/architect-grouping.test.ts b/packages/vscode/src/__tests__/architect-grouping.test.ts new file mode 100644 index 000000000..9565910b5 --- /dev/null +++ b/packages/vscode/src/__tests__/architect-grouping.test.ts @@ -0,0 +1,97 @@ +/** + * Unit tests for the architect-tier partition helpers (Issue 1104) — the pure, + * vscode-free logic behind the multi-architect Agents tree. + */ + +import { describe, it, expect } from 'vitest'; +import type { OverviewBuilder } from '@cluesmith/codev-types'; +import { + partitionByArchitect, + architectBadge, + UNASSIGNED_ARCHITECT, +} from '../views/architect-grouping.js'; + +function builder(overrides: Partial): OverviewBuilder { + return { + id: 'pir-1', issueId: '1', issueTitle: 't', phase: 'implement', protocolPhase: 'implement', + mode: 'strict', gates: {}, worktreePath: '/tmp/wt', roleId: null, protocol: 'pir', + planPhases: [], progress: 0, blocked: null, blockedGate: null, blockedSince: null, + startedAt: null, idleMs: 0, lastDataAt: null, spawnedByArchitect: null, + area: 'Uncategorized', prReady: false, + ...overrides, + } as OverviewBuilder; +} + +describe('partitionByArchitect (#1104)', () => { + it('buckets builders under their owning architect, roster order preserved', () => { + const builders = [ + builder({ id: 'a', spawnedByArchitect: 'main' }), + builder({ id: 'b', spawnedByArchitect: 'vscode' }), + builder({ id: 'c', spawnedByArchitect: 'main' }), + ]; + const parts = partitionByArchitect(builders, ['main', 'vscode']); + expect(parts.map(p => p.name)).toEqual(['main', 'vscode']); + expect(parts[0].builders.map(b => b.id)).toEqual(['a', 'c']); + expect(parts[1].builders.map(b => b.id)).toEqual(['b']); + expect(parts.every(p => p.interactive)).toBe(true); + }); + + it('keeps a passive architect (zero builders) as an interactive empty partition', () => { + const parts = partitionByArchitect( + [builder({ id: 'a', spawnedByArchitect: 'main' })], + ['main', 'reviewer'], + ); + const reviewer = parts.find(p => p.name === 'reviewer'); + expect(reviewer).toBeDefined(); + expect(reviewer!.builders).toEqual([]); + expect(reviewer!.interactive).toBe(true); + }); + + it('collects null-owner builders under a trailing, non-interactive Unassigned bucket', () => { + const builders = [ + builder({ id: 'a', spawnedByArchitect: 'main' }), + builder({ id: 'orphan', spawnedByArchitect: null }), + ]; + const parts = partitionByArchitect(builders, ['main']); + const last = parts[parts.length - 1]; + expect(last.name).toBe(UNASSIGNED_ARCHITECT); + expect(last.interactive).toBe(false); + expect(last.builders.map(b => b.id)).toEqual(['orphan']); + }); + + it('routes a builder whose owner is no longer in the roster to Unassigned', () => { + // Architect removed after spawn: the row would otherwise vanish. + const builders = [builder({ id: 'stale', spawnedByArchitect: 'gone' })]; + const parts = partitionByArchitect(builders, ['main']); + expect(parts.find(p => p.name === UNASSIGNED_ARCHITECT)?.builders.map(b => b.id)).toEqual(['stale']); + }); + + it('emits no Unassigned bucket when every builder is owned', () => { + const parts = partitionByArchitect( + [builder({ id: 'a', spawnedByArchitect: 'main' })], + ['main'], + ); + expect(parts.some(p => p.name === UNASSIGNED_ARCHITECT)).toBe(false); + }); +}); + +describe('architectBadge (#1104)', () => { + const b = builder({ spawnedByArchitect: 'vscode' }); + + it('is empty in single-architect workspaces (one architect, badge = noise)', () => { + expect(architectBadge(b, 1, false)).toBe(''); + }); + + it('is empty when the owning architect is already the row ancestor', () => { + expect(architectBadge(b, 3, true)).toBe(''); + }); + + it('shows the owning architect when multi-architect AND not an ancestor', () => { + // The Unassigned-bucket case: a stale owner still surfaces as attribution. + expect(architectBadge(b, 3, false)).toBe('vscode'); + }); + + it('is empty for a truly ownerless builder even when surfaced detached', () => { + expect(architectBadge(builder({ spawnedByArchitect: null }), 3, false)).toBe(''); + }); +}); diff --git a/packages/vscode/src/views/architect-grouping.ts b/packages/vscode/src/views/architect-grouping.ts new file mode 100644 index 000000000..8320f2c2f --- /dev/null +++ b/packages/vscode/src/views/architect-grouping.ts @@ -0,0 +1,86 @@ +/** + * Architect-tier partition for the Agents tree (Issue 1104) — the outer level-1 + * wrapper around the existing per-axis grouping strategies (`builder-grouping.ts`), + * which stay one level deep and untouched. This module owns only "which builder + * belongs to which architect"; the area/phase sub-grouping below an architect is + * still the existing strategy's job. + * + * Pure / vscode-free, so it's unit-testable under the vitest `__tests__/` + * harness (mirrors `builder-grouping.ts` / `builder-row.ts`). + */ + +import type { OverviewBuilder } from '@cluesmith/codev-types'; + +/** + * Sentinel "architect" key for builders whose `spawnedByArchitect` is `null` + * (legacy / pre-#755 rows, or a missing state.db row) OR whose owner is no + * longer in the workspace roster (architect removed). In the multi-architect + * tree these collect under one trailing "Unassigned" node so a builder is never + * silently dropped just because Tower can't attribute it. Not a real architect — + * its node carries no open-terminal command and no remove action. + */ +export const UNASSIGNED_ARCHITECT = 'unassigned'; + +/** One architect's slice of the builder list for the level-1 Agents tier. */ +export interface ArchitectPartition { + /** Architect name (canonical lowercase), or `UNASSIGNED_ARCHITECT`. */ + name: string; + /** + * `false` only for the Unassigned bucket — drives the node's non-interactive + * (no open-terminal, no remove) rendering. `true` for every real architect, + * including passive ones with zero builders. + */ + interactive: boolean; + /** This architect's builders, in the caller's display order. */ + builders: OverviewBuilder[]; +} + +/** + * Partition already-ordered builders under architects, roster order preserved + * (`architectNames` is main-first as Tower returns it). Every roster architect + * gets a partition — including passive ones, which get an empty `builders` list + * so they still render as leaf rows. Builders whose `spawnedByArchitect` is + * null/unknown or names an architect absent from the roster collect under a + * single trailing `UNASSIGNED_ARCHITECT` partition, emitted only when non-empty. + * + * `ordered` should already be `orderForDisplay`-sorted; the per-architect + * `filter` preserves that order within each bucket. + */ +export function partitionByArchitect( + ordered: OverviewBuilder[], + architectNames: string[], +): ArchitectPartition[] { + const roster = new Set(architectNames); + const partitions: ArchitectPartition[] = architectNames.map(name => ({ + name, + interactive: true, + builders: ordered.filter(b => b.spawnedByArchitect === name), + })); + + const unowned = ordered.filter( + b => !b.spawnedByArchitect || !roster.has(b.spawnedByArchitect), + ); + if (unowned.length > 0) { + partitions.push({ name: UNASSIGNED_ARCHITECT, interactive: false, builders: unowned }); + } + return partitions; +} + +/** + * Whether the owning architect should be surfaced as a dim `description` badge + * on a builder row (Issue 1104). True only when the workspace has more than one + * architect AND the architect is NOT already the row's ancestor in the tree — + * so single-architect workspaces stay clean (the badge would just repeat the + * lone architect on every row), and the nested multi-architect tree stays clean + * too (the architect node is already the ancestor). Returns the badge text, or + * `''` to render no badge. + */ +export function architectBadge( + b: OverviewBuilder, + architectCount: number, + ownerIsAncestor: boolean, +): string { + if (architectCount <= 1) { return ''; } + if (ownerIsAncestor) { return ''; } + return b.spawnedByArchitect ?? ''; +} diff --git a/packages/vscode/src/views/builder-tree-item.ts b/packages/vscode/src/views/builder-tree-item.ts index a08bd69d1..06abfa746 100644 --- a/packages/vscode/src/views/builder-tree-item.ts +++ b/packages/vscode/src/views/builder-tree-item.ts @@ -1,6 +1,7 @@ import * as vscode from 'vscode'; import { AreaGroupTreeItem } from './area-group-tree-item.js'; import { BUILDER_STATE_GLYPH, worstBuilderState, type GroupRollup } from './builder-row.js'; +import { displayArchitectName } from './architect-display.js'; /** * TreeItem subclass that carries a builder id as a typed field. @@ -50,10 +51,83 @@ export class BuilderGroupTreeItem extends AreaGroupTreeItem { count: number, collapsibleState: vscode.TreeItemCollapsibleState, rollup: GroupRollup, + /** + * Owning architect's name when this group sits under an architect node in + * the multi-architect Agents tree (Issue 1104), else `undefined` for the + * single-architect root-level grouping. When set it namespaces the row id + * so two architects' identically-keyed groups (e.g. both own a `TOWER` + * group) don't collide on VSCode's id-keyed expansion state, and lets + * `getChildren` filter that group's rows to the right architect. + */ + public readonly architectName?: string, ) { super(groupName, 'builder', count, collapsibleState); + if (architectName) { + this.id = `builder-group:${architectName}:${groupName}`; + } const { icon, color } = BUILDER_STATE_GLYPH[worstBuilderState(rollup)]; this.iconPath = new vscode.ThemeIcon(icon, new vscode.ThemeColor(color)); this.tooltip = `${rollup.blocked} blocked · ${rollup.idle} waiting · ${rollup.active} active`; } } + +/** + * Architect-tier node — the level-1 grouping in the Agents tree when the + * workspace hosts more than one architect (Issue 1104). Renders the architect's + * (uppercased) name and, when it owns builders, a worst-of rollup glyph + count + * over ALL builders beneath it (summed across its area/phase sub-groups) — the + * same `BUILDER_STATE_GLYPH` vocabulary and `" blocked · waiting · + * active"` tooltip as `BuilderGroupTreeItem`, one tier up. + * + * Two shapes: + * - **Owns builders** → `Collapsed`, rollup glyph, click opens that architect's + * terminal. Siblings carry a `-sibling` contextValue (Remove menu); `main` + * carries `-main` (undeletable). + * - **Passive** (REVIEWER pattern: zero builders) → `None` leaf with a neutral + * `person` icon, so it reads as an interactive-but-empty identity row rather + * than a falsely-green "active" dot. Still clickable to open its terminal. + * + * The synthetic "Unassigned" bucket (`UNASSIGNED_ARCHITECT`) is not a real + * architect: `interactive: false` drops the open-terminal command and the + * remove menu, and it gets a neutral `question` icon. + */ +export class ArchitectGroupTreeItem extends vscode.TreeItem { + constructor( + public readonly architectName: string, + builderCount: number, + rollup: GroupRollup, + interactive: boolean = true, + ) { + const hasBuilders = builderCount > 0; + super( + hasBuilders ? `${displayArchitectName(architectName)} (${builderCount})` : displayArchitectName(architectName), + hasBuilders ? vscode.TreeItemCollapsibleState.Collapsed : vscode.TreeItemCollapsibleState.None, + ); + this.id = `agent-architect:${architectName}`; + + if (!interactive) { + // Unassigned bucket: neutral, non-clickable, no remove. + this.iconPath = new vscode.ThemeIcon('question', new vscode.ThemeColor('disabledForeground')); + this.tooltip = `${rollup.blocked} blocked · ${rollup.idle} waiting · ${rollup.active} active`; + this.contextValue = 'agent-unassigned'; + return; + } + + if (hasBuilders) { + const { icon, color } = BUILDER_STATE_GLYPH[worstBuilderState(rollup)]; + this.iconPath = new vscode.ThemeIcon(icon, new vscode.ThemeColor(color)); + this.tooltip = `${rollup.blocked} blocked · ${rollup.idle} waiting · ${rollup.active} active`; + } else { + this.iconPath = new vscode.ThemeIcon('person'); + this.tooltip = `${displayArchitectName(architectName)} — no builders`; + } + // `main` is workspace-defining and undeletable; siblings expose Remove via + // the package.json menus contribution (mirrors Workspace > Architects). + this.contextValue = architectName === 'main' ? 'agent-architect-main' : 'agent-architect-sibling'; + this.command = { + command: 'codev.openArchitectTerminal', + title: 'Open Architect Terminal', + arguments: [architectName], + }; + } +} diff --git a/packages/vscode/src/views/builders.ts b/packages/vscode/src/views/builders.ts index 5a69489f2..198b16dde 100644 --- a/packages/vscode/src/views/builders.ts +++ b/packages/vscode/src/views/builders.ts @@ -7,7 +7,12 @@ import { UNCATEGORIZED_AREA } from '@cluesmith/codev-core/constants'; import type { OverviewCache } from './overview-data.js'; import { builderWithWorktree, type OverviewBuilderWithWorktree } from '../builder-lookup.js'; import { readBuildersFileViewAsTree } from '../builders-config.js'; -import { BuilderGroupTreeItem, BuilderTreeItem } from './builder-tree-item.js'; +import { ArchitectGroupTreeItem, BuilderGroupTreeItem, BuilderTreeItem } from './builder-tree-item.js'; +import { + partitionByArchitect, + architectBadge, + type ArchitectPartition, +} from './architect-grouping.js'; import { BuilderFileTreeItem } from './builder-file-tree-item.js'; import { BuilderFolderTreeItem } from './builder-folder-tree-item.js'; import { buildFilePathTree, type FilePathNode } from './file-path-tree.js'; @@ -95,6 +100,21 @@ export class BuildersProvider implements vscode.TreeDataProvider(); + // Multi-architect tier (Issue 1104). Populated by `multiArchRoot` when + // `architectCount > 1`; empty (and unused) in the single-architect / zero + // path, which keeps today's two-level behaviour bit-for-bit. Precomputed at + // root render (not lazily) so `getParent` can walk builder → group → + // architect for `reveal` before any node is expanded. + // - architectChildren: architect-node id → its level-2 children (group + // nodes, or flattened builder rows in the lone-Uncategorized case). + // - groupRows: namespaced group-node id → its builder rows. + // - multiArchBuilderParent: builder id → its parent row (a group node, or + // the architect node directly in the flattened case). + // - multiArchGroupParent: group-node id → its owning architect node. + private architectChildren = new Map(); + private groupRows = new Map(); + private multiArchBuilderParent = new Map(); + private multiArchGroupParent = new Map(); // Accordion row-id versioning (#913) — see AccordionRowIds. private readonly rowIds = new AccordionRowIds(); @@ -160,7 +180,21 @@ export class BuildersProvider implements vscode.TreeDataProvider { if (element instanceof BuilderTreeItem) { - return this.groupParentByBuilderId.get(element.builderId); + // Multi-architect: builder → group node (or architect node when the + // architect's builders flattened). Single-architect: builder → group + // node (or undefined in the lone-Uncategorized flatten). The multi-arch + // map is empty in single-architect mode, so the fallback is today's path. + return this.multiArchBuilderParent.get(element.builderId) + ?? this.groupParentByBuilderId.get(element.builderId); + } + // Multi-architect group node → its owning architect node. Single-architect + // group nodes are roots (architectName undefined → not in the map). + if (element instanceof BuilderGroupTreeItem) { + return this.multiArchGroupParent.get(element.id as string); + } + // Architect-tier nodes are roots. + if (element instanceof ArchitectGroupTreeItem) { + return undefined; } if (element instanceof BuilderFileTreeItem || element instanceof BuilderFolderTreeItem) { return this.parentForFileNode(element); @@ -255,24 +289,65 @@ export class BuildersProvider implements vscode.TreeDataProvider 1) { + return this.multiArchRoot(data.builders, architects.map(a => a.name), now); + } + return this.singleArchRoot(orderForDisplay(data.builders, now), now); + } + + /** Clear every per-render tier map (both single- and multi-architect). */ + private clearTierMaps(): void { + this.groupParentByBuilderId.clear(); + this.architectChildren.clear(); + this.groupRows.clear(); + this.multiArchBuilderParent.clear(); + this.multiArchGroupParent.clear(); + } + + /** + * Single-architect (or zero-architect) root: today's area/phase grouping, + * unchanged. Returns flattened builder rows for the lone-Uncategorized case, + * else `BuilderGroupTreeItem` headers (each rendered Expanded — #913). + */ + private singleArchRoot(ordered: OverviewBuilder[], now: number): vscode.TreeItem[] { const grouping = this.active(); - const ordered = orderForDisplay(data.builders, now); const groups = grouping.group(ordered); // A repo that doesn't use `area/*` labels yields a single `Uncategorized` @@ -280,14 +355,12 @@ export class BuildersProvider implements vscode.TreeDataProvider this.makeBuilderRow(b, now)); } // Groups always render Expanded (#913) — no persisted state. VSCode's // native per-id memory keeps a user-collapsed group collapsed for the // rest of the session; on a fresh session this default applies again. - this.groupParentByBuilderId.clear(); return groups.map(g => { const groupItem = new BuilderGroupTreeItem( g.key, @@ -302,6 +375,94 @@ export class BuildersProvider implements vscode.TreeDataProvider this.makeArchitectNode(p, architectNames.length, now)); + } + + /** + * Build one architect node and precompute its level-2 children (group nodes, + * or flattened builder rows when its slice is a lone Uncategorized group), + * wiring the parent maps as it goes. A passive architect (empty slice) becomes + * a leaf node with no children. + */ + private makeArchitectNode( + partition: ArchitectPartition, + architectCount: number, + now: number, + ): ArchitectGroupTreeItem { + const node = new ArchitectGroupTreeItem( + partition.name, + partition.builders.length, + rollupGroupState(partition.builders, now), + partition.interactive, + ); + + if (partition.builders.length === 0) { + return node; // passive architect → leaf, no children + } + + // For a real architect the node IS the row's ancestor, so the attribution + // badge is suppressed; under "Unassigned" it is not, so a builder whose + // owner was removed still shows its stale architect name (`architectBadge`). + const ownerIsAncestor = partition.interactive; + const rowOf = (b: OverviewBuilder) => + this.makeBuilderRow(b, now, architectBadge(b, architectCount, ownerIsAncestor)); + + const grouping = this.active(); + const groups = grouping.group(partition.builders); + const nodeId = node.id as string; + + // Lone-Uncategorized: flatten to builder rows directly under the architect + // (same rule as the single-architect root, one tier down). The architect + // node is then the builders' direct parent. + if (grouping.flattenLoneUncategorized && groups.length === 1 && groups[0].key === UNCATEGORIZED_AREA) { + const rows = groups[0].items.map(rowOf); + for (const b of groups[0].items) { + this.multiArchBuilderParent.set(b.id, node); + } + this.architectChildren.set(nodeId, rows); + return node; + } + + const groupNodes = groups.map(g => { + const groupItem = new BuilderGroupTreeItem( + g.key, + g.items.length, + vscode.TreeItemCollapsibleState.Expanded, + rollupGroupState(g.items, now), + partition.name, + ); + const groupId = groupItem.id as string; + this.multiArchGroupParent.set(groupId, node); + const rows = g.items.map(rowOf); + for (const b of g.items) { + this.multiArchBuilderParent.set(b.id, groupItem); + } + this.groupRows.set(groupId, rows); + return groupItem; + }); + this.architectChildren.set(nodeId, groupNodes); + return node; + } + private rowsForGroup(key: string): vscode.TreeItem[] { const data = this.cache.getData(); if (!data) { return []; } @@ -314,10 +475,19 @@ export class BuildersProvider implements vscode.TreeDataProvider this.makeBuilderRow(b, now)); } - private makeBuilderRow(b: OverviewBuilder, now: number): BuilderTreeItem { + private makeBuilderRow(b: OverviewBuilder, now: number, descriptionBadge: string = ''): BuilderTreeItem { const isBlocked = !!b.blocked; const isIdle = !isBlocked && isIdleWaiting(b, now); const item = new BuilderTreeItem(b.id, builderRowLabel(b, isIdle, now, this.active().rowPrefix(b))); + // Architect-attribution badge (Issue 1104): the dim, right-aligned + // `description` carries the owning architect's name only when it isn't + // already the row's ancestor (see `architectBadge`). Empty in the nested + // multi-architect tree (the architect node is the ancestor) and in + // single-architect workspaces (one architect, repeated badge = noise), so + // it stays unset there. + if (descriptionBadge) { + item.description = descriptionBadge; + } // Versioned id (#913). The base `b.id` is stable (not the churning label) so // VSCode preserves a row's expansion across the frequent overview-poll // refreshes; the `#` suffix is the accordion lever (see From cc4bc4d4b524bd28cc971751d8842ae2121e6c59 Mon Sep 17 00:00:00 2001 From: Amr Elsayed Date: Sat, 27 Jun 2026 22:38:17 +1000 Subject: [PATCH 10/29] [PIR #1104] Conversational Add Architect + rename Builders view to Agents --- packages/vscode/package.json | 40 ++++++------ .../src/__tests__/add-architect.test.ts | 40 ++++++++++++ .../src/__tests__/contributes-panel.test.ts | 2 +- .../extension-architect-commands.test.ts | 37 +++++++++-- .../src/__tests__/menu-when-clauses.test.ts | 2 +- packages/vscode/src/commands/add-architect.ts | 35 +++++++++++ packages/vscode/src/extension.ts | 61 +++++++++++++------ 7 files changed, 173 insertions(+), 44 deletions(-) create mode 100644 packages/vscode/src/__tests__/add-architect.test.ts create mode 100644 packages/vscode/src/commands/add-architect.ts diff --git a/packages/vscode/package.json b/packages/vscode/package.json index 011d83fea..7e116f829 100644 --- a/packages/vscode/package.json +++ b/packages/vscode/package.json @@ -490,62 +490,62 @@ }, { "command": "codev.approveGate", - "when": "view == codev.builders && viewItem =~ /^blocked-builder-/", + "when": "view == codev.agents && viewItem =~ /^blocked-builder-/", "group": "inline@1" }, { "command": "codev.openBuilderById", - "when": "view == codev.builders && viewItem =~ /^(builder|blocked-builder|awaiting-builder)-/", + "when": "view == codev.agents && viewItem =~ /^(builder|blocked-builder|awaiting-builder)-/", "group": "1_primary@1" }, { "command": "codev.approveGate", - "when": "view == codev.builders && viewItem =~ /^blocked-builder-/", + "when": "view == codev.agents && viewItem =~ /^blocked-builder-/", "group": "1_primary@2" }, { "command": "codev.viewSpecFile", - "when": "view == codev.builders && viewItem =~ /^(builder|blocked-builder|awaiting-builder)-(spir|aspir)(-review)?$/", + "when": "view == codev.agents && viewItem =~ /^(builder|blocked-builder|awaiting-builder)-(spir|aspir)(-review)?$/", "group": "1_primary@3" }, { "command": "codev.viewPlanFile", - "when": "view == codev.builders && viewItem =~ /^(builder|blocked-builder|awaiting-builder)-(spir|aspir|pir)(-review)?$/", + "when": "view == codev.agents && viewItem =~ /^(builder|blocked-builder|awaiting-builder)-(spir|aspir|pir)(-review)?$/", "group": "1_primary@4" }, { "command": "codev.viewReviewFile", - "when": "view == codev.builders && viewItem =~ /^(builder|blocked-builder|awaiting-builder)-((spir|aspir|air)(-review)?|pir-review)$/", + "when": "view == codev.agents && viewItem =~ /^(builder|blocked-builder|awaiting-builder)-((spir|aspir|air)(-review)?|pir-review)$/", "group": "1_primary@5" }, { "command": "codev.viewDiff", - "when": "view == codev.builders && viewItem =~ /^(builder|blocked-builder|awaiting-builder)-/", + "when": "view == codev.agents && viewItem =~ /^(builder|blocked-builder|awaiting-builder)-/", "group": "2_worktree@0" }, { "command": "codev.openWorktreeWindow", - "when": "view == codev.builders && viewItem =~ /^(builder|blocked-builder|awaiting-builder)-/", + "when": "view == codev.agents && viewItem =~ /^(builder|blocked-builder|awaiting-builder)-/", "group": "2_worktree@1" }, { "command": "codev.openWorktreeFolder", - "when": "view == codev.builders && viewItem =~ /^(builder|blocked-builder|awaiting-builder)-/", + "when": "view == codev.agents && viewItem =~ /^(builder|blocked-builder|awaiting-builder)-/", "group": "2_worktree@2" }, { "command": "codev.runWorktreeSetup", - "when": "view == codev.builders && viewItem =~ /^(builder|blocked-builder|awaiting-builder)-/", + "when": "view == codev.agents && viewItem =~ /^(builder|blocked-builder|awaiting-builder)-/", "group": "3_dev@1" }, { "command": "codev.runWorktreeDev", - "when": "view == codev.builders && viewItem =~ /^(builder|blocked-builder|awaiting-builder)-/ && codev.hasDevCommand", + "when": "view == codev.agents && viewItem =~ /^(builder|blocked-builder|awaiting-builder)-/ && codev.hasDevCommand", "group": "3_dev@2" }, { "command": "codev.stopWorktreeDev", - "when": "view == codev.builders && viewItem =~ /^(builder|blocked-builder|awaiting-builder)-/ && codev.hasDevCommand", + "when": "view == codev.agents && viewItem =~ /^(builder|blocked-builder|awaiting-builder)-/ && codev.hasDevCommand", "group": "3_dev@3" }, { @@ -592,37 +592,37 @@ "view/title": [ { "command": "codev.refreshOverview", - "when": "view == codev.builders", + "when": "view == codev.agents", "group": "navigation" }, { "command": "codev.disableBuildersAutoCollapse", - "when": "view == codev.builders && codev.buildersAutoCollapse", + "when": "view == codev.agents && codev.buildersAutoCollapse", "group": "navigation" }, { "command": "codev.enableBuildersAutoCollapse", - "when": "view == codev.builders && !codev.buildersAutoCollapse", + "when": "view == codev.agents && !codev.buildersAutoCollapse", "group": "navigation" }, { "command": "codev.disableBuildersFileTreeMode", - "when": "view == codev.builders && codev.buildersFileViewAsTree", + "when": "view == codev.agents && codev.buildersFileViewAsTree", "group": "navigation" }, { "command": "codev.enableBuildersFileTreeMode", - "when": "view == codev.builders && !codev.buildersFileViewAsTree", + "when": "view == codev.agents && !codev.buildersFileViewAsTree", "group": "navigation" }, { "command": "codev.groupBuildersByArea", - "when": "view == codev.builders && codev.buildersGroupBy != 'area'", + "when": "view == codev.agents && codev.buildersGroupBy != 'area'", "group": "navigation" }, { "command": "codev.groupBuildersByPhase", - "when": "view == codev.builders && codev.buildersGroupBy == 'area'", + "when": "view == codev.agents && codev.buildersGroupBy == 'area'", "group": "navigation" }, { @@ -794,7 +794,7 @@ "views": { "codev": [ { "id": "codev.workspace", "name": "Workspace" }, - { "id": "codev.builders", "name": "Builders" }, + { "id": "codev.agents", "name": "Agents" }, { "id": "codev.backlog", "name": "Backlog" }, { "id": "codev.pullRequests", "name": "Pull Requests" }, { "id": "codev.recentlyClosed", "name": "Recently Closed" }, diff --git a/packages/vscode/src/__tests__/add-architect.test.ts b/packages/vscode/src/__tests__/add-architect.test.ts new file mode 100644 index 000000000..cfad234a0 --- /dev/null +++ b/packages/vscode/src/__tests__/add-architect.test.ts @@ -0,0 +1,40 @@ +/** + * Unit tests for the conversational Add Architect helpers (Issue 1104). + */ + +import { describe, it, expect } from 'vitest'; +import type { ArchitectState } from '@cluesmith/codev-types'; +import { + resolveMainArchitect, + addArchitectRequestMessage, + ADD_ARCHITECT_RECIPIENT, +} from '../commands/add-architect.js'; + +function arch(name: string): ArchitectState { + return { name, port: 0, pid: 1, terminalId: `t-${name}`, persistent: false }; +} + +describe('resolveMainArchitect (#1104)', () => { + it('returns the main architect when present', () => { + const main = resolveMainArchitect([arch('vscode'), arch('main'), arch('reviewer')]); + expect(main?.name).toBe('main'); + }); + + it('returns undefined when no main session is in the roster', () => { + expect(resolveMainArchitect([arch('vscode'), arch('reviewer')])).toBeUndefined(); + }); + + it('returns undefined for an empty roster', () => { + expect(resolveMainArchitect([])).toBeUndefined(); + }); +}); + +describe('addArchitectRequestMessage / recipient (#1104)', () => { + it('addresses main explicitly', () => { + expect(ADD_ARCHITECT_RECIPIENT).toBe('architect:main'); + }); + + it('builds a name-only request message', () => { + expect(addArchitectRequestMessage('security')).toBe('Please add a security architect.'); + }); +}); diff --git a/packages/vscode/src/__tests__/contributes-panel.test.ts b/packages/vscode/src/__tests__/contributes-panel.test.ts index 7cf9eebe3..09b85a6d9 100644 --- a/packages/vscode/src/__tests__/contributes-panel.test.ts +++ b/packages/vscode/src/__tests__/contributes-panel.test.ts @@ -59,7 +59,7 @@ describe('codevPanel placeholder view (#812)', () => { const sidebar = (views.codev ?? []).map((v) => v.id); expect(sidebar).toEqual([ 'codev.workspace', - 'codev.builders', + 'codev.agents', 'codev.backlog', 'codev.pullRequests', 'codev.recentlyClosed', diff --git a/packages/vscode/src/__tests__/extension-architect-commands.test.ts b/packages/vscode/src/__tests__/extension-architect-commands.test.ts index 9610a7d7c..10b89c4fe 100644 --- a/packages/vscode/src/__tests__/extension-architect-commands.test.ts +++ b/packages/vscode/src/__tests__/extension-architect-commands.test.ts @@ -62,20 +62,47 @@ describe('Spec 786 Phase 6 — extension.ts architect commands', () => { }); it('codev.addArchitect is registered and validates with the shared rule', () => { - // Gap 1: the new UI command. Validates via the codev-core validator (same - // rule Tower enforces) and refreshes the tree on success. + // Issue 1104: the command is now CONVERSATIONAL — it still validates the + // name via the codev-core validator (parity with the CLI) but no longer + // creates the architect directly. expect(EXT_SRC).toMatch(/(?:registerCommand|regCli)\(['"]codev\.addArchitect['"]/); const addBlock = EXT_SRC.split("regCli('codev.addArchitect'")[1] ?? ''; expect(addBlock).toMatch(/showInputBox/); expect(addBlock).toMatch(/validateInput:.*validateArchitectName/); - expect(addBlock).toMatch(/client\.addArchitect\(/); - expect(addBlock).toMatch(/workspaceProvider\.refresh\(\)/); }); - it('codev.addArchitect imports validateArchitectName from codev-core (single source)', () => { + it('codev.addArchitect routes the request to main instead of creating directly (Issue 1104)', () => { + // The handler resolves main from the live roster and dispatches the request + // via sendMessage to the `architect:main` recipient — NOT a direct + // client.addArchitect / REST creation from the sidebar. + const addBlock = EXT_SRC.split("regCli('codev.addArchitect'")[1] ?? ''; + expect(addBlock).toMatch(/resolveMainArchitect\(/); + expect(addBlock).toMatch(/client\.sendMessage\(/); + expect(addBlock).toMatch(/ADD_ARCHITECT_RECIPIENT|architect:main/); + expect(addBlock).toMatch(/addArchitectRequestMessage\(/); + // It must NOT fall back to direct creation from the sidebar `+`. + const beforeRemove = addBlock.split("regCli('codev.removeArchitect'")[0] ?? ''; + expect(beforeRemove).not.toMatch(/client\.addArchitect\(/); + }); + + it('codev.addArchitect refuses when no main architect is active, with a modal CLI fallback', () => { + // Main is the workspace orchestrator; if no main session is running there is + // nothing to ask, so the action refuses (modal) rather than silently + // creating an unbriefed architect. The modal points at the CLI fallback. + const addBlock = (EXT_SRC.split("regCli('codev.addArchitect'")[1] ?? '') + .split("regCli('codev.removeArchitect'")[0] ?? ''; + expect(addBlock).toMatch(/if \(!main\)/); + expect(addBlock).toMatch(/modal: true/); + expect(addBlock).toMatch(/add-architect/); + }); + + it('codev.addArchitect imports its helpers from the pure module (single source)', () => { expect(EXT_SRC).toMatch( /import \{ validateArchitectName \} from ['"]@cluesmith\/codev-core\/architect-name['"]/ ); + expect(EXT_SRC).toMatch( + /import \{ resolveMainArchitect, addArchitectRequestMessage, ADD_ARCHITECT_RECIPIENT \} from ['"]\.\/commands\/add-architect\.js['"]/ + ); }); it("codev.removeArchitect resolves the raw name from item.id, not the (uppercased) label", () => { diff --git a/packages/vscode/src/__tests__/menu-when-clauses.test.ts b/packages/vscode/src/__tests__/menu-when-clauses.test.ts index d4eff8350..baeaea6f0 100644 --- a/packages/vscode/src/__tests__/menu-when-clauses.test.ts +++ b/packages/vscode/src/__tests__/menu-when-clauses.test.ts @@ -153,7 +153,7 @@ describe('codev.hasDevCommand gating for dev-server commands', () => { for (const cmd of ['codev.runWorktreeDev', 'codev.stopWorktreeDev']) { it(`${cmd} builder-row menu entry is gated by codev.hasDevCommand`, () => { const entry = viewItemMenuEntries.find( - e => e.command === cmd && e.when.includes('view == codev.builders')); + e => e.command === cmd && e.when.includes('view == codev.agents')); expect(entry, `builders view/item/context entry for ${cmd}`).toBeDefined(); expect(entry!.when, `${cmd} when-clause`).toContain('&& codev.hasDevCommand'); }); diff --git a/packages/vscode/src/commands/add-architect.ts b/packages/vscode/src/commands/add-architect.ts new file mode 100644 index 000000000..c4417b39b --- /dev/null +++ b/packages/vscode/src/commands/add-architect.ts @@ -0,0 +1,35 @@ +/** + * Pure helpers for the conversational `Codev: Add Architect` flow (Issue 1104). + * + * Architect creation is no longer a direct CLI/REST call from the sidebar — it + * is a request routed to the `main` architect, the workspace orchestrator, who + * decides whether the specialisation makes sense, runs + * `afx workspace add-architect`, and briefs the new architect. These helpers + * hold the vscode-free decision logic (resolve main, build the request message) + * so it can be unit-tested directly; `extension.ts` owns the VS Code UI around + * them. + */ + +import type { ArchitectState } from '@cluesmith/codev-types'; + +/** The recipient addressing form for the request — main, explicitly. */ +export const ADD_ARCHITECT_RECIPIENT = 'architect:main'; + +/** + * Resolve the `main` architect from a roster (the overview payload's + * `architects`), or `undefined` when no main session is running. The roster only + * lists architects with a live session (Tower skips dead registrations), so a + * present `main` is by definition reachable — the contract the Add Architect + * action depends on ("ask main to add"). + */ +export function resolveMainArchitect(architects: ArchitectState[]): ArchitectState | undefined { + return architects.find(a => a.name === 'main'); +} + +/** + * The message asking main to create a new architect. Name-only for v1 (the + * issue defers a scope/brief prompt); main asks for scope if it needs it. + */ +export function addArchitectRequestMessage(name: string): string { + return `Please add a ${name} architect.`; +} diff --git a/packages/vscode/src/extension.ts b/packages/vscode/src/extension.ts index 1ccc418a6..cb05e3c82 100644 --- a/packages/vscode/src/extension.ts +++ b/packages/vscode/src/extension.ts @@ -53,6 +53,7 @@ import { formatTargetName } from './views/dev-server-format.js'; import { WorkspaceProvider } from './views/workspace.js'; import { displayArchitectName, sortArchitectsForPicker } from './views/architect-display.js'; import { validateArchitectName } from '@cluesmith/codev-core/architect-name'; +import { resolveMainArchitect, addArchitectRequestMessage, ADD_ARCHITECT_RECIPIENT } from './commands/add-architect.js'; import { BuilderTreeItem } from './views/builder-tree-item.js'; import { BuilderFileTreeItem } from './views/builder-file-tree-item.js'; import { BuilderDiffCache } from './views/builder-diff-cache.js'; @@ -415,7 +416,7 @@ export async function activate(context: vscode.ExtensionContext) { // List views use createTreeView so their title can carry a live item // count; the rest stay on registerTreeDataProvider. const buildersProvider = new BuildersProvider(overviewCache, builderDiffCache); - buildersView = vscode.window.createTreeView('codev.builders', { treeDataProvider: buildersProvider }); + buildersView = vscode.window.createTreeView('codev.agents', { treeDataProvider: buildersProvider }); // Publish a builder-active event when a builder row is selected in the sidebar. // Builder tree items carry `builderId` (= OverviewBuilder.id); selecting a // builder's root node or a file row re-targets any configured hook. @@ -794,11 +795,18 @@ export async function activate(context: vscode.ExtensionContext) { vscode.window.showErrorMessage('Codev: Failed to get workspace state'); } }), - // Issue 841 Gap 1: register a new sibling architect from the UI (the - // inline `+` on the Architects tree row, or the Command Palette). The - // Tower REST endpoint + client method (TowerClient.addArchitect) - // already exist; this is the missing UI affordance. Mirrors - // codev.removeArchitect's connection guard + refresh-on-success. + // Issue 1104: architect creation is now CONVERSATIONAL, not a direct + // CLI/REST call. The action asks the `main` architect (the workspace + // orchestrator) to create the new architect, so the roster stays + // intentional — main decides whether the specialisation makes sense, + // runs `afx workspace add-architect`, and briefs it. Letting any + // developer create an unbriefed architect from the sidebar `+` leads to + // architect proliferation and roster drift in main's working memory. + // + // Main must be active: if no main session is running there is nothing to + // ask, so the action refuses with the CLI fallback rather than silently + // creating one. (Power users can still bypass main via + // `afx workspace add-architect --name `.) regCli('codev.addArchitect', async () => { const client = connectionManager?.getClient(); const workspacePath = connectionManager?.getWorkspacePath(); @@ -806,30 +814,49 @@ export async function activate(context: vscode.ExtensionContext) { vscode.window.showErrorMessage('Codev: Not connected to Tower'); return; } + // Resolve main from the live roster (the overview payload carries it + // since Issue 1104). A present `main` is reachable by construction — + // Tower lists only architects with a live session. + const overview = await client.getOverview(workspacePath); + const main = resolveMainArchitect(overview?.architects ?? []); + if (!main) { + await vscode.window.showInformationMessage( + 'Codev: No active main architect to ask.', + { + modal: true, + detail: 'Add Architect asks the main architect to create the new architect, but no main session is running. Start the workspace with `afx workspace start`, or add one directly via `afx workspace add-architect --name `.', + }, + ); + return; + } const name = await vscode.window.showInputBox({ title: 'Add Architect', - prompt: 'Name for the new sibling architect', + prompt: 'Name for the new architect (main decides whether to create it)', placeHolder: 'e.g. web, mobile, security', // Validate with the exact rule Tower enforces server-side - // (Issue 841 — shared validator in codev-core). Gives inline - // red-text feedback before the round-trip; Tower still has the - // final say on duplicates (which this pure check can't see). + // (Issue 841 — shared validator in codev-core). Parity with + // `afx workspace add-architect`'s own check. validateInput: value => validateArchitectName(value.trim()), }); if (name === undefined) { return; } // user cancelled const trimmed = name.trim(); try { - const result = await client.addArchitect(workspacePath, trimmed); + // Dispatch the request to main via the `architect:main` addressing + // form (Tower's /api/send). No tree refresh here — the roster + // updates via the `architects-updated` SSE once main actually + // creates the architect. + const result = await client.sendMessage( + ADD_ARCHITECT_RECIPIENT, + addArchitectRequestMessage(trimmed), + { workspace: workspacePath }, + ); if (result.ok) { - vscode.window.showInformationMessage(`Codev: Added architect '${result.name ?? trimmed}'.`); - // Refresh immediately so the new row appears without waiting - // for the `architects-updated` SSE (mirrors removeArchitect). - workspaceProvider.refresh(); + vscode.window.showInformationMessage(`Codev: Asked main to add a '${trimmed}' architect.`); } else { - vscode.window.showErrorMessage(`Codev: ${result.error ?? `Failed to add architect '${trimmed}'.`}`); + vscode.window.showErrorMessage(`Codev: ${result.error ?? `Failed to message main about '${trimmed}'.`}`); } } catch (err) { - vscode.window.showErrorMessage(`Codev: Failed to add architect '${trimmed}': ${err instanceof Error ? err.message : String(err)}`); + vscode.window.showErrorMessage(`Codev: Failed to message main about '${trimmed}': ${err instanceof Error ? err.message : String(err)}`); } }), // Spec 786 Phase 6: remove a sibling architect via the REST endpoint From 777d9bbf7ec8ee07536e42ae9930d81892d8f0c7 Mon Sep 17 00:00:00 2001 From: Amr Elsayed Date: Sat, 27 Jun 2026 22:38:17 +1000 Subject: [PATCH 11/29] [PIR #1104] Update builder thread (implement phase) --- codev/state/pir-1104_thread.md | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/codev/state/pir-1104_thread.md b/codev/state/pir-1104_thread.md index 1a5695ee3..3507d2bef 100644 --- a/codev/state/pir-1104_thread.md +++ b/codev/state/pir-1104_thread.md @@ -41,3 +41,30 @@ area/cross-cutting may fit better than area/vscode (architect's call). Plan revi Decision: keep `ArchitectState[]` (not a leaner `string[]`) for shape-parity with DashboardState.architects + the shared `collectArchitects` helper, even though the Agents tree only reads `name` today. Reviewer confirmed. Plan already reflects this — no change. + +## Implement phase + +Plan approved. Two reviewer corrections applied at the start of implement: +1. NO Add Architect `+` on the Agents title bar — a `+` there is ambiguous (add-builder vs + add-architect). Add Architect stays only in Workspace > Architects. +2. Rename the view ID `codev.builders` → `codev.agents` (not just the label) for consistency. + Config keys (buildersGroupBy/AutoCollapse/FileViewAsTree) and command ids keep their names — + only the tree-view id + all `view == codev.builders` when-clauses changed. + +Built: +- **Wire (Option B):** `OverviewData.architects: ArchitectState[]`; extracted `collectArchitects` + in tower-routes.ts, reused by dashboard-state + handleOverview (single source of truth); + overview.ts defaults `architects: []` (handleOverview injects the live roster like lastDataAt). +- **Agents tree:** `architect-grouping.ts` (pure partition + badge helper), `ArchitectGroupTreeItem`, + adaptive root in builders.ts (architectCount>1 → architect tier; else today's behaviour bit-for-bit). + Passive architect → leaf; orphan/stale-owner builders → non-interactive Unassigned bucket; + 3-level getParent chain for reveal; group ids namespaced by architect to avoid collisions. +- **Conversational Add Architect:** `commands/add-architect.ts` (resolveMain + request message), + extension.ts handler routes to `architect:main` via sendMessage; refuses with modal+CLI fallback + when no main session. + +All green: full build ✓, vscode check-types+lint+522 unit ✓, dashboard 322 ✓, codev 3375 ✓. +Note: the architect-attribution `description` badge is dormant in the nested tree (owner is always +the ancestor) — it only surfaces for stale-owner builders under Unassigned. Working as designed. +Dashboard Agents view is NOT built here (out of scope) — the enrichment just makes the roster +available for future reuse. From 7b13a9eb97057101ef078195ca7431fd50c9e2b4 Mon Sep 17 00:00:00 2001 From: Amr Elsayed Date: Sat, 27 Jun 2026 22:39:09 +1000 Subject: [PATCH 12/29] chore(porch): 1104 dev-approval gate-requested --- codev/projects/1104-vscode-merge-architects-builde/status.yaml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/codev/projects/1104-vscode-merge-architects-builde/status.yaml b/codev/projects/1104-vscode-merge-architects-builde/status.yaml index d5e5feaaa..08699d38e 100644 --- a/codev/projects/1104-vscode-merge-architects-builde/status.yaml +++ b/codev/projects/1104-vscode-merge-architects-builde/status.yaml @@ -11,10 +11,11 @@ gates: approved_at: '2026-06-27T12:13:50.827Z' dev-approval: status: pending + requested_at: '2026-06-27T12:39:09.461Z' pr: status: pending iteration: 1 build_complete: false history: [] started_at: '2026-06-27T11:55:09.921Z' -updated_at: '2026-06-27T12:14:00.246Z' +updated_at: '2026-06-27T12:39:09.462Z' From b5bfce2fbf9eed004263499fda45580b452f47c9 Mon Sep 17 00:00:00 2001 From: Amr Elsayed Date: Sat, 27 Jun 2026 22:45:47 +1000 Subject: [PATCH 13/29] [PIR #1104] Fix Agents view title flipping back to 'Builders' on data load --- packages/vscode/src/extension.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/vscode/src/extension.ts b/packages/vscode/src/extension.ts index cb05e3c82..3a7f385f4 100644 --- a/packages/vscode/src/extension.ts +++ b/packages/vscode/src/extension.ts @@ -306,7 +306,7 @@ export async function activate(context: vscode.ExtensionContext) { statusBarItem.text = text; }; - // List views show their item count in the title: "Builders (3)". + // List views show their item count in the title: "Agents (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)". @@ -320,7 +320,7 @@ export async function activate(context: vscode.ExtensionContext) { 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 (buildersView) { buildersView.title = withCount('Agents', data?.builders.length); } if (pullRequestsView) { pullRequestsView.title = withCount('Pull Requests', data?.pendingPRs.length); } if (backlogView) { // Backlog title reflects the *visible* row count (mine-only vs show-all), From c8f3ad87224e0a4f2a3f525971ae41718569bb06 Mon Sep 17 00:00:00 2001 From: Amr Elsayed Date: Sat, 27 Jun 2026 22:50:46 +1000 Subject: [PATCH 14/29] [PIR #1104] Sweep user-facing 'Builders' labels to 'Agents' (titles + settings) --- packages/vscode/package.json | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/packages/vscode/package.json b/packages/vscode/package.json index 7e116f829..26b1f48f0 100644 --- a/packages/vscode/package.json +++ b/packages/vscode/package.json @@ -128,12 +128,12 @@ }, { "command": "codev.enableBuildersAutoCollapse", - "title": "Codev: Turn On Builders Auto-Collapse", + "title": "Codev: Turn On Auto-Collapse", "icon": "$(unfold)" }, { "command": "codev.disableBuildersAutoCollapse", - "title": "Codev: Turn Off Builders Auto-Collapse", + "title": "Codev: Turn Off Auto-Collapse", "icon": "$(fold)" }, { @@ -148,12 +148,12 @@ }, { "command": "codev.groupBuildersByArea", - "title": "Codev: Group Builders by Area", + "title": "Codev: Group by Area", "icon": "$(tag)" }, { "command": "codev.groupBuildersByPhase", - "title": "Codev: Group Builders by Phase", + "title": "Codev: Group by Phase", "icon": "$(milestone)" }, { @@ -872,22 +872,22 @@ "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." + "markdownDescription": "Auto-refresh the Agents / 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." }, "codev.buildersAutoCollapse": { "type": "boolean", "default": true, - "description": "Builders view accordion: expanding one builder auto-collapses the others, so only one builder's changed-files diff is open at a time." + "description": "Agents view accordion: expanding one builder auto-collapses the others, so only one builder's changed-files diff is open at a time." }, "codev.buildersFileViewAsTree": { "type": "boolean", "default": true, - "description": "Render a builder's changed-files list as a folder tree (with single-child folder chains compacted, like VSCode's Source Control panel) instead of a flat list. Toggleable via the Builders title-bar button." + "description": "Render a builder's changed-files list as a folder tree (with single-child folder chains compacted, like VSCode's Source Control panel) instead of a flat list. Toggleable via the Agents title-bar button." }, "codev.buildersAutoReveal": { "type": "boolean", "default": true, - "description": "Builders view: when the active builder-diff editor changes (keyboard navigation, a click inside the multi-file View Diff, or a per-file diff), reveal and select the matching file's row in the tree. The Builders-view equivalent of the Explorer's auto-reveal. When off, the sidebar selection only follows an explicit click." + "description": "Agents view: when the active builder-diff editor changes (keyboard navigation, a click inside the multi-file View Diff, or a per-file diff), reveal and select the matching file's row in the tree. The Agents-view equivalent of the Explorer's auto-reveal. When off, the sidebar selection only follows an explicit click." }, "codev.backlogShowAll": { "type": "boolean", @@ -905,7 +905,7 @@ "Group builders by their area/* label (the domain axis), matching the Backlog view." ], "default": "stage", - "description": "Which axis the Builders view groups by. 'stage' (default) is the action axis; 'area' is the domain axis. Toggleable via the Builders title-bar button." + "description": "Which axis the Agents view groups by. 'stage' (default) is the action axis; 'area' is the domain axis. Toggleable via the Agents title-bar button." }, "codev.markdownPreview.fontSize": { "type": "number", From c014bf3c45a3cf651bfc3f3e6d10bb83fc9d1fc8 Mon Sep 17 00:00:00 2001 From: Amr Elsayed Date: Sat, 27 Jun 2026 22:53:28 +1000 Subject: [PATCH 15/29] [PIR #1104] Update VSCode README: Builders view -> Agents --- packages/vscode/README.md | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/vscode/README.md b/packages/vscode/README.md index eb860709e..52aa2f1f4 100644 --- a/packages/vscode/README.md +++ b/packages/vscode/README.md @@ -4,14 +4,14 @@ Bring Codev's Agent Farm into VS Code — monitor builders, open terminals, appr ## Features -- **Unified sidebar** — Workspace, Builders, Pull Requests, Backlog, Recently Closed, Team, and Status in a single pane. Blocked builders are flagged inline in Builders; live item counts appear in view titles. +- **Unified sidebar** — Workspace, Agents, Pull Requests, Backlog, Recently Closed, Team, and Status in a single pane. Blocked builders are flagged inline in Agents; live item counts appear in view titles. - **Native terminals** — Architect / builder / shell terminals in the editor area; dev servers in the bottom panel. - **One-click dev servers** — Start / Stop the dev server for the current workspace or any builder worktree from the sidebar (`Cmd/Ctrl+Alt+R` / `Cmd/Ctrl+Alt+S`). One runs at a time and swaps on demand. Configurable via `worktree.devCommand` in `.codev/config.json` — see the **Dev servers and runnable worktrees** section below. - **Open Dev URL rows** — surface staging / preview / tunnel links as one-click rows in the Workspace view via `worktree.devUrls`. - **Per-engineer config overrides** — `.codev/config.local.json` layers your personal settings (local devCommand, tunnel hostnames, staging URLs) over the shared project config without committing them. - **Per-builder changed files** — expand any builder row to see its diff vs main inline with native SCM-style status badges. Toggle between folder tree and flat list. - **Gate review** — toast with one-click **Approve** when a builder reaches a human-approval gate, plus inline `REVIEW(@architect):` comment threads on plan / spec files. -- **"Waiting on input" indicator** — a builder whose terminal has been idle for ≥5 minutes outside a gate gets a chat-bubble icon in Builders and is counted in the status bar. +- **"Waiting on input" indicator** — a builder whose terminal has been idle for ≥5 minutes outside a gate gets a chat-bubble icon in Agents and is counted in the status bar. - **Image paste** — `Cmd+Alt+V` / `Ctrl+Alt+V` in a focused Codev terminal uploads the clipboard image to Tower and injects the saved file path into the terminal. ## Requirements @@ -33,7 +33,7 @@ Bring Codev's Agent Farm into VS Code — monitor builders, open terminals, appr The Codev sidebar contains seven collapsible views: - **Workspace** — Open Architect, Open Web Interface, Spawn Builder, New Shell, and Start / Stop Dev Server rows. Any `worktree.devUrls` you've configured appear here as **Open Dev URL** rows. -- **Builders** — every active builder, with status (active / blocked / waiting on input / awaiting). Click a row to open its terminal *and* expand its changed-files list. Right-click for the full builder action menu (see the **Builder actions (right-click)** section below). The title bar carries buttons to toggle accordion mode and tree-vs-list file view. +- **Agents** — every active builder, with status (active / blocked / waiting on input / awaiting). When more than one architect is registered, builders nest under the architect that spawned them (a passive architect with no builders still appears as a leaf row); with a single architect the view groups by area or lifecycle stage as before. Click a row to open its terminal *and* expand its changed-files list. Right-click for the full builder action menu (see the **Builder actions (right-click)** section below). The title bar carries buttons to toggle accordion mode and tree-vs-list file view. - **Pull Requests** — open PRs in the repo, with a live count in the title. - **Backlog** — open issues without a builder. Inline row actions drop the issue's `#` into the architect input, preview the issue, spawn a builder for it, open it in the browser, or copy the issue number. - **Recently Closed** — recently closed PRs; manual refresh from the title bar. @@ -42,7 +42,7 @@ The Codev sidebar contains seven collapsible views: ## Builder actions (right-click) -Right-click any builder in the Builders view for three grouped action menus: +Right-click any builder in the Agents view for three grouped action menus: **Primary** @@ -140,7 +140,7 @@ The pnpm example above adapts directly to npm / yarn / bun / cargo / poetry / go | (sidebar) | (terminal) | Builder 42 | | | | (terminal) | | - Workspace| | | -| - Builders | Left editor | Right editor | +| - Agents | Left editor | Right editor | | - PRs | group | group | | - Backlog | | | | - Recent | | | @@ -211,9 +211,9 @@ When a builder reaches a human-approval gate, a toast surfaces it with the issue | `codev.autoConnect` | `true` | Connect to Tower on activation | | `codev.autoStartTower` | `true` | Auto-start Tower if not running | | `codev.autoOpenBuilderTerminal` | `notify` | Behavior on builder-spawn 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.overviewRefreshSeconds` | `60` | Auto-refresh Agents / 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.buildersAutoCollapse` | `true` | Builders view accordion — expanding one builder auto-collapses the others | +| `codev.buildersAutoCollapse` | `true` | Agents view accordion — expanding one builder auto-collapses the others | | `codev.buildersFileViewAsTree` | `true` | Render a builder's changed-files list as a folder tree (`false` for a flat list) | | `codev.markdownPreview.fontSize` | `0` | Prose font size (px) for the Codev Markdown Preview. `0` = built-in default (16px). Edit in Settings (Cmd+,) → the open preview reflows live | | `codev.markdownPreview.lineHeight` | `0` | Prose line-height (unitless) for the Codev Markdown Preview. `0` = built-in default (1.5). Reflows the open preview live | From ae035b6197c8f970d36c6eb1dfcbe1561a1ec9f1 Mon Sep 17 00:00:00 2001 From: Amr Elsayed Date: Sun, 28 Jun 2026 08:18:35 +1000 Subject: [PATCH 16/29] [PIR #1104] Rename collectArchitects -> liveArchitects (live-session reader, distinct from persisted getArchitects) --- .../codev/src/agent-farm/servers/overview.ts | 4 +- .../src/agent-farm/servers/tower-routes.ts | 52 ++++++++++--------- packages/types/src/api.ts | 16 +++--- .../vscode/src/__tests__/agents-tree.test.ts | 2 +- 4 files changed, 38 insertions(+), 36 deletions(-) diff --git a/packages/codev/src/agent-farm/servers/overview.ts b/packages/codev/src/agent-farm/servers/overview.ts index 5907a7fa5..807631267 100644 --- a/packages/codev/src/agent-farm/servers/overview.ts +++ b/packages/codev/src/agent-farm/servers/overview.ts @@ -958,8 +958,8 @@ export class OverviewCache { } // `architects` defaults to `[]` here — the filesystem/git-derived overview - // has no view of the live terminal roster. `handleOverview` (tower-routes.ts) - // injects the real roster via `collectArchitects` before serialization, + // has no view of the live terminal sessions. `handleOverview` (tower-routes.ts) + // injects the real architect list via `liveArchitects` before serialization, // mirroring how it enriches `lastDataAt`. const result: OverviewData = { builders, pendingPRs, backlog, recentlyClosed, architects: [] }; if (currentUser) { diff --git a/packages/codev/src/agent-farm/servers/tower-routes.ts b/packages/codev/src/agent-farm/servers/tower-routes.ts index 2fd439284..9f877af93 100644 --- a/packages/codev/src/agent-farm/servers/tower-routes.ts +++ b/packages/codev/src/agent-farm/servers/tower-routes.ts @@ -863,22 +863,24 @@ async function handleStatus(res: http.ServerResponse): Promise { } /** - * Build the `ArchitectState[]` roster from a workspace's (rehydrated) terminals - * entry: one entry per registered architect whose PtySession is live (stale or - * racing registrations are skipped), with `main` moved to index 0 so consumers - * can rely on `architects[0]` as the default architect. + * List a workspace's architects whose PtySession is live, built from the + * (rehydrated) terminals entry: one entry per registered architect (stale or + * racing registrations whose session is gone are skipped), with `main` moved to + * index 0 so consumers can rely on `architects[0]` as the default architect. * - * Single source of truth (Issue 1104) shared by the dashboard-state handler - * (`/api/state`) and the overview handler (`/api/overview`), so the two payloads - * carry an identical roster and can't drift. Extracted verbatim from the - * dashboard-state builder's former inline loop. + * The live-terminal sibling of state.ts's `getArchitects` (which reads the + * persisted `architect` table): this one reflects which architects actually have + * a running session right now. Single source of truth (Issue 1104) shared by the + * dashboard-state handler (`/api/state`) and the overview handler + * (`/api/overview`), so the two payloads list an identical set and can't drift. + * Extracted verbatim from the dashboard-state builder's former inline loop. */ -function collectArchitects(entry: WorkspaceTerminals, manager: TerminalManager): ArchitectState[] { - const collected: ArchitectState[] = []; +function liveArchitects(entry: WorkspaceTerminals, manager: TerminalManager): ArchitectState[] { + const architects: ArchitectState[] = []; for (const [architectName, terminalId] of entry.architects) { const session = manager.getSession(terminalId); if (!session) continue; - collected.push({ + architects.push({ name: architectName, port: 0, pid: session.pid || 0, @@ -886,12 +888,12 @@ function collectArchitects(entry: WorkspaceTerminals, manager: TerminalManager): persistent: isSessionPersistent(terminalId, session), }); } - const mainIdx = collected.findIndex(a => a.name === 'main'); + const mainIdx = architects.findIndex(a => a.name === 'main'); if (mainIdx > 0) { - const [mainEntry] = collected.splice(mainIdx, 1); - collected.unshift(mainEntry); + const [mainEntry] = architects.splice(mainIdx, 1); + architects.unshift(mainEntry); } - return collected; + return architects; } async function handleOverview(res: http.ServerResponse, url: URL, workspaceOverride?: string, ctx?: RouteContext): Promise { @@ -942,11 +944,11 @@ async function handleOverview(res: http.ServerResponse, url: URL, workspaceOverr builder.lastDataAt = new Date(ptySession.lastDataAt).toISOString(); } - // Issue 1104: enrich with the live architect roster (main-first) so the - // VSCode Agents tree can render its architect tier and attribution badge - // straight off the overview cache. Same `collectArchitects` helper (and so - // the same roster) the dashboard-state handler uses. - data.architects = collectArchitects(entry, terminalManager); + // Issue 1104: enrich with the live architects (main-first) so the VSCode + // Agents tree can render its architect tier and attribution badge straight + // off the overview cache. Same `liveArchitects` helper (and so the same set) + // the dashboard-state handler uses. + data.architects = liveArchitects(entry, terminalManager); res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(data)); @@ -1854,13 +1856,13 @@ async function handleWorkspaceState( }; // Spec 761: build the architects collection from entry.architects (skip dead - // sessions, main-first). Shared with /api/overview via `collectArchitects` - // (Issue 1104) so both payloads carry an identical roster. + // sessions, main-first). Shared with /api/overview via `liveArchitects` + // (Issue 1104) so both payloads list an identical set. // Spec 755: the scalar `state.architect` is preserved as a backward-compat // pointer to the same default architect (architects[0] when present). - const collectedArchitects = collectArchitects(entry, manager); - state.architects = collectedArchitects; - state.architect = collectedArchitects[0] ?? null; + const architects = liveArchitects(entry, manager); + state.architects = architects; + state.architect = architects[0] ?? null; // Add shells from refreshed cache for (const [shellId, terminalId] of entry.shells) { diff --git a/packages/types/src/api.ts b/packages/types/src/api.ts index c890ec58f..ecc75b0f9 100644 --- a/packages/types/src/api.ts +++ b/packages/types/src/api.ts @@ -286,15 +286,15 @@ export interface OverviewData { backlog: OverviewBacklogItem[]; recentlyClosed: OverviewRecentlyClosed[]; /** - * Registered architects for the workspace (Issue 1104), main-first. Carries - * the same `ArchitectState[]` shape `DashboardState.architects` exposes, - * built by the shared `collectArchitects` helper from the live terminal - * roster, so the overview payload and the dashboard-state payload never + * The workspace's architects with a live session (Issue 1104), main-first. + * Carries the same `ArchitectState[]` shape `DashboardState.architects` + * exposes, built by the shared `liveArchitects` helper from the live terminal + * sessions, so the overview payload and the dashboard-state payload never * drift. Only architects with a live session are listed (stale registrations - * are skipped). `[]` when the workspace has no architects or the roster is - * unavailable — never `undefined`, so consumers don't branch. Lets the VSCode - * Agents tree render its architect tier and the architect-attribution badge - * straight off the overview cache without a second fetch. + * are skipped). `[]` when the workspace has no architects or none are live — + * never `undefined`, so consumers don't branch. Lets the VSCode Agents tree + * render its architect tier and the architect-attribution badge straight off + * the overview cache without a second fetch. */ architects: ArchitectState[]; /** Auto-detected GitHub login of the current user (via the user-identity forge concept). */ diff --git a/packages/vscode/src/__tests__/agents-tree.test.ts b/packages/vscode/src/__tests__/agents-tree.test.ts index 41b02f5e0..8ec2242c9 100644 --- a/packages/vscode/src/__tests__/agents-tree.test.ts +++ b/packages/vscode/src/__tests__/agents-tree.test.ts @@ -100,7 +100,7 @@ describe('Agents tree — adaptive root (#1104)', () => { builder({ id: 'a', spawnedByArchitect: 'main' }), builder({ id: 'b', spawnedByArchitect: 'vscode' }), ], - [arch('vscode'), arch('main')], // collectArchitects returns main-first; emulate that order + [arch('vscode'), arch('main')], // liveArchitects returns main-first; emulate that order ); const roots = await provider.getChildren(); expect(roots.every(r => r instanceof ArchitectGroupTreeItem)).toBe(true); From 1ec60502c6d1f944eb2b6ed0d589735b9dcf9bd4 Mon Sep 17 00:00:00 2001 From: Amr Elsayed Date: Sun, 28 Jun 2026 10:19:32 +1000 Subject: [PATCH 17/29] [PIR #1104] Pivot to flat 3-way group-by toggle (stage/area/architect), retire nested tier - Replace the nested architect tier with an architectGrouping() strategy: builders group by exactly one axis at a time. Childless architects produce no group and vanish from the work view (full roster stays in Workspace > Architects). - Three Agents title-bar buttons (Stage/Area/Architect) with toggled clauses so the active axis renders pressed. Octopus SVG (theme light/dark pair) is the Architect button icon. - Remove ArchitectGroupTreeItem, partitionByArchitect/architectBadge, the adaptive architectCount gate, and the description badge. --- codev/state/pir-1104_thread.md | 32 +++ packages/vscode/icons/architect-dark.svg | 1 + packages/vscode/icons/architect-light.svg | 1 + packages/vscode/icons/architect.svg | 1 + packages/vscode/package.json | 40 +++- .../vscode/src/__tests__/agents-tree.test.ts | 175 --------------- .../src/__tests__/architect-grouping.test.ts | 97 -------- .../src/__tests__/builder-grouping.test.ts | 42 +++- packages/vscode/src/extension.ts | 10 +- .../vscode/src/views/architect-grouping.ts | 86 ------- packages/vscode/src/views/builder-grouping.ts | 51 ++++- .../vscode/src/views/builder-tree-item.ts | 74 ------ packages/vscode/src/views/builders.ts | 210 ++---------------- 13 files changed, 184 insertions(+), 636 deletions(-) create mode 100644 packages/vscode/icons/architect-dark.svg create mode 100644 packages/vscode/icons/architect-light.svg create mode 100644 packages/vscode/icons/architect.svg delete mode 100644 packages/vscode/src/__tests__/agents-tree.test.ts delete mode 100644 packages/vscode/src/__tests__/architect-grouping.test.ts delete mode 100644 packages/vscode/src/views/architect-grouping.ts diff --git a/codev/state/pir-1104_thread.md b/codev/state/pir-1104_thread.md index 3507d2bef..747cb29da 100644 --- a/codev/state/pir-1104_thread.md +++ b/codev/state/pir-1104_thread.md @@ -68,3 +68,35 @@ Note: the architect-attribution `description` badge is dormant in the nested tre the ancestor) — it only surfaces for stale-owner builders under Unassigned. Working as designed. Dashboard Agents view is NOT built here (out of scope) — the enrichment just makes the roster available for future reuse. + +## DEV-GATE PIVOT: nested architect tier → flat 3-way group-by toggle + +At the dev-approval gate the architect (reviewer) reviewed the running nested-tier tree and +redirected the design. Sequence of decisions: +1. Childless architects in the nested tree duplicated Workspace > Architects → first asked to hide them. +2. Explored alternatives; landed on: builders grouped by EXACTLY ONE axis at a time (stage | area | + architect), a natural extension of the existing binary stage/area toggle. RETIRE the nested + architect tier entirely. +3. Custom octopus icon for the architect axis (metaphor: one body, many arms = orchestrating builders). + Iterated via Playwright-rendered previews (couldn't judge SVG blind). Final: radial 8-arm, no eyes, + body r3.2, stroke 2.7, MONOCHROME light/dark pair (#1f1f1f / #cccccc) matching codev-light/dark + convention (reviewer rejected hardcoded purple — must theme-adapt). Files: icons/architect{,-light,-dark}.svg. + +Refactor done: +- REMOVED: views/architect-grouping.ts (partitionByArchitect/architectBadge), ArchitectGroupTreeItem, + multiArchRoot/makeArchitectNode + all tier maps in builders.ts, the adaptive architectCount gate, + the description badge, the architectName param on BuilderGroupTreeItem. Tests agents-tree.test.ts + + architect-grouping.test.ts deleted. +- ADDED: architectGrouping() strategy in builder-grouping.ts (group by spawnedByArchitect, main-first, + Unassigned last; childless architects produce no group = vanish for free; rowPrefix = lifecycle + stage via stageForPhase). buildersGroupBy enum +'architect'. Three toolbar commands + (groupBuildersByStage/ByArea/ByArchitect), three view/title buttons with `toggled` clauses keyed off + the codev.buildersGroupBy context key (active one renders pressed). Octopus is the ByArchitect button + icon. Architect grouping cases added to builder-grouping.test.ts. + +Kept: /api/overview liveArchitects enrichment (still used by conversational Add Architect's +resolveMainArchitect). Workspace > Architects unchanged (the full-roster launch/config home). +Group headers keep the state-rollup glyph in all 3 modes (architect names distinguish architect mode; +octopus lives on the toggle button). + +vscode check-types + lint + 512 unit ✓ after refactor. diff --git a/packages/vscode/icons/architect-dark.svg b/packages/vscode/icons/architect-dark.svg new file mode 100644 index 000000000..89ec7bb87 --- /dev/null +++ b/packages/vscode/icons/architect-dark.svg @@ -0,0 +1 @@ + diff --git a/packages/vscode/icons/architect-light.svg b/packages/vscode/icons/architect-light.svg new file mode 100644 index 000000000..eb8c9b0f2 --- /dev/null +++ b/packages/vscode/icons/architect-light.svg @@ -0,0 +1 @@ + diff --git a/packages/vscode/icons/architect.svg b/packages/vscode/icons/architect.svg new file mode 100644 index 000000000..d3fb3b36a --- /dev/null +++ b/packages/vscode/icons/architect.svg @@ -0,0 +1 @@ + diff --git a/packages/vscode/package.json b/packages/vscode/package.json index 26b1f48f0..feb7dec85 100644 --- a/packages/vscode/package.json +++ b/packages/vscode/package.json @@ -146,15 +146,23 @@ "title": "Codev: View Changed Files as List", "icon": "$(list-flat)" }, + { + "command": "codev.groupBuildersByStage", + "title": "Codev: Group by Stage", + "icon": "$(milestone)" + }, { "command": "codev.groupBuildersByArea", "title": "Codev: Group by Area", "icon": "$(tag)" }, { - "command": "codev.groupBuildersByPhase", - "title": "Codev: Group by Phase", - "icon": "$(milestone)" + "command": "codev.groupBuildersByArchitect", + "title": "Codev: Group by Architect", + "icon": { + "light": "icons/architect-light.svg", + "dark": "icons/architect-dark.svg" + } }, { "command": "codev.openBacklogSearch", @@ -615,15 +623,23 @@ "when": "view == codev.agents && !codev.buildersFileViewAsTree", "group": "navigation" }, + { + "command": "codev.groupBuildersByStage", + "when": "view == codev.agents", + "toggled": "!codev.buildersGroupBy || codev.buildersGroupBy == 'stage'", + "group": "navigation@1" + }, { "command": "codev.groupBuildersByArea", - "when": "view == codev.agents && codev.buildersGroupBy != 'area'", - "group": "navigation" + "when": "view == codev.agents", + "toggled": "codev.buildersGroupBy == 'area'", + "group": "navigation@2" }, { - "command": "codev.groupBuildersByPhase", - "when": "view == codev.agents && codev.buildersGroupBy == 'area'", - "group": "navigation" + "command": "codev.groupBuildersByArchitect", + "when": "view == codev.agents", + "toggled": "codev.buildersGroupBy == 'architect'", + "group": "navigation@3" }, { "command": "codev.refreshOverview", @@ -898,14 +914,16 @@ "type": "string", "enum": [ "stage", - "area" + "area", + "architect" ], "enumDescriptions": [ "Group builders by lifecycle stage (the action axis: SPECIFY → PLAN → IMPLEMENT → REVIEW → PR → VERIFIED) — answers \"where do I need to act?\"", - "Group builders by their area/* label (the domain axis), matching the Backlog view." + "Group builders by their area/* label (the domain axis), matching the Backlog view.", + "Group builders by the architect that spawned them (the ownership axis); architects with no in-flight work don't appear (the full roster lives in Workspace > Architects)." ], "default": "stage", - "description": "Which axis the Agents view groups by. 'stage' (default) is the action axis; 'area' is the domain axis. Toggleable via the Agents title-bar button." + "description": "Which axis the Agents view groups by. 'stage' (default) is the action axis, 'area' is the domain axis, 'architect' is the ownership axis. Builders are grouped by exactly one axis at a time; toggle via the three Agents title-bar buttons (the active one is shown pressed)." }, "codev.markdownPreview.fontSize": { "type": "number", diff --git a/packages/vscode/src/__tests__/agents-tree.test.ts b/packages/vscode/src/__tests__/agents-tree.test.ts deleted file mode 100644 index 8ec2242c9..000000000 --- a/packages/vscode/src/__tests__/agents-tree.test.ts +++ /dev/null @@ -1,175 +0,0 @@ -/** - * Unit tests for the adaptive architect tier of the Agents tree (Issue 1104). - * - * Pins the count-check root (single-architect collapse vs architect-rooted), - * the passive-architect leaf rule, the level-2 area/phase delegation under an - * architect, the Unassigned bucket, and the three-level `getParent` chain that - * keeps `reveal` working. Mocks `vscode` per the established `__tests__` pattern - * (see builders-accordion.test.ts). - */ - -import { describe, it, expect, vi } from 'vitest'; -import type { OverviewBuilder, OverviewData, ArchitectState } from '@cluesmith/codev-types'; - -/** Per-test config overrides the mocked `workspace.getConfiguration` reads. */ -const configValues: Record = {}; - -vi.mock('vscode', () => { - class FakeEventEmitter { - private listeners: Array<(e: T) => void> = []; - readonly event = (listener: (e: T) => void): { dispose: () => void } => { - this.listeners.push(listener); - return { dispose: () => { this.listeners = this.listeners.filter((l) => l !== listener); } }; - }; - fire = vi.fn((e: T) => { this.listeners.forEach((l) => l(e)); }); - } - class TreeItem { - id?: string; - tooltip?: string; - description?: string; - contextValue?: string; - iconPath?: unknown; - command?: unknown; - constructor(public label: string, public collapsibleState?: number) {} - } - class ThemeIcon { constructor(public id: string, public color?: unknown) {} } - class ThemeColor { constructor(public id: string) {} } - return { - EventEmitter: FakeEventEmitter, - TreeItem, - ThemeIcon, - ThemeColor, - TreeItemCollapsibleState: { None: 0, Collapsed: 1, Expanded: 2 }, - workspace: { - getConfiguration: () => ({ - get: (key: string, def: unknown) => (key in configValues ? configValues[key] : def), - }), - }, - }; -}); - -const vscode = await import('vscode'); -const { BuildersProvider } = await import('../views/builders.js'); -const { ArchitectGroupTreeItem, BuilderGroupTreeItem, BuilderTreeItem } = await import('../views/builder-tree-item.js'); - -function builder(overrides: Partial): OverviewBuilder { - return { - id: 'pir-1', issueId: '1', issueTitle: 't', phase: 'implement', protocolPhase: 'implement', - mode: 'strict', gates: {}, worktreePath: '/tmp/wt', roleId: null, protocol: 'pir', - planPhases: [], progress: 0, blocked: null, blockedGate: null, blockedSince: null, - startedAt: null, idleMs: 0, lastDataAt: null, spawnedByArchitect: null, - area: 'Uncategorized', prReady: false, - ...overrides, - } as OverviewBuilder; -} - -function arch(name: string): ArchitectState { - return { name, port: 0, pid: 1, terminalId: `t-${name}`, persistent: false }; -} - -function fakeCache(builders: OverviewBuilder[], architects: ArchitectState[]) { - const data = { builders, backlog: [], pendingPRs: [], recentlyClosed: [], architects } as unknown as OverviewData; - return { getData: () => data, onDidChange: () => ({ dispose() {} }) } as never; -} - -const fakeDiffCache = {} as never; - -function makeProvider(builders: OverviewBuilder[], architects: ArchitectState[]) { - return new BuildersProvider(fakeCache(builders, architects), fakeDiffCache); -} - -describe('Agents tree — adaptive root (#1104)', () => { - it('roots at area/phase groups (no architect tier) when one architect', async () => { - const provider = makeProvider( - [builder({ id: 'a', spawnedByArchitect: 'main' })], - [arch('main')], - ); - const roots = await provider.getChildren(); - expect(roots.some(r => r instanceof ArchitectGroupTreeItem)).toBe(false); - }); - - it('roots at area/phase groups when zero architects (today behaviour)', async () => { - const provider = makeProvider([builder({ id: 'a' })], []); - const roots = await provider.getChildren(); - expect(roots.some(r => r instanceof ArchitectGroupTreeItem)).toBe(false); - }); - - it('roots at architect nodes (main-first) when more than one architect', async () => { - const provider = makeProvider( - [ - builder({ id: 'a', spawnedByArchitect: 'main' }), - builder({ id: 'b', spawnedByArchitect: 'vscode' }), - ], - [arch('vscode'), arch('main')], // liveArchitects returns main-first; emulate that order - ); - const roots = await provider.getChildren(); - expect(roots.every(r => r instanceof ArchitectGroupTreeItem)).toBe(true); - expect((roots as Array<{ architectName: string }>).map(r => r.architectName)).toContain('main'); - expect((roots as Array<{ architectName: string }>).map(r => r.architectName)).toContain('vscode'); - }); - - it('renders a passive architect (zero builders) as a leaf, its owner as collapsible', async () => { - const provider = makeProvider( - [builder({ id: 'a', spawnedByArchitect: 'main' })], - [arch('main'), arch('reviewer')], - ); - const roots = await provider.getChildren() as Array<{ architectName: string; collapsibleState?: number }>; - const main = roots.find(r => r.architectName === 'main')!; - const reviewer = roots.find(r => r.architectName === 'reviewer')!; - expect(main.collapsibleState).toBe(vscode.TreeItemCollapsibleState.Collapsed); - expect(reviewer.collapsibleState).toBe(vscode.TreeItemCollapsibleState.None); - }); - - it('an architect node expands to its own builders, partitioned by owner', async () => { - const provider = makeProvider( - [ - builder({ id: 'a', spawnedByArchitect: 'main' }), - builder({ id: 'b', spawnedByArchitect: 'vscode' }), - builder({ id: 'c', spawnedByArchitect: 'main' }), - ], - [arch('main'), arch('vscode')], - ); - const roots = await provider.getChildren() as Array<{ architectName: string }>; - const mainNode = roots.find(r => r.architectName === 'main'); - const groups = await provider.getChildren(mainNode as never); - const rows: unknown[] = []; - for (const g of groups) { rows.push(...await provider.getChildren(g as never)); } - const ids = (rows as Array<{ builderId: string }>).map(r => r.builderId); - expect(ids.sort()).toEqual(['a', 'c']); - expect(rows.every(r => r instanceof BuilderTreeItem)).toBe(true); - }); - - it('collects an orphan builder under a non-interactive Unassigned node', async () => { - const provider = makeProvider( - [ - builder({ id: 'a', spawnedByArchitect: 'main' }), - builder({ id: 'orphan', spawnedByArchitect: null }), - ], - [arch('main'), arch('vscode')], - ); - const roots = await provider.getChildren() as Array<{ architectName: string; contextValue?: string; command?: unknown }>; - const unassigned = roots.find(r => r.contextValue === 'agent-unassigned'); - expect(unassigned).toBeDefined(); - expect(unassigned!.command).toBeUndefined(); // not a real architect → no open-terminal - }); - - it('walks builder → group → architect via getParent (reveal chain)', async () => { - const provider = makeProvider( - [builder({ id: 'a', spawnedByArchitect: 'main', protocolPhase: 'implement' }), - builder({ id: 'z', spawnedByArchitect: 'vscode' })], - [arch('main'), arch('vscode')], - ); - const roots = await provider.getChildren() as Array<{ architectName: string }>; - const mainNode = roots.find(r => r.architectName === 'main')!; - const groups = await provider.getChildren(mainNode as never); - const group = groups[0]; - const rows = await provider.getChildren(group as never); - const row = rows[0]; - - expect(row).toBeInstanceOf(BuilderTreeItem); - expect(group).toBeInstanceOf(BuilderGroupTreeItem); - expect(await provider.getParent(row)).toBe(group); - expect(await provider.getParent(group)).toBe(mainNode); - expect(await provider.getParent(mainNode as never)).toBeUndefined(); - }); -}); diff --git a/packages/vscode/src/__tests__/architect-grouping.test.ts b/packages/vscode/src/__tests__/architect-grouping.test.ts deleted file mode 100644 index 9565910b5..000000000 --- a/packages/vscode/src/__tests__/architect-grouping.test.ts +++ /dev/null @@ -1,97 +0,0 @@ -/** - * Unit tests for the architect-tier partition helpers (Issue 1104) — the pure, - * vscode-free logic behind the multi-architect Agents tree. - */ - -import { describe, it, expect } from 'vitest'; -import type { OverviewBuilder } from '@cluesmith/codev-types'; -import { - partitionByArchitect, - architectBadge, - UNASSIGNED_ARCHITECT, -} from '../views/architect-grouping.js'; - -function builder(overrides: Partial): OverviewBuilder { - return { - id: 'pir-1', issueId: '1', issueTitle: 't', phase: 'implement', protocolPhase: 'implement', - mode: 'strict', gates: {}, worktreePath: '/tmp/wt', roleId: null, protocol: 'pir', - planPhases: [], progress: 0, blocked: null, blockedGate: null, blockedSince: null, - startedAt: null, idleMs: 0, lastDataAt: null, spawnedByArchitect: null, - area: 'Uncategorized', prReady: false, - ...overrides, - } as OverviewBuilder; -} - -describe('partitionByArchitect (#1104)', () => { - it('buckets builders under their owning architect, roster order preserved', () => { - const builders = [ - builder({ id: 'a', spawnedByArchitect: 'main' }), - builder({ id: 'b', spawnedByArchitect: 'vscode' }), - builder({ id: 'c', spawnedByArchitect: 'main' }), - ]; - const parts = partitionByArchitect(builders, ['main', 'vscode']); - expect(parts.map(p => p.name)).toEqual(['main', 'vscode']); - expect(parts[0].builders.map(b => b.id)).toEqual(['a', 'c']); - expect(parts[1].builders.map(b => b.id)).toEqual(['b']); - expect(parts.every(p => p.interactive)).toBe(true); - }); - - it('keeps a passive architect (zero builders) as an interactive empty partition', () => { - const parts = partitionByArchitect( - [builder({ id: 'a', spawnedByArchitect: 'main' })], - ['main', 'reviewer'], - ); - const reviewer = parts.find(p => p.name === 'reviewer'); - expect(reviewer).toBeDefined(); - expect(reviewer!.builders).toEqual([]); - expect(reviewer!.interactive).toBe(true); - }); - - it('collects null-owner builders under a trailing, non-interactive Unassigned bucket', () => { - const builders = [ - builder({ id: 'a', spawnedByArchitect: 'main' }), - builder({ id: 'orphan', spawnedByArchitect: null }), - ]; - const parts = partitionByArchitect(builders, ['main']); - const last = parts[parts.length - 1]; - expect(last.name).toBe(UNASSIGNED_ARCHITECT); - expect(last.interactive).toBe(false); - expect(last.builders.map(b => b.id)).toEqual(['orphan']); - }); - - it('routes a builder whose owner is no longer in the roster to Unassigned', () => { - // Architect removed after spawn: the row would otherwise vanish. - const builders = [builder({ id: 'stale', spawnedByArchitect: 'gone' })]; - const parts = partitionByArchitect(builders, ['main']); - expect(parts.find(p => p.name === UNASSIGNED_ARCHITECT)?.builders.map(b => b.id)).toEqual(['stale']); - }); - - it('emits no Unassigned bucket when every builder is owned', () => { - const parts = partitionByArchitect( - [builder({ id: 'a', spawnedByArchitect: 'main' })], - ['main'], - ); - expect(parts.some(p => p.name === UNASSIGNED_ARCHITECT)).toBe(false); - }); -}); - -describe('architectBadge (#1104)', () => { - const b = builder({ spawnedByArchitect: 'vscode' }); - - it('is empty in single-architect workspaces (one architect, badge = noise)', () => { - expect(architectBadge(b, 1, false)).toBe(''); - }); - - it('is empty when the owning architect is already the row ancestor', () => { - expect(architectBadge(b, 3, true)).toBe(''); - }); - - it('shows the owning architect when multi-architect AND not an ancestor', () => { - // The Unassigned-bucket case: a stale owner still surfaces as attribution. - expect(architectBadge(b, 3, false)).toBe('vscode'); - }); - - it('is empty for a truly ownerless builder even when surfaced detached', () => { - expect(architectBadge(builder({ spawnedByArchitect: null }), 3, false)).toBe(''); - }); -}); diff --git a/packages/vscode/src/__tests__/builder-grouping.test.ts b/packages/vscode/src/__tests__/builder-grouping.test.ts index 032a4ad2c..b303861aa 100644 --- a/packages/vscode/src/__tests__/builder-grouping.test.ts +++ b/packages/vscode/src/__tests__/builder-grouping.test.ts @@ -7,7 +7,7 @@ import { describe, it, expect } from 'vitest'; import type { OverviewBuilder } from '@cluesmith/codev-types'; -import { stageGrouping, areaGrouping } from '../views/builder-grouping.js'; +import { stageGrouping, areaGrouping, architectGrouping, UNASSIGNED_ARCHITECT } from '../views/builder-grouping.js'; function builder(overrides: Partial): OverviewBuilder { return { @@ -75,3 +75,43 @@ describe('areaGrouping', () => { expect(g.flattenLoneUncategorized).toBe(true); }); }); + +describe('architectGrouping (#1104)', () => { + const g = architectGrouping(); + + it('id is architect', () => { + expect(g.id).toBe('architect'); + }); + + it('buckets by spawnedByArchitect: main first, others alphabetical, Unassigned last', () => { + const builders = [ + builder({ id: 'a', spawnedByArchitect: 'vscode' }), + builder({ id: 'b', spawnedByArchitect: 'main' }), + builder({ id: 'c', spawnedByArchitect: null }), + builder({ id: 'd', spawnedByArchitect: 'security' }), + ]; + expect(g.group(builders).map(x => x.key)).toEqual(['main', 'security', 'vscode', UNASSIGNED_ARCHITECT]); + }); + + it('only produces a group for an architect that owns builders (childless never appears)', () => { + // The strategy never sees the roster — it groups by owner present on + // builders, so an architect with no builders simply yields no group. + const builders = [builder({ id: 'a', spawnedByArchitect: 'main' })]; + expect(g.group(builders).map(x => x.key)).toEqual(['main']); + }); + + it('collects unowned builders under the Unassigned bucket only when present', () => { + expect(g.group([builder({ id: 'a', spawnedByArchitect: 'main' })]).some(x => x.key === UNASSIGNED_ARCHITECT)).toBe(false); + expect(g.group([builder({ id: 'b', spawnedByArchitect: null })]).map(x => x.key)).toEqual([UNASSIGNED_ARCHITECT]); + }); + + it('rowPrefix carries the complementary lifecycle stage; unknown omitted', () => { + expect(g.rowPrefix(builder({ protocolPhase: 'implement' }))).toBe('[implement] '); + expect(g.rowPrefix(builder({ protocolPhase: 'plan' }))).toBe('[plan] '); + expect(g.rowPrefix(builder({ protocolPhase: 'nonsense-phase' }))).toBe(''); + }); + + it('does not flatten a lone group (the architect name is information)', () => { + expect(g.flattenLoneUncategorized).toBe(false); + }); +}); diff --git a/packages/vscode/src/extension.ts b/packages/vscode/src/extension.ts index 3a7f385f4..0e509d9c7 100644 --- a/packages/vscode/src/extension.ts +++ b/packages/vscode/src/extension.ts @@ -1191,10 +1191,16 @@ export async function activate(context: vscode.ExtensionContext) { vscode.workspace.getConfiguration('codev').update('backlogShowAll', true, vscode.ConfigurationTarget.Global)), reg('codev.showBacklogMineOnly', () => vscode.workspace.getConfiguration('codev').update('backlogShowAll', false, vscode.ConfigurationTarget.Global)), + // Issue 1104: three mutually-exclusive group-by-axis commands, one per + // Agents title-bar button. Each writes its axis; the buttons render the + // active one pressed via the `toggled` menu clause keyed off the + // `codev.buildersGroupBy` context key. + reg('codev.groupBuildersByStage', () => + vscode.workspace.getConfiguration('codev').update('buildersGroupBy', 'stage', vscode.ConfigurationTarget.Global)), reg('codev.groupBuildersByArea', () => vscode.workspace.getConfiguration('codev').update('buildersGroupBy', 'area', vscode.ConfigurationTarget.Global)), - reg('codev.groupBuildersByPhase', () => - vscode.workspace.getConfiguration('codev').update('buildersGroupBy', 'stage', vscode.ConfigurationTarget.Global)), + reg('codev.groupBuildersByArchitect', () => + vscode.workspace.getConfiguration('codev').update('buildersGroupBy', 'architect', vscode.ConfigurationTarget.Global)), reg('codev.reconnect', () => connectionManager?.reconnect()), regCli('codev.connectTunnel', () => connectTunnel(connectionManager!)), regCli('codev.disconnectTunnel', () => disconnectTunnel(connectionManager!)), diff --git a/packages/vscode/src/views/architect-grouping.ts b/packages/vscode/src/views/architect-grouping.ts deleted file mode 100644 index 8320f2c2f..000000000 --- a/packages/vscode/src/views/architect-grouping.ts +++ /dev/null @@ -1,86 +0,0 @@ -/** - * Architect-tier partition for the Agents tree (Issue 1104) — the outer level-1 - * wrapper around the existing per-axis grouping strategies (`builder-grouping.ts`), - * which stay one level deep and untouched. This module owns only "which builder - * belongs to which architect"; the area/phase sub-grouping below an architect is - * still the existing strategy's job. - * - * Pure / vscode-free, so it's unit-testable under the vitest `__tests__/` - * harness (mirrors `builder-grouping.ts` / `builder-row.ts`). - */ - -import type { OverviewBuilder } from '@cluesmith/codev-types'; - -/** - * Sentinel "architect" key for builders whose `spawnedByArchitect` is `null` - * (legacy / pre-#755 rows, or a missing state.db row) OR whose owner is no - * longer in the workspace roster (architect removed). In the multi-architect - * tree these collect under one trailing "Unassigned" node so a builder is never - * silently dropped just because Tower can't attribute it. Not a real architect — - * its node carries no open-terminal command and no remove action. - */ -export const UNASSIGNED_ARCHITECT = 'unassigned'; - -/** One architect's slice of the builder list for the level-1 Agents tier. */ -export interface ArchitectPartition { - /** Architect name (canonical lowercase), or `UNASSIGNED_ARCHITECT`. */ - name: string; - /** - * `false` only for the Unassigned bucket — drives the node's non-interactive - * (no open-terminal, no remove) rendering. `true` for every real architect, - * including passive ones with zero builders. - */ - interactive: boolean; - /** This architect's builders, in the caller's display order. */ - builders: OverviewBuilder[]; -} - -/** - * Partition already-ordered builders under architects, roster order preserved - * (`architectNames` is main-first as Tower returns it). Every roster architect - * gets a partition — including passive ones, which get an empty `builders` list - * so they still render as leaf rows. Builders whose `spawnedByArchitect` is - * null/unknown or names an architect absent from the roster collect under a - * single trailing `UNASSIGNED_ARCHITECT` partition, emitted only when non-empty. - * - * `ordered` should already be `orderForDisplay`-sorted; the per-architect - * `filter` preserves that order within each bucket. - */ -export function partitionByArchitect( - ordered: OverviewBuilder[], - architectNames: string[], -): ArchitectPartition[] { - const roster = new Set(architectNames); - const partitions: ArchitectPartition[] = architectNames.map(name => ({ - name, - interactive: true, - builders: ordered.filter(b => b.spawnedByArchitect === name), - })); - - const unowned = ordered.filter( - b => !b.spawnedByArchitect || !roster.has(b.spawnedByArchitect), - ); - if (unowned.length > 0) { - partitions.push({ name: UNASSIGNED_ARCHITECT, interactive: false, builders: unowned }); - } - return partitions; -} - -/** - * Whether the owning architect should be surfaced as a dim `description` badge - * on a builder row (Issue 1104). True only when the workspace has more than one - * architect AND the architect is NOT already the row's ancestor in the tree — - * so single-architect workspaces stay clean (the badge would just repeat the - * lone architect on every row), and the nested multi-architect tree stays clean - * too (the architect node is already the ancestor). Returns the badge text, or - * `''` to render no badge. - */ -export function architectBadge( - b: OverviewBuilder, - architectCount: number, - ownerIsAncestor: boolean, -): string { - if (architectCount <= 1) { return ''; } - if (ownerIsAncestor) { return ''; } - return b.spawnedByArchitect ?? ''; -} diff --git a/packages/vscode/src/views/builder-grouping.ts b/packages/vscode/src/views/builder-grouping.ts index a0ec840a9..bc0f67e3e 100644 --- a/packages/vscode/src/views/builder-grouping.ts +++ b/packages/vscode/src/views/builder-grouping.ts @@ -14,10 +14,18 @@ import type { OverviewBuilder } from '@cluesmith/codev-types'; import { UNCATEGORIZED_AREA } from '@cluesmith/codev-core/constants'; import { groupByArea } from '@cluesmith/codev-core/area-grouping'; -import { groupByStage } from '@cluesmith/codev-core/phase-grouping'; +import { groupByStage, stageForPhase } from '@cluesmith/codev-core/phase-grouping'; -/** The grouping axes the Builders view offers. Persisted as `codev.buildersGroupBy`. */ -export type BuildersGroupBy = 'stage' | 'area'; +/** The grouping axes the Agents view offers. Persisted as `codev.buildersGroupBy`. */ +export type BuildersGroupBy = 'stage' | 'area' | 'architect'; + +/** + * Group key for builders with no resolvable owner (`spawnedByArchitect` null — + * legacy / pre-#755 rows) under the `architect` axis (Issue 1104). A flat group + * only exists when it has builders, so a childless architect never appears; this + * bucket only collects genuinely unowned builders, sorted last. + */ +export const UNASSIGNED_ARCHITECT = 'Unassigned'; /** A display group: a header key (an area name or a stage name) and its builders. */ export interface BuilderGroup { @@ -76,3 +84,40 @@ export function areaGrouping(): BuilderGrouping { flattenLoneUncategorized: true, }; } + +/** + * Architect axis (Issue 1104): groups are the architects that own in-flight work + * (`spawnedByArchitect`), `main` first, then the rest alphabetically, with the + * `Unassigned` bucket (unowned builders) last. Because a flat group only exists + * when it holds builders, a *childless* architect produces no group and simply + * doesn't appear — the work view shows owners of work, not the full roster (the + * full roster lives in Workspace > Architects). The row prefix carries the + * complementary lifecycle stage (`[implement]`, etc.) so a row still reads + * "where in the lifecycle" under its owner. A lone group is never flattened — + * the architect name is information worth a header even when there's only one. + */ +export function architectGrouping(): BuilderGrouping { + return { + id: 'architect', + group: ordered => { + const buckets = new Map(); + for (const b of ordered) { + const key = b.spawnedByArchitect || UNASSIGNED_ARCHITECT; + const bucket = buckets.get(key); + if (bucket) { bucket.push(b); } else { buckets.set(key, [b]); } + } + const keys = [...buckets.keys()]; + const ordering = [ + ...(buckets.has('main') ? ['main'] : []), + ...keys.filter(k => k !== 'main' && k !== UNASSIGNED_ARCHITECT).sort(), + ...(buckets.has(UNASSIGNED_ARCHITECT) ? [UNASSIGNED_ARCHITECT] : []), + ]; + return ordering.map(k => ({ key: k, items: buckets.get(k)! })); + }, + rowPrefix: b => { + const stage = stageForPhase(b.protocolPhase); + return stage && stage !== 'unknown' ? `[${stage}] ` : ''; + }, + flattenLoneUncategorized: false, + }; +} diff --git a/packages/vscode/src/views/builder-tree-item.ts b/packages/vscode/src/views/builder-tree-item.ts index 06abfa746..a08bd69d1 100644 --- a/packages/vscode/src/views/builder-tree-item.ts +++ b/packages/vscode/src/views/builder-tree-item.ts @@ -1,7 +1,6 @@ import * as vscode from 'vscode'; import { AreaGroupTreeItem } from './area-group-tree-item.js'; import { BUILDER_STATE_GLYPH, worstBuilderState, type GroupRollup } from './builder-row.js'; -import { displayArchitectName } from './architect-display.js'; /** * TreeItem subclass that carries a builder id as a typed field. @@ -51,83 +50,10 @@ export class BuilderGroupTreeItem extends AreaGroupTreeItem { count: number, collapsibleState: vscode.TreeItemCollapsibleState, rollup: GroupRollup, - /** - * Owning architect's name when this group sits under an architect node in - * the multi-architect Agents tree (Issue 1104), else `undefined` for the - * single-architect root-level grouping. When set it namespaces the row id - * so two architects' identically-keyed groups (e.g. both own a `TOWER` - * group) don't collide on VSCode's id-keyed expansion state, and lets - * `getChildren` filter that group's rows to the right architect. - */ - public readonly architectName?: string, ) { super(groupName, 'builder', count, collapsibleState); - if (architectName) { - this.id = `builder-group:${architectName}:${groupName}`; - } const { icon, color } = BUILDER_STATE_GLYPH[worstBuilderState(rollup)]; this.iconPath = new vscode.ThemeIcon(icon, new vscode.ThemeColor(color)); this.tooltip = `${rollup.blocked} blocked · ${rollup.idle} waiting · ${rollup.active} active`; } } - -/** - * Architect-tier node — the level-1 grouping in the Agents tree when the - * workspace hosts more than one architect (Issue 1104). Renders the architect's - * (uppercased) name and, when it owns builders, a worst-of rollup glyph + count - * over ALL builders beneath it (summed across its area/phase sub-groups) — the - * same `BUILDER_STATE_GLYPH` vocabulary and `" blocked · waiting · - * active"` tooltip as `BuilderGroupTreeItem`, one tier up. - * - * Two shapes: - * - **Owns builders** → `Collapsed`, rollup glyph, click opens that architect's - * terminal. Siblings carry a `-sibling` contextValue (Remove menu); `main` - * carries `-main` (undeletable). - * - **Passive** (REVIEWER pattern: zero builders) → `None` leaf with a neutral - * `person` icon, so it reads as an interactive-but-empty identity row rather - * than a falsely-green "active" dot. Still clickable to open its terminal. - * - * The synthetic "Unassigned" bucket (`UNASSIGNED_ARCHITECT`) is not a real - * architect: `interactive: false` drops the open-terminal command and the - * remove menu, and it gets a neutral `question` icon. - */ -export class ArchitectGroupTreeItem extends vscode.TreeItem { - constructor( - public readonly architectName: string, - builderCount: number, - rollup: GroupRollup, - interactive: boolean = true, - ) { - const hasBuilders = builderCount > 0; - super( - hasBuilders ? `${displayArchitectName(architectName)} (${builderCount})` : displayArchitectName(architectName), - hasBuilders ? vscode.TreeItemCollapsibleState.Collapsed : vscode.TreeItemCollapsibleState.None, - ); - this.id = `agent-architect:${architectName}`; - - if (!interactive) { - // Unassigned bucket: neutral, non-clickable, no remove. - this.iconPath = new vscode.ThemeIcon('question', new vscode.ThemeColor('disabledForeground')); - this.tooltip = `${rollup.blocked} blocked · ${rollup.idle} waiting · ${rollup.active} active`; - this.contextValue = 'agent-unassigned'; - return; - } - - if (hasBuilders) { - const { icon, color } = BUILDER_STATE_GLYPH[worstBuilderState(rollup)]; - this.iconPath = new vscode.ThemeIcon(icon, new vscode.ThemeColor(color)); - this.tooltip = `${rollup.blocked} blocked · ${rollup.idle} waiting · ${rollup.active} active`; - } else { - this.iconPath = new vscode.ThemeIcon('person'); - this.tooltip = `${displayArchitectName(architectName)} — no builders`; - } - // `main` is workspace-defining and undeletable; siblings expose Remove via - // the package.json menus contribution (mirrors Workspace > Architects). - this.contextValue = architectName === 'main' ? 'agent-architect-main' : 'agent-architect-sibling'; - this.command = { - command: 'codev.openArchitectTerminal', - title: 'Open Architect Terminal', - arguments: [architectName], - }; - } -} diff --git a/packages/vscode/src/views/builders.ts b/packages/vscode/src/views/builders.ts index 198b16dde..a44d4ffde 100644 --- a/packages/vscode/src/views/builders.ts +++ b/packages/vscode/src/views/builders.ts @@ -7,12 +7,7 @@ import { UNCATEGORIZED_AREA } from '@cluesmith/codev-core/constants'; import type { OverviewCache } from './overview-data.js'; import { builderWithWorktree, type OverviewBuilderWithWorktree } from '../builder-lookup.js'; import { readBuildersFileViewAsTree } from '../builders-config.js'; -import { ArchitectGroupTreeItem, BuilderGroupTreeItem, BuilderTreeItem } from './builder-tree-item.js'; -import { - partitionByArchitect, - architectBadge, - type ArchitectPartition, -} from './architect-grouping.js'; +import { BuilderGroupTreeItem, BuilderTreeItem } from './builder-tree-item.js'; import { BuilderFileTreeItem } from './builder-file-tree-item.js'; import { BuilderFolderTreeItem } from './builder-folder-tree-item.js'; import { buildFilePathTree, type FilePathNode } from './file-path-tree.js'; @@ -22,6 +17,7 @@ import { type BuildersGroupBy, stageGrouping, areaGrouping, + architectGrouping, } from './builder-grouping.js'; import { builderRowLabel, @@ -100,21 +96,6 @@ export class BuildersProvider implements vscode.TreeDataProvider(); - // Multi-architect tier (Issue 1104). Populated by `multiArchRoot` when - // `architectCount > 1`; empty (and unused) in the single-architect / zero - // path, which keeps today's two-level behaviour bit-for-bit. Precomputed at - // root render (not lazily) so `getParent` can walk builder → group → - // architect for `reveal` before any node is expanded. - // - architectChildren: architect-node id → its level-2 children (group - // nodes, or flattened builder rows in the lone-Uncategorized case). - // - groupRows: namespaced group-node id → its builder rows. - // - multiArchBuilderParent: builder id → its parent row (a group node, or - // the architect node directly in the flattened case). - // - multiArchGroupParent: group-node id → its owning architect node. - private architectChildren = new Map(); - private groupRows = new Map(); - private multiArchBuilderParent = new Map(); - private multiArchGroupParent = new Map(); // Accordion row-id versioning (#913) — see AccordionRowIds. private readonly rowIds = new AccordionRowIds(); @@ -125,6 +106,7 @@ export class BuildersProvider implements vscode.TreeDataProvider this.changeEmitter.fire()); } @@ -143,17 +125,19 @@ export class BuildersProvider implements vscode.TreeDataProvider('buildersGroupBy', 'stage'); - return this.groupings[mode === 'area' ? 'area' : 'stage']; + return this.groupings[mode] ?? this.groupings.stage; } /** @@ -180,25 +164,14 @@ export class BuildersProvider implements vscode.TreeDataProvider { if (element instanceof BuilderTreeItem) { - // Multi-architect: builder → group node (or architect node when the - // architect's builders flattened). Single-architect: builder → group - // node (or undefined in the lone-Uncategorized flatten). The multi-arch - // map is empty in single-architect mode, so the fallback is today's path. - return this.multiArchBuilderParent.get(element.builderId) - ?? this.groupParentByBuilderId.get(element.builderId); - } - // Multi-architect group node → its owning architect node. Single-architect - // group nodes are roots (architectName undefined → not in the map). - if (element instanceof BuilderGroupTreeItem) { - return this.multiArchGroupParent.get(element.id as string); - } - // Architect-tier nodes are roots. - if (element instanceof ArchitectGroupTreeItem) { - return undefined; + // Builder → its group node (or undefined in the lone-Uncategorized flatten + // case — VSCode treats those builders as roots). + return this.groupParentByBuilderId.get(element.builderId); } if (element instanceof BuilderFileTreeItem || element instanceof BuilderFolderTreeItem) { return this.parentForFileNode(element); } + // Group rows are roots. return undefined; } @@ -289,31 +262,17 @@ export class BuildersProvider implements vscode.TreeDataProvider 1) { - return this.multiArchRoot(data.builders, architects.map(a => a.name), now); - } - return this.singleArchRoot(orderForDisplay(data.builders, now), now); - } - - /** Clear every per-render tier map (both single- and multi-architect). */ - private clearTierMaps(): void { - this.groupParentByBuilderId.clear(); - this.architectChildren.clear(); - this.groupRows.clear(); - this.multiArchBuilderParent.clear(); - this.multiArchGroupParent.clear(); - } - - /** - * Single-architect (or zero-architect) root: today's area/phase grouping, - * unchanged. Returns flattened builder rows for the lone-Uncategorized case, - * else `BuilderGroupTreeItem` headers (each rendered Expanded — #913). - */ - private singleArchRoot(ordered: OverviewBuilder[], now: number): vscode.TreeItem[] { const grouping = this.active(); - const groups = grouping.group(ordered); + const groups = grouping.group(orderForDisplay(data.builders, now)); // A repo that doesn't use `area/*` labels yields a single `Uncategorized` // group; in area mode its header adds no information, so flatten to root rows - // — zero visual regression for unlabeled repos. Stage mode opts out of this - // (the stage axis always applies; every builder has a stage). + // — zero visual regression for unlabeled repos. Stage and architect modes + // opt out of this (their lone group always carries information). if (grouping.flattenLoneUncategorized && groups.length === 1 && groups[0].key === UNCATEGORIZED_AREA) { return groups[0].items.map(b => this.makeBuilderRow(b, now)); } @@ -375,94 +308,6 @@ export class BuildersProvider implements vscode.TreeDataProvider this.makeArchitectNode(p, architectNames.length, now)); - } - - /** - * Build one architect node and precompute its level-2 children (group nodes, - * or flattened builder rows when its slice is a lone Uncategorized group), - * wiring the parent maps as it goes. A passive architect (empty slice) becomes - * a leaf node with no children. - */ - private makeArchitectNode( - partition: ArchitectPartition, - architectCount: number, - now: number, - ): ArchitectGroupTreeItem { - const node = new ArchitectGroupTreeItem( - partition.name, - partition.builders.length, - rollupGroupState(partition.builders, now), - partition.interactive, - ); - - if (partition.builders.length === 0) { - return node; // passive architect → leaf, no children - } - - // For a real architect the node IS the row's ancestor, so the attribution - // badge is suppressed; under "Unassigned" it is not, so a builder whose - // owner was removed still shows its stale architect name (`architectBadge`). - const ownerIsAncestor = partition.interactive; - const rowOf = (b: OverviewBuilder) => - this.makeBuilderRow(b, now, architectBadge(b, architectCount, ownerIsAncestor)); - - const grouping = this.active(); - const groups = grouping.group(partition.builders); - const nodeId = node.id as string; - - // Lone-Uncategorized: flatten to builder rows directly under the architect - // (same rule as the single-architect root, one tier down). The architect - // node is then the builders' direct parent. - if (grouping.flattenLoneUncategorized && groups.length === 1 && groups[0].key === UNCATEGORIZED_AREA) { - const rows = groups[0].items.map(rowOf); - for (const b of groups[0].items) { - this.multiArchBuilderParent.set(b.id, node); - } - this.architectChildren.set(nodeId, rows); - return node; - } - - const groupNodes = groups.map(g => { - const groupItem = new BuilderGroupTreeItem( - g.key, - g.items.length, - vscode.TreeItemCollapsibleState.Expanded, - rollupGroupState(g.items, now), - partition.name, - ); - const groupId = groupItem.id as string; - this.multiArchGroupParent.set(groupId, node); - const rows = g.items.map(rowOf); - for (const b of g.items) { - this.multiArchBuilderParent.set(b.id, groupItem); - } - this.groupRows.set(groupId, rows); - return groupItem; - }); - this.architectChildren.set(nodeId, groupNodes); - return node; - } - private rowsForGroup(key: string): vscode.TreeItem[] { const data = this.cache.getData(); if (!data) { return []; } @@ -475,19 +320,10 @@ export class BuildersProvider implements vscode.TreeDataProvider this.makeBuilderRow(b, now)); } - private makeBuilderRow(b: OverviewBuilder, now: number, descriptionBadge: string = ''): BuilderTreeItem { + private makeBuilderRow(b: OverviewBuilder, now: number): BuilderTreeItem { const isBlocked = !!b.blocked; const isIdle = !isBlocked && isIdleWaiting(b, now); const item = new BuilderTreeItem(b.id, builderRowLabel(b, isIdle, now, this.active().rowPrefix(b))); - // Architect-attribution badge (Issue 1104): the dim, right-aligned - // `description` carries the owning architect's name only when it isn't - // already the row's ancestor (see `architectBadge`). Empty in the nested - // multi-architect tree (the architect node is the ancestor) and in - // single-architect workspaces (one architect, repeated badge = noise), so - // it stays unset there. - if (descriptionBadge) { - item.description = descriptionBadge; - } // Versioned id (#913). The base `b.id` is stable (not the churning label) so // VSCode preserves a row's expansion across the frequent overview-poll // refreshes; the `#` suffix is the accordion lever (see From 8e7821e8b7d0b54680ac0148c651bf1625965015 Mon Sep 17 00:00:00 2001 From: Amr Elsayed Date: Sun, 28 Jun 2026 10:29:35 +1000 Subject: [PATCH 18/29] [PIR #1104] Name the active group-by axis in the Agents title (clear at-a-glance selection) --- packages/vscode/src/extension.ts | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/packages/vscode/src/extension.ts b/packages/vscode/src/extension.ts index 0e509d9c7..1f867c88a 100644 --- a/packages/vscode/src/extension.ts +++ b/packages/vscode/src/extension.ts @@ -320,7 +320,15 @@ export async function activate(context: vscode.ExtensionContext) { const data = overviewCache.getData(); const withCount = (base: string, n: number | undefined) => typeof n === 'number' ? `${base} (${n})` : base; - if (buildersView) { buildersView.title = withCount('Agents', data?.builders.length); } + // Agents title names the active grouping axis (Issue 1104) so the selected + // mode is unambiguous at a glance — the `toggled` title-bar buttons only + // render a faint pressed-highlight (washed out in light themes), which + // isn't a reliable "which axis am I in" signal on its own. The workbench + // uppercases view titles, so this reads e.g. `AGENTS (2) · ARCHITECT`. + if (buildersView) { + const axis = vscode.workspace.getConfiguration('codev').get('buildersGroupBy', 'stage'); + buildersView.title = `${withCount('Agents', data?.builders.length)} · ${axis}`; + } if (pullRequestsView) { pullRequestsView.title = withCount('Pull Requests', data?.pendingPRs.length); } if (backlogView) { // Backlog title reflects the *visible* row count (mine-only vs show-all), @@ -627,6 +635,8 @@ export async function activate(context: vscode.ExtensionContext) { if (!e.affectsConfiguration('codev.buildersGroupBy')) { return; } vscode.commands.executeCommand('setContext', 'codev.buildersGroupBy', readBuildersGroupBy()); buildersProvider.refresh(); + // Keep the "· " suffix in the Agents title in sync with the toggle. + updateListViewTitles(); }), ); From acaba08bbab86892e3da999c7c62ff2ca15fcab1 Mon Sep 17 00:00:00 2001 From: Amr Elsayed Date: Sun, 28 Jun 2026 10:47:13 +1000 Subject: [PATCH 19/29] [PIR #1104] Agents group-by: single cycling toolbar button (icon = current axis) VS Code menus have no 'toggled'/pressed state (verified against the schema), and a toolbar action's icon is fixed to its command. So show the active grouping with one button whose icon IS the current axis (milestone/tag/octopus), swapped via three show/hide agentsCycleGroupFrom* commands; clicking advances to the next axis. The direct groupBuildersBy* commands stay for palette/keybindings. --- packages/vscode/package.json | 49 +++++++++++++++++++++++++------- packages/vscode/src/extension.ts | 47 ++++++++++++++++-------------- 2 files changed, 64 insertions(+), 32 deletions(-) diff --git a/packages/vscode/package.json b/packages/vscode/package.json index feb7dec85..a97ed2192 100644 --- a/packages/vscode/package.json +++ b/packages/vscode/package.json @@ -164,6 +164,24 @@ "dark": "icons/architect-dark.svg" } }, + { + "command": "codev.agentsCycleGroupFromStage", + "title": "Codev: Agents Grouped by Stage (click to change)", + "icon": "$(milestone)" + }, + { + "command": "codev.agentsCycleGroupFromArea", + "title": "Codev: Agents Grouped by Area (click to change)", + "icon": "$(tag)" + }, + { + "command": "codev.agentsCycleGroupFromArchitect", + "title": "Codev: Agents Grouped by Architect (click to change)", + "icon": { + "light": "icons/architect-light.svg", + "dark": "icons/architect-dark.svg" + } + }, { "command": "codev.openBacklogSearch", "title": "Codev: Search Backlog", @@ -372,6 +390,18 @@ "command": "codev.openMarkdownPreview", "when": "resourceLangId == markdown && resourcePath =~ /\\/codev\\/(plans|specs|reviews)\\//" }, + { + "command": "codev.agentsCycleGroupFromStage", + "when": "false" + }, + { + "command": "codev.agentsCycleGroupFromArea", + "when": "false" + }, + { + "command": "codev.agentsCycleGroupFromArchitect", + "when": "false" + }, { "command": "codev.forwardSelectionToBuilder", "when": "false" @@ -624,22 +654,19 @@ "group": "navigation" }, { - "command": "codev.groupBuildersByStage", - "when": "view == codev.agents", - "toggled": "!codev.buildersGroupBy || codev.buildersGroupBy == 'stage'", + "command": "codev.agentsCycleGroupFromStage", + "when": "view == codev.agents && (!codev.buildersGroupBy || codev.buildersGroupBy == 'stage')", "group": "navigation@1" }, { - "command": "codev.groupBuildersByArea", - "when": "view == codev.agents", - "toggled": "codev.buildersGroupBy == 'area'", - "group": "navigation@2" + "command": "codev.agentsCycleGroupFromArea", + "when": "view == codev.agents && codev.buildersGroupBy == 'area'", + "group": "navigation@1" }, { - "command": "codev.groupBuildersByArchitect", - "when": "view == codev.agents", - "toggled": "codev.buildersGroupBy == 'architect'", - "group": "navigation@3" + "command": "codev.agentsCycleGroupFromArchitect", + "when": "view == codev.agents && codev.buildersGroupBy == 'architect'", + "group": "navigation@1" }, { "command": "codev.refreshOverview", diff --git a/packages/vscode/src/extension.ts b/packages/vscode/src/extension.ts index 1f867c88a..8b6baa3b6 100644 --- a/packages/vscode/src/extension.ts +++ b/packages/vscode/src/extension.ts @@ -320,15 +320,7 @@ export async function activate(context: vscode.ExtensionContext) { const data = overviewCache.getData(); const withCount = (base: string, n: number | undefined) => typeof n === 'number' ? `${base} (${n})` : base; - // Agents title names the active grouping axis (Issue 1104) so the selected - // mode is unambiguous at a glance — the `toggled` title-bar buttons only - // render a faint pressed-highlight (washed out in light themes), which - // isn't a reliable "which axis am I in" signal on its own. The workbench - // uppercases view titles, so this reads e.g. `AGENTS (2) · ARCHITECT`. - if (buildersView) { - const axis = vscode.workspace.getConfiguration('codev').get('buildersGroupBy', 'stage'); - buildersView.title = `${withCount('Agents', data?.builders.length)} · ${axis}`; - } + if (buildersView) { buildersView.title = withCount('Agents', data?.builders.length); } if (pullRequestsView) { pullRequestsView.title = withCount('Pull Requests', data?.pendingPRs.length); } if (backlogView) { // Backlog title reflects the *visible* row count (mine-only vs show-all), @@ -635,8 +627,6 @@ export async function activate(context: vscode.ExtensionContext) { if (!e.affectsConfiguration('codev.buildersGroupBy')) { return; } vscode.commands.executeCommand('setContext', 'codev.buildersGroupBy', readBuildersGroupBy()); buildersProvider.refresh(); - // Keep the "· " suffix in the Agents title in sync with the toggle. - updateListViewTitles(); }), ); @@ -745,6 +735,12 @@ export async function activate(context: vscode.ExtensionContext) { // eslint-disable-next-line no-restricted-syntax -- this IS the regCli helper (#791) vscode.commands.registerCommand(id, guard(handler)); + // Issue 1104: write the Agents group-by axis. Shared by the three direct + // `groupBuildersBy*` commands (palette / keybindings) and the three + // `agentsCycleGroupFrom*` toolbar buttons. + const setGroupBy = (axis: 'stage' | 'area' | 'architect') => + vscode.workspace.getConfiguration('codev').update('buildersGroupBy', axis, vscode.ConfigurationTarget.Global); + // Commands context.subscriptions.push( reg('codev.helloWorld', () => { @@ -1201,16 +1197,25 @@ export async function activate(context: vscode.ExtensionContext) { vscode.workspace.getConfiguration('codev').update('backlogShowAll', true, vscode.ConfigurationTarget.Global)), reg('codev.showBacklogMineOnly', () => vscode.workspace.getConfiguration('codev').update('backlogShowAll', false, vscode.ConfigurationTarget.Global)), - // Issue 1104: three mutually-exclusive group-by-axis commands, one per - // Agents title-bar button. Each writes its axis; the buttons render the - // active one pressed via the `toggled` menu clause keyed off the - // `codev.buildersGroupBy` context key. - reg('codev.groupBuildersByStage', () => - vscode.workspace.getConfiguration('codev').update('buildersGroupBy', 'stage', vscode.ConfigurationTarget.Global)), - reg('codev.groupBuildersByArea', () => - vscode.workspace.getConfiguration('codev').update('buildersGroupBy', 'area', vscode.ConfigurationTarget.Global)), - reg('codev.groupBuildersByArchitect', () => - vscode.workspace.getConfiguration('codev').update('buildersGroupBy', 'architect', vscode.ConfigurationTarget.Global)), + // Issue 1104: Agents group-by axis (stage | area | architect). + // + // VS Code's menu schema has NO `toggled`/pressed-state for toolbar + // buttons (verified against menusExtensionPoint.ts), and a toolbar + // action's icon comes from its command, so the only reliable way to show + // the *current* axis is a single button whose icon IS that axis. We do + // that with three show/hide "cycle" commands (one per axis, swapped by + // `when` in package.json): exactly one is visible, its icon names the + // current grouping, and clicking it advances to the next axis. The three + // direct `groupBuildersBy*` commands remain for the palette / keybindings + // (jump straight to an axis). The `setGroupBy` helper is declared above the + // push() call (a const can't live in an argument list). + reg('codev.groupBuildersByStage', () => setGroupBy('stage')), + reg('codev.groupBuildersByArea', () => setGroupBy('area')), + reg('codev.groupBuildersByArchitect', () => setGroupBy('architect')), + // Cycle buttons: shown only when their axis is active; click → next axis. + reg('codev.agentsCycleGroupFromStage', () => setGroupBy('area')), + reg('codev.agentsCycleGroupFromArea', () => setGroupBy('architect')), + reg('codev.agentsCycleGroupFromArchitect', () => setGroupBy('stage')), reg('codev.reconnect', () => connectionManager?.reconnect()), regCli('codev.connectTunnel', () => connectTunnel(connectionManager!)), regCli('codev.disconnectTunnel', () => disconnectTunnel(connectionManager!)), From 189e0f7f01c5878f822b95a787a2cab41c942682 Mon Sep 17 00:00:00 2001 From: Amr Elsayed Date: Sun, 28 Jun 2026 10:56:45 +1000 Subject: [PATCH 20/29] [PIR #1104] Group-by button: present-tense titles, render first, drop redundant direct commands - Titles use present tense 'Group by X' (was 'Grouped by X'), no '(click to change)'. - Grouping button rendered first in the Agents toolbar (navigation@1; refresh/ collapse/file-tree pushed to @2/@3/@4). - Drop the redundant direct groupBuildersBy* commands (they duplicated the cycle commands' titles and were palette-only); the cycle button is the single grouping affordance. --- packages/vscode/package.json | 34 +++++++-------------------- packages/vscode/src/extension.ts | 16 ++++--------- packages/vscode/src/views/builders.ts | 12 +++++----- 3 files changed, 19 insertions(+), 43 deletions(-) diff --git a/packages/vscode/package.json b/packages/vscode/package.json index a97ed2192..f0277cdfc 100644 --- a/packages/vscode/package.json +++ b/packages/vscode/package.json @@ -146,37 +146,19 @@ "title": "Codev: View Changed Files as List", "icon": "$(list-flat)" }, - { - "command": "codev.groupBuildersByStage", - "title": "Codev: Group by Stage", - "icon": "$(milestone)" - }, - { - "command": "codev.groupBuildersByArea", - "title": "Codev: Group by Area", - "icon": "$(tag)" - }, - { - "command": "codev.groupBuildersByArchitect", - "title": "Codev: Group by Architect", - "icon": { - "light": "icons/architect-light.svg", - "dark": "icons/architect-dark.svg" - } - }, { "command": "codev.agentsCycleGroupFromStage", - "title": "Codev: Agents Grouped by Stage (click to change)", + "title": "Codev: Group by Stage", "icon": "$(milestone)" }, { "command": "codev.agentsCycleGroupFromArea", - "title": "Codev: Agents Grouped by Area (click to change)", + "title": "Codev: Group by Area", "icon": "$(tag)" }, { "command": "codev.agentsCycleGroupFromArchitect", - "title": "Codev: Agents Grouped by Architect (click to change)", + "title": "Codev: Group by Architect", "icon": { "light": "icons/architect-light.svg", "dark": "icons/architect-dark.svg" @@ -631,27 +613,27 @@ { "command": "codev.refreshOverview", "when": "view == codev.agents", - "group": "navigation" + "group": "navigation@2" }, { "command": "codev.disableBuildersAutoCollapse", "when": "view == codev.agents && codev.buildersAutoCollapse", - "group": "navigation" + "group": "navigation@3" }, { "command": "codev.enableBuildersAutoCollapse", "when": "view == codev.agents && !codev.buildersAutoCollapse", - "group": "navigation" + "group": "navigation@3" }, { "command": "codev.disableBuildersFileTreeMode", "when": "view == codev.agents && codev.buildersFileViewAsTree", - "group": "navigation" + "group": "navigation@4" }, { "command": "codev.enableBuildersFileTreeMode", "when": "view == codev.agents && !codev.buildersFileViewAsTree", - "group": "navigation" + "group": "navigation@4" }, { "command": "codev.agentsCycleGroupFromStage", diff --git a/packages/vscode/src/extension.ts b/packages/vscode/src/extension.ts index 8b6baa3b6..0196081c6 100644 --- a/packages/vscode/src/extension.ts +++ b/packages/vscode/src/extension.ts @@ -735,9 +735,8 @@ export async function activate(context: vscode.ExtensionContext) { // eslint-disable-next-line no-restricted-syntax -- this IS the regCli helper (#791) vscode.commands.registerCommand(id, guard(handler)); - // Issue 1104: write the Agents group-by axis. Shared by the three direct - // `groupBuildersBy*` commands (palette / keybindings) and the three - // `agentsCycleGroupFrom*` toolbar buttons. + // Issue 1104: write the Agents group-by axis, used by the three + // `agentsCycleGroupFrom*` toolbar buttons (one visible at a time). const setGroupBy = (axis: 'stage' | 'area' | 'architect') => vscode.workspace.getConfiguration('codev').update('buildersGroupBy', axis, vscode.ConfigurationTarget.Global); @@ -1205,14 +1204,9 @@ export async function activate(context: vscode.ExtensionContext) { // the *current* axis is a single button whose icon IS that axis. We do // that with three show/hide "cycle" commands (one per axis, swapped by // `when` in package.json): exactly one is visible, its icon names the - // current grouping, and clicking it advances to the next axis. The three - // direct `groupBuildersBy*` commands remain for the palette / keybindings - // (jump straight to an axis). The `setGroupBy` helper is declared above the - // push() call (a const can't live in an argument list). - reg('codev.groupBuildersByStage', () => setGroupBy('stage')), - reg('codev.groupBuildersByArea', () => setGroupBy('area')), - reg('codev.groupBuildersByArchitect', () => setGroupBy('architect')), - // Cycle buttons: shown only when their axis is active; click → next axis. + // current grouping, and clicking it advances to the next axis. The + // `setGroupBy` helper is declared above the push() call (a const can't + // live in an argument list). reg('codev.agentsCycleGroupFromStage', () => setGroupBy('area')), reg('codev.agentsCycleGroupFromArea', () => setGroupBy('architect')), reg('codev.agentsCycleGroupFromArchitect', () => setGroupBy('stage')), diff --git a/packages/vscode/src/views/builders.ts b/packages/vscode/src/views/builders.ts index a44d4ffde..9142ec7ab 100644 --- a/packages/vscode/src/views/builders.ts +++ b/packages/vscode/src/views/builders.ts @@ -126,12 +126,12 @@ export class BuildersProvider implements vscode.TreeDataProvider Date: Sun, 28 Jun 2026 11:02:10 +1000 Subject: [PATCH 21/29] [PIR #1104] Group-by button shows the NEXT axis (action), not the current one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The button is an action ('click to group by X'), so each shows the icon + title of the axis clicking will apply: grouped by architect → button reads 'Group by Stage'. The current grouping is read from the tree (group headers). --- packages/vscode/package.json | 12 ++++++------ packages/vscode/src/extension.ts | 16 +++++++++------- packages/vscode/src/views/builders.ts | 5 +++-- 3 files changed, 18 insertions(+), 15 deletions(-) diff --git a/packages/vscode/package.json b/packages/vscode/package.json index f0277cdfc..14f6cb5bc 100644 --- a/packages/vscode/package.json +++ b/packages/vscode/package.json @@ -148,22 +148,22 @@ }, { "command": "codev.agentsCycleGroupFromStage", - "title": "Codev: Group by Stage", - "icon": "$(milestone)" - }, - { - "command": "codev.agentsCycleGroupFromArea", "title": "Codev: Group by Area", "icon": "$(tag)" }, { - "command": "codev.agentsCycleGroupFromArchitect", + "command": "codev.agentsCycleGroupFromArea", "title": "Codev: Group by Architect", "icon": { "light": "icons/architect-light.svg", "dark": "icons/architect-dark.svg" } }, + { + "command": "codev.agentsCycleGroupFromArchitect", + "title": "Codev: Group by Stage", + "icon": "$(milestone)" + }, { "command": "codev.openBacklogSearch", "title": "Codev: Search Backlog", diff --git a/packages/vscode/src/extension.ts b/packages/vscode/src/extension.ts index 0196081c6..c234daa20 100644 --- a/packages/vscode/src/extension.ts +++ b/packages/vscode/src/extension.ts @@ -1200,13 +1200,15 @@ export async function activate(context: vscode.ExtensionContext) { // // VS Code's menu schema has NO `toggled`/pressed-state for toolbar // buttons (verified against menusExtensionPoint.ts), and a toolbar - // action's icon comes from its command, so the only reliable way to show - // the *current* axis is a single button whose icon IS that axis. We do - // that with three show/hide "cycle" commands (one per axis, swapped by - // `when` in package.json): exactly one is visible, its icon names the - // current grouping, and clicking it advances to the next axis. The - // `setGroupBy` helper is declared above the push() call (a const can't - // live in an argument list). + // action's icon comes from its command. So this is a single action + // button: three show/hide commands (swapped by `when` on the CURRENT + // axis), exactly one visible, and each shows the icon + title of the + // NEXT axis (what clicking applies) — e.g. while grouped by architect + // the button reads "Group by Stage". The CURRENT grouping is read from + // the tree itself (group headers are stages / areas / architect names). + // Each `*FromX` command is the button shown while grouped by X and + // advances to the next axis. The `setGroupBy` helper is declared above + // the push() call (a const can't live in an argument list). reg('codev.agentsCycleGroupFromStage', () => setGroupBy('area')), reg('codev.agentsCycleGroupFromArea', () => setGroupBy('architect')), reg('codev.agentsCycleGroupFromArchitect', () => setGroupBy('stage')), diff --git a/packages/vscode/src/views/builders.ts b/packages/vscode/src/views/builders.ts index 9142ec7ab..fa522d108 100644 --- a/packages/vscode/src/views/builders.ts +++ b/packages/vscode/src/views/builders.ts @@ -128,8 +128,9 @@ export class BuildersProvider implements vscode.TreeDataProvider Date: Sun, 28 Jun 2026 11:13:45 +1000 Subject: [PATCH 22/29] [PIR #1104] Drop the bogus 'Unassigned' architect group Every builder is spawned with an owner (afx spawn defaults to 'main'), so there's no such thing as an unassigned builder. A null spawnedByArchitect is only a data-integrity edge (missing/legacy state.db row); fold it into the main group, matching the affinity router's fallback. Removes UNASSIGNED_ARCHITECT. --- .../src/__tests__/builder-grouping.test.ts | 20 +++++++---- packages/vscode/src/views/builder-grouping.ts | 35 +++++++++---------- 2 files changed, 29 insertions(+), 26 deletions(-) diff --git a/packages/vscode/src/__tests__/builder-grouping.test.ts b/packages/vscode/src/__tests__/builder-grouping.test.ts index b303861aa..43f4d0819 100644 --- a/packages/vscode/src/__tests__/builder-grouping.test.ts +++ b/packages/vscode/src/__tests__/builder-grouping.test.ts @@ -7,7 +7,7 @@ import { describe, it, expect } from 'vitest'; import type { OverviewBuilder } from '@cluesmith/codev-types'; -import { stageGrouping, areaGrouping, architectGrouping, UNASSIGNED_ARCHITECT } from '../views/builder-grouping.js'; +import { stageGrouping, areaGrouping, architectGrouping } from '../views/builder-grouping.js'; function builder(overrides: Partial): OverviewBuilder { return { @@ -83,14 +83,13 @@ describe('architectGrouping (#1104)', () => { expect(g.id).toBe('architect'); }); - it('buckets by spawnedByArchitect: main first, others alphabetical, Unassigned last', () => { + it('buckets by spawnedByArchitect: main first, others alphabetical', () => { const builders = [ builder({ id: 'a', spawnedByArchitect: 'vscode' }), builder({ id: 'b', spawnedByArchitect: 'main' }), - builder({ id: 'c', spawnedByArchitect: null }), builder({ id: 'd', spawnedByArchitect: 'security' }), ]; - expect(g.group(builders).map(x => x.key)).toEqual(['main', 'security', 'vscode', UNASSIGNED_ARCHITECT]); + expect(g.group(builders).map(x => x.key)).toEqual(['main', 'security', 'vscode']); }); it('only produces a group for an architect that owns builders (childless never appears)', () => { @@ -100,9 +99,16 @@ describe('architectGrouping (#1104)', () => { expect(g.group(builders).map(x => x.key)).toEqual(['main']); }); - it('collects unowned builders under the Unassigned bucket only when present', () => { - expect(g.group([builder({ id: 'a', spawnedByArchitect: 'main' })]).some(x => x.key === UNASSIGNED_ARCHITECT)).toBe(false); - expect(g.group([builder({ id: 'b', spawnedByArchitect: null })]).map(x => x.key)).toEqual([UNASSIGNED_ARCHITECT]); + it('folds a null-owner builder (data-integrity edge) into the main group', () => { + // Every spawn records an owner (default main); a null owner is only a + // missing/legacy state.db row, so it folds into main, never an "unassigned" + // group. + const groups = g.group([ + builder({ id: 'a', spawnedByArchitect: 'main' }), + builder({ id: 'b', spawnedByArchitect: null }), + ]); + expect(groups.map(x => x.key)).toEqual(['main']); + expect(groups[0].items.map(b => b.id)).toEqual(['a', 'b']); }); it('rowPrefix carries the complementary lifecycle stage; unknown omitted', () => { diff --git a/packages/vscode/src/views/builder-grouping.ts b/packages/vscode/src/views/builder-grouping.ts index bc0f67e3e..8efb6b4ec 100644 --- a/packages/vscode/src/views/builder-grouping.ts +++ b/packages/vscode/src/views/builder-grouping.ts @@ -19,14 +19,6 @@ import { groupByStage, stageForPhase } from '@cluesmith/codev-core/phase-groupin /** The grouping axes the Agents view offers. Persisted as `codev.buildersGroupBy`. */ export type BuildersGroupBy = 'stage' | 'area' | 'architect'; -/** - * Group key for builders with no resolvable owner (`spawnedByArchitect` null — - * legacy / pre-#755 rows) under the `architect` axis (Issue 1104). A flat group - * only exists when it has builders, so a childless architect never appears; this - * bucket only collects genuinely unowned builders, sorted last. - */ -export const UNASSIGNED_ARCHITECT = 'Unassigned'; - /** A display group: a header key (an area name or a stage name) and its builders. */ export interface BuilderGroup { key: string; @@ -87,14 +79,20 @@ export function areaGrouping(): BuilderGrouping { /** * Architect axis (Issue 1104): groups are the architects that own in-flight work - * (`spawnedByArchitect`), `main` first, then the rest alphabetically, with the - * `Unassigned` bucket (unowned builders) last. Because a flat group only exists - * when it holds builders, a *childless* architect produces no group and simply - * doesn't appear — the work view shows owners of work, not the full roster (the - * full roster lives in Workspace > Architects). The row prefix carries the - * complementary lifecycle stage (`[implement]`, etc.) so a row still reads - * "where in the lifecycle" under its owner. A lone group is never flattened — - * the architect name is information worth a header even when there's only one. + * (`spawnedByArchitect`), `main` first, then the rest alphabetically. Because a + * flat group only exists when it holds builders, a *childless* architect + * produces no group and simply doesn't appear — the work view shows owners of + * work, not the full roster (the full roster lives in Workspace > Architects). + * + * Every builder has an owner: `afx spawn` always records one, defaulting to + * `main` (spawn.ts). A null `spawnedByArchitect` is only a data-integrity edge + * (a discovered worktree with no `state.db` row, or a pre-#755 legacy row), so + * there is no "unassigned" group — a null owner folds into `main`, matching the + * affinity router's same fallback (`lookupBuilderSpawningArchitect`). + * + * The row prefix carries the complementary lifecycle stage (`[implement]`, etc.) + * so a row still reads "where in the lifecycle" under its owner. A lone group is + * never flattened — the architect name is information worth a header. */ export function architectGrouping(): BuilderGrouping { return { @@ -102,15 +100,14 @@ export function architectGrouping(): BuilderGrouping { group: ordered => { const buckets = new Map(); for (const b of ordered) { - const key = b.spawnedByArchitect || UNASSIGNED_ARCHITECT; + const key = b.spawnedByArchitect || 'main'; const bucket = buckets.get(key); if (bucket) { bucket.push(b); } else { buckets.set(key, [b]); } } const keys = [...buckets.keys()]; const ordering = [ ...(buckets.has('main') ? ['main'] : []), - ...keys.filter(k => k !== 'main' && k !== UNASSIGNED_ARCHITECT).sort(), - ...(buckets.has(UNASSIGNED_ARCHITECT) ? [UNASSIGNED_ARCHITECT] : []), + ...keys.filter(k => k !== 'main').sort(), ]; return ordering.map(k => ({ key: k, items: buckets.get(k)! })); }, From f649d72176e1845d5e80d7f42ce975723dffd91c Mon Sep 17 00:00:00 2001 From: Amr Elsayed Date: Sun, 28 Jun 2026 11:14:44 +1000 Subject: [PATCH 23/29] chore(porch): 1104 dev-approval gate-approved --- .../projects/1104-vscode-merge-architects-builde/status.yaml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/codev/projects/1104-vscode-merge-architects-builde/status.yaml b/codev/projects/1104-vscode-merge-architects-builde/status.yaml index 08699d38e..9c8126d4d 100644 --- a/codev/projects/1104-vscode-merge-architects-builde/status.yaml +++ b/codev/projects/1104-vscode-merge-architects-builde/status.yaml @@ -10,12 +10,13 @@ gates: requested_at: '2026-06-27T12:00:20.441Z' approved_at: '2026-06-27T12:13:50.827Z' dev-approval: - status: pending + status: approved requested_at: '2026-06-27T12:39:09.461Z' + approved_at: '2026-06-28T01:14:44.858Z' pr: status: pending iteration: 1 build_complete: false history: [] started_at: '2026-06-27T11:55:09.921Z' -updated_at: '2026-06-27T12:39:09.462Z' +updated_at: '2026-06-28T01:14:44.858Z' From 557d2206f4ddc939016b594703e9295628006376 Mon Sep 17 00:00:00 2001 From: Amr Elsayed Date: Sun, 28 Jun 2026 11:15:01 +1000 Subject: [PATCH 24/29] chore(porch): 1104 review phase-transition --- .../projects/1104-vscode-merge-architects-builde/status.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/codev/projects/1104-vscode-merge-architects-builde/status.yaml b/codev/projects/1104-vscode-merge-architects-builde/status.yaml index 9c8126d4d..1ed39a75f 100644 --- a/codev/projects/1104-vscode-merge-architects-builde/status.yaml +++ b/codev/projects/1104-vscode-merge-architects-builde/status.yaml @@ -1,7 +1,7 @@ id: '1104' title: vscode-merge-architects-builde protocol: pir -phase: implement +phase: review plan_phases: [] current_plan_phase: null gates: @@ -19,4 +19,4 @@ iteration: 1 build_complete: false history: [] started_at: '2026-06-27T11:55:09.921Z' -updated_at: '2026-06-28T01:14:44.858Z' +updated_at: '2026-06-28T01:15:01.321Z' From 7e421728604ccf86861d93ac024ce8cb2f7e3ae3 Mon Sep 17 00:00:00 2001 From: Amr Elsayed Date: Sun, 28 Jun 2026 11:20:06 +1000 Subject: [PATCH 25/29] [PIR #1104] Review + retrospective --- codev/resources/arch.md | 1 + codev/resources/lessons-learned.md | 2 + .../1104-vscode-merge-architects-builde.md | 84 +++++++++++++++++++ codev/state/pir-1104_thread.md | 8 ++ 4 files changed, 95 insertions(+) create mode 100644 codev/reviews/1104-vscode-merge-architects-builde.md diff --git a/codev/resources/arch.md b/codev/resources/arch.md index 255c8ee2b..d2f43ccee 100644 --- a/codev/resources/arch.md +++ b/codev/resources/arch.md @@ -1096,6 +1096,7 @@ The VS Code extension (`packages/vscode`) is a thin client over Tower's existing - **Startup CLI preflight (#791)**: On `activate()` the extension verifies the `codev` CLI is installed and at least its own `package.json` version (`codev --version`, resolved like `resolveAfxPath`, cached per session, 400ms-bounded, fire-and-forget so activation never blocks). Missing → `Get started with Codev` walkthrough; outdated → upgrade notification; either dismissed → CLI-dependent commands no-op with one "run setup" toast. Commands register through two helpers — `reg` (unguarded) and `regCli` (guarded) — so the registrar name *is* the guard policy (no separate list). Preflight also sets the `codev.cliReady` context key, which drives the walkthrough's Verify-step completion. Lives in `src/preflight/` (`preflight-core.ts` pure + unit-tested, `preflight.ts` vscode glue). - **Markdown Preview / artifact-canvas host integration (#859)**: The first integration of the shared `@cluesmith/codev-artifact-canvas` React surface into a host. A read-only `CustomTextEditor` (`codev.markdownPreview`, `priority: "option"` so it never replaces the default `.md` editor; selector scoped to `**/codev/{specs,plans,reviews}/**/*.md`) renders an artifact and lets a reviewer add comments by hovering a block and clicking `+`. It is the extension's **first bundled React webview**: a *second* esbuild entry (`esbuild.js`, browser/IIFE, bundles react/react-dom/the canvas + emits `markdown-preview.css`), type-checked by a dedicated `tsconfig.webview.json` (DOM libs) since the host `tsconfig.json` excludes the browser dir. Host↔webview bridge (`markdown-preview/preview-provider.ts`, HTML in a sibling `preview-template.ts` per #920): the host posts **raw** document text + parsed markers; the webview mounts ``; `onAddComment(line)` → host `showInputBox` → `WorkspaceEdit` → the round-trip goes through the file text. **Cross-cutting invariants this established**: (1) the on-disk REVIEW-marker convention (``, a marker annotates the nearest non-marker line above it) lives in `@cluesmith/codev-core/review-markers` so every host (vscode now, dashboard later) writes/parses identical bytes — the editor Comments-API path (`comments/plan-review.ts`) shares it. (2) The canvas renderer runs markdown-it with **`html: true` + DOMPurify as the sole guard** (#1042 amends spec-945 D7: safe static HTML renders, scripts/handlers/`javascript:` stripped, document JS never executes) and **strips full-line HTML comments before block parsing** with a cleaned→original line map (#1036), so markers never render as text and never split a multi-line block while `data-line` stays accurate. - **Builders diff-review: navigation + active-file sync (#1060/#1066)**: The "current builder/file" in a diff review is **derived from the active editor, not stored**. The diff-inject registry (`diff-inject-codelens.ts`) maps each right-side worktree fsPath → `{ builderId, relPath, hunks }`; because a worktree-absolute path is unique per builder, two builders that changed the same relative path stay distinct. Everything keys off `getDiffInjectEntry(activeEditor.fsPath)`: cross-file keyboard nav (`codev.diffNextFile`/`diffPreviousFile`, #1060) and the Builders-tree active-file reveal (#1066). Navigation walks **one builder's** changed-file list (it never crosses builders) in the **visible tree order** — depth-first via `flattenTreeOrder(buildFilePathTree(...))` in tree-view mode, raw git `--name-status` order in flat mode — and **wraps** at both ends (`computeNavTarget` modulo) to match VSCode's built-in hunk navigation, which also wraps. The reveal (#1066, Explorer-style, gated by `codev.buildersAutoReveal`) requires two pieces of TreeView groundwork: file rows carry a stable `::` id and `BuildersProvider.getParent` reconstructs the full chain (file → compacted folder(s) → builder → group), since `reveal` matches by id and walks parents while the subtree is still collapsed. It fires on **both** the active-editor change AND the diff-inject registry change, because a programmatic diff open registers its entry *after* the editor activates (same dual-trigger the lens context-key sync uses). +- **Agents view + group-by axes (#1104)**: The sidebar's primary work view is **Agents** (view id `codev.agents`, formerly `codev.builders` — only the user-facing id/label changed; internal symbols like `BuildersProvider` and the `codev.buildersGroupBy` setting keep their names). It groups in-flight builders by **exactly one of three axes** (`stage` | `area` | `architect`), each a `BuilderGrouping` strategy in `views/builder-grouping.ts`; `architect` groups by `spawnedByArchitect` (null folds into `main`, matching the affinity router) and a childless architect produces no group, so the work view shows owners-of-work while the *full* roster stays in Workspace > Architects. The axis is a single toolbar button (see the no-`toggled` lesson). The architect roster the Add-Architect flow needs rides on **`/api/overview` as `architects: ArchitectState[]`** (additive wire field), built by a shared `liveArchitects(entry, manager)` helper in `tower-routes.ts` reused by the `/api/state` dashboard-state path so the two payloads can't drift — `liveArchitects` (running-session set, from `terminal_sessions`) is distinct from state.ts's `getArchitects` (persisted `architect` table). `Codev: Add Architect` is conversational: it resolves `main` from that roster and routes a request via `sendMessage('architect:main', …)` rather than creating the architect directly. - **Running-Tower version probe (#983)**: A second preflight dimension catches the case the CLI check structurally can't — an `npm install -g` upgrade that updated the on-disk binary but left **Tower running stale in-memory code**. Tower exposes read-only `GET /api/version` (`{ version, startedAt }`, wire type `TowerVersionInfo` in `codev-types`, served from `RouteContext` so it reports the *running* process's version, not the disk binary; unauthenticated like `/health`). On each `connected` transition the extension probes it (`TowerClient.getVersion()`, returning the raw `{ status }` so the preflight distinguishes a 404 "Tower too old to report" from an unreachable Tower). Divergence fires **only on `running < installedCLI`** — the case a restart actually fixes; running-vs-extension is left to #791 (a restart can't load code that isn't installed). The toast offers a `Restart Tower` action (`afx tower stop && afx tower start`, local host only — safe to self-invoke because #991 scoped `afx tower stop` to the listening process; remote hosts get informational wording). The two async inputs (installed-CLI version, running-Tower version) are reconciled against the startup race by re-probing once the CLI check resolves. Decision/wording logic is pure + unit-tested in `preflight-core.ts` (`decideTowerStatus`, `towerDivergenceMessage`). ## Repository Dual Nature diff --git a/codev/resources/lessons-learned.md b/codev/resources/lessons-learned.md index c3c43e340..d8a62e0e9 100644 --- a/codev/resources/lessons-learned.md +++ b/codev/resources/lessons-learned.md @@ -335,6 +335,8 @@ Generalizable wisdom extracted from review documents, ordered by impact. Updated - [From #1053] When styling rendered markdown, **prose and code want opposite overflow behavior.** Long unbreakable tokens in prose (a long inline-code span, URL, or file path) overflow the column and force a *page-level* horizontal scrollbar unless you set `overflow-wrap: break-word` on the prose container — but `pre` and `table` must NOT wrap (wrapping code corrupts it; a wide table shouldn't reflow), so they opt out with their own `overflow: auto` to scroll *within their block*. The correct outcome is: prose never causes page scroll; code/tables scroll locally. (github-markdown-css uses exactly this split.) - [From #1053] Watch `em`-on-`em` compounding when sizing nested rendered elements. Sizing both `pre` and `code` to `0.85em` made fenced code (`pre code`) ~72% (0.85 × 0.85); the fix is to size the chip (`code`) and the block (`pre`) but **reset the inner `pre code` to `font-size: inherit`** (plus reset its chip background/padding, since code inside a fence is plain text, not an inline chip). Anchor `em` chains to a single px root and reset where they'd stack. - [From #841] When a TreeView row's **display label is transformed for presentation** (here architect names rendered UPPERCASE for visual consistency) so it no longer equals the canonical identifier, any command that targets the row must read the identifier from a **stable channel** — set `item.id = -` and have the command strip the prefix back off — **not** re-derive it from `arg.label`. The label-as-identifier shortcut works only while the label *is* the identifier; the moment a cosmetic transform diverges them, `arg.label` silently sends the wrong target downstream (e.g. a DELETE for `WEB`, a name the server knows only as `web`). The right-click context menu passes the whole `TreeItem`, so `item.id` is the natural carrier. This also future-proofs against a later switch to a `displayName` field. +- [From #1104] **VS Code view-title toolbar buttons have no pressed/selected state.** The menu contribution schema (verified against `menusExtensionPoint.ts`) supports only `command`/`alt`/`when`/`group` — there is **no `toggled` property**, and an extension cannot override the workbench's toolbar colors. A toolbar action's icon is also fixed to its command. So to express a one-of-N selection (e.g. the Agents group-by axis), don't chase a pressed highlight: use a **single button whose icon/title reflect state**, implemented as N show/hide commands swapped by a `when`-clause on a context key (the pattern VS Code itself uses for View-as-Tree/List). Make the button an **action** — show the *next* state (what clicking applies), not the current one — and let the view content itself (group headers) be the primary "what am I looking at" signal. Don't overload the view title text with the state either; it truncates and clutters. +- [From #1104] **A custom tree-row SVG renders at 16px — author for that size, and verify by rendering, not by eyeballing a large preview.** A radial 8-arm octopus looked great at 200px and collapsed into an indistinct asterisk at 16px (eyes vanished entirely). Rasterize the candidate (e.g. Playwright screenshot) on both light and dark backgrounds at *actual* 16px before shipping. Tree `iconPath` SVGs are **not** theme-tinted by VS Code, so ship a monochrome **light/dark pair** using the theme-foreground colors (`#1f1f1f` / `#cccccc`, matching the existing `codev-light`/`codev-dark`), never a hardcoded brand color; eyes/holes via a `` so they contrast on either background. ## Documentation diff --git a/codev/reviews/1104-vscode-merge-architects-builde.md b/codev/reviews/1104-vscode-merge-architects-builde.md new file mode 100644 index 000000000..3500e6063 --- /dev/null +++ b/codev/reviews/1104-vscode-merge-architects-builde.md @@ -0,0 +1,84 @@ +# PIR Review: Agents view — architect-aware grouping (VSCode) + +Fixes #1104 + +## Summary + +Renames the VSCode sidebar **Builders** view to **Agents** and makes architect→builder ownership visible by adding **architect** as a third group-by axis alongside stage and area (builders are grouped by exactly one axis at a time). The owning architect roster is plumbed onto `/api/overview` so the conversational `Codev: Add Architect` action can route through `main`. Note: the implementation pivoted at the dev-approval gate from the issue's originally-proposed *nested* architect tier to a *flat 3-way toggle* — the reviewer found the flat axis cleaner (no duplication with Workspace > Architects, no single-architect collapse awkwardness, and childless architects vanish for free). + +## Files Changed + +(`git diff --stat` against the merge-base; excludes the porch `status.yaml`/thread bookkeeping) + +- `codev/plans/1104-vscode-merge-architects-builde.md` (+267) — plan +- `codev/state/pir-1104_thread.md` (+102) — builder thread +- `packages/types/src/api.ts` (+12) — `OverviewData.architects: ArchitectState[]` +- `packages/codev/src/agent-farm/servers/tower-routes.ts` (+/-72) — shared `liveArchitects` helper, `/api/overview` enrichment +- `packages/codev/src/agent-farm/servers/overview.ts` (+/-6) — `architects: []` default +- `packages/codev/src/agent-farm/__tests__/tower-routes.test.ts` (+42) — overview-roster tests +- `packages/dashboard/__tests__/{useOverview.stability,useSSE.reconnect}.test.ts` (+1 each) — mock `architects: []` +- `packages/vscode/package.json` (+/-111) — view id rename, group-by commands + toolbar button, octopus icon, settings/labels +- `packages/vscode/icons/architect{,-light,-dark}.svg` (new) — octopus glyph (monochrome light/dark pair) +- `packages/vscode/src/views/builder-grouping.ts` (+/-48) — `architectGrouping()` strategy +- `packages/vscode/src/views/builders.ts` (+/-35) — flat single-level grouping (nested tier removed) +- `packages/vscode/src/views/builder-tree-item.ts` — drop `ArchitectGroupTreeItem` +- `packages/vscode/src/commands/add-architect.ts` (new, +35) — conversational Add Architect helpers +- `packages/vscode/src/extension.ts` (+/-90) — group-by cycle commands, Add Architect rewrite, view-id wiring +- `packages/vscode/README.md` (+/-14) — Builders → Agents +- vscode tests: `builder-grouping`, `add-architect`, `extension-architect-commands`, `contributes-panel`, `menu-when-clauses` + +## Commits + +`git log main..HEAD --oneline` (squash-free; the development history shows the dev-gate pivot): + +- `b7e738f2` Enrich /api/overview with the architect roster (shared collectArchitects) +- `08346c61` Add adaptive architect tier to the Agents tree *(later retired)* +- `cc4bc4d4` Conversational Add Architect + rename Builders view to Agents +- `b5bfce2f` Fix Agents view title flipping back to 'Builders' on data load +- `c8f3ad87` / `c014bf3c` Sweep user-facing 'Builders' labels to 'Agents' (settings + README) +- `ae035b61` Rename collectArchitects -> liveArchitects (live-session reader) +- `1ec60502` Pivot to flat 3-way group-by toggle, retire nested tier +- `8e7821e8` Name the active axis in the title *(later reverted)* +- `acaba08b` Single cycling toolbar button (icon = current axis) +- `189e0f7f` Present-tense titles, render first, drop redundant direct commands +- `9b4f88e3` Group-by button shows the NEXT axis (action), not current +- `85cc8f21` Drop the bogus 'Unassigned' architect group + +## Test Results + +- `pnpm build` (core + codev + dashboard): ✓ pass +- `pnpm --filter @cluesmith/codev test`: ✓ 3375 pass / 48 skipped +- `codev-vscode` check-types ✓, lint ✓, `test:unit` ✓ 512 pass +- `codev-dashboard` test: ✓ 322 pass / 1 skipped +- Manual verification (human, at dev-approval gate): Agents view in a multi-architect workspace; the single group-by toolbar button cycling stage → area → architect with the icon reflecting the next axis; the octopus icon at row size on light + dark; group headers correctly showing stage names / area labels / architect names per axis. + +## Architecture Updates + +COLD `codev/resources/arch.md` — added one bullet to the **VS Code Extension** design decisions ("Agents view + group-by axes (#1104)"): the view-id rename (`codev.builders` → `codev.agents`, user-facing only), the three group-by strategies, the `architect` axis semantics (null → main, childless absent), and the additive `/api/overview` `architects` field built by the shared `liveArchitects` helper (distinct from state.ts's persisted `getArchitects`). No HOT `arch-critical.md` change — this is feature/reference detail, not an always-inject system-shape invariant; module boundaries are unchanged (the extension stays a thin client; the Tower API gained one additive field). + +## Lessons Learned Updates + +COLD `codev/resources/lessons-learned.md` (UI/UX) — two `[From #1104]` entries: +1. **VS Code toolbar buttons have no pressed/selected state** (no `toggled` in the menu schema; icon is fixed to the command) — express one-of-N selection with a single button whose icon reflects state via show/hide commands, make it an action (show the *next* state), and let view content be the primary "what am I looking at" signal. +2. **Custom tree-row SVGs render at 16px** — author/verify at that size (rasterize on light+dark), and ship a monochrome light/dark pair (theme-foreground colors), not a hardcoded brand color, since tree `iconPath` SVGs aren't theme-tinted. + +No HOT `lessons-critical.md` change — both are VS-Code-specific recipes, not high-blast-radius cross-cutting rules. + +## Things to Look At During PR Review + +- **The dev-gate pivot.** The approved plan describes a *nested* architect tier; the shipped code is a *flat* 3-way grouping axis. Commit `1ec60502` is the pivot. The nested-tier code (`ArchitectGroupTreeItem`, `partitionByArchitect`, the adaptive `architectCount` gate) was fully removed, so review `builders.ts` / `builder-grouping.ts` against the *flat* model, not the plan. +- **`liveArchitects` vs `getArchitects`.** `tower-routes.ts`'s `liveArchitects` (live terminal-session set) is intentionally distinct from state.ts's `getArchitects` (persisted table). The `/api/overview` enrichment reuses the exact helper the dashboard-state path uses so the two payloads can't drift — confirm the extraction is byte-equivalent to the former inline loop. +- **`/api/overview` is the only remaining consumer of the roster enrichment** (via Add Architect's `resolveMainArchitect`). The nested tier that originally also consumed it is gone; the enrichment is retained deliberately for Add Architect, not dead code. +- **No `toggled`** (it isn't a real menu property). The group-by button is one of three show/hide cycle commands; verify the `when`-clauses cover the unset-context-key case (`!codev.buildersGroupBy` → stage). +- **Null-owner folds into `main`** in `architectGrouping` — confirm that matches the affinity router's fallback and that there's no user-visible "Unassigned" group. + +## How to Test Locally + +- **View diff**: VSCode sidebar → right-click builder `pir-1104` → **Review Diff** +- **Run dev server**: `afx dev pir-1104` (or sidebar → Run Dev Server) +- **What to verify**: + - Agents view renders; the group-by button is the leftmost toolbar item, its icon = the *next* axis, clicking cycles stage → area → architect. + - In `architect` mode, group headers are architect names; a builder whose owner isn't running still appears under `main`. + - Single-architect workspace: architect mode shows one group (e.g. `MAIN`); no "Unassigned". + - `Codev: Add Architect` with `main` running → request lands in main's terminal; with main absent → modal pointing at the CLI fallback. + - Octopus icon legible at row size in both light and dark themes. diff --git a/codev/state/pir-1104_thread.md b/codev/state/pir-1104_thread.md index 747cb29da..5be6f4f7f 100644 --- a/codev/state/pir-1104_thread.md +++ b/codev/state/pir-1104_thread.md @@ -100,3 +100,11 @@ Group headers keep the state-rollup glyph in all 3 modes (architect names distin octopus lives on the toggle button). vscode check-types + lint + 512 unit ✓ after refactor. + +## Review phase + +dev-approval approved. Wrote review (codev/reviews/1104-*). Routed 2 COLD lessons (UI/UX: +no-toggled toolbar selection; 16px custom-SVG legibility) + 1 COLD arch.md bullet (Agents view ++ group-by axes + /api/overview architects). No HOT-tier changes (VS-Code-specific, not +always-inject-worthy). Full suites green: build, codev 3375, vscode 512, dashboard 322. +Opening PR; porch runs single-pass 3-way CMAP next. From 6a269e9b2c40aeff3eda9187b4ede5eb0b35038d Mon Sep 17 00:00:00 2001 From: Amr Elsayed Date: Sun, 28 Jun 2026 11:20:31 +1000 Subject: [PATCH 26/29] chore(porch): 1104 record PR #1106 --- .../1104-vscode-merge-architects-builde/status.yaml | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/codev/projects/1104-vscode-merge-architects-builde/status.yaml b/codev/projects/1104-vscode-merge-architects-builde/status.yaml index 1ed39a75f..eaf28f257 100644 --- a/codev/projects/1104-vscode-merge-architects-builde/status.yaml +++ b/codev/projects/1104-vscode-merge-architects-builde/status.yaml @@ -19,4 +19,9 @@ iteration: 1 build_complete: false history: [] started_at: '2026-06-27T11:55:09.921Z' -updated_at: '2026-06-28T01:15:01.321Z' +updated_at: '2026-06-28T01:20:31.046Z' +pr_history: + - phase: review + pr_number: 1106 + branch: builder/pir-1104 + created_at: '2026-06-28T01:20:31.045Z' From c5eba1678486298a10e4ed5192500ab1b30bb998 Mon Sep 17 00:00:00 2001 From: Amr Elsayed Date: Sun, 28 Jun 2026 11:20:43 +1000 Subject: [PATCH 27/29] chore(porch): 1104 review build-complete --- .../projects/1104-vscode-merge-architects-builde/status.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/codev/projects/1104-vscode-merge-architects-builde/status.yaml b/codev/projects/1104-vscode-merge-architects-builde/status.yaml index eaf28f257..07784678c 100644 --- a/codev/projects/1104-vscode-merge-architects-builde/status.yaml +++ b/codev/projects/1104-vscode-merge-architects-builde/status.yaml @@ -16,10 +16,10 @@ gates: pr: status: pending iteration: 1 -build_complete: false +build_complete: true history: [] started_at: '2026-06-27T11:55:09.921Z' -updated_at: '2026-06-28T01:20:31.046Z' +updated_at: '2026-06-28T01:20:43.943Z' pr_history: - phase: review pr_number: 1106 From 0e10b6a6512e2a3c40c105df9f9a0b7dc8853db7 Mon Sep 17 00:00:00 2001 From: Amr Elsayed Date: Sun, 28 Jun 2026 11:29:17 +1000 Subject: [PATCH 28/29] [PIR #1104] Address CMAP iter-1: contract-complete overview payload + doc fixes - Fix (codex REQUEST_CHANGES #5): handleOverview no-workspace branch now emits all required OverviewData collection fields (recentlyClosed/architects) empty, honoring the 'never undefined' contract; regression assertion added. - Fix (claude COMMENT): README Agents description rewritten for the flat group-by axis (was describing the retired nested tier); buildersGroupBy setting description no longer references a nonexistent 'pressed' button state. - Rebut: flat-axis/childless-absent/view-id-rename/no-Agents-+ are deliberate dev-gate decisions (see rebuttals doc). --- .../1104-review-iter1-rebuttals.md | 69 +++++++++++++++++++ .../1104-vscode-merge-architects-builde.md | 10 +++ .../agent-farm/__tests__/tower-routes.test.ts | 5 ++ .../src/agent-farm/servers/tower-routes.ts | 5 +- packages/vscode/README.md | 2 +- packages/vscode/package.json | 2 +- 6 files changed, 90 insertions(+), 3 deletions(-) create mode 100644 codev/projects/1104-vscode-merge-architects-builde/1104-review-iter1-rebuttals.md diff --git a/codev/projects/1104-vscode-merge-architects-builde/1104-review-iter1-rebuttals.md b/codev/projects/1104-vscode-merge-architects-builde/1104-review-iter1-rebuttals.md new file mode 100644 index 000000000..b871b4b90 --- /dev/null +++ b/codev/projects/1104-vscode-merge-architects-builde/1104-review-iter1-rebuttals.md @@ -0,0 +1,69 @@ +# PIR #1104 — Review iteration 1 rebuttals + +Consultation verdicts: **codex = REQUEST_CHANGES**, **claude = COMMENT**. + +PIR is single-pass (`max_iterations: 1`): this is not re-reviewed by the models. Real +defects below are fixed + regression-tested; plan-deviation findings are rebutted because they +were deliberate decisions the human made at the `dev-approval` gate. All of this is escalated to +the human at the `pr` gate (the only remaining reviewer of these dispositions). + +## Codex (REQUEST_CHANGES) + +Codex's first four points all reduce to "the implementation deviates from the approved plan." +That is true and **intentional**: the plan described a *nested adaptive architect tier*, and at +the `dev-approval` gate the human reviewed it running and explicitly redirected the design to a +*flat 3-way group-by axis*. The review file documents this pivot (Summary + "Things to Look At"); +the builder thread records the live decisions. PIR's `dev-approval` gate exists precisely to let +"seeing it running changes the design" happen — so a plan/implementation divergence here is the +gate working as intended, not a regression. + +1. **"Flat axis instead of nested architect-rooted tree."** Deliberate. The human directed + "pivot to the flat 3-way toggle" after reviewing the running nested tree (duplication with + Workspace > Architects, single-architect collapse awkwardness, and an icon-collision problem + drove it). The nested-tier code was fully removed. **No change** — rebutted. + +2. **"Childless architects dropped (should stay as interactive leaf rows)."** Deliberate. The + human's exact objection to the nested tree was that childless architects duplicated + Workspace > Architects. In the flat model a group exists only when it owns work, so childless + architects naturally don't appear — which is the requested behavior. The full roster remains + in Workspace > Architects. **No change** — rebutted. + +3. **"View id renamed `codev.builders` → `codev.agents` (plan said keep it)."** Deliberate and + human-directed ("rename the codev.builders ID to codev.agents for consistency"). All internal + `when`-clauses, `createTreeView`, and tests were updated together; there are no external + consumers of the id (it's our own extension). The only cost is that a user who manually + repositioned the old view returns to the default position once — acceptable, and the human + chose it knowingly. **No change** — rebutted. + +4. **"Add Architect `+` missing from the Agents title bar."** Deliberate. The human explicitly + rejected an Agents title-bar `+`: a `+` there is ambiguous (add-builder vs add-architect), so + Add Architect stays on Workspace > Architects only. **No change** — rebutted. + +5. **"No-workspace `/api/overview` branch returns a payload missing `recentlyClosed` / + `architects`, violating the declared contract."** **Valid — real defect, fixed.** `OverviewData` + now declares `architects` required ("never undefined"), but the early `if (!workspaceRoot)` + return in `handleOverview` emitted only `{ builders, pendingPRs, backlog }`. Fixed to emit all + collection fields empty (`recentlyClosed: []`, `architects: []`). Added a regression assertion + to the existing "returns empty data when no workspace is known" test in `tower-routes.test.ts` + (now checks `recentlyClosed`/`architects` === `[]`). Pinning test fails without the fix. + +## Claude (COMMENT) + +Both of Claude's points are real doc-staleness bugs introduced by my own pivot (not plan +deviations). Both fixed. + +1. **"README.md Agents description still describes the retired nested tier."** **Valid — fixed.** + The paragraph ("builders nest under the architect… passive architect appears as a leaf row…") + predated the pivot. Rewritten to describe the flat 3-way group-by axis and the + architects-with-work-only behavior. + +2. **"`buildersGroupBy` setting description references a 'pressed' button state that doesn't + exist."** **Valid — fixed.** VS Code toolbar buttons have no pressed state (the very lesson this + PR recorded). Rewritten to describe the single cycling group-by button (icon shows the next + axis; cycles stage → area → architect). + +## Net + +- 3 real fixes (1 code + regression test, 2 docs), committed on the PR branch. +- 4 plan-deviation findings rebutted as deliberate `dev-approval`-gate decisions. +- Escalated to the human at the `pr` gate per PIR single-pass design. diff --git a/codev/reviews/1104-vscode-merge-architects-builde.md b/codev/reviews/1104-vscode-merge-architects-builde.md index 3500e6063..0610bc865 100644 --- a/codev/reviews/1104-vscode-merge-architects-builde.md +++ b/codev/reviews/1104-vscode-merge-architects-builde.md @@ -72,6 +72,16 @@ No HOT `lessons-critical.md` change — both are VS-Code-specific recipes, not h - **No `toggled`** (it isn't a real menu property). The group-by button is one of three show/hide cycle commands; verify the `when`-clauses cover the unset-context-key case (`!codev.buildersGroupBy` → stage). - **Null-owner folds into `main`** in `architectGrouping` — confirm that matches the affinity router's fallback and that there's no user-visible "Unassigned" group. +## Consultation (CMAP iter-1) + +Single advisory pass (PIR `max_iterations: 1`): **codex = REQUEST_CHANGES**, **claude = COMMENT**. Full verdicts in `codev/projects/1104-*/1104-review-iter1-*.txt`; dispositions in `1104-review-iter1-rebuttals.md`. + +- **Fixed (real defects):** + - `handleOverview` no-workspace branch returned a payload missing the now-required `architects` (and `recentlyClosed`) — now emits all collection fields empty; regression assertion added to `tower-routes.test.ts`. (codex #5) + - `README.md` Agents description still described the retired nested tier — rewritten for the flat axis. (claude #1) + - `buildersGroupBy` setting description referenced a "pressed" button state that doesn't exist — rewritten for the single cycling button. (claude #2) +- **Rebutted (deliberate `dev-approval`-gate decisions, not regressions):** flat axis vs nested tier; childless architects absent; view-id rename `codev.builders`→`codev.agents`; no Add-Architect `+` on the Agents title bar. All four were human-directed at the dev gate (see Summary + builder thread). + ## How to Test Locally - **View diff**: VSCode sidebar → right-click builder `pir-1104` → **Review Diff** diff --git a/packages/codev/src/agent-farm/__tests__/tower-routes.test.ts b/packages/codev/src/agent-farm/__tests__/tower-routes.test.ts index 663f0e5d6..ab7043e4a 100644 --- a/packages/codev/src/agent-farm/__tests__/tower-routes.test.ts +++ b/packages/codev/src/agent-farm/__tests__/tower-routes.test.ts @@ -977,6 +977,11 @@ describe('tower-routes', () => { expect(parsed.builders).toEqual([]); expect(parsed.pendingPRs).toEqual([]); expect(parsed.backlog).toEqual([]); + // Issue 1104: the no-workspace branch must still honor the full + // OverviewData contract — `architects` is required ('never undefined'), + // and `recentlyClosed` likewise — so consumers don't have to branch. + expect(parsed.recentlyClosed).toEqual([]); + expect(parsed.architects).toEqual([]); }); it('works via workspace-scoped route', async () => { diff --git a/packages/codev/src/agent-farm/servers/tower-routes.ts b/packages/codev/src/agent-farm/servers/tower-routes.ts index 9f877af93..9d82f58e7 100644 --- a/packages/codev/src/agent-farm/servers/tower-routes.ts +++ b/packages/codev/src/agent-farm/servers/tower-routes.ts @@ -906,8 +906,11 @@ async function handleOverview(res: http.ServerResponse, url: URL, workspaceOverr } if (!workspaceRoot) { + // Honor the full OverviewData contract even on the no-workspace branch: + // every collection field is required ('never undefined' for `architects`, + // Issue 1104), so emit them all empty rather than a partial payload. res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ builders: [], pendingPRs: [], backlog: [] })); + res.end(JSON.stringify({ builders: [], pendingPRs: [], backlog: [], recentlyClosed: [], architects: [] })); return; } diff --git a/packages/vscode/README.md b/packages/vscode/README.md index 52aa2f1f4..f55387427 100644 --- a/packages/vscode/README.md +++ b/packages/vscode/README.md @@ -33,7 +33,7 @@ Bring Codev's Agent Farm into VS Code — monitor builders, open terminals, appr The Codev sidebar contains seven collapsible views: - **Workspace** — Open Architect, Open Web Interface, Spawn Builder, New Shell, and Start / Stop Dev Server rows. Any `worktree.devUrls` you've configured appear here as **Open Dev URL** rows. -- **Agents** — every active builder, with status (active / blocked / waiting on input / awaiting). When more than one architect is registered, builders nest under the architect that spawned them (a passive architect with no builders still appears as a leaf row); with a single architect the view groups by area or lifecycle stage as before. Click a row to open its terminal *and* expand its changed-files list. Right-click for the full builder action menu (see the **Builder actions (right-click)** section below). The title bar carries buttons to toggle accordion mode and tree-vs-list file view. +- **Agents** — every active builder, with status (active / blocked / waiting on input / awaiting). Builders are grouped by one of three axes, switched with the title-bar group-by button (its icon shows the axis you'll switch *to*): lifecycle **stage** (the default action axis), **area** label, or the **architect** that spawned them. In architect mode only architects that own in-flight builders appear as group headers (the full architect roster lives in Workspace > Architects). Click a row to open its terminal *and* expand its changed-files list. Right-click for the full builder action menu (see the **Builder actions (right-click)** section below). The title bar also carries buttons to toggle accordion mode and tree-vs-list file view. - **Pull Requests** — open PRs in the repo, with a live count in the title. - **Backlog** — open issues without a builder. Inline row actions drop the issue's `#` into the architect input, preview the issue, spawn a builder for it, open it in the browser, or copy the issue number. - **Recently Closed** — recently closed PRs; manual refresh from the title bar. diff --git a/packages/vscode/package.json b/packages/vscode/package.json index 14f6cb5bc..6bd368cd7 100644 --- a/packages/vscode/package.json +++ b/packages/vscode/package.json @@ -932,7 +932,7 @@ "Group builders by the architect that spawned them (the ownership axis); architects with no in-flight work don't appear (the full roster lives in Workspace > Architects)." ], "default": "stage", - "description": "Which axis the Agents view groups by. 'stage' (default) is the action axis, 'area' is the domain axis, 'architect' is the ownership axis. Builders are grouped by exactly one axis at a time; toggle via the three Agents title-bar buttons (the active one is shown pressed)." + "description": "Which axis the Agents view groups by. 'stage' (default) is the action axis, 'area' is the domain axis, 'architect' is the ownership axis. Builders are grouped by exactly one axis at a time; switch via the Agents title-bar group-by button, whose icon shows the axis you'll switch to and which cycles stage → area → architect on click." }, "codev.markdownPreview.fontSize": { "type": "number", From ce7aba051f7b7f52b37120954769daf8d6b0056c Mon Sep 17 00:00:00 2001 From: Amr Elsayed Date: Sun, 28 Jun 2026 11:29:33 +1000 Subject: [PATCH 29/29] chore(porch): 1104 pr gate-requested --- .../projects/1104-vscode-merge-architects-builde/status.yaml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/codev/projects/1104-vscode-merge-architects-builde/status.yaml b/codev/projects/1104-vscode-merge-architects-builde/status.yaml index 07784678c..a87df0ef8 100644 --- a/codev/projects/1104-vscode-merge-architects-builde/status.yaml +++ b/codev/projects/1104-vscode-merge-architects-builde/status.yaml @@ -15,13 +15,15 @@ gates: approved_at: '2026-06-28T01:14:44.858Z' pr: status: pending + requested_at: '2026-06-28T01:29:33.253Z' iteration: 1 build_complete: true history: [] started_at: '2026-06-27T11:55:09.921Z' -updated_at: '2026-06-28T01:20:43.943Z' +updated_at: '2026-06-28T01:29:33.254Z' pr_history: - phase: review pr_number: 1106 branch: builder/pir-1104 created_at: '2026-06-28T01:20:31.045Z' +pr_ready_for_human: true