pi-soly 0.7.0 → 0.9.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/README.md CHANGED
@@ -78,7 +78,7 @@ State lives in `.soly/` — portable, git-friendly, human-readable.
78
78
 
79
79
  ### 🎤 Multi-Question Picker
80
80
 
81
- Claude Code-style `ask_pro` for the LLM. Single-select, multi-select, recommended ⭐, free-text Other.
81
+ Multi-question picker `ask_pro` for the LLM. Tabbed UI: single-select, multi-select, recommended ⭐, free-text Other.
82
82
 
83
83
  ```ts
84
84
  ask_pro({
package/agents-install.ts CHANGED
@@ -34,10 +34,15 @@ const SHIPPED_SKILLS = [
34
34
  ] as const;
35
35
 
36
36
  /** Where pi looks for user agents. Respects HOME/USERPROFILE for
37
- * testability (otherwise we'd always write to the real user home). */
38
- function userAgentsDir(): string {
37
+ * testability (otherwise we'd always write to the real user home).
38
+ * Path scheme: `<root>/.agents/agents/` (vendor-neutral) and
39
+ * `<root>/.pi/agent/agents/` (pi native, legacy). */
40
+ function userAgentsDirs(): string[] {
39
41
  const home = process.env.HOME || process.env.USERPROFILE || os.homedir();
40
- return path.join(home, ".pi", "agent", "agents");
42
+ return [
43
+ path.join(home, ".agents", "agents"), // vendor-neutral (preferred)
44
+ path.join(home, ".pi", "agent", "agents"), // pi native
45
+ ];
41
46
  }
42
47
 
43
48
  /** Where pi looks for user skills. */
@@ -87,20 +92,27 @@ function copyDirIfMissing(from: string, to: string): "installed" | "skipped" | "
87
92
  }
88
93
  }
89
94
 
90
- /** Install shipped soly agents to `~/.pi/agent/agents/`. Idempotent. */
95
+ /** Install shipped soly agents to `~/.agents/` (vendor-neutral,
96
+ * preferred). Legacy `~/.pi/agent/agents/` copies are left alone —
97
+ * `discoverUserAgents` reads both, so old installs still work. */
91
98
  export function installSolyAgents(extensionRoot: string): InstallResult {
92
99
  const result: InstallResult = { installed: [], skipped: [], errors: [] };
93
100
  const src = shippedAgentsDir(extensionRoot);
94
- const dst = userAgentsDir();
95
101
 
96
102
  if (!fs.existsSync(src)) return result; // dev mode no-op
97
103
 
98
- try {
99
- fs.mkdirSync(dst, { recursive: true });
100
- } catch (err) {
101
- result.errors.push(`mkdir ${dst}: ${(err as Error).message}`);
102
- return result;
104
+ // Try vendor-neutral first, then fall back to pi's native dir.
105
+ let dst: string | null = null;
106
+ for (const candidate of userAgentsDirs()) {
107
+ try {
108
+ fs.mkdirSync(candidate, { recursive: true });
109
+ dst = candidate;
110
+ break;
111
+ } catch (err) {
112
+ result.errors.push(`mkdir ${candidate}: ${(err as Error).message}`);
113
+ }
103
114
  }
115
+ if (!dst) return result;
104
116
 
105
117
  for (const name of SHIPPED_AGENTS) {
106
118
  const from = path.join(src, name);
@@ -145,20 +157,18 @@ export function installSolyAssets(extensionRoot: string): {
145
157
  };
146
158
  }
147
159
 
148
- /** Check which shipped soly agents are present in the user dir. Used by doctor. */
160
+ /** Check which shipped soly agents are present across all user agent
161
+ * homes. A file counts as "installed" if it's in ANY of the dirs. */
149
162
  export function checkSolyAgentsInstalled(extensionRoot: string): {
150
163
  installed: string[];
151
164
  missing: string[];
152
165
  } {
153
- const dst = userAgentsDir();
154
166
  const installed: string[] = [];
155
167
  const missing: string[] = [];
156
168
  for (const name of SHIPPED_AGENTS) {
157
- if (fs.existsSync(path.join(dst, name))) {
158
- installed.push(name);
159
- } else {
160
- missing.push(name);
161
- }
169
+ const present = userAgentsDirs().some((dir) => fs.existsSync(path.join(dir, name)));
170
+ if (present) installed.push(name);
171
+ else missing.push(name);
162
172
  }
163
173
  return { installed, missing };
164
174
  }
package/ask/README.md CHANGED
@@ -1,8 +1,7 @@
1
- # pi-ask — Claude Code-style multi-question picker for pi
1
+ # pi-ask — multi-question picker for pi
2
2
 
3
3
  A small pi-coding-agent extension that registers one tool (`ask_pro`) for
4
- showing a **tabbed, multi-question picker** in pi's TUI. Inspired by Claude
5
- Code's `AskUserQuestion`.
4
+ showing a **tabbed, multi-question picker** in pi's TUI.
6
5
 
7
6
  ## Features
8
7
 
@@ -88,10 +87,10 @@ Result:
88
87
  | `Enter` | **Single-select:** confirm + advance (or submit on last). **Multi-select:** advance to next question (or submit on last + all answered). Does NOT toggle. |
89
88
  | `Esc` | Cancel (returns `{cancelled: true}`) |
90
89
 
91
- Multi-select follows the Claude Code convention: **Space toggles, Enter
92
- advances/submits**. Single-select uses Enter as the universal action key
93
- (toggle/pick + advance). When you're on the last question and all
94
- questions are answered, the footer shows `⏎ submit` in accent color.
90
+ Multi-select: **Space toggles, Enter advances/submits**. Single-select
91
+ uses Enter as the universal action key (toggle/pick + advance). When
92
+ you're on the last question and all questions are answered, the footer
93
+ shows `⏎ submit` in accent color.
95
94
 
96
95
  ## Limits
97
96
 
@@ -101,30 +100,15 @@ questions are answered, the footer shows `⏎ submit` in accent color.
101
100
  - At most 1 `recommended: true` per question
102
101
  - TUI and RPC modes only (`hasUI: true`); print mode returns an error
103
102
 
104
- ## Setup
105
-
106
- Drop the directory in `~/.pi/agent/extensions/`:
107
-
108
- ```bash
109
- ls ~/.pi/agent/extensions/pi-ask/
110
- # index.ts picker.ts tests/ package.json tsconfig.json README.md
111
- ```
112
-
113
- pi auto-discovers and loads it on next start. The `ask_pro` tool is then
114
- available to the LLM. No config required.
115
-
116
103
  ## Development
117
104
 
118
105
  ```bash
