Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
fa3d5f1
docs(tui): #275 unified action registry design spec
windoliver May 29, 2026
9f46a3e
docs(tui): #275 unified action registry implementation plan
windoliver May 29, 2026
3dc6aee
feat(tui): #275 extend Action model (slash, suggested, keybind, enabl…
windoliver May 29, 2026
5ae2988
feat(tui): #275 persistent ActionRegistry (static + dynamic sources)
windoliver May 29, 2026
f52054d
feat(tui): #275 split built-ins into static actions + dynamic sources
windoliver May 29, 2026
c8ad522
feat(tui): #275 keymap binding → registry action id map
windoliver May 29, 2026
6fee82c
feat(tui): #275 dispatch keymap through registry; retire executeKeyma…
windoliver May 29, 2026
56e6c98
feat(tui): #275 leader-chord candidates + overlay + 2s window constant
windoliver May 29, 2026
c2cbfd7
feat(tui): #275 palette keybind column + disabled reason footer
windoliver May 29, 2026
c8940a9
feat(tui): #275 wire ActionRegistry into app (palette + keymap + lead…
windoliver May 29, 2026
f257509
feat(tui): #275 slash index + slash triggers on built-in actions
windoliver May 29, 2026
9958a1f
feat(tui): #275 '/' opens slash-filtered command palette
windoliver May 29, 2026
2cc8c22
fix(tui): #275 biome — registry memo ignore + drop redundant keyboard…
windoliver May 29, 2026
ef2d2b0
feat(tui): #275 dedicated slash command-line input (:cmd args)
windoliver May 29, 2026
9aefa0b
feat(mcp): #275 expose prompts from prompts/ dir (prompts capability)
windoliver May 29, 2026
71e8054
feat(tui): #275 TuiPromptProvider.listMcpPrompts + prompts capability
windoliver May 29, 2026
2305512
feat(tui): #275 prompt.* action source (Prompts group)
windoliver May 29, 2026
b0cf0f5
feat(core): #275 listAvailableSkills (bundled + topology)
windoliver May 29, 2026
2fdd4dc
feat(tui): #275 TuiSkillProvider.listAvailableSkills + skills capability
windoliver May 29, 2026
7799e13
feat(tui): #275 slot-scoped skill.request.* action source (Skills group)
windoliver May 29, 2026
15e35ee
fix(tui): #275 final-review cleanups — skill slash colon form, : surf…
windoliver May 30, 2026
180822e
fix(tui): #275 sort registry.ts imports (biome organizeImports — CI l…
windoliver May 30, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2,164 changes: 2,164 additions & 0 deletions docs/superpowers/plans/2026-05-29-tui-275-action-registry.md

Large diffs are not rendered by default.

376 changes: 376 additions & 0 deletions docs/superpowers/specs/2026-05-29-tui-275-action-registry-design.md

Large diffs are not rendered by default.

20 changes: 20 additions & 0 deletions src/core/runtime-skill-acquisition.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import type { RuntimeSkillsConfig } from "./config.js";
import type { RuntimeSkillResolver } from "./runtime-skill-acquisition.js";
import {
DefaultRuntimeSkillAcquisitionService,
listAvailableSkills,
listBundledSkillNames,
RuntimeSkillAcquisitionError,
} from "./runtime-skill-acquisition.js";
import type { RuntimeSkillSessionStore } from "./session.js";
Expand Down Expand Up @@ -346,3 +348,21 @@ describe("DefaultRuntimeSkillAcquisitionService", () => {
).rejects.toMatchObject({ code: "CATALOG_UNAVAILABLE" });
});
});

describe("listAvailableSkills (#275)", () => {
test("includes the bundled grove skill", async () => {
const skills = await listAvailableSkills();
expect(skills.map((s) => s.name)).toContain("grove");
expect(skills.find((s) => s.name === "grove")?.source).toBe("bundled");
});
test("merges topology skills and dedupes (bundled wins)", async () => {
const skills = await listAvailableSkills(["custom-role-skill", "grove"]);
const names = skills.map((s) => s.name);
expect(names).toContain("custom-role-skill");
expect(names.filter((n) => n === "grove").length).toBe(1); // deduped
expect(skills.find((s) => s.name === "custom-role-skill")?.source).toBe("topology");
});
test("listBundledSkillNames returns [] for a missing dir", async () => {
expect(await listBundledSkillNames("/no/such/dir")).toEqual([]);
});
});
57 changes: 56 additions & 1 deletion src/core/runtime-skill-acquisition.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { existsSync } from "node:fs";
import { readFile, stat } from "node:fs/promises";
import { readdir, readFile, stat } from "node:fs/promises";
import { join, relative } from "node:path";
import type { RuntimeSkillsConfig } from "./config.js";
import type { RuntimeSkillSessionStore } from "./session.js";
Expand Down Expand Up @@ -176,6 +176,61 @@ function relativeTargets(workspacePath: string, targets: readonly string[]): rea
const SESSION_PERSIST_RETRY_MESSAGE =
"Runtime skill workspace install succeeded, but session persistence failed; fix or recover session state, then retry grove_request_skill.";

