@codazen/harmonica-mcp 0.27.0 → 0.28.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.
Files changed (2) hide show
  1. package/dist/index.js +435 -118
  2. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -21518,63 +21518,6 @@ Poll progress with: get_task_status({ taskId: "${task.taskId}" })`
21518
21518
 
21519
21519
  // ../../libs/harmonica-services/src/mcp/tools/beat-composer-tools.ts
21520
21520
  function registerBeatComposerTools(server, ctx, client) {
21521
- server.tool(
21522
- "compose_revisions",
21523
- "Compose delivery Revisions from a Beat's Coda (description) \u2014 creates concept-status Revisions for progressive delivery. IMPORTANT: If the Beat already has active revisions, this returns the existing ones instead of creating duplicates. Use list_revisions first to check current state. Use transition_revision to move revisions forward.",
21524
- {
21525
- beatId: external_exports.string().describe("The beat ID"),
21526
- drafts: external_exports.array(external_exports.object({
21527
- title: external_exports.string().describe("Title for this revision"),
21528
- description: external_exports.string().describe("What this revision delivers"),
21529
- changeSummary: external_exports.string().describe("Brief summary of the change"),
21530
- tags: external_exports.array(external_exports.string()).optional().describe("Tags for categorization"),
21531
- priority: external_exports.coerce.number().optional().describe("Priority (lower = higher priority)"),
21532
- estimatedEffort: external_exports.string().optional().describe('Estimated effort (e.g., "S", "M", "L")')
21533
- })).min(1).describe("Revision drafts to scaffold"),
21534
- force: external_exports.boolean().optional().describe("Bypass idempotency guard \u2014 create new revisions even if active ones exist. Use when intentionally adding revisions alongside existing ones.")
21535
- },
21536
- async ({ beatId, drafts, force }) => {
21537
- try {
21538
- console.error("[MCP:ScaffoldRevisions] beatId=%s draftCount=%d actor=%s force=%s", beatId, drafts.length, ctx.user.userId, force ?? false);
21539
- await assertBeatInOrg(client, beatId, ctx.orgId);
21540
- const result = await client.composeRevisions(beatId, drafts, ctx.user.userId, { force });
21541
- const { revisions, alreadyExisted } = result;
21542
- if (alreadyExisted) {
21543
- console.error("[MCP:ScaffoldRevisions] returning %d existing revisions beatId=%s", revisions.length, beatId);
21544
- const lines2 = [
21545
- `# Beat ${beatId} already has ${revisions.length} active Revision(s) \u2014 no new ones created`,
21546
- "",
21547
- "Use update_revision or transition_revision to modify existing revisions, or archive/reject them first if you need to start fresh.",
21548
- ""
21549
- ];
21550
- for (const r of revisions) {
21551
- lines2.push(`## ${r.revisionId} \u2014 ${r.title} [${r.status}]`);
21552
- lines2.push(`- **Description:** ${r.description}`);
21553
- lines2.push("");
21554
- }
21555
- return { content: [{ type: "text", text: lines2.join("\n") }] };
21556
- }
21557
- console.error(
21558
- "[MCP:ScaffoldRevisions] created %d revisions beatId=%s ids=%s",
21559
- revisions.length,
21560
- beatId,
21561
- revisions.map((r) => r.revisionId).join(",")
21562
- );
21563
- const lines = [`# Scaffolded ${revisions.length} Revisions for ${beatId}`, ""];
21564
- for (const r of revisions) {
21565
- lines.push(`## ${r.revisionId} \u2014 ${r.title} [${r.status}]`);
21566
- lines.push(`- **Description:** ${r.description}`);
21567
- lines.push(`- **Change:** ${r.changeSummary}`);
21568
- lines.push("");
21569
- }
21570
- return { content: [{ type: "text", text: lines.join("\n") }] };
21571
- } catch (err) {
21572
- console.error("[MCP:ScaffoldRevisions] beatId=%s error=%s", beatId, err instanceof Error ? err.message : String(err));
21573
- const message = err instanceof Error ? err.message : String(err);
21574
- return { content: [{ type: "text", text: `Failed to scaffold revisions: ${message}` }], isError: true };
21575
- }
21576
- }
21577
- );
21578
21521
  server.tool(
21579
21522
  "record_beat_merge",
21580
21523
  "Record that Beats were merged \u2014 the surviving Beat absorbs others. Creates an Activity record for audit trail.",
@@ -22446,9 +22389,24 @@ function registerBeatTools(server, ctx, client) {
22446
22389
  reason: `Beat "${title}" created via MCP`,
22447
22390
  actorId: ctx.user.email
22448
22391
  });
