Skill: kangyishuai/agent-workflow (version 1.0.0)

--- name: agent-workflow description: "A structured workflow plugin for OpenClaw agents. Guides work through brainstorm → plan → execute → verify → deliver with persistent state, branching, parallel context-plugins, and multi-project support. Trigger when the user wants to start a new project, follow a structured workflow, manage multiple concurrent projects, or navigate between workflow steps. Install as a Plugin (not a Skill) for full functionality including the agent_workflow tool. Do not trigger for simple one-off tasks." --- # Agent Workflow A structured workflow engine for OpenClaw agents. Migrated and generalized from the [superpowers](https://github.com/anthropics/claude-code-superpowers) workflow system into a code-agnostic, general-purpose workflow plugin. ## What it does Provides a persistent state machine that guides your agent through a complete work lifecycle: ``` brainstorming → writing-plans → [execute] → verification → finishing-work ↓ subagent-driven-execution OR executing-plans ``` With support for: - **Persistent state** — workflow survives session restarts - **Multi-project** — run multiple workflows concurrently - **Branching** — choose execution strategy at branch points - **Context-plugins** — fork into review/parallel-agents without leaving main flow - **Soft-guard goto** — jump to any step with warnings about skipped prerequisites - **11 bundled Skills** — covering the full workflow lifecycle ## Installation This is a **Plugin**, not a plain Skill. Install via: ```bash openclaw plugins install clawhub:agent-workflow openclaw gateway restart ``` Then enable in your `~/.openclaw/openclaw.json`: ```json { "plugins": { "allow": ["agent-workflow"] }, "tools": { "allow": ["agent_workflow"] } } ``` ## Usage In your agent (via Feishu, Discord, or any channel): ``` Start a new workflow for my Q2 planning project ``` The agent will call `agent_workflow` with `action: "start"` and guide you through the workflow. ## Tool: `agent_workflow` | Action | Description | |--------|-------------| | `start` | Begin a new workflow | | `status` | View current state (all active workflows if no ID given) | | `next` | Advance to the next step | | `goto` | Jump to any node (soft-guard warns about skipped steps) | | `complete` | Mark current node done | | `fork` | Activate a context-plugin without leaving main flow | | `join` | Complete a fork and return | | `getSkill` | Load full SKILL.md for the current node | | `list` | List all workflows | | `abandon` | Abandon a workflow | ## Bundled Skills - `brainstorming` — Turn ideas into specs - `writing-plans` — Break specs into tasks - `executing-plans` — Sequential execution - `subagent-driven-execution` — Parallel subagent execution - `verification-before-completion` — Evidence before claims - `finishing-work` — Delivery options - `dispatching-parallel-agents` — Fork independent tasks - `requesting-review` — Dispatch reviewer subagent - `receiving-review` — Evaluate feedback rigorously - `systematic-problem-solving` — Root-cause diagnosis - `writing-skills` — Create/improve Skills

path: index.ts
content: // index.ts
// Plugin entry point. Registers the agent_workflow tool with OpenClaw.

import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
import { Type } from "@sinclair/typebox";
import { fileURLToPath } from "node:url";
import { WorkflowEngine } from "./src/workflow-engine.js";
import { StateStore } from "./src/state-store.js";
import { SkillLoader } from "./src/skill-loader.js";
import path from "node:path";
import os from "node:os";

