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/commands.ts CHANGED
@@ -1,1239 +1,44 @@
1
1
  // =============================================================================
2
- // commands.ts — Slash commands for the soly extension
2
+ // commands.ts — Slash command registry (thin orchestrator)
3
3
  // =============================================================================
4
4
  //
5
- // Registers slash commands (via pi.registerCommand):
6
- // - /rules manage soly rules (list/show/stats/analytics/reload/enable/disable/add/new)
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
- // All commands take their live state via CommandsDeps (rules, state, etc.)
17
- // and a ui object for the handlers to call into.
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 * as path from "node:path";
21
- import * as fs from "node:fs";
22
- import { platform } from "node:os";
23
- import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
24
- import {
25
- analyzeRules,
26
- buildProgressBar,
27
- buildRulesContextStats,
28
- CONTEXT_WINDOW_TOKENS,
29
- extractFilePathsFromPrompt,
30
- formatAnalyticsFull,
31
- formatRulesContextStats,
32
- formatTok,
33
- readIfExists,
34
- solyDirFor,
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
- const {
141
- getRules,
142
- getOverridden,
143
- refreshRules,
144
- getState,
145
- refreshState,
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
  }