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.
@@ -0,0 +1,560 @@
1
+ // =============================================================================
2
+ // commands/soly.ts — /soly + /sly + /s commands
3
+ // =============================================================================
4
+ //
5
+ // The big one. State inspection picker with three groups (Status /
6
+ // Inspect / Manage). The body has the subcommand table (each entry
7
+ // dispatches to a buildXxxTransform or showFile) + the picker UI.
8
+ //
9
+ // Aliases /sly and /s share the same body. Keep them all here because
10
+ // the subcommand table IS the picker — splitting them would create
11
+ // artificial seams.
12
+ // =============================================================================
13
+
14
+ import * as path from "node:path";
15
+ import { readIfExists } from "../core.ts";
16
+ import { ListPanel, type ListItem, type ListGroup } from "../visual/list-panel.ts";
17
+ import { openSettingsUI } from "../workflows/settings-ui.ts";
18
+ import { buildExecuteTransform } from "../workflows/execute.ts";
19
+ import { buildNewTransform } from "../workflows/new.ts";
20
+ import { buildDoneTransform } from "../workflows/done.ts";
21
+ import { buildMigrateTransform } from "../workflows/migrate.ts";
22
+ import { buildPlanTransform, buildDiscussTransform } from "../workflows/planning.ts";
23
+ import { openListPanel, type CommandUI, type CommandsDeps } from "./_helpers.ts";
24
+ import { initSolyProject } from "../init.js";
25
+ import { parseSolyCommand, type SolyCommand, type WorkflowVerb } from "../workflows/parser.ts";
26
+ import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
27
+
28
+ type SolyDeps = Pick<CommandsDeps, "getState" | "getConfig" | "reloadConfig" | "updateStatus" | "refreshState">;
29
+
30
+ export function registerSolyCommand(pi: ExtensionAPI, deps: SolyDeps): void {
31
+ const { getState, getConfig, reloadConfig, updateStatus, refreshState } = deps;
32
+
33
+ const solyBody = async (args: string, ctx: ExtensionCommandContext): Promise<void> => {
34
+ const ui: CommandUI = {
35
+ notify: (t, k) => ctx.ui.notify(t, k ?? "info"),
36
+ select: async (label, options) => {
37
+ const result = await ctx.ui.select(label, options);
38
+ return result === undefined ? null : options.indexOf(result);
39
+ },
40
+ confirm: (title, message) => ctx.ui.confirm(title, message),
41
+ };
42
+ // `init` is special — it scaffolds a NEW project, so it must run even
43
+ // when there's no `.agents/` dir yet. Dispatch it before the project
44
+ // guard below. (Moved here from the old standalone `/soly-init`.)
45
+ {
46
+ const firstArg = args.trim().split(/\s+/).filter(Boolean)[0] ?? "";
47
+ if (firstArg === "init") {
48
+ const template = (args.match(/--template[= ](\S+)/)?.[1] as
49
+ | "minimal" | "web-app" | "library" | "cli" | undefined) ?? undefined;
50
+ const autoYes = args.includes("--yes");
51
+ const projectName = args.match(/--name[= ](\S+)/)?.[1];
52
+ const initUi = {
53
+ notify: (t: string, k?: "info" | "warning" | "error") => ctx.ui.notify(t, k ?? "info"),
54
+ select: async (label: string, options: string[]) => ctx.ui.select(label, options),
55
+ confirm: (title: string, message: string) => ctx.ui.confirm(title, message),
56
+ input: (label: string, placeholder?: string) => ctx.ui.input(label, placeholder),
57
+ };
58
+ await initSolyProject(ctx.cwd, initUi, { template, autoYes, projectName });
59
+ return;
60
+ }
61
+ }
62
+
63
+ const state = getState();
64
+ if (!state.exists) {
65
+
66
+ return;
67
+ }
68
+
69
+ const showFile = (label: string, content: string | null) => {
70
+ if (!content) {
71
+ ui.notify(`${label}: not found`, "error");
72
+ return;
73
+ }
74
+ const MAX = 4000;
75
+ const truncated =
76
+ content.length > MAX
77
+ ? `${content.slice(0, MAX)}\n\n[...truncated, file is ${content.length} chars]`
78
+ : content;
79
+
80
+ };
81
+
82
+ /** Dispatch a workflow verb from the `/soly` slash-command picker.
83
+ * Same handlers used by the `soly <verb> ...` text-command path —
84
+ * registered here so humans can also drive them via
85
+ * `/soly <verb> ...` without having to type natural language and
86
+ * wait for the LLM to translate. */
87
+ const runWorkflow = async (
88
+ verb: WorkflowVerb,
89
+ parts: string[],
90
+ ctx: ExtensionCommandContext,
91
+ ui: CommandUI,
92
+ ): Promise<void> => {
93
+ // parts[0] is the verb name; the args follow.
94
+ const args = parts.slice(1);
95
+ const cmd: SolyCommand = {
96
+ verb,
97
+ args,
98
+ raw: `soly ${verb}${args.length ? " " + args.join(" ") : ""}`,
99
+ };
100
+ const state = getState();
101
+ if (!state.exists) {
102
+
103
+ return;
104
+ }
105
+ switch (verb) {
106
+ case "new": {
107
+ const r = buildNewTransform(cmd, state, ui, ctx.cwd, getConfig().plan.defaultBranchPrefix);
108
+ if (r.handled && r.transformedText) ui.notify(r.transformedText, "info");
109
+ return;
110
+ }
111
+ case "done": {
112
+ const r = buildDoneTransform(cmd, state, ui, ctx.cwd, { defaultBranchPrefix: getConfig().plan.defaultBranchPrefix });
113
+ if (r.handled && r.transformedText) ui.notify(r.transformedText, "info");
114
+ return;
115
+ }
116
+ case "migrate": {
117
+ const r = buildMigrateTransform(state, ui, ctx.cwd);
118
+ if (r.handled && r.transformedText) ui.notify(r.transformedText, "info");
119
+ return;
120
+ }
121
+ case "plan": {
122
+ const r = buildPlanTransform(cmd, state);
123
+ if (r.handled && r.transformedText) ui.notify(r.transformedText, "info");
124
+ return;
125
+ }
126
+ case "discuss": {
127
+ // Slash-command path doesn't have live ask_pro detection.
128
+ // Pass `false` so the discuss transform falls back to its
129
+ // plain-text discussion format; the LLM-driven path still
130
+ // gets the richer ask_pro flow.
131
+ const r = buildDiscussTransform(cmd, state, { hasAskPro: false });
132
+ if (r.handled && r.transformedText) ui.notify(r.transformedText, "info");
133
+ return;
134
+ }
135
+ case "execute": {
136
+ // Slash-command path doesn't have live interactive rules.
137
+ const r = buildExecuteTransform(cmd, state, []);
138
+ if (r.handled && r.transformedText) ui.notify(r.transformedText, "info");
139
+ return;
140
+ }
141
+ default:
142
+
143
+ return;
144
+ }
145
+ };
146
+
147
+ const subcommands = {
148
+ // `agent` subcommand REMOVED — moved to the separate `pi-switch`
149
+ // extension (rotor switcher removed in 1.4.0).
150
+ // Soly no longer owns a rotor switcher.
151
+ config: {
152
+ description: "show merged config (per-project + global + defaults); edit .agents/soly.json or ~/.agents/soly.json",
153
+ run: () => {
154
+ const cfg = getConfig();
155
+ const out: string[] = [];
156
+ out.push("=== soly config (merged) ===");
157
+ out.push("");
158
+ out.push("```json");
159
+ out.push(JSON.stringify(cfg, null, 2));
160
+ out.push("```");
161
+ out.push("");
162
+ out.push("Sources:");
163
+ out.push(` global: ~/.agents/soly.json`);
164
+ out.push(` project: <cwd>/.agents/soly.json`);
165
+ out.push("");
166
+ out.push("To edit:");
167
+ out.push(` - project: edit \`${state.solyDir}/soly.json\` directly`);
168
+ out.push(` - global: edit \`~/.agents/soly.json\``);
169
+ out.push("After editing, run /soly reload to re-pick up changes.");
170
+
171
+ },
172
+ },
173
+ position: {
174
+ description: "one-screen position summary (default)",
175
+ run: () => {
176
+ const s = getState();
177
+ if (s.position) {
178
+ // Brief status; full inspector lives in `/soly inspect`.
179
+ ui.notify(
180
+ `pos: ${s.position.phase} · ${s.position.plan} (${s.position.status}) · ${s.progress.percent}%`,
181
+ "info",
182
+ );
183
+ } else {
184
+ ui.notify(`${s.milestone} — no position set`, "info");
185
+ }
186
+ },
187
+ },
188
+ state: {
189
+ description: "full STATE.md body",
190
+ run: () => showFile("STATE.md", getState().stateBody),
191
+ },
192
+ plan: {
193
+ description: "current PLAN.md body, OR `plan <slug>` to flesh out a plan",
194
+ run: (parts: string[]) => {
195
+ // Dual-purpose: no args = show current PLAN.md; with args
196
+ // = dispatch to the plan-mode workflow. Same key the
197
+ // state picker already had (state picker items and
198
+ // workflow verbs share the same `/soly` namespace).
199
+ if (parts.length <= 1) {
200
+ const s = getState();
201
+ if (!s.currentPlanPath) {
202
+ ui.notify("soly: no current plan", "error");
203
+ return;
204
+ }
205
+ showFile(
206
+ `PLAN: ${path.basename(s.currentPlanPath)}`,
207
+ readIfExists(s.currentPlanPath),
208
+ );
209
+ return;
210
+ }
211
+ void runWorkflow("plan", parts, ctx, ui);
212
+ },
213
+ },
214
+ context: {
215
+ description: "current CONTEXT.md body",
216
+ run: () => {
217
+ const s = getState();
218
+ if (!s.currentPhase) {
219
+ ui.notify("soly: no current phase", "error");
220
+ return;
221
+ }
222
+ const p = path.join(s.currentPhase.dir, `${s.currentPhase.slug}-CONTEXT.md`);
223
+ showFile("CONTEXT.md", readIfExists(p));
224
+ },
225
+ },
226
+ research: {
227
+ description: "current RESEARCH.md body",
228
+ run: () => {
229
+ const s = getState();
230
+ if (!s.currentPhase) {
231
+ ui.notify("soly: no current phase", "error");
232
+ return;
233
+ }
234
+ const p = path.join(s.currentPhase.dir, `${s.currentPhase.slug}-RESEARCH.md`);
235
+ showFile("RESEARCH.md", readIfExists(p));
236
+ },
237
+ },
238
+ roadmap: {
239
+ description: "ROADMAP.md body",
240
+ run: () => showFile("ROADMAP.md", getState().roadmapBody),
241
+ },
242
+ progress: {
243
+ description: "progress bar + counts",
244
+ run: () => {
245
+ const s = getState();
246
+ const line = `${s.progress.percent}% · ${s.progress.completedPhases}/${s.progress.totalPhases} phases · ${s.progress.completedPlans}/${s.progress.totalPlans} plans`;
247
+ ui.notify(line, "info");
248
+ },
249
+ },
250
+ phases: {
251
+ description: "list all phases with plan counts and C/R markers",
252
+ run: () => {
253
+ const phases = getState().phases;
254
+ if (phases.length === 0) {
255
+ ui.notify("no phases", "info");
256
+ return;
257
+ }
258
+ const current = getState().currentPhase?.number;
259
+ const lines = phases.map((p) => {
260
+ const marker = current === p.number ? "→" : " ";
261
+ const cr = (p.contextExists ? "C" : "·") + (p.researchExists ? "R" : "·");
262
+ return `${marker} ${String(p.number).padStart(2, "0")}. ${p.name} [${cr}] plans=${p.planCount}`;
263
+ });
264
+ ui.notify(lines.join("\n"), "info");
265
+ },
266
+ },
267
+ tasks: {
268
+ description: "list all tasks grouped by feature (mirrors soly_list_tasks tool)",
269
+ run: () => {
270
+ const s = getState();
271
+ if (s.tasks.length === 0) {
272
+ ui.notify("no tasks", "info");
273
+ return;
274
+ }
275
+ const byFeature = new Map<string, typeof s.tasks>();
276
+ for (const t of s.tasks) {
277
+ const list = byFeature.get(t.feature) ?? [];
278
+ list.push(t);
279
+ byFeature.set(t.feature, list);
280
+ }
281
+ const out: string[] = [`tasks (${s.tasks.length} total):`, ""];
282
+ for (const [feature, list] of [...byFeature.entries()].sort()) {
283
+ out.push(`[${feature}] ${list.length} task(s)`);
284
+ for (const t of list) {
285
+ const deps = t.dependsOn.length > 0 ? ` deps=[${t.dependsOn.join(",")}]` : "";
286
+ const par = t.parallelizable ? " ⚡" : "";
287
+ out.push(` ${t.id} [${t.kind}] status=${t.status} prio=${t.priority}${par}${deps}`);
288
+ }
289
+ out.push("");
290
+ }
291
+ ui.notify(out.join("\n"), "info");
292
+ },
293
+ },
294
+ task: {
295
+ description: "show one task's PLAN.md + SUMMARY.md if present (usage: /soly task <id>)",
296
+ run: (parts: string[]) => {
297
+ const id = (parts[1] ?? "").trim();
298
+ if (!id) {
299
+ ui.notify("Usage: /soly task <task-id>", "error");
300
+ return;
301
+ }
302
+ const s = getState();
303
+ const task = s.tasks.find((t) => t.id === id);
304
+ if (!task) {
305
+ ui.notify(
306
+ `soly: task ${id} not found.\nKnown: ${s.tasks.map((t) => t.id).join(", ") || "(none)"}`,
307
+ "error",
308
+ );
309
+ return;
310
+ }
311
+ const planPath = path.join(task.dir, "PLAN.md");
312
+ const summaryPath = path.join(task.dir, "SUMMARY.md");
313
+ const planBody = readIfExists(planPath);
314
+ const summaryBody = readIfExists(summaryPath);
315
+ const header = `task ${task.id} [${task.feature}/${task.kind}] status=${task.status} prio=${task.priority}`;
316
+ const deps = task.dependsOn.length > 0 ? `\ndepends-on: [${task.dependsOn.join(", ")}]` : "";
317
+ const planLabel = planBody ? `PLAN.md (${planBody.length} chars)` : `PLAN.md (missing)`;
318
+ showFile(`${header}${deps}\n${planLabel}`, planBody ?? "(no PLAN.md)");
319
+ if (summaryBody) {
320
+ showFile("SUMMARY.md", summaryBody);
321
+ } else {
322
+ ui.notify("no SUMMARY.md yet", "info");
323
+ }
324
+ },
325
+ },
326
+ features: {
327
+ description: "list all features with task counts and README presence",
328
+ run: () => {
329
+ const features = getState().features;
330
+ if (features.length === 0) {
331
+ ui.notify("no features", "info");
332
+ return;
333
+ }
334
+ const lines = features.map((f) => {
335
+ const rm = f.readmeExists ? "R" : "·";
336
+ return ` ${f.name.padEnd(28)} tasks=${f.taskCount} [${rm}]`;
337
+ });
338
+ ui.notify(lines.join("\n"), "info");
339
+ },
340
+ },
341
+ milestone: {
342
+ description: "show the active milestone document (.agents/milestones/<v>.md)",
343
+ run: () => {
344
+ const s = getState();
345
+ if (!s.milestone || s.milestone === "—") {
346
+ ui.notify("soly: no milestone set", "info");
347
+ return;
348
+ }
349
+ const candidates = [
350
+ path.join(s.solyDir, "milestones", `${s.milestone}.md`),
351
+ path.join(s.solyDir, "MILESTONES.md"),
352
+ ];
353
+ for (const c of candidates) {
354
+ const body = readIfExists(c);
355
+ if (body) {
356
+ showFile(`MILESTONE ${s.milestone} (${path.relative(process.cwd(), c)})`, body);
357
+ return;
358
+ }
359
+ }
360
+ ui.notify(
361
+ `soly: no milestone file found. tried:\n ${candidates.map((c) => path.relative(process.cwd(), c)).join("\n ")}`,
362
+ "error",
363
+ );
364
+ },
365
+ },
366
+ reload: {
367
+ description: "re-read project state from disk",
368
+ run: () => {
369
+ refreshState();
370
+ updateStatus(ui);
371
+ const s = getState();
372
+ ui.notify(`re-read state: ${s.phases.length} phases · ${s.tasks.length} tasks`, "info");
373
+ },
374
+ },
375
+ // ------------------------------------------------------------------
376
+ // Workflow verbs — same handlers used by the `soly <verb> ...`
377
+ // text-command path. Registered here so humans can also drive
378
+ // them via `/soly <verb> ...` from the slash picker without
379
+ // typing natural language and waiting for the LLM to translate.
380
+ // Each verb's buildXxxTransform already calls ui.notify on
381
+ // success, so we just need to construct a fake `SolyCommand`
382
+ // from the slash args and call the handler.
383
+ // ------------------------------------------------------------------
384
+ new: {
385
+ description: "scaffold a new plan: create branch + .agents/plans/<slug>/PLAN.md (e.g. `/soly new statistic-preparation`)",
386
+ run: (parts: string[]) => runWorkflow("new", parts, ctx, ui),
387
+ },
388
+ execute: {
389
+ description: "execute a plan via subagent (e.g. `/soly execute statistic-preparation`)",
390
+ run: (parts: string[]) => runWorkflow("execute", parts, ctx, ui),
391
+ },
392
+ discuss: {
393
+ description: "interactive discussion of a plan (e.g. `/soly discuss statistic-preparation`)",
394
+ run: (parts: string[]) => runWorkflow("discuss", parts, ctx, ui),
395
+ },
396
+ done: {
397
+ description: "commit + push + open draft PR for a plan branch (e.g. `/soly done statistic-preparation`)",
398
+ run: (parts: string[]) => runWorkflow("done", parts, ctx, ui),
399
+ },
400
+ migrate: {
401
+ description: "one-shot: import .agents/phases/<NN>-<slug>/plans/PLAN.md as plan branches",
402
+ run: () => runWorkflow("migrate", [], ctx, ui),
403
+ },
404
+ where: {
405
+ description: "alias for `position` — current position + progress",
406
+ run: (parts: string[]) => {
407
+ const p = subcommands.position;
408
+ if (p) void p.run();
409
+ },
410
+ },
411
+ settings: {
412
+ description: "interactive config editor (toggles, enums, numbers)",
413
+ run: () => {
414
+ void openSettingsUI({
415
+ ctx,
416
+ ui,
417
+ solyDir: state.solyDir,
418
+ getConfig,
419
+ reloadConfig,
420
+ });
421
+ },
422
+ },
423
+ } as const;
424
+
425
+ // Single-width BMP glyphs only — astral/VS16 emoji are mis-measured by
426
+ // visibleWidth, overflow the panel, and ghost the modal on ↑↓.
427
+ const ICONS: Record<string, string> = {
428
+ position: "◎", state: "▤", plan: "▥", context: "◌",
429
+ research: "⊙", roadmap: "≣", progress: "▰",
430
+ phases: "▭", tasks: "✓", task: "◍",
431
+ features: "★", milestone: "◈", reload: "↻", config: "▣",
432
+ settings: "⚙", where: "◎",
433
+ };
434
+
435
+ // Short live preview shown in the modal's preview pane per subcommand.
436
+ const previewFor = (name: string): string => {
437
+ const s = getState();
438
+ const teaser = (p: string | null): string => (p ?? "").replace(/\s+/g, " ").slice(0, 240);
439
+ const phaseFile = (suffix: string) =>
440
+ s.currentPhase ? readIfExists(path.join(s.currentPhase.dir, `${s.currentPhase.slug}-${suffix}.md`)) : null;
441
+ switch (name) {
442
+ case "where": return s.position ? `${s.position.phase} · ${s.position.plan} · ${s.position.status} · ${s.progress.percent}%` : `${s.milestone} — no position set`;
443
+ case "position": return s.position ? `${s.position.phase} · ${s.position.plan} · ${s.position.status} · ${s.progress.percent}%` : `${s.milestone} — no position set`;
444
+ case "progress": return `${s.progress.percent}% · ${s.progress.completedPhases}/${s.progress.totalPhases} phases · ${s.progress.completedPlans}/${s.progress.totalPlans} plans`;
445
+ case "phases": return s.phases.length ? s.phases.map((p) => `${p.number}.${p.name}`).join(" · ") : "no phases";
446
+ case "tasks": return s.tasks.length ? `${s.tasks.length} task(s) across ${s.features.length} feature(s)` : "no tasks";
447
+ case "features": return s.features.length ? s.features.map((f) => f.name).join(" · ") : "no features";
448
+ case "milestone": return s.milestone && s.milestone !== "—" ? String(s.milestoneName ?? s.milestone) : "no milestone set";
449
+ case "state": return teaser(s.stateBody) || "STATE.md not found";
450
+ case "roadmap": return teaser(s.roadmapBody) || "ROADMAP.md not found";
451
+ case "plan": return s.currentPlanPath ? teaser(readIfExists(s.currentPlanPath)) : "no current plan";
452
+ case "context": return s.currentPhase ? teaser(phaseFile("CONTEXT")) || "CONTEXT.md not found" : "no current phase";
453
+ case "research": return s.currentPhase ? teaser(phaseFile("RESEARCH")) || "RESEARCH.md not found" : "no current phase";
454
+ case "settings": return "interactive config editor — toggles, enums, numbers";
455
+ case "config": return "merged config JSON (read-only view)";
456
+ case "reload": return "re-read project state from disk";
457
+ default: return subcommands[name as keyof typeof subcommands]?.description ?? "";
458
+ }
459
+ };
460
+
461
+ // Top-to-bottom group order in the /soly modal. Items in
462
+ // `items[]` show in declaration order; subcommands not listed here
463
+ // still work via `/soly <name>` directly but don't appear in the picker.
464
+ const SOLY_GROUP_ORDER = ["status", "inspect", "manage"] as const;
465
+ const SOLY_GROUPS: Record<string, { id: string; title: string; icon: string; items: string[] }> = {
466
+ status: {
467
+ id: "status",
468
+ title: "Status",
469
+ icon: "▰",
470
+ items: ["where", "progress"],
471
+ },
472
+ inspect: {
473
+ id: "inspect",
474
+ title: "Inspect",
475
+ icon: "▤",
476
+ items: ["plan", "state", "roadmap", "context", "phases", "tasks", "milestone"],
477
+ },
478
+ manage: {
479
+ id: "manage",
480
+ title: "Manage",
481
+ icon: "⚙",
482
+ items: ["settings", "reload", "config"],
483
+ },
484
+ };
485
+
486
+ const solyGroups = (): ListGroup[] =>
487
+ SOLY_GROUP_ORDER.map((gid) => {
488
+ const def = SOLY_GROUPS[gid]!;
489
+ const items: ListItem[] = [];
490
+ for (const name of def.items) {
491
+ const spec = (subcommands as Record<string, { description: string; run: (parts: string[]) => unknown }>)[name];
492
+ if (!spec) continue;
493
+ items.push({
494
+ id: name,
495
+ marker: ICONS[name] ?? "▸",
496
+ label: name,
497
+ meta: spec.description,
498
+ body: previewFor(name),
499
+ });
500
+ }
501
+ return { id: def.id, title: def.title, icon: def.icon, items };
502
+ }).filter((g) => g.items.length > 0);
503
+
504
+ // Plain-select fallback for non-TUI (RPC/print) modes. Lists every
505
+ // subcommand in declaration order (not grouped — grouping is a UI
506
+ // nicety that only makes sense in the TUI overlay).
507
+ const picker = async (label: string) => {
508
+ const entries = Object.entries(subcommands);
509
+ const lines = entries.map(([name, spec]) => `${ICONS[name] ?? "▸"} ${name} — ${spec.description}`);
510
+ const choice = await ui.select(label, lines);
511
+ if (choice != null && typeof choice === "number") {
512
+ const name = entries[choice]?.[0];
513
+ if (name) await (subcommands as Record<string, { run: (parts: string[]) => unknown }>)[name]!.run([name]);
514
+ }
515
+ };
516
+
517
+ const openMenu = async () => {
518
+ if (ctx.mode !== "tui") return picker("soly (esc to cancel):");
519
+ const s = getState();
520
+ await openListPanel(ctx, {
521
+ title: "soly · state",
522
+ headerRight: `${s.phases.length} phases · ${s.progress.percent}% · /sly /s`,
523
+ build: solyGroups,
524
+ onSelect: (it) => {
525
+ void (subcommands as Record<string, { run: (parts: string[]) => unknown }>)[it.id]?.run([it.id]);
526
+ },
527
+ });
528
+ };
529
+
530
+ const parts = args.trim().split(/\s+/).filter(Boolean);
531
+ const sub = parts[0] ?? "";
532
+
533
+ // /soly (or help) with no specific subcommand → the modal (TUI) / picker.
534
+ if (!sub || sub === "help" || sub === "?" || sub === "--help" || sub === "-h") {
535
+ return openMenu();
536
+ }
537
+
538
+ if (!(subcommands as Record<string, unknown>)[sub]) {
539
+ ui.notify(`soly: unknown subcommand '${sub}'`, "error");
540
+ return openMenu();
541
+ }
542
+
543
+ await (subcommands as Record<string, { run: (parts: string[]) => Promise<unknown> | unknown }>)[sub]!.run(parts);
544
+ };
545
+
546
+ pi.registerCommand("soly", {
547
+ description: "soly: project state inspection (position, plan, state, phases, etc.) — type 'help' for subcommand picker",
548
+ handler: solyBody,
549
+ });
550
+ // Short aliases — same body, three names. /sly is the typing-friendly form;
551
+ // /s is the speed-freak form.
552
+ pi.registerCommand("sly", {
553
+ description: "alias for /soly",
554
+ handler: solyBody,
555
+ });
556
+ pi.registerCommand("s", {
557
+ description: "alias for /soly",
558
+ handler: solyBody,
559
+ });
560
+ }
@@ -0,0 +1,106 @@
1
+ // =============================================================================
2
+ // commands/why.ts — /why command (show context that grounded the last turn)
3
+ // =============================================================================
4
+
5
+ import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
6
+ import type { CommandsDeps } from "./_helpers.ts";
7
+
8
+ type WhyDeps = Pick<CommandsDeps, "getState" | "getRules">;
9
+
10
+ export function registerWhyCommand(pi: ExtensionAPI, deps: WhyDeps): void {
11
+ const { getState, getRules } = deps;
12
+
13
+ pi.registerCommand("why", {
14
+ description:
15
+ "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.",
16
+ handler: async (args, ctx) => {
17
+ const state = getState();
18
+ const rules = getRules();
19
+ const branch = ctx.sessionManager.getBranch();
20
+ const lastTurnEntries = branch.slice(-6);
21
+
22
+ const lines: string[] = [];
23
+ lines.push("=== /why — basis for the most recent turn ===");
24
+ lines.push("");
25
+
26
+ // State
27
+ if (state.exists) {
28
+ lines.push("**Project state (injected):**");
29
+ lines.push(` milestone: ${state.milestone}${state.milestoneName ? ` — ${state.milestoneName}` : ""}`);
30
+ if (state.position) {
31
+ lines.push(` position: ${state.position.phase} / ${state.position.plan} (${state.position.status})`);
32
+ }
33
+ lines.push(` progress: ${state.progress.completedPhases}/${state.progress.totalPhases} phases, ${state.progress.completedPlans}/${state.progress.totalPlans} plans (${state.progress.percent}%)`);
34
+ lines.push("");
35
+ }
36
+
37
+ // Rules
38
+ if (rules.length > 0) {
39
+ lines.push(`**Rules loaded (${rules.length} of which ${rules.filter((r) => r.enabled).length} enabled):**`);
40
+ const bySource = rules.reduce<Record<string, number>>((acc, r) => {
41
+ acc[r.sourceLabel] = (acc[r.sourceLabel] ?? 0) + 1;
42
+ return acc;
43
+ }, {});
44
+ lines.push(
45
+ ` by source: ${Object.entries(bySource)
46
+ .map(([k, v]) => `${v} ${k}`)
47
+ .join(", ")}`,
48
+ );
49
+ const phaseRuleCount = rules.filter((r) => r.phaseNumber != null).length;
50
+ if (phaseRuleCount > 0) {
51
+ lines.push(` phase-scoped: ${phaseRuleCount}`);
52
+ }
53
+ lines.push("");
54
+ }
55
+
56
+ // List the actual loaded rule files with paths + descriptions
57
+ if (rules.length > 0) {
58
+ lines.push("**Loaded rule files (the LLM was reading these):**");
59
+ const enabled = rules.filter((rr) => rr.enabled);
60
+ for (const r of enabled.slice(0, 30)) {
61
+ const desc = r.meta.description ? ` — ${r.meta.description}` : "";
62
+ const interactive = r.interactiveOnly ? " [interactive-only]" : "";
63
+ lines.push(` - \`${r.sourceLabel}/${r.relPath}\`${desc}${interactive}`);
64
+ }
65
+ if (enabled.length > 30) {
66
+ lines.push(` - ... and ${enabled.length - 30} more`);
67
+ }
68
+ lines.push("");
69
+ }
70
+
71
+ // Last few turns
72
+ if (lastTurnEntries.length > 0) {
73
+ lines.push("**Last few branch entries (what happened):**");
74
+ for (const entry of lastTurnEntries) {
75
+ if (entry.type === "message" && entry.message) {
76
+ const role = entry.message.role;
77
+ let text = "";
78
+ if ("content" in entry.message) {
79
+ const content = entry.message.content;
80
+ if (typeof content === "string") text = content;
81
+ else if (Array.isArray(content)) {
82
+ text = content
83
+ .filter((b: any) => b && b.type === "text")
84
+ .map((b: any) => b.text)
85
+ .join("\n");
86
+ }
87
+ }
88
+ const summary = text.split(/\r?\n/)[0]?.slice(0, 120) ?? "";
89
+ lines.push(` [${role}] ${summary}${text.length > 120 ? "…" : ""}`);
90
+ }
91
+ }
92
+ lines.push("");
93
+ }
94
+
95
+ lines.push(
96
+ "The LLM's most recent turn was grounded in the rules and state shown above. " +
97
+ "If a behavior surprises you, look here first for the basis.",
98
+ );
99
+
100
+ ctx.ui.notify(lines.join("\n"), "info");
101
+
102
+ // Suppress unused arg
103
+ void args;
104
+ },
105
+ });
106
+ }