agentxchain 2.112.0 → 2.114.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.
@@ -0,0 +1,543 @@
1
+ /**
2
+ * Mission decomposition — plan artifact CRUD and schema validation.
3
+ *
4
+ * Plans are advisory repo-local artifacts under `.agentxchain/missions/plans/<mission_id>/`.
5
+ * They contain dependency-ordered workstreams derived from a mission goal.
6
+ * Plans are NOT protocol-normative.
7
+ */
8
+
9
+ import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'fs';
10
+ import { randomUUID } from 'crypto';
11
+ import { join } from 'path';
12
+ import { loadChainReport } from './chain-reports.js';
13
+
14
+ // ── Plan artifact directory ──────────────────────────────────────────────────
15
+
16
+ export function getPlansDir(root, missionId) {
17
+ return join(root, '.agentxchain', 'missions', 'plans', missionId);
18
+ }
19
+
20
+ export function getPlanPath(root, missionId, planId) {
21
+ return join(getPlansDir(root, missionId), `${planId}.json`);
22
+ }
23
+
24
+ function writePlanArtifact(root, missionId, plan) {
25
+ mkdirSync(getPlansDir(root, missionId), { recursive: true });
26
+ writeFileSync(getPlanPath(root, missionId, plan.plan_id), JSON.stringify(plan, null, 2));
27
+ }
28
+
29
+ // ── Plan ID generation ───────────────────────────────────────────────────────
30
+
31
+ export function generatePlanId(now = new Date()) {
32
+ const iso = now.toISOString().replace(/[:.]/g, '-').replace('Z', 'Z');
33
+ const epochMs = String(now.getTime()).padStart(13, '0');
34
+ const monotonic = process.hrtime.bigint().toString().slice(-10).padStart(10, '0');
35
+ return `plan-${iso}-${epochMs}-${monotonic}`;
36
+ }
37
+
38
+ // ── Schema validation ────────────────────────────────────────────────────────
39
+
40
+ const REQUIRED_WORKSTREAM_FIELDS = [
41
+ 'workstream_id',
42
+ 'title',
43
+ 'goal',
44
+ 'roles',
45
+ 'phases',
46
+ 'depends_on',
47
+ 'acceptance_checks',
48
+ ];
49
+
50
+ /**
51
+ * Validate planner output against the plan schema.
52
+ * Returns { ok: true, workstreams } or { ok: false, errors: string[] }.
53
+ */
54
+ export function validatePlannerOutput(output) {
55
+ const errors = [];
56
+
57
+ if (!output || typeof output !== 'object') {
58
+ return { ok: false, errors: ['Planner output must be a non-null object.'] };
59
+ }
60
+
61
+ if (!Array.isArray(output.workstreams)) {
62
+ return { ok: false, errors: ['Planner output must contain a "workstreams" array.'] };
63
+ }
64
+
65
+ if (output.workstreams.length === 0) {
66
+ return { ok: false, errors: ['Planner output must contain at least one workstream.'] };
67
+ }
68
+
69
+ const seenIds = new Set();
70
+ for (let i = 0; i < output.workstreams.length; i++) {
71
+ const ws = output.workstreams[i];
72
+ const prefix = `workstreams[${i}]`;
73
+
74
+ if (!ws || typeof ws !== 'object') {
75
+ errors.push(`${prefix}: must be an object.`);
76
+ continue;
77
+ }
78
+
79
+ for (const field of REQUIRED_WORKSTREAM_FIELDS) {
80
+ if (ws[field] === undefined || ws[field] === null) {
81
+ errors.push(`${prefix}: missing required field "${field}".`);
82
+ }
83
+ }
84
+
85
+ if (typeof ws.workstream_id === 'string') {
86
+ if (seenIds.has(ws.workstream_id)) {
87
+ errors.push(`${prefix}: duplicate workstream_id "${ws.workstream_id}".`);
88
+ }
89
+ seenIds.add(ws.workstream_id);
90
+ } else if (ws.workstream_id !== undefined) {
91
+ errors.push(`${prefix}: workstream_id must be a string.`);
92
+ }
93
+
94
+ if (ws.title !== undefined && typeof ws.title !== 'string') {
95
+ errors.push(`${prefix}: title must be a string.`);
96
+ }
97
+ if (ws.goal !== undefined && typeof ws.goal !== 'string') {
98
+ errors.push(`${prefix}: goal must be a string.`);
99
+ }
100
+ if (ws.roles !== undefined && !Array.isArray(ws.roles)) {
101
+ errors.push(`${prefix}: roles must be an array.`);
102
+ }
103
+ if (ws.phases !== undefined && !Array.isArray(ws.phases)) {
104
+ errors.push(`${prefix}: phases must be an array.`);
105
+ }
106
+ if (ws.depends_on !== undefined && !Array.isArray(ws.depends_on)) {
107
+ errors.push(`${prefix}: depends_on must be an array.`);
108
+ }
109
+ if (ws.acceptance_checks !== undefined && !Array.isArray(ws.acceptance_checks)) {
110
+ errors.push(`${prefix}: acceptance_checks must be an array.`);
111
+ }
112
+
113
+ // Validate no pre-allocated chain_id
114
+ if (ws.chain_id !== undefined) {
115
+ errors.push(`${prefix}: workstreams must not contain pre-allocated "chain_id". Chain IDs are runtime artifacts.`);
116
+ }
117
+ }
118
+
119
+ // Validate dependency references
120
+ if (errors.length === 0) {
121
+ const allIds = new Set(output.workstreams.map((ws) => ws.workstream_id));
122
+ for (let i = 0; i < output.workstreams.length; i++) {
123
+ const ws = output.workstreams[i];
124
+ if (Array.isArray(ws.depends_on)) {
125
+ for (const dep of ws.depends_on) {
126
+ if (!allIds.has(dep)) {
127
+ errors.push(`workstreams[${i}]: depends_on references unknown workstream_id "${dep}".`);
128
+ }
129
+ }
130
+ }
131
+ }
132
+ }
133
+
134
+ if (errors.length > 0) {
135
+ return { ok: false, errors };
136
+ }
137
+
138
+ return { ok: true, workstreams: output.workstreams };
139
+ }
140
+
141
+ // ── Plan artifact creation ───────────────────────────────────────────────────
142
+
143
+ /**
144
+ * Create a plan artifact from validated planner output.
145
+ *
146
+ * @param {string} root - project root
147
+ * @param {object} mission - mission artifact (must have mission_id, goal)
148
+ * @param {object} options - { constraints, roleHints, plannerOutput }
149
+ * @returns {{ ok: boolean, plan?: object, errors?: string[] }}
150
+ */
151
+ export function createPlanArtifact(root, mission, { constraints = [], roleHints = [], plannerOutput }) {
152
+ const validation = validatePlannerOutput(plannerOutput);
153
+ if (!validation.ok) {
154
+ return { ok: false, errors: validation.errors };
155
+ }
156
+
157
+ const missionId = mission.mission_id;
158
+ const existingPlans = loadAllPlans(root, missionId);
159
+ const supersedes = existingPlans[0] || null;
160
+
161
+ const createdAt = new Date();
162
+ const planId = generatePlanId(createdAt);
163
+ const now = createdAt.toISOString();
164
+
165
+ const workstreams = validation.workstreams.map((ws) => ({
166
+ workstream_id: ws.workstream_id,
167
+ title: ws.title,
168
+ goal: ws.goal,
169
+ roles: ws.roles,
170
+ phases: ws.phases,
171
+ depends_on: ws.depends_on,
172
+ acceptance_checks: ws.acceptance_checks,
173
+ launch_status: Array.isArray(ws.depends_on) && ws.depends_on.length > 0 ? 'blocked' : 'ready',
174
+ }));
175
+
176
+ const plan = {
177
+ plan_id: planId,
178
+ mission_id: missionId,
179
+ status: 'proposed',
180
+ supersedes_plan_id: supersedes ? supersedes.plan_id : null,
181
+ created_at: now,
182
+ updated_at: now,
183
+ input: {
184
+ goal: mission.goal,
185
+ constraints,
186
+ role_hints: roleHints,
187
+ },
188
+ planner: {
189
+ mode: 'llm_one_shot',
190
+ model: 'configured mission planner',
191
+ },
192
+ workstreams,
193
+ launch_records: [],
194
+ };
195
+
196
+ writePlanArtifact(root, missionId, plan);
197
+ return { ok: true, plan };
198
+ }
199
+
200
+ export function approvePlanArtifact(root, missionId, planId) {
201
+ const plans = loadAllPlans(root, missionId);
202
+ if (plans.length === 0) {
203
+ return { ok: false, error: `No plans found for mission ${missionId}.` };
204
+ }
205
+
206
+ const target = plans.find((plan) => plan.plan_id === planId);
207
+ if (!target) {
208
+ return { ok: false, error: `Plan not found: ${planId}` };
209
+ }
210
+
211
+ if (target.status === 'approved') {
212
+ return { ok: false, error: `Plan ${planId} is already approved.` };
213
+ }
214
+
215
+ if (target.status !== 'proposed') {
216
+ return { ok: false, error: `Plan ${planId} cannot be approved from status "${target.status}".` };
217
+ }
218
+
219
+ const latestPlan = plans[0];
220
+ if (latestPlan?.plan_id !== target.plan_id) {
221
+ return {
222
+ ok: false,
223
+ error: `Plan ${planId} has been superseded by newer plan ${latestPlan.plan_id}. Approve the latest plan instead.`,
224
+ };
225
+ }
226
+
227
+ const now = new Date().toISOString();
228
+ const supersededPlanIds = [];
229
+ let approvedPlan = null;
230
+
231
+ for (const plan of plans) {
232
+ if (plan.plan_id === target.plan_id) {
233
+ const { superseded_by_plan_id, ...rest } = plan;
234
+ approvedPlan = {
235
+ ...rest,
236
+ status: 'approved',
237
+ approved_at: now,
238
+ updated_at: now,
239
+ };
240
+ writePlanArtifact(root, missionId, approvedPlan);
241
+ continue;
242
+ }
243
+
244
+ if (plan.status === 'approved' || plan.status === 'proposed') {
245
+ const nextPlan = {
246
+ ...plan,
247
+ status: 'superseded',
248
+ superseded_by_plan_id: target.plan_id,
249
+ updated_at: now,
250
+ };
251
+ supersededPlanIds.push(plan.plan_id);
252
+ writePlanArtifact(root, missionId, nextPlan);
253
+ }
254
+ }
255
+
256
+ return { ok: true, plan: approvedPlan, supersededPlanIds };
257
+ }
258
+
259
+ // ── Plan artifact loading ────────────────────────────────────────────────────
260
+
261
+ export function loadAllPlans(root, missionId) {
262
+ const plansDir = getPlansDir(root, missionId);
263
+ if (!existsSync(plansDir)) return [];
264
+
265
+ const plans = [];
266
+ for (const file of readdirSync(plansDir).filter((f) => f.endsWith('.json')).sort()) {
267
+ try {
268
+ const parsed = JSON.parse(readFileSync(join(plansDir, file), 'utf8'));
269
+ if (parsed && parsed.plan_id) {
270
+ plans.push(parsed);
271
+ }
272
+ } catch {
273
+ // Advisory surface only. Skip malformed plan files.
274
+ }
275
+ }
276
+
277
+ // Newest first by created_at, then by plan_id descending as tiebreaker
278
+ plans.sort((a, b) => {
279
+ const aTime = new Date(a.created_at || 0).getTime();
280
+ const bTime = new Date(b.created_at || 0).getTime();
281
+ if (bTime !== aTime) return bTime - aTime;
282
+ return (b.plan_id || '').localeCompare(a.plan_id || '');
283
+ });
284
+
285
+ return plans;
286
+ }
287
+
288
+ export function loadLatestPlan(root, missionId) {
289
+ const plans = loadAllPlans(root, missionId);
290
+ return plans.length > 0 ? plans[0] : null;
291
+ }
292
+
293
+ export function loadPlan(root, missionId, planId) {
294
+ const plansDir = getPlansDir(root, missionId);
295
+ const exactPath = getPlanPath(root, missionId, planId);
296
+ if (existsSync(exactPath)) {
297
+ try {
298
+ return JSON.parse(readFileSync(exactPath, 'utf8'));
299
+ } catch {
300
+ return null;
301
+ }
302
+ }
303
+
304
+ // Scan for matching plan_id
305
+ const plans = loadAllPlans(root, missionId);
306
+ return plans.find((p) => p.plan_id === planId) || null;
307
+ }
308
+
309
+ // ── Workstream launch ───────────────────────────────────────────────────────
310
+
311
+ export function didChainFinishSuccessfully(chainReport) {
312
+ if (!chainReport || !Array.isArray(chainReport.runs) || chainReport.runs.length === 0) {
313
+ return false;
314
+ }
315
+
316
+ const lastRun = chainReport.runs[chainReport.runs.length - 1];
317
+ return lastRun?.status === 'completed';
318
+ }
319
+
320
+ /**
321
+ * Check whether a workstream's dependencies are satisfied.
322
+ * A dependency is satisfied when its launch_record exists AND the bound chain's
323
+ * most recent run completed successfully.
324
+ *
325
+ * @returns {string[]} list of unsatisfied dependency workstream IDs
326
+ */
327
+ export function checkDependencySatisfaction(plan, workstream, root) {
328
+ const unsatisfied = [];
329
+ if (!Array.isArray(workstream.depends_on)) return unsatisfied;
330
+
331
+ for (const depId of workstream.depends_on) {
332
+ const depRecord = (plan.launch_records || []).find((r) => r.workstream_id === depId);
333
+ if (!depRecord) {
334
+ unsatisfied.push(depId);
335
+ continue;
336
+ }
337
+ // Check that the dependency chain actually completed
338
+ const chainReport = loadChainReport(root, depRecord.chain_id);
339
+ if (!didChainFinishSuccessfully(chainReport)) {
340
+ unsatisfied.push(depId);
341
+ }
342
+ }
343
+ return unsatisfied;
344
+ }
345
+
346
+ /**
347
+ * Launch a single workstream from an approved plan.
348
+ *
349
+ * Validates plan approval, workstream existence, dependency satisfaction.
350
+ * Records launch_record with workstream_id → chain_id binding.
351
+ * The actual chain report attachment happens through the existing mission/chain
352
+ * surface after execution writes the chain report.
353
+ *
354
+ * @returns {{ ok: boolean, plan?: object, workstream?: object, chainId?: string, launchRecord?: object, error?: string }}
355
+ */
356
+ export function launchWorkstream(root, missionId, planId, workstreamId, options = {}) {
357
+ const plan = loadPlan(root, missionId, planId);
358
+ if (!plan) {
359
+ return { ok: false, error: `Plan not found: ${planId}` };
360
+ }
361
+ if (plan.status !== 'approved') {
362
+ return { ok: false, error: `Plan ${planId} is not approved (status: "${plan.status}"). Approve the plan before launching workstreams.` };
363
+ }
364
+
365
+ const ws = plan.workstreams.find((w) => w.workstream_id === workstreamId);
366
+ if (!ws) {
367
+ return { ok: false, error: `Workstream not found: ${workstreamId}` };
368
+ }
369
+
370
+ if (ws.launch_status === 'launched' || ws.launch_status === 'completed') {
371
+ return { ok: false, error: `Workstream ${workstreamId} has already been launched (status: "${ws.launch_status}").` };
372
+ }
373
+
374
+ // Check dependency satisfaction
375
+ const unsatisfied = checkDependencySatisfaction(plan, ws, root);
376
+ if (unsatisfied.length > 0) {
377
+ return {
378
+ ok: false,
379
+ error: `Workstream ${workstreamId} has unsatisfied dependencies: ${unsatisfied.join(', ')}. Launch and complete those workstreams first.`,
380
+ };
381
+ }
382
+
383
+ // Generate chain ID and record launch
384
+ const chainId = options.chainId || `chain-${randomUUID().slice(0, 8)}`;
385
+ const now = new Date().toISOString();
386
+ const launchRecord = {
387
+ workstream_id: workstreamId,
388
+ chain_id: chainId,
389
+ launched_at: now,
390
+ status: 'launched',
391
+ };
392
+
393
+ if (!Array.isArray(plan.launch_records)) {
394
+ plan.launch_records = [];
395
+ }
396
+ plan.launch_records.push(launchRecord);
397
+ ws.launch_status = 'launched';
398
+ plan.updated_at = now;
399
+
400
+ writePlanArtifact(root, missionId, plan);
401
+
402
+ return { ok: true, plan, workstream: ws, chainId, launchRecord };
403
+ }
404
+
405
+ /**
406
+ * Record the outcome of a launched workstream after its chain completes.
407
+ *
408
+ * Updates launch_record with terminal reason, updates workstream launch_status,
409
+ * and recalculates dependency-blocked workstreams.
410
+ *
411
+ * @returns {{ ok: boolean, plan?: object, workstream?: object, error?: string }}
412
+ */
413
+ export function markWorkstreamOutcome(root, missionId, planId, workstreamId, { terminalReason, completedAt }) {
414
+ const plan = loadPlan(root, missionId, planId);
415
+ if (!plan) {
416
+ return { ok: false, error: `Plan not found: ${planId}` };
417
+ }
418
+
419
+ const ws = plan.workstreams.find((w) => w.workstream_id === workstreamId);
420
+ if (!ws) {
421
+ return { ok: false, error: `Workstream not found: ${workstreamId}` };
422
+ }
423
+
424
+ const record = (plan.launch_records || []).find((r) => r.workstream_id === workstreamId);
425
+ if (!record) {
426
+ return { ok: false, error: `No launch record found for workstream ${workstreamId}.` };
427
+ }
428
+
429
+ const now = completedAt || new Date().toISOString();
430
+ record.terminal_reason = terminalReason;
431
+ record.completed_at = now;
432
+ record.status = terminalReason === 'completed' ? 'completed' : 'failed';
433
+
434
+ if (terminalReason === 'completed') {
435
+ ws.launch_status = 'completed';
436
+
437
+ // Recalculate blocked dependents — some may now be ready
438
+ for (const depWs of plan.workstreams) {
439
+ if (depWs.launch_status === 'blocked' && Array.isArray(depWs.depends_on) && depWs.depends_on.includes(workstreamId)) {
440
+ const stillBlocked = checkDependencySatisfaction(plan, depWs, root);
441
+ if (stillBlocked.length === 0) {
442
+ depWs.launch_status = 'ready';
443
+ }
444
+ }
445
+ }
446
+ } else {
447
+ ws.launch_status = 'needs_attention';
448
+ plan.status = 'needs_attention';
449
+ }
450
+
451
+ plan.updated_at = now;
452
+ writePlanArtifact(root, missionId, plan);
453
+
454
+ return { ok: true, plan, workstream: ws };
455
+ }
456
+
457
+ // ── Batch launch helpers ───────────────────────────────────────────────────
458
+
459
+ /**
460
+ * Return all workstreams with launch_status === 'ready', in plan order.
461
+ */
462
+ export function getReadyWorkstreams(plan) {
463
+ if (!plan || !Array.isArray(plan.workstreams)) return [];
464
+ return plan.workstreams.filter((ws) => ws.launch_status === 'ready');
465
+ }
466
+
467
+ /**
468
+ * Return a summary of workstream status distribution for operator messaging.
469
+ */
470
+ export function getWorkstreamStatusSummary(plan) {
471
+ if (!plan || !Array.isArray(plan.workstreams)) return {};
472
+ const summary = {};
473
+ for (const ws of plan.workstreams) {
474
+ const status = ws.launch_status || 'unknown';
475
+ summary[status] = (summary[status] || 0) + 1;
476
+ }
477
+ return summary;
478
+ }
479
+
480
+ // ── LLM planner prompt ──────────────────────────────────────────────────────
481
+
482
+ /**
483
+ * Build the system+user prompt for the mission planner LLM call.
484
+ */
485
+ export function buildPlannerPrompt(mission, constraints, roleHints) {
486
+ const systemPrompt = `You are a mission decomposition planner for AgentXchain, a governed multi-agent software delivery system.
487
+
488
+ Given a mission goal, optional constraints, and optional role hints, produce a JSON object with a single "workstreams" array.
489
+
490
+ Each workstream must have:
491
+ - workstream_id: a short kebab-case identifier starting with "ws-"
492
+ - title: a human-readable title
493
+ - goal: what this workstream should accomplish
494
+ - roles: array of role names needed
495
+ - phases: array of workflow phase names
496
+ - depends_on: array of workstream_ids this depends on (empty array if none)
497
+ - acceptance_checks: array of pass/fail acceptance criteria strings
498
+
499
+ Rules:
500
+ - Order workstreams so dependencies come before dependents.
501
+ - Do NOT include chain_id — chain IDs are runtime artifacts.
502
+ - Keep workstream count between 2 and 8.
503
+ - Each workstream should be a meaningful delivery slice, not a single task.
504
+ - Use concrete, testable acceptance checks.
505
+
506
+ Respond with ONLY valid JSON. No markdown, no explanation.`;
507
+
508
+ const parts = [`Mission goal: ${mission.goal}`];
509
+ if (constraints.length > 0) {
510
+ parts.push(`Constraints:\n${constraints.map((c) => `- ${c}`).join('\n')}`);
511
+ }
512
+ if (roleHints.length > 0) {
513
+ parts.push(`Available roles: ${roleHints.join(', ')}`);
514
+ }
515
+
516
+ const userPrompt = parts.join('\n\n');
517
+ return { systemPrompt, userPrompt };
518
+ }
519
+
520
+ /**
521
+ * Parse raw planner response text into a structured object.
522
+ * Handles JSON extraction from markdown fences or raw text.
523
+ */
524
+ export function parsePlannerResponse(responseText) {
525
+ if (!responseText || typeof responseText !== 'string') {
526
+ return { ok: false, error: 'Empty or non-string planner response.' };
527
+ }
528
+
529
+ let text = responseText.trim();
530
+
531
+ // Strip markdown JSON fences if present
532
+ const fenceMatch = text.match(/```(?:json)?\s*\n?([\s\S]*?)\n?```/);
533
+ if (fenceMatch) {
534
+ text = fenceMatch[1].trim();
535
+ }
536
+
537
+ try {
538
+ const parsed = JSON.parse(text);
539
+ return { ok: true, data: parsed };
540
+ } catch (err) {
541
+ return { ok: false, error: `Failed to parse planner response as JSON: ${err.message}` };
542
+ }
543
+ }
@@ -24,7 +24,7 @@ const DEFAULT_COOLDOWN_SECONDS = 5;
24
24
  *
