#!/usr/bin/env node /** * Syncs Fusion skill docs from the canonical pi extension registrations. * * Full flow: * packages/cli/src/extension.ts * → scripts/sync-fusion-skill-tools.mjs * → packages/cli/skill/fusion/SKILL.md (tool categories) * → packages/cli/skill/fusion/references/extension-tools.md (tool sections + params) * → packages/cli/skill/fusion/references/fusion-capabilities.md (tool table) * * Usage: * node scripts/sync-fusion-skill-tools.mjs * node scripts/sync-fusion-skill-tools.mjs --check */ import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { fileURLToPath } from "node:url"; import { dirname, join, resolve } from "node:path"; import { computeContentHash, fusionCacheDir, readJsonCache, } from "./lib/content-hash.mjs"; const __dirname = dirname(fileURLToPath(import.meta.url)); const repoRoot = resolve(__dirname, ".."); const extensionPath = resolve(repoRoot, "packages/cli/src/extension.ts"); const engineAgentToolsPath = resolve(repoRoot, "packages/engine/src/agent-tools.ts"); const skillPath = resolve(repoRoot, "packages/cli/skill/fusion/SKILL.md"); const extensionToolsPath = resolve( repoRoot, "packages/cli/skill/fusion/references/extension-tools.md", ); const capabilitiesPath = resolve( repoRoot, "packages/cli/skill/fusion/references/fusion-capabilities.md", ); // --------------------------------------------------------------------------- // U3: skip-the-spawn cache for the --check path. // // The inner loop (scripts/test-changed.mjs) used to spawn this script on every // `pnpm test`. The --check pass is deterministic over a small, fixed set of // inputs: the extension source of truth, the three generated docs, and this // script itself (its logic affects the output). When none of those have changed // since the last passing --check, the spawn is pure overhead. We cache the // content hash of those inputs after a clean --check and let the caller skip // spawning when the hash is unchanged. // --------------------------------------------------------------------------- /** Repo-relative input paths whose content determines the --check result. */ export const SKILL_SYNC_INPUT_PATHS = [ "packages/cli/src/extension.ts", "packages/engine/src/agent-tools.ts", "packages/cli/skill/fusion/SKILL.md", "packages/cli/skill/fusion/references/extension-tools.md", "packages/cli/skill/fusion/references/fusion-capabilities.md", "scripts/sync-fusion-skill-tools.mjs", ]; const SKILL_SYNC_CACHE_VERSION = 1; function skillSyncCachePath(rootDir = repoRoot) { return join(fusionCacheDir(rootDir), "skill-sync-cache.json"); } /** * Compute the content hash over the skill-sync inputs. * * @param {string} [rootDir] * @param {object} [deps] Injectable git/read fns for tests. * @returns {string} */ export function computeSkillSyncHash(rootDir = repoRoot, deps = {}) { return computeContentHash({ rootDir, inputPaths: SKILL_SYNC_INPUT_PATHS, versionPrefix: `skill-sync-v${SKILL_SYNC_CACHE_VERSION}`, ...deps, }); } /** * Return true when a clean --check is already cached for the current inputs, so * the caller can skip spawning the check entirely. Full runs (CI / --full) * bypass this and always run. * * @param {string} [rootDir] * @param {object} [deps] * @returns {boolean} */ export function isSkillSyncCheckCached(rootDir = repoRoot, deps = {}) { const cache = readJsonCache(skillSyncCachePath(rootDir), null); if (!cache || cache.version !== SKILL_SYNC_CACHE_VERSION || typeof cache.hash !== "string") { return false; } return cache.hash === computeSkillSyncHash(rootDir, deps); } /** * Persist a passing --check result so the next run can skip the spawn. * * @param {string} [rootDir] * @param {object} [deps] */ export function recordSkillSyncCheckPass(rootDir = repoRoot, deps = {}) { try { const dir = fusionCacheDir(rootDir); mkdirSync(dir, { recursive: true }); const payload = { version: SKILL_SYNC_CACHE_VERSION, hash: computeSkillSyncHash(rootDir, deps), passedAt: new Date().toISOString(), }; writeFileSync(skillSyncCachePath(rootDir), JSON.stringify(payload, null, 2)); } catch { // Cache is an optimization; a write failure just means we spawn next time. } } const SKILL_BEGIN = ""; const SKILL_END = ""; const EXT_TOOLS_BEGIN = ""; const EXT_TOOLS_END = ""; const CAP_TABLE_BEGIN = ""; const CAP_TABLE_END = ""; const CATEGORY_ORDER = ["Task", "Workflow", "GitHub", "Mission", "Goal", "Agent", "Skills", "Insight", "Other"]; const CATEGORY_LABELS = { Task: "Task tools", Workflow: "Workflow tools", GitHub: "GitHub tools", Mission: "Mission tools", Goal: "Goal tools", Agent: "Agent tools", Skills: "Skills tools", Insight: "Insight tools", Other: "Other tools", }; const CATEGORY_HEADERS = { Task: "## Task Tools", Workflow: "## Workflow Tools", GitHub: "## GitHub Tools", Mission: "## Mission Tools", Goal: "## Goal Tools", Agent: "## Agent Tools", Skills: "## Skills Tools", Insight: "## Insight Tools", Other: "## Other Tools", }; function categorize(name) { if (name.includes("github")) return "GitHub"; if (name.startsWith("fn_task_")) return "Task"; /* * FNXC:SkillSync 2026-06-30-00:00: * FN-7245 made workflow authoring tools part of the public pi extension surface. Keep fn_workflow_* plus the trait vocabulary grouped as Workflow so generated skill docs teach agents to create, update, inspect, configure, and select workflows instead of hiding those tools under Other. */ if (name.startsWith("fn_workflow_") || name === "fn_trait_list") return "Workflow"; if ( name.startsWith("fn_mission_") || name.startsWith("fn_milestone_") || name.startsWith("fn_slice_") || name.startsWith("fn_feature_") ) { return "Mission"; } if (name.startsWith("fn_goal_")) return "Goal"; if ( name.startsWith("fn_agent_") || name === "fn_list_agents" || name === "fn_delegate_task" ) { return "Agent"; } if (name.startsWith("fn_skills_")) return "Skills"; if (name.startsWith("fn_insight_")) return "Insight"; return "Other"; } function parseStringLiterals(expression) { const literals = []; const regex = /"([^"\\]*(?:\\.[^"\\]*)*)"/g; for (const match of expression.matchAll(regex)) { literals.push(match[1].replace(/\\n/g, " ").replace(/\\"/g, '"')); } return literals; } function normalizeWhitespace(text) { return text.replace(/\s+/g, " ").trim(); } function findMatchingDelimiter(source, openIndex, openChar, closeChar) { let depth = 0; let inSingle = false; let inDouble = false; let inTemplate = false; let inLineComment = false; let inBlockComment = false; let escaped = false; for (let i = openIndex; i < source.length; i++) { const char = source[i]; const next = source[i + 1]; if (inLineComment) { if (char === "\n") inLineComment = false; continue; } if (inBlockComment) { if (char === "*" && next === "/") { inBlockComment = false; i++; } continue; } if (inSingle || inDouble || inTemplate) { if (escaped) { escaped = false; continue; } if (char === "\\") { escaped = true; continue; } if (inSingle && char === "'") inSingle = false; else if (inDouble && char === '"') inDouble = false; else if (inTemplate && char === "`") inTemplate = false; continue; } if (char === "/" && next === "/") { inLineComment = true; i++; continue; } if (char === "/" && next === "*") { inBlockComment = true; i++; continue; } if (char === "'") { inSingle = true; continue; } if (char === '"') { inDouble = true; continue; } if (char === "`") { inTemplate = true; continue; } if (char === openChar) depth++; if (char === closeChar) { depth--; if (depth === 0) return i; } } throw new Error(`Unbalanced ${openChar}${closeChar} while parsing extension.ts`); } function findMatchingBrace(source, openIndex) { return findMatchingDelimiter(source, openIndex, "{", "}"); } function findMatchingBracket(source, openIndex) { return findMatchingDelimiter(source, openIndex, "[", "]"); } function splitTopLevelProperties(objectBody) { const props = []; let start = 0; let depthParen = 0; let depthBrace = 0; let depthBracket = 0; let inSingle = false; let inDouble = false; let inTemplate = false; let inLineComment = false; let inBlockComment = false; let escaped = false; for (let i = 0; i < objectBody.length; i++) { const ch = objectBody[i]; const next = objectBody[i + 1]; if (inLineComment) { if (ch === "\n") inLineComment = false; continue; } if (inBlockComment) { if (ch === "*" && next === "/") { inBlockComment = false; i++; } continue; } if (inSingle || inDouble || inTemplate) { if (escaped) { escaped = false; continue; } if (ch === "\\") { escaped = true; continue; } if (inSingle && ch === "'") inSingle = false; else if (inDouble && ch === '"') inDouble = false; else if (inTemplate && ch === "`") inTemplate = false; continue; } if (ch === "/" && next === "/") { inLineComment = true; i++; continue; } if (ch === "/" && next === "*") { inBlockComment = true; i++; continue; } if (ch === "'") { inSingle = true; continue; } if (ch === '"') { inDouble = true; continue; } if (ch === "`") { inTemplate = true; continue; } if (ch === "(") depthParen++; else if (ch === ")") depthParen--; else if (ch === "{") depthBrace++; else if (ch === "}") depthBrace--; else if (ch === "[") depthBracket++; else if (ch === "]") depthBracket--; if ( ch === "," && depthParen === 0 && depthBrace === 0 && depthBracket === 0 ) { const prop = objectBody.slice(start, i).trim(); if (prop) props.push(prop); start = i + 1; } } const tail = objectBody.slice(start).trim(); if (tail) props.push(tail); return props; } function extractDescription(block) { const descMatch = block.match(/description:\s*([\s\S]*?),\s*promptSnippet:/); if (!descMatch) { const inline = block.match(/description:\s*([\s\S]*?),\s*parameters:/); if (!inline) return ""; return normalizeWhitespace(parseStringLiterals(inline[1]).join(" ")); } return normalizeWhitespace(parseStringLiterals(descMatch[1]).join(" ")); } function mapType(raw) { const value = raw.trim(); if (/^Type\.String\(/.test(value)) return "string"; if (/^Type\.Number\(/.test(value)) return "number"; if (/^Type\.Boolean\(/.test(value)) return "boolean"; if (/^Type\.Array\(/.test(value)) return "array"; if (/^Type\.Union\(/.test(value)) return "union"; if (/^Type\.Record\(/.test(value)) return "record"; if (/^Type\.Unknown\(/.test(value)) return "unknown"; if (/^StringEnum\(/.test(value)) return "string(enum)"; if (/^Type\.Literal\(/.test(value)) return "literal"; if (/^Type\.Null\(/.test(value)) return "null"; return "unknown"; } function parseParameters(block) { const paramsStart = block.indexOf("parameters:"); if (paramsStart === -1) return []; const objectStart = block.indexOf("Type.Object(", paramsStart); if (objectStart === -1) return []; const braceStart = block.indexOf("{", objectStart); if (braceStart === -1) return []; const braceEnd = findMatchingBrace(block, braceStart); return parseTypeObjectParameterBody(block.slice(braceStart + 1, braceEnd)); } function slicePropertyExpression(source, propertyName) { const propertyStart = source.indexOf(`${propertyName}:`); if (propertyStart === -1) return ""; let start = propertyStart + propertyName.length + 1; while (/\s/.test(source[start] ?? "")) start++; let depthParen = 0; let depthBrace = 0; let depthBracket = 0; let inSingle = false; let inDouble = false; let inTemplate = false; let escaped = false; for (let i = start; i < source.length; i++) { const ch = source[i]; if (inSingle || inDouble || inTemplate) { if (escaped) { escaped = false; continue; } if (ch === "\\") { escaped = true; continue; } if (inSingle && ch === "'") inSingle = false; else if (inDouble && ch === '"') inDouble = false; else if (inTemplate && ch === "`") inTemplate = false; continue; } if (ch === "'") { inSingle = true; continue; } if (ch === '"') { inDouble = true; continue; } if (ch === "`") { inTemplate = true; continue; } if (ch === "(") depthParen++; else if (ch === ")") depthParen--; else if (ch === "{") depthBrace++; else if (ch === "}") depthBrace--; else if (ch === "[") depthBracket++; else if (ch === "]") depthBracket--; else if (ch === "," && depthParen === 0 && depthBrace === 0 && depthBracket === 0) { return source.slice(start, i); } } return source.slice(start); } function extractParameterDescription(rawValue) { const descriptionExpression = slicePropertyExpression(rawValue, "description"); const concatenated = normalizeWhitespace(parseStringLiterals(descriptionExpression).join(" ")); if (concatenated) return concatenated; const inlineDescription = rawValue.match(/description:\s*"([^"\\]*(?:\\.[^"\\]*)*)"/); return inlineDescription ? normalizeWhitespace(inlineDescription[1].replace(/\\n/g, " ")) : ""; } function parseTypeObjectParameterBody(body) { const params = []; for (const prop of splitTopLevelProperties(body)) { const match = prop.match(/^([A-Za-z_][A-Za-z0-9_]*)\s*:\s*([\s\S]+)$/); if (!match) continue; const [, name, rawValue] = match; const optional = /^Type\.Optional\(/.test(rawValue.trim()); const inner = optional ? rawValue.trim().replace(/^Type\.Optional\(/, "").replace(/\)\s*$/, "") : rawValue.trim(); params.push({ name, type: mapType(inner), required: !optional, description: extractParameterDescription(rawValue), }); } return params; } function getExportedTypeObjectParameters(source, exportName) { const exportStart = source.indexOf(`export const ${exportName} = Type.Object(`); if (exportStart === -1) return []; const braceStart = source.indexOf("{", exportStart); if (braceStart === -1) return []; const braceEnd = findMatchingBrace(source, braceStart); return parseTypeObjectParameterBody(source.slice(braceStart + 1, braceEnd)); } function extractWorkflowExtensionSpecTools(source, engineSource) { const specStart = source.indexOf("const workflowExtensionToolSpecs"); if (specStart === -1) return []; const equalsStart = source.indexOf("=", specStart); const arrayStart = source.indexOf("[", equalsStart); if (arrayStart === -1) return []; const arrayEnd = findMatchingBracket(source, arrayStart); const body = source.slice(arrayStart + 1, arrayEnd); const tools = []; let fromIndex = 0; while (true) { const objectStart = body.indexOf("{", fromIndex); if (objectStart === -1) break; const objectEnd = findMatchingBrace(body, objectStart); const block = body.slice(objectStart, objectEnd + 1); const nameMatch = block.match(/name:\s*"(fn_[a-z_]+)"/); if (nameMatch) { const labelMatch = block.match(/label:\s*"([^"\\]*(?:\\.[^"\\]*)*)"/); const descriptionMatch = block.match(/description:\s*"([^"\\]*(?:\\.[^"\\]*)*)"/); const paramsRefMatch = block.match(/parameters:\s*([A-Za-z_][A-Za-z0-9_]*)/); tools.push({ name: nameMatch[1], label: labelMatch ? labelMatch[1] : "", description: descriptionMatch ? normalizeWhitespace(descriptionMatch[1]) : "", parameters: paramsRefMatch ? getExportedTypeObjectParameters(engineSource, paramsRefMatch[1]) : [], }); } fromIndex = objectEnd + 1; } return tools; } function extractTools(source, engineSource = "") { const tools = extractWorkflowExtensionSpecTools(source, engineSource); const seen = new Set(tools.map((tool) => tool.name)); const registerToken = "pi.registerTool("; let fromIndex = 0; while (true) { const start = source.indexOf(registerToken, fromIndex); if (start === -1) break; const braceStart = source.indexOf("{", start); const braceEnd = findMatchingBrace(source, braceStart); const block = source.slice(braceStart, braceEnd + 1); const nameMatch = block.match(/name:\s*"(fn_[a-z_]+)"/); if (nameMatch) { const name = nameMatch[1]; if (!seen.has(name)) { const labelMatch = block.match(/label:\s*"([^"\\]*(?:\\.[^"\\]*)*)"/); const label = labelMatch ? labelMatch[1] : ""; const description = extractDescription(block); const parameters = parseParameters(block); tools.push({ name, label, description, parameters }); seen.add(name); } } fromIndex = braceEnd + 1; } if (tools.length === 0) { throw new Error(`No fn_* tool registrations found in ${extensionPath}.`); } return tools; } function buildSkillCategoriesBlock(tools) { const grouped = new Map(CATEGORY_ORDER.map((c) => [c, []])); for (const tool of tools) { grouped.get(categorize(tool.name)).push(tool.name); } const lines = [SKILL_BEGIN]; for (const category of CATEGORY_ORDER) { const names = grouped.get(category); if (!names.length) continue; lines.push( `- **${CATEGORY_LABELS[category]}** — ${names.map((n) => `\`${n}\``).join(", ")}`, ); } lines.push(SKILL_END); return lines.join("\n"); } function buildExtensionToolsBlock(tools) { const grouped = new Map(CATEGORY_ORDER.map((c) => [c, []])); for (const tool of tools) { grouped.get(categorize(tool.name)).push(tool); } const lines = [EXT_TOOLS_BEGIN, ""]; for (const category of CATEGORY_ORDER) { const categoryTools = grouped.get(category); if (!categoryTools.length) continue; lines.push(CATEGORY_HEADERS[category]); lines.push(""); for (const tool of categoryTools) { lines.push(`### ${tool.name}`); lines.push(""); lines.push(tool.description || tool.label || "No description provided."); lines.push(""); if (tool.parameters.length === 0) { lines.push("No parameters."); } else { lines.push("| Parameter | Type | Required | Description |"); lines.push("|-----------|------|----------|-------------|"); for (const param of tool.parameters) { lines.push( `| \`${param.name}\` | ${param.type} | ${param.required ? "✓" : "—"} | ${param.description || ""} |`, ); } } lines.push(""); } } lines.push(EXT_TOOLS_END); return lines.join("\n"); } function buildCapabilitiesTableBlock(tools) { const lines = [ CAP_TABLE_BEGIN, "| Tool | Purpose |", "|------|---------|", ]; for (const tool of tools) { lines.push(`| \`${tool.name}\` | ${tool.description || tool.label || ""} |`); } lines.push(CAP_TABLE_END); return lines.join("\n"); } function replaceBlock(content, beginMarker, endMarker, newBlock, filePath) { const beginIdx = content.indexOf(beginMarker); const endIdx = content.indexOf(endMarker); if (beginIdx === -1 || endIdx === -1 || endIdx < beginIdx) { throw new Error( `Could not find markers in ${filePath}: ${beginMarker} ... ${endMarker}`, ); } const endLineEnd = endIdx + endMarker.length; return content.slice(0, beginIdx) + newBlock + content.slice(endLineEnd); } function main() { const checkOnly = process.argv.includes("--check"); const extensionSource = readFileSync(extensionPath, "utf-8"); const engineSource = readFileSync(engineAgentToolsPath, "utf-8"); const tools = extractTools(extensionSource, engineSource); const files = [ { path: skillPath, begin: SKILL_BEGIN, end: SKILL_END, block: buildSkillCategoriesBlock(tools), label: "SKILL.md tool-categories", }, { path: extensionToolsPath, begin: EXT_TOOLS_BEGIN, end: EXT_TOOLS_END, block: buildExtensionToolsBlock(tools), label: "extension-tools.md", }, { path: capabilitiesPath, begin: CAP_TABLE_BEGIN, end: CAP_TABLE_END, block: buildCapabilitiesTableBlock(tools), label: "fusion-capabilities.md tool table", }, ]; let changedCount = 0; const driftLabels = []; for (const file of files) { const current = readFileSync(file.path, "utf-8"); const updated = replaceBlock(current, file.begin, file.end, file.block, file.path); if (updated !== current) { changedCount++; driftLabels.push(file.label); if (!checkOnly) { writeFileSync(file.path, updated); } } } if (checkOnly) { if (changedCount > 0) { console.error( `[sync-fusion-skill-tools] Drift detected in ${driftLabels.join(", ")}.\n` + "Run `node scripts/sync-fusion-skill-tools.mjs` to regenerate synced docs.", ); process.exit(1); } // U3: cache the passing result so the inner loop can skip the next spawn. recordSkillSyncCheckPass(repoRoot); return; } if (changedCount === 0) { console.log( `[sync-fusion-skill-tools] All generated docs are up to date (${tools.length} tools).`, ); return; } console.log( `[sync-fusion-skill-tools] Updated ${changedCount} file(s) from extension.ts (${tools.length} tools).`, ); } // Only run the sync when invoked directly as a script — importing the module // (e.g. from tests for the cache helpers) must not trigger a full sync. if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { main(); }