agentxchain 2.47.0 → 2.48.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.
@@ -106,6 +106,7 @@ import { intakeResolveCommand } from '../src/commands/intake-resolve.js';
106
106
  import { intakeStatusCommand } from '../src/commands/intake-status.js';
107
107
  import { demoCommand } from '../src/commands/demo.js';
108
108
  import { historyCommand } from '../src/commands/history.js';
109
+ import { eventsCommand } from '../src/commands/events.js';
109
110
 
110
111
  const __dirname = dirname(fileURLToPath(import.meta.url));
111
112
  const pkg = JSON.parse(readFileSync(join(__dirname, '..', 'package.json'), 'utf8'));
@@ -267,6 +268,17 @@ program
267
268
  .option('-d, --dir <path>', 'Project directory')
268
269
  .action(historyCommand);
269
270
 
271
+ program
272
+ .command('events')
273
+ .description('Show repo-local run lifecycle events')
274
+ .option('-f, --follow', 'Stream events as they occur')
275
+ .option('-t, --type <type>', 'Filter by event type (comma-separated)')
276
+ .option('--since <timestamp>', 'Show events after ISO-8601 timestamp')
277
+ .option('-j, --json', 'Output raw JSONL')
278
+ .option('-l, --limit <n>', 'Max events to show (default: 50, 0 = all)')
279
+ .option('-d, --dir <path>', 'Project directory')
280
+ .action(eventsCommand);
281
+
270
282
  program
271
283
  .command('validate')
272
284
  .description('Validate project protocol artifacts')
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentxchain",
3
- "version": "2.47.0",
3
+ "version": "2.48.0",
4
4
  "description": "CLI for AgentXchain — governed multi-agent software delivery",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,150 @@