119
- cd ~/.pi/agent/extensions/pi-ask
106
+ cd packages/pi-soly/ask
120
107
  bun test # runs tests/picker.test.ts
121
108
  bun run typecheck # tsc --noEmit
122
109
  ```
123
110
 
124
- CI: not configured (this is a single-file TUI component, low risk).
125
- Add `.github/workflows/ci.yml` if you want green-tick PRs.
126
-
127
- ## Why a separate extension?
111
+ ## Why a separate module?
128
112
 
129
113
  The picker is **generic** — any pi extension (soly, your own tool, etc.) can
130
114
  use `ask_pro` to drive multi-question Q&A without re-implementing the TUI.
package/ask/index.ts CHANGED
@@ -3,7 +3,7 @@
3
3
  // =============================================================================
4
4
  //
5
5
  // Registers one LLM tool: `ask_pro`. Lets the LLM ask the user a list of
6
- // questions at once via a Claude Code-style tabbed picker (tabs, numbered
6
+ // questions at once via a tabbed picker (tabs, numbered
7
7
  // options, ⭐ recommended answer, optional multi-select). All answers
8
8
  // returned in a single call.
9
9
  //
@@ -39,7 +39,7 @@ export default function piAskExtension(pi: ExtensionAPI) {
39
39
  name: "ask_pro",
40
40
  label: "pi-ask ask_pro",
41
41
  description:
42
- "Ask the user multiple questions at once via a Claude Code-style tabbed picker. Each question is a tab at the top. Options are numbered (1-N instant-pick), the recommended answer is marked ⭐. Supports single-select (default, auto-advance on pick) and multi-select (Enter toggles, last question shows Submit). All answers returned in one call. Use for progressive Q&A flows like `soly discuss`.",
42
+ "Ask the user multiple questions at once via a tabbed picker. Each question is a tab at the top. Options are numbered (1-N instant-pick), the recommended answer is marked ⭐. Supports single-select (default, auto-advance on pick) and multi-select (Enter toggles, last question shows Submit). All answers returned in one call. Use for progressive Q&A flows like `soly discuss`.",
43
43
  parameters: Type.Object({
44
44
  questions: Type.Array(
45
45
  Type.Object({
package/ask/picker.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  // =============================================================================
2
- // picker.ts — Claude Code-style multi-question picker TUI component
2
+ // picker.ts — multi-question picker TUI component
3
3
  // =============================================================================
4
4
  //
5
5
  // Renders a tabbed multi-question flow inside pi's TUI. One `ask_pro` tool
@@ -493,7 +493,7 @@ export class AskProComponent extends Container {
493
493
  return;
494
494
  }
495
495
 
496
- // Space — toggle in multi-select (Claude Code convention).
496
+ // Space — toggle in multi-select.
497
497
  // On "Other…", opens the input dialog (or toggles existing custom string).
498
498
  // In single-select, Space is a no-op (Enter is the action key there).
499
499
  if (keyData === KEY_SPACE) {
@@ -247,7 +247,7 @@ describe("AskProComponent — multi-select", () => {
247
247
  expect(picker.getAnswers().get(0)).toEqual([0]); // multi preserved
248
248
  });
249
249
 
250
- test("Space toggles current selection in multi-select (Claude Code style)", () => {
250
+ test("Space toggles current selection in multi-select", () => {
251
251
  const { picker } = setup(multiQuestions);
252
252
  picker.handleInput("j"); // selectedIndex = 1 (Tokens)
253
253
  picker.handleInput(" "); // Space toggles
package/index.ts CHANGED
@@ -353,10 +353,14 @@ export default function solyExtension(pi: ExtensionAPI) {
353
353
  // Rules sources (priority order, higher wins on relPath collision).
354
354
  // Project rules always beat global rules. .soly/rules.local/ is
355
355
  // gitignored — for personal overrides on top of the project's rules.
356
+ // .agents/rules/ is the vendor-neutral project-level convention
357
+ // (same role as the old .claude/rules/).
356
358
  ruleSources = [
357
359
  { dir: path.join(ctx.cwd, ".soly", "rules.local"), source: "project-soly", sourceLabel: "local", priority: 5 },
358
360
  { dir: path.join(ctx.cwd, ".soly", "rules"), source: "project-soly", sourceLabel: "soly", priority: 4 },
361
+ { dir: path.join(ctx.cwd, ".agents", "rules"), source: "project-agents", sourceLabel: "agents", priority: 3 },
359
362
  { dir: path.join(os.homedir(), ".soly", "rules"), source: "global-soly", sourceLabel: "soly", priority: 2 },
363
+ { dir: path.join(os.homedir(), ".agents", "rules"), source: "global-agents", sourceLabel: "agents", priority: 1 },
360
364
  ];
361
365
  refreshRules();
362
366
 
package/integrations.ts CHANGED
@@ -29,7 +29,7 @@ export const KNOWN_INTEGRATIONS: KnownIntegration[] = [
29
29
  {
30
30
  tool: "ask_pro",
31
31
  extension: "pi-ask",
32
- summary: "Multi-question tabbed picker (Claude Code style).",
32
+ summary: "Multi-question tabbed picker.",
33
33
  whenToUse:
34
34
  "Use instead of `soly_ask_user` for `soly discuss` flows when you have multiple related questions. PREFERRED in `soly discuss` when available.",
35
35
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-soly",
3
- "version": "0.7.0",
3
+ "version": "0.9.0",
4
4
  "description": "Project management framework for pi-coding-agent. Workflows, planning, multi-question picker, agent switcher, live task list — one npm install, zero config.",
5
5
  "type": "module",
6
6
  "main": "index.ts",
@@ -43,13 +43,15 @@ The **soly** extension adds project-management workflow to [pi-coding-agent](htt
43
43
 
44
44
  ```
