@leonarto/spec-embryo 0.1.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 +156 -0
  2. package/package.json +48 -0
  3. package/src/backends/base.ts +18 -0
  4. package/src/backends/deterministic.ts +105 -0
  5. package/src/backends/index.ts +26 -0
  6. package/src/backends/prompt.ts +169 -0
  7. package/src/backends/subprocess.ts +198 -0
  8. package/src/cli.ts +111 -0
  9. package/src/commands/agents.ts +16 -0
  10. package/src/commands/current.ts +95 -0
  11. package/src/commands/doctor.ts +12 -0
  12. package/src/commands/handoff.ts +64 -0
  13. package/src/commands/init.ts +101 -0
  14. package/src/commands/reshape.ts +20 -0
  15. package/src/commands/resume.ts +19 -0
  16. package/src/commands/spec.ts +108 -0
  17. package/src/commands/status.ts +98 -0
  18. package/src/commands/task.ts +190 -0
  19. package/src/commands/ui.ts +35 -0
  20. package/src/domain.ts +357 -0
  21. package/src/engine.ts +290 -0
  22. package/src/frontmatter.ts +83 -0
  23. package/src/index.ts +75 -0
  24. package/src/paths.ts +32 -0
  25. package/src/repository.ts +807 -0
  26. package/src/services/adoption.ts +169 -0
  27. package/src/services/agents.ts +191 -0
  28. package/src/services/dashboard.ts +776 -0
  29. package/src/services/details.ts +453 -0
  30. package/src/services/doctor.ts +452 -0
  31. package/src/services/layout.ts +420 -0
  32. package/src/services/spec-answer-evaluation.ts +103 -0
  33. package/src/services/spec-import.ts +217 -0
  34. package/src/services/spec-questions.ts +343 -0
  35. package/src/services/ui.ts +34 -0
  36. package/src/storage.ts +57 -0
  37. package/src/templates.ts +270 -0
  38. package/tsconfig.json +17 -0
  39. package/web/package.json +24 -0
  40. package/web/src/app.css +83 -0
  41. package/web/src/app.d.ts +6 -0
  42. package/web/src/app.html +11 -0
  43. package/web/src/lib/components/AnalysisFilters.svelte +293 -0
  44. package/web/src/lib/components/DocumentBody.svelte +100 -0
  45. package/web/src/lib/components/MultiSelectDropdown.svelte +280 -0
  46. package/web/src/lib/components/SelectDropdown.svelte +265 -0
  47. package/web/src/lib/server/project-root.ts +34 -0
  48. package/web/src/lib/task-board.ts +20 -0
  49. package/web/src/routes/+layout.server.ts +57 -0
  50. package/web/src/routes/+layout.svelte +421 -0
  51. package/web/src/routes/+layout.ts +1 -0
  52. package/web/src/routes/+page.svelte +530 -0
  53. package/web/src/routes/specs/+page.svelte +416 -0
  54. package/web/src/routes/specs/[specId]/+page.server.ts +81 -0
  55. package/web/src/routes/specs/[specId]/+page.svelte +675 -0
  56. package/web/src/routes/tasks/+page.svelte +341 -0
  57. package/web/src/routes/tasks/[taskId]/+page.server.ts +12 -0
  58. package/web/src/routes/tasks/[taskId]/+page.svelte +431 -0
  59. package/web/src/routes/timeline/+page.svelte +1093 -0
  60. package/web/svelte.config.js +10 -0
  61. package/web/tsconfig.json +9 -0
  62. package/web/vite.config.ts +11 -0
