pi-soly 0.2.1

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