agentxchain 2.104.0 → 2.105.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.
Files changed (62) hide show
  1. package/README.md +12 -6
  2. package/bin/agentxchain.js +5 -5
  3. package/dashboard/app.js +111 -7
  4. package/dashboard/components/blocked.js +95 -11
  5. package/dashboard/components/blockers.js +85 -86
  6. package/dashboard/components/coordinator-timeouts.js +13 -0
  7. package/dashboard/components/cross-repo.js +17 -12
  8. package/dashboard/components/gate.js +31 -11
  9. package/dashboard/components/initiative.js +173 -78
  10. package/dashboard/components/ledger.js +28 -0
  11. package/dashboard/components/live-status.js +39 -0
  12. package/dashboard/components/run-history.js +76 -1
  13. package/dashboard/components/timeline.js +5 -1
  14. package/dashboard/index.html +21 -0
  15. package/dashboard/live-observer.js +91 -0
  16. package/package.json +1 -1
  17. package/scripts/release-bump.sh +26 -3
  18. package/src/commands/accept-turn.js +3 -3
  19. package/src/commands/decisions.js +98 -29
  20. package/src/commands/diff.js +27 -4
  21. package/src/commands/doctor.js +48 -16
  22. package/src/commands/history.js +21 -3
  23. package/src/commands/multi.js +223 -54
  24. package/src/commands/phase.js +11 -13
  25. package/src/commands/reject-turn.js +1 -1
  26. package/src/commands/restart.js +28 -11
  27. package/src/commands/resume.js +6 -6
  28. package/src/commands/role.js +51 -14
  29. package/src/commands/run.js +5 -11
  30. package/src/commands/status.js +145 -13
  31. package/src/commands/step.js +36 -29
  32. package/src/lib/admission-control.js +14 -12
  33. package/src/lib/blocked-state.js +150 -0
  34. package/src/lib/conflict-actions.js +17 -0
  35. package/src/lib/context-section-parser.js +2 -0
  36. package/src/lib/continuity-status.js +1 -1
  37. package/src/lib/coordinator-blocker-presentation.js +127 -0
  38. package/src/lib/coordinator-event-narrative.js +43 -0
  39. package/src/lib/coordinator-gate-approval.js +98 -0
  40. package/src/lib/coordinator-gate-evaluation-presentation.js +57 -0
  41. package/src/lib/coordinator-next-actions.js +128 -0
  42. package/src/lib/coordinator-pending-gate-presentation.js +79 -0
  43. package/src/lib/coordinator-presentation-detail.js +11 -0
  44. package/src/lib/coordinator-repo-snapshots.js +53 -0
  45. package/src/lib/coordinator-repo-status-presentation.js +134 -0
  46. package/src/lib/dashboard/actions.js +105 -29
  47. package/src/lib/dashboard/bridge-server.js +7 -0
  48. package/src/lib/dashboard/coordinator-blockers.js +17 -0
  49. package/src/lib/dashboard/coordinator-repo-status.js +50 -0
  50. package/src/lib/dashboard/coordinator-timeout-status.js +34 -11
  51. package/src/lib/dashboard/state-reader.js +36 -1
  52. package/src/lib/dispatch-bundle.js +23 -0
  53. package/src/lib/export-diff.js +70 -38
  54. package/src/lib/export-verifier.js +3 -0
  55. package/src/lib/history-diff-summary.js +249 -0
  56. package/src/lib/manual-qa-fallback.js +18 -0
  57. package/src/lib/normalized-config.js +27 -22
  58. package/src/lib/recent-event-summary.js +132 -0
  59. package/src/lib/repo-decisions.js +69 -28
  60. package/src/lib/report.js +353 -145
  61. package/src/lib/run-diff.js +4 -0
  62. package/src/lib/runtime-capabilities.js +222 -0