22392
+ let similarityWarning = "";
22393
+ try {
22394
+ const similar = await client.findSimilarBeats(beat.beatId, projectId, { threshold: 0.7, limit: 5 });
22395
+ if (similar.length > 0) {
22396
+ const rows = similar.map((r) => `- **${r.entityId}** (score: ${r.score.toFixed(2)}): ${r.snippet.replace(/[\n\r]/g, " ")}`).join("\n");
22397
+ similarityWarning = `
22398
+
22399
+ ---
22400
+
22401
+ \u26A0 **Similar Beats detected** \u2014 consider adding a Beat Version to an existing Beat instead of creating a new one:
22402
+
22403
+ ${rows}`;
22404
+ }
22405
+ } catch {
22406
+ }
22449
22407
  return { content: [{ type: "text", text: `Beat created.
22450
22408
 
22451
- ${formatBeatDetail(beat)}` }] };
22409
+ ${formatBeatDetail(beat)}${similarityWarning}` }] };
22452
22410
  } catch (err) {
22453
22411
  const message = err instanceof Error ? err.message : String(err);
22454
22412
  return { content: [{ type: "text", text: `Failed to create beat: ${message}` }], isError: true };
@@ -22604,6 +22562,26 @@ var LIFECYCLE_STATES = [
22604
22562
  ];
22605
22563
  var PR_LIFECYCLE_STATES = ["draft", "open"];
22606
22564
 
22565
+ // ../../libs/harmonica-services/src/mcp/errors.ts
22566
+ var BeatQualityGateError = class extends Error {
22567
+ code = "beat_quality_gate_failed";
22568
+ failure;
22569
+ constructor(failure) {
22570
+ super(failure.message);
22571
+ this.failure = failure;
22572
+ }
22573
+ };
22574
+ var PlanVersionsIdempotencyError = class extends Error {
22575
+ code = "plan_versions_already_exist";
22576
+ existingBeatVersionIds;
22577
+ constructor(existingBeatVersionIds) {
22578
+ super(
22579
+ `Beat already has ${existingBeatVersionIds.length} planned Beat Version(s). Pass force=true to plan again.`
22580
+ );
22581
+ this.existingBeatVersionIds = existingBeatVersionIds;
22582
+ }
22583
+ };
22584
+
22607
22585
  // ../../libs/harmonica-services/src/mcp/tools/beat-version-tools.ts
22608
22586
  function registerBeatVersionTools(server, ctx, client) {
22609
22587
  server.tool(
@@ -22774,6 +22752,61 @@ function registerBeatVersionTools(server, ctx, client) {
22774
22752
  }
22775
22753
  }
22776
22754
  );
22755
+ server.tool(
22756
+ "plan_beat_versions",
22757
+ "Agent-decompose a composed Beat into 2-5 planning-state Beat Versions on the path to the Coda. Uses the Beat's Coda, project strategy, sibling Beats, Notes, and codebase grep as context. Requires a recent passing beat_quality check (run check_beat_quality first if needed). Idempotent: refuses when planned BVs already exist on the Beat unless force=true is set.",
22758
+ {
22759
+ beatId: external_exports.string().describe("The Beat ID to decompose (must have a Coda and a passing beat_quality check)"),
22760
+ force: external_exports.boolean().optional().describe("Bypass the idempotency guard and plan additional Beat Versions even when prior planned BVs are still active")
22761
+ },
22762
+ async ({ beatId, force }) => {
22763
+ try {
22764
+ await assertBeatInOrg(client, beatId, ctx.orgId);
22765
+ const actorId = ctx.user.email ?? ctx.user.userId;
22766
+ const beatVersions = await client.planBeatVersions(beatId, actorId, { force });
22767
+ const lines = [
22768
+ `Planned ${beatVersions.length} Beat Version${beatVersions.length === 1 ? "" : "s"} for ${beatId}:`,
22769
+ ""
22770
+ ];
22771
+ for (const bv of beatVersions) {
22772
+ lines.push(`- **${bv.beatVersionId}** v${bv.versionNumber} [${bv.status}]: ${bv.title}`);
22773
+ if (bv.changeSummary) lines.push(` Change: ${bv.changeSummary}`);
22774
+ }
22775
+ lines.push("", "Run check_plan_quality on each Beat Version before transitioning to building.");
22776
+ return { content: [{ type: "text", text: lines.join("\n") }] };
22777
+ } catch (err) {
22778
+ if (err instanceof BeatQualityGateError) {
22779
+ const remedy = err.failure.remedy ? `
22780
+
22781
+ **Remedy:** ${err.failure.remedy}` : "";
22782
+ return {
22783
+ content: [{
22784
+ type: "text",
22785
+ text: `Beat Quality gate failed (${err.failure.reason}): ${err.failure.message}${remedy}`
22786
+ }],
22787
+ isError: true
22788
+ };
22789
+ }
22790
+ if (err instanceof PlanVersionsIdempotencyError) {
22791
+ return {
22792
+ content: [{
22793
+ type: "text",
22794
+ text: [
22795
+ `${err.message}`,
22796
+ "",
22797
+ `Existing planned Beat Versions: ${err.existingBeatVersionIds.join(", ")}`,
22798
+ "",
22799
+ "Pass `force: true` if you intentionally want to plan additional Beat Versions on top of the existing ones."
22800
+ ].join("\n")
22801
+ }],
22802
+ isError: true
22803
+ };
22804
+ }
22805
+ const message = err instanceof Error ? err.message : String(err);
22806
+ return { content: [{ type: "text", text: `Failed to plan Beat Versions: ${message}` }], isError: true };
22807
+ }
22808
+ }
22809
+ );
22777
22810
  server.tool(
22778
22811
  "transition_beat_version",
22779
22812
  "Transition a Beat Version to a new lifecycle state. The planning \u2192 building transition requires a passing plan_quality check (score \u2265 3, not stale). Use skip_quality_check only for reconciling already-shipped work.",
@@ -22825,7 +22858,8 @@ var CHECK_MAX_SCORES = {
22825
22858
  beat_quality: 5,
22826
22859
  plan_quality: 5,
22827
22860
  build_quality: 5,
22828
- pii_scan: 100
22861
+ pii_scan: 100,
22862
+ portfolio_coherence: 5
22829
22863
  };
22830
22864
 
22831
22865
  // ../../libs/harmonica-services/src/mcp/tools/check-tools.ts
@@ -22856,7 +22890,7 @@ function registerCheckTools(server, ctx, client) {
22856
22890
  projectId: external_exports.string().describe("The project ID"),
22857
22891
  beatId: external_exports.string().optional().describe("List checks for this specific beat"),
22858
22892
  revisionId: external_exports.string().optional().describe("List checks for this specific revision"),
22859
- checkType: external_exports.enum(["beat_quality", "plan_quality", "build_quality", "pii_scan"]).optional().describe("Filter by check type")
22893
+ checkType: external_exports.enum(["beat_quality", "plan_quality", "build_quality", "pii_scan", "portfolio_coherence"]).optional().describe("Filter by check type")
22860
22894
  },
22861
22895
  async ({ projectId, beatId, revisionId, checkType }) => {
22862
22896
  try {
@@ -22869,7 +22903,8 @@ function registerCheckTools(server, ctx, client) {
22869
22903
  checks = await client.listBeatChecks(beatId, projectId, checkType);
22870
22904
  scope = `beat ${beatId}`;
22871
22905
  } else {
22872
- checks = await client.listProjectChecks(projectId, checkType);
22906
+ const result = await client.listProjectChecks(projectId, checkType, { limit: 200 });
22907
+ checks = result.checks;
22873
22908
  scope = `project ${projectId}`;
22874
22909
  }
22875
22910
  if (checks.length === 0) {
@@ -22918,7 +22953,8 @@ var CHECK_TYPE_DISPLAY = {
22918
22953
  beat_quality: "Quality",
22919
22954
  plan_quality: "Plan Quality",
22920
22955
  build_quality: "Build Quality",
22921
- pii_scan: "PII Scan"
22956
+ pii_scan: "PII Scan",
22957
+ portfolio_coherence: "Portfolio Coherence"
22922
22958
  };
22923
22959
  function formatCheck(a) {
22924
22960
  const typeLabel = CHECK_TYPE_DISPLAY[a.checkType] ?? a.checkType;
@@ -24582,6 +24618,253 @@ Use get_task_status with taskId="${task.taskId}" to poll for results, or list_ch
24582
24618
  );
24583
24619
  }
24584
24620
 
24621
+ // ../../libs/harmonica-services/src/mcp/tools/portfolio-coherence-tools.ts
24622
+ var POLL_INTERVAL_MS3 = 4e3;
24623
+ var POLL_TIMEOUT_MS3 = 18e4;
24624
+ var POLL_MAX_CONSECUTIVE_ERRORS3 = 3;
24625
+ async function pollForCheck3(client, taskId) {
24626
+ const deadline = Date.now() + POLL_TIMEOUT_MS3;
24627
+ let consecutiveErrors = 0;
24628
+ while (Date.now() < deadline) {
24629
+ const [task, err] = await client.getTask(taskId).then(
24630
+ (t) => [t, null],
24631
+ (e) => [null, e]
24632
+ );
24633
+ if (err) {
24634
+ consecutiveErrors++;
24635
+ if (consecutiveErrors >= POLL_MAX_CONSECUTIVE_ERRORS3) {
24636
+ throw new Error(`getTask failed ${consecutiveErrors} times: ${err instanceof Error ? err.message : String(err)}`);
24637
+ }
24638
+ await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS3));
24639
+ continue;
24640
+ }
24641
+ consecutiveErrors = 0;
24642
+ if (!task) throw new Error(`Task ${taskId} not found`);
24643
+ if (task.status === "completed") {
24644
+ if (!task.result) throw new Error(`Task ${taskId} completed with no result`);
24645
+ return task.result;
24646
+ }
24647
+ if (task.status === "failed") throw new Error(`Portfolio coherence check failed: ${task.error ?? "unknown error"}`);
24648
+ await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS3));
24649
+ }
24650
+ throw new Error(`Portfolio coherence check timed out after ${POLL_TIMEOUT_MS3 / 1e3}s`);
24651
+ }
24652
+ function formatReport(check2) {
24653
+ const bar = (score, max) => "\u2588".repeat(score) + "\u2591".repeat(max - score);
24654
+ const dims = check2.dimensions.map(
24655
+ (d) => `**${d.label}** ${bar(d.score, d.maxScore)} ${d.score}/${d.maxScore}
24656
+ ${d.rationale}${d.suggestions?.length ? `
24657
+ \u2022 ${d.suggestions.join("\n\u2022 ")}` : ""}`
24658
+ ).join("\n\n");
24659
+ const snapshot = check2.targetSnapshot;
24660
+ const totalBeats = snapshot?.totalBeats;
24661
+ const mergeCandidates = snapshot?.mergeCandidateGroups;
24662
+ const parts = [
24663
+ `## Portfolio Coherence \u2014 ${check2.targetId}`,
24664
+ `**Overall Score:** ${check2.overallScore}/5${totalBeats !== void 0 ? ` | **Beats analyzed:** ${totalBeats}` : ""}`,
24665
+ `**Summary:** ${check2.summary}`,
24666
+ check2.topSuggestion ? `**Top Suggestion:** ${check2.topSuggestion}` : "",
24667
+ "",
24668
+ dims
24669
+ ].filter(Boolean);
24670
+ if (mergeCandidates?.length) {
24671
+ const groupLines = mergeCandidates.map((g, i) => `${i + 1}. [${(g.beatIds ?? []).join(", ")}] \u2014 ${Math.round((g.avgSimilarity ?? 0) * 100)}% similarity`).join("\n");
24672
+ parts.push(`
24673
+ ## Merge Candidate Groups (${mergeCandidates.length})
24674
+ ${groupLines}`);
24675
+ }
24676
+ return parts.join("\n");
24677
+ }
24678
+ function formatConsolidationPreview(primaryId, primary, duplicates, rationale) {
24679
+ const archiveLines = duplicates.map((d) => `- \`${d.beat.beatId}\` \u2014 "${d.beat.title}" [${d.beat.beatStatus ?? "composing"}]`).join("\n");
24680
+ const inflightBvs = duplicates.flatMap((d) => d.inflightBvs);
24681
+ const bvWarning = inflightBvs.length > 0 ? [
24682
+ "",
24683
+ `**\u26A0 In-flight Beat Versions (reassign manually after merge):**`,
24684
+ ...inflightBvs.map((bv) => `- \`${bv.beatVersionId}\` \u2014 "${bv.title}" [${bv.status}]`)
24685
+ ].join("\n") : "";
24686
+ return [
24687
+ `## Consolidation Preview \u2014 Dry Run`,
24688
+ "",
24689
+ `**Primary (survives):** \`${primaryId}\` \u2014 "${primary.title}" [${primary.beatStatus ?? "composing"}]`,
24690
+ "",
24691
+ `**To archive (${duplicates.length}):**`,
24692
+ archiveLines,
24693
+ "",
24694
+ `**Rationale:** ${rationale}`,
24695
+ ...bvWarning ? [bvWarning] : [],
24696
+ "",
24697
+ "---",
24698
+ "Re-call `consolidate_beats` with `confirm: true` to execute this merge."
24699
+ ].join("\n");
24700
+ }
24701
+ function formatConsolidationResult(primaryId, primary, archivedBeatIds, notesReassigned, inflightBvs) {
24702
+ const bvSection = inflightBvs.length > 0 ? [
24703
+ "",
24704
+ `**In-flight Beat Versions \u2014 reassign manually:**`,
24705
+ ...inflightBvs.map(
24706
+ (bv) => `- \`${bv.beatVersionId}\` \u2014 "${bv.title}" [${bv.status}] \u2192 use \`update_beat_version\` to move to \`${primaryId}\``
24707
+ )
24708
+ ].join("\n") : "";
24709
+ return [
24710
+ `## Merge Complete`,
24711
+ "",
24712
+ `**Primary:** \`${primaryId}\` \u2014 "${primary.title}"`,
24713
+ `**Archived:** ${archivedBeatIds.length} Beat${archivedBeatIds.length !== 1 ? "s" : ""}${archivedBeatIds.length > 0 ? ` \u2014 [${archivedBeatIds.join(", ")}]` : ""}`,
24714
+ `**Notes reassigned:** ${notesReassigned}`,
24715
+ ...bvSection ? [bvSection] : [],
24716
+ "",
24717
+ archivedBeatIds.length > 0 ? "Merge event recorded. Run `check_portfolio_coherence` to verify the cluster is resolved." : "\u26A0 No beats were archived \u2014 check logs for archive failures. Re-run after resolving."
24718
+ ].join("\n");
24719
+ }
24720
+ function registerPortfolioCoherenceTools(server, ctx, client) {
24721
+ server.tool(
24722
+ "check_portfolio_coherence",
24723
+ "Run a portfolio coherence check on a project. Clusters Beats by semantic similarity to surface fragmentation (merge candidates) and checks what fraction of Beats have a VP-readable buyer abstraction score. By default returns a taskId immediately \u2014 set wait=true to block and receive the full report inline.",
24724
+ {
24725
+ projectId: external_exports.string().min(1).describe("The project ID"),
24726
+ wait: external_exports.boolean().optional().describe("Block until the check completes and return the report inline. Default: false.")
24727
+ },
24728
+ async ({ projectId, wait }) => {
24729
+ try {
24730
+ await assertProjectInOrg(client, projectId, ctx.orgId);
24731
+ const { taskId } = await client.runCheck(projectId, "portfolio_coherence", projectId);
24732
+ if (!wait) {
24733
+ return {
24734
+ content: [{
24735
+ type: "text",
24736
+ text: [
24737
+ `Portfolio coherence check enqueued for ${projectId}.`,
24738
+ `Task ID: ${taskId}`,
24739
+ "",
24740
+ `Use get_task_status with taskId="${taskId}" to poll, or list_checks with projectId="${projectId}" and checkType="portfolio_coherence" to view results.`
24741
+ ].join("\n")
24742
+ }]
24743
+ };
24744
+ }
24745
+ const check2 = await pollForCheck3(client, taskId);
24746
+ return { content: [{ type: "text", text: formatReport(check2) }] };
24747
+ } catch (err) {
24748
+ const message = err instanceof Error ? err.message : String(err);
24749
+ return { content: [{ type: "text", text: `Portfolio coherence check failed: ${message}` }], isError: true };
24750
+ }
24751
+ }
24752
+ );
24753
+ server.tool(
24754
+ "consolidate_beats",
24755
+ "Consolidate a fragmented Beat cluster \u2014 identified by check_portfolio_coherence \u2014 into a single VP-level capability. By default (confirm omitted or false) returns a dry-run preview: surviving Beat, Beats to archive, Notes to reassign, and any in-flight Beat Versions. Set confirm=true to execute the merge.",
24756
+ {
24757
+ projectId: external_exports.string().min(1).describe("The project ID \u2014 all beatIds must belong to this project"),
24758
+ beatIds: external_exports.array(external_exports.string().min(1)).min(2).max(50).describe("All Beat IDs in the cluster (including the one that will survive)"),
24759
+ primaryBeatId: external_exports.string().min(1).optional().describe("Beat ID that survives \u2014 defaults to beatIds[0] if omitted"),
24760
+ rationale: external_exports.string().max(2e3).optional().describe("Why these Beats are being merged \u2014 auto-generated if omitted"),
24761
+ confirm: external_exports.boolean().optional().describe("false/omitted = preview; true = execute the merge")
24762
+ },
24763
+ async ({ projectId, beatIds, primaryBeatId, rationale, confirm }) => {
24764
+ let archivedBeatIds = [];
24765
+ let totalNotesReassigned = 0;
24766
+ try {
24767
+ await assertProjectInOrg(client, projectId, ctx.orgId);
24768
+ const uniqueBeatIds = [...new Set(beatIds)];
24769
+ const resolvedPrimaryId = primaryBeatId ?? uniqueBeatIds[0];
24770
+ if (!uniqueBeatIds.includes(resolvedPrimaryId)) {
24771
+ return {
24772
+ content: [{ type: "text", text: `primaryBeatId "${resolvedPrimaryId}" is not in beatIds \u2014 include it in the cluster or omit to use beatIds[0].` }],
24773
+ isError: true
24774
+ };
24775
+ }
24776
+ const duplicateBeatIds = uniqueBeatIds.filter((id) => id !== resolvedPrimaryId);
24777
+ if (duplicateBeatIds.length === 0) {
24778
+ return {
24779
+ content: [{ type: "text", text: `No duplicates \u2014 all beatIds resolve to "${resolvedPrimaryId}" after deduplication. Include at least one distinct duplicate Beat ID.` }],
24780
+ isError: true
24781
+ };
24782
+ }
24783
+ const allProjectBeats = await client.listProjectBeats(projectId);
24784
+ const beatMap = new Map(allProjectBeats.map((b) => [b.beatId, b]));
24785
+ const primaryBeat = beatMap.get(resolvedPrimaryId);
24786
+ if (!primaryBeat) {
24787
+ return { content: [{ type: "text", text: `Primary Beat "${resolvedPrimaryId}" not found in project "${projectId}".` }], isError: true };
24788
+ }
24789
+ const missingIds = duplicateBeatIds.filter((id) => !beatMap.has(id));
24790
+ if (missingIds.length > 0) {
24791
+ return { content: [{ type: "text", text: `Beat(s) not found in project "${projectId}": ${missingIds.join(", ")}` }], isError: true };
24792
+ }
24793
+ const dupBvResults = await Promise.allSettled(
24794
+ duplicateBeatIds.map(
24795
+ (id) => client.listBeatVersions(projectId, { beatId: id, includeTerminal: false })
24796
+ )
24797
+ );
24798
+ const bvRejected = dupBvResults.filter((r) => r.status === "rejected");
24799
+ if (bvRejected.length > 0) {
24800
+ console.error("[consolidate_beats] listBeatVersions partial failure:", bvRejected.map((r) => r.reason instanceof Error ? r.reason.message : r.reason));
24801
+ }
24802
+ const duplicates = duplicateBeatIds.map((id, i) => ({
24803
+ beat: beatMap.get(id),
24804
+ inflightBvs: dupBvResults[i]?.status === "fulfilled" ? dupBvResults[i].value : []
24805
+ }));
24806
+ const resolvedRationale = rationale ?? `Merge ${duplicateBeatIds.length} overlapping Beat${duplicateBeatIds.length !== 1 ? "s" : ""} into "${primaryBeat.title}" \u2014 consolidating duplicate value proposition.`;
24807
+ if (!confirm) {
24808
+ return { content: [{ type: "text", text: formatConsolidationPreview(resolvedPrimaryId, primaryBeat, duplicates, resolvedRationale) }] };
24809
+ }
24810
+ for (const { beat } of duplicates) {
24811
+ const archiveResult = await client.updateBeatStatus(beat.beatId, "archived", {
24812
+ actor: { type: "human", id: ctx.user.userId, name: ctx.user.name, email: ctx.user.email },
24813
+ reason: `Merged into ${resolvedPrimaryId}: ${resolvedRationale}`
24814
+ });
24815
+ if (!archiveResult.success) {
24816
+ console.error("[consolidate_beats] archive failed for %s: %s", beat.beatId, archiveResult.error?.message ?? "unknown");
24817
+ continue;
24818
+ }
24819
+ archivedBeatIds.push(beat.beatId);
24820
+ try {
24821
+ const notes = await client.listBeatNotes(beat.beatId);
24822
+ for (const note of notes) {
24823
+ try {
24824
+ const reassigned = await client.reassignNote(note.noteId, resolvedPrimaryId, resolvedRationale, ctx.user.userId);
24825
+ if (reassigned !== void 0) {
24826
+ totalNotesReassigned++;
24827
+ } else {
24828
+ console.error("[consolidate_beats] reassignNote returned undefined for %s", note.noteId);
24829
+ }
24830
+ } catch (err) {
24831
+ console.error("[consolidate_beats] note reassign failed %s: %s", note.noteId, err instanceof Error ? err.message : err);
24832
+ }
24833
+ }
24834
+ } catch (err) {
24835
+ console.error("[consolidate_beats] listBeatNotes failed for %s: %s", beat.beatId, err instanceof Error ? err.message : err);
24836
+ }
24837
+ }
24838
+ if (archivedBeatIds.length > 0) {
24839
+ await client.recordMergeEvent(resolvedPrimaryId, archivedBeatIds, resolvedRationale, ctx.user.userId);
24840
+ }
24841
+ const inflightBvs = duplicates.flatMap((d) => d.inflightBvs);
24842
+ return { content: [{ type: "text", text: formatConsolidationResult(resolvedPrimaryId, primaryBeat, archivedBeatIds, totalNotesReassigned, inflightBvs) }] };
24843
+ } catch (err) {
24844
+ const message = err instanceof Error ? err.message : String(err);
24845
+ if (archivedBeatIds.length > 0) {
24846
+ return {
24847
+ content: [{
24848
+ type: "text",
24849
+ text: [
24850
+ `## Merge Partially Complete`,
24851
+ "",
24852
+ `**Archived:** ${archivedBeatIds.length} Beat${archivedBeatIds.length !== 1 ? "s" : ""} \u2014 [${archivedBeatIds.join(", ")}]`,
24853
+ `**Notes reassigned:** ${totalNotesReassigned}`,
24854
+ "",
24855
+ `\u26A0 Merge interrupted before completion: ${message}`,
24856
+ "Run `check_portfolio_coherence` to verify current state."
24857
+ ].join("\n")
24858
+ }],
24859
+ isError: true
24860
+ };
24861
+ }
24862
+ return { content: [{ type: "text", text: `consolidate_beats failed: ${message}` }], isError: true };
24863
+ }
24864
+ }
24865
+ );
24866
+ }
24867
+
24585
24868
  // ../../libs/harmonica-services/src/mcp/tools/project-lifecycle-tools.ts
