agentic-orchestrator 0.1.26 → 0.1.28

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 (172) hide show
  1. package/AGENTS.md +2 -2
  2. package/CLAUDE.md +2 -2
  3. package/README.md +47 -14
  4. package/agentic/orchestrator/agents.yaml +13 -0
  5. package/agentic/orchestrator/policy.yaml +3 -0
  6. package/agentic/orchestrator/schemas/agents.schema.json +76 -0
  7. package/agentic/orchestrator/schemas/policy.schema.json +16 -0
  8. package/agentic/orchestrator/schemas/policy.user.schema.json +16 -0
  9. package/agentic/orchestrator/schemas/state.schema.json +53 -0
  10. package/apps/control-plane/src/application/configuration-service.ts +181 -0
  11. package/apps/control-plane/src/application/kernel-tool-wiring.ts +292 -0
  12. package/apps/control-plane/src/application/services/checkpoint-service.ts +523 -0
  13. package/apps/control-plane/src/application/services/feature-send-message-service.ts +132 -0
  14. package/apps/control-plane/src/application/services/patch-service.ts +29 -5
  15. package/apps/control-plane/src/application/services/repo-operations-service.ts +276 -0
  16. package/apps/control-plane/src/application/services/worktree-watchdog-service.ts +156 -0
  17. package/apps/control-plane/src/cli/cli-argument-parser.ts +12 -0
  18. package/apps/control-plane/src/cli/help-command-handler.ts +17 -0
  19. package/apps/control-plane/src/cli/init-command-handler.ts +31 -0
  20. package/apps/control-plane/src/cli/resume-command-handler.ts +31 -4
  21. package/apps/control-plane/src/cli/rollback-command-handler.ts +217 -0
  22. package/apps/control-plane/src/cli/run-command-handler.ts +8 -0
  23. package/apps/control-plane/src/cli/types.ts +3 -0
  24. package/apps/control-plane/src/core/kernel-types.ts +55 -0
  25. package/apps/control-plane/src/core/kernel.ts +61 -878
  26. package/apps/control-plane/src/core/tool-caller.ts +10 -0
  27. package/apps/control-plane/src/core/utils/field-readers.ts +38 -0
  28. package/apps/control-plane/src/core/utils/index-normalizer.ts +119 -0
  29. package/apps/control-plane/src/core/utils/path-normalizers.ts +22 -0
  30. package/apps/control-plane/src/interfaces/cli/bootstrap.ts +15 -0
  31. package/apps/control-plane/src/providers/api-worker-provider.ts +14 -12
  32. package/apps/control-plane/src/providers/cli-worker-provider.ts +82 -12
  33. package/apps/control-plane/src/providers/providers.ts +45 -24
  34. package/apps/control-plane/src/providers/worker-provider-factory.ts +36 -1
  35. package/apps/control-plane/src/supervisor/run-coordinator.ts +91 -36
  36. package/apps/control-plane/src/supervisor/runtime.ts +107 -1
  37. package/apps/control-plane/src/supervisor/types.ts +9 -0
  38. package/apps/control-plane/src/supervisor/worker-decision-loop.ts +253 -14
  39. package/apps/control-plane/test/checkpoint-service.spec.ts +537 -0
  40. package/apps/control-plane/test/cli-helpers.spec.ts +28 -0
  41. package/apps/control-plane/test/cli.unit.spec.ts +52 -0
  42. package/apps/control-plane/test/configuration-service.spec.ts +466 -0
  43. package/apps/control-plane/test/dashboard-api.integration.spec.ts +537 -0
  44. package/apps/control-plane/test/dashboard-client.spec.ts +233 -0
  45. package/apps/control-plane/test/feature-send-message-service.spec.ts +314 -0
  46. package/apps/control-plane/test/init-wizard.spec.ts +35 -0
  47. package/apps/control-plane/test/path-normalizers.spec.ts +41 -0
  48. package/apps/control-plane/test/repo-operations-service.spec.ts +339 -0
  49. package/apps/control-plane/test/resume-command.spec.ts +33 -0
  50. package/apps/control-plane/test/review-workspace-logic.spec.ts +130 -0
  51. package/apps/control-plane/test/rollback-command.spec.ts +208 -0
  52. package/apps/control-plane/test/run-coordinator.spec.ts +119 -0
  53. package/apps/control-plane/test/worker-decision-loop.spec.ts +209 -0
  54. package/apps/control-plane/test/worker-provider-adapters.spec.ts +102 -0
  55. package/apps/control-plane/test/worker-provider-factory.spec.ts +14 -0
  56. package/apps/control-plane/test/worktree-watchdog-service.spec.ts +147 -0
  57. package/config/agentic/orchestrator/agents.yaml +13 -0
  58. package/dist/apps/control-plane/application/configuration-service.d.ts +19 -0
  59. package/dist/apps/control-plane/application/configuration-service.js +123 -0
  60. package/dist/apps/control-plane/application/configuration-service.js.map +1 -0
  61. package/dist/apps/control-plane/application/kernel-tool-wiring.d.ts +39 -0
  62. package/dist/apps/control-plane/application/kernel-tool-wiring.js +38 -0
  63. package/dist/apps/control-plane/application/kernel-tool-wiring.js.map +1 -0
  64. package/dist/apps/control-plane/application/services/checkpoint-service.d.ts +84 -0
  65. package/dist/apps/control-plane/application/services/checkpoint-service.js +367 -0
  66. package/dist/apps/control-plane/application/services/checkpoint-service.js.map +1 -0
  67. package/dist/apps/control-plane/application/services/feature-send-message-service.d.ts +25 -0
  68. package/dist/apps/control-plane/application/services/feature-send-message-service.js +105 -0
  69. package/dist/apps/control-plane/application/services/feature-send-message-service.js.map +1 -0
  70. package/dist/apps/control-plane/application/services/patch-service.d.ts +6 -0
  71. package/dist/apps/control-plane/application/services/patch-service.js +11 -2
  72. package/dist/apps/control-plane/application/services/patch-service.js.map +1 -1
  73. package/dist/apps/control-plane/application/services/repo-operations-service.d.ts +70 -0
  74. package/dist/apps/control-plane/application/services/repo-operations-service.js +213 -0
  75. package/dist/apps/control-plane/application/services/repo-operations-service.js.map +1 -0
  76. package/dist/apps/control-plane/application/services/worktree-watchdog-service.d.ts +23 -0
  77. package/dist/apps/control-plane/application/services/worktree-watchdog-service.js +119 -0
  78. package/dist/apps/control-plane/application/services/worktree-watchdog-service.js.map +1 -0
  79. package/dist/apps/control-plane/cli/cli-argument-parser.js +12 -0
  80. package/dist/apps/control-plane/cli/cli-argument-parser.js.map +1 -1
  81. package/dist/apps/control-plane/cli/help-command-handler.js +17 -0
  82. package/dist/apps/control-plane/cli/help-command-handler.js.map +1 -1
  83. package/dist/apps/control-plane/cli/init-command-handler.js +23 -0
  84. package/dist/apps/control-plane/cli/init-command-handler.js.map +1 -1
  85. package/dist/apps/control-plane/cli/resume-command-handler.js +25 -5
  86. package/dist/apps/control-plane/cli/resume-command-handler.js.map +1 -1
  87. package/dist/apps/control-plane/cli/rollback-command-handler.d.ts +6 -0
  88. package/dist/apps/control-plane/cli/rollback-command-handler.js +177 -0
  89. package/dist/apps/control-plane/cli/rollback-command-handler.js.map +1 -0
  90. package/dist/apps/control-plane/cli/run-command-handler.js +7 -1
  91. package/dist/apps/control-plane/cli/run-command-handler.js.map +1 -1
  92. package/dist/apps/control-plane/cli/types.d.ts +3 -0
  93. package/dist/apps/control-plane/cli/types.js +1 -0
  94. package/dist/apps/control-plane/cli/types.js.map +1 -1
  95. package/dist/apps/control-plane/core/configuration-service.d.ts +25 -0
  96. package/dist/apps/control-plane/core/configuration-service.js +130 -0
  97. package/dist/apps/control-plane/core/configuration-service.js.map +1 -0
  98. package/dist/apps/control-plane/core/kernel-tool-wiring.d.ts +50 -0
  99. package/dist/apps/control-plane/core/kernel-tool-wiring.js +44 -0
  100. package/dist/apps/control-plane/core/kernel-tool-wiring.js.map +1 -0
  101. package/dist/apps/control-plane/core/kernel-types.d.ts +48 -0
  102. package/dist/apps/control-plane/core/kernel-types.js +2 -0
  103. package/dist/apps/control-plane/core/kernel-types.js.map +1 -0
  104. package/dist/apps/control-plane/core/kernel.d.ts +17 -48
  105. package/dist/apps/control-plane/core/kernel.js +44 -539
  106. package/dist/apps/control-plane/core/kernel.js.map +1 -1
  107. package/dist/apps/control-plane/core/tool-caller.d.ts +10 -0
  108. package/dist/apps/control-plane/core/utils/error-normalizer.d.ts +2 -0
  109. package/dist/apps/control-plane/core/utils/error-normalizer.js +51 -0
  110. package/dist/apps/control-plane/core/utils/error-normalizer.js.map +1 -0
  111. package/dist/apps/control-plane/core/utils/field-readers.d.ts +9 -0
  112. package/dist/apps/control-plane/core/utils/field-readers.js +30 -0
  113. package/dist/apps/control-plane/core/utils/field-readers.js.map +1 -0
  114. package/dist/apps/control-plane/core/utils/index-normalizer.d.ts +7 -0
  115. package/dist/apps/control-plane/core/utils/index-normalizer.js +92 -0
  116. package/dist/apps/control-plane/core/utils/index-normalizer.js.map +1 -0
  117. package/dist/apps/control-plane/core/utils/path-normalizers.d.ts +2 -0
  118. package/dist/apps/control-plane/core/utils/path-normalizers.js +17 -0
  119. package/dist/apps/control-plane/core/utils/path-normalizers.js.map +1 -0
  120. package/dist/apps/control-plane/interfaces/cli/bootstrap.js +13 -1
  121. package/dist/apps/control-plane/interfaces/cli/bootstrap.js.map +1 -1
  122. package/dist/apps/control-plane/providers/api-worker-provider.d.ts +4 -13
  123. package/dist/apps/control-plane/providers/api-worker-provider.js +10 -0
  124. package/dist/apps/control-plane/providers/api-worker-provider.js.map +1 -1
  125. package/dist/apps/control-plane/providers/cli-worker-provider.d.ts +11 -13
  126. package/dist/apps/control-plane/providers/cli-worker-provider.js +64 -0
  127. package/dist/apps/control-plane/providers/cli-worker-provider.js.map +1 -1
  128. package/dist/apps/control-plane/providers/providers.d.ts +31 -24
  129. package/dist/apps/control-plane/providers/providers.js +10 -0
  130. package/dist/apps/control-plane/providers/providers.js.map +1 -1
  131. package/dist/apps/control-plane/providers/worker-provider-factory.d.ts +11 -0
  132. package/dist/apps/control-plane/providers/worker-provider-factory.js +20 -1
  133. package/dist/apps/control-plane/providers/worker-provider-factory.js.map +1 -1
  134. package/dist/apps/control-plane/supervisor/run-coordinator.d.ts +3 -0
  135. package/dist/apps/control-plane/supervisor/run-coordinator.js +81 -33
  136. package/dist/apps/control-plane/supervisor/run-coordinator.js.map +1 -1
  137. package/dist/apps/control-plane/supervisor/runtime.d.ts +8 -1
  138. package/dist/apps/control-plane/supervisor/runtime.js +90 -0
  139. package/dist/apps/control-plane/supervisor/runtime.js.map +1 -1
  140. package/dist/apps/control-plane/supervisor/types.d.ts +11 -0
  141. package/dist/apps/control-plane/supervisor/types.js.map +1 -1
  142. package/dist/apps/control-plane/supervisor/worker-decision-loop.d.ts +21 -1
  143. package/dist/apps/control-plane/supervisor/worker-decision-loop.js +207 -13
  144. package/dist/apps/control-plane/supervisor/worker-decision-loop.js.map +1 -1
  145. package/package.json +1 -1
  146. package/packages/web-dashboard/package.json +2 -0
  147. package/packages/web-dashboard/src/app/analytics/page.tsx +83 -2
  148. package/packages/web-dashboard/src/app/api/actions/route.ts +92 -1
  149. package/packages/web-dashboard/src/app/api/analytics/route.ts +5 -2
  150. package/packages/web-dashboard/src/app/api/features/[id]/checkpoints/[checkpointId]/diff/route.ts +43 -0
  151. package/packages/web-dashboard/src/app/api/features/[id]/checkpoints/compare/route.ts +45 -0
  152. package/packages/web-dashboard/src/app/api/features/[id]/checkpoints/stream/route.ts +170 -0
  153. package/packages/web-dashboard/src/app/api/features/[id]/file-diff/route.ts +144 -0
  154. package/packages/web-dashboard/src/app/api/features/[id]/log-stream/route.ts +167 -0
  155. package/packages/web-dashboard/src/app/api/features/[id]/raw-logs/[filename]/route.ts +65 -0
  156. package/packages/web-dashboard/src/app/api/features/[id]/raw-logs/route.ts +63 -0
  157. package/packages/web-dashboard/src/app/api/features/[id]/timeline/route.ts +60 -0
  158. package/packages/web-dashboard/src/app/feature/[id]/page.tsx +32 -11
  159. package/packages/web-dashboard/src/app/globals.css +2 -0
  160. package/packages/web-dashboard/src/components/detail-panel.tsx +483 -0
  161. package/packages/web-dashboard/src/components/review-workspace.tsx +1162 -0
  162. package/packages/web-dashboard/src/lib/aop-client.ts +725 -0
  163. package/packages/web-dashboard/src/lib/review-contracts.ts +182 -0
  164. package/packages/web-dashboard/src/lib/review-workspace-logic.ts +64 -0
  165. package/packages/web-dashboard/src/lib/types.ts +131 -0
  166. package/packages/web-dashboard/src/styles/dashboard.module.css +333 -0
  167. package/spec-files/completed/agentic_orchestrator_execution_mode_spec.md +1905 -0
  168. package/spec-files/outstanding/agentic_orchestrator_runtime_inspection_spec.md +940 -0
  169. package/spec-files/outstanding/execution_mode_critical_review.md +355 -0
  170. package/spec-files/outstanding/shadow_workspace_implementation_spec.md +1271 -0
  171. package/spec-files/outstanding/shadow_workspace_spec_summary.md +222 -0
  172. package/spec-files/progress.md +269 -1
