@codazen/harmonica-mcp 0.29.0 → 0.31.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 +569 -243
  2. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -21877,7 +21877,7 @@ function formatBeatSummaryTable(beats) {
21877
21877
  });
21878
21878
  return [header, ...rows].join("\n");
21879
21879
  }
21880
- function formatBeatDetail(beat, workItemNotes) {
21880
+ function formatBeatDetail(beat) {
21881
21881
  const sections = [];
21882
21882
  sections.push(`# ${beat.beatId}: ${beat.title}`);
21883
21883
  sections.push(`**Status:** ${resolveStatus(beat).replace("_", " ")}`);
@@ -21896,38 +21896,8 @@ ${beat.description}`);
21896
21896
  if (beat.priority != null) {
21897
21897
  sections.push(`**Priority:** ${beat.priority}`);
21898
21898
  }
21899
- if (workItemNotes && workItemNotes.length > 0) {
21900
- formatWorkItemNotes(sections, workItemNotes);
21901
- }
21902
21899
  return sections.join("\n");
21903
21900
  }
21904
- function formatWorkItemNotes(sections, notes) {
21905
- const essentials = notes.filter((n) => n.workItemMeta?.scope === "essential");
21906
- const niceToHaves = notes.filter((n) => n.workItemMeta?.scope === "nice_to_have");
21907
- if (essentials.length > 0) {
21908
- const lines = essentials.filter((n) => n.workItemMeta).map((n) => {
21909
- const meta = n.workItemMeta;
21910
- const hours = meta.estimatedHoursRange ? ` [${meta.estimatedHoursRange}]` : "";
21911
- const check2 = meta.acceptanceCheck ? `
21912
- - _Check:_ ${meta.acceptanceCheck}` : "";
21913
- return `- **${n.content}** (${meta.category}, ${meta.complexity})${hours}${check2}`;
21914
- }).join("\n");
21915
- sections.push(`
21916
- ## Essentials (${essentials.length})
21917
- ${lines}`);
21918
- }
21919
- if (niceToHaves.length > 0) {
21920
- const lines = niceToHaves.filter((n) => n.workItemMeta).map((n) => {
21921
- const meta = n.workItemMeta;
21922
- const why = meta.whyOutOfScope ? `
21923
- - _Why out of scope:_ ${meta.whyOutOfScope}` : "";
21924
- return `- **${n.content}** (${meta.category}, ${meta.complexity})${why}`;
21925
- }).join("\n");
21926
- sections.push(`
21927
- ## Nice-to-Haves \u2014 Out of Scope (${niceToHaves.length})
21928
- ${lines}`);
21929
- }
21930
- }
21931
21901
 
21932
21902
  // ../../libs/harmonica-services/src/mcp/formatters/project-formatter.ts
21933
21903
  function formatProjectSummaryTable(projects) {
@@ -21945,7 +21915,6 @@ var NOTE_TYPE_LABELS = {
21945
21915
  constraint: "Constraints",
21946
21916
  guidance: "Guidance",
21947
21917
  decision: "Decisions",
21948
- workItem: "Work Items",
21949
21918
  document: "Documents"
21950
21919
  };
21951
21920
  function formatProjectContext(project, notes, orgCoda) {
@@ -22042,7 +22011,6 @@ var TYPE_LABELS = {
22042
22011
  constraint: "Constraint",
22043
22012
  guidance: "Guidance",
22044
22013
  decision: "Decision",
22045
- workItem: "Work Item",
22046
22014
  document: "Document"
22047
22015
  };
22048
22016
  function formatNoteSummary(note) {
@@ -22127,15 +22095,6 @@ function formatNoteDetail(note) {
22127
22095
  parts.push(`- Criticality: ${note.criticality.toFixed(2)}`);
22128
22096
  }
22129
22097
  }
22130
- if (note.workItemMeta) {
22131
- parts.push("", "### Work Item Details");
22132
- parts.push(`- Category: ${note.workItemMeta.category}`);
22133
- parts.push(`- Complexity: ${note.workItemMeta.complexity}`);
22134
- parts.push(`- Scope: ${note.workItemMeta.scope}`);
22135
- if (note.workItemMeta.estimatedHoursRange) {
22136
- parts.push(`- Estimated Hours: ${note.workItemMeta.estimatedHoursRange}`);
22137
- }
22138
- }
22139
22098
  if (note.documentMeta) {
22140
22099
  parts.push("", "### Document Details");
22141
22100
  parts.push(`- Filename: ${note.documentMeta.originalFilename}`);
@@ -22168,6 +22127,7 @@ var TYPE_LABELS2 = {
22168
22127
  };
22169
22128
  var STATUS_LABELS = {
22170
22129
  running: "Running",
22130
+ held: "Held",
22171
22131
  succeeded: "Succeeded",
22172
22132
  failed: "Failed",
22173
22133
  timed_out: "Timed Out",
@@ -22310,7 +22270,7 @@ function registerBeatTools(server, ctx, client) {
22310
22270
  );
22311
22271
  server.tool(
22312
22272
  "get_beat",
22313
- "Get full beat detail including description and work items",
22273
+ "Get full beat detail including description",
22314
22274
  { beatId: external_exports.string().describe("The beat ID (e.g., TF-B-001)") },
22315
22275
  async ({ beatId }) => {
22316
22276
  await assertBeatInOrg(client, beatId, ctx.orgId);
@@ -22318,12 +22278,7 @@ function registerBeatTools(server, ctx, client) {
22318
22278
  if (!beat) {
22319
22279
  return { content: [{ type: "text", text: `Beat not found: "${beatId}"` }], isError: true };
22320
22280
  }
22321
- const workItemIndex = await client.listBeatNotes(beatId, { noteType: "workItem" });
22322
- const workItemNotes = await Promise.all(
22323
- workItemIndex.map((n) => client.getNote(n.noteId))
22324
- );
22325
- const validNotes = workItemNotes.filter((n) => n !== void 0);
22326
- const text = formatBeatDetail(beat, validNotes);
22281
+ const text = formatBeatDetail(beat);
22327
22282
  return { content: [{ type: "text", text }] };
22328
22283
  }
22329
22284
  );
@@ -22681,6 +22636,15 @@ function registerBeatVersionTools(server, ctx, client) {
22681
22636
  createdBy: ctx.user.email,
22682
22637
  sourceType: "mcp_tool"
22683
22638
  });
22639
+ if (!beatVersion?.beatVersionId || !beatVersion.title) {
22640
+ return {
22641
+ content: [{
22642
+ type: "text",
22643
+ text: `Beat Version was stored but the response is missing required fields (beatVersionId or title). Use list_beat_versions on beat "${beatId}" to find the newly created version.`
22644
+ }],
22645
+ isError: true
22646
+ };
22647
+ }
22684
22648
  const lines = [
22685
22649
  `Beat Version created successfully.`,
22686
22650
  "",
@@ -22844,6 +22808,34 @@ function registerBeatVersionTools(server, ctx, client) {
22844
22808
  `**Transition:** ${bv.status} \u2192 ${targetState}`
22845
22809
  ];
22846
22810
  if (reason) lines.push(`**Reason:** ${reason}`);
22811
+ if (result.fanOutWarning) {
22812
+ lines.push("");
22813
+ lines.push(`\u26A0 Fan-out warning: ${result.fanOutWarning.message}`);
22814
+ }
22815
+ if (targetState === "building" && !bv.targetDropId) {
22816
+ try {
22817
+ const project = await client.getProject(bv.projectId);
22818
+ const teamspaceId = project?.teamspaceId;
22819
+ if (teamspaceId) {
22820
+ const draftDrops = await client.listTeamspaceDrops(teamspaceId, { state: "draft" });
22821
+ lines.push("");
22822
+ if (draftDrops.length > 0) {
22823
+ lines.push("\u26A0 **No Drop assigned.** This Beat Version has no target Drop. Assign it to track which release this work belongs to.");
22824
+ lines.push("");
22825
+ lines.push("Available draft Drops:");
22826
+ for (const drop of draftDrops) {
22827
+ lines.push(` \u2022 ${drop.dropCode} \u2014 ${drop.name} (${drop.dropId})`);
22828
+ }
22829
+ lines.push("");
22830
+ lines.push("Use `update_beat_version` to set `targetDropId`, or call `add_beat_version_to_drop`.");
22831
+ } else {
22832
+ lines.push(`\u26A0 **No Drop assigned and no draft Drops exist in Teamspace \`${teamspaceId}\`.** Create a Drop first with \`create_drop\`, then assign this Beat Version to it.`);
22833
+ }
22834
+ }
22835
+ } catch (err) {
22836
+ console.warn("[drop-warning] drop lookup failed for Beat Version", beatVersionId, err);
22837
+ }
22838
+ }
22847
22839
  return { content: [{ type: "text", text: lines.join("\n") }] };
