mrvn-cli 0.3.6 → 0.4.0

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/README.md CHANGED
@@ -109,24 +109,26 @@ Each persona has a tuned system prompt that shapes how Claude approaches your pr
109
109
  Marvin enforces a structured product development workflow:
110
110
 
111
111
  1. **Product Owner** defines features (`F-xxx`) as `draft`, then approves them when requirements are clear
112
- 2. **Tech Lead** breaks approved features into implementation epics (`E-xxx`) — the system **enforces** that epics can only be created against approved features
112
+ 2. **Tech Lead** breaks approved features into implementation epics (`E-xxx`) — the system **enforces** that epics can only be created against approved features. An epic can link to **one or more features** (e.g. a cross-cutting epic spanning auth and profiles)
113
113
  3. **Delivery Manager** creates sprints (`SP-xxx`) with goals and date boundaries, assigns epics to sprints, and tracks progress
114
114
 
115
115
  ```
116
- Feature (PO) Epic (TL) Sprint (DM)
117
- ┌──────────┐ ┌──────────────┐ ┌──────────────────────┐
118
- │ F-001 │───▶│ E-001 │───▶│ SP-001 │
119
- │ approved │ │ linked: F-001│ │ linkedEpics: [E-001] │
120
- └──────────┘ ├──────────────┤ │ goal: "Deliver auth" │
121
- │ E-002 │ │ 2026-03-01..03-14 │
122
- linked: F-001│ └──────────────────────┘
123
- └──────────────┘
124
- ┌────────┴─────────────┐
125
- │ A-001 (sprint:SP-001) │
126
- │ D-003 (sprint:SP-001) │
127
- └──────────────────────┘
116
+ Feature (PO) Epic (TL) Sprint (DM)
117
+ ┌──────────┐ ┌────────────────────────┐ ┌──────────────────────┐
118
+ │ F-001 │───▶│ E-001 │───▶│ SP-001 │
119
+ │ approved │ │ linked: [F-001] │ │ linkedEpics: [E-001] │
120
+ └──────────┘ ├────────────────────────┤ │ goal: "Deliver auth" │
121
+ │ E-002 │ │ 2026-03-01..03-14 │
122
+ ┌──────────┐───▶│ linked: [F-001, F-002] │ └──────────────────────┘
123
+ │ F-002 │ └────────────────────────┘
124
+ │ approved │ ┌────────┴─────────────┐
125
+ └──────────┘ │ A-001 (sprint:SP-001) │
126
+ │ D-003 (sprint:SP-001) │
127
+ └──────────────────────┘
128
128
  ```
129
129
 
130
+ Epics store `linkedFeature` as an array (e.g. `["F-001", "F-002"]`). Legacy files with a single string value are normalized to an array on read for backwards compatibility. Multi-linked epics appear in progress reports under each linked feature, and feature tags (`feature:F-xxx`) are generated for all linked features.
131
+
130
132
  **Sprints** are time-boxed iterations with:
131
133
  - `goal` — what the sprint aims to deliver
132
134
  - `startDate` / `endDate` — sprint boundaries (ISO dates)
