@gethmy/mcp 2.8.0 → 2.8.2

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.
@@ -724,6 +724,87 @@ function getDisplayLinkType(linkType, direction) {
724
724
  return linkType;
725
725
  return LINK_TYPE_INVERSES[linkType];
726
726
  }
727
+ // ../harmony-shared/dist/commentSerializer.js
728
+ var CONFLICT_INSTRUCTION = "When two comments conflict, prefer the latest created_at, UNLESS a later " + "comment explicitly confirms or restates the earlier finding. Evaluate " + "substance, not just recency. Cite the comment id(s) you relied on.";
729
+ function authorLabel(c) {
730
+ if (c.author_type === "agent")
731
+ return "AI agent";
732
+ return c.author?.full_name || c.author?.email || "teammate";
733
+ }
734
+ function criticalIds(comments) {
735
+ const keep = new Set;
736
+ for (const c of comments) {
737
+ if (c.comment_type === "decision")
738
+ keep.add(c.id);
739
+ if (c.supersedes_id) {
740
+ keep.add(c.id);
741
+ keep.add(c.supersedes_id);
742
+ }
743
+ if (c.confirms_id) {
744
+ keep.add(c.id);
745
+ keep.add(c.confirms_id);
746
+ }
747
+ }
748
+ return keep;
749
+ }
750
+ function serializeCommentThread(comments, options = {}) {
751
+ const { heading = "Conversation", includeInstructions = true, activity = [], maxComments } = options;
752
+ const visible = comments.filter((c) => !c.deleted_at).slice().sort((a, b) => a.created_at.localeCompare(b.created_at));
753
+ if (visible.length === 0)
754
+ return "";
755
+ const indexById = new Map;
756
+ visible.forEach((c, i) => {
757
+ indexById.set(c.id, i + 1);
758
+ });
759
+ let rendered = visible;
760
+ let elidedCount = 0;
761
+ if (maxComments && visible.length > maxComments) {
762
+ const keep = criticalIds(visible);
763
+ const recentThreshold = visible.length - maxComments;
764
+ rendered = visible.filter((c, i) => i >= recentThreshold || keep.has(c.id));
765
+ elidedCount = visible.length - rendered.length;
766
+ }
767
+ const ref = (id) => {
768
+ const n = indexById.get(id);
769
+ return n ? `#${n}` : `#${id.slice(0, 8)}`;
770
+ };
771
+ const lines = [];
772
+ if (elidedCount > 0) {
773
+ lines.push({
774
+ at: visible[0]?.created_at ?? "",
775
+ text: `(${elidedCount} earlier comment(s) omitted for brevity)`
776
+ });
777
+ }
778
+ for (const c of rendered) {
779
+ const tags = [];
780
+ if (c.edited_at)
781
+ tags.push("edited");
782
+ if (c.supersedes_id)
783
+ tags.push(`supersedes ${ref(c.supersedes_id)}`);
784
+ if (c.confirms_id)
785
+ tags.push(`confirms ${ref(c.confirms_id)}`);
786
+ if (c.resolved_at)
787
+ tags.push("resolved");
788
+ const tagStr = tags.length ? ` | ${tags.join(" | ")}` : "";
789
+ const header = `[${ref(c.id)} | ${c.author_type} | ${authorLabel(c)} | ${c.comment_type} | ${c.created_at}${tagStr}]`;
790
+ lines.push({ at: c.created_at, text: `${header}
791
+ ${c.body.trim()}` });
792
+ }
793
+ for (const a of activity) {
794
+ const actor = a.actor ? `${a.actor} ` : "";
795
+ lines.push({ at: a.at, text: `· (system) ${a.at} — ${actor}${a.text}` });
796
+ }
797
+ lines.sort((a, b) => a.at.localeCompare(b.at));
798
+ const body = lines.map((l) => l.text).join(`
799
+
800
+ `);
801
+ const instruction = includeInstructions ? `
802
+
803
+ ${CONFLICT_INSTRUCTION}` : "";
804
+ return `## ${heading} (oldest → newest)
805
+
806
+ ${body}${instruction}`;
807
+ }
727
808
  // ../harmony-shared/dist/constants.js