1
+ /**
2
+ * agentxchain events — repo-local run event stream reader.
3
+ *
4
+ * Reads and optionally follows the `.agentxchain/events.jsonl` log,
5
+ * giving operators structured visibility into governed run lifecycle
6
+ * without requiring webhooks or a dashboard.
7
+ */
8
+
9
+ import { resolve } from 'path';
10
+ import { existsSync, watchFile, unwatchFile } from 'fs';
11
+ import { readFileSync } from 'fs';
12
+ import chalk from 'chalk';
13
+ import { readRunEvents, RUN_EVENTS_PATH, VALID_RUN_EVENTS } from '../lib/run-events.js';
14
+
15
+ /**
16
+ * @param {object} opts
17
+ * @param {boolean} [opts.follow] - Stream events as they arrive
18
+ * @param {string} [opts.type] - Comma-separated event types
19
+ * @param {string} [opts.since] - ISO-8601 timestamp filter
20
+ * @param {boolean} [opts.json] - Raw JSONL output
21
+ * @param {number} [opts.limit] - Max events to show (default 50)
22
+ * @param {string} [opts.dir] - Project directory
23
+ */
24
+ export async function eventsCommand(opts) {
25
+ const root = findProjectRoot(opts.dir || process.cwd());
26
+ if (!root) {
27
+ console.error(chalk.red('No AgentXchain project found. Run this inside a governed project.'));
28
+ process.exit(1);
29
+ }
30
+
31
+ const limit = opts.limit != null ? parseInt(opts.limit, 10) : 50;
32
+ const events = readRunEvents(root, {
33
+ type: opts.type,
34
+ since: opts.since,
35
+ limit: limit === 0 ? undefined : limit,
36
+ });
37
+
38
+ if (opts.json) {
39
+ for (const evt of events) {
40
+ console.log(JSON.stringify(evt));
41
+ }
42
+ } else {
43
+ if (events.length === 0 && !opts.follow) {
44
+ console.log(chalk.dim('No events found.'));
45
+ if (opts.type) console.log(chalk.dim(` (filtered by type: ${opts.type})`));
46
+ return;
47
+ }
48
+ for (const evt of events) {
49
+ printEvent(evt);
50
+ }
51
+ }
52
+
53
+ if (opts.follow) {
54
+ return followEvents(root, opts);
55
+ }
56
+ }
57
+
58
+ function printEvent(evt) {
59
+ const ts = evt.timestamp ? new Date(evt.timestamp).toLocaleTimeString() : '—';
60
+ const type = colorEventType(evt.event_type);
61
+ const runId = evt.run_id ? evt.run_id.slice(0, 12) : '—';
62
+ const phase = evt.phase || '—';
63
+ const turnInfo = evt.turn?.role_id ? ` [${evt.turn.role_id}]` : '';
64
+ console.log(`${chalk.dim(ts)} ${type} ${chalk.cyan(runId)} ${phase}${turnInfo}`);
65
+ }
66
+
67
+ function colorEventType(type) {
68
+ const colors = {
69
+ run_started: chalk.green,
70
+ run_completed: chalk.green.bold,
71
+ run_blocked: chalk.red,
72
+ turn_dispatched: chalk.blue,
73
+ turn_accepted: chalk.green,
74
+ turn_rejected: chalk.yellow,
75
+ phase_entered: chalk.magenta,
76
+ escalation_raised: chalk.red.bold,
77
+ escalation_resolved: chalk.green,
78
+ gate_pending: chalk.yellow,
79
+ gate_approved: chalk.green,
80
+ };
81
+ const colorFn = colors[type] || chalk.white;
82
+ return colorFn(pad(type, 22));
83
+ }
84
+
85
+ function pad(str, len) {
86
+ return (str || '').padEnd(len);
87
+ }
88
+
89
+ function followEvents(root, opts) {
90
+ const filePath = resolve(root, RUN_EVENTS_PATH);
91
+ let lastSize = 0;
92
+
93
+ try {
94
+ if (existsSync(filePath)) {
95
+ lastSize = readFileSync(filePath).length;
96
+ }
97
+ } catch {}
98
+
99
+ console.log(chalk.dim('Watching for events... (Ctrl+C to stop)'));
100
+
101
+ return new Promise(() => {
102
+ const checkForNewEvents = () => {
103
+ try {
104
+ if (!existsSync(filePath)) return;
105
+ const content = readFileSync(filePath, 'utf8');
106
+ if (content.length <= lastSize) return;
107
+
108
+ const newContent = content.slice(lastSize);
109
+ lastSize = content.length;
110
+
111
+ const lines = newContent.split('\n').filter(Boolean);
112
+ for (const line of lines) {
113
+ try {
114
+ const evt = JSON.parse(line);
115
+ if (opts.type) {
116
+ const types = new Set(opts.type.split(',').map(t => t.trim()));
117
+ if (!types.has(evt.event_type)) continue;
118
+ }
119
+ if (opts.json) {
120
+ console.log(JSON.stringify(evt));
121
+ } else {
122
+ printEvent(evt);
123
+ }
124
+ } catch {}
125
+ }
126
+ } catch {}
127
+ };
128
+
129
+ watchFile(filePath, { interval: 200 }, checkForNewEvents);
130
+
131
+ process.on('SIGINT', () => {
132
+ unwatchFile(filePath, checkForNewEvents);
133
+ process.exit(0);
134
+ });
135
+ });
136
+ }
137
+
138
+ /**
139
+ * Walk up to find the nearest directory containing agentxchain.json.
140
+ */
141
+ function findProjectRoot(start) {
142
+ let dir = resolve(start);
143
+ while (true) {
144
+ if (existsSync(resolve(dir, 'agentxchain.json'))) return dir;
145
+ if (existsSync(resolve(dir, '.agentxchain', 'state.json'))) return dir;
146
+ const parent = resolve(dir, '..');
147
+ if (parent === dir) return null;
148
+ dir = parent;
149
+ }
150
+ }
package/src/lib/export.js CHANGED
@@ -31,6 +31,7 @@ export const RUN_EXPORT_INCLUDED_ROOTS = [
31
31
  '.agentxchain/hook-annotations.jsonl',
32
32
  '.agentxchain/notification-audit.jsonl',
33
33
  '.agentxchain/run-history.jsonl',
34
+ '.agentxchain/events.jsonl',
34
35
  '.agentxchain/dispatch',
35
36
  '.agentxchain/staging',
36
37
  '.agentxchain/transactions/accept',
@@ -53,6 +54,7 @@ export const RUN_RESTORE_ROOTS = [
53
54
  '.agentxchain/hook-annotations.jsonl',
54
55
  '.agentxchain/notification-audit.jsonl',
55
56
  '.agentxchain/run-history.jsonl',
57
+ '.agentxchain/events.jsonl',
56
58
  '.agentxchain/dispatch',
57
59
  '.agentxchain/staging',
58
60
  '.agentxchain/transactions/accept',
@@ -40,6 +40,7 @@ import { getMaxConcurrentTurns } from './normalized-config.js';
40
40
  import { getTurnStagingResultPath, getTurnStagingDir, getDispatchTurnDir, getReviewArtifactPath } from './turn-paths.js';
41
41
  import { runHooks } from './hook-runner.js';
42
42
  import { emitNotifications } from './notification-runner.js';
43
+ import { emitRunEvent } from './run-events.js';
43
44
  import { writeSessionCheckpoint } from './session-checkpoint.js';
44
45
  import { recordRunHistory } from './run-history.js';
45
46
  import { buildDefaultRunProvenance } from './run-provenance.js';
@@ -1751,6 +1752,12 @@ export function raiseOperatorEscalation(root, config, details) {
1751
1752
  detail,
1752
1753
  recovery_action: recoveryAction,
1753
1754
  }, targetTurn);
1755
+ emitRunEvent(root, 'escalation_raised', {
1756
+ run_id: blocked.state.run_id,
1757
+ phase: blocked.state.phase,
1758
+ status: 'blocked',
1759
+ payload: { source: 'operator', reason },
1760
+ });
1754
1761
 
1755
1762
  return {
1756
1763
  ok: true,
@@ -1794,6 +1801,12 @@ export function reactivateGovernedRun(root, state, details = {}) {
1794
1801
  resolved_via: details.via || 'unknown',
1795
1802
  previous_escalation: state.escalation || null,
1796
1803
  }, state.escalation?.from_turn_id ? getActiveTurns(state)[state.escalation.from_turn_id] || getActiveTurn(state) : getActiveTurn(state));
1804
+ emitRunEvent(details.root || root, 'escalation_resolved', {
1805
+ run_id: nextState.run_id,
1806
+ phase: nextState.phase,
1807
+ status: nextState.status,
1808
+ payload: { resolved_via: details.via || 'unknown' },
1809
+ });
1797
1810
  }
