pi-soly 2.2.0 → 2.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/built-in-rules/release-discipline.md +105 -0
- package/commands/_helpers.ts +87 -0
- package/commands/artifacts.ts +86 -0
- package/commands/docs.ts +74 -0
- package/commands/rules.ts +187 -0
- package/commands/rulewizard.ts +43 -0
- package/commands/soly.ts +560 -0
- package/commands/why.ts +106 -0
- package/commands.ts +36 -1231
- package/core.ts +2 -15
- package/index.ts +0 -13
- package/init.ts +2 -2
- package/package.json +2 -1
- package/skills/soly-framework/SKILL.md +1 -1
package/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.
|
|
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
|
+
}
|
package/commands/docs.ts
ADDED
|
@@ -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
|
+
}
|