agentxchain 2.46.2 → 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'));
@@ -262,10 +263,22 @@ program
262
263
  .description('Show cross-run history of governed runs in this project')
263
264
  .option('-j, --json', 'Output as JSON')
264
265
  .option('-l, --limit <n>', 'Number of recent runs to show (default: 20)')
265
- .option('-s, --status <status>', 'Filter by status: completed, blocked, failed')
266
+ .option('-s, --status <status>', 'Filter by status: completed or blocked')
267
+ .option('--lineage <run_id>', 'Show lineage chain for a specific run')
266
268
  .option('-d, --dir <path>', 'Project directory')
267
269
  .action(historyCommand);
268
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
+
269
282
  program
270
283
  .command('validate')
271
284
  .description('Validate project protocol artifacts')
@@ -355,6 +368,8 @@ program
355
368
  .option('--verbose', 'Stream adapter subprocess output')
356
369
  .option('--dry-run', 'Print what would be dispatched without executing')
357
370
  .option('--no-report', 'Suppress automatic governance report after run completes')
371
+ .option('--continue-from <run_id>', 'Continue from a prior terminal run (sets trigger=continuation)')
372
+ .option('--recover-from <run_id>', 'Recover from a prior blocked run (sets trigger=recovery)')
358
373
  .action(runCommand);
359
374
 
360
375
  program
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentxchain",
3
- "version": "2.46.2",
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
+ }
@@ -7,7 +7,8 @@
7
7
  import { resolve } from 'path';
8
8
  import { existsSync, readFileSync } from 'fs';
9
9
  import chalk from 'chalk';
10
- import { queryRunHistory } from '../lib/run-history.js';
10
+ import { queryRunHistory, queryRunLineage } from '../lib/run-history.js';
11
+ import { getRunTriggerLabel, summarizeRunProvenance } from '../lib/run-provenance.js';
11
12
 