1798
1811
 
1799
1812
  return { ok: true, state: attachLegacyCurrentTurnAlias(nextState) };
@@ -1846,6 +1859,12 @@ export function initializeGovernedRun(root, config, options = {}) {
1846
1859
  };
1847
1860
 
1848
1861
  writeState(root, updatedState);
1862
+ emitRunEvent(root, 'run_started', {
1863
+ run_id: runId,
1864
+ phase: updatedState.phase,
1865
+ status: 'active',
1866
+ payload: { provenance: provenance || {} },
1867
+ });
1849
1868
  return { ok: true, state: attachLegacyCurrentTurnAlias(updatedState) };
1850
1869
  }
1851
1870
 
@@ -2051,6 +2070,13 @@ export function assignGovernedTurn(root, config, roleId) {
2051
2070
 
2052
2071
  writeState(root, updatedState);
2053
2072
 
2073
+ emitRunEvent(root, 'turn_dispatched', {
2074
+ run_id: updatedState.run_id,
2075
+ phase: updatedState.phase,
2076
+ status: updatedState.status,
2077
+ turn: { turn_id: turnId, role_id: roleId },
2078
+ });
2079
+
2054
2080
  // Session checkpoint — non-fatal, written after every successful turn assignment
2055
2081
  writeSessionCheckpoint(root, updatedState, 'turn_assigned', {
2056
2082
  role: roleId,
@@ -3027,6 +3053,14 @@ function _acceptGovernedTurnLocked(root, config, opts) {
3027
3053
  }
3028
3054
  }
3029
3055
 
3056
+ // Emit turn_accepted event to local log.
3057
+ emitRunEvent(root, 'turn_accepted', {
3058
+ run_id: updatedState.run_id,
3059
+ phase: updatedState.phase,
3060
+ status: updatedState.status,
3061
+ turn: { turn_id: currentTurn.turn_id, role_id: currentTurn.assigned_role },
3062
+ });
3063
+
3030
3064
  if (updatedState.status === 'blocked') {
3031
3065
  // DEC-RHTR-SPEC: Record blocked outcome in cross-run history (non-fatal)
3032
3066
  // Covers needs_human, budget:exhausted, and any other non-hook blocked states
@@ -3037,6 +3071,13 @@ function _acceptGovernedTurnLocked(root, config, opts) {
3037
3071
  blockedOn: updatedState.blocked_on,
3038
3072
  recovery: updatedState.blocked_reason?.recovery || null,
3039
3073
  }, currentTurn);
3074
+ emitRunEvent(root, 'run_blocked', {
3075
+ run_id: updatedState.run_id,
3076
+ phase: updatedState.phase,
3077
+ status: 'blocked',
3078
+ turn: { turn_id: currentTurn.turn_id, role_id: currentTurn.assigned_role },
3079
+ payload: { category: updatedState.blocked_reason?.category || 'needs_human' },
3080
+ });
3040
3081
  }
3041
3082
 
3042
3083
  if (updatedState.pending_phase_transition) {
@@ -3046,6 +3087,16 @@ function _acceptGovernedTurnLocked(root, config, opts) {
3046
3087
  gate: updatedState.pending_phase_transition.gate,
3047
3088
  requested_by_turn: updatedState.pending_phase_transition.requested_by_turn,
3048
3089
  }, currentTurn);
3090
+ emitRunEvent(root, 'gate_pending', {
3091
+ run_id: updatedState.run_id,
3092
+ phase: updatedState.phase,
3093
+ status: updatedState.status,
3094
+ payload: {
3095
+ gate_type: 'phase_transition',
3096
+ from: updatedState.pending_phase_transition.from,
3097
+ to: updatedState.pending_phase_transition.to,
3098
+ },
3099
+ });
3049
3100
  }
