pi-soly 0.8.0 → 1.0.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 +7 -7
- package/agents-install.ts +6 -3
- package/commands.ts +2 -2
- package/index.ts +8 -4
- package/package.json +1 -1
- package/skills/soly-framework/SKILL.md +35 -12
- package/switch/core.ts +49 -41
- package/switch/index.ts +52 -51
- package/switch/tests/core.test.ts +45 -34
package/README.md
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
**The project management framework for [pi-coding-agent](https://github.com/nicobailon/pi-coding-agent).**
|
|
6
6
|
|
|
7
|
-
Plans · State · Subagents · Multi-question picker ·
|
|
7
|
+
Plans · State · Subagents · Multi-question picker · Rotor switcher · Live task list.
|
|
8
8
|
|
|
9
9
|
One `npm install`. Zero config. Pure magic.
|
|
10
10
|
|
|
@@ -30,7 +30,7 @@ That's it. Restart pi, and you have:
|
|
|
30
30
|
|
|
31
31
|
- **A complete project management engine** — plans, state, subagent-driven execution
|
|
32
32
|
- **A multi-question picker** — `ask_pro` tool for the LLM
|
|
33
|
-
- **
|
|
33
|
+
- **A rotor switcher** — `Ctrl+Tab` to cycle, footer pill always visible
|
|
34
34
|
- **A live task list** — `todo_update` tool renders in the footer
|
|
35
35
|
- **7 soly agents** installed on first run
|
|
36
36
|
|
|
@@ -43,7 +43,7 @@ That's it. Restart pi, and you have:
|
|
|
43
43
|
| Write your own planning workflow | `/plan`, `/execute`, `/resume`, `/inspect` — ready |
|
|
44
44
|
| Manually dispatch subagents | `useSolyWorkerSubagents: true` — automatic routing |
|
|
45
45
|
| 3 different packages for pickers/tasks/agents | One package, one config, one install |
|
|
46
|
-
|
|
|
46
|
+
| Rotor name as free text in slash commands | Footer pill + `Ctrl+Tab` + `/rotor` picker |
|
|
47
47
|
| Re-invent the state machine | `.soly/STATE.md` + auto-managed phases |
|
|
48
48
|
|
|
49
49
|
---
|
|
@@ -94,7 +94,7 @@ ask_pro({
|
|
|
94
94
|
})
|
|
95
95
|
```
|
|
96
96
|
|
|
97
|
-
### 🎛
|
|
97
|
+
### 🎛 Rotor Switcher
|
|
98
98
|
|
|
99
99
|
Footer pill that's always there. `Ctrl+Tab` to cycle. No popup, no friction.
|
|
100
100
|
|
|
@@ -171,11 +171,11 @@ todo_update({
|
|
|
171
171
|
▼
|
|
172
172
|
┌──────────────────┐
|
|
173
173
|
│ switch/ │
|
|
174
|
-
│
|
|
174
|
+
│ rotor switcher │
|
|
175
175
|
│ │
|
|
176
176
|
│ Ctrl+Tab │
|
|
177
177
|
│ footer pill │
|
|
178
|
-
│ /
|
|
178
|
+
│ /rotor picker │
|
|
179
179
|
└────────┬─────────┘
|
|
180
180
|
│
|
|
181
181
|
▼
|
|
@@ -196,7 +196,7 @@ todo_update({
|
|
|
196
196
|
|
|
197
197
|
## 📚 Documentation
|
|
198
198
|
|
|
199
|
-
- **Slash commands** — `/plan`, `/execute`, `/resume`, `/inspect`, `/discuss <N>`, `/quick <task>`, `/
|
|
199
|
+
- **Slash commands** — `/plan`, `/execute`, `/resume`, `/inspect`, `/discuss <N>`, `/quick <task>`, `/rotor`
|
|
200
200
|
- **Tools** — `ask_pro(question[])` and `todo_update(todo[])`
|
|
201
201
|
- **Events** — `session_start`, `before_agent_start`, `message_end`, `tool_call`, `tool_result`
|
|
202
202
|
- **State files** — `.soly/STATE.md`, `.soly/ROADMAP.md`, `.soly/phases/<N>-<slug>/<N>-PLAN.md`
|
package/agents-install.ts
CHANGED
|
@@ -34,12 +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).
|
|
37
|
+
* testability (otherwise we'd always write to the real user home).
|
|
38
|
+
* Agent files live directly in the dir (no `agents/` subfolder):
|
|
39
|
+
* ~/.agents/reviewer.md (NOT ~/.agents/agents/reviewer.md)
|
|
40
|
+
* This keeps the path clean and matches the project-level convention. */
|
|
38
41
|
function userAgentsDirs(): string[] {
|
|
39
42
|
const home = process.env.HOME || process.env.USERPROFILE || os.homedir();
|
|
40
43
|
return [
|
|
41
44
|
path.join(home, ".agents"), // vendor-neutral (preferred)
|
|
42
|
-
path.join(home, ".pi", "agent", "agents"), // pi
|
|
45
|
+
path.join(home, ".pi", "agent", "agents"), // pi native (legacy)
|
|
43
46
|
];
|
|
44
47
|
}
|
|
45
48
|
|
|
@@ -92,7 +95,7 @@ function copyDirIfMissing(from: string, to: string): "installed" | "skipped" | "
|
|
|
92
95
|
|
|
93
96
|
/** Install shipped soly agents to `~/.agents/` (vendor-neutral,
|
|
94
97
|
* preferred). Legacy `~/.pi/agent/agents/` copies are left alone —
|
|
95
|
-
* `
|
|
98
|
+
* `discoverUserRotors` reads both, so old installs still work. */
|
|
96
99
|
export function installSolyAgents(extensionRoot: string): InstallResult {
|
|
97
100
|
const result: InstallResult = { installed: [], skipped: [], errors: [] };
|
|
98
101
|
const src = shippedAgentsDir(extensionRoot);
|
package/commands.ts
CHANGED
|
@@ -371,8 +371,8 @@ What must the LLM do?
|
|
|
371
371
|
};
|
|
372
372
|
const subcommands: Record<string, SolySub> = {
|
|
373
373
|
// `agent` subcommand REMOVED — moved to the separate `pi-switch`
|
|
374
|
-
// extension as the `/
|
|
375
|
-
// Soly no longer owns the
|
|
374
|
+
// extension as the `/rotor` slash command (footer pill + Ctrl+Tab).
|
|
375
|
+
// Soly no longer owns the rotor switcher UI.
|
|
376
376
|
config: {
|
|
377
377
|
description: "show merged config (per-project + global + defaults); edit .soly/config.json or ~/.soly/config.json",
|
|
378
378
|
run: () => {
|
package/index.ts
CHANGED
|
@@ -80,12 +80,12 @@ export default function solyExtension(pi: ExtensionAPI) {
|
|
|
80
80
|
let sessionCwd = "";
|
|
81
81
|
|
|
82
82
|
// ============================================================================
|
|
83
|
-
//
|
|
83
|
+
// Rotor switcher (Shift+Tab cycles through available rotors)
|
|
84
84
|
// ============================================================================
|
|
85
85
|
|
|
86
86
|
// ============================================================================
|
|
87
|
-
//
|
|
88
|
-
// separate `pi-switch` extension (footer pill + Ctrl+Tab + /
|
|
87
|
+
// Rotor switcher: REMOVED. The rotor cycler is now owned by the
|
|
88
|
+
// separate `pi-switch` extension (footer pill + Ctrl+Tab + /rotor slash).
|
|
89
89
|
// Soly owns a single subagent (soly-manager.md) and the auto-install on
|
|
90
90
|
// opt-in. Workflows read the current agent from
|
|
91
91
|
// globalThis.__PI_SWITCH_AGENT__ (set by pi-switch).
|
|
@@ -328,7 +328,7 @@ export default function solyExtension(pi: ExtensionAPI) {
|
|
|
328
328
|
// and mnemonic for "A"gent.)
|
|
329
329
|
// ============================================================================
|
|
330
330
|
// Agent switcher REMOVED — moved to the separate `pi-switch` extension.
|
|
331
|
-
// Soly no longer owns Ctrl+Tab, the footer pill, or /
|
|
331
|
+
// Soly no longer owns Ctrl+Tab, the footer pill, or /rotor slash.
|
|
332
332
|
// The current agent is read by soly workflows from
|
|
333
333
|
// globalThis.__PI_SWITCH_AGENT__ (set by pi-switch), with a fallback
|
|
334
334
|
// to "worker" if pi-switch isn't installed.
|
|
@@ -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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-soly",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "1.0.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",
|
|
@@ -35,7 +35,7 @@ The **soly** extension adds project-management workflow to [pi-coding-agent](htt
|
|
|
35
35
|
| `/quick <task>` | One-shot task that doesn't need a full plan — direct dispatch |
|
|
36
36
|
| `/soly` | Project state inspection (alias for `/inspect`) |
|
|
37
37
|
| `/why` | Show what context the LLM's last turn was based on |
|
|
38
|
-
| `/
|
|
38
|
+
| `/rotor [name]` | Switch the current rotor (or open picker) |
|
|
39
39
|
|
|
40
40
|
`/soly <verb>` plain-text aliases also work for some verbs (legacy compat).
|
|
41
41
|
|
|
@@ -45,16 +45,13 @@ The **soly** extension adds project-management workflow to [pi-coding-agent](htt
|
|
|
45
45
|
<project-root>/
|
|
46
46
|
├── AGENTS.md # vendor-neutral agent context (loaded by pi)
|
|
47
47
|
├── agents.md # same as AGENTS.md, lowercase accepted
|
|
48
|
-
├── .
|
|
49
|
-
│ ├── project-reviewer.md
|
|
50
|
-
│ └── data-scientist.md
|
|
51
|
-
├── .soly/
|
|
48
|
+
├── .soly/ # soly state (phases, plans, summaries)
|
|
52
49
|
│ ├── ROADMAP.md # phase table
|
|
53
50
|
│ ├── STATE.md # current position + decisions log
|
|
54
51
|
│ ├── docs/ # 0-point intent docs (human-written, locked)
|
|
55
52
|
│ │ ├── vision.md
|
|
56
53
|
│ │ └── architecture.md
|
|
57
|
-
│ ├── rules/ # project rules (version-controlled)
|
|
54
|
+
│ ├── rules/ # soly project rules (version-controlled)
|
|
58
55
|
│ │ ├── code-style.md
|
|
59
56
|
│ │ └── testing.md
|
|
60
57
|
│ ├── phases/
|
|
@@ -70,8 +67,26 @@ The **soly** extension adds project-management workflow to [pi-coding-agent](htt
|
|
|
70
67
|
│ ├── iterations/ # per-execution context bundles (auto)
|
|
71
68
|
│ ├── HANDOFF.json # pause snapshot
|
|
72
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
|
|
73
82
|
```
|
|
74
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
|
+
|
|
75
90
|
## Frontmatter conventions
|
|
76
91
|
|
|
77
92
|
### PLAN.md frontmatter (required)
|
|
@@ -183,16 +198,18 @@ The only legal sequence for finishing a plan:
|
|
|
183
198
|
|
|
184
199
|
Once production commits exist, returning without a committed `SUMMARY.md` is an **illegal partial-plan state** — the next `/execute` will detect it and refuse to start.
|
|
185
200
|
|
|
186
|
-
## Cycle
|
|
201
|
+
## Cycle rotors (4 built-in)
|
|
187
202
|
|
|
188
|
-
|
|
|
203
|
+
| Rotor | Writes | Use for |
|
|
189
204
|
|---|---|---|
|
|
190
205
|
| `worker` | ✅ | Generic implementation, full tools |
|
|
191
206
|
| `oracle` | ❌ | Decision-consistency, no file edits |
|
|
192
207
|
| `scout` | ❌ | Codebase recon, read-only |
|
|
193
208
|
| `reviewer` | ❌ | Adversarial code review |
|
|
194
209
|
|
|
195
|
-
Switch with `/
|
|
210
|
+
Switch with `/rotor <name>` or `Ctrl+Tab` (cycles through). Footer pill shows current: `· ⚡ worker` / `▶ 🐢 oracle`.
|
|
211
|
+
|
|
212
|
+
**Why "rotors"?** Because they *rotate* — `Ctrl+Tab` cycles through them. The word emphasizes the cycling behavior. Subagents (like `soly-manager`) are still called agents — they're a different concept (spawned for a task, not cycled through).
|
|
196
213
|
|
|
197
214
|
## Subagent: soly-manager (single, mode-switching)
|
|
198
215
|
|
|
@@ -298,7 +315,13 @@ tools: read, bash
|
|
|
298
315
|
You are a data scientist. ...
|
|
299
316
|
```
|
|
300
317
|
|
|
301
|
-
|
|
318
|
+
**Discovered from 4 locations** (priority order):
|
|
319
|
+
1. `<project>/.agents/` — project vendor-neutral (preferred)
|
|
320
|
+
2. `<project>/.pi/agent/agents/` — project pi native (legacy)
|
|
321
|
+
3. `~/.agents/` — user vendor-neutral (preferred)
|
|
322
|
+
4. `~/.pi/agent/agents/` — user pi native (legacy)
|
|
323
|
+
|
|
324
|
+
`Ctrl+Tab` to see them in the cycle.
|
|
302
325
|
|
|
303
326
|
### Add a feature to an existing phase
|
|
304
327
|
|
|
@@ -334,9 +357,9 @@ If `/execute` complains about illegal partial state:
|
|
|
334
357
|
- ❌ Edit `.soly/rules/` files you didn't write — those are project invariants
|
|
335
358
|
- ❌ Skip the SUMMARY — illegal partial state
|
|
336
359
|
- ❌ Spawn `soly-worker` or `soly-debugger` — use `soly-manager` (mode-switches)
|
|
337
|
-
- ❌ Write rules in code comments — use `.soly/rules/*.md` files
|
|
360
|
+
- ❌ Write rules in code comments — use `.soly/rules/*.md` or `.agents/rules/*.md` files
|
|
338
361
|
- ❌ Edit `.soly/phases/*/PLAN.md` after `status: in_progress` — create a new plan
|
|
339
|
-
- ❌ Put intent docs anywhere other than `.soly/docs/`
|
|
362
|
+
- ❌ Put intent docs anywhere other than `.soly/docs/` (or `.agents/docs/` for vendor-neutral)
|
|
340
363
|
|
|
341
364
|
## When in doubt
|
|
342
365
|
|
package/switch/core.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// =============================================================================
|
|
2
|
-
// core.ts — Generic
|
|
2
|
+
// core.ts — Generic subrotor switcher for pi
|
|
3
3
|
// =============================================================================
|
|
4
4
|
//
|
|
5
5
|
// Lets the user pick which subagent the LLM uses (for `subagent(...)` calls
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
// pass Ctrl+Tab through).
|
|
13
13
|
//
|
|
14
14
|
// Communication with other extensions:
|
|
15
|
-
// - Writes `globalThis.
|
|
15
|
+
// - Writes `globalThis.__PI_SWITCH_ROTOR__` (in-process)
|
|
16
16
|
// - Reads/writes `.soly/agent` if it exists (cross-session persistence,
|
|
17
17
|
// shared with soly extension). If no soly project, persists to
|
|
18
18
|
// `~/.pi-switch/agent` instead.
|
|
@@ -23,10 +23,10 @@ import * as os from "node:os";
|
|
|
23
23
|
import * as path from "node:path";
|
|
24
24
|
|
|
25
25
|
/** Default agent used when no override is set. */
|
|
26
|
-
export const
|
|
26
|
+
export const DEFAULT_ROTOR = "worker";
|
|
27
27
|
|
|
28
28
|
/** Built-in pi-subagents that we always offer in the cycle. */
|
|
29
|
-
export const
|
|
29
|
+
export const BUILTIN_ROTORS: readonly string[] = [
|
|
30
30
|
"worker",
|
|
31
31
|
"oracle",
|
|
32
32
|
"scout",
|
|
@@ -35,14 +35,14 @@ export const BUILTIN_AGENTS: readonly string[] = [
|
|
|
35
35
|
|
|
36
36
|
/** Visual metadata for every known agent. Used by the rich status badge,
|
|
37
37
|
* the header bar, and the multi-line switch notify. */
|
|
38
|
-
export interface
|
|
38
|
+
export interface RotorMeta {
|
|
39
39
|
emoji: string;
|
|
40
40
|
shortLabel: string;
|
|
41
41
|
description: string;
|
|
42
42
|
writesFiles: boolean;
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
-
export const
|
|
45
|
+
export const ROTOR_META: Record<string, RotorMeta> = {
|
|
46
46
|
worker: { emoji: "\u26a1", shortLabel: "worker", description: "generic implementation, all tools", writesFiles: true },
|
|
47
47
|
oracle: { emoji: "\ud83d\udd2e", shortLabel: "oracle", description: "decision-consistency, no file edits", writesFiles: false },
|
|
48
48
|
scout: { emoji: "\ud83d\udd0d", shortLabel: "scout", description: "codebase recon, read-only", writesFiles: false },
|
|
@@ -50,8 +50,8 @@ export const AGENT_META: Record<string, AgentMeta> = {
|
|
|
50
50
|
};
|
|
51
51
|
|
|
52
52
|
/** Get metadata for an agent. Falls back to a neutral entry for unknown. */
|
|
53
|
-
export function
|
|
54
|
-
return
|
|
53
|
+
export function getRotorMeta(name: string): RotorMeta {
|
|
54
|
+
return ROTOR_META[name] ?? {
|
|
55
55
|
emoji: "\u2753",
|
|
56
56
|
shortLabel: name.length > 12 ? name.slice(0, 11) + "\u2026" : name,
|
|
57
57
|
description: "user-defined agent",
|
|
@@ -60,28 +60,36 @@ export function getAgentMeta(name: string): AgentMeta {
|
|
|
60
60
|
}
|
|
61
61
|
|
|
62
62
|
/** Validate an agent name. */
|
|
63
|
-
export function
|
|
63
|
+
export function isValidRotorName(name: string): boolean {
|
|
64
64
|
return /^[a-zA-Z0-9_-]{1,64}$/.test(name);
|
|
65
65
|
}
|
|
66
66
|
|
|
67
67
|
/** Discover agent `.md` files in user dir. */
|
|
68
|
-
/**
|
|
69
|
-
*
|
|
70
|
-
*
|
|
71
|
-
|
|
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/` is a vendor-neutral per-project
|
|
71
|
+
* convention — same role as `.soly/` or the old `.claude/`.
|
|
72
|
+
* Agent .md files live DIRECTLY in the dir (not in a subfolder):
|
|
73
|
+
* .agents/reviewer.md (NOT .agents/agents/reviewer.md)
|
|
74
|
+
* Honors $HOME / $USERPROFILE for testability. */
|
|
75
|
+
export function rotorHomeDirs(cwd?: string): string[] {
|
|
72
76
|
const home = process.env.HOME || process.env.USERPROFILE || os.homedir();
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
path.join(
|
|
76
|
-
|
|
77
|
+
const dirs: string[] = [];
|
|
78
|
+
if (cwd) {
|
|
79
|
+
dirs.push(path.join(cwd, ".agents")); // project (vendor-neutral, preferred)
|
|
80
|
+
dirs.push(path.join(cwd, ".pi", "agent", "agents")); // project (pi native, legacy)
|
|
81
|
+
}
|
|
82
|
+
dirs.push(path.join(home, ".agents")); // user (vendor-neutral)
|
|
83
|
+
dirs.push(path.join(home, ".pi", "agent", "agents")); // user (pi native, legacy)
|
|
84
|
+
return dirs;
|
|
77
85
|
}
|
|
78
86
|
|
|
79
|
-
/** Read all
|
|
80
|
-
*
|
|
81
|
-
export function
|
|
87
|
+
/** Read all agent names from every home dir. Dedupes, first-occurrence
|
|
88
|
+
* wins. If cwd is provided, project dirs are scanned first. */
|
|
89
|
+
export function discoverUserRotors(cwd?: string): string[] {
|
|
82
90
|
const seen = new Set<string>();
|
|
83
91
|
const out: string[] = [];
|
|
84
|
-
for (const dir of
|
|
92
|
+
for (const dir of rotorHomeDirs(cwd)) {
|
|
85
93
|
if (!fs.existsSync(dir)) continue;
|
|
86
94
|
let entries: string[];
|
|
87
95
|
try {
|
|
@@ -99,7 +107,7 @@ export function discoverUserAgents(): string[] {
|
|
|
99
107
|
const nameMatch = fm.match(/^name:\s*(.+)$/m);
|
|
100
108
|
if (nameMatch) {
|
|
101
109
|
const n = (nameMatch[1] ?? "").trim();
|
|
102
|
-
if (
|
|
110
|
+
if (isValidRotorName(n) && !seen.has(n)) {
|
|
103
111
|
seen.add(n);
|
|
104
112
|
out.push(n);
|
|
105
113
|
}
|
|
@@ -111,9 +119,9 @@ export function discoverUserAgents(): string[] {
|
|
|
111
119
|
}
|
|
112
120
|
|
|
113
121
|
/** Build the full cycle of available agents. Built-ins first, then
|
|
114
|
-
*
|
|
115
|
-
* preserving first-occurrence order. */
|
|
116
|
-
export function availableAgents(): string[] {
|
|
122
|
+
* project-level agents (if cwd given), then user-home agents.
|
|
123
|
+
* Dedupes while preserving first-occurrence order. */
|
|
124
|
+
export function availableAgents(cwd?: string): string[] {
|
|
117
125
|
const out: string[] = [];
|
|
118
126
|
const seen = new Set<string>();
|
|
119
127
|
const push = (n: string) => {
|
|
@@ -122,37 +130,37 @@ export function availableAgents(): string[] {
|
|
|
122
130
|
out.push(n);
|
|
123
131
|
}
|
|
124
132
|
};
|
|
125
|
-
for (const a of
|
|
126
|
-
for (const a of
|
|
133
|
+
for (const a of BUILTIN_ROTORS) push(a);
|
|
134
|
+
for (const a of discoverUserRotors(cwd)) push(a);
|
|
127
135
|
return out;
|
|
128
136
|
}
|
|
129
137
|
|
|
130
138
|
/** Cycle order. */
|
|
131
139
|
export function nextAgent(current: string, cycle: readonly string[]): string {
|
|
132
|
-
if (cycle.length === 0) return
|
|
140
|
+
if (cycle.length === 0) return DEFAULT_ROTOR;
|
|
133
141
|
const idx = cycle.indexOf(current);
|
|
134
142
|
if (idx < 0) return cycle[0]!;
|
|
135
143
|
return cycle[(idx + 1) % cycle.length]!;
|
|
136
144
|
}
|
|
137
145
|
|
|
138
146
|
/** Parse a user-supplied agent name. */
|
|
139
|
-
export function
|
|
147
|
+
export function parseRotorName(raw: string): string | null {
|
|
140
148
|
const n = raw.trim();
|
|
141
|
-
if (!
|
|
149
|
+
if (!isValidRotorName(n)) return null;
|
|
142
150
|
return n;
|
|
143
151
|
}
|
|
144
152
|
|
|
145
153
|
/** Short badge: `<emoji> <name>`. Null for default (silent). */
|
|
146
154
|
export function formatAgentBadge(agent: string): string | null {
|
|
147
|
-
if (agent ===
|
|
148
|
-
const meta =
|
|
155
|
+
if (agent === DEFAULT_ROTOR) return null;
|
|
156
|
+
const meta = getRotorMeta(agent);
|
|
149
157
|
return `${meta.emoji} ${agent}`;
|
|
150
158
|
}
|
|
151
159
|
|
|
152
160
|
/** Multi-line switch notify. */
|
|
153
|
-
export function
|
|
154
|
-
const prevMeta =
|
|
155
|
-
const nextMeta =
|
|
161
|
+
export function formatRotorSwitchNotify(prev: string, next: string): string {
|
|
162
|
+
const prevMeta = getRotorMeta(prev);
|
|
163
|
+
const nextMeta = getRotorMeta(next);
|
|
156
164
|
const lines: string[] = [
|
|
157
165
|
"pi-switch agent changed",
|
|
158
166
|
"",
|
|
@@ -165,19 +173,19 @@ export function formatAgentSwitchNotify(prev: string, next: string): string {
|
|
|
165
173
|
}
|
|
166
174
|
|
|
167
175
|
/** Group agents: built-ins + user-defined. */
|
|
168
|
-
export function
|
|
169
|
-
const all = availableAgents();
|
|
176
|
+
export function groupedAvailableRotors(cwd?: string): Array<{ header: string; agents: string[] }> {
|
|
177
|
+
const all = availableAgents(cwd);
|
|
170
178
|
const groups: Array<{ header: string; agents: string[] }> = [];
|
|
171
|
-
const builtin = all.filter((a) =>
|
|
179
|
+
const builtin = all.filter((a) => BUILTIN_ROTORS.includes(a));
|
|
172
180
|
if (builtin.length > 0) groups.push({ header: "built-in", agents: builtin });
|
|
173
|
-
const user = all.filter((a) => !
|
|
181
|
+
const user = all.filter((a) => !BUILTIN_ROTORS.includes(a));
|
|
174
182
|
if (user.length > 0) groups.push({ header: "user-defined", agents: user });
|
|
175
183
|
return groups;
|
|
176
184
|
}
|
|
177
185
|
|
|
178
186
|
/** Header line shown above chat. Persistent, dim, single line. */
|
|
179
187
|
export function formatHeaderLine(agent: string): string {
|
|
180
|
-
const meta =
|
|
188
|
+
const meta = getRotorMeta(agent);
|
|
181
189
|
const writeTag = meta.writesFiles ? "" : " \u00b7 read-only";
|
|
182
190
|
return `${meta.emoji} ${agent} \u00b7 ${meta.description}${writeTag} [Ctrl+Tab to cycle]`;
|
|
183
191
|
}
|
|
@@ -204,7 +212,7 @@ export function loadAgent(cwd: string): string | null {
|
|
|
204
212
|
const file = agentFilePath(cwd);
|
|
205
213
|
if (!fs.existsSync(file)) return null;
|
|
206
214
|
const raw = fs.readFileSync(file, "utf-8").trim();
|
|
207
|
-
if (!
|
|
215
|
+
if (!isValidRotorName(raw)) return null;
|
|
208
216
|
return raw;
|
|
209
217
|
} catch {
|
|
210
218
|
return null;
|
package/switch/index.ts
CHANGED
|
@@ -2,18 +2,18 @@
|
|
|
2
2
|
// index.ts — pi-switch extension entry (v2: footer-pill UI)
|
|
3
3
|
// =============================================================================
|
|
4
4
|
//
|
|
5
|
-
// Wires the
|
|
5
|
+
// Wires the rotor switcher into pi as a compact footer pill:
|
|
6
6
|
// - Footer status pill: "▶ ⚡ worker" (or "· ⚡ worker" for the default)
|
|
7
|
-
// - Click pill or `/
|
|
8
|
-
// - Ctrl+Tab → cycle to next
|
|
7
|
+
// - Click pill or `/rotor` → open full picker modal (SelectList)
|
|
8
|
+
// - Ctrl+Tab → cycle to next rotor (no popup, hot switch)
|
|
9
9
|
// - F2 → same, fallback if your terminal doesn't pass Ctrl+Tab through
|
|
10
|
-
// - Persists current
|
|
11
|
-
// - Exposes `globalThis.
|
|
10
|
+
// - Persists current rotor to .soly/agent or ~/.pi-switch/agent
|
|
11
|
+
// - Exposes `globalThis.__PI_SWITCH_ROTOR__` for other extensions
|
|
12
12
|
// - Injects a short system-prompt section so the LLM knows the current
|
|
13
|
-
//
|
|
13
|
+
// rotor and the available alternatives
|
|
14
14
|
//
|
|
15
15
|
// UI philosophy:
|
|
16
|
-
// - Header is for content, not for tool chrome. Move
|
|
16
|
+
// - Header is for content, not for tool chrome. Move rotors to footer.
|
|
17
17
|
// - Click to explore, hotkey to power-use, no DOM clutter in between.
|
|
18
18
|
// - Visual change is the pill text only. Chat stays clean.
|
|
19
19
|
// =============================================================================
|
|
@@ -24,57 +24,57 @@ import * as fs from "node:fs";
|
|
|
24
24
|
import * as os from "node:os";
|
|
25
25
|
import * as path from "node:path";
|
|
26
26
|
import {
|
|
27
|
-
|
|
28
|
-
|
|
27
|
+
DEFAULT_ROTOR,
|
|
28
|
+
BUILTIN_ROTORS,
|
|
29
29
|
availableAgents,
|
|
30
30
|
nextAgent,
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
31
|
+
parseRotorName,
|
|
32
|
+
groupedAvailableRotors,
|
|
33
|
+
getRotorMeta,
|
|
34
34
|
loadAgent,
|
|
35
35
|
saveAgent,
|
|
36
36
|
} from "./core.ts";
|
|
37
37
|
import { buildPiSwitchSection, recommendAgent } from "./prompt.ts";
|
|
38
38
|
|
|
39
|
-
const GLOBAL_KEY = "
|
|
39
|
+
const GLOBAL_KEY = "__PI_SWITCH_ROTOR__";
|
|
40
40
|
|
|
41
41
|
export default function piSwitchExtension(pi: ExtensionAPI) {
|
|
42
42
|
let cwd = "";
|
|
43
|
-
let
|
|
44
|
-
let cycle: string[] = [
|
|
43
|
+
let currentRotor: string = DEFAULT_ROTOR;
|
|
44
|
+
let cycle: string[] = [DEFAULT_ROTOR];
|
|
45
45
|
let lastUi: ExtensionUIContext | null = null;
|
|
46
46
|
|
|
47
47
|
function refreshCycle(): void {
|
|
48
|
-
cycle = availableAgents();
|
|
49
|
-
if (!cycle.includes(
|
|
48
|
+
cycle = availableAgents(cwd);
|
|
49
|
+
if (!cycle.includes(currentRotor)) currentRotor = DEFAULT_ROTOR;
|
|
50
50
|
}
|
|
51
51
|
|
|
52
52
|
function publish(): void {
|
|
53
|
-
(globalThis as Record<string, unknown>)[GLOBAL_KEY] =
|
|
53
|
+
(globalThis as Record<string, unknown>)[GLOBAL_KEY] = currentRotor;
|
|
54
54
|
}
|
|
55
55
|
|
|
56
56
|
function rerender(): void {
|
|
57
57
|
if (!lastUi) return;
|
|
58
58
|
try {
|
|
59
|
-
const meta =
|
|
59
|
+
const meta = getRotorMeta(currentRotor);
|
|
60
60
|
// Persistent pill — always visible above the input, even for the
|
|
61
|
-
// default
|
|
61
|
+
// default rotor. The user wants a constant mode indicator, not a
|
|
62
62
|
// transient one. Marker "▶" makes it scannable.
|
|
63
|
-
const marker =
|
|
64
|
-
const pill = `${marker} ${meta.emoji} ${
|
|
63
|
+
const marker = currentRotor === DEFAULT_ROTOR ? "·" : "▶";
|
|
64
|
+
const pill = `${marker} ${meta.emoji} ${currentRotor}`;
|
|
65
65
|
lastUi.setStatus("pi-switch", pill);
|
|
66
66
|
} catch { /* no ui yet */ }
|
|
67
67
|
}
|
|
68
68
|
|
|
69
|
-
function
|
|
70
|
-
const prev =
|
|
69
|
+
function setRotor(next: string): void {
|
|
70
|
+
const prev = currentRotor;
|
|
71
71
|
if (next === prev) return;
|
|
72
|
-
|
|
72
|
+
currentRotor = next;
|
|
73
73
|
publish();
|
|
74
74
|
if (cwd) saveAgent(cwd, next);
|
|
75
75
|
rerender();
|
|
76
76
|
// Footer pill is the only visible signal of the switch.
|
|
77
|
-
// Chat stays clean —
|
|
77
|
+
// Chat stays clean — rotor is plumbing, not conversation content.
|
|
78
78
|
}
|
|
79
79
|
|
|
80
80
|
// ----- session_start: load persisted agent + set initial pill -----
|
|
@@ -83,7 +83,7 @@ export default function piSwitchExtension(pi: ExtensionAPI) {
|
|
|
83
83
|
lastUi = ctx.ui;
|
|
84
84
|
publish();
|
|
85
85
|
const restored = loadAgent(cwd);
|
|
86
|
-
if (restored)
|
|
86
|
+
if (restored) currentRotor = restored;
|
|
87
87
|
refreshCycle();
|
|
88
88
|
publish();
|
|
89
89
|
rerender();
|
|
@@ -113,7 +113,7 @@ export default function piSwitchExtension(pi: ExtensionAPI) {
|
|
|
113
113
|
lastCycleTs = now;
|
|
114
114
|
lastUi = sctx.ui;
|
|
115
115
|
refreshCycle();
|
|
116
|
-
|
|
116
|
+
setRotor(nextAgent(currentRotor, cycle));
|
|
117
117
|
};
|
|
118
118
|
pi.registerShortcut("ctrl+tab", {
|
|
119
119
|
description: "Cycle to next agent (worker → oracle → scout → …)",
|
|
@@ -124,9 +124,10 @@ export default function piSwitchExtension(pi: ExtensionAPI) {
|
|
|
124
124
|
handler: (sctx) => cycleShortcut(sctx),
|
|
125
125
|
});
|
|
126
126
|
|
|
127
|
-
// ----- /
|
|
128
|
-
|
|
129
|
-
|
|
127
|
+
// ----- /rotor: open picker, or subcommands (create / doctor / recommend / set) -----
|
|
128
|
+
// (formerly /agent — renamed to /rotor in 1.0.0 to match the "rotor" naming convention)
|
|
129
|
+
pi.registerCommand("rotor", {
|
|
130
|
+
description: "open rotor picker, or `set <name>`, `create`, `doctor`, `recommend <task>`",
|
|
130
131
|
handler: async (args, ctx) => {
|
|
131
132
|
lastUi = ctx.ui;
|
|
132
133
|
refreshCycle();
|
|
@@ -140,7 +141,7 @@ export default function piSwitchExtension(pi: ExtensionAPI) {
|
|
|
140
141
|
if (subcommand === "set" && arg) return handleSet(arg, ctx.ui);
|
|
141
142
|
|
|
142
143
|
// Direct agent name → set
|
|
143
|
-
if (subcommand && cycle.includes(subcommand)) return
|
|
144
|
+
if (subcommand && cycle.includes(subcommand)) return setRotor(subcommand);
|
|
144
145
|
if (arg && !subcommand) return handleSet(arg, ctx.ui);
|
|
145
146
|
|
|
146
147
|
// No arg: open picker modal
|
|
@@ -159,28 +160,28 @@ function openPicker(ui: ExtensionUIContext): void {
|
|
|
159
160
|
for (const g of groups) {
|
|
160
161
|
all.push({ value: "__sep__", label: `── ${g.header} `, description: "", isCurrent: false });
|
|
161
162
|
for (const a of g.agents) {
|
|
162
|
-
const m =
|
|
163
|
+
const m = getRotorMeta(a);
|
|
163
164
|
all.push({
|
|
164
165
|
value: a,
|
|
165
166
|
label: `${m.emoji} ${a}`,
|
|
166
167
|
description: `${m.description}${m.writesFiles ? "" : " · read-only"}`,
|
|
167
|
-
isCurrent: a ===
|
|
168
|
+
isCurrent: a === currentRotorRef(),
|
|
168
169
|
});
|
|
169
170
|
}
|
|
170
171
|
}
|
|
171
172
|
return all;
|
|
172
173
|
}, ui, (choice) => {
|
|
173
|
-
if (choice && choice !== "__sep__")
|
|
174
|
+
if (choice && choice !== "__sep__") setRotorRef(choice);
|
|
174
175
|
});
|
|
175
176
|
}
|
|
176
177
|
|
|
177
178
|
function handleSet(name: string, ui: ExtensionUIContext): void {
|
|
178
|
-
const target =
|
|
179
|
+
const target = parseRotorName(name);
|
|
179
180
|
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");
|
|
181
|
+
if (!availableAgents(cwd).includes(target)) {
|
|
182
|
+
return ui.notify(`pi-switch: unknown "${target}". available: ${availableAgents(cwd).join(", ")}`, "error");
|
|
182
183
|
}
|
|
183
|
-
|
|
184
|
+
setRotorRef(target);
|
|
184
185
|
}
|
|
185
186
|
|
|
186
187
|
function handleRecommend(task: string, ui: ExtensionUIContext): void {
|
|
@@ -191,22 +192,22 @@ function handleRecommend(task: string, ui: ExtensionUIContext): void {
|
|
|
191
192
|
}
|
|
192
193
|
|
|
193
194
|
// ---------------------------------------------------------------------------
|
|
194
|
-
// setAgent /
|
|
195
|
+
// setAgent / currentRotor — module-scope so the modal can mutate them
|
|
195
196
|
// ---------------------------------------------------------------------------
|
|
196
197
|
|
|
197
|
-
let
|
|
198
|
-
let
|
|
198
|
+
let currentRotorRef: () => string = () => DEFAULT_ROTOR;
|
|
199
|
+
let setRotorRef: (next: string) => void = () => {};
|
|
199
200
|
|
|
200
201
|
// The picker and the main extension share state via these refs.
|
|
201
202
|
// We patch them in `wire()` at the top of the default export.
|
|
202
203
|
function wire(get: () => string, set: (n: string) => void): void {
|
|
203
|
-
|
|
204
|
-
|
|
204
|
+
currentRotorRef = get;
|
|
205
|
+
setRotorRef = set;
|
|
205
206
|
}
|
|
206
207
|
|
|
207
208
|
function refreshAndBuild<T>(
|
|
208
209
|
ui: ExtensionUIContext,
|
|
209
|
-
build: (groups: ReturnType<typeof
|
|
210
|
+
build: (groups: ReturnType<typeof groupedAvailableRotors>) => T,
|
|
210
211
|
_ui: ExtensionUIContext,
|
|
211
212
|
_onSelect: (value: string) => void,
|
|
212
213
|
): void {
|
|
@@ -227,7 +228,7 @@ function createAgent(
|
|
|
227
228
|
ui.notify("pi-switch: usage — `/agent create <name>`", "info");
|
|
228
229
|
return;
|
|
229
230
|
}
|
|
230
|
-
if (!
|
|
231
|
+
if (!parseRotorName(name)) {
|
|
231
232
|
ui.notify(`pi-switch: invalid name "${name}". Use letters/digits/dashes/underscores, ≤64 chars.`, "error");
|
|
232
233
|
return;
|
|
233
234
|
}
|
|
@@ -266,7 +267,7 @@ function createAgent(
|
|
|
266
267
|
// race where two parallel createAgent calls would clobber each
|
|
267
268
|
// other's write after both pass the existsSync check.
|
|
268
269
|
const tmp = `${file}.tmp-${process.pid}-${Date.now()}`;
|
|
269
|
-
fs.writeFileSync(tmp,
|
|
270
|
+
fs.writeFileSync(tmp, rotorTemplate(name, description), "utf-8");
|
|
270
271
|
fs.renameSync(tmp, file);
|
|
271
272
|
ui.notify(
|
|
272
273
|
`pi-switch: created ${file}\n → next Ctrl+Tab to see it in the cycle\n → edit the system prompt to specialize`,
|
|
@@ -278,7 +279,7 @@ function createAgent(
|
|
|
278
279
|
});
|
|
279
280
|
}
|
|
280
281
|
|
|
281
|
-
function
|
|
282
|
+
function rotorTemplate(name: string, description: string): string {
|
|
282
283
|
return `---
|
|
283
284
|
name: ${name}
|
|
284
285
|
description: ${description}
|
|
@@ -314,11 +315,11 @@ you have a specific reason to change it.
|
|
|
314
315
|
}
|
|
315
316
|
|
|
316
317
|
function doctorReport(): string {
|
|
317
|
-
const cycle = availableAgents();
|
|
318
|
+
const cycle = availableAgents(cwd);
|
|
318
319
|
const userDir = path.join(os.homedir(), ".pi", "agent", "agents");
|
|
319
320
|
const lines: string[] = ["pi-switch doctor:", ""];
|
|
320
|
-
const builtins = cycle.filter((a) =>
|
|
321
|
-
const users = cycle.filter((a) => !
|
|
321
|
+
const builtins = cycle.filter((a) => BUILTIN_ROTORS.includes(a));
|
|
322
|
+
const users = cycle.filter((a) => !BUILTIN_ROTORS.includes(a));
|
|
322
323
|
lines.push(`cycle: ${cycle.length} agents (${builtins.length} built-in, ${users.length} user)`);
|
|
323
324
|
lines.push("");
|
|
324
325
|
if (!fs.existsSync(userDir)) {
|
|
@@ -8,58 +8,58 @@ import * as fs from "node:fs";
|
|
|
8
8
|
import * as os from "node:os";
|
|
9
9
|
import * as path from "node:path";
|
|
10
10
|
import {
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
11
|
+
DEFAULT_ROTOR,
|
|
12
|
+
BUILTIN_ROTORS,
|
|
13
|
+
ROTOR_META,
|
|
14
|
+
getRotorMeta,
|
|
15
|
+
isValidRotorName,
|
|
16
|
+
discoverUserRotors,
|
|
17
17
|
availableAgents,
|
|
18
18
|
nextAgent,
|
|
19
|
-
|
|
19
|
+
parseRotorName,
|
|
20
20
|
formatAgentBadge,
|
|
21
|
-
|
|
21
|
+
formatRotorSwitchNotify,
|
|
22
22
|
formatHeaderLine,
|
|
23
|
-
|
|
23
|
+
groupedAvailableRotors,
|
|
24
24
|
agentFilePath,
|
|
25
25
|
loadAgent,
|
|
26
26
|
saveAgent,
|
|
27
27
|
} from "../core.js";
|
|
28
28
|
|
|
29
|
-
describe("
|
|
29
|
+
describe("DEFAULT_ROTOR", () => {
|
|
30
30
|
test("is 'worker'", () => {
|
|
31
|
-
expect(
|
|
31
|
+
expect(DEFAULT_ROTOR).toBe("worker");
|
|
32
32
|
});
|
|
33
33
|
});
|
|
34
34
|
|
|
35
|
-
describe("
|
|
35
|
+
describe("isValidRotorName", () => {
|
|
36
36
|
test("accepts simple names", () => {
|
|
37
|
-
expect(
|
|
38
|
-
expect(
|
|
37
|
+
expect(isValidRotorName("worker")).toBe(true);
|
|
38
|
+
expect(isValidRotorName("my_agent")).toBe(true);
|
|
39
39
|
});
|
|
40
40
|
test("rejects invalid", () => {
|
|
41
|
-
expect(
|
|
42
|
-
expect(
|
|
43
|
-
expect(
|
|
41
|
+
expect(isValidRotorName("with space")).toBe(false);
|
|
42
|
+
expect(isValidRotorName("")).toBe(false);
|
|
43
|
+
expect(isValidRotorName("a".repeat(65))).toBe(false);
|
|
44
44
|
});
|
|
45
45
|
});
|
|
46
46
|
|
|
47
|
-
describe("
|
|
47
|
+
describe("ROTOR_META", () => {
|
|
48
48
|
test("every built-in has metadata", () => {
|
|
49
|
-
for (const a of
|
|
50
|
-
expect(
|
|
51
|
-
expect(
|
|
49
|
+
for (const a of BUILTIN_ROTORS) {
|
|
50
|
+
expect(ROTOR_META[a]).toBeDefined();
|
|
51
|
+
expect(ROTOR_META[a]!.emoji.length).toBeGreaterThan(0);
|
|
52
52
|
}
|
|
53
53
|
});
|
|
54
54
|
test("meta has writesFiles flag", () => {
|
|
55
|
-
expect(
|
|
56
|
-
expect(
|
|
55
|
+
expect(ROTOR_META.worker!.writesFiles).toBe(true);
|
|
56
|
+
expect(ROTOR_META.oracle!.writesFiles).toBe(false);
|
|
57
57
|
});
|
|
58
58
|
});
|
|
59
59
|
|
|
60
|
-
describe("
|
|
60
|
+
describe("getRotorMeta", () => {
|
|
61
61
|
test("returns fallback for unknown", () => {
|
|
62
|
-
const m =
|
|
62
|
+
const m = getRotorMeta("zzz");
|
|
63
63
|
expect(m.emoji.length).toBeGreaterThan(0);
|
|
64
64
|
});
|
|
65
65
|
});
|
|
@@ -74,16 +74,16 @@ describe("nextAgent", () => {
|
|
|
74
74
|
});
|
|
75
75
|
});
|
|
76
76
|
|
|
77
|
-
describe("
|
|
77
|
+
describe("parseRotorName", () => {
|
|
78
78
|
test("trims and validates", () => {
|
|
79
|
-
expect(
|
|
80
|
-
expect(
|
|
79
|
+
expect(parseRotorName(" oracle ")).toBe("oracle");
|
|
80
|
+
expect(parseRotorName("with space")).toBeNull();
|
|
81
81
|
});
|
|
82
82
|
});
|
|
83
83
|
|
|
84
84
|
describe("formatAgentBadge", () => {
|
|
85
85
|
test("null for default", () => {
|
|
86
|
-
expect(formatAgentBadge(
|
|
86
|
+
expect(formatAgentBadge(DEFAULT_ROTOR)).toBeNull();
|
|
87
87
|
});
|
|
88
88
|
test("emoji + name for non-default", () => {
|
|
89
89
|
const b = formatAgentBadge("oracle");
|
|
@@ -91,9 +91,9 @@ describe("formatAgentBadge", () => {
|
|
|
91
91
|
});
|
|
92
92
|
});
|
|
93
93
|
|
|
94
|
-
describe("
|
|
94
|
+
describe("formatRotorSwitchNotify", () => {
|
|
95
95
|
test("multi-line: old → new + capability", () => {
|
|
96
|
-
const out =
|
|
96
|
+
const out = formatRotorSwitchNotify("worker", "oracle");
|
|
97
97
|
expect(out).toContain("pi-switch agent changed");
|
|
98
98
|
expect(out).toContain("worker");
|
|
99
99
|
expect(out).toContain("oracle");
|
|
@@ -117,9 +117,9 @@ describe("formatHeaderLine", () => {
|
|
|
117
117
|
});
|
|
118
118
|
});
|
|
119
119
|
|
|
120
|
-
describe("
|
|
120
|
+
describe("groupedAvailableRotors", () => {
|
|
121
121
|
test("includes built-in group", () => {
|
|
122
|
-
const groups =
|
|
122
|
+
const groups = groupedAvailableRotors("/nonexistent");
|
|
123
123
|
expect(groups[0]?.header).toBe("built-in");
|
|
124
124
|
});
|
|
125
125
|
test("includes user group when present", () => {
|
|
@@ -133,7 +133,7 @@ describe("groupedAvailableAgents", () => {
|
|
|
133
133
|
const agentsDir = path.join(tmp, ".agents");
|
|
134
134
|
fs.mkdirSync(agentsDir, { recursive: true });
|
|
135
135
|
fs.writeFileSync(path.join(agentsDir, "my.md"), "---\nname: my-helper\n---\n# body\n");
|
|
136
|
-
const groups =
|
|
136
|
+
const groups = groupedAvailableRotors();
|
|
137
137
|
const userGroup = groups.find((g) => g.header === "user-defined");
|
|
138
138
|
expect(userGroup?.agents).toContain("my-helper");
|
|
139
139
|
// restore
|
|
@@ -141,6 +141,17 @@ describe("groupedAvailableAgents", () => {
|
|
|
141
141
|
process.env.USERPROFILE = prevUserProfile ?? home;
|
|
142
142
|
fs.rmSync(tmp, { recursive: true, force: true });
|
|
143
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");
|
|
148
|
+
fs.mkdirSync(agentsDir, { recursive: true });
|
|
149
|
+
fs.writeFileSync(path.join(agentsDir, "proj.md"), "---\nname: project-helper\n---\n# body\n");
|
|
150
|
+
const groups = groupedAvailableRotors(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
|
+
});
|
|
144
155
|
});
|
|
145
156
|
|
|
146
157
|
// ---------------------------------------------------------------------------
|