agentxchain 2.111.0 → 2.113.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.
@@ -122,6 +122,7 @@ import { eventsCommand } from '../src/commands/events.js';
122
122
  import { connectorCheckCommand } from '../src/commands/connector.js';
123
123
  import { scheduleDaemonCommand, scheduleListCommand, scheduleRunDueCommand, scheduleStatusCommand } from '../src/commands/schedule.js';
124
124
  import { chainLatestCommand, chainListCommand, chainShowCommand } from '../src/commands/chain.js';
125
+ import { missionAttachChainCommand, missionListCommand, missionPlanApproveCommand, missionPlanCommand, missionPlanLaunchCommand, missionPlanListCommand, missionPlanShowCommand, missionShowCommand, missionStartCommand } from '../src/commands/mission.js';
125
126
 
126
127
  const __dirname = dirname(fileURLToPath(import.meta.url));
127
128
  const pkg = JSON.parse(readFileSync(join(__dirname, '..', 'package.json'), 'utf8'));
@@ -419,6 +420,90 @@ chainCmd
419
420
  .option('-d, --dir <path>', 'Project directory')
420
421
  .action(chainShowCommand);
421
422
 
423
+ const missionCmd = program
424
+ .command('mission')
425
+ .description('Group chained runs under a single-repo long-horizon mission');
426
+
427
+ missionCmd
428
+ .command('start')
429
+ .description('Create a durable mission artifact for a single repo')
430
+ .requiredOption('--title <text>', 'Mission title')
431
+ .requiredOption('--goal <text>', 'Mission goal')
432
+ .option('--id <mission_id>', 'Override the derived mission ID')
433
+ .option('-j, --json', 'Output as JSON')
434
+ .option('-d, --dir <path>', 'Project directory')
435
+ .action(missionStartCommand);
436
+
437
+ missionCmd
438
+ .command('list')
439
+ .description('List mission artifacts newest first')
440
+ .option('-l, --limit <n>', 'Max missions to show (default: 20)')
441
+ .option('-j, --json', 'Output as JSON')
442
+ .option('-d, --dir <path>', 'Project directory')
443
+ .action(missionListCommand);
444
+
445
+ missionCmd
446
+ .command('show [mission_id]')
447
+ .description('Show one mission, or the latest mission when no ID is provided')
448
+ .option('-j, --json', 'Output as JSON')
449
+ .option('-d, --dir <path>', 'Project directory')
450
+ .action(missionShowCommand);
451
+
452
+ missionCmd
453
+ .command('attach-chain [chain_id]')
454
+ .description('Attach a chain report to a mission (default: latest chain on latest mission)')
455
+ .option('-m, --mission <mission_id>', 'Explicit mission ID (defaults to latest mission)')
456
+ .option('-j, --json', 'Output as JSON')
457
+ .option('-d, --dir <path>', 'Project directory')
458
+ .action(missionAttachChainCommand);
459
+
460
+ const missionPlanCmd = missionCmd
461
+ .command('plan [mission_id]')
462
+ .description('Generate a decomposition plan for a mission (default: latest mission)')
463
+ .option('--constraint <text>', 'Add a constraint to the planner (repeatable)', collectOption, [])
464
+ .option('--role-hint <role>', 'Hint available roles to the planner (repeatable)', collectOption, [])
465
+ .option('-j, --json', 'Output as JSON')
466
+ .option('-d, --dir <path>', 'Project directory')
467
+ .action(missionPlanCommand);
468
+
469
+ missionPlanCmd
470
+ .command('show [plan_id]')
471
+ .description('Show a decomposition plan (default: latest plan)')
472
+ .option('-m, --mission <mission_id>', 'Explicit mission ID')
473
+ .option('-j, --json', 'Output as JSON')
474
+ .option('-d, --dir <path>', 'Project directory')
475
+ .action(missionPlanShowCommand);
476
+
477
+ missionPlanCmd
478
+ .command('approve [plan_id]')
479
+ .description('Approve a decomposition plan (default: latest plan)')
480
+ .option('-m, --mission <mission_id>', 'Explicit mission ID')
481
+ .option('-d, --dir <path>', 'Project directory')
482
+ .action(missionPlanApproveCommand);
483
+
484
+ missionPlanCmd
485
+ .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')
488
+ .option('-m, --mission <mission_id>', 'Explicit mission ID')
489
+ .option('--auto-approve', 'Auto-approve run gates while executing the launched workstream')
490
+ .option('-j, --json', 'Output as JSON')
491
+ .option('-d, --dir <path>', 'Project directory')
492
+ .action(missionPlanLaunchCommand);
493
+
494
+ missionPlanCmd
495
+ .command('list')
496
+ .description('List all plans for a mission')
497
+ .option('-m, --mission <mission_id>', 'Explicit mission ID')
498
+ .option('-l, --limit <n>', 'Max plans to show (default: 20)')
499
+ .option('-j, --json', 'Output as JSON')
500
+ .option('-d, --dir <path>', 'Project directory')
501
+ .action(missionPlanListCommand);
502
+
503
+ function collectOption(value, previous) {
504
+ return previous.concat([value]);
505
+ }
506
+
422
507
  program
