agentic-orchestrator 0.1.25 → 0.1.26

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 (177) hide show
  1. package/agentic/orchestrator/defaults/policy.defaults.yaml +1 -0
  2. package/agentic/orchestrator/policy.yaml +1 -0
  3. package/agentic/orchestrator/schemas/policy.schema.json +4 -0
  4. package/agentic/orchestrator/schemas/policy.user.schema.json +4 -0
  5. package/apps/control-plane/src/application/services/activity-monitor-service.ts +42 -0
  6. package/apps/control-plane/src/application/services/collision-queue-service.ts +48 -0
  7. package/apps/control-plane/src/application/services/cost-tracking-service.ts +80 -0
  8. package/apps/control-plane/src/application/services/feature-deletion-service.ts +57 -0
  9. package/apps/control-plane/src/application/services/feature-lifecycle-service.ts +65 -0
  10. package/apps/control-plane/src/application/services/feature-state-service.ts +87 -0
  11. package/apps/control-plane/src/application/services/gate-selection-service.ts +28 -0
  12. package/apps/control-plane/src/application/services/gate-service.ts +148 -0
  13. package/apps/control-plane/src/application/services/issue-tracker-service.ts +28 -0
  14. package/apps/control-plane/src/application/services/lock-service.ts +88 -0
  15. package/apps/control-plane/src/application/services/merge-service.ts +63 -0
  16. package/apps/control-plane/src/application/services/notifier-service.ts +70 -0
  17. package/apps/control-plane/src/application/services/patch-service.ts +95 -0
  18. package/apps/control-plane/src/application/services/performance-analytics-service.ts +90 -0
  19. package/apps/control-plane/src/application/services/plan-service.ts +184 -0
  20. package/apps/control-plane/src/application/services/pr-monitor-service.ts +58 -0
  21. package/apps/control-plane/src/application/services/qa-index-service.ts +68 -0
  22. package/apps/control-plane/src/application/services/reactions-service.ts +77 -0
  23. package/apps/control-plane/src/application/services/reporting-service.ts +79 -0
  24. package/apps/control-plane/src/application/services/run-lease-service.ts +79 -0
  25. package/apps/control-plane/src/application/tools/tool-router.ts +76 -0
  26. package/apps/control-plane/src/mcp/tool-runtime.ts +4 -0
  27. package/apps/control-plane/src/providers/providers.ts +96 -0
  28. package/apps/control-plane/src/providers/worker-provider-factory.ts +4 -0
  29. package/apps/control-plane/src/supervisor/build-wave-executor.ts +4 -0
  30. package/apps/control-plane/src/supervisor/planning-wave-executor.ts +4 -0
  31. package/apps/control-plane/src/supervisor/qa-wave-executor.ts +4 -0
  32. package/apps/control-plane/src/supervisor/run-coordinator.ts +4 -0
  33. package/apps/control-plane/src/supervisor/runtime.ts +4 -0
  34. package/apps/control-plane/src/supervisor/session-orchestrator.ts +4 -0
  35. package/apps/control-plane/test/dashboard-api.integration.spec.ts +310 -1
  36. package/apps/control-plane/test/dashboard-client.spec.ts +136 -1
  37. package/apps/control-plane/test/dashboard-live-feed.spec.ts +153 -0
  38. package/apps/control-plane/test/dashboard-ui-utils.spec.ts +132 -0
  39. package/apps/control-plane/test/mcp.spec.ts +96 -0
  40. package/apps/control-plane/test/tool-runtime.spec.ts +68 -0
  41. package/config/agentic/orchestrator/policy.yaml +1 -0
  42. package/dist/apps/control-plane/application/services/activity-monitor-service.d.ts +42 -0
  43. package/dist/apps/control-plane/application/services/activity-monitor-service.js +42 -0
  44. package/dist/apps/control-plane/application/services/activity-monitor-service.js.map +1 -1
  45. package/dist/apps/control-plane/application/services/collision-queue-service.d.ts +48 -0
  46. package/dist/apps/control-plane/application/services/collision-queue-service.js +48 -0
  47. package/dist/apps/control-plane/application/services/collision-queue-service.js.map +1 -1
  48. package/dist/apps/control-plane/application/services/cost-tracking-service.d.ts +79 -0
  49. package/dist/apps/control-plane/application/services/cost-tracking-service.js +76 -0
  50. package/dist/apps/control-plane/application/services/cost-tracking-service.js.map +1 -1
  51. package/dist/apps/control-plane/application/services/feature-deletion-service.d.ts +57 -0
  52. package/dist/apps/control-plane/application/services/feature-deletion-service.js +57 -0
  53. package/dist/apps/control-plane/application/services/feature-deletion-service.js.map +1 -1
  54. package/dist/apps/control-plane/application/services/feature-lifecycle-service.d.ts +64 -0
  55. package/dist/apps/control-plane/application/services/feature-lifecycle-service.js +61 -0
  56. package/dist/apps/control-plane/application/services/feature-lifecycle-service.js.map +1 -1
  57. package/dist/apps/control-plane/application/services/feature-state-service.d.ts +86 -0
  58. package/dist/apps/control-plane/application/services/feature-state-service.js +83 -0
  59. package/dist/apps/control-plane/application/services/feature-state-service.js.map +1 -1
  60. package/dist/apps/control-plane/application/services/gate-selection-service.d.ts +28 -0
  61. package/dist/apps/control-plane/application/services/gate-selection-service.js +28 -0
  62. package/dist/apps/control-plane/application/services/gate-selection-service.js.map +1 -1
  63. package/dist/apps/control-plane/application/services/gate-service.d.ts +148 -0
  64. package/dist/apps/control-plane/application/services/gate-service.js +120 -0
  65. package/dist/apps/control-plane/application/services/gate-service.js.map +1 -1
  66. package/dist/apps/control-plane/application/services/issue-tracker-service.d.ts +28 -0
  67. package/dist/apps/control-plane/application/services/issue-tracker-service.js +28 -0
  68. package/dist/apps/control-plane/application/services/issue-tracker-service.js.map +1 -1
  69. package/dist/apps/control-plane/application/services/lock-service.d.ts +88 -0
  70. package/dist/apps/control-plane/application/services/lock-service.js +64 -0
  71. package/dist/apps/control-plane/application/services/lock-service.js.map +1 -1
  72. package/dist/apps/control-plane/application/services/merge-service.d.ts +62 -0
  73. package/dist/apps/control-plane/application/services/merge-service.js +59 -0
  74. package/dist/apps/control-plane/application/services/merge-service.js.map +1 -1
  75. package/dist/apps/control-plane/application/services/notifier-service.d.ts +70 -0
  76. package/dist/apps/control-plane/application/services/notifier-service.js +70 -0
  77. package/dist/apps/control-plane/application/services/notifier-service.js.map +1 -1
  78. package/dist/apps/control-plane/application/services/patch-service.d.ts +94 -0
  79. package/dist/apps/control-plane/application/services/patch-service.js +91 -0
  80. package/dist/apps/control-plane/application/services/patch-service.js.map +1 -1
  81. package/dist/apps/control-plane/application/services/performance-analytics-service.d.ts +89 -0
  82. package/dist/apps/control-plane/application/services/performance-analytics-service.js +86 -0
  83. package/dist/apps/control-plane/application/services/performance-analytics-service.js.map +1 -1
  84. package/dist/apps/control-plane/application/services/plan-service.d.ts +287 -0
  85. package/dist/apps/control-plane/application/services/plan-service.js +207 -0
  86. package/dist/apps/control-plane/application/services/plan-service.js.map +1 -1
  87. package/dist/apps/control-plane/application/services/pr-monitor-service.d.ts +58 -0
  88. package/dist/apps/control-plane/application/services/pr-monitor-service.js +58 -0
  89. package/dist/apps/control-plane/application/services/pr-monitor-service.js.map +1 -1
  90. package/dist/apps/control-plane/application/services/qa-index-service.d.ts +67 -0
  91. package/dist/apps/control-plane/application/services/qa-index-service.js +64 -0
  92. package/dist/apps/control-plane/application/services/qa-index-service.js.map +1 -1
  93. package/dist/apps/control-plane/application/services/reactions-service.d.ts +76 -0
  94. package/dist/apps/control-plane/application/services/reactions-service.js +73 -0
  95. package/dist/apps/control-plane/application/services/reactions-service.js.map +1 -1
  96. package/dist/apps/control-plane/application/services/reporting-service.d.ts +78 -0
  97. package/dist/apps/control-plane/application/services/reporting-service.js +75 -0
  98. package/dist/apps/control-plane/application/services/reporting-service.js.map +1 -1
  99. package/dist/apps/control-plane/application/services/run-lease-service.d.ts +79 -0
  100. package/dist/apps/control-plane/application/services/run-lease-service.js +79 -0
  101. package/dist/apps/control-plane/application/services/run-lease-service.js.map +1 -1
  102. package/dist/apps/control-plane/application/tools/tool-router.d.ts +76 -0
  103. package/dist/apps/control-plane/application/tools/tool-router.js +62 -0
  104. package/dist/apps/control-plane/application/tools/tool-router.js.map +1 -1
  105. package/dist/apps/control-plane/mcp/tool-runtime.d.ts +3 -0
  106. package/dist/apps/control-plane/mcp/tool-runtime.js +3 -0
  107. package/dist/apps/control-plane/mcp/tool-runtime.js.map +1 -1
  108. package/dist/apps/control-plane/providers/providers.d.ts +88 -0
  109. package/dist/apps/control-plane/providers/providers.js.map +1 -1
  110. package/dist/apps/control-plane/providers/worker-provider-factory.d.ts +3 -0
  111. package/dist/apps/control-plane/providers/worker-provider-factory.js.map +1 -1
  112. package/dist/apps/control-plane/supervisor/build-wave-executor.d.ts +3 -0
  113. package/dist/apps/control-plane/supervisor/build-wave-executor.js +3 -0
  114. package/dist/apps/control-plane/supervisor/build-wave-executor.js.map +1 -1
  115. package/dist/apps/control-plane/supervisor/planning-wave-executor.d.ts +3 -0
  116. package/dist/apps/control-plane/supervisor/planning-wave-executor.js +3 -0
  117. package/dist/apps/control-plane/supervisor/planning-wave-executor.js.map +1 -1
  118. package/dist/apps/control-plane/supervisor/qa-wave-executor.d.ts +3 -0
  119. package/dist/apps/control-plane/supervisor/qa-wave-executor.js +3 -0
  120. package/dist/apps/control-plane/supervisor/qa-wave-executor.js.map +1 -1
  121. package/dist/apps/control-plane/supervisor/run-coordinator.d.ts +3 -0
  122. package/dist/apps/control-plane/supervisor/run-coordinator.js +3 -0
  123. package/dist/apps/control-plane/supervisor/run-coordinator.js.map +1 -1
  124. package/dist/apps/control-plane/supervisor/runtime.d.ts +3 -0
  125. package/dist/apps/control-plane/supervisor/runtime.js +3 -0
  126. package/dist/apps/control-plane/supervisor/runtime.js.map +1 -1
  127. package/dist/apps/control-plane/supervisor/session-orchestrator.d.ts +3 -0
  128. package/dist/apps/control-plane/supervisor/session-orchestrator.js +3 -0
  129. package/dist/apps/control-plane/supervisor/session-orchestrator.js.map +1 -1
  130. package/package.json +1 -1
  131. package/packages/web-dashboard/src/app/analytics/page.tsx +451 -0
  132. package/packages/web-dashboard/src/app/api/analytics/route.ts +62 -0
  133. package/packages/web-dashboard/src/app/api/collisions/route.ts +63 -0
  134. package/packages/web-dashboard/src/app/api/features/[id]/cost/route.ts +29 -0
  135. package/packages/web-dashboard/src/app/api/features/[id]/review-brief/route.ts +31 -0
  136. package/packages/web-dashboard/src/app/api/features/[id]/route.ts +5 -8
  137. package/packages/web-dashboard/src/app/api/features/[id]/test-index/route.ts +31 -0
  138. package/packages/web-dashboard/src/app/api/flaky/route.ts +20 -0
  139. package/packages/web-dashboard/src/app/api/policy/budget/route.ts +31 -0
  140. package/packages/web-dashboard/src/app/api/run/route.ts +162 -0
  141. package/packages/web-dashboard/src/app/api/status/route.ts +2 -5
  142. package/packages/web-dashboard/src/app/feature/[id]/page.tsx +112 -0
  143. package/packages/web-dashboard/src/app/page.tsx +379 -77
  144. package/packages/web-dashboard/src/components/agent-pipeline-stepper.tsx +100 -0
  145. package/packages/web-dashboard/src/components/dependency-chains.tsx +58 -0
  146. package/packages/web-dashboard/src/components/detail-panel.tsx +215 -5
  147. package/packages/web-dashboard/src/components/feature-card.tsx +5 -0
  148. package/packages/web-dashboard/src/components/feature-cost-panel.tsx +66 -0
  149. package/packages/web-dashboard/src/components/feature-list-view.tsx +336 -0
  150. package/packages/web-dashboard/src/components/gate-step-drilldown.tsx +92 -0
  151. package/packages/web-dashboard/src/components/kanban-board.tsx +3 -0
  152. package/packages/web-dashboard/src/components/live-event-feed.tsx +60 -0
  153. package/packages/web-dashboard/src/components/lock-resource-map.tsx +73 -0
  154. package/packages/web-dashboard/src/components/plan-revision-timeline.tsx +55 -0
  155. package/packages/web-dashboard/src/components/plan-risk-annotations.tsx +63 -0
  156. package/packages/web-dashboard/src/components/plan-scope-tree.tsx +285 -0
  157. package/packages/web-dashboard/src/components/qa-coverage-map.tsx +174 -0
  158. package/packages/web-dashboard/src/components/quick-launch-panel.tsx +70 -0
  159. package/packages/web-dashboard/src/components/review-brief-panel.tsx +75 -0
  160. package/packages/web-dashboard/src/components/run-health-panel.tsx +85 -0
  161. package/packages/web-dashboard/src/components/summary-bar.tsx +59 -2
  162. package/packages/web-dashboard/src/components/verification-signals.tsx +167 -0
  163. package/packages/web-dashboard/src/lib/analytics-utils.ts +3 -0
  164. package/packages/web-dashboard/src/lib/aop-client.ts +510 -11
  165. package/packages/web-dashboard/src/lib/api-envelope.ts +48 -0
  166. package/packages/web-dashboard/src/lib/authz-adapter.ts +33 -0
  167. package/packages/web-dashboard/src/lib/dashboard-utils.ts +304 -0
  168. package/packages/web-dashboard/src/lib/live-feed.ts +218 -0
  169. package/packages/web-dashboard/src/lib/policy-reader.ts +22 -0
  170. package/packages/web-dashboard/src/lib/types.ts +114 -0
  171. package/packages/web-dashboard/src/styles/dashboard.module.css +341 -1
  172. package/spec-files/{outstanding → completed}/agentic_orchestrator_dashboard_advanced_ux_spec.md +1 -1
  173. package/spec-files/outstanding/agentic_orchestrator_evidence_integrity_doctor_spec.md +561 -0
  174. package/spec-files/outstanding/agentic_orchestrator_kernel_simplification_spec.md +834 -0
  175. package/spec-files/outstanding/agentic_orchestrator_observability_integrity_diagnostics_spec.md +12 -5
  176. package/spec-files/outstanding/agentic_orchestrator_observability_replay_spec.md +442 -0
  177. package/spec-files/progress.md +174 -1