3050
3101
 
3051
3102
  if (updatedState.pending_run_completion) {
@@ -3054,6 +3105,12 @@ function _acceptGovernedTurnLocked(root, config, opts) {
3054
3105
  requested_by_turn: updatedState.pending_run_completion.requested_by_turn,
3055
3106
  requested_at: updatedState.pending_run_completion.requested_at,
3056
3107
  }, currentTurn);
3108
+ emitRunEvent(root, 'gate_pending', {
3109
+ run_id: updatedState.run_id,
3110
+ phase: updatedState.phase,
3111
+ status: updatedState.status,
3112
+ payload: { gate_type: 'run_completion' },
3113
+ });
3057
3114
  }
3058
3115
 
3059
3116
  if (updatedState.status === 'completed') {
@@ -3062,6 +3119,12 @@ function _acceptGovernedTurnLocked(root, config, opts) {
3062
3119
  completed_via: completionResult?.action === 'complete' ? 'accept_turn' : 'accept_turn_direct',
3063
3120
  requested_by_turn: completionResult?.requested_by_turn || turnResult.turn_id,
3064
3121
  }, currentTurn);
3122
+ emitRunEvent(root, 'run_completed', {
3123
+ run_id: updatedState.run_id,
3124
+ phase: updatedState.phase,
3125
+ status: 'completed',
3126
+ payload: { completed_at: updatedState.completed_at || now },
3127
+ });
3065
3128
  }
3066
3129
 
3067
3130
  // Session checkpoint — non-fatal, written after every successful acceptance
@@ -3211,6 +3274,13 @@ export function rejectGovernedTurn(root, config, validationResult, reasonOrOptio
3211
3274
  };
3212
3275
 
3213
3276
  writeState(root, updatedState);
3277
+ emitRunEvent(root, 'turn_rejected', {
3278
+ run_id: updatedState.run_id,
3279
+ phase: updatedState.phase,
3280
+ status: updatedState.status,
3281
+ turn: { turn_id: currentTurn.turn_id, role_id: currentTurn.assigned_role },
3282
+ payload: { attempt: currentAttempt, retrying: true },
3283
+ });
3214
3284
  return {
3215
3285
  ok: true,
3216
3286
  state: attachLegacyCurrentTurnAlias(updatedState),
@@ -3271,6 +3341,19 @@ export function rejectGovernedTurn(root, config, validationResult, reasonOrOptio
3271
3341
  blockedOn: updatedState.blocked_on,
3272
3342
  recovery: updatedState.blocked_reason?.recovery || null,
3273
3343
  }, updatedState.active_turns[currentTurn.turn_id]);
