agentxchain 2.108.0 → 2.109.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,67 @@ 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 blockedState = {
1285
+ ...state,
1286
+ status: 'blocked',
1287
+ blocked_on: `gate_action:${gateFailure.gate_id || 'unknown'}`,
1288
+ blocked_reason: {
1289
+ category: 'gate_action_failed',
1290
+ blocked_at: blockedAt,
1291
+ turn_id: gateFailure.requested_by_turn || null,
1292
+ detail: `Gate action failed for "${gateFailure.gate_id || 'unknown'}": ${actionLabel}`,
1293
+ recovery: {
1294
+ typed_reason: 'gate_action_failed',
1295
+ owner: 'human',
1296
+ recovery_action: recoveryAction,
1297
+ turn_retained: false,
1298
+ detail: `${gateFailure.gate_id || 'unknown'} action ${gateFailure.action_index || '?'} (${actionLabel})`,
1299
+ },
1300
+ gate_action: {
1301
+ attempt_id: gateFailure.attempt_id || null,
1302
+ gate_id: gateFailure.gate_id || null,
1303
+ gate_type: gateType,
1304
+ action_index: gateFailure.action_index || null,
1305
+ action_label: gateFailure.action_label || null,
1306
+ command: gateFailure.command || null,
1307
+ exit_code: gateFailure.exit_code ?? null,
1308
+ stderr_tail: gateFailure.stderr_tail || null,
1309
+ },
1310
+ },
1311
+ };
1312
+
1313
+ writeState(root, blockedState);
1314
+ emitBlockedNotification(root, config, blockedState, {
1315
+ category: 'gate_action_failed',
1316
+ blockedOn: blockedState.blocked_on,
1317
+ recovery: blockedState.blocked_reason.recovery,
1318
+ });
1319
+ emitRunEvent(root, 'run_blocked', {
1320
+ run_id: blockedState.run_id,
1321
+ phase: blockedState.phase,
1322
+ status: blockedState.status,
1323
+ turn: gateFailure.requested_by_turn
1324
+ ? { turn_id: gateFailure.requested_by_turn, role_id: null }
1325
+ : undefined,
1326
+ payload: {
1327
+ category: 'gate_action_failed',
1328
+ gate_id: gateFailure.gate_id || null,
1329
+ gate_type: gateType,
1330
+ action_index: gateFailure.action_index || null,
1331
+ exit_code: gateFailure.exit_code ?? null,
1332
+ },
1333
+ });
1334
+
1335
+ return blockedState;
1336
+ }
1337
+
1276
1338
  function deriveHookRecovery(state, { phase, hookName, detail, errorCode, turnId, turnRetained }) {
1277
1339
  const isTamper = errorCode?.includes('_tamper');
1278
1340
  const pendingPhaseTransition = state?.pending_phase_transition;
@@ -3820,9 +3882,10 @@ export function rejectGovernedTurn(root, config, validationResult, reasonOrOptio
3820
3882
  *
3821
3883
  * @param {string} root - project root directory
3822
3884
  * @param {object} [config] - normalized config (optional; required for hook support)
3885
+ * @param {object} [opts] - optional execution controls
3823
3886
  * @returns {{ ok: boolean, error?: string, error_code?: string, state?: object, transition?: object, hookResults?: object }}
3824
3887
  */
3825
- export function approvePhaseTransition(root, config) {
3888
+ export function approvePhaseTransition(root, config, opts = {}) {
3826
3889
  const state = readState(root);
3827
3890
  if (!state) {
3828
3891
  return { ok: false, error: 'No governed state.json found' };
@@ -3836,6 +3899,23 @@ export function approvePhaseTransition(root, config) {
3836
3899
 
3837
3900
  const transition = state.pending_phase_transition;
3838
3901
 
3902
+ if (opts.dryRun) {
3903
+ const gateActions = executeGateActions(root, config, {
3904
+ gateId: transition.gate,
3905
+ gateType: 'phase_transition',
3906
+ phase: state.phase,
3907
+ requestedByTurn: transition.requested_by_turn || null,
3908
+ triggerCommand: 'approve-transition',
3909
+ }, { dryRun: true });
3910
+ return {
3911
+ ok: true,
3912
+ dry_run: true,
3913
+ state: attachLegacyCurrentTurnAlias(state),
3914
+ transition,
3915
+ gate_actions: gateActions.actions,
3916
+ };
3917
+ }
3918
+
3839
3919
  // ── before_gate hooks ──────────────────────────────────────────────
3840
3920
  const hooksConfig = config?.hooks || {};
3841
3921
  if (hooksConfig.before_gate && hooksConfig.before_gate.length > 0) {
@@ -3877,6 +3957,32 @@ export function approvePhaseTransition(root, config) {
3877
3957
  }
3878
3958
  }
3879
3959
 
3960
+ const gateActions = executeGateActions(root, config, {
3961
+ gateId: transition.gate,
3962
+ gateType: 'phase_transition',
3963
+ phase: state.phase,
3964
+ requestedByTurn: transition.requested_by_turn || null,
3965
+ triggerCommand: 'approve-transition',
3966
+ });
3967
+
3968
+ if (!gateActions.ok) {
3969
+ for (const entry of gateActions.actions || []) {
3970
+ appendJsonl(root, LEDGER_PATH, entry);
3971
+ }
3972
+ const blockedState = blockRunForGateActionFailure(root, state, gateActions.failed_action, config);
3973
+ return {
3974
+ ok: false,
3975
+ error: gateActions.error,
3976
+ error_code: 'gate_action_failed',
3977
+ state: blockedState,
3978
+ gateActionRun: gateActions,
3979
+ };
3980
+ }
3981
+
3982
+ for (const entry of gateActions.actions || []) {
3983
+ appendJsonl(root, LEDGER_PATH, entry);
3984
+ }
3985
+
3880
3986
  const updatedState = {
3881
3987
  ...state,
3882
3988
  phase: transition.to,
@@ -3893,6 +3999,7 @@ export function approvePhaseTransition(root, config) {
3893
3999
  };
3894
4000
 
3895
4001
  writeState(root, updatedState);
4002
+ clearSlaReminders(root, 'pending_phase_transition');
3896
4003
  emitRunEvent(root, 'gate_approved', {
3897
4004
  run_id: updatedState.run_id,
3898
4005
  phase: updatedState.phase,
@@ -3918,6 +4025,7 @@ export function approvePhaseTransition(root, config) {
3918
4025
  ok: true,
3919
4026
  state: attachLegacyCurrentTurnAlias(updatedState),
3920
4027
  transition,
4028
+ gateActionRun: gateActions,
3921
4029
  };
3922
4030
  }
3923
4031
 
@@ -3933,9 +4041,10 @@ export function approvePhaseTransition(root, config) {
3933
4041
  *
3934
4042
  * @param {string} root - project root directory
3935
4043
  * @param {object} [config] - normalized config (optional; required for hook support)
4044
+ * @param {object} [opts] - optional execution controls
3936
4045
  * @returns {{ ok: boolean, error?: string, error_code?: string, state?: object, completion?: object, hookResults?: object }}
3937
4046
  */
3938
- export function approveRunCompletion(root, config) {
4047
+ export function approveRunCompletion(root, config, opts = {}) {
3939
4048
  const state = readState(root);
3940
4049
  if (!state) {
3941
4050
  return { ok: false, error: 'No governed state.json found' };
@@ -3949,6 +4058,23 @@ export function approveRunCompletion(root, config) {
3949
4058
 
3950
4059
  const completion = state.pending_run_completion;
3951
4060
 
4061
+ if (opts.dryRun) {
4062
+ const gateActions = executeGateActions(root, config, {
4063
+ gateId: completion.gate,
4064
+ gateType: 'run_completion',
4065
+ phase: state.phase,
4066
+ requestedByTurn: completion.requested_by_turn || null,
4067
+ triggerCommand: 'approve-completion',
4068
+ }, { dryRun: true });
4069
+ return {
4070
+ ok: true,
4071
+ dry_run: true,
4072
+ state: attachLegacyCurrentTurnAlias(state),
4073
+ completion,
4074
+ gate_actions: gateActions.actions,
4075
+ };
4076
+ }
4077
+
3952
4078
  // ── before_gate hooks ──────────────────────────────────────────────
3953
4079
  const hooksConfig = config?.hooks || {};
3954
4080
  if (hooksConfig.before_gate && hooksConfig.before_gate.length > 0) {
@@ -3990,6 +4116,32 @@ export function approveRunCompletion(root, config) {
3990
4116
  }
3991
4117
  }
3992
4118
 
4119
+ const gateActions = executeGateActions(root, config, {
4120
+ gateId: completion.gate,
4121
+ gateType: 'run_completion',
4122
+ phase: state.phase,
4123
+ requestedByTurn: completion.requested_by_turn || null,
4124
+ triggerCommand: 'approve-completion',
4125
+ });
4126
+
4127
+ if (!gateActions.ok) {
4128
+ for (const entry of gateActions.actions || []) {
4129
+ appendJsonl(root, LEDGER_PATH, entry);
4130
+ }
4131
+ const blockedState = blockRunForGateActionFailure(root, state, gateActions.failed_action, config);
4132
+ return {
4133
+ ok: false,
4134
+ error: gateActions.error,
4135
+ error_code: 'gate_action_failed',
4136
+ state: blockedState,
4137
+ gateActionRun: gateActions,
4138
+ };
4139
+ }
4140
+
4141
+ for (const entry of gateActions.actions || []) {
4142
+ appendJsonl(root, LEDGER_PATH, entry);
4143
+ }
4144
+
3993
4145
  const updatedState = {
3994
4146
  ...state,
3995
4147
  status: 'completed',
@@ -4005,6 +4157,7 @@ export function approveRunCompletion(root, config) {
4005
4157
  };
4006
4158
 
4007
4159
  writeState(root, updatedState);
4160
+ clearSlaReminders(root, 'pending_run_completion');
4008
4161
 
4009
4162
  emitPendingLifecycleNotification(root, config, updatedState, 'run_completed', {
4010
4163
  completed_at: updatedState.completed_at,
@@ -4035,6 +4188,7 @@ export function approveRunCompletion(root, config) {
4035
4188
  ok: true,
4036
4189
  state: attachLegacyCurrentTurnAlias(updatedState),
4037
4190
  completion,
4191
+ gateActionRun: gateActions,
4038
4192
  };
4039
4193
  }
4040
4194
 
@@ -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,18 @@ 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
+ lines.push(` - ${action.gate_id || 'unknown'} | ${action.gate_type || 'unknown'} | action ${action.action_index || '?'} | ${action.status} | ${label} | exit: ${exit} | at: ${action.timestamp || 'n/a'}`);
1448
+ if (action.stderr_tail) {
1449
+ lines.push(` stderr: ${action.stderr_tail}`);
1450
+ }
1451
+ }
1452
+ }
1453
+
1434
1454
  if (run.approval_policy_events && run.approval_policy_events.length > 0) {
1435
1455
  lines.push('', 'Approval Policy:');
1436
1456
  for (const evt of run.approval_policy_events) {
@@ -1989,6 +2009,18 @@ export function formatGovernanceReportMarkdown(report) {
1989
2009
  }
1990
2010
  }
1991
2011
 
2012
+ if (run.gate_actions && run.gate_actions.length > 0) {
2013
+ lines.push('', '## Gate Actions', '');
2014
+ for (const action of run.gate_actions) {
2015
+ const label = action.action_label || action.command || `action ${action.action_index || '?'}`;
2016
+ const exit = action.exit_code == null ? 'n/a' : String(action.exit_code);
2017
+ lines.push(`- \`${action.gate_id || 'unknown'}\` (${action.gate_type || 'unknown'}) action ${action.action_index || '?'} — **${action.status}** at \`${action.timestamp || 'n/a'}\`: ${label} (exit \`${exit}\`)`);
2018
+ if (action.stderr_tail) {
2019
+ lines.push(` - stderr: ${action.stderr_tail}`);
2020
+ }
2021
+ }
2022
+ }
2023
+
1992
2024
  if (run.approval_policy_events && run.approval_policy_events.length > 0) {
1993
2025
  lines.push('', '## Approval Policy', '');
1994
2026
  for (const evt of run.approval_policy_events) {
@@ -2686,6 +2718,21 @@ function renderRunHtml(report) {
2686
2718
  sections.push(`<div class="section">${htmlSection('Gate Failures', gfHtml)}</div>`);
2687
2719
  }
2688
2720
 
2721
+ if (run.gate_actions && run.gate_actions.length > 0) {
2722
+ let gaHtml = '<ul>';
2723
+ for (const action of run.gate_actions) {
2724
+ const label = action.action_label || action.command || `action ${action.action_index || '?'}`;
2725
+ const exit = action.exit_code == null ? 'n/a' : String(action.exit_code);
2726
+ 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>)`;
2727
+ if (action.stderr_tail) {
2728
+ gaHtml += `<br><code>${esc(action.stderr_tail)}</code>`;
2729
+ }
2730
+ gaHtml += '</li>';
2731
+ }
2732
+ gaHtml += '</ul>';
2733
+ sections.push(`<div class="section">${htmlSection('Gate Actions', gaHtml)}</div>`);
2734
+ }
2735
+
2689
2736
  // Approval Policy
2690
2737
  if (run.approval_policy_events && run.approval_policy_events.length > 0) {
2691
2738
  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;