gsd-pi 2.18.0 → 2.19.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 (73) hide show
  1. package/dist/resources/extensions/gsd/auto-dashboard.ts +14 -2
  2. package/dist/resources/extensions/gsd/auto-prompts.ts +45 -15
  3. package/dist/resources/extensions/gsd/auto.ts +276 -19
  4. package/dist/resources/extensions/gsd/captures.ts +384 -0
  5. package/dist/resources/extensions/gsd/commands.ts +139 -3
  6. package/dist/resources/extensions/gsd/complexity-classifier.ts +322 -0
  7. package/dist/resources/extensions/gsd/dashboard-overlay.ts +10 -0
  8. package/dist/resources/extensions/gsd/metrics.ts +48 -0
  9. package/dist/resources/extensions/gsd/model-cost-table.ts +65 -0
  10. package/dist/resources/extensions/gsd/model-router.ts +256 -0
  11. package/dist/resources/extensions/gsd/post-unit-hooks.ts +2 -1
  12. package/dist/resources/extensions/gsd/preferences.ts +73 -0
  13. package/dist/resources/extensions/gsd/prompt-loader.ts +45 -9
  14. package/dist/resources/extensions/gsd/prompts/reassess-roadmap.md +6 -0
  15. package/dist/resources/extensions/gsd/prompts/replan-slice.md +8 -0
  16. package/dist/resources/extensions/gsd/prompts/triage-captures.md +62 -0
  17. package/dist/resources/extensions/gsd/tests/captures.test.ts +438 -0
  18. package/dist/resources/extensions/gsd/tests/complexity-classifier.test.ts +181 -0
  19. package/dist/resources/extensions/gsd/tests/feature-branch-lifecycle-integration.test.ts +434 -0
  20. package/dist/resources/extensions/gsd/tests/milestone-transition-worktree.test.ts +144 -0
  21. package/dist/resources/extensions/gsd/tests/model-cost-table.test.ts +69 -0
  22. package/dist/resources/extensions/gsd/tests/model-router.test.ts +167 -0
  23. package/dist/resources/extensions/gsd/tests/remote-questions.test.ts +227 -1
  24. package/dist/resources/extensions/gsd/tests/routing-history.test.ts +215 -62
  25. package/dist/resources/extensions/gsd/tests/triage-dispatch.test.ts +224 -0
  26. package/dist/resources/extensions/gsd/tests/triage-resolution.test.ts +215 -0
  27. package/dist/resources/extensions/gsd/tests/visualizer-data.test.ts +198 -0
  28. package/dist/resources/extensions/gsd/tests/visualizer-views.test.ts +255 -0
  29. package/dist/resources/extensions/gsd/triage-resolution.ts +200 -0
  30. package/dist/resources/extensions/gsd/triage-ui.ts +175 -0
  31. package/dist/resources/extensions/gsd/visualizer-data.ts +154 -0
  32. package/dist/resources/extensions/gsd/visualizer-overlay.ts +193 -0
  33. package/dist/resources/extensions/gsd/visualizer-views.ts +293 -0
  34. package/dist/resources/extensions/remote-questions/discord-adapter.ts +33 -0
  35. package/dist/resources/extensions/remote-questions/format.ts +12 -6
  36. package/dist/resources/extensions/remote-questions/manager.ts +8 -0
  37. package/package.json +1 -1
  38. package/src/resources/extensions/gsd/auto-dashboard.ts +14 -2
  39. package/src/resources/extensions/gsd/auto-prompts.ts +45 -15
  40. package/src/resources/extensions/gsd/auto.ts +276 -19
  41. package/src/resources/extensions/gsd/captures.ts +384 -0
  42. package/src/resources/extensions/gsd/commands.ts +139 -3
  43. package/src/resources/extensions/gsd/complexity-classifier.ts +322 -0
  44. package/src/resources/extensions/gsd/dashboard-overlay.ts +10 -0
  45. package/src/resources/extensions/gsd/metrics.ts +48 -0
  46. package/src/resources/extensions/gsd/model-cost-table.ts +65 -0
  47. package/src/resources/extensions/gsd/model-router.ts +256 -0
  48. package/src/resources/extensions/gsd/post-unit-hooks.ts +2 -1
  49. package/src/resources/extensions/gsd/preferences.ts +73 -0
  50. package/src/resources/extensions/gsd/prompt-loader.ts +45 -9
  51. package/src/resources/extensions/gsd/prompts/reassess-roadmap.md +6 -0
  52. package/src/resources/extensions/gsd/prompts/replan-slice.md +8 -0
  53. package/src/resources/extensions/gsd/prompts/triage-captures.md +62 -0
  54. package/src/resources/extensions/gsd/tests/captures.test.ts +438 -0
  55. package/src/resources/extensions/gsd/tests/complexity-classifier.test.ts +181 -0
  56. package/src/resources/extensions/gsd/tests/feature-branch-lifecycle-integration.test.ts +434 -0
  57. package/src/resources/extensions/gsd/tests/milestone-transition-worktree.test.ts +144 -0
  58. package/src/resources/extensions/gsd/tests/model-cost-table.test.ts +69 -0
  59. package/src/resources/extensions/gsd/tests/model-router.test.ts +167 -0
  60. package/src/resources/extensions/gsd/tests/remote-questions.test.ts +227 -1
  61. package/src/resources/extensions/gsd/tests/routing-history.test.ts +215 -62
  62. package/src/resources/extensions/gsd/tests/triage-dispatch.test.ts +224 -0
  63. package/src/resources/extensions/gsd/tests/triage-resolution.test.ts +215 -0
  64. package/src/resources/extensions/gsd/tests/visualizer-data.test.ts +198 -0
  65. package/src/resources/extensions/gsd/tests/visualizer-views.test.ts +255 -0
  66. package/src/resources/extensions/gsd/triage-resolution.ts +200 -0
  67. package/src/resources/extensions/gsd/triage-ui.ts +175 -0
  68. package/src/resources/extensions/gsd/visualizer-data.ts +154 -0
  69. package/src/resources/extensions/gsd/visualizer-overlay.ts +193 -0
  70. package/src/resources/extensions/gsd/visualizer-views.ts +293 -0
  71. package/src/resources/extensions/remote-questions/discord-adapter.ts +33 -0
  72. package/src/resources/extensions/remote-questions/format.ts +12 -6
  73. package/src/resources/extensions/remote-questions/manager.ts +8 -0
