|
| 1 | +import fs from 'node:fs'; |
| 2 | +import path from 'node:path'; |
| 3 | +import { Command } from 'commander'; |
| 4 | +import * as workgraph from '@versatly/workgraph-kernel'; |
| 5 | +import { |
| 6 | + addWorkspaceOption, |
| 7 | + csv, |
| 8 | + resolveWorkspacePath, |
| 9 | + runCommand, |
| 10 | +} from '../core.js'; |
| 11 | + |
| 12 | +export function registerMissionCommands(program: Command, defaultActor: string): void { |
| 13 | + const missionCmd = program |
| 14 | + .command('mission') |
| 15 | + .description('Mission primitive lifecycle and orchestration'); |
| 16 | + |
| 17 | + addWorkspaceOption( |
| 18 | + missionCmd |
| 19 | + .command('create <title>') |
| 20 | + .description('Create a mission in planning state') |
| 21 | + .requiredOption('--goal <goal>', 'Mission goal statement') |
| 22 | + .option('-a, --actor <name>', 'Actor', defaultActor) |
| 23 | + .option('--mid <mid>', 'Mission identifier slug override') |
| 24 | + .option('--description <text>', 'Mission summary/description') |
| 25 | + .option('--priority <level>', 'urgent|high|medium|low', 'medium') |
| 26 | + .option('--owner <name>', 'Mission owner') |
| 27 | + .option('--project <ref>', 'Project ref (projects/<slug>.md)') |
| 28 | + .option('--space <ref>', 'Space ref (spaces/<slug>.md)') |
| 29 | + .option('--constraints <items>', 'Comma-separated mission constraints') |
| 30 | + .option('--tags <items>', 'Comma-separated tags') |
| 31 | + .option('--json', 'Emit structured JSON output'), |
| 32 | + ).action((title, opts) => |
| 33 | + runCommand( |
| 34 | + opts, |
| 35 | + () => { |
| 36 | + const workspacePath = resolveWorkspacePath(opts); |
| 37 | + return { |
| 38 | + mission: workgraph.mission.createMission(workspacePath, title, opts.goal, opts.actor, { |
| 39 | + mid: opts.mid, |
| 40 | + description: opts.description, |
| 41 | + priority: normalizePriority(opts.priority), |
| 42 | + owner: opts.owner, |
| 43 | + project: opts.project, |
| 44 | + space: opts.space, |
| 45 | + constraints: csv(opts.constraints), |
| 46 | + tags: csv(opts.tags), |
| 47 | + }), |
| 48 | + }; |
| 49 | + }, |
| 50 | + (result) => [ |
| 51 | + `Created mission: ${result.mission.path}`, |
| 52 | + `Status: ${String(result.mission.fields.status)}`, |
| 53 | + ], |
| 54 | + ), |
| 55 | + ); |
| 56 | + |
| 57 | + addWorkspaceOption( |
| 58 | + missionCmd |
| 59 | + .command('plan <missionRef>') |
| 60 | + .description('Plan mission milestones/features and create feature threads') |
| 61 | + .option('-a, --actor <name>', 'Actor', defaultActor) |
| 62 | + .option('--goal <goal>', 'Plan goal override') |
| 63 | + .option('--constraints <items>', 'Comma-separated constraints') |
| 64 | + .option('--estimated-runs <n>', 'Estimated number of runs') |
| 65 | + .option('--estimated-cost-usd <n>', 'Estimated USD cost') |
| 66 | + .option('--append', 'Append milestones instead of replacing') |
| 67 | + .option('--milestones <json>', 'Milestones JSON payload') |
| 68 | + .option('--milestones-file <path>', 'Milestones JSON file path') |
| 69 | + .option('--json', 'Emit structured JSON output'), |
| 70 | + ).action((missionRef, opts) => |
| 71 | + runCommand( |
| 72 | + opts, |
| 73 | + () => { |
| 74 | + const workspacePath = resolveWorkspacePath(opts); |
| 75 | + const milestones = readMissionMilestonesInput(opts.milestones, opts.milestonesFile); |
| 76 | + return { |
| 77 | + mission: workgraph.mission.planMission( |
| 78 | + workspacePath, |
| 79 | + missionRef, |
| 80 | + { |
| 81 | + goal: opts.goal, |
| 82 | + constraints: csv(opts.constraints), |
| 83 | + estimated_runs: parseOptionalInt(opts.estimatedRuns), |
| 84 | + estimated_cost_usd: parseOptionalNumber(opts.estimatedCostUsd), |
| 85 | + replaceMilestones: !opts.append, |
| 86 | + milestones, |
| 87 | + }, |
| 88 | + opts.actor, |
| 89 | + ), |
| 90 | + }; |
| 91 | + }, |
| 92 | + (result) => [ |
| 93 | + `Planned mission: ${result.mission.path}`, |
| 94 | + `Milestones: ${Array.isArray(result.mission.fields.milestones) ? result.mission.fields.milestones.length : 0}`, |
| 95 | + ], |
| 96 | + ), |
| 97 | + ); |
| 98 | + |
| 99 | + addWorkspaceOption( |
| 100 | + missionCmd |
| 101 | + .command('approve <missionRef>') |
| 102 | + .description('Approve planned mission') |
| 103 | + .option('-a, --actor <name>', 'Actor', defaultActor) |
| 104 | + .option('--json', 'Emit structured JSON output'), |
| 105 | + ).action((missionRef, opts) => |
| 106 | + runCommand( |
| 107 | + opts, |
| 108 | + () => { |
| 109 | + const workspacePath = resolveWorkspacePath(opts); |
| 110 | + return { |
| 111 | + mission: workgraph.mission.approveMission(workspacePath, missionRef, opts.actor), |
| 112 | + }; |
| 113 | + }, |
| 114 | + (result) => [`Approved mission: ${result.mission.path}`], |
| 115 | + ), |
| 116 | + ); |
| 117 | + |
| 118 | + addWorkspaceOption( |
| 119 | + missionCmd |
| 120 | + .command('start <missionRef>') |
| 121 | + .description('Start mission execution and optionally run one orchestrator cycle') |
| 122 | + .option('-a, --actor <name>', 'Actor', defaultActor) |
| 123 | + .option('--no-run-cycle', 'Do not run orchestrator cycle after start') |
| 124 | + .option('--json', 'Emit structured JSON output'), |
| 125 | + ).action((missionRef, opts) => |
| 126 | + runCommand( |
| 127 | + opts, |
| 128 | + () => { |
| 129 | + const workspacePath = resolveWorkspacePath(opts); |
| 130 | + const started = workgraph.mission.startMission(workspacePath, missionRef, opts.actor); |
| 131 | + const cycle = opts.runCycle === false |
| 132 | + ? null |
| 133 | + : workgraph.missionOrchestrator.runMissionOrchestratorCycle(workspacePath, started.path, opts.actor); |
| 134 | + return { mission: started, cycle }; |
| 135 | + }, |
| 136 | + (result) => [ |
| 137 | + `Started mission: ${result.mission.path}`, |
| 138 | + ...(result.cycle ? [`Cycle actions: ${result.cycle.actions.length}`] : []), |
| 139 | + ], |
| 140 | + ), |
| 141 | + ); |
| 142 | + |
| 143 | + addWorkspaceOption( |
| 144 | + missionCmd |
| 145 | + .command('status <missionRef>') |
| 146 | + .description('Show mission primitive status and milestones') |
| 147 | + .option('--json', 'Emit structured JSON output'), |
| 148 | + ).action((missionRef, opts) => |
| 149 | + runCommand( |
| 150 | + opts, |
| 151 | + () => { |
| 152 | + const workspacePath = resolveWorkspacePath(opts); |
| 153 | + const missionInstance = workgraph.mission.missionStatus(workspacePath, missionRef); |
| 154 | + const progress = workgraph.mission.missionProgress(workspacePath, missionInstance.path); |
| 155 | + return { mission: missionInstance, progress }; |
| 156 | + }, |
| 157 | + (result) => [ |
| 158 | + `Mission: ${result.mission.path}`, |
| 159 | + `Status: ${String(result.mission.fields.status)}`, |
| 160 | + `Progress: ${result.progress.percentComplete}% (${result.progress.doneFeatures}/${result.progress.totalFeatures} features)`, |
| 161 | + ], |
| 162 | + ), |
| 163 | + ); |
| 164 | + |
| 165 | + addWorkspaceOption( |
| 166 | + missionCmd |
| 167 | + .command('progress <missionRef>') |
| 168 | + .description('Show mission progress metrics only') |
| 169 | + .option('--json', 'Emit structured JSON output'), |
| 170 | + ).action((missionRef, opts) => |
| 171 | + runCommand( |
| 172 | + opts, |
| 173 | + () => { |
| 174 | + const workspacePath = resolveWorkspacePath(opts); |
| 175 | + return workgraph.mission.missionProgress(workspacePath, missionRef); |
| 176 | + }, |
| 177 | + (result) => [ |
| 178 | + `Mission ${result.mid}: ${result.status}`, |
| 179 | + `Milestones: ${result.passedMilestones}/${result.totalMilestones}`, |
| 180 | + `Features: ${result.doneFeatures}/${result.totalFeatures}`, |
| 181 | + ], |
| 182 | + ), |
| 183 | + ); |
| 184 | + |
| 185 | + addWorkspaceOption( |
| 186 | + missionCmd |
| 187 | + .command('intervene <missionRef>') |
| 188 | + .description('Intervene in mission execution (status/priority/skip/append milestones)') |
| 189 | + .requiredOption('--reason <reason>', 'Intervention reason') |
| 190 | + .option('-a, --actor <name>', 'Actor', defaultActor) |
| 191 | + .option('--set-priority <priority>', 'urgent|high|medium|low') |
| 192 | + .option('--set-status <status>', 'planning|approved|active|validating|completed|failed') |
| 193 | + .option('--skip-feature <milestoneId:threadPath>', 'Skip one feature in a milestone') |
| 194 | + .option('--append-milestones <json>', 'Milestones JSON to append') |
| 195 | + .option('--append-milestones-file <path>', 'Milestones JSON file to append') |
| 196 | + .option('--json', 'Emit structured JSON output'), |
| 197 | + ).action((missionRef, opts) => |
| 198 | + runCommand( |
| 199 | + opts, |
| 200 | + () => { |
| 201 | + const workspacePath = resolveWorkspacePath(opts); |
| 202 | + const skipFeature = parseSkipFeature(opts.skipFeature); |
| 203 | + const appendMilestones = readMissionMilestonesInput(opts.appendMilestones, opts.appendMilestonesFile, false); |
| 204 | + return { |
| 205 | + mission: workgraph.mission.interveneMission(workspacePath, missionRef, { |
| 206 | + reason: String(opts.reason), |
| 207 | + setPriority: opts.setPriority ? normalizePriority(opts.setPriority) : undefined, |
| 208 | + setStatus: opts.setStatus ? normalizeMissionStatus(opts.setStatus) : undefined, |
| 209 | + skipFeature: skipFeature ?? undefined, |
| 210 | + appendMilestones: appendMilestones.length > 0 ? appendMilestones : undefined, |
| 211 | + }, opts.actor), |
| 212 | + }; |
| 213 | + }, |
| 214 | + (result) => [`Intervened mission: ${result.mission.path}`], |
| 215 | + ), |
| 216 | + ); |
| 217 | + |
| 218 | + addWorkspaceOption( |
| 219 | + missionCmd |
| 220 | + .command('list') |
| 221 | + .description('List missions') |
| 222 | + .option('--status <status>', 'Filter by mission status') |
| 223 | + .option('--json', 'Emit structured JSON output'), |
| 224 | + ).action((opts) => |
| 225 | + runCommand( |
| 226 | + opts, |
| 227 | + () => { |
| 228 | + const workspacePath = resolveWorkspacePath(opts); |
| 229 | + const missions = workgraph.mission.listMissions(workspacePath) |
| 230 | + .filter((entry) => !opts.status || String(entry.fields.status) === String(opts.status)); |
| 231 | + return { missions }; |
| 232 | + }, |
| 233 | + (result) => { |
| 234 | + if (result.missions.length === 0) return ['No missions found.']; |
| 235 | + return result.missions.map((entry) => |
| 236 | + `[${String(entry.fields.status)}] ${String(entry.fields.title)} -> ${entry.path}`, |
| 237 | + ); |
| 238 | + }, |
| 239 | + ), |
| 240 | + ); |
| 241 | +} |
| 242 | + |
| 243 | +function readMissionMilestonesInput( |
| 244 | + rawJson: string | undefined, |
| 245 | + jsonFile: string | undefined, |
| 246 | + required: boolean = true, |
| 247 | +): workgraph.mission.MissionMilestonePlanInput[] { |
| 248 | + if (!rawJson && !jsonFile) { |
| 249 | + if (required) { |
| 250 | + throw new Error('Mission milestones input is required. Use --milestones or --milestones-file.'); |
| 251 | + } |
| 252 | + return []; |
| 253 | + } |
| 254 | + const parsed = rawJson |
| 255 | + ? JSON.parse(rawJson) |
| 256 | + : JSON.parse(fs.readFileSync(path.resolve(String(jsonFile)), 'utf-8')); |
| 257 | + if (!Array.isArray(parsed)) { |
| 258 | + throw new Error('Milestones input must be a JSON array.'); |
| 259 | + } |
| 260 | + return parsed as workgraph.mission.MissionMilestonePlanInput[]; |
| 261 | +} |
| 262 | + |
| 263 | +function normalizePriority(value: string): 'urgent' | 'high' | 'medium' | 'low' { |
| 264 | + const normalized = String(value).trim().toLowerCase(); |
| 265 | + if (normalized === 'urgent' || normalized === 'high' || normalized === 'medium' || normalized === 'low') { |
| 266 | + return normalized; |
| 267 | + } |
| 268 | + throw new Error(`Invalid mission priority "${value}". Expected urgent|high|medium|low.`); |
| 269 | +} |
| 270 | + |
| 271 | +function normalizeMissionStatus(value: string): workgraph.MissionStatus { |
| 272 | + const normalized = String(value).trim().toLowerCase(); |
| 273 | + if ( |
| 274 | + normalized === 'planning' |
| 275 | + || normalized === 'approved' |
| 276 | + || normalized === 'active' |
| 277 | + || normalized === 'validating' |
| 278 | + || normalized === 'completed' |
| 279 | + || normalized === 'failed' |
| 280 | + ) { |
| 281 | + return normalized; |
| 282 | + } |
| 283 | + throw new Error(`Invalid mission status "${value}". Expected planning|approved|active|validating|completed|failed.`); |
| 284 | +} |
| 285 | + |
| 286 | +function parseOptionalInt(value: unknown): number | undefined { |
| 287 | + if (value === undefined || value === null || String(value).trim() === '') return undefined; |
| 288 | + const parsed = Number.parseInt(String(value), 10); |
| 289 | + if (!Number.isFinite(parsed)) { |
| 290 | + throw new Error(`Invalid integer value "${String(value)}".`); |
| 291 | + } |
| 292 | + return parsed; |
| 293 | +} |
| 294 | + |
| 295 | +function parseOptionalNumber(value: unknown): number | null | undefined { |
| 296 | + if (value === undefined || value === null || String(value).trim() === '') return undefined; |
| 297 | + const parsed = Number(value); |
| 298 | + if (!Number.isFinite(parsed)) { |
| 299 | + throw new Error(`Invalid number value "${String(value)}".`); |
| 300 | + } |
| 301 | + return parsed; |
| 302 | +} |
| 303 | + |
| 304 | +function parseSkipFeature( |
| 305 | + value: unknown, |
| 306 | +): { milestoneId: string; threadPath: string } | null { |
| 307 | + if (value === undefined || value === null) return null; |
| 308 | + const raw = String(value).trim(); |
| 309 | + if (!raw) return null; |
| 310 | + const separator = raw.indexOf(':'); |
| 311 | + if (separator <= 0 || separator >= raw.length - 1) { |
| 312 | + throw new Error('Invalid --skip-feature value. Expected "<milestoneId>:<threadPath>".'); |
| 313 | + } |
| 314 | + return { |
| 315 | + milestoneId: raw.slice(0, separator).trim(), |
| 316 | + threadPath: raw.slice(separator + 1).trim(), |
| 317 | + }; |
| 318 | +} |
0 commit comments