728
809
  var TIMINGS = {
729
810
  SEARCH_DEBOUNCE: 300,
@@ -1076,6 +1157,28 @@ class HarmonyApiClient {
1076
1157
  async deleteSubtask(subtaskId) {
1077
1158
  return this.request("DELETE", `/subtasks/${subtaskId}`);
1078
1159
  }
1160
+ async addComment(cardId, body, opts) {
1161
+ return this.request("POST", `/cards/${cardId}/comments`, {
1162
+ body,
1163
+ authorType: "agent",
1164
+ commentType: opts?.commentType,
1165
+ supersedesId: opts?.supersedesId,
1166
+ confirmsId: opts?.confirmsId,
1167
+ agentSessionId: opts?.agentSessionId
1168
+ });
1169
+ }
1170
+ async getComments(cardId, opts) {
1171
+ const qs = new URLSearchParams;
1172
+ if (opts?.limit != null)
1173
+ qs.set("limit", String(opts.limit));
1174
+ if (opts?.offset != null)
1175
+ qs.set("offset", String(opts.offset));
1176
+ const suffix = qs.toString() ? `?${qs.toString()}` : "";
1177
+ return this.request("GET", `/cards/${cardId}/comments${suffix}`);
1178
+ }
1179
+ async updateComment(commentId, updates) {
1180
+ return this.request("PATCH", `/comments/${commentId}`, updates);
1181
+ }
1079
1182
  async startAgentSession(cardId, data) {
1080
1183
  return this.request("POST", `/cards/${cardId}/agent-context`, data);
1081
1184
  }
@@ -1433,6 +1536,24 @@ class HarmonyApiClient {
1433
1536
  assembledContext: assembledContextStr,
1434
1537
  assemblyId
1435
1538
  });
1539
+ try {
1540
+ const { comments } = await this.getComments(options.cardId, {
1541
+ limit: 200
1542
+ });
1543
+ if (Array.isArray(comments) && comments.length > 0) {
1544
+ const section = serializeCommentThread(comments, {
1545
+ heading: "Comments",
1546
+ maxComments: 40
1547
+ });
1548
+ if (section)
1549
+ result.prompt = `${result.prompt}
1550
+
1551
+ ${section}`;
1552
+ }
1553
+ } catch (err) {
1554
+ const msg = err instanceof Error ? err.message : String(err);
1555
+ console.debug(`[generateCardPrompt] comments fetch failed: ${msg}`);
1556
+ }
1436
1557
  try {
1437
1558
  await this.recordPromptHistory({
1438
1559
  cardId: cardData.id,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gethmy/mcp",
3
- "version": "2.8.0",
3
+ "version": "2.8.2",
4
4
  "description": "MCP server for Harmony Kanban board - enables AI coding agents to manage your boards",
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -60,7 +60,7 @@
60
60
  "serve:remote": "bun src/remote.ts",
61
61
  "dev": "bun --watch src/index.ts",
62
62
  "test": "bun run test:unit && bun run test:integration",
63
- "test:unit": "bun test src/__tests__/active-learning.test.ts src/__tests__/context-assembly.test.ts src/__tests__/prompt-builder.test.ts src/__tests__/memory-audit.test.ts src/__tests__/skills.test.ts src/__tests__/tool-dispatch.test.ts src/__tests__/mcp-integration.test.ts",
63
+ "test:unit": "bun test src/__tests__/active-learning.test.ts src/__tests__/context-assembly.test.ts src/__tests__/prompt-builder.test.ts src/__tests__/memory-audit.test.ts src/__tests__/skills.test.ts src/__tests__/hmy-config.test.ts src/__tests__/tool-dispatch.test.ts src/__tests__/mcp-integration.test.ts",
64
64
  "test:integration": "bun test src/__tests__/integration-memory-system.test.ts src/__tests__/integration-memory-crud.test.ts",
65
65
  "typecheck": "tsc --noEmit",
66
66
  "prepublishOnly": "bun run build"
package/src/api-client.ts CHANGED
@@ -1,4 +1,8 @@
1
- import { getDisplayLinkType } from "@harmony/shared";
1
+ import {
2
+ type Comment,
3
+ getDisplayLinkType,
4
+ serializeCommentThread,
5
+ } from "@harmony/shared";
2
6
  import { getApiKey, getApiUrl } from "./config.js";
3
7
 
4
8
  export interface ApiResponse<T = unknown> {
@@ -454,6 +458,7 @@ export class HarmonyApiClient {
454
458
  description?: string;
455
459
  priority?: string;
456
460
  assigneeId?: string;
461
+ planId?: string;
457
462
  },
458
463
  ): Promise<{ card: unknown }> {
459
464
  return this.request("POST", "/cards", { projectId, ...data });
@@ -616,6 +621,46 @@ export class HarmonyApiClient {
616
621
  return this.request("DELETE", `/subtasks/${subtaskId}`);
617
622
  }
618
623
 
624
+ // ============ COMMENT OPERATIONS ============
625
+
626
+ async addComment(
627
+ cardId: string,
628
+ body: string,
629
+ opts?: {
630
+ commentType?: string;
631
+ supersedesId?: string;
632
+ confirmsId?: string;
633
+ agentSessionId?: string;
634
+ },
635
+ ): Promise<{ comment: unknown }> {
636
+ return this.request("POST", `/cards/${cardId}/comments`, {
637
+ body,
638
+ authorType: "agent",
639
+ commentType: opts?.commentType,
640
+ supersedesId: opts?.supersedesId,
641
+ confirmsId: opts?.confirmsId,
642
+ agentSessionId: opts?.agentSessionId,
643
+ });
644
+ }
645
+
646
+ async getComments(
647
+ cardId: string,
648
+ opts?: { limit?: number; offset?: number },
649
+ ): Promise<{ comments: unknown[] }> {
650
+ const qs = new URLSearchParams();
651
+ if (opts?.limit != null) qs.set("limit", String(opts.limit));
652
+ if (opts?.offset != null) qs.set("offset", String(opts.offset));
653
+ const suffix = qs.toString() ? `?${qs.toString()}` : "";
654
+ return this.request("GET", `/cards/${cardId}/comments${suffix}`);
655
+ }
656
+
657
+ async updateComment(
658
+ commentId: string,
659
+ updates: { body?: string; pinned?: boolean; resolve?: boolean },
660
+ ): Promise<{ comment: unknown }> {
661
+ return this.request("PATCH", `/comments/${commentId}`, updates);
662
+ }
663
+
619
664
  // ============ AGENT CONTEXT OPERATIONS ============
620
665
 
621
666
  async startAgentSession(
@@ -1447,6 +1492,26 @@ export class HarmonyApiClient {
1447
1492
  assemblyId,
1448
1493
  });
1449
1494
 
1495
+ // Append the card's comment thread (people + agents). This is the central
1496
+ // injection point: every caller of harmony_generate_prompt (daemon, Claude
1497
+ // Code, in-app builder) gets the recency-ordered thread + conflict rule.
1498
+ // Best-effort — never fail prompt generation on a comments fetch error.
1499
+ try {
1500
+ const { comments } = await this.getComments(options.cardId, {
1501
+ limit: 200,
1502
+ });
1503
+ if (Array.isArray(comments) && comments.length > 0) {
1504
+ const section = serializeCommentThread(comments as Comment[], {
1505
+ heading: "Comments",
1506
+ maxComments: 40,
1507
+ });
1508
+ if (section) result.prompt = `${result.prompt}\n\n${section}`;
1509
+ }
1510
+ } catch (err) {
1511
+ const msg = err instanceof Error ? err.message : String(err);
1512
+ console.debug(`[generateCardPrompt] comments fetch failed: ${msg}`);
1513
+ }
1514
+
1450
1515
  // AGP P2: persist a session-linked snapshot. Best-effort — never fail
1451
1516
  // prompt generation just because logging didn't land.
1452
1517
  try {
package/src/cli.ts CHANGED
@@ -34,9 +34,9 @@ program
34
34
  console.error("Run: npx @gethmy/mcp setup");
35
35
  process.exit(1);
36
36
  }
37
- await refreshSkills();
37
+ const { updated } = await refreshSkills();
38
38
  const server = new HarmonyMCPServer();
39
- await server.run();
39
+ await server.run({ skillsUpdated: updated });
40
40
  });
41
41
 
42
42
  program
@@ -0,0 +1,70 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { join } from "node:path";
4
+
5
+ /**
6
+ * User-facing knobs for the skill auto-update, stored at `~/.hmy/config.yaml`.
7
+ *
8
+ * This file used to be read only by the bash auto-update preamble. Now that
9
+ * the MCP server owns auto-update (refreshSkills at `serve` startup), the
10
+ * server reads it directly. We intentionally avoid a YAML dependency: the file
11
+ * is a flat `key: value` list, so a tiny line parser is enough and keeps the
12
+ * install footprint small.
13
+ */
14
+ export interface HmyConfig {
15
+ /** Master switch. `update_check: false` disables auto-update entirely. */
16
+ updateCheck: boolean;
17
+ /**
18
+ * Freeze to a specific skills version. When set, the server skips
19
+ * auto-update so the user stays on whatever is currently installed.
20
+ */
21
+ pin: string | null;
22
+ }
23
+
24
+ const DEFAULTS: HmyConfig = { updateCheck: true, pin: null };
25
+
26
+ export function getHmyConfigPath(): string {
27
+ return join(homedir(), ".hmy", "config.yaml");
28
+ }
29
+
30
+ /** Read `~/.hmy/config.yaml`, falling back to defaults on any error. */
31
+ export function loadHmyConfig(): HmyConfig {
32
+ const path = getHmyConfigPath();
33
+ if (!existsSync(path)) return { ...DEFAULTS };
34
+ try {
35
+ return parseHmyConfig(readFileSync(path, "utf-8"));
36
+ } catch {
37
+ return { ...DEFAULTS };
38
+ }
39
+ }
40
+
41
+ /**
42
+ * Parse the flat `key: value` config body. Unknown keys are ignored;
43
+ * surrounding quotes and inline comments after a `#` are stripped. Exported
44
+ * for tests.
45
+ */
46
+ export function parseHmyConfig(text: string): HmyConfig {
47
+ const cfg: HmyConfig = { ...DEFAULTS };
48
+ for (const rawLine of text.split("\n")) {
49
+ const line = rawLine.trim();
50
+ if (!line || line.startsWith("#")) continue;
51
+ const sep = line.indexOf(":");
52
+ if (sep === -1) continue;
53
+ const key = line.slice(0, sep).trim();
54
+ let value = line.slice(sep + 1).trim();
55
+ // Drop trailing inline comment, then surrounding quotes.
56
+ const hash = value.indexOf(" #");
57
+ if (hash !== -1) value = value.slice(0, hash).trim();
58
+ value = value.replace(/^["']|["']$/g, "");
59
+ switch (key) {
60
+ case "update_check":
61
+ cfg.updateCheck = value !== "false";
62
+ break;
63
+ case "pin":
64
+ case "pin_version":
65
+ cfg.pin = value || null;
66
+ break;
67
+ }
68
+ }
69
+ return cfg;
70
+ }
package/src/server.ts CHANGED
@@ -48,6 +48,7 @@ import {
48
48
  sessionScopeFor,
49
49
  } from "./memory-session.js";
50
50
  import { onboardNewUser } from "./onboard.js";
51
+ import { stripSkillPreamble } from "./skills.js";
51
52
 
52
53
  /**
53
54
  * Dependencies injected into tool handlers.
@@ -286,6 +287,11 @@ export const TOOLS = {
286
287
  description: "Priority level",
287
288
  },
288
289
  assigneeId: { type: "string", description: "Assignee user ID" },
290
+ planId: {
291
+ type: "string",
292
+ description:
293
+ "Plan ID to link this card to (optional). Links the card to that plan via its plan_id.",
294
+ },
289
295
  },
290
296
  required: ["title"],
291
297
  },
@@ -630,6 +636,72 @@ export const TOOLS = {
630
636
  },
631
637
  },
632
638
 
639
+ // Comment operations
640
+ harmony_add_comment: {
641
+ description:
642
+ "Post a comment on a card as the agent. Use this to converse with the human in the open: report progress, ask a question, record a decision, or note a finding — instead of editing the card description. Set supersedesId to correct an earlier comment, confirmsId to reaffirm one. When the thread conflicts, prefer the latest comment unless a later one confirms an earlier finding; cite the comment id(s) you relied on.",
643
+ inputSchema: {
644
+ type: "object",
645
+ properties: {
646
+ cardId: { type: "string", description: "Card UUID to comment on" },
647
+ body: { type: "string", description: "Comment body (Markdown)" },
648
+ commentType: {
649
+ type: "string",
650
+ enum: [
651
+ "message",
652
+ "progress",
653
+ "question",
654
+ "blocker",
655
+ "decision",
656
+ "summary",
657
+ "finding",
658
+ ],
659
+ description:
660
+ "Type of comment. 'question'/'blocker' signal you need a human; default 'message'.",
661
+ },
662
+ supersedesId: {
663
+ type: "string",
664
+ description: "Comment id this comment corrects/updates",
665
+ },
666
+ confirmsId: {
667
+ type: "string",
668
+ description: "Comment id this comment reaffirms",
669
+ },
670
+ },
671
+ required: ["cardId", "body"],
672
+ },
673
+ },
674
+ harmony_get_comments: {
675
+ description:
676
+ "Get the comment thread on a card (oldest → newest). Returns human + agent comments with author, type, edited/resolved state, and supersede/confirm links. Read this before acting so you weigh later comments over earlier ones they contradict.",
677
+ inputSchema: {
678
+ type: "object",
679
+ properties: {
680
+ cardId: { type: "string" },
681
+ limit: { type: "number" },
682
+ offset: { type: "number" },
683
+ },
684
+ required: ["cardId"],
685
+ },
686
+ },
687
+ harmony_update_comment: {
688
+ description:
689
+ "Update one of your own comments: edit the body, pin it, or resolve a question/blocker once it has been answered.",
690
+ inputSchema: {
691
+ type: "object",
692
+ properties: {
693
+ commentId: { type: "string" },
694
+ body: { type: "string" },
695
+ pinned: { type: "boolean" },
696
+ resolve: {
697
+ type: "boolean",
698
+ description: "Mark (true) or clear (false) the resolved state",
699
+ },
700
+ },
701
+ required: ["commentId"],
702
+ },
703
+ },
704
+
633
705
  // Context operations
634
706
  harmony_list_workspaces: {
635
707
  description: "List all workspaces the user has access to",
@@ -1781,7 +1853,9 @@ export function registerHandlers(server: Server, deps: ToolDeps): void {
1781
1853
  {
1782
1854
  uri,
1783
1855
  mimeType: "text/markdown",
1784
- text: fetched.content,
1856
+ // Strip the legacy auto-update bash block if a stale render still
1857
+ // carries it — the agent shouldn't be handed curl→chmod→exec.
1858
+ text: stripSkillPreamble(fetched.content),
1785
1859
  },
1786
1860
  ],
1787
1861
  };
@@ -1863,6 +1937,7 @@ async function handleToolCall(
1863
1937
  description: args.description as string | undefined,
1864
1938
  priority: args.priority as string | undefined,
1865
1939
  assigneeId: args.assigneeId as string | undefined,
1940
+ planId: args.planId as string | undefined,
1866
1941
  });
1867
1942
  return { success: true, ...result };
1868
1943
  }
@@ -2168,6 +2243,71 @@ async function handleToolCall(
2168
2243
  return { success: true };
2169
2244
  }
2170
2245
 
2246
+ // Comment operations
2247
+ case "harmony_add_comment": {
2248
+ const cardId = z.string().uuid().parse(args.cardId);
2249
+ const body = z.string().min(1).max(10_000).parse(args.body);
2250
+ const commentType =
2251
+ args.commentType !== undefined
2252
+ ? z
2253
+ .enum([
2254
+ "message",
2255
+ "progress",
2256
+ "question",
2257
+ "blocker",
2258
+ "decision",
2259
+ "summary",
2260
+ "finding",
2261
+ ])
2262
+ .parse(args.commentType)
2263
+ : undefined;
2264
+ const supersedesId =
2265
+ args.supersedesId !== undefined
2266
+ ? z.string().uuid().parse(args.supersedesId)
2267
+ : undefined;
2268
+ const confirmsId =
2269
+ args.confirmsId !== undefined
2270
+ ? z.string().uuid().parse(args.confirmsId)
2271
+ : undefined;
2272
+ const result = await client.addComment(cardId, body, {
2273
+ commentType,
2274
+ supersedesId,
2275
+ confirmsId,
2276
+ });
2277
+ return { success: true, ...result };
2278
+ }
2279
+
2280
+ case "harmony_get_comments": {
2281
+ const cardId = z.string().uuid().parse(args.cardId);
2282
+ const limit =
2283
+ args.limit !== undefined
2284
+ ? z.number().int().min(1).max(500).parse(args.limit)
2285
+ : undefined;
2286
+ const offset =
2287
+ args.offset !== undefined
2288
+ ? z.number().int().min(0).parse(args.offset)
2289
+ : undefined;
2290
+ const result = await client.getComments(cardId, { limit, offset });
2291
+ return { success: true, ...result };
2292
+ }
2293
+
2294
+ case "harmony_update_comment": {
2295
+ const commentId = z.string().uuid().parse(args.commentId);
2296
+ const updates: { body?: string; pinned?: boolean; resolve?: boolean } =
2297
+ {};
2298
+ if (args.body !== undefined) {
2299
+ updates.body = z.string().min(1).max(10_000).parse(args.body);
2300
+ }
2301
+ if (args.pinned !== undefined) {
2302
+ updates.pinned = z.boolean().parse(args.pinned);
2303
+ }
2304
+ if (args.resolve !== undefined) {
2305
+ updates.resolve = z.boolean().parse(args.resolve);
2306
+ }
2307
+ const result = await client.updateComment(commentId, updates);
2308
+ return { success: true, ...result };
2309
+ }
2310
+
2171
2311
  // Context operations
2172
2312
  case "harmony_list_workspaces": {
2173
2313
  const result = await client.listWorkspaces();
@@ -3383,17 +3523,32 @@ export class HarmonyMCPServer {
3383
3523
  constructor() {
3384
3524
  this.server = new Server(
3385
3525
  { name: "@gethmy/mcp", version: "2.0.0" },
3386
- { capabilities: { tools: {}, resources: {} } },
3526
+ // resources.listChanged lets us notify the client when a startup skill
3527
+ // refresh rewrites SKILL.md files, so it re-reads them this session.
3528
+ { capabilities: { tools: {}, resources: { listChanged: true } } },
3387
3529
  );
3388
3530
 
3389
3531
  registerHandlers(this.server, createConfigDeps());
3390
3532
  }
3391
3533
 
3392
- async run() {
3534
+ /**
3535
+ * @param opts.skillsUpdated Set when the pre-connect skill refresh
3536
+ * (cli.ts) rewrote files. Triggers a resources/list_changed notification
3537
+ * once the transport is connected so the client picks up the new skills.
3538
+ */
3539
+ async run(opts: { skillsUpdated?: boolean } = {}) {
3393
3540
  const transport = new StdioServerTransport();
3394
3541
  await this.server.connect(transport);
3395
3542
  console.error("Harmony MCP server running on stdio");
3396
3543
 
3544
+ if (opts.skillsUpdated) {
3545
+ // Client is connected now — tell it the skill resources changed.
3546
+ this.server.sendResourceListChanged().catch(() => {
3547
+ // Best-effort: a missed notification just means the new skills load
3548
+ // on the next session instead of this one.
3549
+ });
3550
+ }
3551
+
3397
3552
  // Initialize auto-session tracking with MCP client identity detection
3398
3553
  const configDeps = createConfigDeps();
3399
3554
  initAutoSession(