export default definePluginEntry({
  id: "agent-workflow",
  name: "Agent Workflow",
  description: "Structured workflow engine with persistent state, branching, and multi-project support.",

  register(api) {
    // Resolve storage directory
    const storageDir: string =
      (api.pluginConfig?.storageDir as string | undefined) ??
      path.join(os.homedir(), ".openclaw", "workspace");

    const maxForks: number =
      (api.pluginConfig?.maxForks as number | undefined) ?? 3;

    // Plugin root: prefer api.rootDir (always reliable), fall back to fileURLToPath
    // which correctly handles Windows paths (unlike raw import.meta.url.pathname)
    const pluginRoot = api.rootDir ?? path.dirname(fileURLToPath(import.meta.url));

    const store = new StateStore(storageDir);
    const skillLoader = new SkillLoader(pluginRoot);
    const engine = new WorkflowEngine(store, skillLoader, maxForks);

    // ── Register the agent_workflow tool ──────────────────────────────────────

    api.registerTool(() => ({
      name: "agent_workflow",
      label: "Agent Workflow",
      description: [
        "Navigate and manage structured workflows with persistent state.",
        "Supports brainstorm → plan → execute → verify → deliver with branching,",
        "parallel context-plugins, and multi-project concurrency.",
        "",
        "Actions:",
        "  start        — Begin a new workflow (auto-extracts projectName from context)",
        "  status       — View current state (omit workflowId to see all active)",
        "  next         — Advance to the next step (specify branch at branch points)",
        "  goto         — Jump to any node (soft guard warns about skipped steps)",
        "  complete     — Mark current node done, stay in place until next() is called",
        "  fork         — Activate a context-plugin without leaving the main flow",
        "  join         — Complete a fork and return to main flow",
        "  getSkill     — Load full SKILL.md content for a node (call after next/goto)",
        "  list         — List workflows (filter by status)",
        "  abandon      — Abandon a workflow",
      ].join("\n"),

      parameters: Type.Object({
        action: Type.Union([
          Type.Literal("start"),
          Type.Literal("status"),
          Type.Literal("next"),
          Type.Literal("goto"),
          Type.Literal("complete"),
          Type.Literal("fork"),
          Type.Literal("join"),
          Type.Literal("getSkill"),
          Type.Literal("list"),
          Type.Literal("abandon"),
        ]),

        // start
        projectName: Type.Optional(Type.String({
          description: "Project name. Extract from the user's conversation context and pass explicitly. Falls back to 'Untitled Project' if omitted.",
        })),

        // most state actions
        workflowId: Type.Optional(Type.String({
          description: "Target workflow ID. Required for next/goto/complete/fork/join/abandon.",
        })),

        // next
        branch: Type.Optional(Type.String({
          description: "Branch id to follow at a branch point (e.g. 'subagent' or 'sequential').",
        })),

        // goto / getSkill / fork
        step: Type.Optional(Type.String({
          description: [
            "Purpose depends on action:",
            "  goto: target node id to jump to",
            "  getSkill: node name whose SKILL.md to load",
            "  fork: context-plugin name to activate (one of: dispatching-parallel-agents, requesting-review, receiving-review)",
          ].join(" "),
        })),

        // join
        forkId: Type.Optional(Type.String({
          description: "Fork ID returned by fork(), required for join().",
        })),

        // complete / join
        note: Type.Optional(Type.String({
          description: "Optional note or summary to attach to the completed step.",
        })),

        // complete
        output: Type.Optional(Type.Any({
          description: "Optional structured output from the completed step.",
        })),

        // list
        status: Type.Optional(Type.String({
          description: "Filter workflows by status: active | paused | completed | abandoned.",
        })),
      }),

      async execute(_id, params) {
        let result: unknown;

        try {
          switch (params.action) {

            case "start": {
              const name = params.projectName ?? "Untitled Project";
              result = engine.start(name);
              break;
            }

            case "status": {
              result = engine.status(params.workflowId);
              break;
            }

            case "next": {
              if (!params.workflowId) {
                result = { ok: false, message: "workflowId is required for next()." };
                break;
              }
              result = engine.next(params.workflowId, params.branch);
              break;
            }

            case "goto": {
              if (!params.workflowId || !params.step) {
                result = { ok: false, message: "workflowId and step are required for goto()." };
                break;
              }
              result = engine.goto(params.workflowId, params.step);
              break;
            }

            case "complete": {
              if (!params.workflowId) {
                result = { ok: false, message: "workflowId is required for complete()." };
                break;
              }
              result = engine.complete(params.workflowId, params.note, params.output);
              break;
            }

            case "fork": {
              if (!params.workflowId || !params.step) {
                result = { ok: false, message: "workflowId and step (plugin name) are required for fork()." };
                break;
              }
              result = engine.fork(params.workflowId, params.step);
              break;
            }

            case "join": {
              if (!params.workflowId || !params.forkId) {
                result = { ok: false, message: "workflowId and forkId are required for join()." };
                break;
              }
              result = engine.join(params.workflowId, params.forkId, params.note);
              break;
            }

            case "getSkill": {
              if (!params.step) {
                result = { ok: false, message: "step (node name) is required for getSkill()." };
                break;
              }
              result = engine.getSkill(params.step);
              break;
            }

            case "list": {
              result = engine.list(params.status);
              break;
            }

            case "abandon": {
              if (!params.workflowId) {
                result = { ok: false, message: "workflowId is required for abandon()." };
                break;
              }
              result = engine.abandon(params.workflowId);
              break;
            }

            default:
              result = { ok: false, message: `Unknown action: ${String(params.action)}` };
          }
        } catch (err: unknown) {
          const msg = err instanceof Error ? err.message : String(err);
          api.logger.error(`[agent-workflow] Unexpected error in execute(): ${err instanceof Error ? err.message : String(err)}`);
          result = { ok: false, error: { code: "UNKNOWN", message: msg }, message: msg };
        }

        return {
          content: [
            {
              type: "text" as const,
              text: JSON.stringify(result, null, 2),
            },
          ],
          details: {},
        };
      },
    }));

    api.logger.info("agent-workflow plugin registered. Storage: " + storageDir);
  },
});


path: openclaw.plugin.json
content: {
  "id": "agent-workflow",
  "name": "Agent Workflow",
  "description": "Structured workflow engine for agents. Guides work through brainstorm → plan → execute → verify → deliver with persistent state, branching, parallel context-plugins, and multi-project support.",
  "skills": ["./skills"],
  "configSchema": {
    "type": "object",
    "additionalProperties": false,
    "properties": {
      "maxForks": {
        "type": "number",
        "default": 3,
        "description": "Maximum number of concurrent parallel forks per workflow"
      },
      "storageDir": {
        "type": "string",
        "description": "Custom directory for workflow state files. Defaults to ~/.openclaw/workspace/agent-workflow/"
      }
    }
  }
}


path: package.json
content: {
  "name": "@openclaw/agent-workflow",
  "version": "1.0.0",
  "description": "A structured workflow plugin for OpenClaw agents — brainstorm, plan, execute, verify, and deliver with persistent state and multi-project support.",
  "type": "module",
  "main": "./dist/index.js",
  "openclaw": {
    "extensions": [
      "./dist/index.js"
    ]
  },
  "scripts": {
    "build": "tsc",
    "prepublish": "tsc"
  },
  "keywords": [
    "workflow",
    "planning",
    "execution",
    "agent",
    "productivity"
  ],
  "license": "MIT",
  "dependencies": {
    "@sinclair/typebox": "^0.34.48"
  },
  "devDependencies": {
    "openclaw": "^2026.3.24",
    "typescript": "^6.0.2"
  }
}


path: src/skill-loader.ts
content: // src/skill-loader.ts
// Reads SKILL.md files from the skills/ directory.
// Parses frontmatter to extract summary (description field).
// Caches content in memory to avoid repeated disk reads.

import fs from "node:fs";
import path from "node:path";

interface SkillContent {
  summary: string;   // from frontmatter description field
  body: string;      // full markdown body (after frontmatter)
  raw: string;       // full file content
}

export class SkillLoader {
  private skillsDir: string;
  private cache: Map<string, SkillContent> = new Map();

  constructor(pluginRootDir: string) {
    this.skillsDir = path.join(pluginRootDir, "skills");
  }

  load(skillFile: string): SkillContent {
    if (this.cache.has(skillFile)) {
      return this.cache.get(skillFile)!;
    }

    const fullPath = path.join(this.skillsDir, skillFile);
    if (!fs.existsSync(fullPath)) {
      throw new Error(`Skill file not found: ${fullPath}`);
    }

    const raw = fs.readFileSync(fullPath, "utf-8");
    const parsed = this.parse(raw);
    this.cache.set(skillFile, parsed);
    return parsed;
  }

