mrvn-cli 0.4.5 → 0.4.7

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.
@@ -176,9 +176,9 @@ var DocumentStore = class {
176
176
  }
177
177
  }
178
178
  }
179
- list(query2) {
179
+ list(query3) {
180
180
  const results = [];
181
- const types = query2?.type ? [query2.type] : Object.keys(this.typeDirs);
181
+ const types = query3?.type ? [query3.type] : Object.keys(this.typeDirs);
182
182
  for (const type of types) {
183
183
  const dirName = this.typeDirs[type];
184
184
  if (!dirName) continue;
@@ -189,9 +189,9 @@ var DocumentStore = class {
189
189
  const filePath = path3.join(dir, file2);
190
190
  const raw = fs3.readFileSync(filePath, "utf-8");
191
191
  const doc = parseDocument(raw, filePath);
192
- if (query2?.status && doc.frontmatter.status !== query2.status) continue;
193
- if (query2?.owner && doc.frontmatter.owner !== query2.owner) continue;
194
- if (query2?.tag && (!doc.frontmatter.tags || !doc.frontmatter.tags.includes(query2.tag)))
192
+ if (query3?.status && doc.frontmatter.status !== query3.status) continue;
193
+ if (query3?.owner && doc.frontmatter.owner !== query3.owner) continue;
194
+ if (query3?.tag && (!doc.frontmatter.tags || !doc.frontmatter.tags.includes(query3.tag)))
195
195
  continue;
196
196
  results.push(doc);
197
197
  }
@@ -15482,6 +15482,346 @@ function evaluateHealth(projectName, metrics) {
15482
15482
  };
15483
15483
  }
15484
15484
 
15485
+ // src/plugins/builtin/tools/task-utils.ts
15486
+ function normalizeLinkedEpics(value) {
15487
+ if (value === void 0 || value === null) return [];
15488
+ if (typeof value === "string") {
15489
+ try {
15490
+ const parsed = JSON.parse(value);
15491
+ if (Array.isArray(parsed)) return parsed.filter((v) => typeof v === "string");
15492
+ } catch {
15493
+ }
15494
+ return [value];
15495
+ }
15496
+ if (Array.isArray(value)) return value.filter((v) => typeof v === "string");
15497
+ return [];
15498
+ }
15499
+ function generateEpicTags(epics) {
15500
+ return epics.map((id) => `epic:${id}`);
15501
+ }
15502
+
15503
+ // src/reports/sprint-summary/collector.ts
15504
+ var DONE_STATUSES = /* @__PURE__ */ new Set(["done", "closed", "resolved", "cancelled"]);
15505
+ function collectSprintSummaryData(store, sprintId) {
15506
+ const allDocs = store.list();
15507
+ const sprintDocs = allDocs.filter((d) => d.frontmatter.type === "sprint");
15508
+ let sprintDoc;
15509
+ if (sprintId) {
15510
+ sprintDoc = sprintDocs.find((d) => d.frontmatter.id === sprintId);
15511
+ } else {
15512
+ sprintDoc = sprintDocs.find((d) => d.frontmatter.status === "active");
15513
+ }
15514
+ if (!sprintDoc) return null;
15515
+ const fm = sprintDoc.frontmatter;
15516
+ const startDate = fm.startDate;
15517
+ const endDate = fm.endDate;
15518
+ const today = /* @__PURE__ */ new Date();
15519
+ const todayStr = today.toISOString().slice(0, 10);
15520
+ let daysElapsed = 0;
15521
+ let daysRemaining = 0;
15522
+ let totalDays = 0;
15523
+ let percentComplete = 0;
15524
+ if (startDate && endDate) {
15525
+ const startMs = new Date(startDate).getTime();
15526
+ const endMs = new Date(endDate).getTime();
15527
+ const todayMs = today.getTime();
15528
+ const msPerDay = 864e5;
15529
+ totalDays = Math.max(1, Math.round((endMs - startMs) / msPerDay));
15530
+ daysElapsed = Math.max(0, Math.round((todayMs - startMs) / msPerDay));
15531
+ daysRemaining = Math.max(0, Math.round((endMs - todayMs) / msPerDay));
15532
+ percentComplete = Math.min(100, Math.round(daysElapsed / totalDays * 100));
15533
+ }
15534
+ const linkedEpicIds = normalizeLinkedEpics(fm.linkedEpics);
15535
+ const epicToTasks = /* @__PURE__ */ new Map();
15536
+ const allTasks = allDocs.filter((d) => d.frontmatter.type === "task");
15537
+ for (const task of allTasks) {
15538
+ const tags = task.frontmatter.tags ?? [];
15539
+ for (const tag of tags) {
15540
+ if (tag.startsWith("epic:")) {
15541
+ const epicId = tag.slice(5);
15542
+ if (!epicToTasks.has(epicId)) epicToTasks.set(epicId, []);
15543
+ epicToTasks.get(epicId).push(task);
15544
+ }
15545
+ }
15546
+ }
15547
+ const linkedEpics = linkedEpicIds.map((epicId) => {
15548
+ const epic = store.get(epicId);
15549
+ const tasks = epicToTasks.get(epicId) ?? [];
15550
+ const tasksDone = tasks.filter((t) => DONE_STATUSES.has(t.frontmatter.status)).length;
15551
+ return {
15552
+ id: epicId,
15553
+ title: epic?.frontmatter.title ?? "(not found)",
15554
+ status: epic?.frontmatter.status ?? "unknown",
15555
+ tasksDone,
15556
+ tasksTotal: tasks.length
15557
+ };
15558
+ });
15559
+ const sprintTag = `sprint:${fm.id}`;
15560
+ const workItemDocs = allDocs.filter(
15561
+ (d) => d.frontmatter.type !== "sprint" && d.frontmatter.type !== "epic" && d.frontmatter.tags?.includes(sprintTag)
15562
+ );
15563
+ const byStatus = {};
15564
+ const byType = {};
15565
+ let doneCount = 0;
15566
+ let inProgressCount = 0;
15567
+ let openCount = 0;
15568
+ let blockedCount = 0;
15569
+ for (const doc of workItemDocs) {
15570
+ const s = doc.frontmatter.status;
15571
+ byStatus[s] = (byStatus[s] ?? 0) + 1;
15572
+ byType[doc.frontmatter.type] = (byType[doc.frontmatter.type] ?? 0) + 1;
15573
+ if (DONE_STATUSES.has(s)) doneCount++;
15574
+ else if (s === "in-progress") inProgressCount++;
15575
+ else if (s === "blocked") blockedCount++;
15576
+ else openCount++;
15577
+ }
15578
+ const workItems = {
15579
+ total: workItemDocs.length,
15580
+ done: doneCount,
15581
+ inProgress: inProgressCount,
15582
+ open: openCount,
15583
+ blocked: blockedCount,
15584
+ completionPct: workItemDocs.length > 0 ? Math.round(doneCount / workItemDocs.length * 100) : 0,
15585
+ byStatus,
15586
+ byType,
15587
+ items: workItemDocs.map((d) => ({
15588
+ id: d.frontmatter.id,
15589
+ title: d.frontmatter.title,
15590
+ type: d.frontmatter.type,
15591
+ status: d.frontmatter.status
15592
+ }))
15593
+ };
15594
+ const meetings = [];
15595
+ if (startDate && endDate) {
15596
+ const meetingDocs = allDocs.filter((d) => d.frontmatter.type === "meeting");
15597
+ for (const m of meetingDocs) {
15598
+ const meetingDate = m.frontmatter.date ?? m.frontmatter.created.slice(0, 10);
15599
+ if (meetingDate >= startDate && meetingDate <= endDate) {
15600
+ meetings.push({
15601
+ id: m.frontmatter.id,
15602
+ title: m.frontmatter.title,
15603
+ date: meetingDate
15604
+ });
15605
+ }
15606
+ }
15607
+ meetings.sort((a, b) => a.date.localeCompare(b.date));
15608
+ }
15609
+ const artifacts = [];
15610
+ if (startDate && endDate) {
15611
+ for (const doc of allDocs) {
15612
+ if (doc.frontmatter.type === "sprint") continue;
15613
+ const created = doc.frontmatter.created.slice(0, 10);
15614
+ const updated = doc.frontmatter.updated.slice(0, 10);
15615
+ if (created >= startDate && created <= endDate) {
15616
+ artifacts.push({
15617
+ id: doc.frontmatter.id,
15618
+ title: doc.frontmatter.title,
15619
+ type: doc.frontmatter.type,
15620
+ action: "created",
15621
+ date: created
15622
+ });
15623
+ } else if (updated >= startDate && updated <= endDate && updated !== created) {
15624
+ artifacts.push({
15625
+ id: doc.frontmatter.id,
15626
+ title: doc.frontmatter.title,
15627
+ type: doc.frontmatter.type,
15628
+ action: "updated",
15629
+ date: updated
15630
+ });
15631
+ }
15632
+ }
15633
+ artifacts.sort((a, b) => b.date.localeCompare(a.date));
15634
+ }
15635
+ const relevantTags = /* @__PURE__ */ new Set([sprintTag, ...linkedEpicIds.map((id) => `epic:${id}`)]);
15636
+ const openActions = allDocs.filter(
15637
+ (d) => d.frontmatter.type === "action" && !DONE_STATUSES.has(d.frontmatter.status) && d.frontmatter.tags?.some((t) => relevantTags.has(t))
15638
+ ).map((d) => ({
15639
+ id: d.frontmatter.id,
15640
+ title: d.frontmatter.title,
15641
+ owner: d.frontmatter.owner,
15642
+ dueDate: d.frontmatter.dueDate
15643
+ }));
15644
+ const openQuestions = allDocs.filter(
15645
+ (d) => d.frontmatter.type === "question" && d.frontmatter.status === "open" && d.frontmatter.tags?.some((t) => relevantTags.has(t))
15646
+ ).map((d) => ({
15647
+ id: d.frontmatter.id,
15648
+ title: d.frontmatter.title
15649
+ }));
15650
+ const blockers = allDocs.filter(
15651
+ (d) => d.frontmatter.status === "blocked" && d.frontmatter.tags?.includes(sprintTag)
15652
+ ).map((d) => ({
15653
+ id: d.frontmatter.id,
15654
+ title: d.frontmatter.title,
15655
+ type: d.frontmatter.type
15656
+ }));
15657
+ const riskBlockers = allDocs.filter(
15658
+ (d) => !DONE_STATUSES.has(d.frontmatter.status) && d.frontmatter.tags?.includes("risk") && d.frontmatter.tags?.some((t) => relevantTags.has(t)) && !blockers.some((b) => b.id === d.frontmatter.id)
15659
+ );
15660
+ for (const d of riskBlockers) {
15661
+ blockers.push({
15662
+ id: d.frontmatter.id,
15663
+ title: d.frontmatter.title,
15664
+ type: d.frontmatter.type
15665
+ });
15666
+ }
15667
+ let velocity = null;
15668
+ const currentRate = workItems.completionPct;
15669
+ const completedSprints = sprintDocs.filter((s) => DONE_STATUSES.has(s.frontmatter.status) && s.frontmatter.id !== fm.id).sort((a, b) => (b.frontmatter.endDate ?? "").localeCompare(a.frontmatter.endDate ?? ""));
15670
+ if (completedSprints.length > 0) {
15671
+ const prev = completedSprints[0];
15672
+ const prevTag = `sprint:${prev.frontmatter.id}`;
15673
+ const prevWorkItems = allDocs.filter(
15674
+ (d) => d.frontmatter.type !== "sprint" && d.frontmatter.type !== "epic" && d.frontmatter.tags?.includes(prevTag)
15675
+ );
15676
+ const prevDone = prevWorkItems.filter((d) => DONE_STATUSES.has(d.frontmatter.status)).length;
15677
+ const prevRate = prevWorkItems.length > 0 ? Math.round(prevDone / prevWorkItems.length * 100) : 0;
15678
+ velocity = {
15679
+ currentCompletionRate: currentRate,
15680
+ previousSprintRate: prevRate,
15681
+ previousSprintId: prev.frontmatter.id
15682
+ };
15683
+ } else {
15684
+ velocity = { currentCompletionRate: currentRate };
15685
+ }
15686
+ return {
15687
+ sprint: {
15688
+ id: fm.id,
15689
+ title: fm.title,
15690
+ goal: fm.goal,
15691
+ status: fm.status,
15692
+ startDate,
15693
+ endDate
15694
+ },
15695
+ timeline: { daysElapsed, daysRemaining, totalDays, percentComplete },
15696
+ linkedEpics,
15697
+ workItems,
15698
+ meetings,
15699
+ artifacts,
15700
+ openActions,
15701
+ openQuestions,
15702
+ blockers,
15703
+ velocity
15704
+ };
15705
+ }
15706
+
15707
+ // src/reports/sprint-summary/generator.ts
15708
+ import { query } from "@anthropic-ai/claude-agent-sdk";
15709
+ async function generateSprintSummary(data) {
15710
+ const prompt = buildPrompt(data);
15711
+ const result = query({
15712
+ prompt,
15713
+ options: {
15714
+ systemPrompt: SYSTEM_PROMPT,
15715
+ maxTurns: 1,
15716
+ tools: [],
15717
+ allowedTools: []
15718
+ }
15719
+ });
15720
+ for await (const msg of result) {
15721
+ if (msg.type === "assistant") {
15722
+ const text = msg.message.content.find(
15723
+ (b) => b.type === "text"
15724
+ );
15725
+ if (text) return text.text;
15726
+ }
15727
+ }
15728
+ return "Unable to generate sprint summary.";
15729
+ }
15730
+ var SYSTEM_PROMPT = `You are a delivery management assistant generating a sprint summary narrative. Produce a concise, insightful markdown report. Do NOT include a top-level heading \u2014 the caller will add one. Use the following structure:
15731
+
15732
+ ## Sprint Health
15733
+ One-line verdict on overall sprint health (healthy / at risk / behind).
15734
+
15735
+ ## Goal Progress
15736
+ How close the team is to achieving the sprint goal. Reference the goal text and completion metrics.
15737
+
15738
+ ## Key Achievements
15739
+ Notable completions, decisions made, meetings held during the sprint. Use bullet points.
15740
+
15741
+ ## Current Risks
15742
+ Blockers, overdue items, unresolved questions, items without owners. Use bullet points. If none, say so.
15743
+
15744
+ ## Outcome Projection
15745
+ Given the current pace and time remaining, what's the likely outcome? Will the sprint goal be met?
15746
+
15747
+ Be specific \u2014 reference artifact IDs, dates, and numbers from the data. Keep the tone professional but direct.`;
15748
+ function buildPrompt(data) {
15749
+ const sections = [];
15750
+ sections.push(`# Sprint: ${data.sprint.id} \u2014 ${data.sprint.title}`);
15751
+ sections.push(`Status: ${data.sprint.status}`);
15752
+ if (data.sprint.goal) sections.push(`Goal: ${data.sprint.goal}`);
15753
+ if (data.sprint.startDate) sections.push(`Start: ${data.sprint.startDate}`);
15754
+ if (data.sprint.endDate) sections.push(`End: ${data.sprint.endDate}`);
15755
+ sections.push(`
15756
+ ## Timeline`);
15757
+ sections.push(`Days elapsed: ${data.timeline.daysElapsed} / ${data.timeline.totalDays}`);
15758
+ sections.push(`Days remaining: ${data.timeline.daysRemaining}`);
15759
+ sections.push(`Timeline progress: ${data.timeline.percentComplete}%`);
15760
+ sections.push(`
15761
+ ## Work Items`);
15762
+ sections.push(`Total: ${data.workItems.total}, Done: ${data.workItems.done}, In Progress: ${data.workItems.inProgress}, Open: ${data.workItems.open}, Blocked: ${data.workItems.blocked}`);
15763
+ sections.push(`Completion: ${data.workItems.completionPct}%`);
15764
+ if (Object.keys(data.workItems.byType).length > 0) {
15765
+ sections.push(`By type: ${Object.entries(data.workItems.byType).map(([t, n]) => `${t}: ${n}`).join(", ")}`);
15766
+ }
15767
+ if (data.linkedEpics.length > 0) {
15768
+ sections.push(`
15769
+ ## Linked Epics`);
15770
+ for (const e of data.linkedEpics) {
15771
+ sections.push(`- ${e.id}: ${e.title} [${e.status}] \u2014 ${e.tasksDone}/${e.tasksTotal} tasks done`);
15772
+ }
15773
+ }
15774
+ if (data.meetings.length > 0) {
15775
+ sections.push(`
15776
+ ## Meetings During Sprint`);
15777
+ for (const m of data.meetings) {
15778
+ sections.push(`- ${m.date}: ${m.id} \u2014 ${m.title}`);
15779
+ }
15780
+ }
15781
+ if (data.artifacts.length > 0) {
15782
+ sections.push(`
15783
+ ## Artifacts Created/Updated During Sprint`);
15784
+ for (const a of data.artifacts.slice(0, 20)) {
15785
+ sections.push(`- ${a.date}: ${a.id} (${a.type}) ${a.action} \u2014 ${a.title}`);
15786
+ }
15787
+ if (data.artifacts.length > 20) {
15788
+ sections.push(`... and ${data.artifacts.length - 20} more`);
15789
+ }
15790
+ }
15791
+ if (data.openActions.length > 0) {
15792
+ sections.push(`
15793
+ ## Open Actions`);
15794
+ for (const a of data.openActions) {
15795
+ const owner = a.owner ?? "unowned";
15796
+ const due = a.dueDate ?? "no due date";
15797
+ sections.push(`- ${a.id}: ${a.title} (${owner}, ${due})`);
15798
+ }
15799
+ }
15800
+ if (data.openQuestions.length > 0) {
15801
+ sections.push(`
15802
+ ## Open Questions`);
15803
+ for (const q of data.openQuestions) {
15804
+ sections.push(`- ${q.id}: ${q.title}`);
15805
+ }
15806
+ }
15807
+ if (data.blockers.length > 0) {
15808
+ sections.push(`
15809
+ ## Blockers`);
15810
+ for (const b of data.blockers) {
15811
+ sections.push(`- ${b.id} (${b.type}): ${b.title}`);
15812
+ }
15813
+ }
15814
+ if (data.velocity) {
15815
+ sections.push(`
15816
+ ## Velocity`);
15817
+ sections.push(`Current sprint completion rate: ${data.velocity.currentCompletionRate}%`);
15818
+ if (data.velocity.previousSprintRate !== void 0) {
15819
+ sections.push(`Previous sprint (${data.velocity.previousSprintId}): ${data.velocity.previousSprintRate}%`);
15820
+ }
15821
+ }
15822
+ return sections.join("\n");
15823
+ }
15824
+
15485
15825
  // src/plugins/builtin/tools/reports.ts
15486
15826
  function createReportTools(store) {
15487
15827
  return [
@@ -15773,6 +16113,25 @@ function createReportTools(store) {
15773
16113
  },
15774
16114
  { annotations: { readOnlyHint: true } }
15775
16115
  ),
16116
+ tool8(
16117
+ "generate_sprint_summary",
16118
+ "Generate an AI-powered narrative summary of a sprint's progress, health, achievements, risks, and projected outcome",
16119
+ {
16120
+ sprint: external_exports.string().optional().describe("Sprint ID (e.g. 'SP-001'). Omit for the active sprint.")
16121
+ },
16122
+ async (args) => {
16123
+ const data = collectSprintSummaryData(store, args.sprint);
16124
+ if (!data) {
16125
+ const msg = args.sprint ? `Sprint ${args.sprint} not found.` : "No active sprint found.";
16126
+ return { content: [{ type: "text", text: msg }], isError: true };
16127
+ }
16128
+ const summary = await generateSprintSummary(data);
16129
+ return {
16130
+ content: [{ type: "text", text: summary }]
16131
+ };
16132
+ },
16133
+ { annotations: { readOnlyHint: true } }
16134
+ ),
15776
16135
  tool8(
15777
16136
  "save_report",
15778
16137
  "Save a generated report as a persistent document",
@@ -16616,26 +16975,6 @@ function createSprintPlanningTools(store) {
16616
16975
 
16617
16976
  // src/plugins/builtin/tools/tasks.ts
16618
16977
  import { tool as tool14 } from "@anthropic-ai/claude-agent-sdk";
16619
-
16620
- // src/plugins/builtin/tools/task-utils.ts
16621
- function normalizeLinkedEpics(value) {
16622
- if (value === void 0 || value === null) return [];
16623
- if (typeof value === "string") {
16624
- try {
16625
- const parsed = JSON.parse(value);
16626
- if (Array.isArray(parsed)) return parsed.filter((v) => typeof v === "string");
16627
- } catch {
16628
- }
16629
- return [value];
16630
- }
16631
- if (Array.isArray(value)) return value.filter((v) => typeof v === "string");
16632
- return [];
16633
- }
16634
- function generateEpicTags(epics) {
16635
- return epics.map((id) => `epic:${id}`);
16636
- }
16637
-
16638
- // src/plugins/builtin/tools/tasks.ts
16639
16978
  var linkedEpicArray = external_exports.preprocess(
16640
16979
  (val) => {
16641
16980
  if (typeof val === "string") {
@@ -18502,8 +18841,8 @@ function gatherContext(store, focusFeature, includeDecisions = true, includeQues
18502
18841
  title: e.frontmatter.title,
18503
18842
  status: e.frontmatter.status,
18504
18843
  linkedFeature: normalizeLinkedFeatures(e.frontmatter.linkedFeature),
18505
- targetDate: e.frontmatter.targetDate ?? null,
18506
- estimatedEffort: e.frontmatter.estimatedEffort ?? null,
18844
+ targetDate: typeof e.frontmatter.targetDate === "string" ? e.frontmatter.targetDate : null,
18845
+ estimatedEffort: typeof e.frontmatter.estimatedEffort === "string" ? e.frontmatter.estimatedEffort : null,
18507
18846
  content: e.content,
18508
18847
  linkedTaskCount: tasks.filter(
18509
18848
  (t) => normalizeLinkedEpics(t.frontmatter.linkedEpic).includes(e.frontmatter.id)
@@ -18514,10 +18853,10 @@ function gatherContext(store, focusFeature, includeDecisions = true, includeQues
18514
18853
  title: t.frontmatter.title,
18515
18854
  status: t.frontmatter.status,
18516
18855
  linkedEpic: normalizeLinkedEpics(t.frontmatter.linkedEpic),
18517
- acceptanceCriteria: t.frontmatter.acceptanceCriteria ?? null,
18518
- technicalNotes: t.frontmatter.technicalNotes ?? null,
18519
- complexity: t.frontmatter.complexity ?? null,
18520
- estimatedPoints: t.frontmatter.estimatedPoints ?? null,
18856
+ acceptanceCriteria: typeof t.frontmatter.acceptanceCriteria === "string" ? t.frontmatter.acceptanceCriteria : null,
18857
+ technicalNotes: typeof t.frontmatter.technicalNotes === "string" ? t.frontmatter.technicalNotes : null,
18858
+ complexity: typeof t.frontmatter.complexity === "string" ? t.frontmatter.complexity : null,
18859
+ estimatedPoints: typeof t.frontmatter.estimatedPoints === "number" ? t.frontmatter.estimatedPoints : null,
18521
18860
  priority: t.frontmatter.priority ?? null
18522
18861
  })),
18523
18862
  decisions: allDecisions.map((d) => ({
@@ -19020,7 +19359,7 @@ ${fragment}`);
19020
19359
  import { tool as tool23 } from "@anthropic-ai/claude-agent-sdk";
19021
19360
 
19022
19361
  // src/skills/action-runner.ts
19023
- import { query } from "@anthropic-ai/claude-agent-sdk";
19362
+ import { query as query2 } from "@anthropic-ai/claude-agent-sdk";
19024
19363
 
19025
19364
  // src/agent/mcp-server.ts
19026
19365
  import {
@@ -19152,7 +19491,7 @@ function computeUrgency(dueDateStr, todayStr) {
19152
19491
  if (diffDays <= 14) return "upcoming";
19153
19492
  return "later";
19154
19493
  }
19155
- var DONE_STATUSES = /* @__PURE__ */ new Set(["done", "closed", "resolved", "cancelled"]);
19494
+ var DONE_STATUSES2 = /* @__PURE__ */ new Set(["done", "closed", "resolved", "cancelled"]);
19156
19495
  function getUpcomingData(store) {
19157
19496
  const today = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
19158
19497
  const allDocs = store.list();
@@ -19161,7 +19500,7 @@ function getUpcomingData(store) {
19161
19500
  docById.set(doc.frontmatter.id, doc);
19162
19501
  }
19163
19502
  const actions = allDocs.filter(
19164
- (d) => d.frontmatter.type === "action" && !DONE_STATUSES.has(d.frontmatter.status)
19503
+ (d) => d.frontmatter.type === "action" && !DONE_STATUSES2.has(d.frontmatter.status)
19165
19504
  );
19166
19505
  const actionsWithDue = actions.filter((d) => d.frontmatter.dueDate);
19167
19506
  const sprints = allDocs.filter((d) => d.frontmatter.type === "sprint");
@@ -19225,7 +19564,7 @@ function getUpcomingData(store) {
19225
19564
  const sprintEnd = sprint.frontmatter.endDate;
19226
19565
  const sprintTaskDocs = getSprintTasks(sprint);
19227
19566
  for (const task of sprintTaskDocs) {
19228
- if (DONE_STATUSES.has(task.frontmatter.status)) continue;
19567
+ if (DONE_STATUSES2.has(task.frontmatter.status)) continue;
19229
19568
  const existing = taskSprintMap.get(task.frontmatter.id);
19230
19569
  if (!existing || sprintEnd < existing.sprintEnd) {
19231
19570
  taskSprintMap.set(task.frontmatter.id, { task, sprint, sprintEnd });
@@ -19242,7 +19581,7 @@ function getUpcomingData(store) {
19242
19581
  urgency: computeUrgency(sprintEnd, today)
19243
19582
  })).sort((a, b) => a.sprintEndDate.localeCompare(b.sprintEndDate));
19244
19583
  const openItems = allDocs.filter(
19245
- (d) => ["action", "question", "task"].includes(d.frontmatter.type) && !DONE_STATUSES.has(d.frontmatter.status)
19584
+ (d) => ["action", "question", "task"].includes(d.frontmatter.type) && !DONE_STATUSES2.has(d.frontmatter.status)
19246
19585
  );
19247
19586
  const fourteenDaysAgo = new Date(todayMs - fourteenDaysMs).toISOString().slice(0, 10);
19248
19587
  const recentMeetings = allDocs.filter(
@@ -19340,8 +19679,28 @@ function getUpcomingData(store) {
19340
19679
  }).filter((item) => item.score > 0).sort((a, b) => b.score - a.score).slice(0, 15);
19341
19680
  return { dueSoonActions, dueSoonSprintTasks, trending };
19342
19681
  }
19682
+ function getSprintSummaryData(store, sprintId) {
19683
+ return collectSprintSummaryData(store, sprintId);
19684
+ }
19343
19685
 
19344
19686
  // src/web/templates/layout.ts
19687
+ function collapsibleSection(sectionId, title, content, opts) {
19688
+ const tag = opts?.titleTag ?? "div";
19689
+ const cls = opts?.titleClass ?? "section-title";
19690
+ const collapsed = opts?.defaultCollapsed ? " collapsed" : "";
19691
+ return `
19692
+ <div class="collapsible${collapsed}" data-section-id="${escapeHtml(sectionId)}">
19693
+ <${tag} class="${cls} collapsible-header" onclick="toggleSection(this)">
19694
+ <svg class="collapsible-chevron" viewBox="0 0 16 16" width="14" height="14" fill="currentColor">
19695
+ <path d="M4.94 5.72a.75.75 0 0 1 1.06-.02L8 7.56l1.97-1.84a.75.75 0 1 1 1.02 1.1l-2.5 2.34a.75.75 0 0 1-1.02 0l-2.5-2.34a.75.75 0 0 1-.03-1.06z"/>
19696
+ </svg>
19697
+ <span>${title}</span>
19698
+ </${tag}>
19699
+ <div class="collapsible-body">
19700
+ ${content}
19701
+ </div>
19702
+ </div>`;
19703
+ }
19345
19704
  function escapeHtml(str) {
19346
19705
  return str.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
19347
19706
  }
@@ -19464,6 +19823,7 @@ function layout(opts, body) {
19464
19823
  const topItems = [
19465
19824
  { href: "/", label: "Overview" },
19466
19825
  { href: "/upcoming", label: "Upcoming" },
19826
+ { href: "/sprint-summary", label: "Sprint Summary" },
19467
19827
  { href: "/timeline", label: "Timeline" },
19468
19828
  { href: "/board", label: "Board" },
19469
19829
  { href: "/gar", label: "GAR Report" },
@@ -19509,6 +19869,32 @@ function layout(opts, body) {
19509
19869
  ${body}
19510
19870
  </main>
19511
19871
  </div>
19872
+ <script>
19873
+ function toggleSection(header) {
19874
+ var section = header.closest('.collapsible');
19875
+ if (!section) return;
19876
+ section.classList.toggle('collapsed');
19877
+ var id = section.getAttribute('data-section-id');
19878
+ if (id) {
19879
+ try {
19880
+ var state = JSON.parse(localStorage.getItem('marvin-collapsed') || '{}');
19881
+ state[id] = section.classList.contains('collapsed');
19882
+ localStorage.setItem('marvin-collapsed', JSON.stringify(state));
19883
+ } catch(e) {}
19884
+ }
19885
+ }
19886
+ // Restore collapsed state on load
19887
+ (function() {
19888
+ try {
19889
+ var state = JSON.parse(localStorage.getItem('marvin-collapsed') || '{}');
19890
+ document.querySelectorAll('.collapsible[data-section-id]').forEach(function(el) {
19891
+ var id = el.getAttribute('data-section-id');
19892
+ if (state[id] === true) el.classList.add('collapsed');
19893
+ else if (state[id] === false) el.classList.remove('collapsed');
19894
+ });
19895
+ } catch(e) {}
19896
+ })();
19897
+ </script>
19512
19898
  <script src="https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.min.js"></script>
19513
19899
  <script>mermaid.initialize({
19514
19900
  startOnLoad: true,
@@ -20332,13 +20718,60 @@ tr:hover td {
20332
20718
  white-space: nowrap;
20333
20719
  }
20334
20720
 
20721
+ .gantt-grid-line {
20722
+ position: absolute;
20723
+ top: 0;
20724
+ bottom: 0;
20725
+ width: 1px;
20726
+ background: var(--border);
20727
+ opacity: 0.35;
20728
+ }
20729
+
20730
+ .gantt-sprint-line {
20731
+ position: absolute;
20732
+ top: 0;
20733
+ bottom: 0;
20734
+ width: 1px;
20735
+ background: var(--text-dim);
20736
+ opacity: 0.3;
20737
+ }
20738
+
20335
20739
  .gantt-today {
20336
20740
  position: absolute;
20337
20741
  top: 0;
20338
20742
  bottom: 0;
20339
- width: 2px;
20743
+ width: 3px;
20340
20744
  background: var(--red);
20341
- opacity: 0.7;
20745
+ opacity: 0.8;
20746
+ border-radius: 1px;
20747
+ }
20748
+
20749
+ /* Sprint band in timeline */
20750
+ .gantt-sprint-band-row {
20751
+ border-bottom: 1px solid var(--border);
20752
+ margin-bottom: 0.25rem;
20753
+ }
20754
+
20755
+ .gantt-sprint-band {
20756
+ height: 32px;
20757
+ }
20758
+
20759
+ .gantt-sprint-block {
20760
+ position: absolute;
20761
+ top: 2px;
20762
+ bottom: 2px;
20763
+ background: var(--bg-hover);
20764
+ border: 1px solid var(--border);
20765
+ border-radius: 4px;
20766
+ font-size: 0.65rem;
20767
+ color: var(--text-dim);
20768
+ display: flex;
20769
+ align-items: center;
20770
+ justify-content: center;
20771
+ overflow: hidden;
20772
+ white-space: nowrap;
20773
+ text-overflow: ellipsis;
20774
+ padding: 0 0.4rem;
20342
20775
  }
20343
20776
 
20344
20777
  /* Pie chart color overrides */
@@ -20400,6 +20833,146 @@ tr:hover td {
20400
20833
  }
20401
20834
 
20402
20835
  .text-dim { color: var(--text-dim); }
20836
+
20837
+ /* Sprint Summary */
20838
+ .sprint-goal {
20839
+ background: var(--bg-card);
20840
+ border: 1px solid var(--border);
20841
+ border-radius: var(--radius);
20842
+ padding: 0.75rem 1rem;
20843
+ margin-bottom: 1rem;
20844
+ font-size: 0.9rem;
20845
+ color: var(--text);
20846
+ }
20847
+
20848
+ .sprint-progress-bar {
20849
+ position: relative;
20850
+ height: 24px;
20851
+ background: var(--bg-card);
20852
+ border: 1px solid var(--border);
20853
+ border-radius: 12px;
20854
+ margin-bottom: 1.25rem;
20855
+ overflow: hidden;
20856
+ }
20857
+
20858
+ .sprint-progress-fill {
20859
+ height: 100%;
20860
+ background: linear-gradient(90deg, var(--accent-dim), var(--accent));
20861
+ border-radius: 12px;
20862
+ transition: width 0.3s ease;
20863
+ }
20864
+
20865
+ .sprint-progress-label {
20866
+ position: absolute;
20867
+ top: 50%;
20868
+ left: 50%;
20869
+ transform: translate(-50%, -50%);
20870
+ font-size: 0.7rem;
20871
+ font-weight: 700;
20872
+ color: var(--text);
20873
+ }
20874
+
20875
+ .sprint-ai-section {
20876
+ margin-top: 2rem;
20877
+ background: var(--bg-card);
20878
+ border: 1px solid var(--border);
20879
+ border-radius: var(--radius);
20880
+ padding: 1.5rem;
20881
+ }
20882
+
20883
+ .sprint-ai-section h3 {
20884
+ font-size: 1rem;
20885
+ font-weight: 600;
20886
+ margin-bottom: 0.5rem;
20887
+ }
20888
+
20889
+ .sprint-generate-btn {
20890
+ background: var(--accent);
20891
+ color: #fff;
20892
+ border: none;
20893
+ border-radius: var(--radius);
20894
+ padding: 0.5rem 1.25rem;
20895
+ font-size: 0.85rem;
20896
+ font-weight: 600;
20897
+ cursor: pointer;
20898
+ margin-top: 0.75rem;
20899
+ transition: background 0.15s;
20900
+ }
20901
+
20902
+ .sprint-generate-btn:hover:not(:disabled) {
20903
+ background: var(--accent-dim);
20904
+ }
20905
+
20906
+ .sprint-generate-btn:disabled {
20907
+ opacity: 0.5;
20908
+ cursor: not-allowed;
20909
+ }
20910
+
20911
+ .sprint-loading {
20912
+ display: flex;
20913
+ align-items: center;
20914
+ gap: 0.75rem;
20915
+ padding: 1rem 0;
20916
+ color: var(--text-dim);
20917
+ font-size: 0.85rem;
20918
+ }
20919
+
20920
+ .sprint-spinner {
20921
+ width: 20px;
20922
+ height: 20px;
20923
+ border: 2px solid var(--border);
20924
+ border-top-color: var(--accent);
20925
+ border-radius: 50%;
20926
+ animation: sprint-spin 0.8s linear infinite;
20927
+ }
20928
+
20929
+ @keyframes sprint-spin {
20930
+ to { transform: rotate(360deg); }
20931
+ }
20932
+
20933
+ .sprint-error {
20934
+ color: var(--red);
20935
+ font-size: 0.85rem;
20936
+ padding: 0.5rem 0;
20937
+ }
20938
+
20939
+ .sprint-ai-section .detail-content {
20940
+ margin-top: 1rem;
20941
+ }
20942
+
20943
+ /* Collapsible sections */
20944
+ .collapsible-header {
20945
+ cursor: pointer;
20946
+ display: flex;
20947
+ align-items: center;
20948
+ gap: 0.4rem;
20949
+ user-select: none;
20950
+ }
20951
+
20952
+ .collapsible-header:hover {
20953
+ color: var(--accent);
20954
+ }
20955
+
20956
+ .collapsible-chevron {
20957
+ transition: transform 0.2s ease;
20958
+ flex-shrink: 0;
20959
+ }
20960
+
20961
+ .collapsible.collapsed .collapsible-chevron {
20962
+ transform: rotate(-90deg);
20963
+ }
20964
+
20965
+ .collapsible-body {
20966
+ overflow: hidden;
20967
+ max-height: 5000px;
20968
+ transition: max-height 0.3s ease, opacity 0.2s ease;
20969
+ opacity: 1;
20970
+ }
20971
+
20972
+ .collapsible.collapsed .collapsible-body {
20973
+ max-height: 0;
20974
+ opacity: 0;
20975
+ }
20403
20976
  `;
20404
20977
  }
20405
20978
 
@@ -20452,35 +21025,73 @@ function buildTimelineGantt(data, maxSprints = 6) {
20452
21025
  );
20453
21026
  tick += 7 * DAY;
20454
21027
  }
21028
+ const gridLines = [];
21029
+ let gridTick = timelineStart;
21030
+ const gridStartDay = new Date(gridTick).getDay();
21031
+ gridTick += (8 - gridStartDay) % 7 * DAY;
21032
+ while (gridTick <= timelineEnd) {
21033
+ gridLines.push(`<div class="gantt-grid-line" style="left:${pct(gridTick).toFixed(2)}%"></div>`);
21034
+ gridTick += 7 * DAY;
21035
+ }
21036
+ const sprintBoundaries = /* @__PURE__ */ new Set();
21037
+ for (const sprint of visibleSprints) {
21038
+ sprintBoundaries.add(toMs(sprint.startDate));
21039
+ sprintBoundaries.add(toMs(sprint.endDate));
21040
+ }
21041
+ const sprintLines = [...sprintBoundaries].map(
21042
+ (ms) => `<div class="gantt-sprint-line" style="left:${pct(ms).toFixed(2)}%"></div>`
21043
+ );
20455
21044
  const now = Date.now();
20456
21045
  let todayMarker = "";
20457
21046
  if (now >= timelineStart && now <= timelineEnd) {
20458
21047
  todayMarker = `<div class="gantt-today" style="left:${pct(now).toFixed(2)}%"></div>`;
20459
21048
  }
20460
- const rows = [];
21049
+ const sprintBlocks = visibleSprints.map((sprint) => {
21050
+ const sStart = toMs(sprint.startDate);
21051
+ const sEnd = toMs(sprint.endDate);
21052
+ const left = pct(sStart).toFixed(2);
21053
+ const width = (pct(sEnd) - pct(sStart)).toFixed(2);
21054
+ return `<div class="gantt-sprint-block" style="left:${left}%;width:${width}%">${sanitize(sprint.id, 20)}</div>`;
21055
+ }).join("");
21056
+ const sprintBandRow = `<div class="gantt-row gantt-sprint-band-row">
21057
+ <div class="gantt-label gantt-section-label">Sprints</div>
21058
+ <div class="gantt-track gantt-sprint-band">${sprintBlocks}</div>
21059
+ </div>`;
21060
+ const epicSpanMap = /* @__PURE__ */ new Map();
20461
21061
  for (const sprint of visibleSprints) {
20462
21062
  const sStart = toMs(sprint.startDate);
20463
21063
  const sEnd = toMs(sprint.endDate);
20464
- rows.push(`<div class="gantt-section-row">
20465
- <div class="gantt-label gantt-section-label">${sanitize(sprint.id + " " + sprint.title, 50)}</div>
20466
- <div class="gantt-track">
20467
- <div class="gantt-section-bg" style="left:${pct(sStart).toFixed(2)}%;width:${(pct(sEnd) - pct(sStart)).toFixed(2)}%"></div>
20468
- </div>
20469
- </div>`);
20470
- const linked = sprint.linkedEpics.map((eid) => epicMap.get(eid)).filter(Boolean);
20471
- const items = linked.length > 0 ? linked.map((e) => ({ label: sanitize(e.id + " " + e.title), status: e.status })) : [{ label: sanitize(sprint.title), status: sprint.status }];
20472
- for (const item of items) {
20473
- const cls = item.status === "done" || item.status === "completed" ? "gantt-bar-done" : item.status === "in-progress" || item.status === "active" ? "gantt-bar-active" : item.status === "blocked" ? "gantt-bar-blocked" : "gantt-bar-default";
20474
- const left = pct(sStart).toFixed(2);
20475
- const width = (pct(sEnd) - pct(sStart)).toFixed(2);
20476
- rows.push(`<div class="gantt-row">
20477
- <div class="gantt-label">${item.label}</div>
21064
+ for (const eid of sprint.linkedEpics) {
21065
+ if (!epicMap.has(eid)) continue;
21066
+ const existing = epicSpanMap.get(eid);
21067
+ if (existing) {
21068
+ existing.startMs = Math.min(existing.startMs, sStart);
21069
+ existing.endMs = Math.max(existing.endMs, sEnd);
21070
+ } else {
21071
+ epicSpanMap.set(eid, { startMs: sStart, endMs: sEnd });
21072
+ }
21073
+ }
21074
+ }
21075
+ const sortedEpicIds = [...epicSpanMap.keys()].sort((a, b) => {
21076
+ const aSpan = epicSpanMap.get(a);
21077
+ const bSpan = epicSpanMap.get(b);
21078
+ if (aSpan.startMs !== bSpan.startMs) return aSpan.startMs - bSpan.startMs;
21079
+ return a.localeCompare(b);
21080
+ });
21081
+ const epicRows = sortedEpicIds.map((eid) => {
21082
+ const epic = epicMap.get(eid);
21083
+ const { startMs, endMs } = epicSpanMap.get(eid);
21084
+ const cls = epic.status === "done" || epic.status === "completed" ? "gantt-bar-done" : epic.status === "in-progress" || epic.status === "active" ? "gantt-bar-active" : epic.status === "blocked" ? "gantt-bar-blocked" : "gantt-bar-default";
21085
+ const left = pct(startMs).toFixed(2);
21086
+ const width = (pct(endMs) - pct(startMs)).toFixed(2);
21087
+ const label = sanitize(epic.id + " " + epic.title);
21088
+ return `<div class="gantt-row">
21089
+ <div class="gantt-label">${label}</div>
20478
21090
  <div class="gantt-track">
20479
21091
  <div class="gantt-bar ${cls}" style="left:${left}%;width:${width}%"></div>
20480
21092
  </div>
20481
- </div>`);
20482
- }
20483
- }
21093
+ </div>`;
21094
+ }).join("\n");
20484
21095
  const note = truncated ? `<div class="mermaid-note">${hiddenCount} earlier sprint${hiddenCount > 1 ? "s" : ""} not shown</div>` : "";
20485
21096
  return `${note}
20486
21097
  <div class="gantt">
@@ -20489,11 +21100,12 @@ function buildTimelineGantt(data, maxSprints = 6) {
20489
21100
  <div class="gantt-label"></div>
20490
21101
  <div class="gantt-track gantt-dates">${markers.join("")}</div>
20491
21102
  </div>
20492
- ${rows.join("\n")}
21103
+ ${sprintBandRow}
21104
+ ${epicRows}
20493
21105
  </div>
20494
21106
  <div class="gantt-overlay">
20495
21107
  <div class="gantt-label"></div>
20496
- <div class="gantt-track">${todayMarker}</div>
21108
+ <div class="gantt-track">${gridLines.join("")}${sprintLines.join("")}${todayMarker}</div>
20497
21109
  </div>
20498
21110
  </div>`;
20499
21111
  }
@@ -20773,11 +21385,12 @@ function overviewPage(data, diagrams, navGroups) {
20773
21385
 
20774
21386
  <div class="section-title"><a href="/timeline">Project Timeline &rarr;</a></div>
20775
21387
 
20776
- <div class="section-title">Artifact Relationships</div>
20777
- ${buildArtifactFlowchart(diagrams)}
21388
+ ${collapsibleSection("overview-relationships", "Artifact Relationships", buildArtifactFlowchart(diagrams))}
20778
21389
 
20779
- <div class="section-title">Recent Activity</div>
20780
- ${data.recent.length > 0 ? `
21390
+ ${collapsibleSection(
21391
+ "overview-recent",
21392
+ "Recent Activity",
21393
+ data.recent.length > 0 ? `
20781
21394
  <div class="table-wrap">
20782
21395
  <table>
20783
21396
  <thead>
@@ -20793,7 +21406,8 @@ function overviewPage(data, diagrams, navGroups) {
20793
21406
  ${rows}
20794
21407
  </tbody>
20795
21408
  </table>
20796
- </div>` : `<div class="empty"><p>No documents yet.</p></div>`}
21409
+ </div>` : `<div class="empty"><p>No documents yet.</p></div>`
21410
+ )}
20797
21411
  `;
20798
21412
  }
20799
21413
 
@@ -20938,23 +21552,24 @@ function garPage(report) {
20938
21552
  <div class="label">Overall: ${escapeHtml(report.overall)}</div>
20939
21553
  </div>
20940
21554
 
20941
- <div class="gar-areas">
20942
- ${areaCards}
20943
- </div>
21555
+ ${collapsibleSection("gar-areas", "Areas", `<div class="gar-areas">${areaCards}</div>`)}
20944
21556
 
20945
- <div class="section-title">Status Distribution</div>
20946
- ${buildStatusPie("Action Status", {
20947
- Open: report.metrics.scope.open,
20948
- Done: report.metrics.scope.done,
20949
- "In Progress": Math.max(0, report.metrics.scope.total - report.metrics.scope.open - report.metrics.scope.done)
20950
- })}
21557
+ ${collapsibleSection(
21558
+ "gar-status-dist",
21559
+ "Status Distribution",
21560
+ buildStatusPie("Action Status", {
21561
+ Open: report.metrics.scope.open,
21562
+ Done: report.metrics.scope.done,
21563
+ "In Progress": Math.max(0, report.metrics.scope.total - report.metrics.scope.open - report.metrics.scope.done)
21564
+ })
21565
+ )}
20951
21566
  `;
20952
21567
  }
20953
21568
 
20954
21569
  // src/web/templates/pages/health.ts
20955
21570
  function healthPage(report, metrics) {
20956
21571
  const dotClass = `dot-${report.overall}`;
20957
- function renderSection(title, categories) {
21572
+ function renderSection(sectionId, title, categories) {
20958
21573
  const cards = categories.map(
20959
21574
  (cat) => `
20960
21575
  <div class="gar-area">
@@ -20966,10 +21581,9 @@ function healthPage(report, metrics) {
20966
21581
  ${cat.items.length > 0 ? `<ul>${cat.items.map((item) => `<li><span class="ref-id">${escapeHtml(item.id)}</span>${escapeHtml(item.detail)}</li>`).join("")}</ul>` : ""}
20967
21582
  </div>`
20968
21583
  ).join("\n");
20969
- return `
20970
- <div class="health-section-title">${escapeHtml(title)}</div>
20971
- <div class="gar-areas">${cards}</div>
20972
- `;
21584
+ return collapsibleSection(sectionId, title, `<div class="gar-areas">${cards}</div>`, {
21585
+ titleClass: "health-section-title"
21586
+ });
20973
21587
  }
20974
21588
  return `
20975
21589
  <div class="page-header">
@@ -20982,35 +21596,43 @@ function healthPage(report, metrics) {
20982
21596
  <div class="label">Overall: ${escapeHtml(report.overall)}</div>
20983
21597
  </div>
20984
21598
 
20985
- ${renderSection("Completeness", report.completeness)}
20986
-
20987
- <div class="health-section-title">Completeness Overview</div>
20988
- ${buildHealthGauge(
20989
- metrics ? Object.entries(metrics.completeness).map(([name, cat]) => ({
20990
- name: name.replace(/\b\w/g, (c) => c.toUpperCase()),
20991
- complete: cat.complete,
20992
- total: cat.total
20993
- })) : report.completeness.map((c) => {
20994
- const match = c.summary.match(/(\d+)\s*\/\s*(\d+)/);
20995
- return {
20996
- name: c.name,
20997
- complete: match ? parseInt(match[1], 10) : 0,
20998
- total: match ? parseInt(match[2], 10) : 0
20999
- };
21000
- })
21599
+ ${renderSection("health-completeness", "Completeness", report.completeness)}
21600
+
21601
+ ${collapsibleSection(
21602
+ "health-completeness-overview",
21603
+ "Completeness Overview",
21604
+ buildHealthGauge(
21605
+ metrics ? Object.entries(metrics.completeness).map(([name, cat]) => ({
21606
+ name: name.replace(/\b\w/g, (c) => c.toUpperCase()),
21607
+ complete: cat.complete,
21608
+ total: cat.total
21609
+ })) : report.completeness.map((c) => {
21610
+ const match = c.summary.match(/(\d+)\s*\/\s*(\d+)/);
21611
+ return {
21612
+ name: c.name,
21613
+ complete: match ? parseInt(match[1], 10) : 0,
21614
+ total: match ? parseInt(match[2], 10) : 0
21615
+ };
21616
+ })
21617
+ ),
21618
+ { titleClass: "health-section-title" }
21001
21619
  )}
21002
21620
 
21003
- ${renderSection("Process", report.process)}
21004
-
21005
- <div class="health-section-title">Process Summary</div>
21006
- ${metrics ? buildStatusPie("Process Health", {
21007
- Stale: metrics.process.stale.length,
21008
- "Aging Actions": metrics.process.agingActions.length,
21009
- Healthy: Math.max(
21010
- 0,
21011
- (metrics.completeness ? Object.values(metrics.completeness).reduce((sum, c) => sum + c.total, 0) : 0) - metrics.process.stale.length - metrics.process.agingActions.length
21012
- )
21013
- }) : ""}
21621
+ ${renderSection("health-process", "Process", report.process)}
21622
+
21623
+ ${collapsibleSection(
21624
+ "health-process-summary",
21625
+ "Process Summary",
21626
+ metrics ? buildStatusPie("Process Health", {
21627
+ Stale: metrics.process.stale.length,
21628
+ "Aging Actions": metrics.process.agingActions.length,
21629
+ Healthy: Math.max(
21630
+ 0,
21631
+ (metrics.completeness ? Object.values(metrics.completeness).reduce((sum, c) => sum + c.total, 0) : 0) - metrics.process.stale.length - metrics.process.agingActions.length
21632
+ )
21633
+ }) : "",
21634
+ { titleClass: "health-section-title" }
21635
+ )}
21014
21636
  `;
21015
21637
  }
21016
21638
 
@@ -21068,7 +21690,7 @@ function timelinePage(diagrams) {
21068
21690
  return `
21069
21691
  <div class="page-header">
21070
21692
  <h2>Project Timeline</h2>
21071
- <div class="subtitle">Sprint schedule with linked epics</div>
21693
+ <div class="subtitle">Epic timeline across sprints</div>
21072
21694
  </div>
21073
21695
 
21074
21696
  ${buildTimelineGantt(diagrams)}
@@ -21096,9 +21718,10 @@ function upcomingPage(data) {
21096
21718
  const hasActions = data.dueSoonActions.length > 0;
21097
21719
  const hasSprintTasks = data.dueSoonSprintTasks.length > 0;
21098
21720
  const hasTrending = data.trending.length > 0;
21099
- const actionsTable = hasActions ? `
21100
- <h3 class="section-title">Due Soon \u2014 Actions</h3>
21101
- <div class="table-wrap">
21721
+ const actionsTable = hasActions ? collapsibleSection(
21722
+ "upcoming-actions",
21723
+ "Due Soon \u2014 Actions",
21724
+ `<div class="table-wrap">
21102
21725
  <table>
21103
21726
  <thead>
21104
21727
  <tr>
@@ -21113,7 +21736,7 @@ function upcomingPage(data) {
21113
21736
  </thead>
21114
21737
  <tbody>
21115
21738
  ${data.dueSoonActions.map(
21116
- (a) => `
21739
+ (a) => `
21117
21740
  <tr class="${urgencyRowClass(a.urgency)}">
21118
21741
  <td><a href="/docs/action/${escapeHtml(a.id)}">${escapeHtml(a.id)}</a></td>
21119
21742
  <td>${escapeHtml(a.title)}</td>
@@ -21123,13 +21746,16 @@ function upcomingPage(data) {
21123
21746
  <td>${urgencyBadge(a.urgency)}</td>
21124
21747
  <td>${a.relatedTaskCount > 0 ? a.relatedTaskCount : "\u2014"}</td>
21125
21748
  </tr>`
21126
- ).join("")}
21749
+ ).join("")}
21127
21750
  </tbody>
21128
21751
  </table>
21129
- </div>` : "";
21130
- const sprintTasksTable = hasSprintTasks ? `
21131
- <h3 class="section-title">Due Soon \u2014 Sprint Tasks</h3>
21132
- <div class="table-wrap">
21752
+ </div>`,
21753
+ { titleTag: "h3" }
21754
+ ) : "";
21755
+ const sprintTasksTable = hasSprintTasks ? collapsibleSection(
21756
+ "upcoming-sprint-tasks",
21757
+ "Due Soon \u2014 Sprint Tasks",
21758
+ `<div class="table-wrap">
21133
21759
  <table>
21134
21760
  <thead>
21135
21761
  <tr>
@@ -21143,7 +21769,7 @@ function upcomingPage(data) {
21143
21769
  </thead>
21144
21770
  <tbody>
21145
21771
  ${data.dueSoonSprintTasks.map(
21146
- (t) => `
21772
+ (t) => `
21147
21773
  <tr class="${urgencyRowClass(t.urgency)}">
21148
21774
  <td><a href="/docs/task/${escapeHtml(t.id)}">${escapeHtml(t.id)}</a></td>
21149
21775
  <td>${escapeHtml(t.title)}</td>
@@ -21152,13 +21778,16 @@ function upcomingPage(data) {
21152
21778
  <td>${formatDate(t.sprintEndDate)}</td>
21153
21779
  <td>${urgencyBadge(t.urgency)}</td>
21154
21780
  </tr>`
21155
- ).join("")}
21781
+ ).join("")}
21156
21782
  </tbody>
21157
21783
  </table>
21158
- </div>` : "";
21159
- const trendingTable = hasTrending ? `
21160
- <h3 class="section-title">Trending</h3>
21161
- <div class="table-wrap">
21784
+ </div>`,
21785
+ { titleTag: "h3" }
21786
+ ) : "";
21787
+ const trendingTable = hasTrending ? collapsibleSection(
21788
+ "upcoming-trending",
21789
+ "Trending",
21790
+ `<div class="table-wrap">
21162
21791
  <table>
21163
21792
  <thead>
21164
21793
  <tr>
@@ -21173,7 +21802,7 @@ function upcomingPage(data) {
21173
21802
  </thead>
21174
21803
  <tbody>
21175
21804
  ${data.trending.map(
21176
- (t, i) => `
21805
+ (t, i) => `
21177
21806
  <tr>
21178
21807
  <td><span class="trending-rank">${i + 1}</span></td>
21179
21808
  <td><a href="/docs/${escapeHtml(t.type)}/${escapeHtml(t.id)}">${escapeHtml(t.id)}</a></td>
@@ -21183,10 +21812,12 @@ function upcomingPage(data) {
21183
21812
  <td><span class="trending-score">${t.score}</span></td>
21184
21813
  <td>${t.signals.map((s) => `<span class="signal-tag">${escapeHtml(s.factor)} +${s.points}</span>`).join(" ")}</td>
21185
21814
  </tr>`
21186
- ).join("")}
21815
+ ).join("")}
21187
21816
  </tbody>
21188
21817
  </table>
21189
- </div>` : "";
21818
+ </div>`,
21819
+ { titleTag: "h3" }
21820
+ ) : "";
21190
21821
  const emptyState = !hasActions && !hasSprintTasks && !hasTrending ? '<div class="empty"><p>No upcoming items or trending activity found.</p></div>' : "";
21191
21822
  return `
21192
21823
  <div class="page-header">
@@ -21200,7 +21831,199 @@ function upcomingPage(data) {
21200
21831
  `;
21201
21832
  }
21202
21833
 
21834
+ // src/web/templates/pages/sprint-summary.ts
21835
+ function progressBar(pct) {
21836
+ return `<div class="sprint-progress-bar">
21837
+ <div class="sprint-progress-fill" style="width: ${pct}%"></div>
21838
+ <span class="sprint-progress-label">${pct}%</span>
21839
+ </div>`;
21840
+ }
21841
+ function sprintSummaryPage(data, cached2) {
21842
+ if (!data) {
21843
+ return `
21844
+ <div class="page-header">
21845
+ <h2>Sprint Summary</h2>
21846
+ <div class="subtitle">AI-powered sprint narrative</div>
21847
+ </div>
21848
+ <div class="empty">
21849
+ <h3>No Active Sprint</h3>
21850
+ <p>No active sprint found. Create a sprint and set its status to "active" to see the summary.</p>
21851
+ </div>`;
21852
+ }
21853
+ const statsCards = `
21854
+ <div class="cards">
21855
+ <div class="card">
21856
+ <div class="card-label">Completion</div>
21857
+ <div class="card-value">${data.workItems.completionPct}%</div>
21858
+ <div class="card-sub">${data.workItems.done} / ${data.workItems.total} items done</div>
21859
+ </div>
21860
+ <div class="card">
21861
+ <div class="card-label">Days Remaining</div>
21862
+ <div class="card-value">${data.timeline.daysRemaining}</div>
21863
+ <div class="card-sub">${data.timeline.daysElapsed} of ${data.timeline.totalDays} elapsed</div>
21864
+ </div>
21865
+ <div class="card">
21866
+ <div class="card-label">Epics</div>
21867
+ <div class="card-value">${data.linkedEpics.length}</div>
21868
+ <div class="card-sub">linked to sprint</div>
21869
+ </div>
21870
+ <div class="card">
21871
+ <div class="card-label">Blockers</div>
21872
+ <div class="card-value${data.blockers.length > 0 ? " priority-high" : ""}">${data.blockers.length}</div>
21873
+ <div class="card-sub">${data.openActions.length} open actions</div>
21874
+ </div>
21875
+ </div>`;
21876
+ const epicsTable = data.linkedEpics.length > 0 ? collapsibleSection(
21877
+ "ss-epics",
21878
+ "Linked Epics",
21879
+ `<div class="table-wrap">
21880
+ <table>
21881
+ <thead>
21882
+ <tr><th>ID</th><th>Title</th><th>Status</th><th>Tasks</th></tr>
21883
+ </thead>
21884
+ <tbody>
21885
+ ${data.linkedEpics.map((e) => `
21886
+ <tr>
21887
+ <td><a href="/docs/epic/${escapeHtml(e.id)}">${escapeHtml(e.id)}</a></td>
21888
+ <td>${escapeHtml(e.title)}</td>
21889
+ <td>${statusBadge(e.status)}</td>
21890
+ <td>${e.tasksDone} / ${e.tasksTotal}</td>
21891
+ </tr>`).join("")}
21892
+ </tbody>
21893
+ </table>
21894
+ </div>`,
21895
+ { titleTag: "h3" }
21896
+ ) : "";
21897
+ const workItemsSection = data.workItems.total > 0 ? collapsibleSection(
21898
+ "ss-work-items",
21899
+ "Work Items",
21900
+ `<div class="table-wrap">
21901
+ <table>
21902
+ <thead>
21903
+ <tr><th>ID</th><th>Title</th><th>Type</th><th>Status</th></tr>
21904
+ </thead>
21905
+ <tbody>
21906
+ ${data.workItems.items.map((w) => `
21907
+ <tr>
21908
+ <td><a href="/docs/${escapeHtml(w.type)}/${escapeHtml(w.id)}">${escapeHtml(w.id)}</a></td>
21909
+ <td>${escapeHtml(w.title)}</td>
21910
+ <td>${escapeHtml(typeLabel(w.type))}</td>
21911
+ <td>${statusBadge(w.status)}</td>
21912
+ </tr>`).join("")}
21913
+ </tbody>
21914
+ </table>
21915
+ </div>`,
21916
+ { titleTag: "h3", defaultCollapsed: true }
21917
+ ) : "";
21918
+ const activitySection = data.artifacts.length > 0 ? collapsibleSection(
21919
+ "ss-activity",
21920
+ "Recent Activity",
21921
+ `<div class="table-wrap">
21922
+ <table>
21923
+ <thead>
21924
+ <tr><th>Date</th><th>ID</th><th>Title</th><th>Type</th><th>Action</th></tr>
21925
+ </thead>
21926
+ <tbody>
21927
+ ${data.artifacts.slice(0, 15).map((a) => `
21928
+ <tr>
21929
+ <td>${formatDate(a.date)}</td>
21930
+ <td><a href="/docs/${escapeHtml(a.type)}/${escapeHtml(a.id)}">${escapeHtml(a.id)}</a></td>
21931
+ <td>${escapeHtml(a.title)}</td>
21932
+ <td>${escapeHtml(typeLabel(a.type))}</td>
21933
+ <td>${escapeHtml(a.action)}</td>
21934
+ </tr>`).join("")}
21935
+ </tbody>
21936
+ </table>
21937
+ </div>`,
21938
+ { titleTag: "h3", defaultCollapsed: true }
21939
+ ) : "";
21940
+ const meetingsSection = data.meetings.length > 0 ? collapsibleSection(
21941
+ "ss-meetings",
21942
+ `Meetings (${data.meetings.length})`,
21943
+ `<div class="table-wrap">
21944
+ <table>
21945
+ <thead>
21946
+ <tr><th>Date</th><th>ID</th><th>Title</th></tr>
21947
+ </thead>
21948
+ <tbody>
21949
+ ${data.meetings.map((m) => `
21950
+ <tr>
21951
+ <td>${formatDate(m.date)}</td>
21952
+ <td><a href="/docs/meeting/${escapeHtml(m.id)}">${escapeHtml(m.id)}</a></td>
21953
+ <td>${escapeHtml(m.title)}</td>
21954
+ </tr>`).join("")}
21955
+ </tbody>
21956
+ </table>
21957
+ </div>`,
21958
+ { titleTag: "h3", defaultCollapsed: true }
21959
+ ) : "";
21960
+ const goalHtml = data.sprint.goal ? `<div class="sprint-goal"><strong>Goal:</strong> ${escapeHtml(data.sprint.goal)}</div>` : "";
21961
+ const dateRange = data.sprint.startDate && data.sprint.endDate ? `<span class="text-dim">${formatDate(data.sprint.startDate)} \u2014 ${formatDate(data.sprint.endDate)}</span>` : "";
21962
+ return `
21963
+ <div class="page-header">
21964
+ <h2>${escapeHtml(data.sprint.id)} \u2014 ${escapeHtml(data.sprint.title)} ${statusBadge(data.sprint.status)}</h2>
21965
+ <div class="subtitle">Sprint Summary ${dateRange}</div>
21966
+ </div>
21967
+ ${goalHtml}
21968
+ ${progressBar(data.timeline.percentComplete)}
21969
+ ${statsCards}
21970
+ ${epicsTable}
21971
+ ${workItemsSection}
21972
+ ${activitySection}
21973
+ ${meetingsSection}
21974
+
21975
+ <div class="sprint-ai-section">
21976
+ <h3>AI Summary</h3>
21977
+ ${cached2 ? `<p class="text-dim">Generated ${formatDate(cached2.generatedAt)} at ${cached2.generatedAt.slice(11, 16)} UTC</p>` : `<p class="text-dim">Generate a narrative summary of this sprint's progress, risks, and projections.</p>`}
21978
+ <button class="sprint-generate-btn" onclick="generateSummary()" id="generate-btn">${cached2 ? "Regenerate" : "Generate AI Summary"}</button>
21979
+ <div id="summary-loading" class="sprint-loading" style="display:none">
21980
+ <div class="sprint-spinner"></div>
21981
+ <span>Generating summary...</span>
21982
+ </div>
21983
+ <div id="summary-error" class="sprint-error" style="display:none"></div>
21984
+ <div id="summary-content" class="detail-content"${cached2 ? "" : ' style="display:none"'}>${cached2 ? cached2.html : ""}</div>
21985
+ </div>
21986
+
21987
+ <script>
21988
+ async function generateSummary() {
21989
+ var btn = document.getElementById('generate-btn');
21990
+ var loading = document.getElementById('summary-loading');
21991
+ var errorEl = document.getElementById('summary-error');
21992
+ var content = document.getElementById('summary-content');
21993
+
21994
+ btn.disabled = true;
21995
+ btn.style.display = 'none';
21996
+ loading.style.display = 'flex';
21997
+ errorEl.style.display = 'none';
21998
+ content.style.display = 'none';
21999
+
22000
+ try {
22001
+ var res = await fetch('/api/sprint-summary', {
22002
+ method: 'POST',
22003
+ headers: { 'Content-Type': 'application/json' },
22004
+ body: JSON.stringify({ sprintId: '${escapeHtml(data.sprint.id)}' })
22005
+ });
22006
+ var json = await res.json();
22007
+ if (!res.ok) throw new Error(json.error || 'Failed to generate summary');
22008
+ loading.style.display = 'none';
22009
+ content.innerHTML = json.html;
22010
+ content.style.display = 'block';
22011
+ btn.textContent = 'Regenerate';
22012
+ btn.style.display = '';
22013
+ btn.disabled = false;
22014
+ } catch (e) {
22015
+ loading.style.display = 'none';
22016
+ errorEl.textContent = e.message;
22017
+ errorEl.style.display = 'block';
22018
+ btn.style.display = '';
22019
+ btn.disabled = false;
22020
+ }
22021
+ }
22022
+ </script>`;
22023
+ }
22024
+
21203
22025
  // src/web/router.ts
22026
+ var sprintSummaryCache = /* @__PURE__ */ new Map();
21204
22027
  function handleRequest(req, res, store, projectName, navGroups) {
21205
22028
  const parsed = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);
21206
22029
  const pathname = parsed.pathname;
@@ -21246,6 +22069,42 @@ function handleRequest(req, res, store, projectName, navGroups) {
21246
22069
  respond(res, layout({ title: "Upcoming", activePath: "/upcoming", projectName, navGroups }, body));
21247
22070
  return;
21248
22071
  }
22072
+ if (pathname === "/sprint-summary" && req.method === "GET") {
22073
+ const sprintId = parsed.searchParams.get("sprint") ?? void 0;
22074
+ const data = getSprintSummaryData(store, sprintId);
22075
+ const cached2 = data ? sprintSummaryCache.get(data.sprint.id) : void 0;
22076
+ const body = sprintSummaryPage(data, cached2 ? { html: cached2.html, generatedAt: cached2.generatedAt } : void 0);
22077
+ respond(res, layout({ title: "Sprint Summary", activePath: "/sprint-summary", projectName, navGroups }, body));
22078
+ return;
22079
+ }
22080
+ if (pathname === "/api/sprint-summary" && req.method === "POST") {
22081
+ let bodyStr = "";
22082
+ req.on("data", (chunk) => {
22083
+ bodyStr += chunk;
22084
+ });
22085
+ req.on("end", async () => {
22086
+ try {
22087
+ const { sprintId } = JSON.parse(bodyStr || "{}");
22088
+ const data = getSprintSummaryData(store, sprintId);
22089
+ if (!data) {
22090
+ res.writeHead(404, { "Content-Type": "application/json" });
22091
+ res.end(JSON.stringify({ error: "Sprint not found" }));
22092
+ return;
22093
+ }
22094
+ const summary = await generateSprintSummary(data);
22095
+ const html = renderMarkdown(summary);
22096
+ const generatedAt = (/* @__PURE__ */ new Date()).toISOString();
22097
+ sprintSummaryCache.set(data.sprint.id, { html, generatedAt });
22098
+ res.writeHead(200, { "Content-Type": "application/json" });
22099
+ res.end(JSON.stringify({ summary, html, generatedAt }));
22100
+ } catch (err) {
22101
+ console.error("[marvin web] Sprint summary generation error:", err);
22102
+ res.writeHead(500, { "Content-Type": "application/json" });
22103
+ res.end(JSON.stringify({ error: "Failed to generate summary" }));
22104
+ }
22105
+ });
22106
+ return;
22107
+ }
21249
22108
  const boardMatch = pathname.match(/^\/board(?:\/([^/]+))?$/);
21250
22109
  if (boardMatch) {
21251
22110
  const type = boardMatch[1];
@@ -21489,6 +22348,24 @@ function createWebTools(store, projectName, navGroups) {
21489
22348
  };
21490
22349
  },
21491
22350
  { annotations: { readOnlyHint: true } }
22351
+ ),
22352
+ tool22(
22353
+ "get_dashboard_sprint_summary",
22354
+ "Get sprint summary data for the active sprint or a specific sprint. Returns structured data about progress, epics, work items, meetings, and blockers. Works without the web server running.",
22355
+ {
22356
+ sprint: external_exports.string().optional().describe("Sprint ID (e.g. 'SP-001'). Omit for the active sprint.")
22357
+ },
22358
+ async (args) => {
22359
+ const data = getSprintSummaryData(store, args.sprint);
22360
+ if (!data) {
22361
+ const msg = args.sprint ? `Sprint ${args.sprint} not found.` : "No active sprint found.";
22362
+ return { content: [{ type: "text", text: msg }], isError: true };
22363
+ }
22364
+ return {
22365
+ content: [{ type: "text", text: JSON.stringify(data, null, 2) }]
22366
+ };
22367
+ },
22368
+ { annotations: { readOnlyHint: true } }
21492
22369
  )
21493
22370
  ];
21494
22371
  }
@@ -21535,7 +22412,7 @@ async function runSkillAction(action, userPrompt, context) {
21535
22412
  try {
21536
22413
  const mcpServer = createMarvinMcpServer(context.store);
21537
22414
  const allowedTools = action.allowGovernanceTools !== false ? GOVERNANCE_TOOL_NAMES : [];
21538
- const conversation = query({
22415
+ const conversation = query2({
21539
22416
  prompt: userPrompt,
21540
22417
  options: {
21541
22418
  systemPrompt: action.systemPrompt,