@exaudeus/workrail 3.40.0 → 3.41.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. package/dist/cli/commands/init.js +0 -3
  2. package/dist/cli-worktrain.js +8 -0
  3. package/dist/cli.js +0 -18
  4. package/dist/config/app-config.d.ts +0 -16
  5. package/dist/config/app-config.js +0 -14
  6. package/dist/config/config-file.js +0 -3
  7. package/dist/console-ui/assets/index-CQt4UhPB.js +28 -0
  8. package/dist/console-ui/assets/index-DGj8EsFR.css +1 -0
  9. package/dist/console-ui/index.html +2 -2
  10. package/dist/coordinators/pr-review.d.ts +17 -0
  11. package/dist/coordinators/pr-review.js +164 -0
  12. package/dist/daemon/daemon-events.d.ts +9 -1
  13. package/dist/daemon/soul-template.d.ts +2 -2
  14. package/dist/daemon/soul-template.js +11 -1
  15. package/dist/daemon/workflow-runner.d.ts +14 -1
  16. package/dist/daemon/workflow-runner.js +395 -25
  17. package/dist/di/container.js +1 -25
  18. package/dist/di/tokens.d.ts +0 -3
  19. package/dist/di/tokens.js +0 -3
  20. package/dist/engine/engine-factory.js +0 -1
  21. package/dist/infrastructure/console-defaults.d.ts +1 -0
  22. package/dist/infrastructure/console-defaults.js +4 -0
  23. package/dist/infrastructure/session/index.d.ts +0 -1
  24. package/dist/infrastructure/session/index.js +1 -3
  25. package/dist/manifest.json +87 -103
  26. package/dist/mcp/handlers/session.d.ts +1 -0
  27. package/dist/mcp/handlers/session.js +61 -13
  28. package/dist/mcp/server.js +1 -18
  29. package/dist/mcp/transports/http-entry.js +0 -2
  30. package/dist/mcp/transports/stdio-entry.js +1 -2
  31. package/dist/mcp/types.d.ts +0 -2
  32. package/dist/trigger/daemon-console.d.ts +2 -0
  33. package/dist/trigger/daemon-console.js +1 -1
  34. package/dist/trigger/trigger-listener.d.ts +2 -0
  35. package/dist/trigger/trigger-listener.js +3 -1
  36. package/dist/trigger/trigger-router.d.ts +4 -3
  37. package/dist/trigger/trigger-router.js +4 -3
  38. package/dist/trigger/trigger-store.js +17 -4
  39. package/dist/v2/usecases/console-routes.d.ts +2 -1
  40. package/dist/v2/usecases/console-routes.js +29 -5
  41. package/dist/v2/usecases/console-service.js +14 -0
  42. package/dist/v2/usecases/console-types.d.ts +1 -0
  43. package/docs/authoring.md +16 -16
  44. package/docs/design/coordinator-message-queue-drain-plan.md +241 -0
  45. package/docs/design/coordinator-message-queue-drain-review.md +120 -0
  46. package/docs/design/coordinator-message-queue-drain.md +289 -0
  47. package/docs/design/shaping-workflow-external-research.md +119 -0
  48. package/docs/discovery/late-bound-goals-impl-plan.md +147 -0
  49. package/docs/discovery/late-bound-goals-review.md +82 -0
  50. package/docs/discovery/late-bound-goals.md +118 -0
  51. package/docs/discovery/steer-endpoint-design-candidates.md +288 -0
  52. package/docs/discovery/steer-endpoint-design-review-findings.md +104 -0
  53. package/docs/discovery/steer-endpoint-implementation-plan.md +284 -0
  54. package/docs/ideas/backlog.md +292 -0
  55. package/docs/ideas/design-candidates-console-session-tree-impl.md +64 -0
  56. package/docs/ideas/design-candidates-session-tree-view.md +196 -0
  57. package/docs/ideas/design-review-findings-console-session-tree-impl.md +75 -0
  58. package/docs/ideas/design-review-findings-session-tree-view.md +88 -0
  59. package/docs/ideas/implementation_plan_session_tree_view.md +238 -0
  60. package/package.json +2 -1
  61. package/spec/authoring-spec.json +16 -16
  62. package/spec/shape.schema.json +178 -0
  63. package/spec/workflow-tags.json +232 -47
  64. package/workflows/coding-task-workflow-agentic.json +491 -480
  65. package/workflows/wr.shaping.json +182 -0
  66. package/dist/console-ui/assets/index-8dh0Psu-.css +0 -1
  67. package/dist/console-ui/assets/index-CXWCAonr.js +0 -28
  68. package/dist/infrastructure/session/DashboardHeartbeat.d.ts +0 -8
  69. package/dist/infrastructure/session/DashboardHeartbeat.js +0 -39
  70. package/dist/infrastructure/session/DashboardLockRelease.d.ts +0 -2
  71. package/dist/infrastructure/session/DashboardLockRelease.js +0 -29
  72. package/dist/infrastructure/session/HttpServer.d.ts +0 -60
  73. package/dist/infrastructure/session/HttpServer.js +0 -912
  74. package/workflows/coding-task-workflow-agentic.lean.v2.json +0 -648
  75. package/workflows/coding-task-workflow-agentic.v2.json +0 -324
