@gotgenes/pi-subagents 16.3.1 → 16.4.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,100 @@
1
+ ---
2
+ issue: 378
3
+ issue_title: "Consolidate lifecycle test fixtures"
4
+ ---
5
+
6
+ # Retro: #378 — Consolidate lifecycle test fixtures
7
+
8
+ ## Stage: Planning (2026-06-15T00:00:00Z)
9
+
10
+ ### Session summary
11
+
12
+ Planned Phase 17 Step 7 — consolidating the lifecycle test clone families into shared/file-local helpers.
13
+ Produced `docs/plans/0378-consolidate-lifecycle-test-fixtures.md` with a six-step lift-and-shift TDD order and committed it.
14
+
15
+ ### Observations
16
+
17
+ - The issue body is stale relative to `main`: it cites five families across six files (including `concurrency-queue.test.ts` and a 766-LOC `subagent-manager.test.ts`).
18
+ Measuring with `fallow dupes -r packages/pi-subagents` against today's `main` shows **four** lifecycle families — Steps 1–6 already removed the queue (`concurrency-queue.test.ts` → `concurrency-limiter.test.ts`) and the `subagent.test.ts`/`concurrency-limiter.test.ts` families.
19
+ The plan is written against the measured current state, not the issue snapshot.
20
+ - Design call: promote to `test/helpers/` only the genuinely cross-file duplication (the `createSubagentSession`-test mock-session builder, shared by `create-subagent-session.test.ts` and `create-subagent-session-extension-tools.test.ts` — `createFactorySession`).
21
+ The manager and `subagent-session` families are intra-file (fallow recommends same-file extraction), so they get file-local helpers.
22
+ Force-promoting intra-file families to `test/helpers/` would manufacture cross-file coupling that does not exist.
23
+ - Resisted extracting the `io.createSession.mockResolvedValue(...)` + `createSubagentSession(...)` invoke pair into a helper — two lines with per-test varying overrides; wrapping the system-under-test call would be procedure-splitting, not design improvement.
24
+ - Invariants at risk flagged: Step 1/Step 3's "every spawned agent has a `promise` at spawn" (pinned by the queued-promise test) and Step 3's "zero external `.promise`/`.notification` writes outside `subagent.ts`" (grep-verifiable).
25
+ `arrangeQueuedPair()` must return the queued id; Step 4 folds in a re-grep.
26
+ - Baseline: package test duplication 669 lines / 3.3% across 20 files; the four lifecycle families total ~122 lines, so Step 7 alone should land below the 600-line goal (~547).
27
+ Flagged as an Open Question pending the Step 6 `fallow dupes` measurement.
28
+ - Not breaking — test-only, no `src/`, public-surface, or behavior change.
29
+
30
+ ## Stage: Implementation — TDD (2026-06-15T23:10:00Z)
31
+
32
+ ### Session summary
33
+
34
+ Executed the lifecycle test fixture consolidation across 8 commits.
35
+ Added a shared `createFactorySession` builder, migrated the four lifecycle clone families, and (on operator steer) folded `create-subagent-session-extension-tools.test.ts` into `create-subagent-session.test.ts`, deleting the file.
36
+ Package test duplication dropped 669 → 512 lines (under the 600 goal); test count 1005 → 1010 (+5 `createFactorySession` self-tests); test files 64 → 63.
37
+ Pre-completion reviewer: PASS.
38
+
39
+ ### Observations
40
+
41
+ - Plan premise was wrong on one point: extracting `createFactorySession` alone did **not** collapse the create-subagent-session families — the dominant clone was the arrange-act invoke block, not the builder.
42
+ I first extracted a `runCreate`/`runCreateWith` act-helper to hit the metric, then the operator flagged it: mixing arrange + act hides the system under test, and arranges should be grouped by `describe`.
43
+ Reworked to AAA — describe-scoped `beforeEach` for arrange, `createSubagentSession(...)` act kept explicit per test.
44
+ - The operator relaxed the roadmap's "lifecycle families ≤ 1" Outcome.
45
+ Two families remain by design (the repeated act with test-specific arrange); documented as intentional in `architecture.md` Step 7.
46
+ Lesson recorded: a clone-count metric is a weak signal for *test* code — AAA structure beats it, and chasing the metric produced the wrong abstraction before it was caught.
47
+ - `programTurns(session, listeners, turns)` is a legitimate arrange helper for the turn-limit tests (turn count is the meaningful input, not a discriminator flag); removed the restated-boundary comments per `code-design` (names/args over comments).
48
+ - Folding the extension-tools tests was safe because the recursion guard reads only the session mock's `getActiveToolNames`/`setActiveToolsByName`; the agent config and `type` don't affect those assertions (mocked `io.createSession` ignores `cfg.toolNames`).
49
+ - Surfaced three overlapping session-mock builders (`createMockSession`, `createSubagentSessionStub`, `createFactorySession`); filed [#412] as a follow-up rather than expanding #378 scope.
50
+ - Cross-step invariants verified intact: queued-agent "promise at spawn" ([#374]) test preserved through the `arrangeQueuedPair` extraction; zero external `.promise`/`.notification` writes in `test/lifecycle/`.
51
+
52
+ ## Stage: Final Retrospective (2026-06-15T23:45:00Z)
53
+
54
+ ### Session summary
55
+
56
+ Shipped Phase 17 Step 7 across plan → TDD → ship in one continuous session: 11 `test:`/`docs:` commits, package test duplication 669 → 512 lines, test files 64 → 63, tests 1005 → 1010.
57
+ The dominant arc was a metric-driven detour — an act-wrapping helper extracted to satisfy the roadmap's "families ≤ 1" target, caught by the operator and reworked into an AAA structure (describe-scoped arrange, visible act).
58
+ CI passed, issue closed, no release (test/docs commits only); follow-up [#412] filed.
59
+
60
+ ### Observations
61
+
62
+ #### What went well
63
+
64
+ - The "no sacred cows" invitation → structured `ask_user` multi-select produced a real improvement: deleting the redundant `create-subagent-session-extension-tools.test.ts` entirely (#1) and filing [#412], rather than accepting the status quo.
65
+ Self-critique under explicit invitation surfaced a whole-file removal the plan had not considered.
66
+ - Verification was incremental, not end-only: `pnpm run check` plus a per-file `vitest run` plus a `fallow dupes` re-measurement ran between every TDD step, so each consolidation's effect on the clone count was visible immediately.
67
+ - Cross-step invariant discipline held: the queued-agent "promise at spawn" ([#374]) test survived the `arrangeQueuedPair` extraction, and the grep for external `.promise`/`.notification` writes stayed at zero.
68
+ - The release-timing gate correctly fired on the phased roadmap (Phase 17 Step 7 of 9) and surfaced the batch-vs-now choice instead of releasing silently.
69
+
70
+ #### What caused friction (agent side)
71
+
72
+ - `wrong-abstraction` — extracted a `runCreate`/`runCreateWith` act-helper that bundled arrange + the `createSubagentSession(...)` act, hiding the system under test, purely to drive the clone count to the roadmap's "families ≤ 1" target.
73
+ User-caught.
74
+ Impact: one commit (`96acc6c7`) committed then undone via `git reset --soft HEAD~1` and reworked into the AAA structure (`14375b07`); ~1 commit of rework, caught before ship.
75
+ - `missing-context` — the plan asserted `createFactorySession` was "the larger half of each clone" without decomposing the clone; the post-Step-3 `fallow dupes` measurement showed the arrange-act invoke block was the dominant half, which is what drove the wrong-abstraction detour.
76
+ Impact: a wrong planning premise that fed the rework above.
77
+ A `fallow dupes --trace <file:line>` per-line decomposition during planning would have shown the split.
78
+ - `other` (tool misuse) — first invoked `set_session_name` as a shell command (`command not found`), then self-corrected to the tool call.
79
+ Impact: one wasted call, no rework.
80
+
81
+ #### What caused friction (user side)
82
+
83
+ - The operator's redirect on arrange/act mixing landed after the act-helper was already committed.
84
+ The natural self-checkpoint was earlier: when the Step-3 measurement contradicted the plan's "builder is the larger half" premise, that contradiction was itself a deviation worth surfacing via `ask_user` ("wrap the act to hit the metric, or accept the residual for better structure?") rather than pushing forward to the helper.
85
+ Framed as opportunity: treat a plan's quantitative premise proving wrong as a stop-and-ask trigger, not a cue to escalate the abstraction.
86
+
87
+ ### Diagnostic details
88
+
89
+ - **Model-performance correlation** — one subagent dispatch (`pre-completion-reviewer`) on a judgment-heavy review task; appropriate match, no mismatch.
90
+ - **Escalation-delay tracking** — no rabbit-hole: the wrong-abstraction was a single committed approach redirected by the operator, not a repeated-error loop, so no 5+ consecutive same-error sequence.
91
+ - **Unused-tool detection** — `fallow dupes --trace <file:line>` was available and would have decomposed the clone into builder-vs-act portions during planning; it was not used, and its absence is what let the wrong premise stand.
92
+ - **Feedback-loop gap analysis** — no gap; checks ran incrementally after each step.
93
+
94
+ ### Changes made
95
+
96
+ 1. `.pi/skills/testing/SKILL.md` (§ Test organization) — added a rule to group shared arrange in a describe-scoped `beforeEach`, keep the act explicit, and not wrap the system-under-test call to eliminate a duplication-metric clone.
97
+ 2. `.pi/prompts/tdd-plan.md` — sharpened the deviation rule: a quantitative target (LOC, clone count, complexity) that does not fall out as predicted is a stop-and-ask deviation, re-decided via `ask_user` rather than forcing the number.
98
+
99
+ [#374]: https://github.com/gotgenes/pi-packages/issues/374
100
+ [#412]: https://github.com/gotgenes/pi-packages/issues/412
@@ -0,0 +1,52 @@
1
+ ---
2
+ issue: 379
3
+ issue_title: "Consolidate UI and tools test fixtures"
4
+ ---
5
+
6
+ # Retro: #379 — Consolidate UI and tools test fixtures
7
+
8
+ ## Stage: Planning (2026-06-16T00:00:00Z)
9
+
10
+ ### Session summary
11
+
12
+ Produced a 9-step lift-and-shift plan for Phase 17 Step 8 — consolidating the non-lifecycle test clone families.
13
+ Measured the live baseline (`fallow dupes -r packages/pi-subagents`: 32 clone groups / 512 lines / 2.49%, seven families) and characterized each family by reading all seven test files, classifying each as genuine cross-file duplication (one) versus intra-file (six).
14
+
15
+ ### Observations
16
+
17
+ - The only genuine cross-file duplication is the `ResolvedSpawnConfig` builder shared by `foreground-runner.test.ts` and `background-spawner.test.ts` (`dup:80ee2004`) — the one promotion to `test/helpers/` (`make-spawn-config.ts`).
18
+ Everything else fallow scores same-file, so it stays file-local or moves into a describe-scoped `beforeEach`, per Step 7's ([#378]) discipline.
19
+ - The plan's central constraint is Step 7's hard-won lesson: never wrap the system-under-test **act** in a helper to chase a duplication metric.
20
+ Every extracted helper returns a value (`createResolvedSpawnConfig`, `disabledConfig`, `exploreConfig`, `createManagerStub`, `spawnAndWaitRegistering`) or seeds `beforeEach`; the acts stay inline.
21
+ Added an "Invariants at risk" grep check to enforce this at review.
22
+ - Scope was genuinely ambiguous and resolved via `ask_user`: the issue body names six files, but fallow reports a seventh non-lifecycle family in `test/service/service-adapter.test.ts` (four near-identical `SubagentManagerLike` stubs).
23
+ Operator chose to include service-adapter (seven files total) and to **not** bind a numeric group target — acceptance is "each named family consolidated, resulting fallow numbers reported."
24
+ My `ask_user` prompt incorrectly claimed the architecture roadmap's Step 8 `Targets` lists service-adapter; it does not (it lists the six issue-body files).
25
+ The plan corrects this and notes service-adapter was added in planning.
26
+ - The roadmap's stated Step 8 outcome ("clone groups 44 → ≤ 25; overall duplication ≤ 0.6%") predates Steps 1–7 and does not match the current fallow metric (2.49%); flagged as an Open Question rather than treated as binding.
27
+ - Non-Goals call out the two residual lifecycle families (Step 7 left them as the visible act), the 11-line production clone inside `src/ui/agent-config-editor.ts` (test-only issue), and the three overlapping session-mock builders ([#412]).
28
+ - `ResolvedSpawnConfig` is deeply nested (`identity`/`execution`/`presentation` with mirrored scalars).
29
+ Designed the shared builder to take **flat** options and assemble the nested structure internally — sidesteps the `Partial<T>` deep-merge trap and encapsulates the mirroring (`agentInvocation.runInBackground`, `presentation.detailBase`) the hand-built copies duplicate.
30
+
31
+ ## Stage: Implementation — TDD (2026-06-16T13:10:00Z)
32
+
33
+ ### Session summary
34
+
35
+ Executed all 9 plan steps as green-to-green lift-and-shift refactors (no red phase — the existing assertions were the spec), one commit per step plus the docs commit.
36
+ Promoted the shared `createResolvedSpawnConfig` builder to `test/helpers/` (+5 self-tests) and consolidated the six remaining families with file-local value-returning builders / `beforeEach` setup.
37
+ Test count 1010 → 1015; test files 63 → 64; clone groups 32 → 24; package test duplication 512 → 355 lines (2.49% → 1.73%).
38
+
39
+ ### Observations
40
+
41
+ - The `agent-config-editor.test.ts` family did not collapse as the plan loosely estimated ("4 → ideally 2"): it stayed at 4 groups / 28 lines.
42
+ The `disabledConfig` helper removed only the small `enabled: false` arrange; the 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 (Step 7 lesson).
43
+ No `ask_user` was needed: acceptance was "consolidate and measure" (no binding numeric target), and the only way to force the number would violate the plan's stated discipline.
44
+ - `service-adapter.test.ts` had four near-identical `SubagentManagerLike` stub factories with diverging defaults (`hasRunning` true vs false, varying `spawn` returns).
45
+ Consolidated into one file-local `createManagerStub()` with **unannotated** return (typed `vi.fn<SubagentManagerLike[...]>()` stubs) so callers retain `Mock<...>` methods — per the testing-skill rule that annotating the return with the production interface, or spreading `Partial<Interface>` overrides, erases mock methods.
46
+ Dropped the overrides parameter entirely; per-test behavior is configured on the returned object (`mgr.getRecord.mockImplementation(...)`, `mgr.hasRunning.mockReturnValue(true)`).
47
+ Used `sed` to swap the `defaultManager()` / `createTestManager()` call sites after deleting the factory definitions.
48
+ - `ui-observer` update-counting tests moved from closure `let updateCount` counters to `vi.fn()` onUpdate with `toHaveBeenCalledTimes` — removes the repeated scaffolding without hiding the `session.emit` act.
49
+ - Pre-completion reviewer: WARN — sole finding was this missing TDD retro entry (now written); all deterministic checks PASS, cross-step invariant ("don't wrap the act") verified upheld, 6 Mermaid blocks validated, dead-code clean.
50
+
51
+ [#378]: https://github.com/gotgenes/pi-packages/issues/378
52
+ [#412]: https://github.com/gotgenes/pi-packages/issues/412
@@ -0,0 +1,48 @@
1
+ ---
2
+ issue: 380
3
+ issue_title: "Resolve the cross-package settings-loader duplication"
4
+ ---
5
+
6
+ # Retro: #380 — Resolve the cross-package settings-loader duplication
7
+
8
+ ## Stage: Planning (2026-06-16T00:00:00Z)
9
+
10
+ ### Session summary
11
+
12
+ Planned the resolution of the 23-line cross-package production clone between `src/settings.ts` and `@gotgenes/pi-subagents-worktrees`'s `src/config.ts`.
13
+ Issue #380 posed a binary: extract a shared loader vs. document-and-suppress.
14
+ The operator chose extraction, delivered as a dedicated subpath export `@gotgenes/pi-subagents/settings`, sequenced as two stages (this plan lands the helper in pi-subagents; a follow-up migrates worktrees).
15
+ The plan is a single-package TDD plan in `packages/pi-subagents/docs/plans/0380-extract-layered-settings-loader.md`.
16
+
17
+ ### Observations
18
+
19
+ - The issue is the operator's own (author `gotgenes` == gh user), so the "Proposed change" was a hypothesis.
20
+ Used `ask_user` twice: first to confirm Option 1 vs Option 2 (operator picked extract, then asked for explicit for/against), then to pin API placement (`./settings` subpath, recommended) and release sequencing (two-stage, follow-up for worktrees).
21
+ - Key mechanism finding that shaped the framing: fallow's `code-duplication` suppression is **file-scoped only** (`// fallow-ignore-file code-duplication`) — there is no line-level next-line directive, contrary to the issue's "rationale on the line above" phrasing for Option 2.
22
+ This would have mattered had Option 2 been chosen.
23
+ - The shared idiom is the read-sanitize-warn-merge mechanism; the per-package `sanitize` (numeric ceilings vs `string[]`) is the genuinely-different part.
24
+ A generic `loadLayeredSettings<T>({ sanitize, warnLabel, filename, agentDir, cwd })` is a clean extraction with no discriminator parameter.
25
+ - Honest Outcome caveat captured in the plan: because worktrees keeps its copy until the follow-up, `fallow:dupes` may still report a residual (or dissolve below `min-lines: 5`, since the generic helper's tokens diverge).
26
+ Definitive elimination is the follow-up's outcome, not a hard gate on this plan.
27
+ - Followed the `0270-type-consumable-public-surface.md` plan as the template for the `.d.ts`-bundle + `verify:public-types` machinery; the new subpath extends that rather than introducing a new mechanism.
28
+ - `loadSettings`'s `process.cwd()` default is dropped per the code-design "no `process.*` in library functions" rule; the sole caller (`SettingsManager.load`) already passes `this.cwd` (verified by grep).
29
+ - The worktrees-migration follow-up issue should be created at ship time and back-referenced from the architecture Step 9 roadmap entry.
30
+
31
+ ## Stage: Implementation — TDD (2026-06-16T16:00:00Z)
32
+
33
+ ### Session summary
34
+
35
+ Completed all 4 TDD steps from the plan: added `src/layered-settings.ts` with 15 unit tests, refactored `settings.ts` to delegate through the helper (removing `readSettingsFile` and `globalPath`), published the `@gotgenes/pi-subagents/settings` subpath export with a rolled `dist/settings.d.ts` and extended `verify:public-types` harness, and recorded the decision in the architecture doc.
36
+ Test count grew from 1015 to 1030 (+15).
37
+ Pre-completion reviewer returned **PASS**.
38
+ Follow-up issue [#415] created for the worktrees migration.
39
+
40
+ ### Observations
41
+
42
+ - The plan's "Outcome caveat" resolved favourably: `pnpm fallow:dupes --skip-local` no longer reports the `settings.ts` ↔ `config.ts` pair after the extraction.
43
+ The parametrised helper's token sequence diverged enough that the contiguous identical run dropped below the reporting threshold — a better outcome than the plan's hedged prediction.
44
+ - ESLint's pre-commit hook removed `!` non-null assertions from `spy.mock.calls[0]![0]` in the test file (typed `vi.spyOn` mock calls are non-optional tuples; the assertions were redundant).
45
+ Staged the auto-fix into the same commit without issue.
46
+ - The `rollup.dts.config.mjs` array-of-configs approach worked without incident: both bundles (`dist/public.d.ts` and `dist/settings.d.ts`) are self-contained and `verify:public-types` confirmed both probes type-check against the packaged tarball.
47
+ - The `satisfies LayeredSettingsSource<SubagentsSettings>` annotation at the `loadSettings` call site serves double duty: validates the object literal and keeps `LayeredSettingsSource` referenced for fallow dead-code (fallow confirmed: 0 issues).
48
+ - Follow-up issue [#415] created before the TDD stage notes were written (operator requested it during the session); architecture doc updated with the `[#415]` reference and link definition.
package/package.json CHANGED
@@ -1,11 +1,15 @@
1
1
  {
2
2
  "name": "@gotgenes/pi-subagents",
3
- "version": "16.3.1",
3
+ "version": "16.4.0",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": {
7
7
  "types": "./dist/public.d.ts",
8
8
  "default": "./src/service/service.ts"
9
+ },
10
+ "./settings": {
11
+ "types": "./dist/settings.d.ts",
12
+ "default": "./src/layered-settings.ts"
9
13
  }
10
14
  },
11
15
  "files": [
@@ -0,0 +1,105 @@
1
+ /**
2
+ * Generic layered settings loader for `@gotgenes/pi-*` extensions.
3
+ *
4
+ * Extensions that store configuration in JSON files under a global agent
5
+ * directory and a per-project `.pi/` folder share the same three-step idiom:
6
+ *
7
+ * 1. Read the global file (`<agentDir>/<filename>`).
8
+ * 2. Read the project file (`<cwd>/.pi/<filename>`).
9
+ * 3. Merge them — project wins on conflicts — and return the result.
10
+ *
11
+ * Both layers are optional: a missing file is silent (`{}`), and a file that
12
+ * cannot be parsed warns to stderr and is treated as absent so startup
13
+ * proceeds normally.
14
+ *
15
+ * ## Usage
16
+ *
17
+ * ```typescript
18
+ * import { loadLayeredSettings, type LayeredSettingsSource } from "@gotgenes/pi-subagents/settings";
19
+ *
20
+ * interface MyConfig { enabled?: boolean; limit?: number }
21
+ *
22
+ * function sanitize(raw: unknown): Partial<MyConfig> {
23
+ * if (!raw || typeof raw !== "object") return {};
24
+ * const r = raw as Record<string, unknown>;
25
+ * const out: Partial<MyConfig> = {};
26
+ * if (typeof r.enabled === "boolean") out.enabled = r.enabled;
27
+ * if (typeof r.limit === "number") out.limit = r.limit;
28
+ * return out;
29
+ * }
30
+ *
31
+ * const config = loadLayeredSettings<MyConfig>({
32
+ * agentDir, // e.g. from the Pi runtime env — the agent home directory
33
+ * cwd, // project root — project file is at <cwd>/.pi/<filename>
34
+ * filename: "my-extension.json",
35
+ * sanitize,
36
+ * warnLabel: "my-extension",
37
+ * });
38
+ * ```
39
+ *
40
+ * @public
41
+ */
42
+
43
+ import { existsSync, readFileSync } from "node:fs";
44
+ import { join } from "node:path";
45
+
46
+ /**
47
+ * Parameters for one layered settings load: describes where the files live,
48
+ * how to validate their contents, and what label to use in warnings.
49
+ *
50
+ * @public
51
+ */
52
+ export interface LayeredSettingsSource<T> {
53
+ /** Directory holding the global settings file (typically the Pi agent dir). */
54
+ agentDir: string;
55
+ /** Project root; the project file lives at `<cwd>/.pi/<filename>`. */
56
+ cwd: string;
57
+ /** Base filename for both layers, e.g. `"subagents.json"`. */
58
+ filename: string;
59
+ /**
60
+ * Validate and coerce parsed JSON into a partial settings object.
61
+ * Unknown or invalid fields should be silently dropped — return `{}` for
62
+ * unrecognised shapes. Never throw.
63
+ */
64
+ sanitize: (raw: unknown) => Partial<T>;
65
+ /**
66
+ * Short label used in the malformed-file warning prefix,
67
+ * e.g. `"pi-subagents"` → `"[pi-subagents] Ignoring malformed settings at …"`.
68
+ */
69
+ warnLabel: string;
70
+ }
71
+
72
+ /**
73
+ * Load merged layered settings: global provides defaults, project overrides.
74
+ *
75
+ * - A missing file is silent — returns `{}` for that layer.
76
+ * - A file that exists but cannot be parsed warns to stderr and returns `{}` for
77
+ * that layer, so startup proceeds normally.
78
+ * - The two layers are merged with a shallow spread; project keys win.
79
+ *
80
+ * Throws nothing. All error conditions produce a warning and fall back to `{}`.
81
+ *
82
+ * @public
83
+ */
84
+ export function loadLayeredSettings<T>(source: LayeredSettingsSource<T>): Partial<T> {
85
+ const { agentDir, cwd, filename, sanitize, warnLabel } = source;
86
+ const global = readLayer(join(agentDir, filename), sanitize, warnLabel);
87
+ const project = readLayer(join(cwd, ".pi", filename), sanitize, warnLabel);
88
+ return { ...global, ...project };
89
+ }
90
+
91
+ // ── Private helpers ──────────────────────────────────────────────────────────
92
+
93
+ /**
94
+ * Read one settings file. Missing → `{}` (silent). Malformed → `{}` + warn.
95
+ */
96
+ function readLayer<T>(path: string, sanitize: (raw: unknown) => Partial<T>, warnLabel: string): Partial<T> {
97
+ if (!existsSync(path)) return {};
98
+ try {
99
+ return sanitize(JSON.parse(readFileSync(path, "utf-8")));
100
+ } catch (err) {
101
+ const reason = err instanceof Error ? err.message : String(err);
102
+ console.warn(`[${warnLabel}] Ignoring malformed settings at ${path}: ${reason}`);
103
+ return {};
104
+ }
105
+ }
package/src/settings.ts CHANGED
@@ -2,8 +2,9 @@
2
2
  // - Global: ~/.pi/agent/subagents.json (agentDir injected at construction) — manual defaults, never written here
3
3
  // - Project: <cwd>/.pi/subagents.json — written by /agents → Settings; overrides global on load
4
4
 
5
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
5
+ import { mkdirSync, writeFileSync } from "node:fs";
6
6
  import { dirname, join } from "node:path";
7
+ import { type LayeredSettingsSource, loadLayeredSettings } from "#src/layered-settings";
7
8
  export interface SubagentsSettings {
8
9
  maxConcurrent?: number;
9
10
  /**
@@ -181,33 +182,19 @@ function sanitize(raw: unknown): SubagentsSettings {
181
182
  return out;
182
183
  }
183
184
 
184
- function globalPath(agentDir: string): string {
185
- return join(agentDir, "subagents.json");
186
- }
187
-
188
185
  function projectPath(cwd: string): string {
189
186
  return join(cwd, ".pi", "subagents.json");
190
187
  }
191
188
 
192
- /**
193
- * Read a settings file. Missing file is silent (returns `{}`). A file that
194
- * exists but can't be parsed emits a warning to stderr so users aren't
195
- * silently reverted to defaults — and still returns `{}` so startup proceeds.
196
- */
197
- function readSettingsFile(path: string): SubagentsSettings {
198
- if (!existsSync(path)) return {};
199
- try {
200
- return sanitize(JSON.parse(readFileSync(path, "utf-8")));
201
- } catch (err) {
202
- const reason = err instanceof Error ? err.message : String(err);
203
- console.warn(`[pi-subagents] Ignoring malformed settings at ${path}: ${reason}`);
204
- return {};
205
- }
206
- }
207
-
208
189
  /** Load merged settings: global provides defaults, project overrides. */
209
- export function loadSettings(agentDir: string, cwd: string = process.cwd()): SubagentsSettings {
210
- return { ...readSettingsFile(globalPath(agentDir)), ...readSettingsFile(projectPath(cwd)) };
190
+ export function loadSettings(agentDir: string, cwd: string): SubagentsSettings {
191
+ return loadLayeredSettings({
192
+ agentDir,
193
+ cwd,
194
+ filename: "subagents.json",
195
+ sanitize,
196
+ warnLabel: "pi-subagents",
197
+ } satisfies LayeredSettingsSource<SubagentsSettings>);
211
198
  }
212
199
 
213
200
  /**