  getSummary(skillFile: string): string {
    return this.load(skillFile).summary;
  }

  getBody(skillFile: string): string {
    return this.load(skillFile).body;
  }

  private parse(raw: string): SkillContent {
    // Extract YAML frontmatter between --- delimiters
    const frontmatterMatch = raw.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/);
    if (!frontmatterMatch) {
      return { summary: "", body: raw, raw };
    }

    const frontmatterText = frontmatterMatch[1];
    const body = frontmatterMatch[2].trim();

    // Extract description field (handles quoted and unquoted values)
    const descMatch = frontmatterText.match(
      /^description:\s*["']?([\s\S]*?)["']?\s*$/m
    );
    let summary = "";
    if (descMatch) {
      // Clean up multi-line quoted descriptions
      summary = descMatch[1]
        .replace(/^["']|["']$/g, "")
        .replace(/\n\s*/g, " ")
        .trim();
      // Truncate to first sentence for the summary
      const firstSentence = summary.match(/^[^.!?]+[.!?]/);
      if (firstSentence) {
        summary = firstSentence[0].trim();
      }
    }

    return { summary, body, raw };
  }
}


path: src/state-store.ts
content: // src/state-store.ts
// Persistent workflow state using one JSON file per workflow.
// Storage: ~/.openclaw/workspace/agent-workflow/workflows/
// Each workflow gets its own file: {workflowId}.json
// An index file tracks all workflows: index.json

import fs from "node:fs";
import path from "node:path";
import crypto from "node:crypto";

// ─── Types ────────────────────────────────────────────────────────────────────

export type NodeStatus = "pending" | "active" | "completed" | "skipped";
export type WorkflowStatus = "active" | "paused" | "completed" | "abandoned";

export interface ForkState {
  forkId: string;
  plugin: string;             // context-plugin node id
  status: "active" | "completed";
  startedAt: string;
  completedAt?: string;
  joinTarget: string;         // main-flow node to return to after join
  note?: string;
}

export interface ContextStackEntry {
  returnTo: string;           // node to return to after utility is done
  enteredAt: string;
}

export interface HistoryEntry {
  node: string;
  enteredAt: string;
  completedAt?: string;
  note?: string;
  output?: unknown;
}

export interface WorkflowState {
  version: 1;
  workflowId: string;
  stateVersion: number;       // optimistic lock: increment on every write
  projectName: string;
  createdAt: string;
  updatedAt: string;
  status: WorkflowStatus;

  // Node state map (replaces currentNode + completedNodes)
  nodes: Record<string, NodeStatus>;

  // Currently active main-flow node id
  currentNode: string;

  // Parallel forks (context-plugins)
  forks: Record<string, ForkState>;

  // Utility node call stack
  contextStack: ContextStackEntry[];

  // Branch choices made at branch points
  branchChoices: Record<string, string>;

  // Full history
  history: HistoryEntry[];
}

export interface WorkflowIndex {
  workflows: WorkflowIndexEntry[];
}

export interface WorkflowIndexEntry {
  workflowId: string;
  projectName: string;
  status: WorkflowStatus;
  currentNode: string;
  createdAt: string;
  updatedAt: string;
}

// ─── Store class ──────────────────────────────────────────────────────────────

export class StateStore {
  private storageDir: string;
  private workflowsDir: string;
  private indexPath: string;
  private lockPath: string;

  constructor(baseDir: string) {
    this.storageDir = path.join(baseDir, "agent-workflow");
    this.workflowsDir = path.join(this.storageDir, "workflows");
    this.indexPath = path.join(this.workflowsDir, "index.json");
    this.lockPath = path.join(this.storageDir, ".lock");
    this.ensureDirectories();
  }

  private ensureDirectories(): void {
    fs.mkdirSync(this.workflowsDir, { recursive: true });
    if (!fs.existsSync(this.indexPath)) {
      this.writeJson(this.indexPath, { workflows: [] } as WorkflowIndex);
    }
  }

  private workflowPath(workflowId: string): string {
    return path.join(this.workflowsDir, `${workflowId}.json`);
  }

  private readJson<T>(filePath: string): T {
    try {
      const raw = fs.readFileSync(filePath, "utf-8");
      return JSON.parse(raw) as T;
    } catch (err) {
      throw new Error(
        `Failed to read or parse JSON at ${filePath}: ${err instanceof Error ? err.message : String(err)}`
      );
    }
  }

  private writeJson(filePath: string, data: unknown): void {
    // Use UUID for tmp name to avoid conflicts when same process writes concurrently
    const tmp = `${filePath}.tmp.${crypto.randomUUID()}`;
    fs.writeFileSync(tmp, JSON.stringify(data, null, 2), "utf-8");
    fs.renameSync(tmp, filePath);  // atomic replace on same filesystem
  }

  // Acquire a file lock with non-blocking retries (avoids busy-wait blocking event loop).
  // Uses a lock file with PID + timestamp to detect stale locks.
  private acquireLock(timeoutMs = 3000): void {
    const lockContent = `${process.pid}:${Date.now()}`;
    const staleThresholdMs = 5000; // locks older than 5s are considered stale
    const retryIntervalMs = 50;
    const start = Date.now();

    while (true) {
      try {
        fs.writeFileSync(this.lockPath, lockContent, { flag: "wx" });
        return; // acquired
      } catch {
        // Lock exists — check if stale
        try {
          const existing = fs.readFileSync(this.lockPath, "utf-8");
          const parts = existing.split(":");
          const lockTime = parts.length === 2 ? Number(parts[1]) : 0;
          if (Date.now() - lockTime > staleThresholdMs) {
            // Stale lock: remove it and retry immediately
            fs.unlinkSync(this.lockPath);
            continue;
          }
        } catch {
          // Lock file disappeared between check and read — retry
          continue;
        }

        if (Date.now() - start > timeoutMs) {
          throw new Error(
            `Could not acquire workflow lock after ${timeoutMs}ms. ` +
            `Another process may be holding it. Delete ${this.lockPath} if stuck.`
          );
        }

        // Yield to event loop instead of busy-waiting
        // In sync context this is the best we can do without going fully async
        Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, retryIntervalMs);
      }
    }
  }

  private releaseLock(): void {
    try { fs.unlinkSync(this.lockPath); } catch { /* ignore */ }
  }

  // ── Public API ──────────────────────────────────────────────────────────────

  createWorkflow(projectName: string, entryNode: string): WorkflowState {
    const workflowId = crypto.randomUUID();
    const now = new Date().toISOString();
    const state: WorkflowState = {
      version: 1,
      workflowId,
      stateVersion: 1,
      projectName,
      createdAt: now,
      updatedAt: now,
      status: "active",
      nodes: { [entryNode]: "active" },
      currentNode: entryNode,
      forks: {},
      contextStack: [],
      branchChoices: {},
      history: [{ node: entryNode, enteredAt: now }],
    };

    this.acquireLock();
    try {
      this.writeJson(this.workflowPath(workflowId), state);
      this.updateIndex(state);
    } finally {
      this.releaseLock();
    }

    return state;
  }

  loadWorkflow(workflowId: string): WorkflowState | null {
    const p = this.workflowPath(workflowId);
    if (!fs.existsSync(p)) return null;
    return this.readJson<WorkflowState>(p);
  }

  saveWorkflow(state: WorkflowState, expectedVersion: number): WorkflowState {
    this.acquireLock();
    try {
      const current = this.loadWorkflow(state.workflowId);
      if (current && current.stateVersion !== expectedVersion) {
        throw new Error(
          `Version conflict: expected ${expectedVersion}, found ${current.stateVersion}. ` +
          `Re-load the workflow and retry.`
        );
      }
      const updated: WorkflowState = {
        ...state,
        stateVersion: expectedVersion + 1,
        updatedAt: new Date().toISOString(),
      };
      this.writeJson(this.workflowPath(state.workflowId), updated);
      this.updateIndex(updated);
      return updated;
    } finally {
      this.releaseLock();
    }
  }

  listWorkflows(statusFilter?: WorkflowStatus): WorkflowIndexEntry[] {
    if (!fs.existsSync(this.indexPath)) return [];
    const index = this.readJson<WorkflowIndex>(this.indexPath);
    if (!statusFilter) return index.workflows;
    return index.workflows.filter(w => w.status === statusFilter);
  }

  private updateIndex(state: WorkflowState): void {
    const index: WorkflowIndex = fs.existsSync(this.indexPath)
      ? this.readJson<WorkflowIndex>(this.indexPath)
      : { workflows: [] };

    const entry: WorkflowIndexEntry = {
      workflowId: state.workflowId,
      projectName: state.projectName,
      status: state.status,
      currentNode: state.currentNode,
      createdAt: state.createdAt,
      updatedAt: state.updatedAt,
    };

    const idx = index.workflows.findIndex(w => w.workflowId === state.workflowId);
    if (idx >= 0) {
      index.workflows[idx] = entry;
    } else {
      index.workflows.push(entry);
    }

    this.writeJson(this.indexPath, index);
  }
}


path: src/workflow-engine.ts
content: // src/workflow-engine.ts
// Core FSM logic: transitions, guards, fork/join, and response building.

import {
  WORKFLOW_GRAPH,
  getNode,
  getMissingPrevious,
  getMainFlowOrder,
  type WorkflowNode,
} from "./workflow-graph.js";
import {
  StateStore,
  type WorkflowState,
  type ForkState,
  type NodeStatus,
  type HistoryEntry,
} from "./state-store.js";
import { SkillLoader } from "./skill-loader.js";
import crypto from "node:crypto";

// ─── Response types ────────────────────────────────────────────────────────────

export type ErrorCode =
  | "NOT_FOUND"
  | "INVALID_TRANSITION"
  | "MISSING_DEPENDENCIES"
  | "BRANCH_REQUIRED"
  | "FORK_LIMIT_EXCEEDED"
  | "VERSION_CONFLICT"
  | "WORKFLOW_ENDED"
  | "UNKNOWN_NODE";

export interface NextOption {
  node: string;
  label: string;
  condition?: string;
}

export interface ActiveForkSummary {
  forkId: string;
  plugin: string;
  status: string;
  startedAt: string;
}

export interface WorkflowResponse {
  ok: boolean;
  workflowId?: string;
  projectName?: string;

