@gethmy/mcp 2.23.0 → 2.25.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 CHANGED
@@ -20,7 +20,7 @@ var __require = /* @__PURE__ */ createRequire(import.meta.url);
20
20
  // src/config.ts
21
21
  import { existsSync as existsSync2, mkdirSync as mkdirSync2, readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "node:fs";
22
22
  import { homedir } from "node:os";
23
- import { join as join2 } from "node:path";
23
+ import { dirname, join as join2, parse, resolve } from "node:path";
24
24
  function getConfigDir() {
25
25
  return join2(homedir(), ".harmony-mcp");
26
26
  }
@@ -30,6 +30,22 @@ function getConfigPath() {
30
30
  function getLocalConfigPath(cwd) {
31
31
  return join2(cwd || process.cwd(), LOCAL_CONFIG_FILENAME);
32
32
  }
33
+ function findLocalConfigPath(cwd) {
34
+ const home = resolve(homedir());
35
+ let dir = resolve(cwd || process.cwd());
36
+ const { root } = parse(dir);
37
+ for (;; ) {
38
+ if (dir !== home && dir !== root) {
39
+ const candidate = join2(dir, LOCAL_CONFIG_FILENAME);
40
+ if (existsSync2(candidate))
41
+ return candidate;
42
+ }
43
+ const parent = dirname(dir);
44
+ if (parent === dir)
45
+ return null;
46
+ dir = parent;
47
+ }
48
+ }
33
49
  function emptyConfig() {
34
50
  return {
35
51
  apiKey: null,
@@ -81,8 +97,8 @@ function saveConfig(config) {
81
97
  });
82
98
  }
83
99
  function loadLocalConfig(cwd) {
84
- const localConfigPath = getLocalConfigPath(cwd);
85
- if (!existsSync2(localConfigPath)) {
100
+ const localConfigPath = findLocalConfigPath(cwd);
101
+ if (localConfigPath === null) {
86
102
  return null;
87
103
  }
88
104
  try {
@@ -97,7 +113,7 @@ function loadLocalConfig(cwd) {
97
113
  }
98
114
  }
99
115
  function saveLocalConfig(config, cwd) {
100
- const localConfigPath = getLocalConfigPath(cwd);
116
+ const localConfigPath = findLocalConfigPath(cwd) ?? getLocalConfigPath(cwd);
101
117
  const existingConfig = loadLocalConfig(cwd) || {
102
118
  workspaceId: null,
103
119
  projectId: null
@@ -111,7 +127,7 @@ function saveLocalConfig(config, cwd) {
111
127
  writeFileSync2(localConfigPath, JSON.stringify(cleanConfig, null, 2));
112
128
  }
113
129
  function hasLocalConfig(cwd) {
114
- return existsSync2(getLocalConfigPath(cwd));
130
+ return findLocalConfigPath(cwd) !== null;
115
131
  }
116
132
  function getActiveCredential() {
117
133
  const config = loadConfig();
@@ -133,33 +149,69 @@ function getUserEmail() {
133
149
  const config = loadConfig();
134
150
  return config.userEmail;
135
151
  }
136
- function setActiveWorkspace(workspaceId, options) {
137
- if (options?.local) {
138
- saveLocalConfig({ workspaceId }, options.cwd);
139
- } else {
140
- saveConfig({ activeWorkspaceId: workspaceId });
152
+ function setActiveContext(context, options) {
153
+ if (options?.global) {
154
+ saveConfig({
155
+ activeWorkspaceId: context.workspaceId,
156
+ activeProjectId: context.projectId
157
+ });
158
+ return;
141
159
  }
142
- }
143
- function setActiveProject(projectId, options) {
144
- if (options?.local) {
145
- saveLocalConfig({ projectId }, options.cwd);
160
+ const localPath = findLocalConfigPath(options?.cwd);
161
+ if (options?.local || localPath !== null) {
162
+ saveLocalConfig({ workspaceId: context.workspaceId, projectId: context.projectId }, options?.cwd);
146
163
  } else {
147
- saveConfig({ activeProjectId: projectId });
164
+ saveConfig({
165
+ activeWorkspaceId: context.workspaceId,
166
+ activeProjectId: context.projectId
167
+ });
148
168
  }
149
169
  }
150
- function getActiveWorkspaceId(cwd) {
170
+ function setActiveWorkspace(workspaceId, options) {
171
+ const currentWorkspaceId = getActiveWorkspaceId(options?.cwd);
172
+ const keepProject = currentWorkspaceId === workspaceId;
173
+ setActiveContext({
174
+ workspaceId,
175
+ projectId: keepProject ? getActiveProjectId(options?.cwd) : null
176
+ }, options);
177
+ }
178
+ function readActiveContext(cwd) {
151
179
  const localConfig = loadLocalConfig(cwd);
152
- if (localConfig?.workspaceId) {
153
- return localConfig.workspaceId;
180
+ if (localConfig) {
181
+ return {
182
+ workspaceId: localConfig.workspaceId ?? null,
183
+ projectId: localConfig.projectId ?? null
184
+ };
154
185
  }
155
- return loadConfig().activeWorkspaceId;
186
+ const globalConfig = loadConfig();
187
+ return {
188
+ workspaceId: globalConfig.activeWorkspaceId,
189
+ projectId: globalConfig.activeProjectId
190
+ };
191
+ }
192
+ function getActiveWorkspaceId(cwd) {
193
+ return readActiveContext(cwd).workspaceId;
156
194
  }
157
195
  function getActiveProjectId(cwd) {
158
- const localConfig = loadLocalConfig(cwd);
159
- if (localConfig?.projectId) {
160
- return localConfig.projectId;
196
+ return readActiveContext(cwd).projectId;
197
+ }
198
+ function getActiveContext(cwd) {
199
+ return describeActiveContext({
200
+ projectId: getActiveProjectId(cwd),
201
+ workspaceId: getActiveWorkspaceId(cwd)
202
+ });
203
+ }
204
+ function describeActiveContext(context) {
205
+ const { projectId, workspaceId } = context;
206
+ if (projectId && !workspaceId) {
207
+ return {
208
+ projectId,
209
+ workspaceId,
210
+ consistent: false,
211
+ note: `An active project (${projectId}) is set with no active workspace, so ` + "workspace-scoped tools cannot resolve one from it. Re-set it with " + "harmony_set_project_context, or pass workspaceId explicitly."
212
+ };
161
213
  }
162
- return loadConfig().activeProjectId;
214
+ return { projectId, workspaceId, consistent: true, note: null };
163
215
  }
164
216
  function isConfigured() {
165
217
  const config = loadConfig();
@@ -302,15 +354,7 @@ ${lines.join(`
302
354
  `)}`;
303
355
  }
304
356
  function generatePrompt(options) {
305
- const {
306
- card,
307
- column,
308
- variant,
309
- customConstraints,
310
- memories,
311
- assembledContext,
312
- assemblyId
313
- } = options;
357
+ const { card, column, variant, customConstraints, memories, assemblyId } = options;
314
358
  const contextOpts = {
315
359
  includeTitle: true,
316
360
  includeDescription: true,
@@ -388,10 +432,7 @@ ${card.description}`);
388
432
  roleFraming.outputSuggestions.forEach((s) => {
389
433
  sections.push(`- ${s}`);
390
434
  });
391
- if (assembledContext) {
392
- sections.push(`
393
- ${assembledContext}`);
394
- } else if (memories && memories.length > 0) {
435
+ if (memories && memories.length > 0) {
395
436
  sections.push(`
396
437
  ## Relevant Memories`);
397
438
  sections.push(`*${memories.length} memories recalled from knowledge graph:*`);
@@ -402,7 +443,7 @@ ${assembledContext}`);
402
443
  sections.push(memory.content);
403
444
  }
404
445
  }
405
- const oneThingLine = synthesizeOneThing(card, subtasks, links, assembledContext);
446
+ const oneThingLine = synthesizeOneThing(card, subtasks, links);
406
447
  if (oneThingLine) {
407
448
  sections.push(`
408
449
  ## Recommended Next Step
@@ -429,7 +470,7 @@ ${customConstraints}`);
429
470
  *Card #${card.short_id} | Generated for ${variant} mode*`);
430
471
  const prompt = sections.join(`
431
472
  `);
432
- const memoryCount = assembledContext ? (assembledContext.match(/^### /gm) || []).length : memories?.length || 0;
473
+ const memoryCount = memories?.length ?? 0;
433
474
  return {
434
475
  prompt,
435
476
  variant,
@@ -450,40 +491,7 @@ ${customConstraints}`);
450
491
  version: PROMPT_TEMPLATE_VERSION
451
492
  };
452
493
  }
453
- function extractSessionInsights(assembledContext) {
454
- const result = {
455
- lastSessionStatus: null,
456
- lastSessionTask: null,
457
- lastSessionProgress: null,
458
- blockers: [],
459
- procedureNextStep: null
460
- };
461
- const sessionMatches = assembledContext.match(/### Session:.*?\n([\s\S]*?)(?=\n###|\n## |\n---|\n\*Assembly|$)/g);
462
- if (sessionMatches && sessionMatches.length > 0) {
463
- const latest = sessionMatches[0];
464
- if (/Completed work on/i.test(latest)) {
465
- result.lastSessionStatus = "completed";
466
- } else if (/Paused work on|status:\s*paused/i.test(latest)) {
467
- result.lastSessionStatus = "paused";
468
- }
469
- const taskMatch = latest.match(/Final task:\s*(.+)/);
470
- if (taskMatch)
471
- result.lastSessionTask = taskMatch[1].trim();
472
- const progressMatch = latest.match(/Progress:\s*(\d+)%/);
473
- if (progressMatch)
474
- result.lastSessionProgress = parseInt(progressMatch[1], 10);
475
- }
476
- const blockerMatches = assembledContext.match(/(?:blocker|blocked by|blocking):\s*(.+)/gi);
477
- if (blockerMatches) {
478
- result.blockers = blockerMatches.map((m) => m.replace(/(?:blocker|blocked by|blocking):\s*/i, "").trim());
479
- }
480
- const stepMatches = assembledContext.match(/^\d+\.\s+(?!.*\*\*\[key step\]\*\*.*✓)(.+?)(?:\s*\*\*\[key step\]\*\*)?$/gm);
481
- if (stepMatches && stepMatches.length > 0) {
482
- result.procedureNextStep = stepMatches[0].replace(/^\d+\.\s+/, "").replace(/\s*\*\*\[key step\]\*\*.*$/, "").trim();
483
- }
484
- return result;
485
- }
486
- function synthesizeOneThing(card, subtasks, links, assembledContext) {
494
+ function synthesizeOneThing(card, subtasks, links) {
487
495
  if (card.done)
488
496
  return null;
489
497
  const blockers = links.filter((l) => l.display_type === "is_blocked_by" && l.direction === "incoming");
@@ -491,14 +499,6 @@ function synthesizeOneThing(card, subtasks, links, assembledContext) {
491
499
  const blocker = blockers[0];
492
500
  return `Unblock first: resolve #${blocker.target_card.short_id} "${blocker.target_card.title}" which is blocking this card.`;
493
501
  }
494
- const session = assembledContext ? extractSessionInsights(assembledContext) : null;
495
- if (session?.blockers && session.blockers.length > 0) {
496
- return `Resolve blocker: ${session.blockers[0]}`;
497
- }
498
- if (session?.lastSessionStatus === "paused" && session.lastSessionTask) {
499
- const progress = session.lastSessionProgress ? ` (was ${session.lastSessionProgress}% complete)` : "";
500
- return `Resume previous session${progress}: "${session.lastSessionTask}".`;
501
- }
502
502
  if (subtasks.length > 0) {
503
503
  const completed = subtasks.filter((s) => s.completed).length;
504
504
  if (completed === subtasks.length) {
@@ -509,12 +509,6 @@ function synthesizeOneThing(card, subtasks, links, assembledContext) {
509
509
  return `Work on next subtask: "${nextSubtask.title}" (${completed}/${subtasks.length} done).`;
510
510
  }
511
511
  }
512
- if (session?.procedureNextStep) {
513
- return `Follow procedure: ${session.procedureNextStep}`;
514
- }
515
- if (session?.lastSessionStatus === "completed" && session.lastSessionTask) {
516
- return `Previous session completed ("${session.lastSessionTask}"). Review results and continue with remaining work.`;
517
- }
518
512
  if (card.due_date && (card.priority === "urgent" || card.priority === "high")) {
519
513
  return `High-priority task with deadline ${card.due_date}. Start implementation immediately.`;
520
514
  }
@@ -878,7 +872,7 @@ function lockPath() {
878
872
  return join3(getConfigDir(), LOCK_FILENAME);
879
873
  }
880
874
  function sleep(ms) {
881
- return new Promise((resolve) => setTimeout(resolve, ms));
875
+ return new Promise((resolve2) => setTimeout(resolve2, ms));
882
876
  }
883
877
  async function withRefreshLock(fn) {
884
878
  const path = lockPath();
@@ -1381,6 +1375,9 @@ import {
1381
1375
  ReadResourceRequestSchema
1382
1376
  } from "@modelcontextprotocol/sdk/types.js";
1383
1377
  import { z } from "zod";
1378
+
1379
+ // src/api-client.ts
1380
+ import { randomUUID as randomUUID2 } from "node:crypto";
1384
1381
  // ../harmony-shared/dist/agentStaleness.js
1385
1382
  var AGENT_HEARTBEAT_LIVENESS_MS = 5 * 60 * 1000;
1386
1383
  var AGENT_MILESTONE_LIVENESS_MS = 30 * 60 * 1000;
@@ -1502,12 +1499,97 @@ var TIMINGS = {
1502
1499
  QUERY_STALE_TIME: 1000 * 60 * 5,
1503
1500
  QUERY_GC_TIME: 1000 * 60 * 60 * 24
1504
1501
  };
1502
+ // ../harmony-shared/dist/declaredGateMetrics.js
1503
+ function declaredGateMetricsFromAgents(agents) {
1504
+ const names = new Set;
1505
+ let known = false;
1506
+ for (const agent of agents) {
1507
+ const declared = agent.declared_gate_metrics;
1508
+ if (!Array.isArray(declared))
1509
+ continue;
1510
+ known = true;
1511
+ for (const name of declared) {
1512
+ if (typeof name === "string" && name.trim())
1513
+ names.add(name.trim());
1514
+ }
1515
+ }
1516
+ return { names, known };
1517
+ }
1518
+ // ../harmony-shared/dist/gateConfigError.js
1519
+ var GATE_CONFIG_ERROR_KEY = "configError";
1520
+ var GATE_CONFIG_ERROR_MARK = Object.freeze({ [GATE_CONFIG_ERROR_KEY]: true });
1505
1521
  // ../harmony-shared/dist/playbookStage.js
1522
+ var DEFAULT_LOOP_MAX_ITERATIONS = 5;
1523
+ function normalizeLoopDef(raw) {
1524
+ if (raw === null || typeof raw !== "object" || Array.isArray(raw))
1525
+ return null;
1526
+ const obj = raw;
1527
+ if (obj.mode !== "converge" && obj.mode !== "fanout")
1528
+ return null;
1529
+ const mode = obj.mode;
1530
+ const rawMax = obj.max_iterations;
1531
+ const maxInt = typeof rawMax === "number" && Number.isFinite(rawMax) && rawMax >= 1 ? Math.floor(rawMax) : DEFAULT_LOOP_MAX_ITERATIONS;
1532
+ const exitGate = obj.exit_gate && typeof obj.exit_gate === "object" && !Array.isArray(obj.exit_gate) ? obj.exit_gate : null;
1533
+ const def = { mode, max_iterations: maxInt };
1534
+ if (exitGate)
1535
+ def.exit_gate = exitGate;
1536
+ if (obj.item_source && typeof obj.item_source === "object" && !Array.isArray(obj.item_source)) {
1537
+ def.item_source = obj.item_source;
1538
+ }
1539
+ if (typeof obj.concurrency === "number" && obj.concurrency >= 1) {
1540
+ def.concurrency = Math.floor(obj.concurrency);
1541
+ }
1542
+ if (obj.on_item_fail === "continue" || obj.on_item_fail === "halt") {
1543
+ def.on_item_fail = obj.on_item_fail;
1544
+ }
1545
+ return def;
1546
+ }
1547
+ function readStageDefs(def) {
1548
+ if (def.steps_version !== 2)
1549
+ return [];
1550
+ return Array.isArray(def.steps) ? def.steps : [];
1551
+ }
1506
1552
  var STAGE_DAEMON_OWNED_TOOLS = [
1507
1553
  "mcp__harmony__harmony_end_agent_session",
1508
1554
  "mcp__harmony__harmony_start_agent_session",
1509
1555
  "mcp__harmony__harmony_move_card"
1510
1556
  ];
1557
+ function customGateMetric(gate) {
1558
+ if (gate === null || typeof gate !== "object" || Array.isArray(gate)) {
1559
+ return null;
1560
+ }
1561
+ const record = gate;
1562
+ if (record.kind !== "custom")
1563
+ return null;
1564
+ if (record.pendingEngine === true)
1565
+ return null;
1566
+ const metric = typeof record.metric === "string" ? record.metric.trim() : "";
1567
+ return metric ? metric : null;
1568
+ }
1569
+ function referencedGateMetrics(def) {
1570
+ const out = [];
1571
+ for (const stage of readStageDefs(def)) {
1572
+ if (!stage || typeof stage !== "object")
1573
+ continue;
1574
+ const stageId = typeof stage.id === "string" ? stage.id : "";
1575
+ const stageName = typeof stage.name === "string" ? stage.name : stageId;
1576
+ const gateMetric = customGateMetric(stage.gate);
1577
+ if (gateMetric) {
1578
+ out.push({ stageId, stageName, metric: gateMetric, source: "gate" });
1579
+ }
1580
+ const loop = normalizeLoopDef(stage.loop);
1581
+ const loopMetric = loop?.exit_gate ? customGateMetric(loop.exit_gate) : null;
1582
+ if (loopMetric) {
1583
+ out.push({
1584
+ stageId,
1585
+ stageName,
1586
+ metric: loopMetric,
1587
+ source: "loop_exit_gate"
1588
+ });
1589
+ }
1590
+ }
1591
+ return out;
1592
+ }
1511
1593
  // ../harmony-shared/dist/reviewTools.js
1512
1594
  var REVIEW_DISALLOWED_TOOLS = [
1513
1595
  ...STAGE_DAEMON_OWNED_TOOLS,
@@ -1541,7 +1623,20 @@ function getRetryDelay(attempt) {
1541
1623
  const delay = Math.min(RETRY_CONFIG.baseDelayMs * 2 ** attempt, RETRY_CONFIG.maxDelayMs);
1542
1624
  return Math.round(delay + delay * 0.25 * (Math.random() * 2 - 1));
1543
1625
  }
1544
- var sleep2 = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
1626
+ var sleep2 = (ms) => new Promise((resolve2) => setTimeout(resolve2, ms));
1627
+ function buildMemoryQuery(title, description) {
1628
+ const DESCRIPTION_CAP = 600;
1629
+ const trimmedTitle = title.trim();
1630
+ const trimmedBody = (description ?? "").trim();
1631
+ if (!trimmedTitle && !trimmedBody)
1632
+ return "";
1633
+ if (!trimmedTitle)
1634
+ return trimmedBody.slice(0, DESCRIPTION_CAP);
1635
+ if (!trimmedBody)
1636
+ return trimmedTitle;
1637
+ return `${trimmedTitle}
1638
+ ${trimmedBody.slice(0, DESCRIPTION_CAP)}`;
1639
+ }
1545
1640
 
1546
1641
  class Semaphore {
1547
1642
  permits;
@@ -1554,7 +1649,7 @@ class Semaphore {
1554
1649
  this.permits--;
1555
1650
  return;
1556
1651
  }
1557
- return new Promise((resolve) => this.queue.push(resolve));
1652
+ return new Promise((resolve2) => this.queue.push(resolve2));
1558
1653
  }
1559
1654
  release() {
1560
1655
  const next = this.queue.shift();
@@ -2012,6 +2107,14 @@ class HarmonyApiClient {
2012
2107
  params.set("sinceSeq", String(sinceSeq));
2013
2108
  return this.request("GET", `/cards/${cardId}/agent-messages?${params.toString()}`);
2014
2109
  }
2110
+ async postBudgetDecision(cardId, data) {
2111
+ return this.request("POST", `/cards/${cardId}/budget-decisions`, data);
2112
+ }
2113
+ async getBudgetDecisions(cardId, sinceIso) {
2114
+ const params = new URLSearchParams;
2115
+ params.set("sinceIso", sinceIso);
2116
+ return this.request("GET", `/cards/${cardId}/budget-decisions?${params.toString()}`);
2117
+ }
2015
2118
  async updateAgentProgress(cardId, data) {
2016
2119
  return this.request("POST", `/cards/${cardId}/agent-context`, data);
2017
2120
  }
@@ -2057,6 +2160,8 @@ class HarmonyApiClient {
2057
2160
  params.set("offset", String(options.offset));
2058
2161
  if (options.include_superseded)
2059
2162
  params.set("include_superseded", "true");
2163
+ if (options.consumer)
2164
+ params.set("consumer", options.consumer);
2060
2165
  if (options.include_episodes)
2061
2166
  params.set("include_episodes", "true");
2062
2167
  return this.request("GET", `/memory/entities?${params.toString()}`);
@@ -2112,6 +2217,12 @@ class HarmonyApiClient {
2112
2217
  if (options.topK !== undefined) {
2113
2218
  entities = entities.slice(0, options.topK);
2114
2219
  }
2220
+ if (options.consumer) {
2221
+ const deliveredIds = entities.map((e) => e.id).filter((id) => typeof id === "string");
2222
+ if (deliveredIds.length > 0) {
2223
+ this.batchTouchMemoryEntities(deliveredIds, options.consumer).catch(() => {});
2224
+ }
2225
+ }
2115
2226
  return { entities };
2116
2227
  }
2117
2228
  async deleteMemoryEntity(entityId) {
@@ -2120,9 +2231,10 @@ class HarmonyApiClient {
2120
2231
  async touchMemoryEntity(entityId) {
2121
2232
  return this.request("POST", `/memory/entities/${entityId}/touch`);
2122
2233
  }
2123
- async batchTouchMemoryEntities(entityIds) {
2234
+ async batchTouchMemoryEntities(entityIds, consumer) {
2124
2235
  return this.request("POST", "/memory/entities/batch-touch", {
2125
- entity_ids: entityIds
2236
+ entity_ids: entityIds,
2237
+ ...consumer ? { consumer } : {}
2126
2238
  });
2127
2239
  }
2128
2240
  async createMemoryRelation(data) {
@@ -2148,8 +2260,12 @@ class HarmonyApiClient {
2148
2260
  params.append("tags", tag);
2149
2261
  if (options?.include_superseded)
2150
2262
  params.set("include_superseded", "true");
2263
+ if (options?.consumer)
2264
+ params.set("consumer", options.consumer);
2151
2265
  if (options?.include_episodes)
2152
2266
  params.set("include_episodes", "true");
2267
+ if (options?.assembly_id)
2268
+ params.set("assembly_id", options.assembly_id);
2153
2269
  return this.request("GET", `/memory/search?${params.toString()}`);
2154
2270
  }
2155
2271
  async getVaultIndex(options) {
@@ -2161,6 +2277,8 @@ class HarmonyApiClient {
2161
2277
  params.set("type", options.type);
2162
2278
  if (options.limit !== undefined)
2163
2279
  params.set("limit", String(options.limit));
2280
+ if (options.consumer)
2281
+ params.set("consumer", options.consumer);
2164
2282
  if (options.include_episodes)
2165
2283
  params.set("include_episodes", "true");
2166
2284
  return this.request("GET", `/memory/index?${params.toString()}`);
@@ -2174,6 +2292,8 @@ class HarmonyApiClient {
2174
2292
  params.set("type", options.type);
2175
2293
  if (options.limit !== undefined)
2176
2294
  params.set("limit", String(options.limit));
2295
+ if (options.consumer)
2296
+ params.set("consumer", options.consumer);
2177
2297
  if (options.include_episodes)
2178
2298
  params.set("include_episodes", "true");
2179
2299
  return this.requestRaw("GET", `/memory/index?${params.toString()}`, undefined, {
@@ -2233,6 +2353,8 @@ class HarmonyApiClient {
2233
2353
  params.set("type", options.type);
2234
2354
  if (options?.limit !== undefined)
2235
2355
  params.set("limit", String(options.limit));
2356
+ if (options?.consumer)
2357
+ params.set("consumer", options.consumer);
2236
2358
  if (options?.include_episodes)
2237
2359
  params.set("include_episodes", "true");
2238
2360
  return this.requestRaw("GET", `/memory/search?${params.toString()}`, undefined, {
@@ -2338,14 +2460,15 @@ class HarmonyApiClient {
2338
2460
  } catch {}
2339
2461
  }
2340
2462
  const variant = options.variant || "execute";
2341
- const assembledContextStr = undefined;
2342
- const assemblyId = undefined;
2463
+ const assemblyId = randomUUID2();
2343
2464
  let memories;
2344
2465
  try {
2345
2466
  if (options.workspaceId && cardData.title) {
2346
- const memoryResult = await this.searchMemoryEntities(options.workspaceId, cardData.title, {
2467
+ const memoryResult = await this.searchMemoryEntities(options.workspaceId, buildMemoryQuery(cardData.title, cardData.description), {
2347
2468
  project_id: options.projectId,
2348
- limit: 5
2469
+ limit: 5,
2470
+ consumer: "agent-prompt",
2471
+ assembly_id: assemblyId
2349
2472
  });
2350
2473
  if (memoryResult.entities?.length > 0) {
2351
2474
  memories = memoryResult.entities.map((e) => ({
@@ -2369,7 +2492,6 @@ class HarmonyApiClient {
2369
2492
  contextOptions: options.contextOptions,
2370
2493
  customConstraints: options.customConstraints,
2371
2494
  memories,
2372
- assembledContext: assembledContextStr,
2373
2495
  assemblyId
2374
2496
  });
2375
2497
  try {
@@ -2700,7 +2822,7 @@ async function autoExpandGraph(client3, entityId, title, content, _tags, workspa
2700
2822
  });
2701
2823
  candidates = entities.filter((e) => e.id !== entityId && (e.confidence ?? 1) >= 0.4).slice(0, maxRelations);
2702
2824
  if (candidates.length === 0) {
2703
- await new Promise((resolve) => setTimeout(resolve, 2000));
2825
+ await new Promise((resolve2) => setTimeout(resolve2, 2000));
2704
2826
  const retry = await client3.searchMemoryEntities(workspaceId, query, {
2705
2827
  project_id: projectId,
2706
2828
  limit: 20
@@ -3283,6 +3405,34 @@ async function onboardNewUser(params) {
3283
3405
  };
3284
3406
  }
3285
3407
 
3408
+ // src/playbook-metric-warnings.ts
3409
+ function playbookMetricWarnings(agents, steps) {
3410
+ if (!Array.isArray(steps))
3411
+ return [];
3412
+ const declared = declaredGateMetricsFromAgents(agents);
3413
+ if (!declared.known)
3414
+ return [];
3415
+ const warnings = [];
3416
+ const seen = new Set;
3417
+ for (const ref of referencedGateMetrics({ steps, steps_version: 2 })) {
3418
+ if (declared.names.has(ref.metric) || seen.has(ref.metric))
3419
+ continue;
3420
+ seen.add(ref.metric);
3421
+ warnings.push(`Gate metric "${ref.metric}" (stage "${ref.stageName}") is not declared by any agent in this workspace — a stage run gating on it will hold until a daemon declares it under agent.playbooks.metrics.${ref.metric}.`);
3422
+ }
3423
+ return warnings;
3424
+ }
3425
+ async function collectPlaybookMetricWarnings(client3, workspaceId, steps) {
3426
+ if (!workspaceId || !Array.isArray(steps))
3427
+ return [];
3428
+ try {
3429
+ const { agents } = await client3.listWorkspaceAgents(workspaceId);
3430
+ return playbookMetricWarnings(agents, steps);
3431
+ } catch {
3432
+ return [];
3433
+ }
3434
+ }
3435
+
3286
3436
  // src/skills.ts
3287
3437
  import {
3288
3438
  existsSync as existsSync4,
@@ -3292,7 +3442,7 @@ import {
3292
3442
  writeFileSync as writeFileSync3
3293
3443
  } from "node:fs";
3294
3444
  import { homedir as homedir3 } from "node:os";
3295
- import { dirname, join as join5 } from "node:path";
3445
+ import { dirname as dirname2, join as join5 } from "node:path";
3296
3446
  init_config();
3297
3447
 
3298
3448
  // src/hmy-config.ts
@@ -3439,7 +3589,7 @@ function stripSkillPreamble(content) {
3439
3589
  `;
3440
3590
  }
3441
3591
  function atomicWrite(filePath, content) {
3442
- const dir = dirname(filePath);
3592
+ const dir = dirname2(filePath);
3443
3593
  if (!existsSync4(dir))
3444
3594
  mkdirSync3(dir, { recursive: true });
3445
3595
  const tmp = `${filePath}.tmp-${process.pid}-${Date.now()}`;
@@ -3530,10 +3680,10 @@ async function refreshSkills(opts = {}) {
3530
3680
  continue;
3531
3681
  let siblingPath;
3532
3682
  if (samplePath.endsWith("SKILL.md")) {
3533
- const parentDir = dirname(dirname(samplePath));
3683
+ const parentDir = dirname2(dirname2(samplePath));
3534
3684
  siblingPath = `${parentDir}/${name}/SKILL.md`;
3535
3685
  } else {
3536
- const parentDir = dirname(samplePath);
3686
+ const parentDir = dirname2(samplePath);
3537
3687
  siblingPath = `${parentDir}/${name}.md`;
3538
3688
  }
3539
3689
  if (existsSync4(siblingPath)) {
@@ -4327,7 +4477,7 @@ var TOOLS = {
4327
4477
  }
4328
4478
  },
4329
4479
  harmony_classify_card: {
4330
- description: "Classify a card with the LLM classifier: sets `intent` (plan/think/implement/review), `complexity_score` (0-10), `model_tier` (simple/advanced/research), stamps `classified_at`, and applies the type label (feature/bug/idea). Call right after creating a card to classify it in-flow. Idempotent; never touches the user-owned `model_override`.",
4480
+ description: "DEPRECATED run sizing now happens at daemon pickup and is run-scoped, so nothing reads `model_tier`, `intent` or `complexity_score` any more; this tool still writes them, but only the type label has an effect. Prefer letting card creation apply the type label. Sets `intent` (plan/think/implement/review), `complexity_score` (0-10), `model_tier` (simple/advanced/research), stamps `classified_at`, and applies the type label (feature/bug/idea). Idempotent; never touches the user-owned `model_override`.",
4331
4481
  inputSchema: {
4332
4482
  type: "object",
4333
4483
  properties: {
@@ -4581,11 +4731,15 @@ var TOOLS = {
4581
4731
  }
4582
4732
  },
4583
4733
  harmony_set_project_context: {
4584
- description: "Set the active project context for subsequent operations",
4734
+ description: "Set the active project context for subsequent operations. The project's workspace is set with it, so the two can never point at different places; pass workspaceId to skip the lookup.",
4585
4735
  inputSchema: {
4586
4736
  type: "object",
4587
4737
  properties: {
4588
- projectId: { type: "string" }
4738
+ projectId: { type: "string" },
4739
+ workspaceId: {
4740
+ type: "string",
4741
+ description: "The workspace this project belongs to. Optional — looked up when omitted."
4742
+ }
4589
4743
  },
4590
4744
  required: ["projectId"]
4591
4745
  }
@@ -5389,13 +5543,26 @@ var TOOLS = {
5389
5543
  type: "array",
5390
5544
  description: "The playbook's ordered stage objects.",
5391
5545
  items: { type: "object" }
5546
+ },
5547
+ triggerType: {
5548
+ type: "string",
5549
+ enum: ["manual", "auto"],
5550
+ description: "'manual' (default) — the playbook is applied by a person. 'auto' — it claims matching cards itself, and requires autoBind."
5551
+ },
5552
+ autoBind: {
5553
+ type: "object",
5554
+ description: "Auto-bind rule: {priority?: number, mode?: 'all'|'any', when: [{path, op, value}]}. Conditions are evaluated against the card's labels (lowercased), intent, complexity_score and priority with the gate operators eq/neq/gte/gt/lte/lt/contains/exists; 'contains' on labels is membership. Stored even while triggerType is 'manual', so a rule can be armed later without re-authoring it."
5555
+ },
5556
+ catalogId: {
5557
+ type: "string",
5558
+ description: "Slug of the built-in template this came from (provenance only; never used to match)."
5392
5559
  }
5393
5560
  },
5394
5561
  required: ["name"]
5395
5562
  }
5396
5563
  },
5397
5564
  harmony_update_playbook: {
5398
- description: "Update a playbook's name, description, steps/stages, enabled flag, or lifecycle state ('active'|'deprecated').",
5565
+ description: "Update a playbook's name, description, steps/stages, enabled flag, lifecycle state ('active'|'deprecated'), or its auto-bind rule and arming.",
5399
5566
  inputSchema: {
5400
5567
  type: "object",
5401
5568
  properties: {
@@ -5418,6 +5585,15 @@ var TOOLS = {
5418
5585
  type: "string",
5419
5586
  enum: ["active", "deprecated"],
5420
5587
  description: "Lifecycle state"
5588
+ },
5589
+ triggerType: {
5590
+ type: "string",
5591
+ enum: ["manual", "auto"],
5592
+ description: "Arm ('auto') or disarm ('manual') automatic application. Arming requires a rule to be present or supplied in the same call."
5593
+ },
5594
+ autoBind: {
5595
+ type: "object",
5596
+ description: "Replace the auto-bind rule: {priority?, mode?: 'all'|'any', when: [{path, op, value}]}. Pass null to remove it."
5421
5597
  }
5422
5598
  },
5423
5599
  required: ["playbookId"]
@@ -5882,7 +6058,10 @@ async function handleToolCall(name, args, deps) {
5882
6058
  const where = resolved.project.workspaceName ? `project "${resolved.project.name ?? resolved.project.id}" (workspace "${resolved.project.workspaceName}")` : `project "${resolved.project.name ?? resolved.project.id}"`;
5883
6059
  const established = activeProjectId == null;
5884
6060
  if (established) {
5885
- deps.setActiveProject(resolved.project.id);
6061
+ deps.setActiveContext({
6062
+ projectId: resolved.project.id,
6063
+ workspaceId: resolved.project.workspaceId
6064
+ });
5886
6065
  }
5887
6066
  return {
5888
6067
  success: true,
@@ -6321,15 +6500,57 @@ ${options}
6321
6500
  }
6322
6501
  case "harmony_set_project_context": {
6323
6502
  const projectId = z.string().uuid().parse(args.projectId);
6324
- deps.setActiveProject(projectId);
6325
- return { success: true, activeProjectId: projectId };
6503
+ const explicitWorkspaceId = args.workspaceId ? z.string().uuid().parse(args.workspaceId) : null;
6504
+ let owningWorkspaceId = explicitWorkspaceId;
6505
+ if (!owningWorkspaceId) {
6506
+ try {
6507
+ const { workspaces } = await client3.listWorkspaces();
6508
+ for (const workspace of workspaces) {
6509
+ if (!workspace?.id)
6510
+ continue;
6511
+ const { projects } = await client3.listProjects(workspace.id);
6512
+ if (projects.some((p) => p?.id === projectId)) {
6513
+ owningWorkspaceId = workspace.id;
6514
+ break;
6515
+ }
6516
+ }
6517
+ } catch (error) {
6518
+ const reason = error instanceof Error ? error.message : String(error);
6519
+ return {
6520
+ success: false,
6521
+ activeProjectId: deps.getActiveProjectId(),
6522
+ activeWorkspaceId: deps.getActiveWorkspaceId(),
6523
+ note: `Could not resolve this project's workspace (${reason}), so the ` + `active context was left unchanged rather than half-written. ` + `Retry, or pass workspaceId explicitly to skip the lookup.`
6524
+ };
6525
+ }
6526
+ if (!owningWorkspaceId) {
6527
+ return {
6528
+ success: false,
6529
+ activeProjectId: deps.getActiveProjectId(),
6530
+ activeWorkspaceId: deps.getActiveWorkspaceId(),
6531
+ note: `Project ${projectId} is not in any workspace this connection ` + `can reach, so the active context was left unchanged. Check ` + `harmony_list_projects, pass workspaceId explicitly, or ` + `reconnect with /mcp if it lives in another workspace.`
6532
+ };
6533
+ }
6534
+ }
6535
+ deps.setActiveContext({ projectId, workspaceId: owningWorkspaceId });
6536
+ return {
6537
+ success: true,
6538
+ activeProjectId: projectId,
6539
+ activeWorkspaceId: owningWorkspaceId
6540
+ };
6326
6541
  }
6327
6542
  case "harmony_get_context": {
6543
+ const report = describeActiveContext({
6544
+ projectId: deps.getActiveProjectId(),
6545
+ workspaceId: deps.getActiveWorkspaceId()
6546
+ });
6328
6547
  return {
6329
6548
  success: true,
6330
6549
  context: {
6331
- activeWorkspaceId: deps.getActiveWorkspaceId(),
6332
- activeProjectId: deps.getActiveProjectId()
6550
+ activeWorkspaceId: report.workspaceId,
6551
+ activeProjectId: report.projectId,
6552
+ consistent: report.consistent,
6553
+ ...report.note ? { note: report.note } : {}
6333
6554
  }
6334
6555
  };
6335
6556
  }
@@ -6769,7 +6990,7 @@ ${options}
6769
6990
  if (trimmed.length > 0) {
6770
6991
  const touchIds = trimmed.map(({ entity }) => entity?.id).filter((id) => typeof id === "string");
6771
6992
  if (touchIds.length > 0) {
6772
- client3.batchTouchMemoryEntities(touchIds).catch(() => {});
6993
+ client3.batchTouchMemoryEntities(touchIds, "mcp-tool").catch(() => {});
6773
6994
  }
6774
6995
  }
6775
6996
  let sessionEntities = [];
@@ -7178,9 +7399,17 @@ ${options}
7178
7399
  workspaceId,
7179
7400
  name: name2,
7180
7401
  description: args.description,
7181
- steps: args.steps
7402
+ steps: args.steps,
7403
+ triggerType: args.triggerType,
7404
+ autoBind: args.autoBind,
7405
+ catalogId: args.catalogId
7182
7406
  });
7183
- return { success: true, playbook: result.playbook };
7407
+ const warnings = await collectPlaybookMetricWarnings(client3, workspaceId, args.steps);
7408
+ return {
7409
+ success: true,
7410
+ playbook: result.playbook,
7411
+ ...warnings.length > 0 ? { warnings } : {}
7412
+ };
7184
7413
  }
7185
7414
  case "harmony_update_playbook": {
7186
7415
  const playbookId = z.string().uuid().parse(args.playbookId);
@@ -7189,9 +7418,16 @@ ${options}
7189
7418
  description: args.description,
7190
7419
  steps: args.steps,
7191
7420
  enabled: args.enabled,
7192
- state: args.state
7421
+ state: args.state,
7422
+ triggerType: args.triggerType,
7423
+ ..."autoBind" in args ? { autoBind: args.autoBind } : {}
7193
7424
  });
7194
- return { success: true, playbook: result.playbook };
7425
+ const warnings = await collectPlaybookMetricWarnings(client3, result.playbook?.workspace_id, args.steps);
7426
+ return {
7427
+ success: true,
7428
+ playbook: result.playbook,
7429
+ ...warnings.length > 0 ? { warnings } : {}
7430
+ };
7195
7431
  }
7196
7432
  case "harmony_save_card_as_playbook":
7197
7433
  return deprecatedRemovedToolResult("harmony_save_card_as_playbook");
@@ -7266,8 +7502,10 @@ ${options}
7266
7502
  apiUrl: deps.getApiUrl()
7267
7503
  });
7268
7504
  deps.saveConfig({ apiKey: result.apiKey.rawKey });
7269
- deps.setActiveWorkspace(result.workspace.id);
7270
- deps.setActiveProject(result.project.id);
7505
+ deps.setActiveContext({
7506
+ projectId: result.project.id,
7507
+ workspaceId: result.workspace.id
7508
+ });
7271
7509
  deps.resetClient();
7272
7510
  return {
7273
7511
  success: true,
@@ -7289,7 +7527,7 @@ function createConfigDeps() {
7289
7527
  isConfigured,
7290
7528
  getActiveProjectId: () => getActiveProjectId(),
7291
7529
  getActiveWorkspaceId: () => getActiveWorkspaceId(),
7292
- setActiveProject: (id) => setActiveProject(id),
7530
+ setActiveContext: (context) => setActiveContext(context),
7293
7531
  setActiveWorkspace: (id) => setActiveWorkspace(id),
7294
7532
  getApiUrl,
7295
7533
  getMemoryDir: () => getMemoryDir(),