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.
@@ -18054,8 +18054,9 @@ function findByJiraKey(store, jiraKey) {
18054
18054
  const docs = store.list({ type: JIRA_TYPE });
18055
18055
  return docs.find((d) => d.frontmatter.jiraKey === jiraKey);
18056
18056
  }
18057
- function createJiraTools(store) {
18057
+ function createJiraTools(store, projectConfig) {
18058
18058
  const jiraUserConfig = loadUserConfig().jira;
18059
+ const defaultProjectKey = projectConfig?.jira?.projectKey;
18059
18060
  return [
18060
18061
  // --- Local read tools ---
18061
18062
  tool20(
@@ -18219,10 +18220,22 @@ function createJiraTools(store) {
18219
18220
  "Create a Jira issue from any Marvin artifact (D/A/Q/F/E) and create a tracking JI-xxx document",
18220
18221
  {
18221
18222
  artifactId: external_exports.string().describe("Marvin artifact ID (e.g. 'D-001', 'F-003', 'E-002')"),
18222
- projectKey: external_exports.string().describe("Jira project key (e.g. 'PROJ')"),
18223
+ projectKey: external_exports.string().optional().describe("Jira project key (e.g. 'PROJ'). Falls back to jira.projectKey from .marvin/config.yaml if not provided."),
18223
18224
  issueType: external_exports.enum(["Story", "Task", "Bug", "Epic"]).optional().describe("Jira issue type (default: 'Task')")
18224
18225
  },
18225
18226
  async (args) => {
18227
+ const resolvedProjectKey = args.projectKey ?? defaultProjectKey;
18228
+ if (!resolvedProjectKey) {
18229
+ return {
18230
+ content: [
18231
+ {
18232
+ type: "text",
18233
+ text: "No projectKey provided and no default configured. Either pass projectKey or set jira.projectKey in .marvin/config.yaml."
18234
+ }
18235
+ ],
18236
+ isError: true
18237
+ };
18238
+ }
18226
18239
  const jira = createJiraClient(jiraUserConfig);
18227
18240
  if (!jira) return jiraNotConfiguredError();
18228
18241
  const artifact = store.get(args.artifactId);
@@ -18242,7 +18255,7 @@ function createJiraTools(store) {
18242
18255
  `Status: ${artifact.frontmatter.status}`
18243
18256
  ].join("\n");
18244
18257
  const jiraResult = await jira.client.createIssue({
18245
- project: { key: args.projectKey },
18258
+ project: { key: resolvedProjectKey },
18246
18259
  summary: artifact.frontmatter.title,
18247
18260
  description,
18248
18261
  issuetype: { name: args.issueType ?? "Task" }
@@ -18383,14 +18396,14 @@ var jiraSkill = {
18383
18396
  documentTypeRegistrations: [
18384
18397
  { type: "jira-issue", dirName: "jira-issues", idPrefix: "JI" }
18385
18398
  ],
18386
- tools: (store) => createJiraTools(store),
18399
+ tools: (store, projectConfig) => createJiraTools(store, projectConfig),
18387
18400
  promptFragments: {
18388
18401
  "product-owner": `You have the **Jira Integration** skill. You can pull issues from Jira and push Marvin artifacts to Jira.
18389
18402
 
18390
18403
  **Available tools:**
18391
18404
  - \`list_jira_issues\` / \`get_jira_issue\` \u2014 browse locally synced Jira issues
18392
18405
  - \`pull_jira_issue\` / \`pull_jira_issues_jql\` \u2014 import issues from Jira by key or JQL query
18393
- - \`push_artifact_to_jira\` \u2014 create a Jira issue from a Marvin artifact (decision, feature, etc.)
18406
+ - \`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\`.
18394
18407
  - \`sync_jira_issue\` \u2014 bidirectional sync of a local JI-xxx with Jira
18395
18408
  - \`link_artifact_to_jira\` \u2014 link a Marvin artifact to an existing JI-xxx
18396
18409
 
@@ -18404,7 +18417,7 @@ var jiraSkill = {
18404
18417
  **Available tools:**
18405
18418
  - \`list_jira_issues\` / \`get_jira_issue\` \u2014 browse locally synced Jira issues
18406
18419
  - \`pull_jira_issue\` / \`pull_jira_issues_jql\` \u2014 import issues from Jira by key or JQL query
18407
- - \`push_artifact_to_jira\` \u2014 create a Jira issue from a Marvin artifact (decision, action, epic, task, etc.)
18420
+ - \`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\`.
18408
18421
  - \`sync_jira_issue\` \u2014 bidirectional sync of a local JI-xxx with Jira
18409
18422
  - \`link_artifact_to_jira\` \u2014 link a Marvin artifact to an existing JI-xxx
18410
18423
 
@@ -18418,7 +18431,7 @@ var jiraSkill = {
18418
18431
  **Available tools:**
18419
18432
  - \`list_jira_issues\` / \`get_jira_issue\` \u2014 browse locally synced Jira issues
18420
18433
  - \`pull_jira_issue\` / \`pull_jira_issues_jql\` \u2014 import issues from Jira by key or JQL query
18421
- - \`push_artifact_to_jira\` \u2014 create a Jira issue from a Marvin artifact (decision, action, etc.)
18434
+ - \`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\`.
18422
18435
  - \`sync_jira_issue\` \u2014 bidirectional sync of a local JI-xxx with Jira
18423
18436
  - \`link_artifact_to_jira\` \u2014 link a Marvin artifact to an existing JI-xxx
18424
18437
 
@@ -18489,8 +18502,8 @@ function gatherContext(store, focusFeature, includeDecisions = true, includeQues
18489
18502
  title: e.frontmatter.title,
18490
18503
  status: e.frontmatter.status,
18491
18504
  linkedFeature: normalizeLinkedFeatures(e.frontmatter.linkedFeature),
18492
- targetDate: e.frontmatter.targetDate ?? null,
18493
- estimatedEffort: e.frontmatter.estimatedEffort ?? null,
18505
+ targetDate: typeof e.frontmatter.targetDate === "string" ? e.frontmatter.targetDate : null,
18506
+ estimatedEffort: typeof e.frontmatter.estimatedEffort === "string" ? e.frontmatter.estimatedEffort : null,
18494
18507
  content: e.content,
18495
18508
  linkedTaskCount: tasks.filter(
18496
18509
  (t) => normalizeLinkedEpics(t.frontmatter.linkedEpic).includes(e.frontmatter.id)
@@ -18501,10 +18514,10 @@ function gatherContext(store, focusFeature, includeDecisions = true, includeQues
18501
18514
  title: t.frontmatter.title,
18502
18515
  status: t.frontmatter.status,
18503
18516
  linkedEpic: normalizeLinkedEpics(t.frontmatter.linkedEpic),
18504
- acceptanceCriteria: t.frontmatter.acceptanceCriteria ?? null,
18505
- technicalNotes: t.frontmatter.technicalNotes ?? null,
18506
- complexity: t.frontmatter.complexity ?? null,
18507
- estimatedPoints: t.frontmatter.estimatedPoints ?? null,
18517
+ acceptanceCriteria: typeof t.frontmatter.acceptanceCriteria === "string" ? t.frontmatter.acceptanceCriteria : null,
18518
+ technicalNotes: typeof t.frontmatter.technicalNotes === "string" ? t.frontmatter.technicalNotes : null,
18519
+ complexity: typeof t.frontmatter.complexity === "string" ? t.frontmatter.complexity : null,
18520
+ estimatedPoints: typeof t.frontmatter.estimatedPoints === "number" ? t.frontmatter.estimatedPoints : null,
18508
18521
  priority: t.frontmatter.priority ?? null
18509
18522
  })),
18510
18523
  decisions: allDecisions.map((d) => ({
@@ -18979,12 +18992,12 @@ function collectSkillRegistrations(skillIds, allSkills) {
18979
18992
  }
18980
18993
  return registrations;
18981
18994
  }
18982
- function getSkillTools(skillIds, allSkills, store) {
18995
+ function getSkillTools(skillIds, allSkills, store, projectConfig) {
18983
18996
  const tools = [];
18984
18997
  for (const id of skillIds) {
18985
18998
  const skill = allSkills.get(id);
18986
18999
  if (skill?.tools) {
18987
- tools.push(...skill.tools(store));
19000
+ tools.push(...skill.tools(store, projectConfig));
18988
19001
  }
18989
19002
  }
18990
19003
  return tools;
@@ -19129,8 +19142,223 @@ function getDiagramData(store) {
19129
19142
  }
19130
19143
  return { sprints, epics, features, statusCounts };
19131
19144
  }
19145
+ function computeUrgency(dueDateStr, todayStr) {
19146
+ const due = new Date(dueDateStr).getTime();
19147
+ const today = new Date(todayStr).getTime();
19148
+ const diffDays = Math.floor((due - today) / 864e5);
19149
+ if (diffDays < 0) return "overdue";
19150
+ if (diffDays <= 3) return "due-3d";
19151
+ if (diffDays <= 7) return "due-7d";
19152
+ if (diffDays <= 14) return "upcoming";
19153
+ return "later";
19154
+ }
19155
+ var DONE_STATUSES = /* @__PURE__ */ new Set(["done", "closed", "resolved", "cancelled"]);
19156
+ function getUpcomingData(store) {
19157
+ const today = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
19158
+ const allDocs = store.list();
19159
+ const docById = /* @__PURE__ */ new Map();
19160
+ for (const doc of allDocs) {
19161
+ docById.set(doc.frontmatter.id, doc);
19162
+ }
19163
+ const actions = allDocs.filter(
19164
+ (d) => d.frontmatter.type === "action" && !DONE_STATUSES.has(d.frontmatter.status)
19165
+ );
19166
+ const actionsWithDue = actions.filter((d) => d.frontmatter.dueDate);
19167
+ const sprints = allDocs.filter((d) => d.frontmatter.type === "sprint");
19168
+ const epics = allDocs.filter((d) => d.frontmatter.type === "epic");
19169
+ const tasks = allDocs.filter((d) => d.frontmatter.type === "task");
19170
+ const epicToTasks = /* @__PURE__ */ new Map();
19171
+ for (const task of tasks) {
19172
+ const tags = task.frontmatter.tags ?? [];
19173
+ for (const tag of tags) {
19174
+ if (tag.startsWith("epic:")) {
19175
+ const epicId = tag.slice(5);
19176
+ if (!epicToTasks.has(epicId)) epicToTasks.set(epicId, []);
19177
+ epicToTasks.get(epicId).push(task);
19178
+ }
19179
+ }
19180
+ }
19181
+ function getSprintTasks(sprintDoc) {
19182
+ const linkedEpics = normalizeLinkedEpics(sprintDoc.frontmatter.linkedEpics);
19183
+ const result = [];
19184
+ for (const epicId of linkedEpics) {
19185
+ const epicTasks = epicToTasks.get(epicId) ?? [];
19186
+ result.push(...epicTasks);
19187
+ }
19188
+ return result;
19189
+ }
19190
+ function countRelatedTasks(actionDoc) {
19191
+ const actionTags = actionDoc.frontmatter.tags ?? [];
19192
+ const relatedTaskIds = /* @__PURE__ */ new Set();
19193
+ for (const tag of actionTags) {
19194
+ if (tag.startsWith("sprint:")) {
19195
+ const sprintId = tag.slice(7);
19196
+ const sprint = docById.get(sprintId);
19197
+ if (sprint) {
19198
+ const sprintTaskDocs = getSprintTasks(sprint);
19199
+ for (const t of sprintTaskDocs) relatedTaskIds.add(t.frontmatter.id);
19200
+ }
19201
+ }
19202
+ }
19203
+ return relatedTaskIds.size;
19204
+ }
19205
+ const dueSoonActions = actionsWithDue.map((d) => ({
19206
+ id: d.frontmatter.id,
19207
+ title: d.frontmatter.title,
19208
+ status: d.frontmatter.status,
19209
+ owner: d.frontmatter.owner,
19210
+ dueDate: d.frontmatter.dueDate,
19211
+ urgency: computeUrgency(d.frontmatter.dueDate, today),
19212
+ relatedTaskCount: countRelatedTasks(d)
19213
+ })).sort((a, b) => a.dueDate.localeCompare(b.dueDate));
19214
+ const todayMs = new Date(today).getTime();
19215
+ const fourteenDaysMs = 14 * 864e5;
19216
+ const nearSprints = sprints.filter((s) => {
19217
+ const endDate = s.frontmatter.endDate;
19218
+ if (!endDate) return false;
19219
+ const endMs = new Date(endDate).getTime();
19220
+ const diff = endMs - todayMs;
19221
+ return diff >= 0 && diff <= fourteenDaysMs;
19222
+ });
19223
+ const taskSprintMap = /* @__PURE__ */ new Map();
19224
+ for (const sprint of nearSprints) {
19225
+ const sprintEnd = sprint.frontmatter.endDate;
19226
+ const sprintTaskDocs = getSprintTasks(sprint);
19227
+ for (const task of sprintTaskDocs) {
19228
+ if (DONE_STATUSES.has(task.frontmatter.status)) continue;
19229
+ const existing = taskSprintMap.get(task.frontmatter.id);
19230
+ if (!existing || sprintEnd < existing.sprintEnd) {
19231
+ taskSprintMap.set(task.frontmatter.id, { task, sprint, sprintEnd });
19232
+ }
19233
+ }
19234
+ }
19235
+ const dueSoonSprintTasks = [...taskSprintMap.values()].map(({ task, sprint, sprintEnd }) => ({
19236
+ id: task.frontmatter.id,
19237
+ title: task.frontmatter.title,
19238
+ status: task.frontmatter.status,
19239
+ sprintId: sprint.frontmatter.id,
19240
+ sprintTitle: sprint.frontmatter.title,
19241
+ sprintEndDate: sprintEnd,
19242
+ urgency: computeUrgency(sprintEnd, today)
19243
+ })).sort((a, b) => a.sprintEndDate.localeCompare(b.sprintEndDate));
19244
+ const openItems = allDocs.filter(
19245
+ (d) => ["action", "question", "task"].includes(d.frontmatter.type) && !DONE_STATUSES.has(d.frontmatter.status)
19246
+ );
19247
+ const fourteenDaysAgo = new Date(todayMs - fourteenDaysMs).toISOString().slice(0, 10);
19248
+ const recentMeetings = allDocs.filter(
19249
+ (d) => d.frontmatter.type === "meeting" && (d.frontmatter.updated ?? d.frontmatter.created) >= fourteenDaysAgo
19250
+ );
19251
+ const crossRefCounts = /* @__PURE__ */ new Map();
19252
+ for (const doc of allDocs) {
19253
+ const content = doc.content ?? "";
19254
+ for (const item of openItems) {
19255
+ if (doc.frontmatter.id === item.frontmatter.id) continue;
19256
+ if (content.includes(item.frontmatter.id)) {
19257
+ crossRefCounts.set(
19258
+ item.frontmatter.id,
19259
+ (crossRefCounts.get(item.frontmatter.id) ?? 0) + 1
19260
+ );
19261
+ }
19262
+ }
19263
+ }
19264
+ const activeSprints = sprints.filter((s) => {
19265
+ const status = s.frontmatter.status;
19266
+ if (status === "active") return true;
19267
+ const startDate = s.frontmatter.startDate;
19268
+ if (!startDate) return false;
19269
+ const startMs = new Date(startDate).getTime();
19270
+ const diff = startMs - todayMs;
19271
+ return diff >= 0 && diff <= fourteenDaysMs;
19272
+ });
19273
+ const activeSprintIds = new Set(activeSprints.map((s) => s.frontmatter.id));
19274
+ const activeEpicIds = /* @__PURE__ */ new Set();
19275
+ for (const s of activeSprints) {
19276
+ for (const epicId of normalizeLinkedEpics(s.frontmatter.linkedEpics)) {
19277
+ activeEpicIds.add(epicId);
19278
+ }
19279
+ }
19280
+ const trending = openItems.map((doc) => {
19281
+ const signals = [];
19282
+ let score = 0;
19283
+ const updated = doc.frontmatter.updated ?? doc.frontmatter.created;
19284
+ const ageDays = daysBetween(updated, today);
19285
+ const recencyPts = Math.max(0, Math.round(20 * (1 - ageDays / 30)));
19286
+ if (recencyPts > 0) {
19287
+ signals.push({ factor: "recency", points: recencyPts });
19288
+ score += recencyPts;
19289
+ }
19290
+ const tags = doc.frontmatter.tags ?? [];
19291
+ const linkedToActiveSprint = tags.some(
19292
+ (t) => t.startsWith("sprint:") && activeSprintIds.has(t.slice(7))
19293
+ );
19294
+ const linkedToActiveEpic = tags.some(
19295
+ (t) => t.startsWith("epic:") && activeEpicIds.has(t.slice(5))
19296
+ );
19297
+ if (linkedToActiveSprint) {
19298
+ signals.push({ factor: "sprint proximity", points: 25 });
19299
+ score += 25;
19300
+ } else if (linkedToActiveEpic) {
19301
+ signals.push({ factor: "sprint proximity", points: 15 });
19302
+ score += 15;
19303
+ }
19304
+ const mentionCount = recentMeetings.filter(
19305
+ (m) => (m.content ?? "").includes(doc.frontmatter.id)
19306
+ ).length;
19307
+ if (mentionCount > 0) {
19308
+ const meetingPts = Math.min(15, mentionCount * 5);
19309
+ signals.push({ factor: "meeting mentions", points: meetingPts });
19310
+ score += meetingPts;
19311
+ }
19312
+ const priority = doc.frontmatter.priority?.toLowerCase();
19313
+ const priorityPts = priority === "critical" ? 15 : priority === "high" ? 10 : priority === "medium" ? 3 : 0;
19314
+ if (priorityPts > 0) {
19315
+ signals.push({ factor: "priority", points: priorityPts });
19316
+ score += priorityPts;
19317
+ }
19318
+ if (["action", "question"].includes(doc.frontmatter.type)) {
19319
+ const createdDays = daysBetween(doc.frontmatter.created, today);
19320
+ if (createdDays >= 14) {
19321
+ const agingPts = Math.min(10, Math.floor((createdDays - 14) / 7) * 3 + 5);
19322
+ signals.push({ factor: "aging", points: agingPts });
19323
+ score += agingPts;
19324
+ }
19325
+ }
19326
+ const refs = crossRefCounts.get(doc.frontmatter.id) ?? 0;
19327
+ if (refs > 0) {
19328
+ const crossRefPts = Math.min(15, refs * 5);
19329
+ signals.push({ factor: "cross-references", points: crossRefPts });
19330
+ score += crossRefPts;
19331
+ }
19332
+ return {
19333
+ id: doc.frontmatter.id,
19334
+ title: doc.frontmatter.title,
19335
+ type: doc.frontmatter.type,
19336
+ status: doc.frontmatter.status,
19337
+ score,
19338
+ signals
19339
+ };
19340
+ }).filter((item) => item.score > 0).sort((a, b) => b.score - a.score).slice(0, 15);
19341
+ return { dueSoonActions, dueSoonSprintTasks, trending };
19342
+ }
19132
19343
 
19133
19344
  // src/web/templates/layout.ts
19345
+ function collapsibleSection(sectionId, title, content, opts) {
19346
+ const tag = opts?.titleTag ?? "div";
19347
+ const cls = opts?.titleClass ?? "section-title";
19348
+ const collapsed = opts?.defaultCollapsed ? " collapsed" : "";
19349
+ return `
19350
+ <div class="collapsible${collapsed}" data-section-id="${escapeHtml(sectionId)}">
19351
+ <${tag} class="${cls} collapsible-header" onclick="toggleSection(this)">
19352
+ <svg class="collapsible-chevron" viewBox="0 0 16 16" width="14" height="14" fill="currentColor">
19353
+ <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"/>
19354
+ </svg>
19355
+ <span>${title}</span>
19356
+ </${tag}>
19357
+ <div class="collapsible-body">
19358
+ ${content}
19359
+ </div>
19360
+ </div>`;
19361
+ }
19134
19362
  function escapeHtml(str) {
19135
19363
  return str.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
19136
19364
  }
@@ -19252,6 +19480,7 @@ function inline(text) {
19252
19480
  function layout(opts, body) {
19253
19481
  const topItems = [
19254
19482
  { href: "/", label: "Overview" },
19483
+ { href: "/upcoming", label: "Upcoming" },
19255
19484
  { href: "/timeline", label: "Timeline" },
19256
19485
  { href: "/board", label: "Board" },
19257
19486
  { href: "/gar", label: "GAR Report" },
@@ -19297,6 +19526,32 @@ function layout(opts, body) {
19297
19526
  ${body}
19298
19527
  </main>
19299
19528
  </div>
19529
+ <script>
19530
+ function toggleSection(header) {
19531
+ var section = header.closest('.collapsible');
19532
+ if (!section) return;
19533
+ section.classList.toggle('collapsed');
19534
+ var id = section.getAttribute('data-section-id');
19535
+ if (id) {
19536
+ try {
19537
+ var state = JSON.parse(localStorage.getItem('marvin-collapsed') || '{}');
19538
+ state[id] = section.classList.contains('collapsed');
19539
+ localStorage.setItem('marvin-collapsed', JSON.stringify(state));
19540
+ } catch(e) {}
19541
+ }
19542
+ }
19543
+ // Restore collapsed state on load
19544
+ (function() {
19545
+ try {
19546
+ var state = JSON.parse(localStorage.getItem('marvin-collapsed') || '{}');
19547
+ document.querySelectorAll('.collapsible[data-section-id]').forEach(function(el) {
19548
+ var id = el.getAttribute('data-section-id');
19549
+ if (state[id] === true) el.classList.add('collapsed');
19550
+ else if (state[id] === false) el.classList.remove('collapsed');
19551
+ });
19552
+ } catch(e) {}
19553
+ })();
19554
+ </script>
19300
19555
  <script src="https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.min.js"></script>
19301
19556
  <script>mermaid.initialize({
19302
19557
  startOnLoad: true,
@@ -20120,13 +20375,60 @@ tr:hover td {
20120
20375
  white-space: nowrap;
20121
20376
  }
20122
20377
 
20378
+ .gantt-grid-line {
20379
+ position: absolute;
20380
+ top: 0;
20381
+ bottom: 0;
20382
+ width: 1px;
20383
+ background: var(--border);
20384
+ opacity: 0.35;
20385
+ }
20386
+
20387
+ .gantt-sprint-line {
20388
+ position: absolute;
20389
+ top: 0;
20390
+ bottom: 0;
20391
+ width: 1px;
20392
+ background: var(--text-dim);
20393
+ opacity: 0.3;
20394
+ }
20395
+
20123
20396
  .gantt-today {
20124
20397
  position: absolute;
20125
20398
  top: 0;
20126
20399
  bottom: 0;
20127
- width: 2px;
20400
+ width: 3px;
20128
20401
  background: var(--red);
20129
- opacity: 0.7;
20402
+ opacity: 0.8;
20403
+ border-radius: 1px;
20404
+ }
20405
+
20406
+ /* Sprint band in timeline */
20407
+ .gantt-sprint-band-row {
20408
+ border-bottom: 1px solid var(--border);
20409
+ margin-bottom: 0.25rem;
20410
+ }
20411
+
20412
+ .gantt-sprint-band {
20413
+ height: 32px;
20414
+ }
20415
+
20416
+ .gantt-sprint-block {
20417
+ position: absolute;
20418
+ top: 2px;
20419
+ bottom: 2px;
20420
+ background: var(--bg-hover);
20421
+ border: 1px solid var(--border);
20422
+ border-radius: 4px;
20423
+ font-size: 0.65rem;
20424
+ color: var(--text-dim);
20425
+ display: flex;
20426
+ align-items: center;
20427
+ justify-content: center;
20428
+ overflow: hidden;
20429
+ white-space: nowrap;
20430
+ text-overflow: ellipsis;
20431
+ padding: 0 0.4rem;
20130
20432
  }
20131
20433
 
20132
20434
  /* Pie chart color overrides */
@@ -20138,6 +20440,90 @@ tr:hover td {
20138
20440
  fill: var(--bg) !important;
20139
20441
  font-weight: 600;
20140
20442
  }
20443
+
20444
+ /* Urgency row indicators */
20445
+ .urgency-row-overdue { border-left: 3px solid var(--red); }
20446
+ .urgency-row-due-3d { border-left: 3px solid var(--amber); }
20447
+ .urgency-row-due-7d { border-left: 3px solid #e2a308; }
20448
+
20449
+ /* Urgency badge pills */
20450
+ .urgency-badge-overdue { background: rgba(248, 113, 113, 0.15); color: var(--red); }
20451
+ .urgency-badge-due-3d { background: rgba(251, 191, 36, 0.15); color: var(--amber); }
20452
+ .urgency-badge-due-7d { background: rgba(226, 163, 8, 0.15); color: #e2a308; }
20453
+ .urgency-badge-upcoming { background: rgba(108, 140, 255, 0.15); color: var(--accent); }
20454
+ .urgency-badge-later { background: rgba(139, 143, 164, 0.1); color: var(--text-dim); }
20455
+
20456
+ /* Trending */
20457
+ .trending-rank {
20458
+ display: inline-flex;
20459
+ align-items: center;
20460
+ justify-content: center;
20461
+ width: 24px;
20462
+ height: 24px;
20463
+ border-radius: 50%;
20464
+ background: var(--bg-hover);
20465
+ font-size: 0.75rem;
20466
+ font-weight: 600;
20467
+ color: var(--text-dim);
20468
+ }
20469
+
20470
+ .trending-score {
20471
+ display: inline-block;
20472
+ padding: 0.15rem 0.6rem;
20473
+ border-radius: 999px;
20474
+ font-size: 0.7rem;
20475
+ font-weight: 700;
20476
+ background: rgba(108, 140, 255, 0.15);
20477
+ color: var(--accent);
20478
+ }
20479
+
20480
+ .signal-tag {
20481
+ display: inline-block;
20482
+ padding: 0.1rem 0.45rem;
20483
+ border-radius: 4px;
20484
+ font-size: 0.65rem;
20485
+ background: var(--bg-hover);
20486
+ color: var(--text-dim);
20487
+ margin-right: 0.25rem;
20488
+ margin-bottom: 0.15rem;
20489
+ white-space: nowrap;
20490
+ }
20491
+
20492
+ .text-dim { color: var(--text-dim); }
20493
+
20494
+ /* Collapsible sections */
20495
+ .collapsible-header {
20496
+ cursor: pointer;
20497
+ display: flex;
20498
+ align-items: center;
20499
+ gap: 0.4rem;
20500
+ user-select: none;
20501
+ }
20502
+
20503
+ .collapsible-header:hover {
20504
+ color: var(--accent);
20505
+ }
20506
+
20507
+ .collapsible-chevron {
20508
+ transition: transform 0.2s ease;
20509
+ flex-shrink: 0;
20510
+ }
20511
+
20512
+ .collapsible.collapsed .collapsible-chevron {
20513
+ transform: rotate(-90deg);
20514
+ }
20515
+
20516
+ .collapsible-body {
20517
+ overflow: hidden;
20518
+ max-height: 5000px;
20519
+ transition: max-height 0.3s ease, opacity 0.2s ease;
20520
+ opacity: 1;
20521
+ }
20522
+
20523
+ .collapsible.collapsed .collapsible-body {
20524
+ max-height: 0;
20525
+ opacity: 0;
20526
+ }
20141
20527
  `;
20142
20528
  }
20143
20529
 
@@ -20190,35 +20576,73 @@ function buildTimelineGantt(data, maxSprints = 6) {
20190
20576
  );
20191
20577
  tick += 7 * DAY;
20192
20578
  }
20579
+ const gridLines = [];
20580
+ let gridTick = timelineStart;
20581
+ const gridStartDay = new Date(gridTick).getDay();
20582
+ gridTick += (8 - gridStartDay) % 7 * DAY;
20583
+ while (gridTick <= timelineEnd) {
20584
+ gridLines.push(`<div class="gantt-grid-line" style="left:${pct(gridTick).toFixed(2)}%"></div>`);
20585
+ gridTick += 7 * DAY;
20586
+ }
20587
+ const sprintBoundaries = /* @__PURE__ */ new Set();
20588
+ for (const sprint of visibleSprints) {
20589
+ sprintBoundaries.add(toMs(sprint.startDate));
20590
+ sprintBoundaries.add(toMs(sprint.endDate));
20591
+ }
20592
+ const sprintLines = [...sprintBoundaries].map(
20593
+ (ms) => `<div class="gantt-sprint-line" style="left:${pct(ms).toFixed(2)}%"></div>`
20594
+ );
20193
20595
  const now = Date.now();
20194
20596
  let todayMarker = "";
20195
20597
  if (now >= timelineStart && now <= timelineEnd) {
20196
20598
  todayMarker = `<div class="gantt-today" style="left:${pct(now).toFixed(2)}%"></div>`;
20197
20599
  }
20198
- const rows = [];
20600
+ const sprintBlocks = visibleSprints.map((sprint) => {
20601
+ const sStart = toMs(sprint.startDate);
20602
+ const sEnd = toMs(sprint.endDate);
20603
+ const left = pct(sStart).toFixed(2);
20604
+ const width = (pct(sEnd) - pct(sStart)).toFixed(2);
20605
+ return `<div class="gantt-sprint-block" style="left:${left}%;width:${width}%">${sanitize(sprint.id, 20)}</div>`;
20606
+ }).join("");
20607
+ const sprintBandRow = `<div class="gantt-row gantt-sprint-band-row">
20608
+ <div class="gantt-label gantt-section-label">Sprints</div>
20609
+ <div class="gantt-track gantt-sprint-band">${sprintBlocks}</div>
20610
+ </div>`;
20611
+ const epicSpanMap = /* @__PURE__ */ new Map();
20199
20612
  for (const sprint of visibleSprints) {
20200
20613
  const sStart = toMs(sprint.startDate);
20201
20614
  const sEnd = toMs(sprint.endDate);
20202
- rows.push(`<div class="gantt-section-row">
20203
- <div class="gantt-label gantt-section-label">${sanitize(sprint.id + " " + sprint.title, 50)}</div>
20204
- <div class="gantt-track">
20205
- <div class="gantt-section-bg" style="left:${pct(sStart).toFixed(2)}%;width:${(pct(sEnd) - pct(sStart)).toFixed(2)}%"></div>
20206
- </div>
20207
- </div>`);
20208
- const linked = sprint.linkedEpics.map((eid) => epicMap.get(eid)).filter(Boolean);
20209
- const items = linked.length > 0 ? linked.map((e) => ({ label: sanitize(e.id + " " + e.title), status: e.status })) : [{ label: sanitize(sprint.title), status: sprint.status }];
20210
- for (const item of items) {
20211
- 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";
20212
- const left = pct(sStart).toFixed(2);
20213
- const width = (pct(sEnd) - pct(sStart)).toFixed(2);
20214
- rows.push(`<div class="gantt-row">
20215
- <div class="gantt-label">${item.label}</div>
20615
+ for (const eid of sprint.linkedEpics) {
20616
+ if (!epicMap.has(eid)) continue;
20617
+ const existing = epicSpanMap.get(eid);
20618
+ if (existing) {
20619
+ existing.startMs = Math.min(existing.startMs, sStart);
20620
+ existing.endMs = Math.max(existing.endMs, sEnd);
20621
+ } else {
20622
+ epicSpanMap.set(eid, { startMs: sStart, endMs: sEnd });
20623
+ }
20624
+ }
20625
+ }
20626
+ const sortedEpicIds = [...epicSpanMap.keys()].sort((a, b) => {
20627
+ const aSpan = epicSpanMap.get(a);
20628
+ const bSpan = epicSpanMap.get(b);
20629
+ if (aSpan.startMs !== bSpan.startMs) return aSpan.startMs - bSpan.startMs;
20630
+ return a.localeCompare(b);
20631
+ });
20632
+ const epicRows = sortedEpicIds.map((eid) => {
20633
+ const epic = epicMap.get(eid);
20634
+ const { startMs, endMs } = epicSpanMap.get(eid);
20635
+ 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";
20636
+ const left = pct(startMs).toFixed(2);
20637
+ const width = (pct(endMs) - pct(startMs)).toFixed(2);
20638
+ const label = sanitize(epic.id + " " + epic.title);
20639
+ return `<div class="gantt-row">
20640
+ <div class="gantt-label">${label}</div>
20216
20641
  <div class="gantt-track">
20217
20642
  <div class="gantt-bar ${cls}" style="left:${left}%;width:${width}%"></div>
20218
20643
  </div>
20219
- </div>`);
20220
- }
20221
- }
20644
+ </div>`;
20645
+ }).join("\n");
20222
20646
  const note = truncated ? `<div class="mermaid-note">${hiddenCount} earlier sprint${hiddenCount > 1 ? "s" : ""} not shown</div>` : "";
20223
20647
  return `${note}
20224
20648
  <div class="gantt">
@@ -20227,11 +20651,12 @@ function buildTimelineGantt(data, maxSprints = 6) {
20227
20651
  <div class="gantt-label"></div>
20228
20652
  <div class="gantt-track gantt-dates">${markers.join("")}</div>
20229
20653
  </div>
20230
- ${rows.join("\n")}
20654
+ ${sprintBandRow}
20655
+ ${epicRows}
20231
20656
  </div>
20232
20657
  <div class="gantt-overlay">
20233
20658
  <div class="gantt-label"></div>
20234
- <div class="gantt-track">${todayMarker}</div>
20659
+ <div class="gantt-track">${gridLines.join("")}${sprintLines.join("")}${todayMarker}</div>
20235
20660
  </div>
20236
20661
  </div>`;
20237
20662
  }
@@ -20310,13 +20735,14 @@ function buildArtifactFlowchart(data) {
20310
20735
  var svg = document.getElementById('flow-lines');
20311
20736
  if (!container || !svg) return;
20312
20737
 
20313
- // Build adjacency map (bidirectional) for traversal
20314
- var adj = {};
20738
+ // Build directed adjacency maps for traversal
20739
+ var fwd = {}; // from \u2192 [to] (Feature\u2192Epic, Epic\u2192Sprint)
20740
+ var bwd = {}; // to \u2192 [from] (Sprint\u2192Epic, Epic\u2192Feature)
20315
20741
  edges.forEach(function(e) {
20316
- if (!adj[e.from]) adj[e.from] = [];
20317
- if (!adj[e.to]) adj[e.to] = [];
20318
- adj[e.from].push(e.to);
20319
- adj[e.to].push(e.from);
20742
+ if (!fwd[e.from]) fwd[e.from] = [];
20743
+ if (!bwd[e.to]) bwd[e.to] = [];
20744
+ fwd[e.from].push(e.to);
20745
+ bwd[e.to].push(e.from);
20320
20746
  });
20321
20747
 
20322
20748
  function drawLines() {
@@ -20349,14 +20775,28 @@ function buildArtifactFlowchart(data) {
20349
20775
  });
20350
20776
  }
20351
20777
 
20352
- // Find all nodes reachable from a starting node
20778
+ // Find directly related nodes via directed traversal
20779
+ // Follows forward edges (Feature\u2192Epic\u2192Sprint) and backward edges
20780
+ // (Sprint\u2192Epic\u2192Feature) separately to avoid sideways expansion
20353
20781
  function findConnected(startId) {
20354
20782
  var visited = {};
20355
- var queue = [startId];
20356
20783
  visited[startId] = true;
20784
+ // Traverse forward (from\u2192to direction)
20785
+ var queue = [startId];
20786
+ while (queue.length) {
20787
+ var id = queue.shift();
20788
+ (fwd[id] || []).forEach(function(neighbor) {
20789
+ if (!visited[neighbor]) {
20790
+ visited[neighbor] = true;
20791
+ queue.push(neighbor);
20792
+ }
20793
+ });
20794
+ }
20795
+ // Traverse backward (to\u2192from direction)
20796
+ queue = [startId];
20357
20797
  while (queue.length) {
20358
20798
  var id = queue.shift();
20359
- (adj[id] || []).forEach(function(neighbor) {
20799
+ (bwd[id] || []).forEach(function(neighbor) {
20360
20800
  if (!visited[neighbor]) {
20361
20801
  visited[neighbor] = true;
20362
20802
  queue.push(neighbor);
@@ -20496,11 +20936,12 @@ function overviewPage(data, diagrams, navGroups) {
20496
20936
 
20497
20937
  <div class="section-title"><a href="/timeline">Project Timeline &rarr;</a></div>
20498
20938
 
20499
- <div class="section-title">Artifact Relationships</div>
20500
- ${buildArtifactFlowchart(diagrams)}
20939
+ ${collapsibleSection("overview-relationships", "Artifact Relationships", buildArtifactFlowchart(diagrams))}
20501
20940
 
20502
- <div class="section-title">Recent Activity</div>
20503
- ${data.recent.length > 0 ? `
20941
+ ${collapsibleSection(
20942
+ "overview-recent",
20943
+ "Recent Activity",
20944
+ data.recent.length > 0 ? `
20504
20945
  <div class="table-wrap">
20505
20946
  <table>
20506
20947
  <thead>
@@ -20516,7 +20957,8 @@ function overviewPage(data, diagrams, navGroups) {
20516
20957
  ${rows}
20517
20958
  </tbody>
20518
20959
  </table>
20519
- </div>` : `<div class="empty"><p>No documents yet.</p></div>`}
20960
+ </div>` : `<div class="empty"><p>No documents yet.</p></div>`
20961
+ )}
20520
20962
  `;
20521
20963
  }
20522
20964
 
@@ -20661,23 +21103,24 @@ function garPage(report) {
20661
21103
  <div class="label">Overall: ${escapeHtml(report.overall)}</div>
20662
21104
  </div>
20663
21105
 
20664
- <div class="gar-areas">
20665
- ${areaCards}
20666
- </div>
21106
+ ${collapsibleSection("gar-areas", "Areas", `<div class="gar-areas">${areaCards}</div>`)}
20667
21107
 
20668
- <div class="section-title">Status Distribution</div>
20669
- ${buildStatusPie("Action Status", {
20670
- Open: report.metrics.scope.open,
20671
- Done: report.metrics.scope.done,
20672
- "In Progress": Math.max(0, report.metrics.scope.total - report.metrics.scope.open - report.metrics.scope.done)
20673
- })}
21108
+ ${collapsibleSection(
21109
+ "gar-status-dist",
21110
+ "Status Distribution",
21111
+ buildStatusPie("Action Status", {
21112
+ Open: report.metrics.scope.open,
21113
+ Done: report.metrics.scope.done,
21114
+ "In Progress": Math.max(0, report.metrics.scope.total - report.metrics.scope.open - report.metrics.scope.done)
21115
+ })
21116
+ )}
20674
21117
  `;
20675
21118
  }
20676
21119
 
20677
21120
  // src/web/templates/pages/health.ts
20678
21121
  function healthPage(report, metrics) {
20679
21122
  const dotClass = `dot-${report.overall}`;
20680
- function renderSection(title, categories) {
21123
+ function renderSection(sectionId, title, categories) {
20681
21124
  const cards = categories.map(
20682
21125
  (cat) => `
20683
21126
  <div class="gar-area">
@@ -20689,10 +21132,9 @@ function healthPage(report, metrics) {
20689
21132
  ${cat.items.length > 0 ? `<ul>${cat.items.map((item) => `<li><span class="ref-id">${escapeHtml(item.id)}</span>${escapeHtml(item.detail)}</li>`).join("")}</ul>` : ""}
20690
21133
  </div>`
20691
21134
  ).join("\n");
20692
- return `
20693
- <div class="health-section-title">${escapeHtml(title)}</div>
20694
- <div class="gar-areas">${cards}</div>
20695
- `;
21135
+ return collapsibleSection(sectionId, title, `<div class="gar-areas">${cards}</div>`, {
21136
+ titleClass: "health-section-title"
21137
+ });
20696
21138
  }
20697
21139
  return `
20698
21140
  <div class="page-header">
@@ -20705,35 +21147,43 @@ function healthPage(report, metrics) {
20705
21147
  <div class="label">Overall: ${escapeHtml(report.overall)}</div>
20706
21148
  </div>
20707
21149
 
20708
- ${renderSection("Completeness", report.completeness)}
20709
-
20710
- <div class="health-section-title">Completeness Overview</div>
20711
- ${buildHealthGauge(
20712
- metrics ? Object.entries(metrics.completeness).map(([name, cat]) => ({
20713
- name: name.replace(/\b\w/g, (c) => c.toUpperCase()),
20714
- complete: cat.complete,
20715
- total: cat.total
20716
- })) : report.completeness.map((c) => {
20717
- const match = c.summary.match(/(\d+)\s*\/\s*(\d+)/);
20718
- return {
20719
- name: c.name,
20720
- complete: match ? parseInt(match[1], 10) : 0,
20721
- total: match ? parseInt(match[2], 10) : 0
20722
- };
20723
- })
21150
+ ${renderSection("health-completeness", "Completeness", report.completeness)}
21151
+
21152
+ ${collapsibleSection(
21153
+ "health-completeness-overview",
21154
+ "Completeness Overview",
21155
+ buildHealthGauge(
21156
+ metrics ? Object.entries(metrics.completeness).map(([name, cat]) => ({
21157
+ name: name.replace(/\b\w/g, (c) => c.toUpperCase()),
21158
+ complete: cat.complete,
21159
+ total: cat.total
21160
+ })) : report.completeness.map((c) => {
21161
+ const match = c.summary.match(/(\d+)\s*\/\s*(\d+)/);
21162
+ return {
21163
+ name: c.name,
21164
+ complete: match ? parseInt(match[1], 10) : 0,
21165
+ total: match ? parseInt(match[2], 10) : 0
21166
+ };
21167
+ })
21168
+ ),
21169
+ { titleClass: "health-section-title" }
20724
21170
  )}
20725
21171
 
20726
- ${renderSection("Process", report.process)}
20727
-
20728
- <div class="health-section-title">Process Summary</div>
20729
- ${metrics ? buildStatusPie("Process Health", {
20730
- Stale: metrics.process.stale.length,
20731
- "Aging Actions": metrics.process.agingActions.length,
20732
- Healthy: Math.max(
20733
- 0,
20734
- (metrics.completeness ? Object.values(metrics.completeness).reduce((sum, c) => sum + c.total, 0) : 0) - metrics.process.stale.length - metrics.process.agingActions.length
20735
- )
20736
- }) : ""}
21172
+ ${renderSection("health-process", "Process", report.process)}
21173
+
21174
+ ${collapsibleSection(
21175
+ "health-process-summary",
21176
+ "Process Summary",
21177
+ metrics ? buildStatusPie("Process Health", {
21178
+ Stale: metrics.process.stale.length,
21179
+ "Aging Actions": metrics.process.agingActions.length,
21180
+ Healthy: Math.max(
21181
+ 0,
21182
+ (metrics.completeness ? Object.values(metrics.completeness).reduce((sum, c) => sum + c.total, 0) : 0) - metrics.process.stale.length - metrics.process.agingActions.length
21183
+ )
21184
+ }) : "",
21185
+ { titleClass: "health-section-title" }
21186
+ )}
20737
21187
  `;
20738
21188
  }
20739
21189
 
@@ -20791,13 +21241,147 @@ function timelinePage(diagrams) {
20791
21241
  return `
20792
21242
  <div class="page-header">
20793
21243
  <h2>Project Timeline</h2>
20794
- <div class="subtitle">Sprint schedule with linked epics</div>
21244
+ <div class="subtitle">Epic timeline across sprints</div>
20795
21245
  </div>
20796
21246
 
20797
21247
  ${buildTimelineGantt(diagrams)}
20798
21248
  `;
20799
21249
  }
20800
21250
 
21251
+ // src/web/templates/pages/upcoming.ts
21252
+ function urgencyBadge(tier) {
21253
+ const labels = {
21254
+ overdue: "Overdue",
21255
+ "due-3d": "Due in 3d",
21256
+ "due-7d": "Due in 7d",
21257
+ upcoming: "Upcoming",
21258
+ later: "Later"
21259
+ };
21260
+ return `<span class="badge urgency-badge-${tier}">${labels[tier]}</span>`;
21261
+ }
21262
+ function urgencyRowClass(tier) {
21263
+ if (tier === "overdue") return " urgency-row-overdue";
21264
+ if (tier === "due-3d") return " urgency-row-due-3d";
21265
+ if (tier === "due-7d") return " urgency-row-due-7d";
21266
+ return "";
21267
+ }
21268
+ function upcomingPage(data) {
21269
+ const hasActions = data.dueSoonActions.length > 0;
21270
+ const hasSprintTasks = data.dueSoonSprintTasks.length > 0;
21271
+ const hasTrending = data.trending.length > 0;
21272
+ const actionsTable = hasActions ? collapsibleSection(
21273
+ "upcoming-actions",
21274
+ "Due Soon \u2014 Actions",
21275
+ `<div class="table-wrap">
21276
+ <table>
21277
+ <thead>
21278
+ <tr>
21279
+ <th>ID</th>
21280
+ <th>Title</th>
21281
+ <th>Status</th>
21282
+ <th>Owner</th>
21283
+ <th>Due Date</th>
21284
+ <th>Urgency</th>
21285
+ <th>Tasks</th>
21286
+ </tr>
21287
+ </thead>
21288
+ <tbody>
21289
+ ${data.dueSoonActions.map(
21290
+ (a) => `
21291
+ <tr class="${urgencyRowClass(a.urgency)}">
21292
+ <td><a href="/docs/action/${escapeHtml(a.id)}">${escapeHtml(a.id)}</a></td>
21293
+ <td>${escapeHtml(a.title)}</td>
21294
+ <td>${statusBadge(a.status)}</td>
21295
+ <td>${a.owner ? escapeHtml(a.owner) : '<span class="text-dim">\u2014</span>'}</td>
21296
+ <td>${formatDate(a.dueDate)}</td>
21297
+ <td>${urgencyBadge(a.urgency)}</td>
21298
+ <td>${a.relatedTaskCount > 0 ? a.relatedTaskCount : "\u2014"}</td>
21299
+ </tr>`
21300
+ ).join("")}
21301
+ </tbody>
21302
+ </table>
21303
+ </div>`,
21304
+ { titleTag: "h3" }
21305
+ ) : "";
21306
+ const sprintTasksTable = hasSprintTasks ? collapsibleSection(
21307
+ "upcoming-sprint-tasks",
21308
+ "Due Soon \u2014 Sprint Tasks",
21309
+ `<div class="table-wrap">
21310
+ <table>
21311
+ <thead>
21312
+ <tr>
21313
+ <th>ID</th>
21314
+ <th>Title</th>
21315
+ <th>Status</th>
21316
+ <th>Sprint</th>
21317
+ <th>Sprint Ends</th>
21318
+ <th>Urgency</th>
21319
+ </tr>
21320
+ </thead>
21321
+ <tbody>
21322
+ ${data.dueSoonSprintTasks.map(
21323
+ (t) => `
21324
+ <tr class="${urgencyRowClass(t.urgency)}">
21325
+ <td><a href="/docs/task/${escapeHtml(t.id)}">${escapeHtml(t.id)}</a></td>
21326
+ <td>${escapeHtml(t.title)}</td>
21327
+ <td>${statusBadge(t.status)}</td>
21328
+ <td><a href="/docs/sprint/${escapeHtml(t.sprintId)}">${escapeHtml(t.sprintId)}</a></td>
21329
+ <td>${formatDate(t.sprintEndDate)}</td>
21330
+ <td>${urgencyBadge(t.urgency)}</td>
21331
+ </tr>`
21332
+ ).join("")}
21333
+ </tbody>
21334
+ </table>
21335
+ </div>`,
21336
+ { titleTag: "h3" }
21337
+ ) : "";
21338
+ const trendingTable = hasTrending ? collapsibleSection(
21339
+ "upcoming-trending",
21340
+ "Trending",
21341
+ `<div class="table-wrap">
21342
+ <table>
21343
+ <thead>
21344
+ <tr>
21345
+ <th>#</th>
21346
+ <th>ID</th>
21347
+ <th>Title</th>
21348
+ <th>Type</th>
21349
+ <th>Status</th>
21350
+ <th>Score</th>
21351
+ <th>Signals</th>
21352
+ </tr>
21353
+ </thead>
21354
+ <tbody>
21355
+ ${data.trending.map(
21356
+ (t, i) => `
21357
+ <tr>
21358
+ <td><span class="trending-rank">${i + 1}</span></td>
21359
+ <td><a href="/docs/${escapeHtml(t.type)}/${escapeHtml(t.id)}">${escapeHtml(t.id)}</a></td>
21360
+ <td>${escapeHtml(t.title)}</td>
21361
+ <td>${escapeHtml(typeLabel(t.type))}</td>
21362
+ <td>${statusBadge(t.status)}</td>
21363
+ <td><span class="trending-score">${t.score}</span></td>
21364
+ <td>${t.signals.map((s) => `<span class="signal-tag">${escapeHtml(s.factor)} +${s.points}</span>`).join(" ")}</td>
21365
+ </tr>`
21366
+ ).join("")}
21367
+ </tbody>
21368
+ </table>
21369
+ </div>`,
21370
+ { titleTag: "h3" }
21371
+ ) : "";
21372
+ const emptyState = !hasActions && !hasSprintTasks && !hasTrending ? '<div class="empty"><p>No upcoming items or trending activity found.</p></div>' : "";
21373
+ return `
21374
+ <div class="page-header">
21375
+ <h2>Upcoming</h2>
21376
+ <div class="subtitle">Time-sensitive items and trending activity</div>
21377
+ </div>
21378
+ ${actionsTable}
21379
+ ${sprintTasksTable}
21380
+ ${trendingTable}
21381
+ ${emptyState}
21382
+ `;
21383
+ }
21384
+
20801
21385
  // src/web/router.ts
20802
21386
  function handleRequest(req, res, store, projectName, navGroups) {
20803
21387
  const parsed = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);
@@ -20838,6 +21422,12 @@ function handleRequest(req, res, store, projectName, navGroups) {
20838
21422
  respond(res, layout({ title: "Health Check", activePath: "/health", projectName, navGroups }, body));
20839
21423
  return;
20840
21424
  }
21425
+ if (pathname === "/upcoming") {
21426
+ const data = getUpcomingData(store);
21427
+ const body = upcomingPage(data);
21428
+ respond(res, layout({ title: "Upcoming", activePath: "/upcoming", projectName, navGroups }, body));
21429
+ return;
21430
+ }
20841
21431
  const boardMatch = pathname.match(/^\/board(?:\/([^/]+))?$/);
20842
21432
  if (boardMatch) {
20843
21433
  const type = boardMatch[1];
@@ -20995,6 +21585,7 @@ function createWebTools(store, projectName, navGroups) {
20995
21585
  const base = `http://localhost:${runningServer.port}`;
20996
21586
  const urls = {
20997
21587
  overview: base,
21588
+ upcoming: `${base}/upcoming`,
20998
21589
  gar: `${base}/gar`,
20999
21590
  board: `${base}/board`
21000
21591
  };
@@ -21068,6 +21659,18 @@ function createWebTools(store, projectName, navGroups) {
21068
21659
  };
21069
21660
  },
21070
21661
  { annotations: { readOnlyHint: true } }
21662
+ ),
21663
+ tool22(
21664
+ "get_dashboard_upcoming",
21665
+ "Get upcoming data: due-soon actions and sprint tasks, plus trending items scored by relevance signals. Works without the web server running.",
21666
+ {},
21667
+ async () => {
21668
+ const data = getUpcomingData(store);
21669
+ return {
21670
+ content: [{ type: "text", text: JSON.stringify(data, null, 2) }]
21671
+ };
21672
+ },
21673
+ { annotations: { readOnlyHint: true } }
21071
21674
  )
21072
21675
  ];
21073
21676
  }
@@ -21527,7 +22130,7 @@ function collectTools(marvinDir) {
21527
22130
  const sessionStore = new SessionStore(marvinDir);
21528
22131
  const allSkills = loadAllSkills(marvinDir);
21529
22132
  const allSkillIds = [...allSkills.keys()];
21530
- const codeSkillTools = getSkillTools(allSkillIds, allSkills, store);
22133
+ const codeSkillTools = getSkillTools(allSkillIds, allSkills, store, config2);
21531
22134
  const skillsWithActions = allSkillIds.map((id) => allSkills.get(id)).filter((s) => s.actions && s.actions.length > 0);
21532
22135
  const projectRoot = path9.dirname(marvinDir);
21533
22136
  const actionTools = createSkillActionTools(skillsWithActions, { store, marvinDir, projectRoot });