fix: regenerate pi-claude-cli MCP config per session and auto-sync skill tools

The MCP config was generated lazily once and locked, so engine session-scoped
tools (fn_review_spec, fn_review_step) never reached the Claude CLI subprocess
and triage/executor sessions failed with "unknown tool" errors. Now the config
is hashed per call and rewritten when the tool set changes.

Also adds scripts/sync-fusion-skill-tools.mjs to regenerate the SKILL.md
tool-categories block from extension.ts at build time, with a --check mode
wired into skill-sync tests so drift fails CI.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-04-25 14:10:56 -07:00
parent c597dd4055
commit e8aab69c67
8 changed files with 231 additions and 26 deletions

View File

@@ -18,6 +18,8 @@
"dev:ui": "pnpm --filter @fusion/dashboard dev", "dev:ui": "pnpm --filter @fusion/dashboard dev",
"dev:hmr": "node scripts/dev-hmr.mjs", "dev:hmr": "node scripts/dev-hmr.mjs",
"lint": "eslint .", "lint": "eslint .",
"sync:fusion-skill": "node scripts/sync-fusion-skill-tools.mjs",
"sync:fusion-skill:check": "node scripts/sync-fusion-skill-tools.mjs --check",
"build": "pnpm -r build", "build": "pnpm -r build",
"verify:workspace": "pnpm lint && pnpm test && pnpm build", "verify:workspace": "pnpm lint && pnpm test && pnpm build",
"build:exe": "pnpm build && pnpm --filter @runfusion/fusion build:exe", "build:exe": "pnpm build && pnpm --filter @runfusion/fusion build:exe",

View File

