agentxchain 2.113.0 → 2.115.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -430,6 +430,10 @@ missionCmd
430
430
  .requiredOption('--title <text>', 'Mission title')
431
431
  .requiredOption('--goal <text>', 'Mission goal')
432
432
  .option('--id <mission_id>', 'Override the derived mission ID')
433
+ .option('--plan', 'Generate a proposed mission plan immediately after mission creation')
434
+ .option('--constraint <text>', 'Add a constraint to the planner when using --plan (repeatable)', collectOption, [])
435
+ .option('--role-hint <role>', 'Hint available roles to the planner when using --plan (repeatable)', collectOption, [])
436
+ .option('--planner-output-file <path>', 'Read planner JSON output from a file instead of calling the configured planner')
433
437
  .option('-j, --json', 'Output as JSON')
434
438
  .option('-d, --dir <path>', 'Project directory')
435
439
  .action(missionStartCommand);
@@ -462,6 +466,7 @@ const missionPlanCmd = missionCmd
462
466
  .description('Generate a decomposition plan for a mission (default: latest mission)')
463
467
  .option('--constraint <text>', 'Add a constraint to the planner (repeatable)', collectOption, [])
464
468
  .option('--role-hint <role>', 'Hint available roles to the planner (repeatable)', collectOption, [])
469
+ .option('--planner-output-file <path>', 'Read planner JSON output from a file instead of calling the configured planner')
465
470
  .option('-j, --json', 'Output as JSON')
466
471
  .option('-d, --dir <path>', 'Project directory')
467
472
  .action(missionPlanCommand);
@@ -483,8 +488,10 @@ missionPlanCmd
483
488
 
484
489
  missionPlanCmd
485
490
  .command('launch [plan_id]')
486
- .description('Launch a workstream from an approved plan (default: latest plan)')
487
- .requiredOption('-w, --workstream <id>', 'Workstream ID to launch')
491
+ .description('Launch workstream(s) from an approved plan (default: latest plan)')
492
+ .option('-w, --workstream <id>', 'Workstream ID to launch (mutually exclusive with --all-ready)')
493
+ .option('--all-ready', 'Launch all ready workstreams sequentially (mutually exclusive with --workstream)')
494
+ .option('--retry', 'Retry a failed workstream (requires --workstream, only for needs_attention status)')
488
495
  .option('-m, --mission <mission_id>', 'Explicit mission ID')
489
496
  .option('--auto-approve', 'Auto-approve run gates while executing the launched workstream')
490
497
  .option('-j, --json', 'Output as JSON')
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentxchain",
3
- "version": "2.113.0",
3
+ "version": "2.115.0",
4
4
  "description": "CLI for AgentXchain — governed multi-agent software delivery",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,4 +1,6 @@
1
1
  import chalk from 'chalk';
2
+ import { readFileSync } from 'fs';
3
+ import { resolve } from 'path';
2
4
  import { findProjectRoot, loadProjectContext } from '../lib/config.js';
