@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/README.md +14 -14
- package/dist/cli.js +216 -70
- package/dist/index.js +181 -45
- package/dist/lib/api-client.js +78 -26
- package/dist/lib/config.js +79 -24
- package/dist/lib/oauth-refresh.js +76 -24
- package/package.json +2 -2
- package/src/api-client.ts +15 -0
- package/src/cli.ts +25 -14
- package/src/config.ts +201 -27
- package/src/remote.ts +34 -6
- package/src/server.ts +165 -12
- package/src/tui/setup.ts +21 -6
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 =
|
|
142
|
+
const localConfigPath = findLocalConfigPath(cwd);
|
|
111
143
|
|
|
112
|
-
if (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
193
|
-
|
|
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
|
-
|
|
197
|
-
|
|
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({
|
|
287
|
+
saveConfig({
|
|
288
|
+
activeWorkspaceId: context.workspaceId,
|
|
289
|
+
activeProjectId: context.projectId,
|
|
290
|
+
});
|
|
200
291
|
}
|
|
201
292
|
}
|
|
202
293
|
|
|
203
|
-
|
|
204
|
-
|
|
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
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
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
|
-
|
|
215
|
-
|
|
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
|
|
218
|
-
return
|
|
335
|
+
if (localConfig) {
|
|
336
|
+
return {
|
|
337
|
+
workspaceId: localConfig.workspaceId ?? null,
|
|
338
|
+
projectId: localConfig.projectId ?? null,
|
|
339
|
+
};
|
|
219
340
|
}
|
|
220
|
-
|
|
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
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
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
|
-
|
|
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
|
-
| "
|
|
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
|
-
|
|
527
|
-
|
|
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
|
-
|
|
626
|
-
view.projectId =
|
|
627
|
-
ctx.activeProjectId =
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
3788
|
-
|
|
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:
|
|
3796
|
-
activeProjectId:
|
|
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.
|
|
5331
|
-
|
|
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
|
-
|
|
5510
|
+
setActiveContext: (context) => setActiveContext(context),
|
|
5358
5511
|
setActiveWorkspace: (id) => setActiveWorkspace(id),
|
|
5359
5512
|
getApiUrl,
|
|
5360
5513
|
getMemoryDir: () => getMemoryDir(),
|