pi-soly 2.2.0 → 2.2.2
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/built-in-rules/release-discipline.md +105 -0
- package/commands/_helpers.ts +87 -0
- package/commands/artifacts.ts +86 -0
- package/commands/docs.ts +74 -0
- package/commands/rules.ts +187 -0
- package/commands/rulewizard.ts +43 -0
- package/commands/soly.ts +560 -0
- package/commands/why.ts +106 -0
- package/commands.ts +36 -1231
- package/core.ts +2 -15
- package/index.ts +0 -13
- package/init.ts +2 -2
- package/package.json +2 -1
- package/skills/soly-framework/SKILL.md +1 -1
package/commands.ts
CHANGED
|
@@ -1,1239 +1,44 @@
|
|
|
1
1
|
// =============================================================================
|
|
2
|
-
// commands.ts — Slash
|
|
2
|
+
// commands.ts — Slash command registry (thin orchestrator)
|
|
3
3
|
// =============================================================================
|
|
4
4
|
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
// - /docs manage soly intent docs (stats — context breakdown)
|
|
8
|
-
// - /soly project state inspection (position/plan/phases/tasks/...)
|
|
9
|
-
// subcommands: position, state, plan, context, research, roadmap,
|
|
10
|
-
// progress, phases, tasks, task <id>, features,
|
|
11
|
-
// milestone, reload, help
|
|
12
|
-
// - /artifacts browse this session's html_artifact gallery (list/open/clear)
|
|
13
|
-
// - /rulewizard interactive guide for rule vs .editorconfig vs linter
|
|
14
|
-
// - /why show rules + project state that grounded the last turn
|
|
5
|
+
// Wires up the per-command modules under `commands/`. The actual command
|
|
6
|
+
// bodies live in those modules so each one stays small and focused:
|
|
15
7
|
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
8
|
+
// commands/_helpers.ts — CommandUI, CommandsDeps, openListPanel, openExternally
|
|
9
|
+
// commands/rules.ts — /rules
|
|
10
|
+
// commands/docs.ts — /docs
|
|
11
|
+
// commands/artifacts.ts — /artifacts
|
|
12
|
+
// commands/rulewizard.ts — /rulewizard
|
|
13
|
+
// commands/why.ts — /why
|
|
14
|
+
// commands/soly.ts — /soly, /sly, /s (the big one — subcommand table)
|
|
15
|
+
//
|
|
16
|
+
// `registerCommands(pi, deps)` is the public surface called by `index.ts`.
|
|
17
|
+
// It takes a `CommandsDeps` and dispatches to the per-command `register*Command`
|
|
18
|
+
// functions, each of which takes a `Pick<CommandsDeps, …>` of just what
|
|
19
|
+
// it needs.
|
|
18
20
|
// =============================================================================
|
|
19
21
|
|
|
20
|
-
import
|
|
21
|
-
import
|
|
22
|
-
import {
|
|
23
|
-
import
|
|
24
|
-
import {
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
type RuleFile,
|
|
36
|
-
type SolyState,
|
|
37
|
-
} from "./core.ts";
|
|
38
|
-
import {
|
|
39
|
-
buildIntentStats,
|
|
40
|
-
formatIntentStats,
|
|
41
|
-
loadInlineIntentBodies,
|
|
42
|
-
type IntentDoc,
|
|
43
|
-
type IntentInlineDoc,
|
|
44
|
-
} from "./intent.ts";
|
|
45
|
-
import type { SolyConfig } from "./config.ts";
|
|
46
|
-
import { initSolyProject } from "./init.js";
|
|
47
|
-
import { ListPanel, type ListItem, type ListAction, type ListGroup } from "./visual/list-panel.ts";
|
|
48
|
-
import { openSettingsUI } from "./workflows/settings-ui.ts";
|
|
49
|
-
import { getArtifactServer, ensureArtifactServer, artifactDir } from "./artifact/session.ts";
|
|
50
|
-
import { parseSolyCommand, type SolyCommand, type WorkflowVerb } from "./workflows/parser.ts";
|
|
51
|
-
import { buildNewTransform } from "./workflows/new.ts";
|
|
52
|
-
import { buildDoneTransform } from "./workflows/done.ts";
|
|
53
|
-
import { buildMigrateTransform } from "./workflows/migrate.ts";
|
|
54
|
-
import { buildPlanTransform, buildDiscussTransform } from "./workflows/planning.ts";
|
|
55
|
-
import { buildExecuteTransform } from "./workflows/execute.ts";
|
|
56
|
-
|
|
57
|
-
/** Minimum ui surface the command handlers actually need. */
|
|
58
|
-
export interface CommandUI {
|
|
59
|
-
notify: (text: string, kind?: "info" | "warning" | "error") => void;
|
|
60
|
-
select: (label: string, options: string[]) => Promise<number | null>;
|
|
61
|
-
confirm: (title: string, message: string) => Promise<boolean>;
|
|
62
|
-
input?: (label: string, placeholder?: string) => Promise<string | undefined>;
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
export interface CommandsDeps {
|
|
66
|
-
getRules: () => RuleFile[];
|
|
67
|
-
getOverridden: () => string[];
|
|
68
|
-
refreshRules: () => void;
|
|
69
|
-
getState: () => SolyState;
|
|
70
|
-
refreshState: () => void;
|
|
71
|
-
updateStatus: (ui: CommandUI) => void;
|
|
72
|
-
getConfig: () => SolyConfig;
|
|
73
|
-
reloadConfig: () => void;
|
|
74
|
-
getIntentDocs: () => IntentDoc[];
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
/** Open a focused list modal (overlay) for the given grouped items. */
|
|
78
|
-
async function openListPanel(
|
|
79
|
-
ctx: ExtensionCommandContext,
|
|
80
|
-
spec: {
|
|
81
|
-
title: string;
|
|
82
|
-
headerRight?: string;
|
|
83
|
-
build: () => ListGroup[];
|
|
84
|
-
actions?: ListAction[];
|
|
85
|
-
onSelect?: (item: ListItem) => void;
|
|
86
|
-
},
|
|
87
|
-
): Promise<void> {
|
|
88
|
-
await ctx.ui.custom<void>(
|
|
89
|
-
(tui, theme, keybindings, done) =>
|
|
90
|
-
new ListPanel({
|
|
91
|
-
tui,
|
|
92
|
-
theme,
|
|
93
|
-
keybindings,
|
|
94
|
-
done: () => done(),
|
|
95
|
-
title: spec.title,
|
|
96
|
-
headerRight: spec.headerRight,
|
|
97
|
-
groups: spec.build(),
|
|
98
|
-
actions: spec.actions,
|
|
99
|
-
refresh: spec.build,
|
|
100
|
-
onSelect: spec.onSelect,
|
|
101
|
-
}),
|
|
102
|
-
{ overlay: true },
|
|
103
|
-
);
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
/** Open a URL/file with the OS default handler (browser for http/.html). */
|
|
107
|
-
async function openExternally(pi: ExtensionAPI, target: string): Promise<void> {
|
|
108
|
-
const o = platform();
|
|
109
|
-
const r =
|
|
110
|
-
o === "darwin"
|
|
111
|
-
? await pi.exec("open", [target])
|
|
112
|
-
: o === "win32"
|
|
113
|
-
? await pi.exec("cmd", ["/c", "start", "", target])
|
|
114
|
-
: await pi.exec("xdg-open", [target]);
|
|
115
|
-
if (r.code !== 0) throw new Error(r.stderr || `open failed (exit ${r.code})`);
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
/** Status marker for a rule: ● always-on · ◐ glob-matched · ○ disabled. */
|
|
119
|
-
function ruleMarker(r: RuleFile): string {
|
|
120
|
-
if (!r.enabled) return "○";
|
|
121
|
-
return r.meta.always || !(r.meta.globs && r.meta.globs.length) ? "●" : "◐";
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
/** Map a rule to a panel row (token estimate + globs + description preview). */
|
|
125
|
-
function ruleItem(r: RuleFile): ListItem {
|
|
126
|
-
const tokens = Math.ceil(r.body.length / 4);
|
|
127
|
-
const bits = [`${formatTok(tokens)} tok`];
|
|
128
|
-
if (r.meta.globs && r.meta.globs.length) bits.push(r.meta.globs.join(","));
|
|
129
|
-
if (!r.enabled) bits.push("off");
|
|
130
|
-
return {
|
|
131
|
-
id: r.relPath,
|
|
132
|
-
marker: ruleMarker(r),
|
|
133
|
-
label: r.relPath,
|
|
134
|
-
meta: bits.join(" · "),
|
|
135
|
-
body: r.meta.description ? `${r.meta.description}\n\n${r.body}` : r.body,
|
|
136
|
-
};
|
|
137
|
-
}
|
|
138
|
-
|
|
22
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
23
|
+
import type { CommandUI, CommandsDeps } from "./commands/_helpers.ts";
|
|
24
|
+
import { registerRulesCommand } from "./commands/rules.ts";
|
|
25
|
+
import { registerDocsCommand } from "./commands/docs.ts";
|
|
26
|
+
import { registerArtifactsCommand } from "./commands/artifacts.ts";
|
|
27
|
+
import { registerRulewizardCommand } from "./commands/rulewizard.ts";
|
|
28
|
+
import { registerWhyCommand } from "./commands/why.ts";
|
|
29
|
+
import { registerSolyCommand } from "./commands/soly.ts";
|
|
30
|
+
|
|
31
|
+
// Re-exports for backwards compatibility with external imports.
|
|
32
|
+
export type { CommandUI, CommandsDeps };
|
|
33
|
+
|
|
34
|
+
/** Public API called by `index.ts`. Each per-command register function
|
|
35
|
+
* pulls the deps it needs from the passed `CommandsDeps`; we just
|
|
36
|
+
* forward the whole object. */
|
|
139
37
|
export function registerCommands(pi: ExtensionAPI, deps: CommandsDeps): void {
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
updateStatus,
|
|
147
|
-
getConfig,
|
|
148
|
-
getIntentDocs,
|
|
149
|
-
} = deps;
|
|
150
|
-
|
|
151
|
-
// ============================================================================
|
|
152
|
-
// /rules
|
|
153
|
-
// ============================================================================
|
|
154
|
-
|
|
155
|
-
pi.registerCommand("rules", {
|
|
156
|
-
description:
|
|
157
|
-
"manage soly rules (list, show, stats, analytics, reload, enable, disable)",
|
|
158
|
-
handler: async (args, ctx) => {
|
|
159
|
-
const ui: CommandUI = {
|
|
160
|
-
notify: (t, k) => ctx.ui.notify(t, k ?? "info"),
|
|
161
|
-
select: async (label, options) => {
|
|
162
|
-
const result = await ctx.ui.select(label, options);
|
|
163
|
-
return result === undefined ? null : options.indexOf(result);
|
|
164
|
-
},
|
|
165
|
-
confirm: (title, message) => ctx.ui.confirm(title, message),
|
|
166
|
-
};
|
|
167
|
-
const parts = args.trim().split(/\s+/);
|
|
168
|
-
// Bare `/rules` (empty args) opens the modal; an explicit subcommand does not.
|
|
169
|
-
const sub = parts[0] || "list";
|
|
170
|
-
const target = parts[1];
|
|
171
|
-
|
|
172
|
-
if (sub === "list") {
|
|
173
|
-
const rules = getRules();
|
|
174
|
-
const overridden = getOverridden();
|
|
175
|
-
if (rules.length === 0 && overridden.length === 0) {
|
|
176
|
-
|
|
177
|
-
return;
|
|
178
|
-
}
|
|
179
|
-
// Rich modal in the TUI; plain select elsewhere (RPC/print).
|
|
180
|
-
if (ctx.mode === "tui") {
|
|
181
|
-
const analytics = analyzeRules(getRules(), CONTEXT_WINDOW_TOKENS);
|
|
182
|
-
await openListPanel(ctx, {
|
|
183
|
-
title: "soly · rules",
|
|
184
|
-
headerRight: `${getRules().length} rules · ${formatTok(analytics.totalTokens)} · ${analytics.contextBudgetPct.toFixed(1)}%`,
|
|
185
|
-
build: () => [
|
|
186
|
-
{ id: "rules", title: "Rules", icon: "▤", items: getRules().map(ruleItem) },
|
|
187
|
-
],
|
|
188
|
-
actions: [
|
|
189
|
-
{ key: "e", hint: "enable", run: (it) => { const r = getRules().find((x) => x.relPath === it.id); if (r) r.enabled = true; } },
|
|
190
|
-
{ key: "d", hint: "disable", run: (it) => { const r = getRules().find((x) => x.relPath === it.id); if (r) r.enabled = false; } },
|
|
191
|
-
{ key: "r", hint: "reload", run: () => refreshRules() },
|
|
192
|
-
],
|
|
193
|
-
});
|
|
194
|
-
updateStatus(ui);
|
|
195
|
-
return;
|
|
196
|
-
}
|
|
197
|
-
const lines: string[] = [];
|
|
198
|
-
for (const r of rules) {
|
|
199
|
-
const status = r.enabled ? "●" : "○";
|
|
200
|
-
const desc = r.meta.description ? ` — ${r.meta.description}` : "";
|
|
201
|
-
lines.push(`${status} [${r.sourceLabel}] ${r.relPath}${desc}`);
|
|
202
|
-
}
|
|
203
|
-
for (const p of overridden) {
|
|
204
|
-
lines.push(`⊘ [overridden] ${p}`);
|
|
205
|
-
}
|
|
206
|
-
const total = rules.length + overridden.length;
|
|
207
|
-
const choice = await ui.select(`soly rules (${total})`, lines);
|
|
208
|
-
if (choice != null && typeof choice === "number") {
|
|
209
|
-
if (choice < rules.length) {
|
|
210
|
-
const rel = rules[choice];
|
|
211
|
-
if (rel) {
|
|
212
|
-
|
|
213
|
-
}
|
|
214
|
-
} else {
|
|
215
|
-
const idx = choice - rules.length;
|
|
216
|
-
|
|
217
|
-
}
|
|
218
|
-
}
|
|
219
|
-
return;
|
|
220
|
-
}
|
|
221
|
-
|
|
222
|
-
if (sub === "analytics") {
|
|
223
|
-
const rules = getRules();
|
|
224
|
-
const analytics = analyzeRules(rules, CONTEXT_WINDOW_TOKENS);
|
|
225
|
-
|
|
226
|
-
return;
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
if (sub === "stats") {
|
|
230
|
-
// Claude-memory-style breakdown: shows which rules are always-on
|
|
231
|
-
// (every turn) vs glob-matched (only when prompt has file paths).
|
|
232
|
-
// Surfaces context bloat and verifies rules will actually fire.
|
|
233
|
-
const rules = getRules();
|
|
234
|
-
const stats = buildRulesContextStats(rules, CONTEXT_WINDOW_TOKENS);
|
|
235
|
-
|
|
236
|
-
return;
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
if (sub === "show") {
|
|
240
|
-
if (!target) {
|
|
241
|
-
ui.notify("Usage: /rules show <path>", "error");
|
|
242
|
-
return;
|
|
243
|
-
}
|
|
244
|
-
const rule = getRules().find(
|
|
245
|
-
(r) => r.relPath === target || r.relPath.endsWith(target),
|
|
246
|
-
);
|
|
247
|
-
if (!rule) {
|
|
248
|
-
ui.notify(`Rule not found: ${target}`, "error");
|
|
249
|
-
return;
|
|
250
|
-
}
|
|
251
|
-
|
|
252
|
-
return;
|
|
253
|
-
}
|
|
254
|
-
|
|
255
|
-
if (sub === "reload") {
|
|
256
|
-
refreshRules();
|
|
257
|
-
|
|
258
|
-
updateStatus(ui);
|
|
259
|
-
return;
|
|
260
|
-
}
|
|
261
|
-
|
|
262
|
-
if (sub === "enable" || sub === "disable") {
|
|
263
|
-
if (!target) {
|
|
264
|
-
ui.notify(`Usage: /rules ${sub} <path>`, "error");
|
|
265
|
-
return;
|
|
266
|
-
}
|
|
267
|
-
const rule = getRules().find(
|
|
268
|
-
(r) => r.relPath === target || r.relPath.endsWith(target),
|
|
269
|
-
);
|
|
270
|
-
if (!rule) {
|
|
271
|
-
ui.notify(`Rule not found: ${target}`, "error");
|
|
272
|
-
return;
|
|
273
|
-
}
|
|
274
|
-
rule.enabled = sub === "enable";
|
|
275
|
-
|
|
276
|
-
updateStatus(ui);
|
|
277
|
-
return;
|
|
278
|
-
}
|
|
279
|
-
|
|
280
|
-
if (sub === "enable-all" || sub === "disable-all") {
|
|
281
|
-
const enable = sub === "enable-all";
|
|
282
|
-
const rules = getRules();
|
|
283
|
-
let count = 0;
|
|
284
|
-
for (const r of rules) {
|
|
285
|
-
if (r.enabled !== enable) {
|
|
286
|
-
r.enabled = enable;
|
|
287
|
-
count++;
|
|
288
|
-
}
|
|
289
|
-
}
|
|
290
|
-
|
|
291
|
-
updateStatus(ui);
|
|
292
|
-
return;
|
|
293
|
-
}
|
|
294
|
-
|
|
295
|
-
// /rules new — wizard for creating a rule
|
|
296
|
-
if (sub === "new") {
|
|
297
|
-
const cwd = process.cwd();
|
|
298
|
-
const categories = [
|
|
299
|
-
{ name: "architecture", description: "which patterns to use, when" },
|
|
300
|
-
{ name: "code-style", description: "naming, formatting, structure" },
|
|
301
|
-
{ name: "testing", description: "what to test, how, coverage" },
|
|
302
|
-
{ name: "process", description: "git workflow, commit format, PR review" },
|
|
303
|
-
{ name: "performance", description: "perf budgets, hot paths, caching" },
|
|
304
|
-
{ name: "security", description: "auth, secrets, validation, OWASP" },
|
|
305
|
-
];
|
|
306
|
-
const choice = await ui.select(
|
|
307
|
-
"soly rule — pick a category:",
|
|
308
|
-
categories.map((c) => `${c.name} — ${c.description}`),
|
|
309
|
-
);
|
|
310
|
-
if (choice == null) {
|
|
311
|
-
|
|
312
|
-
return;
|
|
313
|
-
}
|
|
314
|
-
const cat = categories[choice];
|
|
315
|
-
if (!cat) return;
|
|
316
|
-
const dir = path.join(solyDirFor(cwd), "rules", cat.name);
|
|
317
|
-
try {
|
|
318
|
-
fs.mkdirSync(dir, { recursive: true });
|
|
319
|
-
} catch {}
|
|
320
|
-
const slug = `${cat.name}-${Date.now().toString(36)}.md`;
|
|
321
|
-
const filePath = path.join(dir, slug);
|
|
322
|
-
const template = `---
|
|
323
|
-
description: TODO — what does this rule constrain or require?
|
|
324
|
-
globs: []
|
|
325
|
-
priority: medium
|
|
326
|
-
---
|
|
327
|
-
|
|
328
|
-
# ${cat.name} rule
|
|
329
|
-
|
|
330
|
-
> TODO: write the rule. Use imperative voice, give Good/Bad examples where
|
|
331
|
-
> useful. State what the LLM must do, not what it should avoid.
|
|
332
|
-
|
|
333
|
-
## Context
|
|
334
|
-
|
|
335
|
-
When does this rule apply?
|
|
336
|
-
|
|
337
|
-
## Rule
|
|
338
|
-
|
|
339
|
-
What must the LLM do?
|
|
340
|
-
|
|
341
|
-
## Examples
|
|
342
|
-
|
|
343
|
-
### Good
|
|
344
|
-
|
|
345
|
-
\`\`\`
|
|
346
|
-
<!-- concrete good example -->
|
|
347
|
-
\`\`\`
|
|
348
|
-
|
|
349
|
-
### Bad
|
|
350
|
-
|
|
351
|
-
\`\`\`
|
|
352
|
-
<!-- concrete bad example -->
|
|
353
|
-
\`\`\`
|
|
354
|
-
`;
|
|
355
|
-
try {
|
|
356
|
-
fs.writeFileSync(filePath, template, "utf-8");
|
|
357
|
-
|
|
358
|
-
refreshRules();
|
|
359
|
-
updateStatus(ui);
|
|
360
|
-
} catch (e) {
|
|
361
|
-
ui.notify(`soly: failed to create rule: ${(e as Error).message}`, "error");
|
|
362
|
-
}
|
|
363
|
-
return;
|
|
364
|
-
}
|
|
365
|
-
|
|
366
|
-
// /rules add <url> — download a remote rule into .agents/rules/
|
|
367
|
-
if (sub === "add") {
|
|
368
|
-
const url = (target ?? "").trim();
|
|
369
|
-
if (!url) {
|
|
370
|
-
ui.notify("Usage: /rules add <url>", "error");
|
|
371
|
-
return;
|
|
372
|
-
}
|
|
373
|
-
try {
|
|
374
|
-
const parsed = new URL(url);
|
|
375
|
-
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
|
376
|
-
ui.notify(`soly: only http(s) URLs are supported (got ${parsed.protocol})`, "error");
|
|
377
|
-
return;
|
|
378
|
-
}
|
|
379
|
-
|
|
380
|
-
const res = await fetch(url, {
|
|
381
|
-
signal: AbortSignal.timeout(10_000),
|
|
382
|
-
headers: { "user-agent": "soly-extension/1.0" },
|
|
383
|
-
});
|
|
384
|
-
if (!res.ok) {
|
|
385
|
-
ui.notify(`soly: HTTP ${res.status} ${res.statusText} from ${url}`, "error");
|
|
386
|
-
return;
|
|
387
|
-
}
|
|
388
|
-
const text = await res.text();
|
|
389
|
-
if (text.length === 0) {
|
|
390
|
-
ui.notify(`soly: empty response from ${url}`, "error");
|
|
391
|
-
return;
|
|
392
|
-
}
|
|
393
|
-
if (text.length > 200_000) {
|
|
394
|
-
ui.notify(
|
|
395
|
-
`soly: refusing to install rule > 200KB (got ${(text.length / 1024).toFixed(1)}KB). Inspect manually.`,
|
|
396
|
-
"error",
|
|
397
|
-
);
|
|
398
|
-
return;
|
|
399
|
-
}
|
|
400
|
-
// Derive filename from URL (strip query, keep last path segment)
|
|
401
|
-
const lastSeg = parsed.pathname.split("/").filter(Boolean).pop() ?? "rule.md";
|
|
402
|
-
const safeName = lastSeg.replace(/[^A-Za-z0-9._-]/g, "_");
|
|
403
|
-
const fileName = safeName.endsWith(".md") ? safeName : `${safeName}.md`;
|
|
404
|
-
const rulesRoot = path.join(solyDirFor(process.cwd()), "rules");
|
|
405
|
-
fs.mkdirSync(rulesRoot, { recursive: true });
|
|
406
|
-
const targetFile = path.join(rulesRoot, fileName);
|
|
407
|
-
// Refuse to overwrite without warning
|
|
408
|
-
if (fs.existsSync(targetFile)) {
|
|
409
|
-
const overwrite = await ui.confirm(
|
|
410
|
-
"Overwrite?",
|
|
411
|
-
`${fileName} already exists. Overwrite?`,
|
|
412
|
-
);
|
|
413
|
-
if (!overwrite) {
|
|
414
|
-
|
|
415
|
-
return;
|
|
416
|
-
}
|
|
417
|
-
}
|
|
418
|
-
fs.writeFileSync(targetFile, text, "utf-8");
|
|
419
|
-
refreshRules();
|
|
420
|
-
|
|
421
|
-
updateStatus(ui);
|
|
422
|
-
} catch (e) {
|
|
423
|
-
ui.notify(`soly: download failed: ${(e as Error).message}`, "error");
|
|
424
|
-
}
|
|
425
|
-
return;
|
|
426
|
-
}
|
|
427
|
-
|
|
428
|
-
ui.notify(
|
|
429
|
-
`unknown subcommand: ${sub}\nUsage: /rules [list|show <path>|analytics|reload|enable <path>|disable <path>|enable-all|disable-all|add <url>|new]`,
|
|
430
|
-
"error",
|
|
431
|
-
);
|
|
432
|
-
},
|
|
433
|
-
});
|
|
434
|
-
|
|
435
|
-
// ============================================================================
|
|
436
|
-
// /docs — manage soly intent docs (stats subcommand shows context usage)
|
|
437
|
-
// ============================================================================
|
|
438
|
-
pi.registerCommand("docs", {
|
|
439
|
-
description:
|
|
440
|
-
"manage soly intent docs (stats — show context breakdown)",
|
|
441
|
-
handler: async (args, ctx) => {
|
|
442
|
-
const ui: CommandUI = {
|
|
443
|
-
notify: (t, k) => ctx.ui.notify(t, k ?? "info"),
|
|
444
|
-
select: async (label, options) => {
|
|
445
|
-
const result = await ctx.ui.select(label, options);
|
|
446
|
-
return result === undefined ? null : options.indexOf(result);
|
|
447
|
-
},
|
|
448
|
-
confirm: (title, message) => ctx.ui.confirm(title, message),
|
|
449
|
-
};
|
|
450
|
-
const parts = args.trim().split(/\s+/);
|
|
451
|
-
// Bare `/docs` opens the modal; an explicit subcommand (e.g. stats) does not.
|
|
452
|
-
const sub = parts[0] || "list";
|
|
453
|
-
|
|
454
|
-
if (sub === "list") {
|
|
455
|
-
const docs = getIntentDocs();
|
|
456
|
-
if (docs.length === 0) {
|
|
457
|
-
|
|
458
|
-
return;
|
|
459
|
-
}
|
|
460
|
-
if (ctx.mode === "tui") {
|
|
461
|
-
const total = docs.reduce((s, d) => s + d.tokens, 0);
|
|
462
|
-
await openListPanel(ctx, {
|
|
463
|
-
title: "soly · docs",
|
|
464
|
-
headerRight: `${docs.length} docs · ${formatTok(total)}`,
|
|
465
|
-
build: () => [
|
|
466
|
-
{
|
|
467
|
-
id: "docs",
|
|
468
|
-
title: "Docs",
|
|
469
|
-
icon: "☖",
|
|
470
|
-
items: getIntentDocs().map((d) => ({
|
|
471
|
-
id: d.relPath,
|
|
472
|
-
marker: "○",
|
|
473
|
-
label: d.title || d.relPath,
|
|
474
|
-
meta: `${d.kind} · ${formatTok(d.tokens)} tok${d.oversized ? " · oversized" : ""}`,
|
|
475
|
-
body: d.preview,
|
|
476
|
-
})),
|
|
477
|
-
},
|
|
478
|
-
],
|
|
479
|
-
});
|
|
480
|
-
return;
|
|
481
|
-
}
|
|
482
|
-
ui.notify(docs.map((d) => `○ ${d.title || d.relPath} (${formatTok(d.tokens)} tok)`).join("\n"), "info");
|
|
483
|
-
return;
|
|
484
|
-
}
|
|
485
|
-
|
|
486
|
-
if (sub === "stats") {
|
|
487
|
-
const docs = getIntentDocs();
|
|
488
|
-
const inlineBodies: IntentInlineDoc[] = loadInlineIntentBodies(docs);
|
|
489
|
-
const stats = buildIntentStats(docs, inlineBodies);
|
|
490
|
-
|
|
491
|
-
return;
|
|
492
|
-
}
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
},
|
|
496
|
-
});
|
|
497
|
-
|
|
498
|
-
// /soly (also hosts `/soly init` — see the early dispatch in the handler)
|
|
499
|
-
// ============================================================================
|
|
500
|
-
|
|
501
|
-
const solyBody = async (args: string, ctx: ExtensionCommandContext): Promise<void> => {
|
|
502
|
-
const ui: CommandUI = {
|
|
503
|
-
notify: (t, k) => ctx.ui.notify(t, k ?? "info"),
|
|
504
|
-
select: async (label, options) => {
|
|
505
|
-
const result = await ctx.ui.select(label, options);
|
|
506
|
-
return result === undefined ? null : options.indexOf(result);
|
|
507
|
-
},
|
|
508
|
-
confirm: (title, message) => ctx.ui.confirm(title, message),
|
|
509
|
-
};
|
|
510
|
-
// `init` is special — it scaffolds a NEW project, so it must run even
|
|
511
|
-
// when there's no `.agents/` dir yet. Dispatch it before the project
|
|
512
|
-
// guard below. (Moved here from the old standalone `/soly-init`.)
|
|
513
|
-
{
|
|
514
|
-
const firstArg = args.trim().split(/\s+/).filter(Boolean)[0] ?? "";
|
|
515
|
-
if (firstArg === "init") {
|
|
516
|
-
const template = (args.match(/--template[= ](\S+)/)?.[1] as
|
|
517
|
-
| "minimal" | "web-app" | "library" | "cli" | undefined) ?? undefined;
|
|
518
|
-
const autoYes = args.includes("--yes");
|
|
519
|
-
const projectName = args.match(/--name[= ](\S+)/)?.[1];
|
|
520
|
-
const initUi = {
|
|
521
|
-
notify: (t: string, k?: "info" | "warning" | "error") => ctx.ui.notify(t, k ?? "info"),
|
|
522
|
-
select: async (label: string, options: string[]) => ctx.ui.select(label, options),
|
|
523
|
-
confirm: (title: string, message: string) => ctx.ui.confirm(title, message),
|
|
524
|
-
input: (label: string, placeholder?: string) => ctx.ui.input(label, placeholder),
|
|
525
|
-
};
|
|
526
|
-
await initSolyProject(ctx.cwd, initUi, { template, autoYes, projectName });
|
|
527
|
-
return;
|
|
528
|
-
}
|
|
529
|
-
}
|
|
530
|
-
|
|
531
|
-
const state = getState();
|
|
532
|
-
if (!state.exists) {
|
|
533
|
-
|
|
534
|
-
return;
|
|
535
|
-
}
|
|
536
|
-
|
|
537
|
-
const showFile = (label: string, content: string | null) => {
|
|
538
|
-
if (!content) {
|
|
539
|
-
ui.notify(`${label}: not found`, "error");
|
|
540
|
-
return;
|
|
541
|
-
}
|
|
542
|
-
const MAX = 4000;
|
|
543
|
-
const truncated =
|
|
544
|
-
content.length > MAX
|
|
545
|
-
? `${content.slice(0, MAX)}\n\n[...truncated, file is ${content.length} chars]`
|
|
546
|
-
: content;
|
|
547
|
-
|
|
548
|
-
};
|
|
549
|
-
|
|
550
|
-
type SolySub = {
|
|
551
|
-
description: string;
|
|
552
|
-
run: (parts: string[]) => void | Promise<void>;
|
|
553
|
-
};
|
|
554
|
-
/** Dispatch a workflow verb from the `/soly` slash-command picker.
|
|
555
|
-
* Same handlers used by the `soly <verb> ...` text-command path —
|
|
556
|
-
* registered here so humans can also drive them via
|
|
557
|
-
* `/soly <verb> ...` without having to type natural language and
|
|
558
|
-
* wait for the LLM to translate. */
|
|
559
|
-
const runWorkflow = async (
|
|
560
|
-
verb: WorkflowVerb,
|
|
561
|
-
parts: string[],
|
|
562
|
-
ctx: ExtensionCommandContext,
|
|
563
|
-
ui: CommandUI,
|
|
564
|
-
): Promise<void> => {
|
|
565
|
-
// parts[0] is the verb name; the args follow.
|
|
566
|
-
const args = parts.slice(1);
|
|
567
|
-
const cmd: SolyCommand = {
|
|
568
|
-
verb,
|
|
569
|
-
args,
|
|
570
|
-
raw: `soly ${verb}${args.length ? " " + args.join(" ") : ""}`,
|
|
571
|
-
};
|
|
572
|
-
const state = getState();
|
|
573
|
-
if (!state.exists) {
|
|
574
|
-
|
|
575
|
-
return;
|
|
576
|
-
}
|
|
577
|
-
switch (verb) {
|
|
578
|
-
case "new": {
|
|
579
|
-
const r = buildNewTransform(cmd, state, ui, ctx.cwd, getConfig().plan.defaultBranchPrefix);
|
|
580
|
-
if (r.handled && r.transformedText) ui.notify(r.transformedText, "info");
|
|
581
|
-
return;
|
|
582
|
-
}
|
|
583
|
-
case "done": {
|
|
584
|
-
const r = buildDoneTransform(cmd, state, ui, ctx.cwd, { defaultBranchPrefix: getConfig().plan.defaultBranchPrefix });
|
|
585
|
-
if (r.handled && r.transformedText) ui.notify(r.transformedText, "info");
|
|
586
|
-
return;
|
|
587
|
-
}
|
|
588
|
-
case "migrate": {
|
|
589
|
-
const r = buildMigrateTransform(state, ui, ctx.cwd);
|
|
590
|
-
if (r.handled && r.transformedText) ui.notify(r.transformedText, "info");
|
|
591
|
-
return;
|
|
592
|
-
}
|
|
593
|
-
case "plan": {
|
|
594
|
-
const r = buildPlanTransform(cmd, state);
|
|
595
|
-
if (r.handled && r.transformedText) ui.notify(r.transformedText, "info");
|
|
596
|
-
return;
|
|
597
|
-
}
|
|
598
|
-
case "discuss": {
|
|
599
|
-
// Slash-command path doesn't have live ask_pro detection.
|
|
600
|
-
// Pass `false` so the discuss transform falls back to its
|
|
601
|
-
// plain-text discussion format; the LLM-driven path still
|
|
602
|
-
// gets the richer ask_pro flow.
|
|
603
|
-
const r = buildDiscussTransform(cmd, state, { hasAskPro: false });
|
|
604
|
-
if (r.handled && r.transformedText) ui.notify(r.transformedText, "info");
|
|
605
|
-
return;
|
|
606
|
-
}
|
|
607
|
-
case "execute": {
|
|
608
|
-
// Slash-command path doesn't have live interactive rules.
|
|
609
|
-
const r = buildExecuteTransform(cmd, state, []);
|
|
610
|
-
if (r.handled && r.transformedText) ui.notify(r.transformedText, "info");
|
|
611
|
-
return;
|
|
612
|
-
}
|
|
613
|
-
default:
|
|
614
|
-
|
|
615
|
-
return;
|
|
616
|
-
}
|
|
617
|
-
};
|
|
618
|
-
const subcommands: Record<string, SolySub> = {
|
|
619
|
-
// `agent` subcommand REMOVED — moved to the separate `pi-switch`
|
|
620
|
-
// extension (rotor switcher removed in 1.4.0).
|
|
621
|
-
// Soly no longer owns a rotor switcher.
|
|
622
|
-
config: {
|
|
623
|
-
description: "show merged config (per-project + global + defaults); edit .agents/soly.json or ~/.agents/soly.json",
|
|
624
|
-
run: () => {
|
|
625
|
-
const cfg = getConfig();
|
|
626
|
-
const out: string[] = [];
|
|
627
|
-
out.push("=== soly config (merged) ===");
|
|
628
|
-
out.push("");
|
|
629
|
-
out.push("```json");
|
|
630
|
-
out.push(JSON.stringify(cfg, null, 2));
|
|
631
|
-
out.push("```");
|
|
632
|
-
out.push("");
|
|
633
|
-
out.push("Sources:");
|
|
634
|
-
out.push(` global: ~/.agents/soly.json`);
|
|
635
|
-
out.push(` project: <cwd>/.agents/soly.json`);
|
|
636
|
-
out.push("");
|
|
637
|
-
out.push("To edit:");
|
|
638
|
-
out.push(` - project: edit \`${state.solyDir}/soly.json\` directly`);
|
|
639
|
-
out.push(` - global: edit \`~/.agents/soly.json\``);
|
|
640
|
-
out.push("After editing, run /soly reload to re-pick up changes.");
|
|
641
|
-
|
|
642
|
-
},
|
|
643
|
-
},
|
|
644
|
-
position: {
|
|
645
|
-
description: "one-screen position summary (default)",
|
|
646
|
-
run: () => {
|
|
647
|
-
const s = getState();
|
|
648
|
-
if (s.position) {
|
|
649
|
-
|
|
650
|
-
} else {
|
|
651
|
-
|
|
652
|
-
}
|
|
653
|
-
},
|
|
654
|
-
},
|
|
655
|
-
state: {
|
|
656
|
-
description: "full STATE.md body",
|
|
657
|
-
run: () => showFile("STATE.md", getState().stateBody),
|
|
658
|
-
},
|
|
659
|
-
plan: {
|
|
660
|
-
description: "current PLAN.md body, OR `plan <slug>` to flesh out a plan",
|
|
661
|
-
run: (parts) => {
|
|
662
|
-
// Dual-purpose: no args = show current PLAN.md; with args
|
|
663
|
-
// = dispatch to the plan-mode workflow. Same key the
|
|
664
|
-
// state picker already had (state picker items and
|
|
665
|
-
// workflow verbs share the same `/soly` namespace).
|
|
666
|
-
if (parts.length <= 1) {
|
|
667
|
-
const s = getState();
|
|
668
|
-
if (!s.currentPlanPath) {
|
|
669
|
-
ui.notify("soly: no current plan", "error");
|
|
670
|
-
return;
|
|
671
|
-
}
|
|
672
|
-
showFile(
|
|
673
|
-
`PLAN: ${path.basename(s.currentPlanPath)}`,
|
|
674
|
-
readIfExists(s.currentPlanPath),
|
|
675
|
-
);
|
|
676
|
-
return;
|
|
677
|
-
}
|
|
678
|
-
void runWorkflow("plan", parts, ctx, ui);
|
|
679
|
-
},
|
|
680
|
-
},
|
|
681
|
-
context: {
|
|
682
|
-
description: "current CONTEXT.md body",
|
|
683
|
-
run: () => {
|
|
684
|
-
const s = getState();
|
|
685
|
-
if (!s.currentPhase) {
|
|
686
|
-
ui.notify("soly: no current phase", "error");
|
|
687
|
-
return;
|
|
688
|
-
}
|
|
689
|
-
const p = path.join(s.currentPhase.dir, `${s.currentPhase.slug}-CONTEXT.md`);
|
|
690
|
-
showFile("CONTEXT.md", readIfExists(p));
|
|
691
|
-
},
|
|
692
|
-
},
|
|
693
|
-
research: {
|
|
694
|
-
description: "current RESEARCH.md body",
|
|
695
|
-
run: () => {
|
|
696
|
-
const s = getState();
|
|
697
|
-
if (!s.currentPhase) {
|
|
698
|
-
ui.notify("soly: no current phase", "error");
|
|
699
|
-
return;
|
|
700
|
-
}
|
|
701
|
-
const p = path.join(s.currentPhase.dir, `${s.currentPhase.slug}-RESEARCH.md`);
|
|
702
|
-
showFile("RESEARCH.md", readIfExists(p));
|
|
703
|
-
},
|
|
704
|
-
},
|
|
705
|
-
roadmap: {
|
|
706
|
-
description: "ROADMAP.md body",
|
|
707
|
-
run: () => showFile("ROADMAP.md", getState().roadmapBody),
|
|
708
|
-
},
|
|
709
|
-
progress: {
|
|
710
|
-
description: "progress bar + counts",
|
|
711
|
-
run: () => {
|
|
712
|
-
const s = getState();
|
|
713
|
-
|
|
714
|
-
},
|
|
715
|
-
},
|
|
716
|
-
phases: {
|
|
717
|
-
description: "list all phases with plan counts and C/R markers",
|
|
718
|
-
run: () => {
|
|
719
|
-
const phases = getState().phases;
|
|
720
|
-
if (phases.length === 0) {
|
|
721
|
-
|
|
722
|
-
return;
|
|
723
|
-
}
|
|
724
|
-
const current = getState().currentPhase?.number;
|
|
725
|
-
const lines = phases.map((p) => {
|
|
726
|
-
const marker = current === p.number ? "→" : " ";
|
|
727
|
-
const cr = (p.contextExists ? "C" : "·") + (p.researchExists ? "R" : "·");
|
|
728
|
-
return `${marker} ${String(p.number).padStart(2, "0")}. ${p.name} [${cr}] plans=${p.planCount}`;
|
|
729
|
-
});
|
|
730
|
-
|
|
731
|
-
},
|
|
732
|
-
},
|
|
733
|
-
tasks: {
|
|
734
|
-
description: "list all tasks grouped by feature (mirrors soly_list_tasks tool)",
|
|
735
|
-
run: () => {
|
|
736
|
-
const s = getState();
|
|
737
|
-
if (s.tasks.length === 0) {
|
|
738
|
-
|
|
739
|
-
return;
|
|
740
|
-
}
|
|
741
|
-
const byFeature = new Map<string, typeof s.tasks>();
|
|
742
|
-
for (const t of s.tasks) {
|
|
743
|
-
const list = byFeature.get(t.feature) ?? [];
|
|
744
|
-
list.push(t);
|
|
745
|
-
byFeature.set(t.feature, list);
|
|
746
|
-
}
|
|
747
|
-
const out: string[] = [`tasks (${s.tasks.length} total):`, ""];
|
|
748
|
-
for (const [feature, list] of [...byFeature.entries()].sort()) {
|
|
749
|
-
out.push(`[${feature}] ${list.length} task(s)`);
|
|
750
|
-
for (const t of list) {
|
|
751
|
-
const deps = t.dependsOn.length > 0 ? ` deps=[${t.dependsOn.join(",")}]` : "";
|
|
752
|
-
const par = t.parallelizable ? " ⚡" : "";
|
|
753
|
-
out.push(` ${t.id} [${t.kind}] status=${t.status} prio=${t.priority}${par}${deps}`);
|
|
754
|
-
}
|
|
755
|
-
out.push("");
|
|
756
|
-
}
|
|
757
|
-
|
|
758
|
-
},
|
|
759
|
-
},
|
|
760
|
-
task: {
|
|
761
|
-
description: "show one task's PLAN.md + SUMMARY.md if present (usage: /soly task <id>)",
|
|
762
|
-
run: (parts) => {
|
|
763
|
-
const id = (parts[1] ?? "").trim();
|
|
764
|
-
if (!id) {
|
|
765
|
-
ui.notify("Usage: /soly task <task-id>", "error");
|
|
766
|
-
return;
|
|
767
|
-
}
|
|
768
|
-
const s = getState();
|
|
769
|
-
const task = s.tasks.find((t) => t.id === id);
|
|
770
|
-
if (!task) {
|
|
771
|
-
ui.notify(
|
|
772
|
-
`soly: task ${id} not found.\nKnown: ${s.tasks.map((t) => t.id).join(", ") || "(none)"}`,
|
|
773
|
-
"error",
|
|
774
|
-
);
|
|
775
|
-
return;
|
|
776
|
-
}
|
|
777
|
-
const planPath = path.join(task.dir, "PLAN.md");
|
|
778
|
-
const summaryPath = path.join(task.dir, "SUMMARY.md");
|
|
779
|
-
const planBody = readIfExists(planPath);
|
|
780
|
-
const summaryBody = readIfExists(summaryPath);
|
|
781
|
-
const header = `task ${task.id} [${task.feature}/${task.kind}] status=${task.status} prio=${task.priority}`;
|
|
782
|
-
const deps = task.dependsOn.length > 0 ? `\ndepends-on: [${task.dependsOn.join(", ")}]` : "";
|
|
783
|
-
const planLabel = planBody ? `PLAN.md (${planBody.length} chars)` : `PLAN.md (missing)`;
|
|
784
|
-
showFile(`${header}${deps}\n${planLabel}`, planBody ?? "(no PLAN.md)");
|
|
785
|
-
if (summaryBody) {
|
|
786
|
-
showFile("SUMMARY.md", summaryBody);
|
|
787
|
-
} else {
|
|
788
|
-
|
|
789
|
-
}
|
|
790
|
-
},
|
|
791
|
-
},
|
|
792
|
-
features: {
|
|
793
|
-
description: "list all features with task counts and README presence",
|
|
794
|
-
run: () => {
|
|
795
|
-
const features = getState().features;
|
|
796
|
-
if (features.length === 0) {
|
|
797
|
-
|
|
798
|
-
return;
|
|
799
|
-
}
|
|
800
|
-
const lines = features.map((f) => {
|
|
801
|
-
const rm = f.readmeExists ? "R" : "·";
|
|
802
|
-
return ` ${f.name.padEnd(28)} tasks=${f.taskCount} [${rm}]`;
|
|
803
|
-
});
|
|
804
|
-
|
|
805
|
-
},
|
|
806
|
-
},
|
|
807
|
-
milestone: {
|
|
808
|
-
description: "show the active milestone document (.agents/milestones/<v>.md)",
|
|
809
|
-
run: () => {
|
|
810
|
-
const s = getState();
|
|
811
|
-
if (!s.milestone || s.milestone === "—") {
|
|
812
|
-
|
|
813
|
-
return;
|
|
814
|
-
}
|
|
815
|
-
const candidates = [
|
|
816
|
-
path.join(s.solyDir, "milestones", `${s.milestone}.md`),
|
|
817
|
-
path.join(s.solyDir, "MILESTONES.md"),
|
|
818
|
-
];
|
|
819
|
-
for (const c of candidates) {
|
|
820
|
-
const body = readIfExists(c);
|
|
821
|
-
if (body) {
|
|
822
|
-
showFile(`MILESTONE ${s.milestone} (${path.relative(process.cwd(), c)})`, body);
|
|
823
|
-
return;
|
|
824
|
-
}
|
|
825
|
-
}
|
|
826
|
-
ui.notify(
|
|
827
|
-
`soly: no milestone file found. tried:\n ${candidates.map((c) => path.relative(process.cwd(), c)).join("\n ")}`,
|
|
828
|
-
"error",
|
|
829
|
-
);
|
|
830
|
-
},
|
|
831
|
-
},
|
|
832
|
-
reload: {
|
|
833
|
-
description: "re-read project state from disk",
|
|
834
|
-
run: () => {
|
|
835
|
-
refreshState();
|
|
836
|
-
updateStatus(ui);
|
|
837
|
-
const s = getState();
|
|
838
|
-
|
|
839
|
-
},
|
|
840
|
-
},
|
|
841
|
-
// ------------------------------------------------------------------
|
|
842
|
-
// Workflow verbs — same handlers used by the `soly <verb> ...`
|
|
843
|
-
// text-command path. Registered here so humans can also drive
|
|
844
|
-
// them via `/soly <verb> ...` from the slash picker without
|
|
845
|
-
// typing natural language and waiting for the LLM to translate.
|
|
846
|
-
// Each verb's buildXxxTransform already calls ui.notify on
|
|
847
|
-
// success, so we just need to construct a fake `SolyCommand`
|
|
848
|
-
// from the slash args and call the handler.
|
|
849
|
-
// ------------------------------------------------------------------
|
|
850
|
-
new: {
|
|
851
|
-
description: "scaffold a new plan: create branch + .agents/plans/<slug>/PLAN.md (e.g. `/soly new statistic-preparation`)",
|
|
852
|
-
run: (parts) => runWorkflow("new", parts, ctx, ui),
|
|
853
|
-
},
|
|
854
|
-
execute: {
|
|
855
|
-
description: "execute a plan via subagent (e.g. `/soly execute statistic-preparation`)",
|
|
856
|
-
run: (parts) => runWorkflow("execute", parts, ctx, ui),
|
|
857
|
-
},
|
|
858
|
-
discuss: {
|
|
859
|
-
description: "interactive discussion of a plan (e.g. `/soly discuss statistic-preparation`)",
|
|
860
|
-
run: (parts) => runWorkflow("discuss", parts, ctx, ui),
|
|
861
|
-
},
|
|
862
|
-
done: {
|
|
863
|
-
description: "commit + push + open draft PR for a plan branch (e.g. `/soly done statistic-preparation`)",
|
|
864
|
-
run: (parts) => runWorkflow("done", parts, ctx, ui),
|
|
865
|
-
},
|
|
866
|
-
migrate: {
|
|
867
|
-
description: "one-shot: import .agents/phases/<NN>-<slug>/plans/PLAN.md as plan branches",
|
|
868
|
-
run: () => runWorkflow("migrate", [], ctx, ui),
|
|
869
|
-
},
|
|
870
|
-
where: {
|
|
871
|
-
description: "alias for `position` — current position + progress",
|
|
872
|
-
run: (parts) => {
|
|
873
|
-
const p = subcommands.position;
|
|
874
|
-
if (p) void p.run(parts);
|
|
875
|
-
},
|
|
876
|
-
},
|
|
877
|
-
settings: {
|
|
878
|
-
description: "interactive config editor (toggles, enums, numbers)",
|
|
879
|
-
run: () => {
|
|
880
|
-
openSettingsUI({
|
|
881
|
-
ctx,
|
|
882
|
-
ui,
|
|
883
|
-
solyDir: state.solyDir,
|
|
884
|
-
getConfig,
|
|
885
|
-
reloadConfig: deps.reloadConfig,
|
|
886
|
-
});
|
|
887
|
-
},
|
|
888
|
-
},
|
|
889
|
-
};
|
|
890
|
-
|
|
891
|
-
// Single-width BMP glyphs only — astral/VS16 emoji are mis-measured by
|
|
892
|
-
// visibleWidth, overflow the panel, and ghost the modal on ↑↓.
|
|
893
|
-
const ICONS: Record<string, string> = {
|
|
894
|
-
position: "◎", state: "▤", plan: "▥", context: "◌",
|
|
895
|
-
research: "⊙", roadmap: "≣", progress: "▰",
|
|
896
|
-
phases: "▭", tasks: "✓", task: "◍",
|
|
897
|
-
features: "★", milestone: "◈", reload: "↻", config: "▣",
|
|
898
|
-
settings: "⚙", where: "◎",
|
|
899
|
-
};
|
|
900
|
-
|
|
901
|
-
// Short live preview shown in the modal's preview pane per subcommand.
|
|
902
|
-
const previewFor = (name: string): string => {
|
|
903
|
-
const s = getState();
|
|
904
|
-
const teaser = (p: string | null): string => (p ?? "").replace(/\s+/g, " ").slice(0, 240);
|
|
905
|
-
const phaseFile = (suffix: string) =>
|
|
906
|
-
s.currentPhase ? readIfExists(path.join(s.currentPhase.dir, `${s.currentPhase.slug}-${suffix}.md`)) : null;
|
|
907
|
-
switch (name) {
|
|
908
|
-
case "where": return s.position ? `${s.position.phase} · ${s.position.plan} · ${s.position.status} · ${s.progress.percent}%` : `${s.milestone} — no position set`;
|
|
909
|
-
case "position": return s.position ? `${s.position.phase} · ${s.position.plan} · ${s.position.status} · ${s.progress.percent}%` : `${s.milestone} — no position set`;
|
|
910
|
-
case "progress": return `${s.progress.percent}% · ${s.progress.completedPhases}/${s.progress.totalPhases} phases · ${s.progress.completedPlans}/${s.progress.totalPlans} plans`;
|
|
911
|
-
case "phases": return s.phases.length ? s.phases.map((p) => `${p.number}.${p.name}`).join(" · ") : "no phases";
|
|
912
|
-
case "tasks": return s.tasks.length ? `${s.tasks.length} task(s) across ${s.features.length} feature(s)` : "no tasks";
|
|
913
|
-
case "features": return s.features.length ? s.features.map((f) => f.name).join(" · ") : "no features";
|
|
914
|
-
case "milestone": return s.milestone && s.milestone !== "—" ? String(s.milestoneName ?? s.milestone) : "no milestone set";
|
|
915
|
-
case "state": return teaser(s.stateBody) || "STATE.md not found";
|
|
916
|
-
case "roadmap": return teaser(s.roadmapBody) || "ROADMAP.md not found";
|
|
917
|
-
case "plan": return s.currentPlanPath ? teaser(readIfExists(s.currentPlanPath)) : "no current plan";
|
|
918
|
-
case "context": return s.currentPhase ? teaser(phaseFile("CONTEXT")) || "CONTEXT.md not found" : "no current phase";
|
|
919
|
-
case "research": return s.currentPhase ? teaser(phaseFile("RESEARCH")) || "RESEARCH.md not found" : "no current phase";
|
|
920
|
-
case "settings": return "interactive config editor — toggles, enums, numbers";
|
|
921
|
-
case "config": return "merged config JSON (read-only view)";
|
|
922
|
-
case "reload": return "re-read project state from disk";
|
|
923
|
-
default: return subcommands[name]?.description ?? "";
|
|
924
|
-
}
|
|
925
|
-
};
|
|
926
|
-
|
|
927
|
-
// Top-to-bottom group order in the /soly modal. Items in
|
|
928
|
-
// `items[]` show in declaration order; subcommands not listed here
|
|
929
|
-
// still work via `/soly <name>` directly but don't appear in the picker.
|
|
930
|
-
const SOLY_GROUP_ORDER = ["status", "inspect", "manage"] as const;
|
|
931
|
-
const SOLY_GROUPS: Record<string, { id: string; title: string; icon: string; items: string[] }> = {
|
|
932
|
-
status: {
|
|
933
|
-
id: "status",
|
|
934
|
-
title: "Status",
|
|
935
|
-
icon: "▰",
|
|
936
|
-
items: ["where", "progress"],
|
|
937
|
-
},
|
|
938
|
-
inspect: {
|
|
939
|
-
id: "inspect",
|
|
940
|
-
title: "Inspect",
|
|
941
|
-
icon: "▤",
|
|
942
|
-
items: ["plan", "state", "roadmap", "context", "phases", "tasks", "milestone"],
|
|
943
|
-
},
|
|
944
|
-
manage: {
|
|
945
|
-
id: "manage",
|
|
946
|
-
title: "Manage",
|
|
947
|
-
icon: "⚙",
|
|
948
|
-
items: ["settings", "reload", "config"],
|
|
949
|
-
},
|
|
950
|
-
};
|
|
951
|
-
|
|
952
|
-
const solyGroups = (): ListGroup[] =>
|
|
953
|
-
SOLY_GROUP_ORDER.map((gid) => {
|
|
954
|
-
const def = SOLY_GROUPS[gid]!;
|
|
955
|
-
const items: ListItem[] = [];
|
|
956
|
-
for (const name of def.items) {
|
|
957
|
-
const spec = subcommands[name];
|
|
958
|
-
if (!spec) continue;
|
|
959
|
-
items.push({
|
|
960
|
-
id: name,
|
|
961
|
-
marker: ICONS[name] ?? "▸",
|
|
962
|
-
label: name,
|
|
963
|
-
meta: spec.description,
|
|
964
|
-
body: previewFor(name),
|
|
965
|
-
});
|
|
966
|
-
}
|
|
967
|
-
return { id: def.id, title: def.title, icon: def.icon, items };
|
|
968
|
-
}).filter((g) => g.items.length > 0);
|
|
969
|
-
|
|
970
|
-
// Plain-select fallback for non-TUI (RPC/print) modes.
|
|
971
|
-
const picker = async (label: string) => {
|
|
972
|
-
const entries = Object.entries(subcommands);
|
|
973
|
-
const lines = entries.map(([name, spec]) => `${ICONS[name] ?? "▸"} ${name} — ${spec.description}`);
|
|
974
|
-
const choice = await ui.select(label, lines);
|
|
975
|
-
if (choice != null && typeof choice === "number") {
|
|
976
|
-
const name = entries[choice]?.[0];
|
|
977
|
-
if (name) await subcommands[name]!.run([name]);
|
|
978
|
-
}
|
|
979
|
-
};
|
|
980
|
-
|
|
981
|
-
const openMenu = async () => {
|
|
982
|
-
if (ctx.mode !== "tui") return picker("soly (esc to cancel):");
|
|
983
|
-
const s = getState();
|
|
984
|
-
await openListPanel(ctx, {
|
|
985
|
-
title: "soly · state",
|
|
986
|
-
headerRight: `${s.phases.length} phases · ${s.progress.percent}% · /sly /s`,
|
|
987
|
-
build: solyGroups,
|
|
988
|
-
onSelect: (it) => {
|
|
989
|
-
void subcommands[it.id]?.run([it.id]);
|
|
990
|
-
},
|
|
991
|
-
});
|
|
992
|
-
};
|
|
993
|
-
|
|
994
|
-
const parts = args.trim().split(/\s+/).filter(Boolean);
|
|
995
|
-
const sub = parts[0] ?? "";
|
|
996
|
-
|
|
997
|
-
// /soly (or help) with no specific subcommand → the modal (TUI) / picker.
|
|
998
|
-
if (!sub || sub === "help" || sub === "?" || sub === "--help" || sub === "-h") {
|
|
999
|
-
return openMenu();
|
|
1000
|
-
}
|
|
1001
|
-
|
|
1002
|
-
if (!subcommands[sub]) {
|
|
1003
|
-
ui.notify(`soly: unknown subcommand '${sub}'`, "error");
|
|
1004
|
-
return openMenu();
|
|
1005
|
-
}
|
|
1006
|
-
|
|
1007
|
-
await subcommands[sub].run(parts);
|
|
1008
|
-
};
|
|
1009
|
-
pi.registerCommand("soly", {
|
|
1010
|
-
description:
|
|
1011
|
-
"soly: project state inspection (position, plan, state, phases, etc.) — type 'help' for subcommand picker",
|
|
1012
|
-
handler: solyBody,
|
|
1013
|
-
});
|
|
1014
|
-
// Short aliases — same body, three names. /sly is the typing-friendly form;
|
|
1015
|
-
// /s is the speed-freak form.
|
|
1016
|
-
pi.registerCommand("sly", {
|
|
1017
|
-
description: "alias for /soly",
|
|
1018
|
-
handler: solyBody,
|
|
1019
|
-
});
|
|
1020
|
-
pi.registerCommand("s", {
|
|
1021
|
-
description: "alias for /soly",
|
|
1022
|
-
handler: solyBody,
|
|
1023
|
-
});
|
|
1024
|
-
// ============================================================================
|
|
1025
|
-
// /artifacts — browse this session's html_artifact gallery
|
|
1026
|
-
// ============================================================================
|
|
1027
|
-
|
|
1028
|
-
pi.registerCommand("artifacts", {
|
|
1029
|
-
description: "browse this project's html_artifact gallery (list, open, clear)",
|
|
1030
|
-
handler: async (args, ctx) => {
|
|
1031
|
-
if (!getConfig().artifacts.server) {
|
|
1032
|
-
ctx.ui.notify("soly: artifact server is disabled (artifacts.server=false)", "info");
|
|
1033
|
-
return;
|
|
1034
|
-
}
|
|
1035
|
-
// Always re-resolve: reuse another window's server or start ours, and
|
|
1036
|
-
// re-probe a stale client whose owner may have died in the meantime.
|
|
1037
|
-
let server = getArtifactServer();
|
|
1038
|
-
try {
|
|
1039
|
-
server = await ensureArtifactServer(artifactDir(getConfig().artifacts.dir, ctx.cwd), ctx.cwd);
|
|
1040
|
-
} catch {
|
|
1041
|
-
// ignore — handled by the count check below
|
|
1042
|
-
}
|
|
1043
|
-
if (!server || server.count === 0) {
|
|
1044
|
-
ctx.ui.notify("soly: no artifacts for this project yet (use the html_artifact tool)", "info");
|
|
1045
|
-
return;
|
|
1046
|
-
}
|
|
1047
|
-
const gallery = server.galleryUrl();
|
|
1048
|
-
const sub = args.trim().split(/\s+/).filter(Boolean)[0] ?? "";
|
|
1049
|
-
|
|
1050
|
-
if (sub === "clear") {
|
|
1051
|
-
const n = server.clear();
|
|
1052
|
-
ctx.ui.notify(`soly: cleared ${n} artifact(s)`, "info");
|
|
1053
|
-
return;
|
|
1054
|
-
}
|
|
1055
|
-
if (sub === "open" || sub === "gallery") {
|
|
1056
|
-
try {
|
|
1057
|
-
await openExternally(pi, gallery);
|
|
1058
|
-
ctx.ui.notify(`soly: opened ${gallery}`, "info");
|
|
1059
|
-
} catch {
|
|
1060
|
-
ctx.ui.notify(`soly artifacts gallery: ${gallery}`, "info");
|
|
1061
|
-
}
|
|
1062
|
-
return;
|
|
1063
|
-
}
|
|
1064
|
-
|
|
1065
|
-
if (ctx.mode === "tui") {
|
|
1066
|
-
await openListPanel(ctx, {
|
|
1067
|
-
title: "soly · artifacts",
|
|
1068
|
-
// BMP-only header (no long URL — it overflowed the bar). Count only.
|
|
1069
|
-
headerRight: `${server.count} artifact${server.count === 1 ? "" : "s"}`,
|
|
1070
|
-
build: () => [
|
|
1071
|
-
{
|
|
1072
|
-
id: "artifacts",
|
|
1073
|
-
title: "Artifacts",
|
|
1074
|
-
icon: "▦",
|
|
1075
|
-
items: (getArtifactServer()?.list() ?? []).map((a) => ({
|
|
1076
|
-
id: a.id,
|
|
1077
|
-
marker: "▦", // BMP glyph — an astral emoji (🖼) breaks ListPanel width
|
|
1078
|
-
label: a.title,
|
|
1079
|
-
meta: new Date(a.createdAt).toLocaleTimeString(),
|
|
1080
|
-
body: a.url,
|
|
1081
|
-
})),
|
|
1082
|
-
},
|
|
1083
|
-
],
|
|
1084
|
-
onSelect: (it) => {
|
|
1085
|
-
const a = getArtifactServer()?.list().find((x) => x.id === it.id);
|
|
1086
|
-
if (a) void openExternally(pi, a.url);
|
|
1087
|
-
},
|
|
1088
|
-
actions: [
|
|
1089
|
-
{ key: "g", hint: "gallery", run: () => void openExternally(pi, gallery) },
|
|
1090
|
-
{ key: "x", hint: "delete", run: (it) => { getArtifactServer()?.remove(it.id); } },
|
|
1091
|
-
],
|
|
1092
|
-
});
|
|
1093
|
-
return;
|
|
1094
|
-
}
|
|
1095
|
-
|
|
1096
|
-
// Non-TUI: print the list + gallery URL.
|
|
1097
|
-
const lines = server.list().map((a) => `🖼 ${a.title} — ${a.url}`);
|
|
1098
|
-
ctx.ui.notify([`soly artifacts gallery: ${gallery}`, "", ...lines].join("\n"), "info");
|
|
1099
|
-
},
|
|
1100
|
-
});
|
|
1101
|
-
|
|
1102
|
-
// ============================================================================
|
|
1103
|
-
// /rulewizard
|
|
1104
|
-
// ============================================================================
|
|
1105
|
-
|
|
1106
|
-
pi.registerCommand("rulewizard", {
|
|
1107
|
-
description:
|
|
1108
|
-
"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.",
|
|
1109
|
-
handler: async (_args, ctx) => {
|
|
1110
|
-
ctx.ui.notify(
|
|
1111
|
-
[
|
|
1112
|
-
"soly-rule-wizard:",
|
|
1113
|
-
"",
|
|
1114
|
-
"tell me what behavior or outcome you want to constrain. I'll help you",
|
|
1115
|
-
"decide whether it should be:",
|
|
1116
|
-
" • a soly rule (.agents/rules/*.md) — for process, behavior, or project",
|
|
1117
|
-
" conventions the LLM must follow",
|
|
1118
|
-
" • an .editorconfig entry — for formatting (indent, line endings, EOL,",
|
|
1119
|
-
" charset, trailing whitespace, max line length)",
|
|
1120
|
-
" • a linter config (eslint / biome / prettier) — for code style that",
|
|
1121
|
-
" a tool can check automatically",
|
|
1122
|
-
" • or nothing — if an existing tool already covers it",
|
|
1123
|
-
"",
|
|
1124
|
-
"decide first:",
|
|
1125
|
-
" 1. is it about LLM behavior / process / project conventions? → soly rule",
|
|
1126
|
-
" 2. is it about whitespace, indent, line endings? → .editorconfig",
|
|
1127
|
-
" 3. is it about code style a linter can check? → eslint/biome",
|
|
1128
|
-
" 4. is it already covered by an existing tool? → don't duplicate",
|
|
1129
|
-
"",
|
|
1130
|
-
"useful commands first:",
|
|
1131
|
-
" /rules — see existing rules (so we don't duplicate)",
|
|
1132
|
-
" /rules analytics — see file sizes, missing descriptions, duplicates",
|
|
1133
|
-
"",
|
|
1134
|
-
"when you've decided:",
|
|
1135
|
-
" /rules new — scaffold a new rule from the soly template",
|
|
1136
|
-
].join("\n"),
|
|
1137
|
-
"info",
|
|
1138
|
-
);
|
|
1139
|
-
},
|
|
1140
|
-
});
|
|
1141
|
-
|
|
1142
|
-
// ============================================================================
|
|
1143
|
-
// /why — show what context the LLM was working from
|
|
1144
|
-
// ============================================================================
|
|
1145
|
-
|
|
1146
|
-
pi.registerCommand("why", {
|
|
1147
|
-
description:
|
|
1148
|
-
"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.",
|
|
1149
|
-
handler: async (args, ctx) => {
|
|
1150
|
-
const state = getState();
|
|
1151
|
-
const rules = getRules();
|
|
1152
|
-
const branch = ctx.sessionManager.getBranch();
|
|
1153
|
-
const lastTurnEntries = branch.slice(-6);
|
|
1154
|
-
|
|
1155
|
-
const lines: string[] = [];
|
|
1156
|
-
lines.push("=== /why — basis for the most recent turn ===");
|
|
1157
|
-
lines.push("");
|
|
1158
|
-
|
|
1159
|
-
// State
|
|
1160
|
-
if (state.exists) {
|
|
1161
|
-
lines.push("**Project state (injected):**");
|
|
1162
|
-
lines.push(` milestone: ${state.milestone}${state.milestoneName ? ` — ${state.milestoneName}` : ""}`);
|
|
1163
|
-
if (state.position) {
|
|
1164
|
-
lines.push(` position: ${state.position.phase} / ${state.position.plan} (${state.position.status})`);
|
|
1165
|
-
}
|
|
1166
|
-
lines.push(` progress: ${state.progress.completedPhases}/${state.progress.totalPhases} phases, ${state.progress.completedPlans}/${state.progress.totalPlans} plans (${state.progress.percent}%)`);
|
|
1167
|
-
lines.push("");
|
|
1168
|
-
}
|
|
1169
|
-
|
|
1170
|
-
// Rules
|
|
1171
|
-
if (rules.length > 0) {
|
|
1172
|
-
lines.push(`**Rules loaded (${rules.length} of which ${rules.filter((r) => r.enabled).length} enabled):**`);
|
|
1173
|
-
const bySource = rules.reduce<Record<string, number>>((acc, r) => {
|
|
1174
|
-
acc[r.sourceLabel] = (acc[r.sourceLabel] ?? 0) + 1;
|
|
1175
|
-
return acc;
|
|
1176
|
-
}, {});
|
|
1177
|
-
lines.push(
|
|
1178
|
-
` by source: ${Object.entries(bySource)
|
|
1179
|
-
.map(([k, v]) => `${v} ${k}`)
|
|
1180
|
-
.join(", ")}`,
|
|
1181
|
-
);
|
|
1182
|
-
const phaseRuleCount = rules.filter((r) => r.phaseNumber != null).length;
|
|
1183
|
-
if (phaseRuleCount > 0) {
|
|
1184
|
-
lines.push(` phase-scoped: ${phaseRuleCount}`);
|
|
1185
|
-
}
|
|
1186
|
-
lines.push("");
|
|
1187
|
-
}
|
|
1188
|
-
|
|
1189
|
-
// NEW (I8): list the actual loaded rule files with paths + descriptions
|
|
1190
|
-
if (rules.length > 0) {
|
|
1191
|
-
lines.push("**Loaded rule files (the LLM was reading these):**");
|
|
1192
|
-
const enabled = rules.filter((rr) => rr.enabled);
|
|
1193
|
-
for (const r of enabled.slice(0, 30)) {
|
|
1194
|
-
const desc = r.meta.description ? ` — ${r.meta.description}` : "";
|
|
1195
|
-
const interactive = r.interactiveOnly ? " [interactive-only]" : "";
|
|
1196
|
-
lines.push(` - \`${r.sourceLabel}/${r.relPath}\`${desc}${interactive}`);
|
|
1197
|
-
}
|
|
1198
|
-
if (enabled.length > 30) {
|
|
1199
|
-
lines.push(` - ... and ${enabled.length - 30} more`);
|
|
1200
|
-
}
|
|
1201
|
-
lines.push("");
|
|
1202
|
-
}
|
|
1203
|
-
|
|
1204
|
-
// Last few turns
|
|
1205
|
-
if (lastTurnEntries.length > 0) {
|
|
1206
|
-
lines.push("**Last few branch entries (what happened):**");
|
|
1207
|
-
for (const entry of lastTurnEntries) {
|
|
1208
|
-
if (entry.type === "message" && entry.message) {
|
|
1209
|
-
const role = entry.message.role;
|
|
1210
|
-
let text = "";
|
|
1211
|
-
if ("content" in entry.message) {
|
|
1212
|
-
const content = entry.message.content;
|
|
1213
|
-
if (typeof content === "string") text = content;
|
|
1214
|
-
else if (Array.isArray(content)) {
|
|
1215
|
-
text = content
|
|
1216
|
-
.filter((b: any) => b && b.type === "text")
|
|
1217
|
-
.map((b: any) => b.text)
|
|
1218
|
-
.join("\n");
|
|
1219
|
-
}
|
|
1220
|
-
}
|
|
1221
|
-
const summary = text.split(/\r?\n/)[0]?.slice(0, 120) ?? "";
|
|
1222
|
-
lines.push(` [${role}] ${summary}${text.length > 120 ? "…" : ""}`);
|
|
1223
|
-
}
|
|
1224
|
-
}
|
|
1225
|
-
lines.push("");
|
|
1226
|
-
}
|
|
1227
|
-
|
|
1228
|
-
lines.push(
|
|
1229
|
-
"The LLM's most recent turn was grounded in the rules and state shown above. " +
|
|
1230
|
-
"If a behavior surprises you, look here first for the basis.",
|
|
1231
|
-
);
|
|
1232
|
-
|
|
1233
|
-
ctx.ui.notify(lines.join("\n"), "info");
|
|
1234
|
-
|
|
1235
|
-
// Suppress unused arg
|
|
1236
|
-
void args;
|
|
1237
|
-
},
|
|
1238
|
-
});
|
|
38
|
+
registerRulesCommand(pi, deps);
|
|
39
|
+
registerDocsCommand(pi, deps);
|
|
40
|
+
registerSolyCommand(pi, deps); // also registers /sly and /s
|
|
41
|
+
registerArtifactsCommand(pi, deps);
|
|
42
|
+
registerRulewizardCommand(pi);
|
|
43
|
+
registerWhyCommand(pi, deps);
|
|
1239
44
|
}
|