12
13
  /**
13
14
  * @param {object} opts - { json?: boolean, limit?: number, status?: string, dir?: string }
@@ -19,6 +20,42 @@ export async function historyCommand(opts) {
19
20
  process.exit(1);
20
21
  }
21
22
 
23
+ // ── Lineage mode ─────────────────────────────────────────────────────────
24
+ if (opts.lineage) {
25
+ const result = queryRunLineage(root, opts.lineage);
26
+ if (!result.ok) {
27
+ console.error(chalk.red(result.error));
28
+ process.exit(1);
29
+ }
30
+
31
+ if (opts.json) {
32
+ console.log(JSON.stringify(result.chain, null, 2));
33
+ return;
34
+ }
35
+
36
+ console.log(chalk.bold(`Run Lineage for ${opts.lineage}:`));
37
+ result.chain.forEach((entry, i) => {
38
+ if (entry.broken_link) {
39
+ const prefix = i === 0 ? ' ' : ' └─ ';
40
+ console.log(chalk.red(`${prefix}[broken link: ${entry.missing_run_id}]`));
41
+ return;
42
+ }
43
+ const runId = (entry.run_id || '—').slice(0, 12);
44
+ const status = formatStatus(entry.status);
45
+ const phases = (entry.phases_completed || []).join(',') || '—';
46
+ const turns = `${entry.total_turns || 0} turns`;
47
+ const cost = entry.total_cost_usd != null ? `$${entry.total_cost_usd.toFixed(2)}` : '';
48
+ const trigger = getRunTriggerLabel(entry.provenance);
49
+ const parentNote = entry.provenance?.parent_run_id
50
+ ? ` from ${entry.provenance.parent_run_id.slice(0, 12)}`
51
+ : '';
52
+ const prefix = i === 0 ? ' ' : ' └─ ';
53
+ console.log(`${prefix}${runId} ${status} ${pad(phases, 20)} ${pad(turns, 10)} ${pad(cost, 8)} (${trigger}${parentNote})`);
54
+ });
55
+ return;
56
+ }
57
+
58
+ // ── Standard history view ────────────────────────────────────────────────
22
59
  const limit = opts.limit ? parseInt(opts.limit, 10) : 20;
23
60
  const entries = queryRunHistory(root, {
24
61
  limit,
@@ -43,6 +80,7 @@ export async function historyCommand(opts) {
43
80
  pad('#', 4),
44
81
  pad('Run ID', 14),
45
82
  pad('Status', 11),
83
+ pad('Trigger', 14),
46
84
  pad('Phases', 8),
47
85
  pad('Turns', 6),
48
86
  pad('Cost', 10),
@@ -57,6 +95,7 @@ export async function historyCommand(opts) {
57
95
  const idx = String(i + 1);
58
96
  const runId = (entry.run_id || '—').slice(0, 12);
59
97
  const status = formatStatus(entry.status);
98
+ const trigger = getRunTriggerLabel(entry.provenance);
60
99
  const phases = String(entry.phases_completed?.length || 0);
61
100
  const turns = String(entry.total_turns || 0);
62
101
  const cost = entry.total_cost_usd != null
@@ -73,6 +112,7 @@ export async function historyCommand(opts) {
73
112
  pad(idx, 4),
74
113
  pad(runId, 14),
75
114
  pad(status, 11),
115
+ pad(trigger, 14),
76
116
  pad(phases, 8),
77
117
  pad(turns, 6),
78
118
  pad(cost, 10),
@@ -20,6 +20,7 @@ import { loadProjectContext, loadProjectState } from '../lib/config.js';
20
20
  import { runLoop } from '../lib/run-loop.js';
21
21
  import { buildRunExport } from '../lib/export.js';
22
22
  import { buildGovernanceReport, formatGovernanceReportMarkdown } from '../lib/report.js';
23
+ import { validateParentRun } from '../lib/run-history.js';
23
24
  import { dispatchApiProxy } from '../lib/adapters/api-proxy-adapter.js';
24
25
  import {
25
26
  dispatchLocalCli,
@@ -56,6 +57,30 @@ export async function runCommand(opts) {
56
57
  process.exit(1);
57
58
  }
58
59
 
60
+ // ── Provenance flag validation ──────────────────────────────────────────
61
+ const continueFrom = opts.continueFrom;
62
+ const recoverFrom = opts.recoverFrom;
63
+
64
+ if (continueFrom && recoverFrom) {
65
+ console.log(chalk.red('Cannot specify both --continue-from and --recover-from'));
66
+ process.exit(1);
67
+ }
68
+
69
+ let provenance = undefined;
70
+ if (continueFrom || recoverFrom) {
71
+ const parentId = continueFrom || recoverFrom;
72
+ const validation = validateParentRun(root, parentId);
73
+ if (!validation.ok) {
74
+ console.log(chalk.red(validation.error));
75
+ process.exit(1);
76
+ }
77
+ provenance = {
78
+ trigger: continueFrom ? 'continuation' : 'recovery',
79
+ parent_run_id: parentId,
80
+ created_by: 'operator',
81
+ };
82
+ }
83
+
59
84
  const maxTurns = opts.maxTurns || 50;
60
85
  const autoApprove = !!opts.autoApprove;
61
86
  const verbose = !!opts.verbose;
@@ -319,7 +344,13 @@ export async function runCommand(opts) {
319
344
  };
320
345
 
321
346
  // ── Execute ─────────────────────────────────────────────────────────────
322
- const result = await runLoop(root, config, callbacks, { maxTurns });
347
+ const runLoopOpts = {
348
+ maxTurns,
349
+ startNewRunFromCompleted: true,
350
+ startNewRunFromBlocked: Boolean(provenance),
351
+ };
352
+ if (provenance) runLoopOpts.provenance = provenance;
353
+ const result = await runLoop(root, config, callbacks, runLoopOpts);
323
354
 
324
355
  // ── Summary ─────────────────────────────────────────────────────────────
325
356
  console.log('');
@@ -6,6 +6,7 @@ import { getContinuityStatus } from '../lib/continuity-status.js';
6
6
  import { getConnectorHealth } from '../lib/connector-health.js';
7
7
  import { deriveWorkflowKitArtifacts } from '../lib/workflow-kit-artifacts.js';
8
8
  import { evaluateTimeouts } from '../lib/timeout-evaluator.js';
9
+ import { summarizeRunProvenance } from '../lib/run-provenance.js';
9
10
 
10
11
  export async function statusCommand(opts) {
11
12
  const context = loadProjectContext();
@@ -90,6 +91,7 @@ function renderGovernedStatus(context, opts) {
90
91
  template: config.template || 'generic',
91
92
  config,
92
93
  state,
94
+ provenance: state?.provenance || null,
93
95
  continuity,
94
96
  connector_health: connectorHealth,
95
97
  workflow_kit_artifacts: workflowKitArtifacts,
@@ -107,6 +109,10 @@ function renderGovernedStatus(context, opts) {
107
109
  console.log(` ${chalk.dim('Template:')} ${config.template || 'generic'}`);
108
110
  console.log(` ${chalk.dim('Phase:')} ${state?.phase ? formatGovernedPhase(state.phase) : chalk.dim('unknown')}`);
109
111
  console.log(` ${chalk.dim('Run:')} ${formatRunStatus(state?.status)}`);
112
+ const provenanceSummary = summarizeRunProvenance(state?.provenance);
113
+ if (provenanceSummary) {
114
+ console.log(` ${chalk.dim('Origin:')} ${chalk.magenta(provenanceSummary)}`);
115
+ }
110
116
  if (state?.accepted_integration_ref) {
111
117
  console.log(` ${chalk.dim('Accepted:')} ${state.accepted_integration_ref}`);
112
118
  }
package/src/lib/export.js CHANGED
@@ -6,6 +6,7 @@ import { join, relative, resolve } from 'node:path';
6
6
  import { loadProjectContext, loadProjectState } from './config.js';
7
7
  import { loadCoordinatorConfig, COORDINATOR_CONFIG_FILE } from './coordinator-config.js';
8
8
  import { loadCoordinatorState } from './coordinator-state.js';
9
+ import { normalizeRunProvenance } from './run-provenance.js';
9
10
 
10
11
  const EXPORT_SCHEMA_VERSION = '0.3';
11
12
 
@@ -30,6 +31,7 @@ export const RUN_EXPORT_INCLUDED_ROOTS = [
30
31
  '.agentxchain/hook-annotations.jsonl',
31
32
  '.agentxchain/notification-audit.jsonl',
32
33
  '.agentxchain/run-history.jsonl',
34
+ '.agentxchain/events.jsonl',
33
35
  '.agentxchain/dispatch',
34
36
  '.agentxchain/staging',
35
37
  '.agentxchain/transactions/accept',
@@ -52,6 +54,7 @@ export const RUN_RESTORE_ROOTS = [
52
54
  '.agentxchain/hook-annotations.jsonl',
53
55
  '.agentxchain/notification-audit.jsonl',
54
56
  '.agentxchain/run-history.jsonl',
57
+ '.agentxchain/events.jsonl',
55
58
  '.agentxchain/dispatch',
56
59
  '.agentxchain/staging',
57
60
  '.agentxchain/transactions/accept',
@@ -296,6 +299,7 @@ export function buildRunExport(startDir = process.cwd()) {
296
299
  run_id: state?.run_id || null,
297
300
  status: state?.status || null,
298
301
  phase: state?.phase || null,
302
+ provenance: normalizeRunProvenance(state?.provenance),
299
303
  active_turn_ids: activeTurns,
300
304
  retained_turn_ids: retainedTurns,
301
305
  history_entries: countJsonl(files, '.agentxchain/history.jsonl'),
@@ -40,8 +40,10 @@ 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';
46
+ import { buildDefaultRunProvenance } from './run-provenance.js';
45
47
 
46
48
  // ── Constants ────────────────────────────────────────────────────────────────
47
49
 
@@ -61,6 +63,48 @@ function generateId(prefix) {
61
63
  return `${prefix}_${randomBytes(8).toString('hex')}`;
62
64
  }
63
65
 
66
+ function getInitialPhase(config) {
67
+ return Object.keys(config?.routing || {})[0] || 'planning';
68
+ }
69
+
70
+ function buildInitialPhaseGateStatus(config) {
71
+ return Object.fromEntries(
72
+ [...new Set(
73
+ Object.values(config?.routing || {})
74
+ .map((route) => route?.exit_gate)
75
+ .filter(Boolean)
76
+ )].map((gateId) => [gateId, 'pending'])
77
+ );
78
+ }
79
+
80
+ function buildFreshIdleStateForNewRun(state, config) {
81
+ return {
82
+ schema_version: state?.schema_version || GOVERNED_SCHEMA_VERSION,
83
+ run_id: null,
84
+ project_id: state?.project_id || config?.project?.id || null,
85
+ status: 'idle',
86
+ phase: getInitialPhase(config),
87
+ accepted_integration_ref: null,
88
+ active_turns: {},
89
+ turn_sequence: 0,
90
+ last_completed_turn_id: null,
91
+ blocked_on: null,
92
+ blocked_reason: null,
93
+ escalation: null,
94
+ pending_phase_transition: null,
95
+ pending_run_completion: null,
96
+ queued_phase_transition: null,
97
+ queued_run_completion: null,
98
+ last_gate_failure: null,
99
+ phase_gate_status: buildInitialPhaseGateStatus(config),
100
+ budget_reservations: {},
101
+ budget_status: {
102
+ spent_usd: 0,
103
+ remaining_usd: config?.budget?.per_run_max_usd ?? null,
104
+ },
105
+ };
106
+ }
107
+
64
108
  function normalizeGateFailure(value) {
65
109
  if (!value || typeof value !== 'object' || Array.isArray(value)) {
66
110
  return null;
@@ -1708,6 +1752,12 @@ export function raiseOperatorEscalation(root, config, details) {
1708
1752
  detail,
1709
1753
  recovery_action: recoveryAction,
1710
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
+ });
1711
1761
 
1712
1762
  return {
1713
1763
  ok: true,
@@ -1751,6 +1801,12 @@ export function reactivateGovernedRun(root, state, details = {}) {
1751
1801
  resolved_via: details.via || 'unknown',
1752
1802
  previous_escalation: state.escalation || null,
1753
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
+ });
1754
1810
  }
1755
1811
 
1756
1812
  return { ok: true, state: attachLegacyCurrentTurnAlias(nextState) };
@@ -1766,21 +1822,27 @@ export function reactivateGovernedRun(root, state, details = {}) {
1766
1822
  * @param {object} config - normalized config
1767
1823
  * @returns {{ ok: boolean, error?: string, state?: object }}
1768
1824
  */