3344
+ emitRunEvent(root, 'turn_rejected', {
3345
+ run_id: updatedState.run_id,
3346
+ phase: updatedState.phase,
3347
+ status: 'blocked',
3348
+ turn: { turn_id: currentTurn.turn_id, role_id: currentTurn.assigned_role },
3349
+ payload: { attempt: currentAttempt, retrying: false, escalated: true },
3350
+ });
3351
+ emitRunEvent(root, 'run_blocked', {
3352
+ run_id: updatedState.run_id,
3353
+ phase: updatedState.phase,
3354
+ status: 'blocked',
3355
+ payload: { category: 'retries_exhausted' },
3356
+ });
3274
3357
 
3275
3358
  // Fire on_escalation hooks (advisory-only) after blocked state is persisted.
3276
3359
  const hooksConfig = config?.hooks || {};
@@ -3382,6 +3465,18 @@ export function approvePhaseTransition(root, config) {
3382
3465
  };
3383
3466
 
3384
3467
  writeState(root, updatedState);
3468
+ emitRunEvent(root, 'gate_approved', {
3469
+ run_id: updatedState.run_id,
3470
+ phase: updatedState.phase,
3471
+ status: 'active',
3472
+ payload: { gate_type: 'phase_transition', from: transition.from, to: transition.to },
3473
+ });
3474
+ emitRunEvent(root, 'phase_entered', {
3475
+ run_id: updatedState.run_id,
3476
+ phase: updatedState.phase,
3477
+ status: 'active',
3478
+ payload: { from: transition.from },
3479
+ });
3385
3480
 
3386
3481
  // Session checkpoint — non-fatal
3387
3482
  writeSessionCheckpoint(root, updatedState, 'phase_approved');
@@ -3484,6 +3579,18 @@ export function approveRunCompletion(root, config) {
3484
3579
  gate: completion.gate,
3485
3580
  requested_by_turn: completion.requested_by_turn || null,
3486
3581
  }, completion.requested_by_turn ? getActiveTurns(state)[completion.requested_by_turn] || null : null);
3582
+ emitRunEvent(root, 'gate_approved', {
3583
+ run_id: updatedState.run_id,
3584
+ phase: updatedState.phase,
3585
+ status: 'completed',
3586
+ payload: { gate_type: 'run_completion' },
3587
+ });
3588
+ emitRunEvent(root, 'run_completed', {
3589
+ run_id: updatedState.run_id,
3590
+ phase: updatedState.phase,
3591
+ status: 'completed',
3592
+ payload: { completed_at: updatedState.completed_at },
3593
+ });
3487
3594
 
3488
3595
  // Session checkpoint — non-fatal
3489
3596
  writeSessionCheckpoint(root, updatedState, 'run_completed');
@@ -41,6 +41,8 @@ const ORCHESTRATOR_STATE_FILES = [
41
41
  '.agentxchain/hook-audit.jsonl',
42
42
  '.agentxchain/hook-annotations.jsonl',
43
43
  '.agentxchain/run-history.jsonl',
44
+ '.agentxchain/events.jsonl',
45
+ '.agentxchain/notification-audit.jsonl',
44
46
  'TALK.md',
45
47
  ];
46
48
 