@@ -0,0 +1,284 @@
1
+ # Implementation Plan: POST /api/v2/sessions/:sessionId/steer
2
+
3
+ **Branch:** `feat/session-steer-endpoint`
4
+ **Confidence:** High
5
+ **PR count:** 1
6
+
7
+ ---
8
+
9
+ ## Problem Statement
10
+
11
+ A coordinator script needs to inject text into an agent's next turn during a running daemon session.
12
+ The `steer()` mechanism in `AgentLoop` already delivers injected text. The gap is a bridge between
13
+ an HTTP endpoint and the closure-scoped `pendingSteerText` variable in `runWorkflow()`.
14
+
15
+ Additionally, the existing `pendingSteerText: string | null` is a single-value field that silently
16
+ drops coordinator steers when overwritten by `onAdvance()`. This must be fixed first (R1 finding).
17
+
18
+ ---
19
+
20
+ ## Acceptance Criteria
21
+
22
+ 1. `POST /api/v2/sessions/:sessionId/steer` with body `{ "text": "..." }` returns HTTP 200
23
+ `{ "success": true }` when the sessionId belongs to an active daemon session.
24
+ 2. The injected text is delivered to the agent via `agent.steer()` on the next `turn_end` event,
25
+ concatenated after the step text from `onAdvance()`.
26
+ 3. Returns HTTP 404 `{ "success": false, "error": "Session not found or not a daemon session" }`
27
+ when the sessionId is not in the registry.
28
+ 4. Returns HTTP 503 `{ "success": false, "error": "Steer not available..." }` in standalone console
29
+ mode (no steerRegistry injected).
30
+ 5. Returns HTTP 400 for missing or non-string `text` body.
31
+ 6. Multiple calls to the endpoint between `turn_end` events: all injected texts are delivered in the
32
+ same steer message, joined with `\n\n`.
33
+ 7. After the session completes, calling the endpoint returns 404.
34
+ 8. The existing `pendingSteerText` variable is replaced by `pendingSteerParts: string[]` and
35
+ `onAdvance()` behavior is unchanged from the caller's perspective (step advance still works).
36
+
37
+ ---
38
+
39
+ ## Non-Goals
40
+
41
+ - Auth token on the endpoint (v1 is localhost-only, network binding is the security layer)
42
+ - `waitForCoordinator` blocking gate mechanism (Phase 2B, separate task)
43
+ - `wr.coordinator_signal` artifact schema (Phase A, separate task)
44
+ - MCP-mode injection (deferred to v2)
45
+ - Crash recovery for in-flight steers (in-memory only, v1 known limitation)
46
+ - Structured request body beyond `{ text: string }` (v2 concern)
47
+
48
+ ---
49
+
50
+ ## Philosophy-Driven Constraints
51
+
52
+ - **DI for boundaries**: `SteerRegistry` must be injected into `mountConsoleRoutes()` and
53
+ `runWorkflow()`. No module-level singletons.
54
+ - **Errors as data**: HTTP responses use `{ success: bool, error?: string }` shape. No thrown
55
+ exceptions at the route level.
56
+ - **Validate at boundaries**: 400 for invalid body, 503 for disabled, 404 for not-found -- all
57
+ checked before touching the registry.
58
+ - **YAGNI**: Only what's listed in acceptance criteria. No speculative extension points.
59
+ - **Explicit domain types**: Named type alias `SteerRegistry` (not raw `Map<string, fn>` literal).
60
+
61
+ ---
62
+
63
+ ## Invariants
64
+
65
+ 1. `pendingSteerParts` is only mutated in two places: `onAdvance()` (push step text) and the steer
66
+ callback registered in the `SteerRegistry` (push coordinator text). No other writer.
67
+ 2. `pendingSteerParts` is only read and drained in the `turn_end` subscriber. Single reader.
68
+ 3. JavaScript single-threaded event loop: no race between push and drain.
69
+ 4. The steer callback is registered after `workrailSessionId` is decoded from the continueToken,
70
+ and deregistered in `runWorkflow()`'s `finally` block. No stale entries possible.
71
+ 5. The endpoint is only active when `steerRegistry` is provided to `mountConsoleRoutes()`.
72
+ The standalone console does not provide it.
73
+ 6. The `steerRegistry` param is optional on all functions. No existing callers are broken.
74
+
75
+ ---
76
+
77
+ ## Selected Approach
78
+
79
+ **Hybrid (type alias + parameter injection):**
80
+ - Named type alias `export type SteerRegistry = Map<string, (text: string) => void>` in
81
+ `src/daemon/workflow-runner.ts`.
82
+ - `pendingSteerText: string | null` replaced by `const pendingSteerParts: string[] = []`.
83
+ - `onAdvance()` uses `pendingSteerParts.push(stepText)`.
84
+ - turn_end subscriber drains with `const parts = pendingSteerParts.splice(0)` and calls
85
+ `agent.steer(buildUserMessage(parts.join('\n\n')))` if `parts.length > 0`.
86
+ - `runWorkflow()` gains optional `steerRegistry?: SteerRegistry` param. After workrailSessionId
87
+ is decoded, calls `steerRegistry?.set(workrailSessionId, (text) => pendingSteerParts.push(text))`.
88
+ In `finally`: `steerRegistry?.delete(workrailSessionId)`.
89
+ - `mountConsoleRoutes()` gains optional `steerRegistry?: SteerRegistry` param after `triggerRouter`.
90
+ - `POST /api/v2/sessions/:sessionId/steer` endpoint added in `console-routes.ts`.
91
+ - `TriggerRouter` constructor gains optional `steerRegistry?: SteerRegistry`; passes to
92
+ `runWorkflowFn()` calls in `route()` and `dispatch()`.
93
+ - `RunWorkflowFn` type in `trigger-router.ts` extended with optional 6th param.
94
+
95
+ **Runner-Up:** C2 (SteerRegistry class). Loses only for having a new file for 3 trivial operations.
96
+ Use if the registry gains additional methods or needs isolated unit tests.
97
+
98
+ ---
99
+
100
+ ## Vertical Slices
101
+
102
+ ### Slice 1: Fix `pendingSteerText` -> `pendingSteerParts` (R1 prerequisite)
103
+
104
+ **Files:** `src/daemon/workflow-runner.ts` only.
105
+
106
+ **Change:**
107
+ - Rename `pendingSteerText: string | null` to `const pendingSteerParts: string[] = []`.
108
+ - Update `onAdvance()`: `pendingSteerText = stepText` -> `pendingSteerParts.push(stepText)`.
109
+ - Update turn_end subscriber drain:
110
+ - Before: `if (pendingSteerText !== null && !isComplete) { ... }`
111
+ - After: `if (!isComplete) { const parts = pendingSteerParts.splice(0); if (parts.length > 0) { agent.steer(buildUserMessage(parts.join('\n\n'))); } }`
112
+ - Note: `isComplete` guard moves outside the `splice(0)` -- drain always happens, steer only if not complete and parts non-empty.
113
+
114
+ **Acceptance:** Existing behavior unchanged. Session still receives step text on each advance.
115
+ No coordinator injection yet. Build+type-check passes. Existing tests pass.
116
+
117
+ **Risk:** Low. Pure refactor, no behavior change from external perspective.
118
+
119
+ ---
120
+
121
+ ### Slice 2: SteerRegistry type alias + runWorkflow() registration
122
+
123
+ **Files:** `src/daemon/workflow-runner.ts`.
124
+
125
+ **Change:**
126
+ - Add: `export type SteerRegistry = Map<string, (text: string) => void>;`
127
+ - Add optional param to `runWorkflow()`: `steerRegistry?: SteerRegistry`
128
+ - After `workrailSessionId` is decoded (line ~2190): register callback:
129
+ ```typescript
130
+ if (steerRegistry && workrailSessionId) {
131
+ steerRegistry.set(workrailSessionId, (text: string) => { pendingSteerParts.push(text); });
132
+ }
133
+ ```
134
+ - In `finally` block: `if (steerRegistry && workrailSessionId) { steerRegistry.delete(workrailSessionId); }`
135
+ - Add code comment on the `set()` call documenting the registration gap.
136
+
137
+ **Acceptance:** `runWorkflow()` compiles with new optional param. Existing callers unchanged.
138
+ Manual test: if a steerRegistry Map is passed and a callback is registered/called during a session,
139
+ text is pushed to `pendingSteerParts`.
140
+
141
+ **Risk:** Low. Additive change, no behavior change when `steerRegistry` is undefined.
142
+
143
+ ---
144
+
145
+ ### Slice 3: TriggerRouter wiring
146
+
147
+ **Files:** `src/trigger/trigger-router.ts`.
148
+
149
+ **Change:**
150
+ - Import `SteerRegistry` from `workflow-runner.js`.
151
+ - Extend `RunWorkflowFn` type with optional 6th param:
152
+ `steerRegistry?: SteerRegistry` after `emitter?`.
153
+ - Add `private readonly steerRegistry?: SteerRegistry` to `TriggerRouter`.
154
+ - Add `steerRegistry?: SteerRegistry` to TriggerRouter constructor params.
155
+ - Assign in constructor: `this.steerRegistry = steerRegistry`.
156
+ - Update `route()` call: `this.runWorkflowFn(workflowTrigger, this.ctx, this.apiKey, undefined, this.emitter, this.steerRegistry)`
157
+ - Update `dispatch()` call: same.
158
+
159
+ **Acceptance:** TriggerRouter compiles. `runWorkflow` in production TriggerRouter path passes
160
+ steerRegistry to the agent loop. Existing trigger tests unaffected (registry is optional).
161
+
162
+ **Risk:** Low. Additive param. All existing test calls to `TriggerRouter` pass `undefined` or
163
+ omit the param.
164
+
165
+ ---
166
+
167
+ ### Slice 4: HTTP endpoint in console-routes.ts
168
+
169
+ **Files:** `src/v2/usecases/console-routes.ts`.
170
+
171
+ **Change:**
172
+ - Import `SteerRegistry` from `../../daemon/workflow-runner.js`.
173
+ - Add `steerRegistry?: SteerRegistry` to `mountConsoleRoutes()` param list (after `triggerRouter`).
174
+ - Add endpoint after the `POST /api/v2/auto/dispatch` block:
175
+
176
+ ```typescript
177
+ // POST /api/v2/sessions/:sessionId/steer
178
+ // Injects text into a running daemon session's next agent turn.
179
+ // Daemon-only: requires steerRegistry to be provided at server startup.
180
+ // Auth: localhost-only (127.0.0.1 binding). No token auth in v1.
181
+ // TODO(v2): Add token auth before any multi-user or remote deployment.
182
+ app.post('/api/v2/sessions/:sessionId/steer', express.json(), (req: Request, res: Response) => {
183
+ if (!steerRegistry) {
184
+ res.status(503).json({ success: false, error: 'Steer not available (not a daemon context).' });
185
+ return;
186
+ }
187
+ const { sessionId } = req.params;
188
+ const body = req.body as { text?: unknown };
189
+ const text = typeof body.text === 'string' ? body.text.trim() : '';
190
+ if (!text) {
191
+ res.status(400).json({ success: false, error: 'text is required and must be a non-empty string.' });
192
+ return;
193
+ }
194
+ const callback = steerRegistry.get(sessionId);
195
+ if (!callback) {
196
+ res.status(404).json({ success: false, error: 'Session not found or not a daemon session.' });
197
+ return;
198
+ }
199
+ callback(text);
200
+ res.json({ success: true });
201
+ });
202
+ ```
203
+
204
+ **Acceptance:** All 5 HTTP response cases work correctly (200, 400, 404, 503). Session receives
205
+ injected text on next turn_end. Standalone console returns 503.
206
+
207
+ **Risk:** Low. New endpoint, no changes to existing routes.
208
+
209
+ ---
210
+
211
+ ### Slice 5: Daemon wiring in daemon-console.ts
212
+
213
+ **Files:** `src/trigger/daemon-console.ts`.
214
+
215
+ **Change:**
216
+ - Import `SteerRegistry` from `../daemon/workflow-runner.js`.
217
+ - Before constructing `TriggerRouter`: `const steerRegistry: SteerRegistry = new Map();`
218
+ - Pass to `TriggerRouter` constructor: `new TriggerRouter(index, ctx, apiKey, runWorkflow, execFn, ..., steerRegistry)`
219
+ - Pass to `mountConsoleRoutes()`: add `steerRegistry` as the last argument (after `triggerRouter`).
220
+ - Also update the direct `runWorkflow()` call in `console-routes.ts` `POST /auto/dispatch` path
221
+ (when `triggerRouter` is absent): pass `steerRegistry` as 6th arg.
222
+
223
+ **Acceptance:** End-to-end: daemon starts, `POST /auto/dispatch` creates a session, coordinator
224
+ calls `POST /sessions/:id/steer`, agent receives injected text on next turn.
225
+
226
+ **Risk:** Medium. This is the wiring step that connects all slices. Most likely source of missed
227
+ call sites.
228
+
229
+ ---
230
+
231
+ ## Test Design
232
+
233
+ ### Unit tests (workflow-runner.ts)
234
+ - Test that `onAdvance()` pushes to `pendingSteerParts`.
235
+ - Test that turn_end subscriber joins and steers when `pendingSteerParts.length > 0`.
236
+ - Test that multiple pushes (simulate both `onAdvance` and steer callback) produce joined text.
237
+ - Test that `steerRegistry.set()` is called after workrailSessionId decoded.
238
+ - Test that `steerRegistry.delete()` is called in finally (mock registry, verify delete).
239
+
240
+ ### Integration test (console-routes.ts)
241
+ - Mock `steerRegistry` with a Map. POST to endpoint with valid body -> 200, callback called.
242
+ - POST with empty body -> 400.
243
+ - POST with unknown sessionId -> 404.
244
+ - POST without steerRegistry injected -> 503.
245
+
246
+ ### Regression: existing tests
247
+ - All existing `runWorkflow()` tests must pass unchanged (optional param, default undefined).
248
+ - All existing `mountConsoleRoutes()` tests must pass unchanged.
249
+ - All existing TriggerRouter tests must pass unchanged.
250
+
251
+ ---
252
+
253
+ ## Risk Register
254
+
255
+ | Risk | Likelihood | Impact | Mitigation |
256
+ |---|---|---|---|
257
+ | Missed call site for steerRegistry in dispatch/route | Medium | High | Search for all `runWorkflowFn(` calls in trigger-router.ts before submitting |
258
+ | `pendingSteerParts.splice(0)` stale closure ref | Low | High | splice(0) mutates in-place; closure over array variable (not array contents) is safe |
259
+ | Registration gap causes 404 for early steers | Very low | Low | Document with code comment; coordinator retries on 404 |
260
+ | `mountConsoleRoutes` callers not updated | Low | Medium | Only 3 callers: daemon-console.ts, standalone-console.ts (no change), console-routes.ts |
261
+
262
+ ---
263
+
264
+ ## PR Packaging Strategy
265
+
266
+ Single PR on branch `feat/session-steer-endpoint`. All 5 slices together. The R1 fix (Slice 1) is
267
+ small enough that it doesn't need its own PR. The endpoint is only usable when all slices are present.
268
+
269
+ PR title: `feat(console): add POST /api/v2/sessions/:sessionId/steer for coordinator injection`
270
+
271
+ ---
272
+
273
+ ## Philosophy Alignment Per Slice
274
+
275
+ | Slice | Principle | Status |
276
+ |---|---|---|
277
+ | S1 pendingSteerParts | Immutability by default | Tension (mutable array) -- acceptable, mutation bounded |
278
+ | S1 pendingSteerParts | Compose with small pure functions | Satisfied -- drain is one expression |
279
+ | S2 SteerRegistry type | Explicit domain types | Satisfied -- named alias |
280
+ | S2 runWorkflow registration | DI for boundaries | Satisfied -- injected, not global |
281
+ | S3 TriggerRouter | Make illegal states unrepresentable | Satisfied -- optional param can't be confused with required |
282
+ | S4 HTTP endpoint | Validate at boundaries | Satisfied -- 400/503/404 before touching registry |
283
+ | S4 HTTP endpoint | Errors as data | Satisfied -- { success: bool } shape |
284
+ | S5 daemon wiring | YAGNI | Satisfied -- single Map, no extra abstraction |
@@ -5891,3 +5891,295 @@ Coordinator logic:
5891
5891
  - Phase 1: coordinator scripts withhold `complete_step` advancement until the condition is met. This already works today -- the coordinator just doesn't advance the session until the fix agent is done.