45
45
  <project-root>/
46
- ├── .soly/
46
+ ├── AGENTS.md # vendor-neutral agent context (loaded by pi)
47
+ ├── agents.md # same as AGENTS.md, lowercase accepted
48
+ ├── .soly/ # soly state (phases, plans, summaries)
47
49
  │ ├── ROADMAP.md # phase table
48
50
  │ ├── STATE.md # current position + decisions log
49
51
  │ ├── docs/ # 0-point intent docs (human-written, locked)
50
52
  │ │ ├── vision.md
51
53
  │ │ └── architecture.md
52
- │ ├── rules/ # project rules (version-controlled)
54
+ │ ├── rules/ # soly project rules (version-controlled)
53
55
  │ │ ├── code-style.md
54
56
  │ │ └── testing.md
55
57
  │ ├── phases/
@@ -65,8 +67,26 @@ The **soly** extension adds project-management workflow to [pi-coding-agent](htt
65
67
  │ ├── iterations/ # per-execution context bundles (auto)
66
68
  │ ├── HANDOFF.json # pause snapshot
67
69
  │ └── .continue-here.md # pause resume marker
70
+ ├── .agents/ # vendor-neutral agent config (per project)
71
+ │ ├── rules/ # agent rules (loaded with priority 3, after .soly/rules/)
72
+ │ │ ├── code-style.md
73
+ │ │ └── testing.md
74
+ │ ├── skills/ # project-scoped skills (pi auto-discovers)
75
+ │ │ └── my-skill/
76
+ │ │ └── SKILL.md
77
+ │ ├── docs/ # agent-specific docs (intent-style)
78
+ │ │ └── architecture.md
79
+ │ └── agents/ # project-specific agent definitions
80
+ │ ├── project-reviewer.md
81
+ │ └── data-scientist.md
68
82
  ```
69
83
 
84
+ **Two parallel conventions:** `.soly/` is soly-specific state (phases, plans). `.agents/` is vendor-neutral agent config (works with any AI tool that follows the AGENTS.md standard). The two coexist:
85
+
86
+ - Use `.soly/` for soly workflow artifacts (PLAN.md, SUMMARY.md, etc.)
87
+ - Use `.agents/` for things other AI tools should also see (rules, skills, agents)
88
+ - Use `AGENTS.md` for top-level project-wide agent conventions
89
+
70
90
  ## Frontmatter conventions
71
91
 
72
92
  ### PLAN.md frontmatter (required)
@@ -274,8 +294,32 @@ Intent docs are 0-point — written BEFORE any plan, by humans. They define the
274
294
 
275
295
  1. `soly init` (or manually create `.soly/`, `docs/`, `rules/`)
276
296
  2. Write 1-3 intent docs in `.soly/docs/`
277
- 3. Create `ROADMAP.md` with phase table
278
- 4. `/plan 1` to start phase 1
297
+ 3. Optionally write `AGENTS.md` (or `agents.md`) at project root with project conventions
298
+ 4. Create `ROADMAP.md` with phase table
299
+ 5. `/plan 1` to start phase 1
300
+
301
+ ### Add project-specific agents
302
+
303
+ Drop a markdown file in `.agents/agents/<name>.md` (project) or `~/.agents/agents/<name>.md` (user):
304
+
305
+ ```markdown
306
+ ---
307
+ name: data-scientist
308
+ description: Reads CSVs, runs pandas, plots results
309
+ thinking: medium
310
+ tools: read, bash
311
+ ---
312
+
313
+ You are a data scientist. ...
314
+ ```
315
+
316
+ **Discovered from 4 locations** (priority order):
317
+ 1. `<project>/.agents/agents/` — project vendor-neutral (preferred)
318
+ 2. `<project>/.pi/agent/agents/` — project pi native (legacy)
319
+ 3. `~/.agents/agents/` — user vendor-neutral (preferred)
320
+ 4. `~/.pi/agent/agents/` — user pi native (legacy)
321
+
322
+ `Ctrl+Tab` to see them in the cycle.
279
323
 
280
324
  ### Add a feature to an existing phase
281
325
 
@@ -311,9 +355,9 @@ If `/execute` complains about illegal partial state:
311
355
  - ❌ Edit `.soly/rules/` files you didn't write — those are project invariants
312
356
  - ❌ Skip the SUMMARY — illegal partial state
313
357
  - ❌ Spawn `soly-worker` or `soly-debugger` — use `soly-manager` (mode-switches)
314
- - ❌ Write rules in code comments — use `.soly/rules/*.md` files
358
+ - ❌ Write rules in code comments — use `.soly/rules/*.md` or `.agents/rules/*.md` files
315
359
  - ❌ Edit `.soly/phases/*/PLAN.md` after `status: in_progress` — create a new plan
316
- - ❌ Put intent docs anywhere other than `.soly/docs/`
360
+ - ❌ Put intent docs anywhere other than `.soly/docs/` (or `.agents/docs/` for vendor-neutral)
317
361
 
318
362
  ## When in doubt
319
363
 
package/switch/core.ts CHANGED
@@ -65,29 +65,61 @@ export function isValidAgentName(name: string): boolean {
65
65
  }
66
66
 
67
67
  /** Discover agent `.md` files in user dir. */
68
- export function discoverUserAgents(userDir: string = path.join(os.homedir(), ".pi", "agent", "agents")): string[] {
69
- if (!fs.existsSync(userDir)) return [];
70
- const names: string[] = [];
71
- for (const file of fs.readdirSync(userDir)) {
72
- if (!file.endsWith(".md")) continue;
68
+ /** All known agent home directories, in priority order (project wins
69
+ * over user-home; user-home `.agents/` wins over pi-native).
70
+ * Project-level `.agents/agents/` is a vendor-neutral per-project
71
+ * convention same role as `.soly/` or the old `.claude/`.
72
+ * Honors $HOME / $USERPROFILE for testability. */
73
+ export function agentHomeDirs(cwd?: string): string[] {
74
+ const home = process.env.HOME || process.env.USERPROFILE || os.homedir();
75
+ const dirs: string[] = [];
76
+ if (cwd) {
77
+ dirs.push(path.join(cwd, ".agents", "agents")); // project (vendor-neutral, preferred)
78
+ dirs.push(path.join(cwd, ".pi", "agent", "agents")); // project (pi native, legacy)
79
+ }
80
+ dirs.push(path.join(home, ".agents", "agents")); // user (vendor-neutral)
81
+ dirs.push(path.join(home, ".pi", "agent", "agents")); // user (pi native, legacy)
82
+ return dirs;
83
+ }
84
+
85
+ /** Read all agent names from every home dir. Dedupes, first-occurrence
86
+ * wins. If cwd is provided, project dirs are scanned first. */
87
+ export function discoverUserAgents(cwd?: string): string[] {
88
+ const seen = new Set<string>();
89
+ const out: string[] = [];
90
+ for (const dir of agentHomeDirs(cwd)) {
91
+ if (!fs.existsSync(dir)) continue;
92
+ let entries: string[];
73
93
  try {
74
- const raw = fs.readFileSync(path.join(userDir, file), "utf-8");
75
- const m = raw.match(/^---\n([\s\S]*?)\n---/);
76
- if (!m) continue;
77
- const fm = m[1] ?? "";
78
- const nameMatch = fm.match(/^name:\s*(.+)$/m);
79
- if (nameMatch) {
80
- const n = (nameMatch[1] ?? "").trim();
81
- if (isValidAgentName(n)) names.push(n);
82
- }
83
- } catch { /* skip */ }
94
+ entries = fs.readdirSync(dir);
95
+ } catch {
96
+ continue;
97
+ }
98
+ for (const file of entries) {
99
+ if (!file.endsWith(".md")) continue;
100
+ try {
101
+ const raw = fs.readFileSync(path.join(dir, file), "utf-8");
102
+ const m = raw.match(/^---\n([\s\S]*?)\n---/);
103
+ if (!m) continue;
104
+ const fm = m[1] ?? "";
105
+ const nameMatch = fm.match(/^name:\s*(.+)$/m);
106
+ if (nameMatch) {
107
+ const n = (nameMatch[1] ?? "").trim();
108
+ if (isValidAgentName(n) && !seen.has(n)) {
109
+ seen.add(n);
110
+ out.push(n);
111
+ }
112
+ }
113
+ } catch { /* skip unreadable */ }
114
+ }
84
115
  }
85
- return names;
116
+ return out;
86
117
  }
87
118
 
88
119
  /** Build the full cycle of available agents. Built-ins first, then
89
- * user-discovered. Dedupes while preserving first-occurrence order. */
90
- export function availableAgents(userDir?: string): string[] {
120
+ * project-level agents (if cwd given), then user-home agents.
121
+ * Dedupes while preserving first-occurrence order. */
122
+ export function availableAgents(cwd?: string): string[] {
91
123
  const out: string[] = [];
92
124
  const seen = new Set<string>();
93
125
  const push = (n: string) => {
@@ -97,7 +129,7 @@ export function availableAgents(userDir?: string): string[] {
97
129
  }
98
130
  };
99
131
  for (const a of BUILTIN_AGENTS) push(a);
100
- for (const a of discoverUserAgents(userDir)) push(a);
132
+ for (const a of discoverUserAgents(cwd)) push(a);
101
133
  return out;
102
134
  }
103
135
 
@@ -139,8 +171,8 @@ export function formatAgentSwitchNotify(prev: string, next: string): string {
139
171
  }
140
172
 
141
173
  /** Group agents: built-ins + user-defined. */
142
- export function groupedAvailableAgents(userDir?: string): Array<{ header: string; agents: string[] }> {
143
- const all = availableAgents(userDir);
174
+ export function groupedAvailableAgents(cwd?: string): Array<{ header: string; agents: string[] }> {
175
+ const all = availableAgents(cwd);
144
176
  const groups: Array<{ header: string; agents: string[] }> = [];
145
177
  const builtin = all.filter((a) => BUILTIN_AGENTS.includes(a));
146
178
  if (builtin.length > 0) groups.push({ header: "built-in", agents: builtin });
package/switch/index.ts CHANGED
@@ -45,7 +45,7 @@ export default function piSwitchExtension(pi: ExtensionAPI) {
45
45
  let lastUi: ExtensionUIContext | null = null;
46
46
 
47
47
  function refreshCycle(): void {
48
- cycle = availableAgents();
48
+ cycle = availableAgents(cwd);
49
49
  if (!cycle.includes(currentAgent)) currentAgent = DEFAULT_AGENT;
50
50
  }
51
51
 
@@ -177,8 +177,8 @@ function openPicker(ui: ExtensionUIContext): void {
177
177
  function handleSet(name: string, ui: ExtensionUIContext): void {
178
178
  const target = parseAgentName(name);
179
179
  if (!target) return ui.notify(`pi-switch: invalid name "${name}".`, "error");
180
- if (!availableAgents().includes(target)) {
181
- return ui.notify(`pi-switch: unknown "${target}". available: ${availableAgents().join(", ")}`, "error");
180
+ if (!availableAgents(cwd).includes(target)) {
181
+ return ui.notify(`pi-switch: unknown "${target}". available: ${availableAgents(cwd).join(", ")}`, "error");
182
182
  }
183
183
  setAgentRef(target);
184
184
  }
@@ -231,20 +231,50 @@ function createAgent(
231
231
  ui.notify(`pi-switch: invalid name "${name}". Use letters/digits/dashes/underscores, ≤64 chars.`, "error");
232
232
  return;
233
233
  }
234
- const userDir = path.join(os.homedir(), ".pi", "agent", "agents");
235
- fs.mkdirSync(userDir, { recursive: true });
236
- const file = path.join(userDir, `${name}.md`);
234
+ // Write to ~/.agents/ (vendor-neutral) first; fall back to ~/.pi/agent/agents/
235
+ // (pi's native) if .agents/ doesn't exist or is unwritable.
236
+ const home = os.homedir();
237
+ const candidates = [
238
+ path.join(home, ".agents"),
239
+ path.join(home, ".pi", "agent", "agents"),
240
+ ];
241
+ const targetDir = candidates.find((d) => {
242
+ try {
243
+ if (!fs.existsSync(d)) fs.mkdirSync(d, { recursive: true });
244
+ // Probe write permission
245
+ const probe = path.join(d, ".pi-switch-write-probe");
246
+ fs.writeFileSync(probe, "");
247
+ fs.unlinkSync(probe);
248
+ return true;
249
+ } catch {
250
+ return false;
251
+ }
252
+ });
253
+ if (!targetDir) {
254
+ ui.notify(`pi-switch: could not find a writable agents dir (tried ${candidates.join(", ")}).`, "error");
255
+ return;
256
+ }
257
+ const file = path.join(targetDir, `${name}.md`);
237
258
  if (fs.existsSync(file)) {
238
259
  ui.notify(`pi-switch: ${file} already exists. edit it directly.`, "warning");
239
260
  return;
240
261
  }
241
262
  void ui.input(`description for "${name}":`, "one-liner that shows in the picker")?.then((desc) => {
242
263
  const description = desc?.trim() || `custom agent (${name})`;
243
- fs.writeFileSync(file, agentTemplate(name, description), "utf-8");
244
- ui.notify(
245
- `pi-switch: created ${file}\n → next Ctrl+Tab to see it in the cycle\n → edit the system prompt to specialize`,
246
- "info",
247
- );
264
+ try {
265
+ // Atomic write: tmp + rename. Avoids partial files and the
266
+ // race where two parallel createAgent calls would clobber each
267
+ // other's write after both pass the existsSync check.
268
+ const tmp = `${file}.tmp-${process.pid}-${Date.now()}`;
269
+ fs.writeFileSync(tmp, agentTemplate(name, description), "utf-8");
270
+ fs.renameSync(tmp, file);
271
+ ui.notify(
272
+ `pi-switch: created ${file}\n → next Ctrl+Tab to see it in the cycle\n → edit the system prompt to specialize`,
273
+ "info",
274
+ );
275
+ } catch (err) {
276
+ ui.notify(`pi-switch: failed to write ${file}: ${(err as Error).message}`, "error");
277
+ }
248
278
  });
249
279
  }
250
280
 
@@ -284,7 +314,7 @@ you have a specific reason to change it.
284
314
  }
285
315
 
286
316
  function doctorReport(): string {
287
- const cycle = availableAgents();
317
+ const cycle = availableAgents(cwd);
288
318
  const userDir = path.join(os.homedir(), ".pi", "agent", "agents");
289
319
  const lines: string[] = ["pi-switch doctor:", ""];
290
320
  const builtins = cycle.filter((a) => BUILTIN_AGENTS.includes(a));
@@ -123,13 +123,35 @@ describe("groupedAvailableAgents", () => {
123
123
  expect(groups[0]?.header).toBe("built-in");
124
124
  });
125
125
  test("includes user group when present", () => {
126
- const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "pis-grp-"));
127
- fs.writeFileSync(path.join(tmp, "my.md"), "---\nname: my-helper\n---\n# body\n");
128
- const groups = groupedAvailableAgents(tmp);
126
+ // Use HOME override so the new ~.agents/agents/ scan picks up our fixture
127
+ const home = process.env.HOME || process.env.USERPROFILE || os.homedir();
128
+ const prevHome = process.env.HOME;
129
+ const prevUserProfile = process.env.USERPROFILE;
130
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "pis-home-"));
131
+ process.env.HOME = tmp;
132
+ process.env.USERPROFILE = tmp;
133
+ const agentsDir = path.join(tmp, ".agents", "agents");
134
+ fs.mkdirSync(agentsDir, { recursive: true });
135
+ fs.writeFileSync(path.join(agentsDir, "my.md"), "---\nname: my-helper\n---\n# body\n");
136
+ const groups = groupedAvailableAgents();
129
137
  const userGroup = groups.find((g) => g.header === "user-defined");
130
138
  expect(userGroup?.agents).toContain("my-helper");
139
+ // restore
140
+ process.env.HOME = prevHome ?? home;
141
+ process.env.USERPROFILE = prevUserProfile ?? home;
131
142
  fs.rmSync(tmp, { recursive: true, force: true });
132
143
  });
144
+
145
+ test("includes project agent when present (cwd scope)", () => {
146
+ const projectDir = fs.mkdtempSync(path.join(os.tmpdir(), "pis-proj-"));
147
+ const agentsDir = path.join(projectDir, ".agents", "agents");
148
+ fs.mkdirSync(agentsDir, { recursive: true });
149
+ fs.writeFileSync(path.join(agentsDir, "proj.md"), "---\nname: project-helper\n---\n# body\n");
150
+ const groups = groupedAvailableAgents(projectDir);
151
+ const userGroup = groups.find((g) => g.header === "user-defined");
152
+ expect(userGroup?.agents).toContain("project-helper");
153
+ fs.rmSync(projectDir, { recursive: true, force: true });
154
+ });
133
155
  });
134
156
 
135
157
  // ---------------------------------------------------------------------------
@@ -309,18 +309,21 @@ When the subagent completes, synthesize the result. Do not re-execute its work.`
309
309
  planNumber: isPlanLevel ? (target.plan ?? undefined) : undefined,
310
310
  });