3
5
  import {
4
6
  attachChainToMission,
@@ -13,7 +15,10 @@ import {
13
15
  import {
14
16
  approvePlanArtifact,
15
17
  createPlanArtifact,
18
+ getReadyWorkstreams,
19
+ getWorkstreamStatusSummary,
16
20
  launchWorkstream,
21
+ retryWorkstream,
17
22
  markWorkstreamOutcome,
18
23
  loadAllPlans,
19
24
  loadLatestPlan,
@@ -54,6 +59,26 @@ export async function missionStartCommand(opts) {
54
59
  }
55
60
 
56
61
  const snapshot = buildMissionSnapshot(root, result.mission);
62
+ if (opts.plan) {
63
+ try {
64
+ const plan = await createMissionPlan(root, result.mission, opts);
65
+ if (opts.json) {
66
+ console.log(JSON.stringify({ mission: snapshot, plan }, null, 2));
67
+ return;
68
+ }
69
+
70
+ console.log(chalk.green(`Created mission ${snapshot.mission_id}`));
71
+ console.log(chalk.dim(` Goal: ${snapshot.goal}`));
72
+ console.log(chalk.green(`Created plan ${plan.plan_id} for mission ${snapshot.mission_id}`));
73
+ renderPlan(plan);
74
+ return;
75
+ } catch (error) {
76
+ console.error(chalk.yellow(`Mission ${snapshot.mission_id} was created, but automatic plan generation failed.`));
77
+ renderMissionPlanError(error);
78
+ process.exit(1);
79
+ }
80
+ }
81
+
57
82
  if (opts.json) {
58
83
  console.log(JSON.stringify(snapshot, null, 2));
59
84
  return;
@@ -211,66 +236,21 @@ export async function missionPlanCommand(missionTarget, opts) {
211
236
  console.error(chalk.red(`Mission "${mission.mission_id}" has no goal text. The planner cannot operate on missing mission intent.`));
212
237
  process.exit(1);
213
238
  }
214
-
215
- const constraints = Array.isArray(opts.constraint) ? opts.constraint : (opts.constraint ? [opts.constraint] : []);
216
- const roleHints = Array.isArray(opts.roleHint) ? opts.roleHint : (opts.roleHint ? [opts.roleHint] : []);
217
-
218
- // Build prompt and attempt LLM call, or use deterministic fallback
219
- let plannerOutput;
220
- const { systemPrompt, userPrompt } = buildPlannerPrompt(mission, constraints, roleHints);
221
-
222
- if (opts._plannerOutput) {
223
- // Injected planner output for testing — skip LLM call
224
- plannerOutput = opts._plannerOutput;
225
- } else {
226
- // Attempt to use the configured api_proxy adapter for decomposition
227
- try {
228
- const { loadConfig } = await import('../lib/config.js');
229
- const config = loadConfig(root);
230
- const plannerConfig = config?.mission_planner || config?.api_proxy;
231
-
232
- if (plannerConfig && plannerConfig.base_url && plannerConfig.model) {
233
- const response = await callPlannerLLM(plannerConfig, systemPrompt, userPrompt);
234
- const parsed = parsePlannerResponse(response);
235
- if (!parsed.ok) {
236
- console.error(chalk.red(`Planner response parse error: ${parsed.error}`));
237
- process.exit(1);
238
- }
239
- plannerOutput = parsed.data;
240
- } else {
241
- console.error(chalk.red('No mission planner or api_proxy configured.'));
242
- console.error(chalk.dim(' Add "mission_planner" or "api_proxy" to agentxchain.json with base_url and model.'));
243
- console.error(chalk.dim(' Or pass planner output via --planner-output-file <path> for offline use.'));
244
- process.exit(1);
245
- }
246
- } catch (err) {
247
- console.error(chalk.red(`Planner call failed: ${err.message}`));
248
- process.exit(1);
249
- }
250
- }
251
-
252
- // Validate and create plan artifact
253
- const result = createPlanArtifact(root, mission, {
254
- constraints,
255
- roleHints,
256
- plannerOutput,
257
- });
258
-
259
- if (!result.ok) {
260
- console.error(chalk.red('Plan validation failed:'));
261
- for (const err of result.errors) {
262
- console.error(chalk.red(` • ${err}`));
263
- }
239
+ let plan;
240
+ try {
241
+ plan = await createMissionPlan(root, mission, opts);
242
+ } catch (error) {
243
+ renderMissionPlanError(error);
264
244
  process.exit(1);
265
245
  }
266
246
 
267
247
  if (opts.json) {
268
- console.log(JSON.stringify(result.plan, null, 2));
248
+ console.log(JSON.stringify(plan, null, 2));
269
249
  return;
270
250
  }
271
251
 
272
- console.log(chalk.green(`Created plan ${result.plan.plan_id} for mission ${mission.mission_id}`));
273
- renderPlan(result.plan);
252
+ console.log(chalk.green(`Created plan ${plan.plan_id} for mission ${mission.mission_id}`));
253
+ renderPlan(plan);
274
254
  }
275
255
 
276
256
  /**
@@ -438,11 +418,32 @@ export async function missionPlanLaunchCommand(planTarget, opts) {
438
418
  }
439
419
  const { root } = context;
440
420
 
441
- if (!opts.workstream) {
442
- console.error(chalk.red('--workstream <id> is required. Specify which workstream to launch.'));
421
+ // Mutual exclusivity guard
422
+ if (opts.allReady && opts.workstream) {
423
+ console.error(chalk.red('--all-ready and --workstream are mutually exclusive. Use one or the other.'));
424
+ process.exit(1);
425
+ }
426
+
427
+ if (opts.retry && !opts.workstream) {
428
+ console.error(chalk.red('--retry requires --workstream <id>. Specify which failed workstream to retry.'));
443
429
  process.exit(1);
444
430
  }
445
431
 
432
+ if (opts.retry && opts.allReady) {
433
+ console.error(chalk.red('--retry and --all-ready are mutually exclusive. Retry targets a specific workstream.'));
434
+ process.exit(1);
435
+ }
436
+
437
+ if (!opts.allReady && !opts.workstream) {
438
+ console.error(chalk.red('--workstream <id> or --all-ready is required. Specify which workstream(s) to launch.'));
439
+ process.exit(1);
440
+ }
441
+
442
+ // Dispatch to batch launch if --all-ready
443
+ if (opts.allReady) {
444
+ return missionPlanLaunchAllReady(planTarget, opts, context);
445
+ }
446
+
446
447
  const mission = opts.mission
447
448
  ? loadMissionArtifact(root, opts.mission)
448
449
  : loadLatestMissionArtifact(root);
@@ -467,7 +468,9 @@ export async function missionPlanLaunchCommand(planTarget, opts) {
467
468
  process.exit(1);
468
469
  }
469
470
 
470
- const launch = launchWorkstream(root, mission.mission_id, plan.plan_id, opts.workstream);
471
+ const launch = opts.retry
472
+ ? retryWorkstream(root, mission.mission_id, plan.plan_id, opts.workstream)
473
+ : launchWorkstream(root, mission.mission_id, plan.plan_id, opts.workstream);
471
474
  if (!launch.ok) {
472
475
  console.error(chalk.red(launch.error));
473
476
  process.exit(1);
@@ -548,6 +551,195 @@ export async function missionPlanLaunchCommand(planTarget, opts) {
548
551
  }
549
552
  }
550
553
 
554
+ // ── Batch launch (--all-ready) ──────────────────────────────────────────────
555
+
556
+ async function missionPlanLaunchAllReady(planTarget, opts, context) {
557
+ const { root } = context;
558
+
559
+ const mission = opts.mission
560
+ ? loadMissionArtifact(root, opts.mission)
561
+ : loadLatestMissionArtifact(root);
562
+
563
+ if (!mission) {
564
+ console.error(chalk.red('No mission found.'));
565
+ console.error(chalk.dim(' Use --mission <id> or create a mission first.'));
566
+ process.exit(1);
567
+ }
568
+
569
+ const plan = planTarget && planTarget !== 'latest'
570
+ ? loadPlan(root, mission.mission_id, planTarget)
571
+ : loadLatestPlan(root, mission.mission_id);
572
+
573
+ if (!plan) {
574
+ if (planTarget && planTarget !== 'latest') {
575
+ console.error(chalk.red(`Plan not found: ${planTarget}`));
576
+ } else {
577
+ console.error(chalk.red(`No plans found for mission ${mission.mission_id}.`));
578
+ console.error(chalk.dim(' Run `agentxchain mission plan latest` to generate one.'));
579
+ }
580
+ process.exit(1);
581
+ }
582
+
583
+ if (plan.status !== 'approved') {
584
+ console.error(chalk.red(`Plan ${plan.plan_id} is not approved (status: "${plan.status}"). Approve the plan before launching workstreams.`));
585
+ process.exit(1);
586
+ }
587
+
588
+ const readyWorkstreams = getReadyWorkstreams(plan);
589
+ if (readyWorkstreams.length === 0) {
590
+ const summary = getWorkstreamStatusSummary(plan);
591
+ const parts = Object.entries(summary).map(([status, count]) => `${count} ${status}`);
592
+ console.error(chalk.red(`No ready workstreams to launch. Current distribution: ${parts.join(', ')}.`));
593
+ process.exit(1);
594
+ }
595
+
596
+ const executor = opts._executeGovernedRun || executeGovernedRun;
597
+ const logger = opts._log || console.log;
598
+ const results = [];
599
+ let hadFailure = false;
600
+
601
+ if (!opts.json) {
602
+ console.log(chalk.bold(`Launching ${readyWorkstreams.length} ready workstream(s) from plan ${plan.plan_id}...\n`));
603
+ }
604
+
605
+ for (let i = 0; i < readyWorkstreams.length; i++) {
606
+ const ws = readyWorkstreams[i];
607
+ const prefix = `[${i + 1}/${readyWorkstreams.length}]`;
608
+
609
+ // Skip remaining if a prior workstream failed
610
+ if (hadFailure) {
611
+ results.push({
612
+ workstream_id: ws.workstream_id,
613
+ status: 'skipped',
614
+ skip_reason: 'prior workstream failed',
615
+ });
616
+ if (!opts.json) {
617
+ console.log(`${prefix} ${chalk.dim(ws.workstream_id)} — ${chalk.dim('skipped (prior workstream failed)')}`);
618
+ }
619
+ continue;
620
+ }
621
+
622
+ // Launch bookkeeping
623
+ const launch = launchWorkstream(root, mission.mission_id, plan.plan_id, ws.workstream_id);
624
+ if (!launch.ok) {
625
+ hadFailure = true;
626
+ results.push({
627
+ workstream_id: ws.workstream_id,
628
+ status: 'launch_error',
629
+ error: launch.error,
630
+ });
631
+ if (!opts.json) {
632
+ console.log(`${prefix} ${chalk.red(ws.workstream_id)} — launch error: ${launch.error}`);
633
+ }
634
+ continue;
635
+ }
636
+
637
+ if (!opts.json) {
638
+ process.stdout.write(`${prefix} ${chalk.cyan(ws.workstream_id)} → ${launch.chainId} ... `);
639
+ }
640
+
641
+ // Execute
642
+ const chainOpts = {
643
+ enabled: true,
644
+ maxChains: 0,
645
+ chainOn: ['completed'],
646
+ cooldownSeconds: 0,
647
+ mission: mission.mission_id,
648
+ chainId: launch.chainId,
649
+ };
650
+ const runOpts = {
651
+ autoApprove: !!opts.autoApprove,
652
+ provenance: {
653
+ trigger: 'manual',
654
+ created_by: 'operator',
655
+ trigger_reason: `mission:${mission.mission_id} workstream:${ws.workstream_id} batch:all-ready`,
656
+ },
657
+ };
658
+
659
+ let execution;
660
+ try {
661
+ execution = await executeChainedRun(context, runOpts, chainOpts, executor, logger);
662
+ } catch (error) {
663
+ markWorkstreamOutcome(root, mission.mission_id, plan.plan_id, ws.workstream_id, {
664
+ terminalReason: 'execution_error',
665
+ completedAt: new Date().toISOString(),
666
+ });
667
+ hadFailure = true;
668
+ results.push({
669
+ workstream_id: ws.workstream_id,
670
+ chain_id: launch.chainId,
671
+ status: 'needs_attention',
672
+ error: error.message,
673
+ exit_code: 1,
674
+ });
675
+ if (!opts.json) {
676
+ console.log(chalk.red(`needs_attention ✗ (${error.message})`));
677
+ }
678
+ continue;
679
+ }
680
+
681
+ // Record outcome
682
+ const lastRun = execution?.chainReport?.runs?.[execution.chainReport.runs.length - 1] || null;
683
+ const terminalReason = lastRun?.status === 'completed'
684
+ ? 'completed'
685
+ : (lastRun?.status || execution?.chainReport?.terminal_reason || 'execution_error');
686
+
687
+ markWorkstreamOutcome(root, mission.mission_id, plan.plan_id, ws.workstream_id, {
688
+ terminalReason,
689
+ completedAt: execution?.chainReport?.completed_at || new Date().toISOString(),
690
+ });
691
+
692
+ const wsStatus = terminalReason === 'completed' ? 'completed' : 'needs_attention';
693
+ if (wsStatus === 'needs_attention') {
694
+ hadFailure = true;
695
+ }
696
+
697
+ results.push({
698
+ workstream_id: ws.workstream_id,
699
+ chain_id: launch.chainId,
700
+ status: wsStatus,
701
+ exit_code: execution.exitCode || 0,
702
+ });
703
+
704
+ if (!opts.json) {
705
+ if (wsStatus === 'completed') {
706
+ console.log(chalk.green('completed ✓'));
707
+ } else {
708
+ console.log(chalk.red(`needs_attention ✗`));
709
+ }
710
+ }
711
+ }
712
+
713
+ // Summary
714
+ const completed = results.filter((r) => r.status === 'completed').length;
715
+ const failed = results.filter((r) => r.status === 'needs_attention' || r.status === 'launch_error').length;
716
+ const skipped = results.filter((r) => r.status === 'skipped').length;
717
+
718
+ if (opts.json) {
719
+ console.log(JSON.stringify({
720
+ plan_id: plan.plan_id,
721
+ mission_id: mission.mission_id,
722
+ results,
723
+ summary: {
724
+ total: results.length,
725
+ completed,
726
+ failed,
727
+ skipped,
728
+ },
729
+ }, null, 2));
730
+ } else {
731
+ console.log('');
732
+ console.log(chalk.bold(`Summary: ${completed} completed, ${failed} failed, ${skipped} skipped`));
733
+ if (hadFailure) {
734
+ console.log(chalk.dim(' Inspect plan state with `agentxchain mission plan show latest`'));
735
+ }
736
+ }
737
+
738
+ if (hadFailure) {
739
+ process.exit(1);
740
+ }
741
+ }
742
+
551
743
  // ── Plan rendering ───────────────────────────────────────────────────────────
552
744
 
553
745
  function renderPlan(plan) {
@@ -691,7 +883,87 @@ async function callPlannerLLM(config, systemPrompt, userPrompt) {
691
883
  return content;
692
884
  }
693
885
 
886
+ async function createMissionPlan(root, mission, opts = {}) {
887
+ const { constraints, roleHints } = normalizePlannerOptions(opts);
888
+ const plannerOutput = await resolvePlannerOutput(root, mission, constraints, roleHints, opts);
889
+ const result = createPlanArtifact(root, mission, {
890
+ constraints,
891
+ roleHints,
892
+ plannerOutput,
893
+ });
894
+
895
+ if (!result.ok) {
896
+ const error = new Error('Plan validation failed.');
897
+ error.validationErrors = result.errors;
898
+ throw error;
899
+ }
900
+
901
+ return result.plan;
902
+ }
903
+
904
+ function normalizePlannerOptions(opts = {}) {
905
+ return {
906
+ constraints: Array.isArray(opts.constraint) ? opts.constraint : (opts.constraint ? [opts.constraint] : []),
907
+ roleHints: Array.isArray(opts.roleHint) ? opts.roleHint : (opts.roleHint ? [opts.roleHint] : []),
908
+ };
909
+ }
910
+
911
+ async function resolvePlannerOutput(root, mission, constraints, roleHints, opts = {}) {
912
+ const { systemPrompt, userPrompt } = buildPlannerPrompt(mission, constraints, roleHints);
913
+
914
+ if (opts._plannerOutput) {
915
+ return opts._plannerOutput;
916
+ }
917
+
918
+ if (opts.plannerOutputFile) {
919
+ const plannerOutputPath = resolve(opts.plannerOutputFile);
920
+ let raw;
921
+ try {
922
+ raw = readFileSync(plannerOutputPath, 'utf8');
923
+ } catch (error) {
924
+ throw new Error(`Planner output file read error: ${error.message}`);
925
+ }
926
+
927
+ const parsed = parsePlannerResponse(raw);
928
+ if (!parsed.ok) {
929
+ throw new Error(`Planner output file parse error: ${parsed.error}`);
930
+ }
931
+ return parsed.data;
932
+ }
933
+
934
+ const { loadConfig } = await import('../lib/config.js');
935
+ const config = loadConfig(root);
936
+ const plannerConfig = config?.mission_planner || config?.api_proxy;
937
+
938
+ if (!plannerConfig || !plannerConfig.base_url || !plannerConfig.model) {
939
+ throw new Error('No mission planner or api_proxy configured.\n Add "mission_planner" or "api_proxy" to agentxchain.json with base_url and model.\n Or pass planner output via --planner-output-file <path> for offline use.');
940
+ }
941
+
942
+ const response = await callPlannerLLM(plannerConfig, systemPrompt, userPrompt);
943
+ const parsed = parsePlannerResponse(response);
944
+ if (!parsed.ok) {
945
+ throw new Error(`Planner response parse error: ${parsed.error}`);
946
+ }
947
+ return parsed.data;
948
+ }
949
+
950
+ function renderMissionPlanError(error) {
951
+ const message = error?.message || 'Mission planning failed.';
952
+ const [firstLine, ...rest] = String(message).split('\n');
953
+ console.error(chalk.red(firstLine));
954
+ for (const line of rest) {
955
+ console.error(chalk.dim(line));
956
+ }
957
+ if (Array.isArray(error?.validationErrors) && error.validationErrors.length > 0) {
958
+ for (const validationError of error.validationErrors) {
959
+ console.error(chalk.red(` • ${validationError}`));
960
+ }
961
+ }
962
+ }
963
+
694
964
  function renderMissionSnapshot(snapshot) {
965
+ const latestPlan = snapshot.latest_plan || null;
966
+
695
967
  console.log(chalk.bold(`Mission: ${snapshot.mission_id}`));
696
968
  console.log('');
697
969
  console.log(` Title: ${snapshot.title || '—'}`);
@@ -710,10 +982,19 @@ function renderMissionSnapshot(snapshot) {
710
982
  console.log(` Missing chains: ${snapshot.missing_chain_ids.join(', ')}`);
711
983
  }
712
984
 
985
+ if (latestPlan) {
986
+ console.log('');
987
+ console.log(chalk.bold(' Latest plan:'));
988
+ console.log(` Plan ID: ${latestPlan.plan_id || '—'}`);
989
+ console.log(` Status: ${formatPlanStatus(latestPlan.status)}`);
990
+ console.log(` Completion: ${latestPlan.completion_percentage}% (${latestPlan.completed_count}/${latestPlan.workstream_count} completed)`);
991
+ console.log(` Workstream summary: ready ${latestPlan.ready_count}, blocked ${latestPlan.blocked_count}, launched ${latestPlan.launched_count}, completed ${latestPlan.completed_count}, needs_attention ${latestPlan.needs_attention_count}`);
992
+ }
993
+
713
994
  if (!snapshot.chains || snapshot.chains.length === 0) {
714
995
  console.log('');
715
996
  console.log(chalk.dim(' No chains attached.'));
716
- console.log(chalk.dim(' Use `agentxchain mission attach-chain latest` after a chained run.'));
997
+ console.log(chalk.dim(' Use `agentxchain run --chain --mission latest` for new work, or `agentxchain mission attach-chain latest` to repair an unbound chain.'));
717
998
  return;
718
999
  }
719
1000
 
@@ -5,9 +5,7 @@
5
5
  * Plans are advisory repo-local artifacts; this reader is not protocol-normative.
6
6
  */
7
7
 
8
- import { existsSync, readdirSync } from 'fs';
9
- import { join } from 'path';
10
- import { loadAllPlans, loadLatestPlan } from '../mission-plans.js';
8
+ import { buildPlanProgressSummary, loadAllPlans } from '../mission-plans.js';
11
9
  import { loadAllMissionArtifacts } from '../missions.js';
12
10
 
13
11
  /**
@@ -65,28 +63,12 @@ export function readPlanSnapshot(workspacePath, { limit, missionId } = {}) {
65
63
  * Build a dashboard-ready summary for a single plan.
66
64
  */
67
65
  function buildPlanSummary(plan) {
66
+ const summary = buildPlanProgressSummary(plan);
68
67
  const workstreams = Array.isArray(plan.workstreams) ? plan.workstreams : [];
69
68
  const launchRecords = Array.isArray(plan.launch_records) ? plan.launch_records : [];
70
69
 
71
- const statusCounts = {};
72
- for (const ws of workstreams) {
73
- const status = ws.launch_status || 'unknown';
74
- statusCounts[status] = (statusCounts[status] || 0) + 1;
75
- }
76
-
77
70
  return {
78
- plan_id: plan.plan_id,
79
- mission_id: plan.mission_id,
80
- status: plan.status,
81
- created_at: plan.created_at,
82
- updated_at: plan.updated_at,
83
- approved_at: plan.approved_at || null,
84
- supersedes_plan_id: plan.supersedes_plan_id || null,
85
- superseded_by_plan_id: plan.superseded_by_plan_id || null,
86
- input_goal: plan.input?.goal || null,
87
- workstream_count: workstreams.length,
88
- launch_record_count: launchRecords.length,
89
- workstream_status_counts: statusCounts,
71
+ ...summary,
90
72
  workstreams: workstreams.map((ws) => ({
91
73
  workstream_id: ws.workstream_id,
92
74
  title: ws.title,
@@ -306,6 +306,38 @@ export function loadPlan(root, missionId, planId) {
306
306
  return plans.find((p) => p.plan_id === planId) || null;
307
307
  }
308
308
 
309
+ export function buildPlanProgressSummary(plan) {
310
+ if (!plan || typeof plan !== 'object') return null;
311
+
312
+ const workstreams = Array.isArray(plan.workstreams) ? plan.workstreams : [];
313
+ const launchRecords = Array.isArray(plan.launch_records) ? plan.launch_records : [];
314
+ const workstreamStatusCounts = getWorkstreamStatusSummary(plan);
315
+ const completedCount = workstreamStatusCounts.completed || 0;
316
+
317
+ return {
318
+ plan_id: plan.plan_id,
319
+ mission_id: plan.mission_id,
320
+ status: plan.status,
321
+ created_at: plan.created_at,
322
+ updated_at: plan.updated_at,
323
+ approved_at: plan.approved_at || null,
324
+ supersedes_plan_id: plan.supersedes_plan_id || null,
325
+ superseded_by_plan_id: plan.superseded_by_plan_id || null,
326
+ input_goal: plan.input?.goal || null,
327
+ workstream_count: workstreams.length,
328
+ launch_record_count: launchRecords.length,
329
+ workstream_status_counts: workstreamStatusCounts,
330
+ ready_count: workstreamStatusCounts.ready || 0,
331
+ blocked_count: workstreamStatusCounts.blocked || 0,
332
+ launched_count: workstreamStatusCounts.launched || 0,
333
+ completed_count: completedCount,
334
+ needs_attention_count: workstreamStatusCounts.needs_attention || 0,
335
+ completion_percentage: workstreams.length === 0
336
+ ? 0
337
+ : Math.round((completedCount / workstreams.length) * 100),
338
+ };
339
+ }
340
+
309
341
  // ── Workstream launch ───────────────────────────────────────────────────────
310
342
 
311
343
  export function didChainFinishSuccessfully(chainReport) {
@@ -443,6 +475,12 @@ export function markWorkstreamOutcome(root, missionId, planId, workstreamId, { t
443
475
  }
444
476
  }
445
477
  }
478
+
479
+ // Auto-complete plan when all workstreams are completed
480
+ const allCompleted = plan.workstreams.every((w) => w.launch_status === 'completed');
481
+ if (allCompleted) {
482
+ plan.status = 'completed';
483
+ }
446
484
  } else {
447
485
  ws.launch_status = 'needs_attention';
448
486
  plan.status = 'needs_attention';
@@ -454,6 +492,85 @@ export function markWorkstreamOutcome(root, missionId, planId, workstreamId, { t
454
492
  return { ok: true, plan, workstream: ws };
455
493
  }
456
494
 
495
+ /**
496
+ * Retry a failed workstream by resetting its status and creating a new launch record.
497
+ *
498
+ * Only workstreams with launch_status === 'needs_attention' can be retried.
499
+ * The old launch record is preserved for audit. A new launch record with a new
500
+ * chain_id is created. If the plan was in 'needs_attention' status, it returns
501
+ * to 'approved' since the retry represents a new attempt.
502
+ *
503
+ * @returns {{ ok: boolean, plan?: object, workstream?: object, chainId?: string, launchRecord?: object, error?: string }}
504
+ */
505
+ export function retryWorkstream(root, missionId, planId, workstreamId, options = {}) {
506
+ const plan = loadPlan(root, missionId, planId);
507
+ if (!plan) {
508
+ return { ok: false, error: `Plan not found: ${planId}` };
509
+ }
510
+
511
+ const ws = plan.workstreams.find((w) => w.workstream_id === workstreamId);
512
+ if (!ws) {
513
+ return { ok: false, error: `Workstream not found: ${workstreamId}` };
514
+ }
515
+
516
+ if (ws.launch_status !== 'needs_attention') {
517
+ return {
518
+ ok: false,
519
+ error: `Workstream ${workstreamId} cannot be retried (status: "${ws.launch_status}"). Only "needs_attention" workstreams can be retried.`,
520
+ };
521
+ }
522
+
523
+ // Generate new chain ID for the retry
524
+ const chainId = options.chainId || `chain-${randomUUID().slice(0, 8)}`;
525
+ const now = new Date().toISOString();
526
+ const launchRecord = {
527
+ workstream_id: workstreamId,
528
+ chain_id: chainId,
529
+ launched_at: now,
530
+ status: 'launched',
531
+ retry: true,
532
+ };
533
+
534
+ if (!Array.isArray(plan.launch_records)) {
535
+ plan.launch_records = [];
536
+ }
537
+ plan.launch_records.push(launchRecord);
538
+ ws.launch_status = 'launched';
539
+
540
+ // Restore plan status from needs_attention to approved (retry in progress)
541
+ if (plan.status === 'needs_attention') {
542
+ plan.status = 'approved';
543
+ }
544
+
545
+ plan.updated_at = now;
546
+ writePlanArtifact(root, missionId, plan);
547
+
548
+ return { ok: true, plan, workstream: ws, chainId, launchRecord };
549
+ }
550
+
551
+ // ── Batch launch helpers ───────────────────────────────────────────────────
552
+
553
+ /**
554
+ * Return all workstreams with launch_status === 'ready', in plan order.
555
+ */
556
+ export function getReadyWorkstreams(plan) {
557
+ if (!plan || !Array.isArray(plan.workstreams)) return [];
558
+ return plan.workstreams.filter((ws) => ws.launch_status === 'ready');
559
+ }
560
+
561
+ /**
562
+ * Return a summary of workstream status distribution for operator messaging.
563
+ */
564
+ export function getWorkstreamStatusSummary(plan) {
565
+ if (!plan || !Array.isArray(plan.workstreams)) return {};
566
+ const summary = {};
567
+ for (const ws of plan.workstreams) {
568
+ const status = ws.launch_status || 'unknown';
569
+ summary[status] = (summary[status] || 0) + 1;
570
+ }
571
+ return summary;
572
+ }
573
+
457
574
  // ── LLM planner prompt ──────────────────────────────────────────────────────
458
575
 
459
576
  /**
@@ -1,6 +1,7 @@
1
1
  import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'fs';
2
2
  import { join } from 'path';
3
3
  import { loadAllChainReports, loadChainReport, loadLatestChainReport } from './chain-reports.js';
4
+ import { buildPlanProgressSummary, loadLatestPlan } from './mission-plans.js';
4
5
  import { getActiveRepoDecisions } from './repo-decisions.js';
5
6
 
6
7
  const MISSION_ATTENTION_TERMINALS = new Set(['operator_abort', 'parent_validation_failed']);
@@ -139,6 +140,7 @@ export function buildMissionSnapshot(root, missionArtifact) {
139
140
  const totalRuns = chains.reduce((sum, chain) => sum + (chain.runs?.length || 0), 0);
140
141
  const totalTurns = chains.reduce((sum, chain) => sum + (chain.total_turns || 0), 0);
141
142
  const latestChain = chains[0] || null;
143
+ const latestPlan = loadLatestPlan(root, missionArtifact.mission_id);
142
144
  const activeRepoDecisions = getActiveRepoDecisions(root);
143
145
 
144
146
  return {
@@ -151,6 +153,7 @@ export function buildMissionSnapshot(root, missionArtifact) {
151
153
  total_turns: totalTurns,
152
154
  latest_chain_id: latestChain?.chain_id || null,
153
155
  latest_terminal_reason: latestChain?.terminal_reason || null,
156
+ latest_plan: buildPlanProgressSummary(latestPlan),
154
157
  active_repo_decisions_count: activeRepoDecisions.length,
155
158
  chains,
156
159
  };