pi-soly 2.2.0 → 2.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -303,7 +303,7 @@ Use it when a visual, rendered result beats terminal text (example galleries, co
303
303
  └──────────────────┘
304
304
  ```
305
305
 
306
- State lives in `.agents/` — portable, git-friendly, human-readable. (Projects from before the rename used `.soly/`; soly no longer reads it — run `mv .soly .agents`.)
306
+ State lives in `.agents/` — portable, git-friendly, human-readable.
307
307
 
308
308
  ```
309
309
  .agents/
@@ -0,0 +1,105 @@
1
+ # Release Discipline Rule
2
+
3
+ > **Every version bump = a CHANGELOG entry.** Soly ships with a release
4
+ > workflow that publishes a tagged version to npm on push. If the
5
+ > workflow fires for a commit that bumped the version but didn't touch
6
+ > `CHANGELOG.md`, the release is shipped blind — users have no way to
7
+ > know what changed. **Always update CHANGELOG.md in the same commit that
8
+ > bumps the version.**
9
+
10
+ ## The rule
11
+
12
+ When `package.json` `version` is incremented (any field under `version`):
13
+
14
+ 1. **Open `CHANGELOG.md` first** (or via `soly_snippet` / `soly_doc_search`).
15
+ 2. **Move the entry out of `[Unreleased]`** into a new section with the
16
+ new version as the header: `## [<new-ver>] — <YYYY-MM-DD>`.
17
+ 3. **Group changes under standard subsections** — pick only what applies:
18
+ - `### Added` — new feature visible to users
19
+ - `### Changed` — behavior change in existing feature
20
+ - `### Deprecated` — feature still works but slated for removal
21
+ - `### Removed` — feature gone
22
+ - `### Fixed` — bug fix
23
+ - `### Security` — security-relevant change
24
+ 4. **Each bullet = one user-visible change** (not "touched X file" —
25
+ that belongs in the commit message, not the changelog).
26
+ 5. **Reference the function / command / file** the change is in
27
+ (e.g., `soly execute`, `goal-verify.ts`, `settings UI`).
28
+ 6. **Semantic Versioning** rules:
29
+ - **PATCH** (x.y.**Z+1**) — bug fix, no API change, no behavior change.
30
+ - **MINOR** (x.**Y+1**.0) — new feature visible to user; existing
31
+ commands behave the same.
32
+ - **MAJOR** (**X+1**.0.0) — breaking change: command renamed,
33
+ output format changed, file moved, default config changed.
34
+ 7. **No empty version blocks** — if the section is empty, don't add the
35
+ header. Leave it in `[Unreleased]`.
36
+
37
+ ## Why this matters
38
+
39
+ - Users `npm update` and read the changelog to decide whether to upgrade.
40
+ - The soly release workflow (`ci.yml` → `publish` job) runs the same
41
+ pipeline as `bun test` and `bun run typecheck`. A clean CHANGELOG
42
+ alongside a version bump is the user's signal that the bump was
43
+ intentional, not a typo.
44
+ - Search engines / GitHub release notes auto-render the `[X.Y.Z]`
45
+ headers — empty or missing entries = "garbage release".
46
+
47
+ ## Examples
48
+
49
+ ### Good
50
+
51
+ ```markdown
52
+ ## [2.2.0] — 2026-07-05
53
+
54
+ ### Added
55
+ - **`/sly` and `/s` aliases** for the `/soly` picker. Same body, three
56
+ command names. The `/soly` modal header shows `· /sly · /s` as a hint.
57
+
58
+ ### Changed
59
+ - **`mcp/panel-keys.ts` → `visual/panel-keys.ts`.** The keybindings
60
+ primitive used by `ListPanel`, `McpPanel`, and `McpSetupPanel` is
61
+ now its own visual-UI module.
62
+ ```
63
+
64
+ ### Bad
65
+
66
+ ```markdown
67
+ ## [2.2.0] — 2026-07-05
68
+
69
+ ### Changed
70
+ - refactored
71
+ ```
72
+
73
+ (Too vague to be useful; the user can't tell what changed or whether
74
+ it matters to them.)
75
+
76
+ ### Worse
77
+
78
+ ```markdown
79
+ ## [2.2.0] — 2026-07-05
80
+ ```
81
+
82
+ (Empty section — the bump is real, the user has no idea why.)
83
+
84
+ ## Workflow
85
+
86
+ When `soly done <plan>` (or any version-bump commit) lands:
87
+
88
+ 1. The `package.json` bump should be in the **same commit** as the
89
+ CHANGELOG edit. If you forgot, the release workflow still passes —
90
+ it doesn't enforce this rule. (We could add a CI check: `git diff
91
+ ${prev-tag}..HEAD -- package.json | grep version && ! git diff
92
+ ${prev-tag}..HEAD -- CHANGELOG.md | head -1` — but for now it's
93
+ convention.)
94
+ 2. The release CI runs `ci.yml` → publishes to npm. It does NOT lint
95
+ CHANGELOG.
96
+ 3. The GitHub release auto-renders from the tag's commit message
97
+ (`release: pi-soly v2.2.0`); the CHANGELOG entry is the canonical
98
+ "what changed" surface.
99
+
100
+ ## Override
101
+
102
+ This rule is built-in to the soly extension and ships with every
103
+ install. If your project ships a `.agents/rules/release-discipline.md`
104
+ that adds (or contradicts) the rules above, the project rule wins per
105
+ the standard soly priority rules (project > global > built-in).
@@ -0,0 +1,87 @@
1
+ // =============================================================================
2
+ // commands/_helpers.ts — shared types + helpers for the per-command modules
3
+ // =============================================================================
4
+ //
5
+ // Re-exports from here:
6
+ // - CommandUI, CommandsDeps — contracts the slash commands need
7
+ // - openListPanel — wraps ListPanel in ctx.ui.custom
8
+ // - openExternally — OS-default handler for URLs/files
9
+ //
10
+ // Each per-command module imports what it needs from here. The thin
11
+ // `commands/index.ts` orchestrator wires the deps object.
12
+ // =============================================================================
13
+
14
+ import * as path from "node:path";
15
+ import { platform } from "node:os";
16
+ import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
17
+ import { ListPanel, type ListItem, type ListAction, type ListGroup } from "../visual/list-panel.ts";
18
+ import type { SolyState, RuleFile } from "../core.ts";
19
+ import type { SolyConfig } from "../config.ts";
20
+ import type { IntentDoc } from "../intent.ts";
21
+
22
+ /** Minimum ui surface the command handlers actually need. */
23
+ export interface CommandUI {
24
+ notify: (text: string, kind?: "info" | "warning" | "error") => void;
25
+ select: (label: string, options: string[]) => Promise<number | null>;
26
+ confirm: (title: string, message: string) => Promise<boolean>;
27
+ input?: (label: string, placeholder?: string) => Promise<string | undefined>;
28
+ }
29
+
30
+ /** All live-state callbacks the slash commands need. The orchestrator
31
+ * (index.ts) injects these from the extension's runtime. Per-command
32
+ * modules take a `Pick<CommandsDeps, …>` of just what they need. */
33
+ export interface CommandsDeps {
34
+ getRules: () => RuleFile[];
35
+ getOverridden: () => string[];
36
+ refreshRules: () => void;
37
+ getState: () => SolyState;
38
+ refreshState: () => void;
39
+ updateStatus: (ui: CommandUI) => void;
40
+ getConfig: () => SolyConfig;
41
+ reloadConfig: () => void;
42
+ getIntentDocs: () => IntentDoc[];
43
+ }
44
+
45
+ /** Open a focused list modal (overlay) for the given grouped items. */
46
+ export async function openListPanel(
47
+ ctx: ExtensionCommandContext,
48
+ spec: {
49
+ title: string;
50
+ headerRight?: string;
51
+ build: () => ListGroup[];
52
+ actions?: ListAction[];
53
+ onSelect?: (item: ListItem) => void;
54
+ },
55
+ ): Promise<void> {
56
+ await ctx.ui.custom<void>(
57
+ (tui, theme, keybindings, done) =>
58
+ new ListPanel({
59
+ tui,
60
+ theme,
61
+ keybindings,
62
+ done: () => done(),
63
+ title: spec.title,
64
+ headerRight: spec.headerRight,
65
+ groups: spec.build(),
66
+ actions: spec.actions,
67
+ refresh: spec.build,
68
+ onSelect: spec.onSelect,
69
+ }),
70
+ { overlay: true },
71
+ );
72
+ }
73
+
74
+ /** Open a URL/file with the OS default handler (browser for http/.html). */
75
+ export async function openExternally(pi: ExtensionAPI, target: string): Promise<void> {
76
+ const o = platform();
77
+ const r =
78
+ o === "darwin"
79
+ ? await pi.exec("open", [target])
80
+ : o === "win32"
81
+ ? await pi.exec("cmd", ["/c", "start", "", target])
82
+ : await pi.exec("xdg-open", [target]);
83
+ if (r.code !== 0) throw new Error(r.stderr || `open failed (exit ${r.code})`);
84
+ }
85
+
86
+ /** Re-export path used by some command bodies. */
87
+ export { path };
@@ -0,0 +1,86 @@
1
+ // =============================================================================
2
+ // commands/artifacts.ts — /artifacts command (browse html_artifact gallery)
3
+ // =============================================================================
4
+
5
+ import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
6
+ import { getArtifactServer, ensureArtifactServer, artifactDir } from "../artifact/session.ts";
7
+ import { openListPanel, openExternally, type CommandsDeps } from "./_helpers.ts";
8
+
9
+ type ArtifactsDeps = Pick<CommandsDeps, "getConfig">;
10
+
11
+ export function registerArtifactsCommand(pi: ExtensionAPI, deps: ArtifactsDeps): void {
12
+ const { getConfig } = deps;
13
+
14
+ pi.registerCommand("artifacts", {
15
+ description: "browse this project's html_artifact gallery (list, open, clear)",
16
+ handler: async (args, ctx) => {
17
+ if (!getConfig().artifacts.server) {
18
+ ctx.ui.notify("soly: artifact server is disabled (artifacts.server=false)", "info");
19
+ return;
20
+ }
21
+ // Always re-resolve: reuse another window's server or start ours, and
22
+ // re-probe a stale client whose owner may have died in the meantime.
23
+ let server = getArtifactServer();
24
+ try {
25
+ server = await ensureArtifactServer(artifactDir(getConfig().artifacts.dir, ctx.cwd), ctx.cwd);
26
+ } catch {
27
+ // ignore — handled by the count check below
28
+ }
29
+ if (!server || server.count === 0) {
30
+ ctx.ui.notify("soly: no artifacts for this project yet (use the html_artifact tool)", "info");
31
+ return;
32
+ }
33
+ const gallery = server.galleryUrl();
34
+ const sub = args.trim().split(/\s+/).filter(Boolean)[0] ?? "";
35
+
36
+ if (sub === "clear") {
37
+ const n = server.clear();
38
+ ctx.ui.notify(`soly: cleared ${n} artifact(s)`, "info");
39
+ return;
40
+ }
41
+ if (sub === "open" || sub === "gallery") {
42
+ try {
43
+ await openExternally(pi, gallery);
44
+ ctx.ui.notify(`soly: opened ${gallery}`, "info");
45
+ } catch {
46
+ ctx.ui.notify(`soly artifacts gallery: ${gallery}`, "info");
47
+ }
48
+ return;
49
+ }
50
+
51
+ if (ctx.mode === "tui") {
52
+ await openListPanel(ctx, {
53
+ title: "soly · artifacts",
54
+ headerRight: `${server.count} artifact${server.count === 1 ? "" : "s"}`,
55
+ build: () => [
56
+ {
57
+ id: "artifacts",
58
+ title: "Artifacts",
59
+ icon: "▦",
60
+ items: (getArtifactServer()?.list() ?? []).map((a) => ({
61
+ id: a.id,
62
+ marker: "▦", // BMP glyph — an astral emoji (🖼) breaks ListPanel width
63
+ label: a.title,
64
+ meta: new Date(a.createdAt).toLocaleTimeString(),
65
+ body: a.url,
66
+ })),
67
+ },
68
+ ],
69
+ onSelect: (it) => {
70
+ const a = getArtifactServer()?.list().find((x) => x.id === it.id);
71
+ if (a) void openExternally(pi, a.url);
72
+ },
73
+ actions: [
74
+ { key: "g", hint: "gallery", run: () => void openExternally(pi, gallery) },
75
+ { key: "x", hint: "delete", run: (it) => { getArtifactServer()?.remove(it.id); } },
76
+ ],
77
+ });
78
+ return;
79
+ }
80
+
81
+ // Non-TUI: print the list + gallery URL.
82
+ const lines = server.list().map((a) => `🖼 ${a.title} — ${a.url}`);
83
+ ctx.ui.notify([`soly artifacts gallery: ${gallery}`, "", ...lines].join("\n"), "info");
84
+ },
85
+ });
86
+ }
@@ -0,0 +1,74 @@
1
+ // =============================================================================
2
+ // commands/docs.ts — /docs command (list, stats)
3
+ // =============================================================================
4
+
5
+ import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
6
+ import { formatTok, solyDirFor } from "../core.ts";
7
+ import { buildIntentStats, formatIntentStats, loadInlineIntentBodies, type IntentInlineDoc } from "../intent.ts";
8
+ import { openListPanel, type CommandUI, type CommandsDeps } from "./_helpers.ts";
9
+
10
+ type DocsDeps = Pick<CommandsDeps, "getIntentDocs">;
11
+
12
+ export function registerDocsCommand(pi: ExtensionAPI, deps: DocsDeps): void {
13
+ const { getIntentDocs } = deps;
14
+
15
+ pi.registerCommand("docs", {
16
+ description: "manage soly intent docs (stats — show context breakdown)",
17
+ handler: async (args, ctx) => {
18
+ const ui: CommandUI = {
19
+ notify: (t, k) => ctx.ui.notify(t, k ?? "info"),
20
+ select: async (label, options) => {
21
+ const result = await ctx.ui.select(label, options);
22
+ return result === undefined ? null : options.indexOf(result);
23
+ },
24
+ confirm: (title, message) => ctx.ui.confirm(title, message),
25
+ };
26
+ const parts = args.trim().split(/\s+/);
27
+ // Bare `/docs` opens the modal; an explicit subcommand (e.g. stats) does not.
28
+ const sub = parts[0] || "list";
29
+
30
+ if (sub === "list") {
31
+ const docs = getIntentDocs();
32
+ if (docs.length === 0) {
33
+ ui.notify("no intent docs found in .agents/docs/ — drop your vision/domain docs there", "info");
34
+ return;
35
+ }
36
+ if (ctx.mode === "tui") {
37
+ const total = docs.reduce((s, d) => s + d.tokens, 0);
38
+ await openListPanel(ctx, {
39
+ title: "soly · docs",
40
+ headerRight: `${docs.length} docs · ${formatTok(total)}`,
41
+ build: () => [
42
+ {
43
+ id: "docs",
44
+ title: "Docs",
45
+ icon: "☖",
46
+ items: getIntentDocs().map((d) => ({
47
+ id: d.relPath,
48
+ marker: "○",
49
+ label: d.title || d.relPath,
50
+ meta: `${d.kind} · ${formatTok(d.tokens)} tok${d.oversized ? " · oversized" : ""}`,
51
+ body: d.preview,
52
+ })),
53
+ },
54
+ ],
55
+ });
56
+ return;
57
+ }
58
+ ui.notify(docs.map((d) => `○ ${d.title || d.relPath} (${formatTok(d.tokens)} tok)`).join("\n"), "info");
59
+ return;
60
+ }
61
+
62
+ if (sub === "stats") {
63
+ const docs = getIntentDocs();
64
+ const inlineBodies: IntentInlineDoc[] = loadInlineIntentBodies(docs);
65
+ const stats = buildIntentStats(docs, inlineBodies);
66
+ ui.notify(formatIntentStats(stats), "info");
67
+ return;
68
+ }
69
+
70
+ ui.notify("Usage: /docs <list|stats>", "error");
71
+ void solyDirFor; // kept available for future subcommands
72
+ },
73
+ });
74
+ }
@@ -0,0 +1,187 @@
1
+ // =============================================================================
2
+ // commands/rules.ts — /rules command (list, show, stats, analytics, reload, enable, disable)
3
+ // =============================================================================
4
+
5
+ import * as path from "node:path";
6
+ import * as fs from "node:fs";
7
+ import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
8
+ import { CONTEXT_WINDOW_TOKENS, formatTok, analyzeRules, buildRulesContextStats, formatAnalyticsFull, formatRulesContextStats, readIfExists, type RuleFile } from "../core.ts";
9
+ import type { ListItem } from "../visual/list-panel.ts";
10
+ import { openListPanel, type CommandUI, type CommandsDeps } from "./_helpers.ts";
11
+
12
+ /** Status marker for a rule: ● always-on · ◐ glob-matched · ○ disabled. */
13
+ function ruleMarker(r: RuleFile): string {
14
+ if (!r.enabled) return "○";
15
+ return r.meta.always || !(r.meta.globs && r.meta.globs.length) ? "●" : "◐";
16
+ }
17
+
18
+ /** Map a rule to a panel row (token estimate + globs + description preview). */
19
+ function ruleItem(r: RuleFile): ListItem {
20
+ const tokens = Math.ceil(r.body.length / 4);
21
+ const bits = [`${formatTok(tokens)} tok`];
22
+ if (r.meta.globs && r.meta.globs.length) bits.push(r.meta.globs.join(","));
23
+ if (!r.enabled) bits.push("off");
24
+ return {
25
+ id: r.relPath,
26
+ marker: ruleMarker(r),
27
+ label: r.relPath,
28
+ meta: bits.join(" · "),
29
+ body: r.meta.description ? `${r.meta.description}\n\n${r.body}` : r.body,
30
+ };
31
+ }
32
+
33
+ type RulesDeps = Pick<CommandsDeps, "getRules" | "getOverridden" | "refreshRules" | "updateStatus">;
34
+
35
+ export function registerRulesCommand(pi: ExtensionAPI, deps: RulesDeps): void {
36
+ const { getRules, getOverridden, refreshRules, updateStatus } = deps;
37
+
38
+ pi.registerCommand("rules", {
39
+ description: "manage soly rules (list, show, stats, analytics, reload, enable, disable)",
40
+ handler: async (args, ctx) => {
41
+ const ui: CommandUI = {
42
+ notify: (t, k) => ctx.ui.notify(t, k ?? "info"),
43
+ select: async (label, options) => {
44
+ const result = await ctx.ui.select(label, options);
45
+ return result === undefined ? null : options.indexOf(result);
46
+ },
47
+ confirm: (title, message) => ctx.ui.confirm(title, message),
48
+ };
49
+ const parts = args.trim().split(/\s+/);
50
+ // Bare `/rules` (empty args) opens the modal; an explicit subcommand does not.
51
+ const sub = parts[0] || "list";
52
+ const target = parts[1];
53
+
54
+ if (sub === "list") {
55
+ const rules = getRules();
56
+ const overridden = getOverridden();
57
+ if (rules.length === 0 && overridden.length === 0) {
58
+ ui.notify("soly: no rules loaded — check `.agents/rules/` or `~/.agents/rules/`", "warning");
59
+ return;
60
+ }
61
+ // Rich modal in the TUI; plain select elsewhere (RPC/print).
62
+ if (ctx.mode === "tui") {
63
+ const analytics = analyzeRules(getRules(), CONTEXT_WINDOW_TOKENS);
64
+ await openListPanel(ctx, {
65
+ title: "soly · rules",
66
+ headerRight: `${getRules().length} rules · ${formatTok(analytics.totalTokens)} · ${analytics.contextBudgetPct.toFixed(1)}%`,
67
+ build: () => [
68
+ { id: "rules", title: "Rules", icon: "▤", items: getRules().map(ruleItem) },
69
+ ],
70
+ actions: [
71
+ { key: "e", hint: "enable", run: (it) => { const r = getRules().find((x) => x.relPath === it.id); if (r) r.enabled = true; } },
72
+ { key: "d", hint: "disable", run: (it) => { const r = getRules().find((x) => x.relPath === it.id); if (r) r.enabled = false; } },
73
+ { key: "r", hint: "reload", run: () => refreshRules() },
74
+ ],
75
+ });
76
+ updateStatus(ui);
77
+ return;
78
+ }
79
+ const lines: string[] = [];
80
+ for (const r of rules) {
81
+ const status = r.enabled ? "●" : "○";
82
+ const desc = r.meta.description ? ` — ${r.meta.description}` : "";
83
+ lines.push(`${status} [${r.sourceLabel}] ${r.relPath}${desc}`);
84
+ }
85
+ for (const p of overridden) {
86
+ lines.push(`⊘ [overridden] ${p}`);
87
+ }
88
+ ui.notify(lines.join("\n"), "info");
89
+ return;
90
+ }
91
+
92
+ if (sub === "show") {
93
+ if (!target) {
94
+ ui.notify("Usage: /rules show <relPath>", "error");
95
+ return;
96
+ }
97
+ const r = getRules().find((x) => x.relPath === target);
98
+ if (!r) {
99
+ ui.notify(`Rule not found: ${target}`, "error");
100
+ return;
101
+ }
102
+ const text = `---\n${r.meta.description ? `description: ${r.meta.description}\n` : ""}source: ${r.sourceLabel}\nenabled: ${r.enabled}\n---\n\n${r.body}`;
103
+ ui.notify(text, "info");
104
+ return;
105
+ }
106
+
107
+ if (sub === "stats") {
108
+ const analytics = analyzeRules(getRules(), CONTEXT_WINDOW_TOKENS);
109
+ ui.notify(formatAnalyticsFull(analytics), "info");
110
+ return;
111
+ }
112
+
113
+ if (sub === "analytics") {
114
+ const rules = getRules();
115
+ const stats = buildRulesContextStats(rules, CONTEXT_WINDOW_TOKENS);
116
+ ui.notify(formatRulesContextStats(stats), "info");
117
+ return;
118
+ }
119
+
120
+ if (sub === "enable" || sub === "disable") {
121
+ if (!target) {
122
+ ui.notify(`Usage: /rules ${sub} <relPath>`, "error");
123
+ return;
124
+ }
125
+ const r = getRules().find((x) => x.relPath === target);
126
+ if (!r) {
127
+ ui.notify(`Rule not found: ${target}`, "error");
128
+ return;
129
+ }
130
+ r.enabled = sub === "enable";
131
+ updateStatus(ui);
132
+ ui.notify(`Rule ${target}: ${sub}d`, "info");
133
+ return;
134
+ }
135
+
136
+ if (sub === "reload") {
137
+ refreshRules();
138
+ updateStatus(ui);
139
+ ui.notify(`Reloaded ${getRules().length} rules`, "info");
140
+ return;
141
+ }
142
+
143
+ if (sub === "add") {
144
+ ui.notify(
145
+ "Use /rulewizard add <url> (or /rules add for the rule-creation guide).",
146
+ "info",
147
+ );
148
+ return;
149
+ }
150
+
151
+ if (sub === "new") {
152
+ ui.notify(
153
+ "Use /rulewizard to scaffold a new rule (it guides the rule-vs-editorconfig-vs-linter decision).",
154
+ "info",
155
+ );
156
+ return;
157
+ }
158
+
159
+ if (sub === "path") {
160
+ const cwd = process.cwd();
161
+ const dirs = [
162
+ path.join(cwd, ".agents", "rules.local"),
163
+ path.join(cwd, ".agents", "rules"),
164
+ path.join(cwd, ".pi", "rules"),
165
+ path.join(require("node:os").homedir(), ".agents", "rules"),
166
+ ];
167
+ const found = dirs.filter((d) => fs.existsSync(d));
168
+ ui.notify(
169
+ "Rules sources (existing first):\n" +
170
+ (found.length ? found.map((d) => ` ✓ ${d}`).join("\n") : " (none)") +
171
+ "\n" +
172
+ dirs
173
+ .filter((d) => !found.includes(d))
174
+ .map((d) => ` - ${d}`)
175
+ .join("\n"),
176
+ "info",
177
+ );
178
+ return;
179
+ }
180
+
181
+ ui.notify(
182
+ `Usage: /rules <list|show|stats|analytics|enable|disable|reload|add|new|path> [target]`,
183
+ "error",
184
+ );
185
+ },
186
+ });
187
+ }
@@ -0,0 +1,43 @@
1
+ // =============================================================================
2
+ // commands/rulewizard.ts — /rulewizard command (interactive guide)
3
+ // =============================================================================
4
+
5
+ import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
6
+
7
+ export function registerRulewizardCommand(pi: ExtensionAPI): void {
8
+ pi.registerCommand("rulewizard", {
9
+ description:
10
+ "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.",
11
+ handler: async (_args, ctx) => {
12
+ ctx.ui.notify(
13
+ [
14
+ "soly-rule-wizard:",
15
+ "",
16
+ "tell me what behavior or outcome you want to constrain. I'll help you",
17
+ "decide whether it should be:",
18
+ " • a soly rule (.agents/rules/*.md) — for process, behavior, or project",
19
+ " conventions the LLM must follow",
20
+ " • an .editorconfig entry — for formatting (indent, line endings, EOL,",
21
+ " charset, trailing whitespace, max line length)",
22
+ " • a linter config (eslint / biome / prettier) — for code style that",
23
+ " a tool can check automatically",
24
+ " • or nothing — if an existing tool already covers it",
25
+ "",
26
+ "decide first:",
27
+ " 1. is it about LLM behavior / process / project conventions? → soly rule",
28
+ " 2. is it about whitespace, indent, line endings? → .editorconfig",
29
+ " 3. is it about code style a linter can check? → eslint/biome",
30
+ " 4. is it already covered by an existing tool? → don't duplicate",
31
+ "",
32
+ "useful commands first:",
33
+ " /rules — see existing rules (so we don't duplicate)",
34
+ " /rules analytics — see file sizes, missing descriptions, duplicates",
35
+ "",
36
+ "when you've decided:",
37
+ " /rules new — scaffold a new rule from the soly template",
38
+ ].join("\n"),
39
+ "info",
40
+ );
41
+ },
42
+ });
43
+ }