@gethmy/mcp 2.22.0 → 2.24.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/config.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
2
  import { homedir } from "node:os";
3
- import { join } from "node:path";
3
+ import { dirname, join, parse, resolve } from "node:path";
4
4
 
5
5
  export interface HarmonyConfig {
6
6
  apiKey: string | null;
@@ -45,6 +45,38 @@ export function getLocalConfigPath(cwd?: string): string {
45
45
  return join(cwd || process.cwd(), LOCAL_CONFIG_FILENAME);
46
46
  }
47
47
 
48
+ /**
49
+ * Find the nearest `.harmony-mcp.json` at or above `cwd` (card #893).
50
+ *
51
+ * The old behaviour looked in the exact cwd and nowhere else, so a server
52
+ * started from a package directory or a git worktree never saw the repo's pin
53
+ * and fell back to the global config **silently** — the active workspace then
54
+ * had nothing to do with the repo you were in.
55
+ *
56
+ * Two directories are deliberately skipped, however deep the walk goes:
57
+ *
58
+ * - **the home directory** — a stray file there would capture every session in
59
+ * every repo, and `~/.harmony-mcp/config.json` is already the global pin;
60
+ * - **the filesystem root** — same reasoning, machine-wide.
61
+ *
62
+ * Returns `null` when no ancestor carries one.
63
+ */
64
+ export function findLocalConfigPath(cwd?: string): string | null {
65
+ const home = resolve(homedir());
66
+ let dir = resolve(cwd || process.cwd());
67
+ const { root } = parse(dir);
68
+
69
+ for (;;) {
70
+ if (dir !== home && dir !== root) {
71
+ const candidate = join(dir, LOCAL_CONFIG_FILENAME);
72
+ if (existsSync(candidate)) return candidate;
73
+ }
74
+ const parent = dirname(dir);
75
+ if (parent === dir) return null;
76
+ dir = parent;
77
+ }
78
+ }
79
+
48
80
  function emptyConfig(): HarmonyConfig {
49
81
  return {
50
82
  apiKey: null,
@@ -107,9 +139,9 @@ export function saveConfig(config: Partial<HarmonyConfig>): void {
107
139
  }
108
140
 
109
141
  export function loadLocalConfig(cwd?: string): LocalConfig | null {
110
- const localConfigPath = getLocalConfigPath(cwd);
142
+ const localConfigPath = findLocalConfigPath(cwd);
111
143
 
112
- if (!existsSync(localConfigPath)) {
144
+ if (localConfigPath === null) {
113
145
  return null;
114
146
  }
115
147
 
@@ -129,7 +161,10 @@ export function saveLocalConfig(
129
161
  config: Partial<LocalConfig>,
130
162
  cwd?: string,
131
163
  ): void {
132
- const localConfigPath = getLocalConfigPath(cwd);
164
+ // Write back to the file we READ, not to the cwd: a `set_project_context`
165
+ // issued from a package directory must update the repo's pin rather than
166
+ // strand a second config file the repo root never looks at (card #893).
167
+ const localConfigPath = findLocalConfigPath(cwd) ?? getLocalConfigPath(cwd);
133
168
 
134
169
  const existingConfig = loadLocalConfig(cwd) || {
135
170
  workspaceId: null,
@@ -146,7 +181,9 @@ export function saveLocalConfig(
146
181
  }
147
182
 
148
183
  export function hasLocalConfig(cwd?: string): boolean {
149
- return existsSync(getLocalConfigPath(cwd));
184
+ // Same walk as `loadLocalConfig`, or this reports "no local config" for a
185
+ // session an ancestor file actually governs.
186
+ return findLocalConfigPath(cwd) !== null;
150
187
  }
151
188
 
152
189
  /**
@@ -185,48 +222,185 @@ export function setUserEmail(email: string | null): void {
185
222
  }
186
223
 
187
224
  export interface SetContextOptions {
225
+ /** Force a write to the repo-local `.harmony-mcp.json` (creating it). */
188
226
  local?: boolean;
227
+ /**
228
+ * Force a write to the global `~/.harmony-mcp/config.json`, even when a local
229
+ * file exists. Setup uses this to mirror the chosen context into the global
230
+ * default; without it the mirror would land back in the local file it just
231
+ * wrote, leaving every server started from another directory with no context.
232
+ */
233
+ global?: boolean;
189
234
  cwd?: string;
190
235
  }
191
236
 
192
- export function setActiveWorkspace(
193
- workspaceId: string | null,
237
+ /** The active context is ONE pair. A project always names its workspace. */
238
+ export interface ActiveContext {
239
+ projectId: string | null;
240
+ workspaceId: string | null;
241
+ }
242
+
243
+ /**
244
+ * Set the active project and workspace together (card #893).
245
+ *
246
+ * This is the ONLY way to set an active project, deliberately: the two ids used
247
+ * to be written by separate functions, so a project could end up active in a
248
+ * workspace it does not belong to. Every workspace-scoped tool then read the
249
+ * wrong workspace and said nothing — `harmony_list_playbook` returned 0 while
250
+ * the project's real workspace held 4.
251
+ *
252
+ * Callers always have both values: the `#shortId` resolver returns the card's
253
+ * project WITH its `workspaceId`, and `harmony_set_project_context` looks the
254
+ * project up before writing.
255
+ */
256
+ export function setActiveContext(
257
+ context: ActiveContext,
194
258
  options?: SetContextOptions,
195
259
  ): void {
196
- if (options?.local) {
197
- saveLocalConfig({ workspaceId }, options.cwd);
260
+ // WRITE WHERE THE READ RESOLVES. `getActiveProjectId` / `getActiveWorkspaceId`
261
+ // are local-first, so writing to the global config while a local file exists
262
+ // made the write invisible: `harmony_set_project_context` answered
263
+ // `success: true` and every later read still returned the local pin. Worse,
264
+ // a workspace switch then wrote `activeProjectId: null` into the GLOBAL file
265
+ // off a locally-read comparison, wiping the active project of every *other*
266
+ // repo that has no local pin.
267
+ //
268
+ // `options.local: true` forces a local write (setup creates the file before
269
+ // it exists) and `options.global: true` forces a global one (setup ALSO
270
+ // mirrors the choice into the global default, so a server started from
271
+ // another directory has a context at all). With neither, the presence of a
272
+ // local file decides.
273
+ if (options?.global) {
274
+ saveConfig({
275
+ activeWorkspaceId: context.workspaceId,
276
+ activeProjectId: context.projectId,
277
+ });
278
+ return;
279
+ }
280
+ const localPath = findLocalConfigPath(options?.cwd);
281
+ if (options?.local || localPath !== null) {
282
+ saveLocalConfig(
283
+ { workspaceId: context.workspaceId, projectId: context.projectId },
284
+ options?.cwd,
285
+ );
198
286
  } else {
199
- saveConfig({ activeWorkspaceId: workspaceId });
287
+ saveConfig({
288
+ activeWorkspaceId: context.workspaceId,
289
+ activeProjectId: context.projectId,
290
+ });
200
291
  }
201
292
  }
202
293
 
203
- export function setActiveProject(
204
- projectId: string | null,
294
+ /**
295
+ * Switch the active workspace.
296
+ *
297
+ * Switching to a DIFFERENT workspace clears the active project, because we
298
+ * cannot know whether that project lives in the new workspace — and assuming it
299
+ * does is exactly what produced the mismatch this function exists to prevent.
300
+ * Clearing fails safe: the next `#shortId` resolve re-establishes the pair.
301
+ * Re-setting the same workspace is a no-op for the project.
302
+ */
303
+ export function setActiveWorkspace(
304
+ workspaceId: string | null,
205
305
  options?: SetContextOptions,
206
306
  ): void {
207
- if (options?.local) {
208
- saveLocalConfig({ projectId }, options.cwd);
209
- } else {
210
- saveConfig({ activeProjectId: projectId });
211
- }
307
+ const currentWorkspaceId = getActiveWorkspaceId(options?.cwd);
308
+ const keepProject = currentWorkspaceId === workspaceId;
309
+ setActiveContext(
310
+ {
311
+ workspaceId,
312
+ projectId: keepProject ? getActiveProjectId(options?.cwd) : null,
313
+ },
314
+ options,
315
+ );
212
316
  }
213
317
 
214
- export function getActiveWorkspaceId(cwd?: string): string | null {
215
- // Local config takes precedence over global
318
+ /**
319
+ * The active pair, read from ONE source (card #893).
320
+ *
321
+ * A local `.harmony-mcp.json` wins **as a whole file**, not field by field. The
322
+ * two ids used to fall back to the global config independently, which quietly
323
+ * re-created the very mismatch this card removes: `saveLocalConfig` omits null
324
+ * values, so writing `projectId: null` locally did not clear the project — it
325
+ * unmasked the GLOBAL one and paired it with the local workspace. A workspace
326
+ * switch then produced `{new workspace, unrelated global project}` and
327
+ * `describeActiveContext` called it consistent, because both halves were
328
+ * non-null.
329
+ *
330
+ * So: local file present ⇒ both ids come from it, and a missing key means
331
+ * `null`, never "ask the global config". One source, one pair.
332
+ */
333
+ function readActiveContext(cwd?: string): ActiveContext {
216
334
  const localConfig = loadLocalConfig(cwd);
217
- if (localConfig?.workspaceId) {
218
- return localConfig.workspaceId;
335
+ if (localConfig) {
336
+ return {
337
+ workspaceId: localConfig.workspaceId ?? null,
338
+ projectId: localConfig.projectId ?? null,
339
+ };
219
340
  }
220
- return loadConfig().activeWorkspaceId;
341
+ const globalConfig = loadConfig();
342
+ return {
343
+ workspaceId: globalConfig.activeWorkspaceId,
344
+ projectId: globalConfig.activeProjectId,
345
+ };
346
+ }
347
+
348
+ export function getActiveWorkspaceId(cwd?: string): string | null {
349
+ return readActiveContext(cwd).workspaceId;
221
350
  }
222
351
 
223
352
  export function getActiveProjectId(cwd?: string): string | null {
224
- // Local config takes precedence over global
225
- const localConfig = loadLocalConfig(cwd);
226
- if (localConfig?.projectId) {
227
- return localConfig.projectId;
353
+ return readActiveContext(cwd).projectId;
354
+ }
355
+
356
+ /** The active pair plus whether it holds together. See {@link getActiveContext}. */
357
+ export interface ActiveContextReport extends ActiveContext {
358
+ consistent: boolean;
359
+ /** Human-readable reason when `consistent` is false; null otherwise. */
360
+ note: string | null;
361
+ }
362
+
363
+ /**
364
+ * Read the active pair and say whether it is coherent (card #893).
365
+ *
366
+ * `setActiveContext` keeps the pair whole from now on, but a config written
367
+ * before this shipped can still carry a project with no workspace. Report that
368
+ * instead of quietly resolving it — a silently wrong workspace is the failure
369
+ * mode this whole change exists to end.
370
+ */
371
+ export function getActiveContext(cwd?: string): ActiveContextReport {
372
+ return describeActiveContext({
373
+ projectId: getActiveProjectId(cwd),
374
+ workspaceId: getActiveWorkspaceId(cwd),
375
+ });
376
+ }
377
+
378
+ /**
379
+ * The consistency rule itself, pure and transport-independent.
380
+ *
381
+ * `harmony_get_context` calls this rather than re-deriving the rule inline: the
382
+ * remote transport holds its pair in memory, not in a config file, so the file
383
+ * reader above cannot serve it — but both must answer the same question the
384
+ * same way. One rule, two callers.
385
+ */
386
+ export function describeActiveContext(
387
+ context: ActiveContext,
388
+ ): ActiveContextReport {
389
+ const { projectId, workspaceId } = context;
390
+
391
+ if (projectId && !workspaceId) {
392
+ return {
393
+ projectId,
394
+ workspaceId,
395
+ consistent: false,
396
+ note:
397
+ `An active project (${projectId}) is set with no active workspace, so ` +
398
+ "workspace-scoped tools cannot resolve one from it. Re-set it with " +
399
+ "harmony_set_project_context, or pass workspaceId explicitly.",
400
+ };
228
401
  }
229
- return loadConfig().activeProjectId;
402
+
403
+ return { projectId, workspaceId, consistent: true, note: null };
230
404
  }
231
405
 
232
406
  export function isConfigured(): boolean {
package/src/remote.ts CHANGED
@@ -468,7 +468,7 @@ function baseDeps(
468
468
  | "getClient"
469
469
  | "getActiveProjectId"
470
470
  | "getActiveWorkspaceId"
471
- | "setActiveProject"
471
+ | "setActiveContext"
472
472
  | "setActiveWorkspace"
473
473
  > {
474
474
  return {
@@ -523,10 +523,21 @@ function createUserContext(apiKey: string, keyInfo: TokenInfo): UserContext {
523
523
  getClient: () => ctx.sweepClient,
524
524
  getActiveProjectId: () => ctx.activeProjectId,
525
525
  getActiveWorkspaceId: () => ctx.activeWorkspaceId,
526
- setActiveProject: (id) => {
527
- ctx.activeProjectId = id;
526
+ setActiveContext: (context) => {
527
+ // Assign UNCONDITIONALLY, null included. Skipping a null workspace here
528
+ // would leave the project active inside the PREVIOUS workspace — the
529
+ // exact mismatch #893 removes — while the tool's reply claims it was
530
+ // cleared, and `harmony_get_context` calls the pair consistent because
531
+ // both halves are non-null.
532
+ ctx.activeProjectId = context.projectId;
533
+ ctx.activeWorkspaceId = context.workspaceId;
534
+ // A cleared workspace must NOT stay pinned, or the request-scoped
535
+ // fallback to the grant's own workspace can never take over again.
536
+ ctx.workspacePinned = Boolean(context.workspaceId);
528
537
  },
529
538
  setActiveWorkspace: (id) => {
539
+ // Switching workspace drops a project we cannot vouch for (#893).
540
+ if (ctx.activeWorkspaceId !== id) ctx.activeProjectId = null;
530
541
  ctx.activeWorkspaceId = id;
531
542
  ctx.workspacePinned = true;
532
543
  },
@@ -622,11 +633,28 @@ function buildRequestScope(
622
633
  getActiveWorkspaceId: () => view.workspaceId,
623
634
  // Write through: the snapshot keeps this request consistent, the context
624
635
  // carries the selection to the next one.
625
- setActiveProject: (id) => {
626
- view.projectId = id;
627
- ctx.activeProjectId = id;
636
+ setActiveContext: (context) => {
637
+ view.projectId = context.projectId;
638
+ ctx.activeProjectId = context.projectId;
639
+ // The project's own workspace pins the scope too — carrying the project
640
+ // without it is what let the two drift apart (#893). A NULL workspace is
641
+ // written through as well: ignoring it would silently keep the previous
642
+ // workspace under the new project, which is the mismatch itself, and the
643
+ // caller has already been told the workspace was cleared.
644
+ view.workspaceId = context.workspaceId;
645
+ ctx.activeWorkspaceId = context.workspaceId;
646
+ // Unpin on a clear, so the next request falls back to the grant's own
647
+ // workspace instead of being stuck on a null it can never leave.
648
+ ctx.workspacePinned = Boolean(context.workspaceId);
628
649
  },
629
650
  setActiveWorkspace: (id) => {
651
+ // Switching workspace drops a project we cannot vouch for (#893): we
652
+ // don't know whether it lives in the new one, and assuming it does is
653
+ // precisely the mismatch this guards against.
654
+ if (ctx.activeWorkspaceId !== id) {
655
+ view.projectId = null;
656
+ ctx.activeProjectId = null;
657
+ }
630
658
  view.workspaceId = id;
631
659
  ctx.activeWorkspaceId = id;
632
660
  // An explicit choice outranks the token's primary workspace from here on.
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 {
@@ -305,7 +306,16 @@ export interface ToolDeps {
305
306
  isConfigured: () => boolean;
306
307
  getActiveProjectId: () => string | null;
307
308
  getActiveWorkspaceId: () => string | null;
308
- setActiveProject: (id: string | null) => void;
309
+ /**
310
+ * Set the active project and its workspace TOGETHER (card #893). There is no
311
+ * project-only setter on purpose: writing the two independently is what let a
312
+ * project go active in a workspace it does not belong to, after which every
313
+ * workspace-scoped tool read the wrong workspace silently.
314
+ */
315
+ setActiveContext: (context: {
316
+ projectId: string | null;
317
+ workspaceId: string | null;
318
+ }) => void;
309
319
  setActiveWorkspace: (id: string | null) => void;
310
320
  getApiUrl: () => string;
311
321
  getMemoryDir: () => string | null;
@@ -1469,11 +1479,17 @@ export const TOOLS = {
1469
1479
  },
1470
1480
  },
1471
1481
  harmony_set_project_context: {
1472
- description: "Set the active project context for subsequent operations",
1482
+ description:
1483
+ "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
1484
  inputSchema: {
1474
1485
  type: "object",
1475
1486
  properties: {
1476
1487
  projectId: { type: "string" },
1488
+ workspaceId: {
1489
+ type: "string",
1490
+ description:
1491
+ "The workspace this project belongs to. Optional — looked up when omitted.",
1492
+ },
1477
1493
  },
1478
1494
  required: ["projectId"],
1479
1495
  },
@@ -1550,6 +1566,12 @@ export const TOOLS = {
1550
1566
  description:
1551
1567
  "Set true only if this session will poll harmony_get_pending_messages at its checkpoints. Enables the live steering composer for the run; leave unset/false if you won't consume steering messages.",
1552
1568
  },
1569
+ driver: {
1570
+ type: "string",
1571
+ enum: ["daemon", "interactive", "script"],
1572
+ description:
1573
+ 'Who is calling: the agent daemon, a human-driven interactive session, or an automation script. Names you as the holder if another caller hits a 409 on this card. Defaults to "interactive".',
1574
+ },
1553
1575
  },
1554
1576
  required: ["cardId", "agentIdentifier", "agentName"],
1555
1577
  },
@@ -2358,6 +2380,22 @@ export const TOOLS = {
2358
2380
  description: "The playbook's ordered stage objects.",
2359
2381
  items: { type: "object" },
2360
2382
  },
2383
+ triggerType: {
2384
+ type: "string",
2385
+ enum: ["manual", "auto"],
2386
+ description:
2387
+ "'manual' (default) — the playbook is applied by a person. 'auto' — it claims matching cards itself, and requires autoBind.",
2388
+ },
2389
+ autoBind: {
2390
+ type: "object",
2391
+ description:
2392
+ "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.",
2393
+ },
2394
+ catalogId: {
2395
+ type: "string",
2396
+ description:
2397
+ "Slug of the built-in template this came from (provenance only; never used to match).",
2398
+ },
2361
2399
  },
2362
2400
  required: ["name"],
2363
2401
  },
@@ -2365,7 +2403,7 @@ export const TOOLS = {
2365
2403
 
2366
2404
  harmony_update_playbook: {
2367
2405
  description:
2368
- "Update a playbook's name, description, steps/stages, enabled flag, or lifecycle state ('active'|'deprecated').",
2406
+ "Update a playbook's name, description, steps/stages, enabled flag, lifecycle state ('active'|'deprecated'), or its auto-bind rule and arming.",
2369
2407
  inputSchema: {
2370
2408
  type: "object",
2371
2409
  properties: {
@@ -2389,6 +2427,17 @@ export const TOOLS = {
2389
2427
  enum: ["active", "deprecated"],
2390
2428
  description: "Lifecycle state",
2391
2429
  },
2430
+ triggerType: {
2431
+ type: "string",
2432
+ enum: ["manual", "auto"],
2433
+ description:
2434
+ "Arm ('auto') or disarm ('manual') automatic application. Arming requires a rule to be present or supplied in the same call.",
2435
+ },
2436
+ autoBind: {
2437
+ type: "object",
2438
+ description:
2439
+ "Replace the auto-bind rule: {priority?, mode?: 'all'|'any', when: [{path, op, value}]}. Pass null to remove it.",
2440
+ },
2392
2441
  },
2393
2442
  required: ["playbookId"],
2394
2443
  },
@@ -3101,7 +3150,13 @@ async function handleToolCall(
3101
3150
  // wrong-context resolve is visible immediately.
3102
3151
  const established = activeProjectId == null;
3103
3152
  if (established) {
3104
- deps.setActiveProject(resolved.project.id);
3153
+ // The pair, never the project alone (#893): `resolved.project`
3154
+ // carries its `workspaceId`, and discarding it here is what left
3155
+ // the active workspace pointing somewhere unrelated.
3156
+ deps.setActiveContext({
3157
+ projectId: resolved.project.id,
3158
+ workspaceId: resolved.project.workspaceId,
3159
+ });
3105
3160
  }
3106
3161
  return {
3107
3162
  success: true,
@@ -3784,16 +3839,97 @@ async function handleToolCall(
3784
3839
 
3785
3840
  case "harmony_set_project_context": {
3786
3841
  const projectId = z.string().uuid().parse(args.projectId);
3787
- deps.setActiveProject(projectId);
3788
- return { success: true, activeProjectId: projectId };
3842
+
3843
+ // Find the project's OWN workspace rather than keeping whichever one
3844
+ // happened to be active (#893). The two ids are one pair; setting half of
3845
+ // it is how a project ended up active in a foreign workspace, after which
3846
+ // every workspace-scoped tool read the wrong one and said nothing.
3847
+ //
3848
+ // An explicit `workspaceId` skips the lookup. Otherwise we search the
3849
+ // reachable workspaces, and REFUSE the whole call if that search cannot
3850
+ // name one — we never write half a pair.
3851
+ //
3852
+ // Refusing beats the two alternatives. Keeping the previous workspace is
3853
+ // the mismatch itself. Setting the project with a NULL workspace looks
3854
+ // safe but is not: on the remote transport an unpinned workspace falls
3855
+ // back to the grant's own, so the project would end up active inside it
3856
+ // anyway, and on stdio the write lands in a config file that outlives the
3857
+ // session. A project this connection cannot place is useless as context.
3858
+ const explicitWorkspaceId = args.workspaceId
3859
+ ? z.string().uuid().parse(args.workspaceId)
3860
+ : null;
3861
+
3862
+ let owningWorkspaceId: string | null = explicitWorkspaceId;
3863
+
3864
+ if (!owningWorkspaceId) {
3865
+ try {
3866
+ const { workspaces } = await client.listWorkspaces();
3867
+ for (const workspace of workspaces as { id?: string }[]) {
3868
+ if (!workspace?.id) continue;
3869
+ const { projects } = await client.listProjects(workspace.id);
3870
+ if (
3871
+ (projects as { id?: string }[]).some((p) => p?.id === projectId)
3872
+ ) {
3873
+ owningWorkspaceId = workspace.id;
3874
+ break;
3875
+ }
3876
+ }
3877
+ } catch (error) {
3878
+ // A timeout or a 500 says nothing about where the project lives.
3879
+ // Leave the pair untouched and say so.
3880
+ const reason = error instanceof Error ? error.message : String(error);
3881
+ return {
3882
+ success: false,
3883
+ activeProjectId: deps.getActiveProjectId(),
3884
+ activeWorkspaceId: deps.getActiveWorkspaceId(),
3885
+ note:
3886
+ `Could not resolve this project's workspace (${reason}), so the ` +
3887
+ `active context was left unchanged rather than half-written. ` +
3888
+ `Retry, or pass workspaceId explicitly to skip the lookup.`,
3889
+ };
3890
+ }
3891
+
3892
+ if (!owningWorkspaceId) {
3893
+ // Definitive: the listing succeeded and no reachable workspace holds
3894
+ // this project. Setting it would produce a context whose every
3895
+ // workspace-scoped call is wrong.
3896
+ return {
3897
+ success: false,
3898
+ activeProjectId: deps.getActiveProjectId(),
3899
+ activeWorkspaceId: deps.getActiveWorkspaceId(),
3900
+ note:
3901
+ `Project ${projectId} is not in any workspace this connection ` +
3902
+ `can reach, so the active context was left unchanged. Check ` +
3903
+ `harmony_list_projects, pass workspaceId explicitly, or ` +
3904
+ `reconnect with /mcp if it lives in another workspace.`,
3905
+ };
3906
+ }
3907
+ }
3908
+
3909
+ deps.setActiveContext({ projectId, workspaceId: owningWorkspaceId });
3910
+ return {
3911
+ success: true,
3912
+ activeProjectId: projectId,
3913
+ activeWorkspaceId: owningWorkspaceId,
3914
+ };
3789
3915
  }
3790
3916
 
3791
3917
  case "harmony_get_context": {
3918
+ // The rule lives in `describeActiveContext` and is called, not re-derived:
3919
+ // a second copy of an invariant is how the two halves drifted apart in
3920
+ // the first place. A project with no workspace still arrives from a
3921
+ // config written before #893.
3922
+ const report = describeActiveContext({
3923
+ projectId: deps.getActiveProjectId(),
3924
+ workspaceId: deps.getActiveWorkspaceId(),
3925
+ });
3792
3926
  return {
3793
3927
  success: true,
3794
3928
  context: {
3795
- activeWorkspaceId: deps.getActiveWorkspaceId(),
3796
- activeProjectId: deps.getActiveProjectId(),
3929
+ activeWorkspaceId: report.workspaceId,
3930
+ activeProjectId: report.projectId,
3931
+ consistent: report.consistent,
3932
+ ...(report.note ? { note: report.note } : {}),
3797
3933
  },
3798
3934
  };
3799
3935
  }
@@ -3920,6 +4056,11 @@ async function handleToolCall(
3920
4056
  args.steerable === true || args.steerable === "true"
3921
4057
  ? true
3922
4058
  : undefined,
4059
+ // Every MCP caller is interactive unless it says otherwise — this is
4060
+ // the human-driven `/hmy` surface, not the daemon or the e2e script.
4061
+ driver:
4062
+ (args.driver as "daemon" | "interactive" | "script" | undefined) ??
4063
+ "interactive",
3923
4064
  });
3924
4065
 
3925
4066
  // Mark as explicit so auto-session won't interfere
@@ -5222,6 +5363,12 @@ async function handleToolCall(
5222
5363
  name,
5223
5364
  description: args.description as string | undefined,
5224
5365
  steps: args.steps,
5366
+ // Auto-bind fields ride through untouched (#892) — the edge function
5367
+ // owns validation, so the rule cannot be shaped here and rejected
5368
+ // there, or vice versa.
5369
+ triggerType: args.triggerType as string | undefined,
5370
+ autoBind: args.autoBind,
5371
+ catalogId: args.catalogId as string | undefined,
5225
5372
  });
5226
5373
  return { success: true, playbook: result.playbook };
5227
5374
  }
@@ -5234,6 +5381,10 @@ async function handleToolCall(
5234
5381
  steps: args.steps,
5235
5382
  enabled: args.enabled as boolean | undefined,
5236
5383
  state: args.state as string | undefined,
5384
+ // Arming and the rule (#892). `autoBind: null` clears the rule, so the
5385
+ // key is forwarded whenever present rather than only when truthy.
5386
+ triggerType: args.triggerType as string | undefined,
5387
+ ...("autoBind" in args ? { autoBind: args.autoBind } : {}),
5237
5388
  });
5238
5389
  return { success: true, playbook: result.playbook };
5239
5390
  }
@@ -5327,8 +5478,10 @@ async function handleToolCall(
5327
5478
 
5328
5479
  // Save config and reset client
5329
5480
  deps.saveConfig({ apiKey: result.apiKey.rawKey });
5330
- deps.setActiveWorkspace(result.workspace.id);
5331
- deps.setActiveProject(result.project.id);
5481
+ deps.setActiveContext({
5482
+ projectId: result.project.id,
5483
+ workspaceId: result.workspace.id,
5484
+ });
5332
5485
  deps.resetClient();
5333
5486
 
5334
5487
  return {
@@ -5354,7 +5507,7 @@ function createConfigDeps(): ToolDeps {
5354
5507
  isConfigured,
5355
5508
  getActiveProjectId: () => getActiveProjectId(),
5356
5509
  getActiveWorkspaceId: () => getActiveWorkspaceId(),
5357
- setActiveProject: (id) => setActiveProject(id),
5510
+ setActiveContext: (context) => setActiveContext(context),
5358
5511
  setActiveWorkspace: (id) => setActiveWorkspace(id),
5359
5512
  getApiUrl,
5360
5513
  getMemoryDir: () => getMemoryDir(),