@@ -2,31 +2,75 @@ import { readdir, readFile, stat } from 'node:fs/promises';
2
2
  import path from 'node:path';
3
3
  import YAML from 'yaml';
4
4
  import { getProjectByName, readMultiProjectConfig } from '@/lib/multi-project-config.js';
5
+ import { parseUnifiedDiff } from '@/lib/dashboard-utils.js';
5
6
  import type {
7
+ AgentLogEntry,
8
+ AgentSessionCluster,
6
9
  AgentPipelineStatus,
7
10
  CostSummary,
8
11
  DashboardStatusPayload,
9
12
  EvidenceArtifact,
13
+ FeatureCheckpointComparison,
14
+ FeatureCheckpointDiff,
10
15
  FeatureDetail,
11
16
  FeatureLog,
12
17
  FeatureLogEntry,
13
18
  FeaturesIndex,
14
19
  FeatureSummary,
20
+ FeatureCheckpoint,
21
+ FeatureExecutionMode,
15
22
  GateRunEvidence,
23
+ InteractiveExecutionModeMetrics,
24
+ InteractivePerformanceLatestCheckpoint,
25
+ InteractivePerformanceMetrics,
16
26
  LockLease,
17
27
  QaTestIndex,
28
+ RawLogFileMeta,
18
29
  ReviewBrief,
19
30
  RoleStatus,
20
31
  RuntimeSession,
32
+ WorkerEventEntry,
21
33
  } from '@/lib/types.js';
