singularity-forge/src/resources/extensions/sf/workflow-plugins.js
Mikael Hugo 04322f110a refactor: replace all inline error message ternaries with getErrorMessage()
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>
2026-05-11 14:46:30 +02:00

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),
};
}