@gotgenes/pi-subagents 16.3.1 → 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.
- package/CHANGELOG.md +17 -0
- package/README.md +47 -0
- package/dist/public.d.ts +7 -4
- package/dist/settings.d.ts +82 -0
- package/docs/architecture/architecture.md +193 -207
- package/docs/architecture/history/phase-17-core-consolidation.md +214 -0
- package/docs/plans/0378-consolidate-lifecycle-test-fixtures.md +243 -0
- package/docs/plans/0379-consolidate-ui-tools-test-fixtures.md +335 -0
- package/docs/plans/0380-extract-layered-settings-loader.md +223 -0
- package/docs/plans/0412-unify-session-mock-builders.md +146 -0
- package/docs/plans/0420-fold-run-metrics-live-activity-onto-record.md +228 -0
- package/docs/retro/0378-consolidate-lifecycle-test-fixtures.md +100 -0
- package/docs/retro/0379-consolidate-ui-tools-test-fixtures.md +52 -0
- package/docs/retro/0380-extract-layered-settings-loader.md +97 -0
- package/docs/retro/0412-unify-session-mock-builders.md +80 -0
- package/docs/retro/0420-fold-run-metrics-live-activity-onto-record.md +45 -0
- package/package.json +5 -1
- package/src/layered-settings.ts +105 -0
- package/src/lifecycle/subagent-state.ts +49 -4
- package/src/lifecycle/subagent.ts +4 -0
- package/src/observation/record-observer.ts +25 -1
- package/src/settings.ts +10 -23
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
---
|
|
2
|
+
issue: 379
|
|
3
|
+
issue_title: "Consolidate UI and tools test fixtures"
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Consolidate UI and tools test fixtures
|
|
7
|
+
|
|
8
|
+
## Problem Statement
|
|
9
|
+
|
|
10
|
+
`pnpm fallow:dupes` reports clone families in the UI, tools, session, and service test suites that sit outside the lifecycle tree consolidated in Step 7 ([#378]).
|
|
11
|
+
These are independent of the `Subagent` refactoring and can be tidied at any time.
|
|
12
|
+
This is Phase 17 Step 8 (core consolidation) — the last non-lifecycle test-duplication cleanup before Step 9's cross-package production clone.
|
|
13
|
+
|
|
14
|
+
Measured against today's `main` (`fallow dupes -r packages/pi-subagents`), the package reports **32 clone groups / 512 duplicated lines (2.49%)**, with seven clone families.
|
|
15
|
+
Two of those families (`create-subagent-session.test.ts`, `subagent-manager.test.ts`) are the residual that Step 7 deliberately left as the visible system-under-test **act** — Non-Goals here.
|
|
16
|
+
The remaining five families are the UI/tools/session targets named in the issue, plus a sixth in `test/service/service-adapter.test.ts` (a repeated `SubagentManagerLike` stub) added to scope by operator decision during planning.
|
|
17
|
+
|
|
18
|
+
## Goals
|
|
19
|
+
|
|
20
|
+
- Consolidate the seven targeted test files' clone families by extracting **value-returning arrange builders** (or describe-scoped `beforeEach` setup), never by wrapping the system-under-test act.
|
|
21
|
+
- Promote genuinely cross-file duplication (the `ResolvedSpawnConfig` builder shared by the two tools tests) into `test/helpers/`; keep intra-file families as file-local helpers.
|
|
22
|
+
- Preserve every existing test assertion — the existing suite is the regression guard for this refactor.
|
|
23
|
+
- Keep the change non-breaking: test-only, no production-code, public-surface, or behavior change.
|
|
24
|
+
- Report the resulting `fallow dupes` group count and duplication percentage as the outcome (no binding numeric group target — see Open Questions).
|
|
25
|
+
|
|
26
|
+
## Non-Goals
|
|
27
|
+
|
|
28
|
+
- The two residual lifecycle families (`create-subagent-session.test.ts`, `subagent-manager.test.ts`).
|
|
29
|
+
Step 7 ([#378]) intentionally left these as the repeated `await createSubagentSession(...)` / `spawn(...)` act with test-specific arrange; re-extracting them would hide the test subject behind a helper (the wrong abstraction Step 7's `Landed` note calls out).
|
|
30
|
+
- The 11-line **production** clone inside `src/ui/agent-config-editor.ts` (lines 125–135 / 163–173, the location-select-and-write block).
|
|
31
|
+
This issue is test-only; the production clone is tracked separately in the architecture doc's "Production duplication" section.
|
|
32
|
+
- The cross-package settings-loader production clone — Step 9 ([#380]).
|
|
33
|
+
- The three overlapping session-mock builders ([#412]).
|
|
34
|
+
- Adding new behavioral test cases — this is duplication removal, not coverage expansion (helper self-tests are the only new `it` blocks).
|
|
35
|
+
|
|
36
|
+
## Background
|
|
37
|
+
|
|
38
|
+
The package already has substantial shared scaffolding under `test/helpers/`:
|
|
39
|
+
|
|
40
|
+
- `make-deps.ts` — `createToolDeps(overrides)` returns `{ manager, runtime, widget }` for the tools tests.
|
|
41
|
+
- `ui-stubs.ts` — `makeFileOps()`, `makeMenuUI(selectResults)`, `makeMenuManager()`, `createTestSubagentConfig(overrides)`.
|
|
42
|
+
- `make-subagent.ts` — `createTestSubagent(overrides)`.
|
|
43
|
+
- `mock-session.ts` — `createMockSession()`, `createSubagentSessionStub()`, `toSubagentSession()`.
|
|
44
|
+
- `manager-stubs.ts` — `createBlockingFactory()`, `createSessionFactory()` (session factories, **not** the `SubagentManagerLike` service stub).
|
|
45
|
+
- `stub-ctx.ts` — `STUB_SNAPSHOT`.
|
|
46
|
+
|
|
47
|
+
Convention: every shared helper module under `test/helpers/` has a companion `*.test.ts` (e.g. `subagent-session-io.test.ts`).
|
|
48
|
+
A new shared helper must ship its own companion test.
|
|
49
|
+
|
|
50
|
+
The seven clone families fallow reports today, and the repeated blocks driving each:
|
|
51
|
+
|
|
52
|
+
| File | Family | Repeated block | Disposition |
|
|
53
|
+
| --------------------------------------- | ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------- |
|
|
54
|
+
| `test/ui/agent-creation-wizard.test.ts` | 3 groups, 33 lines | `makeMenuUI([...selections])` + `ui.input = vi.fn()...` arrange, then `makeWizard(deps)` + `showCreateWizard(...)` act | file-local arrange builders |
|
|
55
|
+
| `test/ui/agent-config-editor.test.ts` | 4 groups, 29 lines | disabled-config spy arrange + `setupDetail(...)`, then `showAgentDetail(...)` act and `ui.select.mock.calls[0][1]` menu assertions | file-local `disabledConfig` builder; `setupDetail` already exists |
|
|
56
|
+
| `test/ui/ui-observer.test.ts` | 3 groups, 20 lines | `const session = createMockSession(); const tracker = new AgentActivityTracker();` setup, then `subscribeUIObserver(...)` + `session.emit(...)` act | describe-scoped `beforeEach` |
|
|
57
|
+
| `test/tools/foreground-runner.test.ts` | 2 groups, 21 lines | a local `makeConfig`/`makeParams` builder (cross-file with background-spawner) + a `spawnAndWait.mockImplementation` observer-registering block (intra-file) | shared `createResolvedSpawnConfig`; file-local observer helper |
|
|
58
|
+
| `test/tools/background-spawner.test.ts` | (cross-file group) | a near-identical local `makeConfig`/`makeParams` builder | shared `createResolvedSpawnConfig` |
|
|
59
|
+
| `test/session/session-config.test.ts` | 1 group, 16 lines | `mockResolveAgentConfig.mockReturnValueOnce({ name: "Explore", ..., model })` arrange, then the 6-arg `assembleSessionConfig(...)` act | file-local `exploreConfig` builder |
|
|
60
|
+
| `test/service/service-adapter.test.ts` | 2 groups, 13 lines | four near-identical 7-method `SubagentManagerLike` stub factories (`createMockManager`, `defaultManager`, `createTestManager`, an inline literal) | file-local `createManagerStub` builder |
|
|
61
|
+
|
|
62
|
+
The cross-file group `dup:80ee2004` (background-spawner `5-16` ↔ foreground-runner `4-15`) is the `ResolvedSpawnConfig` skeleton head — the only genuine **cross-file** duplication and the sole promotion candidate.
|
|
63
|
+
Every other family fallow scores as "same file on both sides," so they stay file-local.
|
|
64
|
+
|
|
65
|
+
AGENTS.md / testing-skill / Step 7 constraints that apply:
|
|
66
|
+
|
|
67
|
+
- Do not wrap the system-under-test act in a helper to eliminate a duplication-metric clone — the repeated act is the test subject (testing skill; Step 7 `Landed` note; the act-wrapping helper was reverted in Step 7).
|
|
68
|
+
- When consolidating duplicate test factories, diff the default values across all copies before writing the shared factory — different defaults cause cascading assertion failures during migration.
|
|
69
|
+
- When a TDD step deletes a test helper, re-check the file's remaining imports for orphans — Biome's `noUnusedImports` is warning-level (exit 0).
|
|
70
|
+
- Run the full package suite (not just the touched file) after each shared-helper change.
|
|
71
|
+
- `Partial<T>` top-level spread does not deep-merge nested sub-objects — relevant to the `ResolvedSpawnConfig` builder (nested `identity`/`execution`/`presentation`).
|
|
72
|
+
|
|
73
|
+
## Design Overview
|
|
74
|
+
|
|
75
|
+
Guiding distinction, inherited from Step 7: **promote to `test/helpers/` only what is genuinely shared across files; keep intra-file families as file-local helpers or describe-scoped `beforeEach`; never extract the act.**
|
|
76
|
+
|
|
77
|
+
### 1. Shared: a `ResolvedSpawnConfig` builder for the tools tests
|
|
78
|
+
|
|
79
|
+
`foreground-runner.test.ts` and `background-spawner.test.ts` each hand-build a full `ResolvedSpawnConfig` with a local `makeConfig(overrides)` — the cross-file clone.
|
|
80
|
+
The two copies share the nested shape and differ only in a handful of scalars:
|
|
81
|
+
|
|
82
|
+
| Field | foreground | background |
|
|
83
|
+
| --------------------------- | --------------- | ------------------- |
|
|
84
|
+
| `identity.displayName` | `"Agent"` | `"General-purpose"` |
|
|
85
|
+
| `execution.prompt` | `"do the task"` | `"do something"` |
|
|
86
|
+
| `execution.description` | `"fg task"` | `"bg task"` |
|
|
87
|
+
| `execution.runInBackground` | `false` | `true` |
|
|
88
|
+
|
|
89
|
+
Crucially, three regions of the config mirror the same scalars — `execution.runInBackground` mirrors into `execution.agentInvocation.runInBackground`; `identity.displayName`/`execution.description`/`identity.subagentType` mirror into `presentation.detailBase`.
|
|
90
|
+
The hand-built copies duplicate that mirroring.
|
|
91
|
+
Add a flat-options builder to a new `test/helpers/make-spawn-config.ts` that **derives** the mirrored regions, so the construction knowledge lives in one tested place:
|
|
92
|
+
|
|
93
|
+
```typescript
|
|
94
|
+
import type { ResolvedSpawnConfig } from "#src/tools/spawn-config";
|
|
95
|
+
|
|
96
|
+
export interface ResolvedSpawnConfigOptions {
|
|
97
|
+
subagentType?: string;
|
|
98
|
+
rawType?: string;
|
|
99
|
+
fellBack?: boolean;
|
|
100
|
+
displayName?: string;
|
|
101
|
+
prompt?: string;
|
|
102
|
+
description?: string;
|
|
103
|
+
model?: string;
|
|
104
|
+
runInBackground?: boolean;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function createResolvedSpawnConfig(
|
|
108
|
+
options: ResolvedSpawnConfigOptions = {},
|
|
109
|
+
): ResolvedSpawnConfig {
|
|
110
|
+
const subagentType = options.subagentType ?? "general-purpose";
|
|
111
|
+
const displayName = options.displayName ?? "Agent";
|
|
112
|
+
const description = options.description ?? "task";
|
|
113
|
+
const runInBackground = options.runInBackground ?? false;
|
|
114
|
+
const agentInvocation = {
|
|
115
|
+
modelName: options.model,
|
|
116
|
+
thinking: undefined,
|
|
117
|
+
maxTurns: undefined,
|
|
118
|
+
inheritContext: false,
|
|
119
|
+
runInBackground,
|
|
120
|
+
};
|
|
121
|
+
return {
|
|
122
|
+
identity: {
|
|
123
|
+
subagentType,
|
|
124
|
+
rawType: options.rawType ?? subagentType,
|
|
125
|
+
fellBack: options.fellBack ?? false,
|
|
126
|
+
displayName,
|
|
127
|
+
},
|
|
128
|
+
execution: {
|
|
129
|
+
prompt: options.prompt ?? "do the task",
|
|
130
|
+
description,
|
|
131
|
+
model: undefined,
|
|
132
|
+
effectiveMaxTurns: undefined,
|
|
133
|
+
thinking: undefined,
|
|
134
|
+
inheritContext: false,
|
|
135
|
+
runInBackground,
|
|
136
|
+
agentInvocation,
|
|
137
|
+
},
|
|
138
|
+
presentation: {
|
|
139
|
+
modelName: options.model,
|
|
140
|
+
agentTags: [],
|
|
141
|
+
detailBase: { displayName, description, subagentType, modelName: options.model, tags: undefined },
|
|
142
|
+
},
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
Flat options (rather than `Partial<ResolvedSpawnConfig>` nested overrides) sidestep the deep-merge trap and cover every override the two files use today: the foreground "fallback note" test (`fellBack: true`, `rawType: "unknown-type"`) and the background "my task" / queued tests (`description`, `runInBackground`).
|
|
148
|
+
Return type **is** annotated `ResolvedSpawnConfig` here because the builder returns a plain data object (no `Mock<...>` methods to preserve), unlike the mock-session factories.
|
|
149
|
+
|
|
150
|
+
Consumer call site (the act stays inline and explicit):
|
|
151
|
+
|
|
152
|
+
```typescript
|
|
153
|
+
const result = await runForeground(
|
|
154
|
+
manager, widget, runtime.agentActivity,
|
|
155
|
+
{ config: createResolvedSpawnConfig({ fellBack: true, rawType: "unknown-type" }), snapshot: STUB_SNAPSHOT, parentSession },
|
|
156
|
+
undefined, undefined,
|
|
157
|
+
);
|
|
158
|
+
expect(result.content[0].text).toContain('Unknown agent type "unknown-type"');
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
`makeParams(overrides)` stays file-local in each tools test (the `parentSession`/`settings` shapes differ between `ForegroundParams` and `BackgroundParams`); only its inner `config:` default switches to `createResolvedSpawnConfig(...)`.
|
|
162
|
+
|
|
163
|
+
### 2. File-local: tools observer-registering arrange
|
|
164
|
+
|
|
165
|
+
`foreground-runner.test.ts` repeats a `spawnAndWait.mockImplementation(async (...) => { ...register observer.onSessionCreated... })` block twice (`dup:fbdc0856`).
|
|
166
|
+
Extract a file-local helper that **returns** the mock implementation (a value), keeping the `createToolDeps(...)` wiring and the `runForeground(...)` act inline:
|
|
167
|
+
|
|
168
|
+
```typescript
|
|
169
|
+
function spawnAndWaitRegistering(record = createTestSubagent({ result: "done" })) {
|
|
170
|
+
record.subagentSession = toSubagentSession(createSubagentSessionStub(createMockSession()));
|
|
171
|
+
return vi.fn(async (_s, _t, _p, opts: { observer?: { onSessionCreated?: (r: Subagent) => void } }) => {
|
|
172
|
+
opts.observer?.onSessionCreated?.(record);
|
|
173
|
+
return record;
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
### 3. File-local: `ui-observer.test.ts` describe-scoped `beforeEach`
|
|
179
|
+
|
|
180
|
+
Every test opens with `const session = createMockSession(); const tracker = new AgentActivityTracker();`.
|
|
181
|
+
Move both into a describe-scoped `beforeEach` (the Step 7 AAA pattern); keep `subscribeUIObserver(session, tracker[, onUpdate])` and `session.emit(...)` inline as the act.
|
|
182
|
+
For the update-counting tests, replace the closure `let updateCount = 0; () => updateCount++` with a `vi.fn()` onUpdate and assert `toHaveBeenCalledTimes(n)` — removing the repeated counter scaffolding without hiding the act.
|
|
183
|
+
|
|
184
|
+
### 4. File-local: `agent-creation-wizard.test.ts` arrange builders
|
|
185
|
+
|
|
186
|
+
The repeated arrange is the menu-selection arrays and the `ui.input = vi.fn().mockResolvedValueOnce(...)` chains; the act is `makeWizard(deps)` + `showCreateWizard(ui, STUB_SNAPSHOT)`.
|
|
187
|
+
Extract value-returning builders for the arrange only:
|
|
188
|
+
|
|
189
|
+
```typescript
|
|
190
|
+
function manualUI(opts: { location?: string; tools?: string; model?: string; thinking?: string }) {
|
|
191
|
+
return makeMenuUI([opts.location ?? "Project (.pi/agents/)", "Manual configuration",
|
|
192
|
+
opts.tools ?? "all", opts.model ?? "inherit (parent model)", opts.thinking ?? "inherit"]);
|
|
193
|
+
}
|
|
194
|
+
function withInputs(ui: ReturnType<typeof makeMenuUI>, ...values: (string | undefined)[]) {
|
|
195
|
+
ui.input = vi.fn();
|
|
196
|
+
for (const v of values) (ui.input as Mock).mockResolvedValueOnce(v);
|
|
197
|
+
return ui;
|
|
198
|
+
}
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
`makeWizard(deps)` + `await wizard.showCreateWizard(...)` stay inline in every test.
|
|
202
|
+
|
|
203
|
+
### 5. File-local: `agent-config-editor.test.ts` disabled-config builder
|
|
204
|
+
|
|
205
|
+
This file is already well-factored (`setupDetail`, `makeEditor`).
|
|
206
|
+
The residual families are the `vi.spyOn(testRegistry, "resolveAgentConfig").mockReturnValue({ ...testXConfig, enabled: false })` arrange (5 sites) and the repeated `showAgentDetail(...)` act with `const options = ui.select.mock.calls[0][1] as string[]` menu assertions.
|
|
207
|
+
Extract a single value-returning `disabledConfig(base)` helper (`(base: AgentConfig) => ({ ...base, enabled: false })`) for the disabled-config arrange.
|
|
208
|
+
Leave the `showAgentDetail(...)` act and the menu-option assertions inline — they are the test subject (see Test Impact Analysis for the menu-structure redundancy note).
|
|
209
|
+
|
|
210
|
+
### 6. File-local: `session-config.test.ts` config builder
|
|
211
|
+
|
|
212
|
+
Extract a value-returning `exploreConfig(model?)` helper returning the `{ name: "Explore", description, systemPrompt, promptMode: "replace", model }` literal that `mockResolveAgentConfig.mockReturnValueOnce(...)` consumes across the model-resolution tests.
|
|
213
|
+
The 6-arg `assembleSessionConfig("Explore", ctx, {}, mockEnv, mockAgentLookup, mockIO)` act stays inline.
|
|
214
|
+
|
|
215
|
+
### 7. File-local: `service-adapter.test.ts` manager-stub builder
|
|
216
|
+
|
|
217
|
+
Four near-identical 7-method `SubagentManagerLike` stubs (`createMockManager`, `defaultManager`, `createTestManager`, and an inline literal) drive both families.
|
|
218
|
+
Diffed defaults: `hasRunning` is `true` in `createTestManager` and `false` elsewhere; `spawn` returns `"spawned-id"` / `"id"` / `"spawned-id"` variously; `getRecord`/`listAgents`/`abort` are typed in `createTestManager`.
|
|
219
|
+
Consolidate into one file-local builder with documented defaults and typed overrides:
|
|
220
|
+
|
|
221
|
+
```typescript
|
|
222
|
+
function createManagerStub(overrides: Partial<SubagentManagerLike> = {}): SubagentManagerLike {
|
|
223
|
+
return {
|
|
224
|
+
spawn: vi.fn(() => "spawned-id"),
|
|
225
|
+
getRecord: vi.fn(),
|
|
226
|
+
listAgents: vi.fn(() => []),
|
|
227
|
+
abort: vi.fn(() => true),
|
|
228
|
+
waitForAll: vi.fn(async () => {}),
|
|
229
|
+
hasRunning: vi.fn(() => false),
|
|
230
|
+
registerWorkspaceProvider: vi.fn(() => () => {}),
|
|
231
|
+
...overrides,
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
Call sites pass the differing fields via `overrides` (`{ hasRunning: vi.fn(() => true) }`, `{ getRecord: vi.fn((id) => records.find(...)) }`, etc.).
|
|
237
|
+
This is file-local — `SubagentManagerLike` is service-adapter-specific and fallow scores the family same-file.
|
|
238
|
+
|
|
239
|
+
## Module-Level Changes
|
|
240
|
+
|
|
241
|
+
- `test/helpers/make-spawn-config.ts` — **new**: `createResolvedSpawnConfig(options)` + `ResolvedSpawnConfigOptions`.
|
|
242
|
+
- `test/helpers/make-spawn-config.test.ts` — **new** companion test: default shape, the four scalar overrides, and the derived-mirroring assertions (`agentInvocation.runInBackground`, `presentation.detailBase`).
|
|
243
|
+
- `test/tools/foreground-runner.test.ts` — delete local `makeConfig`; `makeParams` inner `config:` uses `createResolvedSpawnConfig(...)`; add file-local `spawnAndWaitRegistering()`; re-check imports for orphans.
|
|
244
|
+
- `test/tools/background-spawner.test.ts` — delete local `makeConfig`; `makeParams` inner `config:` uses `createResolvedSpawnConfig({ displayName: "General-purpose", prompt: "do something", description: "bg task", runInBackground: true })`; re-check imports.
|
|
245
|
+
- `test/ui/ui-observer.test.ts` — add describe-scoped `beforeEach` creating `session`/`tracker`; switch update-counting tests to `vi.fn()` onUpdate.
|
|
246
|
+
- `test/ui/agent-creation-wizard.test.ts` — add file-local `manualUI()` / `withInputs()` arrange builders; route cloned arrange through them; keep act inline.
|
|
247
|
+
- `test/ui/agent-config-editor.test.ts` — add file-local `disabledConfig(base)`; route the disabled-config spy arranges through it.
|
|
248
|
+
- `test/session/session-config.test.ts` — add file-local `exploreConfig(model?)`; route the model-resolution arranges through it.
|
|
249
|
+
- `test/service/service-adapter.test.ts` — add file-local `createManagerStub(overrides)`; replace the four stub factories/literal with it; re-check imports.
|
|
250
|
+
- `docs/architecture/architecture.md` — mark Step 8 ✅ Complete with a `Landed:` bullet (matching Step 7); add `test/service/service-adapter.test.ts` to the Step 8 `Targets` line with a note that it was added in planning; refresh the "Test duplication" current-state figure.
|
|
251
|
+
|
|
252
|
+
No `src/` files change.
|
|
253
|
+
No public surface changes, so `verify:public-types` and the type bundle are unaffected.
|
|
254
|
+
The `package-pi-subagents` SKILL.md test-count line ("994 tests across 63 files as of Phase 17 Step 4") is an explicitly point-in-time figure, not a Step-8 reference, and is left untouched.
|
|
255
|
+
|
|
256
|
+
## Test Impact Analysis
|
|
257
|
+
|
|
258
|
+
Inverted lens, per the test-refactoring nature of this issue:
|
|
259
|
+
|
|
260
|
+
1. **New tests the change enables.**
|
|
261
|
+
Only one helper self-test file: `make-spawn-config.test.ts` (per the `test/helpers/` companion-test convention).
|
|
262
|
+
No new behavioral tests — coverage must not change.
|
|
263
|
+
2. **Existing tests that become redundant.**
|
|
264
|
+
The five menu-structure tests in `agent-config-editor.test.ts`'s `showAgentDetail` describe assert the same expected arrays as the five `buildMenuOptions` unit tests below them.
|
|
265
|
+
They are **not** removed: the `showAgentDetail` variants verify the `resolveType` → `resolveAgentConfig` → `buildMenuOptions` wiring (an integration concern), whereas `buildMenuOptions` tests the pure function in isolation.
|
|
266
|
+
Removing either layer is a coverage decision outside this issue's scope; flagged here, deferred.
|
|
267
|
+
3. **Tests that must stay as-is.**
|
|
268
|
+
Every assertion in all seven files is the regression guard; migration is green-to-green.
|
|
269
|
+
In particular the foreground spinner/timer assertions, the `spawnBackground` queued/output-file assertions, the `ui-observer` turn-count/unsubscribe assertions, and the `service-adapter` serialization-stripping assertions are unchanged — only their arrange scaffolding is consolidated.
|
|
270
|
+
|
|
271
|
+
## Invariants at risk
|
|
272
|
+
|
|
273
|
+
This step touches no production code and no surface a prior Phase 17 step refactored for behavior; the lifecycle invariants from Steps 1–6 (`.promise`/`.notification` encapsulation) live in `test/lifecycle/`, which this step does not touch.
|
|
274
|
+
The one cross-step continuity risk is **regressing Step 7's documented discipline** — "do not wrap the system-under-test act."
|
|
275
|
+
Mitigation: every builder extracted here returns a value or seeds `beforeEach` setup; the acts (`showCreateWizard`, `showAgentDetail`, `runForeground`, `spawnBackground`, `assembleSessionConfig`, `subscribeUIObserver` + `emit`, and the `svc.*` calls) stay inline and explicit in each test.
|
|
276
|
+
A reviewer can grep each migrated file to confirm no helper name wraps an act call.
|
|
277
|
+
|
|
278
|
+
## TDD Order
|
|
279
|
+
|
|
280
|
+
Lift-and-shift refactor of tests; the suite is green at every step (no red phase — the existing assertions are the spec).
|
|
281
|
+
Each migration step runs the **full** package suite before committing.
|
|
282
|
+
Steps 4–8 are independent of each other and order-insensitive; Steps 2–3 depend on Step 1.
|
|
283
|
+
|
|
284
|
+
1. **Add shared `createResolvedSpawnConfig` + helper test.**
|
|
285
|
+
Surface: `test/helpers/make-spawn-config.ts`, `test/helpers/make-spawn-config.test.ts`.
|
|
286
|
+
Covers: default shape, the four scalar overrides, derived `agentInvocation`/`presentation.detailBase` mirroring.
|
|
287
|
+
Run the helper test, then `pnpm run check`.
|
|
288
|
+
Commit: `test: add shared createResolvedSpawnConfig builder (#379)`.
|
|
289
|
+
2. **Migrate `foreground-runner.test.ts`.**
|
|
290
|
+
Delete local `makeConfig`; point `makeParams` at the shared builder; add `spawnAndWaitRegistering()`; re-check imports; run full suite green.
|
|
291
|
+
Commit: `test: consolidate foreground-runner spawn-config fixtures (#379)`.
|
|
292
|
+
3. **Migrate `background-spawner.test.ts`.**
|
|
293
|
+
Delete local `makeConfig`; point `makeParams` at the shared builder with background defaults; re-check imports; run full suite green.
|
|
294
|
+
Commit: `test: consolidate background-spawner spawn-config fixtures (#379)`.
|
|
295
|
+
4. **Consolidate `ui-observer.test.ts`.**
|
|
296
|
+
Add describe-scoped `beforeEach`; switch counters to `vi.fn()` onUpdate; run full suite green.
|
|
297
|
+
Commit: `test: consolidate ui-observer arrange into beforeEach (#379)`.
|
|
298
|
+
5. **Consolidate `agent-creation-wizard.test.ts`.**
|
|
299
|
+
Add `manualUI()` / `withInputs()`; route cloned arrange; run full suite green.
|
|
300
|
+
Commit: `test: consolidate agent-creation-wizard arrange builders (#379)`.
|
|
301
|
+
6. **Consolidate `agent-config-editor.test.ts`.**
|
|
302
|
+
Add `disabledConfig(base)`; route the disabled-config arranges; run full suite green.
|
|
303
|
+
Commit: `test: consolidate agent-config-editor disabled-config arrange (#379)`.
|
|
304
|
+
7. **Consolidate `session-config.test.ts`.**
|
|
305
|
+
Add `exploreConfig(model?)`; route the model-resolution arranges; run full suite green.
|
|
306
|
+
Commit: `test: consolidate session-config model fixtures (#379)`.
|
|
307
|
+
8. **Consolidate `service-adapter.test.ts`.**
|
|
308
|
+
Add `createManagerStub(overrides)`; replace the four stub factories/literal; re-check imports; run full suite green.
|
|
309
|
+
Commit: `test: consolidate service-adapter manager stubs (#379)`.
|
|
310
|
+
9. **Measure and record outcome.**
|
|
311
|
+
Run `pnpm exec fallow dupes -r packages/pi-subagents` and record the resulting clone-group count and duplication percentage; run `pnpm run check && pnpm run lint && pnpm fallow dead-code`.
|
|
312
|
+
Update `docs/architecture/architecture.md` Step 8 to ✅ Complete with a `Landed:` bullet (figures, what was extracted, what was intentionally left inline), add service-adapter to its `Targets`, and refresh the current-state "Test duplication" figure.
|
|
313
|
+
Commit: `docs: mark Phase 17 Step 8 complete (#379)`.
|
|
314
|
+
|
|
315
|
+
## Risks and Mitigations
|
|
316
|
+
|
|
317
|
+
- **Over-extraction / procedure-splitting to chase the metric (the Step 7 lesson).**
|
|
318
|
+
Mitigation: only value-returning arrange builders and `beforeEach` setup are extracted; every act stays inline; the "Invariants at risk" grep check confirms no helper wraps an act.
|
|
319
|
+
- **Divergent defaults across the consolidated factories cause assertion failures.**
|
|
320
|
+
Mitigation: the differing scalars are tabulated in Design (tools `ResolvedSpawnConfig`; service-adapter manager stub) before writing each shared builder; migration sets the differing fields via overrides.
|
|
321
|
+
- **Deep-merge trap on the nested `ResolvedSpawnConfig`.**
|
|
322
|
+
Mitigation: the builder takes **flat** options and assembles the nested structure internally, so no caller passes a partial nested object expecting a merge.
|
|
323
|
+
- **Orphaned imports after deleting local builders (Biome `noUnusedImports` is exit 0).**
|
|
324
|
+
Mitigation: re-check each migrated file's imports as part of its step; the Step 9 `pnpm run lint` + `fallow dead-code` pass is the backstop.
|
|
325
|
+
|
|
326
|
+
## Open Questions
|
|
327
|
+
|
|
328
|
+
- The roadmap's stated Step 8 outcome ("clone groups 44 → ≤ 25; overall duplication ≤ 0.6%") predates Steps 1–7; today's baseline is 32 groups / 2.49% under a different fallow metric.
|
|
329
|
+
Per the planning decision, acceptance is "each named family consolidated; resulting `fallow dupes` numbers reported," not a binding group target.
|
|
330
|
+
Step 9's measurement (or a later metric reconciliation) can decide whether to restate the roadmap's numeric target.
|
|
331
|
+
- Whether the menu-structure `showAgentDetail` tests in `agent-config-editor.test.ts` should eventually collapse into the `buildMenuOptions` unit tests (Test Impact Analysis item 2) — deferred as a coverage decision, not duplication removal.
|
|
332
|
+
|
|
333
|
+
[#378]: https://github.com/gotgenes/pi-packages/issues/378
|
|
334
|
+
[#380]: https://github.com/gotgenes/pi-packages/issues/380
|
|
335
|
+
[#412]: https://github.com/gotgenes/pi-packages/issues/412
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
---
|
|
2
|
+
issue: 380
|
|
3
|
+
issue_title: "Resolve the cross-package settings-loader duplication"
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Extract a layered settings loader into the pi-subagents public surface
|
|
7
|
+
|
|
8
|
+
## Problem Statement
|
|
9
|
+
|
|
10
|
+
`pnpm fallow:dupes` reports a 23-line cross-package production clone: `src/settings.ts:198-211` ↔ `@gotgenes/pi-subagents-worktrees`'s `src/config.ts:51-73`.
|
|
11
|
+
Both implement the same layered global/project settings-file loader — read each path, sanitize parsed JSON, warn to stderr on malformed input, and merge with the project file overriding global.
|
|
12
|
+
It is the only cross-package production clone in the package.
|
|
13
|
+
|
|
14
|
+
Issue [#380] frames this as a binary decision: extract a shared `loadLayeredSettings` helper from pi-subagents' public surface, or document the duplication as intentional and record a fallow suppression.
|
|
15
|
+
The operator chose extraction — pi-subagents should provide layered-settings loading as core support for the `@gotgenes/pi-*` family — delivered through a **dedicated subpath export** (`@gotgenes/pi-subagents/settings`) rather than the cross-extension service contract, and sequenced as **two stages**: this plan lands the helper in pi-subagents (exported and adopted internally); a follow-up migrates worktrees onto it after a published release.
|
|
16
|
+
|
|
17
|
+
## Goals
|
|
18
|
+
|
|
19
|
+
- Add a generic `loadLayeredSettings<T>(...)` function that owns the read-sanitize-warn-merge idiom: global file under `<agentDir>/<filename>`, project file under `<cwd>/.pi/<filename>`, project overriding global, missing files silent, malformed files warned-but-continued.
|
|
20
|
+
- Export it from pi-subagents via a new dedicated subpath, `@gotgenes/pi-subagents/settings`, keeping the existing `.` service contract (spawn/abort/workspace seam) cohesive and unchanged.
|
|
21
|
+
- Adopt the helper internally: `src/settings.ts`'s `loadSettings` delegates to `loadLayeredSettings`, proving the helper against a real consumer and removing the pi-subagents copy of the idiom.
|
|
22
|
+
- Extend the `.d.ts` bundle (`rollup.dts.config.mjs`) and the `verify:public-types` harness to cover the new subpath, so the export is provably type-consumable from the packaged tarball.
|
|
23
|
+
- Document the helper for third-party extension authors (input/return contract, throw/warn semantics, a minimal wiring example).
|
|
24
|
+
- This is a non-breaking, additive change: `feat:` — a new public capability plus an internal refactor.
|
|
25
|
+
`loadSettings`'s observable behavior is preserved.
|
|
26
|
+
|
|
27
|
+
## Non-Goals
|
|
28
|
+
|
|
29
|
+
- Migrating `@gotgenes/pi-subagents-worktrees` onto the helper.
|
|
30
|
+
Worktrees resolves pi-subagents from the **registry** (`linkWorkspacePackages: false`), so it can only consume the helper after a pi-subagents release carrying it.
|
|
31
|
+
That migration — swap `config.ts`'s loader, raise the peer/dev dependency floor, delete the worktrees copy — is a separate follow-up issue (created at ship time).
|
|
32
|
+
Until it lands, the worktrees copy of the idiom persists (see Risks).
|
|
33
|
+
- Touching `saveSettings` or the `SettingsManager` class beyond the `loadSettings` delegation.
|
|
34
|
+
Saving is pi-subagents-specific (worktrees has no save path) and stays as-is.
|
|
35
|
+
- Changing how Pi loads the extension (`pi.extensions: ["./src/index.ts"]` is untouched).
|
|
36
|
+
- Adding a fallow suppression — extraction is the chosen path, not Option 2.
|
|
37
|
+
|
|
38
|
+
## Background
|
|
39
|
+
|
|
40
|
+
Relevant modules and facts:
|
|
41
|
+
|
|
42
|
+
- `packages/pi-subagents/src/settings.ts` — owns `SubagentsSettings`, the `SettingsManager` class, and the free functions `loadSettings`, `saveSettings`, `persistToastFor`, plus private `sanitize`, `globalPath`, `projectPath`, `readSettingsFile`.
|
|
43
|
+
The cloned block is `readSettingsFile` (lines 197-208) plus the `loadSettings` merge (lines 209-211).
|
|
44
|
+
`loadSettings(agentDir, cwd = process.cwd())` is called once, by `SettingsManager.load()`, which passes `this.cwd` explicitly.
|
|
45
|
+
`saveSettings` calls `projectPath(cwd)` — so `projectPath` stays; only `globalPath` and `readSettingsFile` lose their callers after the refactor.
|
|
46
|
+
- `packages/pi-subagents-worktrees/src/config.ts` — the clone partner.
|
|
47
|
+
`loadWorktreesConfig(agentDir, cwd)` with private `sanitize`, `globalPath`, `projectPath`, `readConfigFile`.
|
|
48
|
+
Its `sanitize` validates a `string[]` (`worktreeAgents`); pi-subagents' validates numeric fields with ceilings.
|
|
49
|
+
The per-package `sanitize` is the genuinely-different part; the read/merge/warn mechanism is identical.
|
|
50
|
+
- `packages/pi-subagents/rollup.dts.config.mjs` — rolls `src/service/service.ts` into `dist/public.d.ts` (the only public entry today), externals = `@earendil-works/*` + `@sinclair/typebox`.
|
|
51
|
+
Exports a single config object.
|
|
52
|
+
- `packages/pi-subagents/scripts/verify-public-types.sh` — packs the tarball, asserts `dist/public.d.ts` is `#src`-free and carries the expected symbols, then type-checks a throwaway consumer importing from `@gotgenes/pi-subagents`.
|
|
53
|
+
Run in CI via the existing `verify:public-types` step (no `ci.yml` change needed — the extended script covers the new probe).
|
|
54
|
+
- `package.json` `exports` has only `"."`; `files` already allowlists `src` and `dist`, so a new `src/` module and a new `dist/*.d.ts` ship automatically.
|
|
55
|
+
|
|
56
|
+
Constraints from `AGENTS.md` and skills:
|
|
57
|
+
|
|
58
|
+
- Public API documentation: a new cross-extension export must be documented for third-party authors, not just typed ([code-design] skill).
|
|
59
|
+
- Library functions must not read `process.cwd()` / `process.env` internally — accept them as parameters ([code-design] skill).
|
|
60
|
+
The new helper takes `agentDir` and `cwd` explicitly (no `process.cwd()` default), matching the only caller, which already passes `this.cwd`.
|
|
61
|
+
- The published `dist/*.d.ts` must be self-contained (no `#src/*`) — the harness gates on it.
|
|
62
|
+
The helper imports only `node:fs` / `node:path` and is generic over `T`, so its rolled declaration is trivially self-contained.
|
|
63
|
+
- This is the package with the repo's only build step ([ADR-0003]); the subpath export extends that machinery rather than introducing a new mechanism.
|
|
64
|
+
|
|
65
|
+
Why a dedicated subpath rather than `service/service.ts`: the `.` entry is the cross-extension **service contract**.
|
|
66
|
+
A stateless settings-file utility is a different kind of thing; folding it into the service surface dilutes its cohesion.
|
|
67
|
+
A `./settings` subpath keeps the two concerns separate at the cost of a second rollup entry and a second `verify:public-types` probe (accepted in the decision).
|
|
68
|
+
|
|
69
|
+
## Design Overview
|
|
70
|
+
|
|
71
|
+
### Public API shape
|
|
72
|
+
|
|
73
|
+
```typescript
|
|
74
|
+
/**
|
|
75
|
+
* Describes one layered settings source: a global file at `<agentDir>/<filename>`
|
|
76
|
+
* and a project file at `<cwd>/.pi/<filename>`. @public
|
|
77
|
+
*/
|
|
78
|
+
export interface LayeredSettingsSource<T> {
|
|
79
|
+
/** Directory holding the global settings file (typically the Pi agent dir). */
|
|
80
|
+
agentDir: string;
|
|
81
|
+
/** Project root; the project file lives at `<cwd>/.pi/<filename>`. */
|
|
82
|
+
cwd: string;
|
|
83
|
+
/** Base filename, e.g. "subagents.json". */
|
|
84
|
+
filename: string;
|
|
85
|
+
/** Validate/coerce parsed JSON into a partial settings object. Garbage → {}. */
|
|
86
|
+
sanitize: (raw: unknown) => Partial<T>;
|
|
87
|
+
/** Label for the malformed-file stderr warning, e.g. "pi-subagents". */
|
|
88
|
+
warnLabel: string;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Load merged layered settings: global provides defaults, project overrides.
|
|
93
|
+
* Missing files are silent ({}). A file that exists but cannot be parsed warns
|
|
94
|
+
* to stderr (prefixed `[warnLabel]`) and is treated as absent so startup proceeds.
|
|
95
|
+
*/
|
|
96
|
+
export function loadLayeredSettings<T>(source: LayeredSettingsSource<T>): Partial<T>;
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
The helper internalizes everything the two copies share: the two path constructions (`join(agentDir, filename)` and `join(cwd, ".pi", filename)`), the single-file read (`existsSync` → `JSON.parse(readFileSync)` → `sanitize`, `catch` → `console.warn("[label] Ignoring malformed settings at ...")` → `{}`), and the project-over-global merge.
|
|
100
|
+
Callers supply only what genuinely differs: `filename`, `sanitize`, `warnLabel`.
|
|
101
|
+
|
|
102
|
+
Returning `Partial<T>` fits both consumers: `SubagentsSettings`'s fields are all optional (`Partial<SubagentsSettings>` is identical), and worktrees applies its `?? []` default in its own caller after the merge.
|
|
103
|
+
|
|
104
|
+
### Internal consumer call site (pi-subagents `settings.ts`)
|
|
105
|
+
|
|
106
|
+
```typescript
|
|
107
|
+
export function loadSettings(agentDir: string, cwd: string): SubagentsSettings {
|
|
108
|
+
return loadLayeredSettings({
|
|
109
|
+
agentDir,
|
|
110
|
+
cwd,
|
|
111
|
+
filename: "subagents.json",
|
|
112
|
+
sanitize,
|
|
113
|
+
warnLabel: "pi-subagents",
|
|
114
|
+
} satisfies LayeredSettingsSource<SubagentsSettings>);
|
|
115
|
+
}
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
The `satisfies` annotation both validates the call site and gives `LayeredSettingsSource` an in-repo reference, so fallow does not flag the exported interface as an unused type.
|
|
119
|
+
The warn message stays `"[pi-subagents] Ignoring malformed settings at ..."`, so the existing `/Ignoring malformed settings/` assertion in `settings.test.ts` still passes.
|
|
120
|
+
`process.cwd()` default is dropped from `loadSettings`; the sole caller (`SettingsManager.load`) already passes `this.cwd`.
|
|
121
|
+
|
|
122
|
+
### Subpath export wiring
|
|
123
|
+
|
|
124
|
+
```jsonc
|
|
125
|
+
"exports": {
|
|
126
|
+
".": {
|
|
127
|
+
"types": "./dist/public.d.ts",
|
|
128
|
+
"default": "./src/service/service.ts"
|
|
129
|
+
},
|
|
130
|
+
"./settings": {
|
|
131
|
+
"types": "./dist/settings.d.ts",
|
|
132
|
+
"default": "./src/layered-settings.ts"
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
`rollup.dts.config.mjs` becomes an array with a second entry (`input: "src/layered-settings.ts"` → `dist/settings.d.ts`), same plugin/externals.
|
|
138
|
+
`files` already covers `src` and `dist`, so no allowlist change.
|
|
139
|
+
|
|
140
|
+
### Edge cases
|
|
141
|
+
|
|
142
|
+
- Both files missing → `{}` (no warn).
|
|
143
|
+
- A file present but unparseable → warn once per bad file, that layer treated as `{}`.
|
|
144
|
+
- Both present → shallow merge, project wins per key.
|
|
145
|
+
- `sanitize` returning `{}` for garbage shapes is the caller's concern; the helper never inspects field types.
|
|
146
|
+
|
|
147
|
+
## Module-Level Changes
|
|
148
|
+
|
|
149
|
+
- `packages/pi-subagents/src/layered-settings.ts` — **new**.
|
|
150
|
+
Exports `loadLayeredSettings` and `LayeredSettingsSource<T>`; imports only `node:fs` (`existsSync`, `readFileSync`) and `node:path` (`join`).
|
|
151
|
+
Private helpers (path builders, single-file reader) stay in-file below the public function (stepdown order).
|
|
152
|
+
- `packages/pi-subagents/src/settings.ts`
|
|
153
|
+
- `loadSettings` delegates to `loadLayeredSettings`; drop the `process.cwd()` default.
|
|
154
|
+
- Remove private `readSettingsFile` and `globalPath` (no remaining callers once `loadSettings` delegates).
|
|
155
|
+
- Keep `projectPath` (still used by `saveSettings`), `sanitize`, `saveSettings`, `persistToastFor`, `SettingsManager`.
|
|
156
|
+
- `packages/pi-subagents/package.json` — add the `"./settings"` conditional export.
|
|
157
|
+
- `packages/pi-subagents/rollup.dts.config.mjs` — export an array; add the `src/layered-settings.ts` → `dist/settings.d.ts` entry.
|
|
158
|
+
- `packages/pi-subagents/scripts/verify-public-types.sh` — add a self-containment guard for `dist/settings.d.ts` (no `#src`, carries `loadLayeredSettings`) and a probe importing `loadLayeredSettings` from `@gotgenes/pi-subagents/settings`.
|
|
159
|
+
- `packages/pi-subagents/test/layered-settings.test.ts` — **new** unit tests (see TDD Order).
|
|
160
|
+
- `packages/pi-subagents/docs/architecture/architecture.md`
|
|
161
|
+
- Step 9 entry (lines ~1027-1033): record the chosen approach (extraction via `./settings` subpath; internal adoption; worktrees migration deferred to a follow-up) and restate the Outcome (see Risks — full fallow elimination completes with the follow-up).
|
|
162
|
+
- "Production duplication" metric note (line ~900) and any roadmap-summary line referencing the 23-line cross-package clone: update once the pi-subagents copy is gone.
|
|
163
|
+
- `packages/pi-subagents/README.md` and/or `.pi/skills/package-pi-subagents/SKILL.md` — note the new `@gotgenes/pi-subagents/settings` public entry and its contract (third-party-author documentation).
|
|
164
|
+
|
|
165
|
+
No symbol named in this plan is referenced by `.pi/skills/package-*/SKILL.md` today (`readSettingsFile` / `globalPath` are private and not documented) — verified by grep; the only SKILL/README edits are additive (documenting the new export).
|
|
166
|
+
|
|
167
|
+
## Test Impact Analysis
|
|
168
|
+
|
|
169
|
+
1. New tests this enables: direct unit coverage of the generic loader — the `warnLabel`-parametrized warning prefix, an arbitrary `filename`, and an arbitrary `sanitize` — none of which `settings.test.ts` can reach because it only exercises the `"subagents.json"` / `"pi-subagents"` wiring.
|
|
170
|
+
2. Existing tests that become partly redundant: the generic mechanics in `settings.test.ts` (missing→{}, malformed→warn, project-over-global precedence) are now also covered at the lower level.
|
|
171
|
+
They are **kept** — they still genuinely exercise `loadSettings`'s wiring and the `SubagentsSettings`-specific `sanitize` (numeric ceilings), which the lower-level tests do not.
|
|
172
|
+
3. Tests that must stay as-is: all of `settings.test.ts` (wiring + `SubagentsSettings` sanitize + `saveSettings` + `persistToastFor`).
|
|
173
|
+
|
|
174
|
+
## Invariants at risk
|
|
175
|
+
|
|
176
|
+
`settings.ts` was not refactored by an earlier Phase 17 step (it is outside the `Subagent`/lifecycle cluster), so no prior step's documented `Outcome:` is regressed.
|
|
177
|
+
The invariant under change is `loadSettings`'s observable behavior (merge precedence, silent-missing, warn-malformed) — pinned by the existing `settings.test.ts` cases, which stay green throughout the refactor.
|
|
178
|
+
No new pin is needed.
|
|
179
|
+
|
|
180
|
+
## TDD Order
|
|
181
|
+
|
|
182
|
+
1. **Add `loadLayeredSettings` + unit tests.**
|
|
183
|
+
Red: `test/layered-settings.test.ts` driving the helper directly via temp dirs (mirroring `settings.test.ts`'s tmp-dir pattern): missing files → `{}`; malformed file → `console.warn` with the `[warnLabel]` prefix + `{}`; project overrides global; custom `filename` resolves `<agentDir>/<filename>` and `<cwd>/.pi/<filename>`; `sanitize` applied to parsed JSON.
|
|
184
|
+
Green: implement `src/layered-settings.ts`.
|
|
185
|
+
The test import keeps the new export live for fallow dead-code.
|
|
186
|
+
Commit: `feat(pi-subagents): add loadLayeredSettings layered config loader`.
|
|
187
|
+
2. **Adopt internally in `settings.ts`.**
|
|
188
|
+
Refactor `loadSettings` to delegate to `loadLayeredSettings` (with the `satisfies` call site); remove `readSettingsFile` and `globalPath`.
|
|
189
|
+
No test changes — the existing `settings.test.ts` suite (including the `/Ignoring malformed settings/` warn assertion) must stay green, proving behavior preservation.
|
|
190
|
+
Run `pnpm fallow dead-code` to confirm no orphaned private helper remains.
|
|
191
|
+
Commit: `refactor(pi-subagents): load settings via loadLayeredSettings`.
|
|
192
|
+
3. **Publish the `./settings` subpath + document it.**
|
|
193
|
+
Add the `"./settings"` export, the second `rollup.dts.config.mjs` entry, and the `verify-public-types.sh` guard + probe; add the third-party-author JSDoc/README/SKILL note.
|
|
194
|
+
Run `pnpm run build:types` and `pnpm run verify:public-types` — the new `dist/settings.d.ts` must be `#src`-free and the subpath probe must type-check.
|
|
195
|
+
Commit: `feat(pi-subagents): export loadLayeredSettings via ./settings subpath`.
|
|
196
|
+
4. **Record the decision in the architecture doc.**
|
|
197
|
+
Update the Step 9 roadmap entry and the duplication-metric notes to reflect extraction + the deferred worktrees follow-up; run `pnpm fallow:dupes --skip-local` and record whether the cross-package pair still reports (see Risks).
|
|
198
|
+
Commit: `docs(pi-subagents): record settings-loader extraction decision (#380)`.
|
|
199
|
+
|
|
200
|
+
## Risks and Mitigations
|
|
201
|
+
|
|
202
|
+
- **The cross-package clone may still report after this plan.**
|
|
203
|
+
Worktrees' copy persists until the follow-up.
|
|
204
|
+
The extracted helper is generic (parametrized `warnLabel`/`sanitize`), so its token sequence diverges from worktrees' inlined copy; the contiguous identical run likely drops below fallow's `min-lines: 5`, dissolving the pair — but this is not guaranteed.
|
|
205
|
+
Mitigation: Step 4 runs `fallow:dupes --skip-local` and records the actual result.
|
|
206
|
+
Definitive elimination of the pair is an explicit outcome of the worktrees follow-up, not a hard gate on this plan; this plan's measurable outcome is that pi-subagents owns and publishes the canonical loader.
|
|
207
|
+
- **`rollup-plugin-dts` second-entry resolution.**
|
|
208
|
+
Low risk — the module is dependency-free apart from `node:*`.
|
|
209
|
+
Step 3's `build:types` + `verify:public-types` is the checkpoint; if `dist/settings.d.ts` is not self-contained, stop and reassess before wiring the export.
|
|
210
|
+
- **Permanent public-API surface growth.**
|
|
211
|
+
Accepted per the [#380] decision; mitigated by isolating the helper on its own subpath so the service contract stays minimal, and by documenting it for third-party authors.
|
|
212
|
+
- **`loadSettings` signature change (dropped `process.cwd()` default).**
|
|
213
|
+
Internal only — the sole caller passes `cwd` explicitly; verified by grep.
|
|
214
|
+
|
|
215
|
+
## Open Questions
|
|
216
|
+
|
|
217
|
+
- Exact new module filename (`src/layered-settings.ts` proposed) — settle in Step 1.
|
|
218
|
+
- Whether the third-party docs live in `README.md`, the package `SKILL.md`, or both — settle in Step 3.
|
|
219
|
+
- The follow-up worktrees-migration issue number — created at ship time; referenced from the Step 9 roadmap entry.
|
|
220
|
+
|
|
221
|
+
[#380]: https://github.com/gotgenes/pi-packages/issues/380
|
|
222
|
+
[ADR-0003]: ../decisions/0003-publish-bundled-type-declarations.md
|
|
223
|
+
[code-design]: ../../../../.pi/skills/code-design/SKILL.md
|