22
34
 
23
35
  const AOP_ROOT = process.env.AOP_ROOT ?? process.cwd();
36
+ const LOG_FILENAME_PATTERN = /^(planner|builder|qa)-(\d{13})\.txt$/;
37
+ const CHECKPOINT_METRICS_HISTORY_LIMIT = 200;
24
38
 
25
39
  interface ParsedFrontMatter {
26
40
  frontMatter: Record<string, unknown>;
27
41
  body: string;
28
42
  }
29
43
 
44
+ interface InteractiveCheckpointMetricRecord {
45
+ recorded_at: string;
46
+ feature_id: string;
47
+ checkpoint_id: string;
48
+ trigger: string;
49
+ validation_status: 'valid' | 'invalid' | 'skipped';
50
+ checkpoint_latency_ms: number;
51
+ validation_latency_ms: number;
52
+ diff_capture_latency_ms: number;
53
+ files_changed: number;
54
+ }
55
+
56
+ interface InteractiveMetricsFileShape {
57
+ schema_version: number;
58
+ updated_at: string;
59
+ totals: {
60
+ checkpoint_count: number;
61
+ valid_count: number;
62
+ invalid_count: number;
63
+ skipped_count: number;
64
+ files_changed_total: number;
65
+ };
66
+ histories: {
67
+ checkpoint_latency_ms: number[];
68
+ validation_latency_ms: number[];
69
+ diff_capture_latency_ms: number[];
70
+ };
71
+ latest: InteractiveCheckpointMetricRecord | null;
72
+ }
73
+
30
74
  const STATUS_TO_PHASE: Record<string, FeatureSummary['phase']> = {
31
75
  planning: 'planning',
32
76
  building: 'building',
@@ -91,6 +135,39 @@ function parseRoleStatus(frontMatter: Record<string, unknown>): AgentPipelineSta
91
135
  return { planner, builder, qa };
92
136
  }
93
137
 