1769
- export function initializeGovernedRun(root, config) {
1770
- const state = readState(root);
1825
+ export function initializeGovernedRun(root, config, options = {}) {
1826
+ let state = readState(root);
1771
1827
  if (!state) {
1772
1828
  return { ok: false, error: 'No governed state.json found' };
1773
1829
  }
1774
- if (state.status === 'completed') {
1830
+ const allowTerminalRestart = options.allow_terminal_restart === true
1831
+ && (state.status === 'completed' || state.status === 'blocked');
1832
+ if (state.status === 'completed' && !allowTerminalRestart) {
1775
1833
  return { ok: false, error: 'Cannot initialize run: this run is already completed. Start a new project or reset state.' };
1776
1834
  }
1777
1835
  const allowBlockedBootstrap = state.status === 'blocked' && state.run_id === null && getActiveTurnCount(state) === 0;
1778
- if (state.status !== 'idle' && state.status !== 'paused' && !allowBlockedBootstrap) {
1836
+ if (state.status !== 'idle' && state.status !== 'paused' && !allowBlockedBootstrap && !allowTerminalRestart) {
1779
1837
  return { ok: false, error: `Cannot initialize run: status is "${state.status}", expected "idle", "paused", or pre-run "blocked"` };
1780
1838
  }
1839
+ if (allowTerminalRestart) {
1840
+ state = buildFreshIdleStateForNewRun(state, config);
1841
+ }
1781
1842
 
1782
1843
  const runId = generateId('run');
1783
1844
  const now = new Date().toISOString();
1845
+ const provenance = buildDefaultRunProvenance(options.provenance);
1784
1846
  const updatedState = {
1785
1847
  ...state,
1786
1848
  run_id: runId,
@@ -1792,10 +1854,17 @@ export function initializeGovernedRun(root, config) {
1792
1854
  budget_status: {
1793
1855
  spent_usd: 0,
1794
1856
  remaining_usd: config.budget?.per_run_max_usd ?? null
1795
- }
1857
+ },
1858
+ provenance,
1796
1859
  };
1797
1860
 
1798
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
+ });
1799
1868
  return { ok: true, state: attachLegacyCurrentTurnAlias(updatedState) };
1800
1869
  }
1801
1870
 
@@ -2001,6 +2070,13 @@ export function assignGovernedTurn(root, config, roleId) {
2001
2070
 
2002
2071
  writeState(root, updatedState);
2003
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
+
2004
2080
  // Session checkpoint — non-fatal, written after every successful turn assignment
2005
2081
  writeSessionCheckpoint(root, updatedState, 'turn_assigned', {
2006
2082
  role: roleId,
@@ -2977,6 +3053,14 @@ function _acceptGovernedTurnLocked(root, config, opts) {
2977
3053
  }
2978
3054
  }
2979
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
+
2980
3064
  if (updatedState.status === 'blocked') {
2981
3065
  // DEC-RHTR-SPEC: Record blocked outcome in cross-run history (non-fatal)
2982
3066
  // Covers needs_human, budget:exhausted, and any other non-hook blocked states
@@ -2987,6 +3071,13 @@ function _acceptGovernedTurnLocked(root, config, opts) {
2987
3071
  blockedOn: updatedState.blocked_on,
2988
3072
  recovery: updatedState.blocked_reason?.recovery || null,
2989
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
+ });
2990
3081
  }
2991
3082
 
2992
3083
  if (updatedState.pending_phase_transition) {
@@ -2996,6 +3087,16 @@ function _acceptGovernedTurnLocked(root, config, opts) {
2996
3087
  gate: updatedState.pending_phase_transition.gate,
2997
3088
  requested_by_turn: updatedState.pending_phase_transition.requested_by_turn,
2998
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
+ });
2999
3100
  }
3000
3101
 
3001
3102
  if (updatedState.pending_run_completion) {
@@ -3004,6 +3105,12 @@ function _acceptGovernedTurnLocked(root, config, opts) {
3004
3105
  requested_by_turn: updatedState.pending_run_completion.requested_by_turn,
3005
3106
  requested_at: updatedState.pending_run_completion.requested_at,
3006
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
+ });
3007
3114
  }
