FN-7274: document workflow tools in Fusion skill

Document the workflow-authoring tool surface in the packaged Fusion skill references.

- Add workflow tools to the generated Fusion skill category and capability tables.
- Teach the skill sync script to read workflow tool specs from extension and engine agent tool sources.
- Cover workflow tool documentation and cache inputs with sync tests.
- Add a changeset for the published CLI skill update.

Files changed:
 .changeset/fn-7274-workflow-skill-tools.md         |   7 +
 packages/cli/skill/fusion/SKILL.md                 |   1 +
 .../cli/skill/fusion/references/engine-tools.md    |   4 +-
 .../cli/skill/fusion/references/extension-tools.md |  83 +++++++++-
 .../skill/fusion/references/fusion-capabilities.md |   8 +
 packages/cli/src/__tests__/skill-sync.test.ts      |  60 ++++++-
 scripts/__tests__/skill-sync-cache.test.mjs        |   5 +
 scripts/sync-fusion-skill-tools.mjs                | 172 ++++++++++++++++++---
 8 files changed, 312 insertions(+), 28 deletions(-)

Fusion-Task-Id: FN-7274

Fusion-Task-Lineage: e1cd6bd8-aecc-49ca-96ed-a937a15a18a4

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-06-30 08:04:12 -07:00
parent a039c93f57
commit 94ddfe1064
8 changed files with 312 additions and 28 deletions

View File

@@ -47,6 +47,11 @@ function fakeGit(shaByPath, { dirty = [] } = {}) {
const baseShas = Object.fromEntries(SKILL_SYNC_INPUT_PATHS.map((p, i) => [p, `sha${i}`]));
test("skill sync cache watches extension and engine workflow tool sources", () => {
assert.ok(SKILL_SYNC_INPUT_PATHS.includes("packages/cli/src/extension.ts"));
assert.ok(SKILL_SYNC_INPUT_PATHS.includes("packages/engine/src/agent-tools.ts"));
});
test("recordSkillSyncCheckPass then isSkillSyncCheckCached returns true on unchanged inputs", () => {
withRoot((root) => {
const deps = { gitFn: fakeGit(baseShas), readFn: () => Buffer.from("") };

View File

@@ -26,6 +26,7 @@ import {
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,
@@ -51,6 +52,7 @@ const capabilitiesPath = resolve(
/** 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",
@@ -129,9 +131,10 @@ const CAP_TABLE_BEGIN =
"<!-- BEGIN: fusion-capabilities-tool-table (auto-generated by scripts/sync-fusion-skill-tools.mjs — do not edit by hand) -->";
const CAP_TABLE_END = "<!-- END: fusion-capabilities-tool-table -->";
const CATEGORY_ORDER = ["Task", "GitHub", "Mission", "Goal", "Agent", "Skills", "Insight", "Other"];
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",
@@ -143,6 +146,7 @@ const CATEGORY_LABELS = {
const CATEGORY_HEADERS = {
Task: "## Task Tools",
Workflow: "## Workflow Tools",
GitHub: "## GitHub Tools",
Mission: "## Mission Tools",
Goal: "## Goal Tools",
@@ -155,6 +159,11 @@ const CATEGORY_HEADERS = {
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_") ||
@@ -189,7 +198,7 @@ function normalizeWhitespace(text) {
return text.replace(/\s+/g, " ").trim();
}
function findMatchingBrace(source, openIndex) {
function findMatchingDelimiter(source, openIndex, openChar, closeChar) {
let depth = 0;
let inSingle = false;
let inDouble = false;
@@ -253,14 +262,22 @@ function findMatchingBrace(source, openIndex) {
continue;
}
if (char === "{") depth++;
if (char === "}") {
if (char === openChar) depth++;
if (char === closeChar) {
depth--;
if (depth === 0) return i;
}
}
throw new Error("Unbalanced braces while parsing extension.ts");
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) {
@@ -372,7 +389,10 @@ function mapType(raw) {
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";
}
@@ -387,8 +407,77 @@ function parseParameters(block) {
const braceStart = block.indexOf("{", objectStart);
if (braceStart === -1) return [];
const braceEnd = findMatchingBrace(block, braceStart);
const body = block.slice(braceStart + 1, braceEnd);
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]+)$/);
@@ -399,24 +488,65 @@ function parseParameters(block) {
? rawValue.trim().replace(/^Type\.Optional\(/, "").replace(/\)\s*$/, "")
: rawValue.trim();
const descMatch = rawValue.match(/description:\s*"([^"\\]*(?:\\.[^"\\]*)*)"/);
const description = descMatch
? normalizeWhitespace(descMatch[1].replace(/\\n/g, " "))
: "";
params.push({
name,
type: mapType(inner),
required: !optional,
description,
description: extractParameterDescription(rawValue),
});
}
return params;
}
function extractTools(source) {
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;
@@ -430,11 +560,14 @@ function extractTools(source) {
const nameMatch = block.match(/name:\s*"(fn_[a-z_]+)"/);
if (nameMatch) {
const name = nameMatch[1];
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 });
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;
@@ -538,7 +671,8 @@ function main() {
const checkOnly = process.argv.includes("--check");
const extensionSource = readFileSync(extensionPath, "utf-8");
const tools = extractTools(extensionSource);
const engineSource = readFileSync(engineAgentToolsPath, "utf-8");
const tools = extractTools(extensionSource, engineSource);
const files = [
{