agentxchain 2.111.0 → 2.112.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,252 @@
1
+ import chalk from 'chalk';
2
+ import { findProjectRoot } from '../lib/config.js';
3
+ import {
4
+ attachChainToMission,
5
+ buildMissionListSummary,
6
+ buildMissionSnapshot,
7
+ createMission,
8
+ loadLatestMissionArtifact,
9
+ loadLatestMissionSnapshot,
10
+ loadMissionArtifact,
11
+ loadMissionSnapshot,
12
+ } from '../lib/missions.js';
13
+
14
+ export async function missionStartCommand(opts) {
15
+ const root = findProjectRoot(opts.dir || process.cwd());
16
+ if (!root) {
17
+ console.error(chalk.red('No AgentXchain project found. Run this inside a governed project.'));
18
+ process.exit(1);
19
+ }
20
+
21
+ const title = String(opts.title || '').trim();
22
+ const goal = String(opts.goal || '').trim();
23
+ if (!title) {
24
+ console.error(chalk.red('Mission title is required. Use --title <text>.'));
25
+ process.exit(1);
26
+ }
27
+ if (!goal) {
28
+ console.error(chalk.red('Mission goal is required. Use --goal <text>.'));
29
+ process.exit(1);
30
+ }
31
+
32
+ const result = createMission(root, {
33
+ missionId: opts.id,
34
+ title,
35
+ goal,
36
+ });
37
+ if (!result.ok) {
38
+ console.error(chalk.red(result.error));
39
+ process.exit(1);
40
+ }
41
+
42
+ const snapshot = buildMissionSnapshot(root, result.mission);
43
+ if (opts.json) {
44
+ console.log(JSON.stringify(snapshot, null, 2));
45
+ return;
46
+ }
47
+
48
+ console.log(chalk.green(`Created mission ${snapshot.mission_id}`));
49
+ console.log(chalk.dim(` Goal: ${snapshot.goal}`));
50
+ renderMissionSnapshot(snapshot);
51
+ }
52
+
53
+ export async function missionListCommand(opts) {
54
+ const root = findProjectRoot(opts.dir || process.cwd());
55
+ if (!root) {
56
+ console.error(chalk.red('No AgentXchain project found. Run this inside a governed project.'));
57
+ process.exit(1);
58
+ }
59
+
60
+ const limit = opts.limit ? parseInt(opts.limit, 10) : 20;
61
+ const missions = buildMissionListSummary(root, limit);
62
+
63
+ if (opts.json) {
64
+ console.log(JSON.stringify(missions, null, 2));
65
+ return;
66
+ }
67
+
68
+ if (missions.length === 0) {
69
+ console.log(chalk.dim('No missions found.'));
70
+ console.log(chalk.dim(' Run `agentxchain mission start --title "..." --goal "..."` to create one.'));
71
+ return;
72
+ }
73
+
74
+ const header = [
75
+ pad('#', 4),
76
+ pad('Mission ID', 28),
77
+ pad('Status', 18),
78
+ pad('Chains', 8),
79
+ pad('Runs', 7),
80
+ pad('Turns', 7),
81
+ pad('Decisions', 10),
82
+ pad('Updated', 22),
83
+ 'Title',
84
+ ].join(' ');
85
+
86
+ console.log(chalk.bold(header));
87
+ console.log(chalk.dim('─'.repeat(header.length)));
88
+
89
+ missions.forEach((mission, index) => {
90
+ console.log([
91
+ pad(String(index + 1), 4),
92
+ pad(mission.mission_id || '—', 28),
93
+ pad(formatMissionStatus(mission.derived_status), 18),
94
+ pad(String(mission.chain_count || 0), 8),
95
+ pad(String(mission.total_runs || 0), 7),
96
+ pad(String(mission.total_turns || 0), 7),
97
+ pad(String(mission.active_repo_decisions_count || 0), 10),
98
+ pad(formatTimestamp(mission.updated_at), 22),
99
+ mission.title || '—',
100
+ ].join(' '));
101
+ });
102
+
103
+ console.log(chalk.dim(`\n${missions.length} mission(s) shown`));
104
+ }
105
+
106
+ export async function missionShowCommand(missionId, opts) {
107
+ const root = findProjectRoot(opts.dir || process.cwd());
108
+ if (!root) {
109
+ console.error(chalk.red('No AgentXchain project found. Run this inside a governed project.'));
110
+ process.exit(1);
111
+ }
112
+
113
+ const snapshot = missionId
114
+ ? loadMissionSnapshot(root, missionId)
115
+ : loadLatestMissionSnapshot(root);
116
+ if (!snapshot) {
117
+ if (missionId) {
118
+ console.error(chalk.red(`Mission not found: ${missionId}`));
119
+ process.exit(1);
120
+ }
121
+ console.log(chalk.dim('No missions found.'));
122
+ console.log(chalk.dim(' Run `agentxchain mission start --title "..." --goal "..."` to create one.'));
123
+ return;
124
+ }
125
+
126
+ if (opts.json) {
127
+ console.log(JSON.stringify(snapshot, null, 2));
128
+ return;
129
+ }
130
+
131
+ renderMissionSnapshot(snapshot);
132
+ }
133
+
134
+ export async function missionAttachChainCommand(chainId, opts) {
135
+ const root = findProjectRoot(opts.dir || process.cwd());
136
+ if (!root) {
137
+ console.error(chalk.red('No AgentXchain project found. Run this inside a governed project.'));
138
+ process.exit(1);
139
+ }
140
+
141
+ const mission = opts.mission
142
+ ? loadMissionArtifact(root, opts.mission)
143
+ : loadLatestMissionArtifact(root);
144
+ if (!mission) {
145
+ console.error(chalk.red('No mission found to attach to.'));
146
+ console.error(chalk.dim(' Use `agentxchain mission start --title "..." --goal "..."` first.'));
147
+ process.exit(1);
148
+ }
149
+
150
+ const result = attachChainToMission(root, mission.mission_id, chainId || 'latest');
151
+ if (!result.ok) {
152
+ console.error(chalk.red(result.error));
153
+ process.exit(1);
154
+ }
155
+
156
+ const snapshot = buildMissionSnapshot(root, result.mission);
157
+ if (opts.json) {
158
+ console.log(JSON.stringify(snapshot, null, 2));
159
+ return;
160
+ }
161
+
162
+ console.log(chalk.green(`Attached ${result.chain.chain_id} to ${snapshot.mission_id}`));
163
+ renderMissionSnapshot(snapshot);
164
+ }
165
+
166
+ function renderMissionSnapshot(snapshot) {
167
+ console.log(chalk.bold(`Mission: ${snapshot.mission_id}`));
168
+ console.log('');
169
+ console.log(` Title: ${snapshot.title || '—'}`);
170
+ console.log(` Goal: ${snapshot.goal || '—'}`);
171
+ console.log(` Status: ${formatMissionStatus(snapshot.derived_status)}`);
172
+ console.log(` Chains: ${snapshot.chain_count || 0}`);
173
+ console.log(` Total runs: ${snapshot.total_runs || 0}`);
174
+ console.log(` Total turns: ${snapshot.total_turns || 0}`);
175
+ console.log(` Active repo decisions: ${snapshot.active_repo_decisions_count || 0}`);
176
+ console.log(` Latest chain: ${snapshot.latest_chain_id || '—'}`);
177
+ console.log(` Latest terminal: ${snapshot.latest_terminal_reason || '—'}`);
178
+ console.log(` Created: ${snapshot.created_at || '—'}`);
179
+ console.log(` Updated: ${snapshot.updated_at || '—'}`);
180
+
181
+ if (snapshot.missing_chain_ids?.length) {
182
+ console.log(` Missing chains: ${snapshot.missing_chain_ids.join(', ')}`);
183
+ }
184
+
185
+ if (!snapshot.chains || snapshot.chains.length === 0) {
186
+ console.log('');
187
+ console.log(chalk.dim(' No chains attached.'));
188
+ console.log(chalk.dim(' Use `agentxchain mission attach-chain latest` after a chained run.'));
189
+ return;
190
+ }
191
+
192
+ const header = [
193
+ pad('#', 4),
194
+ pad('Chain ID', 16),
195
+ pad('Runs', 6),
196
+ pad('Turns', 7),
197
+ pad('Terminal', 26),
198
+ pad('Started', 22),
199
+ ].join(' ');
200
+
201
+ console.log('');
202
+ console.log(chalk.bold(' Chains:'));
203
+ console.log(` ${chalk.dim(header)}`);
204
+ console.log(` ${chalk.dim('─'.repeat(header.length))}`);
205
+
206
+ snapshot.chains.forEach((chain, index) => {
207
+ console.log(` ${[
208
+ pad(String(index + 1), 4),
209
+ pad(chain.chain_id || '—', 16),
210
+ pad(String(chain.runs?.length || 0), 6),
211
+ pad(String(chain.total_turns || 0), 7),
212
+ pad(formatTerminal(chain.terminal_reason), 26),
213
+ pad(formatTimestamp(chain.started_at), 22),
214
+ ].join(' ')}`);
215
+ });
216
+ }
217
+
218
+ function formatTerminal(reason) {
219
+ if (!reason) return '—';
220
+ if (reason === 'chain_limit_reached') return 'chain limit reached';
221
+ if (reason === 'non_chainable_status') return 'non-chainable status';
222
+ return reason.replace(/_/g, ' ');
223
+ }
224
+
225
+ function formatMissionStatus(status) {
226
+ if (!status) return '—';
227
+ switch (status) {
228
+ case 'planned':
229
+ return chalk.blue('planned');
230
+ case 'progressing':
231
+ return chalk.green('progressing');
232
+ case 'needs_attention':
233
+ return chalk.yellow('needs_attention');
234
+ case 'degraded':
235
+ return chalk.red('degraded');
236
+ default:
237
+ return status;
238
+ }
239
+ }
240
+
241
+ function formatTimestamp(value) {
242
+ if (!value) return '—';
243
+ try {
244
+ return new Date(value).toLocaleString();
245
+ } catch {
246
+ return value;
247
+ }
248
+ }
249
+
250
+ function pad(value, width) {
251
+ return String(value).padEnd(width);
252
+ }
@@ -56,7 +56,9 @@ export async function runCommand(opts) {
56
56
  const chainOpts = resolveChainOptions(opts, context.config);
57
57
  if (chainOpts.enabled) {
58
58
  console.log(chalk.cyan.bold('agentxchain run --chain'));
59
- console.log(chalk.dim(` Chain mode: enabled (max ${chainOpts.maxChains} continuations, on: ${chainOpts.chainOn.join(',')}, cooldown: ${chainOpts.cooldownSeconds}s)`));
59
+ const chainParts = [`max ${chainOpts.maxChains} continuations`, `on: ${chainOpts.chainOn.join(',')}`, `cooldown: ${chainOpts.cooldownSeconds}s`];
60
+ if (chainOpts.mission) chainParts.push(`mission: ${chainOpts.mission}`);
61
+ console.log(chalk.dim(` Chain mode: enabled (${chainParts.join(', ')})`));
60
62
  const { exitCode } = await executeChainedRun(context, opts, chainOpts, executeGovernedRun);
61
63
  process.exit(exitCode);
62
64
  }
@@ -30,6 +30,7 @@ import { loadProjectContext, loadProjectState } from '../config.js';
30
30
  import { evaluateApprovalSlaReminders } from '../notification-runner.js';
31
31
  import { readGateActionSnapshot } from './gate-action-reader.js';
32
32
  import { readChainReportSnapshot } from './chain-report-reader.js';
33
+ import { readMissionSnapshot } from './mission-reader.js';
33
34
 
34
35
  const MIME_TYPES = {
35
36
  '.html': 'text/html; charset=utf-8',
@@ -470,6 +471,13 @@ export function createBridgeServer({ agentxchainDir, dashboardDir, port = 3847,
470
471
  return;
471
472
  }
472
473
 
474
+ if (pathname === '/api/missions') {
475
+ const limit = url.searchParams.get('limit') ? parseInt(url.searchParams.get('limit'), 10) : undefined;
476
+ const result = readMissionSnapshot(workspacePath, { limit });
477
+ writeJson(res, result.status, result.body);
478
+ return;
479
+ }
480
+
473
481
  if (pathname === '/api/gate-actions') {
474
482
  const result = readGateActionSnapshot(workspacePath);
475
483
  writeJson(res, result.status, result.body);
@@ -8,7 +8,7 @@
8
8
  import { watch, existsSync } from 'fs';
9
9
  import { basename, join } from 'path';
10
10
  import { EventEmitter } from 'events';
11
- import { WATCH_DIRECTORIES, resourceForRelativePath } from './state-reader.js';
11
+ import { WATCH_DIRECTORIES, resourcesForRelativePath } from './state-reader.js';
12
12
 
13
13
  const DEBOUNCE_MS = 100;
14
14
 
@@ -40,24 +40,26 @@ export class FileWatcher extends EventEmitter {
40
40
  if (!filename || this.#closed) return;
41
41
  const base = basename(filename);
42
42
  const relativePath = relativeDir ? `${relativeDir}/${base}` : base;
43
- const resource = resourceForRelativePath(relativePath);
43
+ const resources = resourcesForRelativePath(relativePath);
44
44
 
45
- if (!resource) {
45
+ if (resources.length === 0) {
46
46
  if (!relativeDir && base === 'multirepo') {
47
47
  this.#watchPath('multirepo');
48
48
  }
49
49
  return;
50
50
  }
51
51
 
52
- if (this.#debounceTimers.has(resource)) {
53
- clearTimeout(this.#debounceTimers.get(resource));
54
- }
55
- this.#debounceTimers.set(resource, setTimeout(() => {
56
- this.#debounceTimers.delete(resource);
57
- if (!this.#closed) {
58
- this.emit('invalidate', { resource });
52
+ for (const resource of resources) {
53
+ if (this.#debounceTimers.has(resource)) {
54
+ clearTimeout(this.#debounceTimers.get(resource));
59
55
  }
60
- }, DEBOUNCE_MS));
56
+ this.#debounceTimers.set(resource, setTimeout(() => {
57
+ this.#debounceTimers.delete(resource);
58
+ if (!this.#closed) {
59
+ this.emit('invalidate', { resource });
60
+ }
61
+ }, DEBOUNCE_MS));
62
+ }
61
63
  });
62
64
 
63
65
  watcher.on('error', (err) => {
@@ -0,0 +1,14 @@
1
+ import { buildMissionListSummary, loadLatestMissionSnapshot } from '../missions.js';
2
+
3
+ export function readMissionSnapshot(workspacePath, { limit } = {}) {
4
+ const missions = buildMissionListSummary(workspacePath, limit);
5
+
6
+ return {
7
+ ok: true,
8
+ status: 200,
9
+ body: {
10
+ latest: loadLatestMissionSnapshot(workspacePath),
11
+ missions,
12
+ },
13
+ };
14
+ }
@@ -62,6 +62,7 @@ FILE_TO_RESOURCE[normalizeRelativePath(REPO_DECISIONS_FILE)] = '/api/repo-decisi
62
62
  export const WATCH_DIRECTORIES = [
63
63
  '',
64
64
  MULTIREPO_DIR,
65
+ 'missions',
65
66
  'reports',
66
67
  ];
67
68
 
@@ -69,12 +70,20 @@ export function normalizeRelativePath(filePath) {
69
70
  return normalize(filePath).replace(/\\/g, '/').replace(/^\.\/+/, '');
70
71
  }
71
72
 
72
- export function resourceForRelativePath(filePath) {
73
+ export function resourcesForRelativePath(filePath) {
73
74
  const normalized = normalizeRelativePath(filePath);
75
+ if (normalized.startsWith('missions/') && normalized.endsWith('.json')) {
76
+ return ['/api/missions'];
77
+ }
74
78
  if (normalized.startsWith('reports/chain-') && normalized.endsWith('.json')) {
75
- return '/api/chain-reports';
79
+ return ['/api/chain-reports', '/api/missions'];
76
80
  }
77
- return FILE_TO_RESOURCE[normalized] || null;
81
+ return FILE_TO_RESOURCE[normalized] ? [FILE_TO_RESOURCE[normalized]] : [];
82
+ }
83
+
84
+ export function resourceForRelativePath(filePath) {
85
+ const resources = resourcesForRelativePath(filePath);
86
+ return resources[0] || null;
78
87
  }
79
88
 
80
89
  /**
@@ -0,0 +1,195 @@
1
+ import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'fs';
2
+ import { join } from 'path';
3
+ import { loadAllChainReports, loadChainReport, loadLatestChainReport } from './chain-reports.js';
4
+ import { getActiveRepoDecisions } from './repo-decisions.js';
5
+
6
+ const MISSION_ATTENTION_TERMINALS = new Set(['operator_abort', 'parent_validation_failed']);
7
+ const MISSION_ATTENTION_RUN_STATUSES = new Set(['blocked', 'failed']);
8
+
9
+ export function getMissionsDir(root) {
10
+ return join(root, '.agentxchain', 'missions');
11
+ }
12
+
13
+ export function buildMissionId(input) {
14
+ const slug = String(input || '')
15
+ .toLowerCase()
16
+ .replace(/[^a-z0-9]+/g, '-')
17
+ .replace(/^-+|-+$/g, '')
18
+ .slice(0, 64);
19
+ const base = slug || 'untitled';
20
+ return base.startsWith('mission-') ? base : `mission-${base}`;
21
+ }
22
+
23
+ export function createMission(root, { missionId, title, goal }) {
24
+ const normalizedId = buildMissionId(missionId || title);
25
+ const filePath = join(getMissionsDir(root), `${normalizedId}.json`);
26
+ if (existsSync(filePath)) {
27
+ return { ok: false, error: `Mission already exists: ${normalizedId}` };
28
+ }
29
+
30
+ const now = new Date().toISOString();
31
+ const artifact = {
32
+ mission_id: normalizedId,
33
+ title,
34
+ goal,
35
+ status: 'active',
36
+ created_at: now,
37
+ updated_at: now,
38
+ chain_ids: [],
39
+ };
40
+
41
+ mkdirSync(getMissionsDir(root), { recursive: true });
42
+ writeFileSync(filePath, JSON.stringify(artifact, null, 2));
43
+ return { ok: true, mission: artifact };
44
+ }
45
+
46
+ export function loadAllMissionArtifacts(root) {
47
+ const missionsDir = getMissionsDir(root);
48
+ if (!existsSync(missionsDir)) return [];
49
+
50
+ const missions = [];
51
+ for (const file of readdirSync(missionsDir).filter((entry) => entry.endsWith('.json')).sort()) {
52
+ try {
53
+ const parsed = JSON.parse(readFileSync(join(missionsDir, file), 'utf8'));
54
+ if (parsed && parsed.mission_id) {
55
+ missions.push(parsed);
56
+ }
57
+ } catch {
58
+ // Advisory surface only. Skip malformed mission files.
59
+ }
60
+ }
61
+
62
+ missions.sort((left, right) => {
63
+ const leftTime = new Date(left.updated_at || left.created_at || 0).getTime();
64
+ const rightTime = new Date(right.updated_at || right.created_at || 0).getTime();
65
+ return rightTime - leftTime;
66
+ });
67
+
68
+ return missions;
69
+ }
70
+
71
+ export function loadMissionArtifact(root, missionId) {
72
+ const filePath = join(getMissionsDir(root), `${missionId}.json`);
73
+ if (existsSync(filePath)) {
74
+ try {
75
+ const parsed = JSON.parse(readFileSync(filePath, 'utf8'));
76
+ if (parsed?.mission_id) return parsed;
77
+ } catch {
78
+ return null;
79
+ }
80
+ }
81
+
82
+ return loadAllMissionArtifacts(root).find((mission) => mission.mission_id === missionId) || null;
83
+ }
84
+
85
+ export function loadLatestMissionArtifact(root) {
86
+ const missions = loadAllMissionArtifacts(root);
87
+ return missions.length > 0 ? missions[0] : null;
88
+ }
89
+
90
+ export function attachChainToMission(root, missionId, chainRef = 'latest') {
91
+ const mission = loadMissionArtifact(root, missionId);
92
+ if (!mission) {
93
+ return { ok: false, error: `Mission not found: ${missionId}` };
94
+ }
95
+
96
+ const chain = chainRef === 'latest'
97
+ ? loadLatestChainReport(root)
98
+ : loadChainReport(root, chainRef);
99
+ if (!chain) {
100
+ return { ok: false, error: chainRef === 'latest' ? 'No chain reports found.' : `Chain report not found: ${chainRef}` };
101
+ }
102
+
103
+ const nextChainIds = Array.isArray(mission.chain_ids) ? [...mission.chain_ids] : [];
104
+ if (!nextChainIds.includes(chain.chain_id)) {
105
+ nextChainIds.push(chain.chain_id);
106
+ }
107
+
108
+ const updated = {
109
+ ...mission,
110
+ chain_ids: nextChainIds,
111
+ updated_at: new Date().toISOString(),
112
+ };
113
+
114
+ mkdirSync(getMissionsDir(root), { recursive: true });
115
+ writeFileSync(join(getMissionsDir(root), `${updated.mission_id}.json`), JSON.stringify(updated, null, 2));
116
+ return { ok: true, mission: updated, chain };
117
+ }
118
+
119
+ export function buildMissionSnapshot(root, missionArtifact) {
120
+ const chainIds = Array.isArray(missionArtifact.chain_ids) ? missionArtifact.chain_ids : [];
121
+ const chains = [];
122
+ const missingChainIds = [];
123
+
124
+ for (const chainId of chainIds) {
125
+ const report = loadChainReport(root, chainId);
126
+ if (report) {
127
+ chains.push(report);
128
+ } else {
129
+ missingChainIds.push(chainId);
130
+ }
131
+ }
132
+
133
+ chains.sort((left, right) => {
134
+ const leftTime = new Date(left.started_at || 0).getTime();
135
+ const rightTime = new Date(right.started_at || 0).getTime();
136
+ return rightTime - leftTime;
137
+ });
138
+
139
+ const totalRuns = chains.reduce((sum, chain) => sum + (chain.runs?.length || 0), 0);
140
+ const totalTurns = chains.reduce((sum, chain) => sum + (chain.total_turns || 0), 0);
141
+ const latestChain = chains[0] || null;
142
+ const activeRepoDecisions = getActiveRepoDecisions(root);
143
+
144
+ return {
145
+ ...missionArtifact,
146
+ derived_status: deriveMissionStatus(missionArtifact, chains, missingChainIds),
147
+ chain_count: chainIds.length,
148
+ attached_chain_count: chains.length,
149
+ missing_chain_ids: missingChainIds,
150
+ total_runs: totalRuns,
151
+ total_turns: totalTurns,
152
+ latest_chain_id: latestChain?.chain_id || null,
153
+ latest_terminal_reason: latestChain?.terminal_reason || null,
154
+ active_repo_decisions_count: activeRepoDecisions.length,
155
+ chains,
156
+ };
157
+ }
158
+
159
+ export function loadAllMissionSnapshots(root) {
160
+ return loadAllMissionArtifacts(root).map((mission) => buildMissionSnapshot(root, mission));
161
+ }
162
+
163
+ function deriveMissionStatus(missionArtifact, chains, missingChainIds) {
164
+ if (missionArtifact.status && missionArtifact.status !== 'active') {
165
+ return missionArtifact.status;
166
+ }
167
+ if (missingChainIds.length > 0) return 'degraded';
168
+ if (chains.length === 0) return 'planned';
169
+ if (chains.some((chain) => (
170
+ MISSION_ATTENTION_TERMINALS.has(chain.terminal_reason)
171
+ || (chain.runs || []).some((run) => MISSION_ATTENTION_RUN_STATUSES.has(run.status))
172
+ ))) {
173
+ return 'needs_attention';
174
+ }
175
+ return 'progressing';
176
+ }
177
+
178
+ export function loadLatestMissionSnapshot(root) {
179
+ const artifact = loadLatestMissionArtifact(root);
180
+ return artifact ? buildMissionSnapshot(root, artifact) : null;
181
+ }
182
+
183
+ export function loadMissionSnapshot(root, missionId) {
184
+ const artifact = loadMissionArtifact(root, missionId);
185
+ return artifact ? buildMissionSnapshot(root, artifact) : null;
186
+ }
187
+
188
+ export function buildMissionListSummary(root, limit = 20) {
189
+ return loadAllMissionSnapshots(root).slice(0, limit);
190
+ }
191
+
192
+ export function loadMissionAttachmentTarget(root, missionId) {
193
+ if (missionId) return loadMissionArtifact(root, missionId);
194
+ return loadLatestMissionArtifact(root);
195
+ }