3008
3115
 
3009
3116
  if (updatedState.status === 'completed') {
@@ -3012,6 +3119,12 @@ function _acceptGovernedTurnLocked(root, config, opts) {
3012
3119
  completed_via: completionResult?.action === 'complete' ? 'accept_turn' : 'accept_turn_direct',
3013
3120
  requested_by_turn: completionResult?.requested_by_turn || turnResult.turn_id,
3014
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
+ });
3015
3128
  }
3016
3129
 
3017
3130
  // Session checkpoint — non-fatal, written after every successful acceptance
@@ -3161,6 +3274,13 @@ export function rejectGovernedTurn(root, config, validationResult, reasonOrOptio
3161
3274
  };
3162
3275
 
3163
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
+ });
3164
3284
  return {
3165
3285
  ok: true,
3166
3286
  state: attachLegacyCurrentTurnAlias(updatedState),
@@ -3221,6 +3341,19 @@ export function rejectGovernedTurn(root, config, validationResult, reasonOrOptio
3221
3341
  blockedOn: updatedState.blocked_on,
3222
3342
  recovery: updatedState.blocked_reason?.recovery || null,
3223
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
+ });
3224
3357
 
3225
3358
  // Fire on_escalation hooks (advisory-only) after blocked state is persisted.
3226
3359
  const hooksConfig = config?.hooks || {};
