agentxchain 2.107.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.
@@ -14,9 +14,11 @@ import { getConnectorHealth } from '../lib/connector-health.js';
14
14
  import { readRepoDecisions, summarizeRepoDecisions } from '../lib/repo-decisions.js';
15
15
  import { deriveWorkflowKitArtifacts } from '../lib/workflow-kit-artifacts.js';
16
16
  import { evaluateTimeouts } from '../lib/timeout-evaluator.js';
17
+ import { evaluateApprovalSlaReminders } from '../lib/notification-runner.js';
17
18
  import { summarizeRunProvenance } from '../lib/run-provenance.js';
18
19
  import { readRecentRunEventSummary } from '../lib/recent-event-summary.js';
19
20
  import { deriveConflictedTurnResolutionActions } from '../lib/conflict-actions.js';
21
+ import { summarizeLatestGateActionAttempt } from '../lib/gate-actions.js';
20
22
  import { getDashboardPid, getDashboardSession } from './dashboard.js';
21
23
 
22
24
  export async function statusCommand(opts) {
@@ -125,6 +127,14 @@ function renderGovernedStatus(context, opts) {
125
127
  const repoDecisionSummary = summarizeRepoDecisions(readRepoDecisions(root), config);
126
128
 
127
129
  const workflowKitArtifacts = deriveWorkflowKitArtifacts(root, config, state);
130
+ const gateActionAttempt = state?.pending_phase_transition
131
+ ? summarizeLatestGateActionAttempt(root, 'phase_transition', state.pending_phase_transition.gate)
132
+ : state?.pending_run_completion
133
+ ? summarizeLatestGateActionAttempt(root, 'run_completion', state.pending_run_completion.gate)
134
+ : null;
135
+
136
+ // Fire approval SLA reminders as a side effect (webhook-only, no CLI output)
137
+ evaluateApprovalSlaReminders(root, config, state);
128
138
 
129
139
  if (opts.json) {
130
140
  const dashPid = getDashboardPid(root);
@@ -152,6 +162,7 @@ function renderGovernedStatus(context, opts) {
152
162
  next_actions: nextActions,
153
163
  connector_health: connectorHealth,
154
164
  recent_event_summary: recentEventSummary,
165
+ gate_action_attempt: gateActionAttempt,
155
166
  workflow_kit_artifacts: workflowKitArtifacts,
156
167
  dashboard_session: dashboardSessionObj,
157
168
  }, null, 2));
@@ -197,6 +208,7 @@ function renderGovernedStatus(context, opts) {
197
208
  const activeTurnCount = getActiveTurnCount(state);
198
209
  const activeTurns = getActiveTurns(state);
199
210
  const singleActiveTurn = getActiveTurn(state);
211
+ const approvalPending = Boolean(state?.pending_phase_transition || state?.pending_run_completion);
200
212
  if (activeTurnCount > 1) {
201
213
  console.log(` ${chalk.dim('Turns:')} ${activeTurnCount} active`);
202
214
  for (const turn of Object.values(activeTurns)) {
@@ -249,8 +261,14 @@ function renderGovernedStatus(context, opts) {
249
261
  if (singleActiveTurn.status === 'conflicted' && singleActiveTurn.conflict_state) {
250
262
  const cs = singleActiveTurn.conflict_state;
251
263
  const files = cs.conflict_error?.conflicting_files || [];
264
+ const count = cs.detection_count || 1;
252
265
  const [reassignAction, mergeAction] = deriveConflictedTurnResolutionActions(singleActiveTurn.turn_id);
253
- console.log(` ${chalk.dim('Conflict:')} ${chalk.red(`${files.length} file(s) conflicting`)} — detection #${cs.detection_count || 1}`);
266
+ console.log(` ${chalk.dim('Conflict:')} ${chalk.red(`${files.length} file(s) conflicting`)} — detection #${count}`);
267
+ if (cs.conflict_error?.overlap_ratio != null) {
268
+ console.log(` ${chalk.dim('Overlap:')} ${(cs.conflict_error.overlap_ratio * 100).toFixed(0)}%`);
269
+ }
270
+ const suggestion = cs.conflict_error?.suggested_resolution || 'reject_and_reassign';
271
+ console.log(` ${chalk.dim('Suggest:')} ${suggestion}`);
254
272
  console.log(` ${chalk.dim('Resolve:')} ${chalk.cyan(reassignAction.command)}`);
255
273
  console.log(` ${chalk.dim(' or:')} ${chalk.cyan(mergeAction.command)}`);
256
274
  }
@@ -321,12 +339,33 @@ function renderGovernedStatus(context, opts) {
321
339
  const pt = state.pending_phase_transition;
322
340
  console.log(` ${chalk.dim('Pending:')} ${formatGovernedPhase(pt.from)} → ${formatGovernedPhase(pt.to)}`);
323
341
  console.log(` ${chalk.dim('Gate:')} ${pt.gate} (requires human approval)`);
342
+ if (pt.requested_at) {
343
+ console.log(` ${chalk.dim('Requested:')} ${pt.requested_at} (${timeSince(pt.requested_at)} ago)`);
344
+ }
324
345
  }
325
346
 
326
347
  if (state?.pending_run_completion) {
327
348
  const pc = state.pending_run_completion;
328
349
  console.log(` ${chalk.dim('Pending:')} ${chalk.bold('Run Completion')}`);
329
350
  console.log(` ${chalk.dim('Gate:')} ${pc.gate} (requires human approval)`);
351
+ if (pc.requested_at) {
352
+ console.log(` ${chalk.dim('Requested:')} ${pc.requested_at} (${timeSince(pc.requested_at)} ago)`);
353
+ }
354
+ }
355
+
356
+ if (gateActionAttempt) {
357
+ console.log(` ${chalk.dim('Gate actions:')} ${gateActionAttempt.status} at ${gateActionAttempt.attempted_at || 'unknown time'}`);
358
+ for (const action of gateActionAttempt.actions) {
359
+ const label = action.action_label || action.command || `action ${action.action_index || '?'}`;
360
+ const outcome = action.status === 'failed'
361
+ ? chalk.red('failed')
362
+ : chalk.green('succeeded');
363
+ const exit = action.exit_code == null ? '' : ` (exit ${action.exit_code})`;
364
+ console.log(` ${action.action_index || '?'}. ${label} — ${outcome}${exit}`);
365
+ if (action.status === 'failed' && action.stderr_tail) {
366
+ console.log(` ${chalk.dim(action.stderr_tail)}`);
367
+ }
368
+ }
330
369
  }
331
370
 
332
371
  if (state?.status === 'completed') {
@@ -368,14 +407,20 @@ function renderGovernedStatus(context, opts) {
368
407
 
369
408
  renderWorkflowKitArtifactsSection(workflowKitArtifacts);
370
409
 
371
- if (config.timeouts && state?.status === 'active') {
372
- const activeTurn = getActiveTurn(state);
410
+ if (config.timeouts && (state?.status === 'active' || approvalPending)) {
411
+ const activeTurn = state?.status === 'active' ? getActiveTurn(state) : null;
373
412
  const turnResult = activeTurn ? { role: activeTurn.assigned_role } : undefined;
374
413
  const timeoutEval = evaluateTimeouts({ config, state, turn: activeTurn, turnResult, now: new Date().toISOString() });
375
414
  const allItems = [...timeoutEval.exceeded, ...timeoutEval.warnings];
376
- if (allItems.length > 0) {
415
+ if (allItems.length > 0 || approvalPending) {
377
416
  console.log('');
378
417
  console.log(` ${chalk.dim('Timeouts:')}`);
418
+ if (approvalPending) {
419
+ console.log(` ${chalk.yellow('◷')} approval wait does not mutate timeout state; phase/run clocks keep ticking until the next accepted turn`);
420
+ }
421
+ if (approvalPending && allItems.length === 0) {
422
+ console.log(` ${chalk.dim('No current phase/run timeout pressure.')}`);
423
+ }
379
424
  for (const item of allItems) {
380
425
  const isExceeded = timeoutEval.exceeded.includes(item);
381
426
  const elapsed = item.elapsed_minutes != null ? `${item.elapsed_minutes}m` : '?';
@@ -637,6 +682,7 @@ function formatRunStatus(status) {
637
682
 
638
683
  function timeSince(iso) {
639
684
  const ms = Date.now() - new Date(iso).getTime();
685
+ if (!Number.isFinite(ms) || ms < 0) return '0s';
640
686
  const sec = Math.floor(ms / 1000);
641
687
  if (sec < 60) return `${sec}s`;
642
688
  const min = Math.floor(sec / 60);
@@ -67,6 +67,7 @@ import { runHooks } from '../lib/hook-runner.js';
67
67
  import { finalizeDispatchManifest, verifyDispatchManifest } from '../lib/dispatch-manifest.js';
68
68
  import { resolveGovernedRole } from '../lib/role-resolution.js';
69
69
  import { shouldSuggestManualQaFallback } from '../lib/manual-qa-fallback.js';
70
+ import { evaluateApprovalSlaReminders } from '../lib/notification-runner.js';
70
71
 
71
72
  export async function stepCommand(opts) {
72
73
  const context = loadProjectContext();
@@ -169,6 +170,7 @@ export async function stepCommand(opts) {
169
170
 
170
171
  if (!skipAssignment) {
171
172
  if (state.pending_phase_transition || state.pending_run_completion) {
173
+ evaluateApprovalSlaReminders(root, config, state);
172
174
  printRecoverySummary(state, 'This run is awaiting approval.', config);
173
175
  process.exit(1);
174
176
  }
@@ -26,6 +26,9 @@ import { readWorkflowKitArtifacts } from './workflow-kit-artifacts.js';
26
26
  import { readConnectorHealthSnapshot } from './connectors.js';
27
27
  import { readTimeoutStatus } from './timeout-status.js';
28
28
  import { queryRunHistory } from '../run-history.js';
29
+ import { loadProjectContext, loadProjectState } from '../config.js';
30
+ import { evaluateApprovalSlaReminders } from '../notification-runner.js';
31
+ import { readGateActionSnapshot } from './gate-action-reader.js';
29
32
 
30
33
  const MIME_TYPES = {
31
34
  '.html': 'text/html; charset=utf-8',
@@ -214,6 +217,40 @@ function resolveDashboardAssetPath(dashboardDir, pathname) {
214
217
  return { blocked: false, filePath };
215
218
  }
216
219
 
220
+ function readDashboardPollStatus(workspacePath, replayMode) {
221
+ const body = {
222
+ ok: true,
223
+ polled_at: new Date().toISOString(),
224
+ replay_mode: replayMode,
225
+ governed_project_detected: false,
226
+ state_available: false,
227
+ reminder_evaluation: {
228
+ reminders_sent: [],
229
+ notifications_emitted: 0,
230
+ },
231
+ };
232
+
233
+ if (replayMode) {
234
+ return { ok: true, status: 200, body };
235
+ }
236
+
237
+ const context = loadProjectContext(workspacePath);
238
+ if (!context || context.config?.protocol_mode !== 'governed') {
239
+ return { ok: true, status: 200, body };
240
+ }
241
+
242
+ body.governed_project_detected = true;
243
+
244
+ const state = loadProjectState(context.root, context.config);
245
+ if (!state) {
246
+ return { ok: true, status: 200, body };
247
+ }
248
+
249
+ body.state_available = true;
250
+ body.reminder_evaluation = evaluateApprovalSlaReminders(context.root, context.config, state);
251
+ return { ok: true, status: 200, body };
252
+ }
253
+
217
254
  // ── Bridge Server ───────────────────────────────────────────────────────────
218
255
 
219
256
  export function createBridgeServer({ agentxchainDir, dashboardDir, port = 3847, replayMode = false }) {
@@ -315,6 +352,12 @@ export function createBridgeServer({ agentxchainDir, dashboardDir, port = 3847,
315
352
  return;
316
353
  }
317
354
 
355
+ if (pathname === '/api/poll') {
356
+ const result = readDashboardPollStatus(workspacePath, replayMode);
357
+ writeJson(res, result.status, result.body);
358
+ return;
359
+ }
360
+
318
361
  if (pathname === '/api/actions/approve-gate') {
319
362
  if (replayMode) {
320
363
  writeJson(res, 403, { ok: false, code: 'replay_mode', error: 'Replay mode: gate approval is not available on exported snapshots.' });
@@ -419,6 +462,12 @@ export function createBridgeServer({ agentxchainDir, dashboardDir, port = 3847,
419
462
  return;
420
463
  }
421
464
 
465
+ if (pathname === '/api/gate-actions') {
466
+ const result = readGateActionSnapshot(workspacePath);
467
+ writeJson(res, result.status, result.body);
468
+ return;
469
+ }
470
+
422
471
  // API routes
423
472
  if (pathname.startsWith('/api/')) {
424
473
  const result = readResource(agentxchainDir, pathname);
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Gate-action reader — reads gate-action config and latest attempt for the dashboard.
3
+ *
4
+ * Follows the same pattern as other dashboard readers (connectors.js, timeout-status.js):
5
+ * returns { status, body } for the bridge server to serialize.
6
+ *
7
+ * Per DEC-DASHBOARD-GATE-ACTIONS-001: repo-local only, read-only.
8
+ */
9
+
10
+ import { loadProjectContext, loadProjectState } from '../config.js';
11
+ import { getGateActions, summarizeLatestGateActionAttempt } from '../gate-actions.js';
12
+
13
+ /**
14
+ * Read gate-action snapshot for the current pending gate (if any).
15
+ *
16
+ * @param {string} workspacePath — project root
17
+ * @returns {{ status: number, body: object }}
18
+ */
19
+ export function readGateActionSnapshot(workspacePath) {
20
+ const context = loadProjectContext(workspacePath);
21
+ if (!context || context.config?.protocol_mode !== 'governed') {
22
+ return { status: 200, body: { configured: [], latest_attempt: null } };
23
+ }
24
+
25
+ const state = loadProjectState(context.root, context.config);
26
+ if (!state) {
27
+ return { status: 200, body: { configured: [], latest_attempt: null } };
28
+ }
29
+
30
+ const pendingTransition = state.pending_phase_transition || null;
31
+ const pendingCompletion = state.pending_run_completion || null;
32
+
33
+ let gateType = null;
34
+ let gateId = null;
35
+
36
+ if (pendingTransition?.gate) {
37
+ gateType = 'phase_transition';
38
+ gateId = pendingTransition.gate;
39
+ } else if (pendingCompletion?.gate) {
40
+ gateType = 'run_completion';
41
+ gateId = pendingCompletion.gate;
42
+ }
43
+
44
+ if (!gateId) {
45
+ return { status: 200, body: { configured: [], latest_attempt: null } };
46
+ }
47
+
48
+ const configured = getGateActions(context.config, gateId);
49
+ const latestAttempt = summarizeLatestGateActionAttempt(context.root, gateType, gateId);
50
+
51
+ return {
52
+ status: 200,
53
+ body: {
54
+ configured,
55
+ latest_attempt: latestAttempt,
56
+ },
57
+ };
58
+ }
@@ -73,6 +73,30 @@ function emptyLiveTimeouts() {
73
73
  return { exceeded: [], warnings: [] };
74
74
  }
75
75
 
76
+ function buildLiveContext(state) {
77
+ const pendingPhase = state?.pending_phase_transition;
78
+ const pendingCompletion = state?.pending_run_completion;
79
+ if (pendingPhase) {
80
+ return {
81
+ awaiting_approval: true,
82
+ pending_gate_type: 'phase_transition',
83
+ requested_at: typeof pendingPhase.requested_at === 'string' ? pendingPhase.requested_at : null,
84
+ };
85
+ }
86
+ if (pendingCompletion) {
87
+ return {
88
+ awaiting_approval: true,
89
+ pending_gate_type: 'run_completion',
90
+ requested_at: typeof pendingCompletion.requested_at === 'string' ? pendingCompletion.requested_at : null,
91
+ };
92
+ }
93
+ return {
94
+ awaiting_approval: false,
95
+ pending_gate_type: null,
96
+ requested_at: null,
97
+ };
98
+ }
99
+
76
100
  function getActiveTurns(state) {
77
101
  if (!state?.active_turns || typeof state.active_turns !== 'object' || Array.isArray(state.active_turns)) {
78
102
  return [];
@@ -90,7 +114,8 @@ function annotateTurnTimeout(result, state, turn) {
90
114
  }
91
115
 
92
116
  export function evaluateDashboardTimeoutPressure(config, state, now = new Date()) {
93
- if (!config?.timeouts || state?.status !== 'active') {
117
+ const approvalPending = Boolean(state?.pending_phase_transition || state?.pending_run_completion);
118
+ if (!config?.timeouts || (state?.status !== 'active' && !approvalPending)) {
94
119
  return emptyLiveTimeouts();
95
120
  }
96
121
 
@@ -100,16 +125,18 @@ export function evaluateDashboardTimeoutPressure(config, state, now = new Date()
100
125
  warnings: [...base.warnings],
101
126
  };
102
127
 
103
- for (const turn of getActiveTurns(state)) {
104
- const turnEval = evaluateTimeouts({ config, state, turn, now });
105
- for (const item of turnEval.exceeded) {
106
- if (item.scope === 'turn') {
107
- live.exceeded.push(annotateTurnTimeout(item, state, turn));
128
+ if (state?.status === 'active') {
129
+ for (const turn of getActiveTurns(state)) {
130
+ const turnEval = evaluateTimeouts({ config, state, turn, now });
131
+ for (const item of turnEval.exceeded) {
132
+ if (item.scope === 'turn') {
133
+ live.exceeded.push(annotateTurnTimeout(item, state, turn));
134
+ }
108
135
  }
109
- }
110
- for (const item of turnEval.warnings) {
111
- if (item.scope === 'turn') {
112
- live.warnings.push(annotateTurnTimeout(item, state, turn));
136
+ for (const item of turnEval.warnings) {
137
+ if (item.scope === 'turn') {
138
+ live.warnings.push(annotateTurnTimeout(item, state, turn));
139
+ }
113
140
  }
114
141
  }
115
142
  }
@@ -172,6 +199,7 @@ export function readTimeoutStatus(workspacePath) {
172
199
  configured: false,
173
200
  config: null,
174
201
  live: null,
202
+ live_context: null,
175
203
  events: [],
176
204
  },
177
205
  };
@@ -190,12 +218,13 @@ export function readTimeoutStatus(workspacePath) {
190
218
  return {
191
219
  ok: true,
192
220
  status: 200,
193
- body: {
194
- ok: true,
195
- configured: true,
196
- config: configSummary,
197
- live,
198
- events,
199
- },
200
- };
221
+ body: {
222
+ ok: true,
223
+ configured: true,
224
+ config: configSummary,
225
+ live,
226
+ live_context: buildLiveContext(state),
227
+ events,
228
+ },
229
+ };
201
230
  }
@@ -0,0 +1,232 @@
1
+ import { randomBytes } from 'crypto';
2
+ import { existsSync, readFileSync } from 'fs';
3
+ import { join } from 'path';
4
+ import { spawnSync } from 'child_process';
5
+
6
+ const LEDGER_PATH = '.agentxchain/decision-ledger.jsonl';
7
+ const MAX_OUTPUT_TAIL_CHARS = 1200;
8
+
9
+ export function validateGateActionsConfig(gates, errors) {
10
+ if (!gates || typeof gates !== 'object' || Array.isArray(gates)) {
11
+ errors.push('gates must be an object');
12
+ return;
13
+ }
14
+
15
+ for (const [gateId, gate] of Object.entries(gates)) {
16
+ if (!gate || typeof gate !== 'object' || Array.isArray(gate)) {
17
+ errors.push(`Gate "${gateId}" must be an object`);
18
+ continue;
19
+ }
20
+
21
+ if (!('gate_actions' in gate)) {
22
+ continue;
23
+ }
24
+
25
+ if (!Array.isArray(gate.gate_actions) || gate.gate_actions.length === 0) {
26
+ errors.push(`Gate "${gateId}": gate_actions must be a non-empty array when provided`);
27
+ continue;
28
+ }
29
+
30
+ if (gate.requires_human_approval !== true) {
31
+ errors.push(`Gate "${gateId}": gate_actions require requires_human_approval: true`);
32
+ }
33
+
34
+ for (let i = 0; i < gate.gate_actions.length; i++) {
35
+ const action = gate.gate_actions[i];
36
+ const prefix = `gates.${gateId}.gate_actions[${i}]`;
37
+ if (!action || typeof action !== 'object' || Array.isArray(action)) {
38
+ errors.push(`${prefix} must be an object`);
39
+ continue;
40
+ }
41
+ if (typeof action.run !== 'string' || !action.run.trim()) {
42
+ errors.push(`${prefix}.run must be a non-empty string`);
43
+ }
44
+ if ('label' in action && (typeof action.label !== 'string' || !action.label.trim())) {
45
+ errors.push(`${prefix}.label must be a non-empty string when provided`);
46
+ }
47
+ }
48
+ }
49
+ }
50
+
51
+ export function getGateActions(config, gateId) {
52
+ const actions = config?.gates?.[gateId]?.gate_actions;
53
+ if (!Array.isArray(actions) || actions.length === 0) {
54
+ return [];
55
+ }
56
+
57
+ return actions
58
+ .filter((action) => action && typeof action === 'object' && typeof action.run === 'string' && action.run.trim())
59
+ .map((action, index) => ({
60
+ index: index + 1,
61
+ label: typeof action.label === 'string' && action.label.trim() ? action.label.trim() : null,
62
+ run: action.run.trim(),
63
+ }));
64
+ }
65
+
66
+ function trimOutputTail(value) {
67
+ if (typeof value !== 'string') {
68
+ return null;
69
+ }
70
+ const trimmed = value.trim();
71
+ if (!trimmed) {
72
+ return null;
73
+ }
74
+ if (trimmed.length <= MAX_OUTPUT_TAIL_CHARS) {
75
+ return trimmed;
76
+ }
77
+ return trimmed.slice(-MAX_OUTPUT_TAIL_CHARS);
78
+ }
79
+
80
+ function buildGateActionEntry(action, meta, runtimeResult, status) {
81
+ return {
82
+ type: 'gate_action',
83
+ timestamp: new Date().toISOString(),
84
+ attempt_id: meta.attemptId,
85
+ gate_id: meta.gateId,
86
+ gate_type: meta.gateType,
87
+ phase: meta.phase || null,
88
+ requested_by_turn: meta.requestedByTurn || null,
89
+ trigger_command: meta.triggerCommand || null,
90
+ action_index: action.index,
91
+ action_label: action.label,
92
+ command: action.run,
93
+ status,
94
+ exit_code: Number.isInteger(runtimeResult?.status) ? runtimeResult.status : null,
95
+ signal: runtimeResult?.signal || null,
96
+ stdout_tail: trimOutputTail(runtimeResult?.stdout),
97
+ stderr_tail: trimOutputTail(runtimeResult?.stderr),
98
+ spawn_error: runtimeResult?.error?.message || null,
99
+ };
100
+ }
101
+
102
+ export function executeGateActions(root, config, meta, opts = {}) {
103
+ const actions = getGateActions(config, meta.gateId);
104
+ if (actions.length === 0) {
105
+ return { ok: true, dry_run: Boolean(opts.dryRun), actions: [] };
106
+ }
107
+
108
+ if (opts.dryRun) {
109
+ return { ok: true, dry_run: true, actions };
110
+ }
111
+
112
+ const attemptId = `ga_${Date.now()}_${randomBytes(4).toString('hex')}`;
113
+ const results = [];
114
+
115
+ for (const action of actions) {
116
+ const runtimeResult = spawnSync('/bin/sh', ['-lc', action.run], {
117
+ cwd: root,
118
+ env: {
119
+ ...process.env,
120
+ AGENTXCHAIN_GATE_ID: meta.gateId,
121
+ AGENTXCHAIN_GATE_TYPE: meta.gateType,
122
+ AGENTXCHAIN_PHASE: meta.phase || '',
123
+ AGENTXCHAIN_REQUESTED_BY_TURN: meta.requestedByTurn || '',
124
+ AGENTXCHAIN_TRIGGER_COMMAND: meta.triggerCommand || '',
125
+ },
126
+ encoding: 'utf8',
127
+ maxBuffer: 10 * 1024 * 1024,
128
+ });
129
+
130
+ const status = runtimeResult.error || runtimeResult.status !== 0 ? 'failed' : 'succeeded';
131
+ const entry = buildGateActionEntry(action, { ...meta, attemptId }, runtimeResult, status);
132
+ results.push(entry);
133
+
134
+ if (status === 'failed') {
135
+ return {
136
+ ok: false,
137
+ attempt_id: attemptId,
138
+ actions: results,
139
+ failed_action: entry,
140
+ error: `Gate action failed for "${meta.gateId}": ${action.label || action.run}`,
141
+ };
142
+ }
143
+ }
144
+
145
+ return {
146
+ ok: true,
147
+ attempt_id: attemptId,
148
+ actions: results,
149
+ };
150
+ }
151
+
152
+ export function normalizeGateActionEntry(entry) {
153
+ if (!entry || entry.type !== 'gate_action') {
154
+ return null;
155
+ }
156
+
157
+ return {
158
+ attempt_id: entry.attempt_id || null,
159
+ gate_id: entry.gate_id || null,
160
+ gate_type: entry.gate_type || null,
161
+ phase: entry.phase || null,
162
+ requested_by_turn: entry.requested_by_turn || null,
163
+ trigger_command: entry.trigger_command || null,
164
+ action_index: Number.isInteger(entry.action_index) ? entry.action_index : null,
165
+ action_label: entry.action_label || null,
166
+ command: entry.command || null,
167
+ status: entry.status || 'unknown',
168
+ exit_code: Number.isInteger(entry.exit_code) ? entry.exit_code : null,
169
+ signal: entry.signal || null,
170
+ stdout_tail: entry.stdout_tail || null,
171
+ stderr_tail: entry.stderr_tail || null,
172
+ spawn_error: entry.spawn_error || null,
173
+ timestamp: entry.timestamp || null,
174
+ };
175
+ }
176
+
177
+ export function extractGateActionDigest(entries) {
178
+ if (!Array.isArray(entries) || entries.length === 0) {
179
+ return [];
180
+ }
181
+
182
+ return entries
183
+ .map(normalizeGateActionEntry)
184
+ .filter(Boolean)
185
+ .sort((a, b) => (a.timestamp || '').localeCompare(b.timestamp || ''));
186
+ }
187
+
188
+ export function readGateActionEntries(root) {
189
+ const filePath = join(root, LEDGER_PATH);
190
+ if (!existsSync(filePath)) {
191
+ return [];
192
+ }
193
+
194
+ const raw = readFileSync(filePath, 'utf8').trim();
195
+ if (!raw) {
196
+ return [];
197
+ }
198
+
199
+ const entries = raw
200
+ .split('\n')
201
+ .filter(Boolean)
202
+ .map((line) => {
203
+ try {
204
+ return JSON.parse(line);
205
+ } catch {
206
+ return null;
207
+ }
208
+ })
209
+ .filter(Boolean);
210
+
211
+ return extractGateActionDigest(entries);
212
+ }
213
+
214
+ export function summarizeLatestGateActionAttempt(root, gateType, gateId) {
215
+ const entries = readGateActionEntries(root)
216
+ .filter((entry) => entry.gate_type === gateType && entry.gate_id === gateId);
217
+
218
+ if (entries.length === 0) {
219
+ return null;
220
+ }
221
+
222
+ const latest = entries[entries.length - 1];
223
+ const attemptEntries = entries.filter((entry) => entry.attempt_id === latest.attempt_id);
224
+ return {
225
+ attempt_id: latest.attempt_id,
226
+ gate_id: gateId,
227
+ gate_type: gateType,
228
+ status: attemptEntries.some((entry) => entry.status === 'failed') ? 'failed' : 'succeeded',
229
+ attempted_at: latest.timestamp || null,
230
+ actions: attemptEntries,
231
+ };
232
+ }