311
311
 
312
- // For plan-level: also pull out the must_haves summary for inline cache.
313
- const planFile = isPlanLevel
314
- ? path.join(phase.dir, `${phase.slug}-${String(target.plan).padStart(2, "0")}-${phase.slug.split("-").slice(1).join("-") || "plan"}-PLAN.md`)
315
- : null;
316
- // Fall back to findPlanFile if the conventional name didn't match.
317
- let planFileResolved = planFile;
318
- if (isPlanLevel && planFile && !fs.existsSync(planFile)) {
312
+ // For plan-level: find the PLAN.md. We always use the directory scan
313
+ // (the conventional name would require knowing the slug suffix, which
314
+ // is fragile and changed over time). Pattern: NN-MM-slug-PLAN.md.
315
+ let planFileResolved: string | null = null;
316
+ if (isPlanLevel && phase.dir) {
319
317
  const padded = String(target.plan).padStart(2, "0");
320
- const candidates = fs.readdirSync(phase.dir).filter((f) =>
321
- new RegExp(`^\\d+-${padded}-.+-PLAN\\.md$`).test(f),
322
- );
323
- if (candidates.length > 0) planFileResolved = path.join(phase.dir, candidates[0]!);
318
+ let entries: string[];
319
+ try {
320
+ entries = fs.readdirSync(phase.dir);
321
+ } catch {
322
+ entries = [];
323
+ }
324
+ const re = new RegExp(`^\\d{2,}-${padded}-.+-PLAN\\.md$`);
325
+ const match = entries.find((f) => re.test(f));
326
+ if (match) planFileResolved = path.join(phase.dir, match);
324
327
  }
325
328
  const inlineSummary = isPlanLevel ? inlinePlanSummary(planFileResolved) : "_(phase-level exec — iterate all PLAN.md files; each iteration has its own bundle)_";
326
329
 
@@ -124,13 +124,22 @@ export function showDoctor(_cmd: unknown, state: SolyState, ui: InspectUI, confi
124
124
  if (fs.existsSync(phasesDir)) {
125
125
  let totalPlans = 0;
126
126
  let badPlans = 0;
127
+ // Tight match: "NN-something-PLAN.md" — phase number prefix, then
128
+ // slug, then -PLAN.md. Avoids matching "old-PLAN-PLAN.md" or
129
+ // "PLAN.md" without a phase prefix.
130
+ const planRe = /^\d{2,}-.+-PLAN\.md$/;
127
131
  for (const p of fs.readdirSync(phasesDir, { withFileTypes: true })) {
128
132
  if (!p.isDirectory() || p.name.startsWith(".")) continue;
129
133
  for (const f of fs.readdirSync(path.join(phasesDir, p.name))) {
130
- if (/-PLAN\.md$/.test(f)) {
134
+ if (planRe.test(f)) {
131
135
  totalPlans++;
132
- const raw = fs.readFileSync(path.join(phasesDir, p.name, f), "utf-8");
133
- if (!raw.startsWith("---\n") && !raw.startsWith("---\r\n")) badPlans++;
136
+ try {
137
+ const raw = fs.readFileSync(path.join(phasesDir, p.name, f), "utf-8");
138
+ if (!raw.startsWith("---\n") && !raw.startsWith("---\r\n")) badPlans++;
139
+ } catch {
140
+ // Unreadable plan: count as bad so user notices
141
+ badPlans++;
142
+ }
134
143
  }
135
144
  }
136
145
  }
@@ -284,6 +284,15 @@ When the subagent returns, summarize what was refined. Do not execute — planni
284
284
  const featureReadme = path.join(featureDir, "README.md");
285
285
  try {
286
286
  fs.mkdirSync(path.join(featureDir, "tasks"), { recursive: true });
287
+ } catch (e) {
288
+ return {
289
+ handled: true,
290
+ transformedText:
291
+ `soly plan: could not create .soly/features/${target.feature}/tasks/ (${(e as Error).message}). ` +
292
+ `Create it manually: \`mkdir -p .soly/features/${target.feature}/tasks/\``,
293
+ };
294
+ }
295
+ try {
287
296
  if (!fs.existsSync(featureReadme)) {
288
297
  fs.writeFileSync(
289
298
  featureReadme,
@@ -295,8 +304,8 @@ When the subagent returns, summarize what was refined. Do not execute — planni
295
304
  return {
296
305
  handled: true,
297
306
  transformedText:
298
- `soly plan: could not auto-create .soly/features/${target.feature}/ (${(e as Error).message}). ` +
299
- `Create it manually: \`mkdir -p .soly/features/${target.feature}/tasks/\``,
307
+ `soly plan: created .soly/features/${target.feature}/tasks/ but failed to write README.md (${(e as Error).message}). ` +
308
+ `Continue manually: \`touch .soly/features/${target.feature}/README.md\``,
300
309
  };
301
310
  }
302
311
  // Re-read state so the planner sees the new feature