138
+ function isSessionId(value: unknown): value is string {
139
+ if (typeof value !== 'string') {
140
+ return false;
141
+ }
142
+ const trimmed = value.trim();
143
+ return (
144
+ trimmed.length > 0 && trimmed !== 'unknown' && trimmed !== 'unassigned' && trimmed !== 'none'
145
+ );
146
+ }
147
+
148
+ function parseSessionCluster(frontMatter: Record<string, unknown>): AgentSessionCluster | null {
149
+ const raw = frontMatter.cluster;
150
+ if (!raw || typeof raw !== 'object') {
151
+ return null;
152
+ }
153
+ const cluster = raw as Record<string, unknown>;
154
+ const orchestrator = isSessionId(cluster.orchestrator_session_id)
155
+ ? cluster.orchestrator_session_id
156
+ : null;
157
+ const planner = isSessionId(cluster.planner_session_id) ? cluster.planner_session_id : null;
158
+ const builder = isSessionId(cluster.builder_session_id) ? cluster.builder_session_id : null;
159
+ const qa = isSessionId(cluster.qa_session_id) ? cluster.qa_session_id : null;
160
+ if (!orchestrator && !planner && !builder && !qa) {
161
+ return null;
162
+ }
163
+ return {
164
+ orchestrator_session_id: orchestrator ?? 'unknown',
165
+ planner_session_id: planner ?? 'unknown',
166
+ builder_session_id: builder ?? 'unknown',
167
+ qa_session_id: qa ?? 'unknown',
168
+ };
169
+ }
170
+
94
171
  function firstValidTimestamp(frontMatter: Record<string, unknown>): number | null {
95
172
  const candidates = [
96
173
  frontMatter.activity_last_event_at,
@@ -166,6 +243,133 @@ function asRecord(value: unknown): Record<string, unknown> | null {
166
243
  return value && typeof value === 'object' ? (value as Record<string, unknown>) : null;
167
244
  }
168
245
 
246
+ function normalizeFeatureExecutionMode(value: unknown): FeatureExecutionMode | null {
247
+ return value === 'deterministic' || value === 'interactive' ? value : null;
248
+ }
249
+
250
+ function normalizeFeatureCheckpoints(frontMatter: Record<string, unknown>): FeatureCheckpoint[] {
251
+ const raw = frontMatter['checkpoints'];
252
+ if (!Array.isArray(raw)) {
253
+ return [];
254
+ }
255
+ const checkpoints: FeatureCheckpoint[] = [];
256
+ for (const entry of raw) {
257
+ const record = asRecord(entry);
258
+ if (!record) {
259
+ continue;
260
+ }
261
+ const checkpointId =
262
+ typeof record.checkpoint_id === 'string' ? record.checkpoint_id.trim() : '';
263
+ const timestamp = typeof record.timestamp === 'string' ? record.timestamp.trim() : '';
264
+ const diffSnapshot =
265
+ typeof record.diff_snapshot === 'string' ? record.diff_snapshot.trim() : '';
266
+ const validationStatus =
267
+ record.validation_status === 'valid' ||
268
+ record.validation_status === 'invalid' ||
269
+ record.validation_status === 'skipped'
270
+ ? record.validation_status
271
+ : null;
272
+ if (!checkpointId || !timestamp || !diffSnapshot || !validationStatus) {
273
+ continue;
274
+ }
275
+
276
+ const filesChanged = Array.isArray(record.files_changed)
277
+ ? record.files_changed.filter((item): item is string => typeof item === 'string')
278
+ : [];
279
+ const violations = Array.isArray(record.violations)
280
+ ? record.violations.filter((item): item is string => typeof item === 'string')
281
+ : [];
282
+ const severity =
283
+ record.severity === 'info' ||
284
+ record.severity === 'warning' ||
285
+ record.severity === 'error' ||
286
+ record.severity === 'critical'
287
+ ? record.severity
288
+ : undefined;
289
+ checkpoints.push({
290
+ checkpoint_id: checkpointId,
291
+ timestamp,
292
+ files_changed: filesChanged,
293
+ validation_status: validationStatus,
294
+ violations,
295
+ severity,
296
+ diff_snapshot: diffSnapshot,
297
+ });
298
+ }
299
+
300
+ checkpoints.sort((left, right) => {
301
+ const rightTs = Date.parse(right.timestamp);
302
+ const leftTs = Date.parse(left.timestamp);
303
+ if (Number.isNaN(rightTs) || Number.isNaN(leftTs)) {
304
+ return right.checkpoint_id.localeCompare(left.checkpoint_id);
305
+ }
306
+ return rightTs - leftTs;
307
+ });
308
+
309
+ return checkpoints;
310
+ }
311
+
312
+ function average(values: number[]): number | null {
313
+ if (values.length === 0) {
314
+ return null;
315
+ }
316
+ return values.reduce((sum, value) => sum + value, 0) / values.length;
317
+ }
318
+
319
+ function percentile(values: number[], percentileRank: number): number | null {
320
+ if (values.length === 0) {
321
+ return null;
322
+ }
323
+ const sorted = [...values].sort((left, right) => left - right);
324
+ const rank = Math.min(
325
+ sorted.length - 1,
326
+ Math.max(0, Math.ceil((percentileRank / 100) * sorted.length) - 1),
327
+ );
328
+ return sorted[rank] ?? null;
329
+ }
330
+
331
+ function normalizeNumberArray(value: unknown): number[] {
332
+ if (!Array.isArray(value)) {
333
+ return [];
334
+ }
335
+ return value.filter(
336
+ (entry): entry is number => typeof entry === 'number' && Number.isFinite(entry),
337
+ );
338
+ }
339
+
340
+ function truncateNumber(value: number | null, digits = 2): number | null {
341
+ if (value == null) {
342
+ return null;
343
+ }
344
+ const factor = Math.pow(10, digits);
345
+ return Math.round(value * factor) / factor;
346
+ }
347
+
348
+ function resolveRepoRelativePath(repoRoot: string, relativePath: string): string | null {
349
+ const resolved = path.resolve(repoRoot, relativePath);
350
+ const normalizedRoot = path.resolve(repoRoot);
351
+ if (resolved === normalizedRoot) {
352
+ return resolved;
353
+ }
354
+ if (!resolved.startsWith(`${normalizedRoot}${path.sep}`)) {
355
+ return null;
356
+ }
357
+ return resolved;
358
+ }
359
+
360
+ function normalizeCheckpointDiffSignature(rawDiff: string): Map<string, string> {
361
+ const signatures = new Map<string, string>();
362
+ const files = parseUnifiedDiff(rawDiff);
363
+ for (const file of files) {
364
+ const key = file.filePath.trim();
365
+ if (!key) {
366
+ continue;
367
+ }
368
+ signatures.set(key, JSON.stringify(file));
369
+ }
370
+ return signatures;
371
+ }
372
+
169
373
  async function readJsonFile<T>(filePath: string): Promise<T | null> {
170
374
  try {
171
375
  const raw = await readFile(filePath, 'utf-8');
@@ -295,6 +499,7 @@ function normalizeFeatureSummary(
295
499
  }
296
500
  : undefined;
297
501
  const roleStatus = parseRoleStatus(frontMatter);
502
+ const cluster = parseSessionCluster(frontMatter);
298
503
  const rawLocks =
299
504
  frontMatter.locks && typeof frontMatter.locks === 'object'
300
505
  ? (frontMatter.locks as Record<string, unknown>)
@@ -331,6 +536,7 @@ function normalizeFeatureSummary(
331
536
  revision_reason:
332
537
  typeof frontMatter.revision_reason === 'string' ? frontMatter.revision_reason : null,
333
538
  role_status: roleStatus ?? undefined,
539
+ cluster: cluster ?? undefined,
334
540
  gate_retry_count:
335
541
  typeof frontMatter.gate_retry_count === 'number' ? frontMatter.gate_retry_count : undefined,
336
542
  last_retry_at: typeof frontMatter.last_retry_at === 'string' ? frontMatter.last_retry_at : null,
@@ -469,6 +675,26 @@ export async function readFeatureState(
469
675
  }
470
676
  }
471
677
 
678
+ export async function readFeatureCheckpoints(
679
+ featureId: string,
680
+ repoRoot = AOP_ROOT,
681
+ ): Promise<{
682
+ execution_mode: FeatureExecutionMode | null;
683
+ checkpoints: FeatureCheckpoint[];
684
+ } | null> {
685
+ const statePath = path.join(getFeatureRoot(featureId, repoRoot), 'state.md');
686
+ try {
687
+ const raw = await readFile(statePath, 'utf-8');
688
+ const { frontMatter } = parseFrontMatter(raw);
689
+ return {
690
+ execution_mode: normalizeFeatureExecutionMode(frontMatter.execution_mode),
691
+ checkpoints: normalizeFeatureCheckpoints(frontMatter),
692
+ };
693
+ } catch {
694
+ return null;
695
+ }
696
+ }
697
+
472
698
  export async function readFeaturePlan(
473
699
  featureId: string,
474
700
  repoRoot = AOP_ROOT,
@@ -622,6 +848,93 @@ export async function readFeatureDiff(featureId: string, repoRoot = AOP_ROOT): P
622
848
  }
623
849
  }
624
850
 
851
+ export async function readFeatureCheckpointDiff(
852
+ featureId: string,
853
+ checkpointId: string,
854
+ repoRoot = AOP_ROOT,
855
+ ): Promise<FeatureCheckpointDiff | null> {
856
+ const checkpointState = await readFeatureCheckpoints(featureId, repoRoot);
857
+ if (!checkpointState) {
858
+ return null;
859
+ }
860
+ const checkpoint = checkpointState.checkpoints.find(
861
+ (entry) => entry.checkpoint_id === checkpointId,
862
+ );
863
+ if (!checkpoint) {
864
+ return null;
865
+ }
866
+ const resolvedSnapshotPath = resolveRepoRelativePath(repoRoot, checkpoint.diff_snapshot);
867
+ if (!resolvedSnapshotPath) {
868
+ return null;
869
+ }
870
+ try {
871
+ const diff = await readFile(resolvedSnapshotPath, 'utf-8');
872
+ return {
873
+ checkpoint,
874
+ diff,
875
+ };
876
+ } catch {
877
+ return null;
878
+ }
879
+ }
880
+
881
+ export async function compareFeatureCheckpoints(
882
+ featureId: string,
883
+ fromCheckpointId: string,
884
+ toCheckpointId: string,
885
+ repoRoot = AOP_ROOT,
886
+ ): Promise<FeatureCheckpointComparison | null> {
887
+ if (fromCheckpointId === toCheckpointId) {
888
+ return null;
889
+ }
890
+ const [fromSnapshot, toSnapshot] = await Promise.all([
891
+ readFeatureCheckpointDiff(featureId, fromCheckpointId, repoRoot),
892
+ readFeatureCheckpointDiff(featureId, toCheckpointId, repoRoot),
893
+ ]);
894
+ if (!fromSnapshot || !toSnapshot) {
895
+ return null;
896
+ }
897
+
898
+ const fromSignatures = normalizeCheckpointDiffSignature(fromSnapshot.diff);
899
+ const toSignatures = normalizeCheckpointDiffSignature(toSnapshot.diff);
900
+ const allPaths = new Set<string>([...fromSignatures.keys(), ...toSignatures.keys()]);
901
+
902
+ const files: FeatureCheckpointComparison['files'] = [];
903
+ for (const filePath of allPaths) {
904
+ const fromSignature = fromSignatures.get(filePath);
905
+ const toSignature = toSignatures.get(filePath);
906
+ let status: FeatureCheckpointComparison['files'][number]['status'] = 'unchanged';
907
+ if (fromSignature == null && toSignature != null) {
908
+ status = 'added';
909
+ } else if (fromSignature != null && toSignature == null) {
910
+ status = 'removed';
911
+ } else if (fromSignature !== toSignature) {
912
+ status = 'changed';
913
+ }
914
+ files.push({ path: filePath, status });
915
+ }
916
+
917
+ files.sort((left, right) => left.path.localeCompare(right.path));
918
+
919
+ const summary = {
920
+ added: files.filter((file) => file.status === 'added').length,
921
+ removed: files.filter((file) => file.status === 'removed').length,
922
+ changed: files.filter((file) => file.status === 'changed').length,
923
+ unchanged: files.filter((file) => file.status === 'unchanged').length,
924
+ };
925
+
926
+ return {
927
+ from_checkpoint_id: fromCheckpointId,
928
+ to_checkpoint_id: toCheckpointId,
929
+ from_timestamp: fromSnapshot.checkpoint.timestamp,
930
+ to_timestamp: toSnapshot.checkpoint.timestamp,
931
+ summary,
932
+ files,
933
+ from_diff: fromSnapshot.diff,
934
+ to_diff: toSnapshot.diff,
935
+ };
936
+ }
937
+
625
938
  export async function listEvidenceArtifacts(
626
939
  featureId: string,
627
940
  repoRoot = AOP_ROOT,
@@ -798,6 +1111,76 @@ function parseStateLogBody(body: string): FeatureLogEntry[] {
798
1111
  return entries;
799
1112
  }
800
1113
 
1114
+ function parseRoleFromActor(actor: string): AgentLogEntry['role'] {
1115
+ const candidate = actor.split(':')[0]?.trim().toLowerCase();
1116
+ if (!candidate) {
1117
+ return 'orchestrator';
1118
+ }
1119
+ return candidate;
1120
+ }
1121
+
1122
+ function parseDecisionEntries(raw: string): AgentLogEntry[] {
1123
+ const lines = raw.replace(/\r\n/g, '\n').split('\n');
1124
+ const entries: AgentLogEntry[] = [];
1125
+
1126
+ for (const line of lines) {
1127
+ const trimmed = line.trim();
1128
+ if (trimmed.length === 0 || trimmed.startsWith('#')) {
1129
+ continue;
1130
+ }
1131
+ const match = trimmed.match(/^-?\s*([0-9TZ:.-]{10,})\s+\[([^\]]+)\]\s+([\s\S]+)$/);
1132
+ if (match) {
1133
+ const timestampRaw = match[1];
1134
+ const actor = match[2].trim();
1135
+ const text = match[3].trim();
1136
+ const timestamp = Number.isNaN(Date.parse(timestampRaw))
1137
+ ? new Date().toISOString()
1138
+ : new Date(timestampRaw).toISOString();
1139
+ entries.push({
1140
+ id: `${timestamp}:${entries.length}`,
1141
+ timestamp,
1142
+ actor,
1143
+ role: parseRoleFromActor(actor),
1144
+ text,
1145
+ });
1146
+ continue;
1147
+ }
1148
+
1149
+ entries.push({
1150
+ id: `line:${entries.length}`,
1151
+ timestamp: new Date().toISOString(),
1152
+ actor: 'system:unknown',
1153
+ role: 'orchestrator',
1154
+ text: trimmed,
1155
+ });
1156
+ }
1157
+
1158
+ return entries;
1159
+ }
1160
+
1161
+ export async function readDecisionLogEntries(
1162
+ featureId: string,
1163
+ repoRoot = AOP_ROOT,
1164
+ options: { order?: 'asc' | 'desc' } = {},
1165
+ ): Promise<AgentLogEntry[]> {
1166
+ const logPath = path.join(getFeatureRoot(featureId, repoRoot), 'decisions.md');
1167
+ const order = options.order ?? 'desc';
1168
+ try {
1169
+ const raw = await readFile(logPath, 'utf8');
1170
+ const sorted = parseDecisionEntries(raw).sort((left, right) => {
1171
+ const leftMs = Date.parse(left.timestamp);
1172
+ const rightMs = Date.parse(right.timestamp);
1173
+ if (leftMs === rightMs) {
1174
+ return left.id.localeCompare(right.id);
1175
+ }
1176
+ return leftMs - rightMs;
1177
+ });
1178
+ return order === 'desc' ? [...sorted].reverse() : sorted;
1179
+ } catch {
1180
+ return [];
1181
+ }
1182
+ }
1183
+
801
1184
  export async function readFeatureLog(featureId: string, repoRoot = AOP_ROOT): Promise<FeatureLog> {
802
1185
  const statePath = path.join(getFeatureRoot(featureId, repoRoot), 'state.md');
803
1186
  const entries: FeatureLogEntry[] = [];
@@ -833,6 +1216,342 @@ export async function readFeatureLog(featureId: string, repoRoot = AOP_ROOT): Pr
833
1216
  };
834
1217
  }
835
1218
 
1219
+ function normalizeRawLogRole(value: unknown): RawLogFileMeta['role'] | null {
1220
+ return value === 'planner' || value === 'builder' || value === 'qa' ? value : null;
1221
+ }
1222
+
1223
+ export async function listRawLogFiles(
1224
+ featureId: string,
1225
+ repoRoot = AOP_ROOT,
1226
+ options: { role?: string | null; limit?: number } = {},
1227
+ ): Promise<RawLogFileMeta[]> {
1228
+ const logsDir = path.join(getFeatureRoot(featureId, repoRoot), 'logs');
1229
+ const roleFilter = normalizeRawLogRole(options.role ?? null);
1230
+ const hardLimit = Math.min(Math.max(options.limit ?? 100, 1), 200);
1231
+ try {
1232
+ const files = await readdir(logsDir);
1233
+ const entries: RawLogFileMeta[] = [];
1234
+ for (const filename of files) {
1235
+ const match = filename.match(LOG_FILENAME_PATTERN);
1236
+ if (!match) {
1237
+ continue;
1238
+ }
1239
+ const role = normalizeRawLogRole(match[1]);
1240
+ if (!role) {
1241
+ continue;
1242
+ }
1243
+ if (roleFilter && role !== roleFilter) {
1244
+ continue;
1245
+ }
1246
+ const unixMs = Number.parseInt(match[2], 10);
1247
+ const filePath = path.join(logsDir, filename);
1248
+ const fileStat = await stat(filePath).catch(() => null);
1249
+ if (!fileStat?.isFile()) {
1250
+ continue;
1251
+ }
1252
+ entries.push({
1253
+ filename,
1254
+ role,
1255
+ timestamp: Number.isNaN(unixMs)
1256
+ ? fileStat.mtime.toISOString()
1257
+ : new Date(unixMs).toISOString(),
1258
+ size_bytes: fileStat.size,
1259
+ });
1260
+ }
1261
+ return entries
1262
+ .sort((left, right) => Date.parse(right.timestamp) - Date.parse(left.timestamp))
1263
+ .slice(0, hardLimit);
1264
+ } catch {
1265
+ return [];
1266
+ }
1267
+ }
1268
+
1269
+ export async function readRawLogFile(
1270
+ featureId: string,
1271
+ filename: string,
1272
+ repoRoot = AOP_ROOT,
1273
+ ): Promise<string | null> {
1274
+ if (!LOG_FILENAME_PATTERN.test(filename)) {
1275
+ return null;
1276
+ }
1277
+ const logPath = path.join(getFeatureRoot(featureId, repoRoot), 'logs', filename);
1278
+ try {
1279
+ return await readFile(logPath, 'utf8');
1280
+ } catch {
1281
+ return null;
1282
+ }
1283
+ }
1284
+
1285
+ interface WorkerTimelineReadOptions {
1286
+ role?: string | null;
1287
+ valid?: 'all' | 'valid' | 'invalid';
1288
+ limit?: number;
1289
+ }
1290
+
1291
+ function asWorkerEventEntry(value: unknown): WorkerEventEntry | null {
1292
+ if (!value || typeof value !== 'object') {
1293
+ return null;
1294
+ }
1295
+ const record = value as Record<string, unknown>;
1296
+ if (typeof record.ts !== 'string' || typeof record.role !== 'string') {
1297
+ return null;
1298
+ }
1299
+ const outputTypes = Array.isArray(record.output_types)
1300
+ ? record.output_types.filter((item): item is string => typeof item === 'string')
1301
+ : [];
1302
+ return {
1303
+ ts: record.ts,
1304
+ role: record.role,
1305
+ output_types: outputTypes,
1306
+ patch_count: typeof record.patch_count === 'number' ? record.patch_count : 0,
1307
+ plan_submission_count:
1308
+ typeof record.plan_submission_count === 'number' ? record.plan_submission_count : 0,
1309
+ request_count: typeof record.request_count === 'number' ? record.request_count : 0,
1310
+ note_count: typeof record.note_count === 'number' ? record.note_count : 0,
1311
+ valid: record.valid !== false,
1312
+ error_code: typeof record.error_code === 'string' ? record.error_code : null,
1313
+ provider: typeof record.provider === 'string' ? record.provider : 'unknown',
1314
+ model: typeof record.model === 'string' ? record.model : 'unknown',
1315
+ elapsed_ms: typeof record.elapsed_ms === 'number' ? record.elapsed_ms : undefined,
1316
+ feature_id: typeof record.feature_id === 'string' ? record.feature_id : undefined,
1317
+ event_type: typeof record.event_type === 'string' ? record.event_type : undefined,
1318
+ execution_mode:
1319
+ record.execution_mode === 'interactive' || record.execution_mode === 'deterministic'
1320
+ ? record.execution_mode
1321
+ : undefined,
1322
+ run_id: typeof record.run_id === 'string' ? record.run_id : undefined,
1323
+ };
1324
+ }
1325
+
1326
+ export async function readWorkerTimelineEntries(
1327
+ featureId: string,
1328
+ repoRoot = AOP_ROOT,
1329
+ options: WorkerTimelineReadOptions = {},
1330
+ ): Promise<WorkerEventEntry[]> {
1331
+ const eventsRoot = path.join(repoRoot, '.aop', 'runtime', 'worker-events');
1332
+ const hardLimit = Math.min(Math.max(options.limit ?? 100, 1), 100);
1333
+ const roleFilter =
1334
+ typeof options.role === 'string' && options.role.length > 0 ? options.role : null;
1335
+ const validityFilter = options.valid ?? 'all';
1336
+
1337
+ try {
1338
+ const files = (await readdir(eventsRoot))
1339
+ .filter((name) => name.endsWith('.jsonl'))
1340
+ .sort((left, right) => right.localeCompare(left));
1341
+ const entries: WorkerEventEntry[] = [];
1342
+ for (const file of files) {
1343
+ const fullPath = path.join(eventsRoot, file);
1344
+ const raw = await readFile(fullPath, 'utf8').catch(() => '');
1345
+ if (!raw) {
1346
+ continue;
1347
+ }
1348
+ for (const line of raw.replace(/\r\n/g, '\n').split('\n')) {
1349
+ if (line.trim().length === 0) {
1350
+ continue;
1351
+ }
1352
+ let parsed: unknown;
1353
+ try {
1354
+ parsed = JSON.parse(line);
1355
+ } catch {
1356
+ continue;
1357
+ }
1358
+ const entry = asWorkerEventEntry(parsed);
1359
+ if (!entry) {
1360
+ continue;
1361
+ }
1362
+ if (entry.feature_id !== featureId) {
1363
+ continue;
1364
+ }
1365
+ if (roleFilter && entry.role !== roleFilter) {
1366
+ continue;
1367
+ }
1368
+ if (validityFilter === 'valid' && !entry.valid) {
1369
+ continue;
1370
+ }
1371
+ if (validityFilter === 'invalid' && entry.valid) {
1372
+ continue;
1373
+ }
1374
+ entries.push(entry);
1375
+ }
1376
+ }
1377
+ return entries
1378
+ .sort((left, right) => Date.parse(right.ts) - Date.parse(left.ts))
1379
+ .slice(0, hardLimit);
1380
+ } catch {
1381
+ return [];
1382
+ }
1383
+ }
1384
+
1385
+ function normalizeLatestInteractiveMetric(
1386
+ value: unknown,
1387
+ ): InteractivePerformanceLatestCheckpoint | null {
1388
+ const record = asRecord(value);
1389
+ if (!record) {
1390
+ return null;
1391
+ }
1392
+ if (
1393
+ typeof record.feature_id !== 'string' ||
1394
+ typeof record.checkpoint_id !== 'string' ||
1395
+ typeof record.trigger !== 'string' ||
1396
+ typeof record.validation_status !== 'string' ||
1397
+ typeof record.recorded_at !== 'string'
1398
+ ) {
1399
+ return null;
1400
+ }
1401
+ if (
1402
+ record.validation_status !== 'valid' &&
1403
+ record.validation_status !== 'invalid' &&
1404
+ record.validation_status !== 'skipped'
1405
+ ) {
1406
+ return null;
1407
+ }
1408
+ return {
1409
+ feature_id: record.feature_id,
1410
+ checkpoint_id: record.checkpoint_id,
1411
+ trigger: record.trigger,
1412
+ validation_status: record.validation_status,
1413
+ checkpoint_latency_ms:
1414
+ typeof record.checkpoint_latency_ms === 'number' ? record.checkpoint_latency_ms : 0,
1415
+ validation_latency_ms:
1416
+ typeof record.validation_latency_ms === 'number' ? record.validation_latency_ms : 0,
1417
+ diff_capture_latency_ms:
1418
+ typeof record.diff_capture_latency_ms === 'number' ? record.diff_capture_latency_ms : 0,
1419
+ files_changed: typeof record.files_changed === 'number' ? record.files_changed : 0,
1420
+ recorded_at: record.recorded_at,
1421
+ };
1422
+ }
1423
+
1424
+ async function readAllWorkerEvents(repoRoot = AOP_ROOT): Promise<WorkerEventEntry[]> {
1425
+ const eventsRoot = path.join(repoRoot, '.aop', 'runtime', 'worker-events');
1426
+ try {
1427
+ const files = (await readdir(eventsRoot))
1428
+ .filter((name) => name.endsWith('.jsonl'))
1429
+ .sort((left, right) => right.localeCompare(left));
1430
+ const entries: WorkerEventEntry[] = [];
1431
+ for (const file of files) {
1432
+ const fullPath = path.join(eventsRoot, file);
1433
+ const raw = await readFile(fullPath, 'utf8').catch(() => '');
1434
+ if (!raw) {
1435
+ continue;
1436
+ }
1437
+ for (const line of raw.replace(/\r\n/g, '\n').split('\n')) {
1438
+ if (!line.trim()) {
1439
+ continue;
1440
+ }
1441
+ let parsed: unknown;
1442
+ try {
1443
+ parsed = JSON.parse(line);
1444
+ } catch {
1445
+ continue;
1446
+ }
1447
+ const entry = asWorkerEventEntry(parsed);
1448
+ if (entry) {
1449
+ entries.push(entry);
1450
+ }
1451
+ }
1452
+ }
1453
+ return entries;
1454
+ } catch {
1455
+ return [];
1456
+ }
1457
+ }
1458
+
1459
+ function computeExecutionModeMetrics(entries: WorkerEventEntry[]): InteractiveExecutionModeMetrics {
1460
+ const deterministic = entries.filter((entry) => entry.execution_mode === 'deterministic');
1461
+ const interactive = entries.filter((entry) => entry.execution_mode === 'interactive');
1462
+
1463
+ const deterministicElapsed = deterministic
1464
+ .map((entry) => entry.elapsed_ms)
1465
+ .filter((value): value is number => typeof value === 'number' && Number.isFinite(value));
1466
+ const interactiveElapsed = interactive
1467
+ .map((entry) => entry.elapsed_ms)
1468
+ .filter((value): value is number => typeof value === 'number' && Number.isFinite(value));
1469
+ const deterministicRequests = deterministic.map((entry) => entry.request_count);
1470
+ const interactiveRequests = interactive.map((entry) => entry.request_count);
1471
+
1472
+ const deterministicAvgRequest = average(deterministicRequests);
1473
+ const interactiveAvgRequest = average(interactiveRequests);
1474
+ const reductionRatio =
1475
+ deterministicAvgRequest != null && deterministicAvgRequest > 0 && interactiveAvgRequest != null
1476
+ ? (deterministicAvgRequest - interactiveAvgRequest) / deterministicAvgRequest
1477
+ : null;
1478
+
1479
+ return {
1480
+ interactive_iterations: interactive.length,
1481
+ deterministic_iterations: deterministic.length,
1482
+ interactive_avg_elapsed_ms: truncateNumber(average(interactiveElapsed), 1),
1483
+ deterministic_avg_elapsed_ms: truncateNumber(average(deterministicElapsed), 1),
1484
+ interactive_avg_request_count: truncateNumber(interactiveAvgRequest, 2),
1485
+ deterministic_avg_request_count: truncateNumber(deterministicAvgRequest, 2),
1486
+ context_request_reduction_ratio: truncateNumber(reductionRatio, 4),
1487
+ };
1488
+ }
1489
+
1490
+ export async function readInteractivePerformanceMetrics(
1491
+ repoRoot = AOP_ROOT,
1492
+ ): Promise<InteractivePerformanceMetrics> {
1493
+ const metricsPath = path.join(repoRoot, '.aop', 'analytics', 'interactive-mode.json');
1494
+ const parsed = await readJsonFile<InteractiveMetricsFileShape>(metricsPath);
1495
+ const executionModes = computeExecutionModeMetrics(await readAllWorkerEvents(repoRoot));
1496
+
1497
+ if (!parsed) {
1498
+ return {
1499
+ updated_at: null,
1500
+ checkpoint_count: 0,
1501
+ valid_count: 0,
1502
+ invalid_count: 0,
1503
+ skipped_count: 0,
1504
+ avg_files_changed: null,
1505
+ avg_checkpoint_latency_ms: null,
1506
+ p95_checkpoint_latency_ms: null,
1507
+ avg_validation_latency_ms: null,
1508
+ p95_validation_latency_ms: null,
1509
+ avg_diff_capture_latency_ms: null,
1510
+ latest: null,
1511
+ execution_modes: executionModes,
1512
+ };
1513
+ }
1514
+
1515
+ const checkpointLatencies = normalizeNumberArray(parsed.histories?.checkpoint_latency_ms).slice(
1516
+ -CHECKPOINT_METRICS_HISTORY_LIMIT,
1517
+ );
1518
+ const validationLatencies = normalizeNumberArray(parsed.histories?.validation_latency_ms).slice(
1519
+ -CHECKPOINT_METRICS_HISTORY_LIMIT,
1520
+ );
1521
+ const diffCaptureLatencies = normalizeNumberArray(
1522
+ parsed.histories?.diff_capture_latency_ms,
1523
+ ).slice(-CHECKPOINT_METRICS_HISTORY_LIMIT);
1524
+
1525
+ const checkpointCount =
1526
+ typeof parsed.totals?.checkpoint_count === 'number'
1527
+ ? parsed.totals.checkpoint_count
1528
+ : checkpointLatencies.length;
1529
+ const validCount = typeof parsed.totals?.valid_count === 'number' ? parsed.totals.valid_count : 0;
1530
+ const invalidCount =
1531
+ typeof parsed.totals?.invalid_count === 'number' ? parsed.totals.invalid_count : 0;
1532
+ const skippedCount =
1533
+ typeof parsed.totals?.skipped_count === 'number' ? parsed.totals.skipped_count : 0;
1534
+ const filesChangedTotal =
1535
+ typeof parsed.totals?.files_changed_total === 'number' ? parsed.totals.files_changed_total : 0;
1536
+ const averageFilesChanged = checkpointCount > 0 ? filesChangedTotal / checkpointCount : null;
1537
+
1538
+ return {
1539
+ updated_at: typeof parsed.updated_at === 'string' ? parsed.updated_at : null,
1540
+ checkpoint_count: checkpointCount,
1541
+ valid_count: validCount,
1542
+ invalid_count: invalidCount,
1543
+ skipped_count: skippedCount,
1544
+ avg_files_changed: truncateNumber(averageFilesChanged, 2),
1545
+ avg_checkpoint_latency_ms: truncateNumber(average(checkpointLatencies), 1),
1546
+ p95_checkpoint_latency_ms: truncateNumber(percentile(checkpointLatencies, 95), 1),
1547
+ avg_validation_latency_ms: truncateNumber(average(validationLatencies), 1),
1548
+ p95_validation_latency_ms: truncateNumber(percentile(validationLatencies, 95), 1),
1549
+ avg_diff_capture_latency_ms: truncateNumber(average(diffCaptureLatencies), 1),
1550
+ latest: normalizeLatestInteractiveMetric(parsed.latest),
1551
+ execution_modes: executionModes,
1552
+ };
1553
+ }
1554
+
836
1555
  function buildMergeHistogram14d(features: FeatureSummary[]): number[] {
837
1556
  const days = 14;
838
1557
  const buckets = new Array<number>(days).fill(0);
@@ -947,6 +1666,9 @@ export async function readFeatureDetail(
947
1666
  if (!feature) {
948
1667
  return null;
949
1668
  }
1669
+
1670
+ const checkpointState = await readFeatureCheckpoints(featureId, repoRoot);
1671
+
950
1672
  return {
951
1673
  feature,
952
1674
  plan: await readFeaturePlan(featureId, repoRoot),
@@ -956,5 +1678,8 @@ export async function readFeatureDetail(
956
1678
  cost: await readFeatureCost(featureId, repoRoot),
957
1679
  qa_test_index: await readFeatureQaTestIndex(featureId, repoRoot),
958
1680
  review_brief: await readFeatureReviewBrief(featureId, repoRoot),
1681
+ log_entries: (await readDecisionLogEntries(featureId, repoRoot)).slice(0, 50),
1682
+ execution_mode: checkpointState?.execution_mode ?? null,
1683
+ checkpoints: checkpointState?.checkpoints ?? [],
959
1684
  };
960
1685
  }