@@ -0,0 +1,249 @@
1
+ function normalizeSingleLine(value) {
2
+ if (typeof value !== 'string') return null;
3
+ const normalized = value.replace(/\s+/g, ' ').trim();
4
+ return normalized.length > 0 ? normalized : null;
5
+ }
6
+
7
+ export function buildRunOutcomeSummary(entry) {
8
+ const status = typeof entry?.status === 'string' ? entry.status : 'unknown';
9
+ const nextAction = normalizeSingleLine(
10
+ entry?.retrospective?.next_operator_action
11
+ || entry?.retrospective?.follow_on_hint
12
+ || null,
13
+ );
14
+
15
+ if (status === 'blocked' && nextAction) {
16
+ return { label: 'operator', status, next_action: nextAction };
17
+ }
18
+ if (status === 'blocked') {
19
+ return { label: 'blocked', status, next_action: null };
20
+ }
21
+ if (status === 'completed' && nextAction) {
22
+ return { label: 'follow-on', status, next_action: nextAction };
23
+ }
24
+ if (status === 'completed') {
25
+ return { label: 'clean', status, next_action: null };
26
+ }
27
+ return { label: 'unknown', status, next_action: nextAction };
28
+ }
29
+
30
+ export function buildRunDiffSummary(diff) {
31
+ if (!diff?.changed) {
32
+ return {
33
+ outcome: 'unchanged',
34
+ risk_level: 'none',
35
+ highlights: [],
36
+ changed_field_count: 0,
37
+ };
38
+ }
39
+
40
+ const regressionSignals = [];
41
+ const improvementSignals = [];
42
+ const changeSignals = [];
43
+
44
+ const statusChange = diff.scalar_changes?.status;
45
+ if (statusChange?.changed) {
46
+ const leftRank = getRunStatusRank(statusChange.left);
47
+ const rightRank = getRunStatusRank(statusChange.right);
48
+ if (rightRank < leftRank) {
49
+ regressionSignals.push(`status worsened to ${statusChange.right}`);
50
+ } else if (rightRank > leftRank) {
51
+ improvementSignals.push(`status improved to ${statusChange.right}`);
52
+ } else {
53
+ changeSignals.push(`status changed to ${statusChange.right}`);
54
+ }
55
+ }
56
+
57
+ const blockedReason = diff.scalar_changes?.blocked_reason;
58
+ if (blockedReason?.changed && blockedReason.right) {
59
+ regressionSignals.push(`blocked reason: ${blockedReason.right}`);
60
+ }
61
+
62
+ const nextAction = diff.scalar_changes?.next_action;
63
+ if (nextAction?.changed) {
64
+ if (nextAction.right) {
65
+ changeSignals.push(`next action: ${nextAction.right}`);
66
+ } else {
67
+ improvementSignals.push('operator follow-up cleared');
68
+ }
69
+ }
70
+
71
+ for (const gateChange of diff.gate_changes || []) {
72
+ if (!gateChange.changed) continue;
73
+ if (isBlockingGateState(gateChange.right) && !isBlockingGateState(gateChange.left)) {
74
+ regressionSignals.push(`gate ${gateChange.gate_id} is ${gateChange.right}`);
75
+ } else if (isPassingGateState(gateChange.right) && isBlockingGateState(gateChange.left)) {
76
+ improvementSignals.push(`gate ${gateChange.gate_id} recovered to ${gateChange.right}`);
77
+ } else {
78
+ changeSignals.push(`gate ${gateChange.gate_id}: ${gateChange.left ?? '—'} -> ${gateChange.right ?? '—'}`);
79
+ }
80
+ }
81
+
82
+ const phases = diff.list_changes?.phases_completed;
83
+ if (phases?.changed) {
84
+ if (phases.added.length > 0) {
85
+ changeSignals.push(`phases added: ${phases.added.join(', ')}`);
86
+ }
87
+ if (phases.removed.length > 0) {
88
+ regressionSignals.push(`phases removed: ${phases.removed.join(', ')}`);
89
+ }
90
+ }
91
+
92
+ const roles = diff.list_changes?.roles_used;
93
+ if (roles?.changed) {
94
+ if (roles.added.length > 0) {
95
+ changeSignals.push(`roles added: ${roles.added.join(', ')}`);
96
+ }
97
+ if (roles.removed.length > 0) {
98
+ changeSignals.push(`roles removed: ${roles.removed.join(', ')}`);
99
+ }
100
+ }
101
+
102
+ const cost = diff.numeric_changes?.total_cost_usd;
103
+ if (cost?.changed) {
104
+ changeSignals.push(`cost ${formatSignedNumber(cost.delta, 4, '$')}`);
105
+ }
106
+
107
+ const duration = diff.numeric_changes?.duration_ms;
108
+ if (duration?.changed) {
109
+ changeSignals.push(`duration ${formatDurationDelta(duration.delta)}`);
110
+ }
111
+
112
+ let outcome = 'changed';
113
+ let riskLevel = 'low';
114
+ if (regressionSignals.length > 0 && improvementSignals.length > 0) {
115
+ outcome = 'mixed';
116
+ riskLevel = regressionSignals.some((signal) => signal.startsWith('status worsened') || signal.startsWith('gate '))
117
+ ? 'high'
118
+ : 'medium';
119
+ } else if (regressionSignals.length > 0) {
120
+ outcome = 'regressed';
121
+ riskLevel = regressionSignals.some((signal) => signal.startsWith('status worsened') || signal.startsWith('gate '))
122
+ ? 'high'
123
+ : 'medium';
124
+ } else if (improvementSignals.length > 0) {
125
+ outcome = 'improved';
126
+ riskLevel = changeSignals.length > 0 ? 'low' : 'none';
127
+ }
128
+
129
+ return {
130
+ outcome,
131
+ risk_level: riskLevel,
132
+ highlights: [...regressionSignals, ...improvementSignals, ...changeSignals].slice(0, 3),
133
+ changed_field_count: countChangedFields(diff),
134
+ };
135
+ }
136
+
137
+ export function buildExportDiffSummary(diff) {
138
+ if (!diff?.changed) {
139
+ return {
140
+ outcome: 'unchanged',
141
+ risk_level: 'none',
142
+ highlights: [],
143
+ changed_field_count: 0,
144
+ };
145
+ }
146
+
147
+ const regressions = Array.isArray(diff.regressions) ? diff.regressions : [];
148
+ const highlights = [];
149
+
150
+ if (regressions.length > 0) {
151
+ for (const reg of regressions.slice(0, 3)) {
152
+ highlights.push(`${reg.id}: ${reg.message}`);
153
+ }
154
+ return {
155
+ outcome: 'regressed',
156
+ risk_level: regressions.some((reg) => reg.severity === 'error') ? 'high' : 'medium',
157
+ highlights,
158
+ changed_field_count: countChangedFields(diff),
159
+ };
160
+ }
161
+
162
+ pushFirstChangeHighlights(diff, highlights);
163
+
164
+ return {
165
+ outcome: 'changed',
166
+ risk_level: 'low',
167
+ highlights: highlights.slice(0, 3),
168
+ changed_field_count: countChangedFields(diff),
169
+ };
170
+ }
171
+
172
+ function pushFirstChangeHighlights(diff, highlights) {
173
+ const scalarChanges = Object.values(diff.scalar_changes || {}).filter((entry) => entry.changed);
174
+ for (const entry of scalarChanges) {
175
+ if (highlights.length >= 3) return;
176
+ highlights.push(`${entry.label}: ${entry.left ?? '—'} -> ${entry.right ?? '—'}`);
177
+ }
178
+
179
+ const numericChanges = Object.values(diff.numeric_changes || {}).filter((entry) => entry.changed);
180
+ for (const entry of numericChanges) {
181
+ if (highlights.length >= 3) return;
182
+ highlights.push(`${entry.label}: ${entry.left ?? '—'} -> ${entry.right ?? '—'}`);
183
+ }
184
+
185
+ const listChanges = Object.values(diff.list_changes || {}).filter((entry) => entry.changed);
186
+ for (const entry of listChanges) {
187
+ if (highlights.length >= 3) return;
188
+ if (entry.added?.length > 0) {
189
+ highlights.push(`${entry.label} added: ${entry.added.join(', ')}`);
190
+ } else if (entry.removed?.length > 0) {
191
+ highlights.push(`${entry.label} removed: ${entry.removed.join(', ')}`);
192
+ }
193
+ }
194
+ }
195
+
196
+ function countChangedFields(diff) {
197
+ const scalar = Object.values(diff.scalar_changes || {}).filter((entry) => entry.changed).length;
198
+ const numeric = Object.values(diff.numeric_changes || {}).filter((entry) => entry.changed).length;
199
+ const lists = Object.values(diff.list_changes || {}).filter((entry) => entry.changed).length;
200
+ const gates = Array.isArray(diff.gate_changes)
201
+ ? diff.gate_changes.filter((entry) => entry.changed).length
202
+ : 0;
203
+ const repoStatuses = Array.isArray(diff.repo_status_changes)
204
+ ? diff.repo_status_changes.filter((entry) => entry.changed).length
205
+ : 0;
206
+ const repoExports = Array.isArray(diff.repo_export_changes)
207
+ ? diff.repo_export_changes.filter((entry) => entry.changed).length
208
+ : 0;
209
+ const eventTypes = Array.isArray(diff.event_type_changes)
210
+ ? diff.event_type_changes.filter((entry) => entry.changed).length
211
+ : 0;
212
+ return scalar + numeric + lists + gates + repoStatuses + repoExports + eventTypes;
213
+ }
214
+
215
+ function getRunStatusRank(status) {
216
+ if (status === 'completed') return 3;
217
+ if (status === 'blocked') return 2;
218
+ if (status === 'failed') return 1;
219
+ return 0;
220
+ }
221
+
222
+ function isBlockingGateState(value) {
223
+ return value === 'failed' || value === 'blocked' || value === 'pending';
224
+ }
225
+
226
+ function isPassingGateState(value) {
227
+ return value === 'passed' || value === 'approved';
228
+ }
229
+
230
+ function formatSignedNumber(value, digits, prefix = '') {
231
+ if (typeof value !== 'number' || Number.isNaN(value) || value === 0) return 'unchanged';
232
+ const sign = value > 0 ? '+' : '';
233
+ return `${sign}${prefix}${Math.abs(value).toFixed(digits)}`;
234
+ }
235
+
236
+ function formatDurationDelta(ms) {
237
+ if (typeof ms !== 'number' || Number.isNaN(ms) || ms === 0) return 'unchanged';
238
+ const sign = ms > 0 ? '+' : '-';
239
+ const absolute = Math.abs(ms);
240
+ if (absolute < 1000) return `${sign}${absolute}ms`;
241
+ const secs = Math.floor(absolute / 1000);
242
+ if (secs < 60) return `${sign}${secs}s`;
243
+ const mins = Math.floor(secs / 60);
244
+ const remainSecs = secs % 60;
245
+ if (mins < 60) return `${sign}${mins}m ${remainSecs}s`;
246
+ const hrs = Math.floor(mins / 60);
247
+ const remainMins = mins % 60;
248
+ return `${sign}${hrs}h ${remainMins}m`;
249
+ }
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Narrow recovery hint for the built-in manual QA fallback path.
3
+ *
4
+ * The check must use normalized config so operator-facing guidance stays
5
+ * consistent across commands and does not depend on raw config exceptions.
6
+ */
7
+ export function shouldSuggestManualQaFallback({
8
+ roleId,
9
+ runtimeId,
10
+ classified,
11
+ config,
12
+ }) {
13
+ return classified?.error_class === 'missing_credentials'
14
+ && roleId === 'qa'
15
+ && runtimeId === 'api-qa'
16
+ && config?.roles?.qa?.runtime_id === 'api-qa'
17
+ && config?.runtimes?.['manual-qa']?.type === 'manual';
18
+ }
@@ -15,6 +15,11 @@
15
15
  import { validateHooksConfig } from './hook-runner.js';
