mrvn-cli 0.5.17 → 0.5.19

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
@@ -14594,6 +14594,14 @@ function calculateSprintCompletionPct(primaryDocs) {
14594
14594
 
14595
14595
  // src/reports/sprint-summary/collector.ts
14596
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;
14597
14605
  function collectSprintSummaryData(store, sprintId) {
14598
14606
  const allDocs = store.list();
14599
14607
  const sprintDocs = allDocs.filter((d) => d.frontmatter.type === "sprint");
@@ -14679,12 +14687,14 @@ function collectSprintSummaryData(store, sprintId) {
14679
14687
  for (const doc of workItemDocs) {
14680
14688
  const about = doc.frontmatter.aboutArtifact;
14681
14689
  const focusTag = (doc.frontmatter.tags ?? []).find((t) => t.startsWith("focus:"));
14690
+ const complexity = doc.frontmatter.complexity;
14682
14691
  const item = {
14683
14692
  id: doc.frontmatter.id,
14684
14693
  title: doc.frontmatter.title,
14685
14694
  type: doc.frontmatter.type,
14686
14695
  status: doc.frontmatter.status,
14687
14696
  progress: getEffectiveProgress(doc.frontmatter),
14697
+ weight: complexity && complexity in COMPLEXITY_WEIGHTS ? COMPLEXITY_WEIGHTS[complexity] : DEFAULT_WEIGHT,
14688
14698
  owner: doc.frontmatter.owner,
14689
14699
  workFocus: focusTag ? focusTag.slice(6) : void 0,
14690
14700
  aboutArtifact: about,
@@ -16814,7 +16824,7 @@ function createTaskTools(store) {
16814
16824
  priority: external_exports.enum(["critical", "high", "medium", "low"]).optional().describe("New priority"),
16815
16825
  tags: external_exports.array(external_exports.string()).optional().describe("Replace tags (e.g. remove old tags, add new ones)"),
16816
16826
  workFocus: external_exports.string().optional().describe("Work focus name (e.g. 'Budget UX'). Replaces existing focus:<value> tag."),
16817
- progress: external_exports.number().optional().describe("Explicit progress percentage (0-100). Overrides auto-calculation from child contributions.")
16827
+ progress: external_exports.number().nullable().optional().describe("Explicit progress percentage (0-100). Overrides auto-calculation from child contributions. Pass null to clear the override and revert to auto-calculation.")
16818
16828
  },
16819
16829
  async (args) => {
16820
16830
  const { id, content, linkedEpic: rawLinkedEpic, tags: userTags, workFocus, progress, ...updates } = args;
@@ -16847,6 +16857,8 @@ function createTaskTools(store) {
16847
16857
  if (typeof progress === "number") {
16848
16858
  updates.progress = Math.max(0, Math.min(100, Math.round(progress)));
16849
16859
  updates.progressOverride = true;
16860
+ } else if (progress === null) {
16861
+ updates.progressOverride = false;
16850
16862
  }
16851
16863
  const doc = store.update(id, updates, content);
16852
16864
  if (args.status !== void 0 || typeof progress === "number") {
@@ -18903,7 +18915,7 @@ function createActionTools(store) {
18903
18915
  tags: external_exports.array(external_exports.string()).optional().describe("Replace all tags. When provided with sprints, sprint tags are merged into this array."),
18904
18916
  sprints: external_exports.array(external_exports.string()).optional().describe("Sprint IDs to assign (replaces existing sprint tags). E.g. ['SP-001']."),
18905
18917
  workFocus: external_exports.string().optional().describe("Work focus name (e.g. 'Budget UX'). Replaces existing focus:<value> tag."),
18906
- progress: external_exports.number().optional().describe("Explicit progress percentage (0-100).")
18918
+ progress: external_exports.number().nullable().optional().describe("Explicit progress percentage (0-100). Pass null to clear the override and revert to auto-calculation from children.")
18907
18919
  },
18908
18920
  async (args) => {
18909
18921
  const { id, content, sprints, tags, workFocus, progress, owner, assignee, ...updates } = args;
@@ -18946,6 +18958,8 @@ function createActionTools(store) {
18946
18958
  if (typeof progress === "number") {
18947
18959
  updates.progress = Math.max(0, Math.min(100, Math.round(progress)));
18948
18960
  updates.progressOverride = true;
18961
+ } else if (progress === null) {
18962
+ updates.progressOverride = false;
18949
18963
  }
18950
18964
  const doc = store.update(id, updates, content);
18951
18965
  if (args.status !== void 0 || typeof progress === "number") {
@@ -22398,7 +22412,7 @@ function poBacklogPage(ctx) {
22398
22412
  }
22399
22413
  }
22400
22414
  }
22401
- const DONE_STATUSES17 = /* @__PURE__ */ new Set(["done", "closed", "resolved", "cancelled"]);
22415
+ const DONE_STATUSES16 = /* @__PURE__ */ new Set(["done", "closed", "resolved", "cancelled"]);
22402
22416
  function featureTaskStats(featureId) {
22403
22417
  const fEpics = featureToEpics.get(featureId) ?? [];
22404
22418
  let total = 0;
@@ -22407,7 +22421,7 @@ function poBacklogPage(ctx) {
22407
22421
  for (const epic of fEpics) {
22408
22422
  for (const t of epicToTasks.get(epic.frontmatter.id) ?? []) {
22409
22423
  total++;
22410
- if (DONE_STATUSES17.has(t.frontmatter.status)) done++;
22424
+ if (DONE_STATUSES16.has(t.frontmatter.status)) done++;
22411
22425
  progressSum += getEffectiveProgress(t.frontmatter);
22412
22426
  }
22413
22427
  }
@@ -22653,23 +22667,34 @@ function hashString(s) {
22653
22667
  }
22654
22668
  return Math.abs(h);
22655
22669
  }
22670
+ var DONE_STATUS_SET = /* @__PURE__ */ new Set(["done", "closed", "resolved", "decided"]);
22671
+ var DEFAULT_WEIGHT2 = 3;
22656
22672
  function countFocusStats(items) {
22657
22673
  let total = 0;
22658
22674
  let done = 0;
22659
22675
  let inProgress = 0;
22660
- function walk(list) {
22676
+ let totalWeight = 0;
22677
+ let weightedSum = 0;
22678
+ function walkStats(list) {
22661
22679
  for (const w of list) {
22662
22680
  if (w.type !== "contribution") {
22663
22681
  total++;
22664
22682
  const s = w.status.toLowerCase();
22665
- if (s === "done" || s === "closed" || s === "resolved" || s === "decided") done++;
22683
+ if (DONE_STATUS_SET.has(s)) done++;
22666
22684
  else if (s === "in-progress" || s === "in progress") inProgress++;
22667
22685
  }
22668
- if (w.children) walk(w.children);
22686
+ if (w.children) walkStats(w.children);
22669
22687
  }
22670
22688
  }
22671
- walk(items);
22672
- return { total, done, inProgress };
22689
+ walkStats(items);
22690
+ for (const w of items) {
22691
+ if (w.type === "contribution") continue;
22692
+ const weight = w.weight ?? DEFAULT_WEIGHT2;
22693
+ const progress = w.progress ?? (DONE_STATUS_SET.has(w.status.toLowerCase()) ? 100 : 0);
22694
+ totalWeight += weight;
22695
+ weightedSum += weight * progress;
22696
+ }
22697
+ return { total, done, inProgress, weightedProgress: totalWeight > 0 ? Math.round(weightedSum / totalWeight) : 0 };
22673
22698
  }
22674
22699
  var KNOWN_OWNERS2 = /* @__PURE__ */ new Set(["po", "tl", "dm"]);
22675
22700
  function ownerBadge2(owner) {
@@ -22718,7 +22743,7 @@ function renderWorkItemsTable(items, options) {
22718
22743
  for (const [focus, groupItems] of focusGroups) {
22719
22744
  const color = focusColorMap.get(focus);
22720
22745
  const stats = countFocusStats(groupItems);
22721
- const pct = stats.total > 0 ? Math.round(stats.done / stats.total * 100) : 0;
22746
+ const pct = stats.weightedProgress;
22722
22747
  const summaryParts = [];
22723
22748
  if (stats.done > 0) summaryParts.push(`${stats.done} done`);
22724
22749
  if (stats.inProgress > 0) summaryParts.push(`${stats.inProgress} in progress`);
@@ -25431,6 +25456,47 @@ function extractJiraKeyFromTags(tags) {
25431
25456
  const tag = tags.find((t) => /^jira:[A-Z]+-\d+$/i.test(t));
25432
25457
  return tag ? tag.slice(5) : void 0;
25433
25458
  }
25459
+ function collectLinkedIssues(issue2) {
25460
+ const linkedIssues = [];
25461
+ if (issue2.fields.subtasks) {
25462
+ for (const sub of issue2.fields.subtasks) {
25463
+ linkedIssues.push({
25464
+ key: sub.key,
25465
+ summary: sub.fields.summary,
25466
+ status: sub.fields.status.name,
25467
+ relationship: "subtask",
25468
+ isDone: DONE_STATUSES14.has(sub.fields.status.name.toLowerCase())
25469
+ });
25470
+ }
25471
+ }
25472
+ if (issue2.fields.issuelinks) {
25473
+ for (const link of issue2.fields.issuelinks) {
25474
+ if (link.outwardIssue) {
25475
+ linkedIssues.push({
25476
+ key: link.outwardIssue.key,
25477
+ summary: link.outwardIssue.fields.summary,
25478
+ status: link.outwardIssue.fields.status.name,
25479
+ relationship: link.type.outward,
25480
+ isDone: DONE_STATUSES14.has(
25481
+ link.outwardIssue.fields.status.name.toLowerCase()
25482
+ )
25483
+ });
25484
+ }
25485
+ if (link.inwardIssue) {
25486
+ linkedIssues.push({
25487
+ key: link.inwardIssue.key,
25488
+ summary: link.inwardIssue.fields.summary,
25489
+ status: link.inwardIssue.fields.status.name,
25490
+ relationship: link.type.inward,
25491
+ isDone: DONE_STATUSES14.has(
25492
+ link.inwardIssue.fields.status.name.toLowerCase()
25493
+ )
25494
+ });
25495
+ }
25496
+ }
25497
+ }
25498
+ return linkedIssues;
25499
+ }
25434
25500
  function computeSubtaskProgress(subtasks) {
25435
25501
  if (subtasks.length === 0) return 0;
25436
25502
  const done = subtasks.filter(
@@ -25471,44 +25537,7 @@ async function fetchJiraStatus(store, client, host, artifactId, statusMap) {
25471
25537
  const resolved = statusMap ?? {};
25472
25538
  const proposedStatus = artifactType === "task" ? mapJiraStatusForTask(issue2.fields.status.name, resolved, inSprint) : mapJiraStatusForAction(issue2.fields.status.name, resolved, inSprint);
25473
25539
  const currentStatus = doc.frontmatter.status;
25474
- const linkedIssues = [];
25475
- if (issue2.fields.subtasks) {
25476
- for (const sub of issue2.fields.subtasks) {
25477
- linkedIssues.push({
25478
- key: sub.key,
25479
- summary: sub.fields.summary,
25480
- status: sub.fields.status.name,
25481
- relationship: "subtask",
25482
- isDone: DONE_STATUSES14.has(sub.fields.status.name.toLowerCase())
25483
- });
25484
- }
25485
- }
25486
- if (issue2.fields.issuelinks) {
25487
- for (const link of issue2.fields.issuelinks) {
25488
- if (link.outwardIssue) {
25489
- linkedIssues.push({
25490
- key: link.outwardIssue.key,
25491
- summary: link.outwardIssue.fields.summary,
25492
- status: link.outwardIssue.fields.status.name,
25493
- relationship: link.type.outward,
25494
- isDone: DONE_STATUSES14.has(
25495
- link.outwardIssue.fields.status.name.toLowerCase()
25496
- )
25497
- });
25498
- }
25499
- if (link.inwardIssue) {
25500
- linkedIssues.push({
25501
- key: link.inwardIssue.key,
25502
- summary: link.inwardIssue.fields.summary,
25503
- status: link.inwardIssue.fields.status.name,
25504
- relationship: link.type.inward,
25505
- isDone: DONE_STATUSES14.has(
25506
- link.inwardIssue.fields.status.name.toLowerCase()
25507
- )
25508
- });
25509
- }
25510
- }
25511
- }
25540
+ const linkedIssues = collectLinkedIssues(issue2);
25512
25541
  const subtasks = issue2.fields.subtasks ?? [];
25513
25542
  let proposedProgress;
25514
25543
  if (subtasks.length > 0 && !doc.frontmatter.progressOverride) {
@@ -25770,7 +25799,6 @@ function isWithinRange(timestamp, range) {
25770
25799
  function isConfluenceUrl(url2) {
25771
25800
  return /atlassian\.net\/wiki\//i.test(url2) || /\/confluence\//i.test(url2);
25772
25801
  }
25773
- var DONE_STATUSES15 = /* @__PURE__ */ new Set(["done", "closed", "resolved", "obsolete", "wont do"]);
25774
25802
  async function fetchJiraDaily(store, client, host, projectKey, dateRange, statusMap) {
25775
25803
  const summary = {
25776
25804
  dateRange,
@@ -25881,42 +25909,7 @@ async function processIssue(issue2, client, host, dateRange, jiraKeyToArtifacts,
25881
25909
  });
25882
25910
  }
25883
25911
  }
25884
- const linkedIssues = [];
25885
- if (issueWithLinks) {
25886
- if (issueWithLinks.fields.subtasks) {
25887
- for (const sub of issueWithLinks.fields.subtasks) {
25888
- linkedIssues.push({
25889
- key: sub.key,
25890
- summary: sub.fields.summary,
25891
- status: sub.fields.status.name,
25892
- relationship: "subtask",
25893
- isDone: DONE_STATUSES15.has(sub.fields.status.name.toLowerCase())
25894
- });
25895
- }
25896
- }
25897
- if (issueWithLinks.fields.issuelinks) {
25898
- for (const link of issueWithLinks.fields.issuelinks) {
25899
- if (link.outwardIssue) {
25900
- linkedIssues.push({
25901
- key: link.outwardIssue.key,
25902
- summary: link.outwardIssue.fields.summary,
25903
- status: link.outwardIssue.fields.status.name,
25904
- relationship: link.type.outward,
25905
- isDone: DONE_STATUSES15.has(link.outwardIssue.fields.status.name.toLowerCase())
25906
- });
25907
- }
25908
- if (link.inwardIssue) {
25909
- linkedIssues.push({
25910
- key: link.inwardIssue.key,
25911
- summary: link.inwardIssue.fields.summary,
25912
- status: link.inwardIssue.fields.status.name,
25913
- relationship: link.type.inward,
25914
- isDone: DONE_STATUSES15.has(link.inwardIssue.fields.status.name.toLowerCase())
25915
- });
25916
- }
25917
- }
25918
- }
25919
- }
25912
+ const linkedIssues = issueWithLinks ? collectLinkedIssues(issueWithLinks) : [];
25920
25913
  const marvinArtifacts = [];
25921
25914
  const artifacts = jiraKeyToArtifacts.get(issue2.key) ?? [];
25922
25915
  for (const doc of artifacts) {
@@ -26046,22 +26039,23 @@ function generateProposedActions(issues) {
26046
26039
 
26047
26040
  // src/skills/builtin/jira/sprint-progress.ts
26048
26041
  import { query as query3 } from "@anthropic-ai/claude-agent-sdk";
26049
- var DONE_STATUSES16 = /* @__PURE__ */ new Set(["done", "closed", "resolved", "obsolete", "wont do", "cancelled"]);
26042
+ var DONE_STATUSES15 = /* @__PURE__ */ new Set(["done", "closed", "resolved", "obsolete", "wont do", "cancelled"]);
26050
26043
  var BATCH_SIZE = 5;
26044
+ var MAX_LINKED_ISSUES = 50;
26051
26045
  var BLOCKED_WEIGHT_RISK_THRESHOLD = 0.3;
26052
- var COMPLEXITY_WEIGHTS = {
26046
+ var COMPLEXITY_WEIGHTS2 = {
26053
26047
  trivial: 1,
26054
26048
  simple: 2,
26055
26049
  moderate: 3,
26056
26050
  complex: 5,
26057
26051
  "very-complex": 8
26058
26052
  };
26059
- var DEFAULT_WEIGHT = 3;
26053
+ var DEFAULT_WEIGHT3 = 3;
26060
26054
  function resolveWeight(complexity) {
26061
- if (complexity && complexity in COMPLEXITY_WEIGHTS) {
26062
- return { weight: COMPLEXITY_WEIGHTS[complexity], weightSource: "complexity" };
26055
+ if (complexity && complexity in COMPLEXITY_WEIGHTS2) {
26056
+ return { weight: COMPLEXITY_WEIGHTS2[complexity], weightSource: "complexity" };
26063
26057
  }
26064
- return { weight: DEFAULT_WEIGHT, weightSource: "default" };
26058
+ return { weight: DEFAULT_WEIGHT3, weightSource: "default" };
26065
26059
  }
26066
26060
  function resolveProgress(frontmatter, commentAnalysisProgress) {
26067
26061
  const hasExplicitProgress = "progress" in frontmatter && typeof frontmatter.progress === "number";
@@ -26150,6 +26144,52 @@ async function assessSprintProgress(store, client, host, options = {}) {
26150
26144
  }
26151
26145
  }
26152
26146
  }
26147
+ const linkedJiraIssues = /* @__PURE__ */ new Map();
26148
+ if (options.traverseLinks) {
26149
+ const visited = new Set(jiraIssues.keys());
26150
+ const queue = [];
26151
+ for (const [, data] of jiraIssues) {
26152
+ const links = collectLinkedIssues(data.issue);
26153
+ for (const link of links) {
26154
+ if (link.relationship !== "subtask" && !visited.has(link.key)) {
26155
+ visited.add(link.key);
26156
+ queue.push(link.key);
26157
+ }
26158
+ }
26159
+ }
26160
+ while (queue.length > 0 && linkedJiraIssues.size < MAX_LINKED_ISSUES) {
26161
+ const remaining = MAX_LINKED_ISSUES - linkedJiraIssues.size;
26162
+ const batch = queue.splice(0, Math.min(BATCH_SIZE, remaining));
26163
+ const results = await Promise.allSettled(
26164
+ batch.map(async (key) => {
26165
+ const [issue2, comments] = await Promise.all([
26166
+ client.getIssueWithLinks(key),
26167
+ client.getComments(key)
26168
+ ]);
26169
+ return { key, issue: issue2, comments };
26170
+ })
26171
+ );
26172
+ for (const result of results) {
26173
+ if (result.status === "fulfilled") {
26174
+ const { key, issue: issue2, comments } = result.value;
26175
+ linkedJiraIssues.set(key, { issue: issue2, comments });
26176
+ const newLinks = collectLinkedIssues(issue2);
26177
+ for (const link of newLinks) {
26178
+ if (link.relationship !== "subtask" && !visited.has(link.key)) {
26179
+ visited.add(link.key);
26180
+ queue.push(link.key);
26181
+ }
26182
+ }
26183
+ } else {
26184
+ const batchKey = batch[results.indexOf(result)];
26185
+ errors.push(`Failed to fetch linked issue ${batchKey}: ${result.reason instanceof Error ? result.reason.message : String(result.reason)}`);
26186
+ }
26187
+ }
26188
+ }
26189
+ if (queue.length > 0) {
26190
+ errors.push(`Link traversal capped at ${MAX_LINKED_ISSUES} linked issues (${queue.length} remaining undiscovered)`);
26191
+ }
26192
+ }
26153
26193
  const proposedUpdates = [];
26154
26194
  const itemReports = [];
26155
26195
  const childReportsByParent = /* @__PURE__ */ new Map();
@@ -26215,6 +26255,23 @@ async function assessSprintProgress(store, client, host, options = {}) {
26215
26255
  const focusTag = tags.find((t) => t.startsWith("focus:"));
26216
26256
  const { weight, weightSource } = resolveWeight(fm.complexity);
26217
26257
  const { progress: resolvedProgress, progressSource } = resolveProgress(fm, null);
26258
+ let itemLinkedIssues = [];
26259
+ const itemLinkedIssueSignals = [];
26260
+ if (options.traverseLinks && jiraData) {
26261
+ const { allLinks, allSignals } = collectTransitiveLinks(
26262
+ jiraData.issue,
26263
+ jiraIssues,
26264
+ linkedJiraIssues
26265
+ );
26266
+ itemLinkedIssues = allLinks;
26267
+ itemLinkedIssueSignals.push(...allSignals);
26268
+ analyzeLinkedIssueSignals(
26269
+ allLinks,
26270
+ fm,
26271
+ jiraKey,
26272
+ proposedUpdates
26273
+ );
26274
+ }
26218
26275
  const report = {
26219
26276
  id: fm.id,
26220
26277
  title: fm.title,
@@ -26233,6 +26290,8 @@ async function assessSprintProgress(store, client, host, options = {}) {
26233
26290
  progressDrift,
26234
26291
  commentSignals,
26235
26292
  commentSummary: null,
26293
+ linkedIssues: itemLinkedIssues,
26294
+ linkedIssueSignals: itemLinkedIssueSignals,
26236
26295
  children: [],
26237
26296
  owner: fm.owner ?? null,
26238
26297
  focusArea: focusTag ? focusTag.slice(6) : null
@@ -26276,7 +26335,7 @@ async function assessSprintProgress(store, client, host, options = {}) {
26276
26335
  const focusAreas = [];
26277
26336
  for (const [name, items] of focusAreaMap) {
26278
26337
  const allFlatItems = items.flatMap((i) => [i, ...i.children]);
26279
- const doneCount = allFlatItems.filter((i) => DONE_STATUSES16.has(i.marvinStatus)).length;
26338
+ const doneCount = allFlatItems.filter((i) => DONE_STATUSES15.has(i.marvinStatus)).length;
26280
26339
  const blockedCount = allFlatItems.filter((i) => i.marvinStatus === "blocked").length;
26281
26340
  const progress = computeWeightedProgress(items);
26282
26341
  const totalWeight = items.reduce((s, i) => s + i.weight, 0);
@@ -26328,6 +26387,26 @@ async function assessSprintProgress(store, client, host, options = {}) {
26328
26387
  errors.push(`Comment analysis failed: ${err instanceof Error ? err.message : String(err)}`);
26329
26388
  }
26330
26389
  }
26390
+ if (options.traverseLinks) {
26391
+ try {
26392
+ const linkedSummaries = await analyzeLinkedIssueComments(
26393
+ itemReports,
26394
+ linkedJiraIssues
26395
+ );
26396
+ for (const [artifactId, signalSummaries] of linkedSummaries) {
26397
+ const report = itemReports.find((r) => r.id === artifactId);
26398
+ if (!report) continue;
26399
+ for (const [sourceKey, summary] of signalSummaries) {
26400
+ const signal = report.linkedIssueSignals.find((s) => s.sourceKey === sourceKey);
26401
+ if (signal) {
26402
+ signal.commentSummary = summary;
26403
+ }
26404
+ }
26405
+ }
26406
+ } catch (err) {
26407
+ errors.push(`Linked issue comment analysis failed: ${err instanceof Error ? err.message : String(err)}`);
26408
+ }
26409
+ }
26331
26410
  }
26332
26411
  const appliedUpdates = [];
26333
26412
  if (options.applyUpdates && proposedUpdates.length > 0) {
@@ -26444,6 +26523,155 @@ ${commentTexts}`);
26444
26523
  }
26445
26524
  return summaries;
26446
26525
  }
26526
+ function collectTransitiveLinks(primaryIssue, primaryIssues, linkedJiraIssues) {
26527
+ const allLinks = [];
26528
+ const allSignals = [];
26529
+ const visited = /* @__PURE__ */ new Set([primaryIssue.key]);
26530
+ const directLinks = collectLinkedIssues(primaryIssue).filter((l) => l.relationship !== "subtask");
26531
+ const queue = [...directLinks];
26532
+ for (const link of directLinks) {
26533
+ visited.add(link.key);
26534
+ }
26535
+ while (queue.length > 0) {
26536
+ const link = queue.shift();
26537
+ allLinks.push(link);
26538
+ const linkedData = linkedJiraIssues.get(link.key) ?? primaryIssues.get(link.key);
26539
+ if (!linkedData) continue;
26540
+ const linkedCommentSignals = [];
26541
+ for (const comment of linkedData.comments) {
26542
+ const text = extractCommentText(comment.body);
26543
+ const signals = detectCommentSignals(text);
26544
+ linkedCommentSignals.push(...signals);
26545
+ }
26546
+ if (linkedCommentSignals.length > 0 || linkedData.comments.length > 0) {
26547
+ allSignals.push({
26548
+ sourceKey: link.key,
26549
+ linkType: link.relationship,
26550
+ commentSignals: linkedCommentSignals,
26551
+ commentSummary: null
26552
+ });
26553
+ }
26554
+ const nextLinks = collectLinkedIssues(linkedData.issue).filter((l) => l.relationship !== "subtask" && !visited.has(l.key));
26555
+ for (const next of nextLinks) {
26556
+ visited.add(next.key);
26557
+ queue.push(next);
26558
+ }
26559
+ }
26560
+ return { allLinks, allSignals };
26561
+ }
26562
+ var BLOCKER_LINK_PATTERNS = ["blocks", "is blocked by"];
26563
+ var WONT_DO_STATUSES = /* @__PURE__ */ new Set(["wont do", "won't do", "cancelled"]);
26564
+ function analyzeLinkedIssueSignals(linkedIssues, frontmatter, jiraKey, proposedUpdates) {
26565
+ if (linkedIssues.length === 0) return;
26566
+ const blockerLinks = linkedIssues.filter(
26567
+ (l) => BLOCKER_LINK_PATTERNS.some((p) => l.relationship.toLowerCase().includes(p.split(" ")[0]))
26568
+ );
26569
+ if (blockerLinks.length > 0 && blockerLinks.every((l) => l.isDone) && frontmatter.status === "blocked") {
26570
+ proposedUpdates.push({
26571
+ artifactId: frontmatter.id,
26572
+ field: "status",
26573
+ currentValue: "blocked",
26574
+ proposedValue: "in-progress",
26575
+ reason: `All blocking issues resolved: ${blockerLinks.map((l) => l.key).join(", ")}`
26576
+ });
26577
+ }
26578
+ const wontDoLinks = linkedIssues.filter(
26579
+ (l) => WONT_DO_STATUSES.has(l.status.toLowerCase())
26580
+ );
26581
+ if (wontDoLinks.length > 0) {
26582
+ proposedUpdates.push({
26583
+ artifactId: frontmatter.id,
26584
+ field: "review",
26585
+ currentValue: null,
26586
+ proposedValue: "needs-review",
26587
+ reason: `Linked issue(s) cancelled/won't do: ${wontDoLinks.map((l) => `${l.key} "${l.summary}"`).join(", ")}`
26588
+ });
26589
+ }
26590
+ }
26591
+ var LINKED_COMMENT_ANALYSIS_PROMPT = `You are a delivery management assistant analyzing Jira comments from linked issues for progress signals.
26592
+
26593
+ For each linked issue below, read the comments and produce a 1-sentence summary focused on: impact on the parent issue, blockers, or decisions.
26594
+
26595
+ Return your response as a JSON object mapping artifact IDs to objects mapping linked issue keys to summary strings.
26596
+ Example: {"T-001": {"PROJ-301": "DBA review scheduled for Thursday."}}
26597
+
26598
+ IMPORTANT: Only return the JSON object, no other text.`;
26599
+ async function analyzeLinkedIssueComments(items, linkedJiraIssues) {
26600
+ const results = /* @__PURE__ */ new Map();
26601
+ const promptParts = [];
26602
+ const itemsWithLinkedComments = [];
26603
+ for (const item of items) {
26604
+ if (item.linkedIssueSignals.length === 0) continue;
26605
+ const linkedParts = [];
26606
+ for (const signal of item.linkedIssueSignals) {
26607
+ const linkedData = linkedJiraIssues.get(signal.sourceKey);
26608
+ if (!linkedData || linkedData.comments.length === 0) continue;
26609
+ const commentTexts = linkedData.comments.map((c) => {
26610
+ const text = extractCommentText(c.body);
26611
+ return ` [${c.author.displayName}, ${c.created.slice(0, 10)}]: ${text.slice(0, 300)}`;
26612
+ }).join("\n");
26613
+ linkedParts.push(` ### ${signal.sourceKey} (${signal.linkType})
26614
+ ${commentTexts}`);
26615
+ }
26616
+ if (linkedParts.length > 0) {
26617
+ itemsWithLinkedComments.push(item);
26618
+ promptParts.push(`## ${item.id} \u2014 ${item.title}
26619
+ Linked issues:
26620
+ ${linkedParts.join("\n")}`);
26621
+ }
26622
+ }
26623
+ if (promptParts.length === 0) return results;
26624
+ const prompt = promptParts.join("\n\n");
26625
+ const llmResult = query3({
26626
+ prompt,
26627
+ options: {
26628
+ systemPrompt: LINKED_COMMENT_ANALYSIS_PROMPT,
26629
+ maxTurns: 1,
26630
+ tools: [],
26631
+ allowedTools: []
26632
+ }
26633
+ });
26634
+ for await (const msg of llmResult) {
26635
+ if (msg.type === "assistant") {
26636
+ const textBlock = msg.message.content.find(
26637
+ (b) => b.type === "text"
26638
+ );
26639
+ if (textBlock) {
26640
+ const parsed = parseLlmJson(textBlock.text);
26641
+ if (parsed) {
26642
+ for (const [artifactId, linkedSummaries] of Object.entries(parsed)) {
26643
+ if (typeof linkedSummaries === "object" && linkedSummaries !== null) {
26644
+ const signalMap = /* @__PURE__ */ new Map();
26645
+ for (const [key, summary] of Object.entries(linkedSummaries)) {
26646
+ if (typeof summary === "string") {
26647
+ signalMap.set(key, summary);
26648
+ }
26649
+ }
26650
+ if (signalMap.size > 0) {
26651
+ results.set(artifactId, signalMap);
26652
+ }
26653
+ }
26654
+ }
26655
+ }
26656
+ }
26657
+ }
26658
+ }
26659
+ return results;
26660
+ }
26661
+ function parseLlmJson(text) {
26662
+ try {
26663
+ return JSON.parse(text);
26664
+ } catch {
26665
+ const match = text.match(/```(?:json)?\s*([\s\S]*?)```/);
26666
+ if (match) {
26667
+ try {
26668
+ return JSON.parse(match[1]);
26669
+ } catch {
26670
+ }
26671
+ }
26672
+ return null;
26673
+ }
26674
+ }
26447
26675
  function formatProgressReport(report) {
26448
26676
  const parts = [];
26449
26677
  parts.push(`# Sprint Progress Assessment \u2014 ${report.sprintId}`);
@@ -26527,7 +26755,7 @@ function formatProgressReport(report) {
26527
26755
  }
26528
26756
  function formatItemLine(parts, item, depth) {
26529
26757
  const indent = " ".repeat(depth + 1);
26530
- const statusIcon = DONE_STATUSES16.has(item.marvinStatus) ? "\u2713" : item.marvinStatus === "blocked" ? "\u{1F6AB}" : item.marvinStatus === "in-progress" ? "\u25B6" : "\u25CB";
26758
+ const statusIcon = DONE_STATUSES15.has(item.marvinStatus) ? "\u2713" : item.marvinStatus === "blocked" ? "\u{1F6AB}" : item.marvinStatus === "in-progress" ? "\u25B6" : "\u25CB";
26531
26759
  const jiraLabel = item.jiraKey ? ` [${item.jiraKey}: ${item.jiraStatus}]` : "";
26532
26760
  const driftFlag = item.statusDrift ? " \u26A0drift" : "";
26533
26761
  const progressLabel = ` ${item.progress}%`;
@@ -26537,6 +26765,19 @@ function formatItemLine(parts, item, depth) {
26537
26765
  if (item.commentSummary) {
26538
26766
  parts.push(`${indent} \u{1F4AC} ${item.commentSummary}`);
26539
26767
  }
26768
+ if (item.linkedIssues.length > 0) {
26769
+ parts.push(`${indent} \u{1F517} Linked Issues:`);
26770
+ for (const link of item.linkedIssues) {
26771
+ const doneMarker = link.isDone ? " \u2713" : "";
26772
+ const blockerResolved = link.isDone && BLOCKER_LINK_PATTERNS.some((p) => link.relationship.toLowerCase().includes(p.split(" ")[0])) ? " unblock signal" : "";
26773
+ const wontDo = WONT_DO_STATUSES.has(link.status.toLowerCase()) ? " \u26A0 needs review" : "";
26774
+ parts.push(`${indent} ${link.relationship} ${link.key} "${link.summary}" [${link.status}]${doneMarker}${blockerResolved}${wontDo}`);
26775
+ const signal = item.linkedIssueSignals.find((s) => s.sourceKey === link.key);
26776
+ if (signal?.commentSummary) {
26777
+ parts.push(`${indent} \u{1F4AC} ${signal.commentSummary}`);
26778
+ }
26779
+ }
26780
+ }
26540
26781
  for (const child of item.children) {
26541
26782
  formatItemLine(parts, child, depth + 1);
26542
26783
  }
@@ -26546,6 +26787,527 @@ function progressBar6(pct) {
26546
26787
  const empty = 10 - filled;
26547
26788
  return `[${"\u2588".repeat(filled)}${"\u2591".repeat(empty)}]`;
26548
26789
  }
26790
+ var MAX_ARTIFACT_NODES = 50;
26791
+ var MAX_LLM_DEPTH = 3;
26792
+ var MAX_LLM_COMMENT_CHARS = 8e3;
26793
+ async function assessArtifact(store, client, host, options) {
26794
+ const visited = /* @__PURE__ */ new Set();
26795
+ return _assessArtifactRecursive(store, client, host, options, visited, 0);
26796
+ }
26797
+ async function _assessArtifactRecursive(store, client, host, options, visited, depth) {
26798
+ const errors = [];
26799
+ if (visited.has(options.artifactId)) {
26800
+ return emptyArtifactReport(options.artifactId, [`Cycle detected: ${options.artifactId} already visited`]);
26801
+ }
26802
+ if (visited.size >= MAX_ARTIFACT_NODES) {
26803
+ return emptyArtifactReport(options.artifactId, [`Node cap reached (${MAX_ARTIFACT_NODES}), skipping ${options.artifactId}`]);
26804
+ }
26805
+ visited.add(options.artifactId);
26806
+ const doc = store.get(options.artifactId);
26807
+ if (!doc) {
26808
+ return emptyArtifactReport(options.artifactId, [`Artifact ${options.artifactId} not found`]);
26809
+ }
26810
+ const fm = doc.frontmatter;
26811
+ const jiraKey = fm.jiraKey ?? extractJiraKeyFromTags(fm.tags) ?? null;
26812
+ const tags = fm.tags ?? [];
26813
+ const sprintTag = tags.find((t) => t.startsWith("sprint:"));
26814
+ const sprint = sprintTag ? sprintTag.slice(7) : null;
26815
+ const parent = fm.aboutArtifact ?? null;
26816
+ let jiraStatus = null;
26817
+ let jiraAssignee = null;
26818
+ let proposedMarvinStatus = null;
26819
+ let jiraSubtaskProgress = null;
26820
+ const commentSignals = [];
26821
+ let commentSummary = null;
26822
+ let linkedIssues = [];
26823
+ let linkedIssueSignals = [];
26824
+ const proposedUpdates = [];
26825
+ const jiraIssues = /* @__PURE__ */ new Map();
26826
+ const linkedJiraIssues = /* @__PURE__ */ new Map();
26827
+ if (jiraKey) {
26828
+ try {
26829
+ const [issue2, comments] = await Promise.all([
26830
+ client.getIssueWithLinks(jiraKey),
26831
+ client.getComments(jiraKey)
26832
+ ]);
26833
+ jiraIssues.set(jiraKey, { issue: issue2, comments });
26834
+ jiraStatus = issue2.fields.status.name;
26835
+ jiraAssignee = issue2.fields.assignee?.displayName ?? null;
26836
+ const inSprint = isInActiveSprint(store, fm.tags);
26837
+ const resolved = options.statusMap ?? {};
26838
+ proposedMarvinStatus = fm.type === "task" ? mapJiraStatusForTask(jiraStatus, resolved, inSprint) : mapJiraStatusForAction(jiraStatus, resolved, inSprint);
26839
+ const subtasks = issue2.fields.subtasks ?? [];
26840
+ if (subtasks.length > 0) {
26841
+ jiraSubtaskProgress = computeSubtaskProgress(subtasks);
26842
+ }
26843
+ for (const comment of comments) {
26844
+ const text = extractCommentText(comment.body);
26845
+ const signals2 = detectCommentSignals(text);
26846
+ commentSignals.push(...signals2);
26847
+ }
26848
+ const jiraVisited = /* @__PURE__ */ new Set([jiraKey]);
26849
+ const queue = [];
26850
+ const directLinks = collectLinkedIssues(issue2).filter((l) => l.relationship !== "subtask");
26851
+ for (const link of directLinks) {
26852
+ if (!jiraVisited.has(link.key)) {
26853
+ jiraVisited.add(link.key);
26854
+ queue.push(link.key);
26855
+ }
26856
+ }
26857
+ while (queue.length > 0 && linkedJiraIssues.size < MAX_LINKED_ISSUES) {
26858
+ const remaining = MAX_LINKED_ISSUES - linkedJiraIssues.size;
26859
+ const batch = queue.splice(0, Math.min(BATCH_SIZE, remaining));
26860
+ const results = await Promise.allSettled(
26861
+ batch.map(async (key) => {
26862
+ const [li, lc] = await Promise.all([
26863
+ client.getIssueWithLinks(key),
26864
+ client.getComments(key)
26865
+ ]);
26866
+ return { key, issue: li, comments: lc };
26867
+ })
26868
+ );
26869
+ for (const result of results) {
26870
+ if (result.status === "fulfilled") {
26871
+ const { key, issue: li, comments: lc } = result.value;
26872
+ linkedJiraIssues.set(key, { issue: li, comments: lc });
26873
+ const newLinks = collectLinkedIssues(li).filter((l) => l.relationship !== "subtask" && !jiraVisited.has(l.key));
26874
+ for (const nl of newLinks) {
26875
+ jiraVisited.add(nl.key);
26876
+ queue.push(nl.key);
26877
+ }
26878
+ } else {
26879
+ const batchKey = batch[results.indexOf(result)];
26880
+ errors.push(`Failed to fetch linked issue ${batchKey}: ${result.reason instanceof Error ? result.reason.message : String(result.reason)}`);
26881
+ }
26882
+ }
26883
+ }
26884
+ const { allLinks, allSignals } = collectTransitiveLinks(
26885
+ issue2,
26886
+ jiraIssues,
26887
+ linkedJiraIssues
26888
+ );
26889
+ linkedIssues = allLinks;
26890
+ linkedIssueSignals = allSignals;
26891
+ analyzeLinkedIssueSignals(allLinks, fm, jiraKey, proposedUpdates);
26892
+ } catch (err) {
26893
+ errors.push(`Failed to fetch ${jiraKey}: ${err instanceof Error ? err.message : String(err)}`);
26894
+ }
26895
+ }
26896
+ const currentProgress = getEffectiveProgress(fm);
26897
+ const statusDrift = proposedMarvinStatus !== null && proposedMarvinStatus !== fm.status;
26898
+ const progressDrift = jiraSubtaskProgress !== null && !fm.progressOverride && jiraSubtaskProgress !== currentProgress;
26899
+ if (statusDrift && proposedMarvinStatus) {
26900
+ proposedUpdates.push({
26901
+ artifactId: fm.id,
26902
+ field: "status",
26903
+ currentValue: fm.status,
26904
+ proposedValue: proposedMarvinStatus,
26905
+ reason: `Jira ${jiraKey} is "${jiraStatus}" \u2192 maps to "${proposedMarvinStatus}"`
26906
+ });
26907
+ }
26908
+ if (progressDrift && jiraSubtaskProgress !== null) {
26909
+ proposedUpdates.push({
26910
+ artifactId: fm.id,
26911
+ field: "progress",
26912
+ currentValue: currentProgress,
26913
+ proposedValue: jiraSubtaskProgress,
26914
+ reason: `Jira ${jiraKey} subtask progress is ${jiraSubtaskProgress}%`
26915
+ });
26916
+ } else if (statusDrift && proposedMarvinStatus && !fm.progressOverride) {
26917
+ const hasExplicitProgress = "progress" in fm && typeof fm.progress === "number";
26918
+ if (!hasExplicitProgress) {
26919
+ const proposedProgress = STATUS_PROGRESS_DEFAULTS[proposedMarvinStatus] ?? 0;
26920
+ if (proposedProgress !== currentProgress) {
26921
+ proposedUpdates.push({
26922
+ artifactId: fm.id,
26923
+ field: "progress",
26924
+ currentValue: currentProgress,
26925
+ proposedValue: proposedProgress,
26926
+ reason: `Status changing to "${proposedMarvinStatus}" \u2192 default progress ${proposedProgress}%`
26927
+ });
26928
+ }
26929
+ }
26930
+ }
26931
+ const primaryHasComments = jiraKey ? (jiraIssues.get(jiraKey)?.comments.length ?? 0) > 0 : false;
26932
+ if (depth < MAX_LLM_DEPTH && jiraKey && primaryHasComments) {
26933
+ const estimatedChars = estimateCommentTextSize(jiraIssues, linkedJiraIssues, linkedIssueSignals);
26934
+ if (estimatedChars <= MAX_LLM_COMMENT_CHARS) {
26935
+ try {
26936
+ const summary = await analyzeSingleArtifactComments(
26937
+ fm.id,
26938
+ fm.title,
26939
+ jiraKey,
26940
+ jiraStatus,
26941
+ jiraIssues,
26942
+ linkedJiraIssues,
26943
+ linkedIssueSignals
26944
+ );
26945
+ commentSummary = summary;
26946
+ } catch (err) {
26947
+ errors.push(`Comment analysis failed: ${err instanceof Error ? err.message : String(err)}`);
26948
+ }
26949
+ }
26950
+ }
26951
+ const childIds = findChildIds(store, fm);
26952
+ const children = [];
26953
+ for (const childId of childIds) {
26954
+ if (visited.size >= MAX_ARTIFACT_NODES) {
26955
+ errors.push(`Node cap reached (${MAX_ARTIFACT_NODES}), ${childIds.length - children.length} children skipped`);
26956
+ break;
26957
+ }
26958
+ const childReport = await _assessArtifactRecursive(
26959
+ store,
26960
+ client,
26961
+ host,
26962
+ { ...options, artifactId: childId },
26963
+ visited,
26964
+ depth + 1
26965
+ );
26966
+ children.push(childReport);
26967
+ }
26968
+ if (children.length > 0) {
26969
+ const rolledUpProgress = computeWeightedProgress(
26970
+ children.map((c) => ({
26971
+ weight: resolveWeight(void 0).weight,
26972
+ progress: c.marvinProgress
26973
+ }))
26974
+ );
26975
+ if (rolledUpProgress !== currentProgress) {
26976
+ proposedUpdates.push({
26977
+ artifactId: fm.id,
26978
+ field: "progress",
26979
+ currentValue: currentProgress,
26980
+ proposedValue: rolledUpProgress,
26981
+ reason: `Rolled up from ${children.length} children (weighted average ${rolledUpProgress}%)`
26982
+ });
26983
+ }
26984
+ }
26985
+ const signals = buildSignals(commentSignals, linkedIssues, statusDrift, proposedMarvinStatus);
26986
+ const appliedUpdates = [];
26987
+ if (options.applyUpdates && proposedUpdates.length > 0) {
26988
+ for (const update of proposedUpdates) {
26989
+ if (update.field === "review") continue;
26990
+ try {
26991
+ const updatePayload = {
26992
+ [update.field]: update.proposedValue,
26993
+ lastJiraSyncAt: (/* @__PURE__ */ new Date()).toISOString()
26994
+ };
26995
+ if (update.field === "progress") {
26996
+ updatePayload.progressOverride = false;
26997
+ }
26998
+ store.update(update.artifactId, updatePayload);
26999
+ const updatedDoc = store.get(update.artifactId);
27000
+ if (updatedDoc) {
27001
+ if (updatedDoc.frontmatter.type === "task") {
27002
+ propagateProgressFromTask(store, update.artifactId);
27003
+ } else if (updatedDoc.frontmatter.type === "action") {
27004
+ propagateProgressToAction(store, update.artifactId);
27005
+ }
27006
+ }
27007
+ appliedUpdates.push(update);
27008
+ } catch (err) {
27009
+ errors.push(`Failed to apply update: ${err instanceof Error ? err.message : String(err)}`);
27010
+ }
27011
+ }
27012
+ }
27013
+ return {
27014
+ artifactId: fm.id,
27015
+ title: fm.title,
27016
+ type: fm.type,
27017
+ marvinStatus: fm.status,
27018
+ marvinProgress: currentProgress,
27019
+ sprint,
27020
+ parent,
27021
+ jiraKey,
27022
+ jiraStatus,
27023
+ jiraAssignee,
27024
+ jiraSubtaskProgress,
27025
+ proposedMarvinStatus,
27026
+ statusDrift,
27027
+ progressDrift,
27028
+ commentSignals,
27029
+ commentSummary,
27030
+ linkedIssues,
27031
+ linkedIssueSignals,
27032
+ children,
27033
+ proposedUpdates: options.applyUpdates ? [] : proposedUpdates,
27034
+ appliedUpdates,
27035
+ signals,
27036
+ errors
27037
+ };
27038
+ }
27039
+ function findChildIds(store, fm) {
27040
+ if (fm.type === "action") {
27041
+ return store.list({ type: "task" }).filter((d) => d.frontmatter.aboutArtifact === fm.id).map((d) => d.frontmatter.id);
27042
+ }
27043
+ if (fm.type === "epic") {
27044
+ const epicTag = `epic:${fm.id}`;
27045
+ const isLinked = (d) => {
27046
+ const le = d.frontmatter.linkedEpic;
27047
+ if (le?.includes(fm.id)) return true;
27048
+ const t = d.frontmatter.tags ?? [];
27049
+ return t.includes(epicTag);
27050
+ };
27051
+ return [
27052
+ ...store.list({ type: "action" }).filter(isLinked),
27053
+ ...store.list({ type: "task" }).filter(isLinked)
27054
+ ].map((d) => d.frontmatter.id);
27055
+ }
27056
+ return [];
27057
+ }
27058
+ function buildSignals(commentSignals, linkedIssues, statusDrift, proposedStatus) {
27059
+ const signals = [];
27060
+ const blockerSignals = commentSignals.filter((s) => s.type === "blocker");
27061
+ if (blockerSignals.length > 0) {
27062
+ for (const s of blockerSignals) {
27063
+ signals.push(`\u{1F6AB} Blocker \u2014 "${s.snippet}"`);
27064
+ }
27065
+ }
27066
+ const blockingLinks = linkedIssues.filter(
27067
+ (l) => l.relationship.toLowerCase().includes("block")
27068
+ );
27069
+ const activeBlockers = blockingLinks.filter((l) => !l.isDone);
27070
+ const resolvedBlockers = blockingLinks.filter((l) => l.isDone);
27071
+ if (activeBlockers.length > 0) {
27072
+ for (const b of activeBlockers) {
27073
+ signals.push(`\u{1F6AB} Blocker \u2014 ${b.relationship} ${b.key} "${b.summary}" [${b.status}]`);
27074
+ }
27075
+ }
27076
+ if (resolvedBlockers.length > 0 && activeBlockers.length === 0) {
27077
+ signals.push(`\u2705 Unblocked \u2014 all blocking issues resolved: ${resolvedBlockers.map((l) => l.key).join(", ")}`);
27078
+ }
27079
+ const wontDoLinks = linkedIssues.filter((l) => WONT_DO_STATUSES.has(l.status.toLowerCase()));
27080
+ for (const l of wontDoLinks) {
27081
+ signals.push(`\u{1F504} Superseded \u2014 ${l.key} "${l.summary}" is ${l.status}`);
27082
+ }
27083
+ const questionSignals = commentSignals.filter((s) => s.type === "question");
27084
+ for (const s of questionSignals) {
27085
+ signals.push(`\u23F3 Waiting \u2014 "${s.snippet}"`);
27086
+ }
27087
+ const relatedInProgress = linkedIssues.filter(
27088
+ (l) => l.relationship.toLowerCase().includes("relate") && !l.isDone
27089
+ );
27090
+ if (relatedInProgress.length > 0) {
27091
+ for (const l of relatedInProgress) {
27092
+ signals.push(`\u{1F4CB} Handoff \u2014 related work on ${l.key} "${l.summary}" [${l.status}]`);
27093
+ }
27094
+ }
27095
+ if (signals.length === 0) {
27096
+ if (statusDrift && proposedStatus) {
27097
+ signals.push(`\u26A0 Drift detected \u2014 Marvin and Jira statuses diverge`);
27098
+ } else {
27099
+ signals.push(`\u2705 No active blockers or concerns detected`);
27100
+ }
27101
+ }
27102
+ return signals;
27103
+ }
27104
+ function estimateCommentTextSize(jiraIssues, linkedJiraIssues, linkedIssueSignals) {
27105
+ let total = 0;
27106
+ for (const [, data] of jiraIssues) {
27107
+ for (const c of data.comments) {
27108
+ total += typeof c.body === "string" ? c.body.length : JSON.stringify(c.body).length;
27109
+ }
27110
+ }
27111
+ for (const signal of linkedIssueSignals) {
27112
+ const linkedData = linkedJiraIssues.get(signal.sourceKey);
27113
+ if (!linkedData) continue;
27114
+ for (const c of linkedData.comments) {
27115
+ total += typeof c.body === "string" ? c.body.length : JSON.stringify(c.body).length;
27116
+ }
27117
+ }
27118
+ return total;
27119
+ }
27120
+ var SINGLE_ARTIFACT_COMMENT_PROMPT = `You are a delivery management assistant analyzing Jira comments for a single work item.
27121
+
27122
+ Produce a 2-3 sentence progress summary covering:
27123
+ - What work has been completed
27124
+ - What is pending or blocked
27125
+ - Any decisions, handoffs, or scheduling mentioned
27126
+ - Relevant context from linked issue comments (if provided)
27127
+
27128
+ Return ONLY the summary text, no JSON or formatting.`;
27129
+ async function analyzeSingleArtifactComments(artifactId, title, jiraKey, jiraStatus, jiraIssues, linkedJiraIssues, linkedIssueSignals) {
27130
+ const promptParts = [];
27131
+ const primaryData = jiraIssues.get(jiraKey);
27132
+ if (primaryData && primaryData.comments.length > 0) {
27133
+ const commentTexts = primaryData.comments.map((c) => {
27134
+ const text = extractCommentText(c.body);
27135
+ return `[${c.author.displayName}, ${c.created.slice(0, 10)}]: ${text.slice(0, 500)}`;
27136
+ }).join("\n");
27137
+ promptParts.push(`## ${artifactId} \u2014 ${title} (${jiraKey}, status: ${jiraStatus})
27138
+ Comments:
27139
+ ${commentTexts}`);
27140
+ }
27141
+ for (const signal of linkedIssueSignals) {
27142
+ const linkedData = linkedJiraIssues.get(signal.sourceKey);
27143
+ if (!linkedData || linkedData.comments.length === 0) continue;
27144
+ const commentTexts = linkedData.comments.map((c) => {
27145
+ const text = extractCommentText(c.body);
27146
+ return ` [${c.author.displayName}, ${c.created.slice(0, 10)}]: ${text.slice(0, 300)}`;
27147
+ }).join("\n");
27148
+ promptParts.push(`### Linked: ${signal.sourceKey} (${signal.linkType})
27149
+ ${commentTexts}`);
27150
+ }
27151
+ if (promptParts.length === 0) return null;
27152
+ const prompt = promptParts.join("\n\n");
27153
+ const result = query3({
27154
+ prompt,
27155
+ options: {
27156
+ systemPrompt: SINGLE_ARTIFACT_COMMENT_PROMPT,
27157
+ maxTurns: 1,
27158
+ tools: [],
27159
+ allowedTools: []
27160
+ }
27161
+ });
27162
+ for await (const msg of result) {
27163
+ if (msg.type === "assistant") {
27164
+ const textBlock = msg.message.content.find(
27165
+ (b) => b.type === "text"
27166
+ );
27167
+ if (textBlock) {
27168
+ return textBlock.text.trim();
27169
+ }
27170
+ }
27171
+ }
27172
+ return null;
27173
+ }
27174
+ function emptyArtifactReport(artifactId, errors) {
27175
+ return {
27176
+ artifactId,
27177
+ title: "Not found",
27178
+ type: "unknown",
27179
+ marvinStatus: "unknown",
27180
+ marvinProgress: 0,
27181
+ sprint: null,
27182
+ parent: null,
27183
+ jiraKey: null,
27184
+ jiraStatus: null,
27185
+ jiraAssignee: null,
27186
+ jiraSubtaskProgress: null,
27187
+ proposedMarvinStatus: null,
27188
+ statusDrift: false,
27189
+ progressDrift: false,
27190
+ commentSignals: [],
27191
+ commentSummary: null,
27192
+ linkedIssues: [],
27193
+ linkedIssueSignals: [],
27194
+ children: [],
27195
+ proposedUpdates: [],
27196
+ appliedUpdates: [],
27197
+ signals: [],
27198
+ errors
27199
+ };
27200
+ }
27201
+ function formatArtifactReport(report) {
27202
+ const parts = [];
27203
+ parts.push(`# Artifact Assessment \u2014 ${report.artifactId}`);
27204
+ parts.push(report.title);
27205
+ parts.push("");
27206
+ parts.push(`## Marvin State`);
27207
+ const marvinParts = [`Status: ${report.marvinStatus}`, `Progress: ${report.marvinProgress}%`];
27208
+ if (report.sprint) marvinParts.push(`Sprint: ${report.sprint}`);
27209
+ if (report.parent) marvinParts.push(`Parent: ${report.parent}`);
27210
+ parts.push(marvinParts.join(" | "));
27211
+ parts.push("");
27212
+ if (report.jiraKey) {
27213
+ parts.push(`## Jira State (${report.jiraKey})`);
27214
+ const jiraParts = [`Status: ${report.jiraStatus ?? "unknown"}`];
27215
+ if (report.jiraAssignee) jiraParts.push(`Assignee: ${report.jiraAssignee}`);
27216
+ if (report.jiraSubtaskProgress !== null) jiraParts.push(`Subtask progress: ${report.jiraSubtaskProgress}%`);
27217
+ parts.push(jiraParts.join(" | "));
27218
+ if (report.statusDrift) {
27219
+ parts.push(`\u26A0 Drift: ${report.marvinStatus} \u2192 ${report.proposedMarvinStatus}`);
27220
+ }
27221
+ if (report.progressDrift && report.jiraSubtaskProgress !== null) {
27222
+ parts.push(`\u26A0 Progress drift: ${report.marvinProgress}% \u2192 ${report.jiraSubtaskProgress}%`);
27223
+ }
27224
+ parts.push("");
27225
+ }
27226
+ if (report.commentSummary) {
27227
+ parts.push(`## Comments`);
27228
+ parts.push(report.commentSummary);
27229
+ parts.push("");
27230
+ }
27231
+ if (report.children.length > 0) {
27232
+ const doneCount = report.children.filter((c) => DONE_STATUSES15.has(c.marvinStatus)).length;
27233
+ const childWeights = report.children.map((c) => {
27234
+ const { weight } = resolveWeight(void 0);
27235
+ return { weight, progress: c.marvinProgress };
27236
+ });
27237
+ 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;
27238
+ const bar = progressBar6(childProgress);
27239
+ parts.push(`## Children (${doneCount}/${report.children.length} done) ${bar} ${childProgress}%`);
27240
+ for (const child of report.children) {
27241
+ formatArtifactChild(parts, child, 1);
27242
+ }
27243
+ parts.push("");
27244
+ }
27245
+ if (report.linkedIssues.length > 0) {
27246
+ parts.push(`## Linked Issues (${report.linkedIssues.length})`);
27247
+ for (const link of report.linkedIssues) {
27248
+ const doneMarker = link.isDone ? " \u2713" : "";
27249
+ parts.push(` ${link.relationship} ${link.key} "${link.summary}" [${link.status}]${doneMarker}`);
27250
+ const signal = report.linkedIssueSignals.find((s) => s.sourceKey === link.key);
27251
+ if (signal?.commentSummary) {
27252
+ parts.push(` \u{1F4AC} ${signal.commentSummary}`);
27253
+ }
27254
+ }
27255
+ parts.push("");
27256
+ }
27257
+ if (report.signals.length > 0) {
27258
+ parts.push(`## Signals`);
27259
+ for (const s of report.signals) {
27260
+ parts.push(` ${s}`);
27261
+ }
27262
+ parts.push("");
27263
+ }
27264
+ if (report.proposedUpdates.length > 0) {
27265
+ parts.push(`## Proposed Updates (${report.proposedUpdates.length})`);
27266
+ for (const update of report.proposedUpdates) {
27267
+ parts.push(` ${update.artifactId}.${update.field}: ${String(update.currentValue)} \u2192 ${String(update.proposedValue)}`);
27268
+ parts.push(` Reason: ${update.reason}`);
27269
+ }
27270
+ parts.push("");
27271
+ parts.push("Run with applyUpdates=true to apply these changes.");
27272
+ parts.push("");
27273
+ }
27274
+ if (report.appliedUpdates.length > 0) {
27275
+ parts.push(`## Applied Updates (${report.appliedUpdates.length})`);
27276
+ for (const update of report.appliedUpdates) {
27277
+ parts.push(` \u2713 ${update.artifactId}.${update.field}: ${String(update.currentValue)} \u2192 ${String(update.proposedValue)}`);
27278
+ }
27279
+ parts.push("");
27280
+ }
27281
+ if (report.errors.length > 0) {
27282
+ parts.push(`## Errors`);
27283
+ for (const err of report.errors) {
27284
+ parts.push(` ${err}`);
27285
+ }
27286
+ parts.push("");
27287
+ }
27288
+ return parts.join("\n");
27289
+ }
27290
+ function formatArtifactChild(parts, child, depth) {
27291
+ const indent = " ".repeat(depth);
27292
+ const icon = DONE_STATUSES15.has(child.marvinStatus) ? "\u2713" : child.marvinStatus === "blocked" ? "\u{1F6AB}" : child.marvinStatus === "in-progress" ? "\u25B6" : "\u25CB";
27293
+ const jiraLabel = child.jiraKey ? ` [${child.jiraKey}: ${child.jiraStatus ?? "?"}]` : "";
27294
+ const driftLabel = child.statusDrift ? ` \u26A0drift \u2192 ${child.proposedMarvinStatus}` : "";
27295
+ const signalHints = [];
27296
+ for (const s of child.signals) {
27297
+ if (s.startsWith("\u2705 No active")) continue;
27298
+ signalHints.push(s);
27299
+ }
27300
+ parts.push(`${indent}${icon} ${child.artifactId} \u2014 ${child.title} [${child.marvinStatus}] ${child.marvinProgress}%${jiraLabel}${driftLabel}`);
27301
+ if (child.commentSummary) {
27302
+ parts.push(`${indent} \u{1F4AC} ${child.commentSummary}`);
27303
+ }
27304
+ for (const hint of signalHints) {
27305
+ parts.push(`${indent} ${hint}`);
27306
+ }
27307
+ for (const grandchild of child.children) {
27308
+ formatArtifactChild(parts, grandchild, depth + 1);
27309
+ }
27310
+ }
26549
27311
 
26550
27312
  // src/skills/builtin/jira/tools.ts
26551
27313
  var JIRA_TYPE = "jira-issue";
@@ -27332,7 +28094,8 @@ function createJiraTools(store, projectConfig) {
27332
28094
  {
27333
28095
  sprintId: external_exports.string().optional().describe("Sprint ID (e.g. 'SP-001'). Defaults to active sprint."),
27334
28096
  analyzeComments: external_exports.boolean().optional().describe("Use LLM to summarize Jira comments for progress signals (default false)"),
27335
- applyUpdates: external_exports.boolean().optional().describe("Apply proposed status/progress updates to Marvin artifacts (default false)")
28097
+ applyUpdates: external_exports.boolean().optional().describe("Apply proposed status/progress updates to Marvin artifacts (default false)"),
28098
+ 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)")
27336
28099
  },
27337
28100
  async (args) => {
27338
28101
  const jira = createJiraClient(jiraUserConfig);
@@ -27345,6 +28108,7 @@ function createJiraTools(store, projectConfig) {
27345
28108
  sprintId: args.sprintId,
27346
28109
  analyzeComments: args.analyzeComments ?? false,
27347
28110
  applyUpdates: args.applyUpdates ?? false,
28111
+ traverseLinks: args.traverseLinks ?? false,
27348
28112
  statusMap
27349
28113
  }
27350
28114
  );
@@ -27354,6 +28118,34 @@ function createJiraTools(store, projectConfig) {
27354
28118
  };
27355
28119
  },
27356
28120
  { annotations: { readOnlyHint: false } }
28121
+ ),
28122
+ // --- Single-artifact assessment ---
28123
+ tool20(
28124
+ "assess_artifact",
28125
+ "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).",
28126
+ {
28127
+ artifactId: external_exports.string().describe("Marvin artifact ID (e.g. 'T-063', 'A-151', 'E-003')"),
28128
+ applyUpdates: external_exports.boolean().optional().describe("Apply proposed status/progress updates to the artifact (default false)")
28129
+ },
28130
+ async (args) => {
28131
+ const jira = createJiraClient(jiraUserConfig);
28132
+ if (!jira) return jiraNotConfiguredError();
28133
+ const report = await assessArtifact(
28134
+ store,
28135
+ jira.client,
28136
+ jira.host,
28137
+ {
28138
+ artifactId: args.artifactId,
28139
+ applyUpdates: args.applyUpdates ?? false,
28140
+ statusMap
28141
+ }
28142
+ );
28143
+ return {
28144
+ content: [{ type: "text", text: formatArtifactReport(report) }],
28145
+ isError: report.errors.length > 0 && report.type === "unknown"
28146
+ };
28147
+ },
28148
+ { annotations: { readOnlyHint: false } }
27357
28149
  )
27358
28150
  ];
27359
28151
  }
@@ -27455,7 +28247,8 @@ var COMMON_TOOLS = `**Available tools:**
27455
28247
  - \`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.
27456
28248
  - \`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.
27457
28249
  - \`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).
27458
- - \`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.
28250
+ - \`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.
28251
+ - \`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.
27459
28252
  - \`fetch_jira_statuses\` \u2014 **read-only**: discover all Jira statuses in a project and show their Marvin mappings (mapped vs unmapped).
27460
28253
  - \`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.
27461
28254
  - \`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).
@@ -27472,6 +28265,11 @@ var COMMON_WORKFLOW = `**Jira sync workflow:**
27472
28265
  2. Review focus area rollups, status drift, and blockers
27473
28266
  3. Optionally run with \`applyUpdates=true\` to bulk-sync statuses, or \`analyzeComments=true\` for LLM-powered comment summaries
27474
28267
 
28268
+ **Single-artifact deep dive:**
28269
+ 1. Call \`assess_artifact\` with an artifact ID to get a focused assessment with Jira sync, comment analysis, link traversal, and child rollup
28270
+ 2. Review signals (blockers, unblocks, handoffs) and proposed updates
28271
+ 3. Use \`applyUpdates=true\` to apply changes
28272
+
27475
28273
  **Daily review workflow:**
27476
28274
  1. Call \`fetch_jira_daily\` (optionally with \`from\`/\`to\` date range) to get a summary of all Jira activity
27477
28275
  2. Review the proposed actions: status updates, unlinked issues to track, questions that may be answered, Confluence pages to review
@@ -27496,6 +28294,7 @@ ${COMMON_WORKFLOW}
27496
28294
  **As Product Owner, use Jira integration to:**
27497
28295
  - Use \`fetch_jira_daily\` for daily standups \u2014 review what changed, identify status drift, spot untracked work
27498
28296
  - Use \`assess_sprint_progress\` for sprint reviews \u2014 see overall progress by focus area, detect drift, and identify blockers
28297
+ - Use \`assess_artifact\` for deep dives into specific features or epics \u2014 full Jira context, linked issues, and child rollup
27499
28298
  - Pull stakeholder-reported issues for triage and prioritization
27500
28299
  - Push approved features as Stories for development tracking
27501
28300
  - Link decisions to Jira issues for audit trail and traceability
@@ -27509,6 +28308,7 @@ ${COMMON_WORKFLOW}
27509
28308
  **As Tech Lead, use Jira integration to:**
27510
28309
  - Use \`fetch_jira_daily\` to review technical progress \u2014 status transitions, new comments, Confluence design docs
27511
28310
  - Use \`assess_sprint_progress\` for sprint health checks \u2014 focus area rollups, Jira drift detection, blocker tracking
28311
+ - Use \`assess_artifact\` to investigate a specific task \u2014 see full dependency chain, comment context, and blocker status
27512
28312
  - Pull technical issues and bugs for sprint planning and estimation
27513
28313
  - Push epics, tasks, and technical decisions to Jira for cross-team visibility
27514
28314
  - Use \`link_to_jira\` to connect Marvin tasks to existing Jira tickets
@@ -27524,6 +28324,7 @@ This is a third path for progress tracking alongside Contributions and Meetings.
27524
28324
  - Use \`fetch_jira_daily\` for daily progress reports \u2014 track what moved, identify blockers, spot untracked work
27525
28325
  - Use \`assess_sprint_progress\` for sprint reviews and stakeholder updates \u2014 comprehensive progress by focus area with Jira enrichment
27526
28326
  - Use \`assess_sprint_progress\` with \`applyUpdates=true\` to bulk-sync Marvin statuses from Jira
28327
+ - Use \`assess_artifact\` for focused status checks on critical items \u2014 full Jira context without running the whole sprint assessment
27527
28328
  - Pull sprint issues for tracking progress and blockers
27528
28329
  - Push actions and tasks to Jira for stakeholder visibility
27529
28330
  - Use \`fetch_jira_daily\` with a date range for sprint retrospectives (e.g. \`from: "2026-03-10", to: "2026-03-21"\`)
@@ -32750,7 +33551,7 @@ function createProgram() {
32750
33551
  const program2 = new Command();
32751
33552
  program2.name("marvin").description(
32752
33553
  "AI-powered product development assistant with Product Owner, Delivery Manager, and Technical Lead personas"
32753
- ).version("0.5.17");
33554
+ ).version("0.5.19");
32754
33555
  program2.command("init").description("Initialize a new Marvin project in the current directory").action(async () => {
32755
33556
  await initCommand();
32756
33557
  });