@@ -0,0 +1,200 @@
1
+ /**
2
+ * GSD Triage Resolution — Execute triage classifications
3
+ *
4
+ * Provides resolution executors for each capture classification type:
5
+ *
6
+ * - inject: appends a new task to the current slice plan
7
+ * - replan: writes REPLAN-TRIGGER.md so next dispatchNextUnit enters replanning-slice
8
+ * - defer/note: query helpers for loading deferred/replan captures
9
+ *
10
+ * Also provides detectFileOverlap() for surfacing downstream impact on quick tasks.
11
+ */
12
+
13
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
14
+ import { join } from "node:path";
15
+ import type { Classification, CaptureEntry } from "./captures.js";
16
+ import {
17
+ loadPendingCaptures,
18
+ loadAllCaptures,
19
+ markCaptureResolved,
20
+ } from "./captures.js";
21
+
22
+ // ─── Resolution Executors ─────────────────────────────────────────────────────
23
+
24
+ /**
25
+ * Inject a new task into the current slice plan.
26
+ * Reads the plan, finds the highest task ID, appends a new task entry.
27
+ * Returns the new task ID, or null if injection failed.
28
+ */
29
+ export function executeInject(
30
+ basePath: string,
31
+ mid: string,
32
+ sid: string,
33
+ capture: CaptureEntry,
34
+ ): string | null {
35
+ try {
36
+ // Resolve the plan file path
37
+ const planPath = join(basePath, ".gsd", "milestones", mid, "slices", sid, `${sid}-PLAN.md`);
38
+ if (!existsSync(planPath)) return null;
39
+
40
+ const content = readFileSync(planPath, "utf-8");
41
+
42
+ // Find the highest existing task ID
43
+ const taskMatches = [...content.matchAll(/- \[[ x]\] \*\*T(\d+):/g)];
44
+ if (taskMatches.length === 0) return null;
45
+
46
+ const maxId = Math.max(...taskMatches.map(m => parseInt(m[1], 10)));
47
+ const newId = `T${String(maxId + 1).padStart(2, "0")}`;
48
+
49
+ // Build the new task entry
50
+ const newTask = [
51
+ `- [ ] **${newId}: ${capture.text}** \`est:30m\``,
52
+ ` - Why: Injected from capture ${capture.id} during triage`,
53
+ ` - Do: ${capture.text}`,
54
+ ` - Done when: Capture intent fulfilled`,
55
+ ].join("\n");
56
+
57
+ // Find the last task entry and append after it
58
+ // Look for the "## Files Likely Touched" section as the boundary
59
+ const filesSection = content.indexOf("## Files Likely Touched");
60
+ if (filesSection !== -1) {
61
+ const updated = content.slice(0, filesSection) + newTask + "\n\n" + content.slice(filesSection);
62
+ writeFileSync(planPath, updated, "utf-8");
63
+ } else {
64
+ // No Files section — append at end
65
+ writeFileSync(planPath, content.trimEnd() + "\n\n" + newTask + "\n", "utf-8");
66
+ }
67
+
68
+ return newId;
69
+ } catch {
70
+ return null;
71
+ }
72
+ }
73
+
74
+ /**
75
+ * Trigger replanning by writing a REPLAN-TRIGGER.md marker file.
76
+ * The existing state.ts derivation detects this and sets phase to "replanning-slice".
77
+ * Returns true if the trigger was written successfully.
78
+ */
79
+ export function executeReplan(
80
+ basePath: string,
81
+ mid: string,
82
+ sid: string,
83
+ capture: CaptureEntry,
84
+ ): boolean {
85
+ try {
86
+ const triggerPath = join(
87
+ basePath, ".gsd", "milestones", mid, "slices", sid, `${sid}-REPLAN-TRIGGER.md`,
88
+ );
89
+ const content = [
90
+ `# Replan Trigger`,
91
+ ``,
92
+ `**Source:** Capture ${capture.id}`,
93
+ `**Capture:** ${capture.text}`,
94
+ `**Rationale:** ${capture.rationale ?? "User-initiated replan via capture triage"}`,
95
+ `**Triggered:** ${new Date().toISOString()}`,
96
+ ``,
97
+ `This file was created by the triage pipeline. The next dispatch cycle`,
98
+ `will detect it and enter the replanning-slice phase.`,
99
+ ].join("\n");
100
+
101
+ writeFileSync(triggerPath, content, "utf-8");
102
+ return true;
103
+ } catch {
104
+ return false;
105
+ }
106
+ }
107
+
108
+ // ─── File Overlap Detection ───────────────────────────────────────────────────
109
+
110
+ /**
111
+ * Detect file overlap between a capture's affected files and planned tasks.
112
+ *
113
+ * Parses the slice plan for task file references and returns task IDs
114
+ * whose files overlap with the capture's affected files.
115
+ *
116
+ * @param affectedFiles - Files the capture would touch
117
+ * @param planContent - Content of the slice plan.md
118
+ * @returns Array of task IDs (e.g., ["T03", "T04"]) whose files overlap
119
+ */
120
+ export function detectFileOverlap(
121
+ affectedFiles: string[],
122
+ planContent: string,
123
+ ): string[] {
124
+ if (!affectedFiles || affectedFiles.length === 0) return [];
125
+
126
+ const overlappingTasks: string[] = [];
127
+
128
+ // Normalize affected files for comparison
129
+ const normalizedAffected = new Set(
130
+ affectedFiles.map(f => f.replace(/^\.\//, "").toLowerCase()),
131
+ );
132
+
133
+ // Parse plan for incomplete tasks and their file references
134
+ const taskPattern = /- \[ \] \*\*(T\d+):[^*]*\*\*/g;
135
+ const tasks = [...planContent.matchAll(taskPattern)];
136
+
137
+ for (const taskMatch of tasks) {
138
+ const taskId = taskMatch[1];
139
+ const taskStart = taskMatch.index!;
140
+
141
+ // Find the end of this task (next task or end of section)
142
+ const nextTask = planContent.indexOf("- [", taskStart + 1);
143
+ const sectionEnd = planContent.indexOf("##", taskStart + 1);
144
+ const taskEnd = Math.min(
145
+ nextTask === -1 ? planContent.length : nextTask,
146
+ sectionEnd === -1 ? planContent.length : sectionEnd,
147
+ );
148
+
149
+ const taskContent = planContent.slice(taskStart, taskEnd);
150
+
151
+ // Extract file references — look for backtick-quoted paths
152
+ const fileRefs = [...taskContent.matchAll(/`([^`]+\.[a-z]+)`/g)]
153
+ .map(m => m[1].replace(/^\.\//, "").toLowerCase());
154
+
155
+ // Check for overlap
156
+ const hasOverlap = fileRefs.some(f => normalizedAffected.has(f));
157
+ if (hasOverlap) {
158
+ overlappingTasks.push(taskId);
159
+ }
160
+ }
161
+
162
+ return overlappingTasks;
163
+ }
164
+
165
+ /**
166
+ * Load deferred captures (classification === "defer") for injection into
167
+ * reassess-roadmap prompts.
168
+ */
169
+ export function loadDeferredCaptures(basePath: string): CaptureEntry[] {
170
+ return loadAllCaptures(basePath).filter(c => c.classification === "defer");
171
+ }
172
+
173
+ /**
174
+ * Load replan-triggering captures for injection into replan-slice prompts.
175
+ */
176
+ export function loadReplanCaptures(basePath: string): CaptureEntry[] {
177
+ return loadAllCaptures(basePath).filter(c => c.classification === "replan");
178
+ }
179
+
180
+ /**
181
+ * Build a quick-task execution prompt from a capture.
182
+ */
183
+ export function buildQuickTaskPrompt(capture: CaptureEntry): string {
184
+ return [
185
+ `You are executing a quick one-off task captured during a GSD auto-mode session.`,
186
+ ``,
187
+ `## Quick Task`,
188
+ ``,
189
+ `**Capture ID:** ${capture.id}`,
190
+ `**Task:** ${capture.text}`,
191
+ ``,
192
+ `## Instructions`,
193
+ ``,
194
+ `1. Execute this task as a small, self-contained change.`,
195
+ `2. Do NOT modify any \`.gsd/\` plan files — this is a one-off, not a planned task.`,
196
+ `3. Commit your changes with a descriptive message.`,
197
+ `4. Keep changes minimal and focused on the capture text.`,
198
+ `5. When done, say: "Quick task complete."`,
199
+ ].join("\n");
200
+ }
@@ -0,0 +1,175 @@
1
+ /**
2
+ * GSD Triage UI — Confirmation flow for programmatic triage results
3
+ *
4
+ * Used by auto-mode dispatch (S02) when triage fires between tasks.
5
+ * For manual `/gsd triage`, the LLM session handles confirmation directly.
6
+ *
7
+ * This module provides `showTriageConfirmation` which presents each
8
+ * triage result to the user via `showNextAction` and returns the
9
+ * confirmed classifications.
10
+ */
11
+
12
+ import type { ExtensionCommandContext } from "@gsd/pi-coding-agent";
13
+ import { showNextAction } from "../shared/next-action-ui.js";
14
+ import type { CaptureEntry, Classification, TriageResult } from "./captures.js";
15
+ import { markCaptureResolved } from "./captures.js";
16
+
17
+ // ─── Types ────────────────────────────────────────────────────────────────────
18
+
19
+ export interface ConfirmedTriage {
20
+ captureId: string;
21
+ classification: Classification;
22
+ rationale: string;
23
+ affectedFiles?: string[];
24
+ targetSlice?: string;
25
+ userOverride: boolean; // true if user changed the proposed classification
26
+ }
27
+
28
+ // ─── Classification Labels ────────────────────────────────────────────────────
29
+
30
+ const CLASSIFICATION_LABELS: Record<Classification, { label: string; description: string }> = {
31
+ "quick-task": {
32
+ label: "Quick task",
33
+ description: "Execute as a one-off at the next seam — no plan modification.",
34
+ },
35
+ "inject": {
36
+ label: "Inject into plan",
37
+ description: "Add a new task to the current slice plan.",
38
+ },
39
+ "defer": {
40
+ label: "Defer",
41
+ description: "Move to a future slice or milestone — not urgent now.",
42
+ },
43
+ "replan": {
44
+ label: "Replan slice",
45
+ description: "Remaining tasks need rewriting — triggers slice replan.",
46
+ },
47
+ "note": {
48
+ label: "Note",
49
+ description: "Informational only — no action needed.",
50
+ },
51
+ };
52
+
53
+ const ALL_CLASSIFICATIONS: Classification[] = [
54
+ "quick-task", "inject", "defer", "replan", "note",
55
+ ];
56
+
57
+ // ─── Public API ───────────────────────────────────────────────────────────────
58
+
59
+ /**
60
+ * Present triage results to the user for confirmation.
61
+ *
62
+ * For each capture:
63
+ * - note/defer: auto-confirm (no user interaction needed)
64
+ * - quick-task/inject/replan: show confirmation UI with proposed + alternatives
65
+ *
66
+ * Returns confirmed results with final classifications.
67
+ * Updates CAPTURES.md with resolved status.
68
+ *
69
+ * @param fileOverlaps - Map of captureId → list of planned task IDs whose files overlap
70
+ */
71
+ export async function showTriageConfirmation(
72
+ ctx: ExtensionCommandContext,
73
+ triageResults: TriageResult[],
74
+ captures: CaptureEntry[],
75
+ basePath: string,
76
+ fileOverlaps?: Map<string, string[]>,
77
+ ): Promise<ConfirmedTriage[]> {
78
+ const confirmed: ConfirmedTriage[] = [];
79
+ const captureMap = new Map(captures.map(c => [c.id, c]));
80
+
81
+ for (const result of triageResults) {
82
+ const capture = captureMap.get(result.captureId);
83
+ if (!capture) continue;
84
+
85
+ // Auto-confirm note and defer — low-impact, no plan modification
86
+ if (result.classification === "note" || result.classification === "defer") {
87
+ const resolution = result.classification === "note"
88
+ ? "acknowledged as note"
89
+ : `deferred${result.targetSlice ? ` to ${result.targetSlice}` : ""}`;
90
+
91
+ markCaptureResolved(
92
+ basePath,
93
+ result.captureId,
94
+ result.classification,
95
+ resolution,
96
+ result.rationale,
97
+ );
98
+
99
+ confirmed.push({
100
+ captureId: result.captureId,
101
+ classification: result.classification,
102
+ rationale: result.rationale,
103
+ affectedFiles: result.affectedFiles,
104
+ targetSlice: result.targetSlice,
105
+ userOverride: false,
106
+ });
107
+ continue;
108
+ }
109
+
110
+ // Build summary lines for the confirmation UI
111
+ const summary: string[] = [
112
+ `"${capture.text}"`,
113
+ "",
114
+ `Proposed: **${CLASSIFICATION_LABELS[result.classification].label}** — ${result.rationale}`,
115
+ ];
116
+
117
+ // Add file overlap warning if present
118
+ const overlaps = fileOverlaps?.get(result.captureId);
119
+ if (overlaps && overlaps.length > 0) {
120
+ summary.push("");
121
+ summary.push(`⚠ Touches files planned for ${overlaps.join(", ")} — consider inject or defer`);
122
+ }
123
+
124
+ if (result.affectedFiles && result.affectedFiles.length > 0) {
125
+ summary.push("");
126
+ summary.push(`Files: ${result.affectedFiles.join(", ")}`);
127
+ }
128
+
129
+ // Build action options — proposed first (recommended), then alternatives
130
+ const proposed = result.classification;
131
+ const actions = ALL_CLASSIFICATIONS.map(cls => ({
132
+ id: cls,
133
+ label: CLASSIFICATION_LABELS[cls].label,
134
+ description: CLASSIFICATION_LABELS[cls].description,
135
+ recommended: cls === proposed,
136
+ }));
137
+
138
+ const choice = await showNextAction(ctx as any, {
139
+ title: `Triage: ${result.captureId}`,
140
+ summary,
141
+ actions,
142
+ notYetMessage: "Capture will remain pending for later triage.",
143
+ });
144
+
145
+ if (choice === "not_yet") {
146
+ // User skipped — leave capture pending
147
+ continue;
148
+ }
149
+
150
+ const finalClassification = choice as Classification;
151
+ const userOverride = finalClassification !== proposed;
152
+ const resolution = userOverride
153
+ ? `user chose ${finalClassification} (was ${proposed})`
154
+ : `confirmed as ${finalClassification}`;
155
+
156
+ markCaptureResolved(
157
+ basePath,
158
+ result.captureId,
159
+ finalClassification,
160
+ resolution,
161
+ userOverride ? `User override: ${result.rationale}` : result.rationale,
162
+ );
163
+
164
+ confirmed.push({
165
+ captureId: result.captureId,
166
+ classification: finalClassification,
167
+ rationale: result.rationale,
168
+ affectedFiles: result.affectedFiles,
169
+ targetSlice: result.targetSlice,
170
+ userOverride,
171
+ });
172
+ }
173
+
174
+ return confirmed;
175
+ }
@@ -0,0 +1,154 @@
1
+ // Data loader for workflow visualizer overlay — aggregates state + metrics.
2
+
3
+ import { deriveState } from './state.js';
4
+ import { parseRoadmap, parsePlan, loadFile } from './files.js';
5
+ import { findMilestoneIds } from './guided-flow.js';
6
+ import { resolveMilestoneFile, resolveSliceFile } from './paths.js';
7
+ import {
8
+ getLedger,
9
+ getProjectTotals,
10
+ aggregateByPhase,
11
+ aggregateBySlice,
12
+ aggregateByModel,
13
+ loadLedgerFromDisk,
14
+ } from './metrics.js';
15
+
16
+ import type { Phase } from './types.js';
17
+ import type {
18
+ ProjectTotals,
19
+ PhaseAggregate,
20
+ SliceAggregate,
21
+ ModelAggregate,
22
+ UnitMetrics,
23
+ } from './metrics.js';
24
+
25
+ // ─── Visualizer Types ─────────────────────────────────────────────────────────
26
+
27
+ export interface VisualizerMilestone {
28
+ id: string;
29
+ title: string;
30
+ status: 'complete' | 'active' | 'pending';
31
+ dependsOn: string[];
32
+ slices: VisualizerSlice[];
33
+ }
34
+
35
+ export interface VisualizerSlice {
36
+ id: string;
37
+ title: string;
38
+ done: boolean;
39
+ active: boolean;
40
+ risk: string;
41
+ depends: string[];
42
+ tasks: VisualizerTask[];
43
+ }
44
+
45
+ export interface VisualizerTask {
46
+ id: string;
47
+ title: string;
48
+ done: boolean;
49
+ active: boolean;
50
+ }
51
+
52
+ export interface VisualizerData {
53
+ milestones: VisualizerMilestone[];
54
+ phase: Phase;
55
+ totals: ProjectTotals | null;
56
+ byPhase: PhaseAggregate[];
57
+ bySlice: SliceAggregate[];
58
+ byModel: ModelAggregate[];
59
+ units: UnitMetrics[];
60
+ }
61
+
62
+ // ─── Loader ───────────────────────────────────────────────────────────────────
63
+
64
+ export async function loadVisualizerData(basePath: string): Promise<VisualizerData> {
65
+ const state = await deriveState(basePath);
66
+ const milestoneIds = findMilestoneIds(basePath);
67
+
68
+ const milestones: VisualizerMilestone[] = [];
69
+
70
+ for (const mid of milestoneIds) {
71
+ const entry = state.registry.find(r => r.id === mid);
72
+ const status = entry?.status ?? 'pending';
73
+ const dependsOn = entry?.dependsOn ?? [];
74
+
75
+ const slices: VisualizerSlice[] = [];
76
+
77
+ const roadmapFile = resolveMilestoneFile(basePath, mid, 'ROADMAP');
78
+ const roadmapContent = roadmapFile ? await loadFile(roadmapFile) : null;
79
+
80
+ if (roadmapContent) {
81
+ const roadmap = parseRoadmap(roadmapContent);
82
+
83
+ for (const s of roadmap.slices) {
84
+ const isActiveSlice =
85
+ state.activeMilestone?.id === mid &&
86
+ state.activeSlice?.id === s.id;
87
+
88
+ const tasks: VisualizerTask[] = [];
89
+
90
+ if (isActiveSlice) {
91
+ const planFile = resolveSliceFile(basePath, mid, s.id, 'PLAN');
92
+ const planContent = planFile ? await loadFile(planFile) : null;
93
+
94
+ if (planContent) {
95
+ const plan = parsePlan(planContent);
96
+ for (const t of plan.tasks) {
97
+ tasks.push({
98
+ id: t.id,
99
+ title: t.title,
100
+ done: t.done,
101
+ active: state.activeTask?.id === t.id,
102
+ });
103
+ }
104
+ }
105
+ }
106
+
107
+ slices.push({
108
+ id: s.id,
109
+ title: s.title,
110
+ done: s.done,
111
+ active: isActiveSlice,
112
+ risk: s.risk,
113
+ depends: s.depends,
114
+ tasks,
115
+ });
116
+ }
117
+ }
118
+
119
+ milestones.push({
120
+ id: mid,
121
+ title: entry?.title ?? mid,
122
+ status,
123
+ dependsOn,
124
+ slices,
125
+ });
126
+ }
127
+
128
+ // Metrics
129
+ let totals: ProjectTotals | null = null;
130
+ let byPhase: PhaseAggregate[] = [];
131
+ let bySlice: SliceAggregate[] = [];
132
+ let byModel: ModelAggregate[] = [];
133
+ let units: UnitMetrics[] = [];
134
+
135
+ const ledger = getLedger() ?? loadLedgerFromDisk(basePath);
136
+
137
+ if (ledger && ledger.units.length > 0) {
138
+ units = [...ledger.units].sort((a, b) => a.startedAt - b.startedAt);
139
+ totals = getProjectTotals(units);
140
+ byPhase = aggregateByPhase(units);
141
+ bySlice = aggregateBySlice(units);
142
+ byModel = aggregateByModel(units);
143
+ }
144
+
145
+ return {
146
+ milestones,
147
+ phase: state.phase,
148
+ totals,
149
+ byPhase,
150
+ bySlice,
151
+ byModel,
152
+ units,
153
+ };
154
+ }