mrvn-cli 0.4.4 → 0.4.6

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.
package/dist/index.js CHANGED
@@ -15019,132 +15019,22 @@ function generateFeatureTags(features) {
15019
15019
  return features.map((id) => `feature:${id}`);
15020
15020
  }
15021
15021
 
15022
- // src/reports/gar/collector.ts
15023
- function collectGarMetrics(store) {
15024
- const allActions = store.list({ type: "action" });
15025
- const openActions = allActions.filter((d) => d.frontmatter.status === "open");
15026
- const doneActions = allActions.filter((d) => d.frontmatter.status === "done");
15027
- const allDocs = store.list();
15028
- const blockedItems = allDocs.filter(
15029
- (d) => d.frontmatter.tags?.includes("blocked")
15030
- );
15031
- const tagOverdueItems = allDocs.filter(
15032
- (d) => d.frontmatter.tags?.includes("overdue")
15033
- );
15034
- const today = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
15035
- const dateOverdueActions = openActions.filter((d) => {
15036
- const dueDate = d.frontmatter.dueDate;
15037
- return typeof dueDate === "string" && dueDate < today;
15038
- });
15039
- const overdueItems = [...tagOverdueItems, ...dateOverdueActions].filter(
15040
- (d, i, arr) => arr.findIndex((x) => x.frontmatter.id === d.frontmatter.id) === i
15041
- );
15042
- const openQuestions = store.list({ type: "question", status: "open" });
15043
- const riskItems = allDocs.filter(
15044
- (d) => d.frontmatter.tags?.includes("risk") && d.frontmatter.status !== "done" && d.frontmatter.status !== "closed"
15045
- );
15046
- const unownedActions = openActions.filter((d) => !d.frontmatter.owner);
15047
- const total = allActions.length;
15048
- const done = doneActions.length;
15049
- const completionPct = total > 0 ? Math.round(done / total * 100) : 100;
15050
- const scheduleItems = [
15051
- ...blockedItems,
15052
- ...overdueItems
15053
- ].filter(
15054
- (d, i, arr) => arr.findIndex((x) => x.frontmatter.id === d.frontmatter.id) === i
15055
- ).map((d) => ({ id: d.frontmatter.id, title: d.frontmatter.title }));
15056
- const qualityItems = [
15057
- ...riskItems,
15058
- ...openQuestions
15059
- ].filter(
15060
- (d, i, arr) => arr.findIndex((x) => x.frontmatter.id === d.frontmatter.id) === i
15061
- ).map((d) => ({ id: d.frontmatter.id, title: d.frontmatter.title }));
15062
- const resourceItems = unownedActions.map((d) => ({
15063
- id: d.frontmatter.id,
15064
- title: d.frontmatter.title
15065
- }));
15066
- return {
15067
- scope: {
15068
- total,
15069
- open: openActions.length,
15070
- done,
15071
- completionPct
15072
- },
15073
- schedule: {
15074
- blocked: blockedItems.length,
15075
- overdue: overdueItems.length,
15076
- items: scheduleItems
15077
- },
15078
- quality: {
15079
- risks: riskItems.length,
15080
- openQuestions: openQuestions.length,
15081
- items: qualityItems
15082
- },
15083
- resources: {
15084
- unowned: unownedActions.length,
15085
- items: resourceItems
15022
+ // src/plugins/builtin/tools/task-utils.ts
15023
+ function normalizeLinkedEpics(value) {
15024
+ if (value === void 0 || value === null) return [];
15025
+ if (typeof value === "string") {
15026
+ try {
15027
+ const parsed = JSON.parse(value);
15028
+ if (Array.isArray(parsed)) return parsed.filter((v) => typeof v === "string");
15029
+ } catch {
15086
15030
  }
15087
- };
15088
- }
15089
-
15090
- // src/reports/gar/evaluator.ts
15091
- function worstStatus(statuses) {
15092
- if (statuses.includes("red")) return "red";
15093
- if (statuses.includes("amber")) return "amber";
15094
- return "green";
15031
+ return [value];
15032
+ }
15033
+ if (Array.isArray(value)) return value.filter((v) => typeof v === "string");
15034
+ return [];
15095
15035
  }
15096
- function evaluateGar(projectName, metrics) {
15097
- const areas = [];
15098
- const scopePct = metrics.scope.completionPct;
15099
- const scopeStatus = scopePct >= 70 ? "green" : scopePct >= 40 ? "amber" : "red";
15100
- areas.push({
15101
- name: "Scope",
15102
- status: scopeStatus,
15103
- summary: `${scopePct}% complete (${metrics.scope.done}/${metrics.scope.total})`,
15104
- items: []
15105
- });
15106
- const scheduleCount = metrics.schedule.blocked + metrics.schedule.overdue;
15107
- const scheduleStatus = scheduleCount === 0 ? "green" : scheduleCount <= 2 ? "amber" : "red";
15108
- const scheduleParts = [];
15109
- if (metrics.schedule.blocked > 0)
15110
- scheduleParts.push(`${metrics.schedule.blocked} blocked`);
15111
- if (metrics.schedule.overdue > 0)
15112
- scheduleParts.push(`${metrics.schedule.overdue} overdue`);
15113
- areas.push({
15114
- name: "Schedule",
15115
- status: scheduleStatus,
15116
- summary: scheduleParts.length > 0 ? scheduleParts.join(", ") : "on track",
15117
- items: metrics.schedule.items
15118
- });
15119
- const qualityCount = metrics.quality.risks + metrics.quality.openQuestions;
15120
- const qualityStatus = qualityCount === 0 ? "green" : qualityCount <= 2 ? "amber" : "red";
15121
- const qualityParts = [];
15122
- if (metrics.quality.risks > 0)
15123
- qualityParts.push(`${metrics.quality.risks} risk(s)`);
15124
- if (metrics.quality.openQuestions > 0)
15125
- qualityParts.push(`${metrics.quality.openQuestions} open question(s)`);
15126
- areas.push({
15127
- name: "Quality",
15128
- status: qualityStatus,
15129
- summary: qualityParts.length > 0 ? qualityParts.join(", ") : "no issues",
15130
- items: metrics.quality.items
15131
- });
15132
- const resourceCount = metrics.resources.unowned;
15133
- const resourceStatus = resourceCount === 0 ? "green" : resourceCount <= 2 ? "amber" : "red";
15134
- areas.push({
15135
- name: "Resources",
15136
- status: resourceStatus,
15137
- summary: resourceCount > 0 ? `${resourceCount} unowned action(s)` : "all assigned",
15138
- items: metrics.resources.items
15139
- });
15140
- const overall = worstStatus(areas.map((a) => a.status));
15141
- return {
15142
- projectName,
15143
- generatedAt: (/* @__PURE__ */ new Date()).toISOString().slice(0, 10),
15144
- overall,
15145
- areas,
15146
- metrics
15147
- };
15036
+ function generateEpicTags(epics) {
15037
+ return epics.map((id) => `epic:${id}`);
15148
15038
  }
15149
15039
 
15150
15040
  // src/reports/health/collector.ts
@@ -15281,8 +15171,136 @@ function collectProcess(store) {
15281
15171
  }
15282
15172
  function collectHealthMetrics(store) {
15283
15173
  return {
15284
- completeness: collectCompleteness(store),
15285
- process: collectProcess(store)
15174
+ completeness: collectCompleteness(store),
15175
+ process: collectProcess(store)
15176
+ };
15177
+ }
15178
+
15179
+ // src/reports/gar/collector.ts
15180
+ function collectGarMetrics(store) {
15181
+ const allActions = store.list({ type: "action" });
15182
+ const openActions = allActions.filter((d) => d.frontmatter.status === "open");
15183
+ const doneActions = allActions.filter((d) => d.frontmatter.status === "done");
15184
+ const allDocs = store.list();
15185
+ const blockedItems = allDocs.filter(
15186
+ (d) => d.frontmatter.tags?.includes("blocked")
15187
+ );
15188
+ const tagOverdueItems = allDocs.filter(
15189
+ (d) => d.frontmatter.tags?.includes("overdue")
15190
+ );
15191
+ const today = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
15192
+ const dateOverdueActions = openActions.filter((d) => {
15193
+ const dueDate = d.frontmatter.dueDate;
15194
+ return typeof dueDate === "string" && dueDate < today;
15195
+ });
15196
+ const overdueItems = [...tagOverdueItems, ...dateOverdueActions].filter(
15197
+ (d, i, arr) => arr.findIndex((x) => x.frontmatter.id === d.frontmatter.id) === i
15198
+ );
15199
+ const openQuestions = store.list({ type: "question", status: "open" });
15200
+ const riskItems = allDocs.filter(
15201
+ (d) => d.frontmatter.tags?.includes("risk") && d.frontmatter.status !== "done" && d.frontmatter.status !== "closed"
15202
+ );
15203
+ const unownedActions = openActions.filter((d) => !d.frontmatter.owner);
15204
+ const total = allActions.length;
15205
+ const done = doneActions.length;
15206
+ const completionPct = total > 0 ? Math.round(done / total * 100) : 100;
15207
+ const scheduleItems = [
15208
+ ...blockedItems,
15209
+ ...overdueItems
15210
+ ].filter(
15211
+ (d, i, arr) => arr.findIndex((x) => x.frontmatter.id === d.frontmatter.id) === i
15212
+ ).map((d) => ({ id: d.frontmatter.id, title: d.frontmatter.title }));
15213
+ const qualityItems = [
15214
+ ...riskItems,
15215
+ ...openQuestions
15216
+ ].filter(
15217
+ (d, i, arr) => arr.findIndex((x) => x.frontmatter.id === d.frontmatter.id) === i
15218
+ ).map((d) => ({ id: d.frontmatter.id, title: d.frontmatter.title }));
15219
+ const resourceItems = unownedActions.map((d) => ({
15220
+ id: d.frontmatter.id,
15221
+ title: d.frontmatter.title
15222
+ }));
15223
+ return {
15224
+ scope: {
15225
+ total,
15226
+ open: openActions.length,
15227
+ done,
15228
+ completionPct
15229
+ },
15230
+ schedule: {
15231
+ blocked: blockedItems.length,
15232
+ overdue: overdueItems.length,
15233
+ items: scheduleItems
15234
+ },
15235
+ quality: {
15236
+ risks: riskItems.length,
15237
+ openQuestions: openQuestions.length,
15238
+ items: qualityItems
15239
+ },
15240
+ resources: {
15241
+ unowned: unownedActions.length,
15242
+ items: resourceItems
15243
+ }
15244
+ };
15245
+ }
15246
+
15247
+ // src/reports/gar/evaluator.ts
15248
+ function worstStatus(statuses) {
15249
+ if (statuses.includes("red")) return "red";
15250
+ if (statuses.includes("amber")) return "amber";
15251
+ return "green";
15252
+ }
15253
+ function evaluateGar(projectName, metrics) {
15254
+ const areas = [];
15255
+ const scopePct = metrics.scope.completionPct;
15256
+ const scopeStatus = scopePct >= 70 ? "green" : scopePct >= 40 ? "amber" : "red";
15257
+ areas.push({
15258
+ name: "Scope",
15259
+ status: scopeStatus,
15260
+ summary: `${scopePct}% complete (${metrics.scope.done}/${metrics.scope.total})`,
15261
+ items: []
15262
+ });
15263
+ const scheduleCount = metrics.schedule.blocked + metrics.schedule.overdue;
15264
+ const scheduleStatus = scheduleCount === 0 ? "green" : scheduleCount <= 2 ? "amber" : "red";
15265
+ const scheduleParts = [];
15266
+ if (metrics.schedule.blocked > 0)
15267
+ scheduleParts.push(`${metrics.schedule.blocked} blocked`);
15268
+ if (metrics.schedule.overdue > 0)
15269
+ scheduleParts.push(`${metrics.schedule.overdue} overdue`);
15270
+ areas.push({
15271
+ name: "Schedule",
15272
+ status: scheduleStatus,
15273
+ summary: scheduleParts.length > 0 ? scheduleParts.join(", ") : "on track",
15274
+ items: metrics.schedule.items
15275
+ });
15276
+ const qualityCount = metrics.quality.risks + metrics.quality.openQuestions;
15277
+ const qualityStatus = qualityCount === 0 ? "green" : qualityCount <= 2 ? "amber" : "red";
15278
+ const qualityParts = [];
15279
+ if (metrics.quality.risks > 0)
15280
+ qualityParts.push(`${metrics.quality.risks} risk(s)`);
15281
+ if (metrics.quality.openQuestions > 0)
15282
+ qualityParts.push(`${metrics.quality.openQuestions} open question(s)`);
15283
+ areas.push({
15284
+ name: "Quality",
15285
+ status: qualityStatus,
15286
+ summary: qualityParts.length > 0 ? qualityParts.join(", ") : "no issues",
15287
+ items: metrics.quality.items
15288
+ });
15289
+ const resourceCount = metrics.resources.unowned;
15290
+ const resourceStatus = resourceCount === 0 ? "green" : resourceCount <= 2 ? "amber" : "red";
15291
+ areas.push({
15292
+ name: "Resources",
15293
+ status: resourceStatus,
15294
+ summary: resourceCount > 0 ? `${resourceCount} unowned action(s)` : "all assigned",
15295
+ items: metrics.resources.items
15296
+ });
15297
+ const overall = worstStatus(areas.map((a) => a.status));
15298
+ return {
15299
+ projectName,
15300
+ generatedAt: (/* @__PURE__ */ new Date()).toISOString().slice(0, 10),
15301
+ overall,
15302
+ areas,
15303
+ metrics
15286
15304
  };
15287
15305
  }
15288
15306
 
@@ -15505,8 +15523,223 @@ function getDiagramData(store) {
15505
15523
  }
15506
15524
  return { sprints, epics, features, statusCounts };
15507
15525
  }
15526
+ function computeUrgency(dueDateStr, todayStr) {
15527
+ const due = new Date(dueDateStr).getTime();
15528
+ const today = new Date(todayStr).getTime();
15529
+ const diffDays = Math.floor((due - today) / 864e5);
15530
+ if (diffDays < 0) return "overdue";
15531
+ if (diffDays <= 3) return "due-3d";
15532
+ if (diffDays <= 7) return "due-7d";
15533
+ if (diffDays <= 14) return "upcoming";
15534
+ return "later";
15535
+ }
15536
+ var DONE_STATUSES = /* @__PURE__ */ new Set(["done", "closed", "resolved", "cancelled"]);
15537
+ function getUpcomingData(store) {
15538
+ const today = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
15539
+ const allDocs = store.list();
15540
+ const docById = /* @__PURE__ */ new Map();
15541
+ for (const doc of allDocs) {
15542
+ docById.set(doc.frontmatter.id, doc);
15543
+ }
15544
+ const actions = allDocs.filter(
15545
+ (d) => d.frontmatter.type === "action" && !DONE_STATUSES.has(d.frontmatter.status)
15546
+ );
15547
+ const actionsWithDue = actions.filter((d) => d.frontmatter.dueDate);
15548
+ const sprints = allDocs.filter((d) => d.frontmatter.type === "sprint");
15549
+ const epics = allDocs.filter((d) => d.frontmatter.type === "epic");
15550
+ const tasks = allDocs.filter((d) => d.frontmatter.type === "task");
15551
+ const epicToTasks = /* @__PURE__ */ new Map();
15552
+ for (const task of tasks) {
15553
+ const tags = task.frontmatter.tags ?? [];
15554
+ for (const tag of tags) {
15555
+ if (tag.startsWith("epic:")) {
15556
+ const epicId = tag.slice(5);
15557
+ if (!epicToTasks.has(epicId)) epicToTasks.set(epicId, []);
15558
+ epicToTasks.get(epicId).push(task);
15559
+ }
15560
+ }
15561
+ }
15562
+ function getSprintTasks(sprintDoc) {
15563
+ const linkedEpics = normalizeLinkedEpics(sprintDoc.frontmatter.linkedEpics);
15564
+ const result = [];
15565
+ for (const epicId of linkedEpics) {
15566
+ const epicTasks = epicToTasks.get(epicId) ?? [];
15567
+ result.push(...epicTasks);
15568
+ }
15569
+ return result;
15570
+ }
15571
+ function countRelatedTasks(actionDoc) {
15572
+ const actionTags = actionDoc.frontmatter.tags ?? [];
15573
+ const relatedTaskIds = /* @__PURE__ */ new Set();
15574
+ for (const tag of actionTags) {
15575
+ if (tag.startsWith("sprint:")) {
15576
+ const sprintId = tag.slice(7);
15577
+ const sprint = docById.get(sprintId);
15578
+ if (sprint) {
15579
+ const sprintTaskDocs = getSprintTasks(sprint);
15580
+ for (const t of sprintTaskDocs) relatedTaskIds.add(t.frontmatter.id);
15581
+ }
15582
+ }
15583
+ }
15584
+ return relatedTaskIds.size;
15585
+ }
15586
+ const dueSoonActions = actionsWithDue.map((d) => ({
15587
+ id: d.frontmatter.id,
15588
+ title: d.frontmatter.title,
15589
+ status: d.frontmatter.status,
15590
+ owner: d.frontmatter.owner,
15591
+ dueDate: d.frontmatter.dueDate,
15592
+ urgency: computeUrgency(d.frontmatter.dueDate, today),
15593
+ relatedTaskCount: countRelatedTasks(d)
15594
+ })).sort((a, b) => a.dueDate.localeCompare(b.dueDate));
15595
+ const todayMs = new Date(today).getTime();
15596
+ const fourteenDaysMs = 14 * 864e5;
15597
+ const nearSprints = sprints.filter((s) => {
15598
+ const endDate = s.frontmatter.endDate;
15599
+ if (!endDate) return false;
15600
+ const endMs = new Date(endDate).getTime();
15601
+ const diff = endMs - todayMs;
15602
+ return diff >= 0 && diff <= fourteenDaysMs;
15603
+ });
15604
+ const taskSprintMap = /* @__PURE__ */ new Map();
15605
+ for (const sprint of nearSprints) {
15606
+ const sprintEnd = sprint.frontmatter.endDate;
15607
+ const sprintTaskDocs = getSprintTasks(sprint);
15608
+ for (const task of sprintTaskDocs) {
15609
+ if (DONE_STATUSES.has(task.frontmatter.status)) continue;
15610
+ const existing = taskSprintMap.get(task.frontmatter.id);
15611
+ if (!existing || sprintEnd < existing.sprintEnd) {
15612
+ taskSprintMap.set(task.frontmatter.id, { task, sprint, sprintEnd });
15613
+ }
15614
+ }
15615
+ }
15616
+ const dueSoonSprintTasks = [...taskSprintMap.values()].map(({ task, sprint, sprintEnd }) => ({
15617
+ id: task.frontmatter.id,
15618
+ title: task.frontmatter.title,
15619
+ status: task.frontmatter.status,
15620
+ sprintId: sprint.frontmatter.id,
15621
+ sprintTitle: sprint.frontmatter.title,
15622
+ sprintEndDate: sprintEnd,
15623
+ urgency: computeUrgency(sprintEnd, today)
15624
+ })).sort((a, b) => a.sprintEndDate.localeCompare(b.sprintEndDate));
15625
+ const openItems = allDocs.filter(
15626
+ (d) => ["action", "question", "task"].includes(d.frontmatter.type) && !DONE_STATUSES.has(d.frontmatter.status)
15627
+ );
15628
+ const fourteenDaysAgo = new Date(todayMs - fourteenDaysMs).toISOString().slice(0, 10);
15629
+ const recentMeetings = allDocs.filter(
15630
+ (d) => d.frontmatter.type === "meeting" && (d.frontmatter.updated ?? d.frontmatter.created) >= fourteenDaysAgo
15631
+ );
15632
+ const crossRefCounts = /* @__PURE__ */ new Map();
15633
+ for (const doc of allDocs) {
15634
+ const content = doc.content ?? "";
15635
+ for (const item of openItems) {
15636
+ if (doc.frontmatter.id === item.frontmatter.id) continue;
15637
+ if (content.includes(item.frontmatter.id)) {
15638
+ crossRefCounts.set(
15639
+ item.frontmatter.id,
15640
+ (crossRefCounts.get(item.frontmatter.id) ?? 0) + 1
15641
+ );
15642
+ }
15643
+ }
15644
+ }
15645
+ const activeSprints = sprints.filter((s) => {
15646
+ const status = s.frontmatter.status;
15647
+ if (status === "active") return true;
15648
+ const startDate = s.frontmatter.startDate;
15649
+ if (!startDate) return false;
15650
+ const startMs = new Date(startDate).getTime();
15651
+ const diff = startMs - todayMs;
15652
+ return diff >= 0 && diff <= fourteenDaysMs;
15653
+ });
15654
+ const activeSprintIds = new Set(activeSprints.map((s) => s.frontmatter.id));
15655
+ const activeEpicIds = /* @__PURE__ */ new Set();
15656
+ for (const s of activeSprints) {
15657
+ for (const epicId of normalizeLinkedEpics(s.frontmatter.linkedEpics)) {
15658
+ activeEpicIds.add(epicId);
15659
+ }
15660
+ }
15661
+ const trending = openItems.map((doc) => {
15662
+ const signals = [];
15663
+ let score = 0;
15664
+ const updated = doc.frontmatter.updated ?? doc.frontmatter.created;
15665
+ const ageDays = daysBetween(updated, today);
15666
+ const recencyPts = Math.max(0, Math.round(20 * (1 - ageDays / 30)));
15667
+ if (recencyPts > 0) {
15668
+ signals.push({ factor: "recency", points: recencyPts });
15669
+ score += recencyPts;
15670
+ }
15671
+ const tags = doc.frontmatter.tags ?? [];
15672
+ const linkedToActiveSprint = tags.some(
15673
+ (t) => t.startsWith("sprint:") && activeSprintIds.has(t.slice(7))
15674
+ );
15675
+ const linkedToActiveEpic = tags.some(
15676
+ (t) => t.startsWith("epic:") && activeEpicIds.has(t.slice(5))
15677
+ );
15678
+ if (linkedToActiveSprint) {
15679
+ signals.push({ factor: "sprint proximity", points: 25 });
15680
+ score += 25;
15681
+ } else if (linkedToActiveEpic) {
15682
+ signals.push({ factor: "sprint proximity", points: 15 });
15683
+ score += 15;
15684
+ }
15685
+ const mentionCount = recentMeetings.filter(
15686
+ (m) => (m.content ?? "").includes(doc.frontmatter.id)
15687
+ ).length;
15688
+ if (mentionCount > 0) {
15689
+ const meetingPts = Math.min(15, mentionCount * 5);
15690
+ signals.push({ factor: "meeting mentions", points: meetingPts });
15691
+ score += meetingPts;
15692
+ }
15693
+ const priority = doc.frontmatter.priority?.toLowerCase();
15694
+ const priorityPts = priority === "critical" ? 15 : priority === "high" ? 10 : priority === "medium" ? 3 : 0;
15695
+ if (priorityPts > 0) {
15696
+ signals.push({ factor: "priority", points: priorityPts });
15697
+ score += priorityPts;
15698
+ }
15699
+ if (["action", "question"].includes(doc.frontmatter.type)) {
15700
+ const createdDays = daysBetween(doc.frontmatter.created, today);
15701
+ if (createdDays >= 14) {
15702
+ const agingPts = Math.min(10, Math.floor((createdDays - 14) / 7) * 3 + 5);
15703
+ signals.push({ factor: "aging", points: agingPts });
15704
+ score += agingPts;
15705
+ }
15706
+ }
15707
+ const refs = crossRefCounts.get(doc.frontmatter.id) ?? 0;
15708
+ if (refs > 0) {
15709
+ const crossRefPts = Math.min(15, refs * 5);
15710
+ signals.push({ factor: "cross-references", points: crossRefPts });
15711
+ score += crossRefPts;
15712
+ }
15713
+ return {
15714
+ id: doc.frontmatter.id,
15715
+ title: doc.frontmatter.title,
15716
+ type: doc.frontmatter.type,
15717
+ status: doc.frontmatter.status,
15718
+ score,
15719
+ signals
15720
+ };
15721
+ }).filter((item) => item.score > 0).sort((a, b) => b.score - a.score).slice(0, 15);
15722
+ return { dueSoonActions, dueSoonSprintTasks, trending };
15723
+ }
15508
15724
 
15509
15725
  // src/web/templates/layout.ts
15726
+ function collapsibleSection(sectionId, title, content, opts) {
15727
+ const tag = opts?.titleTag ?? "div";
15728
+ const cls = opts?.titleClass ?? "section-title";
15729
+ const collapsed = opts?.defaultCollapsed ? " collapsed" : "";
15730
+ return `
15731
+ <div class="collapsible${collapsed}" data-section-id="${escapeHtml(sectionId)}">
15732
+ <${tag} class="${cls} collapsible-header" onclick="toggleSection(this)">
15733
+ <svg class="collapsible-chevron" viewBox="0 0 16 16" width="14" height="14" fill="currentColor">
15734
+ <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"/>
15735
+ </svg>
15736
+ <span>${title}</span>
15737
+ </${tag}>
15738
+ <div class="collapsible-body">
15739
+ ${content}
15740
+ </div>
15741
+ </div>`;
15742
+ }
15510
15743
  function escapeHtml(str) {
15511
15744
  return str.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
15512
15745
  }
@@ -15628,6 +15861,7 @@ function inline(text) {
15628
15861
  function layout(opts, body) {
15629
15862
  const topItems = [
15630
15863
  { href: "/", label: "Overview" },
15864
+ { href: "/upcoming", label: "Upcoming" },
15631
15865
  { href: "/timeline", label: "Timeline" },
15632
15866
  { href: "/board", label: "Board" },
15633
15867
  { href: "/gar", label: "GAR Report" },
@@ -15673,6 +15907,32 @@ function layout(opts, body) {
15673
15907
  ${body}
15674
15908
  </main>
15675
15909
  </div>
15910
+ <script>
15911
+ function toggleSection(header) {
15912
+ var section = header.closest('.collapsible');
15913
+ if (!section) return;
15914
+ section.classList.toggle('collapsed');
15915
+ var id = section.getAttribute('data-section-id');
15916
+ if (id) {
15917
+ try {
15918
+ var state = JSON.parse(localStorage.getItem('marvin-collapsed') || '{}');
15919
+ state[id] = section.classList.contains('collapsed');
15920
+ localStorage.setItem('marvin-collapsed', JSON.stringify(state));
15921
+ } catch(e) {}
15922
+ }
15923
+ }
15924
+ // Restore collapsed state on load
15925
+ (function() {
15926
+ try {
15927
+ var state = JSON.parse(localStorage.getItem('marvin-collapsed') || '{}');
15928
+ document.querySelectorAll('.collapsible[data-section-id]').forEach(function(el) {
15929
+ var id = el.getAttribute('data-section-id');
15930
+ if (state[id] === true) el.classList.add('collapsed');
15931
+ else if (state[id] === false) el.classList.remove('collapsed');
15932
+ });
15933
+ } catch(e) {}
15934
+ })();
15935
+ </script>
15676
15936
  <script src="https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.min.js"></script>
15677
15937
  <script>mermaid.initialize({
15678
15938
  startOnLoad: true,
@@ -16496,13 +16756,60 @@ tr:hover td {
16496
16756
  white-space: nowrap;
16497
16757
  }
16498
16758
 
16759
+ .gantt-grid-line {
16760
+ position: absolute;
16761
+ top: 0;
16762
+ bottom: 0;
16763
+ width: 1px;
16764
+ background: var(--border);
16765
+ opacity: 0.35;
16766
+ }
16767
+
16768
+ .gantt-sprint-line {
16769
+ position: absolute;
16770
+ top: 0;
16771
+ bottom: 0;
16772
+ width: 1px;
16773
+ background: var(--text-dim);
16774
+ opacity: 0.3;
16775
+ }
16776
+
16499
16777
  .gantt-today {
16500
16778
  position: absolute;
16501
16779
  top: 0;
16502
16780
  bottom: 0;
16503
- width: 2px;
16781
+ width: 3px;
16504
16782
  background: var(--red);
16505
- opacity: 0.7;
16783
+ opacity: 0.8;
16784
+ border-radius: 1px;
16785
+ }
16786
+
16787
+ /* Sprint band in timeline */
16788
+ .gantt-sprint-band-row {
16789
+ border-bottom: 1px solid var(--border);
16790
+ margin-bottom: 0.25rem;
16791
+ }
16792
+
16793
+ .gantt-sprint-band {
16794
+ height: 32px;
16795
+ }
16796
+
16797
+ .gantt-sprint-block {
16798
+ position: absolute;
16799
+ top: 2px;
16800
+ bottom: 2px;
16801
+ background: var(--bg-hover);
16802
+ border: 1px solid var(--border);
16803
+ border-radius: 4px;
16804
+ font-size: 0.65rem;
16805
+ color: var(--text-dim);
16806
+ display: flex;
16807
+ align-items: center;
16808
+ justify-content: center;
16809
+ overflow: hidden;
16810
+ white-space: nowrap;
16811
+ text-overflow: ellipsis;
16812
+ padding: 0 0.4rem;
16506
16813
  }
16507
16814
 
16508
16815
  /* Pie chart color overrides */
@@ -16514,6 +16821,90 @@ tr:hover td {
16514
16821
  fill: var(--bg) !important;
16515
16822
  font-weight: 600;
16516
16823
  }
16824
+
16825
+ /* Urgency row indicators */
16826
+ .urgency-row-overdue { border-left: 3px solid var(--red); }
16827
+ .urgency-row-due-3d { border-left: 3px solid var(--amber); }
16828
+ .urgency-row-due-7d { border-left: 3px solid #e2a308; }
16829
+
16830
+ /* Urgency badge pills */
16831
+ .urgency-badge-overdue { background: rgba(248, 113, 113, 0.15); color: var(--red); }
16832
+ .urgency-badge-due-3d { background: rgba(251, 191, 36, 0.15); color: var(--amber); }
16833
+ .urgency-badge-due-7d { background: rgba(226, 163, 8, 0.15); color: #e2a308; }
16834
+ .urgency-badge-upcoming { background: rgba(108, 140, 255, 0.15); color: var(--accent); }
16835
+ .urgency-badge-later { background: rgba(139, 143, 164, 0.1); color: var(--text-dim); }
16836
+
16837
+ /* Trending */
16838
+ .trending-rank {
16839
+ display: inline-flex;
16840
+ align-items: center;
16841
+ justify-content: center;
16842
+ width: 24px;
16843
+ height: 24px;
16844
+ border-radius: 50%;
16845
+ background: var(--bg-hover);
16846
+ font-size: 0.75rem;
16847
+ font-weight: 600;
16848
+ color: var(--text-dim);
16849
+ }
16850
+
16851
+ .trending-score {
16852
+ display: inline-block;
16853
+ padding: 0.15rem 0.6rem;
16854
+ border-radius: 999px;
16855
+ font-size: 0.7rem;
16856
+ font-weight: 700;
16857
+ background: rgba(108, 140, 255, 0.15);
16858
+ color: var(--accent);
16859
+ }
16860
+
16861
+ .signal-tag {
16862
+ display: inline-block;
16863
+ padding: 0.1rem 0.45rem;
16864
+ border-radius: 4px;
16865
+ font-size: 0.65rem;
16866
+ background: var(--bg-hover);
16867
+ color: var(--text-dim);
16868
+ margin-right: 0.25rem;
16869
+ margin-bottom: 0.15rem;
16870
+ white-space: nowrap;
16871
+ }
16872
+
16873
+ .text-dim { color: var(--text-dim); }
16874
+
16875
+ /* Collapsible sections */
16876
+ .collapsible-header {
16877
+ cursor: pointer;
16878
+ display: flex;
16879
+ align-items: center;
16880
+ gap: 0.4rem;
16881
+ user-select: none;
16882
+ }
16883
+
16884
+ .collapsible-header:hover {
16885
+ color: var(--accent);
16886
+ }
16887
+
16888
+ .collapsible-chevron {
16889
+ transition: transform 0.2s ease;
16890
+ flex-shrink: 0;
16891
+ }
16892
+
16893
+ .collapsible.collapsed .collapsible-chevron {
16894
+ transform: rotate(-90deg);
16895
+ }
16896
+
16897
+ .collapsible-body {
16898
+ overflow: hidden;
16899
+ max-height: 5000px;
16900
+ transition: max-height 0.3s ease, opacity 0.2s ease;
16901
+ opacity: 1;
16902
+ }
16903
+
16904
+ .collapsible.collapsed .collapsible-body {
16905
+ max-height: 0;
16906
+ opacity: 0;
16907
+ }
16517
16908
  `;
16518
16909
  }
16519
16910
 
@@ -16566,35 +16957,73 @@ function buildTimelineGantt(data, maxSprints = 6) {
16566
16957
  );
16567
16958
  tick += 7 * DAY;
16568
16959
  }
16960
+ const gridLines = [];
16961
+ let gridTick = timelineStart;
16962
+ const gridStartDay = new Date(gridTick).getDay();
16963
+ gridTick += (8 - gridStartDay) % 7 * DAY;
16964
+ while (gridTick <= timelineEnd) {
16965
+ gridLines.push(`<div class="gantt-grid-line" style="left:${pct(gridTick).toFixed(2)}%"></div>`);
16966
+ gridTick += 7 * DAY;
16967
+ }
16968
+ const sprintBoundaries = /* @__PURE__ */ new Set();
16969
+ for (const sprint of visibleSprints) {
16970
+ sprintBoundaries.add(toMs(sprint.startDate));
16971
+ sprintBoundaries.add(toMs(sprint.endDate));
16972
+ }
16973
+ const sprintLines = [...sprintBoundaries].map(
16974
+ (ms) => `<div class="gantt-sprint-line" style="left:${pct(ms).toFixed(2)}%"></div>`
16975
+ );
16569
16976
  const now = Date.now();
16570
16977
  let todayMarker = "";
16571
16978
  if (now >= timelineStart && now <= timelineEnd) {
16572
16979
  todayMarker = `<div class="gantt-today" style="left:${pct(now).toFixed(2)}%"></div>`;
16573
16980
  }
16574
- const rows = [];
16981
+ const sprintBlocks = visibleSprints.map((sprint) => {
16982
+ const sStart = toMs(sprint.startDate);
16983
+ const sEnd = toMs(sprint.endDate);
16984
+ const left = pct(sStart).toFixed(2);
16985
+ const width = (pct(sEnd) - pct(sStart)).toFixed(2);
16986
+ return `<div class="gantt-sprint-block" style="left:${left}%;width:${width}%">${sanitize(sprint.id, 20)}</div>`;
16987
+ }).join("");
16988
+ const sprintBandRow = `<div class="gantt-row gantt-sprint-band-row">
16989
+ <div class="gantt-label gantt-section-label">Sprints</div>
16990
+ <div class="gantt-track gantt-sprint-band">${sprintBlocks}</div>
16991
+ </div>`;
16992
+ const epicSpanMap = /* @__PURE__ */ new Map();
16575
16993
  for (const sprint of visibleSprints) {
16576
16994
  const sStart = toMs(sprint.startDate);
16577
16995
  const sEnd = toMs(sprint.endDate);
16578
- rows.push(`<div class="gantt-section-row">
16579
- <div class="gantt-label gantt-section-label">${sanitize(sprint.id + " " + sprint.title, 50)}</div>
16580
- <div class="gantt-track">
16581
- <div class="gantt-section-bg" style="left:${pct(sStart).toFixed(2)}%;width:${(pct(sEnd) - pct(sStart)).toFixed(2)}%"></div>
16582
- </div>
16583
- </div>`);
16584
- const linked = sprint.linkedEpics.map((eid) => epicMap.get(eid)).filter(Boolean);
16585
- const items = linked.length > 0 ? linked.map((e) => ({ label: sanitize(e.id + " " + e.title), status: e.status })) : [{ label: sanitize(sprint.title), status: sprint.status }];
16586
- for (const item of items) {
16587
- 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";
16588
- const left = pct(sStart).toFixed(2);
16589
- const width = (pct(sEnd) - pct(sStart)).toFixed(2);
16590
- rows.push(`<div class="gantt-row">
16591
- <div class="gantt-label">${item.label}</div>
16996
+ for (const eid of sprint.linkedEpics) {
16997
+ if (!epicMap.has(eid)) continue;
16998
+ const existing = epicSpanMap.get(eid);
16999
+ if (existing) {
17000
+ existing.startMs = Math.min(existing.startMs, sStart);
17001
+ existing.endMs = Math.max(existing.endMs, sEnd);
17002
+ } else {
17003
+ epicSpanMap.set(eid, { startMs: sStart, endMs: sEnd });
17004
+ }
17005
+ }
17006
+ }
17007
+ const sortedEpicIds = [...epicSpanMap.keys()].sort((a, b) => {
17008
+ const aSpan = epicSpanMap.get(a);
17009
+ const bSpan = epicSpanMap.get(b);
17010
+ if (aSpan.startMs !== bSpan.startMs) return aSpan.startMs - bSpan.startMs;
17011
+ return a.localeCompare(b);
17012
+ });
17013
+ const epicRows = sortedEpicIds.map((eid) => {
17014
+ const epic = epicMap.get(eid);
17015
+ const { startMs, endMs } = epicSpanMap.get(eid);
17016
+ 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";
17017
+ const left = pct(startMs).toFixed(2);
17018
+ const width = (pct(endMs) - pct(startMs)).toFixed(2);
17019
+ const label = sanitize(epic.id + " " + epic.title);
17020
+ return `<div class="gantt-row">
17021
+ <div class="gantt-label">${label}</div>
16592
17022
  <div class="gantt-track">
16593
17023
  <div class="gantt-bar ${cls}" style="left:${left}%;width:${width}%"></div>
16594
17024
  </div>
16595
- </div>`);
16596
- }
16597
- }
17025
+ </div>`;
17026
+ }).join("\n");
16598
17027
  const note = truncated ? `<div class="mermaid-note">${hiddenCount} earlier sprint${hiddenCount > 1 ? "s" : ""} not shown</div>` : "";
16599
17028
  return `${note}
16600
17029
  <div class="gantt">
@@ -16603,11 +17032,12 @@ function buildTimelineGantt(data, maxSprints = 6) {
16603
17032
  <div class="gantt-label"></div>
16604
17033
  <div class="gantt-track gantt-dates">${markers.join("")}</div>
16605
17034
  </div>
16606
- ${rows.join("\n")}
17035
+ ${sprintBandRow}
17036
+ ${epicRows}
16607
17037
  </div>
16608
17038
  <div class="gantt-overlay">
16609
17039
  <div class="gantt-label"></div>
16610
- <div class="gantt-track">${todayMarker}</div>
17040
+ <div class="gantt-track">${gridLines.join("")}${sprintLines.join("")}${todayMarker}</div>
16611
17041
  </div>
16612
17042
  </div>`;
16613
17043
  }
@@ -16686,13 +17116,14 @@ function buildArtifactFlowchart(data) {
16686
17116
  var svg = document.getElementById('flow-lines');
16687
17117
  if (!container || !svg) return;
16688
17118
 
16689
- // Build adjacency map (bidirectional) for traversal
16690
- var adj = {};
17119
+ // Build directed adjacency maps for traversal
17120
+ var fwd = {}; // from \u2192 [to] (Feature\u2192Epic, Epic\u2192Sprint)
17121
+ var bwd = {}; // to \u2192 [from] (Sprint\u2192Epic, Epic\u2192Feature)
16691
17122
  edges.forEach(function(e) {
16692
- if (!adj[e.from]) adj[e.from] = [];
16693
- if (!adj[e.to]) adj[e.to] = [];
16694
- adj[e.from].push(e.to);
16695
- adj[e.to].push(e.from);
17123
+ if (!fwd[e.from]) fwd[e.from] = [];
17124
+ if (!bwd[e.to]) bwd[e.to] = [];
17125
+ fwd[e.from].push(e.to);
17126
+ bwd[e.to].push(e.from);
16696
17127
  });
16697
17128
 
16698
17129
  function drawLines() {
@@ -16725,14 +17156,28 @@ function buildArtifactFlowchart(data) {
16725
17156
  });
16726
17157
  }
16727
17158
 
16728
- // Find all nodes reachable from a starting node
17159
+ // Find directly related nodes via directed traversal
17160
+ // Follows forward edges (Feature\u2192Epic\u2192Sprint) and backward edges
17161
+ // (Sprint\u2192Epic\u2192Feature) separately to avoid sideways expansion
16729
17162
  function findConnected(startId) {
16730
17163
  var visited = {};
16731
- var queue = [startId];
16732
17164
  visited[startId] = true;
17165
+ // Traverse forward (from\u2192to direction)
17166
+ var queue = [startId];
17167
+ while (queue.length) {
17168
+ var id = queue.shift();
17169
+ (fwd[id] || []).forEach(function(neighbor) {
17170
+ if (!visited[neighbor]) {
17171
+ visited[neighbor] = true;
17172
+ queue.push(neighbor);
17173
+ }
17174
+ });
17175
+ }
17176
+ // Traverse backward (to\u2192from direction)
17177
+ queue = [startId];
16733
17178
  while (queue.length) {
16734
17179
  var id = queue.shift();
16735
- (adj[id] || []).forEach(function(neighbor) {
17180
+ (bwd[id] || []).forEach(function(neighbor) {
16736
17181
  if (!visited[neighbor]) {
16737
17182
  visited[neighbor] = true;
16738
17183
  queue.push(neighbor);
@@ -16872,11 +17317,12 @@ function overviewPage(data, diagrams, navGroups) {
16872
17317
 
16873
17318
  <div class="section-title"><a href="/timeline">Project Timeline &rarr;</a></div>
16874
17319
 
16875
- <div class="section-title">Artifact Relationships</div>
16876
- ${buildArtifactFlowchart(diagrams)}
17320
+ ${collapsibleSection("overview-relationships", "Artifact Relationships", buildArtifactFlowchart(diagrams))}
16877
17321
 
16878
- <div class="section-title">Recent Activity</div>
16879
- ${data.recent.length > 0 ? `
17322
+ ${collapsibleSection(
17323
+ "overview-recent",
17324
+ "Recent Activity",
17325
+ data.recent.length > 0 ? `
16880
17326
  <div class="table-wrap">
16881
17327
  <table>
16882
17328
  <thead>
@@ -16892,7 +17338,8 @@ function overviewPage(data, diagrams, navGroups) {
16892
17338
  ${rows}
16893
17339
  </tbody>
16894
17340
  </table>
16895
- </div>` : `<div class="empty"><p>No documents yet.</p></div>`}
17341
+ </div>` : `<div class="empty"><p>No documents yet.</p></div>`
17342
+ )}
16896
17343
  `;
16897
17344
  }
16898
17345
 
@@ -17037,23 +17484,24 @@ function garPage(report) {
17037
17484
  <div class="label">Overall: ${escapeHtml(report.overall)}</div>
17038
17485
  </div>
17039
17486
 
17040
- <div class="gar-areas">
17041
- ${areaCards}
17042
- </div>
17487
+ ${collapsibleSection("gar-areas", "Areas", `<div class="gar-areas">${areaCards}</div>`)}
17043
17488
 
17044
- <div class="section-title">Status Distribution</div>
17045
- ${buildStatusPie("Action Status", {
17046
- Open: report.metrics.scope.open,
17047
- Done: report.metrics.scope.done,
17048
- "In Progress": Math.max(0, report.metrics.scope.total - report.metrics.scope.open - report.metrics.scope.done)
17049
- })}
17489
+ ${collapsibleSection(
17490
+ "gar-status-dist",
17491
+ "Status Distribution",
17492
+ buildStatusPie("Action Status", {
17493
+ Open: report.metrics.scope.open,
17494
+ Done: report.metrics.scope.done,
17495
+ "In Progress": Math.max(0, report.metrics.scope.total - report.metrics.scope.open - report.metrics.scope.done)
17496
+ })
17497
+ )}
17050
17498
  `;
17051
17499
  }
17052
17500
 
17053
17501
  // src/web/templates/pages/health.ts
17054
17502
  function healthPage(report, metrics) {
17055
17503
  const dotClass = `dot-${report.overall}`;
17056
- function renderSection(title, categories) {
17504
+ function renderSection(sectionId, title, categories) {
17057
17505
  const cards = categories.map(
17058
17506
  (cat) => `
17059
17507
  <div class="gar-area">
@@ -17065,10 +17513,9 @@ function healthPage(report, metrics) {
17065
17513
  ${cat.items.length > 0 ? `<ul>${cat.items.map((item) => `<li><span class="ref-id">${escapeHtml(item.id)}</span>${escapeHtml(item.detail)}</li>`).join("")}</ul>` : ""}
17066
17514
  </div>`
17067
17515
  ).join("\n");
17068
- return `
17069
- <div class="health-section-title">${escapeHtml(title)}</div>
17070
- <div class="gar-areas">${cards}</div>
17071
- `;
17516
+ return collapsibleSection(sectionId, title, `<div class="gar-areas">${cards}</div>`, {
17517
+ titleClass: "health-section-title"
17518
+ });
17072
17519
  }
17073
17520
  return `
17074
17521
  <div class="page-header">
@@ -17081,35 +17528,43 @@ function healthPage(report, metrics) {
17081
17528
  <div class="label">Overall: ${escapeHtml(report.overall)}</div>
17082
17529
  </div>
17083
17530
 
17084
- ${renderSection("Completeness", report.completeness)}
17085
-
17086
- <div class="health-section-title">Completeness Overview</div>
17087
- ${buildHealthGauge(
17088
- metrics ? Object.entries(metrics.completeness).map(([name, cat]) => ({
17089
- name: name.replace(/\b\w/g, (c) => c.toUpperCase()),
17090
- complete: cat.complete,
17091
- total: cat.total
17092
- })) : report.completeness.map((c) => {
17093
- const match = c.summary.match(/(\d+)\s*\/\s*(\d+)/);
17094
- return {
17095
- name: c.name,
17096
- complete: match ? parseInt(match[1], 10) : 0,
17097
- total: match ? parseInt(match[2], 10) : 0
17098
- };
17099
- })
17531
+ ${renderSection("health-completeness", "Completeness", report.completeness)}
17532
+
17533
+ ${collapsibleSection(
17534
+ "health-completeness-overview",
17535
+ "Completeness Overview",
17536
+ buildHealthGauge(
17537
+ metrics ? Object.entries(metrics.completeness).map(([name, cat]) => ({
17538
+ name: name.replace(/\b\w/g, (c) => c.toUpperCase()),
17539
+ complete: cat.complete,
17540
+ total: cat.total
17541
+ })) : report.completeness.map((c) => {
17542
+ const match = c.summary.match(/(\d+)\s*\/\s*(\d+)/);
17543
+ return {
17544
+ name: c.name,
17545
+ complete: match ? parseInt(match[1], 10) : 0,
17546
+ total: match ? parseInt(match[2], 10) : 0
17547
+ };
17548
+ })
17549
+ ),
17550
+ { titleClass: "health-section-title" }
17100
17551
  )}
17101
17552
 
17102
- ${renderSection("Process", report.process)}
17103
-
17104
- <div class="health-section-title">Process Summary</div>
17105
- ${metrics ? buildStatusPie("Process Health", {
17106
- Stale: metrics.process.stale.length,
17107
- "Aging Actions": metrics.process.agingActions.length,
17108
- Healthy: Math.max(
17109
- 0,
17110
- (metrics.completeness ? Object.values(metrics.completeness).reduce((sum, c) => sum + c.total, 0) : 0) - metrics.process.stale.length - metrics.process.agingActions.length
17111
- )
17112
- }) : ""}
17553
+ ${renderSection("health-process", "Process", report.process)}
17554
+
17555
+ ${collapsibleSection(
17556
+ "health-process-summary",
17557
+ "Process Summary",
17558
+ metrics ? buildStatusPie("Process Health", {
17559
+ Stale: metrics.process.stale.length,
17560
+ "Aging Actions": metrics.process.agingActions.length,
17561
+ Healthy: Math.max(
17562
+ 0,
17563
+ (metrics.completeness ? Object.values(metrics.completeness).reduce((sum, c) => sum + c.total, 0) : 0) - metrics.process.stale.length - metrics.process.agingActions.length
17564
+ )
17565
+ }) : "",
17566
+ { titleClass: "health-section-title" }
17567
+ )}
17113
17568
  `;
17114
17569
  }
17115
17570
 
@@ -17167,13 +17622,147 @@ function timelinePage(diagrams) {
17167
17622
  return `
17168
17623
  <div class="page-header">
17169
17624
  <h2>Project Timeline</h2>
17170
- <div class="subtitle">Sprint schedule with linked epics</div>
17625
+ <div class="subtitle">Epic timeline across sprints</div>
17171
17626
  </div>
17172
17627
 
17173
17628
  ${buildTimelineGantt(diagrams)}
17174
17629
  `;
17175
17630
  }
17176
17631
 
17632
+ // src/web/templates/pages/upcoming.ts
17633
+ function urgencyBadge(tier) {
17634
+ const labels = {
17635
+ overdue: "Overdue",
17636
+ "due-3d": "Due in 3d",
17637
+ "due-7d": "Due in 7d",
17638
+ upcoming: "Upcoming",
17639
+ later: "Later"
17640
+ };
17641
+ return `<span class="badge urgency-badge-${tier}">${labels[tier]}</span>`;
17642
+ }
17643
+ function urgencyRowClass(tier) {
17644
+ if (tier === "overdue") return " urgency-row-overdue";
17645
+ if (tier === "due-3d") return " urgency-row-due-3d";
17646
+ if (tier === "due-7d") return " urgency-row-due-7d";
17647
+ return "";
17648
+ }
17649
+ function upcomingPage(data) {
17650
+ const hasActions = data.dueSoonActions.length > 0;
17651
+ const hasSprintTasks = data.dueSoonSprintTasks.length > 0;
17652
+ const hasTrending = data.trending.length > 0;
17653
+ const actionsTable = hasActions ? collapsibleSection(
17654
+ "upcoming-actions",
17655
+ "Due Soon \u2014 Actions",
17656
+ `<div class="table-wrap">
17657
+ <table>
17658
+ <thead>
17659
+ <tr>
17660
+ <th>ID</th>
17661
+ <th>Title</th>
17662
+ <th>Status</th>
17663
+ <th>Owner</th>
17664
+ <th>Due Date</th>
17665
+ <th>Urgency</th>
17666
+ <th>Tasks</th>
17667
+ </tr>
17668
+ </thead>
17669
+ <tbody>
17670
+ ${data.dueSoonActions.map(
17671
+ (a) => `
17672
+ <tr class="${urgencyRowClass(a.urgency)}">
17673
+ <td><a href="/docs/action/${escapeHtml(a.id)}">${escapeHtml(a.id)}</a></td>
17674
+ <td>${escapeHtml(a.title)}</td>
17675
+ <td>${statusBadge(a.status)}</td>
17676
+ <td>${a.owner ? escapeHtml(a.owner) : '<span class="text-dim">\u2014</span>'}</td>
17677
+ <td>${formatDate(a.dueDate)}</td>
17678
+ <td>${urgencyBadge(a.urgency)}</td>
17679
+ <td>${a.relatedTaskCount > 0 ? a.relatedTaskCount : "\u2014"}</td>
17680
+ </tr>`
17681
+ ).join("")}
17682
+ </tbody>
17683
+ </table>
17684
+ </div>`,
17685
+ { titleTag: "h3" }
17686
+ ) : "";
17687
+ const sprintTasksTable = hasSprintTasks ? collapsibleSection(
17688
+ "upcoming-sprint-tasks",
17689
+ "Due Soon \u2014 Sprint Tasks",
17690
+ `<div class="table-wrap">
17691
+ <table>
17692
+ <thead>
17693
+ <tr>
17694
+ <th>ID</th>
17695
+ <th>Title</th>
17696
+ <th>Status</th>
17697
+ <th>Sprint</th>
17698
+ <th>Sprint Ends</th>
17699
+ <th>Urgency</th>
17700
+ </tr>
17701
+ </thead>
17702
+ <tbody>
17703
+ ${data.dueSoonSprintTasks.map(
17704
+ (t) => `
17705
+ <tr class="${urgencyRowClass(t.urgency)}">
17706
+ <td><a href="/docs/task/${escapeHtml(t.id)}">${escapeHtml(t.id)}</a></td>
17707
+ <td>${escapeHtml(t.title)}</td>
17708
+ <td>${statusBadge(t.status)}</td>
17709
+ <td><a href="/docs/sprint/${escapeHtml(t.sprintId)}">${escapeHtml(t.sprintId)}</a></td>
17710
+ <td>${formatDate(t.sprintEndDate)}</td>
17711
+ <td>${urgencyBadge(t.urgency)}</td>
17712
+ </tr>`
17713
+ ).join("")}
17714
+ </tbody>
17715
+ </table>
17716
+ </div>`,
17717
+ { titleTag: "h3" }
17718
+ ) : "";
17719
+ const trendingTable = hasTrending ? collapsibleSection(
17720
+ "upcoming-trending",
17721
+ "Trending",
17722
+ `<div class="table-wrap">
17723
+ <table>
17724
+ <thead>
17725
+ <tr>
17726
+ <th>#</th>
17727
+ <th>ID</th>
17728
+ <th>Title</th>
17729
+ <th>Type</th>
17730
+ <th>Status</th>
17731
+ <th>Score</th>
17732
+ <th>Signals</th>
17733
+ </tr>
17734
+ </thead>
17735
+ <tbody>
17736
+ ${data.trending.map(
17737
+ (t, i) => `
17738
+ <tr>
17739
+ <td><span class="trending-rank">${i + 1}</span></td>
17740
+ <td><a href="/docs/${escapeHtml(t.type)}/${escapeHtml(t.id)}">${escapeHtml(t.id)}</a></td>
17741
+ <td>${escapeHtml(t.title)}</td>
17742
+ <td>${escapeHtml(typeLabel(t.type))}</td>
17743
+ <td>${statusBadge(t.status)}</td>
17744
+ <td><span class="trending-score">${t.score}</span></td>
17745
+ <td>${t.signals.map((s) => `<span class="signal-tag">${escapeHtml(s.factor)} +${s.points}</span>`).join(" ")}</td>
17746
+ </tr>`
17747
+ ).join("")}
17748
+ </tbody>
17749
+ </table>
17750
+ </div>`,
17751
+ { titleTag: "h3" }
17752
+ ) : "";
17753
+ const emptyState = !hasActions && !hasSprintTasks && !hasTrending ? '<div class="empty"><p>No upcoming items or trending activity found.</p></div>' : "";
17754
+ return `
17755
+ <div class="page-header">
17756
+ <h2>Upcoming</h2>
17757
+ <div class="subtitle">Time-sensitive items and trending activity</div>
17758
+ </div>
17759
+ ${actionsTable}
17760
+ ${sprintTasksTable}
17761
+ ${trendingTable}
17762
+ ${emptyState}
17763
+ `;
17764
+ }
17765
+
17177
17766
  // src/web/router.ts
17178
17767
  function handleRequest(req, res, store, projectName, navGroups) {
17179
17768
  const parsed = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);
@@ -17214,6 +17803,12 @@ function handleRequest(req, res, store, projectName, navGroups) {
17214
17803
  respond(res, layout({ title: "Health Check", activePath: "/health", projectName, navGroups }, body));
17215
17804
  return;
17216
17805
  }
17806
+ if (pathname === "/upcoming") {
17807
+ const data = getUpcomingData(store);
17808
+ const body = upcomingPage(data);
17809
+ respond(res, layout({ title: "Upcoming", activePath: "/upcoming", projectName, navGroups }, body));
17810
+ return;
17811
+ }
17217
17812
  const boardMatch = pathname.match(/^\/board(?:\/([^/]+))?$/);
17218
17813
  if (boardMatch) {
17219
17814
  const type = boardMatch[1];
@@ -18586,26 +19181,6 @@ function createSprintPlanningTools(store) {
18586
19181
 
18587
19182
  // src/plugins/builtin/tools/tasks.ts
18588
19183
  import { tool as tool14 } from "@anthropic-ai/claude-agent-sdk";
18589
-
18590
- // src/plugins/builtin/tools/task-utils.ts
18591
- function normalizeLinkedEpics(value) {
18592
- if (value === void 0 || value === null) return [];
18593
- if (typeof value === "string") {
18594
- try {
18595
- const parsed = JSON.parse(value);
18596
- if (Array.isArray(parsed)) return parsed.filter((v) => typeof v === "string");
18597
- } catch {
18598
- }
18599
- return [value];
18600
- }
18601
- if (Array.isArray(value)) return value.filter((v) => typeof v === "string");
18602
- return [];
18603
- }
18604
- function generateEpicTags(epics) {
18605
- return epics.map((id) => `epic:${id}`);
18606
- }
18607
-
18608
- // src/plugins/builtin/tools/tasks.ts
18609
19184
  var linkedEpicArray = external_exports.preprocess(
18610
19185
  (val) => {
18611
19186
  if (typeof val === "string") {
@@ -20024,8 +20599,9 @@ function findByJiraKey(store, jiraKey) {
20024
20599
  const docs = store.list({ type: JIRA_TYPE });
20025
20600
  return docs.find((d) => d.frontmatter.jiraKey === jiraKey);
20026
20601
  }
20027
- function createJiraTools(store) {
20602
+ function createJiraTools(store, projectConfig) {
20028
20603
  const jiraUserConfig = loadUserConfig().jira;
20604
+ const defaultProjectKey = projectConfig?.jira?.projectKey;
20029
20605
  return [
20030
20606
  // --- Local read tools ---
20031
20607
  tool20(
@@ -20189,10 +20765,22 @@ function createJiraTools(store) {
20189
20765
  "Create a Jira issue from any Marvin artifact (D/A/Q/F/E) and create a tracking JI-xxx document",
20190
20766
  {
20191
20767
  artifactId: external_exports.string().describe("Marvin artifact ID (e.g. 'D-001', 'F-003', 'E-002')"),
20192
- projectKey: external_exports.string().describe("Jira project key (e.g. 'PROJ')"),
20768
+ projectKey: external_exports.string().optional().describe("Jira project key (e.g. 'PROJ'). Falls back to jira.projectKey from .marvin/config.yaml if not provided."),
20193
20769
  issueType: external_exports.enum(["Story", "Task", "Bug", "Epic"]).optional().describe("Jira issue type (default: 'Task')")
20194
20770
  },
20195
20771
  async (args) => {
20772
+ const resolvedProjectKey = args.projectKey ?? defaultProjectKey;
20773
+ if (!resolvedProjectKey) {
20774
+ return {
20775
+ content: [
20776
+ {
20777
+ type: "text",
20778
+ text: "No projectKey provided and no default configured. Either pass projectKey or set jira.projectKey in .marvin/config.yaml."
20779
+ }
20780
+ ],
20781
+ isError: true
20782
+ };
20783
+ }
20196
20784
  const jira = createJiraClient(jiraUserConfig);
20197
20785
  if (!jira) return jiraNotConfiguredError();
20198
20786
  const artifact = store.get(args.artifactId);
@@ -20212,7 +20800,7 @@ function createJiraTools(store) {
20212
20800
  `Status: ${artifact.frontmatter.status}`
20213
20801
  ].join("\n");
20214
20802
  const jiraResult = await jira.client.createIssue({
20215
- project: { key: args.projectKey },
20803
+ project: { key: resolvedProjectKey },
20216
20804
  summary: artifact.frontmatter.title,
20217
20805
  description,
20218
20806
  issuetype: { name: args.issueType ?? "Task" }
@@ -20353,14 +20941,14 @@ var jiraSkill = {
20353
20941
  documentTypeRegistrations: [
20354
20942
  { type: "jira-issue", dirName: "jira-issues", idPrefix: "JI" }
20355
20943
  ],
20356
- tools: (store) => createJiraTools(store),
20944
+ tools: (store, projectConfig) => createJiraTools(store, projectConfig),
20357
20945
  promptFragments: {
20358
20946
  "product-owner": `You have the **Jira Integration** skill. You can pull issues from Jira and push Marvin artifacts to Jira.
20359
20947
 
20360
20948
  **Available tools:**
20361
20949
  - \`list_jira_issues\` / \`get_jira_issue\` \u2014 browse locally synced Jira issues
20362
20950
  - \`pull_jira_issue\` / \`pull_jira_issues_jql\` \u2014 import issues from Jira by key or JQL query
20363
- - \`push_artifact_to_jira\` \u2014 create a Jira issue from a Marvin artifact (decision, feature, etc.)
20951
+ - \`push_artifact_to_jira\` \u2014 create a Jira issue from a Marvin artifact (decision, feature, etc.). The \`projectKey\` parameter is optional when a default is configured in \`.marvin/config.yaml\` under \`jira.projectKey\`.
20364
20952
  - \`sync_jira_issue\` \u2014 bidirectional sync of a local JI-xxx with Jira
20365
20953
  - \`link_artifact_to_jira\` \u2014 link a Marvin artifact to an existing JI-xxx
20366
20954
 
@@ -20374,7 +20962,7 @@ var jiraSkill = {
20374
20962
  **Available tools:**
20375
20963
  - \`list_jira_issues\` / \`get_jira_issue\` \u2014 browse locally synced Jira issues
20376
20964
  - \`pull_jira_issue\` / \`pull_jira_issues_jql\` \u2014 import issues from Jira by key or JQL query
20377
- - \`push_artifact_to_jira\` \u2014 create a Jira issue from a Marvin artifact (decision, action, epic, task, etc.)
20965
+ - \`push_artifact_to_jira\` \u2014 create a Jira issue from a Marvin artifact (decision, action, epic, task, etc.). The \`projectKey\` parameter is optional when a default is configured in \`.marvin/config.yaml\` under \`jira.projectKey\`.
20378
20966
  - \`sync_jira_issue\` \u2014 bidirectional sync of a local JI-xxx with Jira
20379
20967
  - \`link_artifact_to_jira\` \u2014 link a Marvin artifact to an existing JI-xxx
20380
20968
 
@@ -20388,7 +20976,7 @@ var jiraSkill = {
20388
20976
  **Available tools:**
20389
20977
  - \`list_jira_issues\` / \`get_jira_issue\` \u2014 browse locally synced Jira issues
20390
20978
  - \`pull_jira_issue\` / \`pull_jira_issues_jql\` \u2014 import issues from Jira by key or JQL query
20391
- - \`push_artifact_to_jira\` \u2014 create a Jira issue from a Marvin artifact (decision, action, etc.)
20979
+ - \`push_artifact_to_jira\` \u2014 create a Jira issue from a Marvin artifact (decision, action, etc.). The \`projectKey\` parameter is optional when a default is configured in \`.marvin/config.yaml\` under \`jira.projectKey\`.
20392
20980
  - \`sync_jira_issue\` \u2014 bidirectional sync of a local JI-xxx with Jira
20393
20981
  - \`link_artifact_to_jira\` \u2014 link a Marvin artifact to an existing JI-xxx
20394
20982
 
@@ -20459,8 +21047,8 @@ function gatherContext(store, focusFeature, includeDecisions = true, includeQues
20459
21047
  title: e.frontmatter.title,
20460
21048
  status: e.frontmatter.status,
20461
21049
  linkedFeature: normalizeLinkedFeatures(e.frontmatter.linkedFeature),
20462
- targetDate: e.frontmatter.targetDate ?? null,
20463
- estimatedEffort: e.frontmatter.estimatedEffort ?? null,
21050
+ targetDate: typeof e.frontmatter.targetDate === "string" ? e.frontmatter.targetDate : null,
21051
+ estimatedEffort: typeof e.frontmatter.estimatedEffort === "string" ? e.frontmatter.estimatedEffort : null,
20464
21052
  content: e.content,
20465
21053
  linkedTaskCount: tasks.filter(
20466
21054
  (t) => normalizeLinkedEpics(t.frontmatter.linkedEpic).includes(e.frontmatter.id)
@@ -20471,10 +21059,10 @@ function gatherContext(store, focusFeature, includeDecisions = true, includeQues
20471
21059
  title: t.frontmatter.title,
20472
21060
  status: t.frontmatter.status,
20473
21061
  linkedEpic: normalizeLinkedEpics(t.frontmatter.linkedEpic),
20474
- acceptanceCriteria: t.frontmatter.acceptanceCriteria ?? null,
20475
- technicalNotes: t.frontmatter.technicalNotes ?? null,
20476
- complexity: t.frontmatter.complexity ?? null,
20477
- estimatedPoints: t.frontmatter.estimatedPoints ?? null,
21062
+ acceptanceCriteria: typeof t.frontmatter.acceptanceCriteria === "string" ? t.frontmatter.acceptanceCriteria : null,
21063
+ technicalNotes: typeof t.frontmatter.technicalNotes === "string" ? t.frontmatter.technicalNotes : null,
21064
+ complexity: typeof t.frontmatter.complexity === "string" ? t.frontmatter.complexity : null,
21065
+ estimatedPoints: typeof t.frontmatter.estimatedPoints === "number" ? t.frontmatter.estimatedPoints : null,
20478
21066
  priority: t.frontmatter.priority ?? null
20479
21067
  })),
20480
21068
  decisions: allDecisions.map((d) => ({
@@ -20966,12 +21554,12 @@ function collectSkillRegistrations(skillIds, allSkills) {
20966
21554
  }
20967
21555
  return registrations;
20968
21556
  }
20969
- function getSkillTools(skillIds, allSkills, store) {
21557
+ function getSkillTools(skillIds, allSkills, store, projectConfig) {
20970
21558
  const tools = [];
20971
21559
  for (const id of skillIds) {
20972
21560
  const skill = allSkills.get(id);
20973
21561
  if (skill?.tools) {
20974
- tools.push(...skill.tools(store));
21562
+ tools.push(...skill.tools(store, projectConfig));
20975
21563
  }
20976
21564
  }
20977
21565
  return tools;
@@ -21211,6 +21799,7 @@ function createWebTools(store, projectName, navGroups) {
21211
21799
  const base = `http://localhost:${runningServer.port}`;
21212
21800
  const urls = {
21213
21801
  overview: base,
21802
+ upcoming: `${base}/upcoming`,
21214
21803
  gar: `${base}/gar`,
21215
21804
  board: `${base}/board`
21216
21805
  };
@@ -21284,6 +21873,18 @@ function createWebTools(store, projectName, navGroups) {
21284
21873
  };
21285
21874
  },
21286
21875
  { annotations: { readOnlyHint: true } }
21876
+ ),
21877
+ tool22(
21878
+ "get_dashboard_upcoming",
21879
+ "Get upcoming data: due-soon actions and sprint tasks, plus trending items scored by relevance signals. Works without the web server running.",
21880
+ {},
21881
+ async () => {
21882
+ const data = getUpcomingData(store);
21883
+ return {
21884
+ content: [{ type: "text", text: JSON.stringify(data, null, 2) }]
21885
+ };
21886
+ },
21887
+ { annotations: { readOnlyHint: true } }
21287
21888
  )
21288
21889
  ];
21289
21890
  }
@@ -21544,7 +22145,7 @@ async function startSession(options) {
21544
22145
  const manifest = hasSourcesDir ? new SourceManifestManager(marvinDir) : void 0;
21545
22146
  const pluginTools = plugin ? getPluginTools(plugin, store, marvinDir) : [];
21546
22147
  const pluginPromptFragment = plugin ? getPluginPromptFragment(plugin, persona.id) : void 0;
21547
- const codeSkillTools = getSkillTools(skillIds, allSkills, store);
22148
+ const codeSkillTools = getSkillTools(skillIds, allSkills, store, config2.project);
21548
22149
  const skillAgents = getSkillAgentDefinitions(skillIds, allSkills);
21549
22150
  const skillPromptFragment = getSkillPromptFragment(skillIds, allSkills, persona.id);
21550
22151
  const allSkillIds = [...allSkills.keys()];
@@ -21656,6 +22257,7 @@ Marvin \u2014 ${persona.name}
21656
22257
  "mcp__marvin-governance__get_dashboard_overview",
21657
22258
  "mcp__marvin-governance__get_dashboard_gar",
21658
22259
  "mcp__marvin-governance__get_dashboard_board",
22260
+ "mcp__marvin-governance__get_dashboard_upcoming",
21659
22261
  ...pluginTools.map((t) => `mcp__marvin-governance__${t.name}`),
21660
22262
  ...codeSkillTools.map((t) => `mcp__marvin-governance__${t.name}`)
21661
22263
  ]
@@ -22055,7 +22657,7 @@ function collectTools(marvinDir) {
22055
22657
  const sessionStore = new SessionStore(marvinDir);
22056
22658
  const allSkills = loadAllSkills(marvinDir);
22057
22659
  const allSkillIds = [...allSkills.keys()];
22058
- const codeSkillTools = getSkillTools(allSkillIds, allSkills, store);
22660
+ const codeSkillTools = getSkillTools(allSkillIds, allSkills, store, config2);
22059
22661
  const skillsWithActions = allSkillIds.map((id) => allSkills.get(id)).filter((s) => s.actions && s.actions.length > 0);
22060
22662
  const projectRoot = path11.dirname(marvinDir);
22061
22663
  const actionTools = createSkillActionTools(skillsWithActions, { store, marvinDir, projectRoot });
@@ -24821,7 +25423,7 @@ function createProgram() {
24821
25423
  const program = new Command();
24822
25424
  program.name("marvin").description(
24823
25425
  "AI-powered product development assistant with Product Owner, Delivery Manager, and Technical Lead personas"
24824
- ).version("0.4.4");
25426
+ ).version("0.4.6");
24825
25427
  program.command("init").description("Initialize a new Marvin project in the current directory").action(async () => {
24826
25428
  await initCommand();
24827
25429
  });