25
25
  * @param {object} opts - CLI options
26
26
  * @param {object} config - agentxchain.json config
27
- * @returns {{ enabled: boolean, maxChains: number, chainOn: string[], cooldownSeconds: number, mission: string|null }}
27
+ * @returns {{ enabled: boolean, maxChains: number, chainOn: string[], cooldownSeconds: number, mission: string|null, chainId?: string|null }}
28
28
  */
29
29
  export function resolveChainOptions(opts, config) {
30
30
  const configChain = config?.run_loop?.chain || {};
@@ -46,7 +46,9 @@ export function resolveChainOptions(opts, config) {
46
46
 
47
47
  const mission = opts.mission ?? configChain.mission ?? null;
48
48
 
49
- return { enabled, maxChains, chainOn, cooldownSeconds, mission };
49
+ const chainId = opts.chainId ?? null;
50
+
51
+ return { enabled, maxChains, chainOn, cooldownSeconds, mission, chainId };
50
52
  }
51
53
 
52
54
  /**
@@ -60,7 +62,7 @@ export function resolveChainOptions(opts, config) {
60
62
  * @returns {Promise<{ exitCode: number, chainReport: object }>}
61
63
  */
62
64
  export async function executeChainedRun(context, opts, chainOpts, executeGovernedRun, log = console.log) {
63
- const chainId = `chain-${randomUUID().slice(0, 8)}`;
65
+ const chainId = chainOpts.chainId || `chain-${randomUUID().slice(0, 8)}`;
64
66
  const chainOnSet = new Set(chainOpts.chainOn);
65
67
  const maxRuns = chainOpts.maxChains + 1; // initial + continuations
66
68
  const startedAt = new Date().toISOString();