package/dist/index.js CHANGED
@@ -15000,6 +15000,17 @@ function createSessionTools(store) {
15000
15000
  import * as http2 from "http";
15001
15001
  import { tool as tool20 } from "@anthropic-ai/claude-agent-sdk";
15002
15002
 
15003
+ // src/plugins/builtin/tools/epic-utils.ts
15004
+ function normalizeLinkedFeatures(value) {
15005
+ if (value === void 0 || value === null) return [];
15006
+ if (typeof value === "string") return [value];
15007
+ if (Array.isArray(value)) return value.filter((v) => typeof v === "string");
15008
+ return [];
15009
+ }
15010
+ function generateFeatureTags(features) {
15011
+ return features.map((id) => `feature:${id}`);
15012
+ }
15013
+
15003
15014
  // src/reports/gar/collector.ts
15004
15015
  function collectGarMetrics(store) {
15005
15016
  const allActions = store.list({ type: "action" });
@@ -15472,7 +15483,7 @@ function getDiagramData(store) {
15472
15483
  id: fm.id,
15473
15484
  title: fm.title,
15474
15485
  status: fm.status,
15475
- linkedFeature: fm.linkedFeature
15486
+ linkedFeature: normalizeLinkedFeatures(fm.linkedFeature)
15476
15487
  });
15477
15488
  break;
15478
15489
  case "feature":
@@ -16263,8 +16274,8 @@ function buildArtifactFlowchart(data) {
16263
16274
  lines.push(" classDef default fill:#1e293b,stroke:#475569,color:#e2e8f0");
16264
16275
  const nodeIds = /* @__PURE__ */ new Set();
16265
16276
  for (const epic of data.epics) {
16266
- if (epic.linkedFeature) {
16267
- const feature = data.features.find((f) => f.id === epic.linkedFeature);
16277
+ for (const featureId of epic.linkedFeature) {
16278
+ const feature = data.features.find((f) => f.id === featureId);
16268
16279
  if (feature) {
16269
16280
  const fNode = feature.id.replace(/-/g, "_");
16270
16281
  const eNode = epic.id.replace(/-/g, "_");
@@ -17079,7 +17090,7 @@ function createReportTools(store) {
17079
17090
  id: epicDoc.frontmatter.id,
17080
17091
  title: epicDoc.frontmatter.title,
17081
17092
  status: epicDoc.frontmatter.status,
17082
- linkedFeature: epicDoc.frontmatter.linkedFeature,
17093
+ linkedFeature: normalizeLinkedFeatures(epicDoc.frontmatter.linkedFeature),
17083
17094
  targetDate: epicDoc.frontmatter.targetDate,
17084
17095
  estimatedEffort: epicDoc.frontmatter.estimatedEffort,
17085
17096
  workItems: {
@@ -17206,7 +17217,7 @@ function createReportTools(store) {
17206
17217
  const epicDocs = store.list({ type: "epic" });
17207
17218
  const features = featureDocs.filter((f) => !args.feature || f.frontmatter.id === args.feature).map((f) => {
17208
17219
  const linkedEpics = epicDocs.filter(
17209
- (e) => e.frontmatter.linkedFeature === f.frontmatter.id
17220
+ (e) => normalizeLinkedFeatures(e.frontmatter.linkedFeature).includes(f.frontmatter.id)
17210
17221
  );
17211
17222
  const byStatus = {};
17212
17223
  for (const e of linkedEpics) {
@@ -17410,14 +17421,14 @@ function createEpicTools(store) {
17410
17421
  let docs = store.list({ type: "epic", status: args.status });
17411
17422
  if (args.linkedFeature) {
17412
17423
  docs = docs.filter(
17413
- (d) => d.frontmatter.linkedFeature === args.linkedFeature
17424
+ (d) => normalizeLinkedFeatures(d.frontmatter.linkedFeature).includes(args.linkedFeature)
17414
17425
  );
17415
17426
  }
17416
17427
  const summary = docs.map((d) => ({
17417
17428
  id: d.frontmatter.id,
17418
17429
  title: d.frontmatter.title,
17419
17430
  status: d.frontmatter.status,
17420
- linkedFeature: d.frontmatter.linkedFeature,
17431
+ linkedFeature: normalizeLinkedFeatures(d.frontmatter.linkedFeature),
17421
17432
  owner: d.frontmatter.owner,
17422
17433
  targetDate: d.frontmatter.targetDate,
17423
17434
  estimatedEffort: d.frontmatter.estimatedEffort,
@@ -17458,11 +17469,11 @@ function createEpicTools(store) {
17458
17469
  ),
17459
17470
  tool10(
17460
17471
  "create_epic",
17461
- "Create a new epic linked to an approved feature. The linked feature must exist and be approved.",
17472
+ "Create a new epic linked to one or more approved features. All linked features must exist and be approved.",
17462
17473
  {
17463
17474
  title: external_exports.string().describe("Epic title"),
17464
17475
  content: external_exports.string().describe("Epic description and scope"),
17465
- linkedFeature: external_exports.string().describe("Feature ID to link this epic to (e.g. 'F-001')"),
17476
+ linkedFeature: external_exports.union([external_exports.string(), external_exports.array(external_exports.string())]).describe("Feature ID(s) to link this epic to (e.g. 'F-001' or ['F-001', 'F-002'])"),
17466
17477
  status: external_exports.enum(["planned", "in-progress", "done"]).optional().describe("Epic status (default: 'planned')"),
17467
17478
  owner: external_exports.string().optional().describe("Epic owner"),
17468
17479
  targetDate: external_exports.string().optional().describe("Target completion date (ISO format)"),
@@ -17470,45 +17481,48 @@ function createEpicTools(store) {
17470
17481
  tags: external_exports.array(external_exports.string()).optional().describe("Additional tags")
17471
17482
  },
17472
17483
  async (args) => {
17473
- const feature = store.get(args.linkedFeature);
17474
- if (!feature) {
17475
- return {
17476
- content: [
17477
- {
17478
- type: "text",
17479
- text: `Feature ${args.linkedFeature} not found`
17480
- }
17481
- ],
17482
- isError: true
17483
- };
17484
- }
17485
- if (feature.frontmatter.type !== "feature") {
17486
- return {
17487
- content: [
17488
- {
17489
- type: "text",
17490
- text: `${args.linkedFeature} is a ${feature.frontmatter.type}, not a feature`
17491
- }
17492
- ],
17493
- isError: true
17494
- };
17495
- }
17496
- if (feature.frontmatter.status !== "approved") {
17497
- return {
17498
- content: [
17499
- {
17500
- type: "text",
17501
- text: `Feature ${args.linkedFeature} has status '${feature.frontmatter.status}'. Only approved features can have epics. Ask the Product Owner to approve it first.`
17502
- }
17503
- ],
17504
- isError: true
17505
- };
17484
+ const linkedFeatures = normalizeLinkedFeatures(args.linkedFeature);
17485
+ for (const featureId of linkedFeatures) {
17486
+ const feature = store.get(featureId);
17487
+ if (!feature) {
17488
+ return {
17489
+ content: [
17490
+ {
17491
+ type: "text",
17492
+ text: `Feature ${featureId} not found`
17493
+ }
17494
+ ],
17495
+ isError: true
17496
+ };
17497
+ }
17498
+ if (feature.frontmatter.type !== "feature") {
17499
+ return {
17500
+ content: [
17501
+ {
17502
+ type: "text",
17503
+ text: `${featureId} is a ${feature.frontmatter.type}, not a feature`
17504
+ }
17505
+ ],
17506
+ isError: true
17507
+ };
17508
+ }
17509
+ if (feature.frontmatter.status !== "approved") {
17510
+ return {
17511
+ content: [
17512
+ {
17513
+ type: "text",
17514
+ text: `Feature ${featureId} has status '${feature.frontmatter.status}'. Only approved features can have epics. Ask the Product Owner to approve it first.`
17515
+ }
17516
+ ],
17517
+ isError: true
17518
+ };
17519
+ }
17506
17520
  }
17507
17521
  const frontmatter = {
17508
17522
  title: args.title,
17509
17523
  status: args.status ?? "planned",
17510
- linkedFeature: args.linkedFeature,
17511
- tags: [`feature:${args.linkedFeature}`, ...args.tags ?? []]
17524
+ linkedFeature: linkedFeatures,
17525
+ tags: [...generateFeatureTags(linkedFeatures), ...args.tags ?? []]
17512
17526
  };
17513
17527
  if (args.owner) frontmatter.owner = args.owner;
17514
17528
  if (args.targetDate) frontmatter.targetDate = args.targetDate;
@@ -17518,7 +17532,7 @@ function createEpicTools(store) {
17518
17532
  content: [
17519
17533
  {
17520
17534
  type: "text",
17521
- text: `Created epic ${doc.frontmatter.id}: ${doc.frontmatter.title} (linked to ${args.linkedFeature})`
17535
+ text: `Created epic ${doc.frontmatter.id}: ${doc.frontmatter.title} (linked to ${linkedFeatures.join(", ")})`
17522
17536
  }
17523
17537
  ]
17524
17538
  };
@@ -17526,7 +17540,7 @@ function createEpicTools(store) {
17526
17540
  ),
17527
17541
  tool10(
17528
17542
  "update_epic",
17529
- "Update an existing epic. The linked feature cannot be changed.",
17543
+ "Update an existing epic, including its linked features.",
17530
17544
  {
17531
17545
  id: external_exports.string().describe("Epic ID to update"),
17532
17546
  title: external_exports.string().optional().describe("New title"),
@@ -17535,10 +17549,49 @@ function createEpicTools(store) {
17535
17549
  owner: external_exports.string().optional().describe("New owner"),
17536
17550
  targetDate: external_exports.string().optional().describe("New target date"),
17537
17551
  estimatedEffort: external_exports.string().optional().describe("New estimated effort"),
17552
+ linkedFeature: external_exports.union([external_exports.string(), external_exports.array(external_exports.string())]).optional().describe("New linked feature ID(s)"),
17538
17553
  tags: external_exports.array(external_exports.string()).optional().describe("Replace tags (e.g. remove 'risk', add 'risk-mitigated')")
17539
17554
  },
17540
17555
  async (args) => {
17541
- const { id, content, ...updates } = args;
17556
+ const { id, content, linkedFeature: rawLinkedFeature, tags: userTags, ...updates } = args;
17557
+ if (rawLinkedFeature !== void 0) {
17558
+ const linkedFeatures = normalizeLinkedFeatures(rawLinkedFeature);
17559
+ for (const featureId of linkedFeatures) {
17560
+ const feature = store.get(featureId);
17561
+ if (!feature) {
17562
+ return {
17563
+ content: [
17564
+ { type: "text", text: `Feature ${featureId} not found` }
17565
+ ],
17566
+ isError: true
17567
+ };
17568
+ }
17569
+ if (feature.frontmatter.type !== "feature") {
17570
+ return {
17571
+ content: [
17572
+ { type: "text", text: `${featureId} is a ${feature.frontmatter.type}, not a feature` }
17573
+ ],
17574
+ isError: true
17575
+ };
17576
+ }
17577
+ if (feature.frontmatter.status !== "approved") {
17578
+ return {
17579
+ content: [
17580
+ { type: "text", text: `Feature ${featureId} has status '${feature.frontmatter.status}'. Only approved features can have epics. Ask the Product Owner to approve it first.` }
17581
+ ],
17582
+ isError: true
17583
+ };
17584
+ }
17585
+ }
17586
+ updates.linkedFeature = linkedFeatures;
17587
+ const existingDoc = store.get(id);
17588
+ const existingTags = existingDoc?.frontmatter.tags ?? [];
17589
+ const nonFeatureTags = existingTags.filter((t) => !t.startsWith("feature:"));
17590
+ const baseTags = userTags ?? nonFeatureTags;
17591
+ updates.tags = [...generateFeatureTags(linkedFeatures), ...baseTags];
17592
+ } else if (userTags !== void 0) {
17593
+ updates.tags = userTags;
17594
+ }
17542
17595
  const doc = store.update(id, updates, content);
17543
17596
  return {
17544
17597
  content: [
@@ -17873,7 +17926,9 @@ function createSprintPlanningTools(store) {
17873
17926
  const questions = store.list({ type: "question", status: "open" });
17874
17927
  const contributions = store.list({ type: "contribution" });
17875
17928
  const approvedFeatures = features.filter((f) => f.frontmatter.status === "approved").sort((a, b) => priorityRank(a.frontmatter.priority) - priorityRank(b.frontmatter.priority)).map((f) => {
17876
- const linkedEpics = epics.filter((e) => e.frontmatter.linkedFeature === f.frontmatter.id);
17929
+ const linkedEpics = epics.filter(
17930
+ (e) => normalizeLinkedFeatures(e.frontmatter.linkedFeature).includes(f.frontmatter.id)
17931
+ );
17877
17932
  const epicsByStatus = {};
17878
17933
  for (const e of linkedEpics) {
17879
17934
  epicsByStatus[e.frontmatter.status] = (epicsByStatus[e.frontmatter.status] ?? 0) + 1;
@@ -17898,22 +17953,25 @@ function createSprintPlanningTools(store) {
17898
17953
  );
17899
17954
  if (args.focusFeature) {
17900
17955
  backlogEpics = backlogEpics.filter(
17901
- (e) => e.frontmatter.linkedFeature === args.focusFeature
17956
+ (e) => normalizeLinkedFeatures(e.frontmatter.linkedFeature).includes(args.focusFeature)
17902
17957
  );
17903
17958
  }
17904
17959
  const backlog = backlogEpics.sort((a, b) => {
17905
- const fa = featureMap.get(a.frontmatter.linkedFeature);
17906
- const fb = featureMap.get(b.frontmatter.linkedFeature);
17907
- return priorityRank(fa?.frontmatter.priority) - priorityRank(fb?.frontmatter.priority);
17960
+ const aFeatures = normalizeLinkedFeatures(a.frontmatter.linkedFeature);
17961
+ const bFeatures = normalizeLinkedFeatures(b.frontmatter.linkedFeature);
17962
+ const aRank = Math.min(...aFeatures.map((id) => priorityRank(featureMap.get(id)?.frontmatter.priority)), 99);
17963
+ const bRank = Math.min(...bFeatures.map((id) => priorityRank(featureMap.get(id)?.frontmatter.priority)), 99);
17964
+ return aRank - bRank;
17908
17965
  }).map((e) => {
17909
- const parent = featureMap.get(e.frontmatter.linkedFeature);
17966
+ const linkedFeatures = normalizeLinkedFeatures(e.frontmatter.linkedFeature);
17967
+ const parents = linkedFeatures.map((id) => featureMap.get(id)).filter(Boolean);
17910
17968
  return {
17911
17969
  id: e.frontmatter.id,
17912
17970
  title: e.frontmatter.title,
17913
17971
  status: e.frontmatter.status,
17914
- linkedFeature: e.frontmatter.linkedFeature,
17915
- featureTitle: parent?.frontmatter.title ?? null,
17916
- featurePriority: parent?.frontmatter.priority ?? null,
17972
+ linkedFeature: linkedFeatures,
17973
+ featureTitle: parents.map((p) => p.frontmatter.title).join(", ") || null,
17974
+ featurePriority: parents.map((p) => p.frontmatter.priority).join(", ") || null,
17917
17975
  estimatedEffort: e.frontmatter.estimatedEffort ?? null,
17918
17976
  targetDate: e.frontmatter.targetDate ?? null
17919
17977
  };
@@ -17994,8 +18052,8 @@ function createSprintPlanningTools(store) {
17994
18052
  const epicsAtRisk = epics.filter((e) => {
17995
18053
  if (e.frontmatter.status === "done") return false;
17996
18054
  if (e.frontmatter.targetDate && e.frontmatter.targetDate < now) return true;
17997
- const parent = featureMap.get(e.frontmatter.linkedFeature);
17998
- if (parent?.frontmatter.status === "deferred") return true;
18055
+ const linkedIds = normalizeLinkedFeatures(e.frontmatter.linkedFeature);
18056
+ if (linkedIds.some((id) => featureMap.get(id)?.frontmatter.status === "deferred")) return true;
17999
18057
  return false;
18000
18058
  }).map((e) => ({
18001
18059
  id: e.frontmatter.id,
@@ -20888,6 +20946,12 @@ function collectTools(marvinDir) {
20888
20946
  const skillsWithActions = allSkillIds.map((id) => allSkills.get(id)).filter((s) => s.actions && s.actions.length > 0);
20889
20947
  const projectRoot = path10.dirname(marvinDir);
20890
20948
  const actionTools = createSkillActionTools(skillsWithActions, { store, marvinDir, projectRoot });
20949
+ const allSkillRegs = collectSkillRegistrations(allSkillIds, allSkills);
20950
+ const navGroups = buildNavGroups({
20951
+ pluginRegs: registrations,
20952
+ skillRegs: allSkillRegs,
20953
+ pluginName: plugin?.name
20954
+ });
20891
20955
  return [
20892
20956
  ...createDecisionTools(store),
20893
20957
  ...createActionTools(store),
@@ -20895,6 +20959,7 @@ function collectTools(marvinDir) {
20895
20959
  ...createDocumentTools(store),
20896
20960
  ...manifest ? createSourceTools(manifest) : [],
20897
20961
  ...createSessionTools(sessionStore),
20962
+ ...createWebTools(store, config2.name, navGroups),
20898
20963
  ...pluginTools,
20899
20964
  ...codeSkillTools,
20900
20965
  ...actionTools
@@ -23643,7 +23708,7 @@ function createProgram() {
23643
23708
  const program = new Command();
23644
23709
  program.name("marvin").description(
23645
23710
  "AI-powered product development assistant with Product Owner, Delivery Manager, and Technical Lead personas"
23646
- ).version("0.3.6");
23711
+ ).version("0.4.0");
23647
23712
  program.command("init").description("Initialize a new Marvin project in the current directory").action(async () => {
23648
23713
  await initCommand();
23649
23714
  });