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 <goal>', 'Mission goal statement') + .option('-a, --actor <name>', 'Actor', defaultActor) + .option('--mid <mid>', 'Mission identifier slug override') + .option('--description <text>', 'Mission summary/description') + .option('--priority <level>', 'urgent|high|medium|low', 'medium') + .option('--owner <name>', 'Mission owner') + .option('--project <ref>', 'Project ref (projects/<slug>.md)') + .option('--space <ref>', 'Space ref (spaces/<slug>.md)') + .option('--constraints <items>', 'Comma-separated mission constraints') + .option('--tags <items>', '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 <missionRef>') + .description('Plan mission milestones/features and create feature threads') + .option('-a, --actor <name>', 'Actor', defaultActor) + .option('--goal <goal>', 'Plan goal override') + .option('--constraints <items>', 'Comma-separated constraints') + .option('--estimated-runs <n>', 'Estimated number of runs') + .option('--estimated-cost-usd <n>', 'Estimated USD cost') + .option('--append', 'Append milestones instead of replacing') + .option('--milestones <json>', 'Milestones JSON payload') + .option('--milestones-file <path>', '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 <missionRef>') + .description('Approve planned mission') + .option('-a, --actor <name>', '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 <missionRef>') + .description('Start mission execution and optionally run one orchestrator cycle') + .option('-a, --actor <name>', '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 <missionRef>') + .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 <missionRef>') + .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 <missionRef>') + .description('Intervene in mission execution (status/priority/skip/append milestones)') + .requiredOption('--reason <reason>', 'Intervention reason') + .option('-a, --actor <name>', 'Actor', defaultActor) + .option('--set-priority <priority>', 'urgent|high|medium|low') + .option('--set-status <status>', 'planning|approved|active|validating|completed|failed') + .option('--skip-feature <milestoneId:threadPath>', 'Skip one feature in a milestone') + .option('--append-milestones <json>', 'Milestones JSON to append') + .option('--append-milestones-file <path>', '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 <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 "<milestoneId>:<threadPath>".'); + } + 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<string, unknown>, +): 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<string, unknown> | undefined)?.goal) ?? '', + constraints: asStringArray((instance.fields.plan as Record<string, unknown> | undefined)?.constraints), + estimated_runs: asNumber((instance.fields.plan as Record<string, unknown> | undefined)?.estimated_runs), + estimated_cost_usd: asNullableNumber((instance.fields.plan as Record<string, unknown> | 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<string, unknown>; + 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<string, unknown>; + 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<T>(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<string, unknown> = {}; + for (const [key, innerValue] of Object.entries(value as Record<string, unknown>)) { + 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<string, number> { + if (!value || typeof value !== 'object' || Array.isArray(value)) return {}; + const output: Record<string, number> = {}; + for (const [key, rawValue] of Object.entries(value as Record<string, unknown>)) { + 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<string | MissionFeaturePlanInput>; + 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<string, number>; + 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<string, Milestone> { + const map = new Map<string, Milestone>(); + 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<string, unknown>; + 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<string, unknown>; + 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<string, unknown>; + 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<string, number> { + if (!value || typeof value !== 'object' || Array.isArray(value)) return {}; + const record = value as Record<string, unknown>; + const output: Record<string, number> = {}; + 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<T>(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<string, unknown> = {}; + for (const [key, innerValue] of Object.entries(value as Record<string, unknown>)) { + 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<ThreadStatus, ThreadStatus[]> = { 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<string, number>; + tags?: string[]; + created: string; + updated: string; +} + +export const MISSION_STATUS_TRANSITIONS: Record<MissionStatus, MissionStatus[]> = { + 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<MilestoneStatus, MilestoneStatus[]> = { + 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', {