@@ -39,6 +39,7 @@
], ],
"scripts": { "scripts": {
"dev": "tsx src/bin.ts", "dev": "tsx src/bin.ts",
"prebuild": "node ../../scripts/sync-fusion-skill-tools.mjs",
"build": "tsup", "build": "tsup",
"build:exe": "bun run build.ts", "build:exe": "bun run build.ts",
"build:exe:all": "bun run build.ts --all", "build:exe:all": "bun run build.ts --all",

View File

@@ -24,11 +24,13 @@ Mission → Milestone → Slice → Feature → Task
**Naming boundary:** The published skill surface always uses `fn_*` tool names (for example `fn_task_create`, `fn_mission_create`). Internal engine runtime tools like `task_create`, `task_update`, `task_log`, and `task_done` are intentionally unprefixed and not part of this skill. **Naming boundary:** The published skill surface always uses `fn_*` tool names (for example `fn_task_create`, `fn_mission_create`). Internal engine runtime tools like `task_create`, `task_update`, `task_log`, and `task_done` are intentionally unprefixed and not part of this skill.
**Tool categories:** **Tool categories:**
<!-- BEGIN: tool-categories (auto-generated by scripts/sync-fusion-skill-tools.mjs — do not edit by hand) -->
- **Task tools** — `fn_task_create`, `fn_task_update`, `fn_task_list`, `fn_task_show`, `fn_task_attach`, `fn_task_pause`, `fn_task_unpause`, `fn_task_retry`, `fn_task_duplicate`, `fn_task_refine`, `fn_task_archive`, `fn_task_unarchive`, `fn_task_delete`, `fn_task_plan` - **Task tools** — `fn_task_create`, `fn_task_update`, `fn_task_list`, `fn_task_show`, `fn_task_attach`, `fn_task_pause`, `fn_task_unpause`, `fn_task_retry`, `fn_task_duplicate`, `fn_task_refine`, `fn_task_archive`, `fn_task_unarchive`, `fn_task_delete`, `fn_task_plan`
- **GitHub tools** — `fn_task_import_github`, `fn_task_import_github_issue`, `fn_task_browse_github_issues` - **GitHub tools** — `fn_task_import_github`, `fn_task_import_github_issue`, `fn_task_browse_github_issues`
- **Mission tools** — `fn_mission_create`, `fn_mission_list`, `fn_mission_show`, `fn_mission_delete`, `fn_milestone_add`, `fn_slice_add`, `fn_feature_add`, `fn_slice_activate`, `fn_feature_link_task` - **Mission tools** — `fn_mission_create`, `fn_mission_list`, `fn_mission_show`, `fn_mission_delete`, `fn_milestone_add`, `fn_slice_add`, `fn_feature_add`, `fn_slice_activate`, `fn_feature_link_task`
- **Agent tools** — `fn_agent_stop`, `fn_agent_start` - **Agent tools** — `fn_agent_stop`, `fn_agent_start`
- **Skills tools** — `fn_skills_search`, `fn_skills_install` - **Skills tools** — `fn_skills_search`, `fn_skills_install`
<!-- END: tool-categories -->
- **Dashboard** — Use `/fn` command to start/stop the dashboard - **Dashboard** — Use `/fn` command to start/stop the dashboard
</essential_principles> </essential_principles>

View File

@@ -2,6 +2,7 @@ import { describe, it, expect } from "vitest";
import { readFileSync, existsSync, readdirSync } from "node:fs"; import { readFileSync, existsSync, readdirSync } from "node:fs";
import { resolve, dirname, relative, join } from "node:path"; import { resolve, dirname, relative, join } from "node:path";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import { spawnSync } from "node:child_process";
const __dirname = dirname(fileURLToPath(import.meta.url)); const __dirname = dirname(fileURLToPath(import.meta.url));
const cliRoot = resolve(__dirname, "../.."); const cliRoot = resolve(__dirname, "../..");
@@ -194,6 +195,19 @@ describe("Skill-Extension Sync", () => {
expect(dashboardCli).toContain("/fn"); expect(dashboardCli).toContain("/fn");
}); });
it("SKILL.md tool-categories block matches the sync script output (no drift)", () => {
const repoRoot = resolve(cliRoot, "../..");
const script = resolve(repoRoot, "scripts/sync-fusion-skill-tools.mjs");
const result = spawnSync("node", [script, "--check"], {
encoding: "utf-8",
});
if (result.status !== 0) {
throw new Error(
`sync-fusion-skill-tools --check failed:\n${result.stderr || result.stdout}`,
);
}
});
it("package.json includes skills in pi config and files array", () => { it("package.json includes skills in pi config and files array", () => {
const pkg = JSON.parse( const pkg = JSON.parse(
readFileSync(resolve(cliRoot, "package.json"), "utf-8"), readFileSync(resolve(cliRoot, "package.json"), "utf-8"),

View File

@@ -13,6 +13,7 @@ import {
validateCliAuth, validateCliAuth,
killAllProcesses, killAllProcesses,
} from "./src/process-manager.js"; } from "./src/process-manager.js";
import { createHash } from "node:crypto";
import { getCustomToolDefs, writeMcpConfig } from "./src/mcp-config.js"; import { getCustomToolDefs, writeMcpConfig } from "./src/mcp-config.js";
// Kill all active Claude subprocesses on process exit to prevent orphans // Kill all active Claude subprocesses on process exit to prevent orphans
@@ -20,49 +21,63 @@ process.on("exit", killAllProcesses);
const PROVIDER_ID = "pi-claude-cli"; const PROVIDER_ID = "pi-claude-cli";
let mcpConfigPath: string | undefined; let cachedMcpConfig: { hash: string; configPath: string } | undefined;
let mcpConfigResolved = false;
/** /**
* Lazily generate MCP config on first request (not at load time). * Resolve the MCP config path for the current request, regenerating it when
* pi.getAllTools() fails during extension loading; this defers it * the set of custom tools changes.
* until the pi runtime is fully initialized.
* *
* Only locks (sets mcpConfigResolved) when getAllTools() returns a * Why per-call instead of once-and-lock:
* real array — if it returns undefined/null (registry not ready), * - The engine registers session-scoped custom tools (e.g. `fn_review_spec`,
* we retry on the next request. Once the registry is ready we * `fn_review_step`) when it spawns triage/executor sessions. These appear
* commit to the result even if there are zero custom tools. * in `pi.getAllTools()` only while that session is active.
* - A locked-on-first-call cache would freeze in the global tool set and
* silently drop session tools, so the Claude CLI subprocess would refuse
* to call them ("unknown tool fn_review_spec").
* - Hashing the tool defs lets us reuse the same temp files when the tool
* set is unchanged across calls, and produce fresh files (with the hash
* in the filename to avoid races) when it changes.
* *
* Uses warn-don't-block: failure logs a warning but does not * Uses warn-don't-block: failure logs a warning but does not prevent the
* prevent the provider from functioning (built-ins still work). * provider from functioning (built-ins still work).
*/ */
function ensureMcpConfig(pi: ExtensionAPI): string | undefined { function ensureMcpConfig(pi: ExtensionAPI): string | undefined {
if (mcpConfigResolved) return mcpConfigPath;
try { try {
const allTools = pi.getAllTools(); const allTools = pi.getAllTools();
// Registry not ready yet — don't lock, retry on next call // Registry not ready yet — fall back to whatever we last computed (if any)
if (!Array.isArray(allTools)) { if (!Array.isArray(allTools)) {
return mcpConfigPath; return cachedMcpConfig?.configPath;
} }
// Registry is ready — lock regardless of whether custom tools exist
mcpConfigResolved = true;
const toolDefs = getCustomToolDefs(pi); const toolDefs = getCustomToolDefs(pi);
if (toolDefs.length > 0) { if (toolDefs.length === 0) {
mcpConfigPath = writeMcpConfig(toolDefs); cachedMcpConfig = undefined;
console.error( return undefined;
`[pi-claude-cli] MCP config generated with ${toolDefs.length} custom tool(s)`,
);
} }
const hash = createHash("sha1")
.update(JSON.stringify(toolDefs))
.digest("hex")
.slice(0, 12);
if (cachedMcpConfig?.hash === hash) {
return cachedMcpConfig.configPath;
}
const configPath = writeMcpConfig(toolDefs, hash);
cachedMcpConfig = { hash, configPath };
console.error(
`[pi-claude-cli] MCP config refreshed with ${toolDefs.length} custom tool(s) (hash=${hash})`,
);
return configPath;
} catch (err) { } catch (err) {
console.warn( console.warn(
"[pi-claude-cli] MCP config generation failed, custom tools unavailable:", "[pi-claude-cli] MCP config generation failed, custom tools unavailable:",
err, err,
); );
return cachedMcpConfig?.configPath;
} }
return mcpConfigPath;
} }
export default function (pi: ExtensionAPI) { export default function (pi: ExtensionAPI) {

View File

@@ -269,4 +269,26 @@ describe("writeMcpConfig", () => {
expect(result).toMatch(/pi-claude-mcp-config/); expect(result).toMatch(/pi-claude-mcp-config/);
expect(result).toMatch(/\.json$/); expect(result).toMatch(/\.json$/);
}); });
it("includes cacheKey in filenames when provided so distinct tool sets do not collide", () => {
const toolDefs: McpToolDef[] = [
{
name: "search",
description: "Search",
inputSchema: { type: "object" },
},
];
const pathA = writeMcpConfig(toolDefs, "aaaaaaaaaaaa");
const pathB = writeMcpConfig(toolDefs, "bbbbbbbbbbbb");
expect(pathA).toContain("aaaaaaaaaaaa");
expect(pathB).toContain("bbbbbbbbbbbb");
expect(pathA).not.toBe(pathB);
const schemaPathA = mocks.writeFileSync.mock.calls[0][0];
const schemaPathB = mocks.writeFileSync.mock.calls[2][0];
expect(schemaPathA).toContain("aaaaaaaaaaaa");
expect(schemaPathB).toContain("bbbbbbbbbbbb");
});
}); });

View File

@@ -75,13 +75,21 @@ export function getCustomToolDefs(pi: PiInstance): McpToolDef[] {
* 2. Config file: MCP config pointing to the schema-only server * 2. Config file: MCP config pointing to the schema-only server
* *
* @param toolDefs - Array of custom tool definitions * @param toolDefs - Array of custom tool definitions
* @param cacheKey - Optional suffix appended to filenames so that distinct
* tool sets (e.g. session-scoped tool registrations) get distinct files
* and don't race on a single shared path.
* @returns Path to the MCP config file * @returns Path to the MCP config file
*/ */
export function writeMcpConfig(toolDefs: McpToolDef[]): string { export function writeMcpConfig(
toolDefs: McpToolDef[],
cacheKey?: string,
): string {
const suffix = cacheKey ? `${process.pid}-${cacheKey}` : `${process.pid}`;
// Write tool schemas to temp file // Write tool schemas to temp file
const schemaFilePath = join( const schemaFilePath = join(
tmpdir(), tmpdir(),
`pi-claude-mcp-schemas-${process.pid}.json`, `pi-claude-mcp-schemas-${suffix}.json`,
); );
writeFileSync(schemaFilePath, JSON.stringify(toolDefs)); writeFileSync(schemaFilePath, JSON.stringify(toolDefs));
@@ -103,7 +111,7 @@ export function writeMcpConfig(toolDefs: McpToolDef[]): string {
// Write config to temp file // Write config to temp file
const configFilePath = join( const configFilePath = join(
tmpdir(), tmpdir(),
`pi-claude-mcp-config-${process.pid}.json`, `pi-claude-mcp-config-${suffix}.json`,
); );
writeFileSync(configFilePath, JSON.stringify(config)); writeFileSync(configFilePath, JSON.stringify(config));

View File

@@ -0,0 +1,141 @@
#!/usr/bin/env node
/**
* Regenerates the `tool-categories` block in the Fusion skill SKILL.md from
* the canonical list of `fn_*` tools registered in `packages/cli/src/extension.ts`.
*
* Source of truth: extension.ts (each tool is registered with `name: "fn_..."`).
* Output: the `<!-- BEGIN: tool-categories ... -->` / `<!-- END: tool-categories -->`
* block inside `packages/cli/skill/fusion/SKILL.md`.
*
* The `.claude/skills/fusion` path is a symlink to `packages/cli/skill/fusion`,
* so it picks up the change automatically.
*
* Usage:
* node scripts/sync-fusion-skill-tools.mjs # rewrite SKILL.md in place
* node scripts/sync-fusion-skill-tools.mjs --check # exit 1 if drift detected
*
* Wired into `packages/cli` build via the `prebuild` script so a fresh build
* always emits an up-to-date skill, and CI catches drift via `--check`.
*/
import { readFileSync, writeFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { dirname, resolve } from "node:path";
const __dirname = dirname(fileURLToPath(import.meta.url));
const repoRoot = resolve(__dirname, "..");
const extensionPath = resolve(repoRoot, "packages/cli/src/extension.ts");
const skillPath = resolve(repoRoot, "packages/cli/skill/fusion/SKILL.md");
const BEGIN_MARKER =
"<!-- BEGIN: tool-categories (auto-generated by scripts/sync-fusion-skill-tools.mjs — do not edit by hand) -->";
const END_MARKER = "<!-- END: tool-categories -->";
/**
* Categorize a tool name into one of the published skill categories.
* The order of categories here defines the order of bullets in SKILL.md.
*/
function categorize(name) {
if (name.includes("github")) return "GitHub";
if (name.startsWith("fn_task_")) return "Task";
if (
name.startsWith("fn_mission_") ||
name.startsWith("fn_milestone_") ||
name.startsWith("fn_slice_") ||
name.startsWith("fn_feature_")
) {
return "Mission";
}
if (name.startsWith("fn_agent_")) return "Agent";
if (name.startsWith("fn_skills_")) return "Skills";
return "Other";
}
const CATEGORY_ORDER = ["Task", "GitHub", "Mission", "Agent", "Skills", "Other"];
const CATEGORY_LABELS = {
Task: "Task tools",
GitHub: "GitHub tools",
Mission: "Mission tools",
Agent: "Agent tools",
Skills: "Skills tools",
Other: "Other tools",
};
function extractToolNames(source) {
const names = [];
for (const match of source.matchAll(/name:\s*"(fn_[a-z_]+)"/g)) {
names.push(match[1]);
}
if (names.length === 0) {
throw new Error(
`No fn_* tool registrations found in ${extensionPath} — refusing to wipe the skill.`,
);
}
return names;
}
function buildBlock(toolNames) {
const grouped = new Map(CATEGORY_ORDER.map((c) => [c, []]));
for (const name of toolNames) {
grouped.get(categorize(name)).push(name);
}
const lines = [BEGIN_MARKER];
for (const category of CATEGORY_ORDER) {
const tools = grouped.get(category);
if (tools.length === 0) continue;
const formatted = tools.map((t) => `\`${t}\``).join(", ");
lines.push(`- **${CATEGORY_LABELS[category]}** — ${formatted}`);
}
lines.push(END_MARKER);
return lines.join("\n");
}
function replaceBlock(skillContent, newBlock) {
const beginIdx = skillContent.indexOf(BEGIN_MARKER);
const endIdx = skillContent.indexOf(END_MARKER);
if (beginIdx === -1 || endIdx === -1 || endIdx < beginIdx) {
throw new Error(
`Could not find BEGIN/END tool-categories markers in ${skillPath}. ` +
`Add them around the tool-categories bullet list.`,
);
}
const endLineEnd = endIdx + END_MARKER.length;
return (
skillContent.slice(0, beginIdx) + newBlock + skillContent.slice(endLineEnd)
);
}
function main() {
const checkOnly = process.argv.includes("--check");
const source = readFileSync(extensionPath, "utf-8");
const skillContent = readFileSync(skillPath, "utf-8");
const toolNames = extractToolNames(source);
const newBlock = buildBlock(toolNames);
const updated = replaceBlock(skillContent, newBlock);
if (updated === skillContent) {
if (!checkOnly) {
console.log(
`[sync-fusion-skill-tools] SKILL.md tool-categories block is already up to date (${toolNames.length} tools).`,
);
}
return;
}
if (checkOnly) {
console.error(
`[sync-fusion-skill-tools] SKILL.md tool-categories block is out of date.\n` +
`Run \`node scripts/sync-fusion-skill-tools.mjs\` to regenerate it.`,
);
process.exit(1);
}
writeFileSync(skillPath, updated);
console.log(
`[sync-fusion-skill-tools] Rewrote tool-categories block in ${skillPath} (${toolNames.length} tools).`,
);
}
main();