diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts
index c3df4da..a2c1984 100644
--- a/packages/cli/src/cli.ts
+++ b/packages/cli/src/cli.ts
@@ -10,6 +10,7 @@ import { registerConversationCommands } from './cli/commands/conversation.js';
import { registerCursorCommands } from './cli/commands/cursor.js';
import { registerDispatchCommands } from './cli/commands/dispatch.js';
import { registerMcpCommands } from './cli/commands/mcp.js';
+import { registerMissionCommands } from './cli/commands/mission.js';
import { registerSafetyCommands } from './cli/commands/safety.js';
import { registerPortabilityCommands } from './cli/commands/portability.js';
import { registerFederationCommands } from './cli/commands/federation.js';
@@ -2325,6 +2326,7 @@ registerSafetyCommands(program, DEFAULT_ACTOR);
registerPortabilityCommands(program);
registerFederationCommands(program, threadCmd, DEFAULT_ACTOR);
registerCapabilityCommands(program, DEFAULT_ACTOR);
+registerMissionCommands(program, DEFAULT_ACTOR);
// ============================================================================
// onboarding
diff --git a/packages/cli/src/cli/commands/mission.ts b/packages/cli/src/cli/commands/mission.ts
new file mode 100644
index 0000000..e619d1d
--- /dev/null
+++ b/packages/cli/src/cli/commands/mission.ts
@@ -0,0 +1,318 @@
+import fs from 'node:fs';
+import path from 'node:path';
+import { Command } from 'commander';
+import * as workgraph from '@versatly/workgraph-kernel';
+import {
+ addWorkspaceOption,
+ csv,
+ resolveWorkspacePath,
+ runCommand,
+} from '../core.js';
+
+export function registerMissionCommands(program: Command, defaultActor: string): void {
+ const missionCmd = program
+ .command('mission')
+ .description('Mission primitive lifecycle and orchestration');
+
+ addWorkspaceOption(
+ missionCmd
+ .command('create
')
+ .description('Create a mission in planning state')
+ .requiredOption('--goal ', 'Mission goal statement')
+ .option('-a, --actor ', 'Actor', defaultActor)
+ .option('--mid ', 'Mission identifier slug override')
+ .option('--description ', 'Mission summary/description')
+ .option('--priority ', 'urgent|high|medium|low', 'medium')
+ .option('--owner ', 'Mission owner')
+ .option('--project [', 'Project ref (projects/.md)')
+ .option('--space ][', 'Space ref (spaces/.md)')
+ .option('--constraints ', 'Comma-separated mission constraints')
+ .option('--tags ', 'Comma-separated tags')
+ .option('--json', 'Emit structured JSON output'),
+ ).action((title, opts) =>
+ runCommand(
+ opts,
+ () => {
+ const workspacePath = resolveWorkspacePath(opts);
+ return {
+ mission: workgraph.mission.createMission(workspacePath, title, opts.goal, opts.actor, {
+ mid: opts.mid,
+ description: opts.description,
+ priority: normalizePriority(opts.priority),
+ owner: opts.owner,
+ project: opts.project,
+ space: opts.space,
+ constraints: csv(opts.constraints),
+ tags: csv(opts.tags),
+ }),
+ };
+ },
+ (result) => [
+ `Created mission: ${result.mission.path}`,
+ `Status: ${String(result.mission.fields.status)}`,
+ ],
+ ),
+ );
+
+ addWorkspaceOption(
+ missionCmd
+ .command('plan ')
+ .description('Plan mission milestones/features and create feature threads')
+ .option('-a, --actor ', 'Actor', defaultActor)
+ .option('--goal ', 'Plan goal override')
+ .option('--constraints ', 'Comma-separated constraints')
+ .option('--estimated-runs ', 'Estimated number of runs')
+ .option('--estimated-cost-usd ', 'Estimated USD cost')
+ .option('--append', 'Append milestones instead of replacing')
+ .option('--milestones ', 'Milestones JSON payload')
+ .option('--milestones-file ', 'Milestones JSON file path')
+ .option('--json', 'Emit structured JSON output'),
+ ).action((missionRef, opts) =>
+ runCommand(
+ opts,
+ () => {
+ const workspacePath = resolveWorkspacePath(opts);
+ const milestones = readMissionMilestonesInput(opts.milestones, opts.milestonesFile);
+ return {
+ mission: workgraph.mission.planMission(
+ workspacePath,
+ missionRef,
+ {
+ goal: opts.goal,
+ constraints: csv(opts.constraints),
+ estimated_runs: parseOptionalInt(opts.estimatedRuns),
+ estimated_cost_usd: parseOptionalNumber(opts.estimatedCostUsd),
+ replaceMilestones: !opts.append,
+ milestones,
+ },
+ opts.actor,
+ ),
+ };
+ },
+ (result) => [
+ `Planned mission: ${result.mission.path}`,
+ `Milestones: ${Array.isArray(result.mission.fields.milestones) ? result.mission.fields.milestones.length : 0}`,
+ ],
+ ),
+ );
+
+ addWorkspaceOption(
+ missionCmd
+ .command('approve ')
+ .description('Approve planned mission')
+ .option('-a, --actor ', 'Actor', defaultActor)
+ .option('--json', 'Emit structured JSON output'),
+ ).action((missionRef, opts) =>
+ runCommand(
+ opts,
+ () => {
+ const workspacePath = resolveWorkspacePath(opts);
+ return {
+ mission: workgraph.mission.approveMission(workspacePath, missionRef, opts.actor),
+ };
+ },
+ (result) => [`Approved mission: ${result.mission.path}`],
+ ),
+ );
+
+ addWorkspaceOption(
+ missionCmd
+ .command('start ')
+ .description('Start mission execution and optionally run one orchestrator cycle')
+ .option('-a, --actor ', 'Actor', defaultActor)
+ .option('--no-run-cycle', 'Do not run orchestrator cycle after start')
+ .option('--json', 'Emit structured JSON output'),
+ ).action((missionRef, opts) =>
+ runCommand(
+ opts,
+ () => {
+ const workspacePath = resolveWorkspacePath(opts);
+ const started = workgraph.mission.startMission(workspacePath, missionRef, opts.actor);
+ const cycle = opts.runCycle === false
+ ? null
+ : workgraph.missionOrchestrator.runMissionOrchestratorCycle(workspacePath, started.path, opts.actor);
+ return { mission: started, cycle };
+ },
+ (result) => [
+ `Started mission: ${result.mission.path}`,
+ ...(result.cycle ? [`Cycle actions: ${result.cycle.actions.length}`] : []),
+ ],
+ ),
+ );
+
+ addWorkspaceOption(
+ missionCmd
+ .command('status ')
+ .description('Show mission primitive status and milestones')
+ .option('--json', 'Emit structured JSON output'),
+ ).action((missionRef, opts) =>
+ runCommand(
+ opts,
+ () => {
+ const workspacePath = resolveWorkspacePath(opts);
+ const missionInstance = workgraph.mission.missionStatus(workspacePath, missionRef);
+ const progress = workgraph.mission.missionProgress(workspacePath, missionInstance.path);
+ return { mission: missionInstance, progress };
+ },
+ (result) => [
+ `Mission: ${result.mission.path}`,
+ `Status: ${String(result.mission.fields.status)}`,
+ `Progress: ${result.progress.percentComplete}% (${result.progress.doneFeatures}/${result.progress.totalFeatures} features)`,
+ ],
+ ),
+ );
+
+ addWorkspaceOption(
+ missionCmd
+ .command('progress ')
+ .description('Show mission progress metrics only')
+ .option('--json', 'Emit structured JSON output'),
+ ).action((missionRef, opts) =>
+ runCommand(
+ opts,
+ () => {
+ const workspacePath = resolveWorkspacePath(opts);
+ return workgraph.mission.missionProgress(workspacePath, missionRef);
+ },
+ (result) => [
+ `Mission ${result.mid}: ${result.status}`,
+ `Milestones: ${result.passedMilestones}/${result.totalMilestones}`,
+ `Features: ${result.doneFeatures}/${result.totalFeatures}`,
+ ],
+ ),
+ );
+
+ addWorkspaceOption(
+ missionCmd
+ .command('intervene ')
+ .description('Intervene in mission execution (status/priority/skip/append milestones)')
+ .requiredOption('--reason ', 'Intervention reason')
+ .option('-a, --actor ', 'Actor', defaultActor)
+ .option('--set-priority ', 'urgent|high|medium|low')
+ .option('--set-status ', 'planning|approved|active|validating|completed|failed')
+ .option('--skip-feature ', 'Skip one feature in a milestone')
+ .option('--append-milestones ', 'Milestones JSON to append')
+ .option('--append-milestones-file ', 'Milestones JSON file to append')
+ .option('--json', 'Emit structured JSON output'),
+ ).action((missionRef, opts) =>
+ runCommand(
+ opts,
+ () => {
+ const workspacePath = resolveWorkspacePath(opts);
+ const skipFeature = parseSkipFeature(opts.skipFeature);
+ const appendMilestones = readMissionMilestonesInput(opts.appendMilestones, opts.appendMilestonesFile, false);
+ return {
+ mission: workgraph.mission.interveneMission(workspacePath, missionRef, {
+ reason: String(opts.reason),
+ setPriority: opts.setPriority ? normalizePriority(opts.setPriority) : undefined,
+ setStatus: opts.setStatus ? normalizeMissionStatus(opts.setStatus) : undefined,
+ skipFeature: skipFeature ?? undefined,
+ appendMilestones: appendMilestones.length > 0 ? appendMilestones : undefined,
+ }, opts.actor),
+ };
+ },
+ (result) => [`Intervened mission: ${result.mission.path}`],
+ ),
+ );
+
+ addWorkspaceOption(
+ missionCmd
+ .command('list')
+ .description('List missions')
+ .option('--status ', 'Filter by mission status')
+ .option('--json', 'Emit structured JSON output'),
+ ).action((opts) =>
+ runCommand(
+ opts,
+ () => {
+ const workspacePath = resolveWorkspacePath(opts);
+ const missions = workgraph.mission.listMissions(workspacePath)
+ .filter((entry) => !opts.status || String(entry.fields.status) === String(opts.status));
+ return { missions };
+ },
+ (result) => {
+ if (result.missions.length === 0) return ['No missions found.'];
+ return result.missions.map((entry) =>
+ `[${String(entry.fields.status)}] ${String(entry.fields.title)} -> ${entry.path}`,
+ );
+ },
+ ),
+ );
+}
+
+function readMissionMilestonesInput(
+ rawJson: string | undefined,
+ jsonFile: string | undefined,
+ required: boolean = true,
+): workgraph.mission.MissionMilestonePlanInput[] {
+ if (!rawJson && !jsonFile) {
+ if (required) {
+ throw new Error('Mission milestones input is required. Use --milestones or --milestones-file.');
+ }
+ return [];
+ }
+ const parsed = rawJson
+ ? JSON.parse(rawJson)
+ : JSON.parse(fs.readFileSync(path.resolve(String(jsonFile)), 'utf-8'));
+ if (!Array.isArray(parsed)) {
+ throw new Error('Milestones input must be a JSON array.');
+ }
+ return parsed as workgraph.mission.MissionMilestonePlanInput[];
+}
+
+function normalizePriority(value: string): 'urgent' | 'high' | 'medium' | 'low' {
+ const normalized = String(value).trim().toLowerCase();
+ if (normalized === 'urgent' || normalized === 'high' || normalized === 'medium' || normalized === 'low') {
+ return normalized;
+ }
+ throw new Error(`Invalid mission priority "${value}". Expected urgent|high|medium|low.`);
+}
+
+function normalizeMissionStatus(value: string): workgraph.MissionStatus {
+ const normalized = String(value).trim().toLowerCase();
+ if (
+ normalized === 'planning'
+ || normalized === 'approved'
+ || normalized === 'active'
+ || normalized === 'validating'
+ || normalized === 'completed'
+ || normalized === 'failed'
+ ) {
+ return normalized;
+ }
+ throw new Error(`Invalid mission status "${value}". Expected planning|approved|active|validating|completed|failed.`);
+}
+
+function parseOptionalInt(value: unknown): number | undefined {
+ if (value === undefined || value === null || String(value).trim() === '') return undefined;
+ const parsed = Number.parseInt(String(value), 10);
+ if (!Number.isFinite(parsed)) {
+ throw new Error(`Invalid integer value "${String(value)}".`);
+ }
+ return parsed;
+}
+
+function parseOptionalNumber(value: unknown): number | null | undefined {
+ if (value === undefined || value === null || String(value).trim() === '') return undefined;
+ const parsed = Number(value);
+ if (!Number.isFinite(parsed)) {
+ throw new Error(`Invalid number value "${String(value)}".`);
+ }
+ return parsed;
+}
+
+function parseSkipFeature(
+ value: unknown,
+): { milestoneId: string; threadPath: string } | null {
+ if (value === undefined || value === null) return null;
+ const raw = String(value).trim();
+ if (!raw) return null;
+ const separator = raw.indexOf(':');
+ if (separator <= 0 || separator >= raw.length - 1) {
+ throw new Error('Invalid --skip-feature value. Expected ":".');
+ }
+ return {
+ milestoneId: raw.slice(0, separator).trim(),
+ threadPath: raw.slice(separator + 1).trim(),
+ };
+}
diff --git a/packages/kernel/src/__snapshots__/schema-drift-regression.test.ts.snap b/packages/kernel/src/__snapshots__/schema-drift-regression.test.ts.snap
index 22dd691..d79d6b8 100644
--- a/packages/kernel/src/__snapshots__/schema-drift-regression.test.ts.snap
+++ b/packages/kernel/src/__snapshots__/schema-drift-regression.test.ts.snap
@@ -488,6 +488,31 @@ exports[`schema drift regression > locks MCP tool metadata and input schemas 1`]
"name": "wg_spawn_thread",
"title": "WorkGraph Spawn Thread",
},
+ {
+ "annotations": {
+ "destructiveHint": true,
+ "idempotentHint": false,
+ },
+ "description": "Approve a mission plan and move it to approved status.",
+ "inputSchema": {
+ "$schema": "http://json-schema.org/draft-07/schema#",
+ "properties": {
+ "actor": {
+ "type": "string",
+ },
+ "missionRef": {
+ "minLength": 1,
+ "type": "string",
+ },
+ },
+ "required": [
+ "missionRef",
+ ],
+ "type": "object",
+ },
+ "name": "workgraph_approve_mission",
+ "title": "Mission Approve",
+ },
{
"annotations": {
"destructiveHint": true,
@@ -620,6 +645,73 @@ exports[`schema drift regression > locks MCP tool metadata and input schemas 1`]
"name": "workgraph_checkpoint_create",
"title": "Checkpoint Create",
},
+ {
+ "annotations": {
+ "destructiveHint": true,
+ "idempotentHint": false,
+ },
+ "description": "Create a mission primitive in planning status.",
+ "inputSchema": {
+ "$schema": "http://json-schema.org/draft-07/schema#",
+ "properties": {
+ "actor": {
+ "type": "string",
+ },
+ "constraints": {
+ "items": {
+ "type": "string",
+ },
+ "type": "array",
+ },
+ "description": {
+ "type": "string",
+ },
+ "goal": {
+ "minLength": 1,
+ "type": "string",
+ },
+ "mid": {
+ "minLength": 1,
+ "type": "string",
+ },
+ "owner": {
+ "type": "string",
+ },
+ "priority": {
+ "enum": [
+ "urgent",
+ "high",
+ "medium",
+ "low",
+ ],
+ "type": "string",
+ },
+ "project": {
+ "type": "string",
+ },
+ "space": {
+ "type": "string",
+ },
+ "tags": {
+ "items": {
+ "type": "string",
+ },
+ "type": "array",
+ },
+ "title": {
+ "minLength": 1,
+ "type": "string",
+ },
+ },
+ "required": [
+ "title",
+ "goal",
+ ],
+ "type": "object",
+ },
+ "name": "workgraph_create_mission",
+ "title": "Mission Create",
+ },
{
"annotations": {
"destructiveHint": true,
@@ -766,6 +858,171 @@ exports[`schema drift regression > locks MCP tool metadata and input schemas 1`]
"name": "workgraph_graph_hygiene",
"title": "Graph Hygiene",
},
+ {
+ "annotations": {
+ "destructiveHint": true,
+ "idempotentHint": false,
+ },
+ "description": "Apply mission intervention updates (priority/status/skip/append milestones).",
+ "inputSchema": {
+ "$schema": "http://json-schema.org/draft-07/schema#",
+ "properties": {
+ "actor": {
+ "type": "string",
+ },
+ "appendMilestones": {
+ "items": {
+ "properties": {
+ "deps": {
+ "items": {
+ "type": "string",
+ },
+ "type": "array",
+ },
+ "features": {
+ "items": {
+ "anyOf": [
+ {
+ "minLength": 1,
+ "type": "string",
+ },
+ {
+ "properties": {
+ "deps": {
+ "items": {
+ "type": "string",
+ },
+ "type": "array",
+ },
+ "goal": {
+ "minLength": 1,
+ "type": "string",
+ },
+ "priority": {
+ "enum": [
+ "urgent",
+ "high",
+ "medium",
+ "low",
+ ],
+ "type": "string",
+ },
+ "tags": {
+ "items": {
+ "type": "string",
+ },
+ "type": "array",
+ },
+ "threadPath": {
+ "minLength": 1,
+ "type": "string",
+ },
+ "title": {
+ "minLength": 1,
+ "type": "string",
+ },
+ },
+ "required": [
+ "title",
+ ],
+ "type": "object",
+ },
+ ],
+ },
+ "minItems": 1,
+ "type": "array",
+ },
+ "id": {
+ "minLength": 1,
+ "type": "string",
+ },
+ "title": {
+ "minLength": 1,
+ "type": "string",
+ },
+ "validation": {
+ "properties": {
+ "criteria": {
+ "items": {
+ "type": "string",
+ },
+ "type": "array",
+ },
+ "strategy": {
+ "enum": [
+ "automated",
+ "manual",
+ "hybrid",
+ ],
+ "type": "string",
+ },
+ },
+ "type": "object",
+ },
+ },
+ "required": [
+ "title",
+ "features",
+ ],
+ "type": "object",
+ },
+ "type": "array",
+ },
+ "missionRef": {
+ "minLength": 1,
+ "type": "string",
+ },
+ "reason": {
+ "minLength": 1,
+ "type": "string",
+ },
+ "setPriority": {
+ "enum": [
+ "urgent",
+ "high",
+ "medium",
+ "low",
+ ],
+ "type": "string",
+ },
+ "setStatus": {
+ "enum": [
+ "planning",
+ "approved",
+ "active",
+ "validating",
+ "completed",
+ "failed",
+ ],
+ "type": "string",
+ },
+ "skipFeature": {
+ "properties": {
+ "milestoneId": {
+ "minLength": 1,
+ "type": "string",
+ },
+ "threadPath": {
+ "minLength": 1,
+ "type": "string",
+ },
+ },
+ "required": [
+ "milestoneId",
+ "threadPath",
+ ],
+ "type": "object",
+ },
+ },
+ "required": [
+ "missionRef",
+ "reason",
+ ],
+ "type": "object",
+ },
+ "name": "workgraph_intervene_mission",
+ "title": "Mission Intervene",
+ },
{
"annotations": {
"idempotentHint": true,
@@ -802,6 +1059,203 @@ exports[`schema drift regression > locks MCP tool metadata and input schemas 1`]
"name": "workgraph_ledger_reconcile",
"title": "Ledger Reconcile",
},
+ {
+ "annotations": {
+ "idempotentHint": true,
+ "readOnlyHint": true,
+ },
+ "description": "Read aggregate mission progress across milestones and features.",
+ "inputSchema": {
+ "$schema": "http://json-schema.org/draft-07/schema#",
+ "properties": {
+ "missionRef": {
+ "minLength": 1,
+ "type": "string",
+ },
+ },
+ "required": [
+ "missionRef",
+ ],
+ "type": "object",
+ },
+ "name": "workgraph_mission_progress",
+ "title": "Mission Progress",
+ },
+ {
+ "annotations": {
+ "idempotentHint": true,
+ "readOnlyHint": true,
+ },
+ "description": "Read one mission primitive and computed progress.",
+ "inputSchema": {
+ "$schema": "http://json-schema.org/draft-07/schema#",
+ "properties": {
+ "missionRef": {
+ "minLength": 1,
+ "type": "string",
+ },
+ },
+ "required": [
+ "missionRef",
+ ],
+ "type": "object",
+ },
+ "name": "workgraph_mission_status",
+ "title": "Mission Status",
+ },
+ {
+ "annotations": {
+ "destructiveHint": true,
+ "idempotentHint": false,
+ },
+ "description": "Define or update mission milestones and feature threads.",
+ "inputSchema": {
+ "$schema": "http://json-schema.org/draft-07/schema#",
+ "properties": {
+ "actor": {
+ "type": "string",
+ },
+ "constraints": {
+ "items": {
+ "type": "string",
+ },
+ "type": "array",
+ },
+ "estimatedCostUsd": {
+ "anyOf": [
+ {
+ "minimum": 0,
+ "type": "number",
+ },
+ {
+ "type": "null",
+ },
+ ],
+ },
+ "estimatedRuns": {
+ "maximum": 9007199254740991,
+ "minimum": 0,
+ "type": "integer",
+ },
+ "goal": {
+ "type": "string",
+ },
+ "milestones": {
+ "items": {
+ "properties": {
+ "deps": {
+ "items": {
+ "type": "string",
+ },
+ "type": "array",
+ },
+ "features": {
+ "items": {
+ "anyOf": [
+ {
+ "minLength": 1,
+ "type": "string",
+ },
+ {
+ "properties": {
+ "deps": {
+ "items": {
+ "type": "string",
+ },
+ "type": "array",
+ },
+ "goal": {
+ "minLength": 1,
+ "type": "string",
+ },
+ "priority": {
+ "enum": [
+ "urgent",
+ "high",
+ "medium",
+ "low",
+ ],
+ "type": "string",
+ },
+ "tags": {
+ "items": {
+ "type": "string",
+ },
+ "type": "array",
+ },
+ "threadPath": {
+ "minLength": 1,
+ "type": "string",
+ },
+ "title": {
+ "minLength": 1,
+ "type": "string",
+ },
+ },
+ "required": [
+ "title",
+ ],
+ "type": "object",
+ },
+ ],
+ },
+ "minItems": 1,
+ "type": "array",
+ },
+ "id": {
+ "minLength": 1,
+ "type": "string",
+ },
+ "title": {
+ "minLength": 1,
+ "type": "string",
+ },
+ "validation": {
+ "properties": {
+ "criteria": {
+ "items": {
+ "type": "string",
+ },
+ "type": "array",
+ },
+ "strategy": {
+ "enum": [
+ "automated",
+ "manual",
+ "hybrid",
+ ],
+ "type": "string",
+ },
+ },
+ "type": "object",
+ },
+ },
+ "required": [
+ "title",
+ "features",
+ ],
+ "type": "object",
+ },
+ "minItems": 1,
+ "type": "array",
+ },
+ "missionRef": {
+ "minLength": 1,
+ "type": "string",
+ },
+ "replaceMilestones": {
+ "type": "boolean",
+ },
+ },
+ "required": [
+ "missionRef",
+ "milestones",
+ ],
+ "type": "object",
+ },
+ "name": "workgraph_plan_mission",
+ "title": "Mission Plan",
+ },
{
"annotations": {
"idempotentHint": true,
@@ -879,6 +1333,34 @@ exports[`schema drift regression > locks MCP tool metadata and input schemas 1`]
"name": "workgraph_query",
"title": "Workgraph Query",
},
+ {
+ "annotations": {
+ "destructiveHint": true,
+ "idempotentHint": false,
+ },
+ "description": "Start mission execution and run one orchestrator cycle.",
+ "inputSchema": {
+ "$schema": "http://json-schema.org/draft-07/schema#",
+ "properties": {
+ "actor": {
+ "type": "string",
+ },
+ "missionRef": {
+ "minLength": 1,
+ "type": "string",
+ },
+ "runCycle": {
+ "type": "boolean",
+ },
+ },
+ "required": [
+ "missionRef",
+ ],
+ "type": "object",
+ },
+ "name": "workgraph_start_mission",
+ "title": "Mission Start",
+ },
{
"annotations": {
"idempotentHint": true,
diff --git a/packages/kernel/src/index.ts b/packages/kernel/src/index.ts
index a78424d..f695231 100644
--- a/packages/kernel/src/index.ts
+++ b/packages/kernel/src/index.ts
@@ -4,6 +4,8 @@ export * as ledger from './ledger.js';
export * as auth from './auth.js';
export * as store from './store.js';
export * as thread from './thread.js';
+export * as mission from './mission.js';
+export * as missionOrchestrator from './mission-orchestrator.js';
export * as capability from './capability.js';
export {
inviteThreadParticipant,
diff --git a/packages/kernel/src/mission-orchestrator.test.ts b/packages/kernel/src/mission-orchestrator.test.ts
new file mode 100644
index 0000000..b025150
--- /dev/null
+++ b/packages/kernel/src/mission-orchestrator.test.ts
@@ -0,0 +1,102 @@
+import { afterEach, beforeEach, describe, expect, it } from 'vitest';
+import fs from 'node:fs';
+import os from 'node:os';
+import path from 'node:path';
+import * as dispatch from './dispatch.js';
+import * as mission from './mission.js';
+import * as missionOrchestrator from './mission-orchestrator.js';
+import { loadRegistry, saveRegistry } from './registry.js';
+import * as thread from './thread.js';
+
+let workspacePath: string;
+
+beforeEach(() => {
+ workspacePath = fs.mkdtempSync(path.join(os.tmpdir(), 'wg-mission-orchestrator-'));
+ const registry = loadRegistry(workspacePath);
+ saveRegistry(workspacePath, registry);
+});
+
+afterEach(() => {
+ fs.rmSync(workspacePath, { recursive: true, force: true });
+});
+
+describe('mission orchestrator', () => {
+ it('dispatches features, validates milestones, and completes mission sequentially', () => {
+ const created = mission.createMission(
+ workspacePath,
+ 'Launch payments service',
+ 'Ship payments service to production',
+ 'agent-pm',
+ );
+ mission.planMission(workspacePath, created.path, {
+ milestones: [
+ {
+ id: 'ms-api',
+ title: 'API readiness',
+ features: ['Build API', 'Add auth'],
+ validation: {
+ strategy: 'automated',
+ criteria: ['pnpm run test'],
+ },
+ },
+ {
+ id: 'ms-deploy',
+ title: 'Deployment',
+ deps: ['ms-api'],
+ features: ['Deploy service'],
+ validation: {
+ strategy: 'manual',
+ criteria: ['Smoke test endpoint'],
+ },
+ },
+ ],
+ }, 'agent-pm');
+ mission.approveMission(workspacePath, created.path, 'agent-pm');
+ mission.startMission(workspacePath, created.path, 'agent-pm');
+
+ const firstCycle = missionOrchestrator.runMissionOrchestratorCycle(workspacePath, created.path);
+ expect(firstCycle.dispatchedRuns.length).toBe(2);
+ const allRunsAfterFirst = dispatch.listRuns(workspacePath);
+ expect(allRunsAfterFirst.length).toBe(2);
+
+ completeThread(workspacePath, 'threads/mission-launch-payments-service/build-api.md', 'agent-pm');
+ completeThread(workspacePath, 'threads/mission-launch-payments-service/add-auth.md', 'agent-pm');
+
+ const validationCycleOne = missionOrchestrator.runMissionOrchestratorCycle(workspacePath, created.path);
+ expect(validationCycleOne.validationRunId).toBeDefined();
+ const validationRunOneId = validationCycleOne.validationRunId!;
+ dispatch.markRun(workspacePath, validationRunOneId, 'mission-orchestrator', 'running');
+ dispatch.markRun(workspacePath, validationRunOneId, 'mission-orchestrator', 'succeeded');
+
+ const passFirstMilestone = missionOrchestrator.runMissionOrchestratorCycle(workspacePath, created.path);
+ expect(passFirstMilestone.actions.some((action) => action.startsWith('milestone-passed:ms-api'))).toBe(true);
+
+ const dispatchSecondMilestone = missionOrchestrator.runMissionOrchestratorCycle(workspacePath, created.path);
+ expect(dispatchSecondMilestone.dispatchedRuns.length).toBe(1);
+ completeThread(workspacePath, 'threads/mission-launch-payments-service/deploy-service.md', 'agent-pm');
+
+ const validationCycleTwo = missionOrchestrator.runMissionOrchestratorCycle(workspacePath, created.path);
+ expect(validationCycleTwo.validationRunId).toBeDefined();
+ dispatch.markRun(workspacePath, validationCycleTwo.validationRunId!, 'mission-orchestrator', 'running');
+ dispatch.markRun(workspacePath, validationCycleTwo.validationRunId!, 'mission-orchestrator', 'succeeded');
+
+ const completionCycle = missionOrchestrator.runMissionOrchestratorCycle(workspacePath, created.path);
+ expect(completionCycle.missionStatus).toBe('completed');
+
+ const finalMission = mission.missionStatus(workspacePath, created.path);
+ expect(finalMission.fields.status).toBe('completed');
+ const progress = mission.missionProgress(workspacePath, created.path);
+ expect(progress.passedMilestones).toBe(2);
+ expect(progress.doneFeatures).toBe(3);
+ });
+});
+
+function completeThread(workspacePath: string, threadPath: string, actor: string): void {
+ thread.claim(workspacePath, threadPath, actor);
+ thread.done(
+ workspacePath,
+ threadPath,
+ actor,
+ `Completed ${threadPath} https://github.com/versatly/workgraph/pull/mission`,
+ );
+}
diff --git a/packages/kernel/src/mission-orchestrator.ts b/packages/kernel/src/mission-orchestrator.ts
new file mode 100644
index 0000000..30b1cc6
--- /dev/null
+++ b/packages/kernel/src/mission-orchestrator.ts
@@ -0,0 +1,554 @@
+/**
+ * Mission orchestrator — sequential milestone dispatch and validation.
+ */
+
+import * as dispatch from './dispatch.js';
+import * as ledger from './ledger.js';
+import * as mission from './mission.js';
+import * as store from './store.js';
+import type {
+ Milestone,
+ MilestoneValidationPlan,
+ Mission,
+ MissionStatus,
+ PrimitiveInstance,
+} from './types.js';
+
+export interface MissionOrchestratorCycleResult {
+ missionPath: string;
+ missionStatus: MissionStatus;
+ actions: string[];
+ dispatchedRuns: string[];
+ validationRunId?: string;
+ changed: boolean;
+}
+
+export function runMissionOrchestratorCycle(
+ workspacePath: string,
+ missionRef: string,
+ actor: string = 'mission-orchestrator',
+): MissionOrchestratorCycleResult {
+ const missionInstance = mission.missionStatus(workspacePath, missionRef);
+ const state = asMission(missionInstance);
+ const milestones = state.milestones.map(cloneMilestone);
+ const now = new Date().toISOString();
+ const actions: string[] = [];
+ const dispatchedRuns: string[] = [];
+ let changed = false;
+ let validationRunId: string | undefined;
+
+ if (state.status !== 'active' && state.status !== 'validating') {
+ return {
+ missionPath: missionInstance.path,
+ missionStatus: state.status,
+ actions: ['skipped:mission-not-active'],
+ dispatchedRuns,
+ changed: false,
+ };
+ }
+
+ if (state.status === 'active') {
+ let activeMilestone = milestones.find((milestone) => milestone.status === 'active');
+ if (!activeMilestone) {
+ const next = pickNextReadyMilestone(milestones);
+ if (next) {
+ next.status = 'active';
+ next.started_at = next.started_at ?? now;
+ activeMilestone = next;
+ actions.push(`milestone-activated:${next.id}`);
+ appendMissionEvent(workspacePath, actor, missionInstance.path, 'mission-milestone-activated', {
+ milestone_id: next.id,
+ });
+ changed = true;
+ }
+ }
+
+ if (activeMilestone) {
+ for (const featurePath of activeMilestone.features) {
+ const featureThread = store.read(workspacePath, featurePath);
+ if (!featureThread || featureThread.type !== 'thread') continue;
+ const featureStatus = String(featureThread.fields.status ?? '');
+ if (featureStatus !== 'open') continue;
+ const adapter = pickAdapterForFeature(featureThread);
+ if (!shouldDispatchFeatureRun(workspacePath, featureThread)) continue;
+ const run = dispatch.createRun(workspacePath, {
+ actor,
+ adapter,
+ objective: `Mission ${state.mid} / ${activeMilestone.title}: ${String(featureThread.fields.title ?? featureThread.path)}`,
+ context: {
+ missionId: state.mid,
+ missionPath: missionInstance.path,
+ milestoneId: activeMilestone.id,
+ featureThread: featureThread.path,
+ },
+ });
+ dispatchedRuns.push(run.id);
+ actions.push(`feature-dispatched:${featureThread.path}`);
+ incrementMissionRunStats(state, run.adapter);
+ changed = true;
+ appendMissionEvent(workspacePath, actor, missionInstance.path, 'mission-feature-dispatched', {
+ milestone_id: activeMilestone.id,
+ feature_thread: featureThread.path,
+ run_id: run.id,
+ adapter: run.adapter,
+ });
+ store.update(
+ workspacePath,
+ featureThread.path,
+ {
+ mission_dispatch_last_run_id: run.id,
+ mission_dispatch_last_adapter: run.adapter,
+ mission_dispatch_last_at: now,
+ },
+ undefined,
+ actor,
+ {
+ skipAuthorization: true,
+ action: 'mission.orchestrator.feature.store',
+ requiredCapabilities: ['thread:update', 'thread:manage', 'dispatch:run', 'mission:manage'],
+ },
+ );
+ }
+
+ if (areMilestoneFeaturesDone(workspacePath, activeMilestone)) {
+ activeMilestone.status = 'validating';
+ activeMilestone.validation = ensureMilestoneValidation(activeMilestone.validation);
+ state.status = 'validating';
+ actions.push(`milestone-validating:${activeMilestone.id}`);
+ appendMissionEvent(workspacePath, actor, missionInstance.path, 'mission-milestone-validating', {
+ milestone_id: activeMilestone.id,
+ });
+ const run = ensureValidationDispatch(
+ workspacePath,
+ missionInstance,
+ state,
+ activeMilestone,
+ actor,
+ );
+ validationRunId = run.id;
+ changed = true;
+ }
+ } else if (milestones.every((milestone) => milestone.status === 'passed')) {
+ state.status = 'completed';
+ state.completed_at = state.completed_at ?? now;
+ actions.push('mission-completed');
+ appendMissionEvent(workspacePath, actor, missionInstance.path, 'mission-completed', {});
+ changed = true;
+ }
+ }
+
+ if (state.status === 'validating') {
+ const validatingMilestone = milestones.find((milestone) => milestone.status === 'validating');
+ if (!validatingMilestone) {
+ state.status = 'active';
+ changed = true;
+ actions.push('no-validating-milestone-reset-to-active');
+ } else {
+ validatingMilestone.validation = ensureMilestoneValidation(validatingMilestone.validation);
+ if (!validatingMilestone.validation.run_id) {
+ const run = ensureValidationDispatch(workspacePath, missionInstance, state, validatingMilestone, actor);
+ validationRunId = run.id;
+ changed = true;
+ } else {
+ const validationRun = dispatch.status(workspacePath, validatingMilestone.validation.run_id);
+ validatingMilestone.validation.run_status = validationRun.status;
+ validationRunId = validationRun.id;
+ changed = true;
+ if (validationRun.status === 'succeeded') {
+ validatingMilestone.status = 'passed';
+ validatingMilestone.completed_at = now;
+ validatingMilestone.validation.validated_at = now;
+ state.status = 'active';
+ actions.push(`milestone-passed:${validatingMilestone.id}`);
+ appendMissionEvent(workspacePath, actor, missionInstance.path, 'mission-milestone-complete', {
+ milestone_id: validatingMilestone.id,
+ run_id: validationRun.id,
+ });
+ const nextMilestone = pickNextReadyMilestone(milestones);
+ if (nextMilestone) {
+ nextMilestone.status = 'active';
+ nextMilestone.started_at = nextMilestone.started_at ?? now;
+ actions.push(`milestone-activated:${nextMilestone.id}`);
+ } else if (milestones.every((milestone) => milestone.status === 'passed')) {
+ state.status = 'completed';
+ state.completed_at = state.completed_at ?? now;
+ actions.push('mission-completed');
+ appendMissionEvent(workspacePath, actor, missionInstance.path, 'mission-completed', {});
+ }
+ } else if (validationRun.status === 'failed' || validationRun.status === 'cancelled') {
+ validatingMilestone.status = 'failed';
+ validatingMilestone.failed_at = now;
+ state.status = 'failed';
+ actions.push(`milestone-failed:${validatingMilestone.id}`);
+ const fixThread = createValidationFixThread(workspacePath, missionInstance, validatingMilestone, actor);
+ actions.push(`fix-thread-created:${fixThread.path}`);
+ appendMissionEvent(workspacePath, actor, missionInstance.path, 'mission-validation-failed', {
+ milestone_id: validatingMilestone.id,
+ run_id: validationRun.id,
+ fix_thread: fixThread.path,
+ });
+ }
+ }
+ }
+ }
+
+ if (changed) {
+ store.update(
+ workspacePath,
+ missionInstance.path,
+ {
+ status: state.status,
+ milestones: sanitizeForYaml(milestones),
+ total_runs: state.total_runs,
+ total_cost_usd: state.total_cost_usd,
+ runs_by_adapter: sanitizeForYaml(state.runs_by_adapter),
+ ...(state.completed_at ? { completed_at: state.completed_at } : {}),
+ },
+ undefined,
+ actor,
+ {
+ skipAuthorization: true,
+ action: 'mission.orchestrator.store',
+ requiredCapabilities: ['mission:update', 'mission:manage', 'dispatch:run'],
+ },
+ );
+ }
+
+ return {
+ missionPath: missionInstance.path,
+ missionStatus: state.status,
+ actions,
+ dispatchedRuns,
+ validationRunId,
+ changed,
+ };
+}
+
+export function runMissionOrchestratorForActiveMissions(
+ workspacePath: string,
+ actor: string = 'mission-orchestrator',
+): MissionOrchestratorCycleResult[] {
+ return mission
+ .listMissions(workspacePath)
+ .filter((entry) => {
+ const status = String(entry.fields.status ?? '');
+ return status === 'active' || status === 'validating';
+ })
+ .map((entry) => runMissionOrchestratorCycle(workspacePath, entry.path, actor));
+}
+
+function shouldDispatchFeatureRun(workspacePath: string, featureThread: PrimitiveInstance): boolean {
+ const previousRunId = asOptionalString(featureThread.fields.mission_dispatch_last_run_id);
+ if (!previousRunId) return true;
+ try {
+ const previousRun = dispatch.status(workspacePath, previousRunId);
+ return previousRun.status === 'failed' || previousRun.status === 'cancelled';
+ } catch {
+ return true;
+ }
+}
+
+function ensureValidationDispatch(
+ workspacePath: string,
+ missionInstance: PrimitiveInstance,
+ missionState: Mission,
+ milestone: Milestone,
+ actor: string,
+) {
+ milestone.validation = ensureMilestoneValidation(milestone.validation);
+ const run = dispatch.createRun(workspacePath, {
+ actor,
+ adapter: 'cursor-cloud',
+ objective: `Validate milestone "${milestone.title}": ${milestone.validation.criteria.join('; ') || 'No explicit criteria provided.'}`,
+ context: {
+ missionId: missionState.mid,
+ missionPath: missionInstance.path,
+ milestoneId: milestone.id,
+ isValidation: true,
+ },
+ });
+ milestone.validation.run_id = run.id;
+ milestone.validation.run_status = run.status;
+ incrementMissionRunStats(missionState, run.adapter);
+ appendMissionEvent(workspacePath, actor, missionInstance.path, 'mission-validation-dispatched', {
+ milestone_id: milestone.id,
+ run_id: run.id,
+ });
+ return run;
+}
+
+function createValidationFixThread(
+ workspacePath: string,
+ missionInstance: PrimitiveInstance,
+ milestone: Milestone,
+ actor: string,
+): PrimitiveInstance {
+ const missionId = String(missionInstance.fields.mid ?? 'mission');
+ const fixSlug = `${normalizeSlug(milestone.id)}-validation-fix`;
+ const pathOverride = `threads/mission-${missionId}/fix-${fixSlug}.md`;
+ const existing = store.read(workspacePath, pathOverride);
+ if (existing) return existing;
+ return store.create(
+ workspacePath,
+ 'thread',
+ {
+ tid: `fix-${normalizeSlug(milestone.id)}`,
+ title: `Fix validation failures: ${milestone.title}`,
+ goal: `Resolve validation failures for milestone "${milestone.title}" in mission ${missionInstance.path}.`,
+ status: 'open',
+ priority: 'high',
+ deps: [],
+ parent: missionInstance.path,
+ context_refs: [missionInstance.path],
+ tags: ['fix', 'validation-failure', 'mission-feature'],
+ },
+ `## Goal\n\nResolve failing validation outcomes for milestone "${milestone.title}".\n`,
+ actor,
+ {
+ pathOverride,
+ skipAuthorization: true,
+ action: 'mission.orchestrator.fix-thread.store',
+ requiredCapabilities: ['thread:create', 'thread:manage', 'mission:manage'],
+ },
+ );
+}
+
+function pickAdapterForFeature(featureThread: PrimitiveInstance): string {
+ const tags = asStringArray(featureThread.fields.tags).map((tag) => tag.toLowerCase());
+ if (tags.includes('claude-code') || tags.includes('claude')) return 'claude-code';
+ if (tags.includes('manual')) return 'manual';
+ if (tags.includes('cursor') || tags.includes('cursor-cloud')) return 'cursor-cloud';
+ return 'cursor-cloud';
+}
+
+function areMilestoneFeaturesDone(workspacePath: string, milestone: Milestone): boolean {
+ if (milestone.features.length === 0) return false;
+ return milestone.features.every((threadPath) => {
+ const thread = store.read(workspacePath, threadPath);
+ return !!thread && String(thread.fields.status ?? '') === 'done';
+ });
+}
+
+function pickNextReadyMilestone(milestones: Milestone[]): Milestone | undefined {
+ return milestones.find((milestone) => {
+ if (milestone.status !== 'open') return false;
+ const deps = milestone.deps ?? [];
+ return deps.every((depId) => milestones.some((candidate) => candidate.id === depId && candidate.status === 'passed'));
+ });
+}
+
+function incrementMissionRunStats(state: Mission, adapter: string): void {
+ state.total_runs = (state.total_runs ?? 0) + 1;
+ state.runs_by_adapter = state.runs_by_adapter ?? {};
+ state.runs_by_adapter[adapter] = (state.runs_by_adapter[adapter] ?? 0) + 1;
+}
+
+function ensureMilestoneValidation(validation: MilestoneValidationPlan | undefined): MilestoneValidationPlan {
+ return {
+ strategy: validation?.strategy ?? 'automated',
+ criteria: validation?.criteria ?? [],
+ ...(validation?.run_id ? { run_id: validation.run_id } : {}),
+ ...(validation?.run_status ? { run_status: validation.run_status } : {}),
+ ...(validation?.validated_at ? { validated_at: validation.validated_at } : {}),
+ };
+}
+
+function appendMissionEvent(
+ workspacePath: string,
+ actor: string,
+ missionPath: string,
+ eventType: string,
+ details: Record,
+): void {
+ ledger.append(workspacePath, actor, 'update', missionPath, 'mission', {
+ mission_event: eventType,
+ ...details,
+ });
+}
+
+function asMission(instance: PrimitiveInstance): Mission {
+ const milestones = normalizeMilestones(instance.fields.milestones);
+ return {
+ mid: String(instance.fields.mid ?? instance.path),
+ title: String(instance.fields.title ?? instance.path),
+ description: asOptionalString(instance.fields.description),
+ status: normalizeMissionStatus(instance.fields.status),
+ priority: normalizePriority(instance.fields.priority),
+ owner: asOptionalString(instance.fields.owner),
+ project: asOptionalString(instance.fields.project),
+ space: asOptionalString(instance.fields.space),
+ plan: {
+ goal: asOptionalString((instance.fields.plan as Record | undefined)?.goal) ?? '',
+ constraints: asStringArray((instance.fields.plan as Record | undefined)?.constraints),
+ estimated_runs: asNumber((instance.fields.plan as Record | undefined)?.estimated_runs),
+ estimated_cost_usd: asNullableNumber((instance.fields.plan as Record | undefined)?.estimated_cost_usd),
+ },
+ milestones,
+ started_at: asOptionalString(instance.fields.started_at),
+ completed_at: asOptionalString(instance.fields.completed_at),
+ total_runs: asNumber(instance.fields.total_runs) ?? 0,
+ total_cost_usd: asNumber(instance.fields.total_cost_usd) ?? 0,
+ runs_by_adapter: asStringNumberRecord(instance.fields.runs_by_adapter),
+ tags: asStringArray(instance.fields.tags),
+ created: asOptionalString(instance.fields.created) ?? new Date(0).toISOString(),
+ updated: asOptionalString(instance.fields.updated) ?? new Date(0).toISOString(),
+ };
+}
+
+function normalizeMilestones(value: unknown): Milestone[] {
+ if (!Array.isArray(value)) return [];
+ return value
+ .map((entry, index) => normalizeMilestone(entry, index))
+ .filter((entry): entry is Milestone => !!entry);
+}
+
+function normalizeMilestone(value: unknown, index: number): Milestone | null {
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
+ const record = value as Record;
+ const id = asOptionalString(record.id) ?? `ms-${index + 1}`;
+ const title = asOptionalString(record.title) ?? id;
+ return {
+ id,
+ title,
+ status: normalizeMilestoneStatus(record.status),
+ deps: dedupeStrings(asStringArray(record.deps)),
+ features: dedupeStrings(asStringArray(record.features)),
+ validation: normalizeValidation(record.validation),
+ ...(asOptionalString(record.started_at) ? { started_at: asOptionalString(record.started_at) } : {}),
+ ...(asOptionalString(record.completed_at) ? { completed_at: asOptionalString(record.completed_at) } : {}),
+ ...(asOptionalString(record.failed_at) ? { failed_at: asOptionalString(record.failed_at) } : {}),
+ };
+}
+
+function normalizeValidation(value: unknown): MilestoneValidationPlan | undefined {
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined;
+ const record = value as Record;
+ return {
+ strategy: normalizeValidationStrategy(record.strategy),
+ criteria: asStringArray(record.criteria),
+ ...(asOptionalString(record.run_id) ? { run_id: asOptionalString(record.run_id) } : {}),
+ ...(asOptionalString(record.run_status)
+ ? { run_status: asOptionalString(record.run_status) as MilestoneValidationPlan['run_status'] }
+ : {}),
+ ...(asOptionalString(record.validated_at) ? { validated_at: asOptionalString(record.validated_at) } : {}),
+ };
+}
+
+function sanitizeForYaml(value: T): T {
+ if (Array.isArray(value)) {
+ return value
+ .map((entry) => sanitizeForYaml(entry))
+ .filter((entry) => entry !== undefined) as unknown as T;
+ }
+ if (!value || typeof value !== 'object') return value;
+ const output: Record = {};
+ for (const [key, innerValue] of Object.entries(value as Record)) {
+ if (innerValue === undefined) continue;
+ const sanitized = sanitizeForYaml(innerValue);
+ if (sanitized === undefined) continue;
+ output[key] = sanitized;
+ }
+ return output as T;
+}
+
+function cloneMilestone(milestone: Milestone): Milestone {
+ return {
+ ...milestone,
+ deps: [...(milestone.deps ?? [])],
+ features: [...milestone.features],
+ validation: milestone.validation ? { ...milestone.validation } : undefined,
+ };
+}
+
+function normalizeMissionStatus(value: unknown): MissionStatus {
+ const normalized = String(value ?? 'planning').trim().toLowerCase();
+ if (
+ normalized === 'planning'
+ || normalized === 'approved'
+ || normalized === 'active'
+ || normalized === 'validating'
+ || normalized === 'completed'
+ || normalized === 'failed'
+ ) {
+ return normalized;
+ }
+ return 'planning';
+}
+
+function normalizeMilestoneStatus(value: unknown): Milestone['status'] {
+ const normalized = String(value ?? 'open').trim().toLowerCase();
+ if (
+ normalized === 'open'
+ || normalized === 'active'
+ || normalized === 'validating'
+ || normalized === 'passed'
+ || normalized === 'failed'
+ ) {
+ return normalized;
+ }
+ return 'open';
+}
+
+function normalizePriority(value: unknown): Mission['priority'] {
+ const normalized = String(value ?? 'medium').trim().toLowerCase();
+ if (normalized === 'urgent' || normalized === 'high' || normalized === 'medium' || normalized === 'low') {
+ return normalized;
+ }
+ return 'medium';
+}
+
+function normalizeValidationStrategy(value: unknown): MilestoneValidationPlan['strategy'] {
+ const normalized = String(value ?? 'automated').trim().toLowerCase();
+ if (normalized === 'automated' || normalized === 'manual' || normalized === 'hybrid') {
+ return normalized;
+ }
+ return 'automated';
+}
+
+function asOptionalString(value: unknown): string | undefined {
+ if (typeof value !== 'string') return undefined;
+ const trimmed = value.trim();
+ return trimmed.length > 0 ? trimmed : undefined;
+}
+
+function asStringArray(value: unknown): string[] {
+ if (!Array.isArray(value)) return [];
+ return value
+ .map((entry) => asOptionalString(entry))
+ .filter((entry): entry is string => !!entry);
+}
+
+function asNumber(value: unknown): number | undefined {
+ if (typeof value === 'number' && Number.isFinite(value)) return value;
+ if (typeof value === 'string' && value.trim()) {
+ const parsed = Number(value);
+ if (Number.isFinite(parsed)) return parsed;
+ }
+ return undefined;
+}
+
+function asNullableNumber(value: unknown): number | null | undefined {
+ if (value === null) return null;
+ return asNumber(value);
+}
+
+function asStringNumberRecord(value: unknown): Record {
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return {};
+ const output: Record = {};
+ for (const [key, rawValue] of Object.entries(value as Record)) {
+ output[key] = asNumber(rawValue) ?? 0;
+ }
+ return output;
+}
+
+function dedupeStrings(values: string[]): string[] {
+ return [...new Set(values.map((value) => value.trim()).filter(Boolean))];
+}
+
+function normalizeSlug(value: string): string {
+ return String(value ?? '')
+ .toLowerCase()
+ .replace(/[^a-z0-9]+/g, '-')
+ .replace(/^-+|-+$/g, '')
+ .slice(0, 80);
+}
diff --git a/packages/kernel/src/mission.test.ts b/packages/kernel/src/mission.test.ts
new file mode 100644
index 0000000..639bd15
--- /dev/null
+++ b/packages/kernel/src/mission.test.ts
@@ -0,0 +1,139 @@
+import { afterEach, beforeEach, describe, expect, it } from 'vitest';
+import fs from 'node:fs';
+import os from 'node:os';
+import path from 'node:path';
+import * as mission from './mission.js';
+import { loadRegistry, saveRegistry } from './registry.js';
+import * as store from './store.js';
+
+let workspacePath: string;
+
+beforeEach(() => {
+ workspacePath = fs.mkdtempSync(path.join(os.tmpdir(), 'wg-mission-'));
+ const registry = loadRegistry(workspacePath);
+ saveRegistry(workspacePath, registry);
+});
+
+afterEach(() => {
+ fs.rmSync(workspacePath, { recursive: true, force: true });
+});
+
+describe('mission lifecycle', () => {
+ it('creates, plans, approves, and starts a mission', () => {
+ const created = mission.createMission(
+ workspacePath,
+ 'Deploy cloud backend',
+ 'Ship backend to production',
+ 'agent-planner',
+ {
+ constraints: ['Use zero downtime deploys'],
+ tags: ['deployment'],
+ },
+ );
+ expect(created.path).toBe('missions/deploy-cloud-backend.md');
+ expect(created.fields.status).toBe('planning');
+
+ const planned = mission.planMission(workspacePath, created.path, {
+ goal: 'Production-ready backend rollout',
+ constraints: ['No downtime', 'Database migrations must be reversible'],
+ estimated_runs: 6,
+ milestones: [
+ {
+ id: 'ms-1',
+ title: 'Core API',
+ features: [
+ 'Database migrations',
+ { title: 'Authentication hardening', goal: 'Harden authentication flows' },
+ ],
+ validation: {
+ strategy: 'automated',
+ criteria: ['pnpm run test', 'pnpm run build'],
+ },
+ },
+ {
+ id: 'ms-2',
+ title: 'Deploy and monitor',
+ deps: ['ms-1'],
+ features: ['Production deployment'],
+ validation: {
+ strategy: 'manual',
+ criteria: ['Smoke test production endpoint'],
+ },
+ },
+ ],
+ }, 'agent-planner');
+ expect(Array.isArray(planned.fields.milestones)).toBe(true);
+ expect((planned.fields.milestones as unknown[]).length).toBe(2);
+
+ const missionInstance = store.read(workspacePath, created.path);
+ expect(missionInstance).not.toBeNull();
+ const milestones = missionInstance?.fields.milestones as Array<{ features: string[] }>;
+ expect(milestones[0]?.features.length).toBe(2);
+ expect(milestones[1]?.features.length).toBe(1);
+
+ const featureThreads = store.list(workspacePath, 'thread');
+ expect(featureThreads.length).toBe(3);
+ for (const featureThread of featureThreads) {
+ expect(String(featureThread.fields.parent)).toBe(created.path);
+ expect(featureThread.path.startsWith('threads/mission-deploy-cloud-backend/')).toBe(true);
+ }
+
+ const approved = mission.approveMission(workspacePath, created.path, 'agent-planner');
+ expect(approved.fields.status).toBe('approved');
+
+ const started = mission.startMission(workspacePath, created.path, 'agent-planner');
+ expect(started.fields.status).toBe('active');
+ const startedMilestones = started.fields.milestones as Array<{ id: string; status: string }>;
+ expect(startedMilestones.find((entry) => entry.id === 'ms-1')?.status).toBe('active');
+ });
+
+ it('reports mission progress and supports interventions', () => {
+ const created = mission.createMission(
+ workspacePath,
+ 'Release app v2',
+ 'Ship v2 safely',
+ 'agent-release',
+ );
+ mission.planMission(workspacePath, created.path, {
+ milestones: [
+ {
+ id: 'ms-core',
+ title: 'Core',
+ features: ['API', 'Auth'],
+ },
+ ],
+ }, 'agent-release');
+
+ const before = mission.missionProgress(workspacePath, created.path);
+ expect(before.totalMilestones).toBe(1);
+ expect(before.totalFeatures).toBe(2);
+ expect(before.doneFeatures).toBe(0);
+
+ const intervened = mission.interveneMission(workspacePath, created.path, {
+ reason: 'Scope narrowed after incident review.',
+ skipFeature: {
+ milestoneId: 'ms-core',
+ threadPath: 'threads/mission-release-app-v2/auth.md',
+ },
+ appendMilestones: [
+ {
+ id: 'ms-monitoring',
+ title: 'Monitoring',
+ deps: ['ms-core'],
+ features: ['Dashboards'],
+ },
+ ],
+ setPriority: 'high',
+ }, 'agent-release');
+ expect(intervened.fields.priority).toBe('high');
+
+ const after = mission.missionProgress(workspacePath, created.path);
+ expect(after.totalMilestones).toBe(2);
+ expect(after.totalFeatures).toBe(2);
+ const missionInstance = mission.missionStatus(workspacePath, created.path);
+ const milestones = missionInstance.fields.milestones as Array<{ id: string; features: string[] }>;
+ expect(milestones.find((entry) => entry.id === 'ms-core')?.features).toEqual([
+ 'threads/mission-release-app-v2/api.md',
+ ]);
+ });
+});
diff --git a/packages/kernel/src/mission.ts b/packages/kernel/src/mission.ts
new file mode 100644
index 0000000..aaf01ce
--- /dev/null
+++ b/packages/kernel/src/mission.ts
@@ -0,0 +1,937 @@
+/**
+ * Mission primitive lifecycle operations.
+ */
+
+import * as auth from './auth.js';
+import * as ledger from './ledger.js';
+import * as store from './store.js';
+import {
+ MISSION_STATUS_TRANSITIONS,
+ type Milestone,
+ type MilestoneValidationPlan,
+ type Mission,
+ type MissionPlan,
+ type MissionStatus,
+ type PrimitiveInstance,
+} from './types.js';
+
+export interface CreateMissionOptions {
+ mid?: string;
+ description?: string;
+ priority?: 'urgent' | 'high' | 'medium' | 'low';
+ owner?: string;
+ project?: string;
+ space?: string;
+ constraints?: string[];
+ tags?: string[];
+}
+
+export interface MissionFeaturePlanInput {
+ title?: string;
+ goal?: string;
+ threadPath?: string;
+ priority?: 'urgent' | 'high' | 'medium' | 'low';
+ deps?: string[];
+ tags?: string[];
+}
+
+export interface MissionMilestonePlanInput {
+ id?: string;
+ title: string;
+ deps?: string[];
+ features: Array;
+ validation?: {
+ strategy?: 'automated' | 'manual' | 'hybrid';
+ criteria?: string[];
+ };
+}
+
+export interface PlanMissionInput {
+ goal?: string;
+ constraints?: string[];
+ estimated_runs?: number;
+ estimated_cost_usd?: number | null;
+ milestones: MissionMilestonePlanInput[];
+ replaceMilestones?: boolean;
+}
+
+export interface MissionInterventionInput {
+ reason: string;
+ setPriority?: 'urgent' | 'high' | 'medium' | 'low';
+ setStatus?: MissionStatus;
+ skipFeature?: {
+ milestoneId: string;
+ threadPath: string;
+ };
+ appendMilestones?: MissionMilestonePlanInput[];
+}
+
+export interface MissionProgressMilestoneSummary {
+ id: string;
+ title: string;
+ status: string;
+ featuresTotal: number;
+ featuresDone: number;
+}
+
+export interface MissionProgressReport {
+ missionPath: string;
+ mid: string;
+ status: MissionStatus;
+ totalMilestones: number;
+ passedMilestones: number;
+ totalFeatures: number;
+ doneFeatures: number;
+ percentComplete: number;
+ totalRuns: number;
+ totalCostUsd: number;
+ runsByAdapter: Record;
+ milestones: MissionProgressMilestoneSummary[];
+}
+
+export function createMission(
+ workspacePath: string,
+ title: string,
+ goal: string,
+ actor: string,
+ options: CreateMissionOptions = {},
+): PrimitiveInstance {
+ assertMissionMutationAuthorized(workspacePath, actor, 'mission.create', 'missions', [
+ 'mission:create',
+ 'mission:manage',
+ 'policy:manage',
+ ]);
+ const mid = options.mid ? normalizeSlug(options.mid) : mintMissionId(title);
+ const pathOverride = `missions/${mid}.md`;
+ const created = store.create(
+ workspacePath,
+ 'mission',
+ {
+ mid,
+ title,
+ description: options.description,
+ status: 'planning',
+ priority: options.priority ?? 'medium',
+ owner: options.owner ?? actor,
+ project: normalizeOptionalRef(options.project),
+ space: normalizeOptionalRef(options.space),
+ plan: {
+ goal,
+ constraints: options.constraints ?? [],
+ } satisfies MissionPlan,
+ milestones: [],
+ total_runs: 0,
+ total_cost_usd: 0,
+ runs_by_adapter: {},
+ tags: options.tags ?? [],
+ },
+ renderMissionBody({
+ goal,
+ constraints: options.constraints ?? [],
+ }),
+ actor,
+ {
+ pathOverride,
+ skipAuthorization: true,
+ action: 'mission.create.store',
+ requiredCapabilities: ['mission:create', 'mission:manage', 'policy:manage'],
+ },
+ );
+ ledger.append(workspacePath, actor, 'update', created.path, 'mission', {
+ mission_event: 'mission-created',
+ mid,
+ });
+ return created;
+}
+
+export function planMission(
+ workspacePath: string,
+ missionRef: string,
+ input: PlanMissionInput,
+ actor: string,
+): PrimitiveInstance {
+ assertMissionMutationAuthorized(workspacePath, actor, 'mission.plan', missionRef, [
+ 'mission:update',
+ 'mission:manage',
+ 'thread:create',
+ 'thread:manage',
+ ]);
+ const mission = requireMission(workspacePath, missionRef);
+ const missionState = asMission(mission);
+ if (missionState.status !== 'planning' && missionState.status !== 'approved') {
+ throw new Error(`Cannot plan mission in "${missionState.status}" state.`);
+ }
+ if (!Array.isArray(input.milestones) || input.milestones.length === 0) {
+ throw new Error('Mission plan requires at least one milestone.');
+ }
+
+ const existingMilestones = indexMilestones(missionState.milestones);
+ const nextMilestones = input.milestones.map((milestoneInput, index) =>
+ materializeMilestonePlan(
+ workspacePath,
+ mission,
+ milestoneInput,
+ index,
+ actor,
+ existingMilestones.get(normalizeMilestoneId(milestoneInput.id, index)),
+ ),
+ );
+ const mergedMilestones = input.replaceMilestones === false
+ ? mergeMilestones(missionState.milestones, nextMilestones)
+ : nextMilestones;
+
+ const nextPlan: MissionPlan = {
+ goal: input.goal ?? missionState.plan?.goal ?? String(mission.fields.title ?? mission.path),
+ constraints: input.constraints ?? missionState.plan?.constraints ?? [],
+ estimated_runs: input.estimated_runs ?? missionState.plan?.estimated_runs,
+ estimated_cost_usd: input.estimated_cost_usd ?? missionState.plan?.estimated_cost_usd ?? null,
+ };
+ const safePlan = sanitizeForYaml(nextPlan);
+ const safeMilestones = sanitizeForYaml(mergedMilestones);
+ const updated = store.update(
+ workspacePath,
+ mission.path,
+ {
+ plan: safePlan,
+ milestones: safeMilestones,
+ },
+ renderMissionBody(safePlan, safeMilestones),
+ actor,
+ {
+ skipAuthorization: true,
+ action: 'mission.plan.store',
+ requiredCapabilities: ['mission:update', 'mission:manage', 'thread:create', 'thread:manage'],
+ },
+ );
+ ledger.append(workspacePath, actor, 'update', mission.path, 'mission', {
+ mission_event: 'mission-planned',
+ milestone_count: mergedMilestones.length,
+ feature_count: mergedMilestones.reduce((sum, milestone) => sum + milestone.features.length, 0),
+ });
+ return updated;
+}
+
+export function approveMission(workspacePath: string, missionRef: string, actor: string): PrimitiveInstance {
+ assertMissionMutationAuthorized(workspacePath, actor, 'mission.approve', missionRef, [
+ 'mission:update',
+ 'mission:manage',
+ 'policy:manage',
+ ]);
+ const mission = requireMission(workspacePath, missionRef);
+ const missionState = asMission(mission);
+ assertMissionStatusTransition(missionState.status, 'approved');
+ if (missionState.milestones.length === 0) {
+ throw new Error('Cannot approve mission without planned milestones.');
+ }
+ const updated = store.update(
+ workspacePath,
+ mission.path,
+ {
+ status: 'approved',
+ approved_at: new Date().toISOString(),
+ },
+ undefined,
+ actor,
+ {
+ skipAuthorization: true,
+ action: 'mission.approve.store',
+ requiredCapabilities: ['mission:update', 'mission:manage', 'policy:manage'],
+ },
+ );
+ ledger.append(workspacePath, actor, 'update', mission.path, 'mission', {
+ mission_event: 'mission-approved',
+ });
+ return updated;
+}
+
+export function startMission(workspacePath: string, missionRef: string, actor: string): PrimitiveInstance {
+ assertMissionMutationAuthorized(workspacePath, actor, 'mission.start', missionRef, [
+ 'mission:update',
+ 'mission:manage',
+ 'dispatch:run',
+ ]);
+ const mission = requireMission(workspacePath, missionRef);
+ const missionState = asMission(mission);
+ assertMissionStatusTransition(missionState.status, 'active');
+ if (missionState.milestones.length === 0) {
+ throw new Error('Cannot start mission without milestones.');
+ }
+ const now = new Date().toISOString();
+ const nextMilestones = missionState.milestones.map((milestone) => ({ ...milestone }));
+ const activeOrValidating = nextMilestones.some((milestone) =>
+ milestone.status === 'active' || milestone.status === 'validating',
+ );
+ if (!activeOrValidating) {
+ const firstReady = pickNextReadyMilestone(nextMilestones);
+ if (firstReady) {
+ firstReady.status = 'active';
+ firstReady.started_at = firstReady.started_at ?? now;
+ }
+ }
+ const updated = store.update(
+ workspacePath,
+ mission.path,
+ {
+ status: 'active',
+ started_at: missionState.started_at ?? now,
+ milestones: sanitizeForYaml(nextMilestones),
+ },
+ renderMissionBody(missionState.plan, nextMilestones),
+ actor,
+ {
+ skipAuthorization: true,
+ action: 'mission.start.store',
+ requiredCapabilities: ['mission:update', 'mission:manage', 'dispatch:run'],
+ },
+ );
+ ledger.append(workspacePath, actor, 'update', mission.path, 'mission', {
+ mission_event: 'mission-started',
+ });
+ return updated;
+}
+
+export function missionStatus(workspacePath: string, missionRef: string): PrimitiveInstance {
+ return requireMission(workspacePath, missionRef);
+}
+
+export function missionProgress(workspacePath: string, missionRef: string): MissionProgressReport {
+ const mission = requireMission(workspacePath, missionRef);
+ const missionState = asMission(mission);
+ const milestoneSummaries: MissionProgressMilestoneSummary[] = [];
+ let totalFeatures = 0;
+ let doneFeatures = 0;
+ for (const milestone of missionState.milestones) {
+ const featureStats = summarizeMilestoneFeatures(workspacePath, milestone);
+ totalFeatures += featureStats.total;
+ doneFeatures += featureStats.done;
+ milestoneSummaries.push({
+ id: milestone.id,
+ title: milestone.title,
+ status: milestone.status,
+ featuresTotal: featureStats.total,
+ featuresDone: featureStats.done,
+ });
+ }
+ const passedMilestones = missionState.milestones.filter((milestone) => milestone.status === 'passed').length;
+ const percentComplete = totalFeatures > 0
+ ? Math.round((doneFeatures / totalFeatures) * 100)
+ : missionState.status === 'completed'
+ ? 100
+ : 0;
+ return {
+ missionPath: mission.path,
+ mid: missionState.mid,
+ status: missionState.status,
+ totalMilestones: missionState.milestones.length,
+ passedMilestones,
+ totalFeatures,
+ doneFeatures,
+ percentComplete,
+ totalRuns: missionState.total_runs,
+ totalCostUsd: missionState.total_cost_usd,
+ runsByAdapter: missionState.runs_by_adapter ?? {},
+ milestones: milestoneSummaries,
+ };
+}
+
+export function interveneMission(
+ workspacePath: string,
+ missionRef: string,
+ input: MissionInterventionInput,
+ actor: string,
+): PrimitiveInstance {
+ assertMissionMutationAuthorized(workspacePath, actor, 'mission.intervene', missionRef, [
+ 'mission:update',
+ 'mission:manage',
+ 'thread:update',
+ 'thread:manage',
+ ]);
+ const mission = requireMission(workspacePath, missionRef);
+ const missionState = asMission(mission);
+ const reason = String(input.reason ?? '').trim();
+ if (!reason) {
+ throw new Error('Mission intervention requires a non-empty reason.');
+ }
+ const milestones: Milestone[] = missionState.milestones.map(cloneMilestone);
+ const skipFeature = input.skipFeature;
+ if (skipFeature) {
+ const normalizedFeature = normalizeThreadPath(skipFeature.threadPath);
+ const milestone = milestones.find((entry) => entry.id === skipFeature.milestoneId);
+ if (!milestone) {
+ throw new Error(`Milestone not found: ${skipFeature.milestoneId}`);
+ }
+ milestone.features = milestone.features.filter((threadPath) => normalizeThreadPath(threadPath) !== normalizedFeature);
+ }
+ if (Array.isArray(input.appendMilestones) && input.appendMilestones.length > 0) {
+ const existingById = indexMilestones(milestones);
+ for (let index = 0; index < input.appendMilestones.length; index += 1) {
+ const appendInput = input.appendMilestones[index]!;
+ const id = normalizeMilestoneId(appendInput.id, milestones.length + index);
+ if (existingById.has(id)) {
+ throw new Error(`Cannot append milestone "${id}" because it already exists.`);
+ }
+ milestones.push(materializeMilestonePlan(
+ workspacePath,
+ mission,
+ appendInput,
+ milestones.length + index,
+ actor,
+ ));
+ }
+ }
+
+ const nextStatus = input.setStatus ?? missionState.status;
+ if (nextStatus !== missionState.status) {
+ assertMissionStatusTransition(missionState.status, nextStatus);
+ }
+ const updated = store.update(
+ workspacePath,
+ mission.path,
+ {
+ ...(input.setPriority ? { priority: input.setPriority } : {}),
+ ...(nextStatus !== missionState.status ? { status: nextStatus } : {}),
+ milestones: sanitizeForYaml(milestones),
+ ...(nextStatus === 'completed' ? { completed_at: new Date().toISOString() } : {}),
+ },
+ renderMissionBody(missionState.plan, milestones),
+ actor,
+ {
+ skipAuthorization: true,
+ action: 'mission.intervene.store',
+ requiredCapabilities: ['mission:update', 'mission:manage', 'thread:update', 'thread:manage'],
+ },
+ );
+ ledger.append(workspacePath, actor, 'update', mission.path, 'mission', {
+ mission_event: 'mission-intervened',
+ reason,
+ ...(input.setPriority ? { priority: input.setPriority } : {}),
+ ...(input.setStatus ? { status: input.setStatus } : {}),
+ ...(input.skipFeature ? { skipped_feature: normalizeThreadPath(input.skipFeature.threadPath) } : {}),
+ ...(input.appendMilestones ? { appended_milestones: input.appendMilestones.length } : {}),
+ });
+ return updated;
+}
+
+export function listMissions(workspacePath: string): PrimitiveInstance[] {
+ return store.list(workspacePath, 'mission').sort((left, right) =>
+ String(right.fields.updated ?? '').localeCompare(String(left.fields.updated ?? '')),
+ );
+}
+
+export function mintMissionId(title: string): string {
+ const slug = normalizeSlug(title);
+ return slug || 'mission';
+}
+
+function materializeMilestonePlan(
+ workspacePath: string,
+ mission: PrimitiveInstance,
+ milestoneInput: MissionMilestonePlanInput,
+ index: number,
+ actor: string,
+ existing?: Milestone,
+): Milestone {
+ const milestoneId = normalizeMilestoneId(milestoneInput.id, index);
+ const milestoneTitle = String(milestoneInput.title ?? '').trim();
+ if (!milestoneTitle) {
+ throw new Error(`Milestone ${milestoneId} requires a title.`);
+ }
+ if (!Array.isArray(milestoneInput.features) || milestoneInput.features.length === 0) {
+ throw new Error(`Milestone ${milestoneId} requires at least one feature.`);
+ }
+ const featureRefs = milestoneInput.features.map((feature, featureIndex) =>
+ materializeFeatureThread(workspacePath, mission, feature, milestoneId, featureIndex, actor),
+ );
+ const validation = normalizeValidationPlan(milestoneInput.validation, existing?.validation);
+ return {
+ id: milestoneId,
+ title: milestoneTitle,
+ status: existing?.status ?? 'open',
+ deps: dedupeStrings(milestoneInput.deps ?? existing?.deps ?? []),
+ features: dedupeStrings(featureRefs),
+ ...(validation ? { validation } : {}),
+ ...(existing?.started_at ? { started_at: existing.started_at } : {}),
+ ...(existing?.completed_at ? { completed_at: existing.completed_at } : {}),
+ ...(existing?.failed_at ? { failed_at: existing.failed_at } : {}),
+ };
+}
+
+function cloneMilestone(milestone: Milestone): Milestone {
+ return {
+ ...milestone,
+ deps: [...(milestone.deps ?? [])],
+ features: [...milestone.features],
+ ...(milestone.validation ? { validation: { ...milestone.validation } } : {}),
+ };
+}
+
+function materializeFeatureThread(
+ workspacePath: string,
+ mission: PrimitiveInstance,
+ input: string | MissionFeaturePlanInput,
+ milestoneId: string,
+ featureIndex: number,
+ actor: string,
+): string {
+ const missionMid = String(mission.fields.mid ?? '').trim();
+ const missionPath = mission.path;
+ const missionSpace = normalizeOptionalRef(mission.fields.space);
+ if (typeof input === 'string') {
+ return ensureMissionFeatureThread(
+ workspacePath,
+ mission,
+ {
+ title: input,
+ goal: `Complete feature "${input}" for milestone ${milestoneId}.`,
+ },
+ `threads/mission-${missionMid}/${normalizeSlug(input) || `feature-${featureIndex + 1}`}.md`,
+ actor,
+ missionSpace,
+ missionPath,
+ );
+ }
+ const explicitPath = normalizeThreadPath(input.threadPath);
+ if (explicitPath) {
+ const existing = store.read(workspacePath, explicitPath);
+ if (existing) return existing.path;
+ if (!input.title) {
+ throw new Error(`Feature thread "${explicitPath}" does not exist and no title was provided to create it.`);
+ }
+ }
+ const title = String(input.title ?? '').trim();
+ if (!title) {
+ throw new Error(`Feature at milestone ${milestoneId} index ${featureIndex + 1} requires a title.`);
+ }
+ const featurePath = explicitPath || `threads/mission-${missionMid}/${normalizeSlug(title) || `feature-${featureIndex + 1}`}.md`;
+ return ensureMissionFeatureThread(
+ workspacePath,
+ mission,
+ input,
+ featurePath,
+ actor,
+ missionSpace,
+ missionPath,
+ );
+}
+
+function ensureMissionFeatureThread(
+ workspacePath: string,
+ mission: PrimitiveInstance,
+ input: MissionFeaturePlanInput,
+ featurePath: string,
+ actor: string,
+ missionSpace: string | undefined,
+ missionPath: string,
+): string {
+ const existing = store.read(workspacePath, featurePath);
+ if (existing) return existing.path;
+ const title = String(input.title ?? '').trim();
+ if (!title) {
+ throw new Error(`Cannot create mission feature thread without title (${featurePath}).`);
+ }
+ const goal = String(input.goal ?? `Complete feature "${title}" for mission ${mission.fields.title}.`).trim();
+ const now = new Date().toISOString();
+ const feature = store.create(
+ workspacePath,
+ 'thread',
+ {
+ tid: normalizeSlug(title) || 'feature',
+ title,
+ goal,
+ status: 'open',
+ priority: input.priority ?? 'medium',
+ deps: dedupeStrings(input.deps ?? []),
+ parent: mission.path,
+ space: missionSpace,
+ context_refs: dedupeStrings([missionPath, ...(missionSpace ? [missionSpace] : [])]),
+ participants: [{
+ actor: actor.toLowerCase(),
+ role: 'owner',
+ joined_at: now,
+ invited_by: actor.toLowerCase(),
+ }],
+ tags: dedupeStrings([...(input.tags ?? []), 'mission-feature']),
+ },
+ `## Goal\n\n${goal}\n`,
+ actor,
+ {
+ pathOverride: featurePath,
+ skipAuthorization: true,
+ action: 'mission.plan.feature.store',
+ requiredCapabilities: ['thread:create', 'thread:manage', 'mission:update', 'mission:manage'],
+ },
+ );
+ ledger.append(workspacePath, actor, 'update', mission.path, 'mission', {
+ mission_event: 'mission-feature-created',
+ feature_thread: feature.path,
+ });
+ return feature.path;
+}
+
+function summarizeMilestoneFeatures(workspacePath: string, milestone: Milestone): { total: number; done: number } {
+ let done = 0;
+ for (const threadPath of milestone.features) {
+ const thread = store.read(workspacePath, threadPath);
+ if (thread && String(thread.fields.status ?? '') === 'done') {
+ done += 1;
+ }
+ }
+ return {
+ total: milestone.features.length,
+ done,
+ };
+}
+
+function mergeMilestones(existing: Milestone[], next: Milestone[]): Milestone[] {
+ const byId = indexMilestones(existing);
+ for (const milestone of next) {
+ byId.set(milestone.id, milestone);
+ }
+ return [...byId.values()];
+}
+
+function indexMilestones(milestones: Milestone[]): Map {
+ const map = new Map();
+ for (const milestone of milestones) {
+ map.set(milestone.id, milestone);
+ }
+ return map;
+}
+
+function normalizeValidationPlan(
+ input: MissionMilestonePlanInput['validation'],
+ existing?: MilestoneValidationPlan,
+): MilestoneValidationPlan {
+ const strategy = input?.strategy ?? existing?.strategy ?? 'automated';
+ return {
+ strategy,
+ criteria: dedupeStrings(input?.criteria ?? existing?.criteria ?? []),
+ ...(existing?.run_id ? { run_id: existing.run_id } : {}),
+ ...(existing?.run_status ? { run_status: existing.run_status } : {}),
+ ...(existing?.validated_at ? { validated_at: existing.validated_at } : {}),
+ };
+}
+
+function requireMission(workspacePath: string, missionRef: string): PrimitiveInstance {
+ const missionPath = resolveMissionPath(workspacePath, missionRef);
+ const mission = store.read(workspacePath, missionPath);
+ if (!mission) {
+ throw new Error(`Mission not found: ${missionRef}`);
+ }
+ if (mission.type !== 'mission') {
+ throw new Error(`Target is not a mission primitive: ${missionRef}`);
+ }
+ return mission;
+}
+
+function resolveMissionPath(workspacePath: string, missionRef: string): string {
+ const normalizedRef = normalizeOptionalRef(missionRef);
+ if (!normalizedRef) {
+ throw new Error('Mission reference is required.');
+ }
+ if (normalizedRef.startsWith('missions/')) {
+ return normalizedRef;
+ }
+ const missionPath = `missions/${normalizeSlug(normalizedRef)}.md`;
+ if (store.read(workspacePath, missionPath)) {
+ return missionPath;
+ }
+ const foundByMid = store.list(workspacePath, 'mission').find((mission) =>
+ String(mission.fields.mid ?? '') === normalizedRef || String(mission.fields.mid ?? '') === normalizeSlug(normalizedRef),
+ );
+ if (foundByMid) {
+ return foundByMid.path;
+ }
+ return missionPath;
+}
+
+function asMission(mission: PrimitiveInstance): Mission {
+ const milestones = normalizeMilestones(mission.fields.milestones);
+ return {
+ mid: String(mission.fields.mid ?? mission.path.split('/').pop()?.replace(/\.md$/, '') ?? 'mission'),
+ title: String(mission.fields.title ?? mission.path),
+ description: normalizeOptionalString(mission.fields.description),
+ status: normalizeMissionStatus(mission.fields.status),
+ priority: normalizePriority(mission.fields.priority),
+ owner: normalizeOptionalString(mission.fields.owner),
+ project: normalizeOptionalRef(mission.fields.project),
+ space: normalizeOptionalRef(mission.fields.space),
+ plan: normalizeMissionPlan(mission.fields.plan),
+ milestones,
+ started_at: normalizeOptionalString(mission.fields.started_at),
+ completed_at: normalizeOptionalString(mission.fields.completed_at),
+ total_runs: asFiniteNumber(mission.fields.total_runs, 0),
+ total_cost_usd: asFiniteNumber(mission.fields.total_cost_usd, 0),
+ runs_by_adapter: asStringNumberRecord(mission.fields.runs_by_adapter),
+ tags: asStringArray(mission.fields.tags),
+ created: normalizeOptionalString(mission.fields.created) ?? new Date(0).toISOString(),
+ updated: normalizeOptionalString(mission.fields.updated) ?? new Date(0).toISOString(),
+ };
+}
+
+function normalizeMissionPlan(value: unknown): MissionPlan {
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
+ return { goal: '' };
+ }
+ const record = value as Record;
+ return {
+ goal: String(record.goal ?? ''),
+ constraints: asStringArray(record.constraints),
+ estimated_runs: asFiniteNumber(record.estimated_runs, undefined),
+ estimated_cost_usd: asNullableFiniteNumber(record.estimated_cost_usd, null),
+ };
+}
+
+function normalizeMilestones(value: unknown): Milestone[] {
+ if (!Array.isArray(value)) return [];
+ const milestones: Milestone[] = [];
+ for (let index = 0; index < value.length; index += 1) {
+ const rawMilestone = value[index];
+ if (!rawMilestone || typeof rawMilestone !== 'object' || Array.isArray(rawMilestone)) continue;
+ const record = rawMilestone as Record;
+ const id = normalizeMilestoneId(asOptionalString(record.id), index);
+ const title = asOptionalString(record.title) ?? id;
+ const features = dedupeStrings(asStringArray(record.features).map((entry) => normalizeThreadPath(entry)));
+ const status = normalizeMilestoneStatus(record.status);
+ const validation = normalizeExistingValidation(record.validation);
+ milestones.push({
+ id,
+ title,
+ status,
+ deps: dedupeStrings(asStringArray(record.deps)),
+ features,
+ ...(validation ? { validation } : {}),
+ ...(asOptionalString(record.started_at) ? { started_at: asOptionalString(record.started_at) } : {}),
+ ...(asOptionalString(record.completed_at) ? { completed_at: asOptionalString(record.completed_at) } : {}),
+ ...(asOptionalString(record.failed_at) ? { failed_at: asOptionalString(record.failed_at) } : {}),
+ });
+ }
+ return milestones;
+}
+
+function normalizeExistingValidation(value: unknown): MilestoneValidationPlan | undefined {
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined;
+ const record = value as Record;
+ return {
+ strategy: normalizeValidationStrategy(record.strategy),
+ criteria: asStringArray(record.criteria),
+ ...(asOptionalString(record.run_id) ? { run_id: asOptionalString(record.run_id) } : {}),
+ ...(asOptionalString(record.run_status) ? { run_status: asOptionalString(record.run_status) as MilestoneValidationPlan['run_status'] } : {}),
+ ...(asOptionalString(record.validated_at) ? { validated_at: asOptionalString(record.validated_at) } : {}),
+ };
+}
+
+function normalizeMilestoneStatus(value: unknown): Milestone['status'] {
+ const normalized = String(value ?? 'open').trim().toLowerCase();
+ if (
+ normalized === 'open' ||
+ normalized === 'active' ||
+ normalized === 'validating' ||
+ normalized === 'passed' ||
+ normalized === 'failed'
+ ) {
+ return normalized;
+ }
+ return 'open';
+}
+
+function normalizeMissionStatus(value: unknown): MissionStatus {
+ const normalized = String(value ?? 'planning').trim().toLowerCase();
+ if (
+ normalized === 'planning' ||
+ normalized === 'approved' ||
+ normalized === 'active' ||
+ normalized === 'validating' ||
+ normalized === 'completed' ||
+ normalized === 'failed'
+ ) {
+ return normalized;
+ }
+ return 'planning';
+}
+
+function normalizeValidationStrategy(value: unknown): MilestoneValidationPlan['strategy'] {
+ const normalized = String(value ?? 'automated').trim().toLowerCase();
+ if (normalized === 'manual' || normalized === 'hybrid' || normalized === 'automated') {
+ return normalized;
+ }
+ return 'automated';
+}
+
+function normalizePriority(value: unknown): Mission['priority'] {
+ const normalized = String(value ?? 'medium').trim().toLowerCase();
+ if (normalized === 'urgent' || normalized === 'high' || normalized === 'medium' || normalized === 'low') {
+ return normalized;
+ }
+ return 'medium';
+}
+
+function normalizeMilestoneId(id: string | undefined, index: number): string {
+ const normalized = normalizeSlug(id ?? '');
+ if (normalized) return normalized;
+ return `ms-${index + 1}`;
+}
+
+function normalizeThreadPath(value: unknown): string {
+ const raw = String(value ?? '').trim();
+ if (!raw) return '';
+ const unwrapped = raw.startsWith('[[') && raw.endsWith(']]') ? raw.slice(2, -2) : raw;
+ const withPrefix = unwrapped.includes('/') ? unwrapped : `threads/${unwrapped}`;
+ return withPrefix.endsWith('.md') ? withPrefix : `${withPrefix}.md`;
+}
+
+function normalizeOptionalRef(value: unknown): string | undefined {
+ const raw = String(value ?? '').trim();
+ if (!raw) return undefined;
+ const unwrapped = raw.startsWith('[[') && raw.endsWith(']]') ? raw.slice(2, -2) : raw;
+ return unwrapped.endsWith('.md') ? unwrapped : `${unwrapped}.md`;
+}
+
+function normalizeOptionalString(value: unknown): string | undefined {
+ if (typeof value !== 'string') return undefined;
+ const trimmed = value.trim();
+ return trimmed ? trimmed : undefined;
+}
+
+function asOptionalString(value: unknown): string | undefined {
+ return normalizeOptionalString(value);
+}
+
+function asFiniteNumber(value: unknown, fallback: number | undefined): number {
+ if (typeof value === 'number' && Number.isFinite(value)) return value;
+ if (typeof value === 'string' && value.trim()) {
+ const parsed = Number(value);
+ if (Number.isFinite(parsed)) return parsed;
+ }
+ return fallback ?? 0;
+}
+
+function asNullableFiniteNumber(value: unknown, fallback: number | null): number | null {
+ if (value === null) return null;
+ if (typeof value === 'number' && Number.isFinite(value)) return value;
+ if (typeof value === 'string' && value.trim()) {
+ const parsed = Number(value);
+ if (Number.isFinite(parsed)) return parsed;
+ }
+ return fallback;
+}
+
+function asStringArray(value: unknown): string[] {
+ if (!Array.isArray(value)) return [];
+ return value
+ .map((entry) => String(entry ?? '').trim())
+ .filter(Boolean);
+}
+
+function asStringNumberRecord(value: unknown): Record {
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return {};
+ const record = value as Record;
+ const output: Record = {};
+ for (const [key, rawValue] of Object.entries(record)) {
+ const numeric = asFiniteNumber(rawValue, 0);
+ output[key] = numeric;
+ }
+ return output;
+}
+
+function dedupeStrings(values: string[]): string[] {
+ return [...new Set(values.map((value) => value.trim()).filter(Boolean))];
+}
+
+function normalizeSlug(value: string): string {
+ return String(value ?? '')
+ .toLowerCase()
+ .replace(/[^a-z0-9]+/g, '-')
+ .replace(/^-+|-+$/g, '')
+ .slice(0, 80);
+}
+
+function assertMissionStatusTransition(from: MissionStatus, to: MissionStatus): void {
+ if (from === to) return;
+ const allowed = MISSION_STATUS_TRANSITIONS[from] ?? [];
+ if (!allowed.includes(to)) {
+ throw new Error(`Invalid mission transition: "${from}" -> "${to}". Allowed: ${allowed.join(', ') || 'none'}`);
+ }
+}
+
+function pickNextReadyMilestone(milestones: Milestone[]): Milestone | undefined {
+ return milestones.find((milestone) => {
+ if (milestone.status !== 'open') return false;
+ const deps = milestone.deps ?? [];
+ return deps.every((depId) => milestones.some((candidate) => candidate.id === depId && candidate.status === 'passed'));
+ });
+}
+
+function renderMissionBody(plan?: MissionPlan, milestones: Milestone[] = []): string {
+ const lines: string[] = [];
+ const goal = plan?.goal?.trim();
+ lines.push('## Goal');
+ lines.push('');
+ lines.push(goal && goal.length > 0 ? goal : 'TBD');
+ lines.push('');
+ if (plan?.constraints && plan.constraints.length > 0) {
+ lines.push('## Constraints');
+ lines.push('');
+ for (const constraint of plan.constraints) {
+ lines.push(`- ${constraint}`);
+ }
+ lines.push('');
+ }
+ if (milestones.length > 0) {
+ lines.push('## Milestones');
+ lines.push('');
+ for (const milestone of milestones) {
+ lines.push(`### ${milestone.id}: ${milestone.title}`);
+ lines.push('');
+ lines.push(`status: ${milestone.status}`);
+ if (milestone.deps && milestone.deps.length > 0) {
+ lines.push(`deps: ${milestone.deps.join(', ')}`);
+ }
+ if (milestone.validation && milestone.validation.criteria.length > 0) {
+ lines.push(`validation: ${milestone.validation.strategy}`);
+ lines.push(...milestone.validation.criteria.map((criterion) => `- ${criterion}`));
+ }
+ if (milestone.features.length > 0) {
+ lines.push('features:');
+ lines.push(...milestone.features.map((featurePath) => `- [[${featurePath}]]`));
+ }
+ lines.push('');
+ }
+ }
+ return `${lines.join('\n')}\n`;
+}
+
+function sanitizeForYaml(value: T): T {
+ if (Array.isArray(value)) {
+ return value
+ .map((entry) => sanitizeForYaml(entry))
+ .filter((entry) => entry !== undefined) as unknown as T;
+ }
+ if (!value || typeof value !== 'object') return value;
+ const output: Record = {};
+ for (const [key, innerValue] of Object.entries(value as Record)) {
+ if (innerValue === undefined) continue;
+ const sanitized = sanitizeForYaml(innerValue);
+ if (sanitized === undefined) continue;
+ output[key] = sanitized;
+ }
+ return output as T;
+}
+
+function assertMissionMutationAuthorized(
+ workspacePath: string,
+ actor: string,
+ action: string,
+ target: string,
+ requiredCapabilities: string[],
+): void {
+ auth.assertAuthorizedMutation(workspacePath, {
+ actor,
+ action,
+ target,
+ requiredCapabilities,
+ metadata: {
+ module: 'mission',
+ },
+ });
+}
diff --git a/packages/kernel/src/registry.test.ts b/packages/kernel/src/registry.test.ts
index cb7845e..2300ac0 100644
--- a/packages/kernel/src/registry.test.ts
+++ b/packages/kernel/src/registry.test.ts
@@ -29,6 +29,7 @@ describe('registry', () => {
expect(reg.types.person).toBeDefined();
expect(reg.types.project).toBeDefined();
expect(reg.types.client).toBeDefined();
+ expect(reg.types.mission).toBeDefined();
expect(reg.types.conversation).toBeDefined();
expect(reg.types['plan-step']).toBeDefined();
expect(reg.types.skill).toBeDefined();
diff --git a/packages/kernel/src/registry.ts b/packages/kernel/src/registry.ts
index 0658422..c86c54e 100644
--- a/packages/kernel/src/registry.ts
+++ b/packages/kernel/src/registry.ts
@@ -42,7 +42,7 @@ const BUILT_IN_TYPES: PrimitiveTypeDefinition[] = [
description: 'urgent | high | medium | low',
},
deps: { type: 'list', default: [], description: 'Thread refs this depends on' },
- parent: { type: 'ref', refTypes: ['thread'], description: 'Parent thread if decomposed from larger thread' },
+ parent: { type: 'ref', refTypes: ['thread', 'mission'], description: 'Parent thread/mission ref for decomposition or mission planning' },
tid: { type: 'string', description: 'Thread slug identifier (T-ID)' },
space: { type: 'ref', refTypes: ['space'], description: 'Space ref this thread belongs to' },
context_refs:{ type: 'list', default: [], description: 'Docs that inform this work' },
@@ -214,6 +214,44 @@ const BUILT_IN_TYPES: PrimitiveTypeDefinition[] = [
updated: { type: 'date', required: true },
},
},
+ {
+ name: 'mission',
+ description: 'High-level orchestration primitive composed of milestones and feature threads.',
+ directory: 'missions',
+ builtIn: true,
+ createdAt: '2026-01-01T00:00:00.000Z',
+ createdBy: 'system',
+ fields: {
+ mid: { type: 'string', required: true, template: 'slug' },
+ title: { type: 'string', required: true },
+ description: { type: 'string' },
+ status: {
+ type: 'string',
+ required: true,
+ default: 'planning',
+ enum: ['planning', 'approved', 'active', 'validating', 'completed', 'failed'],
+ },
+ priority: {
+ type: 'string',
+ required: true,
+ default: 'medium',
+ enum: ['urgent', 'high', 'medium', 'low'],
+ },
+ owner: { type: 'string' },
+ project: { type: 'ref', refTypes: ['project'] },
+ space: { type: 'ref', refTypes: ['space'] },
+ plan: { type: 'any', default: {} },
+ milestones: { type: 'list', default: [] },
+ started_at: { type: 'date' },
+ completed_at: { type: 'date' },
+ total_runs: { type: 'number', default: 0 },
+ total_cost_usd: { type: 'number', default: 0 },
+ runs_by_adapter: { type: 'any', default: {} },
+ tags: { type: 'list', default: [] },
+ created: { type: 'date', required: true },
+ updated: { type: 'date', required: true },
+ },
+ },
{
name: 'skill',
description: 'A reusable agent skill shared through the workgraph workspace.',
diff --git a/packages/kernel/src/store.ts b/packages/kernel/src/store.ts
index e92b9f3..5103c64 100644
--- a/packages/kernel/src/store.ts
+++ b/packages/kernel/src/store.ts
@@ -482,6 +482,10 @@ function capabilitiesForStoreMutation(
: ['thread:update', 'thread:manage', 'thread:complete', 'policy:manage'];
case 'run':
return ['dispatch:run', 'policy:manage'];
+ case 'mission':
+ return mutation === 'create'
+ ? ['mission:create', 'mission:manage', 'policy:manage']
+ : ['mission:update', 'mission:manage', 'policy:manage'];
case 'policy':
return ['policy:manage'];
case 'policy-gate':
diff --git a/packages/kernel/src/types.ts b/packages/kernel/src/types.ts
index 7621bc5..cedb1cb 100644
--- a/packages/kernel/src/types.ts
+++ b/packages/kernel/src/types.ts
@@ -160,6 +160,90 @@ export const THREAD_STATUS_TRANSITIONS: Record = {
cancelled: ['open'],
};
+// ---------------------------------------------------------------------------
+// Mission lifecycle
+// ---------------------------------------------------------------------------
+
+export type MissionStatus =
+ | 'planning'
+ | 'approved'
+ | 'active'
+ | 'validating'
+ | 'completed'
+ | 'failed';
+
+export type MilestoneStatus =
+ | 'open'
+ | 'active'
+ | 'validating'
+ | 'passed'
+ | 'failed';
+
+export interface MilestoneValidationPlan {
+ strategy: 'automated' | 'manual' | 'hybrid';
+ criteria: string[];
+ run_id?: string;
+ run_status?: RunStatus;
+ validated_at?: string;
+}
+
+export interface Milestone {
+ id: string;
+ title: string;
+ status: MilestoneStatus;
+ deps?: string[];
+ features: string[];
+ validation?: MilestoneValidationPlan;
+ started_at?: string;
+ completed_at?: string;
+ failed_at?: string;
+}
+
+export interface MissionPlan {
+ goal: string;
+ constraints?: string[];
+ estimated_runs?: number;
+ estimated_cost_usd?: number | null;
+}
+
+export interface Mission {
+ mid: string;
+ title: string;
+ description?: string;
+ status: MissionStatus;
+ priority: 'urgent' | 'high' | 'medium' | 'low';
+ owner?: string;
+ project?: string;
+ space?: string;
+ plan?: MissionPlan;
+ milestones: Milestone[];
+ started_at?: string;
+ completed_at?: string;
+ total_runs: number;
+ total_cost_usd: number;
+ runs_by_adapter?: Record;
+ tags?: string[];
+ created: string;
+ updated: string;
+}
+
+export const MISSION_STATUS_TRANSITIONS: Record = {
+ planning: ['approved', 'failed'],
+ approved: ['planning', 'active', 'failed'],
+ active: ['validating', 'failed'],
+ validating: ['active', 'completed', 'failed'],
+ completed: [],
+ failed: ['planning', 'approved', 'active'],
+};
+
+export const MILESTONE_STATUS_TRANSITIONS: Record = {
+ open: ['active', 'failed'],
+ active: ['validating', 'failed'],
+ validating: ['passed', 'failed'],
+ passed: [],
+ failed: ['open', 'active'],
+};
+
// ---------------------------------------------------------------------------
// Conversation + plan-step lifecycle
// ---------------------------------------------------------------------------
diff --git a/packages/mcp-server/src/mcp-server.test.ts b/packages/mcp-server/src/mcp-server.test.ts
index 064c71c..b28b080 100644
--- a/packages/mcp-server/src/mcp-server.test.ts
+++ b/packages/mcp-server/src/mcp-server.test.ts
@@ -67,6 +67,8 @@ describe('workgraph mcp server', () => {
expect(toolNames).toContain('workgraph_ledger_reconcile');
expect(toolNames).toContain('workgraph_thread_claim');
expect(toolNames).toContain('workgraph_dispatch_execute');
+ expect(toolNames).toContain('workgraph_create_mission');
+ expect(toolNames).toContain('workgraph_mission_status');
const statusTool = await client.callTool({
name: 'workgraph_status',
@@ -206,6 +208,13 @@ describe('workgraph mcp server', () => {
'workgraph_dispatch_stop',
'workgraph_trigger_engine_cycle',
'workgraph_autonomy_run',
+ 'workgraph_create_mission',
+ 'workgraph_plan_mission',
+ 'workgraph_approve_mission',
+ 'workgraph_start_mission',
+ 'workgraph_intervene_mission',
+ 'workgraph_mission_status',
+ 'workgraph_mission_progress',
'wg_post_message',
'wg_ask',
'wg_spawn_thread',
diff --git a/packages/mcp-server/src/mcp/tools/read-tools.ts b/packages/mcp-server/src/mcp/tools/read-tools.ts
index 605eb8e..d4e8d56 100644
--- a/packages/mcp-server/src/mcp/tools/read-tools.ts
+++ b/packages/mcp-server/src/mcp/tools/read-tools.ts
@@ -3,6 +3,7 @@ import { z } from 'zod';
import {
graph as graphModule,
ledger as ledgerModule,
+ mission as missionModule,
orientation as orientationModule,
query as queryModule,
registry as registryModule,
@@ -16,6 +17,7 @@ import { type WorkgraphMcpServerOptions } from '../types.js';
const graph = graphModule;
const ledger = ledgerModule;
+const mission = missionModule;
const orientation = orientationModule;
const query = queryModule;
const registry = registryModule;
@@ -166,6 +168,59 @@ export function registerReadTools(server: McpServer, options: WorkgraphMcpServer
},
);
+ server.registerTool(
+ 'workgraph_mission_status',
+ {
+ title: 'Mission Status',
+ description: 'Read one mission primitive and computed progress.',
+ inputSchema: {
+ missionRef: z.string().min(1),
+ },
+ annotations: {
+ readOnlyHint: true,
+ idempotentHint: true,
+ },
+ },
+ async (args) => {
+ try {
+ const missionInstance = mission.missionStatus(options.workspacePath, args.missionRef);
+ const progress = mission.missionProgress(options.workspacePath, missionInstance.path);
+ return okResult(
+ { mission: missionInstance, progress },
+ `Mission ${missionInstance.path} is ${String(missionInstance.fields.status)}.`,
+ );
+ } catch (error) {
+ return errorResult(error);
+ }
+ },
+ );
+
+ server.registerTool(
+ 'workgraph_mission_progress',
+ {
+ title: 'Mission Progress',
+ description: 'Read aggregate mission progress across milestones and features.',
+ inputSchema: {
+ missionRef: z.string().min(1),
+ },
+ annotations: {
+ readOnlyHint: true,
+ idempotentHint: true,
+ },
+ },
+ async (args) => {
+ try {
+ const progress = mission.missionProgress(options.workspacePath, args.missionRef);
+ return okResult(
+ progress,
+ `Mission progress ${progress.mid}: ${progress.percentComplete}% (${progress.doneFeatures}/${progress.totalFeatures} features).`,
+ );
+ } catch (error) {
+ return errorResult(error);
+ }
+ },
+ );
+
server.registerTool(
'workgraph_thread_list',
{
diff --git a/packages/mcp-server/src/mcp/tools/write-tools.ts b/packages/mcp-server/src/mcp/tools/write-tools.ts
index 6a096cf..e5ee5c7 100644
--- a/packages/mcp-server/src/mcp/tools/write-tools.ts
+++ b/packages/mcp-server/src/mcp/tools/write-tools.ts
@@ -3,6 +3,8 @@ import { z } from 'zod';
import {
autonomy as autonomyModule,
dispatch as dispatchModule,
+ mission as missionModule,
+ missionOrchestrator as missionOrchestratorModule,
orientation as orientationModule,
thread as threadModule,
triggerEngine as triggerEngineModule,
@@ -13,11 +15,235 @@ import { type WorkgraphMcpServerOptions } from '../types.js';
const autonomy = autonomyModule;
const dispatch = dispatchModule;
+const mission = missionModule;
+const missionOrchestrator = missionOrchestratorModule;
const orientation = orientationModule;
const thread = threadModule;
const triggerEngine = triggerEngineModule;
+const missionFeatureInputSchema = z.union([
+ z.string().min(1),
+ z.object({
+ title: z.string().min(1),
+ goal: z.string().min(1).optional(),
+ threadPath: z.string().min(1).optional(),
+ priority: z.enum(['urgent', 'high', 'medium', 'low']).optional(),
+ deps: z.array(z.string()).optional(),
+ tags: z.array(z.string()).optional(),
+ }),
+]);
+
+const missionMilestoneInputSchema = z.object({
+ id: z.string().min(1).optional(),
+ title: z.string().min(1),
+ deps: z.array(z.string()).optional(),
+ features: z.array(missionFeatureInputSchema).min(1),
+ validation: z.object({
+ strategy: z.enum(['automated', 'manual', 'hybrid']).optional(),
+ criteria: z.array(z.string()).optional(),
+ }).optional(),
+});
+
export function registerWriteTools(server: McpServer, options: WorkgraphMcpServerOptions): void {
+ server.registerTool(
+ 'workgraph_create_mission',
+ {
+ title: 'Mission Create',
+ description: 'Create a mission primitive in planning status.',
+ inputSchema: {
+ title: z.string().min(1),
+ goal: z.string().min(1),
+ actor: z.string().optional(),
+ mid: z.string().min(1).optional(),
+ description: z.string().optional(),
+ priority: z.enum(['urgent', 'high', 'medium', 'low']).optional(),
+ owner: z.string().optional(),
+ project: z.string().optional(),
+ space: z.string().optional(),
+ constraints: z.array(z.string()).optional(),
+ tags: z.array(z.string()).optional(),
+ },
+ annotations: {
+ destructiveHint: true,
+ idempotentHint: false,
+ },
+ },
+ async (args) => {
+ try {
+ const actor = resolveActor(options.workspacePath, args.actor, options.defaultActor);
+ const gate = checkWriteGate(options, actor, ['mission:create', 'mcp:write'], {
+ action: 'mcp.mission.create',
+ target: 'missions',
+ });
+ if (!gate.allowed) return errorResult(gate.reason);
+ const created = mission.createMission(options.workspacePath, args.title, args.goal, actor, {
+ mid: args.mid,
+ description: args.description,
+ priority: args.priority,
+ owner: args.owner,
+ project: args.project,
+ space: args.space,
+ constraints: args.constraints,
+ tags: args.tags,
+ });
+ return okResult({ mission: created }, `Created mission ${created.path}.`);
+ } catch (error) {
+ return errorResult(error);
+ }
+ },
+ );
+
+ server.registerTool(
+ 'workgraph_plan_mission',
+ {
+ title: 'Mission Plan',
+ description: 'Define or update mission milestones and feature threads.',
+ inputSchema: {
+ missionRef: z.string().min(1),
+ actor: z.string().optional(),
+ goal: z.string().optional(),
+ constraints: z.array(z.string()).optional(),
+ estimatedRuns: z.number().int().min(0).optional(),
+ estimatedCostUsd: z.number().min(0).nullable().optional(),
+ replaceMilestones: z.boolean().optional(),
+ milestones: z.array(missionMilestoneInputSchema).min(1),
+ },
+ annotations: {
+ destructiveHint: true,
+ idempotentHint: false,
+ },
+ },
+ async (args) => {
+ try {
+ const actor = resolveActor(options.workspacePath, args.actor, options.defaultActor);
+ const gate = checkWriteGate(options, actor, ['mission:update', 'thread:create', 'mcp:write'], {
+ action: 'mcp.mission.plan',
+ target: args.missionRef,
+ });
+ if (!gate.allowed) return errorResult(gate.reason);
+ const updated = mission.planMission(options.workspacePath, args.missionRef, {
+ goal: args.goal,
+ constraints: args.constraints,
+ estimated_runs: args.estimatedRuns,
+ estimated_cost_usd: args.estimatedCostUsd,
+ replaceMilestones: args.replaceMilestones,
+ milestones: args.milestones,
+ }, actor);
+ return okResult({ mission: updated }, `Planned mission ${updated.path}.`);
+ } catch (error) {
+ return errorResult(error);
+ }
+ },
+ );
+
+ server.registerTool(
+ 'workgraph_approve_mission',
+ {
+ title: 'Mission Approve',
+ description: 'Approve a mission plan and move it to approved status.',
+ inputSchema: {
+ missionRef: z.string().min(1),
+ actor: z.string().optional(),
+ },
+ annotations: {
+ destructiveHint: true,
+ idempotentHint: false,
+ },
+ },
+ async (args) => {
+ try {
+ const actor = resolveActor(options.workspacePath, args.actor, options.defaultActor);
+ const gate = checkWriteGate(options, actor, ['mission:update', 'mcp:write'], {
+ action: 'mcp.mission.approve',
+ target: args.missionRef,
+ });
+ if (!gate.allowed) return errorResult(gate.reason);
+ const updated = mission.approveMission(options.workspacePath, args.missionRef, actor);
+ return okResult({ mission: updated }, `Approved mission ${updated.path}.`);
+ } catch (error) {
+ return errorResult(error);
+ }
+ },
+ );
+
+ server.registerTool(
+ 'workgraph_start_mission',
+ {
+ title: 'Mission Start',
+ description: 'Start mission execution and run one orchestrator cycle.',
+ inputSchema: {
+ missionRef: z.string().min(1),
+ actor: z.string().optional(),
+ runCycle: z.boolean().optional(),
+ },
+ annotations: {
+ destructiveHint: true,
+ idempotentHint: false,
+ },
+ },
+ async (args) => {
+ try {
+ const actor = resolveActor(options.workspacePath, args.actor, options.defaultActor);
+ const gate = checkWriteGate(options, actor, ['mission:update', 'dispatch:run', 'mcp:write'], {
+ action: 'mcp.mission.start',
+ target: args.missionRef,
+ });
+ if (!gate.allowed) return errorResult(gate.reason);
+ const updated = mission.startMission(options.workspacePath, args.missionRef, actor);
+ const cycle = args.runCycle === false
+ ? null
+ : missionOrchestrator.runMissionOrchestratorCycle(options.workspacePath, updated.path, actor);
+ return okResult({ mission: updated, cycle }, `Started mission ${updated.path}.`);
+ } catch (error) {
+ return errorResult(error);
+ }
+ },
+ );
+
+ server.registerTool(
+ 'workgraph_intervene_mission',
+ {
+ title: 'Mission Intervene',
+ description: 'Apply mission intervention updates (priority/status/skip/append milestones).',
+ inputSchema: {
+ missionRef: z.string().min(1),
+ actor: z.string().optional(),
+ reason: z.string().min(1),
+ setPriority: z.enum(['urgent', 'high', 'medium', 'low']).optional(),
+ setStatus: z.enum(['planning', 'approved', 'active', 'validating', 'completed', 'failed']).optional(),
+ skipFeature: z.object({
+ milestoneId: z.string().min(1),
+ threadPath: z.string().min(1),
+ }).optional(),
+ appendMilestones: z.array(missionMilestoneInputSchema).optional(),
+ },
+ annotations: {
+ destructiveHint: true,
+ idempotentHint: false,
+ },
+ },
+ async (args) => {
+ try {
+ const actor = resolveActor(options.workspacePath, args.actor, options.defaultActor);
+ const gate = checkWriteGate(options, actor, ['mission:update', 'thread:update', 'mcp:write'], {
+ action: 'mcp.mission.intervene',
+ target: args.missionRef,
+ });
+ if (!gate.allowed) return errorResult(gate.reason);
+ const updated = mission.interveneMission(options.workspacePath, args.missionRef, {
+ reason: args.reason,
+ setPriority: args.setPriority,
+ setStatus: args.setStatus,
+ skipFeature: args.skipFeature,
+ appendMilestones: args.appendMilestones,
+ }, actor);
+ return okResult({ mission: updated }, `Intervened mission ${updated.path}.`);
+ } catch (error) {
+ return errorResult(error);
+ }
+ },
+ );
+
server.registerTool(
'workgraph_thread_claim',
{
]