@kontextmind/kxm
Version:
KXM local-first multi-agent orchestration and operator dashboard
432 lines (396 loc) • 14.7 kB
text/typescript
/**
* KXM Workflow Management Subsystem.
* Supports add, remove, modify, list, and inspection of workflow YAML definitions.
* Handles global (~/.config/kxm/workflows/) and local (.kxm/workflows/) scoping.
*/
import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { stringify } from "yaml";
import { repoConfigDirectory, userConfigDirectory } from "./config.ts";
import { compileKxmWorkflow } from "./engine-compile.ts";
import { KxmConfigError, KxmSchemaRegistry, kxmResourceIdentifier, parseRestrictedYaml } from "./project-config.ts";
function assertWorkflowId(workflowId: string): void {
if (!kxmResourceIdentifier(workflowId)) {
throw new Error(`workflow_id_invalid: '${workflowId}' must be a flat lowercase slug of at most 64 characters, starting with a letter and using letters, digits, single hyphens or underscores; paths and platform-reserved names are not allowed (use 'bug-fix', not 'software-engineering/bug-fix')`);
}
}
let workflowSchemaRegistry: KxmSchemaRegistry | undefined;
function workflowPayload(workflowId: string, content: Record<string, unknown> | string, filePath: string): string {
const payload = typeof content === "string" ? content : stringify(content);
const value = parseRestrictedYaml(payload, filePath);
const issues = (workflowSchemaRegistry ??= new KxmSchemaRegistry()).validate("workflow", value, filePath);
if (issues.length > 0) throw new KxmConfigError(issues);
compileKxmWorkflow({ id: workflowId, value, logicalPath: filePath });
return payload;
}
export interface WorkflowDefSummary {
id: string;
description: string;
scope: "global" | "local" | "overridden";
filePath: string;
stepCount: number;
roles: string[];
}
// Fresh objects per use: YAML stringify turns a repeated object into an
// anchor and alias, which the restricted project loader refuses.
const completed = () => ({ target: "$terminal", terminalStatus: "completed" });
const failed = () => ({ target: "$terminal", terminalStatus: "failed" });
/** The project's `test` gate from `kxm init`. A gate step settles on `passed` or
* `implementation-failure`, never `failed`. `maxAttempts` counts entries into a
* step, so every step a back-edge re-enters allows one entry per transition. */
function verifyStep(retryStep: string): Record<string, unknown> {
return {
id: "verify",
kind: "gate",
gate: "test",
expect: "pass",
maxAttempts: 3,
repositories: { control: "write" },
on: {
passed: completed(),
"implementation-failure": { target: retryStep, maxTransitions: 2 },
},
};
}
/**
* Built-in `kxm workflow add --template` definitions. Each is a complete
* kxm.workflow.v1 document (the file name is the workflow id) that uses only
* what `kxm init` creates: the planner and writer agents, the control
* repository, and the `test` gate. Agent steps declare `failed` because the
* producer falls back to it. `coordinator` and `implementer` still resolve
* as aliases of `planner` and `writer`.
*/
export const WORKFLOW_TEMPLATES: Record<string, Record<string, unknown>> = {
"implement-and-verify": {
schema: "kxm.workflow.v1",
description: "Implement a change, then run the project's test gate; a failing gate sends the work back to writer.",
coordinator: "planner",
limits: {
maxTransitions: 8,
},
steps: [
{
id: "writer",
aliases: ["implement"],
kind: "agent",
agent: "writer",
maxAttempts: 3,
repositories: { control: "write" },
on: {
passed: "verify",
failed: failed(),
},
},
verifyStep("writer"),
],
},
"dual-critic-review": {
schema: "kxm.workflow.v1",
description: "Write, review twice, then run the project's test gate. Both reviews run as the planner agent; for independent reviewers, add reviewer-arch and reviewer-cli agents and point the review steps at them.",
coordinator: "planner",
limits: {
maxTransitions: 12,
},
steps: [
{
id: "writer",
aliases: ["implement"],
kind: "agent",
agent: "writer",
maxAttempts: 3,
repositories: { control: "write" },
on: {
passed: "reviewer-arch",
failed: failed(),
},
},
{
id: "reviewer-arch",
aliases: ["review-arch", "critic-arch"],
kind: "agent",
agent: "planner",
maxAttempts: 3,
repositories: { control: "read" },
on: {
passed: "reviewer-cli",
failed: { target: "writer", maxTransitions: 2 },
},
},
{
id: "reviewer-cli",
aliases: ["review-cli", "critic-cli"],
kind: "agent",
agent: "planner",
maxAttempts: 3,
repositories: { control: "read" },
on: {
passed: "verify",
failed: { target: "writer", maxTransitions: 2 },
},
},
verifyStep("writer"),
],
},
"spec-and-plan": {
schema: "kxm.workflow.v1",
description: "Plan a change, then review the plan. Both steps run as the planner agent and only read the repository.",
coordinator: "planner",
limits: {
maxTransitions: 8,
},
steps: [
{
id: "plan",
kind: "agent",
agent: "planner",
maxAttempts: 3,
repositories: { control: "read" },
on: {
passed: "reviewer-arch",
failed: failed(),
},
},
{
id: "reviewer-arch",
aliases: ["review-arch", "critic-arch"],
kind: "agent",
agent: "planner",
maxAttempts: 3,
repositories: { control: "read" },
on: {
passed: completed(),
failed: { target: "plan", maxTransitions: 2 },
},
},
],
},
};
/** The one-step definition `kxm workflow add <id>` writes without a template or file. */
export function scaffoldWorkflowDefinition(description: string): Record<string, unknown> {
return {
schema: "kxm.workflow.v1",
description,
coordinator: "planner",
limits: { maxTransitions: 8 },
steps: [
{
id: "step-1",
kind: "agent",
agent: "writer",
repositories: { control: "write" },
on: { passed: completed(), failed: failed() },
},
],
};
}
export const DEFAULT_WORKFLOW_TEMPLATE = WORKFLOW_TEMPLATES["implement-and-verify"]!;
export function workflowsDirectory(
scope: "global" | "local",
repoRoot = process.cwd(),
userConfigDir?: string,
): string {
if (scope === "global") {
return join(userConfigDirectory(userConfigDir), "workflows");
}
return join(repoConfigDirectory(repoRoot), "workflows");
}
export function ensureWorkflowsDirectory(
scope: "global" | "local",
repoRoot = process.cwd(),
userConfigDir?: string,
): string {
const dir = workflowsDirectory(scope, repoRoot, userConfigDir);
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true });
}
return dir;
}
export function parseWorkflowFile(filePath: string): Record<string, unknown> | undefined {
if (!existsSync(filePath)) return undefined;
try {
const raw = readFileSync(filePath, "utf8");
return parseRestrictedYaml(raw, filePath);
} catch {
return undefined;
}
}
export function listWorkflowDefinitions(options: {
scope?: "all" | "global" | "local" | undefined;
repoRoot?: string | undefined;
userConfigDir?: string | undefined;
} = {}): WorkflowDefSummary[] {
const scopeFilter = options.scope ?? "all";
const repoRoot = options.repoRoot ?? process.cwd();
const globalDir = workflowsDirectory("global", repoRoot, options.userConfigDir);
const localDir = workflowsDirectory("local", repoRoot, options.userConfigDir);
const localDefs = new Map<string, { def: Record<string, unknown>; filePath: string }>();
if (scopeFilter !== "global" && existsSync(localDir)) {
for (const entry of readdirSync(localDir)) {
const id = entry.endsWith(".yaml") ? entry.slice(0, -5) : entry.endsWith(".yml") ? entry.slice(0, -4) : undefined;
if (id && kxmResourceIdentifier(id)) {
const filePath = join(localDir, entry);
const def = parseWorkflowFile(filePath);
if (def) {
localDefs.set(id, { def, filePath });
}
}
}
}
const globalDefs = new Map<string, { def: Record<string, unknown>; filePath: string }>();
if (scopeFilter !== "local" && existsSync(globalDir)) {
for (const entry of readdirSync(globalDir)) {
const id = entry.endsWith(".yaml") ? entry.slice(0, -5) : entry.endsWith(".yml") ? entry.slice(0, -4) : undefined;
if (id && kxmResourceIdentifier(id)) {
const filePath = join(globalDir, entry);
const def = parseWorkflowFile(filePath);
if (def) {
globalDefs.set(id, { def, filePath });
}
}
}
}
const extractRoles = (def: Record<string, unknown>): string[] => {
const roles: string[] = [];
const steps = Array.isArray(def.steps) ? def.steps : [];
for (const step of steps) {
if (step && typeof step === "object") {
const role = (step as Record<string, unknown>).agent;
if (typeof role === "string" && !roles.includes(role)) {
roles.push(role);
}
}
}
return roles;
};
const result: WorkflowDefSummary[] = [];
for (const [id, { def, filePath }] of localDefs) {
const isOverridden = globalDefs.has(id);
const steps = Array.isArray(def.steps) ? def.steps : [];
result.push({
id,
description: typeof def.description === "string" ? def.description : "",
scope: isOverridden ? "overridden" : "local",
filePath,
stepCount: steps.length,
roles: extractRoles(def),
});
}
for (const [id, { def, filePath }] of globalDefs) {
if (scopeFilter === "global" || !localDefs.has(id)) {
const steps = Array.isArray(def.steps) ? def.steps : [];
result.push({
id,
description: typeof def.description === "string" ? def.description : "",
scope: "global",
filePath,
stepCount: steps.length,
roles: extractRoles(def),
});
}
}
result.sort((a, b) => a.id.localeCompare(b.id));
return result;
}
export function getWorkflowDefinition(
workflowId: string,
options: {
scope?: "all" | "global" | "local" | undefined;
repoRoot?: string | undefined;
userConfigDir?: string | undefined;
} = {},
): { workflow: Record<string, unknown>; scope: "global" | "local"; filePath: string } | undefined {
assertWorkflowId(workflowId);
const scope = options.scope ?? "all";
const repoRoot = options.repoRoot ?? process.cwd();
// 1. Check local first
if (scope !== "global") {
const localDir = workflowsDirectory("local", repoRoot, options.userConfigDir);
const localFile = join(localDir, `${workflowId}.yaml`);
const parsed = parseWorkflowFile(localFile);
if (parsed) return { workflow: parsed, scope: "local", filePath: localFile };
}
// 2. Check global
if (scope !== "local") {
const globalDir = workflowsDirectory("global", repoRoot, options.userConfigDir);
const globalFile = join(globalDir, `${workflowId}.yaml`);
const parsed = parseWorkflowFile(globalFile);
if (parsed) return { workflow: parsed, scope: "global", filePath: globalFile };
}
return undefined;
}
export function addWorkflowDefinition(
workflowId: string,
content: Record<string, unknown> | string = DEFAULT_WORKFLOW_TEMPLATE,
options: {
scope?: "global" | "local" | undefined;
repoRoot?: string | undefined;
userConfigDir?: string | undefined;
overwrite?: boolean | undefined;
dryRun?: boolean | undefined;
} = {},
): { id: string; filePath: string; scope: "global" | "local" } {
assertWorkflowId(workflowId);
const scope = options.scope ?? "local";
const repoRoot = options.repoRoot ?? process.cwd();
const dir = workflowsDirectory(scope, repoRoot, options.userConfigDir);
const filePath = join(dir, `${workflowId}.yaml`);
if (existsSync(filePath) && !options.overwrite) {
throw new Error(`workflow_already_exists: workflow '${workflowId}' already exists at ${filePath}`);
}
const payload = workflowPayload(workflowId, content, filePath);
if (!options.dryRun) {
ensureWorkflowsDirectory(scope, repoRoot, options.userConfigDir);
writeFileSync(filePath, payload, "utf8");
}
return { id: workflowId, filePath, scope };
}
export function removeWorkflowDefinition(
workflowId: string,
options: {
scope?: "global" | "local" | undefined;
repoRoot?: string | undefined;
userConfigDir?: string | undefined;
dryRun?: boolean | undefined;
} = {},
): { id: string; removed: boolean; filePath: string; scope: "global" | "local" } {
assertWorkflowId(workflowId);
const scope = options.scope ?? "local";
const repoRoot = options.repoRoot ?? process.cwd();
const dir = workflowsDirectory(scope, repoRoot, options.userConfigDir);
const filePath = join(dir, `${workflowId}.yaml`);
if (!existsSync(filePath)) {
throw new Error(`workflow_not_found: workflow '${workflowId}' not found in ${scope} directory (${filePath})`);
}
if (options.dryRun) return { id: workflowId, removed: false, filePath, scope };
rmSync(filePath);
return { id: workflowId, removed: true, filePath, scope };
}
export function modifyWorkflowDefinition(
workflowId: string,
updates: Record<string, unknown>,
options: {
scope?: "global" | "local" | undefined;
repoRoot?: string | undefined;
userConfigDir?: string | undefined;
dryRun?: boolean | undefined;
} = {},
): { id: string; workflow: Record<string, unknown>; filePath: string; scope: "global" | "local" } {
const target = getWorkflowDefinition(workflowId, { scope: options.scope, repoRoot: options.repoRoot, userConfigDir: options.userConfigDir });
if (!target) {
throw new Error(`workflow_not_found: workflow '${workflowId}' does not exist`);
}
const updated: Record<string, unknown> = {
...target.workflow,
...updates,
};
const scope = options.scope ?? target.scope;
const repoRoot = options.repoRoot ?? process.cwd();
const dir = workflowsDirectory(scope, repoRoot, options.userConfigDir);
const filePath = join(dir, `${workflowId}.yaml`);
const payload = workflowPayload(workflowId, updated, filePath);
if (!options.dryRun) {
ensureWorkflowsDirectory(scope, repoRoot, options.userConfigDir);
writeFileSync(filePath, payload, "utf8");
}
return { id: workflowId, workflow: updated, filePath, scope };
}