@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/src/server.ts CHANGED
@@ -29,6 +29,7 @@ import {
29
29
  untrack,
30
30
  } from "./auto-session.js";
31
31
  import {
32
+ describeActiveContext,
32
33
  getActiveProjectId,
33
34
  getActiveWorkspaceId,
34
35
  getApiUrl,
@@ -36,7 +37,7 @@ import {
36
37
  getUserEmail,
37
38
  isConfigured,
38
39
  saveConfig,
39
- setActiveProject,
40
+ setActiveContext,
40
41
  setActiveWorkspace,
41
42
  } from "./config.js";
42
43
  import {
@@ -68,6 +69,7 @@ import {
68
69
  } from "./memory-session.js";
69
70
  import { lintTags, normalizeTags } from "./memory-tags.js";
70
71
  import { onboardNewUser } from "./onboard.js";
72
+ import { collectPlaybookMetricWarnings } from "./playbook-metric-warnings.js";
71
73
  import { stripSkillPreamble } from "./skills.js";
72
74
 
73
75
  // --- Signed-upload handshake (artifacts & card attachments) ---
@@ -305,7 +307,16 @@ export interface ToolDeps {
305
307
  isConfigured: () => boolean;
306
308
  getActiveProjectId: () => string | null;
307
309
  getActiveWorkspaceId: () => string | null;
308
- setActiveProject: (id: string | null) => void;
310
+ /**
311
+ * Set the active project and its workspace TOGETHER (card #893). There is no
312
+ * project-only setter on purpose: writing the two independently is what let a
313
+ * project go active in a workspace it does not belong to, after which every
314
+ * workspace-scoped tool read the wrong workspace silently.
315
+ */
316
+ setActiveContext: (context: {
317
+ projectId: string | null;
318
+ workspaceId: string | null;
319
+ }) => void;
309
320
  setActiveWorkspace: (id: string | null) => void;
310
321
  getApiUrl: () => string;
311
322
  getMemoryDir: () => string | null;
@@ -1196,7 +1207,7 @@ export const TOOLS = {
1196
1207
  },
1197
1208
  harmony_classify_card: {
1198
1209
  description:
1199
- "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`.",
1210
+ "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`.",
1200
1211
  inputSchema: {
1201
1212
  type: "object",
1202
1213
  properties: {
@@ -1469,11 +1480,17 @@ export const TOOLS = {
1469
1480
  },
1470
1481
  },
1471
1482
  harmony_set_project_context: {
1472
- description: "Set the active project context for subsequent operations",
1483
+ description:
1484
+ "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.",
1473
1485
  inputSchema: {
1474
1486
  type: "object",
1475
1487
  properties: {
1476
1488
  projectId: { type: "string" },
1489
+ workspaceId: {
1490
+ type: "string",
1491
+ description:
1492
+ "The workspace this project belongs to. Optional — looked up when omitted.",
1493
+ },
1477
1494
  },
1478
1495
  required: ["projectId"],
1479
1496
  },
@@ -2364,6 +2381,22 @@ export const TOOLS = {
2364
2381
  description: "The playbook's ordered stage objects.",
2365
2382
  items: { type: "object" },
2366
2383
  },
2384
+ triggerType: {
2385
+ type: "string",
2386
+ enum: ["manual", "auto"],
2387
+ description:
2388
+ "'manual' (default) — the playbook is applied by a person. 'auto' — it claims matching cards itself, and requires autoBind.",
2389
+ },
2390
+ autoBind: {
2391
+ type: "object",
2392
+ description:
2393
+ "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.",
2394
+ },
2395
+ catalogId: {
2396
+ type: "string",
2397
+ description:
2398
+ "Slug of the built-in template this came from (provenance only; never used to match).",
2399
+ },
2367
2400
  },
2368
2401
  required: ["name"],
2369
2402
  },
@@ -2371,7 +2404,7 @@ export const TOOLS = {
2371
2404
 
2372
2405
  harmony_update_playbook: {
2373
2406
  description:
2374
- "Update a playbook's name, description, steps/stages, enabled flag, or lifecycle state ('active'|'deprecated').",
2407
+ "Update a playbook's name, description, steps/stages, enabled flag, lifecycle state ('active'|'deprecated'), or its auto-bind rule and arming.",
2375
2408
  inputSchema: {
2376
2409
  type: "object",
2377
2410
  properties: {
@@ -2395,6 +2428,17 @@ export const TOOLS = {
2395
2428
  enum: ["active", "deprecated"],
2396
2429
  description: "Lifecycle state",
2397
2430
  },
2431
+ triggerType: {
2432
+ type: "string",
2433
+ enum: ["manual", "auto"],
2434
+ description:
2435
+ "Arm ('auto') or disarm ('manual') automatic application. Arming requires a rule to be present or supplied in the same call.",
2436
+ },
2437
+ autoBind: {
2438
+ type: "object",
2439
+ description:
2440
+ "Replace the auto-bind rule: {priority?, mode?: 'all'|'any', when: [{path, op, value}]}. Pass null to remove it.",
2441
+ },
2398
2442
  },
2399
2443
  required: ["playbookId"],
2400
2444
  },
@@ -3107,7 +3151,13 @@ async function handleToolCall(
3107
3151
  // wrong-context resolve is visible immediately.
3108
3152
  const established = activeProjectId == null;
3109
3153
  if (established) {
3110
- deps.setActiveProject(resolved.project.id);
3154
+ // The pair, never the project alone (#893): `resolved.project`
3155
+ // carries its `workspaceId`, and discarding it here is what left
3156
+ // the active workspace pointing somewhere unrelated.
3157
+ deps.setActiveContext({
3158
+ projectId: resolved.project.id,
3159
+ workspaceId: resolved.project.workspaceId,
3160
+ });
3111
3161
  }
3112
3162
  return {
3113
3163
  success: true,
@@ -3790,16 +3840,97 @@ async function handleToolCall(
3790
3840
 
3791
3841
  case "harmony_set_project_context": {
3792
3842
  const projectId = z.string().uuid().parse(args.projectId);
3793
- deps.setActiveProject(projectId);
3794
- return { success: true, activeProjectId: projectId };
3843
+
3844
+ // Find the project's OWN workspace rather than keeping whichever one
3845
+ // happened to be active (#893). The two ids are one pair; setting half of
3846
+ // it is how a project ended up active in a foreign workspace, after which
3847
+ // every workspace-scoped tool read the wrong one and said nothing.
3848
+ //
3849
+ // An explicit `workspaceId` skips the lookup. Otherwise we search the
3850
+ // reachable workspaces, and REFUSE the whole call if that search cannot
3851
+ // name one — we never write half a pair.
3852
+ //
3853
+ // Refusing beats the two alternatives. Keeping the previous workspace is
3854
+ // the mismatch itself. Setting the project with a NULL workspace looks
3855
+ // safe but is not: on the remote transport an unpinned workspace falls
3856
+ // back to the grant's own, so the project would end up active inside it
3857
+ // anyway, and on stdio the write lands in a config file that outlives the
3858
+ // session. A project this connection cannot place is useless as context.
3859
+ const explicitWorkspaceId = args.workspaceId
3860
+ ? z.string().uuid().parse(args.workspaceId)
3861
+ : null;
3862
+
3863
+ let owningWorkspaceId: string | null = explicitWorkspaceId;
3864
+
3865
+ if (!owningWorkspaceId) {
3866
+ try {
3867
+ const { workspaces } = await client.listWorkspaces();
3868
+ for (const workspace of workspaces as { id?: string }[]) {
3869
+ if (!workspace?.id) continue;
3870
+ const { projects } = await client.listProjects(workspace.id);
3871
+ if (
3872
+ (projects as { id?: string }[]).some((p) => p?.id === projectId)
3873
+ ) {
3874
+ owningWorkspaceId = workspace.id;
3875
+ break;
3876
+ }
3877
+ }
3878
+ } catch (error) {
3879
+ // A timeout or a 500 says nothing about where the project lives.
3880
+ // Leave the pair untouched and say so.
3881
+ const reason = error instanceof Error ? error.message : String(error);
3882
+ return {
3883
+ success: false,
3884
+ activeProjectId: deps.getActiveProjectId(),
3885
+ activeWorkspaceId: deps.getActiveWorkspaceId(),
3886
+ note:
3887
+ `Could not resolve this project's workspace (${reason}), so the ` +
3888
+ `active context was left unchanged rather than half-written. ` +
3889
+ `Retry, or pass workspaceId explicitly to skip the lookup.`,
3890
+ };
3891
+ }
3892
+
3893
+ if (!owningWorkspaceId) {
3894
+ // Definitive: the listing succeeded and no reachable workspace holds
3895
+ // this project. Setting it would produce a context whose every
3896
+ // workspace-scoped call is wrong.
3897
+ return {
3898
+ success: false,
3899
+ activeProjectId: deps.getActiveProjectId(),
3900
+ activeWorkspaceId: deps.getActiveWorkspaceId(),
3901
+ note:
3902
+ `Project ${projectId} is not in any workspace this connection ` +
3903
+ `can reach, so the active context was left unchanged. Check ` +
3904
+ `harmony_list_projects, pass workspaceId explicitly, or ` +
3905
+ `reconnect with /mcp if it lives in another workspace.`,
3906
+ };
3907
+ }
3908
+ }
3909
+
3910
+ deps.setActiveContext({ projectId, workspaceId: owningWorkspaceId });
3911
+ return {
3912
+ success: true,
3913
+ activeProjectId: projectId,
3914
+ activeWorkspaceId: owningWorkspaceId,
3915
+ };
3795
3916
  }
3796
3917
 
3797
3918
  case "harmony_get_context": {
3919
+ // The rule lives in `describeActiveContext` and is called, not re-derived:
3920
+ // a second copy of an invariant is how the two halves drifted apart in
3921
+ // the first place. A project with no workspace still arrives from a
3922
+ // config written before #893.
3923
+ const report = describeActiveContext({
3924
+ projectId: deps.getActiveProjectId(),
3925
+ workspaceId: deps.getActiveWorkspaceId(),
3926
+ });
3798
3927
  return {
3799
3928
  success: true,
3800
3929
  context: {
3801
- activeWorkspaceId: deps.getActiveWorkspaceId(),
3802
- activeProjectId: deps.getActiveProjectId(),
3930
+ activeWorkspaceId: report.workspaceId,
3931
+ activeProjectId: report.projectId,
3932
+ consistent: report.consistent,
3933
+ ...(report.note ? { note: report.note } : {}),
3803
3934
  },
3804
3935
  };
3805
3936
  }
@@ -4550,6 +4681,12 @@ async function handleToolCall(
4550
4681
  : undefined,
4551
4682
  include_superseded: includeSuperseded,
4552
4683
  include_episodes: includeEpisodes,
4684
+ // No consumer here on purpose (fix round 1, task 4): this fetches
4685
+ // an over-fetched candidate pool (up to `fetchLimit`, itself
4686
+ // padded above `requestedLimit`/`topK`) for Park to rescore, not
4687
+ // what the tool actually returns. Recording it would overstate
4688
+ // access_count by up to fetchLimit/finalCount per call. What we
4689
+ // actually deliver is recorded below, after trimming.
4553
4690
  },
4554
4691
  );
4555
4692
  entities = (searchResult.entities ?? []) as any[];
@@ -4611,19 +4748,19 @@ async function handleToolCall(
4611
4748
  trimmed = fitToBudget(trimmed, budgetTokens);
4612
4749
  }
4613
4750
 
4614
- // Touch access counters for the entities we returned (#273, task 4).
4615
- // Previously this wrote `metadata._last_recall` via updateMemoryEntity,
4616
- // which left `access_count` at 0 forever and never updated
4617
- // `last_accessed_at` the column the Park recency term actually reads.
4618
- // Route through batch-touch, which calls the atomic
4619
- // `batch_touch_knowledge_entities` RPC (access_count += 1, last_accessed_at
4620
- // = now) in a single round-trip. Non-blocking; best-effort.
4751
+ // Read accounting (task 4, fix round 1): record only the entities
4752
+ // actually delivered to the caller — after trimming and the budget
4753
+ // pass, not the larger over-fetched candidate pool the search above
4754
+ // deliberately left unrecorded. Routed through batch-touch, which
4755
+ // dispatches to the same recordEntityReads/record_entity_reads path
4756
+ // the search chokepoint uses when a consumer is given. Non-blocking;
4757
+ // best-effort.
4621
4758
  if (trimmed.length > 0) {
4622
4759
  const touchIds = trimmed
4623
4760
  .map(({ entity }: { entity: any }) => entity?.id)
4624
4761
  .filter((id: unknown): id is string => typeof id === "string");
4625
4762
  if (touchIds.length > 0) {
4626
- client.batchTouchMemoryEntities(touchIds).catch(() => {});
4763
+ client.batchTouchMemoryEntities(touchIds, "mcp-tool").catch(() => {});
4627
4764
  }
4628
4765
  }
4629
4766
 
@@ -5233,8 +5370,25 @@ async function handleToolCall(
5233
5370
  name,
5234
5371
  description: args.description as string | undefined,
5235
5372
  steps: args.steps,
5373
+ // Auto-bind fields ride through untouched (#892) — the edge function
5374
+ // owns validation, so the rule cannot be shaped here and rejected
5375
+ // there, or vice versa.
5376
+ triggerType: args.triggerType as string | undefined,
5377
+ autoBind: args.autoBind,
5378
+ catalogId: args.catalogId as string | undefined,
5236
5379
  });
5237
- return { success: true, playbook: result.playbook };
5380
+ // #922: surface custom-gate metrics no agent declares — non-blocking,
5381
+ // the playbook is already created.
5382
+ const warnings = await collectPlaybookMetricWarnings(
5383
+ client,
5384
+ workspaceId,
5385
+ args.steps,
5386
+ );
5387
+ return {
5388
+ success: true,
5389
+ playbook: result.playbook,
5390
+ ...(warnings.length > 0 ? { warnings } : {}),
5391
+ };
5238
5392
  }
5239
5393
 
5240
5394
  case "harmony_update_playbook": {
@@ -5245,8 +5399,24 @@ async function handleToolCall(
5245
5399
  steps: args.steps,
5246
5400
  enabled: args.enabled as boolean | undefined,
5247
5401
  state: args.state as string | undefined,
5402
+ // Arming and the rule (#892). `autoBind: null` clears the rule, so the
5403
+ // key is forwarded whenever present rather than only when truthy.
5404
+ triggerType: args.triggerType as string | undefined,
5405
+ ...("autoBind" in args ? { autoBind: args.autoBind } : {}),
5248
5406
  });
5249
- return { success: true, playbook: result.playbook };
5407
+ // #922: same non-blocking metric check as create. The playbook's own
5408
+ // workspace scopes the lookup — the active context may point elsewhere.
5409
+ const warnings = await collectPlaybookMetricWarnings(
5410
+ client,
5411
+ (result.playbook as { workspace_id?: string } | undefined)
5412
+ ?.workspace_id,
5413
+ args.steps,
5414
+ );
5415
+ return {
5416
+ success: true,
5417
+ playbook: result.playbook,
5418
+ ...(warnings.length > 0 ? { warnings } : {}),
5419
+ };
5250
5420
  }
5251
5421
 
5252
5422
  // Deprecated (#612) — see harmony_run_playbook above.
@@ -5338,8 +5508,10 @@ async function handleToolCall(
5338
5508
 
5339
5509
  // Save config and reset client
5340
5510
  deps.saveConfig({ apiKey: result.apiKey.rawKey });
5341
- deps.setActiveWorkspace(result.workspace.id);
5342
- deps.setActiveProject(result.project.id);
5511
+ deps.setActiveContext({
5512
+ projectId: result.project.id,
5513
+ workspaceId: result.workspace.id,
5514
+ });
5343
5515
  deps.resetClient();
5344
5516
 
5345
5517
  return {
@@ -5365,7 +5537,7 @@ function createConfigDeps(): ToolDeps {
5365
5537
  isConfigured,
5366
5538
  getActiveProjectId: () => getActiveProjectId(),
5367
5539
  getActiveWorkspaceId: () => getActiveWorkspaceId(),
5368
- setActiveProject: (id) => setActiveProject(id),
5540
+ setActiveContext: (context) => setActiveContext(context),
5369
5541
  setActiveWorkspace: (id) => setActiveWorkspace(id),
5370
5542
  getApiUrl,
5371
5543
  getMemoryDir: () => getMemoryDir(),
package/src/tui/setup.ts CHANGED
@@ -19,8 +19,7 @@ import {
19
19
  loadConfig,
20
20
  saveConfig,
21
21
  saveLocalConfig,
22
- setActiveProject,
23
- setActiveWorkspace,
22
+ setActiveContext,
24
23
  } from "../config.js";
25
24
  import { loginWithBrowser, type OAuthTokens } from "../oauth-login.js";
26
25
  import { onboardNewUser } from "../onboard.js";
@@ -960,8 +959,10 @@ export async function runSetup(options: SetupOptions = {}): Promise<void> {
960
959
 
961
960
  // Save config immediately
962
961
  saveConfig({ apiKey, userEmail, apiUrl: API_URL });
963
- setActiveWorkspace(selectedWorkspaceIdFromSignup);
964
- setActiveProject(selectedProjectIdFromSignup);
962
+ setActiveContext({
963
+ workspaceId: selectedWorkspaceIdFromSignup,
964
+ projectId: selectedProjectIdFromSignup,
965
+ });
965
966
 
966
967
  p.log.success("Workspace and board created");
967
968
  } catch (error) {
@@ -1563,8 +1564,22 @@ export async function runSetup(options: SetupOptions = {}): Promise<void> {
1563
1564
  ` ${colors.success("\u2713")} ${colors.dim(formatPath(getLocalConfigPath(cwd), home))} ${colors.dim("(created)")}`,
1564
1565
  );
1565
1566
 
1566
- if (selectedWorkspaceId) setActiveWorkspace(selectedWorkspaceId);
1567
- if (selectedProjectId) setActiveProject(selectedProjectId);
1567
+ // Mirror the choice into the GLOBAL default, explicitly (#893). The local
1568
+ // file above already pins this directory; this second write is what gives a
1569
+ // server started elsewhere — Claude Desktop, another repo — a context at
1570
+ // all. It must say `global: true`: without it the write would find the file
1571
+ // just created one line up and land back in it, and the global default
1572
+ // would stay empty. One pair either way — a project is never set without
1573
+ // its workspace.
1574
+ if (selectedWorkspaceId || selectedProjectId) {
1575
+ setActiveContext(
1576
+ {
1577
+ workspaceId: selectedWorkspaceId ?? null,
1578
+ projectId: selectedProjectId ?? null,
1579
+ },
1580
+ { global: true },
1581
+ );
1582
+ }
1568
1583
  }
1569
1584
 
1570
1585
  // Step 10: Show completion message