  current?: {
    node: string;
    title: string;
    type: string;
    summary: string;
    needsFullContent: boolean;
  };

  next?: NextOption[];
  activeForks?: ActiveForkSummary[];

  progress?: {
    completed: string[];
    remaining: string[];
    percentDone: number;
  };

  warnings?: string[];

  error?: {
    code: ErrorCode;
    message: string;
    suggestion?: string;
  };

  message: string;
}

export interface WorkflowSummary {
  workflowId: string;
  projectName: string;
  status: string;
  currentNode: string;
  currentNodeTitle: string;
  percentDone: number;
  activeForks: number;
  updatedAt: string;
}

// ─── Engine ────────────────────────────────────────────────────────────────────

export class WorkflowEngine {
  constructor(
    private store: StateStore,
    private skillLoader: SkillLoader,
    private maxForks: number = 3,
  ) {}

  // ── start ──────────────────────────────────────────────────────────────────

  start(projectName: string): WorkflowResponse {
    const entryNode = WORKFLOW_GRAPH.entryNode;
    const state = this.store.createWorkflow(projectName, entryNode);
    return this.buildResponse(state, `Workflow started. Beginning with ${entryNode}.`);
  }

  // ── status ─────────────────────────────────────────────────────────────────
  // Returns a lightweight summary list when no workflowId is given,
  // or full WorkflowResponse for a specific workflow.
  // Always returns WorkflowResponse for consistent Agent consumption.