@@ -0,0 +1,117 @@
1
+ /**
2
+ * run-events.js — Repo-local structured event log for governed runs.
3
+ *
4
+ * Appends lifecycle events to `.agentxchain/events.jsonl` so operators
5
+ * can observe run progress without webhooks or dashboard.
6
+ */
7
+
8
+ import { appendFileSync, readFileSync, existsSync, mkdirSync } from 'node:fs';
9
+ import { join, dirname } from 'node:path';
10
+ import { randomBytes } from 'node:crypto';
11
+
12
+ export const RUN_EVENTS_PATH = '.agentxchain/events.jsonl';
13
+
14
+ export const VALID_RUN_EVENTS = [
15
+ 'run_started',
16
+ 'phase_entered',
17
+ 'turn_dispatched',
18
+ 'turn_accepted',
19
+ 'turn_rejected',
20
+ 'run_blocked',
21
+ 'run_completed',
22
+ 'escalation_raised',
23
+ 'escalation_resolved',
24
+ 'gate_pending',
25
+ 'gate_approved',
26
+ ];
27
+
28
+ /**
29
+ * Emit a structured lifecycle event to the local event log.
30
+ *
31
+ * @param {string} root - Project root directory
32
+ * @param {string} eventType - One of VALID_RUN_EVENTS
33
+ * @param {object} details - Event details
34
+ * @param {string} [details.run_id] - Current run ID
35
+ * @param {string} [details.phase] - Current phase
36
+ * @param {string} [details.status] - Current run status
37
+ * @param {object} [details.turn] - Turn context (turn_id, role_id, etc.)
38
+ * @param {object} [details.payload] - Additional event-specific data
39
+ * @returns {{ ok: boolean, event_id: string }}
40
+ */
41
+ export function emitRunEvent(root, eventType, details = {}) {
42
+ const event_id = `evt_${randomBytes(8).toString('hex')}`;
43
+ const entry = {
44
+ event_id,
45
+ event_type: eventType,
46
+ timestamp: new Date().toISOString(),
47
+ run_id: details.run_id || null,
48
+ phase: details.phase || null,
49
+ status: details.status || null,
50
+ turn: details.turn || null,
51
+ payload: details.payload || {},
52
+ };
53
+
54
+ try {
55
+ const filePath = join(root, RUN_EVENTS_PATH);
56
+ const dir = dirname(filePath);
57
+ if (!existsSync(dir)) {
58
+ mkdirSync(dir, { recursive: true });
59
+ }
60
+ appendFileSync(filePath, `${JSON.stringify(entry)}\n`);
61
+ return { ok: true, event_id };
62
+ } catch (err) {
63
+ // Best-effort — never interrupt governed operations for event logging.
64
+ if (process.env.AGENTXCHAIN_DEBUG) {
65
+ process.stderr.write(`[run-events] write failed: ${err.message}\n`);
66
+ }
67
+ return { ok: false, event_id };
68
+ }
69
+ }
70
+
71
+ /**
72
+ * Read events from the local event log.
73
+ *
74
+ * @param {string} root - Project root directory
75
+ * @param {object} [opts] - Filter options
76
+ * @param {string} [opts.type] - Comma-separated event types to include
77
+ * @param {string} [opts.since] - ISO-8601 timestamp; only events after this
78
+ * @param {number} [opts.limit] - Max events to return (from end of file)
79
+ * @returns {object[]}
80
+ */
81
+ export function readRunEvents(root, opts = {}) {
82
+ const filePath = join(root, RUN_EVENTS_PATH);
83
+ if (!existsSync(filePath)) return [];
84
+
85
+ const raw = readFileSync(filePath, 'utf8');
86
+ const lines = raw.split('\n').filter(Boolean);
87
+
88
+ let events = [];
89
+ for (const line of lines) {
90
+ try {
91
+ events.push(JSON.parse(line));
92
+ } catch {
93
+ // Skip malformed lines.
94
+ }
95
+ }
96
+
97
+ // Apply type filter.
98
+ if (opts.type) {
99
+ const types = new Set(opts.type.split(',').map(t => t.trim()));
100
+ events = events.filter(e => types.has(e.event_type));
101
+ }
102
+
103
+ // Apply since filter.
104
+ if (opts.since) {
105
+ const sinceMs = new Date(opts.since).getTime();
106
+ if (!Number.isNaN(sinceMs)) {
107
+ events = events.filter(e => new Date(e.timestamp).getTime() > sinceMs);
108
+ }
109
+ }
110
+
111
+ // Apply limit (from end).
112
+ if (opts.limit && opts.limit > 0 && events.length > opts.limit) {
113
+ events = events.slice(-opts.limit);
114
+ }
115
+
116
+ return events;
117
+ }