pi-soly 0.6.0 → 0.8.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 +131 -42
- 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 +11 -10
- package/integrations.ts +1 -1
- package/package.json +2 -1
- package/skills/soly-framework/SKILL.md +343 -0
- package/switch/core.ts +47 -21
- package/switch/index.ts +38 -8
- package/switch/tests/core.test.ts +14 -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
|
@@ -1,14 +1,18 @@
|
|
|
1
1
|
// =============================================================================
|
|
2
|
-
//
|
|
2
|
+
// assets-install.ts — Idempotent install of soly-managed user assets
|
|
3
3
|
// =============================================================================
|
|
4
4
|
//
|
|
5
|
-
// Soly ships
|
|
6
|
-
// switches modes (worker / debugger / tester / reviewer / refactor /
|
|
7
|
-
// documenter / oracle / planner) based on the task brief the parent passes.
|
|
8
|
-
// One agent, one system prompt, all roles.
|
|
5
|
+
// Soly ships two kinds of user-scope assets:
|
|
9
6
|
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
7
|
+
// 1. Subagent configs → `~/.pi/agent/agents/`
|
|
8
|
+
// The single `soly-manager` subagent (mode-switching executor).
|
|
9
|
+
//
|
|
10
|
+
// 2. Skills → `~/.pi/agent/skills/<name>/`
|
|
11
|
+
// The `soly-framework` skill — framework documentation the LLM
|
|
12
|
+
// loads on demand via the read tool.
|
|
13
|
+
//
|
|
14
|
+
// pi discovers both from `~/.pi/agent/`, so on first session_start we
|
|
15
|
+
// copy our shipped files there.
|
|
12
16
|
//
|
|
13
17
|
// IDEMPOTENT: if the target file already exists (user may have customized
|
|
14
18
|
// it), we do NOT overwrite. This is one-way "first install wins".
|
|
@@ -23,75 +27,160 @@ const SHIPPED_AGENTS = [
|
|
|
23
27
|
"soly-manager.md",
|
|
24
28
|
] as const;
|
|
25
29
|
|
|
26
|
-
/**
|
|
27
|
-
*
|
|
28
|
-
|
|
30
|
+
/** soly skills bundled with the extension. Each entry is a directory
|
|
31
|
+
* under `skills/` containing a SKILL.md. */
|
|
32
|
+
const SHIPPED_SKILLS = [
|
|
33
|
+
"soly-framework",
|
|
34
|
+
] as const;
|
|
35
|
+
|
|
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 userAgentsDirs(): string[] {
|
|
39
|
+
const home = process.env.HOME || process.env.USERPROFILE || os.homedir();
|
|
40
|
+
return [
|
|
41
|
+
path.join(home, ".agents"), // vendor-neutral (preferred)
|
|
42
|
+
path.join(home, ".pi", "agent", "agents"), // pi's native
|
|
43
|
+
];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Where pi looks for user skills. */
|
|
47
|
+
function userSkillsDir(): string {
|
|
29
48
|
const home = process.env.HOME || process.env.USERPROFILE || os.homedir();
|
|
30
|
-
return path.join(home, ".pi", "agent", "
|
|
49
|
+
return path.join(home, ".pi", "agent", "skills");
|
|
31
50
|
}
|
|
32
51
|
|
|
33
52
|
/** Where this soly extension's `agents/` directory lives. */
|
|
34
|
-
function
|
|
53
|
+
function shippedAgentsDir(extensionRoot: string): string {
|
|
35
54
|
return path.join(extensionRoot, "agents");
|
|
36
55
|
}
|
|
37
56
|
|
|
57
|
+
/** Where this soly extension's `skills/` directory lives. */
|
|
58
|
+
function shippedSkillsDir(extensionRoot: string): string {
|
|
59
|
+
return path.join(extensionRoot, "skills");
|
|
60
|
+
}
|
|
61
|
+
|
|
38
62
|
export interface InstallResult {
|
|
39
63
|
installed: string[];
|
|
40
64
|
skipped: string[];
|
|
41
65
|
errors: string[];
|
|
42
66
|
}
|
|
43
67
|
|
|
44
|
-
/**
|
|
68
|
+
/** Copy a single file if destination doesn't exist. Idempotent. */
|
|
69
|
+
function copyIfMissing(from: string, to: string): "installed" | "skipped" | "error" {
|
|
70
|
+
if (!fs.existsSync(from)) return "error";
|
|
71
|
+
if (fs.existsSync(to)) return "skipped";
|
|
72
|
+
try {
|
|
73
|
+
fs.copyFileSync(from, to);
|
|
74
|
+
return "installed";
|
|
75
|
+
} catch {
|
|
76
|
+
return "error";
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Recursively copy a directory tree if destination doesn't exist. Idempotent. */
|
|
81
|
+
function copyDirIfMissing(from: string, to: string): "installed" | "skipped" | "error" {
|
|
82
|
+
if (!fs.existsSync(from)) return "error";
|
|
83
|
+
if (fs.existsSync(to)) return "skipped";
|
|
84
|
+
try {
|
|
85
|
+
fs.mkdirSync(path.dirname(to), { recursive: true });
|
|
86
|
+
fs.cpSync(from, to, { recursive: true });
|
|
87
|
+
return "installed";
|
|
88
|
+
} catch {
|
|
89
|
+
return "error";
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Install shipped soly agents to `~/.agents/` (vendor-neutral,
|
|
94
|
+
* preferred). Legacy `~/.pi/agent/agents/` copies are left alone —
|
|
95
|
+
* `discoverUserAgents` reads both, so old installs still work. */
|
|
45
96
|
export function installSolyAgents(extensionRoot: string): InstallResult {
|
|
46
97
|
const result: InstallResult = { installed: [], skipped: [], errors: [] };
|
|
47
|
-
const src =
|
|
48
|
-
const dst = userAgentsDir();
|
|
98
|
+
const src = shippedAgentsDir(extensionRoot);
|
|
49
99
|
|
|
50
|
-
if (!fs.existsSync(src))
|
|
51
|
-
// Development mode or partial install — silently no-op
|
|
52
|
-
return result;
|
|
53
|
-
}
|
|
100
|
+
if (!fs.existsSync(src)) return result; // dev mode no-op
|
|
54
101
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
102
|
+
// Try vendor-neutral first, then fall back to pi's native dir.
|
|
103
|
+
let dst: string | null = null;
|
|
104
|
+
for (const candidate of userAgentsDirs()) {
|
|
105
|
+
try {
|
|
106
|
+
fs.mkdirSync(candidate, { recursive: true });
|
|
107
|
+
dst = candidate;
|
|
108
|
+
break;
|
|
109
|
+
} catch (err) {
|
|
110
|
+
result.errors.push(`mkdir ${candidate}: ${(err as Error).message}`);
|
|
111
|
+
}
|
|
60
112
|
}
|
|
113
|
+
if (!dst) return result;
|
|
61
114
|
|
|
62
115
|
for (const name of SHIPPED_AGENTS) {
|
|
63
116
|
const from = path.join(src, name);
|
|
64
117
|
const to = path.join(dst, name);
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
118
|
+
const r = copyIfMissing(from, to);
|
|
119
|
+
if (r === "installed") result.installed.push(name);
|
|
120
|
+
else if (r === "skipped") result.skipped.push(name);
|
|
121
|
+
else result.errors.push(`missing source: ${from}`);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return result;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Install shipped soly skills to `~/.pi/agent/skills/`. Idempotent. */
|
|
128
|
+
export function installSolySkills(extensionRoot: string): InstallResult {
|
|
129
|
+
const result: InstallResult = { installed: [], skipped: [], errors: [] };
|
|
130
|
+
const src = shippedSkillsDir(extensionRoot);
|
|
131
|
+
const dst = userSkillsDir();
|
|
132
|
+
|
|
133
|
+
if (!fs.existsSync(src)) return result; // dev mode no-op
|
|
134
|
+
|
|
135
|
+
for (const name of SHIPPED_SKILLS) {
|
|
136
|
+
const from = path.join(src, name);
|
|
137
|
+
const to = path.join(dst, name);
|
|
138
|
+
const r = copyDirIfMissing(from, to);
|
|
139
|
+
if (r === "installed") result.installed.push(name);
|
|
140
|
+
else if (r === "skipped") result.skipped.push(name);
|
|
141
|
+
else result.errors.push(`missing source: ${from}`);
|
|
80
142
|
}
|
|
81
143
|
|
|
82
144
|
return result;
|
|
83
145
|
}
|
|
84
146
|
|
|
85
|
-
/**
|
|
147
|
+
/** Install all soly assets (agents + skills). Combined for convenience. */
|
|
148
|
+
export function installSolyAssets(extensionRoot: string): {
|
|
149
|
+
agents: InstallResult;
|
|
150
|
+
skills: InstallResult;
|
|
151
|
+
} {
|
|
152
|
+
return {
|
|
153
|
+
agents: installSolyAgents(extensionRoot),
|
|
154
|
+
skills: installSolySkills(extensionRoot),
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Check which shipped soly agents are present across all user agent
|
|
159
|
+
* homes. A file counts as "installed" if it's in ANY of the dirs. */
|
|
86
160
|
export function checkSolyAgentsInstalled(extensionRoot: string): {
|
|
87
161
|
installed: string[];
|
|
88
162
|
missing: string[];
|
|
89
163
|
} {
|
|
90
|
-
const dst = userAgentsDir();
|
|
91
164
|
const installed: string[] = [];
|
|
92
165
|
const missing: string[] = [];
|
|
93
166
|
for (const name of SHIPPED_AGENTS) {
|
|
94
|
-
|
|
167
|
+
const present = userAgentsDirs().some((dir) => fs.existsSync(path.join(dir, name)));
|
|
168
|
+
if (present) installed.push(name);
|
|
169
|
+
else missing.push(name);
|
|
170
|
+
}
|
|
171
|
+
return { installed, missing };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** Check which shipped soly skills are present in the user dir. */
|
|
175
|
+
export function checkSolySkillsInstalled(extensionRoot: string): {
|
|
176
|
+
installed: string[];
|
|
177
|
+
missing: string[];
|
|
178
|
+
} {
|
|
179
|
+
const dst = userSkillsDir();
|
|
180
|
+
const installed: string[] = [];
|
|
181
|
+
const missing: string[] = [];
|
|
182
|
+
for (const name of SHIPPED_SKILLS) {
|
|
183
|
+
if (fs.existsSync(path.join(dst, name, "SKILL.md"))) {
|
|
95
184
|
installed.push(name);
|
|
96
185
|
} else {
|
|
97
186
|
missing.push(name);
|
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
|
@@ -45,7 +45,7 @@ import {
|
|
|
45
45
|
type SourceSpec,
|
|
46
46
|
} from "./core.ts";
|
|
47
47
|
import { buildIntegrationsSection } from "./integrations.ts";
|
|
48
|
-
import {
|
|
48
|
+
import { installSolyAssets } from "./agents-install.ts";
|
|
49
49
|
import {
|
|
50
50
|
DEFAULT_CONFIG,
|
|
51
51
|
loadConfig,
|
|
@@ -388,21 +388,22 @@ export default function solyExtension(pi: ExtensionAPI) {
|
|
|
388
388
|
}
|
|
389
389
|
}
|
|
390
390
|
|
|
391
|
-
// Auto-install soly-manager
|
|
392
|
-
// on first run. Opt-in via config
|
|
393
|
-
// (default false). Idempotent — respects
|
|
394
|
-
// customized copies.
|
|
391
|
+
// Auto-install soly user-scope assets (soly-manager agent + soly-framework
|
|
392
|
+
// skill) to ~/.pi/agent/ on first run. Opt-in via config
|
|
393
|
+
// `agent.useSolyWorkerSubagents` (default false). Idempotent — respects
|
|
394
|
+
// any existing user-customized copies.
|
|
395
395
|
if (activeConfig.agent.useSolyWorkerSubagents) {
|
|
396
396
|
const extRoot = path.dirname(new URL(import.meta.url).pathname.replace(/^\/([A-Z]:)/, "$1"));
|
|
397
|
-
const
|
|
398
|
-
|
|
397
|
+
const assets = installSolyAssets(extRoot);
|
|
398
|
+
const installed = [...assets.agents.installed, ...assets.skills.installed.map((s) => `skill:${s}`)];
|
|
399
|
+
if (installed.length > 0) {
|
|
399
400
|
ctx.ui.notify(
|
|
400
|
-
`soly: installed
|
|
401
|
+
`soly: installed (${installed.join(", ")}) — run \`/subagents-doctor\` to verify`,
|
|
401
402
|
"info",
|
|
402
403
|
);
|
|
403
404
|
}
|
|
404
|
-
for (const e of
|
|
405
|
-
ctx.ui.notify(`soly:
|
|
405
|
+
for (const e of [...assets.agents.errors, ...assets.skills.errors]) {
|
|
406
|
+
ctx.ui.notify(`soly: install error — ${e}`, "warning");
|
|
406
407
|
}
|
|
407
408
|
}
|
|
408
409
|
|
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.8.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",
|
|
@@ -40,6 +40,7 @@
|
|
|
40
40
|
"agents-install.ts",
|
|
41
41
|
"agents",
|
|
42
42
|
"ask",
|
|
43
|
+
"skills",
|
|
43
44
|
"switch",
|
|
44
45
|
"workflows",
|
|
45
46
|
"workflows-data"
|
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: soly-framework
|
|
3
|
+
description: Use when the user asks how to do anything with the soly framework for pi — start a new project, plan or execute a phase, pause and resume sessions, add rules, add intent docs, write PLAN.md or SUMMARY.md, troubleshoot issues. Triggers on "how do I", "what's the command for", "soly help", "soly framework", and any practical question about using the soly extension. NOT loaded for generic code questions — only when the user is working with the soly workflow.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# soly framework
|
|
7
|
+
|
|
8
|
+
The **soly** extension adds project-management workflow to [pi-coding-agent](https://github.com/nicobailon/pi-coding-agent): intent docs, ROADMAP/STATE/PHASE state machine, and subagent-driven plan execution. This skill is your complete reference for using it.
|
|
9
|
+
|
|
10
|
+
## Quick start (read first if new)
|
|
11
|
+
|
|
12
|
+
**Mental model — three layers, always in system prompt, in this order:**
|
|
13
|
+
|
|
14
|
+
1. **Project intent** (`.soly/docs/`) — the 0-point. What the user wants the app to be. Written BEFORE plans, by humans.
|
|
15
|
+
2. **Project state** (`.soly/STATE.md`, `ROADMAP.md`) — where we are, current phase, recent decisions.
|
|
16
|
+
3. **Project rules** (`.soly/rules/`, `~/.soly/rules/`) — how to behave in this project.
|
|
17
|
+
|
|
18
|
+
**Workflow model — phases and plans:**
|
|
19
|
+
|
|
20
|
+
- A **phase** is a milestone (e.g. "01-foundation"). Has one or more `PLAN.md` files.
|
|
21
|
+
- A **plan** is one ordered execution unit. Has `<task>` blocks.
|
|
22
|
+
- A **task** is the smallest unit. Has type, description, verify, accept.
|
|
23
|
+
- **Close-out**: production code commits → `SUMMARY.md` → `STATE.md` updated → ROADMAP check.
|
|
24
|
+
|
|
25
|
+
## Slash commands (interactive mode)
|
|
26
|
+
|
|
27
|
+
| Command | What it does |
|
|
28
|
+
|---|---|
|
|
29
|
+
| `/plan [N]` | Generate or update `PLAN.md` for phase N (or current phase) |
|
|
30
|
+
| `/execute [N[.MM]]` | Dispatch plan(s) to `soly-manager` subagent. `N` = all plans in phase. `N.MM` = specific plan. |
|
|
31
|
+
| `/discuss N` | Discussion-driven scoping for phase N — capture decisions before planning |
|
|
32
|
+
| `/inspect` | One-screen summary: position, phases, recent decisions |
|
|
33
|
+
| `/pause` | Save handoff (`HANDOFF.json` + `.continue-here.md`) for later resume |
|
|
34
|
+
| `/resume` | Restore from a paused handoff |
|
|
35
|
+
| `/quick <task>` | One-shot task that doesn't need a full plan — direct dispatch |
|
|
36
|
+
| `/soly` | Project state inspection (alias for `/inspect`) |
|
|
37
|
+
| `/why` | Show what context the LLM's last turn was based on |
|
|
38
|
+
| `/agent [name]` | Switch the current cycle agent (or open picker) |
|
|
39
|
+
|
|
40
|
+
`/soly <verb>` plain-text aliases also work for some verbs (legacy compat).
|
|
41
|
+
|
|
42
|
+
## File structure
|
|
43
|
+
|
|
44
|
+
```
|
|
45
|
+
<project-root>/
|
|
46
|
+
├── AGENTS.md # vendor-neutral agent context (loaded by pi)
|
|
47
|
+
├── agents.md # same as AGENTS.md, lowercase accepted
|
|
48
|
+
├── .agents/ # project-level agent definitions
|
|
49
|
+
│ ├── project-reviewer.md
|
|
50
|
+
│ └── data-scientist.md
|
|
51
|
+
├── .soly/
|
|
52
|
+
│ ├── ROADMAP.md # phase table
|
|
53
|
+
│ ├── STATE.md # current position + decisions log
|
|
54
|
+
│ ├── docs/ # 0-point intent docs (human-written, locked)
|
|
55
|
+
│ │ ├── vision.md
|
|
56
|
+
│ │ └── architecture.md
|
|
57
|
+
│ ├── rules/ # project rules (version-controlled)
|
|
58
|
+
│ │ ├── code-style.md
|
|
59
|
+
│ │ └── testing.md
|
|
60
|
+
│ ├── phases/
|
|
61
|
+
│ │ ├── 01-foundation/
|
|
62
|
+
│ │ │ ├── 01-CONTEXT.md # domain + decisions for phase 1
|
|
63
|
+
│ │ │ ├── 01-RESEARCH.md # what we looked up
|
|
64
|
+
│ │ │ ├── 01-PLAN-01.md # plan 1
|
|
65
|
+
│ │ │ ├── 01-PLAN-01-SUMMARY.md
|
|
66
|
+
│ │ │ ├── 01-PLAN-02.md
|
|
67
|
+
│ │ │ └── 01-PLAN-02-SUMMARY.md
|
|
68
|
+
│ │ └── 02-feature-x/
|
|
69
|
+
│ │ └── ...
|
|
70
|
+
│ ├── iterations/ # per-execution context bundles (auto)
|
|
71
|
+
│ ├── HANDOFF.json # pause snapshot
|
|
72
|
+
│ └── .continue-here.md # pause resume marker
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
## Frontmatter conventions
|
|
76
|
+
|
|
77
|
+
### PLAN.md frontmatter (required)
|
|
78
|
+
|
|
79
|
+
```markdown
|
|
80
|
+
---
|
|
81
|
+
id: 01-02 # phase-plan, zero-padded
|
|
82
|
+
title: Add OAuth flow
|
|
83
|
+
status: pending # pending | in_progress | done
|
|
84
|
+
phase: 1
|
|
85
|
+
depends-on: [] # other plan ids
|
|
86
|
+
parallelizable: true # can run alongside siblings
|
|
87
|
+
---
|
|
88
|
+
|
|
89
|
+
# Add OAuth flow
|
|
90
|
+
|
|
91
|
+
## read_first
|
|
92
|
+
- .soly/STATE.md
|
|
93
|
+
- .soly/ROADMAP.md
|
|
94
|
+
- .soly/rules/code-style.md
|
|
95
|
+
|
|
96
|
+
## tasks
|
|
97
|
+
- [ ] **type: implement**, description: Add token refresh logic
|
|
98
|
+
- files: src/auth/refresh.ts
|
|
99
|
+
- verify: bun test src/auth/refresh.test.ts
|
|
100
|
+
- accept: Refresh succeeds when token is expired; fails when refresh_token is also expired
|
|
101
|
+
|
|
102
|
+
- [ ] **type: tdd**, description: Write integration test for the auth flow
|
|
103
|
+
- verify: bun test src/auth/
|
|
104
|
+
|
|
105
|
+
- [ ] **type: checkpoint**, description: Pause for human review of UX
|
|
106
|
+
|
|
107
|
+
## verification
|
|
108
|
+
- bun test
|
|
109
|
+
- bun run typecheck
|
|
110
|
+
- bun run lint
|
|
111
|
+
|
|
112
|
+
## risks
|
|
113
|
+
- Token storage depends on the encryption scheme (see .soly/docs/architecture.md)
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
### SUMMARY.md frontmatter
|
|
117
|
+
|
|
118
|
+
```markdown
|
|
119
|
+
---
|
|
120
|
+
plan: 01-02
|
|
121
|
+
completed: 2026-06-15
|
|
122
|
+
duration: 47min
|
|
123
|
+
files-touched: 7
|
|
124
|
+
---
|
|
125
|
+
|
|
126
|
+
# Summary
|
|
127
|
+
|
|
128
|
+
## Tasks
|
|
129
|
+
- [x] Add token refresh logic
|
|
130
|
+
- [x] Write integration test
|
|
131
|
+
- [x] Pause for human review
|
|
132
|
+
|
|
133
|
+
## Deviations
|
|
134
|
+
- Refactored `auth/refresh.ts` to use singleton pattern (was factory). Documented in `architecture.md`.
|
|
135
|
+
|
|
136
|
+
## Verification
|
|
137
|
+
- `bun test`: 142 passing
|
|
138
|
+
- `bun run typecheck`: clean
|
|
139
|
+
- `bun run lint`: 0 warnings
|
|
140
|
+
|
|
141
|
+
## Next
|
|
142
|
+
- Phase 02 plan 01: User profile page
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
### Rules file frontmatter (optional)
|
|
146
|
+
|
|
147
|
+
```markdown
|
|
148
|
+
---
|
|
149
|
+
applyTo: "src/**/*.ts" # glob (optional, default: all)
|
|
150
|
+
priority: 50 # higher wins on conflict (default: 0)
|
|
151
|
+
---
|
|
152
|
+
|
|
153
|
+
# TypeScript style
|
|
154
|
+
|
|
155
|
+
- Strict mode required
|
|
156
|
+
- Never use `any` — use `unknown` and narrow
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
## Path discipline (NON-NEGOTIABLE)
|
|
160
|
+
|
|
161
|
+
**All soly-managed files live under `.soly/`.** Source code lives in the project's normal source tree.
|
|
162
|
+
|
|
163
|
+
| File kind | Goes to |
|
|
164
|
+
|---|---|
|
|
165
|
+
| `PLAN.md`, `SUMMARY.md`, `CONTEXT.md`, `RESEARCH.md` | `.soly/phases/<NN>-<slug>/` |
|
|
166
|
+
| Intent docs (0-point) | `.soly/docs/` |
|
|
167
|
+
| Rules | `.soly/rules/` (project) or `~/.soly/rules/` (user) |
|
|
168
|
+
| Handoff | `.soly/HANDOFF.json`, `.soly/.continue-here.md` |
|
|
169
|
+
| Iteration context | `.soly/iterations/` (auto-generated) |
|
|
170
|
+
| Production code, tests | project's normal `src/`, `tests/`, `app/`, etc. |
|
|
171
|
+
|
|
172
|
+
Use absolute paths (or paths starting with `$SOLY_DIR`) when calling tools. Never bare relative names that could land in cwd.
|
|
173
|
+
|
|
174
|
+
## Close-out order
|
|
175
|
+
|
|
176
|
+
The only legal sequence for finishing a plan:
|
|
177
|
+
|
|
178
|
+
1. Production code commits (1+)
|
|
179
|
+
2. `SUMMARY.md` committed
|
|
180
|
+
3. `STATE.md` "Current Position" block updated
|
|
181
|
+
4. `ROADMAP.md` phase checkbox updated
|
|
182
|
+
5. `PLAN.md` frontmatter `status: done`
|
|
183
|
+
|
|
184
|
+
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
|
+
|
|
186
|
+
## Cycle agents (4 built-in)
|
|
187
|
+
|
|
188
|
+
| Agent | Writes | Use for |
|
|
189
|
+
|---|---|---|
|
|
190
|
+
| `worker` | ✅ | Generic implementation, full tools |
|
|
191
|
+
| `oracle` | ❌ | Decision-consistency, no file edits |
|
|
192
|
+
| `scout` | ❌ | Codebase recon, read-only |
|
|
193
|
+
| `reviewer` | ❌ | Adversarial code review |
|
|
194
|
+
|
|
195
|
+
Switch with `/agent <name>` or `Ctrl+Tab` (cycles through). Footer pill shows current: `· ⚡ worker` / `▶ 🐢 oracle`.
|
|
196
|
+
|
|
197
|
+
## Subagent: soly-manager (single, mode-switching)
|
|
198
|
+
|
|
199
|
+
Spawn via `subagent({ agent: "soly-manager", task: ... })`. The task brief tells it which mode to be in:
|
|
200
|
+
|
|
201
|
+
| Task brief mentions | Mode |
|
|
202
|
+
|---|---|
|
|
203
|
+
| implement, build, write code, add feature, create | **worker** |
|
|
204
|
+
| debug, bug, fix, crash, error, repro, broken | **debugger** |
|
|
205
|
+
| test, coverage, spec, assert, only modify tests | **tester** |
|
|
206
|
+
| review, audit, adversarial, find bugs, qa | **reviewer** |
|
|
207
|
+
| refactor, simplify, extract, rename, no behavior change | **refactor** |
|
|
208
|
+
| document, readme, jsdoc, comment, intent doc | **documenter** |
|
|
209
|
+
| validate, scope, drift, decision, before committing | **oracle** |
|
|
210
|
+
| plan, design, outline, structure, decompose | **planner** |
|
|
211
|
+
|
|
212
|
+
**soly-manager is ONE agent that switches modes. Don't spawn soly-worker / soly-debugger / etc. — those don't exist anymore.**
|
|
213
|
+
|
|
214
|
+
## Tools the LLM can call
|
|
215
|
+
|
|
216
|
+
| Tool | Purpose |
|
|
217
|
+
|---|---|
|
|
218
|
+
| `soly_read(artifact, phase, taskId)` | Read soly artifacts: STATE, plan, context, research, ROADMAP, requirements, project, milestone, task |
|
|
219
|
+
| `soly_log_decision(decision, rationale, phase)` | Append to STATE.md Decisions table |
|
|
220
|
+
| `soly_list_phases()` | List all phases with plan counts, C/R markers |
|
|
221
|
+
| `soly_list_tasks()` | List all tasks across features (kind, status, priority, deps) |
|
|
222
|
+
| `soly_todos(paths, limit)` | Scan working tree for TODO/FIXME/HACK/XXX/NOTE |
|
|
223
|
+
| `soly_env()` | Detect runtime (package manager, runtimes, services, scripts) |
|
|
224
|
+
| `soly_snippet(path, offset, limit)` | Read bounded line range with line numbers |
|
|
225
|
+
| `soly_doc_search(query, limit)` | Search .md/.html under cwd (prioritizes intent docs) |
|
|
226
|
+
| `soly_intent()` | List 0-point intent docs from `.soly/docs/` |
|
|
227
|
+
| `soly_scratchpad(limit)` | Recent conversation recap (one line per turn) |
|
|
228
|
+
| `ask_pro(questions)` | Multi-question picker (tabbed, single/multi-select, ⭐, Other…) |
|
|
229
|
+
| `todo_update(todos)` | Update task list rendered in footer |
|
|
230
|
+
|
|
231
|
+
## Add a new rule (most common task)
|
|
232
|
+
|
|
233
|
+
Three places, in priority order:
|
|
234
|
+
|
|
235
|
+
1. **Project rule** — `~/.pi/agent/agents/soly/rules/<name>.md` (version-controlled, shared with team)
|
|
236
|
+
2. **User rule** — `~/.soly/rules/<name>.md` (per-user, not committed)
|
|
237
|
+
3. **Phase rule** — `<phase-dir>/<plan>.md.rules/<name>.md` (active only for that plan)
|
|
238
|
+
|
|
239
|
+
Use `/rulewizard` slash command to scaffold a new rule with the right frontmatter.
|
|
240
|
+
|
|
241
|
+
A rule file looks like:
|
|
242
|
+
|
|
243
|
+
```markdown
|
|
244
|
+
---
|
|
245
|
+
applyTo: "src/**/*.ts"
|
|
246
|
+
priority: 50
|
|
247
|
+
---
|
|
248
|
+
|
|
249
|
+
# TypeScript style
|
|
250
|
+
|
|
251
|
+
- Strict mode required
|
|
252
|
+
- Never use `any`
|
|
253
|
+
- Prefer `type` over `interface`
|
|
254
|
+
```
|
|
255
|
+
|
|
256
|
+
## Add a new intent doc
|
|
257
|
+
|
|
258
|
+
Create a file in `.soly/docs/`:
|
|
259
|
+
|
|
260
|
+
```markdown
|
|
261
|
+
# Architecture
|
|
262
|
+
|
|
263
|
+
## Goal
|
|
264
|
+
|
|
265
|
+
Build a CLI tool that...
|
|
266
|
+
|
|
267
|
+
## Non-obvious constraints
|
|
268
|
+
|
|
269
|
+
- Must work offline (no network calls)
|
|
270
|
+
- Must be a single static binary
|
|
271
|
+
- Must integrate with the existing `~/.config/x` schema
|
|
272
|
+
```
|
|
273
|
+
|
|
274
|
+
Intent docs are 0-point — written BEFORE any plan, by humans. They define the "why", not the "how".
|
|
275
|
+
|
|
276
|
+
## Common workflows
|
|
277
|
+
|
|
278
|
+
### Start a new project
|
|
279
|
+
|
|
280
|
+
1. `soly init` (or manually create `.soly/`, `docs/`, `rules/`)
|
|
281
|
+
2. Write 1-3 intent docs in `.soly/docs/`
|
|
282
|
+
3. Optionally write `AGENTS.md` (or `agents.md`) at project root with project conventions
|
|
283
|
+
4. Create `ROADMAP.md` with phase table
|
|
284
|
+
5. `/plan 1` to start phase 1
|
|
285
|
+
|
|
286
|
+
### Add project-specific agents
|
|
287
|
+
|
|
288
|
+
Drop a markdown file in `.agents/<name>.md` (project) or `~/.agents/<name>.md` (user):
|
|
289
|
+
|
|
290
|
+
```markdown
|
|
291
|
+
---
|
|
292
|
+
name: data-scientist
|
|
293
|
+
description: Reads CSVs, runs pandas, plots results
|
|
294
|
+
thinking: medium
|
|
295
|
+
tools: read, bash
|
|
296
|
+
---
|
|
297
|
+
|
|
298
|
+
You are a data scientist. ...
|
|
299
|
+
```
|
|
300
|
+
|
|
301
|
+
Both `~/.agents/` and `~/.pi/agent/agents/` are read (vendor-neutral preferred). `Ctrl+Tab` to see them in the cycle.
|
|
302
|
+
|
|
303
|
+
### Add a feature to an existing phase
|
|
304
|
+
|
|
305
|
+
1. `/plan 1.05` (next plan number)
|
|
306
|
+
2. Edit the generated `PLAN.md`
|
|
307
|
+
3. `/execute 1.05`
|
|
308
|
+
|
|
309
|
+
### Pause a long session
|
|
310
|
+
|
|
311
|
+
1. `/pause` → writes `HANDOFF.json` + `.continue-here.md`
|
|
312
|
+
2. Later, in a new session: `/resume`
|
|
313
|
+
|
|
314
|
+
### Troubleshoot a partial plan
|
|
315
|
+
|
|
316
|
+
If `/execute` complains about illegal partial state:
|
|
317
|
+
|
|
318
|
+
1. `cat .soly/iterations/<latest>.md` — see what the last run did
|
|
319
|
+
2. Check if `SUMMARY.md` exists for the last plan
|
|
320
|
+
3. If yes, finish close-out: update `STATE.md` + `ROADMAP.md`
|
|
321
|
+
4. If no, either commit the SUMMARY or revert the production commits
|
|
322
|
+
|
|
323
|
+
## Where to look for answers
|
|
324
|
+
|
|
325
|
+
- **"What command does X"** → this skill, Slash commands section
|
|
326
|
+
- **"What does PLAN.md look like"** → this skill, Frontmatter section
|
|
327
|
+
- **"How to add a rule"** → this skill, Add a new rule section
|
|
328
|
+
- **"Why did the LLM do Y"** → `/why`
|
|
329
|
+
- **"What context is loaded"** → `soly_read(artifact: "state")` + `soly_doc_search(...)`
|
|
330
|
+
- **"What was the recent conversation"** → `soly_scratchpad()`
|
|
331
|
+
|
|
332
|
+
## Don'ts
|
|
333
|
+
|
|
334
|
+
- ❌ Edit `.soly/rules/` files you didn't write — those are project invariants
|
|
335
|
+
- ❌ Skip the SUMMARY — illegal partial state
|
|
336
|
+
- ❌ Spawn `soly-worker` or `soly-debugger` — use `soly-manager` (mode-switches)
|
|
337
|
+
- ❌ Write rules in code comments — use `.soly/rules/*.md` files
|
|
338
|
+
- ❌ Edit `.soly/phases/*/PLAN.md` after `status: in_progress` — create a new plan
|
|
339
|
+
- ❌ Put intent docs anywhere other than `.soly/docs/`
|
|
340
|
+
|
|
341
|
+
## When in doubt
|
|
342
|
+
|
|
343
|
+
Call `soly_read(artifact: "state")` and `soly_read(artifact: "roadmap")` first. The system prompt has the layers, but `soly_read` gives you full content. Then check `soly_doc_search` for any other relevant docs.
|
package/switch/core.ts
CHANGED
|
@@ -65,29 +65,55 @@ 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
|
+
/** User agent home directories, in priority order. First existing one
|
|
69
|
+
* wins for new agent creation; all are read and merged in the cycle.
|
|
70
|
+
* Honors $HOME / $USERPROFILE so tests can redirect to a tmp dir. */
|
|
71
|
+
export function userAgentDirs(): string[] {
|
|
72
|
+
const home = process.env.HOME || process.env.USERPROFILE || os.homedir();
|
|
73
|
+
return [
|
|
74
|
+
path.join(home, ".agents"), // vendor-neutral (preferred)
|
|
75
|
+
path.join(home, ".pi", "agent", "agents"), // pi's native
|
|
76
|
+
];
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Read all user agent names from every registered home dir. Dedupes,
|
|
80
|
+
* first-occurrence wins. */
|
|
81
|
+
export function discoverUserAgents(): string[] {
|
|
82
|
+
const seen = new Set<string>();
|
|
83
|
+
const out: string[] = [];
|
|
84
|
+
for (const dir of userAgentDirs()) {
|
|
85
|
+
if (!fs.existsSync(dir)) continue;
|
|
86
|
+
let entries: string[];
|
|
73
87
|
try {
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
if (
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
88
|
+
entries = fs.readdirSync(dir);
|
|
89
|
+
} catch {
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
for (const file of entries) {
|
|
93
|
+
if (!file.endsWith(".md")) continue;
|
|
94
|
+
try {
|
|
95
|
+
const raw = fs.readFileSync(path.join(dir, file), "utf-8");
|
|
96
|
+
const m = raw.match(/^---\n([\s\S]*?)\n---/);
|
|
97
|
+
if (!m) continue;
|
|
98
|
+
const fm = m[1] ?? "";
|
|
99
|
+
const nameMatch = fm.match(/^name:\s*(.+)$/m);
|
|
100
|
+
if (nameMatch) {
|
|
101
|
+
const n = (nameMatch[1] ?? "").trim();
|
|
102
|
+
if (isValidAgentName(n) && !seen.has(n)) {
|
|
103
|
+
seen.add(n);
|
|
104
|
+
out.push(n);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
} catch { /* skip unreadable */ }
|
|
108
|
+
}
|
|
84
109
|
}
|
|
85
|
-
return
|
|
110
|
+
return out;
|
|
86
111
|
}
|
|
87
112
|
|
|
88
113
|
/** Build the full cycle of available agents. Built-ins first, then
|
|
89
|
-
* user-discovered
|
|
90
|
-
|
|
114
|
+
* user-discovered (from all user agent home dirs). Dedupes while
|
|
115
|
+
* preserving first-occurrence order. */
|
|
116
|
+
export function availableAgents(): string[] {
|
|
91
117
|
const out: string[] = [];
|
|
92
118
|
const seen = new Set<string>();
|
|
93
119
|
const push = (n: string) => {
|
|
@@ -97,7 +123,7 @@ export function availableAgents(userDir?: string): string[] {
|
|
|
97
123
|
}
|
|
98
124
|
};
|
|
99
125
|
for (const a of BUILTIN_AGENTS) push(a);
|
|
100
|
-
for (const a of discoverUserAgents(
|
|
126
|
+
for (const a of discoverUserAgents()) push(a);
|
|
101
127
|
return out;
|
|
102
128
|
}
|
|
103
129
|
|
|
@@ -139,8 +165,8 @@ export function formatAgentSwitchNotify(prev: string, next: string): string {
|
|
|
139
165
|
}
|
|
140
166
|
|
|
141
167
|
/** Group agents: built-ins + user-defined. */
|
|
142
|
-
export function groupedAvailableAgents(
|
|
143
|
-
const all = availableAgents(
|
|
168
|
+
export function groupedAvailableAgents(): Array<{ header: string; agents: string[] }> {
|
|
169
|
+
const all = availableAgents();
|
|
144
170
|
const groups: Array<{ header: string; agents: string[] }> = [];
|
|
145
171
|
const builtin = all.filter((a) => BUILTIN_AGENTS.includes(a));
|
|
146
172
|
if (builtin.length > 0) groups.push({ header: "built-in", agents: builtin });
|
package/switch/index.ts
CHANGED
|
@@ -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
|
|
|
@@ -123,11 +123,22 @@ 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/ 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");
|
|
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
|
});
|
|
133
144
|
});
|
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
|