@@ -0,0 +1,776 @@
1
+ import { buildTaskIndex, deriveNextActions, deriveStatusSnapshot, summarizeTaskNode } from "../engine.ts";
2
+ import { loadProjectContext } from "../repository.ts";
3
+ import { buildAgentsHealthReport } from "./agents.ts";
4
+
5
+ export type EffectiveTaskStatus = "todo" | "in_progress" | "blocked" | "review" | "done" | "cancelled";
6
+ export type DashboardKanbanColumnId = "blocked" | "todo" | "in_progress" | "review" | "done";
7
+ export type DashboardTimeWindow = "7d" | "30d" | "90d";
8
+ export type DashboardTimelineMode = "active" | "all";
9
+ export type DashboardTimelineRowSelectionState =
10
+ | "none"
11
+ | "selected"
12
+ | "dependency"
13
+ | "dependency_transitive"
14
+ | "dependent"
15
+ | "dependent_transitive"
16
+ | "blocked_dependent"
17
+ | "blocked_dependent_transitive"
18
+ | "dimmed";
19
+ export type DashboardTimelineGroupSelectionState = "none" | "selected" | "related_direct" | "related_transitive";
20
+
21
+ export interface DashboardFilterOptions {
22
+ selectedSpecIds?: string[];
23
+ timeWindow?: DashboardTimeWindow;
24
+ timelineMode?: DashboardTimelineMode;
25
+ selectedTimelineTaskId?: string;
26
+ }
27
+
28
+ export interface DashboardTaskCard {
29
+ id: string;
30
+ title: string;
31
+ summary: string;
32
+ rawStatus: string;
33
+ effectiveStatus: EffectiveTaskStatus;
34
+ priority: number;
35
+ specIds: string[];
36
+ dependsOn: string[];
37
+ unmetDependencies: string[];
38
+ downstreamCount: number;
39
+ effort: number;
40
+ completedAt?: string;
41
+ cancelledAt?: string;
42
+ archivedAt?: string;
43
+ archiveReason?: string;
44
+ }
45
+
46
+ export interface DashboardSpecCard {
47
+ id: string;
48
+ title: string;
49
+ summary: string;
50
+ status: string;
51
+ taskIds: string[];
52
+ linkedTaskCount: number;
53
+ progress: {
54
+ todo: number;
55
+ inProgress: number;
56
+ blocked: number;
57
+ review: number;
58
+ done: number;
59
+ cancelled: number;
60
+ completionRatio: number;
61
+ };
62
+ }
63
+
64
+ export interface DashboardKanbanColumn {
65
+ id: DashboardKanbanColumnId;
66
+ title: string;
67
+ tasks: DashboardTaskCard[];
68
+ }
69
+
70
+ export interface DashboardTimelineItem {
71
+ taskId: string;
72
+ title: string;
73
+ effectiveStatus: EffectiveTaskStatus;
74
+ specIds: string[];
75
+ effort: number;
76
+ start: number;
77
+ end: number;
78
+ priority: number;
79
+ }
80
+
81
+ export interface DashboardTimelineRow extends DashboardTimelineItem {
82
+ statusLabel: string;
83
+ primarySpecId: string;
84
+ dependencyCount: number;
85
+ archivedAt?: string;
86
+ selectionState: DashboardTimelineRowSelectionState;
87
+ isCrossSpecRelated: boolean;
88
+ }
89
+
90
+ export interface DashboardTimelineGroup {
91
+ specId: string;
92
+ specTitle: string;
93
+ specStatus: string;
94
+ selectionState: DashboardTimelineGroupSelectionState;
95
+ rows: DashboardTimelineRow[];
96
+ }
97
+
98
+ export interface DashboardTimelineSelection {
99
+ taskId: string;
100
+ title: string;
101
+ primarySpecId: string;
102
+ specIds: string[];
103
+ directDependencyIds: string[];
104
+ transitiveDependencyIds: string[];
105
+ directDependentIds: string[];
106
+ transitiveDependentIds: string[];
107
+ directBlockedDependentIds: string[];
108
+ transitiveBlockedDependentIds: string[];
109
+ directRelatedSpecIds: string[];
110
+ transitiveRelatedSpecIds: string[];
111
+ }
112
+
113
+ export interface DashboardAnalysisState {
114
+ selectedSpecIds: string[];
115
+ timeWindow?: DashboardTimeWindow;
116
+ timelineMode: DashboardTimelineMode;
117
+ selectedTimelineTaskId?: string;
118
+ availableSpecs: Array<{
119
+ id: string;
120
+ title: string;
121
+ status: string;
122
+ }>;
123
+ hasActiveFilters: boolean;
124
+ includesArchived: boolean;
125
+ filteredTaskCount: number;
126
+ }
127
+
128
+ export interface DashboardData {
129
+ project: {
130
+ name: string;
131
+ memoryDir: string;
132
+ };
133
+ currentState: {
134
+ summary: string;
135
+ focus: string;
136
+ activeSpecIds: string[];
137
+ activeTaskIds: string[];
138
+ nextActionHints: string[];
139
+ updatedAt?: string;
140
+ };
141
+ counts: {
142
+ specs: number;
143
+ tasks: number;
144
+ activeSpecs: number;
145
+ inProgressTasks: number;
146
+ reviewTasks: number;
147
+ blockedTasks: number;
148
+ readyTasks: number;
149
+ };
150
+ nextActions: string[];
151
+ git: {
152
+ available: boolean;
153
+ branch?: string;
154
+ dirty: boolean;
155
+ };
156
+ agents: {
157
+ ok: boolean;
158
+ maxFileTokens: number;
159
+ maxChainTokens: number;
160
+ failures: string[];
161
+ };
162
+ specs: DashboardSpecCard[];
163
+ tasks: DashboardTaskCard[];
164
+ analysisTasks: DashboardTaskCard[];
165
+ inProgressTasks: DashboardTaskCard[];
166
+ readyTasks: DashboardTaskCard[];
167
+ blockedTasks: DashboardTaskCard[];
168
+ reviewTasks: DashboardTaskCard[];
169
+ criticalPath: DashboardTaskCard[];
170
+ kanban: DashboardKanbanColumn[];
171
+ timeline: {
172
+ totalUnits: number;
173
+ items: DashboardTimelineItem[];
174
+ groups: DashboardTimelineGroup[];
175
+ mode: DashboardTimelineMode;
176
+ selection?: DashboardTimelineSelection;
177
+ };
178
+ recentHandoff?: {
179
+ id: string;
180
+ title: string;
181
+ summary: string;
182
+ createdAt: string;
183
+ };
184
+ dependencyIssues: Array<{ taskId: string; missingTaskId: string }>;
185
+ analysis: DashboardAnalysisState;
186
+ }
187
+
188
+ function formatTaskStatusLabel(status: EffectiveTaskStatus): string {
189
+ return status.replaceAll("_", " ");
190
+ }
191
+
192
+ export function deriveEffectiveStatus(rawStatus: string, unmetDependencies: string[]): EffectiveTaskStatus {
193
+ if (rawStatus === "done" || rawStatus === "cancelled" || rawStatus === "in_progress" || rawStatus === "blocked" || rawStatus === "review") {
194
+ return rawStatus;
195
+ }
196
+
197
+ return unmetDependencies.length > 0 ? "blocked" : "todo";
198
+ }
199
+
200
+ function deriveEffort(priority: number, downstreamCount: number): number {
201
+ const base = priority <= 1 ? 5 : priority === 2 ? 3 : 2;
202
+ return Math.min(8, base + Math.min(2, downstreamCount));
203
+ }
204
+
205
+ function toTaskCard(task: Parameters<typeof summarizeTaskNode>[0], index: ReturnType<typeof buildTaskIndex>): DashboardTaskCard {
206
+ const summary = summarizeTaskNode(task, index);
207
+ const effectiveStatus = deriveEffectiveStatus(task.status, summary.unmetDependencies);
208
+ const effort = deriveEffort(task.priority, summary.downstreamCount);
209
+
210
+ return {
211
+ id: task.id,
212
+ title: task.title,
213
+ summary: task.summary,
214
+ rawStatus: task.status,
215
+ effectiveStatus,
216
+ priority: task.priority,
217
+ specIds: task.specIds,
218
+ dependsOn: task.dependsOn,
219
+ unmetDependencies: summary.unmetDependencies,
220
+ downstreamCount: summary.downstreamCount,
221
+ effort,
222
+ completedAt: task.completedAt,
223
+ cancelledAt: task.cancelledAt,
224
+ archivedAt: task.archivedAt,
225
+ archiveReason: task.archiveReason,
226
+ };
227
+ }
228
+
229
+ function buildSpecProgress(taskCards: DashboardTaskCard[]) {
230
+ const counts = {
231
+ todo: 0,
232
+ inProgress: 0,
233
+ blocked: 0,
234
+ review: 0,
235
+ done: 0,
236
+ cancelled: 0,
237
+ };
238
+
239
+ for (const task of taskCards) {
240
+ if (task.effectiveStatus === "in_progress") {
241
+ counts.inProgress += 1;
242
+ continue;
243
+ }
244
+
245
+ if (task.effectiveStatus === "blocked") {
246
+ counts.blocked += 1;
247
+ continue;
248
+ }
249
+
250
+ if (task.effectiveStatus === "review") {
251
+ counts.review += 1;
252
+ continue;
253
+ }
254
+
255
+ counts[task.effectiveStatus] += 1;
256
+ }
257
+
258
+ const total = taskCards.length;
259
+ return {
260
+ todo: counts.todo,
261
+ inProgress: counts.inProgress,
262
+ blocked: counts.blocked,
263
+ review: counts.review,
264
+ done: counts.done,
265
+ cancelled: counts.cancelled,
266
+ completionRatio: total === 0 ? 0 : counts.done / total,
267
+ };
268
+ }
269
+
270
+ function buildKanban(taskCards: DashboardTaskCard[]): DashboardKanbanColumn[] {
271
+ const order: Array<{ id: DashboardKanbanColumnId; title: string }> = [
272
+ { id: "blocked", title: "Blocked" },
273
+ { id: "todo", title: "Todo" },
274
+ { id: "in_progress", title: "In Progress" },
275
+ { id: "review", title: "Review" },
276
+ { id: "done", title: "Done / Cancelled" },
277
+ ];
278
+
279
+ return order.map((column) => ({
280
+ id: column.id,
281
+ title: column.title,
282
+ tasks: taskCards
283
+ .filter((task) => {
284
+ if (column.id === "done") {
285
+ return task.effectiveStatus === "done" || task.effectiveStatus === "cancelled";
286
+ }
287
+
288
+ return task.effectiveStatus === column.id;
289
+ })
290
+ .sort((left, right) => left.priority - right.priority || left.id.localeCompare(right.id)),
291
+ }));
292
+ }
293
+
294
+ function buildTimeline(taskCards: DashboardTaskCard[]): { totalUnits: number; items: DashboardTimelineItem[] } {
295
+ const taskMap = new Map(taskCards.map((task) => [task.id, task]));
296
+ const cache = new Map<string, { start: number; end: number }>();
297
+
298
+ function place(task: DashboardTaskCard, trail: Set<string>): { start: number; end: number } {
299
+ if (cache.has(task.id)) {
300
+ return cache.get(task.id)!;
301
+ }
302
+
303
+ if (trail.has(task.id)) {
304
+ const cyclePlacement = { start: 0, end: task.effort };
305
+ cache.set(task.id, cyclePlacement);
306
+ return cyclePlacement;
307
+ }
308
+
309
+ const nextTrail = new Set(trail);
310
+ nextTrail.add(task.id);
311
+
312
+ let start = 0;
313
+ for (const dependencyId of task.dependsOn) {
314
+ const dependency = taskMap.get(dependencyId);
315
+ if (!dependency) {
316
+ continue;
317
+ }
318
+
319
+ const placement = place(dependency, nextTrail);
320
+ start = Math.max(start, placement.end);
321
+ }
322
+
323
+ const result = {
324
+ start,
325
+ end: start + task.effort,
326
+ };
327
+ cache.set(task.id, result);
328
+ return result;
329
+ }
330
+
331
+ const items = taskCards
332
+ .filter((task) => task.effectiveStatus !== "cancelled")
333
+ .map((task) => {
334
+ const placement = place(task, new Set());
335
+ return {
336
+ taskId: task.id,
337
+ title: task.title,
338
+ effectiveStatus: task.effectiveStatus,
339
+ specIds: task.specIds,
340
+ effort: task.effort,
341
+ start: placement.start,
342
+ end: placement.end,
343
+ priority: task.priority,
344
+ };
345
+ })
346
+ .sort((left, right) => left.start - right.start || left.priority - right.priority || left.taskId.localeCompare(right.taskId));
347
+
348
+ const totalUnits = items.reduce((max, item) => Math.max(max, item.end), 1);
349
+ return { totalUnits, items };
350
+ }
351
+
352
+ function isClosedStatus(status: EffectiveTaskStatus): boolean {
353
+ return status === "done" || status === "cancelled";
354
+ }
355
+
356
+ function isTimelineActiveRow(task: DashboardTaskCard): boolean {
357
+ return !isClosedStatus(task.effectiveStatus);
358
+ }
359
+
360
+ function primarySpecForTimeline(task: DashboardTaskCard, selectedSpecIds: string[]): string {
361
+ const selectedMatch = task.specIds.find((specId) => selectedSpecIds.includes(specId));
362
+ return selectedMatch ?? task.specIds[0] ?? "UNSCOPED";
363
+ }
364
+
365
+ function collectReachableIds(startIds: string[], nextIds: (taskId: string) => string[]): string[] {
366
+ const visited = new Set<string>();
367
+ const queue = [...startIds];
368
+
369
+ while (queue.length > 0) {
370
+ const taskId = queue.shift()!;
371
+ if (visited.has(taskId)) {
372
+ continue;
373
+ }
374
+
375
+ visited.add(taskId);
376
+ for (const nextId of nextIds(taskId)) {
377
+ if (!visited.has(nextId)) {
378
+ queue.push(nextId);
379
+ }
380
+ }
381
+ }
382
+
383
+ return Array.from(visited);
384
+ }
385
+
386
+ function buildTimelineSelection(
387
+ taskCards: DashboardTaskCard[],
388
+ selectedTaskId: string | undefined,
389
+ selectedSpecIds: string[],
390
+ ): DashboardTimelineSelection | undefined {
391
+ if (!selectedTaskId) {
392
+ return undefined;
393
+ }
394
+
395
+ const taskMap = new Map(taskCards.map((task) => [task.id, task]));
396
+ const selectedTask = taskMap.get(selectedTaskId);
397
+ if (!selectedTask) {
398
+ return undefined;
399
+ }
400
+
401
+ const dependentsMap = new Map<string, string[]>();
402
+ for (const task of taskCards) {
403
+ for (const dependencyId of task.dependsOn) {
404
+ const dependents = dependentsMap.get(dependencyId) ?? [];
405
+ dependents.push(task.id);
406
+ dependentsMap.set(dependencyId, dependents);
407
+ }
408
+ }
409
+
410
+ const directDependencyIds = selectedTask.dependsOn.filter((dependencyId) => taskMap.has(dependencyId));
411
+ const transitiveDependencyIds = collectReachableIds(directDependencyIds, (taskId) => {
412
+ const task = taskMap.get(taskId);
413
+ if (!task) {
414
+ return [];
415
+ }
416
+
417
+ return task.dependsOn.filter((dependencyId) => taskMap.has(dependencyId));
418
+ }).filter((taskId) => !directDependencyIds.includes(taskId));
419
+
420
+ const directDependentIds = (dependentsMap.get(selectedTask.id) ?? []).filter((taskId) => taskMap.has(taskId));
421
+ const transitiveDependentIds = collectReachableIds(directDependentIds, (taskId) =>
422
+ (dependentsMap.get(taskId) ?? []).filter((dependentId) => taskMap.has(dependentId)),
423
+ ).filter((taskId) => !directDependentIds.includes(taskId));
424
+ const directBlockedDependentIds = directDependentIds.filter((taskId) => {
425
+ const task = taskMap.get(taskId);
426
+ return Boolean(task && task.effectiveStatus === "blocked");
427
+ });
428
+ const transitiveBlockedDependentIds = transitiveDependentIds.filter((taskId) => {
429
+ const task = taskMap.get(taskId);
430
+ return Boolean(task && task.effectiveStatus === "blocked");
431
+ });
432
+
433
+ const primarySpecId = primarySpecForTimeline(selectedTask, selectedSpecIds);
434
+ const directRelatedSpecIds = Array.from(
435
+ new Set(
436
+ [
437
+ ...selectedTask.specIds,
438
+ ...directDependencyIds.flatMap((taskId) => taskMap.get(taskId)?.specIds ?? []),
439
+ ...directDependentIds.flatMap((taskId) => taskMap.get(taskId)?.specIds ?? []),
440
+ ...directBlockedDependentIds.flatMap((taskId) => taskMap.get(taskId)?.specIds ?? []),
441
+ ].filter((specId) => specId !== primarySpecId),
442
+ ),
443
+ );
444
+ const transitiveRelatedSpecIds = Array.from(
445
+ new Set(
446
+ [...transitiveDependencyIds, ...transitiveDependentIds, ...transitiveBlockedDependentIds]
447
+ .flatMap((taskId) => taskMap.get(taskId)?.specIds ?? [])
448
+ .filter((specId) => specId !== primarySpecId && !directRelatedSpecIds.includes(specId)),
449
+ ),
450
+ );
451
+
452
+ return {
453
+ taskId: selectedTask.id,
454
+ title: selectedTask.title,
455
+ primarySpecId,
456
+ specIds: selectedTask.specIds,
457
+ directDependencyIds,
458
+ transitiveDependencyIds,
459
+ directDependentIds,
460
+ transitiveDependentIds,
461
+ directBlockedDependentIds,
462
+ transitiveBlockedDependentIds,
463
+ directRelatedSpecIds,
464
+ transitiveRelatedSpecIds,
465
+ };
466
+ }
467
+
468
+ function selectionStateForRow(
469
+ row: DashboardTimelineItem,
470
+ selection: DashboardTimelineSelection | undefined,
471
+ ): DashboardTimelineRowSelectionState {
472
+ if (!selection) {
473
+ return "none";
474
+ }
475
+
476
+ if (row.taskId === selection.taskId) {
477
+ return "selected";
478
+ }
479
+
480
+ if (selection.directDependencyIds.includes(row.taskId)) {
481
+ return "dependency";
482
+ }
483
+
484
+ if (selection.transitiveDependencyIds.includes(row.taskId)) {
485
+ return "dependency_transitive";
486
+ }
487
+
488
+ if (selection.directBlockedDependentIds.includes(row.taskId)) {
489
+ return "blocked_dependent";
490
+ }
491
+
492
+ if (selection.transitiveBlockedDependentIds.includes(row.taskId)) {
493
+ return "blocked_dependent_transitive";
494
+ }
495
+
496
+ if (selection.directDependentIds.includes(row.taskId)) {
497
+ return "dependent";
498
+ }
499
+
500
+ if (selection.transitiveDependentIds.includes(row.taskId)) {
501
+ return "dependent_transitive";
502
+ }
503
+
504
+ return "dimmed";
505
+ }
506
+
507
+ function groupSelectionState(
508
+ specId: string,
509
+ selection: DashboardTimelineSelection | undefined,
510
+ ): DashboardTimelineGroupSelectionState {
511
+ if (!selection) {
512
+ return "none";
513
+ }
514
+
515
+ if (specId === selection.primarySpecId) {
516
+ return "selected";
517
+ }
518
+
519
+ if (selection.directRelatedSpecIds.includes(specId)) {
520
+ return "related_direct";
521
+ }
522
+
523
+ if (selection.transitiveRelatedSpecIds.includes(specId)) {
524
+ return "related_transitive";
525
+ }
526
+
527
+ return "none";
528
+ }
529
+
530
+ function buildTimelineGroups(
531
+ taskCards: DashboardTaskCard[],
532
+ timelineItems: DashboardTimelineItem[],
533
+ specs: DashboardData["analysis"]["availableSpecs"],
534
+ selectedSpecIds: string[],
535
+ selection: DashboardTimelineSelection | undefined,
536
+ ): DashboardTimelineGroup[] {
537
+ const itemMap = new Map(timelineItems.map((item) => [item.taskId, item]));
538
+ const groupMap = new Map<string, DashboardTimelineGroup>();
539
+ const order: string[] = [];
540
+
541
+ for (const task of taskCards) {
542
+ const item = itemMap.get(task.id);
543
+ if (!item) {
544
+ continue;
545
+ }
546
+
547
+ const specId = primarySpecForTimeline(task, selectedSpecIds);
548
+ if (!groupMap.has(specId)) {
549
+ const spec = specs.find((entry) => entry.id === specId);
550
+ groupMap.set(specId, {
551
+ specId,
552
+ specTitle: spec?.title ?? specId,
553
+ specStatus: spec?.status ?? "unknown",
554
+ selectionState: groupSelectionState(specId, selection),
555
+ rows: [],
556
+ });
557
+ order.push(specId);
558
+ }
559
+
560
+ groupMap.get(specId)!.rows.push({
561
+ ...item,
562
+ statusLabel: formatTaskStatusLabel(task.effectiveStatus),
563
+ primarySpecId: specId,
564
+ dependencyCount: task.dependsOn.length,
565
+ archivedAt: task.archivedAt,
566
+ selectionState: selectionStateForRow(item, selection),
567
+ isCrossSpecRelated: Boolean(selection && item.specIds.some((rowSpecId) => rowSpecId !== selection.primarySpecId)),
568
+ });
569
+ }
570
+
571
+ if (selectedSpecIds.length > 0) {
572
+ for (const specId of selectedSpecIds) {
573
+ if (groupMap.has(specId)) {
574
+ continue;
575
+ }
576
+
577
+ const spec = specs.find((entry) => entry.id === specId);
578
+ groupMap.set(specId, {
579
+ specId,
580
+ specTitle: spec?.title ?? specId,
581
+ specStatus: spec?.status ?? "unknown",
582
+ selectionState: groupSelectionState(specId, selection),
583
+ rows: [],
584
+ });
585
+ order.push(specId);
586
+ }
587
+ }
588
+
589
+ return order
590
+ .map((specId) => groupMap.get(specId)!)
591
+ .map((group) => ({
592
+ ...group,
593
+ selectionState: groupSelectionState(group.specId, selection),
594
+ rows: group.rows.sort(
595
+ (left, right) => left.start - right.start || left.priority - right.priority || left.taskId.localeCompare(right.taskId),
596
+ ),
597
+ }));
598
+ }
599
+
600
+ function normalizeSelectedSpecIds(specIds: string[] | undefined, availableSpecIds: string[]): string[] {
601
+ if (!specIds) {
602
+ return [];
603
+ }
604
+
605
+ const known = new Set(availableSpecIds);
606
+ return Array.from(new Set(specIds.map((specId) => specId.trim()).filter((specId) => known.has(specId))));
607
+ }
608
+
609
+ function matchesSelectedSpecs(task: DashboardTaskCard, selectedSpecIds: string[]): boolean {
610
+ if (selectedSpecIds.length === 0) {
611
+ return true;
612
+ }
613
+
614
+ return task.specIds.some((specId) => selectedSpecIds.includes(specId));
615
+ }
616
+
617
+ function parseTimeWindowMilliseconds(timeWindow: DashboardTimeWindow): number {
618
+ switch (timeWindow) {
619
+ case "7d":
620
+ return 7 * 24 * 60 * 60 * 1000;
621
+ case "30d":
622
+ return 30 * 24 * 60 * 60 * 1000;
623
+ case "90d":
624
+ return 90 * 24 * 60 * 60 * 1000;
625
+ }
626
+ }
627
+
628
+ function readAnalyticalTimestamp(task: DashboardTaskCard): string | undefined {
629
+ return task.completedAt ?? task.cancelledAt;
630
+ }
631
+
632
+ function matchesTimeWindow(
633
+ task: DashboardTaskCard,
634
+ timeWindow: DashboardTimeWindow | undefined,
635
+ now: number,
636
+ ): boolean {
637
+ if (!timeWindow) {
638
+ return true;
639
+ }
640
+
641
+ const timestamp = readAnalyticalTimestamp(task);
642
+ if (!timestamp) {
643
+ return false;
644
+ }
645
+
646
+ const value = Date.parse(timestamp);
647
+ if (!Number.isFinite(value)) {
648
+ return false;
649
+ }
650
+
651
+ return value >= now - parseTimeWindowMilliseconds(timeWindow);
652
+ }
653
+
654
+ export async function buildDashboardData(rootDir: string, filters: DashboardFilterOptions = {}): Promise<DashboardData> {
655
+ const [context, agentsReport] = await Promise.all([loadProjectContext(rootDir), buildAgentsHealthReport(rootDir)]);
656
+ const snapshot = deriveStatusSnapshot(context);
657
+ const nextActions = deriveNextActions(context, snapshot);
658
+ const taskIndex = buildTaskIndex(context.tasks);
659
+ const taskCards = context.tasks.map((task) => toTaskCard(task, taskIndex));
660
+ const activeFlowTaskCards = taskCards.filter((task) => !task.archivedAt);
661
+ const availableSpecIds = context.specs.map((spec) => spec.id);
662
+ const selectedSpecIds = normalizeSelectedSpecIds(filters.selectedSpecIds, availableSpecIds);
663
+ const timeWindow = filters.timeWindow;
664
+ const timelineMode = filters.timelineMode ?? "active";
665
+ const selectedTimelineTaskId = filters.selectedTimelineTaskId?.trim() || undefined;
666
+ const now = Date.now();
667
+ const hasActiveFilters = selectedSpecIds.length > 0 || Boolean(timeWindow);
668
+ const analyticalSourceCards = hasActiveFilters ? taskCards : activeFlowTaskCards;
669
+ const analyticalTaskCards = analyticalSourceCards.filter((task) => {
670
+ if (!matchesSelectedSpecs(task, selectedSpecIds)) {
671
+ return false;
672
+ }
673
+
674
+ return matchesTimeWindow(task, timeWindow, now);
675
+ });
676
+ const analyticalTimelineCardsBase = hasActiveFilters ? analyticalTaskCards : activeFlowTaskCards;
677
+ const analyticalTimelineCards =
678
+ timelineMode === "all" ? analyticalTimelineCardsBase : analyticalTimelineCardsBase.filter((task) => isTimelineActiveRow(task));
679
+ const timeline = buildTimeline(analyticalTimelineCards);
680
+ const timelineSelection = buildTimelineSelection(analyticalTimelineCards, selectedTimelineTaskId, selectedSpecIds);
681
+ const availableSpecs = context.specs
682
+ .map((spec) => ({
683
+ id: spec.id,
684
+ title: spec.title,
685
+ status: spec.status,
686
+ }))
687
+ .sort((left, right) => left.id.localeCompare(right.id));
688
+ const timelineGroups = buildTimelineGroups(analyticalTimelineCards, timeline.items, availableSpecs, selectedSpecIds, timelineSelection);
689
+
690
+ return {
691
+ project: {
692
+ name: context.config.projectName,
693
+ memoryDir: context.config.memoryDir,
694
+ },
695
+ currentState: {
696
+ summary: context.currentState.summary,
697
+ focus: context.currentState.focus,
698
+ activeSpecIds: snapshot.activeSpecIds,
699
+ activeTaskIds: snapshot.activeTaskIds,
700
+ nextActionHints: context.currentState.nextActionHints,
701
+ updatedAt: context.currentState.updatedAt,
702
+ },
703
+ counts: {
704
+ specs: snapshot.specCount,
705
+ tasks: snapshot.taskCount,
706
+ activeSpecs: snapshot.activeSpecIds.length,
707
+ inProgressTasks: snapshot.inProgressTasks.length,
708
+ reviewTasks: snapshot.reviewTasks.length,
709
+ blockedTasks: snapshot.blockedTasks.length,
710
+ readyTasks: snapshot.readyTasks.length,
711
+ },
712
+ nextActions,
713
+ git: {
714
+ available: context.git.available,
715
+ branch: context.git.branch,
716
+ dirty: context.git.dirty,
717
+ },
718
+ agents: {
719
+ ok: agentsReport.summary.ok,
720
+ maxFileTokens: agentsReport.summary.maxFileTokens,
721
+ maxChainTokens: agentsReport.summary.maxChainTokens,
722
+ failures: agentsReport.failures,
723
+ },
724
+ specs: context.specs
725
+ .map((spec) => {
726
+ const linkedTaskCards = spec.taskIds
727
+ .map((taskId) => taskCards.find((task) => task.id === taskId))
728
+ .filter((task): task is DashboardTaskCard => Boolean(task));
729
+
730
+ return {
731
+ id: spec.id,
732
+ title: spec.title,
733
+ summary: spec.summary,
734
+ status: spec.status,
735
+ taskIds: spec.taskIds,
736
+ linkedTaskCount: linkedTaskCards.length,
737
+ progress: buildSpecProgress(linkedTaskCards),
738
+ };
739
+ })
740
+ .sort((left, right) => left.id.localeCompare(right.id)),
741
+ tasks: taskCards.sort((left, right) => left.priority - right.priority || left.id.localeCompare(right.id)),
742
+ analysisTasks: analyticalTaskCards.sort((left, right) => left.priority - right.priority || left.id.localeCompare(right.id)),
743
+ inProgressTasks: snapshot.inProgressTasks.map((task) => toTaskCard(task, taskIndex)).filter((task) => !task.archivedAt),
744
+ readyTasks: snapshot.readyTasks.map((item) => toTaskCard(item.task, taskIndex)).filter((task) => !task.archivedAt),
745
+ blockedTasks: snapshot.blockedTasks.map((item) => toTaskCard(item.task, taskIndex)).filter((task) => !task.archivedAt),
746
+ reviewTasks: snapshot.reviewTasks.map((item) => toTaskCard(item.task, taskIndex)).filter((task) => !task.archivedAt),
747
+ criticalPath: snapshot.criticalPath.map((task) => toTaskCard(task, taskIndex)).filter((task) => !task.archivedAt),
748
+ kanban: buildKanban(activeFlowTaskCards),
749
+ timeline: {
750
+ totalUnits: timeline.totalUnits,
751
+ items: timeline.items,
752
+ groups: timelineGroups,
753
+ mode: timelineMode,
754
+ selection: timelineSelection,
755
+ },
756
+ recentHandoff: snapshot.recentHandoff
757
+ ? {
758
+ id: snapshot.recentHandoff.id,
759
+ title: snapshot.recentHandoff.title,
760
+ summary: snapshot.recentHandoff.summary,
761
+ createdAt: snapshot.recentHandoff.createdAt,
762
+ }
763
+ : undefined,
764
+ dependencyIssues: snapshot.missingDependencies,
765
+ analysis: {
766
+ selectedSpecIds,
767
+ timeWindow,
768
+ timelineMode,
769
+ selectedTimelineTaskId: timelineSelection?.taskId,
770
+ availableSpecs,
771
+ hasActiveFilters,
772
+ includesArchived: hasActiveFilters,
773
+ filteredTaskCount: analyticalTaskCards.length,
774
+ },
775
+ };
776
+ }