agentxchain 2.6.0 → 2.7.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.
@@ -65,6 +65,7 @@ import { rebindCommand } from '../src/commands/rebind.js';
65
65
  import { branchCommand } from '../src/commands/branch.js';
66
66
  import { migrateCommand } from '../src/commands/migrate.js';
67
67
  import { resumeCommand } from '../src/commands/resume.js';
68
+ import { escalateCommand } from '../src/commands/escalate.js';
68
69
  import { acceptTurnCommand } from '../src/commands/accept-turn.js';
69
70
  import { rejectTurnCommand } from '../src/commands/reject-turn.js';
70
71
  import { stepCommand } from '../src/commands/step.js';
@@ -260,6 +261,15 @@ program
260
261
  .option('--turn <id>', 'Target a specific retained turn when multiple exist')
261
262
  .action(resumeCommand);
262
263
 
264
+ program
265
+ .command('escalate')
266
+ .description('Raise an operator escalation and block the governed run intentionally')
267
+ .requiredOption('--reason <reason>', 'Operator escalation summary')
268
+ .option('--detail <detail>', 'Longer escalation detail for status and ledger surfaces')
269
+ .option('--action <action>', 'Override the default recovery action string')
270
+ .option('--turn <id>', 'Target a specific active turn when multiple turns exist')
271
+ .action(escalateCommand);
272
+
263
273
  program
264
274
  .command('accept-turn')
265
275
  .description('Accept the currently staged governed turn result')
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentxchain",
3
- "version": "2.6.0",
3
+ "version": "2.7.0",
4
4
  "description": "CLI for AgentXchain — governed multi-agent software delivery",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,63 @@
1
+ import chalk from 'chalk';
2
+ import { loadProjectContext, loadProjectState } from '../lib/config.js';
3
+ import { deriveRecoveryDescriptor } from '../lib/blocked-state.js';
4
+ import { raiseOperatorEscalation } from '../lib/governed-state.js';
5
+
6
+ export async function escalateCommand(opts) {
7
+ const context = loadProjectContext();
8
+ if (!context) {
9
+ console.log(chalk.red('No agentxchain.json found. Run `agentxchain init` first.'));
10
+ process.exit(1);
11
+ }
12
+
13
+ const { root, config } = context;
14
+
15
+ if (config.protocol_mode !== 'governed') {
16
+ console.log(chalk.red('The escalate command is only available for governed projects.'));
17
+ console.log(chalk.dim('Legacy projects use: agentxchain claim / release'));
18
+ process.exit(1);
19
+ }
20
+
21
+ const state = loadProjectState(root, config);
22
+ if (!state) {
23
+ console.log(chalk.red('No governed state.json found. Run `agentxchain init --governed` first.'));
24
+ process.exit(1);
25
+ }
26
+
27
+ const result = raiseOperatorEscalation(root, config, {
28
+ reason: opts.reason,
29
+ detail: opts.detail,
30
+ action: opts.action,
31
+ turnId: opts.turn,
32
+ });
33
+ if (!result.ok) {
34
+ console.log(chalk.red(result.error));
35
+ process.exit(1);
36
+ }
37
+
38
+ const recovery = deriveRecoveryDescriptor(result.state);
39
+
40
+ console.log('');
41
+ console.log(chalk.yellow(' Run Escalated'));
42
+ console.log(chalk.dim(' ' + '─'.repeat(44)));
43
+ console.log('');
44
+ console.log(` ${chalk.dim('Reason:')} ${result.escalation.reason}`);
45
+ if (result.escalation.detail && result.escalation.detail !== result.escalation.reason) {
46
+ console.log(` ${chalk.dim('Detail:')} ${result.escalation.detail}`);
47
+ }
48
+ console.log(` ${chalk.dim('Blocked:')} ${result.state.blocked_on}`);
49
+ console.log(` ${chalk.dim('Source:')} operator`);
50
+ console.log(` ${chalk.dim('Turn:')} ${result.escalation.from_turn_id || 'none retained'}`);
51
+ if (result.escalation.from_role) {
52
+ console.log(` ${chalk.dim('Role:')} ${result.escalation.from_role}`);
53
+ }
54
+ console.log('');
55
+
56
+ if (recovery) {
57
+ console.log(` ${chalk.dim('Typed:')} ${recovery.typed_reason}`);
58
+ console.log(` ${chalk.dim('Owner:')} ${recovery.owner}`);
59
+ console.log(` ${chalk.dim('Action:')} ${recovery.recovery_action}`);
60
+ console.log(` ${chalk.dim('Retained:')} ${recovery.turn_retained ? 'yes' : 'no'}`);
61
+ }
62
+ console.log('');
63
+ }
@@ -24,6 +24,7 @@ import {
24
24
  markRunBlocked,
25
25
  getActiveTurns,
26
26
  getActiveTurnCount,
27
+ reactivateGovernedRun,
27
28
  STATE_PATH,
28
29
  } from '../lib/governed-state.js';
29
30
  import { writeDispatchBundle, getDispatchTurnDir, getTurnStagingResultPath } from '../lib/dispatch-bundle.js';
@@ -154,6 +155,62 @@ export async function resumeCommand(opts) {
154
155
  }
155
156
  }
156
157
 
158
+ if (state.status === 'blocked' && activeCount > 0) {
159
+ let retainedTurn = null;
160
+ if (opts.turn) {
161
+ retainedTurn = activeTurns[opts.turn];
162
+ if (!retainedTurn) {
163
+ console.log(chalk.red(`No active turn found for --turn ${opts.turn}`));
164
+ process.exit(1);
165
+ }
166
+ } else if (activeCount > 1) {
167
+ console.log(chalk.red('Multiple retained turns exist. Use --turn <id> to specify which to re-dispatch.'));
168
+ for (const turn of Object.values(activeTurns)) {
169
+ console.log(` ${chalk.yellow('●')} ${turn.turn_id} — ${chalk.bold(turn.assigned_role)} (${turn.status})`);
170
+ }
171
+ console.log('');
172
+ console.log(chalk.dim('Example: agentxchain resume --turn <turn_id>'));
173
+ process.exit(1);
174
+ } else {
175
+ retainedTurn = Object.values(activeTurns)[0];
176
+ }
177
+
178
+ if (retainedTurn.status === 'conflicted') {
179
+ console.log(chalk.red(`Turn ${retainedTurn.turn_id} is conflicted. Resolve the conflict before resuming.`));
180
+ process.exit(1);
181
+ }
182
+
183
+ console.log(chalk.yellow(`Re-dispatching blocked turn: ${retainedTurn.turn_id}`));
184
+ console.log(` Role: ${retainedTurn.assigned_role}`);
185
+ console.log(` Attempt: ${retainedTurn.attempt}`);
186
+ console.log('');
187
+
188
+ const reactivated = reactivateGovernedRun(root, state, { via: 'resume --turn', notificationConfig: config });
189
+ if (!reactivated.ok) {
190
+ console.log(chalk.red(`Failed to reactivate blocked run: ${reactivated.error}`));
191
+ process.exit(1);
192
+ }
193
+ state = reactivated.state;
194
+
195
+ const bundleResult = writeDispatchBundle(root, state, config, { turnId: retainedTurn.turn_id });
196
+ if (!bundleResult.ok) {
197
+ console.log(chalk.red(`Failed to write dispatch bundle: ${bundleResult.error}`));
198
+ process.exit(1);
199
+ }
200
+ printDispatchBundleWarnings(bundleResult);
201
+
202
+ const hooksConfig = config.hooks || {};
203
+ if (hooksConfig.after_dispatch?.length > 0) {
204
+ const afterDispatchResult = runAfterDispatchHooks(root, hooksConfig, state, retainedTurn, config);
205
+ if (!afterDispatchResult.ok) {
206
+ process.exit(1);
207
+ }
208
+ }
209
+
210
+ printDispatchSummary(state, config, retainedTurn);
211
+ return;
212
+ }
213
+
157
214
  // §47: idle + no run_id → initialize new run