export interface AvailableSkill {
readonly name: string;
readonly source: "bundled" | "topology";
}

/** Names of bundled skills: subdirectories of `dir` that contain a SKILL.md. */
export async function listBundledSkillNames(dir: string): Promise<readonly string[]> {
let entries: string[];
try {
entries = await readdir(dir);
} catch {
return [];
}
const names: string[] = [];
for (const entry of entries.sort()) {
try {
const s = await stat(join(dir, entry));
if (!s.isDirectory()) continue;
const skillFile = await stat(join(dir, entry, "SKILL.md"));
if (skillFile.isFile()) names.push(entry);
} catch {
// skip non-skill entries
}
}
return names;
}

/** The repo-bundled skills directory (contains one subdir per skill). */
export function resolveRepoSkillsDir(): string {
return new URL("../../skills/", import.meta.url).pathname;
}

/**
* Available skills = bundled (from the repo skills/ dir) merged with the given
* topology-declared skills, deduped by name (bundled wins on conflict).
*/
export async function listAvailableSkills(
topologySkills: readonly string[] = [],
): Promise<readonly AvailableSkill[]> {
const bundled = await listBundledSkillNames(resolveRepoSkillsDir());
const seen = new Set<string>();
const out: AvailableSkill[] = [];
for (const name of bundled) {
if (seen.has(name)) continue;
seen.add(name);
out.push({ name, source: "bundled" });
}
for (const name of topologySkills) {
if (seen.has(name)) continue;
seen.add(name);
out.push({ name, source: "topology" });
}
return out;
}