5892
5892
  - Phase 2: the coordinator passes structured context when advancing: `complete_step(session, { injectedContext: fixSummary })`. The session receives it as part of the next step's prompt.
5893
5893
  - Phase 3: declarative pipelines -- workflow JSON declares that step N waits for an external condition before proceeding. The coordinator reads this and manages the timing automatically. No hand-coded coordinator script needed for common patterns.
5894
+
5895
+ ---
5896
+
5897
+ ### Coordinatable workflow steps: confirmation points the coordinator can satisfy (needs discovery, Apr 18, 2026)
5898
+
5899
+ ⚠️ **Needs discovery before implementation. The questions below are open, not answered.**
5900
+
5901
+ **The insight:** workflows already have `requireConfirmation: true` on certain steps -- these are natural coordination points. Right now they pause for a human. The idea is to make them also pausable-for-a-coordinator, so a coordinator (or another agent) can be the one that responds instead of a human.
5902
+
5903
+ **The vision:**
5904
+ A workflow reaches a `requireConfirmation` step. In MCP mode (human-driven), it behaves exactly as today -- pauses and waits. In daemon/coordinator mode, instead of blocking forever, the coordinator can:
5905
+ - Inject a synthesized answer based on external work it just did ("architecture review found X, proceed with approach A")
5906
+ - Spawn another agent to generate the answer and inject its output
5907
+ - Ask a discovery agent to weigh in and forward the result
5908
+ - Simply forward a human's message from the message queue
5909
+
5910
+ The original session never knows whether a human or a coordinator satisfied the confirmation. It just receives the next turn with context.
5911
+
5912
+ **Why this is powerful:**
5913
+ Today the coordinator is external to the workflow -- it orchestrates sessions from outside. This makes the workflow itself coordinatable from within, so multi-agent collaboration can be declared in the workflow spec rather than bolted on in coordinator scripts.
5914
+
5915
+ **What's unknown and needs discovery:**
5916
+ 1. **Mechanism:** is this an enriched `requireConfirmation` (add a `coordinatable: true` flag?), a new step type (`requireCoordinatorInput`?), or something at the engine level? Tradeoffs between each.
5917
+ 2. **What gets injected:** always a structured decision ("proceed/revise/abort + findings"), or also data injection ("here are the file contents", "here's what the API returned")? How does the step receive it -- as a new tool call result, as a steer, as part of the step prompt?
5918
+ 3. **Coordinator discovery:** how does the coordinator know a step is waiting for it vs waiting for a human? Does it poll the session state? Does the session emit a `coordinator_gate_pending` event? (This connects to the `waitForCoordinator` spec in this backlog.)
5919
+ 4. **Timeout/fallback:** if the coordinator never responds, what happens? Fall back to human? Error? Configurable?
5920
+ 5. **MCP invariant:** must behave identically to today in MCP/human-driven mode. The coordinator path is additive, not a behavior change for existing users.
5921
+
5922
+ **Relationship to other specs:**
5923
+ - "Long-running sessions: stay open across agent handoffs" -- the session pauses at the confirmation point, coordinator acts, session resumes
5924
+ - "POST /api/v2/sessions/:id/steer" -- this might be the injection mechanism
5925
+ - `signal_coordinator` tool -- the session might signal the coordinator instead of blocking
5926
+ - `waitForCoordinator` step flag (already in this backlog) -- same underlying need, different framing
5927
+ - "Coordinator review mode: self-healing vs comment-and-wait" -- confirmation points are where that routing decision gets expressed
5928
+
5929
+ ---
5930
+
5931
+ ## Architecture Decision: Three-Workflow Pipeline (Apr 18, 2026)
5932
+
5933
+ ### Decision
5934
+
5935
+ The canonical WorkRail workflow pipeline for new features is:
5936
+
5937
+ ```
5938
+ wr.discovery (optional) → wr.shaping (optional) → coding-task-workflow-agentic
5939
+ ```
5940
+
5941
+ Each workflow is independently useful. The pipeline is an optional chain, not a required sequence.
5942
+
5943
+ ### Rationale
5944
+
5945
+ **wr.discovery** produces a direction -- what problem is worth solving. Output: structured discovery notes at `.workrail/discovery/`.
5946
+
5947
+ **wr.shaping** produces a bounded pitch -- what specifically to build and explicitly NOT build, at a product level. Output: `.workrail/current-pitch.md`. Faithful Shape Up methodology. Tech-agnostic. No code-level content.
5948
+
5949
+ **coding-task-workflow-agentic** produces running code -- engineering approach, sliced implementation, verification. When pitch.md exists (Phase 0.5), it skips design ideation and translates the pitch directly into an engineering approach. The pitch's no-gos and appetite are binding constraints.
5950
+
5951
+ ### No TechSpec workflow needed
5952
+
5953
+ The coding workflow already does everything a TechSpec workflow would do: Phase 1b generates design candidates, Phase 1c selects and challenges the approach, Phase 3 writes the spec and implementation plan. Adding a separate TechSpec workflow would duplicate this and create a question of which is canonical. The coding workflow is the engineering planning layer.
5954
+
5955
+ **The split that matters is product vs engineering:**
5956
+ - Product decisions (what to build, for whom, within what time) → wr.shaping
5957
+ - Engineering decisions (how to build it, which interfaces, which tests) → coding workflow
5958
+
5959
+ ### When to skip shaping
5960
+
5961
+ - Task is small, concrete, and clearly scoped → go straight to coding workflow
5962
+ - Discovery already produced a bounded, implementable direction
5963
+ - You have a pre-written ticket or spec that already defines what to build
5964
+
5965
+ ### Faithful Shape Up constraint
5966
+
5967
+ wr.shaping is tech-agnostic. A pitch for a Kotlin Android app and a pitch for a Python API service look structurally identical. No file paths, no function signatures, no implementation details. This makes pitches usable by human engineering teams at companies using Shape Up, not just WorkRail's coding workflow.
5968
+
5969
+ ### Phase 0.5 mechanics
5970
+
5971
+ When `coding-task-workflow-agentic` finds `.workrail/current-pitch.md`:
5972
+ 1. Reads all five pitch sections (Problem, Appetite, Solution/Elements, Rabbit Holes, No-Gos)
5973
+ 2. Sets `shapedInputDetected=true`
5974
+ 3. Skips phases 1a-1c (hypothesis, design generation, challenge-and-select)
5975
+ 4. Phase 1d translates pitch elements/invariants/no-gos into an engineering approach
5976
+ 5. Plan audit (Phase 4) checks for drift against the pitch
5977
+ 6. Appetite is a hard ceiling -- oversized engineering work becomes follow-up tickets
5978
+
5979
+
5980
+ ---
5981
+
5982
+ ## Idea: `context-gather` Step Type (Apr 19, 2026)
5983
+
5984
+ ### Problem
5985
+
5986
+ Phase 0.5 in the coding workflow currently looks for a shaped pitch by checking a local path. This doesn't handle: coordinator-injected context, manually written docs (GDoc, Confluence, Notion), Glean-indexed artifacts, or URLs embedded in the task description. The search logic is duplicated if other workflows need the same document.
5987
+
5988
+ ### Proposed primitive
5989
+
5990
+ A new engine-level step type `context-gather` that resolves a named context artifact from ordered sources:
5991
+
5992
+ ```json
5993
+ {
5994
+ "type": "context-gather",
5995
+ "id": "gather-pitch",
5996
+ "contextType": "shaped-pitch",
5997
+ "outputVar": "shapedInput",
5998
+ "optional": true,
5999
+ "sources": ["coordinator-injected", "local-paths", "task-url", "glean"]
6000
+ }
6001
+ ```
6002
+
6003
+ **Source resolution order (stops at first hit):**
6004
+ 1. `coordinator-injected` -- coordinator already attached context of this type to the session (most common in autonomous mode)
6005
+ 2. `local-paths` -- check `.workrail/current-pitch.md`, `pitch.md`, `PRD.md`, `.workrail/pitches/` (most recent)
6006
+ 3. `task-url` -- extract any URL from the task description and fetch via WebFetch or matching MCP (GDoc, Confluence, Notion)
6007
+ 4. `glean` -- search Glean for recent docs matching the task keywords and `contextType`; opt-in only (risk of false positives silently constraining wrong scope)
6008
+
6009
+ If `optional: true` and no source resolves: `outputVar = null`, workflow continues normally.
6010
+
6011
+ ### Why engine-level, not a routine
6012
+
6013
+ - Coordinator intercept requires the engine to check "has this type already been provided?" before running any search -- a routine can't express that
6014
+ - `contextType` is a declared intent multiple workflows can share (`wr.shaping`, `coding-task-workflow`, `wr.discovery`) without duplicating resolver logic
6015
+ - New sources (Linear, Jira, Notion) get added to the engine once, immediately available to all workflows
6016
+
6017
+ ### Relationship to existing work
6018
+
6019
+ - Replaces/supersedes Phase 0.5's current local-path check in `coding-task-workflow-agentic`
6020
+ - Coordinator PR-review flow would inject `shaped-pitch` context before spawning the coding session
6021
+ - Any workflow that needs "find the spec/pitch/PRD for this task" uses the same step type
6022
+
6023
+ ### Open questions
6024
+
6025
+ - How does the coordinator inject context into a session? Via a session variable set before `start_workflow`, or a new `inject_context` call?
6026
+ - How does `task-url` distinguish a GDoc URL from a Confluence URL from a Notion URL? MCP routing by domain?
6027
+ - What is the `contextType` vocabulary? Start with `shaped-pitch` -- what else? (`discovery-notes`, `design-spec`, `api-contract`?)
6028
+ - Glean false-positive risk: wrong document fed as shaped input silently constrains wrong scope. Needs confidence threshold or explicit user confirmation when Glean is the only hit.
6029
+
6030
+
6031
+ ---
6032
+
6033
+ ## Completed (Apr 19, 2026)
6034
+
6035
+ ### wr.shaping -- Faithful Shape Up shaping workflow
6036
+
6037
+ Created `workflows/wr.shaping.json`. Faithful Shape Up methodology, tech-agnostic, produces `.workrail/current-pitch.md` only. Nine steps: ingest → frame gate → diverge (6 shapes, Verbalized Sampling) → converge → breadboard + elements → rabbit holes + no-gos → draft/critique loop → approval gate → write pitch.md. Two human gates with autonomous fallback. Appetite is calendar-time only (xs/s/m/l/xl). No code-level content -- a pitch for a Kotlin app and a pitch for a Python service look structurally identical.
6038
+
6039
+ ### coding-task-workflow-agentic -- Upstream context Phase 0.5
6040
+
6041
+ Added Phase 0.5 "Locate Upstream Context" to `coding-task-workflow-agentic.json`. Format-agnostic: the agent uses whatever tools are available (repo search, WebFetch, Confluence/Notion/Glean MCPs, etc.) to find any upstream document -- pitch, PRD, BRD, RFC, design doc, user story, Jira epic, etc. Sets `upstreamSpecDetected` + `solutionFixed` flags. When `solutionFixed=true`, design ideation phases (1a-1c) are skipped and Phase 1d translates upstream constraints directly into an engineering approach. Plan audit (Phase 4) checks for drift against `upstreamBoundaries` whenever an upstream document was found.
6042
+
6043
+ Also consolidated from three workflow variants to one canonical file.
6044
+
6045
+
6046
+ ---
6047
+
6048
+ ## Current state update (Apr 19, 2026)
6049
+
6050
+ **npm version: v3.40.0**
6051
+
6052
+ ### What shipped since v3.36.0 (Apr 18 -- Apr 19)
6053
+
6054
+ - ✅ **`wr.shaping`** -- faithful Shape Up shaping workflow (9 steps, two human gates with autonomous fallback)
6055
+ - ✅ **`coding-task-workflow-agentic` Phase 0.5** -- upstream context detection; skips design phases when solution is pre-specified. Three-workflow pipeline: shaping → discovery → coding.
6056
+ - ✅ **Coding workflow consolidated** -- from three variants (lean, full, lean.v2) to one canonical file.
6057
+ - ✅ **HttpServer removed from MCP server** (#601) -- pure stdio. MCP server can no longer accidentally start an HTTP server.
6058
+ - ✅ **Late-bound goals** (#604) -- `goalTemplate: "{{$.goal}}"` defaults for webhook-driven sessions. Goals can come from the payload, not just the static trigger definition.
6059
+ - ✅ **Coordinator message queue drain** (#606) -- `pr-review` coordinator reads `~/.workrail/message-queue.jsonl` before each spawn cycle. `worktrain tell stop`, `skip-pr <n>`, `add-pr <n>` work.
6060
+ - ✅ **Notifications shipped** -- `NotificationService` implemented, wired into `TriggerRouter` via `trigger-listener.ts`. `WORKTRAIN_NOTIFY_MACOS=true` and `WORKTRAIN_NOTIFY_WEBHOOK=<url>` in `~/.workrail/config.json`.
6061
+ - ✅ **`worktrain run pr-review`** -- fully wired coordinator command. `spawnSession` → `awaitSessions` → `getAgentResult` (session-wide artifact aggregation) → `parseFindingsFromNotes` → route by severity.
6062
+ - ✅ **`wr.review_verdict` artifact path** -- end-to-end wired: `mr-review-workflow.agentic.v2.json` phase-6 emits it, `artifact-contract-validator.ts` validates it at `continue_workflow` time, coordinator reads it with keyword-scan fallback.
6063
+ - ✅ **`worktrain logs` / `worktrain health`** -- structured daemon log tailing and per-session health summary. `worktrain status <id>` deprecated in favor of `worktrain health <id>`.
6064
+ - ✅ **`signal_coordinator` tool** -- agent can emit structured mid-session signals (`progress`, `finding`, `data_needed`, `approval_needed`, `blocked`) without advancing the step.
6065
+ - ✅ **`ChildWorkflowRunResult` + `assertNever`** -- spawn_agent delivery_failed bug fixed. `delivery_failed` impossible state is compile-time excluded.
6066
+ - ✅ **`lastStepArtifacts` on `WorkflowRunSuccess`** -- `onComplete` callback forwards artifacts alongside notes. Coordinator can read typed artifacts from result without a separate HTTP call.
6067
+ - ✅ **`steerRegistry` + POST `/sessions/:id/steer`** -- coordinator injection endpoint wired in daemon console. Running sessions register a steer callback; coordinators can inject mid-session messages via HTTP.
6068
+ - ✅ **GitHub polling adapters** -- `github_issues_poll` and `github_prs_poll` providers fully implemented alongside existing `gitlab_poll`.
6069
+ - ✅ **Knowledge graph spike** -- `src/knowledge-graph/` module: DuckDB in-memory + ts-morph indexer + two validation queries. NOT yet wired to an MCP tool (ts-morph in devDependencies).
6070
+ - ✅ **`worktrain daemon --install`** -- launchd plist creation, load, verify. Daemon survives MCP server reconnects.
6071
+ - ✅ **Performance sweep** -- April 2026 sweep identified 10 highest-leverage fixes, filed as issues #248-257. Not yet merged.
6072
+
6073
+ ### Accurate limitations (as of v3.40.0)
6074
+
6075
+ 1. **Console session tree UI not built** -- `parentSessionId` is stored in the `session_created` event and in `WorkflowRunSuccess`. Console `RunLineageDag` shows the per-session step DAG only. Cross-session parent-child tree is data-only. PRs #607 (tree view) and #608 (steer endpoint) are OPEN.
6076
+ 2. **Daemon tool set is minimal** -- agent has: `complete_step`, `continue_workflow` (deprecated), `Bash`, `Read`, `Write`, `report_issue`, `spawn_agent`, `signal_coordinator`. No `Glob`, `Grep`, or `Edit`. Read/Write are thin wrappers.
6077
+ 3. **`worktrain tell` messages only drained by coordinator** -- `drainMessageQueue` is called by `runPrReviewCoordinator`, not by the daemon loop. A running autonomous session cannot receive mid-run injections from `worktrain tell`. The `steerRegistry` HTTP endpoint is the mid-session channel.
6078
+ 4. **Knowledge graph not wired** -- module exists, ts-morph must move to dependencies before an MCP tool can be built.
6079
+ 5. **`spawn_agent` return missing `artifacts`** -- returns `{ childSessionId, outcome, notes }` only. Typed artifacts from child session are not surfaced to the parent agent. `lastStepArtifacts` on `WorkflowRunSuccess` exists but spawn_agent doesn't return it.
6080
+ 6. **`worktrain inbox --watch` stub** -- `--watch` flag prints "not yet implemented" and exits.
6081
+ 7. **Artifact store not built** -- agents still dump markdown/files directly into the repo. `~/.workrail/artifacts/` directory structure not created.
6082
+ 8. **Performance issues not fixed** -- issues #248-257 filed from April sweep. `continue_workflow` triggers 6+ event log scans, full session rebuild per `/api/v2/sessions` request, N+1 workflow fetches, no caching.
6083
+ 9. **No auto-commit** -- agents can write code but do not commit, push, or open PRs autonomously.
6084
+ 10. **Assessment gates not battle-tested** -- end-to-end flow with `outputContract: required: true` not validated in production use.
6085
+
6086
+ ### Open PRs to merge
6087
+
6088
+ - **#607** `feat(console): add session tree view for coordinator sessions` -- cross-session parent-child hierarchy in console. Blocked on: `parentSessionId` data is in store but console routes need to surface it.
6089
+ - **#608** `feat(console): add POST /api/v2/sessions/:sessionId/steer for coordinator injection` -- NOTE: this endpoint is already implemented in `daemon-console.ts` via `steerRegistry`. PR #608 may be adding this to the MCP server console separately. Check before merging.
6090
+ - **#610** `feat(workflows): add wr.shaping` -- the shaping workflow. Ready to merge.
6091
+ - **#587** `fix(mcp): add assertNever exhaustiveness guard to TriggerRouter` -- likely already applied in codebase (ChildWorkflowRunResult assertNever is live). May be a duplicate or different scope. Check.
6092
+
6093
+ ### Next priorities (groomed Apr 19)
6094
+
6095
+ 1. **Merge #610 (wr.shaping)** -- ready. Workflow is implemented and in the branch.
6096
+ 2. **Merge #587 (TriggerRouter assertNever)** -- quick fix, check if still relevant.
6097
+ 3. **Review and merge #607 + #608** -- console tree view and steer endpoint. Verify #608 doesn't duplicate what's already live in daemon-console.ts.
6098
+ 4. **Performance fixes** -- issues #248-257. Pick highest-leverage first: SessionIndex (#248) and console projection cache (#249) eliminate most of the repeated scans.
6099
+ 5. **Daemon tool set: add Glob + Grep** -- agents routinely need to search files. `Read` + `Bash` grep is slow and lossy. Native `Glob` and `Grep` tools would make coding sessions more reliable.
6100
+ 6. **`spawn_agent` artifacts gap** -- add `artifacts?: readonly unknown[]` to the return value. `lastStepArtifacts` is already on `WorkflowRunSuccess`; wiring it through is ~30 LOC.
6101
+ 7. **Knowledge graph wiring** -- move `ts-morph` and `@duckdb/node-api` to dependencies, add `query_knowledge_graph` MCP tool.
6102
+ 8. **Artifact store foundation** -- `~/.workrail/artifacts/` directory, write path in `complete_step`.
6103
+
6104
+ ---
6105
+
6106
+ ### wr.shaping workflow: shape messy problems into implementation-ready specs (needs authoring, Apr 18, 2026)
6107
+
6108
+ **Status:** Design complete. Ready to author as a WorkRail workflow JSON.
6109
+
6110
+ **Design docs:**
6111
+ - `docs/design/shaping-workflow-discovery.md` -- WorkRail-internal discovery findings
6112
+ - `docs/design/shaping-workflow-external-research.md` -- External research synthesis (Shape Up, LLM failure modes, artifact schema)
6113
+
6114
+ **The gap this fills:** WorkRail has `wr.discovery` (divergent) and `coding-task-workflow-agentic` (convergent). Shaping is the missing middle -- converting messy discovery output into a bounded, implementation-ready spec without mid-implementation rabbit holes.
6115
+
6116
+ **The 11-step skeleton (see design doc for full detail):**
6117
+ 1. ingest_and_extract -- extract problem frames, forces, open questions
6118
+ 2. **frame_gate** -- MANDATORY HUMAN GATE: confirm problem + appetite
6119
+ 3. diverge_solution_shapes -- 4 parallel rough shapes with varied framings
6120
+ 4. converge_pick -- SEPARATE JUDGE (different model/prompt): pick best shape
6121
+ 5. breadboard_and_elements -- fat-marker breadboard + Interface/Invariant/Exclusion classification
6122
+ 6. rabbit_holes_nogos -- adversarial: risks, mitigations, no-gos, assumptions
6123
+ 7. context_pack_build -- file globs, reuse_utilities, conventions, do-not-touch boundaries
6124
+ 8. example_map_and_gherkin -- Given/When/Then acceptance criteria + verification commands
6125
+ 9. draft_pitch -- self-refine ×2, SEPARATE CRITIC (obfuscated authorship)
6126
+ 10. **approval_gate** -- MANDATORY HUMAN GATE: approve, edit, or restart
6127
+ 11. finalize_and_handoff -- schema validation, emit shape.json + pitch.md
6128
+
6129
+ **The single most important design decision:** generator and critic run on structurally different prompts (ideally different model families). CoT and self-reflection alone do NOT mitigate anchoring or self-preference bias (Lou & Sun 2025; Panickssery et al. 2024).
6130
+
6131
+ **Output artifact:** `shape.json` -- contains problem story, appetite (multi-dimensional: calendar + tokens + turns + files), breadboard, elements, context_pack (file boundaries + reuse_utilities), Gherkin acceptance criteria, rabbit holes, no-gos, decomposition with walking skeleton, assumptions_log, build_readiness_score.
6132
+
6133
+ **Key insight for AI implementers:** LLMs need MORE explicit specs than humans on interfaces/invariants/file boundaries (no tacit knowledge, no scope-shame), but LESS explicit than junior humans on standard patterns. The dominant failure mode is confident architectural divergence -- working code that reinvents an existing utility. Context Pack (Step 7) directly prevents this.
6134
+
6135
+ **Next action:** author `wr.shaping` as a WorkRail workflow JSON using workflow-for-workflows, then update `coding-task-workflow-agentic` Phase 0 to detect and consume `shape.json` when present.
6136
+
6137
+ ---
6138
+
6139
+ ## Coordinator architecture: separation of concerns (Apr 19, 2026)
6140
+
6141
+ **Decision: defer knowledge graph implementation until the context assembly layer is designed.**
6142
+
6143
+ ### The god class problem
6144
+
6145
+ `src/coordinators/pr-review.ts` is already ~500 LOC doing: session dispatch, result aggregation, finding classification, merge routing, message queue drain, and outbox writes. Adding knowledge graph queries, context bundle assembly, upstream doc fetching, and prior session lookups would make it a god class.
6146
+
6147
+ "Coordinator" is not a class or a script -- it is a **layer** that orchestrates across multiple concerns. Those concerns need to be separated before we add more to them.
6148
+
6149
+ ### The right layering
6150
+
6151
+ ```
6152
+ Trigger layer src/trigger/ receives events, validates, enqueues
6153
+ Dispatch layer (TBD) decides which workflow + what goal
6154
+ Context assembly (TBD) gathers and packages context before spawning
6155
+ Orchestration layer src/coordinators/ spawns, awaits, routes, retries, escalates
6156
+ Delivery layer src/trigger/delivery posts results back to origin systems
6157
+ ```
6158
+
6159
+ **Context assembly** is the missing layer. Before dispatching a coding session, something needs to:
6160
+ - Run `buildIndex()` and query "what imports the file being changed"
6161
+ - Find the upstream pitch/PRD/BRD for the task
6162
+ - Pull relevant prior session notes
6163
+ - Package everything as a structured context bundle
6164
+
6165
+ This is NOT the orchestration script's job. The orchestration script should call `assembleContext(task, workspace)` and receive a bundle -- it should not know how that bundle was gathered.
6166
+
6167
+ ### Why the knowledge graph belongs in context assembly, not in the daemon
6168
+
6169
+ Two options were considered:
6170
+ - **Daemon tool** (`makeQueryKnowledgeGraphTool` in `workflow-runner.ts`) -- agent queries mid-session on demand
6171
+ - **Coordinator pre-fetch** -- coordinator runs queries before spawning, injects answers as context
6172
+
6173
+ The coordinator pre-fetch is better for known patterns (e.g. "what imports the file being changed" before a coding task). The agent doesn't need to know the graph exists -- it just gets the relevant facts as context. This also avoids adding `ts-morph` + DuckDB to the production build.
6174
+
6175
+ The daemon tool approach is only better for ad-hoc mid-session queries the agent discovers dynamically. That's a secondary use case for v1.
6176
+
6177
+ ### What to build before the knowledge graph
6178
+
6179
+ 1. **Design the `ContextAssembler` abstraction** -- takes task description + workspace + trigger metadata, returns a structured context bundle. The knowledge graph is one of several sources (alongside upstream docs, prior session notes, repo state).
6180
+ 2. **Refactor `pr-review.ts`** to use a `ContextAssembler` for the bits that fit there.
6181
+ 3. **Then** implement knowledge graph as a `ContextAssembler` plugin -- not as a coordinator script addition and not as a daemon tool.
6182
+
6183
+ ### Anti-pattern to avoid
6184
+
6185
+ Adding knowledge graph calls directly into `pr-review.ts` or any other coordinator script. That immediately creates the god class we're trying to avoid and couples the orchestration layer to a specific context source.
@@ -0,0 +1,64 @@
1
+ # Design Candidates: Console Session Tree Implementation (Phase 3)
2
+
3
+ *2026-04-18 -- This document covers only the remaining Slice 5 (SessionTreeView UI component)*
4
+ *Phase 1 and Phase 2 artifacts: see design-candidates-session-tree-view.md and design-review-findings-session-tree-view.md*
5
+
6
+ ## Problem Understanding
7
+
8
+ Slices 1-4 are implemented. The remaining work is Slice 5: add a SessionTreeView rendering path to SessionList.tsx.
9
+
10
+ **Tensions:**
11
+ - Expand toggle vs card navigation: two click targets on the same logical row. Resolved by a flex row with separate button elements.
12
+ - Per-coordinator expand state vs pure component: expand state lives in useState (UI state, not business logic -- correct placement).
13
+ - Auto-expand for in_progress: requires checking status in state initialization.
14
+
15
+ **Likely seam:** SessionList.tsx (presenter) + session-list-use-cases.ts (pure function buildSessionTree, already built).
16
+
17
+ **What makes it hard:** The expand toggle must be keyboard-navigable separately from the card AND must not trigger card navigation on click.
18
+
19
+ ## Philosophy Constraints
20
+
21
+ - Pure presenter: no business logic in the component
22
+ - Immutability: expand state is a ReadonlyMap or regular Map in useState
23
+ - Functional/declarative: map SessionTreeNode[] to JSX
24
+ - Compose with small functions: SessionTreeView as a named function, separate from SessionList
25
+
26
+ ## Impact Surface
27
+
28
+ - SessionList.tsx: adding viewMode branch
29
+ - session-list-use-cases.ts: already has buildSessionTree exported
30
+ - session-list-reducer.ts: already has viewMode + view_mode_changed
31
+
32
+ ## Candidates
33
+
34
+ ### Candidate A: SessionTreeView inline in SessionList.tsx (only candidate)
35
+
36
+ **Summary:** A `SessionTreeView` function component in SessionList.tsx takes `SessionTreeNode[]`, initializes expand state as `Map<string, boolean>` (auto-expand in_progress), and renders a flex row with [expand-toggle, coordinator-card] and children in a TreeLine wrapper below when expanded.
37
+
38
+ **Tensions resolved:** Expand/navigate separation (separate button elements). Accepts: expand state resets on navigation (transient UI state is acceptable).
39
+
40
+ **Boundary:** SessionList.tsx presenter layer.
41
+
42
+ **Failure mode:** Expand toggle accidentally triggers card navigation. Fixed by: expand toggle button is outside the coordinator ConsoleCard, not nested inside it.
43
+
44
+ **Repo pattern:** Follows SessionGroup component pattern in SessionList.tsx exactly.
45
+
46
+ **Gains:** Simple, pure, testable in isolation. **Loses:** Expand state resets when navigating away (transient).
47
+
48
+ **Scope:** Best-fit.
49
+
50
+ **Philosophy:** All principles honored.
51
+
52
+ ## Comparison and Recommendation
53
+
54
+ Single candidate; no comparison needed. Candidate A is the correct approach.
55
+
56
+ ## Self-Critique
57
+
58
+ Strongest counter-argument: expand state should be in the reducer (durable within page session). Counter-counter: expand state is UI state, not domain state. Reducer is for interaction state that needs to persist across renders (search, filter, sort, pagination). Expand state for individual coordinator rows is more like accordion state -- local useState is correct.
59
+
60
+ Pivot condition: if user feedback shows expand state loss is disruptive, move to reducer with `expanded_coordinators: ReadonlySet<string>` field.
61
+
62
+ ## Open Questions for the Main Agent
63
+
64
+ None. Implementation is fully specified in docs/ideas/design-candidates-session-tree-view.md and the Phase 2 design spec.