158
215
  if (state.status === 'idle' && !state.run_id) {
159
216
  const initResult = initializeGovernedRun(root, config);
@@ -165,10 +222,22 @@ export async function resumeCommand(opts) {
165
222
  console.log(chalk.green(`Initialized governed run: ${state.run_id}`));
166
223
  }
167
224
 
225
+ // §47: paused + run_id exists → resume same run
226
+ if (state.status === 'blocked' && state.run_id) {
227
+ const reactivated = reactivateGovernedRun(root, state, { via: 'resume', notificationConfig: config });
228
+ if (!reactivated.ok) {
229
+ console.log(chalk.red(`Failed to reactivate blocked run: ${reactivated.error}`));
230
+ process.exit(1);
231
+ }
232
+ state = reactivated.state;
233
+ console.log(chalk.green(`Resumed blocked run: ${state.run_id}`));
234
+ }
235
+
168
236
  // §47: paused + run_id exists → resume same run
169
237
  if (state.status === 'paused' && state.run_id) {
170
238
  state.status = 'active';
171
239
  state.blocked_on = null;
240
+ state.blocked_reason = null;
172
241
  state.escalation = null;
173
242
  safeWriteJson(statePath, state);
174
243
  console.log(chalk.green(`Resumed governed run: ${state.run_id}`));
@@ -203,7 +272,7 @@ export async function resumeCommand(opts) {
203
272
  const hooksConfig = config.hooks || {};
204
273
  const turn = state.current_turn;
205
274
  if (hooksConfig.after_dispatch?.length > 0) {
206
- const afterDispatchResult = runAfterDispatchHooks(root, hooksConfig, state, turn);
275
+ const afterDispatchResult = runAfterDispatchHooks(root, hooksConfig, state, turn, config);
207
276
  if (!afterDispatchResult.ok) {
208
277
  process.exit(1);
209
278
  }
@@ -259,7 +328,7 @@ function resolveTargetRole(opts, state, config) {
259
328
  return null;
260
329
  }
261
330
 
262
- function runAfterDispatchHooks(root, hooksConfig, state, turn) {
331
+ function runAfterDispatchHooks(root, hooksConfig, state, turn, config) {
263
332
  const turnId = turn.turn_id;
264
333
  const roleId = turn.assigned_role;
265
334
 
@@ -300,6 +369,7 @@ function runAfterDispatchHooks(root, hooksConfig, state, turn) {
300
369
  detail,
301
370
  },
302
371
  turnId,
372
+ notificationConfig: config,
303
373
  });
304
374
 
305
375
  printDispatchHookFailure({
@@ -32,6 +32,7 @@ import {
32
32
  markRunBlocked,
33
33
  getActiveTurnCount,
34
34
  getActiveTurns,
35
+ reactivateGovernedRun,
35
36
  STATE_PATH,
36
37
  } from '../lib/governed-state.js';
37
38
  import { getMaxConcurrentTurns } from '../lib/normalized-config.js';
@@ -203,8 +204,12 @@ export async function stepCommand(opts) {
203
204
  }
204
205
 
205
206
  console.log(chalk.yellow(`Re-dispatching blocked turn: ${targetTurn.turn_id}`));
206
- state = clearBlockedState(state);
207
- safeWriteJson(join(root, STATE_PATH), state);
207
+ const reactivated = reactivateGovernedRun(root, state, { via: 'step --resume', notificationConfig: config });
208
+ if (!reactivated.ok) {
209
+ console.log(chalk.red(`Failed to reactivate blocked run: ${reactivated.error}`));
210
+ process.exit(1);
211
+ }
212
+ state = reactivated.state;
208
213
  skipAssignment = true;
209
214
 
210
215
  const bundleResult = writeDispatchBundle(root, state, config);
@@ -251,8 +256,12 @@ export async function stepCommand(opts) {
251
256
 
252
257
  // paused → resume
253
258
  if (!skipAssignment && state.status === 'blocked' && state.run_id) {
254
- state = clearBlockedState(state);
255
- safeWriteJson(join(root, STATE_PATH), state);
259
+ const reactivated = reactivateGovernedRun(root, state, { via: 'step', notificationConfig: config });
260
+ if (!reactivated.ok) {
261
+ console.log(chalk.red(`Failed to reactivate blocked run: ${reactivated.error}`));
262
+ process.exit(1);
263
+ }
264
+ state = reactivated.state;
256
265
  console.log(chalk.green(`Resumed blocked run: ${state.run_id}`));
257
266
  }
258
267
 
@@ -332,6 +341,7 @@ export async function stepCommand(opts) {
332
341
  hookResults: afterDispatchHooks,
333
342
  phase: 'after_dispatch',
334
343
  defaultDetail: `after_dispatch hook blocked dispatch for turn ${turn.turn_id}`,
344
+ config,
335
345
  });
336
346
  printLifecycleHookFailure('Dispatch Blocked By Hook', blocked.result, {
337
347
  turnId: turn.turn_id,
@@ -382,6 +392,7 @@ export async function stepCommand(opts) {
382
392
  },
383
393
  turnId: turn.turn_id,
384
394
  hooksConfig,
395
+ notificationConfig: config,
385
396
  });
386
397
  if (blocked.ok) {
387
398
  state = blocked.state;
@@ -462,6 +473,7 @@ export async function stepCommand(opts) {
462
473
  },
463
474
  turnId: turn.turn_id,
464
475
  hooksConfig,
476
+ notificationConfig: config,
465
477
  });
466
478
  if (blocked.ok) {
467
479
  state = blocked.state;
@@ -530,6 +542,7 @@ export async function stepCommand(opts) {
530
542
  },
531
543
  turnId: turn.turn_id,
532
544
  hooksConfig,
545
+ notificationConfig: config,
533
546
  });
534
547
  if (blocked.ok) {
535
548
  state = blocked.state;
@@ -556,6 +569,7 @@ export async function stepCommand(opts) {
556
569
  },
557
570
  turnId: turn.turn_id,
558
571
  hooksConfig,
572
+ notificationConfig: config,
559
573
  });
560
574
  if (blocked.ok) {
561
575
  state = blocked.state;
@@ -643,6 +657,7 @@ export async function stepCommand(opts) {
643
657
  hookResults: beforeValidationHooks,
644
658
  phase: 'before_validation',
645
659
  defaultDetail: `before_validation hook blocked validation for turn ${turn.turn_id}`,
660
+ config,
646
661
  });
647
662
  printLifecycleHookFailure('Validation Blocked By Hook', blocked.result, {
648
663
  turnId: turn.turn_id,
@@ -674,6 +689,7 @@ export async function stepCommand(opts) {
674
689
  hookResults: afterValidationHooks,
675
690
  phase: 'after_validation',
676
691
  defaultDetail: `after_validation hook blocked acceptance for turn ${turn.turn_id}`,
692
+ config,
677
693
  });
678
694
  printLifecycleHookFailure('Validation Blocked By Hook', blocked.result, {
679
695
  turnId: turn.turn_id,
@@ -758,7 +774,7 @@ function loadHookStagedTurn(root, stagingRel) {
758
774
  }
759
775
  }
760
776
 
761
- function blockStepForHookIssue(root, turn, { hookResults, phase, defaultDetail }) {
777
+ function blockStepForHookIssue(root, turn, { hookResults, phase, defaultDetail, config }) {
762
778
  const hookName = hookResults.blocker?.hook_name
763
779
  || hookResults.results?.find((entry) => entry.hook_name)?.hook_name
764
780
  || 'unknown';
@@ -777,6 +793,7 @@ function blockStepForHookIssue(root, turn, { hookResults, phase, defaultDetail }
777
793
  detail,
778
794
  },
779
795
  turnId: turn.turn_id,
796
+ notificationConfig: config,
780
797
  });
781
798
 
782
799
  return {
@@ -860,16 +877,6 @@ function resolveTargetRole(opts, state, config) {
860
877
  return null;
861
878
  }
862
879
 
863
- function clearBlockedState(state) {
864
- return {
865
- ...state,
866
- status: 'active',
867
- blocked_on: null,
868
- blocked_reason: null,
869
- escalation: null,
870
- };
871
- }
872
-
873
880
  function printRecoverySummary(state, heading) {
874
881
  const recovery = deriveRecoveryDescriptor(state);
875
882
  console.log(chalk.yellow(heading));
@@ -65,12 +65,16 @@ export function deriveRecoveryDescriptor(state) {
65
65
  }
66
66
 
67
67
  if (state.blocked_on.startsWith('escalation:')) {
68
+ const isOperatorEscalation = state.blocked_on.startsWith('escalation:operator:') || state.escalation?.source === 'operator';
69
+ const recoveryAction = turnRetained
70
+ ? 'Resolve the escalation, then run agentxchain step --resume'
71
+ : 'Resolve the escalation, then run agentxchain step';
68
72
  return {
69
- typed_reason: 'retries_exhausted',
73
+ typed_reason: isOperatorEscalation ? 'operator_escalation' : 'retries_exhausted',
70
74
  owner: 'human',
71
- recovery_action: 'Resolve the escalation, then run agentxchain step --resume',
75
+ recovery_action: recoveryAction,
72
76
  turn_retained: turnRetained,
73
- detail: state.blocked_on,
77
+ detail: state.escalation?.detail || state.escalation?.reason || state.blocked_on,
74
78
  };
75
79
  }
76
80
 
@@ -187,6 +187,7 @@ function verifyRunExport(artifact, errors) {
187
187
  const expectedHistoryEntries = countJsonl(artifact.files, '.agentxchain/history.jsonl');
188
188
  const expectedDecisionEntries = countJsonl(artifact.files, '.agentxchain/decision-ledger.jsonl');
189
189
  const expectedHookAuditEntries = countJsonl(artifact.files, '.agentxchain/hook-audit.jsonl');
190
+ const expectedNotificationAuditEntries = countJsonl(artifact.files, '.agentxchain/notification-audit.jsonl');
190
191
  const expectedDispatchFiles = countDirectoryFiles(artifact.files, '.agentxchain/dispatch');
191
192
  const expectedStagingFiles = countDirectoryFiles(artifact.files, '.agentxchain/staging');
192
193
  const expectedIntakePresent = Object.keys(artifact.files).some((path) => path.startsWith('.agentxchain/intake/'));
@@ -201,6 +202,9 @@ function verifyRunExport(artifact, errors) {
201
202
  if (artifact.summary.hook_audit_entries !== expectedHookAuditEntries) {
202
203
  addError(errors, 'summary.hook_audit_entries', 'must match .agentxchain/hook-audit.jsonl entry count');
203
204
  }
205
+ if (artifact.summary.notification_audit_entries !== expectedNotificationAuditEntries) {
206
+ addError(errors, 'summary.notification_audit_entries', 'must match .agentxchain/notification-audit.jsonl entry count');
207
+ }
204
208
  if (artifact.summary.dispatch_artifact_files !== expectedDispatchFiles) {
205
209
  addError(errors, 'summary.dispatch_artifact_files', 'must match .agentxchain/dispatch file count');
206
210
  }
package/src/lib/export.js CHANGED
@@ -24,6 +24,7 @@ const INCLUDED_ROOTS = [
24
24
  '.agentxchain/decision-ledger.jsonl',
25
25
  '.agentxchain/hook-audit.jsonl',
26
26
  '.agentxchain/hook-annotations.jsonl',
27
+ '.agentxchain/notification-audit.jsonl',
27
28
  '.agentxchain/dispatch',
28
29
  '.agentxchain/staging',
29
30
  '.agentxchain/transactions/accept',
@@ -172,6 +173,7 @@ export function buildRunExport(startDir = process.cwd()) {
172
173
  history_entries: countJsonl(files, '.agentxchain/history.jsonl'),
173
174
  decision_entries: countJsonl(files, '.agentxchain/decision-ledger.jsonl'),
174
175
  hook_audit_entries: countJsonl(files, '.agentxchain/hook-audit.jsonl'),
176
+ notification_audit_entries: countJsonl(files, '.agentxchain/notification-audit.jsonl'),
175
177
  dispatch_artifact_files: countDirectoryFiles(files, '.agentxchain/dispatch'),
176
178
  staging_artifact_files: countDirectoryFiles(files, '.agentxchain/staging'),
177
179
  intake_present: Object.keys(files).some((path) => path.startsWith('.agentxchain/intake/')),
@@ -34,6 +34,7 @@ import {
34
34
  import { getMaxConcurrentTurns } from './normalized-config.js';
35
35
  import { getTurnStagingResultPath, getTurnStagingDir, getDispatchTurnDir } from './turn-paths.js';
36
36
  import { runHooks } from './hook-runner.js';
37
+ import { emitNotifications } from './notification-runner.js';
37
38
 
38
39
  // ── Constants ────────────────────────────────────────────────────────────────
39
40
 
@@ -53,6 +54,29 @@ function generateId(prefix) {
53
54
  return `${prefix}_${randomBytes(8).toString('hex')}`;
54
55
  }
55
56
 
57
+ function emitBlockedNotification(root, config, state, details = {}, turn = null) {
58
+ if (!config?.notifications?.webhooks?.length) {
59
+ return;
60
+ }
61
+
62
+ const recovery = state?.blocked_reason?.recovery || details.recovery || null;
63
+ emitNotifications(root, config, state, 'run_blocked', {
64
+ category: state?.blocked_reason?.category || details.category || 'unknown_block',
65
+ blocked_on: state?.blocked_on || details.blockedOn || null,
66
+ typed_reason: recovery?.typed_reason || null,
67
+ owner: recovery?.owner || null,
68
+ recovery_action: recovery?.recovery_action || null,
69
+ detail: recovery?.detail || null,
70
+ }, turn);
71
+ }
72
+
73
+ function emitPendingLifecycleNotification(root, config, state, eventType, payload, turn = null) {
74
+ if (!config?.notifications?.webhooks?.length) {
75
+ return;
76
+ }
77
+ emitNotifications(root, config, state, eventType, payload, turn);
78
+ }
79
+
56
80
  function normalizeActiveTurns(activeTurns) {
57
81
  if (!activeTurns || typeof activeTurns !== 'object' || Array.isArray(activeTurns)) {
58
82
  return {};
@@ -555,6 +579,22 @@ function buildBlockedReason({ category, recovery, turnId, blockedAt = new Date()
555
579
  };
556
580
  }
557
581
 
582
+ function slugifyEscalationReason(reason) {
583
+ if (typeof reason !== 'string') {
584
+ return 'operator';
585
+ }
586
+ const slug = reason
587
+ .trim()
588
+ .toLowerCase()
589
+ .replace(/[^a-z0-9]+/g, '-')
590
+ .replace(/^-+|-+$/g, '');
591
+ return slug || 'operator';
592
+ }
593
+
594
+ function isOperatorEscalationBlockedOn(blockedOn) {
595
+ return typeof blockedOn === 'string' && blockedOn.startsWith('escalation:operator:');
596
+ }
597
+
558
598
  function canApprovePendingGate(state) {
559
599
  return state?.status === 'paused' || state?.status === 'blocked';
560
600
  }
@@ -599,7 +639,7 @@ function deriveHookRecovery(state, { phase, hookName, detail, errorCode, turnId,
599
639
  };
600
640
  }
601
641
 
602
- function blockRunForHookIssue(root, state, { phase, turnId, hookName, detail, errorCode, turnRetained }) {
642
+ function blockRunForHookIssue(root, state, { phase, turnId, hookName, detail, errorCode, turnRetained, notificationConfig }) {
603
643
  const blockedAt = new Date().toISOString();
604
644
  const typedReason = errorCode?.includes('_tamper') ? 'hook_tamper' : 'hook_block';
605
645
  const recovery = deriveHookRecovery(state, {
@@ -622,6 +662,11 @@ function blockRunForHookIssue(root, state, { phase, turnId, hookName, detail, er
622
662
  }),
623
663
  };
624
664
  writeState(root, blockedState);
665
+ emitBlockedNotification(root, notificationConfig, blockedState, {
666
+ category: typedReason,
667
+ blockedOn: blockedState.blocked_on,
668
+ recovery,
669
+ }, turnId ? getActiveTurns(blockedState)[turnId] || null : null);
625
670
  return attachLegacyCurrentTurnAlias(blockedState);
626
671
  }
627
672
 
@@ -694,14 +739,18 @@ function inferBlockedReasonFromState(state) {
694
739
  }
695
740
 
696
741
  if (state.blocked_on.startsWith('escalation:')) {
742
+ const isOperatorEscalation = isOperatorEscalationBlockedOn(state.blocked_on) || state.escalation?.source === 'operator';
743
+ const recoveryAction = turnRetained
744
+ ? 'Resolve the escalation, then run agentxchain step --resume'
745
+ : 'Resolve the escalation, then run agentxchain step';
697
746
  return buildBlockedReason({
698
- category: 'retries_exhausted',
747
+ category: isOperatorEscalation ? 'operator_escalation' : 'retries_exhausted',
699
748
  recovery: {
700
- typed_reason: 'retries_exhausted',
749
+ typed_reason: isOperatorEscalation ? 'operator_escalation' : 'retries_exhausted',
701
750
  owner: 'human',
702
- recovery_action: 'Resolve the escalation, then run agentxchain step --resume',
751
+ recovery_action: recoveryAction,
703
752
  turn_retained: turnRetained,
704
- detail: state.blocked_on,
753
+ detail: state.escalation?.detail || state.escalation?.reason || state.blocked_on,
705
754
  },
706
755
  turnId: activeTurn?.turn_id ?? null,
707
756
  });
@@ -819,6 +868,12 @@ export function markRunBlocked(root, details) {
819
868
 
820
869
  writeState(root, updatedState);
821
870
 
871
+ emitBlockedNotification(root, details.notificationConfig, updatedState, {
872
+ category: details.category,
873
+ blockedOn: details.blockedOn,
874
+ recovery: details.recovery,
875
+ }, turnId ? getActiveTurns(updatedState)[turnId] || null : null);
876
+
822
877
  // Fire on_escalation hooks (advisory-only) after blocked state is persisted.
823
878
  // Only fire for non-hook-caused blocks to prevent circular invocations.
824
879
  if (details.hooksConfig?.on_escalation?.length > 0) {
@@ -837,6 +892,141 @@ export function markRunBlocked(root, details) {
837
892
  return { ok: true, state: updatedState };
838
893
  }
839
894
 
895
+ export function raiseOperatorEscalation(root, config, details) {
896
+ const state = readState(root);
897
+ if (!state) {
898
+ return { ok: false, error: 'No governed state.json found' };
899
+ }
900
+
901
+ const reason = typeof details.reason === 'string' ? details.reason.trim() : '';
902
+ if (!reason) {
903
+ return { ok: false, error: 'Escalation reason is required.' };
904
+ }
905
+
906
+ if (state.status !== 'active') {
907
+ return { ok: false, error: `Cannot escalate run: status is "${state.status}", expected "active"` };
908
+ }
909
+
910
+ const activeTurns = getActiveTurns(state);
911
+ let targetTurn = null;
912
+ if (details.turnId) {
913
+ targetTurn = activeTurns[details.turnId] || null;
914
+ if (!targetTurn) {
915
+ return { ok: false, error: `No active turn found for --turn ${details.turnId}` };
916
+ }
917
+ } else {
918
+ const turns = Object.values(activeTurns);
919
+ if (turns.length > 1) {
920
+ return { ok: false, error: 'Multiple active turns exist. Use --turn <id> to target the escalation.' };
921
+ }
922
+ targetTurn = turns[0] || null;
923
+ }
924
+
925
+ const turnRetained = Boolean(targetTurn);
926
+ const recoveryAction = details.action
927
+ || (turnRetained
928
+ ? 'Resolve the escalation, then run agentxchain step --resume'
929
+ : 'Resolve the escalation, then run agentxchain step');
930
+ const detail = typeof details.detail === 'string' && details.detail.trim()
931
+ ? details.detail.trim()
932
+ : reason;
933
+ const blockedOn = `escalation:operator:${slugifyEscalationReason(reason)}`;
934
+ const escalatedAt = new Date().toISOString();
935
+
936
+ const escalation = {
937
+ source: 'operator',
938
+ raised_by: 'human',
939
+ from_role: targetTurn?.assigned_role || null,
940
+ from_turn_id: targetTurn?.turn_id || null,
941
+ reason,
942
+ detail,
943
+ recovery_action: recoveryAction,
944
+ escalated_at: escalatedAt,
945
+ };
946
+
947
+ const blocked = markRunBlocked(root, {
948
+ blockedOn,
949
+ category: 'operator_escalation',
950
+ recovery: {
951
+ typed_reason: 'operator_escalation',
952
+ owner: 'human',
953
+ recovery_action: recoveryAction,
954
+ turn_retained: turnRetained,
955
+ detail,
956
+ },
957
+ turnId: targetTurn?.turn_id || null,
958
+ escalation,
959
+ hooksConfig: config?.hooks || {},
960
+ notificationConfig: config,
961
+ });
962
+ if (!blocked.ok) {
963
+ return blocked;
964
+ }
965
+
966
+ appendJsonl(root, LEDGER_PATH, {
967
+ timestamp: escalatedAt,
968
+ decision: 'operator_escalated',
969
+ run_id: blocked.state.run_id,
970
+ phase: blocked.state.phase,
971
+ blocked_on: blockedOn,
972
+ escalation,
973
+ });
974
+
975
+ emitPendingLifecycleNotification(root, config, blocked.state, 'operator_escalation_raised', {
976
+ source: 'operator',
977
+ blocked_on: blockedOn,
978
+ reason,
979
+ detail,
980
+ recovery_action: recoveryAction,
981
+ }, targetTurn);
982
+
983
+ return {
984
+ ok: true,
985
+ state: attachLegacyCurrentTurnAlias(blocked.state),
986
+ escalation,
987
+ };
988
+ }
989
+
990
+ export function reactivateGovernedRun(root, state, details = {}) {
991
+ if (!state || typeof state !== 'object') {
992
+ return { ok: false, error: 'State is required.' };
993
+ }
994
+
995
+ const now = new Date().toISOString();
996
+ const wasEscalation = state.status === 'blocked' && typeof state.blocked_on === 'string' && state.blocked_on.startsWith('escalation:');
997
+ const nextState = {
998
+ ...state,
999
+ status: 'active',
1000
+ blocked_on: null,
1001
+ blocked_reason: null,
1002
+ escalation: null,
1003
+ };
1004
+
1005
+ writeState(root, nextState);
1006
+
1007
+ if (wasEscalation) {
1008
+ appendJsonl(root, LEDGER_PATH, {
1009
+ timestamp: now,
1010
+ decision: 'escalation_resolved',
1011
+ run_id: state.run_id,
1012
+ phase: state.phase,
1013
+ resolved_via: details.via || 'unknown',
1014
+ blocked_on: state.blocked_on,
1015
+ escalation: state.escalation || null,
1016
+ turn_id: state.escalation?.from_turn_id ?? getActiveTurn(state)?.turn_id ?? null,
1017
+ role: state.escalation?.from_role ?? getActiveTurn(state)?.assigned_role ?? null,
1018
+ });
1019
+
1020
+ emitPendingLifecycleNotification(details.root || root, details.notificationConfig, nextState, 'escalation_resolved', {
1021
+ blocked_on: state.blocked_on,
1022
+ resolved_via: details.via || 'unknown',
1023
+ previous_escalation: state.escalation || null,
1024
+ }, state.escalation?.from_turn_id ? getActiveTurns(state)[state.escalation.from_turn_id] || getActiveTurn(state) : getActiveTurn(state));
1025
+ }
1026
+
1027
+ return { ok: true, state: attachLegacyCurrentTurnAlias(nextState) };
1028
+ }
1029
+
840
1030
  // ── Core Operations ──────────────────────────────────────────────────────────
841
1031
 
842
1032
  /**
@@ -1014,6 +1204,7 @@ export function assignGovernedTurn(root, config, roleId) {
1014
1204
  detail,
1015
1205
  errorCode: beforeAssignmentHooks.tamper.error_code,
1016
1206
  turnRetained: activeCount > 0,
1207
+ notificationConfig: config,
1017
1208
  });
1018
1209
  return {
1019
1210
  ok: false,
@@ -1240,6 +1431,7 @@ function _acceptGovernedTurnLocked(root, config, opts) {
1240
1431
  detail,
1241
1432
  errorCode: beforeValidationHooks.tamper?.error_code || 'hook_blocked',
1242
1433
  turnRetained: true,
1434
+ notificationConfig: config,
1243
1435
  });
1244
1436
  return {
1245
1437
  ok: false,
@@ -1281,6 +1473,7 @@ function _acceptGovernedTurnLocked(root, config, opts) {
1281
1473
  detail,
1282
1474
  errorCode: afterValidationHooks.tamper?.error_code || 'hook_blocked',
1283
1475
  turnRetained: true,
1476
+ notificationConfig: config,
1284
1477
  });
1285
1478
  return {
1286
1479
  ok: false,
@@ -1426,6 +1619,7 @@ function _acceptGovernedTurnLocked(root, config, opts) {
1426
1619
  detail,
1427
1620
  errorCode: beforeAcceptanceHooks.tamper?.error_code || 'hook_blocked',
1428
1621
  turnRetained: true,
1622
+ notificationConfig: config,
1429
1623
  });
1430
1624
  return {
1431
1625
  ok: false,
@@ -1698,6 +1892,7 @@ function _acceptGovernedTurnLocked(root, config, opts) {
1698
1892
  detail,
1699
1893
  errorCode: hookResults.tamper?.error_code || 'hook_post_commit_error',
1700
1894
  turnRetained: Object.keys(getActiveTurns(updatedState)).length > 0,
1895
+ notificationConfig: config,
1701
1896
  });
1702
1897
  return {
1703
1898
  ok: false,
@@ -1713,6 +1908,39 @@ function _acceptGovernedTurnLocked(root, config, opts) {
1713
1908
  }
1714
1909
  }
1715
1910
 
1911
+ if (updatedState.status === 'blocked') {
1912
+ emitBlockedNotification(root, config, updatedState, {
1913
+ category: updatedState.blocked_reason?.category || 'needs_human',
1914
+ blockedOn: updatedState.blocked_on,
1915
+ recovery: updatedState.blocked_reason?.recovery || null,
1916
+ }, currentTurn);
1917
+ }
1918
+
1919
+ if (updatedState.pending_phase_transition) {
1920
+ emitPendingLifecycleNotification(root, config, updatedState, 'phase_transition_pending', {
1921
+ from: updatedState.pending_phase_transition.from,
1922
+ to: updatedState.pending_phase_transition.to,
1923
+ gate: updatedState.pending_phase_transition.gate,
1924
+ requested_by_turn: updatedState.pending_phase_transition.requested_by_turn,
1925
+ }, currentTurn);
1926
+ }
1927
+
1928
+ if (updatedState.pending_run_completion) {
1929
+ emitPendingLifecycleNotification(root, config, updatedState, 'run_completion_pending', {
1930
+ gate: updatedState.pending_run_completion.gate,
1931
+ requested_by_turn: updatedState.pending_run_completion.requested_by_turn,
1932
+ requested_at: updatedState.pending_run_completion.requested_at,
1933
+ }, currentTurn);
1934
+ }
1935
+
1936
+ if (updatedState.status === 'completed') {
1937
+ emitPendingLifecycleNotification(root, config, updatedState, 'run_completed', {
1938
+ completed_at: updatedState.completed_at || now,
1939
+ completed_via: completionResult?.action === 'complete' ? 'accept_turn' : 'accept_turn_direct',
1940
+ requested_by_turn: completionResult?.requested_by_turn || turnResult.turn_id,
1941
+ }, currentTurn);
1942
+ }
1943
+
1716
1944
  return {
1717
1945
  ok: true,
1718
1946
  state: attachLegacyCurrentTurnAlias(updatedState),
@@ -1898,6 +2126,12 @@ export function rejectGovernedTurn(root, config, validationResult, reasonOrOptio
1898
2126
 
1899
2127
  writeState(root, updatedState);
1900
2128
 
2129
+ emitBlockedNotification(root, config, updatedState, {
2130
+ category: 'retries_exhausted',
2131
+ blockedOn: updatedState.blocked_on,
2132
+ recovery: updatedState.blocked_reason?.recovery || null,
2133
+ }, updatedState.active_turns[currentTurn.turn_id]);
2134
+
1901
2135
  // Fire on_escalation hooks (advisory-only) after blocked state is persisted.
1902
2136
  const hooksConfig = config?.hooks || {};
1903
2137
  if (hooksConfig.on_escalation?.length > 0) {
@@ -1977,6 +2211,7 @@ export function approvePhaseTransition(root, config) {
1977
2211
  detail,
1978
2212
  errorCode: gateHooks.tamper?.error_code || 'hook_blocked',
1979
2213
  turnRetained: false,
2214
+ notificationConfig: config,
1980
2215
  });
1981
2216
  return {
1982
2217
  ok: false,
@@ -2067,6 +2302,7 @@ export function approveRunCompletion(root, config) {
2067
2302
  detail,
2068
2303
  errorCode: gateHooks.tamper?.error_code || 'hook_blocked',
2069
2304
  turnRetained: false,
2305
+ notificationConfig: config,
2070
2306
  });
2071
2307
  return {
2072
2308
  ok: false,
@@ -2093,6 +2329,13 @@ export function approveRunCompletion(root, config) {
2093
2329
 
2094
2330
  writeState(root, updatedState);
2095
2331
 
2332
+ emitPendingLifecycleNotification(root, config, updatedState, 'run_completed', {
2333
+ completed_at: updatedState.completed_at,
2334
+ completed_via: 'approve_run_completion',
2335
+ gate: completion.gate,
2336
+ requested_by_turn: completion.requested_by_turn || null,
2337
+ }, completion.requested_by_turn ? getActiveTurns(state)[completion.requested_by_turn] || null : null);
2338
+
2096
2339
  return {
2097
2340
  ok: true,
2098
2341
  state: attachLegacyCurrentTurnAlias(updatedState),
@@ -2136,4 +2379,10 @@ function deriveNextRecommendedRole(turnResult, state, config) {
2136
2379
  return routing?.entry_role || null;
2137
2380
  }
2138
2381
 
2139
- export { STATE_PATH, HISTORY_PATH, LEDGER_PATH, STAGING_PATH, TALK_PATH };
2382
+ export {
2383
+ STATE_PATH,
2384
+ HISTORY_PATH,
2385
+ LEDGER_PATH,
2386
+ STAGING_PATH,
2387
+ TALK_PATH,
2388
+ };
@@ -13,6 +13,7 @@
13
13
  */
14
14
 
15
15
  import { validateHooksConfig } from './hook-runner.js';
16
+ import { validateNotificationsConfig } from './notification-runner.js';
16
17
  import { SUPPORTED_TOKEN_COUNTER_PROVIDERS } from './token-counter.js';
17
18
 
18
19
  const VALID_WRITE_AUTHORITIES = ['authoritative', 'proposed', 'review_only'];
@@ -425,6 +426,12 @@ export function validateV4Config(data, projectRoot) {
425
426
  errors.push(...hookValidation.errors);
426
427
  }
427
428
 
429
+ // Notifications (optional but validated if present)
430
+ if (data.notifications) {
431
+ const notificationValidation = validateNotificationsConfig(data.notifications);
432
+ errors.push(...notificationValidation.errors);
433
+ }
434
+
428
435
  return { ok: errors.length === 0, errors };
429
436
  }
430
437
 
@@ -467,6 +474,7 @@ export function normalizeV3(raw) {
467
474
  routing: buildLegacyRouting(Object.keys(agents)),
468
475
  gates: {},
469
476
  hooks: {},
477
+ notifications: {},
470
478
  budget: null,
471
479
  retention: {
472
480
  talk_strategy: 'append_only',
@@ -526,6 +534,7 @@ export function normalizeV4(raw) {
526
534
  routing: raw.routing || {},
527
535
  gates: raw.gates || {},
528
536
  hooks: raw.hooks || {},
537
+ notifications: raw.notifications || {},
529
538
  budget: raw.budget || null,
530
539
  retention: raw.retention || {
531
540
  talk_strategy: 'append_only',
@@ -0,0 +1,330 @@
1
+ import { appendFileSync, existsSync, mkdirSync } from 'node:fs';
2
+ import { spawnSync } from 'node:child_process';
3
+ import { dirname, join } from 'node:path';
4
+ import { randomBytes } from 'node:crypto';
5
+
6
+ import { interpolateHeaders } from './hook-runner.js';
7
+
8
+ export const NOTIFICATION_AUDIT_PATH = '.agentxchain/notification-audit.jsonl';
9
+ export const VALID_NOTIFICATION_EVENTS = [
10
+ 'run_blocked',
11
+ 'operator_escalation_raised',
12
+ 'escalation_resolved',
13
+ 'phase_transition_pending',
14
+ 'run_completion_pending',
15
+ 'run_completed',
16
+ ];
17
+
18
+ const NOTIFICATION_NAME_RE = /^[a-z0-9_-]+$/;
19
+ const MAX_NOTIFICATION_WEBHOOKS = 8;
20
+ const HEADER_VAR_RE = /\$\{([^}]+)\}/g;
21
+ const SIGKILL_GRACE_MS = 2000;
22
+ const MAX_STDERR_CAPTURE = 4096;
23
+
24
+ function collectMissingHeaderVars(headers, webhookEnv) {
25
+ if (!headers) return [];
26
+ const missing = [];
27
+ const mergedEnv = { ...process.env };
28
+
29
+ for (const [key, value] of Object.entries(webhookEnv || {})) {
30
+ if (typeof value === 'string') {
31
+ mergedEnv[key] = value;
32
+ }
33
+ }
34
+
35
+ for (const [headerName, value] of Object.entries(headers)) {
36
+ let match;
37
+ while ((match = HEADER_VAR_RE.exec(value)) !== null) {
38
+ if (mergedEnv[match[1]] === undefined) {
39
+ missing.push({ header: headerName, varName: match[1] });
40
+ }
41
+ }
42
+ HEADER_VAR_RE.lastIndex = 0;
43
+ }
44
+
45
+ return missing;
46
+ }
47
+
48
+ function appendAudit(root, entry) {
49
+ const filePath = join(root, NOTIFICATION_AUDIT_PATH);
50
+ if (!existsSync(dirname(filePath))) {
51
+ mkdirSync(dirname(filePath), { recursive: true });
52
+ }
53
+ appendFileSync(filePath, `${JSON.stringify(entry)}\n`);
54
+ }
55
+
56
+ function generateEventId() {
57
+ return `notif_${randomBytes(8).toString('hex')}`;
58
+ }
59
+
60
+ function executeWebhook(webhook, envelope) {
61
+ const startedAt = Date.now();
62
+ let headers;
63
+
64
+ try {
65
+ headers = interpolateHeaders(webhook.headers, webhook.env);
66
+ } catch (error) {
67
+ return {
68
+ delivered: false,
69
+ timed_out: false,
70
+ status_code: null,
71
+ duration_ms: Date.now() - startedAt,
72
+ message: String(error?.message || error),
73
+ stderr_excerpt: String(error?.message || error).slice(0, MAX_STDERR_CAPTURE),
74
+ };
75
+ }
76
+
77
+ headers['Content-Type'] = 'application/json';
78
+
79
+ const script = `
80
+ const url = process.argv[1];
81
+ const body = process.argv[2];
82
+ const headers = JSON.parse(process.argv[3]);
83
+ headers.Connection = 'close';
84
+ (async () => {
85
+ try {
86
+ const response = await fetch(url, {
87
+ method: 'POST',
88
+ headers,
89
+ body,
90
+ signal: AbortSignal.timeout(${webhook.timeout_ms}),
91
+ });
92
+ const text = await response.text();
93
+ process.stdout.write(JSON.stringify({ status: response.status, body: text }));
94
+ process.exit(0);
95
+ } catch (error) {
96
+ if (error?.name === 'TimeoutError' || error?.name === 'AbortError') {
97
+ process.stderr.write('timeout');
98
+ process.exit(2);
99
+ }
100
+ process.stderr.write(error?.message || String(error));
101
+ process.exit(1);
102
+ }
103
+ })();
104
+ `;
105
+
106
+ const result = spawnSync(process.execPath, ['-e', script, webhook.url, JSON.stringify(envelope), JSON.stringify(headers)], {
107
+ timeout: webhook.timeout_ms + SIGKILL_GRACE_MS,
108
+ maxBuffer: 1024 * 1024,
109
+ stdio: ['ignore', 'pipe', 'pipe'],
110
+ env: { ...process.env, ...(webhook.env || {}) },
111
+ });
112
+
113
+ const durationMs = Date.now() - startedAt;
114
+ const timedOut = result.error?.code === 'ETIMEDOUT' || durationMs > webhook.timeout_ms;
115
+ const stdout = result.stdout ? result.stdout.toString('utf8') : '';
116
+ const stderr = result.stderr ? result.stderr.toString('utf8').slice(0, MAX_STDERR_CAPTURE) : '';
117
+
118
+ if (timedOut) {
119
+ return {
120
+ delivered: false,
121
+ timed_out: true,
122
+ status_code: null,
123
+ duration_ms: durationMs,
124
+ message: `Timed out after ${webhook.timeout_ms}ms`,
125
+ stderr_excerpt: stderr,
126
+ };
127
+ }
128
+
129
+ if (result.status !== 0) {
130
+ return {
131
+ delivered: false,
132
+ timed_out: false,
133
+ status_code: null,
134
+ duration_ms: durationMs,
135
+ message: stderr || `Webhook delivery failed with exit code ${result.status}`,
136
+ stderr_excerpt: stderr,
137
+ };
138
+ }
139
+
140
+ try {
141
+ const bridge = JSON.parse(stdout || '{}');
142
+ const statusCode = Number.isInteger(bridge.status) ? bridge.status : null;
143
+ const delivered = statusCode >= 200 && statusCode < 300;
144
+ return {
145
+ delivered,
146
+ timed_out: false,
147
+ status_code: statusCode,
148
+ duration_ms: durationMs,
149
+ message: delivered ? 'Delivered' : `Webhook returned HTTP ${statusCode}`,
150
+ stderr_excerpt: stderr,
151
+ };
152
+ } catch {
153
+ return {
154
+ delivered: false,
155
+ timed_out: false,
156
+ status_code: null,
157
+ duration_ms: durationMs,
158
+ message: 'Failed to parse webhook bridge response',
159
+ stderr_excerpt: stderr || stdout.slice(0, MAX_STDERR_CAPTURE),
160
+ };
161
+ }
162
+ }
163
+
164
+ export function validateNotificationsConfig(notifications) {
165
+ const errors = [];
166
+
167
+ if (!notifications || typeof notifications !== 'object' || Array.isArray(notifications)) {
168
+ errors.push('notifications must be an object');
169
+ return { ok: false, errors };
170
+ }
171
+
172
+ const allowedKeys = new Set(['webhooks']);
173
+ for (const key of Object.keys(notifications)) {
174
+ if (!allowedKeys.has(key)) {
175
+ errors.push(`notifications contains unknown field "${key}"`);
176
+ }
177
+ }
178
+
179
+ if (!('webhooks' in notifications)) {
180
+ return { ok: errors.length === 0, errors };
181
+ }
182
+
183
+ if (!Array.isArray(notifications.webhooks)) {
184
+ errors.push('notifications.webhooks must be an array');
185
+ return { ok: false, errors };
186
+ }
187
+
188
+ if (notifications.webhooks.length > MAX_NOTIFICATION_WEBHOOKS) {
189
+ errors.push(`notifications.webhooks: maximum ${MAX_NOTIFICATION_WEBHOOKS} webhooks`);
190
+ }
191
+
192
+ const names = new Set();
193
+ notifications.webhooks.forEach((webhook, index) => {
194
+ const label = `notifications.webhooks[${index}]`;
195
+
196
+ if (!webhook || typeof webhook !== 'object' || Array.isArray(webhook)) {
197
+ errors.push(`${label} must be an object`);
198
+ return;
199
+ }
200
+
201
+ if (typeof webhook.name !== 'string' || !webhook.name.trim()) {
202
+ errors.push(`${label}: name must be a non-empty string`);
203
+ } else if (!NOTIFICATION_NAME_RE.test(webhook.name)) {
204
+ errors.push(`${label}: name must match ^[a-z0-9_-]+$`);
205
+ } else if (names.has(webhook.name)) {
206
+ errors.push(`${label}: duplicate webhook name "${webhook.name}"`);
207
+ } else {
208
+ names.add(webhook.name);
209
+ }
210
+
211
+ if (typeof webhook.url !== 'string' || !webhook.url.trim()) {
212
+ errors.push(`${label}: url must be a non-empty string`);
213
+ } else if (!/^https?:\/\/.+/.test(webhook.url)) {
214
+ errors.push(`${label}: url must be a valid HTTP or HTTPS URL`);
215
+ }
216
+
217
+ if (!Array.isArray(webhook.events) || webhook.events.length === 0) {
218
+ errors.push(`${label}: events must be a non-empty array`);
219
+ } else {
220
+ for (const eventName of webhook.events) {
221
+ if (!VALID_NOTIFICATION_EVENTS.includes(eventName)) {
222
+ errors.push(
223
+ `${label}: events contains unknown event "${eventName}". Valid events: ${VALID_NOTIFICATION_EVENTS.join(', ')}`
224
+ );
225
+ }
226
+ }
227
+ }
228
+
229
+ if (!Number.isInteger(webhook.timeout_ms) || webhook.timeout_ms < 100 || webhook.timeout_ms > 30000) {
230
+ errors.push(`${label}: timeout_ms must be an integer between 100 and 30000`);
231
+ }
232
+
233
+ if ('headers' in webhook && webhook.headers !== undefined) {
234
+ if (!webhook.headers || typeof webhook.headers !== 'object' || Array.isArray(webhook.headers)) {
235
+ errors.push(`${label}: headers must be an object`);
236
+ } else {
237
+ for (const [key, value] of Object.entries(webhook.headers)) {
238
+ if (typeof value !== 'string') {
239
+ errors.push(`${label}: headers.${key} must be a string`);
240
+ }
241
+ }
242
+ const missingHeaderVars = collectMissingHeaderVars(webhook.headers, webhook.env);
243
+ if (missingHeaderVars.length > 0) {
244
+ errors.push(
245
+ `${label}: unresolved header env vars ${missingHeaderVars.map(({ header, varName }) => `${header}:${varName}`).join(', ')}`
246
+ );
247
+ }
248
+ }
249
+ }
250
+
251
+ if ('env' in webhook && webhook.env !== undefined) {
252
+ if (!webhook.env || typeof webhook.env !== 'object' || Array.isArray(webhook.env)) {
253
+ errors.push(`${label}: env must be an object`);
254
+ } else {
255
+ for (const [key, value] of Object.entries(webhook.env)) {
256
+ if (typeof value !== 'string') {
257
+ errors.push(`${label}: env.${key} must be a string`);
258
+ }
259
+ }
260
+ }
261
+ }
262
+ });
263
+
264
+ return { ok: errors.length === 0, errors };
265
+ }
266
+
267
+ export function emitNotifications(root, config, state, eventType, payload = {}, turn = null) {
268
+ const webhooks = config?.notifications?.webhooks;
269
+ if (!Array.isArray(webhooks) || webhooks.length === 0) {
270
+ return { ok: true, results: [] };
271
+ }
272
+
273
+ if (!VALID_NOTIFICATION_EVENTS.includes(eventType)) {
274
+ return { ok: false, error: `Unknown notification event "${eventType}"`, results: [] };
275
+ }
276
+
277
+ const eventId = generateEventId();
278
+ const emittedAt = new Date().toISOString();
279
+ const envelope = {
280
+ schema_version: '0.1',
281
+ event_id: eventId,
282
+ event_type: eventType,
283
+ emitted_at: emittedAt,
284
+ project: {
285
+ id: config?.project?.id || 'unknown',
286
+ name: config?.project?.name || 'Unknown',
287
+ root,
288
+ },
289
+ run: {
290
+ run_id: state?.run_id || null,
291
+ status: state?.status || null,
292
+ phase: state?.phase || null,
293
+ },
294
+ turn: turn ? {
295
+ turn_id: turn.turn_id || null,
296
+ role_id: turn.assigned_role || turn.role_id || null,
297
+ attempt: Number.isInteger(turn.attempt) ? turn.attempt : null,
298
+ assigned_sequence: Number.isInteger(turn.assigned_sequence) ? turn.assigned_sequence : null,
299
+ } : null,
300
+ payload,
301
+ };
302
+
303
+ const results = [];
304
+ for (const webhook of webhooks) {
305
+ if (!webhook.events.includes(eventType)) {
306
+ continue;
307
+ }
308
+
309
+ const delivery = executeWebhook(webhook, envelope);
310
+ const auditEntry = {
311
+ event_id: eventId,
312
+ event_type: eventType,
313
+ notification_name: webhook.name,
314
+ transport: 'webhook',
315
+ delivered: delivery.delivered,
316
+ status_code: delivery.status_code,
317
+ timed_out: delivery.timed_out,
318
+ duration_ms: delivery.duration_ms,
319
+ message: delivery.message,
320
+ emitted_at: emittedAt,
321
+ };
322
+ if (delivery.stderr_excerpt) {
323
+ auditEntry.stderr_excerpt = delivery.stderr_excerpt;
324
+ }
325
+ appendAudit(root, auditEntry);
326
+ results.push(auditEntry);
327
+ }
328
+
329
+ return { ok: true, event_id: eventId, results };
330
+ }