Eliminates ~120 repetitions of `err instanceof Error ? err.message : String(err)` across the entire extension source tree. All callers now import and use `getErrorMessage` from the canonical `./error-utils.js`. Files updated (56 files): - auto.js, auto-worktree.js, auto-recovery.js, auto-dashboard.js, auto-timers.js - auto-prompts.js, auto-start.js, auto-post-unit.js, auto-model-selection.js - auto/phases.js, auto/loop.js, auto/infra-errors.js - autonomous-solver-eval.js, bootstrap/agent-end-recovery.js, bootstrap/db-tools.js - bootstrap/exec-tools.js, bootstrap/journal-tools.js, bootstrap/register-extension.js - bootstrap/register-hooks.js, canonical-milestone-plan.js, changelog.js - clean-root-preflight.js, code-intelligence.js, commands-add-tests.js - commands-debug.js, commands-eval-review.js, commands-handlers.js - commands-maintenance.js, commands-pr-branch.js, commands-scan.js, commands-ship.js - commands-todo.js, commands-worktree.js, definition-io.js, doctor.js - doctor-config-checks.js, doctor-engine-checks.js, ecosystem/loader.js - eval-review-schema.js, exec-sandbox.js, execution-instruction-guard.js - graph-context.js, hook-emitter.js, index.js, learning/runtime.js - lifecycle-hooks.js, onboarding-state.js, orphan-worktree-sweep.js - planning-depth.js, quick.js, scaffold-keeper.js, sf-db/sf-db-core.js - slice-cadence.js, sm-client.js, spec-projections.js, subagent/background-jobs.js - subagent/isolation.js, sync-scheduler.js, tools/exec-tool.js - tools/sift-search-tool.js, tools/workflow-tool-executors.js, ui/index.js - uok/a2a-agent-server.js, uok/auto-dispatch.js, uok/auto-unit-closeout.js - uok/auto-verification.js, uok/chaos-monkey.js, uok/gate-runner.js - vault-resolver.js, workflow-install.js, workflow-plugins.js, worktree-manager.js - worktree-resolver.js Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
350 lines
11 KiB
JavaScript
350 lines
11 KiB
JavaScript
/**
|
|
* workflow-plugins.ts — Unified discovery for workflow plugins.
|
|
*
|
|
* Discovers workflow definitions from three tiers (project > global > bundled)
|
|
* in both YAML and markdown formats. Each plugin declares an execution mode
|
|
* that controls how `/workflow <name>` dispatches it:
|
|
*
|
|
* oneshot — prompt-only, no state or scaffolding
|
|
* yaml-step — CustomWorkflowEngine run with GRAPH.yaml
|
|
* markdown-phase — STATE.json + phase gates (current md template behavior)
|
|
* auto-milestone — hooks into /autonomous pipeline (full-project only)
|
|
*
|
|
* Precedence: project > global > bundled. Same-named file wins.
|
|
*/
|
|
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
|
import { sfHome } from './sf-home.js';
|
|
import { basename, extname, join } from "node:path";
|
|
import { parse as parseYaml } from "yaml";
|
|
import { loadRegistry } from "./workflow-templates.js";
|
|
import { getErrorMessage } from "./error-utils.js";
|
|
|
|
// ─── Path resolution ─────────────────────────────────────────────────────
|
|
function resolveBundledDir() {
|
|
const moduleDir = import.meta.dirname;
|
|
const local = join(moduleDir, "workflow-templates");
|
|
if (existsSync(local)) return local;
|
|
const agentSfDir = join(
|
|
sfHome(),
|
|
"agent",
|
|
"extensions",
|
|
"sf",
|
|
"workflow-templates",
|
|
);
|
|
if (existsSync(agentSfDir)) return agentSfDir;
|
|
return local;
|
|
}
|
|
function globalPluginsDir() {
|
|
return join(sfHome(), "workflows");
|
|
}
|
|
function projectPluginsDir(basePath) {
|
|
return join(basePath, ".sf", "workflows");
|
|
}
|
|
function legacyDefsDir(basePath) {
|
|
return join(basePath, ".sf", "workflow-defs");
|
|
}
|
|
// ─── Markdown frontmatter parsing ────────────────────────────────────────
|
|
/**
|
|
* Parse the `<template_meta>` block from bundled/user markdown workflow files.
|
|
* Returns a loose key-value map (strings only).
|
|
*/
|
|
function parseTemplateMeta(content) {
|
|
const match = content.match(/<template_meta>([\s\S]*?)<\/template_meta>/);
|
|
if (!match) return {};
|
|
const body = match[1];
|
|
const result = {};
|
|
for (const line of body.split(/\r?\n/)) {
|
|
const trimmed = line.trim();
|
|
if (!trimmed) continue;
|
|
const colon = trimmed.indexOf(":");
|
|
if (colon === -1) continue;
|
|
const key = trimmed.slice(0, colon).trim();
|
|
const value = trimmed.slice(colon + 1).trim();
|
|
result[key] = value;
|
|
}
|
|
return result;
|
|
}
|
|
function parsePhasesFromMarkdown(content) {
|
|
const match = content.match(/<phases>([\s\S]*?)<\/phases>/);
|
|
if (!match) return [];
|
|
const phases = [];
|
|
for (const line of match[1].split(/\r?\n/)) {
|
|
const m = line.match(/^\s*\d+\.\s*(\S+)/);
|
|
if (m) phases.push(m[1]);
|
|
}
|
|
return phases;
|
|
}
|
|
function firstHeading(content) {
|
|
const match = content.match(/^#\s+(.+)$/m);
|
|
return match ? match[1].trim() : undefined;
|
|
}
|
|
function isValidMode(v) {
|
|
return (
|
|
v === "oneshot" ||
|
|
v === "yaml-step" ||
|
|
v === "markdown-phase" ||
|
|
v === "auto-milestone"
|
|
);
|
|
}
|
|
// ─── Single-file plugin loaders ──────────────────────────────────────────
|
|
function loadMarkdownPlugin(filePath, source) {
|
|
const name = basenameNoExt(filePath);
|
|
let content;
|
|
try {
|
|
content = readFileSync(filePath, "utf-8");
|
|
} catch {
|
|
return null;
|
|
}
|
|
const meta = parseTemplateMeta(content);
|
|
const phases = parsePhasesFromMarkdown(content);
|
|
const declaredMode = meta.mode;
|
|
const mode = isValidMode(declaredMode) ? declaredMode : "markdown-phase";
|
|
const triggers = meta.triggers
|
|
? meta.triggers
|
|
.split(",")
|
|
.map((s) => s.trim())
|
|
.filter(Boolean)
|
|
: undefined;
|
|
const artifactDirValue =
|
|
meta.artifact_dir === "null" || meta.artifact_dir === ""
|
|
? null
|
|
: meta.artifact_dir;
|
|
return {
|
|
name,
|
|
path: filePath,
|
|
format: "md",
|
|
source,
|
|
meta: {
|
|
displayName: meta.name || firstHeading(content) || name,
|
|
description: meta.description,
|
|
mode,
|
|
phases: phases.length > 0 ? phases : undefined,
|
|
triggers,
|
|
complexity: meta.complexity,
|
|
artifactDir: artifactDirValue ?? undefined,
|
|
requiresProject: meta.requires_project === "true",
|
|
},
|
|
};
|
|
}
|
|
function loadYamlPlugin(filePath, source) {
|
|
const name = basenameNoExt(filePath);
|
|
let raw;
|
|
try {
|
|
raw = readFileSync(filePath, "utf-8");
|
|
} catch {
|
|
return null;
|
|
}
|
|
let parsed;
|
|
try {
|
|
parsed = parseYaml(raw);
|
|
} catch (err) {
|
|
return {
|
|
name,
|
|
path: filePath,
|
|
format: "yaml",
|
|
source,
|
|
meta: { displayName: name, mode: "yaml-step" },
|
|
error: `YAML parse error: ${getErrorMessage(err)}`,
|
|
};
|
|
}
|
|
if (parsed == null || typeof parsed !== "object") {
|
|
return {
|
|
name,
|
|
path: filePath,
|
|
format: "yaml",
|
|
source,
|
|
meta: { displayName: name, mode: "yaml-step" },
|
|
error: "Definition is not an object",
|
|
};
|
|
}
|
|
const def = parsed;
|
|
const declaredMode = def.mode;
|
|
const mode = isValidMode(declaredMode) ? declaredMode : "yaml-step";
|
|
const steps = Array.isArray(def.steps) ? def.steps : [];
|
|
const phases = steps.map((s) => String(s.id ?? "")).filter(Boolean);
|
|
return {
|
|
name,
|
|
path: filePath,
|
|
format: "yaml",
|
|
source,
|
|
meta: {
|
|
displayName:
|
|
typeof def.name === "string" && def.name.trim() ? def.name : name,
|
|
description:
|
|
typeof def.description === "string" ? def.description : undefined,
|
|
mode,
|
|
phases: phases.length > 0 ? phases : undefined,
|
|
},
|
|
};
|
|
}
|
|
function basenameNoExt(filePath) {
|
|
const ext = extname(filePath);
|
|
return basename(filePath, ext);
|
|
}
|
|
// ─── Directory walkers ───────────────────────────────────────────────────
|
|
const PLUGIN_EXTENSIONS = new Set([".yaml", ".yml", ".md"]);
|
|
function walkPluginDir(dir, source, out) {
|
|
if (!existsSync(dir)) return;
|
|
let entries;
|
|
try {
|
|
entries = readdirSync(dir);
|
|
} catch {
|
|
return;
|
|
}
|
|
for (const entry of entries) {
|
|
const full = join(dir, entry);
|
|
let info;
|
|
try {
|
|
info = statSync(full);
|
|
} catch {
|
|
continue;
|
|
}
|
|
if (!info.isFile()) continue;
|
|
const ext = extname(entry).toLowerCase();
|
|
if (!PLUGIN_EXTENSIONS.has(ext)) continue;
|
|
const plugin =
|
|
ext === ".md"
|
|
? loadMarkdownPlugin(full, source)
|
|
: loadYamlPlugin(full, source);
|
|
if (!plugin) continue;
|
|
out.set(plugin.name, plugin);
|
|
}
|
|
}
|
|
function loadBundledPlugins(out) {
|
|
const bundledDir = resolveBundledDir();
|
|
if (!existsSync(bundledDir)) return;
|
|
const registry = loadRegistry();
|
|
for (const [id, entry] of Object.entries(registry.templates)) {
|
|
const filePath = join(bundledDir, entry.file);
|
|
if (!existsSync(filePath)) continue;
|
|
const ext = extname(entry.file).toLowerCase();
|
|
const format = ext === ".md" ? "md" : "yaml";
|
|
// TemplateEntry doesn't carry mode — default by format
|
|
const mode = format === "yaml" ? "yaml-step" : "markdown-phase";
|
|
out.set(id, {
|
|
name: id,
|
|
path: filePath,
|
|
format,
|
|
source: "bundled",
|
|
meta: {
|
|
displayName: entry.name,
|
|
description: entry.description,
|
|
mode,
|
|
phases:
|
|
Array.isArray(entry.phases) && entry.phases.length > 0
|
|
? entry.phases
|
|
: undefined,
|
|
triggers: Array.isArray(entry.triggers) ? entry.triggers : undefined,
|
|
complexity: entry.estimated_complexity,
|
|
artifactDir: entry.artifact_dir,
|
|
requiresProject: entry.requires_project,
|
|
},
|
|
});
|
|
}
|
|
}
|
|
// ─── Public API ──────────────────────────────────────────────────────────
|
|
/**
|
|
* Discover all workflow plugins. Project overrides global overrides bundled.
|
|
*
|
|
* The legacy `.sf/workflow-defs/*.yaml` directory is also scanned as a
|
|
* fallback YAML source so existing user definitions keep working.
|
|
*/
|
|
export function discoverPlugins(basePath) {
|
|
const out = new Map();
|
|
loadBundledPlugins(out);
|
|
walkPluginDir(globalPluginsDir(), "global", out);
|
|
walkPluginDir(legacyDefsDir(basePath), "project", out);
|
|
walkPluginDir(projectPluginsDir(basePath), "project", out);
|
|
return out;
|
|
}
|
|
/**
|
|
* Resolve a plugin by name using the precedence chain.
|
|
* Returns null if no plugin by that name exists anywhere.
|
|
*/
|
|
export function resolvePlugin(basePath, name) {
|
|
const plugins = discoverPlugins(basePath);
|
|
return plugins.get(name) ?? null;
|
|
}
|
|
/**
|
|
* Format all discovered plugins for display, grouped by mode.
|
|
*/
|
|
export function listPluginsFormatted(basePath) {
|
|
const plugins = discoverPlugins(basePath);
|
|
if (plugins.size === 0) {
|
|
return "No workflow plugins found.\n\nRun /workflow new to author one.";
|
|
}
|
|
const groups = {
|
|
oneshot: [],
|
|
"yaml-step": [],
|
|
"markdown-phase": [],
|
|
"auto-milestone": [],
|
|
};
|
|
for (const p of plugins.values()) {
|
|
groups[p.meta.mode].push(p);
|
|
}
|
|
const lines = ["Workflow Plugins\n"];
|
|
const order = ["markdown-phase", "yaml-step", "oneshot", "auto-milestone"];
|
|
for (const mode of order) {
|
|
const list = groups[mode]
|
|
.slice()
|
|
.sort((a, b) => a.name.localeCompare(b.name));
|
|
if (list.length === 0) continue;
|
|
lines.push(` [${mode}]`);
|
|
for (const p of list) {
|
|
const tag = `${p.source}/${p.format}`;
|
|
const desc = p.meta.description ? ` — ${p.meta.description}` : "";
|
|
lines.push(` ${p.name.padEnd(22)} ${tag.padEnd(16)}${desc}`);
|
|
}
|
|
lines.push("");
|
|
}
|
|
lines.push("Usage:");
|
|
lines.push(" /workflow <name> Run a plugin directly");
|
|
lines.push(" /workflow info <name> Show plugin details");
|
|
lines.push(" /workflow install <src> Install a plugin from a URL");
|
|
return lines.join("\n");
|
|
}
|
|
/**
|
|
* Format a single plugin's metadata for `/workflow info <name>`.
|
|
*/
|
|
export function formatPluginInfo(plugin) {
|
|
const lines = [
|
|
`Plugin: ${plugin.meta.displayName} (${plugin.name})`,
|
|
"",
|
|
`Source: ${plugin.source}`,
|
|
`Format: ${plugin.format}`,
|
|
`Mode: ${plugin.meta.mode}`,
|
|
`Path: ${plugin.path}`,
|
|
];
|
|
if (plugin.meta.description) {
|
|
lines.push(`About: ${plugin.meta.description}`);
|
|
}
|
|
if (plugin.meta.complexity) {
|
|
lines.push(`Complexity: ${plugin.meta.complexity}`);
|
|
}
|
|
if (plugin.meta.phases && plugin.meta.phases.length > 0) {
|
|
lines.push("", "Phases/Steps:");
|
|
plugin.meta.phases.forEach((p, i) => lines.push(` ${i + 1}. ${p}`));
|
|
}
|
|
if (plugin.meta.triggers && plugin.meta.triggers.length > 0) {
|
|
lines.push("", `Triggers: ${plugin.meta.triggers.join(", ")}`);
|
|
}
|
|
if (plugin.meta.artifactDir) {
|
|
lines.push("", `Artifacts: ${plugin.meta.artifactDir}`);
|
|
}
|
|
if (plugin.error) {
|
|
lines.push("", `Warning: ${plugin.error}`);
|
|
}
|
|
return lines.join("\n");
|
|
}
|
|
/**
|
|
* Get the plugin directory paths for the project/global/bundled tiers.
|
|
* Exposed for the install command and tests.
|
|
*/
|
|
export function getPluginDirs(basePath) {
|
|
return {
|
|
project: projectPluginsDir(basePath),
|
|
global: globalPluginsDir(),
|
|
bundled: resolveBundledDir(),
|
|
legacy: legacyDefsDir(basePath),
|
|
};
|
|
}
|