22848
22840
  } catch (err) {
22849
22841
  const message = err instanceof Error ? err.message : String(err);
@@ -22885,20 +22877,98 @@ function registerCheckTools(server, ctx, client) {
22885
22877
  );
22886
22878
  server.tool(
22887
22879
  "list_checks",
22888
- "List persisted checks for a project, beat, or revision. Shows score trends over time. Filter by check type.",
22880
+ "List persisted checks for a project, beat, beat version, or revision. Shows score trends over time. Filter by check type.",
22889
22881
  {
22890
22882
  projectId: external_exports.string().describe("The project ID"),
22891
22883
  beatId: external_exports.string().optional().describe("List checks for this specific beat"),
22884
+ beatVersionId: external_exports.string().optional().describe("List checks for this specific beat version"),
22892
22885
  revisionId: external_exports.string().optional().describe("List checks for this specific revision"),
22893
22886
  checkType: external_exports.enum(["beat_quality", "plan_quality", "build_quality", "pii_scan", "portfolio_coherence"]).optional().describe("Filter by check type")
22894
22887
  },
22895
- async ({ projectId, beatId, revisionId, checkType }) => {
22888
+ async ({ projectId, beatId, beatVersionId, revisionId, checkType }) => {
22896
22889
  try {
22890
+ const filters = [
22891
+ revisionId ? "revisionId" : null,
22892
+ beatVersionId ? "beatVersionId" : null,
22893
+ beatId ? "beatId" : null
22894
+ ].filter(Boolean);
22895
+ if (filters.length > 1) {
22896
+ return {
22897
+ content: [{
22898
+ type: "text",
22899
+ text: `Only one of revisionId, beatVersionId, beatId may be supplied (got: ${filters.join(", ")}). Pick the target scope and resend.`
22900
+ }],
22901
+ isError: true
22902
+ };
22903
+ }
22904
+ if (revisionId) {
22905
+ if (revisionId.startsWith("bv-")) {
22906
+ return {
22907
+ content: [{
22908
+ type: "text",
22909
+ text: "revisionId must start with 'rev-', got a Beat Version ID (bv-prefix). Use beatVersionId parameter instead."
22910
+ }],
22911
+ isError: true
22912
+ };
22913
+ }
22914
+ if (!revisionId.startsWith("rev-")) {
22915
+ return {
22916
+ content: [{
22917
+ type: "text",
22918
+ text: `revisionId must start with 'rev-', got '${revisionId}'. Pass the full Revision UUID (rev-xxxxxxxx-...).`
22919
+ }],
22920
+ isError: true
22921
+ };
22922
+ }
22923
+ }
22924
+ if (beatVersionId) {
22925
+ if (beatVersionId.startsWith("rev-")) {
22926
+ return {
22927
+ content: [{
22928
+ type: "text",
22929
+ text: "beatVersionId must start with 'bv-', got a Revision ID (rev-prefix). Use revisionId parameter instead."
22930
+ }],
22931
+ isError: true
22932
+ };
22933
+ }
22934
+ if (!beatVersionId.startsWith("bv-")) {
22935
+ return {
22936
+ content: [{
22937
+ type: "text",
22938
+ text: `beatVersionId must start with 'bv-', got '${beatVersionId}'. Pass the full Beat Version UUID (bv-xxxxxxxx-...).`
22939
+ }],
22940
+ isError: true
22941
+ };
22942
+ }
22943
+ }
22944
+ if (beatId) {
22945
+ if (beatId.startsWith("rev-")) {
22946
+ return {
22947
+ content: [{
22948
+ type: "text",
22949
+ text: "beatId must not be a Revision ID (rev-prefix). Use revisionId parameter instead."
22950
+ }],
22951
+ isError: true
22952
+ };
22953
+ }
22954
+ if (beatId.startsWith("bv-")) {
22955
+ return {
22956
+ content: [{
22957
+ type: "text",
22958
+ text: "beatId must not be a Beat Version ID (bv-prefix). Use beatVersionId parameter instead."
22959
+ }],
22960
+ isError: true
22961
+ };
22962
+ }
22963
+ }
22897
22964
  let checks;
22898
22965
  let scope;
22899
22966
  if (revisionId) {
22900
22967
  checks = await client.listRevisionChecks(revisionId, projectId, checkType);
22901
22968
  scope = `revision ${revisionId}`;
22969
+ } else if (beatVersionId) {
22970
+ checks = await client.listBeatVersionChecks(beatVersionId, projectId, checkType);
22971
+ scope = `beat_version ${beatVersionId}`;
22902
22972
  } else if (beatId) {
22903
22973
  checks = await client.listBeatChecks(beatId, projectId, checkType);
22904
22974
  scope = `beat ${beatId}`;
@@ -23108,7 +23178,7 @@ var DROP_STATES = ["draft", "scheduled", "in_progress", "released", "rolled_back
23108
23178
  function registerDropTools(server, ctx, client) {
23109
23179
  server.tool(
23110
23180
  "list_drops",
23111
- "List Drops in a Teamspace, optionally filtered by lifecycle state and sorted. A Drop is a release container \u2014 different from a Beat (capability) or Revision (work item). Use this to see what releases are planned, in flight, or shipped.",
23181
+ "List Drops in a Teamspace, optionally filtered by lifecycle state and sorted. A Drop is a release container \u2014 different from a Beat (capability) or Revision (PR-level increment). Use this to see what releases are planned, in flight, or shipped.",
23112
23182
  {
23113
23183
  teamspaceId: external_exports.string().describe("The teamspace ID"),
23114
23184
  state: external_exports.enum(DROP_STATES).optional().describe("Filter by a single Drop state (use status for multi-state OR filtering)"),
@@ -23433,7 +23503,7 @@ function registerEmbeddingTools(server, ctx, client) {
23433
23503
  limit: external_exports.coerce.number().int().min(1).max(50).optional().default(10).describe("Max results to return. Default 10"),
23434
23504
  includeArchived: external_exports.boolean().optional().default(false).describe("Include archived/deprecated entities in results (excluded by default)"),
23435
23505
  entityType: external_exports.enum(["note", "beat", "revision"]).optional().describe("Restrict results to a specific entity type"),
23436
- noteType: external_exports.enum(["context", "assumption", "constraint", "guidance", "decision", "workItem", "document"]).optional().describe("Restrict results to a specific note type (only applies when entityType is note or unset). Requires embeddings generated after this filter was introduced."),
23506
+ noteType: external_exports.enum(["context", "assumption", "constraint", "guidance", "decision", "document"]).optional().describe("Restrict results to a specific note type (only applies when entityType is note or unset). Requires embeddings generated after this filter was introduced."),
23437
23507
  noteStatus: external_exports.enum(["active", "unvalidated", "validated", "invalidated", "inProgress", "resolved", "superseded", "dismissed"]).optional().describe("Restrict note results to a specific status (e.g. unvalidated). DynamoDB post-filter \u2014 works without re-embedding."),
23438
23508
  beatId: external_exports.string().optional().describe("Restrict note results to those belonging to a specific beat (DynamoDB post-filter, no re-embedding needed)")
23439
23509
  },
@@ -23807,7 +23877,7 @@ function formatTriageActions(actions) {
23807
23877
  }
23808
23878
 
23809
23879
  // ../../libs/harmonica-services/src/mcp/tools/note-tools.ts
23810
- var NOTE_TYPE_VALUES = ["context", "assumption", "constraint", "guidance", "decision", "workItem", "document"];
23880
+ var NOTE_TYPE_VALUES = ["context", "assumption", "constraint", "guidance", "decision", "document"];
23811
23881
  var NOTE_STATUS_VALUES = ["active", "unvalidated", "validated", "invalidated", "inProgress", "resolved", "superseded", "dismissed"];
23812
23882
  function registerNoteTools(server, ctx, client) {
23813
23883
  server.tool(
@@ -23940,7 +24010,6 @@ function registerNoteTools(server, ctx, client) {
23940
24010
  rationale,
23941
24011
  createdBy: ctx.user.userId,
23942
24012
  aiGenerated: false,
23943
- humanAssignee: noteType === "workItem" ? { email: ctx.user.email, name: ctx.user.name } : void 0,
23944
24013
  affectsBeats,
23945
24014
  dependsOnNotes,
23946
24015
  assumptionMeta
@@ -23992,7 +24061,7 @@ ${text}` }] };
23992
24061
  content: external_exports.string().optional().describe("Updated content"),
23993
24062
  response: external_exports.string().optional().describe("User response (for resolving assumptions)"),
23994
24063
  beatId: external_exports.string().optional().describe("Move this note to a different beat (updates all index items transactionally)"),
23995
- revisionId: external_exports.string().optional().describe("Revision ID to associate this note with (primary use case: workItem notes; occasionally useful for revision-scoped assumptions or decisions)"),
24064
+ revisionId: external_exports.string().optional().describe("Revision ID to associate this note with (revision-scoped assumptions, decisions, or guidance)"),
23996
24065
  dependsOnNotes: external_exports.array(external_exports.string()).optional().describe("Note IDs this note depends on (child \u2192 parent links for consolidation)"),
23997
24066
  humanAssignee: humanAssigneeSchema.nullable().optional().describe("Assign or reassign this note to a person (null to clear)"),
23998
24067
  agentAssignee: agentAssigneeSchema.nullable().optional().describe("Assign or reassign this note to an agent (null to clear)")
@@ -24252,7 +24321,7 @@ function registerOnboardingTools(server, ctx, client) {
24252
24321
  server.tool(
24253
24322
  "create_teamspace_onboarding_batch",
24254
24323
  [
24255
- "Create a Teamspace, Projects, Notes, Deliverables, and stakeholder workItems",
24324
+ "Create a Teamspace, Projects, Notes, and Deliverables",
24256
24325
  "from a PM-approved onboarding payload in a single batch operation.",
24257
24326
  "Call this after the PM approves the pre-flight review card from initiate_teamspace_onboarding.",
24258
24327
  "Returns a creation receipt with IDs and counts for all created entities.",
@@ -24262,15 +24331,13 @@ function registerOnboardingTools(server, ctx, client) {
24262
24331
  teamspaceName: external_exports.string().min(1).max(200).describe("Teamspace display name"),
24263
24332
  projects: external_exports.array(OnboardingProjectInputSchema).min(1).describe("Phase 1 projects to create, each linked to the Teamspace"),
24264
24333
  teamspaceNotes: external_exports.array(OnboardingNoteInputSchema).describe("Constraints, assumptions, and context notes at the Teamspace/project level"),
24265
- deliverables: external_exports.array(OnboardingDeliverableInputSchema).describe("Deliverables detected from commitment language in documents"),
24266
- stakeholderWorkItems: external_exports.array(external_exports.string().min(1)).describe("Work item content strings for missing client stakeholder roles")
24334
+ deliverables: external_exports.array(OnboardingDeliverableInputSchema).describe("Deliverables detected from commitment language in documents")
24267
24335
  },
24268
24336
  async ({
24269
24337
  teamspaceName,
24270
24338
  projects,
24271
24339
  teamspaceNotes,
24272
- deliverables,
24273
- stakeholderWorkItems
24340
+ deliverables
24274
24341
  }) => {
24275
24342
  const lines = [];
24276
24343
  let teamspace;
@@ -24373,27 +24440,6 @@ Notes filed: ${createdNotes.length}`);
24373
24440
  }
24374
24441
  }
24375
24442
  lines.push(`Deliverables: ${createdDeliverables.length}`);
24376
- let workItemCount = 0;
24377
- if (stakeholderWorkItems.length > 0 && createdProjects.length > 0) {
24378
- const firstProjectId = createdProjects[0].projectId;
24379
- for (const content of stakeholderWorkItems) {
24380
- try {
24381
- await client.createNote({
24382
- noteId: `N-TEMP-${Date.now()}`,
24383
- // overridden server-side
24384
- projectId: firstProjectId,
24385
- noteType: "workItem",
24386
- content,
24387
- status: "active",
24388
- createdBy: ctx.user?.userId ?? "mcp",
24389
- aiGenerated: false
24390
- });
24391
- workItemCount++;
24392
- } catch {
24393
- }
24394
- }
24395
- }
24396
- lines.push(`Stakeholder workItems: ${workItemCount}`);
24397
24443
  lines.unshift(`\u2705 Onboarding batch created successfully.`);
24398
24444
  return { content: [{ type: "text", text: lines.join("\n") }] };
24399
24445
  }
@@ -24954,17 +25000,18 @@ function registerProjectTools(server, ctx, client) {
24954
25000
  "Get project metadata, description, and notes in one view",
24955
25001
  { projectId: external_exports.string().describe("The project ID") },
24956
25002
  async ({ projectId }) => {
24957
- await assertProjectInOrg(client, projectId, ctx.orgId);
24958
- const [project, org] = await Promise.all([
24959
- client.getProject(projectId),
24960
- client.getOrg(ctx.orgId)
24961
- ]);
24962
- if (!project) {
24963
- return { content: [{ type: "text", text: `Project not found: "${projectId}"` }], isError: true };
25003
+ try {
25004
+ const [project, org] = await Promise.all([
25005
+ fetchProjectInOrg(client, projectId, ctx.orgId),
25006
+ client.getOrg(ctx.orgId)
25007
+ ]);
25008
+ const notes = await client.listProjectNotes(projectId);
25009
+ const text = formatProjectContext(project, notes, org?.coda);
25010
+ return { content: [{ type: "text", text }] };
25011
+ } catch (err) {
25012
+ const message = err instanceof Error ? err.message : String(err);
25013
+ return { content: [{ type: "text", text: `Failed to get project context: ${message}` }], isError: true };
24964
25014
  }
24965
- const notes = await client.listProjectNotes(projectId);
24966
- const text = formatProjectContext(project, notes, org?.coda);
24967
- return { content: [{ type: "text", text }] };
24968
25015
  }
24969
25016
  );
24970
25017
  server.tool(
@@ -24982,39 +25029,44 @@ function registerProjectTools(server, ctx, client) {
24982
25029
  rateLimitOverrides: external_exports.record(external_exports.string(), external_exports.object({ maxPerHour: external_exports.number().min(0) })).optional().describe('Per-task-type rate limit overrides, e.g. {"agent_chat":{"maxPerHour":100}}. Overrides env var and compiled defaults.')
24983
25030
  },
24984
25031
  async ({ projectId, ...updates }) => {
24985
- await assertProjectInOrg(client, projectId, ctx.orgId);
24986
- if (updates.teamspaceId != null) {
24987
- const teamspace = await client.getTeamspace(updates.teamspaceId);
24988
- if (!teamspace) {
24989
- return { content: [{ type: "text", text: "Teamspace not found" }], isError: true };
25032
+ try {
25033
+ const nonEmpty = Object.fromEntries(
25034
+ Object.entries(updates).filter(([, v]) => v !== void 0)
25035
+ );
25036
+ if (Object.keys(nonEmpty).length === 0) {
25037
+ return { content: [{ type: "text", text: "No updates provided." }], isError: true };
24990
25038
  }
24991
- if (teamspace.orgId !== ctx.orgId) {
24992
- return { content: [{ type: "text", text: "Teamspace does not belong to this organization" }], isError: true };
25039
+ await assertProjectInOrg(client, projectId, ctx.orgId);
25040
+ if (updates.teamspaceId != null) {
25041
+ const teamspace = await client.getTeamspace(updates.teamspaceId);
25042
+ if (!teamspace) {
25043
+ return { content: [{ type: "text", text: "Teamspace not found" }], isError: true };
25044
+ }
25045
+ if (teamspace.orgId !== ctx.orgId) {
25046
+ return { content: [{ type: "text", text: "Teamspace does not belong to this organization" }], isError: true };
25047
+ }
24993
25048
  }
25049
+ const updated = await client.updateProject(projectId, nonEmpty);
25050
+ if (!updated) {
25051
+ return { content: [{ type: "text", text: `Project not found: "${projectId}"` }], isError: true };
25052
+ }
25053
+ if (PROJECT_EMBEDDING_FIELDS.some((f) => f in nonEmpty)) {
25054
+ void client.triggerProjectEmbedding(projectId);
25055
+ }
25056
+ const lines = [
25057
+ `Project updated successfully.`,
25058
+ "",
25059
+ `**ID:** ${updated.projectId}`,
25060
+ `**Title:** ${updated.title}`,
25061
+ updated.strategy ? `**Strategy:** (updated)` : "",
25062
+ updated.repoOwner ? `**Repo:** ${updated.repoOwner}/${updated.repoName}` : "",
25063
+ updated.repoDefaultBranch ? `**Default Branch:** ${updated.repoDefaultBranch}` : ""
25064
+ ].filter(Boolean);
25065
+ return { content: [{ type: "text", text: lines.join("\n") }] };
25066
+ } catch (err) {
25067
+ const message = err instanceof Error ? err.message : String(err);
25068
+ return { content: [{ type: "text", text: `Failed to update project: ${message}` }], isError: true };
24994
25069
  }
24995
- const nonEmpty = Object.fromEntries(
24996
- Object.entries(updates).filter(([, v]) => v !== void 0)
24997
- );
24998
- if (Object.keys(nonEmpty).length === 0) {
24999
- return { content: [{ type: "text", text: "No updates provided." }], isError: true };
25000
- }
25001
- const updated = await client.updateProject(projectId, nonEmpty);
25002
- if (!updated) {
25003
- return { content: [{ type: "text", text: `Project not found: "${projectId}"` }], isError: true };
25004
- }
25005
- if (PROJECT_EMBEDDING_FIELDS.some((f) => f in nonEmpty)) {
25006
- void client.triggerProjectEmbedding(projectId);
25007
- }
25008
- const lines = [
25009
- `Project updated successfully.`,
25010
- "",
25011
- `**ID:** ${updated.projectId}`,
25012
- `**Title:** ${updated.title}`,
25013
- updated.strategy ? `**Strategy:** (updated)` : "",
25014
- updated.repoOwner ? `**Repo:** ${updated.repoOwner}/${updated.repoName}` : "",
25015
- updated.repoDefaultBranch ? `**Default Branch:** ${updated.repoDefaultBranch}` : ""
25016
- ].filter(Boolean);
25017
- return { content: [{ type: "text", text: lines.join("\n") }] };
25018
25070
  }
25019
25071
  );
25020
25072
  server.tool(
@@ -25111,13 +25163,13 @@ function registerProjectTools(server, ctx, client) {
25111
25163
  function registerRevisionLifecycleTools(server, ctx, client) {
25112
25164
  server.tool(
25113
25165
  "create_revision",
25114
- "Create a new revision on a Beat. Starts in `draft` state under the PR workflow (draft \u2192 open \u2192 merged) \u2014 NOT the standard Beat Version lifecycle. Transition to `open` when the PR is ready for review; transition to `merged` when the PR is merged. Environment progression (in_staging, ready_for_production, live) is tracked on the parent Beat Version, not the Revision. Use this to add revisions alongside existing ones \u2014 no idempotency guard.",
25166
+ "Create a new revision on a Beat. Starts in `draft` state under the PR workflow (draft \u2192 open \u2192 merged) \u2014 NOT the standard Beat Version lifecycle. Transition to `open` when the PR is ready for review; transition to `merged` when the PR is merged. Environment progression (in_staging, ready_for_production, live) is tracked on the parent Beat Version, not the Revision. `beatVersionId` is optional \u2014 omit it for hotfixes, dep bumps, or other operational fixes that don't belong to a planning increment. Orphan Revisions (no BV) must carry their own `description` since there's no BV context to inherit. Use this to add revisions alongside existing ones \u2014 no idempotency guard.",
25115
25167
  {
25116
25168
  beatId: external_exports.string().describe("The beat ID to create the revision on"),
25117
25169
  title: external_exports.string().describe("Title for this revision"),
25118
- description: external_exports.string().describe("What this revision delivers"),
25170
+ description: external_exports.string().describe("What this revision delivers. Required when beatVersionId is omitted (the description is the audit-trail floor for orphan Revisions)."),
25119
25171
  changeSummary: external_exports.string().describe("Brief summary of the change"),
25120
- beatVersionId: external_exports.string().min(1).optional().describe("Optional Beat Version ID to associate this revision with"),
25172
+ beatVersionId: external_exports.string().min(1).optional().describe("Beat Version ID to associate this revision with. Omit for hotfixes / orphan changes that don't fit a planning increment."),
25121
25173
  tags: external_exports.array(external_exports.string()).optional().describe("Tags for categorization"),
25122
25174
  priority: external_exports.coerce.number().optional().describe("Priority (lower = higher priority)"),
25123
25175
  estimatedEffort: external_exports.string().optional().describe("Estimated effort (e.g., S, M, L, XL)"),
@@ -25131,12 +25183,13 @@ function registerRevisionLifecycleTools(server, ctx, client) {
25131
25183
  if (!beat) {
25132
25184
  return { content: [{ type: "text", text: `Beat not found: "${beatId}"` }], isError: true };
25133
25185
  }
25186
+ let parentBv;
25134
25187
  if (beatVersionId !== void 0) {
25135
- const bv = await client.getBeatVersion(beatVersionId);
25136
- if (!bv) {
25188
+ parentBv = await client.getBeatVersion(beatVersionId);
25189
+ if (!parentBv) {
25137
25190
  return { content: [{ type: "text", text: `Beat Version not found: "${beatVersionId}"` }], isError: true };
25138
25191
  }
25139
- if (bv.beatId !== beatId || bv.projectId !== beat.projectId) {
25192
+ if (parentBv.beatId !== beatId || parentBv.projectId !== beat.projectId) {
25140
25193
  return { content: [{ type: "text", text: `Beat Version "${beatVersionId}" belongs to a different Beat.` }], isError: true };
25141
25194
  }
25142
25195
  }
@@ -25167,6 +25220,30 @@ function registerRevisionLifecycleTools(server, ctx, client) {
25167
25220
  for (const w of warnings) {
25168
25221
  lines.push("", `**Warning (${w.code}):** ${w.message}`);
25169
25222
  }
25223
+ if (parentBv && !parentBv.targetDropId) {
25224
+ try {
25225
+ const project = await client.getProject(parentBv.projectId);
25226
+ const teamspaceId = project?.teamspaceId;
25227
+ if (teamspaceId) {
25228
+ const draftDrops = await client.listTeamspaceDrops(teamspaceId, { state: "draft" });
25229
+ lines.push("");
25230
+ if (draftDrops.length > 0) {
25231
+ lines.push("\u26A0 **No Drop assigned.** The parent Beat Version has no target Drop. Assign it to track which release this work belongs to.");
25232
+ lines.push("");
25233
+ lines.push("Available draft Drops:");
25234
+ for (const drop of draftDrops) {
25235
+ lines.push(` \u2022 ${drop.dropCode} \u2014 ${drop.name} (${drop.dropId})`);
25236
+ }
25237
+ lines.push("");
25238
+ lines.push("Use `update_beat_version` to set `targetDropId`, or call `add_beat_version_to_drop`.");
25239
+ } else {
25240
+ lines.push(`\u26A0 **No Drop assigned and no draft Drops exist in Teamspace \`${teamspaceId}\`.** Create a Drop first with \`create_drop\`, then assign the parent Beat Version to it.`);
25241
+ }
25242
+ }
25243
+ } catch (err) {
25244
+ console.warn("[drop-warning] drop lookup failed for parent Beat Version", beatVersionId, err);
25245
+ }
25246
+ }
25170
25247
  return { content: [{ type: "text", text: lines.join("\n") }] };
25171
25248
  } catch (err) {
25172
25249
  const message = err instanceof Error ? err.message : String(err);
@@ -25240,9 +25317,10 @@ function registerRevisionLifecycleTools(server, ctx, client) {
25240
25317
  targetState: external_exports.enum([...LIFECYCLE_STATES, ...PR_LIFECYCLE_STATES]).describe("The target lifecycle state"),
25241
25318
  reason: external_exports.string().optional().describe("Why this transition is being made"),
25242
25319
  skipQualityCheck: external_exports.boolean().optional().describe("Skip the quality check guard (for reconciling already-shipped code)"),
25243
- reconcile: external_exports.boolean().optional().describe("Walk through all intermediate forward states automatically. Use for already-shipped work that needs state machine catch-up.")
25320
+ reconcile: external_exports.boolean().optional().describe("Walk through all intermediate forward states automatically. Use for already-shipped work that needs state machine catch-up."),
25321
+ mergeCommitSha: external_exports.string().regex(/^[0-9a-f]{7,40}$/).optional().describe("SHA of the merge commit (from the GitHub webhook payload or CI). Stored at the open \u2192 merged transition to enable precise deploy correlation.")
25244
25322
  },
25245
- async ({ revisionId, targetState, reason, skipQualityCheck, reconcile }) => {
25323
+ async ({ revisionId, targetState, reason, skipQualityCheck, reconcile, mergeCommitSha }) => {
25246
25324
  try {
25247
25325
  const revision = await client.getRevision(revisionId);
25248
25326
  if (!revision) {
@@ -25259,7 +25337,8 @@ function registerRevisionLifecycleTools(server, ctx, client) {
25259
25337
  actor: { type: "human", id: ctx.user.userId, name: ctx.user.name, email: ctx.user.email },
25260
25338
  trigger: "human_action",
25261
25339
  reason,
25262
- metadata: { source: "mcp", ...skipQualityCheck ? { skipQualityCheck: true } : {} }
25340
+ metadata: { source: "mcp", ...skipQualityCheck ? { skipQualityCheck: true } : {} },
25341
+ ...mergeCommitSha !== void 0 && { mergeCommitSha }
25263
25342
  };
25264
25343
  const result = reconcile ? await client.reconcileRevisionToState(revisionId, targetState, transitionOptions) : await client.transitionRevisionStatus(revisionId, targetState, transitionOptions);
25265
25344
  if (!result.success) {
@@ -25286,6 +25365,11 @@ function registerRevisionLifecycleTools(server, ctx, client) {
25286
25365
  // ../../libs/harmonica-services/src/mcp/tools/session-tools.ts
25287
25366
  var SESSION_STATUSES = ["running", "succeeded", "failed", "timed_out", "cancelled"];
25288
25367
  var SESSION_TYPES = ["planning", "implementation", "triage", "assessment", "composition", "baseline"];
25368
+ var SESSION_MESSAGE_MAX_LENGTH = 8e3;
25369
+ var STREAM_MAX_EVENTS_DEFAULT = 50;
25370
+ var STREAM_MAX_EVENTS_CEILING = 200;
25371
+ var STREAM_MAX_WAIT_MS_DEFAULT = 5e3;
25372
+ var STREAM_MAX_WAIT_MS_CEILING = 25e3;
25289
25373
  function registerSessionTools(server, ctx, client) {
25290
25374
  server.tool(
25291
25375
  "get_session",
@@ -25370,6 +25454,147 @@ function registerSessionTools(server, ctx, client) {
25370
25454
  };
25371
25455
  }
25372
25456
  );
25457
+ server.tool(
25458
+ "send_session_message",
25459
+ 'Send a user-turn message into a running (or held) agent Session. The message is queued for the worker turn loop to inject as the next user input. Returns immediately once queued \u2014 the agent\'s response will appear in the session\'s event stream. Use this to course-correct a running agent without cancelling and restarting (e.g., "switch to React Testing Library", "scope this PR to just X"). Terminal sessions (succeeded, failed, timed_out, cancelled) cannot accept messages \u2014 call this on a `running` or `held` session only.',
25460
+ {
25461
+ sessionId: external_exports.string().min(1).describe("The Session ID (sess-<uuid>) to deliver the message to"),
25462
+ body: external_exports.string().min(1).max(SESSION_MESSAGE_MAX_LENGTH).describe("The user-turn content. Plain text \u2014 no formatting required.")
25463
+ },
25464
+ async ({ sessionId, body }) => {
25465
+ try {
25466
+ const result = await client.sendSessionMessage(sessionId, {
25467
+ body,
25468
+ enqueuedBy: {
25469
+ email: ctx.user.email ?? ctx.user.userId,
25470
+ ...ctx.user.name && { name: ctx.user.name }
25471
+ }
25472
+ });
25473
+ if (result.status === "not_found") {
25474
+ return {
25475
+ content: [{ type: "text", text: `Session not found: ${sessionId}` }],
25476
+ isError: true
25477
+ };
25478
+ }
25479
+ if (result.status === "not_accepting") {
25480
+ return {
25481
+ content: [
25482
+ {
25483
+ type: "text",
25484
+ text: `Session ${sessionId} cannot accept new messages \u2014 current status: ${result.sessionStatus}. Send messages only to sessions in 'running' or 'held' status.`
25485
+ }
25486
+ ],
25487
+ isError: true
25488
+ };
25489
+ }
25490
+ return {
25491
+ content: [
25492
+ {
25493
+ type: "text",
25494
+ text: `Message queued for session ${sessionId} (messageId: ${result.message.messageId}). It will be delivered as the next user-turn input.`
25495
+ }
25496
+ ]
25497
+ };
25498
+ } catch (err) {
25499
+ return {
25500
+ content: [
25501
+ {
25502
+ type: "text",
25503
+ text: `Failed to send message: ${err instanceof Error ? err.message : String(err)}`
25504
+ }
25505
+ ],
25506
+ isError: true
25507
+ };
25508
+ }
25509
+ }
25510
+ );
25511
+ server.tool(
25512
+ "resume_session",
25513
+ "Resume a held agent Session (B-205 R2b). Use this when a Session is in the `held` state \u2014 paused by a timeout, harness compaction, or manual hold \u2014 and you want it to continue where it left off without losing the SDK conversation context. The call validates the Session is eligible (still `held`, has the captured SDK session id, has resume budget remaining) and enqueues a resume Task; the worker re-attaches the SDK conversation. Returns the enqueued Task ID \u2014 poll `get_job_status` (or stream the Session via `stream_session_events`) to watch progress. Sessions that are not held (running / terminal / out of budget / origin task missing / lost the atomic claim to a sibling resume call) return a structured `not_resumable` reason \u2014 surface it; don't retry blindly.",
25514
+ {
25515
+ sessionId: external_exports.string().min(1).describe("The Session ID (sess-<uuid>) to resume")
25516
+ },
25517
+ async ({ sessionId }) => {
25518
+ try {
25519
+ const result = await client.resumeSession(sessionId);
25520
+ if (result.status === "not_found") {
25521
+ return {
25522
+ content: [{ type: "text", text: `Session not found: ${sessionId}` }],
25523
+ isError: true
25524
+ };
25525
+ }
25526
+ if (result.status === "not_resumable") {
25527
+ return {
25528
+ content: [
25529
+ {
25530
+ type: "text",
25531
+ text: `Session ${sessionId} cannot be resumed \u2014 current status: ${result.sessionStatus}, reason: ${result.reason}. Resume requires a session in 'held' state with an SDK session captured and resume budget remaining.`
25532
+ }
25533
+ ],
25534
+ isError: true
25535
+ };
25536
+ }
25537
+ return {
25538
+ content: [
25539
+ {
25540
+ type: "text",
25541
+ text: `Resume queued for session ${sessionId} (taskId: ${result.result.taskId}). Poll \`get_task ${result.result.taskId}\` or stream the session for progress.`
25542
+ }
25543
+ ]
25544
+ };
25545
+ } catch (err) {
25546
+ return {
25547
+ content: [
25548
+ {
25549
+ type: "text",
25550
+ text: `Failed to resume session: ${err instanceof Error ? err.message : String(err)}`
25551
+ }
25552
+ ],
25553
+ isError: true
25554
+ };
25555
+ }
25556
+ }
25557
+ );
25558
+ server.tool(
25559
+ "stream_session_events",
25560
+ 'Long-poll an agent Session\'s event stream. Subscribes to the SSE endpoint and returns a bounded batch of tool/text events plus current status/metrics \u2014 closes N-4E09-2818 by keeping each MCP RPC short while the upstream SSE connection is the real transport. Iterate by passing the returned `nextSinceEventIndex` as `since_event_index` on the next call; stop when `isTerminal: true`. Use this for "watch this session in real time" workflows (agent supervising another agent, live UI tail).',
25561
+ {
25562
+ sessionId: external_exports.string().min(1).describe("The Session ID (sess-<uuid>) to subscribe to"),
25563
+ since_event_index: external_exports.number().int().nonnegative().optional().describe("Resume cursor from the previous batch's `nextSinceEventIndex`. Omit on the first call to receive a `snapshot` record. Server replays events with `index > since_event_index`."),
25564
+ max_events: external_exports.number().int().min(1).max(STREAM_MAX_EVENTS_CEILING).optional().describe(`Cap on records per batch. Default ${STREAM_MAX_EVENTS_DEFAULT}, max ${STREAM_MAX_EVENTS_CEILING}.`),
25565
+ max_wait_ms: external_exports.number().int().min(100).max(STREAM_MAX_WAIT_MS_CEILING).optional().describe(`Cap on batch duration in milliseconds. Default ${STREAM_MAX_WAIT_MS_DEFAULT}, max ${STREAM_MAX_WAIT_MS_CEILING}.`)
25566
+ },
25567
+ async ({ sessionId, since_event_index, max_events, max_wait_ms }) => {
25568
+ try {
25569
+ const result = await client.streamSessionEvents(sessionId, {
25570
+ ...since_event_index !== void 0 && { sinceEventIndex: since_event_index },
25571
+ ...max_events !== void 0 && { maxEvents: max_events },
25572
+ ...max_wait_ms !== void 0 && { maxWaitMs: max_wait_ms }
25573
+ });
25574
+ if (result.status === "not_found") {
25575
+ return {
25576
+ content: [{ type: "text", text: `Session not found: ${sessionId}` }],
25577
+ isError: true
25578
+ };
25579
+ }
25580
+ const header = `Batch: ${result.events.length} record(s) [reason=${result.reason}, isTerminal=${result.isTerminal}${result.nextSinceEventIndex !== void 0 ? `, nextSinceEventIndex=${result.nextSinceEventIndex}` : ""}]`;
25581
+ const lines = result.events.map((e, i) => {
25582
+ const payloadJson = JSON.stringify(e.payload);
25583
+ return `${i + 1}. ${e.kind} ${payloadJson}`;
25584
+ });
25585
+ const body = lines.length > 0 ? `
25586
+ ${lines.join("\n")}` : "";
25587
+ return { content: [{ type: "text", text: `${header}${body}` }] };
25588
+ } catch (err) {
25589
+ return {
25590
+ content: [
25591
+ { type: "text", text: `Failed to stream session events: ${err instanceof Error ? err.message : String(err)}` }
25592
+ ],
25593
+ isError: true
25594
+ };
25595
+ }
25596
+ }
25597
+ );
25373
25598
  server.tool(
25374
25599
  "cancel_session",
25375
25600
  "Cancel a running agent Session. Idempotent \u2014 calling cancel on a session that is already terminal (succeeded, failed, timed_out, cancelled) returns the existing record unchanged. Use this to stop a runaway agent or free up budget.",
@@ -26003,41 +26228,6 @@ ${JSON.stringify(task.result, null, 2)}
26003
26228
  }
26004
26229
  }
26005
26230
  );
26006
- server.tool(
26007
- "implement_work_item",
26008
- "Trigger an agent session to implement a workItem Note. The agent explores the codebase, writes code, commits, pushes a branch, and creates a PR. Returns a job ID \u2014 use get_job_status to track progress. Requires agentCapabilities.implementation enabled on the project.",
26009
- {
26010
- noteId: external_exports.string().describe("The workItem note ID to implement (e.g., N-TF-001)"),
26011
- projectId: external_exports.string().describe("The project ID")
26012
- },
26013
- async ({ noteId, projectId }) => {
26014
- try {
26015
- await assertProjectInOrg(client, projectId, ctx.orgId);
26016
- const task = await client.implementWorkItem(noteId, projectId, {
26017
- name: ctx.user.name,
26018
- email: ctx.user.email
26019
- });
26020
- return {
26021
- content: [{
26022
- type: "text",
26023
- text: [
26024
- "Work item implementation session queued.",
26025
- "",
26026
- `**Note:** ${noteId}`,
26027
- `**Task ID:** ${task.taskId}`,
26028
- `**Status:** ${task.status}`,
26029
- "",
26030
- "The agent will explore the codebase, implement the work item, and create a PR.",
26031
- "Use `get_job_status` with the job ID to check progress and see the result (branch, PR URL, tool count, duration)."
26032
- ].join("\n")
26033
- }]
26034
- };
26035
- } catch (err) {
26036
- const message = err instanceof Error ? err.message : String(err);
26037
- return { content: [{ type: "text", text: `Failed to start implementation: ${message}` }], isError: true };
26038
- }
26039
- }
26040
- );
26041
26231
  server.tool(
26042
26232
  "implement_revision",
26043
26233
  "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 job ID \u2014 use get_job_status to track progress. Requires agentCapabilities.implementation enabled on the project.",
@@ -26073,42 +26263,9 @@ ${JSON.stringify(task.result, null, 2)}
26073
26263
  }
26074
26264
  }
26075
26265
  );
26076
- server.tool(
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 job ID \u2014 use get_job_status to track progress.",
26079
- {
26080
- projectId: external_exports.string().describe("The project ID"),
26081
- noteIds: external_exports.array(external_exports.string()).optional().describe("Specific note IDs to validate. If omitted, validates all active work items.")
26082
- },
26083
- async ({ projectId, noteIds }) => {
26084
- try {
26085
- await assertProjectInOrg(client, projectId, ctx.orgId);
26086
- const task = await client.validateWorkItems(projectId, noteIds);
26087
- return {
26088
- content: [{
26089
- type: "text",
26090
- text: [
26091
- "Work item validation queued.",
26092
- "",
26093
- `**Project:** ${projectId}`,
26094
- `**Scope:** ${noteIds ? `${noteIds.length} specific items` : "All active work items"}`,
26095
- `**Task ID:** ${task.taskId}`,
26096
- `**Status:** ${task.status}`,
26097
- "",
26098
- "The validator will grep the codebase for evidence, then classify each item.",
26099
- "Use `get_job_status` with the job ID to check progress."
26100
- ].join("\n")
26101
- }]
26102
- };
26103
- } catch (err) {
26104
- const message = err instanceof Error ? err.message : String(err);
26105
- return { content: [{ type: "text", text: `Failed to start validation: ${message}` }], isError: true };
26106
- }
26107
- }
26108
- );
26109
26266
  server.tool(
26110
26267
  "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 job ID \u2014 use get_job_status to track progress.",
26268
+ "Autonomously generate an implementation plan for planning Revisions. If revisionIds are omitted, discovers all unplanned planning Revisions and plans them all. Uses codebase-aware agent sessions when available, LLM-only fallback otherwise. Returns a job ID \u2014 use get_job_status to track progress.",
26112
26269
  {
26113
26270
  projectId: external_exports.string().describe("The project ID"),
26114
26271
  revisionIds: external_exports.array(external_exports.string()).optional().describe("Specific revision IDs to plan. If omitted, auto-discovers all unplanned planning revisions.")
@@ -26128,7 +26285,7 @@ ${JSON.stringify(task.result, null, 2)}
26128
26285
  `**Task ID:** ${task.taskId}`,
26129
26286
  `**Status:** ${task.status}`,
26130
26287
  "",
26131
- "The agent will generate work items for each revision. Revisions stay in planning \u2014 review work items before advancing to building.",
26288
+ "The agent will generate an implementation plan for each revision. Revisions stay in planning \u2014 review the plan before advancing to building.",
26132
26289
  "Use `get_job_status` with the job ID to check progress."
26133
26290
  ].join("\n")
26134
26291
  }]
@@ -26141,7 +26298,7 @@ ${JSON.stringify(task.result, null, 2)}
26141
26298
  );
26142
26299
  server.tool(
26143
26300
  "run_triage",
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 job ID \u2014 use get_job_status to track progress.",
26301
+ "Run the autonomous triage loop for a project. Analyzes next actions, auto-executes safe actions (draft_coda, plan_revision_batch), and escalates items that need human judgment. Requires agentCapabilities.triage enabled on the project. Returns a job ID \u2014 use get_job_status to track progress.",
26145
26302
  {
26146
26303
  projectId: external_exports.string().describe("The project ID")
26147
26304
  },
@@ -26372,6 +26529,34 @@ function registerAllResources(server, ctx, client) {
26372
26529
  }
26373
26530
 
26374
26531
  // ../../apps/harmonica-mcp-server/src/data-client/http-client.ts
26532
+ var STREAM_EVENT_KINDS = /* @__PURE__ */ new Set([
26533
+ "snapshot",
26534
+ "tool_event",
26535
+ "text_event",
26536
+ "metrics_update",
26537
+ "status_change",
26538
+ "terminal"
26539
+ ]);
26540
+ function parseSseBlock(block) {
26541
+ let event = "message";
26542
+ let data = "";
26543
+ for (const line of block.split("\n")) {
26544
+ if (line.startsWith("event: ")) event = line.slice("event: ".length).trim();
26545
+ else if (line.startsWith("data: ")) data += (data ? "\n" : "") + line.slice("data: ".length);
26546
+ }
26547
+ if (!data) return void 0;
26548
+ return { event, data };
26549
+ }
26550
+ function toStreamRecord(event, dataStr) {
26551
+ if (!STREAM_EVENT_KINDS.has(event)) return void 0;
26552
+ let payload;
26553
+ try {
26554
+ payload = JSON.parse(dataStr);
26555
+ } catch {
26556
+ return void 0;
26557
+ }
26558
+ return { kind: event, payload };
26559
+ }
26375
26560
  var ApiError = class extends Error {
26376
26561
  constructor(status, body) {
26377
26562
  super(`API ${status}: ${body}`);
@@ -26709,26 +26894,6 @@ function createHttpClient(config2) {
26709
26894
  getNextNoteId: async (projectCode) => {
26710
26895
  return `N-${projectCode}-${Date.now()}`;
26711
26896
  },
26712
- saveTasksAsWorkItemNotes: async (projectId, beatId, tasks) => {
26713
- const results = await Promise.all(
26714
- tasks.map(
26715
- (task) => request("POST", `/api/projects/${encodeURIComponent(projectId)}/notes`, {
26716
- noteType: "workItem",
26717
- content: `**${task.title}** \u2014 ${task.description}`,
26718
- beatId,
26719
- workItemMeta: {
26720
- category: task.category,
26721
- complexity: task.complexity,
26722
- scope: task.scope,
26723
- estimatedHoursRange: task.estimatedHoursRange,
26724
- acceptanceCheck: task.acceptanceCheck,
26725
- whyOutOfScope: task.whyOutOfScope
26726
- }
26727
- })
26728
- )
26729
- );
26730
- return results.map((r) => r?.note ?? r);
26731
- },
26732
26897
  reassignNote: async (noteId, targetBeatId, reason, _actorId, targetRevisionId) => {
26733
26898
  const note = await request("GET", `/api/notes/${encodeURIComponent(noteId)}`);
26734
26899
  if (!note?.note) return void 0;
@@ -26809,7 +26974,8 @@ function createHttpClient(config2) {
26809
26974
  actorName: options.actor.name,
26810
26975
  trigger: options.trigger,
26811
26976
  reason: options.reason,
26812
- metadata: options.metadata
26977
+ metadata: options.metadata,
26978
+ ...options.mergeCommitSha !== void 0 && { mergeCommitSha: options.mergeCommitSha }
26813
26979
  }
26814
26980
  );
26815
26981
  if (raw === void 0) {
@@ -26878,6 +27044,164 @@ function createHttpClient(config2) {
26878
27044
  return result ?? [];
26879
27045
  },
26880
27046
  cancelSession: (sessionId, options) => request("POST", `/api/sessions/${encodeURIComponent(sessionId)}/cancel`, { reason: options.reason }),
27047
+ sendSessionMessage: async (sessionId, options) => {
27048
+ try {
27049
+ const result = await request("POST", `/api/sessions/${encodeURIComponent(sessionId)}/messages`, {
27050
+ body: options.body,
27051
+ enqueuedBy: options.enqueuedBy
27052
+ });
27053
+ if (!result) return { status: "not_found" };
27054
+ return { status: "enqueued", message: result };
27055
+ } catch (err) {
27056
+ if (err instanceof ApiError && err.status === 409) {
27057
+ const parsed = safeParseErrorBody(err.body);
27058
+ if (parsed?.sessionStatus) {
27059
+ return { status: "not_accepting", sessionStatus: parsed.sessionStatus };
27060
+ }
27061
+ }
27062
+ throw err;
27063
+ }
27064
+ },
27065
+ resumeSession: async (sessionId) => {
27066
+ try {
27067
+ const result = await request("POST", `/api/sessions/${encodeURIComponent(sessionId)}/resume`);
27068
+ if (!result) return { status: "not_found" };
27069
+ return { status: "resumed", result };
27070
+ } catch (err) {
27071
+ if (err instanceof ApiError && err.status === 409) {
27072
+ const parsed = safeParseErrorBody(err.body);
27073
+ if (parsed?.code === "SESSION_NOT_RESUMABLE") {
27074
+ return {
27075
+ status: "not_resumable",
27076
+ sessionStatus: parsed.sessionStatus ?? "held",
27077
+ reason: parsed.reason ?? "not_held"
27078
+ };
27079
+ }
27080
+ }
27081
+ throw err;
27082
+ }
27083
+ },
27084
+ streamSessionEvents: async (sessionId, options) => {
27085
+ const maxEvents = Math.min(Math.max(options?.maxEvents ?? 50, 1), 200);
27086
+ const maxWaitMs = Math.min(Math.max(options?.maxWaitMs ?? 5e3, 100), 25e3);
27087
+ const params = new URLSearchParams();
27088
+ if (options?.sinceEventIndex !== void 0) {
27089
+ params.set("since_event_index", String(options.sinceEventIndex));
27090
+ }
27091
+ const qs = params.toString() ? `?${params.toString()}` : "";
27092
+ const url2 = `${config2.apiBaseUrl}/api/sessions/${encodeURIComponent(sessionId)}/events${qs}`;
27093
+ const controller = new AbortController();
27094
+ const deadlineTimer = setTimeout(() => controller.abort(), maxWaitMs);
27095
+ let res;
27096
+ try {
27097
+ res = await fetch(url2, {
27098
+ method: "GET",
27099
+ headers: {
27100
+ Authorization: `Bearer ${config2.apiKey}`,
27101
+ Accept: "text/event-stream"
27102
+ },
27103
+ signal: controller.signal
27104
+ });
27105
+ } catch (err) {
27106
+ clearTimeout(deadlineTimer);
27107
+ if (err.name === "AbortError" && controller.signal.aborted) {
27108
+ return { status: "ok", events: [], isTerminal: false, reason: "max_wait", ...options?.sinceEventIndex !== void 0 && { nextSinceEventIndex: options.sinceEventIndex } };
27109
+ }
27110
+ throw err;
27111
+ }
27112
+ if (res.status === 404) {
27113
+ clearTimeout(deadlineTimer);
27114
+ return { status: "not_found" };
27115
+ }
27116
+ if (!res.ok) {
27117
+ clearTimeout(deadlineTimer);
27118
+ const text = await res.text();
27119
+ throw new ApiError(res.status, text);
27120
+ }
27121
+ if (!res.body) {
27122
+ clearTimeout(deadlineTimer);
27123
+ return { status: "ok", events: [], isTerminal: false, reason: "max_wait", ...options?.sinceEventIndex !== void 0 && { nextSinceEventIndex: options.sinceEventIndex } };
27124
+ }
27125
+ const reader = res.body.getReader();
27126
+ const decoder = new TextDecoder("utf-8");
27127
+ let buffer = "";
27128
+ const events = [];
27129
+ let nextSinceEventIndex = options?.sinceEventIndex;
27130
+ let isTerminal2 = false;
27131
+ let reason = "max_wait";
27132
+ const deadline = Date.now() + maxWaitMs;
27133
+ const TIMEOUT = Symbol("stream-timeout");
27134
+ try {
27135
+ outer: while (true) {
27136
+ const remaining = deadline - Date.now();
27137
+ if (remaining <= 0) {
27138
+ reason = "max_wait";
27139
+ break outer;
27140
+ }
27141
+ let timerHandle;
27142
+ const raced = await Promise.race([
27143
+ reader.read(),
27144
+ new Promise((resolve) => {
27145
+ timerHandle = setTimeout(() => resolve(TIMEOUT), remaining);
27146
+ })
27147
+ ]);
27148
+ if (timerHandle) clearTimeout(timerHandle);
27149
+ if (raced === TIMEOUT) {
27150
+ reason = "max_wait";
27151
+ break outer;
27152
+ }
27153
+ const { done, value } = raced;
27154
+ if (done) break;
27155
+ buffer += decoder.decode(value, { stream: true });
27156
+ let sepIdx;
27157
+ while ((sepIdx = buffer.indexOf("\n\n")) !== -1) {
27158
+ const block = buffer.slice(0, sepIdx);
27159
+ buffer = buffer.slice(sepIdx + 2);
27160
+ const parsed = parseSseBlock(block);
27161
+ if (!parsed) continue;
27162
+ if (parsed.event === "heartbeat") continue;
27163
+ const record2 = toStreamRecord(parsed.event, parsed.data);
27164
+ if (!record2) continue;
27165
+ events.push(record2);
27166
+ if (record2.kind === "tool_event" || record2.kind === "text_event") {
27167
+ const idx = record2.payload.index;
27168
+ if (typeof idx === "number" && (nextSinceEventIndex === void 0 || idx > nextSinceEventIndex)) {
27169
+ nextSinceEventIndex = idx;
27170
+ }
27171
+ }
27172
+ if (record2.kind === "terminal") {
27173
+ isTerminal2 = true;
27174
+ reason = "terminal";
27175
+ break outer;
27176
+ }
27177
+ if (events.length >= maxEvents) {
27178
+ reason = "max_events";
27179
+ break outer;
27180
+ }
27181
+ }
27182
+ }
27183
+ } catch (err) {
27184
+ if (!controller.signal.aborted) throw err;
27185
+ reason = "max_wait";
27186
+ } finally {
27187
+ clearTimeout(deadlineTimer);
27188
+ try {
27189
+ await reader.cancel();
27190
+ } catch {
27191
+ }
27192
+ try {
27193
+ controller.abort();
27194
+ } catch {
27195
+ }
27196
+ }
27197
+ return {
27198
+ status: "ok",
27199
+ events,
27200
+ isTerminal: isTerminal2,
27201
+ reason,
27202
+ ...nextSinceEventIndex !== void 0 && { nextSinceEventIndex }
27203
+ };
27204
+ },
26881
27205
  // Tasks
26882
27206
  getTask: async (taskId) => {
26883
27207
  return request("GET", `/api/tasks/${encodeURIComponent(taskId)}`);
@@ -27016,15 +27340,6 @@ function createHttpClient(config2) {
27016
27340
  return pollTaskResult(enqueued.taskId);
27017
27341
  },
27018
27342
  // Agent sessions
27019
- implementWorkItem: async (noteId, projectId, triggeredBy) => {
27020
- const result = await request(
27021
- "POST",
27022
- `/api/projects/${encodeURIComponent(projectId)}/notes/${encodeURIComponent(noteId)}/implement`,
27023
- triggeredBy ? { triggeredBy } : {}
27024
- );
27025
- if (!result?.taskId) throw new Error("Implementation enqueue failed: no taskId returned");
27026
- return { taskId: result.taskId, taskType: "work_item_implementation", status: result.status ?? "pending", projectId, createdAt: (/* @__PURE__ */ new Date()).toISOString() };
27027
- },
27028
27343
  implementRevision: async (revisionId, projectId, triggeredBy) => {
27029
27344
  const result = await request(
27030
27345
  "POST",
@@ -27034,15 +27349,6 @@ function createHttpClient(config2) {
27034
27349
  if (!result?.taskId) throw new Error("Revision implementation enqueue failed: no taskId returned");
27035
27350
  return { taskId: result.taskId, taskType: "revision_implementation", status: result.status ?? "pending", projectId, createdAt: (/* @__PURE__ */ new Date()).toISOString() };
27036
27351
  },
27037
- validateWorkItems: async (projectId, noteIds) => {
27038
- const result = await request(
27039
- "POST",
27040
- `/api/projects/${encodeURIComponent(projectId)}/work-items/validate`,
27041
- noteIds ? { noteIds } : {}
27042
- );
27043
- if (!result?.taskId) throw new Error("Validation enqueue failed: no taskId returned");
27044
- return { taskId: result.taskId, taskType: "work_item_validation", status: result.status ?? "pending", projectId, createdAt: (/* @__PURE__ */ new Date()).toISOString() };
27045
- },
27046
27352
  planRevisionBatch: async (projectId, revisionIds) => {
27047
27353
  const result = await request(
27048
27354
  "POST",
@@ -27222,6 +27528,22 @@ function createHttpClient(config2) {
27222
27528
  );
27223
27529
  return res.checks;
27224
27530
  },
27531
+ listBeatVersionChecks: async (beatVersionId, projectId, checkType) => {
27532
+ const qs = checkType ? `?type=${encodeURIComponent(checkType)}` : "";
27533
+ const res = await request(
27534
+ "GET",
27535
+ `/api/projects/${encodeURIComponent(projectId)}/beat-versions/${encodeURIComponent(beatVersionId)}/checks${qs}`
27536
+ );
27537
+ return res.checks;
27538
+ },
27539
+ listDropChecks: async (dropId, _projectId, checkType) => {
27540
+ const qs = checkType ? `?type=${encodeURIComponent(checkType)}` : "";
27541
+ const res = await request(
27542
+ "GET",
27543
+ `/api/drops/${encodeURIComponent(dropId)}/checks${qs}`
27544
+ );
27545
+ return res.checks;
27546
+ },
27225
27547
  addCheckFeedback: async (checkId, feedback) => {
27226
27548
  const res = await request(
27227
27549
  "POST",
@@ -27356,7 +27678,11 @@ function createHttpClient(config2) {
27356
27678
  if (res === void 0) {
27357
27679
  return { success: false, error: { code: "BEAT_VERSION_NOT_FOUND", message: "Beat version not found" } };
27358
27680
  }
27359
- return { success: true };
27681
+ return {
27682
+ success: true,
27683
+ ...res.fanOutCount !== void 0 && { fanOutCount: res.fanOutCount },
27684
+ ...res.fanOutWarning && { fanOutWarning: res.fanOutWarning }
27685
+ };
27360
27686
  } catch (err) {
27361
27687
  const message = err instanceof Error ? err.message : String(err);
27362
27688
  return { success: false, error: { code: "TRANSITION_FAILED", message } };
@@ -27457,7 +27783,7 @@ function loadConfig() {
27457
27783
  };
27458
27784
  }
27459
27785
  async function main() {
27460
- console.error(`[harmonica-mcp] v${"0.29.0"} starting\u2026`);
27786
+ console.error(`[harmonica-mcp] v${"0.31.0"} starting\u2026`);
27461
27787
  const config2 = loadConfig();
27462
27788
  const client = createHttpClient({
27463
27789
  apiBaseUrl: config2.apiBaseUrl,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@codazen/harmonica-mcp",
3
- "version": "0.29.0",
3
+ "version": "0.31.0",
4
4
  "description": "MCP server for Harmonica — connect Claude to your Harmonica projects",
5
5
  "license": "MIT",
6
6
  "bin": {