  status(workflowId?: string): WorkflowResponse {
    if (!workflowId) {
      // Return all active workflows as a structured list inside WorkflowResponse
      const summaries = this.listSummaries("active");
      return {
        ok: true,
        message: summaries.length === 0
          ? "No active workflows. Use start() to begin one."
          : `${summaries.length} active workflow(s).`,
        // Embed summaries in a dedicated field
        allWorkflows: summaries,
      } as WorkflowResponse & { allWorkflows: WorkflowSummary[] };
    }

    const state = this.store.loadWorkflow(workflowId);
    if (!state) return this.notFound(workflowId);
    return this.buildResponse(state, "Current workflow status.");
  }

  // ── list ───────────────────────────────────────────────────────────────────

  list(statusFilter?: string): WorkflowResponse {
    const validStatus = ["active", "paused", "completed", "abandoned"] as const;
    type S = typeof validStatus[number];
    const filter = validStatus.includes(statusFilter as S)
      ? (statusFilter as S)
      : undefined;
    const summaries = this.listSummaries(filter);
    return {
      ok: true,
      allWorkflows: summaries,
      message: `Found ${summaries.length} workflow(s)${filter ? ` with status "${filter}"` : ""}.`,
    } as WorkflowResponse & { allWorkflows: WorkflowSummary[] };
  }

  // ── next ───────────────────────────────────────────────────────────────────

  next(workflowId: string, branch?: string): WorkflowResponse {
    const state = this.store.loadWorkflow(workflowId);
    if (!state) return this.notFound(workflowId);
    if (state.status !== "active") {
      return this.error("WORKFLOW_ENDED", `Workflow is ${state.status}.`);
    }

    const currentNode = getNode(state.currentNode);

    // Branch point: require branch selection
    if (currentNode.branches && currentNode.branches.length > 0 && !branch) {
      return {
        ok: false,
        workflowId,
        projectName: state.projectName,
        current: this.currentNodeInfo(state.currentNode),
        next: currentNode.branches.map(b => ({
          node: b.target,
          label: b.label,
          condition: b.condition,
        })),
        error: {
          code: "BRANCH_REQUIRED",
          message: `Node "${state.currentNode}" is a branch point. Specify a branch.`,
          suggestion: `Call next with branch: one of [${currentNode.branches.map(b => b.id).join(", ")}]`,
        },
        message: `Branch required. Choose: ${currentNode.branches.map(b => `"${b.id}" (${b.label})`).join(" | ")}`,
      };
    }

    // Determine target node
    let targetId: string;
    if (branch && currentNode.branches) {
      const chosen = currentNode.branches.find(b => b.id === branch);
      if (!chosen) {
        return this.error(
          "INVALID_TRANSITION",
          `Unknown branch "${branch}" for node "${state.currentNode}".`,
          `Valid branches: ${currentNode.branches.map(b => b.id).join(", ")}`,
        );
      }
      targetId = chosen.target;
    } else if (currentNode.next.length === 1) {
      targetId = currentNode.next[0];
    } else if (currentNode.next.length === 0) {
      return this.error("WORKFLOW_ENDED", "No further nodes. Use complete() to finish.");
    } else {
      return this.error(
        "BRANCH_REQUIRED",
        `Multiple next nodes available. Specify a branch.`,
        `Options: ${currentNode.next.join(", ")}`,
      );
    }

    return this.transitionTo(state, targetId, branch);
  }

  // ── goto ───────────────────────────────────────────────────────────────────
  // Moves currentNode without marking the previous node as completed.
  // This allows "jumping" to any node without corrupting history/progress.

  goto(workflowId: string, step: string): WorkflowResponse {
    const state = this.store.loadWorkflow(workflowId);
    if (!state) return this.notFound(workflowId);
    if (state.status !== "active") {
      return this.error("WORKFLOW_ENDED", `Workflow is ${state.status}.`);
    }
    if (!WORKFLOW_GRAPH.nodes[step]) {
      return this.error("UNKNOWN_NODE", `Node "${step}" does not exist.`);
    }

    // Soft guard: warn about missing previous nodes
    const completed = Object.entries(state.nodes)
      .filter(([, s]) => s === "completed")
      .map(([id]) => id);
    const missing = getMissingPrevious(step, completed);

    // Move to target without completing the current node
    const updated = this.moveToNode(state, step);
    const response = this.buildResponse(updated, `Jumped to "${step}".`);

    if (missing.length > 0) {
      response.warnings = [
        `Skipped recommended steps: ${missing.join(", ")}. ` +
        `This may affect quality — consider completing them first.`,
      ];
    }

    return response;
  }

  // ── complete ───────────────────────────────────────────────────────────────

  complete(workflowId: string, note?: string, output?: unknown): WorkflowResponse {
    const state = this.store.loadWorkflow(workflowId);
    if (!state) return this.notFound(workflowId);
    if (state.status !== "active") {
      return this.error("WORKFLOW_ENDED", `Workflow is ${state.status}.`);
    }

    const now = new Date().toISOString();
    const newNodes = { ...state.nodes, [state.currentNode]: "completed" as NodeStatus };

    // Close the LAST open history entry for current node, attach note/output
    const history = this.closeLastOpenEntry(state.history, state.currentNode, now)
      .map(h =>
        h.node === state.currentNode && h.completedAt === now
          ? { ...h, note, output }
          : h
      );

    // Check if workflow is done (no more next nodes)
    const currentNode = getNode(state.currentNode);
    const isEnd = currentNode.next.length === 0;

    const updated = this.store.saveWorkflow(
      {
        ...state,
        nodes: newNodes,
        status: isEnd ? "completed" : "active",
        history,
      },
      state.stateVersion,
    );

    if (isEnd) {
      return this.buildResponse(updated, `Workflow completed! All steps done.`);
    }

    return this.buildResponse(updated, `"${state.currentNode}" marked complete. Call next() to proceed.`);
  }

