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 +1 -1
- package/agents-install.ts +27 -17
- package/ask/README.md +8 -24
- package/ask/index.ts +2 -2
- package/ask/picker.ts +2 -2
- package/ask/tests/picker.test.ts +1 -1
- package/index.ts +4 -0
- package/integrations.ts +1 -1
- package/package.json +1 -1
- package/skills/soly-framework/SKILL.md +50 -6
- package/switch/core.ts +53 -21
- package/switch/index.ts +42 -12
- package/switch/tests/core.test.ts +25 -3
- package/workflows/execute.ts +14 -11
- package/workflows/inspect.ts +12 -3
- package/workflows/planning.ts +11 -2
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
|
-
|
|
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
|
-
|
|
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
|
|
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 `~/.
|
|
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
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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
|
|
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
|
-
|
|
158
|
-
|
|
159
|
-
|
|
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 —
|
|
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.
|
|
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
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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 —
|
|
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
|
|
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) {
|
package/ask/tests/picker.test.ts
CHANGED
|
@@ -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
|
|
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
|
|
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.
|
|
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
|
-
├── .
|
|
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.
|
|
278
|
-
4.
|
|
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
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
if (
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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
|
|
116
|
+
return out;
|
|
86
117
|
}
|
|
87
118
|
|
|
88
119
|
/** Build the full cycle of available agents. Built-ins first, then
|
|
89
|
-
*
|
|
90
|
-
|
|
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(
|
|
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(
|
|
143
|
-
const all = availableAgents(
|
|
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
|
-
|
|
235
|
-
|
|
236
|
-
const
|
|
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
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
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
|
-
|
|
127
|
-
|
|
128
|
-
const
|
|
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
|
// ---------------------------------------------------------------------------
|
package/workflows/execute.ts
CHANGED
|
@@ -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:
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
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
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
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
|
|
package/workflows/inspect.ts
CHANGED
|
@@ -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 (
|
|
134
|
+
if (planRe.test(f)) {
|
|
131
135
|
totalPlans++;
|
|
132
|
-
|
|
133
|
-
|
|
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
|
}
|
package/workflows/planning.ts
CHANGED
|
@@ -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:
|
|
299
|
-
`
|
|
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
|