423
508
  .command('validate')
424
509
  .description('Validate project protocol artifacts')
@@ -547,6 +632,7 @@ program
547
632
  .option('--max-chains <n>', 'Maximum continuation runs in chain mode (default: 5)', parseInt)
548
633
  .option('--chain-on <statuses>', 'Comma-separated terminal statuses that trigger chaining (default: completed)')
549
634
  .option('--chain-cooldown <seconds>', 'Seconds to wait between chained runs (default: 5)', parseInt)
635
+ .option('--mission <mission_id>', 'Bind chained runs to a mission (use "latest" for most recent mission)')
550
636
  .action(runCommand);
551
637
 
552
638
  program
package/dashboard/app.js CHANGED
@@ -15,6 +15,7 @@ import { render as renderCrossRepo } from './components/cross-repo.js';
15
15
  import { render as renderDelegations } from './components/delegations.js';
16
16
  import { render as renderBlockers } from './components/blockers.js';
17
17
  import { render as renderArtifacts } from './components/artifacts.js';
18
+ import { render as renderMission } from './components/mission.js';
18
19
  import { render as renderChain } from './components/chain.js';
19
20
  import { render as renderRunHistory } from './components/run-history.js';
20
21
  import { render as renderTimeouts } from './components/timeouts.js';
@@ -36,6 +37,7 @@ const VIEWS = {
36
37
  'cross-repo': { fetch: ['coordinatorState', 'coordinatorHistory'], render: renderCrossRepo },
37
38
  blockers: { fetch: ['coordinatorBlockers'], render: renderBlockers },
38
39
  artifacts: { fetch: ['workflowKitArtifacts'], render: renderArtifacts },
40
+ mission: { fetch: ['missions', 'plans'], render: renderMission },
39
41
  chain: { fetch: ['chainReports'], render: renderChain },
40
42
  'run-history': { fetch: ['runHistory'], render: renderRunHistory },
41
43
  timeouts: { fetch: ['timeouts'], render: renderTimeouts },
@@ -60,6 +62,8 @@ const API_MAP = {
60
62
  coordinatorBlockers: '/api/coordinator/blockers',
61
63
  coordinatorRepoStatusRows: '/api/coordinator/repo-status',
62
64
  workflowKitArtifacts: '/api/workflow-kit-artifacts',
65
+ missions: '/api/missions',
66
+ plans: '/api/plans',
63
67
  chainReports: '/api/chain-reports',
64
68
  connectors: '/api/connectors',
65
69
  runHistory: '/api/run-history',
@@ -0,0 +1,356 @@
1
+ function esc(str) {
2
+ if (str == null) return '';
3
+ return String(str)
4
+ .replace(/&/g, '&amp;')
5
+ .replace(/</g, '&lt;')
6
+ .replace(/>/g, '&gt;')
7
+ .replace(/"/g, '&quot;')
8
+ .replace(/'/g, '&#39;');
9
+ }
10
+
11
+ function badge(label, color = 'var(--text-dim)') {
12
+ return `<span class="badge" style="color:${color};border-color:${color}">${esc(label)}</span>`;
13
+ }
14
+
15
+ function formatMissionStatus(status) {
16
+ switch (status) {
17
+ case 'progressing':
18
+ return badge('progressing', 'var(--green)');
19
+ case 'planned':
20
+ return badge('planned', '#38bdf8');
21
+ case 'needs_attention':
22
+ return badge('needs attention', 'var(--yellow)');
23
+ case 'degraded':
24
+ return badge('degraded', 'var(--red)');
25
+ default:
26
+ return badge(status || 'unknown', 'var(--text-dim)');
27
+ }
28
+ }
29
+
30
+ function formatTerminalReason(reason) {
31
+ switch (reason) {
32
+ case 'chain_limit_reached':
33
+ return badge('chain limit reached', '#38bdf8');
34
+ case 'non_chainable_status':
35
+ return badge('non-chainable status', 'var(--yellow)');
36
+ case 'operator_abort':
37
+ return badge('operator abort', 'var(--red)');
38
+ case 'parent_validation_failed':
39
+ return badge('parent validation failed', 'var(--red)');
40
+ case 'completed':
41
+ return badge('completed', 'var(--green)');
42
+ default:
43
+ return badge(reason || 'none', 'var(--text-dim)');
44
+ }
45
+ }
46
+
47
+ function formatDate(iso) {
48
+ if (!iso) return '—';
49
+ try {
50
+ const date = new Date(iso);
51
+ return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric' })
52
+ + ' ' + date.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' });
53
+ } catch {
54
+ return esc(iso);
55
+ }
56
+ }
57
+
58
+ function truncateId(value, len = 14) {
59
+ if (!value) return '—';
60
+ return value.length > len ? `${value.slice(0, len)}…` : value;
61
+ }
62
+
63
+ function renderAttachedChains(latest) {
64
+ if (!Array.isArray(latest?.chains) || latest.chains.length === 0) {
65
+ return `<div class="section"><h3>Attached Chains</h3><p class="section-subtitle">No chain reports are attached to this mission yet.</p></div>`;
66
+ }
67
+
68
+ let html = `<div class="section"><h3>Attached Chains</h3>
69
+ <table class="data-table">
70
+ <thead>
71
+ <tr>
72
+ <th>#</th>
73
+ <th>Chain ID</th>
74
+ <th>Runs</th>
75
+ <th>Turns</th>
76
+ <th>Terminal</th>
77
+ <th>Started</th>
78
+ </tr>
79
+ </thead>
80
+ <tbody>`;
81
+
82
+ latest.chains.forEach((chain, index) => {
83
+ html += `<tr>
84
+ <td style="color:var(--text-dim)">${index + 1}</td>
85
+ <td class="mono" title="${esc(chain.chain_id || '')}">${esc(truncateId(chain.chain_id))}</td>
86
+ <td>${chain.runs?.length || 0}</td>
87
+ <td>${chain.total_turns ?? '—'}</td>
88
+ <td>${formatTerminalReason(chain.terminal_reason)}</td>
89
+ <td>${formatDate(chain.started_at)}</td>
90
+ </tr>`;
91
+ });
92
+
93
+ html += '</tbody></table></div>';
94
+ return html;
95
+ }
96
+
97
+ function renderRecentMissions(missions) {
98
+ let html = `<div class="section"><h3>Recent Missions</h3>
99
+ <table class="data-table">
100
+ <thead>
101
+ <tr>
102
+ <th>#</th>
103
+ <th>Mission ID</th>
104
+ <th>Status</th>
105
+ <th>Chains</th>
106
+ <th>Runs</th>
107
+ <th>Turns</th>
108
+ <th>Repo Decisions</th>
109
+ <th>Latest Terminal</th>
110
+ <th>Updated</th>
111
+ <th>Title</th>
112
+ </tr>
113
+ </thead>
114
+ <tbody>`;
115
+
116
+ missions.forEach((mission, index) => {
117
+ html += `<tr>
118
+ <td style="color:var(--text-dim)">${index + 1}</td>
119
+ <td class="mono" title="${esc(mission.mission_id || '')}">${esc(truncateId(mission.mission_id, 18))}</td>
120
+ <td>${formatMissionStatus(mission.derived_status)}</td>
121
+ <td>${mission.chain_count ?? 0}</td>
122
+ <td>${mission.total_runs ?? 0}</td>
123
+ <td>${mission.total_turns ?? 0}</td>
124
+ <td>${mission.active_repo_decisions_count ?? 0}</td>
125
+ <td>${formatTerminalReason(mission.latest_terminal_reason)}</td>
126
+ <td>${formatDate(mission.updated_at || mission.created_at)}</td>
127
+ <td>${esc(mission.title || '—')}</td>
128
+ </tr>`;
129
+ });
130
+
131
+ html += '</tbody></table></div>';
132
+ return html;
133
+ }
134
+
135
+ function formatPlanStatus(status) {
136
+ switch (status) {
137
+ case 'proposed':
138
+ return badge('proposed', '#38bdf8');
139
+ case 'approved':
140
+ return badge('approved', 'var(--green)');
141
+ case 'superseded':
142
+ return badge('superseded', 'var(--text-dim)');
143
+ case 'needs_attention':
144
+ return badge('needs attention', 'var(--yellow)');
145
+ default:
146
+ return badge(status || 'unknown', 'var(--text-dim)');
147
+ }
148
+ }
149
+
150
+ function formatLaunchStatus(status) {
151
+ switch (status) {
152
+ case 'ready':
153
+ return badge('ready', 'var(--green)');
154
+ case 'blocked':
155
+ return badge('blocked', 'var(--red)');
156
+ case 'launched':
157
+ return badge('launched', '#38bdf8');
158
+ case 'completed':
159
+ return badge('completed', 'var(--green)');
160
+ case 'needs_attention':
161
+ return badge('needs attention', 'var(--yellow)');
162
+ default:
163
+ return badge(status || 'unknown', 'var(--text-dim)');
164
+ }
165
+ }
166
+
167
+ function renderLatestPlan(plan) {
168
+ if (!plan) {
169
+ return `<div class="section"><h3>Latest Plan</h3><p class="section-subtitle">No decomposition plan found. Run <code>agentxchain mission plan &lt;mission_id&gt;</code> to generate one.</p></div>`;
170
+ }
171
+
172
+ const workstreams = Array.isArray(plan.workstreams) ? plan.workstreams : [];
173
+ const launchRecords = Array.isArray(plan.launch_records) ? plan.launch_records : [];
174
+ const statusCounts = plan.workstream_status_counts || {};
175
+
176
+ let html = `<div class="section"><h3>Latest Plan</h3><dl class="detail-list">`;
177
+ html += `<dt>Plan ID</dt><dd class="mono" title="${esc(plan.plan_id || '')}">${esc(truncateId(plan.plan_id, 30))}</dd>`;
178
+ html += `<dt>Status</dt><dd>${formatPlanStatus(plan.status)}</dd>`;
179
+ html += `<dt>Mission</dt><dd class="mono">${esc(plan.mission_id || '—')}</dd>`;
180
+ html += `<dt>Created</dt><dd>${formatDate(plan.created_at)}</dd>`;
181
+ if (plan.approved_at) {
182
+ html += `<dt>Approved</dt><dd>${formatDate(plan.approved_at)}</dd>`;
183
+ }
184
+ html += `<dt>Workstreams</dt><dd>${plan.workstream_count || 0}</dd>`;
185
+ html += `<dt>Launched</dt><dd>${plan.launch_record_count || 0}</dd>`;
186
+ if (Object.keys(statusCounts).length > 0) {
187
+ const countsStr = Object.entries(statusCounts).map(([k, v]) => `${k}: ${v}`).join(', ');
188
+ html += `<dt>Status Breakdown</dt><dd>${esc(countsStr)}</dd>`;
189
+ }
190
+ if (plan.supersedes_plan_id) {
191
+ html += `<dt>Supersedes</dt><dd class="mono">${esc(truncateId(plan.supersedes_plan_id, 30))}</dd>`;
192
+ }
193
+ html += `</dl></div>`;
194
+
195
+ // Workstreams table
196
+ if (workstreams.length > 0) {
197
+ html += `<div class="section"><h3>Workstreams</h3>
198
+ <table class="data-table">
199
+ <thead>
200
+ <tr>
201
+ <th>#</th>
202
+ <th>ID</th>
203
+ <th>Title</th>
204
+ <th>Status</th>
205
+ <th>Roles</th>
206
+ <th>Phases</th>
207
+ <th>Dependencies</th>
208
+ </tr>
209
+ </thead>
210
+ <tbody>`;
211
+
212
+ workstreams.forEach((ws, index) => {
213
+ const roles = Array.isArray(ws.roles) ? ws.roles.join(', ') : '—';
214
+ const phases = Array.isArray(ws.phases) ? ws.phases.join(', ') : '—';
215
+ const deps = Array.isArray(ws.depends_on) && ws.depends_on.length > 0 ? ws.depends_on.join(', ') : 'none';
216
+
217
+ html += `<tr>
218
+ <td style="color:var(--text-dim)">${index + 1}</td>
219
+ <td class="mono">${esc(ws.workstream_id || '—')}</td>
220
+ <td>${esc(ws.title || '—')}</td>
221
+ <td>${formatLaunchStatus(ws.launch_status)}</td>
222
+ <td>${esc(roles)}</td>
223
+ <td>${esc(phases)}</td>
224
+ <td class="mono">${esc(deps)}</td>
225
+ </tr>`;
226
+ });
227
+
228
+ html += '</tbody></table></div>';
229
+ }
230
+
231
+ // Launch records table
232
+ if (launchRecords.length > 0) {
233
+ html += `<div class="section"><h3>Launch Records</h3>
234
+ <table class="data-table">
235
+ <thead>
236
+ <tr>
237
+ <th>#</th>
238
+ <th>Workstream</th>
239
+ <th>Chain ID</th>
240
+ <th>Status</th>
241
+ <th>Terminal</th>
242
+ <th>Launched</th>
243
+ <th>Completed</th>
244
+ </tr>
245
+ </thead>
246
+ <tbody>`;
247
+
248
+ launchRecords.forEach((lr, index) => {
249
+ html += `<tr>
250
+ <td style="color:var(--text-dim)">${index + 1}</td>
251
+ <td class="mono">${esc(lr.workstream_id || '—')}</td>
252
+ <td class="mono" title="${esc(lr.chain_id || '')}">${esc(truncateId(lr.chain_id))}</td>
253
+ <td>${formatLaunchStatus(lr.status)}</td>
254
+ <td>${esc(lr.terminal_reason || '—')}</td>
255
+ <td>${formatDate(lr.launched_at)}</td>
256
+ <td>${formatDate(lr.completed_at)}</td>
257
+ </tr>`;
258
+ });
259
+
260
+ html += '</tbody></table></div>';
261
+ }
262
+
263
+ return html;
264
+ }
265
+
266
+ function renderRecentPlans(plans) {
267
+ if (!Array.isArray(plans) || plans.length <= 1) {
268
+ return '';
269
+ }
270
+
271
+ // Skip the first (latest) since it's rendered in detail above
272
+ const olderPlans = plans.slice(1);
273
+ if (olderPlans.length === 0) return '';
274
+
275
+ let html = `<div class="section"><h3>Previous Plans</h3>
276
+ <table class="data-table">
277
+ <thead>
278
+ <tr>
279
+ <th>#</th>
280
+ <th>Plan ID</th>
281
+ <th>Mission</th>
282
+ <th>Status</th>
283
+ <th>Workstreams</th>
284
+ <th>Launched</th>
285
+ <th>Created</th>
286
+ </tr>
287
+ </thead>
288
+ <tbody>`;
289
+
290
+ olderPlans.forEach((plan, index) => {
291
+ html += `<tr>
292
+ <td style="color:var(--text-dim)">${index + 1}</td>
293
+ <td class="mono" title="${esc(plan.plan_id || '')}">${esc(truncateId(plan.plan_id, 20))}</td>
294
+ <td class="mono">${esc(truncateId(plan.mission_id, 18))}</td>
295
+ <td>${formatPlanStatus(plan.status)}</td>
296
+ <td>${plan.workstream_count || 0}</td>
297
+ <td>${plan.launch_record_count || 0}</td>
298
+ <td>${formatDate(plan.created_at)}</td>
299
+ </tr>`;
300
+ });
301
+
302
+ html += '</tbody></table></div>';
303
+ return html;
304
+ }
305
+
306
+ export function render({ missions, plans }) {
307
+ if (!missions || typeof missions !== 'object') {
308
+ return `<div class="placeholder"><h2>Mission</h2><p>No mission data available. Create a mission and bind a chain to populate this view.</p></div>`;
309
+ }
310
+
311
+ const missionList = Array.isArray(missions.missions) ? missions.missions : [];
312
+ const latest = missions.latest || missionList[0] || null;
313
+
314
+ if (!latest || missionList.length === 0) {
315
+ return `<div class="placeholder"><h2>Mission</h2><p>No missions found. Run <code>agentxchain mission start --title "..." --goal "..."</code> and then <code>agentxchain run --chain --mission latest</code> to track long-horizon work here.</p></div>`;
316
+ }
317
+
318
+ const missingChains = Array.isArray(latest.missing_chain_ids) ? latest.missing_chain_ids : [];
319
+
320
+ let html = `<div class="mission-view"><div class="run-header"><div class="run-meta">`;
321
+ html += `<span class="turn-count">latest mission ${esc(latest.mission_id || '—')}</span>`;
322
+ html += formatMissionStatus(latest.derived_status);
323
+ html += badge(`${latest.chain_count || 0} chains`, '#38bdf8');
324
+ html += badge(`${latest.total_turns || 0} turns`, 'var(--green)');
325
+ html += badge(`${latest.active_repo_decisions_count || 0} repo decisions`, 'var(--yellow)');
326
+ html += `</div></div>`;
327
+
328
+ html += `<div class="section"><h3>Latest Mission Summary</h3><dl class="detail-list">`;
329
+ html += `<dt>Title</dt><dd>${esc(latest.title || '—')}</dd>`;
330
+ html += `<dt>Goal</dt><dd>${esc(latest.goal || '—')}</dd>`;
331
+ html += `<dt>Updated</dt><dd>${esc(latest.updated_at || latest.created_at || '—')}</dd>`;
332
+ html += `<dt>Latest Chain</dt><dd class="mono">${esc(latest.latest_chain_id || '—')}</dd>`;
333
+ html += `<dt>Latest Terminal</dt><dd>${esc(latest.latest_terminal_reason || '—')}</dd>`;
334
+ html += `<dt>Total Runs</dt><dd>${latest.total_runs ?? 0}</dd>`;
335
+ html += `<dt>Attached Chains</dt><dd>${latest.attached_chain_count ?? 0}</dd>`;
336
+ html += `<dt>Missing Chains</dt><dd>${missingChains.length}</dd>`;
337
+ html += `<dt>Active Repo Decisions</dt><dd>${latest.active_repo_decisions_count ?? 0}</dd>`;
338
+ html += `</dl></div>`;
339
+
340
+ if (missingChains.length > 0) {
341
+ html += `<div class="section"><h3>Missing Chain References</h3><p class="section-subtitle">This mission is degraded until the missing chain reports are restored.</p><p class="mono">${esc(missingChains.join(', '))}</p></div>`;
342
+ }
343
+
344
+ html += renderAttachedChains(latest);
345
+
346
+ // Plan visibility
347
+ const planData = plans && typeof plans === 'object' ? plans : null;
348
+ const latestPlan = planData?.latest || null;
349
+ const allPlans = Array.isArray(planData?.plans) ? planData.plans : [];
350
+ html += renderLatestPlan(latestPlan);
351
+ html += renderRecentPlans(allPlans);
352
+
353
+ html += renderRecentMissions(missionList);
354
+ html += '</div>';
355
+ return html;
356
+ }
@@ -405,6 +405,7 @@
405
405
  <a href="#gate">Gates</a>
406
406
  <a href="#blockers">Blockers</a>
407
407
  <a href="#artifacts">Artifacts</a>
408
+ <a href="#mission">Mission</a>
408
409
  <a href="#chain">Chain</a>
409
410
  <a href="#run-history">Run History</a>
410
411
  <a href="#timeouts">Timeouts</a>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentxchain",
3
- "version": "2.111.0",
3
+ "version": "2.113.0",
4
4
  "description": "CLI for AgentXchain — governed multi-agent software delivery",
5
5
  "type": "module",
6
6
  "bin": {
@@ -27,6 +27,7 @@
27
27
  "test:node": "node --test test/*.test.js",
28
28
  "preflight:release": "bash scripts/release-preflight.sh",
29
29
  "preflight:release:strict": "bash scripts/release-preflight.sh --strict",
30
+ "check:release-alignment": "node scripts/check-release-alignment.mjs",
30
31
  "postflight:release": "bash scripts/release-postflight.sh",
31
32
  "postflight:downstream": "bash scripts/release-downstream-truth.sh",
32
33
  "bump:release": "bash scripts/release-bump.sh",
@@ -0,0 +1,66 @@
1
+ #!/usr/bin/env node
2
+
3
+ import process from 'node:process';
4
+ import { dirname, join } from 'node:path';
5
+ import { fileURLToPath } from 'node:url';
6
+ import {
7
+ RELEASE_ALIGNMENT_SCOPES,
8
+ validateReleaseAlignment,
9
+ } from '../src/lib/release-alignment.js';
10
+
11
+ const __dirname = dirname(fileURLToPath(import.meta.url));
12
+ const REPO_ROOT = join(__dirname, '..', '..');
13
+
14
+ function usage() {
15
+ console.error('Usage: node cli/scripts/check-release-alignment.mjs [--target-version <semver>] [--scope prebump|current] [--json]');
16
+ }
17
+
18
+ let targetVersion = null;
19
+ let scope = RELEASE_ALIGNMENT_SCOPES.CURRENT;
20
+ let json = false;
21
+
22
+ for (let index = 2; index < process.argv.length; index += 1) {
23
+ const arg = process.argv[index];
24
+ if (arg === '--target-version') {
25
+ targetVersion = process.argv[index + 1] || null;
26
+ if (!targetVersion) {
27
+ console.error('Error: --target-version requires a semver argument');
28
+ usage();
29
+ process.exit(1);
30
+ }
31
+ index += 1;
32
+ continue;
33
+ }
34
+ if (arg === '--scope') {
35
+ scope = process.argv[index + 1] || '';
36
+ if (!Object.values(RELEASE_ALIGNMENT_SCOPES).includes(scope)) {
37
+ console.error(`Error: invalid --scope "${scope}"`);
38
+ usage();
39
+ process.exit(1);
40
+ }
41
+ index += 1;
42
+ continue;
43
+ }
44
+ if (arg === '--json') {
45
+ json = true;
46
+ continue;
47
+ }
48
+ console.error(`Error: unknown argument "${arg}"`);
49
+ usage();
50
+ process.exit(1);
51
+ }
52
+
53
+ const result = validateReleaseAlignment(REPO_ROOT, { targetVersion, scope });
54
+
55
+ if (json) {
56
+ console.log(JSON.stringify(result, null, 2));
57
+ } else if (result.ok) {
58
+ console.log(`Release alignment OK for ${result.targetVersion} (${result.scope}, ${result.checkedSurfaceCount} surfaces).`);
59
+ } else {
60
+ console.error(`Release alignment FAILED for ${result.targetVersion} (${result.scope}, ${result.errors.length} issue(s)).`);
61
+ for (const error of result.errors) {
62
+ console.error(`- [${error.surface_id}] ${error.message}`);
63
+ }
64
+ }
65
+
66
+ process.exit(result.ok ? 0 : 1);
@@ -143,74 +143,23 @@ if git rev-parse "v${TARGET_VERSION}" >/dev/null 2>&1; then
143
143
  fi
144
144
  echo " OK: tag v${TARGET_VERSION} does not exist"
145
145
 
146
- # 4. Pre-bump version-surface alignment guard
147
- # Ensures all governed version surfaces already reference the target version
146
+ # 4. Pre-bump release-alignment guard
147
+ # Ensures all manual target-version surfaces already reference the target version
148
148
  # BEFORE the bump commit is created. This catches stale drift that would
149
149
  # otherwise only be discovered after minting local release identities.
150
150
  #
151
151
  # NOTE: Homebrew mirror formula and README are NOT checked here. They are
152
- # auto-aligned in step 5 because the registry SHA256 is inherently a
153
- # post-publish artifact. See DEC-HOMEBREW-SHA-SPLIT-001.
154
- echo "[4/9] Verifying version-surface alignment for ${TARGET_VERSION}..."
155
- SURFACE_ERRORS=()
156
-
157
- # 4a. CHANGELOG top heading
158
- CHANGELOG_TOP=$(grep -m1 -E '^## [0-9]+\.[0-9]+\.[0-9]+$' "${REPO_ROOT}/cli/CHANGELOG.md" 2>/dev/null | sed 's/^## //' || true)
159
- if [[ "$CHANGELOG_TOP" != "$TARGET_VERSION" ]]; then
160
- SURFACE_ERRORS+=("CHANGELOG.md top heading is '${CHANGELOG_TOP:-missing}', expected '${TARGET_VERSION}'")
161
- fi
162
-
163
- # 4b. Release notes page exists
164
- RELEASE_DOC_ID="v${TARGET_VERSION//./-}"
165
- RELEASE_DOC_PATH="website-v2/docs/releases/${RELEASE_DOC_ID}.mdx"
166
- if [[ ! -f "${REPO_ROOT}/${RELEASE_DOC_PATH}" ]]; then
167
- SURFACE_ERRORS+=("release notes page missing: ${RELEASE_DOC_PATH}")
168
- fi
169
-
170
- # 4c. Docs sidebar auto-generates releases from dirName (release doc existence is sufficient)
171
- if ! grep -q "dirName.*releases" "${REPO_ROOT}/website-v2/sidebars.ts" 2>/dev/null; then
172
- SURFACE_ERRORS+=("sidebars.ts does not auto-generate releases (missing dirName: 'releases')")
173
- fi
174
-
175
- # 4d. Homepage hero badge shows target version
176
- if ! grep -q "v${TARGET_VERSION}" "${REPO_ROOT}/website-v2/src/pages/index.tsx" 2>/dev/null; then
177
- SURFACE_ERRORS+=("homepage index.tsx does not contain 'v${TARGET_VERSION}'")
178
- fi
179
-
180
- # 4e. Conformance capabilities version
181
- CAPS_VERSION=$(node -e "try{console.log(JSON.parse(require('fs').readFileSync('${REPO_ROOT}/.agentxchain-conformance/capabilities.json','utf8')).version)}catch{console.log('missing')}" 2>/dev/null || echo "missing")
182
- if [[ "$CAPS_VERSION" != "$TARGET_VERSION" ]]; then
183
- SURFACE_ERRORS+=("capabilities.json version is '${CAPS_VERSION}', expected '${TARGET_VERSION}'")
184
- fi
185
-
186
- # 4f. Protocol implementor guide example
187
- if ! grep -q "\"version\": \"${TARGET_VERSION}\"" "${REPO_ROOT}/website-v2/docs/protocol-implementor-guide.mdx" 2>/dev/null; then
188
- SURFACE_ERRORS+=("protocol-implementor-guide.mdx does not contain '\"version\": \"${TARGET_VERSION}\"'")
189
- fi
190
-
191
- # 4g. Launch evidence report title
192
- ESCAPED_VERSION="${TARGET_VERSION//./\\.}"
193
- if ! grep -qE "^# Launch Evidence Report — AgentXchain v${ESCAPED_VERSION}" "${REPO_ROOT}/.planning/LAUNCH_EVIDENCE_REPORT.md" 2>/dev/null; then
194
- SURFACE_ERRORS+=("LAUNCH_EVIDENCE_REPORT.md title does not carry v${TARGET_VERSION}")
195
- fi
196
-
197
- # 4h. llms.txt must list the current release notes route
198
- CURRENT_RELEASE_ROUTE="/docs/releases/${RELEASE_DOC_ID}"
199
- if ! grep -q "${CURRENT_RELEASE_ROUTE}" "${REPO_ROOT}/website-v2/static/llms.txt" 2>/dev/null; then
200
- SURFACE_ERRORS+=("website-v2/static/llms.txt does not list '${CURRENT_RELEASE_ROUTE}'")
201
- fi
202
-
203
- # 4i. sitemap.xml is now auto-generated by Docusaurus at build time — no static file check needed
204
-
205
- if [[ "${#SURFACE_ERRORS[@]}" -gt 0 ]]; then
206
- echo "FAIL: ${#SURFACE_ERRORS[@]} version-surface(s) not aligned to ${TARGET_VERSION}:" >&2
207
- printf ' - %s\n' "${SURFACE_ERRORS[@]}" >&2
152
+ # auto-aligned in step 6 because the registry tarball URL is deterministic but
153
+ # the registry SHA256 is inherently post-publish truth.
154
+ echo "[4/10] Verifying release alignment for ${TARGET_VERSION}..."
155
+ if node "${CLI_DIR}/scripts/check-release-alignment.mjs" --target-version "${TARGET_VERSION}" --scope prebump; then
156
+ :
157
+ else
208
158
  echo "" >&2
209
159
  echo "Fix these surfaces before running release-bump. The bump script refuses to" >&2
210
160
  echo "create release identity when governed surfaces are stale." >&2
211
161
  exit 1
212
162
  fi
213
- echo " OK: all 8 governed version surfaces reference ${TARGET_VERSION}"
214
163
 
215
164
  # 5. Normalize release-note sidebar ordering
216
165
  echo "[5/10] Normalizing release-note sidebar positions..."