@@ -3,6 +3,8 @@ import path from 'node:path';
3
3
  import YAML from 'yaml';
4
4
  import { getProjectByName, readMultiProjectConfig } from '@/lib/multi-project-config.js';
5
5
  import type {
6
+ AgentPipelineStatus,
7
+ CostSummary,
6
8
  DashboardStatusPayload,
7
9
  EvidenceArtifact,
8
10
  FeatureDetail,
@@ -10,6 +12,12 @@ import type {
10
12
  FeatureLogEntry,
11
13
  FeaturesIndex,
12
14
  FeatureSummary,
15
+ GateRunEvidence,
16
+ LockLease,
17
+ QaTestIndex,
18
+ ReviewBrief,
19
+ RoleStatus,
20
+ RuntimeSession,
13
21
  } from '@/lib/types.js';
14
22
 
15
23
  const AOP_ROOT = process.env.AOP_ROOT ?? process.cwd();
@@ -48,18 +56,39 @@ function isActivityState(value: unknown): value is NonNullable<FeatureSummary['a
48
56
  );
49
57
  }
50
58
 
51
- function parseRoleStatus(frontMatter: Record<string, unknown>): Record<string, string> | null {
59
+ function normalizeRoleStatus(value: unknown): RoleStatus {
60
+ if (typeof value !== 'string') {
61
+ return 'unknown';
62
+ }
63
+ const normalized = value.trim().toLowerCase();
64
+ if (normalized === 'running' || normalized === 'in_progress' || normalized === 'active') {
65
+ return 'running';
66
+ }
67
+ if (normalized === 'blocked' || normalized === 'waiting') {
68
+ return 'blocked';
69
+ }
70
+ if (normalized === 'done' || normalized === 'complete' || normalized === 'completed') {
71
+ return 'done';
72
+ }
73
+ if (normalized === 'ready' || normalized === 'pending' || normalized === 'queued') {
74
+ return 'ready';
75
+ }
76
+ return 'unknown';
77
+ }
78
+
79
+ function parseRoleStatus(frontMatter: Record<string, unknown>): AgentPipelineStatus | null {
52
80
  const raw = frontMatter.role_status;
53
81
  if (!raw || typeof raw !== 'object') {
54
82
  return null;
55
83
  }
56
- const entries = Object.entries(raw as Record<string, unknown>).filter(
57
- (entry): entry is [string, string] => typeof entry[1] === 'string',
58
- );
59
- if (entries.length === 0) {
84
+ const record = raw as Record<string, unknown>;
85
+ const planner = normalizeRoleStatus(record.planner);
86
+ const builder = normalizeRoleStatus(record.builder);
87
+ const qa = normalizeRoleStatus(record.qa);
88
+ if (planner === 'unknown' && builder === 'unknown' && qa === 'unknown') {
60
89
  return null;
61
90
  }
62
- return Object.fromEntries(entries);
91
+ return { planner, builder, qa };
63
92
  }
64
93
 
65
94
  function firstValidTimestamp(frontMatter: Record<string, unknown>): number | null {
@@ -133,6 +162,99 @@ function parseFrontMatter(raw: string): ParsedFrontMatter {
133
162
  return { frontMatter, body: normalized.slice(match[0].length) };
134
163
  }
135
164
 
165
+ function asRecord(value: unknown): Record<string, unknown> | null {
166
+ return value && typeof value === 'object' ? (value as Record<string, unknown>) : null;
167
+ }
168
+
169
+ async function readJsonFile<T>(filePath: string): Promise<T | null> {
170
+ try {
171
+ const raw = await readFile(filePath, 'utf-8');
172
+ return JSON.parse(raw) as T;
173
+ } catch {
174
+ return null;
175
+ }
176
+ }
177
+
178
+ function normalizeRuntimeSession(value: unknown): RuntimeSession | undefined {
179
+ const record = asRecord(value);
180
+ if (!record) {
181
+ return undefined;
182
+ }
183
+ return {
184
+ run_id: typeof record.run_id === 'string' ? record.run_id : undefined,
185
+ owner_instance_id:
186
+ typeof record.owner_instance_id === 'string' ? record.owner_instance_id : undefined,
187
+ provider: typeof record.provider === 'string' ? record.provider : null,
188
+ model: typeof record.model === 'string' ? record.model : null,
189
+ started_at: typeof record.started_at === 'string' ? record.started_at : null,
190
+ last_heartbeat_at:
191
+ typeof record.last_heartbeat_at === 'string' ? record.last_heartbeat_at : null,
192
+ lease_expires_at: typeof record.lease_expires_at === 'string' ? record.lease_expires_at : null,
193
+ orchestrator_epoch:
194
+ typeof record.orchestrator_epoch === 'number' ? record.orchestrator_epoch : undefined,
195
+ };
196
+ }
197
+
198
+ function normalizeLockMap(index: FeaturesIndex): LockLease[] {
199
+ const lockLeases = asRecord(index.lock_leases);
200
+ if (!lockLeases) {
201
+ return [];
202
+ }
203
+ const now = Date.now();
204
+ return Object.entries(lockLeases)
205
+ .map(([resource, lease]) => {
206
+ const record = asRecord(lease);
207
+ if (!record) {
208
+ return null;
209
+ }
210
+ const holder = typeof record.holder === 'string' ? record.holder : null;
211
+ const expiresAt =
212
+ typeof record.expires_at === 'string'
213
+ ? record.expires_at
214
+ : typeof record.lease_expires_at === 'string'
215
+ ? record.lease_expires_at
216
+ : null;
217
+ const parsedExpiry = expiresAt ? Date.parse(expiresAt) : Number.NaN;
218
+ const stale = Number.isNaN(parsedExpiry) ? false : parsedExpiry < now;
219
+ return {
220
+ resource,
221
+ holder,
222
+ expires_at: expiresAt,
223
+ is_stale: stale,
224
+ } satisfies LockLease;
225
+ })
226
+ .filter((entry): entry is LockLease => entry != null)
227
+ .sort((left, right) => left.resource.localeCompare(right.resource));
228
+ }
229
+
230
+ function normalizeDepBlocked(index: FeaturesIndex): Array<{
231
+ feature_id: string;
232
+ depends_on_unresolved: string[];
233
+ }> {
234
+ if (!Array.isArray(index.dep_blocked)) {
235
+ return [];
236
+ }
237
+ return index.dep_blocked
238
+ .map((entry) => {
239
+ const featureId = typeof entry?.feature_id === 'string' ? entry.feature_id : null;
240
+ const deps = Array.isArray(entry?.depends_on_unresolved)
241
+ ? entry.depends_on_unresolved.filter((item): item is string => typeof item === 'string')
242
+ : [];
243
+ if (!featureId) {
244
+ return null;
245
+ }
246
+ return { feature_id: featureId, depends_on_unresolved: deps };
247
+ })
248
+ .filter(
249
+ (
250
+ entry,
251
+ ): entry is {
252
+ feature_id: string;
253
+ depends_on_unresolved: string[];
254
+ } => entry != null,
255
+ );
256
+ }
257
+
136
258
  function normalizeFeatureSummary(
137
259
  featureId: string,
138
260
  frontMatter: Record<string, unknown>,
@@ -172,14 +294,47 @@ function normalizeFeatureSummary(
172
294
  : undefined,
173
295
  }
174
296
  : undefined;
297
+ const roleStatus = parseRoleStatus(frontMatter);
298
+ const rawLocks =
299
+ frontMatter.locks && typeof frontMatter.locks === 'object'
300
+ ? (frontMatter.locks as Record<string, unknown>)
301
+ : null;
302
+ const locksHeldRaw = rawLocks?.held;
303
+ const locksHeld = Array.isArray(locksHeldRaw)
304
+ ? locksHeldRaw.filter((item): item is string => typeof item === 'string')
305
+ : Array.isArray(frontMatter.locks)
306
+ ? frontMatter.locks.filter((item): item is string => typeof item === 'string')
307
+ : undefined;
308
+
309
+ const planVersionRaw = frontMatter.plan_version;
310
+ const planVersion =
311
+ typeof planVersionRaw === 'number'
312
+ ? planVersionRaw
313
+ : typeof planVersionRaw === 'string'
314
+ ? Number.parseInt(planVersionRaw, 10)
315
+ : undefined;
316
+
317
+ const revisionOfRaw = frontMatter.revision_of;
318
+ const revisionOf =
319
+ typeof revisionOfRaw === 'number'
320
+ ? revisionOfRaw
321
+ : typeof revisionOfRaw === 'string'
322
+ ? Number.parseInt(revisionOfRaw, 10)
323
+ : null;
175
324
  return {
176
325
  id: featureId,
177
326
  feature_id: featureId,
178
327
  status,
179
328
  phase,
180
- plan_version:
181
- typeof frontMatter.plan_version === 'number' ? frontMatter.plan_version : undefined,
182
- locks_held: Array.isArray(frontMatter.locks) ? (frontMatter.locks as string[]) : undefined,
329
+ plan_version: Number.isNaN(planVersion) ? undefined : planVersion,
330
+ revision_of: Number.isNaN(revisionOf) ? null : revisionOf,
331
+ revision_reason:
332
+ typeof frontMatter.revision_reason === 'string' ? frontMatter.revision_reason : null,
333
+ role_status: roleStatus ?? undefined,
334
+ gate_retry_count:
335
+ typeof frontMatter.gate_retry_count === 'number' ? frontMatter.gate_retry_count : undefined,
336
+ last_retry_at: typeof frontMatter.last_retry_at === 'string' ? frontMatter.last_retry_at : null,
337
+ locks_held: locksHeld,
183
338
  gates,
184
339
  gate_profile:
185
340
  typeof frontMatter.gate_profile === 'string' ? frontMatter.gate_profile : undefined,
@@ -234,7 +389,62 @@ export async function readFeaturesIndex(repoRoot = AOP_ROOT): Promise<FeaturesIn
234
389
  const indexPath = path.join(repoRoot, '.aop', 'features', 'index.json');
235
390
  try {
236
391
  const raw = await readFile(indexPath, 'utf-8');
237
- return JSON.parse(raw) as FeaturesIndex;
392
+ const parsed = JSON.parse(raw) as Record<string, unknown>;
393
+ const normalized: FeaturesIndex = {
394
+ active: Array.isArray(parsed.active)
395
+ ? parsed.active.filter((item): item is string => typeof item === 'string')
396
+ : [],
397
+ blocked: Array.isArray(parsed.blocked)
398
+ ? parsed.blocked.filter((item): item is string => typeof item === 'string')
399
+ : [],
400
+ merged: Array.isArray(parsed.merged)
401
+ ? parsed.merged.filter((item): item is string => typeof item === 'string')
402
+ : [],
403
+ blocked_queue: Array.isArray(parsed.blocked_queue)
404
+ ? parsed.blocked_queue
405
+ .map((entry) => {
406
+ const record = asRecord(entry);
407
+ const featureId =
408
+ record && typeof record.feature_id === 'string' ? record.feature_id : null;
409
+ const detectedAt =
410
+ record && typeof record.detected_at === 'string' ? record.detected_at : null;
411
+ const collisionFingerprint =
412
+ record && typeof record.collision_fingerprint === 'string'
413
+ ? record.collision_fingerprint
414
+ : null;
415
+ if (!featureId || !detectedAt || !collisionFingerprint) {
416
+ return null;
417
+ }
418
+ return {
419
+ feature_id: featureId,
420
+ detected_at: detectedAt,
421
+ collision_fingerprint: collisionFingerprint,
422
+ };
423
+ })
424
+ .filter(
425
+ (
426
+ entry,
427
+ ): entry is {
428
+ feature_id: string;
429
+ detected_at: string;
430
+ collision_fingerprint: string;
431
+ } => entry != null,
432
+ )
433
+ : [],
434
+ };
435
+
436
+ const lockLeases = asRecord(parsed.lock_leases);
437
+ if (lockLeases) {
438
+ normalized.lock_leases = lockLeases as FeaturesIndex['lock_leases'];
439
+ }
440
+ if (Array.isArray(parsed.dep_blocked)) {
441
+ normalized.dep_blocked = parsed.dep_blocked as FeaturesIndex['dep_blocked'];
442
+ }
443
+ const runtimeSessions = normalizeRuntimeSession(parsed.runtime_sessions);
444
+ if (runtimeSessions) {
445
+ normalized.runtime_sessions = runtimeSessions;
446
+ }
447
+ return normalized;
238
448
  } catch {
239
449
  return { active: [], blocked: [], merged: [], blocked_queue: [] };
240
450
  }
@@ -272,6 +482,137 @@ export async function readFeaturePlan(
272
482
  }
273
483
  }
274
484
 
485
+ export async function readFeatureCost(
486
+ featureId: string,
487
+ repoRoot = AOP_ROOT,
488
+ ): Promise<CostSummary | null> {
489
+ const costPath = path.join(getFeatureRoot(featureId, repoRoot), 'cost.json');
490
+ const parsed = await readJsonFile<Record<string, unknown>>(costPath);
491
+ if (!parsed) {
492
+ return null;
493
+ }
494
+ return {
495
+ feature_id: featureId,
496
+ tokens_used: typeof parsed.tokens_used === 'number' ? parsed.tokens_used : 0,
497
+ estimated_cost_usd:
498
+ typeof parsed.estimated_cost_usd === 'number' ? parsed.estimated_cost_usd : 0,
499
+ recorded_at:
500
+ typeof parsed.recorded_at === 'string'
501
+ ? parsed.recorded_at
502
+ : typeof parsed.last_updated === 'string'
503
+ ? parsed.last_updated
504
+ : null,
505
+ };
506
+ }
507
+
508
+ export async function readFeatureQaTestIndex(
509
+ featureId: string,
510
+ repoRoot = AOP_ROOT,
511
+ ): Promise<QaTestIndex | null> {
512
+ const indexPath = path.join(getFeatureRoot(featureId, repoRoot), 'qa_test_index.json');
513
+ const parsed = await readJsonFile<Record<string, unknown>>(indexPath);
514
+ if (!parsed || !Array.isArray(parsed.items)) {
515
+ return null;
516
+ }
517
+
518
+ const items = parsed.items
519
+ .map((item) => {
520
+ const record = asRecord(item);
521
+ if (!record || typeof record.path !== 'string' || typeof record.status !== 'string') {
522
+ return null;
523
+ }
524
+ const status = record.status.toLowerCase();
525
+ if (!['pending', 'running', 'passed', 'failed', 'waived'].includes(status)) {
526
+ return null;
527
+ }
528
+ const normalizedStatus = status as QaTestIndex['items'][number]['status'];
529
+ const nextItem: QaTestIndex['items'][number] = {
530
+ path: record.path,
531
+ status: normalizedStatus,
532
+ required_tests: Array.isArray(record.required_tests)
533
+ ? record.required_tests.filter((test): test is string => typeof test === 'string')
534
+ : [],
535
+ };
536
+ if (typeof record.last_run_at === 'string') {
537
+ nextItem.last_run_at = record.last_run_at;
538
+ }
539
+ return nextItem;
540
+ })
541
+ .filter((entry): entry is QaTestIndex['items'][number] => entry != null);
542
+
543
+ const version =
544
+ typeof parsed.version === 'number'
545
+ ? parsed.version
546
+ : typeof parsed.version === 'string'
547
+ ? Number.parseInt(parsed.version, 10)
548
+ : 1;
549
+
550
+ return {
551
+ feature_id: typeof parsed.feature_id === 'string' ? parsed.feature_id : featureId,
552
+ version: Number.isNaN(version) ? 1 : version,
553
+ items,
554
+ };
555
+ }
556
+
557
+ export async function readFeatureReviewBrief(
558
+ featureId: string,
559
+ repoRoot = AOP_ROOT,
560
+ ): Promise<ReviewBrief | null> {
561
+ const briefPath = path.join(getFeatureRoot(featureId, repoRoot), 'review_brief.json');
562
+ const parsed = await readJsonFile<Record<string, unknown>>(briefPath);
563
+ if (!parsed) {
564
+ return null;
565
+ }
566
+
567
+ const intent =
568
+ typeof parsed.intent_summary === 'string'
569
+ ? parsed.intent_summary
570
+ : typeof parsed.intent === 'string'
571
+ ? parsed.intent
572
+ : '';
573
+ const scope =
574
+ typeof parsed.scope_summary === 'string'
575
+ ? parsed.scope_summary
576
+ : typeof parsed.scope === 'string'
577
+ ? parsed.scope
578
+ : '';
579
+ const contractRisk =
580
+ typeof parsed.contract_risk_summary === 'string'
581
+ ? parsed.contract_risk_summary
582
+ : typeof parsed.contract_risk === 'string'
583
+ ? parsed.contract_risk
584
+ : '';
585
+ const generatedAt =
586
+ typeof parsed.generated_at === 'string' ? parsed.generated_at : new Date().toISOString();
587
+
588
+ if (intent.length === 0 && scope.length === 0 && contractRisk.length === 0) {
589
+ return null;
590
+ }
591
+
592
+ return {
593
+ intent_summary: intent,
594
+ scope_summary: scope,
595
+ contract_risk_summary: contractRisk,
596
+ feasibility_score:
597
+ typeof parsed.feasibility_score === 'number' ? parsed.feasibility_score : undefined,
598
+ feasibility_breakdown:
599
+ parsed.feasibility_breakdown && typeof parsed.feasibility_breakdown === 'object'
600
+ ? (parsed.feasibility_breakdown as Record<string, number>)
601
+ : undefined,
602
+ gate_matrix:
603
+ parsed.gate_matrix && typeof parsed.gate_matrix === 'object'
604
+ ? (parsed.gate_matrix as Record<string, string>)
605
+ : {},
606
+ unresolved_questions: Array.isArray(parsed.unresolved_questions)
607
+ ? parsed.unresolved_questions.filter((item): item is string => typeof item === 'string')
608
+ : [],
609
+ evidence_refs: Array.isArray(parsed.evidence_refs)
610
+ ? parsed.evidence_refs.filter((item): item is string => typeof item === 'string')
611
+ : [],
612
+ generated_at: generatedAt,
613
+ };
614
+ }
615
+
275
616
  export async function readFeatureDiff(featureId: string, repoRoot = AOP_ROOT): Promise<string> {
276
617
  const diffPath = path.join(getFeatureRoot(featureId, repoRoot), 'evidence', 'diff.patch');
277
618
  try {
@@ -307,6 +648,99 @@ export async function listEvidenceArtifacts(
307
648
  }
308
649
  }
309
650
 
651
+ async function readOutputTail(logPath: unknown, maxLines = 50): Promise<string[] | undefined> {
652
+ if (typeof logPath !== 'string' || logPath.length === 0) {
653
+ return undefined;
654
+ }
655
+ try {
656
+ const raw = await readFile(logPath, 'utf-8');
657
+ const lines = raw.replace(/\r\n/g, '\n').split('\n');
658
+ return lines.slice(Math.max(0, lines.length - maxLines));
659
+ } catch {
660
+ return undefined;
661
+ }
662
+ }
663
+
664
+ export async function readLatestGateEvidence(
665
+ featureId: string,
666
+ repoRoot = AOP_ROOT,
667
+ ): Promise<GateRunEvidence | null> {
668
+ const evidenceDir = path.join(getFeatureRoot(featureId, repoRoot), 'evidence');
669
+ try {
670
+ const files = await readdir(evidenceDir);
671
+ const gateJsons = files.filter(
672
+ (name) =>
673
+ /^latest-(fast|full|merge)\.json$/.test(name) || /^gate-(fast|full|merge)-/.test(name),
674
+ );
675
+ if (gateJsons.length === 0) {
676
+ return null;
677
+ }
678
+
679
+ let latest: { file: string; finishedAt: number; raw: Record<string, unknown> } | null = null;
680
+ for (const file of gateJsons) {
681
+ const fullPath = path.join(evidenceDir, file);
682
+ const parsed = await readJsonFile<Record<string, unknown>>(fullPath);
683
+ if (!parsed) {
684
+ continue;
685
+ }
686
+ const finishedAt =
687
+ typeof parsed.finished_at === 'string' ? Date.parse(parsed.finished_at) : Number.NaN;
688
+ const statInfo = await stat(fullPath);
689
+ const score = Number.isNaN(finishedAt) ? statInfo.mtime.getTime() : finishedAt;
690
+ if (!latest || score > latest.finishedAt) {
691
+ latest = { file, finishedAt: score, raw: parsed };
692
+ }
693
+ }
694
+
695
+ if (!latest) {
696
+ return null;
697
+ }
698
+
699
+ const rawSteps = Array.isArray(latest.raw.step_results) ? latest.raw.step_results : [];
700
+ const steps = await Promise.all(
701
+ rawSteps.map(async (step) => {
702
+ const record = asRecord(step) ?? {};
703
+ const startedAt =
704
+ typeof record.started_at === 'string' ? Date.parse(record.started_at) : NaN;
705
+ const finishedAt =
706
+ typeof record.finished_at === 'string' ? Date.parse(record.finished_at) : NaN;
707
+ const duration =
708
+ Number.isNaN(startedAt) || Number.isNaN(finishedAt)
709
+ ? null
710
+ : Math.max(0, finishedAt - startedAt);
711
+ const exitCode = typeof record.exit_code === 'number' ? record.exit_code : null;
712
+ const timeout = record.timeout === true;
713
+ const status: 'pass' | 'fail' | 'unknown' =
714
+ exitCode == null ? 'unknown' : exitCode === 0 && !timeout ? 'pass' : 'fail';
715
+ return {
716
+ name: typeof record.name === 'string' ? record.name : 'step',
717
+ command: Array.isArray(record.cmd)
718
+ ? record.cmd.filter((token): token is string => typeof token === 'string').join(' ')
719
+ : typeof record.command === 'string'
720
+ ? record.command
721
+ : '',
722
+ status,
723
+ exit_code: exitCode,
724
+ duration_ms: duration,
725
+ log_path: typeof record.log_path === 'string' ? record.log_path : null,
726
+ output_tail: await readOutputTail(record.log_path),
727
+ };
728
+ }),
729
+ );
730
+
731
+ return {
732
+ mode: typeof latest.raw.mode === 'string' ? latest.raw.mode : 'unknown',
733
+ profile: typeof latest.raw.profile === 'string' ? latest.raw.profile : null,
734
+ overall: typeof latest.raw.overall === 'string' ? latest.raw.overall : 'unknown',
735
+ finished_at: typeof latest.raw.finished_at === 'string' ? latest.raw.finished_at : null,
736
+ steps,
737
+ raw: latest.raw,
738
+ };
739
+ } catch {
740
+ return null;
741
+ }
742
+ }
743
+
310
744
  export async function readEvidenceArtifact(
311
745
  featureId: string,
312
746
  artifactName: string,
@@ -399,6 +833,49 @@ export async function readFeatureLog(featureId: string, repoRoot = AOP_ROOT): Pr
399
833
  };
400
834
  }
401
835
 
836
+ function buildMergeHistogram14d(features: FeatureSummary[]): number[] {
837
+ const days = 14;
838
+ const buckets = new Array<number>(days).fill(0);
839
+ const now = Date.now();
840
+ for (const feature of features) {
841
+ if (feature.phase !== 'merged' || typeof feature.last_updated !== 'string') {
842
+ continue;
843
+ }
844
+ const stamp = Date.parse(feature.last_updated);
845
+ if (Number.isNaN(stamp) || stamp > now) {
846
+ continue;
847
+ }
848
+ const ageDays = Math.floor((now - stamp) / 86_400_000);
849
+ if (ageDays < 0 || ageDays >= days) {
850
+ continue;
851
+ }
852
+ buckets[days - 1 - ageDays] += 1;
853
+ }
854
+ return buckets;
855
+ }
856
+
857
+ async function readFlakySuspects(repoRoot = AOP_ROOT): Promise<string[]> {
858
+ const candidates = [
859
+ path.join(repoRoot, '.aop', 'runtime', 'flaky.json'),
860
+ path.join(repoRoot, '.aop', 'analytics', 'flaky.json'),
861
+ ];
862
+
863
+ for (const candidate of candidates) {
864
+ const parsed = await readJsonFile<Record<string, unknown>>(candidate);
865
+ if (!parsed) {
866
+ continue;
867
+ }
868
+ if (Array.isArray(parsed.suspects)) {
869
+ return parsed.suspects.filter((item): item is string => typeof item === 'string');
870
+ }
871
+ if (Array.isArray(parsed.tests)) {
872
+ return parsed.tests.filter((item): item is string => typeof item === 'string');
873
+ }
874
+ }
875
+
876
+ return [];
877
+ }
878
+
402
879
  export async function readDashboardStatus(repoRoot = AOP_ROOT): Promise<DashboardStatusPayload> {
403
880
  const index = await readFeaturesIndex(repoRoot);
404
881
  const allFeatureIds = new Set<string>([
@@ -409,6 +886,7 @@ export async function readDashboardStatus(repoRoot = AOP_ROOT): Promise<Dashboar
409
886
  ]);
410
887
 
411
888
  const features: FeatureSummary[] = [];
889
+ let totalCostUsd = 0;
412
890
  for (const featureId of [...allFeatureIds].sort((a, b) => a.localeCompare(b))) {
413
891
  const statePath = path.join(getFeatureRoot(featureId, repoRoot), 'state.md');
414
892
  try {
@@ -439,9 +917,26 @@ export async function readDashboardStatus(repoRoot = AOP_ROOT): Promise<Dashboar
439
917
  pr: null,
440
918
  });
441
919
  }
920
+
921
+ const cost = await readFeatureCost(featureId, repoRoot);
922
+ if (cost) {
923
+ totalCostUsd += cost.estimated_cost_usd;
924
+ }
442
925
  }
443
926
 
444
- return { index, features };
927
+ const depBlocked = normalizeDepBlocked(index);
928
+ return {
929
+ index,
930
+ features,
931
+ runtime: index.runtime_sessions,
932
+ lock_map: normalizeLockMap(index),
933
+ dep_blocked: depBlocked,
934
+ flaky_suspects: await readFlakySuspects(repoRoot),
935
+ metrics: {
936
+ total_cost_today_usd: Number(totalCostUsd.toFixed(4)),
937
+ merge_histogram_14d: buildMergeHistogram14d(features),
938
+ },
939
+ };
445
940
  }
446
941
 
447
942
  export async function readFeatureDetail(
@@ -457,5 +952,9 @@ export async function readFeatureDetail(
457
952
  plan: await readFeaturePlan(featureId, repoRoot),
458
953
  diff: await readFeatureDiff(featureId, repoRoot),
459
954
  evidence: await listEvidenceArtifacts(featureId, repoRoot),
955
+ gate_evidence: await readLatestGateEvidence(featureId, repoRoot),
956
+ cost: await readFeatureCost(featureId, repoRoot),
957
+ qa_test_index: await readFeatureQaTestIndex(featureId, repoRoot),
958
+ review_brief: await readFeatureReviewBrief(featureId, repoRoot),
460
959
  };
461
960
  }
@@ -0,0 +1,48 @@
1
+ export interface ApiSuccessEnvelope<TData> {
2
+ ok: true;
3
+ data: TData;
4
+ meta: {
5
+ stale: boolean;
6
+ source: 'tool' | 'artifact' | 'derived';
7
+ };
8
+ }
9
+
10
+ export interface ApiErrorEnvelope {
11
+ ok: false;
12
+ error: {
13
+ code: string;
14
+ message: string;
15
+ retryable: boolean;
16
+ };
17
+ }
18
+
19
+ export type ApiEnvelope<TData> = ApiSuccessEnvelope<TData> | ApiErrorEnvelope;
20
+
21
+ export function okEnvelope<TData>(
22
+ data: TData,
23
+ options: { stale?: boolean; source?: 'tool' | 'artifact' | 'derived' } = {},
24
+ ): ApiSuccessEnvelope<TData> {
25
+ return {
26
+ ok: true,
27
+ data,
28
+ meta: {
29
+ stale: options.stale ?? false,
30
+ source: options.source ?? 'artifact',
31
+ },
32
+ };
33
+ }
34
+
35
+ export function errorEnvelope(
36
+ code: string,
37
+ message: string,
38
+ options: { retryable?: boolean } = {},
39
+ ): ApiErrorEnvelope {
40
+ return {
41
+ ok: false,
42
+ error: {
43
+ code,
44
+ message,
45
+ retryable: options.retryable ?? false,
46
+ },
47
+ };
48
+ }
@@ -0,0 +1,33 @@
1
+ export interface DashboardAuthorizationContext {
2
+ action: string;
3
+ project: string | null;
4
+ request: Request;
5
+ }
6
+
7
+ export interface DashboardAuthorizationResult {
8
+ allowed: boolean;
9
+ code?: string;
10
+ message?: string;
11
+ }
12
+
13
+ export interface DashboardAuthorizationAdapter {
14
+ authorize(context: DashboardAuthorizationContext): Promise<DashboardAuthorizationResult>;
15
+ }
16
+
17
+ class AllowAllAuthorizationAdapter implements DashboardAuthorizationAdapter {
18
+ async authorize(): Promise<DashboardAuthorizationResult> {
19
+ return { allowed: true };
20
+ }
21
+ }
22
+
23
+ let adapter: DashboardAuthorizationAdapter = new AllowAllAuthorizationAdapter();
24
+
25
+ export function setDashboardAuthorizationAdapter(nextAdapter: DashboardAuthorizationAdapter): void {
26
+ adapter = nextAdapter;
27
+ }
28
+
29
+ export async function authorizeDashboardAction(
30
+ context: DashboardAuthorizationContext,
31
+ ): Promise<DashboardAuthorizationResult> {
32
+ return await adapter.authorize(context);
33
+ }