  // ── fork ───────────────────────────────────────────────────────────────────

  fork(workflowId: string, plugin: string): WorkflowResponse {
    const state = this.store.loadWorkflow(workflowId);
    if (!state) return this.notFound(workflowId);
    if (state.status !== "active") {
      return this.error("WORKFLOW_ENDED", `Workflow is ${state.status}.`);
    }

    if (!WORKFLOW_GRAPH.contextPlugins.includes(plugin)) {
      return this.error(
        "UNKNOWN_NODE",
        `"${plugin}" is not a context-plugin. Available: ${WORKFLOW_GRAPH.contextPlugins.join(", ")}`,
      );
    }

    const activeForks = Object.values(state.forks).filter(f => f.status === "active");
    if (activeForks.length >= this.maxForks) {
      return this.error(
        "FORK_LIMIT_EXCEEDED",
        `Maximum ${this.maxForks} concurrent forks reached.`,
        `Complete an existing fork with join() before starting a new one.`,
      );
    }

    const forkId = crypto.randomUUID();
    const now = new Date().toISOString();
    const fork: ForkState = {
      forkId,
      plugin,
      status: "active",
      startedAt: now,
      joinTarget: state.currentNode,
    };

    const updated = this.store.saveWorkflow(
      { ...state, forks: { ...state.forks, [forkId]: fork } },
      state.stateVersion,
    );

    const skillInfo = this.currentNodeInfo(plugin);
    return {
      ok: true,
      workflowId,
      projectName: state.projectName,
      current: skillInfo,
      activeForks: this.activeForksSummary(updated),
      message: `Fork "${forkId}" started for "${plugin}". Use join("${forkId}") when done.`,
    };
  }

  // ── join ───────────────────────────────────────────────────────────────────

  join(workflowId: string, forkId: string, note?: string): WorkflowResponse {
    const state = this.store.loadWorkflow(workflowId);
    if (!state) return this.notFound(workflowId);

    const fork = state.forks[forkId];
    if (!fork) {
      return this.error("NOT_FOUND", `Fork "${forkId}" not found.`);
    }
    if (fork.status === "completed") {
      return this.error("INVALID_TRANSITION", `Fork "${forkId}" is already completed.`);
    }

    const now = new Date().toISOString();
    const updatedFork: ForkState = { ...fork, status: "completed", completedAt: now, note };
    const updated = this.store.saveWorkflow(
      { ...state, forks: { ...state.forks, [forkId]: updatedFork } },
      state.stateVersion,
    );

    return this.buildResponse(
      updated,
      `Fork "${forkId}" (${fork.plugin}) completed. Returned to "${fork.joinTarget}".`,
    );
  }

  // ── getSkill ───────────────────────────────────────────────────────────────

  getSkill(node: string): WorkflowResponse & { skillContent: string } {
    const nodeInfo = WORKFLOW_GRAPH.nodes[node];
    if (!nodeInfo) {
      return { ...this.error("UNKNOWN_NODE", `Node "${node}" does not exist.`), skillContent: "" };
    }
    const body = this.skillLoader.getBody(nodeInfo.skillFile);
    return {
      ok: true,
      current: {
        node,
        title: nodeInfo.title,
        type: nodeInfo.type,
        summary: nodeInfo.summary,
        needsFullContent: false,
      },
      skillContent: body,   // dedicated field — keeps message clean
      message: `Full content loaded for "${nodeInfo.title}". Follow the instructions in skillContent.`,
    };
  }

  // ── abandon ────────────────────────────────────────────────────────────────

  abandon(workflowId: string): WorkflowResponse {
    const state = this.store.loadWorkflow(workflowId);
    if (!state) return this.notFound(workflowId);
    const updated = this.store.saveWorkflow(
      { ...state, status: "abandoned" },
      state.stateVersion,
    );
    return {
      ok: true,
      workflowId,
      projectName: state.projectName,
      message: `Workflow "${state.projectName}" abandoned.`,
    };
  }

  // ─── Private helpers ────────────────────────────────────────────────────────

  // Shared summary builder used by status() and list()
  private listSummaries(filter?: "active" | "paused" | "completed" | "abandoned"): WorkflowSummary[] {
    const entries = this.store.listWorkflows(filter);
    const mainOrder = getMainFlowOrder();
    return entries.map(e => {
      const node = WORKFLOW_GRAPH.nodes[e.currentNode];
      const state = this.store.loadWorkflow(e.workflowId);
      const completed = state
        ? Object.entries(state.nodes)
            .filter(([, s]) => s === "completed")
            .map(([id]) => id)
            .filter(id => mainOrder.includes(id))
        : [];
      return {
        workflowId: e.workflowId,
        projectName: e.projectName,
        status: e.status,
        currentNode: e.currentNode,
        currentNodeTitle: node?.title ?? e.currentNode,
        percentDone: mainOrder.length > 0
          ? Math.round((completed.length / mainOrder.length) * 100)
          : 0,
        activeForks: state
          ? Object.values(state.forks).filter(f => f.status === "active").length
          : 0,
        updatedAt: e.updatedAt,
      };
    });
  }

  // transitionTo: used by next() — marks current node completed and moves forward
  private transitionTo(
    state: WorkflowState,
    targetId: string,
    branch?: string,
  ): WorkflowResponse {
    const now = new Date().toISOString();
    const newNodes: Record<string, NodeStatus> = {
      ...state.nodes,
      [state.currentNode]: "completed",
      [targetId]: "active",
    };
    const branchChoices = branch
      ? { ...state.branchChoices, [state.currentNode]: branch }
      : state.branchChoices;

    // Close the LAST open history entry for current node (handles re-visits)
    const history = this.closeLastOpenEntry(state.history, state.currentNode, now);
    history.push({ node: targetId, enteredAt: now });

    const updated = this.store.saveWorkflow(
      {
        ...state,
        nodes: newNodes,
        currentNode: targetId,
        branchChoices,
        history,
      },
      state.stateVersion,
    );

    return this.buildResponse(updated, `Moved to "${targetId}".`);
  }

