pi-soly 0.2.1
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 +372 -0
- package/agents/soly-debugger.md +60 -0
- package/agents/soly-documenter.md +82 -0
- package/agents/soly-oracle.md +69 -0
- package/agents/soly-refactor.md +65 -0
- package/agents/soly-reviewer.md +107 -0
- package/agents/soly-tester.md +56 -0
- package/agents/soly-worker.md +84 -0
- package/agents-install.ts +105 -0
- package/commands.ts +778 -0
- package/config.ts +228 -0
- package/core.ts +1599 -0
- package/docs.ts +235 -0
- package/env.ts +196 -0
- package/git.ts +95 -0
- package/html.ts +157 -0
- package/index.ts +718 -0
- package/integrations.ts +64 -0
- package/intent.ts +303 -0
- package/iteration.ts +712 -0
- package/nudge.ts +123 -0
- package/package.json +66 -0
- package/scratchpad.ts +117 -0
- package/tools.ts +1132 -0
- package/workflows/execute.ts +401 -0
- package/workflows/index.ts +235 -0
- package/workflows/inspect.ts +492 -0
- package/workflows/parser.ts +268 -0
- package/workflows/pause.ts +150 -0
- package/workflows/planning.ts +624 -0
- package/workflows/quick.ts +258 -0
- package/workflows/resume.ts +201 -0
- package/workflows-data/discuss-phase.md +292 -0
- package/workflows-data/execute-phase.md +200 -0
- package/workflows-data/execute-plan.md +251 -0
- package/workflows-data/execute-task.md +116 -0
- package/workflows-data/pause-work.md +142 -0
- package/workflows-data/plan-phase.md +199 -0
- package/workflows-data/plan-task.md +185 -0
package/commands.ts
ADDED
|
@@ -0,0 +1,778 @@
|
|
|
1
|
+
// =============================================================================
|
|
2
|
+
// commands.ts — Slash commands for the soly extension
|
|
3
|
+
// =============================================================================
|
|
4
|
+
//
|
|
5
|
+
// Registers slash commands (via pi.registerCommand):
|
|
6
|
+
// - /rules manage soly rules (list/show/analytics/reload/enable/disable/add/new)
|
|
7
|
+
// - /soly project state inspection (position/plan/phases/tasks/...)
|
|
8
|
+
// subcommands: position, state, plan, context, research, roadmap,
|
|
9
|
+
// progress, phases, tasks, task <id>, features,
|
|
10
|
+
// milestone, reload, help
|
|
11
|
+
// - /rulewizard interactive guide for rule vs .editorconfig vs linter
|
|
12
|
+
// - /why show rules + project state that grounded the last turn
|
|
13
|
+
//
|
|
14
|
+
// All commands take their live state via CommandsDeps (rules, state, etc.)
|
|
15
|
+
// and a ui object for the handlers to call into.
|
|
16
|
+
// =============================================================================
|
|
17
|
+
|
|
18
|
+
import * as path from "node:path";
|
|
19
|
+
import * as fs from "node:fs";
|
|
20
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
21
|
+
import {
|
|
22
|
+
analyzeRules,
|
|
23
|
+
buildProgressBar,
|
|
24
|
+
CONTEXT_WINDOW_TOKENS,
|
|
25
|
+
extractFilePathsFromPrompt,
|
|
26
|
+
formatAnalyticsFull,
|
|
27
|
+
formatTok,
|
|
28
|
+
readIfExists,
|
|
29
|
+
type RuleFile,
|
|
30
|
+
type SolyState,
|
|
31
|
+
} from "./core.js";
|
|
32
|
+
import type { SolyConfig } from "./config.js";
|
|
33
|
+
|
|
34
|
+
/** Minimum ui surface the command handlers actually need. */
|
|
35
|
+
export interface CommandUI {
|
|
36
|
+
notify: (text: string, kind?: "info" | "warning" | "error") => void;
|
|
37
|
+
select: (label: string, options: string[]) => Promise<number | null>;
|
|
38
|
+
confirm: (title: string, message: string) => Promise<boolean>;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface CommandsDeps {
|
|
42
|
+
getRules: () => RuleFile[];
|
|
43
|
+
getOverridden: () => string[];
|
|
44
|
+
refreshRules: () => void;
|
|
45
|
+
getState: () => SolyState;
|
|
46
|
+
refreshState: () => void;
|
|
47
|
+
updateStatus: (ui: CommandUI) => void;
|
|
48
|
+
getConfig: () => SolyConfig;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function registerCommands(pi: ExtensionAPI, deps: CommandsDeps): void {
|
|
52
|
+
const {
|
|
53
|
+
getRules,
|
|
54
|
+
getOverridden,
|
|
55
|
+
refreshRules,
|
|
56
|
+
getState,
|
|
57
|
+
refreshState,
|
|
58
|
+
updateStatus,
|
|
59
|
+
getConfig,
|
|
60
|
+
} = deps;
|
|
61
|
+
|
|
62
|
+
// ============================================================================
|
|
63
|
+
// /rules
|
|
64
|
+
// ============================================================================
|
|
65
|
+
|
|
66
|
+
pi.registerCommand("rules", {
|
|
67
|
+
description:
|
|
68
|
+
"manage soly rules (list, show, analytics, reload, enable, disable)",
|
|
69
|
+
handler: async (args, ctx) => {
|
|
70
|
+
const ui: CommandUI = {
|
|
71
|
+
notify: (t, k) => ctx.ui.notify(t, k ?? "info"),
|
|
72
|
+
select: async (label, options) => {
|
|
73
|
+
const result = await ctx.ui.select(label, options);
|
|
74
|
+
return result === undefined ? null : options.indexOf(result);
|
|
75
|
+
},
|
|
76
|
+
confirm: (title, message) => ctx.ui.confirm(title, message),
|
|
77
|
+
};
|
|
78
|
+
const parts = args.trim().split(/\s+/);
|
|
79
|
+
const sub = parts[0] ?? "list";
|
|
80
|
+
const target = parts[1];
|
|
81
|
+
|
|
82
|
+
if (sub === "list") {
|
|
83
|
+
const rules = getRules();
|
|
84
|
+
const overridden = getOverridden();
|
|
85
|
+
if (rules.length === 0 && overridden.length === 0) {
|
|
86
|
+
ui.notify("no rules loaded from any source", "info");
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
const lines: string[] = [];
|
|
90
|
+
for (const r of rules) {
|
|
91
|
+
const status = r.enabled ? "●" : "○";
|
|
92
|
+
const desc = r.meta.description ? ` — ${r.meta.description}` : "";
|
|
93
|
+
lines.push(`${status} [${r.sourceLabel}] ${r.relPath}${desc}`);
|
|
94
|
+
}
|
|
95
|
+
for (const p of overridden) {
|
|
96
|
+
lines.push(`⊘ [overridden] ${p}`);
|
|
97
|
+
}
|
|
98
|
+
const total = rules.length + overridden.length;
|
|
99
|
+
const choice = await ui.select(`soly rules (${total})`, lines);
|
|
100
|
+
if (choice != null && typeof choice === "number") {
|
|
101
|
+
if (choice < rules.length) {
|
|
102
|
+
const rel = rules[choice];
|
|
103
|
+
if (rel) {
|
|
104
|
+
ui.notify(
|
|
105
|
+
`[${rel.sourceLabel}] ${rel.relPath}\n\n${rel.body}`,
|
|
106
|
+
"info",
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
} else {
|
|
110
|
+
const idx = choice - rules.length;
|
|
111
|
+
ui.notify(
|
|
112
|
+
`overridden: ${overridden[idx]} (skipped — a higher-priority source defines this rule)`,
|
|
113
|
+
"info",
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (sub === "analytics") {
|
|
121
|
+
const rules = getRules();
|
|
122
|
+
const analytics = analyzeRules(rules, CONTEXT_WINDOW_TOKENS);
|
|
123
|
+
ui.notify(formatAnalyticsFull(analytics), "info");
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (sub === "show") {
|
|
128
|
+
if (!target) {
|
|
129
|
+
ui.notify("Usage: /rules show <path>", "error");
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
const rule = getRules().find(
|
|
133
|
+
(r) => r.relPath === target || r.relPath.endsWith(target),
|
|
134
|
+
);
|
|
135
|
+
if (!rule) {
|
|
136
|
+
ui.notify(`Rule not found: ${target}`, "error");
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
ui.notify(`[${rule.sourceLabel}] ${rule.relPath}\n\n${rule.body}`, "info");
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (sub === "reload") {
|
|
144
|
+
refreshRules();
|
|
145
|
+
ui.notify(`Reloaded ${getRules().length} rules`, "info");
|
|
146
|
+
updateStatus(ui);
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if (sub === "enable" || sub === "disable") {
|
|
151
|
+
if (!target) {
|
|
152
|
+
ui.notify(`Usage: /rules ${sub} <path>`, "error");
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
const rule = getRules().find(
|
|
156
|
+
(r) => r.relPath === target || r.relPath.endsWith(target),
|
|
157
|
+
);
|
|
158
|
+
if (!rule) {
|
|
159
|
+
ui.notify(`Rule not found: ${target}`, "error");
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
rule.enabled = sub === "enable";
|
|
163
|
+
ui.notify(`${rule.relPath} ${sub}d`, "info");
|
|
164
|
+
updateStatus(ui);
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
if (sub === "enable-all" || sub === "disable-all") {
|
|
169
|
+
const enable = sub === "enable-all";
|
|
170
|
+
const rules = getRules();
|
|
171
|
+
let count = 0;
|
|
172
|
+
for (const r of rules) {
|
|
173
|
+
if (r.enabled !== enable) {
|
|
174
|
+
r.enabled = enable;
|
|
175
|
+
count++;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
ui.notify(
|
|
179
|
+
`${count} rule(s) ${enable ? "enabled" : "disabled"} (${rules.length} total)`,
|
|
180
|
+
enable ? "info" : "warning",
|
|
181
|
+
);
|
|
182
|
+
updateStatus(ui);
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// /rules new — wizard for creating a rule
|
|
187
|
+
if (sub === "new") {
|
|
188
|
+
const cwd = process.cwd();
|
|
189
|
+
const categories = [
|
|
190
|
+
{ name: "architecture", description: "which patterns to use, when" },
|
|
191
|
+
{ name: "code-style", description: "naming, formatting, structure" },
|
|
192
|
+
{ name: "testing", description: "what to test, how, coverage" },
|
|
193
|
+
{ name: "process", description: "git workflow, commit format, PR review" },
|
|
194
|
+
{ name: "performance", description: "perf budgets, hot paths, caching" },
|
|
195
|
+
{ name: "security", description: "auth, secrets, validation, OWASP" },
|
|
196
|
+
];
|
|
197
|
+
const choice = await ui.select(
|
|
198
|
+
"soly rule — pick a category:",
|
|
199
|
+
categories.map((c) => `${c.name} — ${c.description}`),
|
|
200
|
+
);
|
|
201
|
+
if (choice == null) {
|
|
202
|
+
ui.notify("cancelled", "info");
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
const cat = categories[choice];
|
|
206
|
+
if (!cat) return;
|
|
207
|
+
const dir = path.join(cwd, ".soly", "rules", cat.name);
|
|
208
|
+
try {
|
|
209
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
210
|
+
} catch {}
|
|
211
|
+
const slug = `${cat.name}-${Date.now().toString(36)}.md`;
|
|
212
|
+
const filePath = path.join(dir, slug);
|
|
213
|
+
const template = `---
|
|
214
|
+
description: TODO — what does this rule constrain or require?
|
|
215
|
+
globs: []
|
|
216
|
+
priority: medium
|
|
217
|
+
---
|
|
218
|
+
|
|
219
|
+
# ${cat.name} rule
|
|
220
|
+
|
|
221
|
+
> TODO: write the rule. Use imperative voice, give Good/Bad examples where
|
|
222
|
+
> useful. State what the LLM must do, not what it should avoid.
|
|
223
|
+
|
|
224
|
+
## Context
|
|
225
|
+
|
|
226
|
+
When does this rule apply?
|
|
227
|
+
|
|
228
|
+
## Rule
|
|
229
|
+
|
|
230
|
+
What must the LLM do?
|
|
231
|
+
|
|
232
|
+
## Examples
|
|
233
|
+
|
|
234
|
+
### Good
|
|
235
|
+
|
|
236
|
+
\`\`\`
|
|
237
|
+
<!-- concrete good example -->
|
|
238
|
+
\`\`\`
|
|
239
|
+
|
|
240
|
+
### Bad
|
|
241
|
+
|
|
242
|
+
\`\`\`
|
|
243
|
+
<!-- concrete bad example -->
|
|
244
|
+
\`\`\`
|
|
245
|
+
`;
|
|
246
|
+
try {
|
|
247
|
+
fs.writeFileSync(filePath, template, "utf-8");
|
|
248
|
+
ui.notify(
|
|
249
|
+
`soly: created ${path.relative(cwd, filePath)}\n\n` +
|
|
250
|
+
`Next: edit the file (description, globs, body), then \`/rules reload\` to load it.`,
|
|
251
|
+
"info",
|
|
252
|
+
);
|
|
253
|
+
refreshRules();
|
|
254
|
+
updateStatus(ui);
|
|
255
|
+
} catch (e) {
|
|
256
|
+
ui.notify(`soly: failed to create rule: ${(e as Error).message}`, "error");
|
|
257
|
+
}
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// /rules add <url> — download a remote rule into .soly/rules/
|
|
262
|
+
if (sub === "add") {
|
|
263
|
+
const url = (target ?? "").trim();
|
|
264
|
+
if (!url) {
|
|
265
|
+
ui.notify("Usage: /rules add <url>", "error");
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
try {
|
|
269
|
+
const parsed = new URL(url);
|
|
270
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
|
271
|
+
ui.notify(`soly: only http(s) URLs are supported (got ${parsed.protocol})`, "error");
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
ui.notify(`soly: downloading ${url}…`, "info");
|
|
275
|
+
const res = await fetch(url, {
|
|
276
|
+
signal: AbortSignal.timeout(10_000),
|
|
277
|
+
headers: { "user-agent": "soly-extension/1.0" },
|
|
278
|
+
});
|
|
279
|
+
if (!res.ok) {
|
|
280
|
+
ui.notify(`soly: HTTP ${res.status} ${res.statusText} from ${url}`, "error");
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
const text = await res.text();
|
|
284
|
+
if (text.length === 0) {
|
|
285
|
+
ui.notify(`soly: empty response from ${url}`, "error");
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
if (text.length > 200_000) {
|
|
289
|
+
ui.notify(
|
|
290
|
+
`soly: refusing to install rule > 200KB (got ${(text.length / 1024).toFixed(1)}KB). Inspect manually.`,
|
|
291
|
+
"error",
|
|
292
|
+
);
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
295
|
+
// Derive filename from URL (strip query, keep last path segment)
|
|
296
|
+
const lastSeg = parsed.pathname.split("/").filter(Boolean).pop() ?? "rule.md";
|
|
297
|
+
const safeName = lastSeg.replace(/[^A-Za-z0-9._-]/g, "_");
|
|
298
|
+
const fileName = safeName.endsWith(".md") ? safeName : `${safeName}.md`;
|
|
299
|
+
const rulesRoot = path.join(process.cwd(), ".soly", "rules");
|
|
300
|
+
fs.mkdirSync(rulesRoot, { recursive: true });
|
|
301
|
+
const targetFile = path.join(rulesRoot, fileName);
|
|
302
|
+
// Refuse to overwrite without warning
|
|
303
|
+
if (fs.existsSync(targetFile)) {
|
|
304
|
+
const overwrite = await ui.confirm(
|
|
305
|
+
"Overwrite?",
|
|
306
|
+
`${fileName} already exists. Overwrite?`,
|
|
307
|
+
);
|
|
308
|
+
if (!overwrite) {
|
|
309
|
+
ui.notify("soly: add cancelled", "info");
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
fs.writeFileSync(targetFile, text, "utf-8");
|
|
314
|
+
refreshRules();
|
|
315
|
+
ui.notify(
|
|
316
|
+
`soly: installed ${path.relative(process.cwd(), targetFile)} (${(text.length / 1024).toFixed(1)}KB)`,
|
|
317
|
+
"info",
|
|
318
|
+
);
|
|
319
|
+
updateStatus(ui);
|
|
320
|
+
} catch (e) {
|
|
321
|
+
ui.notify(`soly: download failed: ${(e as Error).message}`, "error");
|
|
322
|
+
}
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
ui.notify(
|
|
327
|
+
`unknown subcommand: ${sub}\nUsage: /rules [list|show <path>|analytics|reload|enable <path>|disable <path>|enable-all|disable-all|add <url>|new]`,
|
|
328
|
+
"error",
|
|
329
|
+
);
|
|
330
|
+
},
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
// ============================================================================
|
|
334
|
+
// /soly
|
|
335
|
+
// ============================================================================
|
|
336
|
+
|
|
337
|
+
pi.registerCommand("soly", {
|
|
338
|
+
description:
|
|
339
|
+
"soly: project state inspection (position, plan, state, phases, etc.) — type 'help' for subcommand picker",
|
|
340
|
+
handler: async (args, ctx) => {
|
|
341
|
+
const ui: CommandUI = {
|
|
342
|
+
notify: (t, k) => ctx.ui.notify(t, k ?? "info"),
|
|
343
|
+
select: async (label, options) => {
|
|
344
|
+
const result = await ctx.ui.select(label, options);
|
|
345
|
+
return result === undefined ? null : options.indexOf(result);
|
|
346
|
+
},
|
|
347
|
+
confirm: (title, message) => ctx.ui.confirm(title, message),
|
|
348
|
+
};
|
|
349
|
+
const state = getState();
|
|
350
|
+
if (!state.exists) {
|
|
351
|
+
ui.notify("soly: no .soly/ directory in cwd", "error");
|
|
352
|
+
return;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
const showFile = (label: string, content: string | null) => {
|
|
356
|
+
if (!content) {
|
|
357
|
+
ui.notify(`${label}: not found`, "error");
|
|
358
|
+
return;
|
|
359
|
+
}
|
|
360
|
+
const MAX = 4000;
|
|
361
|
+
const truncated =
|
|
362
|
+
content.length > MAX
|
|
363
|
+
? `${content.slice(0, MAX)}\n\n[...truncated, file is ${content.length} chars]`
|
|
364
|
+
: content;
|
|
365
|
+
ui.notify(`${label}\n\n${truncated}`, "info");
|
|
366
|
+
};
|
|
367
|
+
|
|
368
|
+
type SolySub = {
|
|
369
|
+
description: string;
|
|
370
|
+
run: (parts: string[]) => void | Promise<void>;
|
|
371
|
+
};
|
|
372
|
+
const subcommands: Record<string, SolySub> = {
|
|
373
|
+
// `agent` subcommand REMOVED — moved to the separate `pi-switch`
|
|
374
|
+
// extension as the `/agent` slash command (header bar + Ctrl+Shift+S).
|
|
375
|
+
// Soly no longer owns the agent switcher UI.
|
|
376
|
+
config: {
|
|
377
|
+
description: "show merged config (per-project + global + defaults); edit .soly/config.json or ~/.soly/config.json",
|
|
378
|
+
run: () => {
|
|
379
|
+
const cfg = getConfig();
|
|
380
|
+
const out: string[] = [];
|
|
381
|
+
out.push("=== soly config (merged) ===");
|
|
382
|
+
out.push("");
|
|
383
|
+
out.push("```json");
|
|
384
|
+
out.push(JSON.stringify(cfg, null, 2));
|
|
385
|
+
out.push("```");
|
|
386
|
+
out.push("");
|
|
387
|
+
out.push("Sources:");
|
|
388
|
+
out.push(` global: ~/.soly/config.json`);
|
|
389
|
+
out.push(` project: <cwd>/.soly/config.json`);
|
|
390
|
+
out.push("");
|
|
391
|
+
out.push("To edit:");
|
|
392
|
+
out.push(` - project: edit \`${state.solyDir}/config.json\` directly`);
|
|
393
|
+
out.push(` - global: edit \`~/.soly/config.json\``);
|
|
394
|
+
out.push("After editing, run /soly reload to re-pick up changes.");
|
|
395
|
+
ui.notify(out.join("\n"), "info");
|
|
396
|
+
},
|
|
397
|
+
},
|
|
398
|
+
position: {
|
|
399
|
+
description: "one-screen position summary (default)",
|
|
400
|
+
run: () => {
|
|
401
|
+
const s = getState();
|
|
402
|
+
if (s.position) {
|
|
403
|
+
ui.notify(
|
|
404
|
+
[
|
|
405
|
+
`milestone: ${s.milestone}${s.milestoneName ? ` — ${s.milestoneName}` : ""}`,
|
|
406
|
+
`phase: ${s.position.phase}`,
|
|
407
|
+
`plan: ${s.position.plan}`,
|
|
408
|
+
`status: ${s.position.status}`,
|
|
409
|
+
`progress: ${buildProgressBar(s.progress.percent, 20)} ${s.progress.percent}% (${s.progress.completedPhases}/${s.progress.totalPhases} phases, ${s.progress.completedPlans}/${s.progress.totalPlans} plans)`,
|
|
410
|
+
].join("\n"),
|
|
411
|
+
"info",
|
|
412
|
+
);
|
|
413
|
+
} else {
|
|
414
|
+
ui.notify(
|
|
415
|
+
`milestone: ${s.milestone} — no position set in STATE.md`,
|
|
416
|
+
"info",
|
|
417
|
+
);
|
|
418
|
+
}
|
|
419
|
+
},
|
|
420
|
+
},
|
|
421
|
+
state: {
|
|
422
|
+
description: "full STATE.md body",
|
|
423
|
+
run: () => showFile("STATE.md", getState().stateBody),
|
|
424
|
+
},
|
|
425
|
+
plan: {
|
|
426
|
+
description: "current PLAN.md body",
|
|
427
|
+
run: () => {
|
|
428
|
+
const s = getState();
|
|
429
|
+
if (!s.currentPlanPath) {
|
|
430
|
+
ui.notify("soly: no current plan", "error");
|
|
431
|
+
return;
|
|
432
|
+
}
|
|
433
|
+
showFile(
|
|
434
|
+
`PLAN: ${path.basename(s.currentPlanPath)}`,
|
|
435
|
+
readIfExists(s.currentPlanPath),
|
|
436
|
+
);
|
|
437
|
+
},
|
|
438
|
+
},
|
|
439
|
+
context: {
|
|
440
|
+
description: "current CONTEXT.md body",
|
|
441
|
+
run: () => {
|
|
442
|
+
const s = getState();
|
|
443
|
+
if (!s.currentPhase) {
|
|
444
|
+
ui.notify("soly: no current phase", "error");
|
|
445
|
+
return;
|
|
446
|
+
}
|
|
447
|
+
const p = path.join(s.currentPhase.dir, `${s.currentPhase.slug}-CONTEXT.md`);
|
|
448
|
+
showFile("CONTEXT.md", readIfExists(p));
|
|
449
|
+
},
|
|
450
|
+
},
|
|
451
|
+
research: {
|
|
452
|
+
description: "current RESEARCH.md body",
|
|
453
|
+
run: () => {
|
|
454
|
+
const s = getState();
|
|
455
|
+
if (!s.currentPhase) {
|
|
456
|
+
ui.notify("soly: no current phase", "error");
|
|
457
|
+
return;
|
|
458
|
+
}
|
|
459
|
+
const p = path.join(s.currentPhase.dir, `${s.currentPhase.slug}-RESEARCH.md`);
|
|
460
|
+
showFile("RESEARCH.md", readIfExists(p));
|
|
461
|
+
},
|
|
462
|
+
},
|
|
463
|
+
roadmap: {
|
|
464
|
+
description: "ROADMAP.md body",
|
|
465
|
+
run: () => showFile("ROADMAP.md", getState().roadmapBody),
|
|
466
|
+
},
|
|
467
|
+
progress: {
|
|
468
|
+
description: "progress bar + counts",
|
|
469
|
+
run: () => {
|
|
470
|
+
const s = getState();
|
|
471
|
+
ui.notify(
|
|
472
|
+
[
|
|
473
|
+
`milestone: ${s.milestone}${s.milestoneName ? ` — ${s.milestoneName}` : ""}`,
|
|
474
|
+
`status: ${s.status}`,
|
|
475
|
+
`progress: ${buildProgressBar(s.progress.percent, 30)} ${s.progress.percent}%`,
|
|
476
|
+
`phases: ${s.progress.completedPhases}/${s.progress.totalPhases}`,
|
|
477
|
+
`plans: ${s.progress.completedPlans}/${s.progress.totalPlans}`,
|
|
478
|
+
].join("\n"),
|
|
479
|
+
"info",
|
|
480
|
+
);
|
|
481
|
+
},
|
|
482
|
+
},
|
|
483
|
+
phases: {
|
|
484
|
+
description: "list all phases with plan counts and C/R markers",
|
|
485
|
+
run: () => {
|
|
486
|
+
const phases = getState().phases;
|
|
487
|
+
if (phases.length === 0) {
|
|
488
|
+
ui.notify("soly: no phases found", "info");
|
|
489
|
+
return;
|
|
490
|
+
}
|
|
491
|
+
const current = getState().currentPhase?.number;
|
|
492
|
+
const lines = phases.map((p) => {
|
|
493
|
+
const marker = current === p.number ? "→" : " ";
|
|
494
|
+
const cr = (p.contextExists ? "C" : "·") + (p.researchExists ? "R" : "·");
|
|
495
|
+
return `${marker} ${String(p.number).padStart(2, "0")}. ${p.name} [${cr}] plans=${p.planCount}`;
|
|
496
|
+
});
|
|
497
|
+
ui.notify(`phases:\n\n${lines.join("\n")}`, "info");
|
|
498
|
+
},
|
|
499
|
+
},
|
|
500
|
+
tasks: {
|
|
501
|
+
description: "list all tasks grouped by feature (mirrors soly_list_tasks tool)",
|
|
502
|
+
run: () => {
|
|
503
|
+
const s = getState();
|
|
504
|
+
if (s.tasks.length === 0) {
|
|
505
|
+
ui.notify("soly: no tasks found in .soly/features/*/tasks/", "info");
|
|
506
|
+
return;
|
|
507
|
+
}
|
|
508
|
+
const byFeature = new Map<string, typeof s.tasks>();
|
|
509
|
+
for (const t of s.tasks) {
|
|
510
|
+
const list = byFeature.get(t.feature) ?? [];
|
|
511
|
+
list.push(t);
|
|
512
|
+
byFeature.set(t.feature, list);
|
|
513
|
+
}
|
|
514
|
+
const out: string[] = [`tasks (${s.tasks.length} total):`, ""];
|
|
515
|
+
for (const [feature, list] of [...byFeature.entries()].sort()) {
|
|
516
|
+
out.push(`[${feature}] ${list.length} task(s)`);
|
|
517
|
+
for (const t of list) {
|
|
518
|
+
const deps = t.dependsOn.length > 0 ? ` deps=[${t.dependsOn.join(",")}]` : "";
|
|
519
|
+
const par = t.parallelizable ? " ⚡" : "";
|
|
520
|
+
out.push(` ${t.id} [${t.kind}] status=${t.status} prio=${t.priority}${par}${deps}`);
|
|
521
|
+
}
|
|
522
|
+
out.push("");
|
|
523
|
+
}
|
|
524
|
+
ui.notify(out.join("\n"), "info");
|
|
525
|
+
},
|
|
526
|
+
},
|
|
527
|
+
task: {
|
|
528
|
+
description: "show one task's PLAN.md + SUMMARY.md if present (usage: /soly task <id>)",
|
|
529
|
+
run: (parts) => {
|
|
530
|
+
const id = (parts[1] ?? "").trim();
|
|
531
|
+
if (!id) {
|
|
532
|
+
ui.notify("Usage: /soly task <task-id>", "error");
|
|
533
|
+
return;
|
|
534
|
+
}
|
|
535
|
+
const s = getState();
|
|
536
|
+
const task = s.tasks.find((t) => t.id === id);
|
|
537
|
+
if (!task) {
|
|
538
|
+
ui.notify(
|
|
539
|
+
`soly: task ${id} not found.\nKnown: ${s.tasks.map((t) => t.id).join(", ") || "(none)"}`,
|
|
540
|
+
"error",
|
|
541
|
+
);
|
|
542
|
+
return;
|
|
543
|
+
}
|
|
544
|
+
const planPath = path.join(task.dir, "PLAN.md");
|
|
545
|
+
const summaryPath = path.join(task.dir, "SUMMARY.md");
|
|
546
|
+
const planBody = readIfExists(planPath);
|
|
547
|
+
const summaryBody = readIfExists(summaryPath);
|
|
548
|
+
const header = `task ${task.id} [${task.feature}/${task.kind}] status=${task.status} prio=${task.priority}`;
|
|
549
|
+
const deps = task.dependsOn.length > 0 ? `\ndepends-on: [${task.dependsOn.join(", ")}]` : "";
|
|
550
|
+
const planLabel = planBody ? `PLAN.md (${planBody.length} chars)` : `PLAN.md (missing)`;
|
|
551
|
+
showFile(`${header}${deps}\n${planLabel}`, planBody ?? "(no PLAN.md)");
|
|
552
|
+
if (summaryBody) {
|
|
553
|
+
showFile("SUMMARY.md", summaryBody);
|
|
554
|
+
} else {
|
|
555
|
+
ui.notify("SUMMARY.md: not found (task not yet executed)", "info");
|
|
556
|
+
}
|
|
557
|
+
},
|
|
558
|
+
},
|
|
559
|
+
features: {
|
|
560
|
+
description: "list all features with task counts and README presence",
|
|
561
|
+
run: () => {
|
|
562
|
+
const features = getState().features;
|
|
563
|
+
if (features.length === 0) {
|
|
564
|
+
ui.notify("soly: no features found in .soly/features/", "info");
|
|
565
|
+
return;
|
|
566
|
+
}
|
|
567
|
+
const lines = features.map((f) => {
|
|
568
|
+
const rm = f.readmeExists ? "R" : "·";
|
|
569
|
+
return ` ${f.name.padEnd(28)} tasks=${f.taskCount} [${rm}]`;
|
|
570
|
+
});
|
|
571
|
+
ui.notify(`features (${features.length}):\n\n${lines.join("\n")}`, "info");
|
|
572
|
+
},
|
|
573
|
+
},
|
|
574
|
+
milestone: {
|
|
575
|
+
description: "show the active milestone document (.soly/milestones/<v>.md)",
|
|
576
|
+
run: () => {
|
|
577
|
+
const s = getState();
|
|
578
|
+
if (!s.milestone || s.milestone === "—") {
|
|
579
|
+
ui.notify("soly: no milestone set in STATE.md frontmatter", "info");
|
|
580
|
+
return;
|
|
581
|
+
}
|
|
582
|
+
const candidates = [
|
|
583
|
+
path.join(s.solyDir, "milestones", `${s.milestone}.md`),
|
|
584
|
+
path.join(s.solyDir, "MILESTONES.md"),
|
|
585
|
+
];
|
|
586
|
+
for (const c of candidates) {
|
|
587
|
+
const body = readIfExists(c);
|
|
588
|
+
if (body) {
|
|
589
|
+
showFile(`MILESTONE ${s.milestone} (${path.relative(process.cwd(), c)})`, body);
|
|
590
|
+
return;
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
ui.notify(
|
|
594
|
+
`soly: no milestone file found. tried:\n ${candidates.map((c) => path.relative(process.cwd(), c)).join("\n ")}`,
|
|
595
|
+
"error",
|
|
596
|
+
);
|
|
597
|
+
},
|
|
598
|
+
},
|
|
599
|
+
reload: {
|
|
600
|
+
description: "re-read project state from disk",
|
|
601
|
+
run: () => {
|
|
602
|
+
refreshState();
|
|
603
|
+
updateStatus(ui);
|
|
604
|
+
const s = getState();
|
|
605
|
+
ui.notify(
|
|
606
|
+
`soly: reloaded — ${s.milestone} · ${s.phases.length} phases`,
|
|
607
|
+
"info",
|
|
608
|
+
);
|
|
609
|
+
},
|
|
610
|
+
},
|
|
611
|
+
};
|
|
612
|
+
|
|
613
|
+
const picker = async (label: string) => {
|
|
614
|
+
const lines = Object.entries(subcommands).map(
|
|
615
|
+
([name, spec]) => `${name} - ${spec.description}`,
|
|
616
|
+
);
|
|
617
|
+
const choice = await ui.select(label, lines);
|
|
618
|
+
if (choice != null && typeof choice === "number") {
|
|
619
|
+
const name = Object.keys(subcommands)[choice];
|
|
620
|
+
if (name) {
|
|
621
|
+
await subcommands[name].run([name]);
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
};
|
|
625
|
+
|
|
626
|
+
const parts = args.trim().split(/\s+/).filter(Boolean);
|
|
627
|
+
const sub = parts[0] ?? "position";
|
|
628
|
+
|
|
629
|
+
if (sub === "help" || sub === "?" || sub === "--help" || sub === "-h") {
|
|
630
|
+
return picker("soly subcommand (esc to cancel):");
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
if (!subcommands[sub]) {
|
|
634
|
+
ui.notify(`soly: unknown subcommand '${sub}'`, "error");
|
|
635
|
+
return picker("did you mean:");
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
await subcommands[sub].run(parts);
|
|
639
|
+
},
|
|
640
|
+
});
|
|
641
|
+
// ============================================================================
|
|
642
|
+
// /rulewizard
|
|
643
|
+
// ============================================================================
|
|
644
|
+
|
|
645
|
+
pi.registerCommand("rulewizard", {
|
|
646
|
+
description:
|
|
647
|
+
"interactive guide: decide whether a constraint should be a soly rule, an .editorconfig entry, or a linter config (eslint/biome/prettier). Use this BEFORE writing a new rule to avoid duplicating what linters already enforce.",
|
|
648
|
+
handler: async (_args, ctx) => {
|
|
649
|
+
ctx.ui.notify(
|
|
650
|
+
[
|
|
651
|
+
"soly-rule-wizard:",
|
|
652
|
+
"",
|
|
653
|
+
"tell me what behavior or outcome you want to constrain. I'll help you",
|
|
654
|
+
"decide whether it should be:",
|
|
655
|
+
" • a soly rule (.soly/rules/*.md) — for process, behavior, or project",
|
|
656
|
+
" conventions the LLM must follow",
|
|
657
|
+
" • an .editorconfig entry — for formatting (indent, line endings, EOL,",
|
|
658
|
+
" charset, trailing whitespace, max line length)",
|
|
659
|
+
" • a linter config (eslint / biome / prettier) — for code style that",
|
|
660
|
+
" a tool can check automatically",
|
|
661
|
+
" • or nothing — if an existing tool already covers it",
|
|
662
|
+
"",
|
|
663
|
+
"decide first:",
|
|
664
|
+
" 1. is it about LLM behavior / process / project conventions? → soly rule",
|
|
665
|
+
" 2. is it about whitespace, indent, line endings? → .editorconfig",
|
|
666
|
+
" 3. is it about code style a linter can check? → eslint/biome",
|
|
667
|
+
" 4. is it already covered by an existing tool? → don't duplicate",
|
|
668
|
+
"",
|
|
669
|
+
"useful commands first:",
|
|
670
|
+
" /rules — see existing rules (so we don't duplicate)",
|
|
671
|
+
" /rules analytics — see file sizes, missing descriptions, duplicates",
|
|
672
|
+
"",
|
|
673
|
+
"when you've decided:",
|
|
674
|
+
" /rules new — scaffold a new rule from the soly template",
|
|
675
|
+
].join("\n"),
|
|
676
|
+
"info",
|
|
677
|
+
);
|
|
678
|
+
},
|
|
679
|
+
});
|
|
680
|
+
|
|
681
|
+
// ============================================================================
|
|
682
|
+
// /why — show what context the LLM was working from
|
|
683
|
+
// ============================================================================
|
|
684
|
+
|
|
685
|
+
pi.registerCommand("why", {
|
|
686
|
+
description:
|
|
687
|
+
"show the rules + project state that were injected into the system prompt for the most recent turn. Use to answer 'why did the LLM do X?' — you can see the basis it was working from.",
|
|
688
|
+
handler: async (args, ctx) => {
|
|
689
|
+
const state = getState();
|
|
690
|
+
const rules = getRules();
|
|
691
|
+
const branch = ctx.sessionManager.getBranch();
|
|
692
|
+
const lastTurnEntries = branch.slice(-6);
|
|
693
|
+
|
|
694
|
+
const lines: string[] = [];
|
|
695
|
+
lines.push("=== /why — basis for the most recent turn ===");
|
|
696
|
+
lines.push("");
|
|
697
|
+
|
|
698
|
+
// State
|
|
699
|
+
if (state.exists) {
|
|
700
|
+
lines.push("**Project state (injected):**");
|
|
701
|
+
lines.push(` milestone: ${state.milestone}${state.milestoneName ? ` — ${state.milestoneName}` : ""}`);
|
|
702
|
+
if (state.position) {
|
|
703
|
+
lines.push(` position: ${state.position.phase} / ${state.position.plan} (${state.position.status})`);
|
|
704
|
+
}
|
|
705
|
+
lines.push(` progress: ${state.progress.completedPhases}/${state.progress.totalPhases} phases, ${state.progress.completedPlans}/${state.progress.totalPlans} plans (${state.progress.percent}%)`);
|
|
706
|
+
lines.push("");
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
// Rules
|
|
710
|
+
if (rules.length > 0) {
|
|
711
|
+
lines.push(`**Rules loaded (${rules.length} of which ${rules.filter((r) => r.enabled).length} enabled):**`);
|
|
712
|
+
const bySource = rules.reduce<Record<string, number>>((acc, r) => {
|
|
713
|
+
acc[r.sourceLabel] = (acc[r.sourceLabel] ?? 0) + 1;
|
|
714
|
+
return acc;
|
|
715
|
+
}, {});
|
|
716
|
+
lines.push(
|
|
717
|
+
` by source: ${Object.entries(bySource)
|
|
718
|
+
.map(([k, v]) => `${v} ${k}`)
|
|
719
|
+
.join(", ")}`,
|
|
720
|
+
);
|
|
721
|
+
const phaseRuleCount = rules.filter((r) => r.phaseNumber != null).length;
|
|
722
|
+
if (phaseRuleCount > 0) {
|
|
723
|
+
lines.push(` phase-scoped: ${phaseRuleCount}`);
|
|
724
|
+
}
|
|
725
|
+
lines.push("");
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
// NEW (I8): list the actual loaded rule files with paths + descriptions
|
|
729
|
+
if (rules.length > 0) {
|
|
730
|
+
lines.push("**Loaded rule files (the LLM was reading these):**");
|
|
731
|
+
const enabled = rules.filter((rr) => rr.enabled);
|
|
732
|
+
for (const r of enabled.slice(0, 30)) {
|
|
733
|
+
const desc = r.meta.description ? ` — ${r.meta.description}` : "";
|
|
734
|
+
const interactive = r.interactiveOnly ? " [interactive-only]" : "";
|
|
735
|
+
lines.push(` - \`${r.sourceLabel}/${r.relPath}\`${desc}${interactive}`);
|
|
736
|
+
}
|
|
737
|
+
if (enabled.length > 30) {
|
|
738
|
+
lines.push(` - ... and ${enabled.length - 30} more`);
|
|
739
|
+
}
|
|
740
|
+
lines.push("");
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
// Last few turns
|
|
744
|
+
if (lastTurnEntries.length > 0) {
|
|
745
|
+
lines.push("**Last few branch entries (what happened):**");
|
|
746
|
+
for (const entry of lastTurnEntries) {
|
|
747
|
+
if (entry.type === "message" && entry.message) {
|
|
748
|
+
const role = entry.message.role;
|
|
749
|
+
let text = "";
|
|
750
|
+
if ("content" in entry.message) {
|
|
751
|
+
const content = entry.message.content;
|
|
752
|
+
if (typeof content === "string") text = content;
|
|
753
|
+
else if (Array.isArray(content)) {
|
|
754
|
+
text = content
|
|
755
|
+
.filter((b: any) => b && b.type === "text")
|
|
756
|
+
.map((b: any) => b.text)
|
|
757
|
+
.join("\n");
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
const summary = text.split(/\r?\n/)[0]?.slice(0, 120) ?? "";
|
|
761
|
+
lines.push(` [${role}] ${summary}${text.length > 120 ? "…" : ""}`);
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
lines.push("");
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
lines.push(
|
|
768
|
+
"The LLM's most recent turn was grounded in the rules and state shown above. " +
|
|
769
|
+
"If a behavior surprises you, look here first for the basis.",
|
|
770
|
+
);
|
|
771
|
+
|
|
772
|
+
ctx.ui.notify(lines.join("\n"), "info");
|
|
773
|
+
|
|
774
|
+
// Suppress unused arg
|
|
775
|
+
void args;
|
|
776
|
+
},
|
|
777
|
+
});
|
|
778
|
+
}
|