mrvn-cli 0.5.16 → 0.5.18

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/marvin.js CHANGED
@@ -14497,11 +14497,25 @@ function evaluateHealth(projectName, metrics) {
14497
14497
 
14498
14498
  // src/storage/progress.ts
14499
14499
  var DONE_STATUSES = /* @__PURE__ */ new Set(["done", "closed", "resolved", "cancelled"]);
14500
+ var STATUS_PROGRESS_DEFAULTS = {
14501
+ done: 100,
14502
+ closed: 100,
14503
+ resolved: 100,
14504
+ obsolete: 100,
14505
+ "wont do": 100,
14506
+ cancelled: 100,
14507
+ review: 80,
14508
+ "in-progress": 40,
14509
+ ready: 5,
14510
+ blocked: 10,
14511
+ backlog: 0,
14512
+ open: 0
14513
+ };
14500
14514
  function getEffectiveProgress(frontmatter) {
14501
14515
  if (DONE_STATUSES.has(frontmatter.status)) return 100;
14502
14516
  const raw = frontmatter.progress;
14503
14517
  if (typeof raw === "number") return Math.max(0, Math.min(100, Math.round(raw)));
14504
- return 0;
14518
+ return STATUS_PROGRESS_DEFAULTS[frontmatter.status] ?? 0;
14505
14519
  }
14506
14520
  function propagateProgressFromTask(store, taskId) {
14507
14521
  const updated = [];
@@ -14580,6 +14594,14 @@ function calculateSprintCompletionPct(primaryDocs) {
14580
14594
 
14581
14595
  // src/reports/sprint-summary/collector.ts
14582
14596
  var DONE_STATUSES2 = /* @__PURE__ */ new Set(["done", "closed", "resolved", "cancelled"]);
14597
+ var COMPLEXITY_WEIGHTS = {
14598
+ trivial: 1,
14599
+ simple: 2,
14600
+ moderate: 3,
14601
+ complex: 5,
14602
+ "very-complex": 8
14603
+ };
14604
+ var DEFAULT_WEIGHT = 3;
14583
14605
  function collectSprintSummaryData(store, sprintId) {
14584
14606
  const allDocs = store.list();
14585
14607
  const sprintDocs = allDocs.filter((d) => d.frontmatter.type === "sprint");
@@ -14665,12 +14687,14 @@ function collectSprintSummaryData(store, sprintId) {
14665
14687
  for (const doc of workItemDocs) {
14666
14688
  const about = doc.frontmatter.aboutArtifact;
14667
14689
  const focusTag = (doc.frontmatter.tags ?? []).find((t) => t.startsWith("focus:"));
14690
+ const complexity = doc.frontmatter.complexity;
14668
14691
  const item = {
14669
14692
  id: doc.frontmatter.id,
14670
14693
  title: doc.frontmatter.title,
14671
14694
  type: doc.frontmatter.type,
14672
14695
  status: doc.frontmatter.status,
14673
14696
  progress: getEffectiveProgress(doc.frontmatter),
14697
+ weight: complexity && complexity in COMPLEXITY_WEIGHTS ? COMPLEXITY_WEIGHTS[complexity] : DEFAULT_WEIGHT,
14674
14698
  owner: doc.frontmatter.owner,
14675
14699
  workFocus: focusTag ? focusTag.slice(6) : void 0,
14676
14700
  aboutArtifact: about,
@@ -22384,7 +22408,7 @@ function poBacklogPage(ctx) {
22384
22408
  }
22385
22409
  }
22386
22410
  }
22387
- const DONE_STATUSES17 = /* @__PURE__ */ new Set(["done", "closed", "resolved", "cancelled"]);
22411
+ const DONE_STATUSES16 = /* @__PURE__ */ new Set(["done", "closed", "resolved", "cancelled"]);
22388
22412
  function featureTaskStats(featureId) {
22389
22413
  const fEpics = featureToEpics.get(featureId) ?? [];
22390
22414
  let total = 0;
@@ -22393,7 +22417,7 @@ function poBacklogPage(ctx) {
22393
22417
  for (const epic of fEpics) {
22394
22418
  for (const t of epicToTasks.get(epic.frontmatter.id) ?? []) {
22395
22419
  total++;
22396
- if (DONE_STATUSES17.has(t.frontmatter.status)) done++;
22420
+ if (DONE_STATUSES16.has(t.frontmatter.status)) done++;
22397
22421
  progressSum += getEffectiveProgress(t.frontmatter);
22398
22422
  }
22399
22423
  }
@@ -22639,23 +22663,34 @@ function hashString(s) {
22639
22663
  }
22640
22664
  return Math.abs(h);
22641
22665
  }
22666
+ var DONE_STATUS_SET = /* @__PURE__ */ new Set(["done", "closed", "resolved", "decided"]);
22667
+ var DEFAULT_WEIGHT2 = 3;
22642
22668
  function countFocusStats(items) {
22643
22669
  let total = 0;
22644
22670
  let done = 0;
22645
22671
  let inProgress = 0;
22646
- function walk(list) {
22672
+ let totalWeight = 0;
22673
+ let weightedSum = 0;
22674
+ function walkStats(list) {
22647
22675
  for (const w of list) {
22648
22676
  if (w.type !== "contribution") {
22649
22677
  total++;
22650
22678
  const s = w.status.toLowerCase();
22651
- if (s === "done" || s === "closed" || s === "resolved" || s === "decided") done++;
22679
+ if (DONE_STATUS_SET.has(s)) done++;
22652
22680
  else if (s === "in-progress" || s === "in progress") inProgress++;
22653
22681
  }
22654
- if (w.children) walk(w.children);
22682
+ if (w.children) walkStats(w.children);
22655
22683
  }
22656
22684
  }
22657
- walk(items);
22658
- return { total, done, inProgress };
22685
+ walkStats(items);
22686
+ for (const w of items) {
22687
+ if (w.type === "contribution") continue;
22688
+ const weight = w.weight ?? DEFAULT_WEIGHT2;
22689
+ const progress = w.progress ?? (DONE_STATUS_SET.has(w.status.toLowerCase()) ? 100 : 0);
22690
+ totalWeight += weight;
22691
+ weightedSum += weight * progress;
22692
+ }
22693
+ return { total, done, inProgress, weightedProgress: totalWeight > 0 ? Math.round(weightedSum / totalWeight) : 0 };
22659
22694
  }
22660
22695
  var KNOWN_OWNERS2 = /* @__PURE__ */ new Set(["po", "tl", "dm"]);
22661
22696
  function ownerBadge2(owner) {
@@ -22704,7 +22739,7 @@ function renderWorkItemsTable(items, options) {
22704
22739
  for (const [focus, groupItems] of focusGroups) {
22705
22740
  const color = focusColorMap.get(focus);
22706
22741
  const stats = countFocusStats(groupItems);
22707
- const pct = stats.total > 0 ? Math.round(stats.done / stats.total * 100) : 0;
22742
+ const pct = stats.weightedProgress;
22708
22743
  const summaryParts = [];
22709
22744
  if (stats.done > 0) summaryParts.push(`${stats.done} done`);
22710
22745
  if (stats.inProgress > 0) summaryParts.push(`${stats.inProgress} in progress`);
@@ -25338,35 +25373,62 @@ var DEFAULT_TASK_STATUS_MAP = {
25338
25373
  blocked: ["Blocked"],
25339
25374
  backlog: ["To Do", "Open", "Backlog", "New"]
25340
25375
  };
25341
- function isConditionalEntry(value) {
25342
- return !Array.isArray(value) && typeof value === "object" && "default" in value;
25376
+ function isLegacyFormat(statusMap) {
25377
+ if (!statusMap || typeof statusMap !== "object") return false;
25378
+ const keys = Object.keys(statusMap);
25379
+ if (!keys.every((k) => k === "action" || k === "task")) return false;
25380
+ for (const key of keys) {
25381
+ const val = statusMap[key];
25382
+ if (typeof val !== "object" || val === null) return false;
25383
+ for (const innerVal of Object.values(val)) {
25384
+ if (!Array.isArray(innerVal)) return false;
25385
+ if (!innerVal.every((v) => typeof v === "string")) return false;
25386
+ }
25387
+ }
25388
+ return true;
25343
25389
  }
25344
- function buildStatusLookup(configMap, defaults, inSprint = false) {
25345
- const map2 = configMap ?? defaults;
25390
+ function buildLegacyLookup(legacyMap) {
25346
25391
  const lookup = /* @__PURE__ */ new Map();
25347
- for (const [marvinStatus, value] of Object.entries(map2)) {
25348
- const statuses = isConditionalEntry(value) ? value.default : value;
25349
- for (const js of statuses) {
25392
+ for (const [marvinStatus, jiraStatuses] of Object.entries(legacyMap)) {
25393
+ for (const js of jiraStatuses) {
25350
25394
  lookup.set(js.toLowerCase(), marvinStatus);
25351
25395
  }
25352
25396
  }
25353
- if (inSprint) {
25354
- for (const [marvinStatus, value] of Object.entries(map2)) {
25355
- if (isConditionalEntry(value) && value.inSprint) {
25356
- for (const js of value.inSprint) {
25357
- lookup.set(js.toLowerCase(), marvinStatus);
25358
- }
25359
- }
25397
+ return lookup;
25398
+ }
25399
+ function buildFlatLookup(flatMap, inSprint) {
25400
+ const lookup = /* @__PURE__ */ new Map();
25401
+ for (const [jiraStatus, value] of Object.entries(flatMap)) {
25402
+ if (typeof value === "string") {
25403
+ lookup.set(jiraStatus.toLowerCase(), value);
25404
+ } else {
25405
+ const resolved = inSprint && value.inSprint ? value.inSprint : value.default;
25406
+ lookup.set(jiraStatus.toLowerCase(), resolved);
25360
25407
  }
25361
25408
  }
25362
25409
  return lookup;
25363
25410
  }
25364
- function mapJiraStatusForAction(status, configMap, inSprint) {
25365
- const lookup = buildStatusLookup(configMap, DEFAULT_ACTION_STATUS_MAP, inSprint ?? false);
25411
+ function normalizeStatusMap(statusMap) {
25412
+ if (!statusMap) return {};
25413
+ if (isLegacyFormat(statusMap)) {
25414
+ return { legacy: statusMap };
25415
+ }
25416
+ return { flat: statusMap };
25417
+ }
25418
+ function mapJiraStatusForAction(status, resolved, inSprint = false) {
25419
+ if (resolved.flat) {
25420
+ const lookup2 = buildFlatLookup(resolved.flat, inSprint);
25421
+ return lookup2.get(status.toLowerCase()) ?? "open";
25422
+ }
25423
+ const lookup = buildLegacyLookup(resolved.legacy?.action ?? DEFAULT_ACTION_STATUS_MAP);
25366
25424
  return lookup.get(status.toLowerCase()) ?? "open";
25367
25425
  }
25368
- function mapJiraStatusForTask(status, configMap, inSprint) {
25369
- const lookup = buildStatusLookup(configMap, DEFAULT_TASK_STATUS_MAP, inSprint ?? false);
25426
+ function mapJiraStatusForTask(status, resolved, inSprint = false) {
25427
+ if (resolved.flat) {
25428
+ const lookup2 = buildFlatLookup(resolved.flat, inSprint);
25429
+ return lookup2.get(status.toLowerCase()) ?? "backlog";
25430
+ }
25431
+ const lookup = buildLegacyLookup(resolved.legacy?.task ?? DEFAULT_TASK_STATUS_MAP);
25370
25432
  return lookup.get(status.toLowerCase()) ?? "backlog";
25371
25433
  }
25372
25434
  function isInActiveSprint(store, tags) {
@@ -25390,6 +25452,47 @@ function extractJiraKeyFromTags(tags) {
25390
25452
  const tag = tags.find((t) => /^jira:[A-Z]+-\d+$/i.test(t));
25391
25453
  return tag ? tag.slice(5) : void 0;
25392
25454
  }
25455
+ function collectLinkedIssues(issue2) {
25456
+ const linkedIssues = [];
25457
+ if (issue2.fields.subtasks) {
25458
+ for (const sub of issue2.fields.subtasks) {
25459
+ linkedIssues.push({
25460
+ key: sub.key,
25461
+ summary: sub.fields.summary,
25462
+ status: sub.fields.status.name,
25463
+ relationship: "subtask",
25464
+ isDone: DONE_STATUSES14.has(sub.fields.status.name.toLowerCase())
25465
+ });
25466
+ }
25467
+ }
25468
+ if (issue2.fields.issuelinks) {
25469
+ for (const link of issue2.fields.issuelinks) {
25470
+ if (link.outwardIssue) {
25471
+ linkedIssues.push({
25472
+ key: link.outwardIssue.key,
25473
+ summary: link.outwardIssue.fields.summary,
25474
+ status: link.outwardIssue.fields.status.name,
25475
+ relationship: link.type.outward,
25476
+ isDone: DONE_STATUSES14.has(
25477
+ link.outwardIssue.fields.status.name.toLowerCase()
25478
+ )
25479
+ });
25480
+ }
25481
+ if (link.inwardIssue) {
25482
+ linkedIssues.push({
25483
+ key: link.inwardIssue.key,
25484
+ summary: link.inwardIssue.fields.summary,
25485
+ status: link.inwardIssue.fields.status.name,
25486
+ relationship: link.type.inward,
25487
+ isDone: DONE_STATUSES14.has(
25488
+ link.inwardIssue.fields.status.name.toLowerCase()
25489
+ )
25490
+ });
25491
+ }
25492
+ }
25493
+ }
25494
+ return linkedIssues;
25495
+ }
25393
25496
  function computeSubtaskProgress(subtasks) {
25394
25497
  if (subtasks.length === 0) return 0;
25395
25498
  const done = subtasks.filter(
@@ -25427,46 +25530,10 @@ async function fetchJiraStatus(store, client, host, artifactId, statusMap) {
25427
25530
  try {
25428
25531
  const issue2 = await client.getIssueWithLinks(jiraKey);
25429
25532
  const inSprint = isInActiveSprint(store, doc.frontmatter.tags);
25430
- const proposedStatus = artifactType === "task" ? mapJiraStatusForTask(issue2.fields.status.name, statusMap?.task, inSprint) : mapJiraStatusForAction(issue2.fields.status.name, statusMap?.action, inSprint);
25533
+ const resolved = statusMap ?? {};
25534
+ const proposedStatus = artifactType === "task" ? mapJiraStatusForTask(issue2.fields.status.name, resolved, inSprint) : mapJiraStatusForAction(issue2.fields.status.name, resolved, inSprint);
25431
25535
  const currentStatus = doc.frontmatter.status;
25432
- const linkedIssues = [];
25433
- if (issue2.fields.subtasks) {
25434
- for (const sub of issue2.fields.subtasks) {
25435
- linkedIssues.push({
25436
- key: sub.key,
25437
- summary: sub.fields.summary,
25438
- status: sub.fields.status.name,
25439
- relationship: "subtask",
25440
- isDone: DONE_STATUSES14.has(sub.fields.status.name.toLowerCase())
25441
- });
25442
- }
25443
- }
25444
- if (issue2.fields.issuelinks) {
25445
- for (const link of issue2.fields.issuelinks) {
25446
- if (link.outwardIssue) {
25447
- linkedIssues.push({
25448
- key: link.outwardIssue.key,
25449
- summary: link.outwardIssue.fields.summary,
25450
- status: link.outwardIssue.fields.status.name,
25451
- relationship: link.type.outward,
25452
- isDone: DONE_STATUSES14.has(
25453
- link.outwardIssue.fields.status.name.toLowerCase()
25454
- )
25455
- });
25456
- }
25457
- if (link.inwardIssue) {
25458
- linkedIssues.push({
25459
- key: link.inwardIssue.key,
25460
- summary: link.inwardIssue.fields.summary,
25461
- status: link.inwardIssue.fields.status.name,
25462
- relationship: link.type.inward,
25463
- isDone: DONE_STATUSES14.has(
25464
- link.inwardIssue.fields.status.name.toLowerCase()
25465
- )
25466
- });
25467
- }
25468
- }
25469
- }
25536
+ const linkedIssues = collectLinkedIssues(issue2);
25470
25537
  const subtasks = issue2.fields.subtasks ?? [];
25471
25538
  let proposedProgress;
25472
25539
  if (subtasks.length > 0 && !doc.frontmatter.progressOverride) {
@@ -25728,7 +25795,6 @@ function isWithinRange(timestamp, range) {
25728
25795
  function isConfluenceUrl(url2) {
25729
25796
  return /atlassian\.net\/wiki\//i.test(url2) || /\/confluence\//i.test(url2);
25730
25797
  }
25731
- var DONE_STATUSES15 = /* @__PURE__ */ new Set(["done", "closed", "resolved", "obsolete", "wont do"]);
25732
25798
  async function fetchJiraDaily(store, client, host, projectKey, dateRange, statusMap) {
25733
25799
  const summary = {
25734
25800
  dateRange,
@@ -25839,42 +25905,7 @@ async function processIssue(issue2, client, host, dateRange, jiraKeyToArtifacts,
25839
25905
  });
25840
25906
  }
25841
25907
  }
25842
- const linkedIssues = [];
25843
- if (issueWithLinks) {
25844
- if (issueWithLinks.fields.subtasks) {
25845
- for (const sub of issueWithLinks.fields.subtasks) {
25846
- linkedIssues.push({
25847
- key: sub.key,
25848
- summary: sub.fields.summary,
25849
- status: sub.fields.status.name,
25850
- relationship: "subtask",
25851
- isDone: DONE_STATUSES15.has(sub.fields.status.name.toLowerCase())
25852
- });
25853
- }
25854
- }
25855
- if (issueWithLinks.fields.issuelinks) {
25856
- for (const link of issueWithLinks.fields.issuelinks) {
25857
- if (link.outwardIssue) {
25858
- linkedIssues.push({
25859
- key: link.outwardIssue.key,
25860
- summary: link.outwardIssue.fields.summary,
25861
- status: link.outwardIssue.fields.status.name,
25862
- relationship: link.type.outward,
25863
- isDone: DONE_STATUSES15.has(link.outwardIssue.fields.status.name.toLowerCase())
25864
- });
25865
- }
25866
- if (link.inwardIssue) {
25867
- linkedIssues.push({
25868
- key: link.inwardIssue.key,
25869
- summary: link.inwardIssue.fields.summary,
25870
- status: link.inwardIssue.fields.status.name,
25871
- relationship: link.type.inward,
25872
- isDone: DONE_STATUSES15.has(link.inwardIssue.fields.status.name.toLowerCase())
25873
- });
25874
- }
25875
- }
25876
- }
25877
- }
25908
+ const linkedIssues = issueWithLinks ? collectLinkedIssues(issueWithLinks) : [];
25878
25909
  const marvinArtifacts = [];
25879
25910
  const artifacts = jiraKeyToArtifacts.get(issue2.key) ?? [];
25880
25911
  for (const doc of artifacts) {
@@ -25885,7 +25916,8 @@ async function processIssue(issue2, client, host, dateRange, jiraKeyToArtifacts,
25885
25916
  const jiraStatus = issue2.fields.status?.name;
25886
25917
  if (jiraStatus) {
25887
25918
  const inSprint = store ? isInActiveSprint(store, fm.tags) : false;
25888
- proposedStatus = artifactType === "task" ? mapJiraStatusForTask(jiraStatus, statusMap?.task, inSprint) : mapJiraStatusForAction(jiraStatus, statusMap?.action, inSprint);
25919
+ const resolved = statusMap ?? {};
25920
+ proposedStatus = artifactType === "task" ? mapJiraStatusForTask(jiraStatus, resolved, inSprint) : mapJiraStatusForAction(jiraStatus, resolved, inSprint);
25889
25921
  }
25890
25922
  }
25891
25923
  marvinArtifacts.push({
@@ -26003,36 +26035,23 @@ function generateProposedActions(issues) {
26003
26035
 
26004
26036
  // src/skills/builtin/jira/sprint-progress.ts
26005
26037
  import { query as query3 } from "@anthropic-ai/claude-agent-sdk";
26006
- var DONE_STATUSES16 = /* @__PURE__ */ new Set(["done", "closed", "resolved", "obsolete", "wont do", "cancelled"]);
26038
+ var DONE_STATUSES15 = /* @__PURE__ */ new Set(["done", "closed", "resolved", "obsolete", "wont do", "cancelled"]);
26007
26039
  var BATCH_SIZE = 5;
26040
+ var MAX_LINKED_ISSUES = 50;
26008
26041
  var BLOCKED_WEIGHT_RISK_THRESHOLD = 0.3;
26009
- var COMPLEXITY_WEIGHTS = {
26042
+ var COMPLEXITY_WEIGHTS2 = {
26010
26043
  trivial: 1,
26011
26044
  simple: 2,
26012
26045
  moderate: 3,
26013
26046
  complex: 5,
26014
26047
  "very-complex": 8
26015
26048
  };
26016
- var DEFAULT_WEIGHT = 3;
26017
- var STATUS_PROGRESS_DEFAULTS = {
26018
- done: 100,
26019
- closed: 100,
26020
- resolved: 100,
26021
- obsolete: 100,
26022
- "wont do": 100,
26023
- cancelled: 100,
26024
- review: 80,
26025
- "in-progress": 40,
26026
- ready: 5,
26027
- backlog: 0,
26028
- open: 0
26029
- };
26030
- var BLOCKED_DEFAULT_PROGRESS = 10;
26049
+ var DEFAULT_WEIGHT3 = 3;
26031
26050
  function resolveWeight(complexity) {
26032
- if (complexity && complexity in COMPLEXITY_WEIGHTS) {
26033
- return { weight: COMPLEXITY_WEIGHTS[complexity], weightSource: "complexity" };
26051
+ if (complexity && complexity in COMPLEXITY_WEIGHTS2) {
26052
+ return { weight: COMPLEXITY_WEIGHTS2[complexity], weightSource: "complexity" };
26034
26053
  }
26035
- return { weight: DEFAULT_WEIGHT, weightSource: "default" };
26054
+ return { weight: DEFAULT_WEIGHT3, weightSource: "default" };
26036
26055
  }
26037
26056
  function resolveProgress(frontmatter, commentAnalysisProgress) {
26038
26057
  const hasExplicitProgress = "progress" in frontmatter && typeof frontmatter.progress === "number";
@@ -26043,9 +26062,6 @@ function resolveProgress(frontmatter, commentAnalysisProgress) {
26043
26062
  return { progress: Math.max(0, Math.min(100, Math.round(commentAnalysisProgress))), progressSource: "comment-analysis" };
26044
26063
  }
26045
26064
  const status = frontmatter.status;
26046
- if (status === "blocked") {
26047
- return { progress: BLOCKED_DEFAULT_PROGRESS, progressSource: "status-default" };
26048
- }
26049
26065
  const defaultProgress = STATUS_PROGRESS_DEFAULTS[status] ?? 0;
26050
26066
  return { progress: defaultProgress, progressSource: "status-default" };
26051
26067
  }
@@ -26124,6 +26140,52 @@ async function assessSprintProgress(store, client, host, options = {}) {
26124
26140
  }
26125
26141
  }
26126
26142
  }
26143
+ const linkedJiraIssues = /* @__PURE__ */ new Map();
26144
+ if (options.traverseLinks) {
26145
+ const visited = new Set(jiraIssues.keys());
26146
+ const queue = [];
26147
+ for (const [, data] of jiraIssues) {
26148
+ const links = collectLinkedIssues(data.issue);
26149
+ for (const link of links) {
26150
+ if (link.relationship !== "subtask" && !visited.has(link.key)) {
26151
+ visited.add(link.key);
26152
+ queue.push(link.key);
26153
+ }
26154
+ }
26155
+ }
26156
+ while (queue.length > 0 && linkedJiraIssues.size < MAX_LINKED_ISSUES) {
26157
+ const remaining = MAX_LINKED_ISSUES - linkedJiraIssues.size;
26158
+ const batch = queue.splice(0, Math.min(BATCH_SIZE, remaining));
26159
+ const results = await Promise.allSettled(
26160
+ batch.map(async (key) => {
26161
+ const [issue2, comments] = await Promise.all([
26162
+ client.getIssueWithLinks(key),
26163
+ client.getComments(key)
26164
+ ]);
26165
+ return { key, issue: issue2, comments };
26166
+ })
26167
+ );
26168
+ for (const result of results) {
26169
+ if (result.status === "fulfilled") {
26170
+ const { key, issue: issue2, comments } = result.value;
26171
+ linkedJiraIssues.set(key, { issue: issue2, comments });
26172
+ const newLinks = collectLinkedIssues(issue2);
26173
+ for (const link of newLinks) {
26174
+ if (link.relationship !== "subtask" && !visited.has(link.key)) {
26175
+ visited.add(link.key);
26176
+ queue.push(link.key);
26177
+ }
26178
+ }
26179
+ } else {
26180
+ const batchKey = batch[results.indexOf(result)];
26181
+ errors.push(`Failed to fetch linked issue ${batchKey}: ${result.reason instanceof Error ? result.reason.message : String(result.reason)}`);
26182
+ }
26183
+ }
26184
+ }
26185
+ if (queue.length > 0) {
26186
+ errors.push(`Link traversal capped at ${MAX_LINKED_ISSUES} linked issues (${queue.length} remaining undiscovered)`);
26187
+ }
26188
+ }
26127
26189
  const proposedUpdates = [];
26128
26190
  const itemReports = [];
26129
26191
  const childReportsByParent = /* @__PURE__ */ new Map();
@@ -26138,7 +26200,8 @@ async function assessSprintProgress(store, client, host, options = {}) {
26138
26200
  if (jiraData) {
26139
26201
  jiraStatus = jiraData.issue.fields.status.name;
26140
26202
  const inSprint = isInActiveSprint(store, fm.tags);
26141
- proposedMarvinStatus = fm.type === "task" ? mapJiraStatusForTask(jiraStatus, options.statusMap?.task, inSprint) : mapJiraStatusForAction(jiraStatus, options.statusMap?.action, inSprint);
26203
+ const resolved = options.statusMap ?? {};
26204
+ proposedMarvinStatus = fm.type === "task" ? mapJiraStatusForTask(jiraStatus, resolved, inSprint) : mapJiraStatusForAction(jiraStatus, resolved, inSprint);
26142
26205
  const subtasks = jiraData.issue.fields.subtasks ?? [];
26143
26206
  if (subtasks.length > 0) {
26144
26207
  jiraSubtaskProgress = computeSubtaskProgress(subtasks);
@@ -26169,11 +26232,42 @@ async function assessSprintProgress(store, client, host, options = {}) {
26169
26232
  proposedValue: jiraSubtaskProgress,
26170
26233
  reason: `Jira ${jiraKey} subtask progress is ${jiraSubtaskProgress}%`
26171
26234
  });
26235
+ } else if (statusDrift && proposedMarvinStatus && !fm.progressOverride) {
26236
+ const hasExplicitProgress = "progress" in fm && typeof fm.progress === "number";
26237
+ if (!hasExplicitProgress) {
26238
+ const proposedProgress = STATUS_PROGRESS_DEFAULTS[proposedMarvinStatus] ?? 0;
26239
+ if (proposedProgress !== currentProgress) {
26240
+ proposedUpdates.push({
26241
+ artifactId: fm.id,
26242
+ field: "progress",
26243
+ currentValue: currentProgress,
26244
+ proposedValue: proposedProgress,
26245
+ reason: `Status changing to "${proposedMarvinStatus}" \u2192 default progress ${proposedProgress}%`
26246
+ });
26247
+ }
26248
+ }
26172
26249
  }
26173
26250
  const tags = fm.tags ?? [];
26174
26251
  const focusTag = tags.find((t) => t.startsWith("focus:"));
26175
26252
  const { weight, weightSource } = resolveWeight(fm.complexity);
26176
26253
  const { progress: resolvedProgress, progressSource } = resolveProgress(fm, null);
26254
+ let itemLinkedIssues = [];
26255
+ const itemLinkedIssueSignals = [];
26256
+ if (options.traverseLinks && jiraData) {
26257
+ const { allLinks, allSignals } = collectTransitiveLinks(
26258
+ jiraData.issue,
26259
+ jiraIssues,
26260
+ linkedJiraIssues
26261
+ );
26262
+ itemLinkedIssues = allLinks;
26263
+ itemLinkedIssueSignals.push(...allSignals);
26264
+ analyzeLinkedIssueSignals(
26265
+ allLinks,
26266
+ fm,
26267
+ jiraKey,
26268
+ proposedUpdates
26269
+ );
26270
+ }
26177
26271
  const report = {
26178
26272
  id: fm.id,
26179
26273
  title: fm.title,
@@ -26192,6 +26286,8 @@ async function assessSprintProgress(store, client, host, options = {}) {
26192
26286
  progressDrift,
26193
26287
  commentSignals,
26194
26288
  commentSummary: null,
26289
+ linkedIssues: itemLinkedIssues,
26290
+ linkedIssueSignals: itemLinkedIssueSignals,
26195
26291
  children: [],
26196
26292
  owner: fm.owner ?? null,
26197
26293
  focusArea: focusTag ? focusTag.slice(6) : null
@@ -26235,7 +26331,7 @@ async function assessSprintProgress(store, client, host, options = {}) {
26235
26331
  const focusAreas = [];
26236
26332
  for (const [name, items] of focusAreaMap) {
26237
26333
  const allFlatItems = items.flatMap((i) => [i, ...i.children]);
26238
- const doneCount = allFlatItems.filter((i) => DONE_STATUSES16.has(i.marvinStatus)).length;
26334
+ const doneCount = allFlatItems.filter((i) => DONE_STATUSES15.has(i.marvinStatus)).length;
26239
26335
  const blockedCount = allFlatItems.filter((i) => i.marvinStatus === "blocked").length;
26240
26336
  const progress = computeWeightedProgress(items);
26241
26337
  const totalWeight = items.reduce((s, i) => s + i.weight, 0);
@@ -26287,6 +26383,26 @@ async function assessSprintProgress(store, client, host, options = {}) {
26287
26383
  errors.push(`Comment analysis failed: ${err instanceof Error ? err.message : String(err)}`);
26288
26384
  }
26289
26385
  }
26386
+ if (options.traverseLinks) {
26387
+ try {
26388
+ const linkedSummaries = await analyzeLinkedIssueComments(
26389
+ itemReports,
26390
+ linkedJiraIssues
26391
+ );
26392
+ for (const [artifactId, signalSummaries] of linkedSummaries) {
26393
+ const report = itemReports.find((r) => r.id === artifactId);
26394
+ if (!report) continue;
26395
+ for (const [sourceKey, summary] of signalSummaries) {
26396
+ const signal = report.linkedIssueSignals.find((s) => s.sourceKey === sourceKey);
26397
+ if (signal) {
26398
+ signal.commentSummary = summary;
26399
+ }
26400
+ }
26401
+ }
26402
+ } catch (err) {
26403
+ errors.push(`Linked issue comment analysis failed: ${err instanceof Error ? err.message : String(err)}`);
26404
+ }
26405
+ }
26290
26406
  }
26291
26407
  const appliedUpdates = [];
26292
26408
  if (options.applyUpdates && proposedUpdates.length > 0) {
@@ -26403,6 +26519,155 @@ ${commentTexts}`);
26403
26519
  }
26404
26520
  return summaries;
26405
26521
  }
26522
+ function collectTransitiveLinks(primaryIssue, primaryIssues, linkedJiraIssues) {
26523
+ const allLinks = [];
26524
+ const allSignals = [];
26525
+ const visited = /* @__PURE__ */ new Set([primaryIssue.key]);
26526
+ const directLinks = collectLinkedIssues(primaryIssue).filter((l) => l.relationship !== "subtask");
26527
+ const queue = [...directLinks];
26528
+ for (const link of directLinks) {
26529
+ visited.add(link.key);
26530
+ }
26531
+ while (queue.length > 0) {
26532
+ const link = queue.shift();
26533
+ allLinks.push(link);
26534
+ const linkedData = linkedJiraIssues.get(link.key) ?? primaryIssues.get(link.key);
26535
+ if (!linkedData) continue;
26536
+ const linkedCommentSignals = [];
26537
+ for (const comment of linkedData.comments) {
26538
+ const text = extractCommentText(comment.body);
26539
+ const signals = detectCommentSignals(text);
26540
+ linkedCommentSignals.push(...signals);
26541
+ }
26542
+ if (linkedCommentSignals.length > 0 || linkedData.comments.length > 0) {
26543
+ allSignals.push({
26544
+ sourceKey: link.key,
26545
+ linkType: link.relationship,
26546
+ commentSignals: linkedCommentSignals,
26547
+ commentSummary: null
26548
+ });
26549
+ }
26550
+ const nextLinks = collectLinkedIssues(linkedData.issue).filter((l) => l.relationship !== "subtask" && !visited.has(l.key));
26551
+ for (const next of nextLinks) {
26552
+ visited.add(next.key);
26553
+ queue.push(next);
26554
+ }
26555
+ }
26556
+ return { allLinks, allSignals };
26557
+ }
26558
+ var BLOCKER_LINK_PATTERNS = ["blocks", "is blocked by"];
26559
+ var WONT_DO_STATUSES = /* @__PURE__ */ new Set(["wont do", "won't do", "cancelled"]);
26560
+ function analyzeLinkedIssueSignals(linkedIssues, frontmatter, jiraKey, proposedUpdates) {
26561
+ if (linkedIssues.length === 0) return;
26562
+ const blockerLinks = linkedIssues.filter(
26563
+ (l) => BLOCKER_LINK_PATTERNS.some((p) => l.relationship.toLowerCase().includes(p.split(" ")[0]))
26564
+ );
26565
+ if (blockerLinks.length > 0 && blockerLinks.every((l) => l.isDone) && frontmatter.status === "blocked") {
26566
+ proposedUpdates.push({
26567
+ artifactId: frontmatter.id,
26568
+ field: "status",
26569
+ currentValue: "blocked",
26570
+ proposedValue: "in-progress",
26571
+ reason: `All blocking issues resolved: ${blockerLinks.map((l) => l.key).join(", ")}`
26572
+ });
26573
+ }
26574
+ const wontDoLinks = linkedIssues.filter(
26575
+ (l) => WONT_DO_STATUSES.has(l.status.toLowerCase())
26576
+ );
26577
+ if (wontDoLinks.length > 0) {
26578
+ proposedUpdates.push({
26579
+ artifactId: frontmatter.id,
26580
+ field: "review",
26581
+ currentValue: null,
26582
+ proposedValue: "needs-review",
26583
+ reason: `Linked issue(s) cancelled/won't do: ${wontDoLinks.map((l) => `${l.key} "${l.summary}"`).join(", ")}`
26584
+ });
26585
+ }
26586
+ }
26587
+ var LINKED_COMMENT_ANALYSIS_PROMPT = `You are a delivery management assistant analyzing Jira comments from linked issues for progress signals.
26588
+
26589
+ For each linked issue below, read the comments and produce a 1-sentence summary focused on: impact on the parent issue, blockers, or decisions.
26590
+
26591
+ Return your response as a JSON object mapping artifact IDs to objects mapping linked issue keys to summary strings.
26592
+ Example: {"T-001": {"PROJ-301": "DBA review scheduled for Thursday."}}
26593
+
26594
+ IMPORTANT: Only return the JSON object, no other text.`;
26595
+ async function analyzeLinkedIssueComments(items, linkedJiraIssues) {
26596
+ const results = /* @__PURE__ */ new Map();
26597
+ const promptParts = [];
26598
+ const itemsWithLinkedComments = [];
26599
+ for (const item of items) {
26600
+ if (item.linkedIssueSignals.length === 0) continue;
26601
+ const linkedParts = [];
26602
+ for (const signal of item.linkedIssueSignals) {
26603
+ const linkedData = linkedJiraIssues.get(signal.sourceKey);
26604
+ if (!linkedData || linkedData.comments.length === 0) continue;
26605
+ const commentTexts = linkedData.comments.map((c) => {
26606
+ const text = extractCommentText(c.body);
26607
+ return ` [${c.author.displayName}, ${c.created.slice(0, 10)}]: ${text.slice(0, 300)}`;
26608
+ }).join("\n");
26609
+ linkedParts.push(` ### ${signal.sourceKey} (${signal.linkType})
26610
+ ${commentTexts}`);
26611
+ }
26612
+ if (linkedParts.length > 0) {
26613
+ itemsWithLinkedComments.push(item);
26614
+ promptParts.push(`## ${item.id} \u2014 ${item.title}
26615
+ Linked issues:
26616
+ ${linkedParts.join("\n")}`);
26617
+ }
26618
+ }
26619
+ if (promptParts.length === 0) return results;
26620
+ const prompt = promptParts.join("\n\n");
26621
+ const llmResult = query3({
26622
+ prompt,
26623
+ options: {
26624
+ systemPrompt: LINKED_COMMENT_ANALYSIS_PROMPT,
26625
+ maxTurns: 1,
26626
+ tools: [],
26627
+ allowedTools: []
26628
+ }
26629
+ });
26630
+ for await (const msg of llmResult) {
26631
+ if (msg.type === "assistant") {
26632
+ const textBlock = msg.message.content.find(
26633
+ (b) => b.type === "text"
26634
+ );
26635
+ if (textBlock) {
26636
+ const parsed = parseLlmJson(textBlock.text);
26637
+ if (parsed) {
26638
+ for (const [artifactId, linkedSummaries] of Object.entries(parsed)) {
26639
+ if (typeof linkedSummaries === "object" && linkedSummaries !== null) {
26640
+ const signalMap = /* @__PURE__ */ new Map();
26641
+ for (const [key, summary] of Object.entries(linkedSummaries)) {
26642
+ if (typeof summary === "string") {
26643
+ signalMap.set(key, summary);
26644
+ }
26645
+ }
26646
+ if (signalMap.size > 0) {
26647
+ results.set(artifactId, signalMap);
26648
+ }
26649
+ }
26650
+ }
26651
+ }
26652
+ }
26653
+ }
26654
+ }
26655
+ return results;
26656
+ }
26657
+ function parseLlmJson(text) {
26658
+ try {
26659
+ return JSON.parse(text);
26660
+ } catch {
26661
+ const match = text.match(/```(?:json)?\s*([\s\S]*?)```/);
26662
+ if (match) {
26663
+ try {
26664
+ return JSON.parse(match[1]);
26665
+ } catch {
26666
+ }
26667
+ }
26668
+ return null;
26669
+ }
26670
+ }
26406
26671
  function formatProgressReport(report) {
26407
26672
  const parts = [];
26408
26673
  parts.push(`# Sprint Progress Assessment \u2014 ${report.sprintId}`);
@@ -26486,7 +26751,7 @@ function formatProgressReport(report) {
26486
26751
  }
26487
26752
  function formatItemLine(parts, item, depth) {
26488
26753
  const indent = " ".repeat(depth + 1);
26489
- const statusIcon = DONE_STATUSES16.has(item.marvinStatus) ? "\u2713" : item.marvinStatus === "blocked" ? "\u{1F6AB}" : item.marvinStatus === "in-progress" ? "\u25B6" : "\u25CB";
26754
+ const statusIcon = DONE_STATUSES15.has(item.marvinStatus) ? "\u2713" : item.marvinStatus === "blocked" ? "\u{1F6AB}" : item.marvinStatus === "in-progress" ? "\u25B6" : "\u25CB";
26490
26755
  const jiraLabel = item.jiraKey ? ` [${item.jiraKey}: ${item.jiraStatus}]` : "";
26491
26756
  const driftFlag = item.statusDrift ? " \u26A0drift" : "";
26492
26757
  const progressLabel = ` ${item.progress}%`;
@@ -26496,6 +26761,19 @@ function formatItemLine(parts, item, depth) {
26496
26761
  if (item.commentSummary) {
26497
26762
  parts.push(`${indent} \u{1F4AC} ${item.commentSummary}`);
26498
26763
  }
26764
+ if (item.linkedIssues.length > 0) {
26765
+ parts.push(`${indent} \u{1F517} Linked Issues:`);
26766
+ for (const link of item.linkedIssues) {
26767
+ const doneMarker = link.isDone ? " \u2713" : "";
26768
+ const blockerResolved = link.isDone && BLOCKER_LINK_PATTERNS.some((p) => link.relationship.toLowerCase().includes(p.split(" ")[0])) ? " unblock signal" : "";
26769
+ const wontDo = WONT_DO_STATUSES.has(link.status.toLowerCase()) ? " \u26A0 needs review" : "";
26770
+ parts.push(`${indent} ${link.relationship} ${link.key} "${link.summary}" [${link.status}]${doneMarker}${blockerResolved}${wontDo}`);
26771
+ const signal = item.linkedIssueSignals.find((s) => s.sourceKey === link.key);
26772
+ if (signal?.commentSummary) {
26773
+ parts.push(`${indent} \u{1F4AC} ${signal.commentSummary}`);
26774
+ }
26775
+ }
26776
+ }
26499
26777
  for (const child of item.children) {
26500
26778
  formatItemLine(parts, child, depth + 1);
26501
26779
  }
@@ -26505,6 +26783,506 @@ function progressBar6(pct) {
26505
26783
  const empty = 10 - filled;
26506
26784
  return `[${"\u2588".repeat(filled)}${"\u2591".repeat(empty)}]`;
26507
26785
  }
26786
+ var MAX_ARTIFACT_NODES = 50;
26787
+ var MAX_LLM_DEPTH = 3;
26788
+ var MAX_LLM_COMMENT_CHARS = 8e3;
26789
+ async function assessArtifact(store, client, host, options) {
26790
+ const visited = /* @__PURE__ */ new Set();
26791
+ return _assessArtifactRecursive(store, client, host, options, visited, 0);
26792
+ }
26793
+ async function _assessArtifactRecursive(store, client, host, options, visited, depth) {
26794
+ const errors = [];
26795
+ if (visited.has(options.artifactId)) {
26796
+ return emptyArtifactReport(options.artifactId, [`Cycle detected: ${options.artifactId} already visited`]);
26797
+ }
26798
+ if (visited.size >= MAX_ARTIFACT_NODES) {
26799
+ return emptyArtifactReport(options.artifactId, [`Node cap reached (${MAX_ARTIFACT_NODES}), skipping ${options.artifactId}`]);
26800
+ }
26801
+ visited.add(options.artifactId);
26802
+ const doc = store.get(options.artifactId);
26803
+ if (!doc) {
26804
+ return emptyArtifactReport(options.artifactId, [`Artifact ${options.artifactId} not found`]);
26805
+ }
26806
+ const fm = doc.frontmatter;
26807
+ const jiraKey = fm.jiraKey ?? extractJiraKeyFromTags(fm.tags) ?? null;
26808
+ const tags = fm.tags ?? [];
26809
+ const sprintTag = tags.find((t) => t.startsWith("sprint:"));
26810
+ const sprint = sprintTag ? sprintTag.slice(7) : null;
26811
+ const parent = fm.aboutArtifact ?? null;
26812
+ let jiraStatus = null;
26813
+ let jiraAssignee = null;
26814
+ let proposedMarvinStatus = null;
26815
+ let jiraSubtaskProgress = null;
26816
+ const commentSignals = [];
26817
+ let commentSummary = null;
26818
+ let linkedIssues = [];
26819
+ let linkedIssueSignals = [];
26820
+ const proposedUpdates = [];
26821
+ const jiraIssues = /* @__PURE__ */ new Map();
26822
+ const linkedJiraIssues = /* @__PURE__ */ new Map();
26823
+ if (jiraKey) {
26824
+ try {
26825
+ const [issue2, comments] = await Promise.all([
26826
+ client.getIssueWithLinks(jiraKey),
26827
+ client.getComments(jiraKey)
26828
+ ]);
26829
+ jiraIssues.set(jiraKey, { issue: issue2, comments });
26830
+ jiraStatus = issue2.fields.status.name;
26831
+ jiraAssignee = issue2.fields.assignee?.displayName ?? null;
26832
+ const inSprint = isInActiveSprint(store, fm.tags);
26833
+ const resolved = options.statusMap ?? {};
26834
+ proposedMarvinStatus = fm.type === "task" ? mapJiraStatusForTask(jiraStatus, resolved, inSprint) : mapJiraStatusForAction(jiraStatus, resolved, inSprint);
26835
+ const subtasks = issue2.fields.subtasks ?? [];
26836
+ if (subtasks.length > 0) {
26837
+ jiraSubtaskProgress = computeSubtaskProgress(subtasks);
26838
+ }
26839
+ for (const comment of comments) {
26840
+ const text = extractCommentText(comment.body);
26841
+ const signals2 = detectCommentSignals(text);
26842
+ commentSignals.push(...signals2);
26843
+ }
26844
+ const jiraVisited = /* @__PURE__ */ new Set([jiraKey]);
26845
+ const queue = [];
26846
+ const directLinks = collectLinkedIssues(issue2).filter((l) => l.relationship !== "subtask");
26847
+ for (const link of directLinks) {
26848
+ if (!jiraVisited.has(link.key)) {
26849
+ jiraVisited.add(link.key);
26850
+ queue.push(link.key);
26851
+ }
26852
+ }
26853
+ while (queue.length > 0 && linkedJiraIssues.size < MAX_LINKED_ISSUES) {
26854
+ const remaining = MAX_LINKED_ISSUES - linkedJiraIssues.size;
26855
+ const batch = queue.splice(0, Math.min(BATCH_SIZE, remaining));
26856
+ const results = await Promise.allSettled(
26857
+ batch.map(async (key) => {
26858
+ const [li, lc] = await Promise.all([
26859
+ client.getIssueWithLinks(key),
26860
+ client.getComments(key)
26861
+ ]);
26862
+ return { key, issue: li, comments: lc };
26863
+ })
26864
+ );
26865
+ for (const result of results) {
26866
+ if (result.status === "fulfilled") {
26867
+ const { key, issue: li, comments: lc } = result.value;
26868
+ linkedJiraIssues.set(key, { issue: li, comments: lc });
26869
+ const newLinks = collectLinkedIssues(li).filter((l) => l.relationship !== "subtask" && !jiraVisited.has(l.key));
26870
+ for (const nl of newLinks) {
26871
+ jiraVisited.add(nl.key);
26872
+ queue.push(nl.key);
26873
+ }
26874
+ } else {
26875
+ const batchKey = batch[results.indexOf(result)];
26876
+ errors.push(`Failed to fetch linked issue ${batchKey}: ${result.reason instanceof Error ? result.reason.message : String(result.reason)}`);
26877
+ }
26878
+ }
26879
+ }
26880
+ const { allLinks, allSignals } = collectTransitiveLinks(
26881
+ issue2,
26882
+ jiraIssues,
26883
+ linkedJiraIssues
26884
+ );
26885
+ linkedIssues = allLinks;
26886
+ linkedIssueSignals = allSignals;
26887
+ analyzeLinkedIssueSignals(allLinks, fm, jiraKey, proposedUpdates);
26888
+ } catch (err) {
26889
+ errors.push(`Failed to fetch ${jiraKey}: ${err instanceof Error ? err.message : String(err)}`);
26890
+ }
26891
+ }
26892
+ const currentProgress = getEffectiveProgress(fm);
26893
+ const statusDrift = proposedMarvinStatus !== null && proposedMarvinStatus !== fm.status;
26894
+ const progressDrift = jiraSubtaskProgress !== null && !fm.progressOverride && jiraSubtaskProgress !== currentProgress;
26895
+ if (statusDrift && proposedMarvinStatus) {
26896
+ proposedUpdates.push({
26897
+ artifactId: fm.id,
26898
+ field: "status",
26899
+ currentValue: fm.status,
26900
+ proposedValue: proposedMarvinStatus,
26901
+ reason: `Jira ${jiraKey} is "${jiraStatus}" \u2192 maps to "${proposedMarvinStatus}"`
26902
+ });
26903
+ }
26904
+ if (progressDrift && jiraSubtaskProgress !== null) {
26905
+ proposedUpdates.push({
26906
+ artifactId: fm.id,
26907
+ field: "progress",
26908
+ currentValue: currentProgress,
26909
+ proposedValue: jiraSubtaskProgress,
26910
+ reason: `Jira ${jiraKey} subtask progress is ${jiraSubtaskProgress}%`
26911
+ });
26912
+ } else if (statusDrift && proposedMarvinStatus && !fm.progressOverride) {
26913
+ const hasExplicitProgress = "progress" in fm && typeof fm.progress === "number";
26914
+ if (!hasExplicitProgress) {
26915
+ const proposedProgress = STATUS_PROGRESS_DEFAULTS[proposedMarvinStatus] ?? 0;
26916
+ if (proposedProgress !== currentProgress) {
26917
+ proposedUpdates.push({
26918
+ artifactId: fm.id,
26919
+ field: "progress",
26920
+ currentValue: currentProgress,
26921
+ proposedValue: proposedProgress,
26922
+ reason: `Status changing to "${proposedMarvinStatus}" \u2192 default progress ${proposedProgress}%`
26923
+ });
26924
+ }
26925
+ }
26926
+ }
26927
+ const primaryHasComments = jiraKey ? (jiraIssues.get(jiraKey)?.comments.length ?? 0) > 0 : false;
26928
+ if (depth < MAX_LLM_DEPTH && jiraKey && primaryHasComments) {
26929
+ const estimatedChars = estimateCommentTextSize(jiraIssues, linkedJiraIssues, linkedIssueSignals);
26930
+ if (estimatedChars <= MAX_LLM_COMMENT_CHARS) {
26931
+ try {
26932
+ const summary = await analyzeSingleArtifactComments(
26933
+ fm.id,
26934
+ fm.title,
26935
+ jiraKey,
26936
+ jiraStatus,
26937
+ jiraIssues,
26938
+ linkedJiraIssues,
26939
+ linkedIssueSignals
26940
+ );
26941
+ commentSummary = summary;
26942
+ } catch (err) {
26943
+ errors.push(`Comment analysis failed: ${err instanceof Error ? err.message : String(err)}`);
26944
+ }
26945
+ }
26946
+ }
26947
+ const childIds = findChildIds(store, fm);
26948
+ const children = [];
26949
+ for (const childId of childIds) {
26950
+ if (visited.size >= MAX_ARTIFACT_NODES) {
26951
+ errors.push(`Node cap reached (${MAX_ARTIFACT_NODES}), ${childIds.length - children.length} children skipped`);
26952
+ break;
26953
+ }
26954
+ const childReport = await _assessArtifactRecursive(
26955
+ store,
26956
+ client,
26957
+ host,
26958
+ { ...options, artifactId: childId },
26959
+ visited,
26960
+ depth + 1
26961
+ );
26962
+ children.push(childReport);
26963
+ }
26964
+ const signals = buildSignals(commentSignals, linkedIssues, statusDrift, proposedMarvinStatus);
26965
+ const appliedUpdates = [];
26966
+ if (options.applyUpdates && proposedUpdates.length > 0) {
26967
+ for (const update of proposedUpdates) {
26968
+ if (update.field === "review") continue;
26969
+ try {
26970
+ store.update(update.artifactId, {
26971
+ [update.field]: update.proposedValue,
26972
+ lastJiraSyncAt: (/* @__PURE__ */ new Date()).toISOString()
26973
+ });
26974
+ const updatedDoc = store.get(update.artifactId);
26975
+ if (updatedDoc) {
26976
+ if (updatedDoc.frontmatter.type === "task") {
26977
+ propagateProgressFromTask(store, update.artifactId);
26978
+ } else if (updatedDoc.frontmatter.type === "action") {
26979
+ propagateProgressToAction(store, update.artifactId);
26980
+ }
26981
+ }
26982
+ appliedUpdates.push(update);
26983
+ } catch (err) {
26984
+ errors.push(`Failed to apply update: ${err instanceof Error ? err.message : String(err)}`);
26985
+ }
26986
+ }
26987
+ }
26988
+ return {
26989
+ artifactId: fm.id,
26990
+ title: fm.title,
26991
+ type: fm.type,
26992
+ marvinStatus: fm.status,
26993
+ marvinProgress: currentProgress,
26994
+ sprint,
26995
+ parent,
26996
+ jiraKey,
26997
+ jiraStatus,
26998
+ jiraAssignee,
26999
+ jiraSubtaskProgress,
27000
+ proposedMarvinStatus,
27001
+ statusDrift,
27002
+ progressDrift,
27003
+ commentSignals,
27004
+ commentSummary,
27005
+ linkedIssues,
27006
+ linkedIssueSignals,
27007
+ children,
27008
+ proposedUpdates: options.applyUpdates ? [] : proposedUpdates,
27009
+ appliedUpdates,
27010
+ signals,
27011
+ errors
27012
+ };
27013
+ }
27014
+ function findChildIds(store, fm) {
27015
+ if (fm.type === "action") {
27016
+ return store.list({ type: "task" }).filter((d) => d.frontmatter.aboutArtifact === fm.id).map((d) => d.frontmatter.id);
27017
+ }
27018
+ if (fm.type === "epic") {
27019
+ const epicTag = `epic:${fm.id}`;
27020
+ const isLinked = (d) => {
27021
+ const le = d.frontmatter.linkedEpic;
27022
+ if (le?.includes(fm.id)) return true;
27023
+ const t = d.frontmatter.tags ?? [];
27024
+ return t.includes(epicTag);
27025
+ };
27026
+ return [
27027
+ ...store.list({ type: "action" }).filter(isLinked),
27028
+ ...store.list({ type: "task" }).filter(isLinked)
27029
+ ].map((d) => d.frontmatter.id);
27030
+ }
27031
+ return [];
27032
+ }
27033
+ function buildSignals(commentSignals, linkedIssues, statusDrift, proposedStatus) {
27034
+ const signals = [];
27035
+ const blockerSignals = commentSignals.filter((s) => s.type === "blocker");
27036
+ if (blockerSignals.length > 0) {
27037
+ for (const s of blockerSignals) {
27038
+ signals.push(`\u{1F6AB} Blocker \u2014 "${s.snippet}"`);
27039
+ }
27040
+ }
27041
+ const blockingLinks = linkedIssues.filter(
27042
+ (l) => l.relationship.toLowerCase().includes("block")
27043
+ );
27044
+ const activeBlockers = blockingLinks.filter((l) => !l.isDone);
27045
+ const resolvedBlockers = blockingLinks.filter((l) => l.isDone);
27046
+ if (activeBlockers.length > 0) {
27047
+ for (const b of activeBlockers) {
27048
+ signals.push(`\u{1F6AB} Blocker \u2014 ${b.relationship} ${b.key} "${b.summary}" [${b.status}]`);
27049
+ }
27050
+ }
27051
+ if (resolvedBlockers.length > 0 && activeBlockers.length === 0) {
27052
+ signals.push(`\u2705 Unblocked \u2014 all blocking issues resolved: ${resolvedBlockers.map((l) => l.key).join(", ")}`);
27053
+ }
27054
+ const wontDoLinks = linkedIssues.filter((l) => WONT_DO_STATUSES.has(l.status.toLowerCase()));
27055
+ for (const l of wontDoLinks) {
27056
+ signals.push(`\u{1F504} Superseded \u2014 ${l.key} "${l.summary}" is ${l.status}`);
27057
+ }
27058
+ const questionSignals = commentSignals.filter((s) => s.type === "question");
27059
+ for (const s of questionSignals) {
27060
+ signals.push(`\u23F3 Waiting \u2014 "${s.snippet}"`);
27061
+ }
27062
+ const relatedInProgress = linkedIssues.filter(
27063
+ (l) => l.relationship.toLowerCase().includes("relate") && !l.isDone
27064
+ );
27065
+ if (relatedInProgress.length > 0) {
27066
+ for (const l of relatedInProgress) {
27067
+ signals.push(`\u{1F4CB} Handoff \u2014 related work on ${l.key} "${l.summary}" [${l.status}]`);
27068
+ }
27069
+ }
27070
+ if (signals.length === 0) {
27071
+ if (statusDrift && proposedStatus) {
27072
+ signals.push(`\u26A0 Drift detected \u2014 Marvin and Jira statuses diverge`);
27073
+ } else {
27074
+ signals.push(`\u2705 No active blockers or concerns detected`);
27075
+ }
27076
+ }
27077
+ return signals;
27078
+ }
27079
+ function estimateCommentTextSize(jiraIssues, linkedJiraIssues, linkedIssueSignals) {
27080
+ let total = 0;
27081
+ for (const [, data] of jiraIssues) {
27082
+ for (const c of data.comments) {
27083
+ total += typeof c.body === "string" ? c.body.length : JSON.stringify(c.body).length;
27084
+ }
27085
+ }
27086
+ for (const signal of linkedIssueSignals) {
27087
+ const linkedData = linkedJiraIssues.get(signal.sourceKey);
27088
+ if (!linkedData) continue;
27089
+ for (const c of linkedData.comments) {
27090
+ total += typeof c.body === "string" ? c.body.length : JSON.stringify(c.body).length;
27091
+ }
27092
+ }
27093
+ return total;
27094
+ }
27095
+ var SINGLE_ARTIFACT_COMMENT_PROMPT = `You are a delivery management assistant analyzing Jira comments for a single work item.
27096
+
27097
+ Produce a 2-3 sentence progress summary covering:
27098
+ - What work has been completed
27099
+ - What is pending or blocked
27100
+ - Any decisions, handoffs, or scheduling mentioned
27101
+ - Relevant context from linked issue comments (if provided)
27102
+
27103
+ Return ONLY the summary text, no JSON or formatting.`;
27104
+ async function analyzeSingleArtifactComments(artifactId, title, jiraKey, jiraStatus, jiraIssues, linkedJiraIssues, linkedIssueSignals) {
27105
+ const promptParts = [];
27106
+ const primaryData = jiraIssues.get(jiraKey);
27107
+ if (primaryData && primaryData.comments.length > 0) {
27108
+ const commentTexts = primaryData.comments.map((c) => {
27109
+ const text = extractCommentText(c.body);
27110
+ return `[${c.author.displayName}, ${c.created.slice(0, 10)}]: ${text.slice(0, 500)}`;
27111
+ }).join("\n");
27112
+ promptParts.push(`## ${artifactId} \u2014 ${title} (${jiraKey}, status: ${jiraStatus})
27113
+ Comments:
27114
+ ${commentTexts}`);
27115
+ }
27116
+ for (const signal of linkedIssueSignals) {
27117
+ const linkedData = linkedJiraIssues.get(signal.sourceKey);
27118
+ if (!linkedData || linkedData.comments.length === 0) continue;
27119
+ const commentTexts = linkedData.comments.map((c) => {
27120
+ const text = extractCommentText(c.body);
27121
+ return ` [${c.author.displayName}, ${c.created.slice(0, 10)}]: ${text.slice(0, 300)}`;
27122
+ }).join("\n");
27123
+ promptParts.push(`### Linked: ${signal.sourceKey} (${signal.linkType})
27124
+ ${commentTexts}`);
27125
+ }
27126
+ if (promptParts.length === 0) return null;
27127
+ const prompt = promptParts.join("\n\n");
27128
+ const result = query3({
27129
+ prompt,
27130
+ options: {
27131
+ systemPrompt: SINGLE_ARTIFACT_COMMENT_PROMPT,
27132
+ maxTurns: 1,
27133
+ tools: [],
27134
+ allowedTools: []
27135
+ }
27136
+ });
27137
+ for await (const msg of result) {
27138
+ if (msg.type === "assistant") {
27139
+ const textBlock = msg.message.content.find(
27140
+ (b) => b.type === "text"
27141
+ );
27142
+ if (textBlock) {
27143
+ return textBlock.text.trim();
27144
+ }
27145
+ }
27146
+ }
27147
+ return null;
27148
+ }
27149
+ function emptyArtifactReport(artifactId, errors) {
27150
+ return {
27151
+ artifactId,
27152
+ title: "Not found",
27153
+ type: "unknown",
27154
+ marvinStatus: "unknown",
27155
+ marvinProgress: 0,
27156
+ sprint: null,
27157
+ parent: null,
27158
+ jiraKey: null,
27159
+ jiraStatus: null,
27160
+ jiraAssignee: null,
27161
+ jiraSubtaskProgress: null,
27162
+ proposedMarvinStatus: null,
27163
+ statusDrift: false,
27164
+ progressDrift: false,
27165
+ commentSignals: [],
27166
+ commentSummary: null,
27167
+ linkedIssues: [],
27168
+ linkedIssueSignals: [],
27169
+ children: [],
27170
+ proposedUpdates: [],
27171
+ appliedUpdates: [],
27172
+ signals: [],
27173
+ errors
27174
+ };
27175
+ }
27176
+ function formatArtifactReport(report) {
27177
+ const parts = [];
27178
+ parts.push(`# Artifact Assessment \u2014 ${report.artifactId}`);
27179
+ parts.push(report.title);
27180
+ parts.push("");
27181
+ parts.push(`## Marvin State`);
27182
+ const marvinParts = [`Status: ${report.marvinStatus}`, `Progress: ${report.marvinProgress}%`];
27183
+ if (report.sprint) marvinParts.push(`Sprint: ${report.sprint}`);
27184
+ if (report.parent) marvinParts.push(`Parent: ${report.parent}`);
27185
+ parts.push(marvinParts.join(" | "));
27186
+ parts.push("");
27187
+ if (report.jiraKey) {
27188
+ parts.push(`## Jira State (${report.jiraKey})`);
27189
+ const jiraParts = [`Status: ${report.jiraStatus ?? "unknown"}`];
27190
+ if (report.jiraAssignee) jiraParts.push(`Assignee: ${report.jiraAssignee}`);
27191
+ if (report.jiraSubtaskProgress !== null) jiraParts.push(`Subtask progress: ${report.jiraSubtaskProgress}%`);
27192
+ parts.push(jiraParts.join(" | "));
27193
+ if (report.statusDrift) {
27194
+ parts.push(`\u26A0 Drift: ${report.marvinStatus} \u2192 ${report.proposedMarvinStatus}`);
27195
+ }
27196
+ if (report.progressDrift && report.jiraSubtaskProgress !== null) {
27197
+ parts.push(`\u26A0 Progress drift: ${report.marvinProgress}% \u2192 ${report.jiraSubtaskProgress}%`);
27198
+ }
27199
+ parts.push("");
27200
+ }
27201
+ if (report.commentSummary) {
27202
+ parts.push(`## Comments`);
27203
+ parts.push(report.commentSummary);
27204
+ parts.push("");
27205
+ }
27206
+ if (report.children.length > 0) {
27207
+ const doneCount = report.children.filter((c) => DONE_STATUSES15.has(c.marvinStatus)).length;
27208
+ const childWeights = report.children.map((c) => {
27209
+ const { weight } = resolveWeight(void 0);
27210
+ return { weight, progress: c.marvinProgress };
27211
+ });
27212
+ const childProgress = childWeights.length > 0 ? Math.round(childWeights.reduce((s, c) => s + c.weight * c.progress, 0) / childWeights.reduce((s, c) => s + c.weight, 0)) : 0;
27213
+ const bar = progressBar6(childProgress);
27214
+ parts.push(`## Children (${doneCount}/${report.children.length} done) ${bar} ${childProgress}%`);
27215
+ for (const child of report.children) {
27216
+ formatArtifactChild(parts, child, 1);
27217
+ }
27218
+ parts.push("");
27219
+ }
27220
+ if (report.linkedIssues.length > 0) {
27221
+ parts.push(`## Linked Issues (${report.linkedIssues.length})`);
27222
+ for (const link of report.linkedIssues) {
27223
+ const doneMarker = link.isDone ? " \u2713" : "";
27224
+ parts.push(` ${link.relationship} ${link.key} "${link.summary}" [${link.status}]${doneMarker}`);
27225
+ const signal = report.linkedIssueSignals.find((s) => s.sourceKey === link.key);
27226
+ if (signal?.commentSummary) {
27227
+ parts.push(` \u{1F4AC} ${signal.commentSummary}`);
27228
+ }
27229
+ }
27230
+ parts.push("");
27231
+ }
27232
+ if (report.signals.length > 0) {
27233
+ parts.push(`## Signals`);
27234
+ for (const s of report.signals) {
27235
+ parts.push(` ${s}`);
27236
+ }
27237
+ parts.push("");
27238
+ }
27239
+ if (report.proposedUpdates.length > 0) {
27240
+ parts.push(`## Proposed Updates (${report.proposedUpdates.length})`);
27241
+ for (const update of report.proposedUpdates) {
27242
+ parts.push(` ${update.artifactId}.${update.field}: ${String(update.currentValue)} \u2192 ${String(update.proposedValue)}`);
27243
+ parts.push(` Reason: ${update.reason}`);
27244
+ }
27245
+ parts.push("");
27246
+ parts.push("Run with applyUpdates=true to apply these changes.");
27247
+ parts.push("");
27248
+ }
27249
+ if (report.appliedUpdates.length > 0) {
27250
+ parts.push(`## Applied Updates (${report.appliedUpdates.length})`);
27251
+ for (const update of report.appliedUpdates) {
27252
+ parts.push(` \u2713 ${update.artifactId}.${update.field}: ${String(update.currentValue)} \u2192 ${String(update.proposedValue)}`);
27253
+ }
27254
+ parts.push("");
27255
+ }
27256
+ if (report.errors.length > 0) {
27257
+ parts.push(`## Errors`);
27258
+ for (const err of report.errors) {
27259
+ parts.push(` ${err}`);
27260
+ }
27261
+ parts.push("");
27262
+ }
27263
+ return parts.join("\n");
27264
+ }
27265
+ function formatArtifactChild(parts, child, depth) {
27266
+ const indent = " ".repeat(depth);
27267
+ const icon = DONE_STATUSES15.has(child.marvinStatus) ? "\u2713" : child.marvinStatus === "blocked" ? "\u{1F6AB}" : child.marvinStatus === "in-progress" ? "\u25B6" : "\u25CB";
27268
+ const jiraLabel = child.jiraKey ? ` [${child.jiraKey}: ${child.jiraStatus ?? "?"}]` : "";
27269
+ const driftLabel = child.statusDrift ? ` \u26A0drift \u2192 ${child.proposedMarvinStatus}` : "";
27270
+ const signalHints = [];
27271
+ for (const s of child.signals) {
27272
+ if (s.startsWith("\u2705 No active")) continue;
27273
+ signalHints.push(s);
27274
+ }
27275
+ parts.push(`${indent}${icon} ${child.artifactId} \u2014 ${child.title} [${child.marvinStatus}] ${child.marvinProgress}%${jiraLabel}${driftLabel}`);
27276
+ if (child.commentSummary) {
27277
+ parts.push(`${indent} \u{1F4AC} ${child.commentSummary}`);
27278
+ }
27279
+ for (const hint of signalHints) {
27280
+ parts.push(`${indent} ${hint}`);
27281
+ }
27282
+ for (const grandchild of child.children) {
27283
+ formatArtifactChild(parts, grandchild, depth + 1);
27284
+ }
27285
+ }
26508
27286
 
26509
27287
  // src/skills/builtin/jira/tools.ts
26510
27288
  var JIRA_TYPE = "jira-issue";
@@ -26547,7 +27325,7 @@ function findByJiraKey(store, jiraKey) {
26547
27325
  function createJiraTools(store, projectConfig) {
26548
27326
  const jiraUserConfig = loadUserConfig().jira;
26549
27327
  const defaultProjectKey = projectConfig?.jira?.projectKey;
26550
- const statusMap = projectConfig?.jira?.statusMap;
27328
+ const statusMap = normalizeStatusMap(projectConfig?.jira?.statusMap);
26551
27329
  return [
26552
27330
  // --- Local read tools ---
26553
27331
  tool20(
@@ -27174,22 +27952,31 @@ function createJiraTools(store, projectConfig) {
27174
27952
  const s = issue2.fields.status.name;
27175
27953
  statusCounts.set(s, (statusCounts.get(s) ?? 0) + 1);
27176
27954
  }
27177
- const actionMap = statusMap?.action ?? DEFAULT_ACTION_STATUS_MAP;
27178
- const taskMap = statusMap?.task ?? DEFAULT_TASK_STATUS_MAP;
27179
27955
  const actionLookup = /* @__PURE__ */ new Map();
27180
- for (const [marvin, value] of Object.entries(actionMap)) {
27181
- const jiraStatuses = Array.isArray(value) ? value : value.default;
27182
- for (const js of jiraStatuses) actionLookup.set(js.toLowerCase(), marvin);
27183
- if (!Array.isArray(value) && value.inSprint) {
27184
- for (const js of value.inSprint) actionLookup.set(js.toLowerCase(), `${marvin} (inSprint)`);
27185
- }
27186
- }
27187
27956
  const taskLookup = /* @__PURE__ */ new Map();
27188
- for (const [marvin, value] of Object.entries(taskMap)) {
27189
- const jiraStatuses = Array.isArray(value) ? value : value.default;
27190
- for (const js of jiraStatuses) taskLookup.set(js.toLowerCase(), marvin);
27191
- if (!Array.isArray(value) && value.inSprint) {
27192
- for (const js of value.inSprint) taskLookup.set(js.toLowerCase(), `${marvin} (inSprint)`);
27957
+ if (statusMap.flat) {
27958
+ for (const [jiraStatus, value] of Object.entries(statusMap.flat)) {
27959
+ const lower = jiraStatus.toLowerCase();
27960
+ if (typeof value === "string") {
27961
+ actionLookup.set(lower, value);
27962
+ taskLookup.set(lower, value);
27963
+ } else {
27964
+ actionLookup.set(lower, value.default);
27965
+ taskLookup.set(lower, value.default);
27966
+ if (value.inSprint) {
27967
+ actionLookup.set(lower, `${value.default} / ${value.inSprint} (inSprint)`);
27968
+ taskLookup.set(lower, `${value.default} / ${value.inSprint} (inSprint)`);
27969
+ }
27970
+ }
27971
+ }
27972
+ } else {
27973
+ const actionMap = statusMap.legacy?.action ?? DEFAULT_ACTION_STATUS_MAP;
27974
+ const taskMap = statusMap.legacy?.task ?? DEFAULT_TASK_STATUS_MAP;
27975
+ for (const [marvin, jiraStatuses] of Object.entries(actionMap)) {
27976
+ for (const js of jiraStatuses) actionLookup.set(js.toLowerCase(), marvin);
27977
+ }
27978
+ for (const [marvin, jiraStatuses] of Object.entries(taskMap)) {
27979
+ for (const js of jiraStatuses) taskLookup.set(js.toLowerCase(), marvin);
27193
27980
  }
27194
27981
  }
27195
27982
  const parts = [
@@ -27211,25 +27998,20 @@ function createJiraTools(store, projectConfig) {
27211
27998
  if (!taskTarget) unmappedTask.push(status);
27212
27999
  }
27213
28000
  if (unmappedAction.length > 0 || unmappedTask.length > 0) {
28001
+ const allUnmapped = [.../* @__PURE__ */ new Set([...unmappedAction, ...unmappedTask])];
27214
28002
  parts.push("");
27215
28003
  parts.push("To fix unmapped statuses, add jira.statusMap to .marvin/config.yaml:");
27216
28004
  parts.push(" jira:");
27217
28005
  parts.push(" statusMap:");
27218
- if (unmappedAction.length > 0) {
27219
- parts.push(" action:");
27220
- parts.push(` # Map these: ${unmappedAction.join(", ")}`);
27221
- parts.push(" # <marvin-status>: [<jira-status>, ...]");
27222
- }
27223
- if (unmappedTask.length > 0) {
27224
- parts.push(" task:");
27225
- parts.push(` # Map these: ${unmappedTask.join(", ")}`);
27226
- parts.push(" # <marvin-status>: [<jira-status>, ...]");
28006
+ for (const s of allUnmapped) {
28007
+ parts.push(` "${s}": <marvin-status>`);
27227
28008
  }
28009
+ parts.push(" # Supported marvin statuses: done, in-progress, review, ready, blocked, backlog, open");
27228
28010
  } else {
27229
28011
  parts.push("");
27230
28012
  parts.push("All statuses are mapped.");
27231
28013
  }
27232
- const usingConfig = statusMap?.action || statusMap?.task;
28014
+ const usingConfig = statusMap.flat || statusMap.legacy;
27233
28015
  parts.push("");
27234
28016
  parts.push(usingConfig ? "Using status maps from .marvin/config.yaml." : "Using built-in default status maps (no jira.statusMap in config).");
27235
28017
  return {
@@ -27287,7 +28069,8 @@ function createJiraTools(store, projectConfig) {
27287
28069
  {
27288
28070
  sprintId: external_exports.string().optional().describe("Sprint ID (e.g. 'SP-001'). Defaults to active sprint."),
27289
28071
  analyzeComments: external_exports.boolean().optional().describe("Use LLM to summarize Jira comments for progress signals (default false)"),
27290
- applyUpdates: external_exports.boolean().optional().describe("Apply proposed status/progress updates to Marvin artifacts (default false)")
28072
+ applyUpdates: external_exports.boolean().optional().describe("Apply proposed status/progress updates to Marvin artifacts (default false)"),
28073
+ traverseLinks: external_exports.boolean().optional().describe("Traverse Jira issue links (1 hop) to surface context from connected issues \u2014 blockers, related work, cancelled items (default false)")
27291
28074
  },
27292
28075
  async (args) => {
27293
28076
  const jira = createJiraClient(jiraUserConfig);
@@ -27300,6 +28083,7 @@ function createJiraTools(store, projectConfig) {
27300
28083
  sprintId: args.sprintId,
27301
28084
  analyzeComments: args.analyzeComments ?? false,
27302
28085
  applyUpdates: args.applyUpdates ?? false,
28086
+ traverseLinks: args.traverseLinks ?? false,
27303
28087
  statusMap
27304
28088
  }
27305
28089
  );
@@ -27309,6 +28093,34 @@ function createJiraTools(store, projectConfig) {
27309
28093
  };
27310
28094
  },
27311
28095
  { annotations: { readOnlyHint: false } }
28096
+ ),
28097
+ // --- Single-artifact assessment ---
28098
+ tool20(
28099
+ "assess_artifact",
28100
+ "Deep assessment of a single Marvin artifact (task, action, or epic). Fetches live Jira status, analyzes comments with LLM, traverses all linked issues, detects drift, rolls up child progress, and extracts contextual signals (blockers, unblocks, handoffs, superseded work).",
28101
+ {
28102
+ artifactId: external_exports.string().describe("Marvin artifact ID (e.g. 'T-063', 'A-151', 'E-003')"),
28103
+ applyUpdates: external_exports.boolean().optional().describe("Apply proposed status/progress updates to the artifact (default false)")
28104
+ },
28105
+ async (args) => {
28106
+ const jira = createJiraClient(jiraUserConfig);
28107
+ if (!jira) return jiraNotConfiguredError();
28108
+ const report = await assessArtifact(
28109
+ store,
28110
+ jira.client,
28111
+ jira.host,
28112
+ {
28113
+ artifactId: args.artifactId,
28114
+ applyUpdates: args.applyUpdates ?? false,
28115
+ statusMap
28116
+ }
28117
+ );
28118
+ return {
28119
+ content: [{ type: "text", text: formatArtifactReport(report) }],
28120
+ isError: report.errors.length > 0 && report.type === "unknown"
28121
+ };
28122
+ },
28123
+ { annotations: { readOnlyHint: false } }
27312
28124
  )
27313
28125
  ];
27314
28126
  }
@@ -27410,7 +28222,8 @@ var COMMON_TOOLS = `**Available tools:**
27410
28222
  - \`read_confluence_page\` \u2014 **read-only**: fetch and return the content of a Confluence page by URL or page ID. Use this to review Confluence content for updating tasks, generating contributions, or answering questions.
27411
28223
  - \`fetch_jira_status\` \u2014 **read-only**: fetch current Jira status, subtask progress, and linked issues for Jira-linked actions/tasks. Returns proposed changes without applying them.
27412
28224
  - \`fetch_jira_daily\` \u2014 **read-only**: fetch a daily/range summary of all Jira changes \u2014 status transitions, comments, linked Confluence pages, and cross-references with Marvin artifacts. Returns proposed actions (status updates, unlinked issues, question candidates, Confluence pages to review).
27413
- - \`assess_sprint_progress\` \u2014 fetch live Jira statuses for all sprint-scoped items, detect drift, group by focus area with rollup progress, and extract comment signals. Use \`analyzeComments=true\` for LLM summaries, \`applyUpdates=true\` to apply changes.
28225
+ - \`assess_sprint_progress\` \u2014 fetch live Jira statuses for all sprint-scoped items, detect drift, group by focus area with rollup progress, and extract comment signals. Use \`analyzeComments=true\` for LLM summaries, \`applyUpdates=true\` to apply changes, \`traverseLinks=true\` to walk Jira issue links recursively.
28226
+ - \`assess_artifact\` \u2014 deep assessment of a single artifact (task, action, or epic). Fetches Jira status, LLM-summarizes comments, recursively traverses linked issues, detects drift, rolls up child progress, and extracts contextual signals (blockers, unblocks, handoffs, superseded work). Use \`applyUpdates=true\` to apply proposed changes.
27414
28227
  - \`fetch_jira_statuses\` \u2014 **read-only**: discover all Jira statuses in a project and show their Marvin mappings (mapped vs unmapped).
27415
28228
  - \`search_jira\` \u2014 **read-only**: search Jira via JQL and return results with Marvin cross-references. No documents created \u2014 use to preview before importing or find issues for linking.
27416
28229
  - \`pull_jira_issue\` / \`pull_jira_issues_jql\` \u2014 import Jira issues as local JI-xxx documents (for Jira-originated items with no existing Marvin artifact).
@@ -27427,6 +28240,11 @@ var COMMON_WORKFLOW = `**Jira sync workflow:**
27427
28240
  2. Review focus area rollups, status drift, and blockers
27428
28241
  3. Optionally run with \`applyUpdates=true\` to bulk-sync statuses, or \`analyzeComments=true\` for LLM-powered comment summaries
27429
28242
 
28243
+ **Single-artifact deep dive:**
28244
+ 1. Call \`assess_artifact\` with an artifact ID to get a focused assessment with Jira sync, comment analysis, link traversal, and child rollup
28245
+ 2. Review signals (blockers, unblocks, handoffs) and proposed updates
28246
+ 3. Use \`applyUpdates=true\` to apply changes
28247
+
27430
28248
  **Daily review workflow:**
27431
28249
  1. Call \`fetch_jira_daily\` (optionally with \`from\`/\`to\` date range) to get a summary of all Jira activity
27432
28250
  2. Review the proposed actions: status updates, unlinked issues to track, questions that may be answered, Confluence pages to review
@@ -27451,6 +28269,7 @@ ${COMMON_WORKFLOW}
27451
28269
  **As Product Owner, use Jira integration to:**
27452
28270
  - Use \`fetch_jira_daily\` for daily standups \u2014 review what changed, identify status drift, spot untracked work
27453
28271
  - Use \`assess_sprint_progress\` for sprint reviews \u2014 see overall progress by focus area, detect drift, and identify blockers
28272
+ - Use \`assess_artifact\` for deep dives into specific features or epics \u2014 full Jira context, linked issues, and child rollup
27454
28273
  - Pull stakeholder-reported issues for triage and prioritization
27455
28274
  - Push approved features as Stories for development tracking
27456
28275
  - Link decisions to Jira issues for audit trail and traceability
@@ -27464,6 +28283,7 @@ ${COMMON_WORKFLOW}
27464
28283
  **As Tech Lead, use Jira integration to:**
27465
28284
  - Use \`fetch_jira_daily\` to review technical progress \u2014 status transitions, new comments, Confluence design docs
27466
28285
  - Use \`assess_sprint_progress\` for sprint health checks \u2014 focus area rollups, Jira drift detection, blocker tracking
28286
+ - Use \`assess_artifact\` to investigate a specific task \u2014 see full dependency chain, comment context, and blocker status
27467
28287
  - Pull technical issues and bugs for sprint planning and estimation
27468
28288
  - Push epics, tasks, and technical decisions to Jira for cross-team visibility
27469
28289
  - Use \`link_to_jira\` to connect Marvin tasks to existing Jira tickets
@@ -27479,6 +28299,7 @@ This is a third path for progress tracking alongside Contributions and Meetings.
27479
28299
  - Use \`fetch_jira_daily\` for daily progress reports \u2014 track what moved, identify blockers, spot untracked work
27480
28300
  - Use \`assess_sprint_progress\` for sprint reviews and stakeholder updates \u2014 comprehensive progress by focus area with Jira enrichment
27481
28301
  - Use \`assess_sprint_progress\` with \`applyUpdates=true\` to bulk-sync Marvin statuses from Jira
28302
+ - Use \`assess_artifact\` for focused status checks on critical items \u2014 full Jira context without running the whole sprint assessment
27482
28303
  - Pull sprint issues for tracking progress and blockers
27483
28304
  - Push actions and tasks to Jira for stakeholder visibility
27484
28305
  - Use \`fetch_jira_daily\` with a date range for sprint retrospectives (e.g. \`from: "2026-03-10", to: "2026-03-21"\`)
@@ -32364,7 +33185,7 @@ async function jiraSyncCommand(artifactId, options = {}) {
32364
33185
  );
32365
33186
  return;
32366
33187
  }
32367
- const statusMap = project.config.jira?.statusMap;
33188
+ const statusMap = normalizeStatusMap(project.config.jira?.statusMap);
32368
33189
  const label = artifactId ? `Checking ${artifactId} against Jira...` : "Checking all Jira-linked actions/tasks...";
32369
33190
  console.log(chalk20.dim(label));
32370
33191
  if (options.dryRun) {
@@ -32481,9 +33302,7 @@ async function jiraStatusesCommand(projectKey) {
32481
33302
  return;
32482
33303
  }
32483
33304
  console.log(chalk20.dim(`Fetching statuses from Jira project ${resolvedProjectKey}...`));
32484
- const statusMap = project.config.jira?.statusMap;
32485
- const actionMap = statusMap?.action ?? DEFAULT_ACTION_STATUS_MAP;
32486
- const taskMap = statusMap?.task ?? DEFAULT_TASK_STATUS_MAP;
33305
+ const statusMap = normalizeStatusMap(project.config.jira?.statusMap);
32487
33306
  const email3 = jiraUserConfig?.email ?? process.env.JIRA_EMAIL;
32488
33307
  const apiToken = jiraUserConfig?.apiToken ?? process.env.JIRA_API_TOKEN;
32489
33308
  const auth = "Basic " + Buffer.from(`${email3}:${apiToken}`).toString("base64");
@@ -32507,14 +33326,28 @@ async function jiraStatusesCommand(projectKey) {
32507
33326
  statusCounts.set(s, (statusCounts.get(s) ?? 0) + 1);
32508
33327
  }
32509
33328
  const actionLookup = /* @__PURE__ */ new Map();
32510
- for (const [marvin, value] of Object.entries(actionMap)) {
32511
- const jiraStatuses = Array.isArray(value) ? value : value.default;
32512
- for (const js of jiraStatuses) actionLookup.set(js.toLowerCase(), marvin);
32513
- }
32514
33329
  const taskLookup = /* @__PURE__ */ new Map();
32515
- for (const [marvin, value] of Object.entries(taskMap)) {
32516
- const jiraStatuses = Array.isArray(value) ? value : value.default;
32517
- for (const js of jiraStatuses) taskLookup.set(js.toLowerCase(), marvin);
33330
+ if (statusMap.flat) {
33331
+ for (const [jiraStatus, value] of Object.entries(statusMap.flat)) {
33332
+ const lower = jiraStatus.toLowerCase();
33333
+ if (typeof value === "string") {
33334
+ actionLookup.set(lower, value);
33335
+ taskLookup.set(lower, value);
33336
+ } else {
33337
+ const label = value.inSprint ? `${value.default} / ${value.inSprint} (inSprint)` : value.default;
33338
+ actionLookup.set(lower, label);
33339
+ taskLookup.set(lower, label);
33340
+ }
33341
+ }
33342
+ } else {
33343
+ const actionMap = statusMap.legacy?.action ?? DEFAULT_ACTION_STATUS_MAP;
33344
+ const taskMap = statusMap.legacy?.task ?? DEFAULT_TASK_STATUS_MAP;
33345
+ for (const [marvin, jiraStatuses] of Object.entries(actionMap)) {
33346
+ for (const js of jiraStatuses) actionLookup.set(js.toLowerCase(), marvin);
33347
+ }
33348
+ for (const [marvin, jiraStatuses] of Object.entries(taskMap)) {
33349
+ for (const js of jiraStatuses) taskLookup.set(js.toLowerCase(), marvin);
33350
+ }
32518
33351
  }
32519
33352
  console.log(
32520
33353
  `
@@ -32537,14 +33370,11 @@ Found ${chalk20.bold(String(statusCounts.size))} distinct statuses in ${chalk20.
32537
33370
  console.log(chalk20.yellow("\nSome statuses are unmapped. Add jira.statusMap to .marvin/config.yaml:"));
32538
33371
  console.log(chalk20.dim(" jira:"));
32539
33372
  console.log(chalk20.dim(" statusMap:"));
32540
- console.log(chalk20.dim(" action:"));
32541
- console.log(chalk20.dim(' <marvin-status>: ["<Jira Status>", ...]'));
32542
- console.log(chalk20.dim(" task:"));
32543
- console.log(chalk20.dim(' <marvin-status>: ["<Jira Status>", ...]'));
33373
+ console.log(chalk20.dim(' "<Jira Status>": <marvin-status>'));
32544
33374
  } else {
32545
33375
  console.log(chalk20.green("\nAll statuses are mapped."));
32546
33376
  }
32547
- const usingConfig = statusMap?.action || statusMap?.task;
33377
+ const usingConfig = statusMap.flat || statusMap.legacy;
32548
33378
  console.log(
32549
33379
  chalk20.dim(
32550
33380
  usingConfig ? "\nUsing status maps from .marvin/config.yaml." : "\nUsing built-in default status maps (no jira.statusMap in config)."
@@ -32579,7 +33409,7 @@ async function jiraDailyCommand(options) {
32579
33409
  const today = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
32580
33410
  const fromDate = options.from ?? today;
32581
33411
  const toDate = options.to ?? fromDate;
32582
- const statusMap = proj.config.jira?.statusMap;
33412
+ const statusMap = normalizeStatusMap(proj.config.jira?.statusMap);
32583
33413
  const rangeLabel = fromDate === toDate ? fromDate : `${fromDate} to ${toDate}`;
32584
33414
  console.log(
32585
33415
  chalk20.dim(`Fetching Jira daily summary for ${resolvedProjectKey} \u2014 ${rangeLabel}...`)
@@ -32696,7 +33526,7 @@ function createProgram() {
32696
33526
  const program2 = new Command();
32697
33527
  program2.name("marvin").description(
32698
33528
  "AI-powered product development assistant with Product Owner, Delivery Manager, and Technical Lead personas"
32699
- ).version("0.5.16");
33529
+ ).version("0.5.18");
32700
33530
  program2.command("init").description("Initialize a new Marvin project in the current directory").action(async () => {
32701
33531
  await initCommand();
32702
33532
  });