  // moveToNode: used by goto() — only changes currentNode, does NOT complete the old node
  private moveToNode(state: WorkflowState, targetId: string): WorkflowState {
    const now = new Date().toISOString();
    const newNodes: Record<string, NodeStatus> = {
      ...state.nodes,
      // Do NOT mark currentNode as completed — goto is navigation, not progression
      [targetId]: state.nodes[targetId] === "completed" ? "completed" : "active",
    };
    // Add a new history entry for the target (don't close the current one)
    const history = [...state.history, { node: targetId, enteredAt: now }];
    return this.store.saveWorkflow(
      { ...state, nodes: newNodes, currentNode: targetId, history },
      state.stateVersion,
    );
  }

  // closeLastOpenEntry: closes only the most recent uncompleted entry for a node
  // Prevents leaking multiple open entries when a node is visited more than once
  private closeLastOpenEntry(
    history: HistoryEntry[],
    nodeId: string,
    completedAt: string,
  ): HistoryEntry[] {
    let closedOne = false;
    // Traverse in reverse to find the LAST open entry for this node
    const result = [...history];
    for (let i = result.length - 1; i >= 0; i--) {
      if (result[i].node === nodeId && !result[i].completedAt) {
        result[i] = { ...result[i], completedAt };
        closedOne = true;
        break;
      }
    }
    if (!closedOne) {
      // No open entry found — add a synthetic completed entry
      result.push({ node: nodeId, enteredAt: completedAt, completedAt });
    }
    return result;
  }

  private buildResponse(state: WorkflowState, message: string): WorkflowResponse {
    const mainOrder = getMainFlowOrder();
    const completed = mainOrder.filter(id => state.nodes[id] === "completed");
    const remaining = mainOrder.filter(
      id => state.nodes[id] !== "completed" && state.nodes[id] !== "skipped",
    );
    const percentDone = mainOrder.length > 0
      ? Math.round((completed.length / mainOrder.length) * 100)
      : 0;

    const currentNodeInfo = this.currentNodeInfo(state.currentNode);
    const node = getNode(state.currentNode);

    const next: NextOption[] = node.branches
      ? node.branches.map(b => ({
          node: b.target,
          label: b.label,
          condition: b.condition,
        }))
      : node.next.map(id => {
          const n = WORKFLOW_GRAPH.nodes[id];
          return { node: id, label: n?.title ?? id };
        });

    return {
      ok: true,
      workflowId: state.workflowId,
      projectName: state.projectName,
      current: currentNodeInfo,
      next,
      activeForks: this.activeForksSummary(state),
      progress: { completed, remaining, percentDone },
      message,
    };
  }

  private currentNodeInfo(nodeId: string): WorkflowResponse["current"] {
    const node = WORKFLOW_GRAPH.nodes[nodeId];
    if (!node) return undefined;
    const summary = node.summary || this.skillLoader.getSummary(node.skillFile);
    return {
      node: nodeId,
      title: node.title,
      type: node.type,
      summary,
      needsFullContent: true,  // always suggest loading full content on navigation
    };
  }

  private activeForksSummary(state: WorkflowState): ActiveForkSummary[] {
    return Object.values(state.forks)
      .filter(f => f.status === "active")
      .map(f => ({
        forkId: f.forkId,
        plugin: f.plugin,
        status: f.status,
        startedAt: f.startedAt,
      }));
  }

  private notFound(workflowId: string): WorkflowResponse {
    return this.error(
      "NOT_FOUND",
      `Workflow "${workflowId}" not found.`,
      `Use list() to see all workflows, or start() to create a new one.`,
    );
  }

