@gotgenes/pi-subagents 16.4.0 → 16.5.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.
@@ -0,0 +1,214 @@
1
+ # Phase 17: Core consolidation
2
+
3
+ ## Summary
4
+
5
+ Phase 17 consolidated the core's remaining structural debt before the UI reconsideration (Phase 18).
6
+ The findings came from the standard discovery pass — fallow suite, entry-point trace, design-review checklist, and test-constructibility audit — run after Phase 16 landed.
7
+
8
+ All nine steps are closed: [#381], [#373], [#374], [#375], [#376], [#377], [#378], [#379], [#380].
9
+ [#412] unified the overlapping session-mock builders identified during Step 7.
10
+ [#415] migrated `pi-subagents-worktrees` to the shared settings helper after the Step 9 published release.
11
+
12
+ ## Context
13
+
14
+ Phase 17 is the consolidation slice of the [first-principles refinement](../architecture.md#first-principles-refinement-and-the-deeper-target), not the full domain split.
15
+ It lands the first cut of the lifecycle-state domain (Step 2's `SubagentState`) plus the wiring, queue, and duplication cleanups.
16
+ The fuller four-domain split — metrics as a projection, result delivery as its own domain, the hook/broadcast reclassification, and the push/pull (DIP) inversion — is recorded in the refinement and sequenced into later phases.
17
+
18
+ ## Findings summary
19
+
20
+ Health metrics at the start of Phase 17 (fallow, package-wide including tests):
21
+
22
+ | Metric | Phase 16 baseline | Phase 17 start |
23
+ | -------------------------- | ------------------------------ | ------------------------------------------------------------------ |
24
+ | Health score | 78/100 (B) | 78/100 (B) |
25
+ | Source LOC | 7,778 (57 files) | 8,356 (61 files, landed Phase 17 Step 5) |
26
+ | Dead code | 0 files, 0 exports | 0 files, 0 exports |
27
+ | Maintainability index | 90.8 (good) | 90.8 (good) |
28
+ | Avg / P90 cyclomatic | 1.4 / 2 | 1.4 / 2 |
29
+ | Production duplication | 11 lines (1 internal group) | 11 lines (1 internal group; cross-package pair resolved in Step 9) |
30
+ | Test duplication | 42 groups, 661 lines | 44 groups, ~750 lines |
31
+ | Fallow refactoring targets | 0 | 0 |
32
+ | Top churn hotspot | `index.ts` 65.0 ▲ accelerating | `index.ts` 31.3 ▼ cooling |
33
+
34
+ The syntactic metrics are healthy and stable — the remaining debt is structural, mostly invisible to fallow, and concentrated in three places:
35
+
36
+ 1. **`Subagent` construction duality.**
37
+ `SubagentInit` carries ~20 fields, nearly all optional with "required for run(), optional for tests" semantics, and `run()` compensates with runtime throws ("not configured for execution").
38
+ This violates principle 8 (construct complete): the class is simultaneously a passive record (tests build display-only snapshots) and an executor (production wires factory, observer, run config, workspace provider).
39
+ The symptoms are in the tests: external writes `record.promise = …` (manager, queue callback, four test files) and `record.notification = new NotificationState(…)` (seven test sites) are output-argument smells on fields the object should own.
40
+ This duality is the two most visible of four domains fused into `Subagent`; Phase 17 resolves it (Step 2) and defers the remaining split (metrics, result delivery) to a later phase per the [first-principles refinement](../architecture.md#first-principles-refinement-and-the-deeper-target).
41
+ 2. **Wiring debt in `index.ts`.**
42
+ Two forward references (settings → queue, queue → manager) are replicated with an `eslint-disable prefer-const` dance in `test/lifecycle/subagent-manager.test.ts`; the queue's start callback (`record.promise = record.run()` after a status check) is duplicated verbatim between `index.ts` and the test helper.
43
+ A ~70-line inline `SubagentManagerObserver` literal mixes three concerns (event emission, `appendEntry` persistence, notification dispatch).
44
+ `runtime.widget` is assigned post-construction behind five relay-only delegation methods on `SubagentRuntime`.
45
+ 3. **Duplication.**
46
+ A 23-line cross-package production clone (`settings.ts:198-211` ↔ `pi-subagents-worktrees/src/config.ts:51-73`: the layered global/project settings-file loader) and 44 test clone groups (~750 lines), with clone families concentrated in `test/lifecycle/` and `test/ui/`.
47
+
48
+ Deferred findings (scored below the priority cut, tracked here rather than as steps): the `resolveModel` error-as-string union return (callers branch on `typeof resolved === "string"`), the file-top SDK `eslint-disable` headers in 14 files (re-audit when the Pi SDK exports improve), missing unit tests for `observation/renderer.ts` (the top CRAP-risk file), and the 11-line internal clone in `ui/agent-config-editor.ts` (folds into the Phase 18 UI extraction).
49
+
50
+ ## Steps
51
+
52
+ Priority = Impact × (6 − Risk).
53
+
54
+ | Step | Title | Category | Impact | Risk | Priority |
55
+ | ---- | ------------------------------------------------------------------------------------ | -------- | ------ | ---- | -------- |
56
+ | 1 | Replace ConcurrencyQueue with a thunk-based ConcurrencyLimiter | A/C | 4 | 2 | 16 |
57
+ | 2 | Extract `SubagentState`; make `Subagent` execution deps mandatory | B/D | 4 | 3 | 12 |
58
+ | 3 | Encapsulate run start and notification attachment on Subagent | C | 3 | 2 | 12 |
59
+ | 4 | Extract run-listener and workspace-bracket collaborators from Subagent | B/C | 3 | 2 | 12 |
60
+ | 5 | Extract the manager observer from index.ts into a class | B/E | 3 | 2 | 12 |
61
+ | 6 | Split widget delegation out of SubagentRuntime | C | 3 | 3 | 9 |
62
+ | 7 | Consolidate lifecycle test fixtures | D | 3 | 1 | 15 |
63
+ | 8 | Consolidate UI and tools test fixtures | D | 2 | 1 | 10 |
64
+ | 9 | Resolve the cross-package settings-loader duplication | A | 2 | 2 | 8 |
65
+
66
+ ### Step 1 — Replace ConcurrencyQueue with a thunk-based ConcurrencyLimiter ([#381])
67
+
68
+ - Targets: `src/lifecycle/concurrency-queue.ts` (→ `concurrency-limiter.ts`), `src/lifecycle/subagent-manager.ts`, `src/index.ts`, `test/lifecycle/concurrency-queue.test.ts`, `test/lifecycle/subagent-manager.test.ts`.
69
+ - Smell: Category C (forward references: the queue's ID-registry design forces a start callback that reaches back into the manager, duplicated between `index.ts` and the test helper) and Category A (dual counting: the queue's `running` counter is fed by `markStarted`/`markFinished` relays in the manager's observer, mirroring state the agents already carry).
70
+ - Change: replace the ID-registry queue with a `ConcurrencyLimiter` that schedules thunks FIFO against a dynamic `getLimit()` — the injected limiter knows nothing about agents, IDs, or the manager.
71
+ Spawn gates background runs with `limiter.schedule(() => record.start())` — `start()` owns the abort-while-queued status guard and stores the promise internally; foreground and `bypassQueue` runs invoke `record.start()` directly.
72
+ The settings `onMaxConcurrentChanged` hook wires to `limiter.recheck()` in `index.ts`; `dispose()` calls `limiter.clear()` to drop pending thunks.
73
+ - Outcome: dependency direction is strictly manager → limiter (no callback back-edge; the `prefer-const` eslint-disable in the test helper is deleted); the observer's two queue relays are gone; every spawned agent has a `promise` at spawn, collapsing `waitForAll`'s `while (true)` drain loop and its eslint-disable.
74
+
75
+ ### Step 2 — Extract `SubagentState`; make `Subagent` execution deps mandatory ([#373])
76
+
77
+ - Targets: `src/lifecycle/subagent.ts` (state fields, transition/accumulation methods, constructor, `run()` guards), `src/lifecycle/subagent-manager.ts` (`spawn`), `test/helpers/make-subagent.ts`, `test/lifecycle/subagent.test.ts`, `test/observation/record-observer.test.ts`.
78
+ - Smell: Category B (god interface — ~20 fields) and Category D (constructibility: "optional for tests" fields with compensating runtime throws).
79
+ The record/executor duality is the two most visible of the four conflated domains (see the first-principles refinement in [architecture.md](../architecture.md#first-principles-refinement-and-the-deeper-target)).
80
+ - Change: extract the passive-record state — status, result, error, timestamps, and the stats (toolUses, lifetimeUsage, compactionCount) — into a `SubagentState` value object that owns the transition and accumulation methods.
81
+ `Subagent` holds one privately; its existing getters and `markX`/`incrementX`/`addUsage` methods become one-line delegations, so the ~40 read sites and the mutation callers are unchanged.
82
+ This is not reach-through: `SubagentState` is a private owned value, not a foreign collaborator (contrast [#277], which removed reach-through to the raw SDK session).
83
+ With the readable state extracted, the remaining execution inputs (snapshot, prompt, model, maxTurns, thinkingLevel, parentSession, signal, createSubagentSession, observer, getRunConfig, getWorkspaceProvider, baseCwd) collapse into a single **mandatory** `SubagentExecution` collaborator: production always supplies it (the one `spawn()` site), the passive-record construction moves entirely into `make-subagent.ts`, and `run()`'s two "not configured" throws vanish by construction.
84
+ - Outcome: state-machine and observer tests target `SubagentState` directly (no stub execution); `Subagent` is construct-complete with no optional execution fields and no runtime throws (grep-verifiable: no "not configured for execution" in `subagent.ts`); the record-vs-executor duality is resolved, not type-encoded.
85
+ - Scope boundary: stats stay on `SubagentState` for now.
86
+ Hoisting **metrics** into a projection over the child session's event stream and extracting **result delivery** (`notification`/`resultConsumed`) into its own domain are the remaining two of the four domains, deferred to a later phase per the refinement.
87
+ - Landed: `SubagentState` (`src/lifecycle/subagent-state.ts`) owns status/result/error/timestamps/stats and the transition/accumulation methods; `Subagent` delegates getters and `markX`/`incrementX`/`addUsage` to it.
88
+ `subscribeSubagentObserver` targets `SubagentState`, so observer and state-machine tests no longer stub execution.
89
+ `SubagentExecution` is a mandatory constructor collaborator (production wires it in the single `spawn()` site; passive records build via `make-subagent.ts`), and the two `run()` throws are gone.
90
+
91
+ ### Step 3 — Encapsulate run start and notification attachment on Subagent ([#374])
92
+
93
+ - Targets: `src/lifecycle/subagent.ts`, `src/lifecycle/subagent-manager.ts`, `test/tools/get-result-tool.test.ts`, `test/lifecycle/subagent-manager.test.ts`, `test/service/service-adapter.test.ts`, `test/observation/notification.test.ts`, `test/helpers/make-subagent.test.ts`, `test/lifecycle/subagent.test.ts`.
94
+ - Smell: Category C — output arguments: external writes to `record.promise` (2 production sites in `subagent-manager.ts`, 4 test sites) and `record.notification` (7 test sites; the production path was resolved in Step 2 — the constructor creates `notification` from `execution.parentSession?.toolCallId`, so Step 3's remaining work is making the field read-only and updating tests to supply it via `parentSession`).
95
+ - Change: add `Subagent.start()` that runs and stores its own promise (plus an awaitable accessor for `spawnAndWait`/`waitForAll`); make `promise` and `notification` externally read-only (private `_promise`/`_notification` fields backed by public getters); the abort-while-queued status guard folds into `start()`, removing the inline check from the limiter callback; tests use `createTestSubagent({ toolCallId })` or spawn with `parentSession.toolCallId` instead of post-construction assignment.
96
+ - Outcome: zero external writes to `Subagent` fields outside its own methods (grep-verifiable: `\.promise =` and `\.notification =` appear only inside `subagent.ts`); 6 new unit tests for `start()` behaviour; test count +6 (975 → 981).
97
+ - Landed: `Subagent.start()` (immediate path) and `Subagent.scheduleVia(schedule)` (queued path) own the promise and the shared `guardedRun()` status guard; `SubagentManager.spawn()` calls one or the other; `TestSubagentOptions.toolCallId` wires notification state via the constructor path.
98
+ - Correction (post-merge): the first cut used `void this.limiter.schedule(() => record.start())`, which left a queued agent's `promise` unset until its slot opened — silently regressing Step 1's "every spawned agent has a `promise` at spawn" invariant.
99
+ Fixed by inverting control: `scheduleVia` captures the limiter promise eagerly inside the agent (no external `.promise =` write), restoring the invariant.
100
+ Lesson: a step's acceptance criteria must include the cross-step invariants it could regress, not only its own grep-verifiable outcome.
101
+
102
+ ### Step 4 — Extract run-listener and workspace-bracket collaborators from Subagent ([#375])
103
+
104
+ - Targets: `src/lifecycle/subagent.ts` (455 LOC after Step 2 extracted SubagentState — still the largest source file).
105
+ - Smell: Category B (oversized class; per-run listener fields declared mid-class) and Category C (state owns its mutations: workspace dispose logic appears in `run()`'s catch, `completeRun`, and `failRun`).
106
+ - Change: extract a `RunListeners` object owning the observer-unsubscribe and signal-detach handles (`wireSignal`/`attachObserver`/`release`), and a `WorkspaceBracket` collaborator owning prepare/dispose-with-addendum, centralising the dispose logic.
107
+ - Outcome: `subagent.ts` ≤ 450 LOC; workspace disposal logic in exactly one place; listener handles no longer raw nullable fields.
108
+ - Landed: `RunListeners` (`src/lifecycle/run-listeners.ts`) owns the signal-detach and observer-unsub handles with a single `release()` call; `WorkspaceBracket` (`src/lifecycle/workspace-bracket.ts`) owns prepare-at-run-start and dispose-with-addendum — `completeRun` and `failRun` call `workspaceBracket.dispose(outcome)` and receive the addendum string (or `""`) without reaching through to the workspace object directly.
109
+ `Subagent.wireSignal`, `attachObserver`, and `releaseListeners` are removed.
110
+ `subagent.ts`: 488 → 448 LOC.
111
+ Test count: 982 → 994 (+12: 7 RunListeners + 13 WorkspaceBracket − 8 redundant Subagent listener tests).
112
+
113
+ ### Step 5 — Extract the manager observer from index.ts into a class ([#376])
114
+
115
+ - Targets: `src/index.ts` (inline `SubagentManagerObserver` literal, ~70 lines), new module under `src/observation/`.
116
+ - Smell: Category B/E — `index.ts` is the dominant churn hotspot (31.3, 91 commits); the literal mixes event emission, record persistence (`appendEntry`), and notification dispatch; principle 9 (state and behavior belong in classes, not closure-captured literals).
117
+ - Change: extract a class (e.g. `SubagentEventsObserver`) constructed with narrow deps (`emit`, `appendEntry`, the `NotificationSystem`).
118
+ - Outcome: `index.ts` < 170 lines; the observer's three concerns unit-tested directly without booting the extension.
119
+ - Landed: `src/observation/subagent-events-observer.ts` (new, 97 LOC); `index.ts` 226 → 177 lines; 60 → 61 source files; 994 → 1009 tests (+15 covering all four observer methods).
120
+
121
+ ### Step 6 — Split widget delegation out of SubagentRuntime ([#377])
122
+
123
+ - Targets: `src/runtime.ts`, `src/tools/agent-tool.ts` (`AgentToolRuntime`), `src/handlers/tool-start.ts` (`ToolStartRuntime`), `src/observation/notification.ts` (`NotificationManager` constructor), `src/ui/agent-widget.ts`, `src/index.ts`.
124
+ - Smell: Category C — relay-only dependency (five delegation methods that only forward to `widget`) and a post-construction `runtime.widget =` write violating principle 8.
125
+ - Constraint discovered in planning: "pass the handle directly to the consumers" is infeasible for `NotificationManager`, which is a _transitive dependency_ of the widget (`NotificationManager → widget → manager → observer → NotificationManager`).
126
+ The `runtime.widget` lazy field exists to break exactly this cycle; removing it forces the one late seam to move, and the operator's principles (no setters, instantiate ready-to-work) rule out relocating it to a setter or forward-ref `let`.
127
+ - Change (tidy-first): first dissolve the cycle by giving `AgentWidget.update()` self-seeding of `finishedTurnAge` for finished agents it observes via `listAgents()`, then drop the `markFinished`/`updateWidget` callbacks from `NotificationManager` (it keeps `agentActivity.delete` + nudge scheduling).
128
+ With the cycle gone, the widget is constructed as a `const` after the manager and injected directly into `AgentTool` and `ToolStartHandler`; the `widget` field, the five relay methods, and the post-construction write delete cleanly, and `AgentToolRuntime` narrows to its context-query slice.
129
+ - Outcome: `SubagentRuntime` has zero widget knowledge; no post-construction field writes in `index.ts`; the construction graph is a cycle-free DAG (`notifications → observer → manager → widget → {AgentTool, ToolStartHandler}`).
130
+ - Landed: `AgentWidget.seedFinishedAgents` owns completion detection; `NotificationManager` has no widget dependency (2-arg constructor); `AgentToolWidget` (in `agent-tool.ts`) and `ToolStartWidget` (in `tool-start.ts`) are narrow per-consumer interfaces the real widget satisfies structurally; tool fixtures stub a `widget` separate from the narrowed `runtime` mock.
131
+ Behavior-preserving: the widget timer runs through every background completion, so self-seeding lands ≤80ms later within the same turn (linger is turn-based).
132
+ Test count: 1009 → 1005 (+3 widget self-seed tests, −7 removed relay/field tests).
133
+
134
+ ### Step 7 — Consolidate lifecycle test fixtures ([#378])
135
+
136
+ - Targets: `test/lifecycle/subagent-manager.test.ts`, `test/lifecycle/subagent-session.test.ts`, `test/lifecycle/create-subagent-session.test.ts`, `test/lifecycle/create-subagent-session-extension-tools.test.ts` (deleted), `test/helpers/subagent-session-io.ts`.
137
+ - Smell: Category D — at planning time fallow reported four clone families across the lifecycle tests (the issue's "five" predated Steps 1–6, which renamed the queue and dissolved the `subagent.test.ts`/`concurrency-limiter.test.ts` families).
138
+ - Change: extract the repeated spawn/run/factory arrangements into shared helpers, migrating incrementally (lift-and-shift, never a single-step rewrite of a large test file).
139
+ - Outcome: package test duplication below 600 lines; lifecycle clone families reduced, with the residual deliberately left as the visible test subject (see Landed).
140
+ - Landed: added a shared `createFactorySession` mock-session builder (`test/helpers/subagent-session-io.ts`); grouped the `createSubagentSession` arrange into describe-scoped `beforeEach` hooks with the `createSubagentSession(...)` act kept explicit per test (AAA); extracted a `programTurns` arrange helper for the turn-limit tests; consolidated the manager spawn/queued-pair arrangements; and folded `create-subagent-session-extension-tools.test.ts` (4 post-bind guard tests) into `create-subagent-session.test.ts`, deleting the file and its duplicated scaffolding.
141
+ Package test duplication: 669 → 512 lines; test files 64 → 63; test count 1005 → 1010 (+5 `createFactorySession` self-tests).
142
+ Two lifecycle clone families remain (`create-subagent-session.test.ts` ~29 lines, `subagent-manager.test.ts` ~23 lines): both are the repeated `await createSubagentSession(...)` / `spawn(...)` **act** with test-specific arrange, intentionally not extracted because hiding the system-under-test behind a helper is the wrong abstraction for test code (Sandi Metz: "duplication is far cheaper than the wrong abstraction").
143
+ Lesson: the original "families ≤ 1" target was a weak signal for _test_ code — an early act-wrapping helper that hit the metric was reverted in favour of the AAA structure above; the metric was relaxed deliberately.
144
+ The three overlapping session-mock builders this surfaced were resolved in [#412] by targeted reuse: `createFactorySession` now spreads the shared `createMockSession` core (inheriting the `messages`/`subscribe`/`emit`/`steer`/`dispose`/`sessionManager` base) and layers only the factory facet on top.
145
+ `createSubagentSessionStub` was left as-is — it already composes `createMockSession` as its wrapped `.session`, so its overlap is intrinsic delegation glue rather than duplication.
146
+
147
+ ### Step 8 — Consolidate UI and tools test fixtures ([#379])
148
+
149
+ - Targets: `test/ui/agent-creation-wizard.test.ts`, `test/ui/agent-config-editor.test.ts`, `test/ui/ui-observer.test.ts`, `test/tools/foreground-runner.test.ts`, `test/tools/background-spawner.test.ts`, `test/session/session-config.test.ts`, and `test/service/service-adapter.test.ts` (a seventh non-lifecycle family fallow reports; added to scope in planning).
150
+ - Smell: Category D — remaining clone families outside the lifecycle tree.
151
+ - Change: extract per-file repeated arrangements into local helpers or `test/helpers/` where shared across files.
152
+ - Outcome: each named family's extractable arrange consolidated; resulting fallow figures reported (the roadmap's original "44 → ≤ 25 groups; ≤ 0.6%" target predated Steps 1–7 and used a different baseline, so acceptance was "consolidate and measure," not a binding number).
153
+ - Landed: promoted the cross-file `ResolvedSpawnConfig` builder to `test/helpers/make-spawn-config.ts` (`createResolvedSpawnConfig`, flat options that derive the mirrored `agentInvocation`/`presentation.detailBase` regions) with a 5-test companion; consolidated the remaining six families with file-local, value-returning arrange builders and `beforeEach` setup (`spawnAndWaitRegistering`, `ui-observer` session/tracker `beforeEach` with `vi.fn()` onUpdate counters, `generateUI`/`manualUI`/`withInputs`, `disabledConfig`, `exploreConfig`, `createManagerStub`).
154
+ Package test duplication: 512 → 355 lines; clone groups 32 → 24; duplication 2.49% → 1.73%; test files 63 → 64; test count 1010 → 1015 (+5 `createResolvedSpawnConfig` self-tests).
155
+ Three multi-group families remain: the two lifecycle residuals from Step 7 (`create-subagent-session.test.ts`, `subagent-manager.test.ts`) and `agent-config-editor.test.ts`, whose residual clones are the repeated `await editor.showAgentDetail(...)` **act** plus its `setupDetail` arrange and `ui.select.mock.calls` menu assertion — left inline because wrapping the system-under-test is the wrong abstraction for test code (Step 7 lesson; Sandi Metz: "duplication is far cheaper than the wrong abstraction").
156
+
157
+ ### Step 9 — Resolve the cross-package settings-loader duplication ([#380])
158
+
159
+ - Targets: `src/settings.ts:198-211`, `packages/pi-subagents-worktrees/src/config.ts:51-73`.
160
+ - Smell: Category A — 23-line production clone: the layered global/project JSON read-sanitize-warn-merge loader.
161
+ - Decision: **extract** — `loadLayeredSettings<T>` added to `src/layered-settings.ts` and published via the `@gotgenes/pi-subagents/settings` dedicated subpath export (`dist/settings.d.ts`); `loadSettings` in `settings.ts` delegates to it internally.
162
+ Option 2 (fallow suppression) was rejected; the public-API cost is accepted as the right long-term tradeoff for a shared convention in the `@gotgenes/pi-*` family.
163
+ Worktrees migration (swap `config.ts`'s inlined loader for the shared helper) is deferred to [#415] — worktrees resolves pi-subagents from the registry, so it must wait for a published release carrying the helper.
164
+ - Outcome: `pnpm fallow:dupes --skip-local` no longer reports the `settings.ts` ↔ `config.ts` pair.
165
+ The parametrised helper's token sequence diverged sufficiently that the contiguous identical run dropped below the reporting threshold even before the worktrees migration lands.
166
+ Definitive semantic elimination completed when worktrees adopted the helper in [#415].
167
+
168
+ ## Step dependency diagram
169
+
170
+ ```mermaid
171
+ flowchart TB
172
+ S1["Step 1 (#381)<br/>ConcurrencyLimiter replacement"]
173
+ S2["Step 2 (#373)<br/>SubagentState extraction"]
174
+ S3["Step 3 (#374)<br/>Encapsulate start + notification"]
175
+ S4["Step 4 (#375)<br/>Run collaborators extraction"]
176
+ S5["Step 5 (#376)<br/>Observer class from index.ts"]
177
+ S6["Step 6 (#377)<br/>Widget handle out of runtime"]
178
+ S7["Step 7 (#378)<br/>Lifecycle test fixtures"]
179
+ S8["Step 8 (#379)<br/>UI/tools test fixtures"]
180
+ S9["Step 9 (#380)<br/>Settings-loader duplication"]
181
+
182
+ S1 --> S3
183
+ S2 --> S3
184
+ S3 --> S4
185
+ S4 --> S7
186
+ S5 --> S6
187
+ ```
188
+
189
+ Steps 8 and 9 have no dependencies and can run at any point.
190
+
191
+ ## Tracks
192
+
193
+ | Track | Steps | Theme |
194
+ | ----------------------------- | ------------- | ---------------------------------------------------------------------------------- |
195
+ | A — Subagent constructibility | 2 → 3 → 4 → 7 | Construct complete; encapsulate run state; then consolidate the tests that churned |
196
+ | B — Wiring debt | 1, 5 → 6 | Shrink index.ts; eliminate forward references and relay delegation |
197
+ | C — Test hygiene | 8 | Clone families outside the lifecycle tree |
198
+ | D — Duplication policy | 9 | Cross-package clone decision |
199
+
200
+ Tracks A and B intersect only at Step 3 (which needs Step 1's queue relocation); otherwise they proceed in parallel.
201
+ Tracks C and D are fully independent.
202
+
203
+ [#277]: https://github.com/gotgenes/pi-packages/issues/277
204
+ [#373]: https://github.com/gotgenes/pi-packages/issues/373
205
+ [#374]: https://github.com/gotgenes/pi-packages/issues/374
206
+ [#375]: https://github.com/gotgenes/pi-packages/issues/375
207
+ [#376]: https://github.com/gotgenes/pi-packages/issues/376
208
+ [#377]: https://github.com/gotgenes/pi-packages/issues/377
209
+ [#378]: https://github.com/gotgenes/pi-packages/issues/378
210
+ [#379]: https://github.com/gotgenes/pi-packages/issues/379
211
+ [#380]: https://github.com/gotgenes/pi-packages/issues/380
212
+ [#381]: https://github.com/gotgenes/pi-packages/issues/381
213
+ [#412]: https://github.com/gotgenes/pi-packages/issues/412
214
+ [#415]: https://github.com/gotgenes/pi-packages/issues/415
@@ -0,0 +1,146 @@
1
+ ---
2
+ issue: 412
3
+ issue_title: "Unify the three overlapping session-mock builders in pi-subagents tests"
4
+ ---
5
+
6
+ # Unify the overlapping session-mock builders (targeted reuse)
7
+
8
+ ## Problem Statement
9
+
10
+ `test/helpers/` carries three session-mock builders whose shapes overlap on four fields — `messages`, `subscribe`, `dispose`, `steer`:
11
+
12
+ - `createMockSession` (`mock-session.ts`) — a subscribable event bus (`messages`, `subscribe`, `emit`, `dispose`, `steer`, `sessionManager`).
13
+ - `createSubagentSessionStub` (`mock-session.ts`) — a born-complete `SubagentSession` wrapper (turn-loop spies plus `messages`/`subscribe`/`dispose`/`steer` delegation).
14
+ - `createFactorySession` (`subagent-session-io.ts`) — the `createSubagentSession`-factory session (`bindExtensions`, `setActiveToolsByName`, `getActiveToolNames`, plus `messages`/`subscribe`/`prompt`/`abort`/`steer`/`dispose`).
15
+
16
+ Issue [#412] asks whether to unify the three behind one configurable builder.
17
+ The issue itself flags the risk — "trading three small honest stubs for one over-parameterized factory" — and quotes Sandi Metz: "duplication is far cheaper than the wrong abstraction."
18
+
19
+ ## Goals
20
+
21
+ - Remove the one genuine independent redeclaration of the four shared base fields, which lives in `createFactorySession`.
22
+ - Make `createMockSession` the explicit shared core, and have `createFactorySession` layer its factory facet on top of that core (working event bus as the core default).
23
+ - Keep the change test-only and behavior-preserving for production code.
24
+
25
+ This change is **not breaking** — it touches only `test/helpers/` and a doc note; no published surface, default, or runtime behavior changes.
26
+
27
+ ## Non-Goals
28
+
29
+ - The full composable-factory design from the issue's "Proposed change" (one `createSessionMock()` with opt-in `withTurnLoop()` / `withBindFacet()` extensions).
30
+ Rejected at the `Decide` gate (operator chose targeted reuse) because it forces a multi-facet parameterized factory the issue itself warns against.
31
+ - Changing `createSubagentSessionStub`.
32
+ It already **composes** a `createMockSession` internally and delegates `steer`/`dispose`/`subscribe`/`messages` to it, so its overlap is intrinsic delegation glue, not duplication.
33
+ - Renaming any builder or changing any builder's public signature.
34
+ - Touching production `src/` modules.
35
+
36
+ ## Background
37
+
38
+ Relevant modules:
39
+
40
+ - `test/helpers/mock-session.ts` — defines `createMockSession` (the event-bus core), `createSubagentSessionStub` (the `SubagentSession` wrapper, which calls `createMockSession()` as its default `session`), plus the `toAgentSession`/`toSubagentSession` casts.
41
+ - `test/helpers/subagent-session-io.ts` — defines `createFactorySession` (and the IO/lookup/lifecycle stubs).
42
+ `createFactorySession` independently redeclares `messages: []` and an **inert** `subscribe: vi.fn(() => () => {})`, plus its own `steer`/`dispose`, then adds the factory facet.
43
+ - `src/lifecycle/create-subagent-session.ts` — production consumer of the raw factory session: calls `getActiveToolNames()`, `setActiveToolsByName()`, `bindExtensions()`, and `dispose()`.
44
+ - `src/lifecycle/subagent-session.ts` — wraps the raw session and calls `session.subscribe(...)`, `session.prompt(...)`, `session.abort(...)`, `session.steer(...)`, `session.dispose()`, `session.messages`.
45
+
46
+ Structural reading (per the `code-design` "structural reasons before extracting duplication" heuristic): the three builders sit on two axes — AgentSession-vs-`SubagentSession` (type) and event-bus-vs-factory (facet).
47
+ `createMockSession` and `createFactorySession` are both AgentSession stubs; `createSubagentSessionStub` is the `SubagentSession` wrapper.
48
+ The only honest, non-delegation duplication is `createFactorySession`'s independent base, so that is the entire target.
49
+
50
+ AGENTS.md constraint: this is the `@gotgenes/pi-subagents` package; run package-scoped scripts via `pnpm --filter @gotgenes/pi-subagents exec vitest run` and type-check with `pnpm run check`.
51
+
52
+ ## Design Overview
53
+
54
+ `createMockSession` is already the core shape, with the working event bus the operator chose as the default.
55
+ `createFactorySession` is rebuilt to spread that core and add only the factory facet:
56
+
57
+ ```typescript
58
+ export function createFactorySession(options: FactorySessionOptions = {}) {
59
+ const before = options.toolsBeforeBind ?? ["read"];
60
+ const after = options.toolsAfterBind ?? before;
61
+ let bound = false;
62
+ return {
63
+ ...createMockSession(), // working event bus + messages/steer/dispose/sessionManager core
64
+ prompt: vi.fn().mockResolvedValue(undefined),
65
+ abort: vi.fn(),
66
+ getActiveToolNames: vi.fn(() => (bound ? after : before)),
67
+ setActiveToolsByName: vi.fn(),
68
+ bindExtensions: vi.fn(async () => {
69
+ bound = true;
70
+ }),
71
+ };
72
+ }
73
+ ```
74
+
75
+ Mock-typing is preserved through the spread.
76
+ `createMockSession()` returns `MockSession & Record<string, unknown>`; the explicit facet properties added in the literal intersect with the `Record`'s `unknown` index signature, and `unknown & Mock<...>` narrows to `Mock<...>`.
77
+ Verified with a throwaway `tsc --noEmit` probe: `session.setActiveToolsByName.mock.calls[0][0]` type-checks after the spread.
78
+
79
+ Facet semantics are unchanged: `getActiveToolNames` closes over a `bound` flag flipped by `bindExtensions`, returning `before` until bind and `after` after.
80
+
81
+ Behavioral delta (intentional, per the operator's "working bus is the core default" choice): `createFactorySession`'s `subscribe` changes from inert (`() => () => {}`) to the core's real bus, and the session gains `emit` and `sessionManager`.
82
+ No factory test emits events, and production `subagent-session.ts` only registers a subscriber (never emits during these tests), so the change is inert in practice — the disposer now truly unregisters instead of being a no-op.
83
+
84
+ Edge cases:
85
+
86
+ - `create-subagent-session.test.ts:194` asserts `expect(session.dispose).toHaveBeenCalledOnce()` — `dispose` remains a `vi.fn()` spy supplied by the core, so the assertion holds.
87
+ - The factory session is returned through `io.createSession.mockResolvedValue({ session })`, not assigned as a fresh literal to a typed slot, so the extra `emit`/`sessionManager` fields raise no excess-property error.
88
+
89
+ ## Module-Level Changes
90
+
91
+ - `test/helpers/subagent-session-io.ts` — rewrite `createFactorySession` to spread `...createMockSession()` and add the factory facet; import `createMockSession` from `#test/helpers/mock-session`.
92
+ Update its docstring to state it layers the factory facet on the shared `createMockSession` core.
93
+ - `test/helpers/subagent-session-io.test.ts` — add one self-test asserting `createFactorySession` exposes the core's working event bus (subscribe a fn, `emit`, assert receipt).
94
+ Existing `createFactorySession` self-tests remain unchanged.
95
+ - `test/helpers/mock-session.ts` — docstring touch-up only: note that `createMockSession` is the shared session-mock core that `createFactorySession` builds on (no code change).
96
+ - `docs/architecture/architecture.md` — update the Phase 17 Step 7 "Landed" closing sentence (currently "The three overlapping session-mock builders this surfaced are tracked separately [#412].") to record the resolution: targeted reuse — `createFactorySession` now layers on the `createMockSession` core; `createSubagentSessionStub`'s overlap is intrinsic delegation and was left as-is.
97
+
98
+ Grep confirmation (no other consumers break): `createFactorySession` is referenced only in `create-subagent-session.test.ts` and its own self-test/source; `.pi/skills/package-*/SKILL.md` mention none of the three builders by name; the `[#412]:` reference-link definition already exists at the bottom of `architecture.md`.
99
+
100
+ ## Test Impact Analysis
101
+
102
+ 1. **New tests enabled** — one self-test for `createFactorySession`'s newly-inherited working event bus (subscribe/emit), previously impossible because its `subscribe` was inert.
103
+ 2. **Tests made redundant** — none.
104
+ The existing `createFactorySession` self-tests exercise the factory facet (the eight methods, the before/after bind flip), which is unchanged and still its own concern.
105
+ 3. **Tests that must stay as-is** — the `createMockSession` event-bus self-tests in `mock-session.test.ts` (they pin the core the factory now reuses); the `createFactorySession` facet self-tests; `create-subagent-session.test.ts` lifecycle tests (they exercise the production consumer of the factory session).
106
+
107
+ ## Invariants at risk
108
+
109
+ The change touches the lifecycle test fixtures refactored in Phase 17 Step 7 ([#378]).
110
+ That step's documented `Outcome`/`Landed` invariants and the tests that pin them:
111
+
112
+ - "AAA structure: `createSubagentSession(...)` act kept explicit per test" — pinned by `create-subagent-session.test.ts` (the act calls remain in each `it`; this plan does not touch them).
113
+ - "`createFactorySession` flips `getActiveToolNames` from before-bind to after-bind" — pinned by the existing `subagent-session-io.test.ts` facet self-tests, which stay green through the rewrite.
114
+
115
+ No earlier outcome is regressed: the rewrite preserves every facet assertion and only adds the event-bus surface.
116
+
117
+ ## TDD Order
118
+
119
+ 1. **`refactor(test):` fold `createFactorySession` onto the shared core** (#412).
120
+ - Red: add `it("exposes the core's working event bus (subscribe/emit)")` to the `createFactorySession` describe in `subagent-session-io.test.ts` — fails to compile/pass because the current return has no `emit` and an inert `subscribe`.
121
+ - Green: rewrite `createFactorySession` to `{ ...createMockSession(), <factory facet> }`; import `createMockSession`; refresh the docstrings in both helper files.
122
+ - Verify: `pnpm --filter @gotgenes/pi-subagents exec vitest run test/helpers/subagent-session-io.test.ts test/helpers/mock-session.test.ts test/lifecycle/create-subagent-session.test.ts`, then `pnpm run check` (spread-typing) and the full package suite.
123
+ - Commit: `refactor(test): build createFactorySession on the shared createMockSession core (#412)`.
124
+ 2. **`docs:` record the resolution in the architecture doc** (#412).
125
+ - Update the Phase 17 Step 7 closing sentence in `docs/architecture/architecture.md` to describe the targeted-reuse outcome (no roadmap-table or metric-row change required; rerun `pnpm fallow:dupes` only to report, not to gate).
126
+ - Commit: `docs: record session-mock builder unification outcome (#412)`.
127
+
128
+ Both steps are test/doc-only with single, isolated surfaces, so no lift-and-shift staging is needed.
129
+
130
+ ## Risks and Mitigations
131
+
132
+ - **Risk:** the spread erases `Mock<...>` typing on the facet methods, breaking `.mock.calls` assertions.
133
+ **Mitigation:** verified with a throwaway `tsc --noEmit` probe that `setActiveToolsByName.mock.calls[0][0]` type-checks after the spread (intersection narrows `unknown & Mock<...>` to `Mock<...>`); Step 1 reruns `pnpm run check`.
134
+ - **Risk:** the inert→working `subscribe` change alters a lifecycle test's behavior.
135
+ **Mitigation:** no factory test emits events; production only registers a subscriber during these tests; `session.dispose` remains a spy.
136
+ Step 1's verify command runs the lifecycle suite.
137
+ - **Risk:** the extra `emit`/`sessionManager` fields trip excess-property checks at a consumer.
138
+ **Mitigation:** the factory session flows through `mockResolvedValue({ session })`, not a fresh typed literal, so excess-property checking does not apply.
139
+
140
+ ## Open Questions
141
+
142
+ None.
143
+ The operator confirmed targeted reuse with a working-bus core default at the `Decide` gate.
144
+
145
+ [#378]: https://github.com/gotgenes/pi-packages/issues/378
146
+ [#412]: https://github.com/gotgenes/pi-packages/issues/412
@@ -0,0 +1,228 @@
1
+ ---
2
+ issue: 420
3
+ issue_title: "pi-subagents: fold subagent run metrics and live activity onto the core record"
4
+ ---
5
+
6
+ # Fold subagent run metrics and live activity onto the core record
7
+
8
+ ## Problem Statement
9
+
10
+ The run-metric domain is split across two parallel observers, each subscribed to every child session.
11
+ `record-observer` accumulates `SubagentState` (tool uses, lifetime usage, compaction count); `ui-observer` accumulates a separate `AgentActivityTracker` (active tools, response text, turn count).
12
+ `turnCount` is a genuine run metric that lives only in the UI tracker, so `notification.ts` and the foreground result text reach into the tracker to recover it (Phase 18 Finding 4).
13
+ Consolidating the live-activity fields onto the single owned run-state value object (`SubagentState`) removes the duplication and is the first move in disentangling the UI's activity tier from the core.
14
+
15
+ This is Phase 18 Step 1 of the architecture roadmap (`docs/architecture/architecture.md`).
16
+ It is a **pure addition, tidy-first**: `AgentActivityTracker` and `ui-observer` still exist, both observers still run, and nothing reads the new getters yet.
17
+ The reader migration is Step 2 ([#421]) and the tracker deletion is Step 3 ([#422]).
18
+
19
+ ## Goals
20
+
21
+ - Extend `SubagentState` (`src/lifecycle/subagent-state.ts`) with the live-activity fields — `turnCount`, `activeTools`, `responseText` — and their transition methods, behind read-only getters, owning all their own mutations (no field written from outside).
22
+ - Have `subscribeSubagentObserver` (`src/observation/record-observer.ts`) handle `turn_end`, `tool_execution_start`, `message_start`, and `message_update` (text_delta) alongside the events it already handles, mutating the same `SubagentState`.
23
+ - Add read-only `turnCount` / `maxTurns` / `activeTools` / `responseText` getters to `Subagent` (`src/lifecycle/subagent.ts`); the first three plus `responseText` delegate to the owned state, `maxTurns` delegates to `this.execution.maxTurns` (the effective max-turns already wired at spawn).
24
+ - Preserve the `AgentActivityTracker` semantics the readers depend on so Step 2 is a clean swap: `turnCount` starts at 1 and increments on each `turn_end`; `activeTools` is a `ReadonlyMap<string, string>` keyed `name_seq` to disambiguate concurrent same-name tools; `responseText` resets at each `message_start` and appends each text delta.
25
+
26
+ This change is **not breaking**.
27
+ The published service surface (`src/service/service.ts`) exposes `SubagentRecord`/`SubagentStatus`, not `SubagentState` or the `Subagent` constructor; the change only adds getters and value-object fields.
28
+ No observable behavior changes — both observers still run and no consumer reads the new getters.
29
+
30
+ ## Non-Goals
31
+
32
+ - Migrating any reader (`widget-renderer`, `conversation-viewer`, `agent-menu`, `foreground-runner`, `notification`) off `AgentActivityTracker` — Step 2 ([#421]).
33
+ - Deleting `AgentActivityTracker` / `ui-observer` or dropping `SubagentRuntime.agentActivity` — Step 3 ([#422]).
34
+ - Removing the second (`ui-observer`) session subscription — Step 3.
35
+ - Adding a `session` reference to `SubagentState` — the tracker's `_session`/`setSession` exists only for the UI's polling reads and is migrated/removed in Steps 2–3, not folded here.
36
+ - The deeper "metrics as a pure observer projection" target — deliberately deferred per the architecture doc's first-principles refinement; folding onto the record (consistent with Phase 17's `SubagentState`) avoids inventing an asynchronous-observation seam.
37
+ - Resetting the new live-activity fields in `resetForResume` — see Design Overview; left unchanged to preserve tracker parity.
38
+
39
+ ## Background
40
+
41
+ Relevant modules and how they relate:
42
+
43
+ - `src/lifecycle/subagent-state.ts` — the value object under change.
44
+ Already owns status/result/error/timestamps and stats (`toolUses`, `lifetimeUsage`, `compactionCount`) behind getters, mutated only via transition/accumulation methods (`markRunning`, `incrementToolUses`, `addUsage`, `incrementCompactions`).
45
+ Pure value object — imports only `LifetimeUsage`/`addUsage` from `usage.ts`; no Pi SDK imports (keep it that way).
46
+ - `src/observation/record-observer.ts` — `subscribeSubagentObserver(session, state, { onCompact })` subscribes once and currently handles `tool_execution_end`, `message_end` (assistant), and `compaction_end`.
47
+ - `src/ui/agent-activity-tracker.ts` — the parallel `AgentActivityTracker` whose live fields (`_activeTools`, `_toolKeySeq`, `_responseText`, `_turnCount` starting at 1) and transition methods (`onToolStart`, `onToolDone`, `onMessageStart`, `onMessageUpdate`, `onTurnEnd`) are the behavior being folded onto `SubagentState`.
48
+ It also carries `_session`/`setSession` and `maxTurns` — `maxTurns` is folded (via `execution`), `_session` is not.
49
+ - `src/ui/ui-observer.ts` — `subscribeUIObserver` maps `tool_execution_start`/`tool_execution_end`/`message_start`/`message_update`/`turn_end` onto the tracker.
50
+ The event-shape reference for the new `record-observer` branches (`event.toolName`; `event.assistantMessageEvent.type === "text_delta"` → `event.assistantMessageEvent.delta`).
51
+ - `src/lifecycle/subagent.ts` — holds one private `SubagentState`; its getters and mutation methods are one-line delegations.
52
+ `run()` and `resume()` both attach `subscribeSubagentObserver(session, this.state, …)`.
53
+ `this.execution.maxTurns` is the effective max-turns (both spawners pass `execution.effectiveMaxTurns` as `options.maxTurns`, threaded into `SubagentExecution.maxTurns` by `SubagentManager.spawn`).
54
+ - `src/tools/spawn-config.ts` — `effectiveMaxTurns = normalizeMaxTurns(resolvedConfig.maxTurns ?? settings.defaultMaxTurns)`.
55
+ `AgentActivityTracker` is constructed with `effectiveMaxTurns`; `SubagentExecution.maxTurns` carries the same value, so the new `maxTurns` getter matches the tracker.
56
+
57
+ AGENTS.md / code-design constraints that apply:
58
+
59
+ - Keep Pi SDK imports out of `SubagentState` — the new fields and methods stay SDK-free.
60
+ - The new `record-observer` branches consume SDK event payloads via the existing lean structural reads (`event.toolName`, `event.assistantMessageEvent`) — no new SDK type imports needed.
61
+ - `SubagentState` and `Subagent` are reached through the `types.ts`/internal barrels; adding getters does not change any export.
62
+
63
+ ## Design Overview
64
+
65
+ ### `SubagentState` additions
66
+
67
+ Three new private fields with read-only getters and five transition methods, mirroring the `AgentActivityTracker` semantics exactly so the Step 2 swap is byte-equivalent:
68
+
69
+ ```typescript
70
+ // Live activity — accumulated via transition methods, readable via getters
71
+ private _turnCount = 1;
72
+ get turnCount(): number { return this._turnCount; }
73
+
74
+ private _activeTools = new Map<string, string>();
75
+ get activeTools(): ReadonlyMap<string, string> { return this._activeTools; }
76
+
77
+ private _toolKeySeq = 0;
78
+
79
+ private _responseText = "";
80
+ get responseText(): string { return this._responseText; }
81
+
82
+ /** Record a turn boundary. Called by record-observer on turn_end. */
83
+ incrementTurnCount(): void { this._turnCount++; }
84
+
85
+ /** Record a tool starting. Called by record-observer on tool_execution_start. */
86
+ addActiveTool(toolName: string): void {
87
+ this._activeTools.set(toolName + "_" + (++this._toolKeySeq), toolName);
88
+ }
89
+
90
+ /** Remove one active tool by name (first match). Called by record-observer on tool_execution_end. */
91
+ removeActiveTool(toolName: string): void {
92
+ for (const [key, name] of this._activeTools) {
93
+ if (name === toolName) { this._activeTools.delete(key); break; }
94
+ }
95
+ }
96
+
97
+ /** Reset the current response text. Called by record-observer on message_start. */
98
+ resetResponseText(): void { this._responseText = ""; }
99
+
100
+ /** Append a text delta to the current response text. Called by record-observer on message_update. */
101
+ appendResponseText(delta: string): void { this._responseText += delta; }
102
+ ```
103
+
104
+ The `_toolKeySeq` counter has no getter — it is internal disambiguation state, exactly as in the tracker.
105
+ `turnCount` initializes to `1` (not `0`) to match the tracker; the readers (`notification.ts` `turnCount ?? 0`, `result-renderer.ts` `turnCount > 0`) already assume the at-least-1 invariant once an agent exists.
106
+
107
+ `resetForResume` is left **unchanged**.
108
+ The tracker is not reset on resume today (the resume path in `agent-tool.ts` does not reconstruct it), so the record-observer accumulating onto the surviving `SubagentState` across a resume preserves parity (turn count continues, response text carries the last message, active tools are empty after a completed run).
109
+ Touching `resetForResume` here would be a behavior change, contradicting the pure-addition contract.
110
+ Flagged in Risks for Step 2 to revisit if a reader demands a reset.
111
+
112
+ ### `record-observer` additions
113
+
114
+ Three new branches alongside the existing ones, reading the same SDK payload shapes `ui-observer` reads:
115
+
116
+ ```typescript
117
+ if (event.type === "tool_execution_start") state.addActiveTool(event.toolName);
118
+ if (event.type === "turn_end") state.incrementTurnCount();
119
+ if (event.type === "message_start") state.resetResponseText();
120
+ if (
121
+ event.type === "message_update" &&
122
+ event.assistantMessageEvent.type === "text_delta"
123
+ ) {
124
+ state.appendResponseText(event.assistantMessageEvent.delta);
125
+ }
126
+ ```
127
+
128
+ The existing `tool_execution_end` branch already fires `state.incrementToolUses()`; it gains a paired `state.removeActiveTool(event.toolName)` so the active-tool map drains symmetrically (the tracker did this in `onToolDone`).
129
+ The observer takes no new options and emits no callbacks for these branches — the live fields are read-only state, polled by consumers later.
130
+
131
+ ### `Subagent` getters
132
+
133
+ Four one-line getters added next to the existing `toolUses`/`lifetimeUsage`/`compactionCount` delegations:
134
+
135
+ ```typescript
136
+ get turnCount(): number { return this.state.turnCount; }
137
+ get activeTools(): ReadonlyMap<string, string> { return this.state.activeTools; }
138
+ get responseText(): string { return this.state.responseText; }
139
+ get maxTurns(): number | undefined { return this.execution.maxTurns; }
140
+ ```
141
+
142
+ `maxTurns` is the one getter that does not delegate to `state` — max-turns is execution config, not accumulated run state, and `this.execution.maxTurns` already holds the effective value (`effectiveMaxTurns`) the tracker was constructed with.
143
+
144
+ ### Extracted-module interaction check (record-observer ↔ SubagentState)
145
+
146
+ The observer already targets `SubagentState` directly (Phase 17) and carries no `Subagent` dependency.
147
+ The new branches are Tell-Don't-Ask: the observer tells the state to mutate (`addActiveTool`, `incrementTurnCount`, …) and never reads-then-writes a field on the state.
148
+ No output arguments, no reach-through, no reverse search — the same shape as the existing three branches.
149
+
150
+ ## Module-Level Changes
151
+
152
+ - `src/lifecycle/subagent-state.ts` — add `_turnCount`/`_activeTools`/`_toolKeySeq`/`_responseText` fields, their getters (`turnCount`, `activeTools`, `responseText`), and five transition methods (`incrementTurnCount`, `addActiveTool`, `removeActiveTool`, `resetResponseText`, `appendResponseText`).
153
+ Update the file header doc comment to note the live-activity fields.
154
+ - `src/observation/record-observer.ts` — add `tool_execution_start`, `turn_end`, `message_start`, `message_update` (text_delta) branches; add `removeActiveTool` to the existing `tool_execution_end` branch.
155
+ Update the JSDoc "Handles:" list.
156
+ - `src/lifecycle/subagent.ts` — add `turnCount`, `activeTools`, `responseText`, `maxTurns` getters.
157
+ - `src/lifecycle/subagent-state.ts` is referenced in `.pi/skills/package-pi-subagents/SKILL.md` and `docs/architecture/architecture.md` only as a value object / domain entry; no symbol is removed or renamed, so no skill/doc prose update is required.
158
+ The architecture doc's Phase 18 Step 1 entry already describes this work — no edit needed.
159
+
160
+ No existing export is removed or renamed; no call site changes.
161
+ The `AgentActivityTracker` / `ui-observer` modules are untouched (they are deleted in Step 3).
162
+
163
+ ## Test Impact Analysis
164
+
165
+ This is an additive change, so the analysis is mostly about new coverage:
166
+
167
+ 1. **New unit tests enabled.**
168
+ `subagent-state.test.ts` gains direct tests for the new fields/methods (turn count starts at 1 and increments; active-tool add/remove with concurrent same-name handling; response-text reset/append) — testable on a bare `SubagentState` with no session or executor.
169
+ `record-observer.test.ts` gains tests that emitting `tool_execution_start`/`turn_end`/`message_start`/`message_update`/`tool_execution_end` drives the new state fields, using the existing `createMockSession` `emit` harness.
170
+ 2. **Tests that become redundant.**
171
+ None yet — `ui-observer.test.ts` and the tracker tests still pin live behavior until Step 3 deletes those modules.
172
+ The new tests overlap conceptually with `ui-observer.test.ts` but exercise a different module (`record-observer` → `SubagentState`), so both stay until the tracker is removed.
173
+ 3. **Tests that must stay as-is.**
174
+ All existing `record-observer.test.ts` cases (tool uses, lifetime usage, compaction) genuinely exercise the observer→state path being extended and must keep passing unchanged.
175
+ `subagent.test.ts` getter/delegation tests stay; new getter tests are added beside them.
176
+
177
+ ## Invariants at risk
178
+
179
+ Phase 17 Step 1 ([#373]) established that `SubagentState` owns all its own mutations (no field written from outside) and that `record-observer` targets `SubagentState` directly with no `Subagent` dependency.
180
+
181
+ - **Invariant: the value object owns every mutation** — pinned by the encapsulation tests in `subagent-state.test.ts` (fields are read-only getters; mutation only via methods).
182
+ The new fields follow the same private-field/getter/transition-method shape; new tests assert mutation flows only through the new methods.
183
+ - **Invariant: `record-observer` carries no `Subagent` dependency** — pinned by `record-observer.test.ts` constructing a bare `SubagentState` (never a `Subagent`).
184
+ The new branches keep that property (they call `state.*` only); the existing test setup (`makeState()` returns `new SubagentState(...)`) already guards it.
185
+
186
+ No earlier phase step's `Outcome:` is regressed — this step only adds to the record both prior steps consolidated onto.
187
+
188
+ ## TDD Order
189
+
190
+ 1. **`SubagentState` live-activity fields and transition methods.**
191
+ Surface: `test/lifecycle/subagent-state.test.ts`.
192
+ Red→green: new `describe` blocks for turn count (defaults to 1, `incrementTurnCount` increments), active tools (`addActiveTool` adds with unique keys for concurrent same-name tools, `removeActiveTool` removes first match and is a no-op when absent, getter is read-only), and response text (defaults to `""`, `resetResponseText` clears, `appendResponseText` concatenates).
193
+ Implement the fields/getters/methods in `src/lifecycle/subagent-state.ts`.
194
+ Commit: `feat: add live-activity fields to SubagentState (#420)`.
195
+ 2. **`record-observer` populates the live-activity fields.**
196
+ Surface: `test/observation/record-observer.test.ts`.
197
+ Red→green: new tests that `tool_execution_start` adds an active tool, paired `tool_execution_end` removes it (extending the existing tool-uses test or a sibling case), `turn_end` increments `turnCount`, `message_start` resets `responseText`, and `message_update` text_delta appends; assert non-text_delta `message_update` is ignored.
198
+ Implement the new branches in `src/observation/record-observer.ts` and the `removeActiveTool` call in the existing `tool_execution_end` branch.
199
+ Commit: `feat: accumulate live activity in record-observer (#420)`.
200
+ 3. **`Subagent` exposes the live-activity getters.**
201
+ Surface: `test/lifecycle/subagent.test.ts`.
202
+ Red→green: tests that `turnCount`/`activeTools`/`responseText` delegate to the owned state (seed via a supplied `SubagentState` or by driving the attached observer), and `maxTurns` returns `execution.maxTurns`.
203
+ Implement the four getters in `src/lifecycle/subagent.ts`.
204
+ Run `pnpm run check` after this commit — the getters are the public-facing surface this step adds.
205
+ Commit: `feat: expose live-activity getters on Subagent (#420)`.
206
+
207
+ Each step is independently green: the field exists before the observer writes it, and the observer writes it before `Subagent` exposes it.
208
+ No existing test breaks at any step (pure addition, no removed/renamed symbol).
209
+
210
+ ## Risks and Mitigations
211
+
212
+ - **Risk: the folded fields drift from `AgentActivityTracker` semantics, making Step 2's swap not behavior-preserving.**
213
+ Mitigation: the transition methods are copied field-for-field (turn count starts at 1, `name_seq` keying, first-match removal, reset-then-append response text); Step 2's reader-migration tests will compare record getters against the tracker before deletion.
214
+ - **Risk: resume parity — the surviving `SubagentState` accumulates turn count across a resume while a fresh tracker would not.**
215
+ Mitigation: the tracker is *not* reset on resume today either (it is not reconstructed), so the record matches.
216
+ `resetForResume` is left unchanged and the decision is documented; Step 2 revisits if a reader needs a per-resume reset.
217
+ - **Risk: a second observer now writes the same conceptual fields (record-observer + ui-observer), briefly duplicating active-tool/turn-count bookkeeping.**
218
+ Mitigation: this is the intended tidy-first overlap; the duplicate (`ui-observer`) is removed in Step 3 ([#422]).
219
+ No consumer reads the record's copy yet, so the duplication is inert.
220
+
221
+ ## Open Questions
222
+
223
+ - Whether `resetForResume` should clear the live-activity fields is deferred to Step 2 ([#421]), where the actual reader behavior on resume is observable.
224
+ Resolving it now would be speculative and would break the pure-addition contract.
225
+
226
+ [#421]: https://github.com/gotgenes/pi-packages/issues/421
227
+ [#422]: https://github.com/gotgenes/pi-packages/issues/422
228
+ [#373]: https://github.com/gotgenes/pi-packages/issues/373