@gethmy/mcp 2.23.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/dist/cli.js +207 -67
- package/dist/index.js +174 -44
- 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 +1 -1
- package/src/api-client.ts +6 -0
- package/src/cli.ts +21 -12
- package/src/config.ts +201 -27
- package/src/remote.ts +34 -6
- package/src/server.ts +154 -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
|
},
|
|
@@ -2364,6 +2380,22 @@ export const TOOLS = {
|
|
|
2364
2380
|
description: "The playbook's ordered stage objects.",
|
|
2365
2381
|
items: { type: "object" },
|
|
2366
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
|
+
},
|
|
2367
2399
|
},
|
|
2368
2400
|
required: ["name"],
|
|
2369
2401
|
},
|
|
@@ -2371,7 +2403,7 @@ export const TOOLS = {
|
|
|
2371
2403
|
|
|
2372
2404
|
harmony_update_playbook: {
|
|
2373
2405
|
description:
|
|
2374
|
-
"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.",
|
|
2375
2407
|
inputSchema: {
|
|
2376
2408
|
type: "object",
|
|
2377
2409
|
properties: {
|
|
@@ -2395,6 +2427,17 @@ export const TOOLS = {
|
|
|
2395
2427
|
enum: ["active", "deprecated"],
|
|
2396
2428
|
description: "Lifecycle state",
|
|
2397
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
|
+
},
|
|
2398
2441
|
},
|
|
2399
2442
|
required: ["playbookId"],
|
|
2400
2443
|
},
|
|
@@ -3107,7 +3150,13 @@ async function handleToolCall(
|
|
|
3107
3150
|
// wrong-context resolve is visible immediately.
|
|
3108
3151
|
const established = activeProjectId == null;
|
|
3109
3152
|
if (established) {
|
|
3110
|
-
|
|
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
|
+
});
|
|
3111
3160
|
}
|
|
3112
3161
|
return {
|
|
3113
3162
|
success: true,
|
|
@@ -3790,16 +3839,97 @@ async function handleToolCall(
|
|
|
3790
3839
|
|
|
3791
3840
|
case "harmony_set_project_context": {
|
|
3792
3841
|
const projectId = z.string().uuid().parse(args.projectId);
|
|
3793
|
-
|
|
3794
|
-
|
|
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
|
+
};
|
|
3795
3915
|
}
|
|
3796
3916
|
|
|
3797
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
|
+
});
|
|
3798
3926
|
return {
|
|
3799
3927
|
success: true,
|
|
3800
3928
|
context: {
|
|
3801
|
-
activeWorkspaceId:
|
|
3802
|
-
activeProjectId:
|
|
3929
|
+
activeWorkspaceId: report.workspaceId,
|
|
3930
|
+
activeProjectId: report.projectId,
|
|
3931
|
+
consistent: report.consistent,
|
|
3932
|
+
...(report.note ? { note: report.note } : {}),
|
|
3803
3933
|
},
|
|
3804
3934
|
};
|
|
3805
3935
|
}
|
|
@@ -5233,6 +5363,12 @@ async function handleToolCall(
|
|
|
5233
5363
|
name,
|
|
5234
5364
|
description: args.description as string | undefined,
|
|
5235
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,
|
|
5236
5372
|
});
|
|
5237
5373
|
return { success: true, playbook: result.playbook };
|
|
5238
5374
|
}
|
|
@@ -5245,6 +5381,10 @@ async function handleToolCall(
|
|
|
5245
5381
|
steps: args.steps,
|
|
5246
5382
|
enabled: args.enabled as boolean | undefined,
|
|
5247
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 } : {}),
|
|
5248
5388
|
});
|
|
5249
5389
|
return { success: true, playbook: result.playbook };
|
|
5250
5390
|
}
|
|
@@ -5338,8 +5478,10 @@ async function handleToolCall(
|
|
|
5338
5478
|
|
|
5339
5479
|
// Save config and reset client
|
|
5340
5480
|
deps.saveConfig({ apiKey: result.apiKey.rawKey });
|
|
5341
|
-
deps.
|
|
5342
|
-
|
|
5481
|
+
deps.setActiveContext({
|
|
5482
|
+
projectId: result.project.id,
|
|
5483
|
+
workspaceId: result.workspace.id,
|
|
5484
|
+
});
|
|
5343
5485
|
deps.resetClient();
|
|
5344
5486
|
|
|
5345
5487
|
return {
|
|
@@ -5365,7 +5507,7 @@ function createConfigDeps(): ToolDeps {
|
|
|
5365
5507
|
isConfigured,
|
|
5366
5508
|
getActiveProjectId: () => getActiveProjectId(),
|
|
5367
5509
|
getActiveWorkspaceId: () => getActiveWorkspaceId(),
|
|
5368
|
-
|
|
5510
|
+
setActiveContext: (context) => setActiveContext(context),
|
|
5369
5511
|
setActiveWorkspace: (id) => setActiveWorkspace(id),
|
|
5370
5512
|
getApiUrl,
|
|
5371
5513
|
getMemoryDir: () => getMemoryDir(),
|
package/src/tui/setup.ts
CHANGED
|
@@ -19,8 +19,7 @@ import {
|
|
|
19
19
|
loadConfig,
|
|
20
20
|
saveConfig,
|
|
21
21
|
saveLocalConfig,
|
|
22
|
-
|
|
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
|
-
|
|
964
|
-
|
|
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
|
-
|
|
1567
|
-
|
|
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
|