@@ -3332,6 +3465,18 @@ export function approvePhaseTransition(root, config) {
3332
3465
  };
3333
3466
 
3334
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
+ });
3335
3480
 
3336
3481
  // Session checkpoint — non-fatal
3337
3482
  writeSessionCheckpoint(root, updatedState, 'phase_approved');
@@ -3434,6 +3579,18 @@ export function approveRunCompletion(root, config) {
3434
3579
  gate: completion.gate,
3435
3580
  requested_by_turn: completion.requested_by_turn || null,
3436
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
+ });
3437
3594
 
3438
3595
  // Session checkpoint — non-fatal
3439
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
 
package/src/lib/report.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { verifyExportArtifact } from './export-verifier.js';
2
+ import { normalizeRunProvenance, summarizeRunProvenance } from './run-provenance.js';
2
3
 
3
4
  export const GOVERNANCE_REPORT_VERSION = '0.1';
4
5
 
@@ -639,6 +640,7 @@ function buildRunSubject(artifact) {
639
640
  phase: artifact.summary?.phase || null,
640
641
  blocked_on: artifact.state?.blocked_on || null,
641
642
  blocked_reason: artifact.state?.blocked_reason || null,
643
+ provenance: normalizeRunProvenance(artifact.summary?.provenance || artifact.state?.provenance),
642
644
  active_turn_count: activeTurns.length,
643
645
  retained_turn_count: retainedTurns.length,
644
646
  active_turn_ids: activeTurns,
@@ -899,6 +901,9 @@ export function formatGovernanceReportText(report) {
899
901
  if (run.duration_seconds != null) {
900
902
  lines.push(`Duration: ${run.duration_seconds}s`);
901
903
  }
904
+ if (summarizeRunProvenance(run.provenance)) {
905
+ lines.push(`Provenance: ${summarizeRunProvenance(run.provenance)}`);
906
+ }
902
907
 
903
908
  lines.push(
904
909
  `History entries: ${artifacts.history_entries}`,
@@ -1300,6 +1305,9 @@ export function formatGovernanceReportMarkdown(report) {
1300
1305
  if (run.duration_seconds != null) {
1301
1306
  lines.push(`- Duration: \`${run.duration_seconds}s\``);
1302
1307
  }
1308
+ if (summarizeRunProvenance(run.provenance)) {
1309
+ lines.push(`- Provenance: \`${summarizeRunProvenance(run.provenance)}\``);
1310
+ }
1303
1311
 
1304
1312
  lines.push(
1305
1313
  `- History entries: ${artifacts.history_entries}`,
@@ -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
+ }
@@ -9,6 +9,7 @@
9
9
 
10
10
  import { readFileSync, appendFileSync, existsSync, mkdirSync } from 'fs';
11
11
  import { join, dirname } from 'path';
12
+ import { normalizeRunProvenance } from './run-provenance.js';
12
13
 
13
14
  const RUN_HISTORY_PATH = '.agentxchain/run-history.jsonl';
14
15
  const HISTORY_PATH = '.agentxchain/history.jsonl';
@@ -79,6 +80,7 @@ export function recordRunHistory(root, state, config, status) {
79
80
  gate_results: state?.phase_gate_status || {},
80
81
  connector_used: connectorUsed,
81
82
  model_used: modelUsed,
83
+ provenance: normalizeRunProvenance(state?.provenance),
82
84
  recorded_at: new Date().toISOString(),
83
85
  };
84
86
 
@@ -132,6 +134,115 @@ export function queryRunHistory(root, opts = {}) {
132
134
  return entries;
133
135
  }
134
136
 
137
+ /**
138
+ * Walk run lineage backwards from a given run_id via parent_run_id links.
139
+ *
140
+ * Returns an ordered array (oldest ancestor first) of history entries
141
+ * forming the lineage chain. If a parent_run_id references a run not
142
+ * found in history, the chain terminates with a broken_link sentinel.
143
+ *
144
+ * @param {string} root - project root directory
145
+ * @param {string} runId - the run to trace lineage for
146
+ * @returns {{ ok: boolean, chain?: Array<object>, error?: string }}
147
+ */
148
+ export function queryRunLineage(root, runId) {
149
+ const filePath = join(root, RUN_HISTORY_PATH);
150
+ if (!existsSync(filePath)) {
151
+ return { ok: false, error: 'No run history found. Run at least one governed run first.' };
152
+ }
153
+
154
+ let content;
155
+ try {
156
+ content = readFileSync(filePath, 'utf8').trim();
157
+ } catch {
158
+ return { ok: false, error: 'Failed to read run history file.' };
159
+ }
160
+ if (!content) {
161
+ return { ok: false, error: 'No run history found. Run at least one governed run first.' };
162
+ }
163
+
164
+ const entries = content
165
+ .split('\n')
166
+ .filter(Boolean)
167
+ .map(line => { try { return JSON.parse(line); } catch { return null; } })
168
+ .filter(Boolean);
169
+
170
+ // Build lookup by run_id
171
+ const byId = new Map();
172
+ for (const entry of entries) {
173
+ if (entry.run_id) byId.set(entry.run_id, entry);
174
+ }
175
+
176
+ // Find the target entry
177
+ const target = byId.get(runId);
178
+ if (!target) {
179
+ return { ok: false, error: `Run ${runId} not found in run history.` };
180
+ }
181
+
182
+ // Walk backwards collecting ancestors
183
+ const chain = [target];
184
+ let current = target;
185
+ const visited = new Set([runId]);
186
+
187
+ while (current.provenance?.parent_run_id) {
188
+ const parentId = current.provenance.parent_run_id;
189
+ if (visited.has(parentId)) break; // safety: prevent cycles
190
+ visited.add(parentId);
191
+
192
+ const parent = byId.get(parentId);
193
+ if (!parent) {
194
+ chain.unshift({ broken_link: true, missing_run_id: parentId });
195
+ break;
196
+ }
197
+ chain.unshift(parent);
198
+ current = parent;
199
+ }
200
+
201
+ return { ok: true, chain };
202
+ }
203
+
204
+ /**
205
+ * Validate that a run_id exists in history and is in a terminal state.
206
+ * Used by --continue-from and --recover-from flag validation.
207
+ *
208
+ * @param {string} root - project root directory
209
+ * @param {string} runId - the run_id to validate
210
+ * @returns {{ ok: boolean, entry?: object, error?: string }}
211
+ */
212
+ export function validateParentRun(root, runId) {
213
+ const filePath = join(root, RUN_HISTORY_PATH);
214
+ if (!existsSync(filePath)) {
215
+ return { ok: false, error: `Run ${runId} not found in run history` };
216
+ }
217
+
218
+ let content;
219
+ try {
220
+ content = readFileSync(filePath, 'utf8').trim();
221
+ } catch {
222
+ return { ok: false, error: `Run ${runId} not found in run history` };
223
+ }
224
+ if (!content) {
225
+ return { ok: false, error: `Run ${runId} not found in run history` };
226
+ }
227
+
228
+ const entries = content
229
+ .split('\n')
230
+ .filter(Boolean)
231
+ .map(line => { try { return JSON.parse(line); } catch { return null; } })
232
+ .filter(Boolean);
233
+
234
+ const entry = entries.find(e => e.run_id === runId);
235
+ if (!entry) {
236
+ return { ok: false, error: `Run ${runId} not found in run history` };
237
+ }
238
+
239
+ if (!WRITABLE_TERMINAL_STATUSES.has(entry.status)) {
240
+ return { ok: false, error: `Run ${runId} is still active (status: ${entry.status}). Cannot chain from a non-terminal run.` };
241
+ }
242
+
243
+ return { ok: true, entry };
244
+ }
245
+
135
246
  /**
136
247
  * Get the path to the run-history file.
137
248
  */
@@ -38,7 +38,7 @@ const DEFAULT_MAX_TURNS = 50;
38
38
  * @param {string} root - project root directory
39
39
  * @param {object} config - normalized governed config
40
40
  * @param {object} callbacks - { selectRole, dispatch, approveGate, onEvent? }
41
- * @param {object} [options] - { maxTurns?: number }
41
+ * @param {object} [options] - { maxTurns?: number, provenance?: object, startNewRunFromCompleted?: boolean, startNewRunFromBlocked?: boolean }
42
42
  * @returns {Promise<RunLoopResult>}
43
43
  */
44
44
  export async function runLoop(root, config, callbacks, options = {}) {
@@ -58,8 +58,14 @@ export async function runLoop(root, config, callbacks, options = {}) {
58
58
 
59
59
  // ── Initialize if idle ──────────────────────────────────────────────────
60
60
  let state = loadState(root, config);
61
- if (!state || state.status === 'idle') {
62
- const initResult = initRun(root, config);
61
+ const shouldRestartCompleted = state?.status === 'completed' && options.startNewRunFromCompleted === true;
62
+ const shouldRestartBlocked = state?.status === 'blocked' && options.startNewRunFromBlocked === true;
63
+ if (!state || state.status === 'idle' || shouldRestartCompleted || shouldRestartBlocked) {
64
+ const initOpts = options.provenance ? { provenance: options.provenance } : {};
65
+ if (shouldRestartCompleted || shouldRestartBlocked) {
66
+ initOpts.allow_terminal_restart = true;
67
+ }
68
+ const initResult = initRun(root, config, initOpts);
63
69
  if (!initResult.ok) {
64
70
  return makeResult(false, 'init_failed', loadState(root, config), turnsExecuted, turnHistory, gatesApproved, [initResult.error]);
65
71
  }
@@ -0,0 +1,90 @@
1
+ const VALID_TRIGGERS = new Set([
2
+ 'manual',
3
+ 'continuation',
4
+ 'recovery',
5
+ 'intake',
6
+ 'schedule',
7
+ 'coordinator',
8
+ ]);
9
+
10
+ const VALID_CREATORS = new Set([
11
+ 'operator',
12
+ 'coordinator',
13
+ ]);
14
+
15
+ function normalizeString(value) {
16
+ return typeof value === 'string' && value.trim().length > 0 ? value.trim() : null;
17
+ }
18
+
19
+ export function normalizeRunProvenance(value, { fallbackManual = false } = {}) {
20
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
21
+ return fallbackManual ? buildDefaultRunProvenance() : null;
22
+ }
23
+
24
+ const trigger = normalizeString(value.trigger);
25
+ const createdBy = normalizeString(value.created_by);
26
+ const normalized = {
27
+ trigger: VALID_TRIGGERS.has(trigger) ? trigger : (fallbackManual ? 'manual' : null),
28
+ parent_run_id: normalizeString(value.parent_run_id),
29
+ trigger_reason: normalizeString(value.trigger_reason),
30
+ intake_intent_id: normalizeString(value.intake_intent_id),
31
+ created_by: VALID_CREATORS.has(createdBy) ? createdBy : 'operator',
32
+ };
33
+
34
+ if (!normalized.trigger) {
35
+ return fallbackManual ? buildDefaultRunProvenance() : null;
36
+ }
37
+
38
+ return normalized;
39
+ }
40
+
41
+ export function buildDefaultRunProvenance(overrides = {}) {
42
+ return normalizeRunProvenance({
43
+ trigger: 'manual',
44
+ parent_run_id: null,
45
+ trigger_reason: null,
46
+ intake_intent_id: null,
47
+ created_by: 'operator',
48
+ ...overrides,
49
+ }, { fallbackManual: true });
50
+ }
51
+
52
+ export function getRunTriggerLabel(provenance) {
53
+ const normalized = normalizeRunProvenance(provenance);
54
+ return normalized?.trigger || 'legacy';
55
+ }
56
+
57
+ export function summarizeRunProvenance(provenance) {
58
+ const normalized = normalizeRunProvenance(provenance);
59
+ if (!normalized) return null;
60
+
61
+ const details = [];
62
+ if (normalized.parent_run_id) {
63
+ details.push(`from ${normalized.parent_run_id}`);
64
+ }
65
+ if (normalized.intake_intent_id) {
66
+ details.push(`intent ${normalized.intake_intent_id}`);
67
+ }
68
+
69
+ const base = details.length > 0
70
+ ? `${normalized.trigger} ${details.join(' ')}`
71
+ : normalized.trigger;
72
+ const creatorSuffix = normalized.created_by === 'coordinator'
73
+ ? ' (created by coordinator)'
74
+ : '';
75
+ const reasonSuffix = normalized.trigger_reason
76
+ ? ` ("${normalized.trigger_reason}")`
77
+ : '';
78
+
79
+ if (
80
+ normalized.trigger === 'manual'
81
+ && !normalized.parent_run_id
82
+ && !normalized.intake_intent_id
83
+ && !normalized.trigger_reason
84
+ && normalized.created_by !== 'coordinator'
85
+ ) {
86
+ return null;
87
+ }
88
+
89
+ return `${base}${creatorSuffix}${reasonSuffix}`;
90
+ }