16
16
  import { validateNotificationsConfig } from './notification-runner.js';
17
17
  import { validatePolicies, normalizePolicies } from './policy-evaluator.js';
18
+ import {
19
+ canRoleParticipateInRequiredFileProduction,
20
+ canRoleSatisfyWorkflowArtifactOwnership,
21
+ getRoleRuntimeCapabilityContract,
22
+ } from './runtime-capabilities.js';
18
23
  import { validateTimeoutsConfig } from './timeout-evaluator.js';
19
24
  import { SUPPORTED_TOKEN_COUNTER_PROVIDERS } from './token-counter.js';
20
25
  import {
@@ -455,21 +460,12 @@ export function validateV4Config(data, projectRoot) {
455
460
  // Cross-reference: review_only roles should not use authoritative runtimes
456
461
  if (data.roles && data.runtimes) {
457
462
  for (const [id, role] of Object.entries(data.roles)) {
458
- if (role.write_authority === 'review_only' && role.runtime && data.runtimes[role.runtime]) {
459
- const rt = data.runtimes[role.runtime];
460
- if (rt.type === 'local_cli') {
461
- errors.push(`Role "${id}" is review_only but uses local_cli runtime "${role.runtime}" — review_only roles should not have authoritative write access`);
462
- }
463
- }
464
- // api_proxy and remote_agent restriction: only review_only and proposed roles may bind.
465
- // These adapters do not have a proven local workspace mutation path in v1.
466
463
  if (role.runtime && data.runtimes[role.runtime]) {
467
464
  const rt = data.runtimes[role.runtime];
468
- if (
469
- (rt.type === 'api_proxy' || rt.type === 'remote_agent')
470
- && role.write_authority !== 'review_only'
471
- && role.write_authority !== 'proposed'
472
- ) {
465
+ const contract = getRoleRuntimeCapabilityContract(id, role, rt);
466
+ if (contract.effective_write_path === 'invalid_review_only_binding') {
467
+ errors.push(`Role "${id}" is review_only but uses local_cli runtime "${role.runtime}" review_only roles should not have authoritative write access`);
468
+ } else if (contract.effective_write_path === 'invalid_authoritative_binding') {
473
469
  errors.push(
474
470
  `Role "${id}" has write_authority "${role.write_authority}" but uses ${rt.type} runtime "${role.runtime}" — ${rt.type} only supports review_only and proposed roles`
475
471
  );
@@ -534,7 +530,7 @@ export function validateV4Config(data, projectRoot) {
534
530
 
535
531
  // Workflow Kit (optional but validated if present)
536
532
  if (data.workflow_kit !== undefined) {
537
- const wkValidation = validateWorkflowKitConfig(data.workflow_kit, data.routing, data.roles);
533
+ const wkValidation = validateWorkflowKitConfig(data.workflow_kit, data.routing, data.roles, data.runtimes);
538
534
  errors.push(...wkValidation.errors);
539
535
  }
540
536
 
@@ -697,7 +693,7 @@ export function validateSchedulesConfig(schedules, roles) {
697
693
  * Validate the workflow_kit config section.
698
694
  * Returns { ok, errors, warnings }.
699
695
  */
700
- export function validateWorkflowKitConfig(wk, routing, roles) {
696
+ export function validateWorkflowKitConfig(wk, routing, roles, runtimes = {}) {
701
697
  const errors = [];
702
698
  const warnings = [];
703
699
 
@@ -837,20 +833,25 @@ export function validateWorkflowKitConfig(wk, routing, roles) {
837
833
  } else if (
838
834
  artifact.required !== false &&
839
835
  roles && typeof roles === 'object' &&
840
- roles[artifact.owned_by]?.write_authority === 'review_only'
836
+ roles[artifact.owned_by]
841
837
  ) {
842
- // Check if any authoritative/proposed role exists in this phase's routing
838
+ const ownerRole = roles[artifact.owned_by];
839
+ const ownerRuntimeKey = ownerRole.runtime_id || ownerRole.runtime;
840
+ const ownerRuntime = runtimes?.[ownerRuntimeKey];
843
841
  const phaseRouting = routing?.[phase];
844
842
  const phaseRoles = new Set([
845
843
  ...(phaseRouting?.allowed_next_roles || []),
846
844
  ...(phaseRouting?.entry_role ? [phaseRouting.entry_role] : []),
847
845
  ]);
848
- const hasWriter = [...phaseRoles].some(rid =>
849
- roles[rid]?.write_authority === 'authoritative' || roles[rid]?.write_authority === 'proposed',
850
- );
851
- if (!hasWriter) {
846
+ const hasReachableProducer = [...phaseRoles].some((rid) => {
847
+ const phaseRole = roles[rid];
848
+ if (!phaseRole) return false;
849
+ const phaseRuntimeKey = phaseRole.runtime_id || phaseRole.runtime;
850
+ return canRoleParticipateInRequiredFileProduction(phaseRole, runtimes?.[phaseRuntimeKey]);
851
+ });
852
+ if (!canRoleSatisfyWorkflowArtifactOwnership(ownerRole, ownerRuntime) && !hasReachableProducer) {
852
853
  warnings.push(
853
- `${prefix} owned_by "${artifact.owned_by}" is a review_only role in phase "${phase}" with no authoritative or proposed role — nobody can write this required artifact`,
854
+ `${prefix} owned_by "${artifact.owned_by}" has no reachable workflow ownership path in phase "${phase}", and no routed role can satisfy the required artifact`,
854
855
  );
855
856
  }
856
857
  }
@@ -1053,6 +1054,9 @@ export function normalizeV4(raw) {
1053
1054
  title: role.title,
1054
1055
  mandate: role.mandate,
1055
1056
  write_authority: role.write_authority,
1057
+ ...(typeof role.decision_authority === 'number'
1058
+ ? { decision_authority: role.decision_authority }
1059
+ : {}),
1056
1060
  runtime_class: raw.runtimes?.[role.runtime]?.type || 'manual',
1057
1061
  runtime_id: role.runtime,
1058
1062
  };
@@ -1205,6 +1209,7 @@ export function normalizeWorkflowKit(raw, routingPhases) {
1205
1209
  if (raw.phases) {
1206
1210
  for (const [phase, phaseConfig] of Object.entries(raw.phases)) {
1207
1211
  phases[phase] = {
1212
+ ...(typeof phaseConfig?.template === 'string' ? { template: phaseConfig.template } : {}),
1208
1213
  artifacts: expandWorkflowKitPhaseArtifacts(phaseConfig).map(a => ({
1209
1214
  path: a.path,
1210
1215
  semantics: a.semantics || null,
@@ -0,0 +1,132 @@
1
+ import { readRunEvents } from './run-events.js';
2
+
3
+ export const RECENT_EVENT_WINDOW_MINUTES = 15;
4
+ export const RECENT_EVENT_WINDOW_MS = RECENT_EVENT_WINDOW_MINUTES * 60 * 1000;
5
+
6
+ function isObject(value) {
7
+ return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
8
+ }
9
+
10
+ function isValidTimestamp(timestamp) {
11
+ return typeof timestamp === 'string' && timestamp.trim().length > 0 && !Number.isNaN(new Date(timestamp).getTime());
12
+ }
13
+
14
+ function trimToNull(value) {
15
+ return typeof value === 'string' && value.trim().length > 0 ? value.trim() : null;
16
+ }
17
+
18
+ function describeEvent(eventType, entry) {
19
+ const repoId = trimToNull(entry.repo_id);
20
+ const roleId = trimToNull(entry.role_id);
21
+ const gateId = trimToNull(entry.payload?.gate_id) || trimToNull(entry.gate);
22
+ const prefix = repoId ? `[${repoId}] ` : '';
23
+
24
+ switch (eventType) {
25
+ case 'turn_dispatched':
26
+ case 'turn_accepted':
27
+ case 'turn_rejected':
28
+ return `${prefix}${eventType}${roleId ? ` [${roleId}]` : ''}`;
29
+ case 'phase_entered': {
30
+ const fromPhase = trimToNull(entry.payload?.from);
31
+ const toPhase = trimToNull(entry.payload?.to) || trimToNull(entry.phase);
32
+ if (fromPhase && toPhase) return `${prefix}${eventType} ${fromPhase} -> ${toPhase}`;
33
+ return `${prefix}${eventType}`;
34
+ }
35
+ case 'gate_pending':
36
+ case 'gate_approved':
37
+ case 'gate_failed':
38
+ return `${prefix}${eventType}${gateId ? ` (${gateId})` : ''}`;
39
+ case 'run_blocked':
40
+ case 'run_completed':
41
+ case 'run_started':
42
+ case 'escalation_raised':
43
+ case 'escalation_resolved':
44
+ case 'budget_exceeded_warn':
45
+ return `${prefix}${eventType}`;
46
+ default:
47
+ if (trimToNull(entry.summary)) return entry.summary.trim();
48
+ return `${prefix}${eventType || 'unknown_event'}`;
49
+ }
50
+ }
51
+
52
+ export function normalizeRecentEventEntry(entry) {
53
+ if (!isObject(entry)) return null;
54
+ const turn = isObject(entry.turn) ? entry.turn : null;
55
+ const eventType = trimToNull(entry.event_type) || trimToNull(entry.type) || 'unknown_event';
56
+ const phase = trimToNull(entry.phase);
57
+ const status = trimToNull(entry.status);
58
+ const turnId = trimToNull(turn?.turn_id) || trimToNull(entry.turn_id) || null;
59
+ const roleId = trimToNull(turn?.role_id)
60
+ || trimToNull(turn?.assigned_role)
61
+ || trimToNull(entry.role_id)
62
+ || trimToNull(entry.role)
63
+ || null;
64
+ const repoId = trimToNull(entry.repo_id);
65
+ const timestamp = trimToNull(entry.timestamp);
66
+
67
+ return {
68
+ event_type: eventType,
69
+ timestamp,
70
+ phase,
71
+ status,
72
+ turn_id: turnId,
73
+ role_id: roleId,
74
+ repo_id: repoId,
75
+ summary: describeEvent(eventType, { ...entry, role_id: roleId, repo_id: repoId, turn }),
76
+ };
77
+ }
78
+
79
+ export function buildRecentEventSummary(entries, { now = Date.now(), windowMs = RECENT_EVENT_WINDOW_MS } = {}) {
80
+ const normalized = Array.isArray(entries)
81
+ ? entries.map(normalizeRecentEventEntry).filter(Boolean)
82
+ : [];
83
+
84
+ if (normalized.length === 0) {
85
+ return {
86
+ window_minutes: Math.round(windowMs / 60000),
87
+ freshness: 'no_events',
88
+ recent_count: 0,
89
+ latest_event: null,
90
+ };
91
+ }
92
+
93
+ const latestEvent = normalized[normalized.length - 1];
94
+ const recentCount = normalized.filter((event) => {
95
+ if (!isValidTimestamp(event.timestamp)) return false;
96
+ return (now - new Date(event.timestamp).getTime()) <= windowMs;
97
+ }).length;
98
+
99
+ let freshness = 'unknown';
100
+ if (isValidTimestamp(latestEvent.timestamp)) {
101
+ freshness = (now - new Date(latestEvent.timestamp).getTime()) <= windowMs ? 'recent' : 'quiet';
102
+ }
103
+
104
+ return {
105
+ window_minutes: Math.round(windowMs / 60000),
106
+ freshness,
107
+ recent_count: recentCount,
108
+ latest_event: latestEvent,
109
+ };
110
+ }
111
+
112
+ export function readRecentRunEventSummary(root, opts = {}) {
113
+ const events = readRunEvents(root);
114
+ return buildRecentEventSummary(events, opts);
115
+ }
116
+
117
+ export function formatRecentEventSummaryLine(summary, scopeLabel = null) {
118
+ const prefix = scopeLabel ? `${scopeLabel}: ` : '';
119
+ if (!summary || typeof summary !== 'object') return `${prefix}unknown`;
120
+ const countLabel = `${summary.recent_count || 0} in last ${summary.window_minutes || RECENT_EVENT_WINDOW_MINUTES}m`;
121
+ switch (summary.freshness) {
122
+ case 'recent':
123
+ return `${prefix}recent (${countLabel})`;
124
+ case 'quiet':
125
+ return `${prefix}quiet (${countLabel})`;
126
+ case 'unknown':
127
+ return `${prefix}unknown timing`;
128
+ case 'no_events':
129
+ default:
130
+ return `${prefix}none recorded`;
131
+ }
132
+ }
@@ -66,38 +66,15 @@ export function summarizeRepoDecisions(decisions, config) {
66
66
  if (!Array.isArray(decisions) || decisions.length === 0) return null;
67
67
  const active = decisions.filter((d) => d.status === 'active');
68
68
  const overridden = decisions.filter((d) => d.status === 'overridden');
69
- const addAuthority = (decision) => {
70
- const authority = getDecisionAuthorityMetadata(decision.role, config);
71
- return {
72
- id: decision.id,
73
- category: decision.category,
74
- statement: decision.statement,
75
- role: decision.role,
76
- run_id: decision.run_id,
77
- overrides: decision.overrides || null,
78
- durability: decision.durability || 'repo',
79
- authority_level: authority?.level ?? null,
80
- authority_source: authority?.source || null,
81
- };
82
- };
69
+ const activeEntries = active.map((decision) => addActiveAuthority(decision, config));
70
+ const overriddenEntries = overridden.map((decision) => addOverriddenAuthority(decision, config));
83
71
  return {
84
72
  total: decisions.length,
85
73
  active_count: active.length,
86
74
  overridden_count: overridden.length,
87
- active: active.map(addAuthority),
88
- overridden: overridden.map((d) => {
89
- const authority = getDecisionAuthorityMetadata(d.role, config);
90
- return {
91
- id: d.id,
92
- overridden_by: d.overridden_by,
93
- statement: d.statement,
94
- overrides: d.overrides || null,
95
- durability: d.durability || 'repo',
96
- role: d.role || null,
97
- authority_level: authority?.level ?? null,
98
- authority_source: authority?.source || null,
99
- };
100
- }),
75
+ active: activeEntries,
76
+ overridden: overriddenEntries,
77
+ operator_summary: buildRepoDecisionOperatorSummaryFromEntries(activeEntries, overriddenEntries),
101
78
  };
102
79
  }
103
80
 
@@ -105,6 +82,17 @@ export function buildRepoDecisionsSummary(decisions) {
105
82
  return summarizeRepoDecisions(decisions, null);
106
83
  }
107
84
 
85
+ export function buildRepoDecisionOperatorSummary(decisions, config) {
86
+ if (!Array.isArray(decisions) || decisions.length === 0) return null;
87
+ const activeEntries = decisions
88
+ .filter((decision) => decision.status === 'active')
89
+ .map((decision) => addActiveAuthority(decision, config));
90
+ const overriddenEntries = decisions
91
+ .filter((decision) => decision.status === 'overridden')
92
+ .map((decision) => addOverriddenAuthority(decision, config));
93
+ return buildRepoDecisionOperatorSummaryFromEntries(activeEntries, overriddenEntries);
94
+ }
95
+
108
96
  // ── Write ───────────────────────────────────────────────────────────────────
109
97
 
110
98
  export function appendRepoDecision(root, entry) {
@@ -255,6 +243,59 @@ export function renderRepoDecisionsMarkdown(activeDecisions, config) {
255
243
  return lines.join('\n');
256
244
  }
257
245
 
246
+ function addActiveAuthority(decision, config) {
247
+ const authority = getDecisionAuthorityMetadata(decision.role, config);
248
+ return {
249
+ id: decision.id,
250
+ category: decision.category,
251
+ statement: decision.statement,
252
+ role: decision.role,
253
+ run_id: decision.run_id,
254
+ overrides: decision.overrides || null,
255
+ durability: decision.durability || 'repo',
256
+ authority_level: authority?.level ?? null,
257
+ authority_source: authority?.source || null,
258
+ };
259
+ }
260
+
261
+ function addOverriddenAuthority(decision, config) {
262
+ const authority = getDecisionAuthorityMetadata(decision.role, config);
263
+ return {
264
+ id: decision.id,
265
+ overridden_by: decision.overridden_by,
266
+ statement: decision.statement,
267
+ overrides: decision.overrides || null,
268
+ durability: decision.durability || 'repo',
269
+ role: decision.role || null,
270
+ authority_level: authority?.level ?? null,
271
+ authority_source: authority?.source || null,
272
+ };
273
+ }
274
+
275
+ function buildRepoDecisionOperatorSummaryFromEntries(activeEntries, overriddenEntries) {
276
+ const activeCategories = [...new Set(
277
+ activeEntries
278
+ .map((decision) => decision.category)
279
+ .filter(Boolean),
280
+ )].sort();
281
+
282
+ const highestAuthority = activeEntries
283
+ .filter((decision) => typeof decision.authority_level === 'number')
284
+ .reduce((current, decision) => {
285
+ if (!current || decision.authority_level > current.authority_level) return decision;
286
+ return current;
287
+ }, null);
288
+
289
+ return {
290
+ active_categories: activeCategories,
291
+ highest_active_authority_level: highestAuthority?.authority_level ?? null,
292
+ highest_active_authority_role: highestAuthority?.role ?? null,
293
+ highest_active_authority_source: highestAuthority?.authority_source ?? null,
294
+ superseding_active_count: activeEntries.filter((decision) => decision.overrides).length,
295
+ overridden_with_successor_count: overriddenEntries.filter((decision) => decision.overridden_by).length,
296
+ };
297
+ }
298
+
258
299
  // ── Constants ───────────────────────────────────────────────────────────────
259
300
 
260
301
  export { REPO_DECISIONS_PATH };