24586
24869
  function registerProjectLifecycleTools(server, ctx, client) {
24587
24870
  server.tool(
@@ -25655,7 +25938,7 @@ ${JSON.stringify(task.result, null, 2)}
25655
25938
  );
25656
25939
  server.tool(
25657
25940
  "draft_coda",
25658
- "Generate and apply a Coda (description) for a Beat that has a title but no description. Uses project context, sibling Beats, related Notes, and codebase grep to write a resolved expression of the capability. The Coda is applied directly to the Beat, making it eligible for compose_beat. An activity is logged so the Beat owner can review and edit. Returns a task ID \u2014 use get_task_status to track progress.",
25941
+ "Generate and apply a Coda (description) for a Beat that has a title but no description. Uses project context, sibling Beats, related Notes, and codebase grep to write a resolved expression of the capability. The Coda is applied directly to the Beat, making it eligible for plan_beat_versions once a beat_quality check passes. An activity is logged so the Beat owner can review and edit. Returns a task ID \u2014 use get_task_status to track progress.",
25659
25942
  {
25660
25943
  projectId: external_exports.string().describe("The project ID"),
25661
25944
  beatId: external_exports.string().describe("The beat ID (must have a title but no description)")
@@ -25756,107 +26039,109 @@ ${JSON.stringify(task.result, null, 2)}
25756
26039
  }
25757
26040
  );
25758
26041
  server.tool(
25759
- "validate_work_items",
25760
- "Validate active workItem Notes against the codebase. Checks if work items are already implemented (resolved), no longer relevant (dismissed), or still needed (active). Active items get a solution plan for the implementation agent. Returns a task ID \u2014 use get_task_status to track progress.",
26042
+ "implement_revision",
26043
+ "Trigger an agent session to implement a Revision. The agent reads the Revision spec and beat-level Notes, writes code, commits, pushes a branch named agent/rev-{id}-{title}, and creates a draft PR with the Revision ID pre-populated in the PR body. Returns a task ID \u2014 use get_task_status to track progress. Requires agentCapabilities.implementation enabled on the project.",
25761
26044
  {
25762
- projectId: external_exports.string().describe("The project ID"),
25763
- noteIds: external_exports.array(external_exports.string()).optional().describe("Specific note IDs to validate. If omitted, validates all active work items.")
26045
+ revisionId: external_exports.string().min(1).describe("The Revision ID to implement (e.g., rev-abc123)"),
26046
+ projectId: external_exports.string().min(1).describe("The project ID")
25764
26047
  },
25765
- async ({ projectId, noteIds }) => {
26048
+ async ({ revisionId, projectId }) => {
25766
26049
  try {
25767
26050
  await assertProjectInOrg(client, projectId, ctx.orgId);
25768
- const task = await client.validateWorkItems(projectId, noteIds);
26051
+ const task = await client.implementRevision(revisionId, projectId, {
26052
+ name: ctx.user.name,
26053
+ email: ctx.user.email
26054
+ });
25769
26055
  return {
25770
26056
  content: [{
25771
26057
  type: "text",
25772
26058
  text: [
25773
- "Work item validation queued.",
26059
+ "Revision implementation session queued.",
25774
26060
  "",
25775
- `**Project:** ${projectId}`,
25776
- `**Scope:** ${noteIds ? `${noteIds.length} specific items` : "All active work items"}`,
26061
+ `**Revision:** ${revisionId}`,
25777
26062
  `**Task ID:** ${task.taskId}`,
25778
26063
  `**Status:** ${task.status}`,
25779
26064
  "",
25780
- "The validator will grep the codebase for evidence, then classify each item.",
25781
- "Use `get_task_status` with the task ID to check progress."
26065
+ "The agent will read the Revision spec and beat-level Notes, implement the code, and create a draft PR.",
26066
+ "Use `get_task_status` with the task ID to check progress and see the result (branch, PR URL, tool count, duration)."
25782
26067
  ].join("\n")
25783
26068
  }]
25784
26069
  };
25785
26070
  } catch (err) {
25786
26071
  const message = err instanceof Error ? err.message : String(err);
25787
- return { content: [{ type: "text", text: `Failed to start validation: ${message}` }], isError: true };
26072
+ return { content: [{ type: "text", text: `Failed to start revision implementation: ${message}` }], isError: true };
25788
26073
  }
25789
26074
  }
25790
26075
  );
25791
26076
  server.tool(
25792
- "plan_revision_batch",
25793
- "Autonomously generate work items for planning Revisions. If revisionIds are omitted, discovers all planning Revisions with no scoped work items and plans them all. Uses codebase-aware agent sessions when available, LLM-only fallback otherwise. Returns a task ID \u2014 use get_task_status to track progress.",
26077
+ "validate_work_items",
26078
+ "Validate active workItem Notes against the codebase. Checks if work items are already implemented (resolved), no longer relevant (dismissed), or still needed (active). Active items get a solution plan for the implementation agent. Returns a task ID \u2014 use get_task_status to track progress.",
25794
26079
  {
25795
26080
  projectId: external_exports.string().describe("The project ID"),
25796
- revisionIds: external_exports.array(external_exports.string()).optional().describe("Specific revision IDs to plan. If omitted, auto-discovers all unplanned planning revisions.")
26081
+ noteIds: external_exports.array(external_exports.string()).optional().describe("Specific note IDs to validate. If omitted, validates all active work items.")
25797
26082
  },
25798
- async ({ projectId, revisionIds }) => {
26083
+ async ({ projectId, noteIds }) => {
25799
26084
  try {
25800
26085
  await assertProjectInOrg(client, projectId, ctx.orgId);
25801
- const task = await client.planRevisionBatch(projectId, revisionIds);
26086
+ const task = await client.validateWorkItems(projectId, noteIds);
25802
26087
  return {
25803
26088
  content: [{
25804
26089
  type: "text",
25805
26090
  text: [
25806
- "Revision batch planning queued.",
26091
+ "Work item validation queued.",
25807
26092
  "",
25808
26093
  `**Project:** ${projectId}`,
25809
- `**Scope:** ${revisionIds ? `${revisionIds.length} specific revision(s)` : "All unplanned planning revisions"}`,
26094
+ `**Scope:** ${noteIds ? `${noteIds.length} specific items` : "All active work items"}`,
25810
26095
  `**Task ID:** ${task.taskId}`,
25811
26096
  `**Status:** ${task.status}`,
25812
26097
  "",
25813
- "The agent will generate work items for each revision. Revisions stay in planning \u2014 review work items before advancing to building.",
26098
+ "The validator will grep the codebase for evidence, then classify each item.",
25814
26099
  "Use `get_task_status` with the task ID to check progress."
25815
26100
  ].join("\n")
25816
26101
  }]
25817
26102
  };
25818
26103
  } catch (err) {
25819
26104
  const message = err instanceof Error ? err.message : String(err);
25820
- return { content: [{ type: "text", text: `Failed to start revision batch planning: ${message}` }], isError: true };
26105
+ return { content: [{ type: "text", text: `Failed to start validation: ${message}` }], isError: true };
25821
26106
  }
25822
26107
  }
25823
26108
  );
25824
26109
  server.tool(
25825
- "compose_beat",
25826
- "Autonomously advance a Beat from composing to composed. Reads the Beat's Coda, project strategy, sibling Beats, existing Notes, and greps the codebase for context \u2014 then enriches the Beat description (acceptance criteria, scope, success criteria) and scaffolds delivery Revisions. Returns a task ID \u2014 use get_task_status to track progress.",
26110
+ "plan_revision_batch",
26111
+ "Autonomously generate work items for planning Revisions. If revisionIds are omitted, discovers all planning Revisions with no scoped work items and plans them all. Uses codebase-aware agent sessions when available, LLM-only fallback otherwise. Returns a task ID \u2014 use get_task_status to track progress.",
25827
26112
  {
25828
26113
  projectId: external_exports.string().describe("The project ID"),
25829
- beatId: external_exports.string().describe("The beat ID to compose (must be in composing status with a Coda set)")
26114
+ revisionIds: external_exports.array(external_exports.string()).optional().describe("Specific revision IDs to plan. If omitted, auto-discovers all unplanned planning revisions.")
25830
26115
  },
25831
- async ({ projectId, beatId }) => {
26116
+ async ({ projectId, revisionIds }) => {
25832
26117
  try {
25833
26118
  await assertProjectInOrg(client, projectId, ctx.orgId);
25834
- const task = await client.composeBeat(projectId, beatId);
26119
+ const task = await client.planRevisionBatch(projectId, revisionIds);
25835
26120
  return {
25836
26121
  content: [{
25837
26122
  type: "text",
25838
26123
  text: [
25839
- "Beat composition session queued.",
26124
+ "Revision batch planning queued.",
25840
26125
  "",
25841
- `**Beat:** ${beatId}`,
25842
26126
  `**Project:** ${projectId}`,
26127
+ `**Scope:** ${revisionIds ? `${revisionIds.length} specific revision(s)` : "All unplanned planning revisions"}`,
25843
26128
  `**Task ID:** ${task.taskId}`,
25844
26129
  `**Status:** ${task.status}`,
25845
26130
  "",
25846
- "The agent will gather context, enrich the Beat description, and scaffold delivery Revisions.",
25847
- "Use `get_task_status` with the task ID to check progress and see the result."
26131
+ "The agent will generate work items for each revision. Revisions stay in planning \u2014 review work items before advancing to building.",
26132
+ "Use `get_task_status` with the task ID to check progress."
25848
26133
  ].join("\n")
25849
26134
  }]
25850
26135
  };
25851
26136
  } catch (err) {
25852
26137
  const message = err instanceof Error ? err.message : String(err);
25853
- return { content: [{ type: "text", text: `Failed to start beat composition: ${message}` }], isError: true };
26138
+ return { content: [{ type: "text", text: `Failed to start revision batch planning: ${message}` }], isError: true };
25854
26139
  }
25855
26140
  }
25856
26141
  );
25857
26142
  server.tool(
25858
26143
  "run_triage",
25859
- "Run the autonomous triage loop for a project. Analyzes next actions, validates work items against the codebase, auto-executes safe actions (draft_coda, compose_beat, plan_revision_batch, validate_work_items), and escalates items that need human judgment. Requires agentCapabilities.triage enabled on the project. Returns a task ID \u2014 use get_task_status to track progress.",
26144
+ "Run the autonomous triage loop for a project. Analyzes next actions, validates work items against the codebase, auto-executes safe actions (draft_coda, plan_revision_batch, validate_work_items), and escalates items that need human judgment. Requires agentCapabilities.triage enabled on the project. Returns a task ID \u2014 use get_task_status to track progress.",
25860
26145
  {
25861
26146
  projectId: external_exports.string().describe("The project ID")
25862
26147
  },
@@ -25945,6 +26230,7 @@ function registerAllTools(server, ctx, client) {
25945
26230
  registerSnapshotTools(server, ctx, client);
25946
26231
  registerBeatQualityTools(server, ctx, client);
25947
26232
  registerPlanQualityTools(server, ctx, client);
26233
+ registerPortfolioCoherenceTools(server, ctx, client);
25948
26234
  registerBeatReframeTools(server, ctx, client);
25949
26235
  registerBeatPlanningTools(server, ctx, client);
25950
26236
  registerRevisionLifecycleTools(server, ctx, client);
@@ -26043,6 +26329,13 @@ var ApiError = class extends Error {
26043
26329
  this.name = "ApiError";
26044
26330
  }
26045
26331
  };
26332
+ function safeParseErrorBody(body) {
26333
+ try {
26334
+ return JSON.parse(body);
26335
+ } catch {
26336
+ return void 0;
26337
+ }
26338
+ }
26046
26339
  function createHttpClient(config2) {
26047
26340
  const url = new URL(config2.apiBaseUrl);
26048
26341
  const isLocalhost = url.hostname === "localhost" || url.hostname === "127.0.0.1";
@@ -26084,7 +26377,7 @@ function createHttpClient(config2) {
26084
26377
  }
26085
26378
  return void 0;
26086
26379
  }
26087
- const POLL_INTERVAL_MS3 = 4e3;
26380
+ const POLL_INTERVAL_MS4 = 4e3;
26088
26381
  async function pollTaskResult(taskId) {
26089
26382
  const deadline = Date.now() + LONG_RUNNING_TIMEOUT_MS;
26090
26383
  while (Date.now() < deadline) {
@@ -26094,7 +26387,7 @@ function createHttpClient(config2) {
26094
26387
  );
26095
26388
  if (task?.status === "completed") return task.result;
26096
26389
  if (task?.status === "failed") throw new Error(`Task failed: ${task.error ?? "unknown error"}`);
26097
- await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS3));
26390
+ await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS4));
26098
26391
  }
26099
26392
  throw new Error(`Task ${taskId} timed out after ${LONG_RUNNING_TIMEOUT_MS}ms`);
26100
26393
  }
@@ -26596,14 +26889,31 @@ function createHttpClient(config2) {
26596
26889
  const result = await request("GET", `/api/projects/${encodeURIComponent(projectId)}/beats/${encodeURIComponent(beatId)}/history`);
26597
26890
  return result?.timeline ?? [];
26598
26891
  },
26599
- // Beat Composer
26600
- composeRevisions: async (beatId, drafts, triggeredBy, options) => {
26601
- const result = await request("POST", `/api/beats/${encodeURIComponent(beatId)}/scaffold-revisions`, { drafts, triggeredBy, force: options?.force });
26602
- return {
26603
- revisions: result.revisions,
26604
- alreadyExisted: result.alreadyExisted ?? false
26605
- };
26892
+ // Plan Beat Versions (agent decomposition of a Beat into waypoint BVs)
26893
+ planBeatVersions: async (beatId, _actorId, options) => {
26894
+ const force = options?.force ? "?force=true" : "";
26895
+ try {
26896
+ const result = await request(
26897
+ "POST",
26898
+ `/api/beats/${encodeURIComponent(beatId)}/plan-versions${force}`,
26899
+ {}
26900
+ );
26901
+ if (!result) throw new Error(`Beat not found: ${beatId}`);
26902
+ return result.beatVersions;
26903
+ } catch (err) {
26904
+ if (err instanceof ApiError) {
26905
+ const parsed = safeParseErrorBody(err.body);
26906
+ if (err.status === 422 && parsed?.code === "beat_quality_gate_failed" && parsed.gate) {
26907
+ throw new BeatQualityGateError(parsed.gate);
26908
+ }
26909
+ if (err.status === 409 && parsed?.code === "plan_versions_already_exist" && Array.isArray(parsed.existingBeatVersionIds)) {
26910
+ throw new PlanVersionsIdempotencyError(parsed.existingBeatVersionIds);
26911
+ }
26912
+ }
26913
+ throw err;
26914
+ }
26606
26915
  },
26916
+ // Beat Composer
26607
26917
  recordMergeEvent: async () => {
26608
26918
  throw new Error("Beat merge not supported via HTTP client yet");
26609
26919
  },
@@ -26664,6 +26974,15 @@ function createHttpClient(config2) {
26664
26974
  if (!result?.taskId) throw new Error("Implementation enqueue failed: no taskId returned");
26665
26975
  return { taskId: result.taskId, taskType: "work_item_implementation", status: result.status ?? "pending", projectId, createdAt: (/* @__PURE__ */ new Date()).toISOString() };
26666
26976
  },
26977
+ implementRevision: async (revisionId, projectId, triggeredBy) => {
26978
+ const result = await request(
26979
+ "POST",
26980
+ `/api/projects/${encodeURIComponent(projectId)}/revisions/${encodeURIComponent(revisionId)}/implement`,
26981
+ triggeredBy ? { triggeredBy } : {}
26982
+ );
26983
+ if (!result?.taskId) throw new Error("Revision implementation enqueue failed: no taskId returned");
26984
+ return { taskId: result.taskId, taskType: "revision_implementation", status: result.status ?? "pending", projectId, createdAt: (/* @__PURE__ */ new Date()).toISOString() };
26985
+ },
26667
26986
  validateWorkItems: async (projectId, noteIds) => {
26668
26987
  const result = await request(
26669
26988
  "POST",
@@ -26673,15 +26992,6 @@ function createHttpClient(config2) {
26673
26992
  if (!result?.taskId) throw new Error("Validation enqueue failed: no taskId returned");
26674
26993
  return { taskId: result.taskId, taskType: "work_item_validation", status: result.status ?? "pending", projectId, createdAt: (/* @__PURE__ */ new Date()).toISOString() };
26675
26994
  },
26676
- composeBeat: async (projectId, beatId) => {
26677
- const result = await request(
26678
- "POST",
26679
- `/api/projects/${encodeURIComponent(projectId)}/beats/${encodeURIComponent(beatId)}/compose`,
26680
- {}
26681
- );
26682
- if (!result?.taskId) throw new Error("Compose beat enqueue failed: no taskId returned");
26683
- return { taskId: result.taskId, taskType: "compose_beat", status: result.status ?? "pending", projectId, createdAt: (/* @__PURE__ */ new Date()).toISOString() };
26684
- },
26685
26995
  planRevisionBatch: async (projectId, revisionIds) => {
26686
26996
  const result = await request(
26687
26997
  "POST",
@@ -26833,10 +27143,17 @@ function createHttpClient(config2) {
26833
27143
  const res = await request("GET", `/api/checks/${encodeURIComponent(checkId)}`);
26834
27144
  return res.check;
26835
27145
  },
26836
- listProjectChecks: async (projectId, checkType) => {
26837
- const qs = checkType ? `?type=${encodeURIComponent(checkType)}` : "";
26838
- const res = await request("GET", `/api/projects/${encodeURIComponent(projectId)}/checks${qs}`);
26839
- return res.checks;
27146
+ listProjectChecks: async (projectId, checkType, options) => {
27147
+ const params = new URLSearchParams();
27148
+ if (checkType) params.set("type", checkType);
27149
+ if (options?.limit !== void 0) params.set("limit", String(options.limit));
27150
+ if (options?.cursor) params.set("cursor", options.cursor);
27151
+ const qs = params.toString() ? `?${params.toString()}` : "";
27152
+ const res = await request(
27153
+ "GET",
27154
+ `/api/projects/${encodeURIComponent(projectId)}/checks${qs}`
27155
+ );
27156
+ return { checks: res?.checks ?? [], nextCursor: res?.nextCursor };
26840
27157
  },
26841
27158
  listBeatChecks: async (beatId, projectId, checkType) => {
26842
27159
  const qs = checkType ? `?type=${encodeURIComponent(checkType)}` : "";
@@ -27089,7 +27406,7 @@ function loadConfig() {
27089
27406
  };
27090
27407
  }
27091
27408
  async function main() {
27092
- console.error(`[harmonica-mcp] v${"0.27.0"} starting\u2026`);
27409
+ console.error(`[harmonica-mcp] v${"0.28.0"} starting\u2026`);
27093
27410
  const config2 = loadConfig();
27094
27411
  const client = createHttpClient({
27095
27412
  apiBaseUrl: config2.apiBaseUrl,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@codazen/harmonica-mcp",
3
- "version": "0.27.0",
3
+ "version": "0.28.0",
4
4
  "description": "MCP server for Harmonica — connect Claude to your Harmonica projects",
5
5
  "license": "MIT",
6
6
  "bin": {