agentxchain 2.108.0 → 2.110.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.
@@ -39,7 +39,7 @@ import {
39
39
  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
- import { emitNotifications } from './notification-runner.js';
42
+ import { emitNotifications, clearSlaReminders } from './notification-runner.js';
43
43
  import { emitRunEvent } from './run-events.js';
44
44
  import { writeSessionCheckpoint } from './session-checkpoint.js';
45
45
  import { recordRunHistory } from './run-history.js';
@@ -54,6 +54,7 @@ import {
54
54
  replayVerificationMachineEvidence,
55
55
  summarizeVerificationReplay,
56
56
  } from './verification-replay.js';
57
+ import { executeGateActions } from './gate-actions.js';
57
58
 
58
59
  // ── Constants ────────────────────────────────────────────────────────────────
59
60
 
@@ -1273,6 +1274,71 @@ function canApprovePendingGate(state) {
1273
1274
  return state?.status === 'paused' || state?.status === 'blocked';
1274
1275
  }
1275
1276
 
1277
+ function blockRunForGateActionFailure(root, state, gateFailure, config) {
1278
+ const gateType = gateFailure.gate_type === 'run_completion' ? 'run_completion' : 'phase_transition';
1279
+ const recoveryAction = gateType === 'run_completion'
1280
+ ? 'agentxchain approve-completion'
1281
+ : 'agentxchain approve-transition';
1282
+ const actionLabel = gateFailure.action_label || gateFailure.command || gateFailure.gate_id || 'gate action';
1283
+ const blockedAt = gateFailure.timestamp || new Date().toISOString();
1284
+ const failureVerb = gateFailure.timed_out ? 'timed out' : 'failed';
1285
+ const failureDetail = `Gate action ${failureVerb} for "${gateFailure.gate_id || 'unknown'}": ${actionLabel}`;
1286
+ const blockedState = {
1287
+ ...state,
1288
+ status: 'blocked',
1289
+ blocked_on: `gate_action:${gateFailure.gate_id || 'unknown'}`,
1290
+ blocked_reason: {
1291
+ category: 'gate_action_failed',
1292
+ blocked_at: blockedAt,
1293
+ turn_id: gateFailure.requested_by_turn || null,
1294
+ detail: failureDetail,
1295
+ recovery: {
1296
+ typed_reason: 'gate_action_failed',
1297
+ owner: 'human',
1298
+ recovery_action: recoveryAction,
1299
+ turn_retained: false,
1300
+ detail: `${gateFailure.gate_id || 'unknown'} action ${gateFailure.action_index || '?'} (${actionLabel})${gateFailure.timed_out ? ` timed out after ${gateFailure.timeout_ms}ms` : ''}`,
1301
+ },
1302
+ gate_action: {
1303
+ attempt_id: gateFailure.attempt_id || null,
1304
+ gate_id: gateFailure.gate_id || null,
1305
+ gate_type: gateType,
1306
+ action_index: gateFailure.action_index || null,
1307
+ action_label: gateFailure.action_label || null,
1308
+ command: gateFailure.command || null,
1309
+ exit_code: gateFailure.exit_code ?? null,
1310
+ stderr_tail: gateFailure.stderr_tail || null,
1311
+ timeout_ms: gateFailure.timeout_ms ?? null,
1312
+ timed_out: gateFailure.timed_out === true,
1313
+ },
1314
+ },
1315
+ };
1316
+
1317
+ writeState(root, blockedState);
1318
+ emitBlockedNotification(root, config, blockedState, {
1319
+ category: 'gate_action_failed',
1320
+ blockedOn: blockedState.blocked_on,
1321
+ recovery: blockedState.blocked_reason.recovery,
1322
+ });
1323
+ emitRunEvent(root, 'run_blocked', {
1324
+ run_id: blockedState.run_id,
1325
+ phase: blockedState.phase,
1326
+ status: blockedState.status,
1327
+ turn: gateFailure.requested_by_turn
1328
+ ? { turn_id: gateFailure.requested_by_turn, role_id: null }
1329
+ : undefined,
1330
+ payload: {
1331
+ category: 'gate_action_failed',
1332
+ gate_id: gateFailure.gate_id || null,
1333
+ gate_type: gateType,
1334
+ action_index: gateFailure.action_index || null,
1335
+ exit_code: gateFailure.exit_code ?? null,
1336
+ },
1337
+ });
1338
+
1339
+ return blockedState;
1340
+ }
1341
+
1276
1342
  function deriveHookRecovery(state, { phase, hookName, detail, errorCode, turnId, turnRetained }) {
1277
1343
  const isTamper = errorCode?.includes('_tamper');
1278
1344
  const pendingPhaseTransition = state?.pending_phase_transition;
@@ -3820,9 +3886,10 @@ export function rejectGovernedTurn(root, config, validationResult, reasonOrOptio
3820
3886
  *
3821
3887
  * @param {string} root - project root directory
3822
3888
  * @param {object} [config] - normalized config (optional; required for hook support)
3889
+ * @param {object} [opts] - optional execution controls
3823
3890
  * @returns {{ ok: boolean, error?: string, error_code?: string, state?: object, transition?: object, hookResults?: object }}
3824
3891
  */
3825
- export function approvePhaseTransition(root, config) {
3892
+ export function approvePhaseTransition(root, config, opts = {}) {
3826
3893
  const state = readState(root);
3827
3894
  if (!state) {
3828
3895
  return { ok: false, error: 'No governed state.json found' };
@@ -3836,6 +3903,23 @@ export function approvePhaseTransition(root, config) {
3836
3903
 
3837
3904
  const transition = state.pending_phase_transition;
3838
3905
 
3906
+ if (opts.dryRun) {
3907
+ const gateActions = executeGateActions(root, config, {
3908
+ gateId: transition.gate,
3909
+ gateType: 'phase_transition',
3910
+ phase: state.phase,
3911
+ requestedByTurn: transition.requested_by_turn || null,
3912
+ triggerCommand: 'approve-transition',
3913
+ }, { dryRun: true });
3914
+ return {
3915
+ ok: true,
3916
+ dry_run: true,
3917
+ state: attachLegacyCurrentTurnAlias(state),
3918
+ transition,
3919
+ gate_actions: gateActions.actions,
3920
+ };
3921
+ }
3922
+
3839
3923
  // ── before_gate hooks ──────────────────────────────────────────────
3840
3924
  const hooksConfig = config?.hooks || {};
3841
3925
  if (hooksConfig.before_gate && hooksConfig.before_gate.length > 0) {
@@ -3877,6 +3961,32 @@ export function approvePhaseTransition(root, config) {
3877
3961
  }
3878
3962
  }
3879
3963
 
3964
+ const gateActions = executeGateActions(root, config, {
3965
+ gateId: transition.gate,
3966
+ gateType: 'phase_transition',
3967
+ phase: state.phase,
3968
+ requestedByTurn: transition.requested_by_turn || null,
3969
+ triggerCommand: 'approve-transition',
3970
+ });
3971
+
3972
+ if (!gateActions.ok) {
3973
+ for (const entry of gateActions.actions || []) {
3974
+ appendJsonl(root, LEDGER_PATH, entry);
3975
+ }
3976
+ const blockedState = blockRunForGateActionFailure(root, state, gateActions.failed_action, config);
3977
+ return {
3978
+ ok: false,
3979
+ error: gateActions.error,
3980
+ error_code: 'gate_action_failed',
3981
+ state: blockedState,
3982
+ gateActionRun: gateActions,
3983
+ };
3984
+ }
3985
+
3986
+ for (const entry of gateActions.actions || []) {
3987
+ appendJsonl(root, LEDGER_PATH, entry);
3988
+ }
3989
+
3880
3990
  const updatedState = {
3881
3991
  ...state,
3882
3992
  phase: transition.to,
@@ -3893,6 +4003,7 @@ export function approvePhaseTransition(root, config) {
3893
4003
  };
3894
4004
 
3895
4005
  writeState(root, updatedState);
4006
+ clearSlaReminders(root, 'pending_phase_transition');
3896
4007
  emitRunEvent(root, 'gate_approved', {
3897
4008
  run_id: updatedState.run_id,
3898
4009
  phase: updatedState.phase,
@@ -3918,6 +4029,7 @@ export function approvePhaseTransition(root, config) {
3918
4029
  ok: true,
3919
4030
  state: attachLegacyCurrentTurnAlias(updatedState),
3920
4031
  transition,
4032
+ gateActionRun: gateActions,
3921
4033
  };
3922
4034
  }
3923
4035
 
@@ -3933,9 +4045,10 @@ export function approvePhaseTransition(root, config) {
3933
4045
  *
3934
4046
  * @param {string} root - project root directory
3935
4047
  * @param {object} [config] - normalized config (optional; required for hook support)
4048
+ * @param {object} [opts] - optional execution controls
3936
4049
  * @returns {{ ok: boolean, error?: string, error_code?: string, state?: object, completion?: object, hookResults?: object }}
3937
4050
  */
3938
- export function approveRunCompletion(root, config) {
4051
+ export function approveRunCompletion(root, config, opts = {}) {
3939
4052
  const state = readState(root);
3940
4053
  if (!state) {
3941
4054
  return { ok: false, error: 'No governed state.json found' };
@@ -3949,6 +4062,23 @@ export function approveRunCompletion(root, config) {
3949
4062
 
3950
4063
  const completion = state.pending_run_completion;
3951
4064
 
4065
+ if (opts.dryRun) {
4066
+ const gateActions = executeGateActions(root, config, {
4067
+ gateId: completion.gate,
4068
+ gateType: 'run_completion',
4069
+ phase: state.phase,
4070
+ requestedByTurn: completion.requested_by_turn || null,
4071
+ triggerCommand: 'approve-completion',
4072
+ }, { dryRun: true });
4073
+ return {
4074
+ ok: true,
4075
+ dry_run: true,
4076
+ state: attachLegacyCurrentTurnAlias(state),
4077
+ completion,
4078
+ gate_actions: gateActions.actions,
4079
+ };
4080
+ }
4081
+
3952
4082
  // ── before_gate hooks ──────────────────────────────────────────────
3953
4083
  const hooksConfig = config?.hooks || {};
3954
4084
  if (hooksConfig.before_gate && hooksConfig.before_gate.length > 0) {
@@ -3990,6 +4120,32 @@ export function approveRunCompletion(root, config) {
3990
4120
  }
3991
4121
  }
3992
4122
 
4123
+ const gateActions = executeGateActions(root, config, {
4124
+ gateId: completion.gate,
4125
+ gateType: 'run_completion',
4126
+ phase: state.phase,
4127
+ requestedByTurn: completion.requested_by_turn || null,
4128
+ triggerCommand: 'approve-completion',
4129
+ });
4130
+
4131
+ if (!gateActions.ok) {
4132
+ for (const entry of gateActions.actions || []) {
4133
+ appendJsonl(root, LEDGER_PATH, entry);
4134
+ }
4135
+ const blockedState = blockRunForGateActionFailure(root, state, gateActions.failed_action, config);
4136
+ return {
4137
+ ok: false,
4138
+ error: gateActions.error,
4139
+ error_code: 'gate_action_failed',
4140
+ state: blockedState,
4141
+ gateActionRun: gateActions,
4142
+ };
4143
+ }
4144
+
4145
+ for (const entry of gateActions.actions || []) {
4146
+ appendJsonl(root, LEDGER_PATH, entry);
4147
+ }
4148
+
3993
4149
  const updatedState = {
3994
4150
  ...state,
3995
4151
  status: 'completed',
@@ -4005,6 +4161,7 @@ export function approveRunCompletion(root, config) {
4005
4161
  };
4006
4162
 
4007
4163
  writeState(root, updatedState);
4164
+ clearSlaReminders(root, 'pending_run_completion');
4008
4165
 
4009
4166
  emitPendingLifecycleNotification(root, config, updatedState, 'run_completed', {
4010
4167
  completed_at: updatedState.completed_at,
@@ -4035,6 +4192,7 @@ export function approveRunCompletion(root, config) {
4035
4192
  ok: true,
4036
4193
  state: attachLegacyCurrentTurnAlias(updatedState),
4037
4194
  completion,
4195
+ gateActionRun: gateActions,
4038
4196
  };
4039
4197
  }
4040
4198
 
@@ -28,6 +28,7 @@ import {
28
28
  isWorkflowKitPhaseTemplateId,
29
29
  VALID_WORKFLOW_KIT_PHASE_TEMPLATE_IDS,
30
30
  } from './workflow-kit-phase-templates.js';
31
+ import { validateGateActionsConfig } from './gate-actions.js';
31
32
 
32
33
  const VALID_WRITE_AUTHORITIES = ['authoritative', 'proposed', 'review_only'];
33
34
  const VALID_RUNTIME_TYPES = ['manual', 'local_cli', 'api_proxy', 'mcp', 'remote_agent'];
@@ -501,7 +502,8 @@ export function validateV4Config(data, projectRoot) {
501
502
 
502
503
  // Gates (optional but validated if present)
503
504
  if (data.gates) {
504
- if (data.routing) {
505
+ validateGateActionsConfig(data.gates, errors);
506
+ if (data.gates && typeof data.gates === 'object' && !Array.isArray(data.gates) && data.routing) {
505
507
  for (const [, route] of Object.entries(data.routing)) {
506
508
  if (route.exit_gate && !data.gates[route.exit_gate]) {
507
509
  errors.push(`Routing references unknown gate: "${route.exit_gate}"`);
@@ -520,6 +522,9 @@ export function validateV4Config(data, projectRoot) {
520
522
  if (data.notifications) {
521
523
  const notificationValidation = validateNotificationsConfig(data.notifications);
522
524
  errors.push(...notificationValidation.errors);
525
+ if (Array.isArray(notificationValidation.warnings)) {
526
+ warnings.push(...notificationValidation.warnings);
527
+ }
523
528
  }
524
529
 
525
530
  // Schedules (optional but validated if present)
@@ -1,4 +1,4 @@
1
- import { appendFileSync, existsSync, mkdirSync } from 'node:fs';
1
+ import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
2
2
  import { spawnSync } from 'node:child_process';
3
3
  import { dirname, join } from 'node:path';
4
4
  import { randomBytes } from 'node:crypto';
@@ -13,6 +13,7 @@ export const VALID_NOTIFICATION_EVENTS = [
13
13
  'phase_transition_pending',
14
14
  'run_completion_pending',
15
15
  'run_completed',
16
+ 'approval_sla_reminder',
16
17
  ];
17
18
 
18
19
  const NOTIFICATION_NAME_RE = /^[a-z0-9_-]+$/;
@@ -166,10 +167,10 @@ export function validateNotificationsConfig(notifications) {
166
167
 
167
168
  if (!notifications || typeof notifications !== 'object' || Array.isArray(notifications)) {
168
169
  errors.push('notifications must be an object');
169
- return { ok: false, errors };
170
+ return { ok: false, errors, warnings: [] };
170
171
  }
171
172
 
172
- const allowedKeys = new Set(['webhooks']);
173
+ const allowedKeys = new Set(['webhooks', 'approval_sla']);
173
174
  for (const key of Object.keys(notifications)) {
174
175
  if (!allowedKeys.has(key)) {
175
176
  errors.push(`notifications contains unknown field "${key}"`);
@@ -177,12 +178,12 @@ export function validateNotificationsConfig(notifications) {
177
178
  }
178
179
 
179
180
  if (!('webhooks' in notifications)) {
180
- return { ok: errors.length === 0, errors };
181
+ return { ok: errors.length === 0, errors, warnings: [] };
181
182
  }
182
183
 
183
184
  if (!Array.isArray(notifications.webhooks)) {
184
185
  errors.push('notifications.webhooks must be an array');
185
- return { ok: false, errors };
186
+ return { ok: false, errors, warnings: [] };
186
187
  }
187
188
 
188
189
  if (notifications.webhooks.length > MAX_NOTIFICATION_WEBHOOKS) {
@@ -261,7 +262,51 @@ export function validateNotificationsConfig(notifications) {
261
262
  }
262
263
  });
263
264
 
264
- return { ok: errors.length === 0, errors };
265
+ // Validate approval_sla if present
266
+ const warnings = [];
267
+ if (notifications.approval_sla !== undefined) {
268
+ const sla = notifications.approval_sla;
269
+ if (!sla || typeof sla !== 'object' || Array.isArray(sla)) {
270
+ errors.push('notifications.approval_sla must be an object');
271
+ } else {
272
+ const slaAllowed = new Set(['enabled', 'reminder_after_seconds']);
273
+ for (const key of Object.keys(sla)) {
274
+ if (!slaAllowed.has(key)) {
275
+ errors.push(`notifications.approval_sla contains unknown field "${key}"`);
276
+ }
277
+ }
278
+ if ('enabled' in sla && typeof sla.enabled !== 'boolean') {
279
+ errors.push('notifications.approval_sla.enabled must be a boolean');
280
+ }
281
+ if (!Array.isArray(sla.reminder_after_seconds) || sla.reminder_after_seconds.length === 0) {
282
+ errors.push('notifications.approval_sla.reminder_after_seconds must be a non-empty array of positive integers');
283
+ } else {
284
+ if (sla.reminder_after_seconds.length > 10) {
285
+ errors.push('notifications.approval_sla.reminder_after_seconds: maximum 10 thresholds');
286
+ }
287
+ let prev = 0;
288
+ for (let i = 0; i < sla.reminder_after_seconds.length; i++) {
289
+ const v = sla.reminder_after_seconds[i];
290
+ if (!Number.isInteger(v) || v <= 0) {
291
+ errors.push(`notifications.approval_sla.reminder_after_seconds[${i}]: must be a positive integer`);
292
+ } else if (v < 300) {
293
+ errors.push(`notifications.approval_sla.reminder_after_seconds[${i}]: minimum value is 300 (5 minutes)`);
294
+ } else if (v <= prev) {
295
+ errors.push(`notifications.approval_sla.reminder_after_seconds[${i}]: values must be strictly ascending`);
296
+ }
297
+ prev = v;
298
+ }
299
+ }
300
+ // Warn if no webhook subscribes to approval_sla_reminder
301
+ const hasSubscriber = Array.isArray(notifications.webhooks) &&
302
+ notifications.webhooks.some(w => Array.isArray(w.events) && w.events.includes('approval_sla_reminder'));
303
+ if (!hasSubscriber) {
304
+ warnings.push('notifications.approval_sla is configured but no webhook subscribes to "approval_sla_reminder"');
305
+ }
306
+ }
307
+ }
308
+
309
+ return { ok: errors.length === 0, errors, warnings };
265
310
  }
266
311
 
267
312
  export function emitNotifications(root, config, state, eventType, payload = {}, turn = null) {
@@ -328,3 +373,114 @@ export function emitNotifications(root, config, state, eventType, payload = {},
328
373
 
329
374
  return { ok: true, event_id: eventId, results };
330
375
  }
376
+
377
+ const SLA_REMINDERS_PATH = '.agentxchain/sla-reminders.json';
378
+
379
+ function readSlaReminders(root) {
380
+ try {
381
+ const filePath = join(root, SLA_REMINDERS_PATH);
382
+ if (!existsSync(filePath)) return [];
383
+ const data = JSON.parse(readFileSync(filePath, 'utf8'));
384
+ return Array.isArray(data) ? data : [];
385
+ } catch { return []; }
386
+ }
387
+
388
+ function writeSlaReminders(root, sent) {
389
+ const filePath = join(root, SLA_REMINDERS_PATH);
390
+ mkdirSync(dirname(filePath), { recursive: true });
391
+ writeFileSync(filePath, JSON.stringify(sent, null, 2) + '\n');
392
+ }
393
+
394
+ /**
395
+ * Evaluate and emit approval SLA reminders for pending approvals.
396
+ * Uses `.agentxchain/sla-reminders.json` for dedup tracking (not governed state).
397
+ * Returns { reminders_sent: string[], notifications_emitted: number }.
398
+ */
399
+ export function evaluateApprovalSlaReminders(root, config, state) {
400
+ const result = { reminders_sent: [], notifications_emitted: 0 };
401
+
402
+ const sla = config?.notifications?.approval_sla;
403
+ if (!sla || sla.enabled === false) return result;
404
+ if (!Array.isArray(sla.reminder_after_seconds) || sla.reminder_after_seconds.length === 0) return result;
405
+
406
+ const webhooks = config?.notifications?.webhooks;
407
+ if (!Array.isArray(webhooks) || !webhooks.some(w => Array.isArray(w.events) && w.events.includes('approval_sla_reminder'))) {
408
+ return result;
409
+ }
410
+
411
+ if (!state) return result;
412
+
413
+ const sent = readSlaReminders(root);
414
+ const now = Date.now();
415
+ const pendingApprovals = [];
416
+
417
+ if (state.pending_phase_transition && state.pending_phase_transition.requested_at) {
418
+ pendingApprovals.push({
419
+ approval_type: 'pending_phase_transition',
420
+ requested_at: state.pending_phase_transition.requested_at,
421
+ from_phase: state.pending_phase_transition.from || null,
422
+ to_phase: state.pending_phase_transition.to || null,
423
+ gate: state.pending_phase_transition.gate || null,
424
+ });
425
+ }
426
+
427
+ if (state.pending_run_completion && state.pending_run_completion.requested_at) {
428
+ pendingApprovals.push({
429
+ approval_type: 'pending_run_completion',
430
+ requested_at: state.pending_run_completion.requested_at,
431
+ from_phase: null,
432
+ to_phase: null,
433
+ gate: state.pending_run_completion.gate || null,
434
+ });
435
+ }
436
+
437
+ let changed = false;
438
+ for (const approval of pendingApprovals) {
439
+ const requestedMs = new Date(approval.requested_at).getTime();
440
+ if (isNaN(requestedMs)) continue;
441
+ const elapsedSeconds = Math.floor((now - requestedMs) / 1000);
442
+
443
+ for (let i = 0; i < sla.reminder_after_seconds.length; i++) {
444
+ const threshold = sla.reminder_after_seconds[i];
445
+ const reminderKey = `${approval.approval_type}:${threshold}`;
446
+
447
+ if (elapsedSeconds >= threshold && !sent.includes(reminderKey)) {
448
+ const payload = {
449
+ approval_type: approval.approval_type,
450
+ requested_at: approval.requested_at,
451
+ elapsed_seconds: elapsedSeconds,
452
+ threshold_seconds: threshold,
453
+ reminder_index: i + 1,
454
+ total_thresholds: sla.reminder_after_seconds.length,
455
+ };
456
+ if (approval.from_phase) payload.from_phase = approval.from_phase;
457
+ if (approval.to_phase) payload.to_phase = approval.to_phase;
458
+ if (approval.gate) payload.gate = approval.gate;
459
+
460
+ emitNotifications(root, config, state, 'approval_sla_reminder', payload);
461
+ sent.push(reminderKey);
462
+ result.reminders_sent.push(reminderKey);
463
+ result.notifications_emitted++;
464
+ changed = true;
465
+ }
466
+ }
467
+ }
468
+
469
+ if (changed) {
470
+ writeSlaReminders(root, sent);
471
+ }
472
+
473
+ return result;
474
+ }
475
+
476
+ /**
477
+ * Clear SLA reminder tracking for a resolved approval type.
478
+ * Called from approve-transition / approve-completion.
479
+ */
480
+ export function clearSlaReminders(root, approvalType) {
481
+ const sent = readSlaReminders(root);
482
+ const filtered = sent.filter(k => !k.startsWith(`${approvalType}:`));
483
+ if (filtered.length !== sent.length) {
484
+ writeSlaReminders(root, filtered);
485
+ }
486
+ }
package/src/lib/report.js CHANGED
@@ -11,6 +11,7 @@ import {
11
11
  } from './coordinator-next-actions.js';
12
12
  import { buildCoordinatorRepoStatusEntries } from './coordinator-repo-status-presentation.js';
13
13
  import { summarizeCoordinatorEvent } from './coordinator-event-narrative.js';
14
+ import { extractGateActionDigest } from './gate-actions.js';
14
15
 
15
16
  export const GOVERNANCE_REPORT_VERSION = '0.1';
16
17
 
@@ -511,6 +512,11 @@ function extractGateFailureDigest(artifact) {
511
512
  }));
512
513
  }
513
514
 
515
+ function extractGateActionEventDigest(artifact) {
516
+ const data = extractFileData(artifact, '.agentxchain/decision-ledger.jsonl');
517
+ return extractGateActionDigest(data);
518
+ }
519
+
514
520
  const GOVERNANCE_EVENT_TYPES = new Set([
515
521
  'policy_escalation',
516
522
  'conflict_detected',
@@ -986,6 +992,7 @@ function buildRunSubject(artifact) {
986
992
  const decisions = extractDecisionDigest(artifact);
987
993
  const approvalPolicyEvents = extractApprovalPolicyDigest(artifact);
988
994
  const gateFailures = extractGateFailureDigest(artifact);
995
+ const gateActions = extractGateActionEventDigest(artifact);
989
996
  const timeoutEvents = extractTimeoutEventDigest(artifact);
990
997
  const hookSummary = extractHookSummary(artifact);
991
998
  const timing = computeTiming(artifact, turns);
@@ -1034,6 +1041,7 @@ function buildRunSubject(artifact) {
1034
1041
  approval_policy_events: approvalPolicyEvents,
1035
1042
  governance_events: governanceEvents,
1036
1043
  gate_failures: gateFailures,
1044
+ gate_actions: gateActions,
1037
1045
  timeout_events: timeoutEvents,
1038
1046
  delegation_summary: delegationSummary,
1039
1047
  hook_summary: hookSummary,
@@ -1431,6 +1439,19 @@ export function formatGovernanceReportText(report) {
1431
1439
  }
1432
1440
  }
1433
1441
 
1442
+ if (run.gate_actions && run.gate_actions.length > 0) {
1443
+ lines.push('', 'Gate Actions:');
1444
+ for (const action of run.gate_actions) {
1445
+ const label = action.action_label || action.command || `action ${action.action_index || '?'}`;
1446
+ const exit = action.exit_code == null ? 'n/a' : String(action.exit_code);
1447
+ const timeoutTag = action.timed_out ? ` | timed_out after ${action.timeout_ms}ms` : '';
1448
+ lines.push(` - ${action.gate_id || 'unknown'} | ${action.gate_type || 'unknown'} | action ${action.action_index || '?'} | ${action.status} | ${label} | exit: ${exit}${timeoutTag} | at: ${action.timestamp || 'n/a'}`);
1449
+ if (action.stderr_tail) {
1450
+ lines.push(` stderr: ${action.stderr_tail}`);
1451
+ }
1452
+ }
1453
+ }
1454
+
1434
1455
  if (run.approval_policy_events && run.approval_policy_events.length > 0) {
1435
1456
  lines.push('', 'Approval Policy:');
1436
1457
  for (const evt of run.approval_policy_events) {
@@ -1989,6 +2010,19 @@ export function formatGovernanceReportMarkdown(report) {
1989
2010
  }
1990
2011
  }
1991
2012
 
2013
+ if (run.gate_actions && run.gate_actions.length > 0) {
2014
+ lines.push('', '## Gate Actions', '');
2015
+ for (const action of run.gate_actions) {
2016
+ const label = action.action_label || action.command || `action ${action.action_index || '?'}`;
2017
+ const exit = action.exit_code == null ? 'n/a' : String(action.exit_code);
2018
+ const mdTimeout = action.timed_out ? ` ⏱ timed out after ${action.timeout_ms}ms` : '';
2019
+ lines.push(`- \`${action.gate_id || 'unknown'}\` (${action.gate_type || 'unknown'}) action ${action.action_index || '?'} — **${action.status}** at \`${action.timestamp || 'n/a'}\`: ${label} (exit \`${exit}\`)${mdTimeout}`);
2020
+ if (action.stderr_tail) {
2021
+ lines.push(` - stderr: ${action.stderr_tail}`);
2022
+ }
2023
+ }
2024
+ }
2025
+
1992
2026
  if (run.approval_policy_events && run.approval_policy_events.length > 0) {
1993
2027
  lines.push('', '## Approval Policy', '');
1994
2028
  for (const evt of run.approval_policy_events) {
@@ -2686,6 +2720,22 @@ function renderRunHtml(report) {
2686
2720
  sections.push(`<div class="section">${htmlSection('Gate Failures', gfHtml)}</div>`);
2687
2721
  }
2688
2722
 
2723
+ if (run.gate_actions && run.gate_actions.length > 0) {
2724
+ let gaHtml = '<ul>';
2725
+ for (const action of run.gate_actions) {
2726
+ const label = action.action_label || action.command || `action ${action.action_index || '?'}`;
2727
+ const exit = action.exit_code == null ? 'n/a' : String(action.exit_code);
2728
+ const htmlTimeout = action.timed_out ? ` <em>⏱ timed out after ${esc(String(action.timeout_ms))}ms</em>` : '';
2729
+ gaHtml += `<li><code>${esc(action.gate_id || 'unknown')}</code> (${esc(action.gate_type || 'unknown')}) action ${esc(String(action.action_index || '?'))} — <strong>${esc(action.status)}</strong> at <code>${esc(action.timestamp || 'n/a')}</code>: ${esc(label)} (exit <code>${esc(exit)}</code>)${htmlTimeout}`;
2730
+ if (action.stderr_tail) {
2731
+ gaHtml += `<br><code>${esc(action.stderr_tail)}</code>`;
2732
+ }
2733
+ gaHtml += '</li>';
2734
+ }
2735
+ gaHtml += '</ul>';
2736
+ sections.push(`<div class="section">${htmlSection('Gate Actions', gaHtml)}</div>`);
2737
+ }
2738
+
2689
2739
  // Approval Policy
2690
2740
  if (run.approval_policy_events && run.approval_policy_events.length > 0) {
2691
2741
  let apHtml = '<ul>';
@@ -35,6 +35,7 @@ import {
35
35
  import { runAdmissionControl } from './admission-control.js';
36
36
  import { mkdirSync, writeFileSync } from 'fs';
37
37
  import { join, dirname } from 'path';
38
+ import { evaluateApprovalSlaReminders } from './notification-runner.js';
38
39
 
39
40
  const DEFAULT_MAX_TURNS = 50;
40
41
 
@@ -495,6 +496,8 @@ async function dispatchAndProcess(root, config, turn, assignState, callbacks, em
495
496
  * Handle a paused state by checking for pending gates and calling approveGate.
496
497
  */
497
498
  async function handleGatePause(root, config, state, callbacks, emit) {
499
+ evaluateApprovalSlaReminders(root, config, state);
500
+
498
501
  if (state.pending_phase_transition) {
499
502
  emit({ type: 'gate_paused', gateType: 'phase_transition', state });
500
503
  let approved;