@codazen/harmonica-mcp 0.26.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.
- package/dist/index.js +462 -124
- 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
|
-
|
|
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;
|
|
@@ -23072,13 +23108,28 @@ var DROP_STATES = ["draft", "scheduled", "in_progress", "released", "rolled_back
|
|
|
23072
23108
|
function registerDropTools(server, ctx, client) {
|
|
23073
23109
|
server.tool(
|
|
23074
23110
|
"list_drops",
|
|
23075
|
-
"List Drops in a Teamspace, optionally filtered by lifecycle state. 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.",
|
|
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.",
|
|
23076
23112
|
{
|
|
23077
23113
|
teamspaceId: external_exports.string().describe("The teamspace ID"),
|
|
23078
|
-
state: external_exports.enum(DROP_STATES).optional().describe("Filter by Drop state
|
|
23114
|
+
state: external_exports.enum(DROP_STATES).optional().describe("Filter by a single Drop state (use status for multi-state OR filtering)"),
|
|
23115
|
+
status: external_exports.string().optional().describe('Comma-separated Drop states for OR filtering (e.g. "draft,scheduled"). Takes precedence over state when provided.'),
|
|
23116
|
+
sortBy: external_exports.enum(["id", "targetDate"]).optional().describe("Sort field: id (D-NNN code) or targetDate. Default order is DynamoDB natural (createdAt ascending)."),
|
|
23117
|
+
sortOrder: external_exports.enum(["asc", "desc"]).optional().describe("Sort direction: asc or desc. Drops without a targetDate always sort last when sortBy=targetDate.")
|
|
23079
23118
|
},
|
|
23080
|
-
async ({ teamspaceId, state }) => {
|
|
23081
|
-
const
|
|
23119
|
+
async ({ teamspaceId, state, status, sortBy, sortOrder }) => {
|
|
23120
|
+
const rawStatusValues = status ? status.split(",").map((s) => s.trim()).filter(Boolean) : void 0;
|
|
23121
|
+
if (rawStatusValues) {
|
|
23122
|
+
const invalid = rawStatusValues.filter((s) => !DROP_STATES.includes(s));
|
|
23123
|
+
if (invalid.length > 0) {
|
|
23124
|
+
return { content: [{ type: "text", text: `Invalid status values: ${invalid.join(", ")}. Must be one of ${DROP_STATES.join(", ")}` }], isError: true };
|
|
23125
|
+
}
|
|
23126
|
+
}
|
|
23127
|
+
const statusValues = rawStatusValues;
|
|
23128
|
+
const hasOptions = state !== void 0 || statusValues !== void 0 || sortBy !== void 0 || sortOrder !== void 0;
|
|
23129
|
+
const drops = await client.listTeamspaceDrops(
|
|
23130
|
+
teamspaceId,
|
|
23131
|
+
hasOptions ? { state, status: statusValues, sortBy, sortOrder } : void 0
|
|
23132
|
+
);
|
|
23082
23133
|
const text = drops.length === 0 ? "No drops found." : drops.map((d) => {
|
|
23083
23134
|
const target = d.targetDate ? ` (target ${d.targetDate.slice(0, 10)})` : "";
|
|
23084
23135
|
return `${d.dropId} [${d.dropCode}] ${d.name} \u2014 ${d.state} \xB7 ${d.revisionCount} rev${d.revisionCount === 1 ? "" : "s"}${target}`;
|
|
@@ -23119,6 +23170,7 @@ function registerDropTools(server, ctx, client) {
|
|
|
23119
23170
|
d.targetDate ? `Target: ${d.targetDate}` : null,
|
|
23120
23171
|
d.releasedAt ? `Released: ${d.releasedAt}` : null,
|
|
23121
23172
|
d.rolledBackAt ? `Rolled back: ${d.rolledBackAt}` : null,
|
|
23173
|
+
`Beat Versions (${(d.beatVersionIds ?? []).length}): ${(d.beatVersionIds ?? []).length === 0 ? "(none)" : (d.beatVersionIds ?? []).join(", ")}`,
|
|
23122
23174
|
`Revisions (${d.revisionIds.length}): ${d.revisionIds.length === 0 ? "(none)" : d.revisionIds.join(", ")}`,
|
|
23123
23175
|
`Version: ${d.version}`,
|
|
23124
23176
|
`Created: ${d.createdAt}`
|
|
@@ -24566,6 +24618,253 @@ Use get_task_status with taskId="${task.taskId}" to poll for results, or list_ch
|
|
|
24566
24618
|
);
|
|
24567
24619
|
}
|
|
24568
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
|
+
|
|
24569
24868
|
// ../../libs/harmonica-services/src/mcp/tools/project-lifecycle-tools.ts
|
|
24570
24869
|
function registerProjectLifecycleTools(server, ctx, client) {
|
|
24571
24870
|
server.tool(
|
|
@@ -25639,7 +25938,7 @@ ${JSON.stringify(task.result, null, 2)}
|
|
|
25639
25938
|
);
|
|
25640
25939
|
server.tool(
|
|
25641
25940
|
"draft_coda",
|
|
25642
|
-
"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
|
|
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.",
|
|
25643
25942
|
{
|
|
25644
25943
|
projectId: external_exports.string().describe("The project ID"),
|
|
25645
25944
|
beatId: external_exports.string().describe("The beat ID (must have a title but no description)")
|
|
@@ -25740,107 +26039,109 @@ ${JSON.stringify(task.result, null, 2)}
|
|
|
25740
26039
|
}
|
|
25741
26040
|
);
|
|
25742
26041
|
server.tool(
|
|
25743
|
-
"
|
|
25744
|
-
"
|
|
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.",
|
|
25745
26044
|
{
|
|
25746
|
-
|
|
25747
|
-
|
|
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")
|
|
25748
26047
|
},
|
|
25749
|
-
async ({
|
|
26048
|
+
async ({ revisionId, projectId }) => {
|
|
25750
26049
|
try {
|
|
25751
26050
|
await assertProjectInOrg(client, projectId, ctx.orgId);
|
|
25752
|
-
const task = await client.
|
|
26051
|
+
const task = await client.implementRevision(revisionId, projectId, {
|
|
26052
|
+
name: ctx.user.name,
|
|
26053
|
+
email: ctx.user.email
|
|
26054
|
+
});
|
|
25753
26055
|
return {
|
|
25754
26056
|
content: [{
|
|
25755
26057
|
type: "text",
|
|
25756
26058
|
text: [
|
|
25757
|
-
"
|
|
26059
|
+
"Revision implementation session queued.",
|
|
25758
26060
|
"",
|
|
25759
|
-
`**
|
|
25760
|
-
`**Scope:** ${noteIds ? `${noteIds.length} specific items` : "All active work items"}`,
|
|
26061
|
+
`**Revision:** ${revisionId}`,
|
|
25761
26062
|
`**Task ID:** ${task.taskId}`,
|
|
25762
26063
|
`**Status:** ${task.status}`,
|
|
25763
26064
|
"",
|
|
25764
|
-
"The
|
|
25765
|
-
"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)."
|
|
25766
26067
|
].join("\n")
|
|
25767
26068
|
}]
|
|
25768
26069
|
};
|
|
25769
26070
|
} catch (err) {
|
|
25770
26071
|
const message = err instanceof Error ? err.message : String(err);
|
|
25771
|
-
return { content: [{ type: "text", text: `Failed to start
|
|
26072
|
+
return { content: [{ type: "text", text: `Failed to start revision implementation: ${message}` }], isError: true };
|
|
25772
26073
|
}
|
|
25773
26074
|
}
|
|
25774
26075
|
);
|
|
25775
26076
|
server.tool(
|
|
25776
|
-
"
|
|
25777
|
-
"
|
|
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.",
|
|
25778
26079
|
{
|
|
25779
26080
|
projectId: external_exports.string().describe("The project ID"),
|
|
25780
|
-
|
|
26081
|
+
noteIds: external_exports.array(external_exports.string()).optional().describe("Specific note IDs to validate. If omitted, validates all active work items.")
|
|
25781
26082
|
},
|
|
25782
|
-
async ({ projectId,
|
|
26083
|
+
async ({ projectId, noteIds }) => {
|
|
25783
26084
|
try {
|
|
25784
26085
|
await assertProjectInOrg(client, projectId, ctx.orgId);
|
|
25785
|
-
const task = await client.
|
|
26086
|
+
const task = await client.validateWorkItems(projectId, noteIds);
|
|
25786
26087
|
return {
|
|
25787
26088
|
content: [{
|
|
25788
26089
|
type: "text",
|
|
25789
26090
|
text: [
|
|
25790
|
-
"
|
|
26091
|
+
"Work item validation queued.",
|
|
25791
26092
|
"",
|
|
25792
26093
|
`**Project:** ${projectId}`,
|
|
25793
|
-
`**Scope:** ${
|
|
26094
|
+
`**Scope:** ${noteIds ? `${noteIds.length} specific items` : "All active work items"}`,
|
|
25794
26095
|
`**Task ID:** ${task.taskId}`,
|
|
25795
26096
|
`**Status:** ${task.status}`,
|
|
25796
26097
|
"",
|
|
25797
|
-
"The
|
|
26098
|
+
"The validator will grep the codebase for evidence, then classify each item.",
|
|
25798
26099
|
"Use `get_task_status` with the task ID to check progress."
|
|
25799
26100
|
].join("\n")
|
|
25800
26101
|
}]
|
|
25801
26102
|
};
|
|
25802
26103
|
} catch (err) {
|
|
25803
26104
|
const message = err instanceof Error ? err.message : String(err);
|
|
25804
|
-
return { content: [{ type: "text", text: `Failed to start
|
|
26105
|
+
return { content: [{ type: "text", text: `Failed to start validation: ${message}` }], isError: true };
|
|
25805
26106
|
}
|
|
25806
26107
|
}
|
|
25807
26108
|
);
|
|
25808
26109
|
server.tool(
|
|
25809
|
-
"
|
|
25810
|
-
"Autonomously
|
|
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.",
|
|
25811
26112
|
{
|
|
25812
26113
|
projectId: external_exports.string().describe("The project ID"),
|
|
25813
|
-
|
|
26114
|
+
revisionIds: external_exports.array(external_exports.string()).optional().describe("Specific revision IDs to plan. If omitted, auto-discovers all unplanned planning revisions.")
|
|
25814
26115
|
},
|
|
25815
|
-
async ({ projectId,
|
|
26116
|
+
async ({ projectId, revisionIds }) => {
|
|
25816
26117
|
try {
|
|
25817
26118
|
await assertProjectInOrg(client, projectId, ctx.orgId);
|
|
25818
|
-
const task = await client.
|
|
26119
|
+
const task = await client.planRevisionBatch(projectId, revisionIds);
|
|
25819
26120
|
return {
|
|
25820
26121
|
content: [{
|
|
25821
26122
|
type: "text",
|
|
25822
26123
|
text: [
|
|
25823
|
-
"
|
|
26124
|
+
"Revision batch planning queued.",
|
|
25824
26125
|
"",
|
|
25825
|
-
`**Beat:** ${beatId}`,
|
|
25826
26126
|
`**Project:** ${projectId}`,
|
|
26127
|
+
`**Scope:** ${revisionIds ? `${revisionIds.length} specific revision(s)` : "All unplanned planning revisions"}`,
|
|
25827
26128
|
`**Task ID:** ${task.taskId}`,
|
|
25828
26129
|
`**Status:** ${task.status}`,
|
|
25829
26130
|
"",
|
|
25830
|
-
"The agent will
|
|
25831
|
-
"Use `get_task_status` with the task ID to check progress
|
|
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."
|
|
25832
26133
|
].join("\n")
|
|
25833
26134
|
}]
|
|
25834
26135
|
};
|
|
25835
26136
|
} catch (err) {
|
|
25836
26137
|
const message = err instanceof Error ? err.message : String(err);
|
|
25837
|
-
return { content: [{ type: "text", text: `Failed to start
|
|
26138
|
+
return { content: [{ type: "text", text: `Failed to start revision batch planning: ${message}` }], isError: true };
|
|
25838
26139
|
}
|
|
25839
26140
|
}
|
|
25840
26141
|
);
|
|
25841
26142
|
server.tool(
|
|
25842
26143
|
"run_triage",
|
|
25843
|
-
"Run the autonomous triage loop for a project. Analyzes next actions, validates work items against the codebase, auto-executes safe actions (draft_coda,
|
|
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.",
|
|
25844
26145
|
{
|
|
25845
26146
|
projectId: external_exports.string().describe("The project ID")
|
|
25846
26147
|
},
|
|
@@ -25929,6 +26230,7 @@ function registerAllTools(server, ctx, client) {
|
|
|
25929
26230
|
registerSnapshotTools(server, ctx, client);
|
|
25930
26231
|
registerBeatQualityTools(server, ctx, client);
|
|
25931
26232
|
registerPlanQualityTools(server, ctx, client);
|
|
26233
|
+
registerPortfolioCoherenceTools(server, ctx, client);
|
|
25932
26234
|
registerBeatReframeTools(server, ctx, client);
|
|
25933
26235
|
registerBeatPlanningTools(server, ctx, client);
|
|
25934
26236
|
registerRevisionLifecycleTools(server, ctx, client);
|
|
@@ -26027,6 +26329,13 @@ var ApiError = class extends Error {
|
|
|
26027
26329
|
this.name = "ApiError";
|
|
26028
26330
|
}
|
|
26029
26331
|
};
|
|
26332
|
+
function safeParseErrorBody(body) {
|
|
26333
|
+
try {
|
|
26334
|
+
return JSON.parse(body);
|
|
26335
|
+
} catch {
|
|
26336
|
+
return void 0;
|
|
26337
|
+
}
|
|
26338
|
+
}
|
|
26030
26339
|
function createHttpClient(config2) {
|
|
26031
26340
|
const url = new URL(config2.apiBaseUrl);
|
|
26032
26341
|
const isLocalhost = url.hostname === "localhost" || url.hostname === "127.0.0.1";
|
|
@@ -26068,7 +26377,7 @@ function createHttpClient(config2) {
|
|
|
26068
26377
|
}
|
|
26069
26378
|
return void 0;
|
|
26070
26379
|
}
|
|
26071
|
-
const
|
|
26380
|
+
const POLL_INTERVAL_MS4 = 4e3;
|
|
26072
26381
|
async function pollTaskResult(taskId) {
|
|
26073
26382
|
const deadline = Date.now() + LONG_RUNNING_TIMEOUT_MS;
|
|
26074
26383
|
while (Date.now() < deadline) {
|
|
@@ -26078,7 +26387,7 @@ function createHttpClient(config2) {
|
|
|
26078
26387
|
);
|
|
26079
26388
|
if (task?.status === "completed") return task.result;
|
|
26080
26389
|
if (task?.status === "failed") throw new Error(`Task failed: ${task.error ?? "unknown error"}`);
|
|
26081
|
-
await new Promise((r) => setTimeout(r,
|
|
26390
|
+
await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS4));
|
|
26082
26391
|
}
|
|
26083
26392
|
throw new Error(`Task ${taskId} timed out after ${LONG_RUNNING_TIMEOUT_MS}ms`);
|
|
26084
26393
|
}
|
|
@@ -26580,14 +26889,31 @@ function createHttpClient(config2) {
|
|
|
26580
26889
|
const result = await request("GET", `/api/projects/${encodeURIComponent(projectId)}/beats/${encodeURIComponent(beatId)}/history`);
|
|
26581
26890
|
return result?.timeline ?? [];
|
|
26582
26891
|
},
|
|
26583
|
-
// Beat
|
|
26584
|
-
|
|
26585
|
-
const
|
|
26586
|
-
|
|
26587
|
-
|
|
26588
|
-
|
|
26589
|
-
|
|
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
|
+
}
|
|
26590
26915
|
},
|
|
26916
|
+
// Beat Composer
|
|
26591
26917
|
recordMergeEvent: async () => {
|
|
26592
26918
|
throw new Error("Beat merge not supported via HTTP client yet");
|
|
26593
26919
|
},
|
|
@@ -26648,6 +26974,15 @@ function createHttpClient(config2) {
|
|
|
26648
26974
|
if (!result?.taskId) throw new Error("Implementation enqueue failed: no taskId returned");
|
|
26649
26975
|
return { taskId: result.taskId, taskType: "work_item_implementation", status: result.status ?? "pending", projectId, createdAt: (/* @__PURE__ */ new Date()).toISOString() };
|
|
26650
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
|
+
},
|
|
26651
26986
|
validateWorkItems: async (projectId, noteIds) => {
|
|
26652
26987
|
const result = await request(
|
|
26653
26988
|
"POST",
|
|
@@ -26657,15 +26992,6 @@ function createHttpClient(config2) {
|
|
|
26657
26992
|
if (!result?.taskId) throw new Error("Validation enqueue failed: no taskId returned");
|
|
26658
26993
|
return { taskId: result.taskId, taskType: "work_item_validation", status: result.status ?? "pending", projectId, createdAt: (/* @__PURE__ */ new Date()).toISOString() };
|
|
26659
26994
|
},
|
|
26660
|
-
composeBeat: async (projectId, beatId) => {
|
|
26661
|
-
const result = await request(
|
|
26662
|
-
"POST",
|
|
26663
|
-
`/api/projects/${encodeURIComponent(projectId)}/beats/${encodeURIComponent(beatId)}/compose`,
|
|
26664
|
-
{}
|
|
26665
|
-
);
|
|
26666
|
-
if (!result?.taskId) throw new Error("Compose beat enqueue failed: no taskId returned");
|
|
26667
|
-
return { taskId: result.taskId, taskType: "compose_beat", status: result.status ?? "pending", projectId, createdAt: (/* @__PURE__ */ new Date()).toISOString() };
|
|
26668
|
-
},
|
|
26669
26995
|
planRevisionBatch: async (projectId, revisionIds) => {
|
|
26670
26996
|
const result = await request(
|
|
26671
26997
|
"POST",
|
|
@@ -26817,10 +27143,17 @@ function createHttpClient(config2) {
|
|
|
26817
27143
|
const res = await request("GET", `/api/checks/${encodeURIComponent(checkId)}`);
|
|
26818
27144
|
return res.check;
|
|
26819
27145
|
},
|
|
26820
|
-
listProjectChecks: async (projectId, checkType) => {
|
|
26821
|
-
const
|
|
26822
|
-
|
|
26823
|
-
|
|
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 };
|
|
26824
27157
|
},
|
|
26825
27158
|
listBeatChecks: async (beatId, projectId, checkType) => {
|
|
26826
27159
|
const qs = checkType ? `?type=${encodeURIComponent(checkType)}` : "";
|
|
@@ -26881,8 +27214,13 @@ function createHttpClient(config2) {
|
|
|
26881
27214
|
},
|
|
26882
27215
|
// Drops
|
|
26883
27216
|
listTeamspaceDrops: async (teamspaceId, options) => {
|
|
26884
|
-
const
|
|
26885
|
-
|
|
27217
|
+
const qs = new URLSearchParams();
|
|
27218
|
+
if (options?.state) qs.set("state", options.state);
|
|
27219
|
+
if (options?.status?.length) qs.set("status", options.status.join(","));
|
|
27220
|
+
if (options?.sortBy) qs.set("sortBy", options.sortBy);
|
|
27221
|
+
if (options?.sortOrder) qs.set("sortOrder", options.sortOrder);
|
|
27222
|
+
const qsStr = qs.toString();
|
|
27223
|
+
const result = await request("GET", `/api/teamspaces/${encodeURIComponent(teamspaceId)}/drops${qsStr ? `?${qsStr}` : ""}`);
|
|
26886
27224
|
return result?.drops ?? [];
|
|
26887
27225
|
},
|
|
26888
27226
|
getDrop: async (dropId) => {
|
|
@@ -27068,7 +27406,7 @@ function loadConfig() {
|
|
|
27068
27406
|
};
|
|
27069
27407
|
}
|
|
27070
27408
|
async function main() {
|
|
27071
|
-
console.error(`[harmonica-mcp] v${"0.
|
|
27409
|
+
console.error(`[harmonica-mcp] v${"0.28.0"} starting\u2026`);
|
|
27072
27410
|
const config2 = loadConfig();
|
|
27073
27411
|
const client = createHttpClient({
|
|
27074
27412
|
apiBaseUrl: config2.apiBaseUrl,
|