export class DefaultRuntimeSkillAcquisitionService implements RuntimeSkillAcquisitionService {
private readonly deps: RuntimeSkillAcquisitionDeps;

Expand Down
18 changes: 18 additions & 0 deletions src/mcp/prompts.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { describe, expect, test } from "bun:test";
import { loadPromptDefinitions } from "./prompts.js";

describe("loadPromptDefinitions", () => {
test("loads .md files from the repo prompts dir as named prompts", async () => {
const defs = await loadPromptDefinitions(new URL("../../prompts/", import.meta.url).pathname);
expect(defs.length).toBeGreaterThan(0);
for (const d of defs) {
expect(typeof d.name).toBe("string");
expect(d.template.length).toBeGreaterThan(0);
}
// The repo ships coder/coordinator/reviewer prompts.
expect(defs.map((d) => d.name)).toContain("coder");
});
test("returns [] for a missing directory", async () => {
expect(await loadPromptDefinitions("/no/such/dir")).toEqual([]);
});
});
34 changes: 34 additions & 0 deletions src/mcp/prompts.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { readdir, readFile } from "node:fs/promises";
import { basename, extname, join } from "node:path";

export interface PromptDefinition {
readonly name: string;
readonly description: string;
readonly template: string;
}

/** Load the prompts bundled in the repo `prompts/` directory. */
export async function listBundledPrompts(): Promise<readonly PromptDefinition[]> {
return loadPromptDefinitions(new URL("../../prompts/", import.meta.url).pathname);
}

/** Load each *.md / *.txt file in `dir` as a named prompt (name = file stem). */
export async function loadPromptDefinitions(dir: string): Promise<readonly PromptDefinition[]> {
let entries: string[];
try {
entries = await readdir(dir);
} catch {
return [];
}
const defs: PromptDefinition[] = [];
for (const entry of entries.sort()) {
const ext = extname(entry).toLowerCase();
if (ext !== ".md" && ext !== ".txt") continue;
const template = (await readFile(join(dir, entry), "utf8")).trim();
if (template.length === 0) continue;
const name = basename(entry, ext);
const firstLine = template.split("\n", 1)[0]?.replace(/^#+\s*/, "") ?? name;
defs.push({ name, description: firstLine.slice(0, 120), template });
}
return defs;
}
3 changes: 3 additions & 0 deletions src/mcp/serve-http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1325,9 +1325,12 @@ async function handleRequest(req: IncomingMessage, res: ServerResponse): Promise
try {
// Runtime skill acquisition mutates the caller workspace and requires
// stdio's per-agent role/cwd binding. HTTP MCP intentionally omits it.
// promptsDir: repo-root prompts/ relative to this file (src/mcp/serve-http.ts)
// so prompts/list exposes the bundled templates; degrades to [] if absent.
server = await createMcpServer(scopedDeps, {
eval: evalEnabled,
transport: "http",
promptsDir: new URL("../../prompts/", import.meta.url).pathname,
});
} catch (err) {
acquiredScope.release();
Expand Down
5 changes: 5 additions & 0 deletions src/mcp/serve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -823,6 +823,9 @@ try {
// enable with GROVE_MCP_EVAL_ENABLED=true (stdio) or AUTH_TOKEN +
// GROVE_MCP_EVAL_ENABLED=true (HTTP — enforced in serve-http.ts).
const evalEnabled = process.env.GROVE_MCP_EVAL_ENABLED === "true";
// Repo-root prompts/ dir, relative to this file (src/mcp/serve.ts). Registers
// bundled prompt templates for prompts/list; degrades to [] on a missing dir.
const promptsDir = new URL("../../prompts/", import.meta.url).pathname;
preset =
contractMode === "evaluation"
? {
Expand All @@ -837,6 +840,7 @@ try {
plans: true,
goals: true,
eval: evalEnabled,
promptsDir,
}
: {
queries: true,
Expand All @@ -850,6 +854,7 @@ try {
plans: false,
goals: true,
eval: evalEnabled,
promptsDir,
};

close = () => {
Expand Down
46 changes: 46 additions & 0 deletions src/mcp/server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,14 @@ function getRegisteredToolNames(server: McpServer): string[] {
return Object.keys(internal._registeredTools).sort();
}

/** Extract the list of registered prompt names from a McpServer instance. */
function getRegisteredPromptNames(server: McpServer): string[] {
const internal = server as unknown as {
_registeredPrompts: Record<string, unknown>;
};
return Object.keys(internal._registeredPrompts).sort();
}

// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -325,3 +333,41 @@ describe("createMcpServer preset scoping", () => {
expect(names).toContain("grove_goal");
});
});

// ---------------------------------------------------------------------------
// Prompt registration tests
// ---------------------------------------------------------------------------

describe("createMcpServer prompt registration", () => {
let testDeps: TestMcpDeps;
let deps: McpDeps;

beforeEach(async () => {
testDeps = await createTestMcpDeps();
deps = testDeps.deps;
});

afterEach(async () => {
await testDeps.cleanup();
});

test("no promptsDir → no prompts registered", async () => {
const server = await createMcpServer(deps);
expect(getRegisteredPromptNames(server)).toEqual([]);
});

test("promptsDir pointing at repo prompts/ → registers coder/coordinator/reviewer", async () => {
const promptsDir = new URL("../../prompts/", import.meta.url).pathname;
const server = await createMcpServer(deps, { promptsDir });
const names = getRegisteredPromptNames(server);
expect(names).toContain("coder");
expect(names).toContain("coordinator");
expect(names).toContain("reviewer");
expect(names.length).toBeGreaterThan(0);
});

test("promptsDir pointing at missing dir → no prompts registered (no error)", async () => {
const server = await createMcpServer(deps, { promptsDir: "/no/such/dir" });
expect(getRegisteredPromptNames(server)).toEqual([]);
});
});
18 changes: 17 additions & 1 deletion src/mcp/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { registerAskUserTools } from "@grove/ask-user";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";

import type { McpDeps } from "./deps.js";
import { loadPromptDefinitions } from "./prompts.js";
import { registerBountyTools } from "./tools/bounties.js";
import { registerClaimTools } from "./tools/claims.js";
import { registerContributionTools } from "./tools/contributions.js";
Expand Down Expand Up @@ -67,6 +68,11 @@ export interface McpPresetConfig {
* Default: "stdio" (backwards-compatible for existing callers).
*/
readonly transport?: "stdio" | "http";
/**
* Directory containing *.md / *.txt prompt templates to expose as MCP
* prompts. When omitted no prompts are registered.
*/
readonly promptsDir?: string;
}

// ---------------------------------------------------------------------------
Expand All @@ -90,7 +96,7 @@ export interface McpPresetConfig {
export async function createMcpServer(deps: McpDeps, preset?: McpPresetConfig): Promise<McpServer> {
const server = new McpServer(
{ name: "grove-mcp", version: "0.1.0" },
{ capabilities: { tools: {} } },
{ capabilities: { tools: {}, prompts: {} } },
);

// Contribution + done tools are always registered (core functionality).
Expand Down Expand Up @@ -126,5 +132,15 @@ export async function createMcpServer(deps: McpDeps, preset?: McpPresetConfig):
// ask_user is always registered (core functionality).
await registerAskUserTools(server);

// Register prompts from promptsDir when configured.
const promptsDir = preset?.promptsDir;
if (promptsDir !== undefined) {
for (const def of await loadPromptDefinitions(promptsDir)) {
server.registerPrompt(def.name, { title: def.name, description: def.description }, () => ({
messages: [{ role: "user", content: { type: "text", text: def.template } }],
}));
}
}

return server;
}
Loading
Loading