  private error(
    code: ErrorCode,
    message: string,
    suggestion?: string,
  ): WorkflowResponse {
    return { ok: false, error: { code, message, suggestion }, message };
  }
}


path: src/workflow-graph.ts
content: // src/workflow-graph.ts
// Defines the workflow graph: nodes, edges, transition rules, and guard conditions.

export type NodeType = "main-flow" | "context-plugin" | "utility" | "meta";

export interface RequiredPrevious {
  all?: string[];   // AND semantics: ALL must be completed
  any?: string[];   // OR semantics: AT LEAST ONE must be completed
}

export interface WorkflowNode {
  id: string;
  title: string;
  type: NodeType;
  summary: string;         // one-line description surfaced to Agent
  skillFile: string;       // relative path to SKILL.md within skills/
  next: string[];          // valid next nodes (for main-flow)
  branches?: BranchOption[]; // defined when node is a branch point
  requiredPrevious?: RequiredPrevious; // soft-guard: warn if prerequisites unmet
}

export interface BranchOption {
  id: string;
  label: string;
  target: string;
  condition: string;       // human-readable condition description
}

export interface WorkflowGraph {
  entryNode: string;
  nodes: Record<string, WorkflowNode>;
  contextPlugins: string[];  // nodes that can be forked at any time
  utilityNodes: string[];    // nodes available anytime, not part of state machine
}

export const WORKFLOW_GRAPH: WorkflowGraph = {
  entryNode: "brainstorming",

  nodes: {
    "brainstorming": {
      id: "brainstorming",
      title: "Brainstorming",
      type: "main-flow",
      summary: "Turn ideas into fully formed designs through collaborative dialogue. Produces an approved spec document.",
      skillFile: "brainstorming/SKILL.md",
      next: ["writing-plans"],
    },

    "writing-plans": {
      id: "writing-plans",
      title: "Writing Plans",
      type: "main-flow",
      summary: "Break an approved spec into bite-sized, actionable execution tasks with explicit acceptance criteria.",
      skillFile: "writing-plans/SKILL.md",
      next: ["subagent-driven-execution", "executing-plans"],
      requiredPrevious: { all: ["brainstorming"] },
      branches: [
        {
          id: "subagent",
          label: "Subagent-Driven (recommended)",
          target: "subagent-driven-execution",
          condition: "Subagents are available and tasks are mostly independent",
        },
        {
          id: "sequential",
          label: "Sequential Execution",
          target: "executing-plans",
          condition: "No subagent support, or tasks are tightly coupled",
        },
      ],
    },

    "subagent-driven-execution": {
      id: "subagent-driven-execution",
      title: "Subagent-Driven Execution",
      type: "main-flow",
      summary: "Dispatch one fresh subagent per task with two-stage review (spec + quality) after each.",
      skillFile: "subagent-driven-execution/SKILL.md",
      next: ["verification-before-completion"],
      requiredPrevious: { all: ["writing-plans"] },
    },

    "executing-plans": {
      id: "executing-plans",
      title: "Executing Plans",
      type: "main-flow",
      summary: "Execute the plan sequentially in the current session with checkpoints.",
      skillFile: "executing-plans/SKILL.md",
      next: ["verification-before-completion"],
      requiredPrevious: { all: ["writing-plans"] },
    },

    "verification-before-completion": {
      id: "verification-before-completion",
      title: "Verification Before Completion",
      type: "main-flow",
      summary: "Verify all work against acceptance criteria before claiming completion. Evidence before assertions.",
      skillFile: "verification-before-completion/SKILL.md",
      next: ["finishing-work"],
      // OR semantics: user will have completed exactly one of the two execution branches
      requiredPrevious: { any: ["subagent-driven-execution", "executing-plans"] },
    },

    "finishing-work": {
      id: "finishing-work",
      title: "Finishing Work",
      type: "main-flow",
      summary: "Present delivery options (integrate / submit / keep / discard) and execute the chosen path.",
      skillFile: "finishing-work/SKILL.md",
      next: [],
      requiredPrevious: { all: ["verification-before-completion"] },
    },

    // --- Context plugins (can be forked at any time) ---

    "dispatching-parallel-agents": {
      id: "dispatching-parallel-agents",
      title: "Dispatching Parallel Agents",
      type: "context-plugin",
      summary: "Split 2+ independent tasks across parallel agents for concurrent execution.",
      skillFile: "dispatching-parallel-agents/SKILL.md",
      next: [],
    },

    "requesting-review": {
      id: "requesting-review",
      title: "Requesting Review",
      type: "context-plugin",
      summary: "Dispatch a reviewer subagent to verify work meets requirements before delivery.",
      skillFile: "requesting-review/SKILL.md",
      // context-plugins have no next: they are forked and joined, not chained
      // receiving-review is a separate context-plugin, fork it independently if needed
      next: [],
    },

    "receiving-review": {
      id: "receiving-review",
      title: "Receiving Review",
      type: "context-plugin",
      summary: "Evaluate review feedback with technical rigor — verify before implementing suggestions.",
      skillFile: "receiving-review/SKILL.md",
      next: [],
    },

    // --- Utility nodes (always available, no state machine effect) ---

    "systematic-problem-solving": {
      id: "systematic-problem-solving",
      title: "Systematic Problem Solving",
      type: "utility",
      summary: "Diagnose any problem using root-cause investigation before proposing fixes.",
      skillFile: "systematic-problem-solving/SKILL.md",
      next: [],
    },

    "writing-skills": {
      id: "writing-skills",
      title: "Writing Skills",
      type: "utility",
      summary: "Create or improve skill documents using TDD applied to process documentation.",
      skillFile: "writing-skills/SKILL.md",
      next: [],
    },
  },

  contextPlugins: [
    "dispatching-parallel-agents",
    "requesting-review",
    "receiving-review",
  ],

  utilityNodes: [
    "systematic-problem-solving",
    "writing-skills",
  ],
};

// Returns the node or throws if not found
export function getNode(nodeId: string): WorkflowNode {
  const node = WORKFLOW_GRAPH.nodes[nodeId];
  if (!node) throw new Error(`Unknown node: ${nodeId}`);
  return node;
}

// Returns all nodes that can follow the given node in main-flow
export function getNextNodes(nodeId: string): WorkflowNode[] {
  const node = getNode(nodeId);
  return node.next.map(id => getNode(id));
}

// Returns missing required-previous nodes (for soft guard)
// Supports both AND (all) and OR (any) semantics via RequiredPrevious struct.
export function getMissingPrevious(
  nodeId: string,
  completedNodes: string[]
): string[] {
  const node = getNode(nodeId);
  if (!node.requiredPrevious) return [];

  const missing: string[] = [];

  // AND semantics: every listed node must be completed
  if (node.requiredPrevious.all) {
    for (const req of node.requiredPrevious.all) {
      if (!completedNodes.includes(req)) missing.push(req);
    }
  }

  // OR semantics: at least one listed node must be completed
  if (node.requiredPrevious.any) {
    const anyCompleted = node.requiredPrevious.any.some(req =>
      completedNodes.includes(req)
    );
    if (!anyCompleted) {
      missing.push(`one of [${node.requiredPrevious.any.join(", ")}]`);
    }
  }

  return missing;
}

// Resolves the active main-flow node (first "active" in main-flow chain)
export function getMainFlowOrder(): string[] {
  const order: string[] = [];
  const visited = new Set<string>();
  const queue = [WORKFLOW_GRAPH.entryNode];
  while (queue.length > 0) {
    const id = queue.shift()!;
    if (visited.has(id)) continue;
    visited.add(id);
    const node = WORKFLOW_GRAPH.nodes[id];
    if (node.type === "main-flow") {
      order.push(id);
      node.next.forEach(n => queue.push(n));
    }
  }
  return order;
}


path: tsconfig.json
content: {
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "outDir": "./dist",
    "rootDir": ".",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "declaration": true,
    "declarationMap": true,
    "sourceMap": true
  },
  "include": ["index.ts", "src/**/*.ts"],
  "exclude": ["node_modules", "dist"]
}


Security scan verdicts — ClawScan: suspicious | static analysis: clean | VirusTotal: clean | SkillSpector: suspicious (CRITICAL)