@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.
@@ -17,7 +17,7 @@ var __esm = (fn, res) => () => (fn && (res = fn(fn = 0)), res);
17
17
  // src/config.ts
18
18
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
19
19
  import { homedir } from "node:os";
20
- import { join } from "node:path";
20
+ import { dirname, join, parse, resolve } from "node:path";
21
21
  function getConfigDir() {
22
22
  return join(homedir(), ".harmony-mcp");
23
23
  }
@@ -27,6 +27,22 @@ function getConfigPath() {
27
27
  function getLocalConfigPath(cwd) {
28
28
  return join(cwd || process.cwd(), LOCAL_CONFIG_FILENAME);
29
29
  }
30
+ function findLocalConfigPath(cwd) {
31
+ const home = resolve(homedir());
32
+ let dir = resolve(cwd || process.cwd());
33
+ const { root } = parse(dir);
34
+ for (;; ) {
35
+ if (dir !== home && dir !== root) {
36
+ const candidate = join(dir, LOCAL_CONFIG_FILENAME);
37
+ if (existsSync(candidate))
38
+ return candidate;
39
+ }
40
+ const parent = dirname(dir);
41
+ if (parent === dir)
42
+ return null;
43
+ dir = parent;
44
+ }
45
+ }
30
46
  function emptyConfig() {
31
47
  return {
32
48
  apiKey: null,
@@ -78,8 +94,8 @@ function saveConfig(config) {
78
94
  });
79
95
  }
80
96
  function loadLocalConfig(cwd) {
81
- const localConfigPath = getLocalConfigPath(cwd);
82
- if (!existsSync(localConfigPath)) {
97
+ const localConfigPath = findLocalConfigPath(cwd);
98
+ if (localConfigPath === null) {
83
99
  return null;
84
100
  }
85
101
  try {
@@ -94,7 +110,7 @@ function loadLocalConfig(cwd) {
94
110
  }
95
111
  }
96
112
  function saveLocalConfig(config, cwd) {
97
- const localConfigPath = getLocalConfigPath(cwd);
113
+ const localConfigPath = findLocalConfigPath(cwd) ?? getLocalConfigPath(cwd);
98
114
  const existingConfig = loadLocalConfig(cwd) || {
99
115
  workspaceId: null,
100
116
  projectId: null
@@ -108,7 +124,7 @@ function saveLocalConfig(config, cwd) {
108
124
  writeFileSync(localConfigPath, JSON.stringify(cleanConfig, null, 2));
109
125
  }
110
126
  function hasLocalConfig(cwd) {
111
- return existsSync(getLocalConfigPath(cwd));
127
+ return findLocalConfigPath(cwd) !== null;
112
128
  }
113
129
  function getActiveCredential() {
114
130
  const config = loadConfig();
@@ -133,33 +149,69 @@ function getUserEmail() {
133
149
  function setUserEmail(email) {
134
150
  saveConfig({ userEmail: email });
135
151
  }
136
- function setActiveWorkspace(workspaceId, options) {
137
- if (options?.local) {
138
- saveLocalConfig({ workspaceId }, options.cwd);
139
- } else {
140
- saveConfig({ activeWorkspaceId: workspaceId });
152
+ function setActiveContext(context, options) {
153
+ if (options?.global) {
154
+ saveConfig({
155
+ activeWorkspaceId: context.workspaceId,
156
+ activeProjectId: context.projectId
157
+ });
158
+ return;
141
159
  }
142
- }
143
- function setActiveProject(projectId, options) {
144
- if (options?.local) {
145
- saveLocalConfig({ projectId }, options.cwd);
160
+ const localPath = findLocalConfigPath(options?.cwd);
161
+ if (options?.local || localPath !== null) {
162
+ saveLocalConfig({ workspaceId: context.workspaceId, projectId: context.projectId }, options?.cwd);
146
163
  } else {
147
- saveConfig({ activeProjectId: projectId });
164
+ saveConfig({
165
+ activeWorkspaceId: context.workspaceId,
166
+ activeProjectId: context.projectId
167
+ });
148
168
  }
149
169
  }
150
- function getActiveWorkspaceId(cwd) {
170
+ function setActiveWorkspace(workspaceId, options) {
171
+ const currentWorkspaceId = getActiveWorkspaceId(options?.cwd);
172
+ const keepProject = currentWorkspaceId === workspaceId;
173
+ setActiveContext({
174
+ workspaceId,
175
+ projectId: keepProject ? getActiveProjectId(options?.cwd) : null
176
+ }, options);
177
+ }
178
+ function readActiveContext(cwd) {
151
179
  const localConfig = loadLocalConfig(cwd);
152
- if (localConfig?.workspaceId) {
153
- return localConfig.workspaceId;
180
+ if (localConfig) {
181
+ return {
182
+ workspaceId: localConfig.workspaceId ?? null,
183
+ projectId: localConfig.projectId ?? null
184
+ };
154
185
  }
155
- return loadConfig().activeWorkspaceId;
186
+ const globalConfig = loadConfig();
187
+ return {
188
+ workspaceId: globalConfig.activeWorkspaceId,
189
+ projectId: globalConfig.activeProjectId
190
+ };
191
+ }
192
+ function getActiveWorkspaceId(cwd) {
193
+ return readActiveContext(cwd).workspaceId;
156
194
  }
157
195
  function getActiveProjectId(cwd) {
158
- const localConfig = loadLocalConfig(cwd);
159
- if (localConfig?.projectId) {
160
- return localConfig.projectId;
196
+ return readActiveContext(cwd).projectId;
197
+ }
198
+ function getActiveContext(cwd) {
199
+ return describeActiveContext({
200
+ projectId: getActiveProjectId(cwd),
201
+ workspaceId: getActiveWorkspaceId(cwd)
202
+ });
203
+ }
204
+ function describeActiveContext(context) {
205
+ const { projectId, workspaceId } = context;
206
+ if (projectId && !workspaceId) {
207
+ return {
208
+ projectId,
209
+ workspaceId,
210
+ consistent: false,
211
+ note: `An active project (${projectId}) is set with no active workspace, so ` + "workspace-scoped tools cannot resolve one from it. Re-set it with " + "harmony_set_project_context, or pass workspaceId explicitly."
212
+ };
161
213
  }
162
- return loadConfig().activeProjectId;
214
+ return { projectId, workspaceId, consistent: true, note: null };
163
215
  }
164
216
  function isConfigured() {
165
217
  const config = loadConfig();
@@ -238,7 +290,7 @@ function lockPath() {
238
290
  return join2(getConfigDir(), LOCK_FILENAME);
239
291
  }
240
292
  function sleep(ms) {
241
- return new Promise((resolve) => setTimeout(resolve, ms));
293
+ return new Promise((resolve2) => setTimeout(resolve2, ms));
242
294
  }
243
295
  async function withRefreshLock(fn) {
244
296
  const path = lockPath();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gethmy/mcp",
3
- "version": "2.23.0",
3
+ "version": "2.25.0",
4
4
  "description": "MCP server for Harmony, the shared surface for human–agent teams — agents claim cards, report progress, and move work on your board.",
5
5
  "publishConfig": {
6
6
  "access": "public"
package/src/api-client.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { randomUUID } from "node:crypto";
1
2
  import {
2
3
  type AgentRunEventDraft,
3
4
  type Comment,
@@ -9,6 +10,7 @@ import {
9
10
  type WorkspaceAgent,
10
11
  } from "@harmony/shared";
11
12
  import { getApiKey, getApiUrl } from "./config.js";
13
+ import type { ReadConsumer } from "./read-consumer.js";
12
14
 
13
15
  export interface ApiResponse<T = unknown> {
14
16
  success?: boolean;
@@ -49,6 +51,27 @@ function getRetryDelay(attempt: number): number {
49
51
 
50
52
  const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
51
53
 
54
+ /**
55
+ * Build the recall query for a card. The title alone was the whole query, which
56
+ * is too thin to rank against. The description is capped because a card body
57
+ * runs to 10,000 characters, the embedding truncates at END, and an uncapped
58
+ * body drowns the title it is meant to sharpen.
59
+ */
60
+ export function buildMemoryQuery(
61
+ title: string,
62
+ description: string | null | undefined,
63
+ ): string {
64
+ const DESCRIPTION_CAP = 600;
65
+ const trimmedTitle = title.trim();
66
+ const trimmedBody = (description ?? "").trim();
67
+
68
+ if (!trimmedTitle && !trimmedBody) return "";
69
+ if (!trimmedTitle) return trimmedBody.slice(0, DESCRIPTION_CAP);
70
+ if (!trimmedBody) return trimmedTitle;
71
+
72
+ return `${trimmedTitle}\n${trimmedBody.slice(0, DESCRIPTION_CAP)}`;
73
+ }
74
+
52
75
  // Semaphore for concurrency control
53
76
  class Semaphore {
54
77
  private permits: number;
@@ -532,7 +555,17 @@ export class HarmonyApiClient {
532
555
  /** Register/upsert this daemon's virtual agent. Idempotent by (workspace, identifier). */
533
556
  async registerWorkspaceAgent(
534
557
  workspaceId: string,
535
- data: { identifier: string; name: string; color?: string },
558
+ data: {
559
+ identifier: string;
560
+ name: string;
561
+ color?: string;
562
+ /**
563
+ * Gate metric NAMES the daemon declares under `agent.playbooks.metrics`
564
+ * (#922) — names only, never the commands behind them. Omit to leave the
565
+ * stored report untouched.
566
+ */
567
+ declaredGateMetrics?: string[];
568
+ },
536
569
  ): Promise<{ agent: WorkspaceAgent }> {
537
570
  return this.request("POST", `/workspaces/${workspaceId}/agents`, data);
538
571
  }
@@ -1097,6 +1130,15 @@ export class HarmonyApiClient {
1097
1130
  * working); the MCP tool dispatch defaults to `interactive`.
1098
1131
  */
1099
1132
  driver?: "daemon" | "interactive" | "script";
1133
+ /**
1134
+ * When a human budget decision is pending on this session, as an ISO
1135
+ * timestamp (card #915). A run START passes `null` explicitly: the route
1136
+ * REUSES a live session row, so a card whose previous run parked would
1137
+ * otherwise inherit that park's deadline — the panel offering Continue
1138
+ * over a run that is already going again, and the sweep exemption
1139
+ * masking a genuinely dead run for the rest of the window.
1140
+ */
1141
+ awaitingDecisionUntil?: string | null;
1100
1142
  },
1101
1143
  ): Promise<{
1102
1144
  session: unknown;
@@ -1147,6 +1189,53 @@ export class HarmonyApiClient {
1147
1189
  );
1148
1190
  }
1149
1191
 
1192
+ /**
1193
+ * Post a human's durable answer to a parked run's budget question (card
1194
+ * #915): grant more turns and continue, or stop and hand the card back.
1195
+ *
1196
+ * Unlike pause/resume/stop — an ephemeral Realtime broadcast nothing hears
1197
+ * once the daemon is offline — this MUST survive a daemon restart hours
1198
+ * later, so it lands as a `budget_decision` row rather than a broadcast.
1199
+ * The server resolves which run to decide for from the card's one active
1200
+ * session; the caller does not (and, deciding from the board, cannot) name
1201
+ * a session id.
1202
+ */
1203
+ async postBudgetDecision(
1204
+ cardId: string,
1205
+ data: {
1206
+ decision: "continue" | "stop";
1207
+ extraTurns: number;
1208
+ message?: string;
1209
+ },
1210
+ ): Promise<{ id: string; seq: number; createdAt: string }> {
1211
+ return this.request("POST", `/cards/${cardId}/budget-decisions`, data);
1212
+ }
1213
+
1214
+ /**
1215
+ * Drain `budget_decision` events for a card since `sinceIso` (card #915).
1216
+ * Keyed on card + time, not session + seq like `getPendingMessages`: the
1217
+ * daemon asking on startup may not know the session id of a run it parked
1218
+ * hours or days earlier, but it always knows the card.
1219
+ */
1220
+ async getBudgetDecisions(
1221
+ cardId: string,
1222
+ sinceIso: string,
1223
+ ): Promise<{
1224
+ decisions: Array<{
1225
+ decision: "continue" | "stop";
1226
+ extraTurns: number;
1227
+ message?: string;
1228
+ createdAt: string;
1229
+ }>;
1230
+ }> {
1231
+ const params = new URLSearchParams();
1232
+ params.set("sinceIso", sinceIso);
1233
+ return this.request(
1234
+ "GET",
1235
+ `/cards/${cardId}/budget-decisions?${params.toString()}`,
1236
+ );
1237
+ }
1238
+
1150
1239
  async updateAgentProgress(
1151
1240
  cardId: string,
1152
1241
  data: {
@@ -1205,6 +1294,16 @@ export class HarmonyApiClient {
1205
1294
  * deferred the respawn by 10 minutes (#770). Reply carries `stopped: true`.
1206
1295
  */
1207
1296
  implicitCreate?: boolean;
1297
+ /**
1298
+ * Set while the session waits for a human budget decision (#915),
1299
+ * cleared (null) once one arrives. Exempts the row from the daemon's
1300
+ * stale-session sweep until this instant — a parked run's deliberate
1301
+ * silence would otherwise read as abandonment well before a person can
1302
+ * answer. ISO timestamp string; the daemon-side mirror
1303
+ * (`StateStore.parkRun`'s `awaitingDecisionUntil`) is the same instant
1304
+ * in epoch ms, not this string.
1305
+ */
1306
+ awaitingDecisionUntil?: string | null;
1208
1307
  },
1209
1308
  ): Promise<{
1210
1309
  session: unknown;
@@ -1351,6 +1450,9 @@ export class HarmonyApiClient {
1351
1450
  // Opt in to agent-run episodes (default excluded, #677). Only the daemon's
1352
1451
  // own rolling-episode lookup should set this.
1353
1452
  include_episodes?: boolean;
1453
+ /** Read-accounting label (task 4) — omitted means the read is not
1454
+ * recorded. Callers that know their own identity should pass it. */
1455
+ consumer?: ReadConsumer;
1354
1456
  }): Promise<{ entities: unknown[]; count: number }> {
1355
1457
  const params = new URLSearchParams();
1356
1458
  params.set("workspace_id", options.workspace_id);
@@ -1367,6 +1469,7 @@ export class HarmonyApiClient {
1367
1469
  if (options.offset !== undefined)
1368
1470
  params.set("offset", String(options.offset));
1369
1471
  if (options.include_superseded) params.set("include_superseded", "true");
1472
+ if (options.consumer) params.set("consumer", options.consumer);
1370
1473
  if (options.include_episodes) params.set("include_episodes", "true");
1371
1474
  return this.request("GET", `/memory/entities?${params.toString()}`);
1372
1475
  }
@@ -1419,9 +1522,17 @@ export class HarmonyApiClient {
1419
1522
  // Opt in to agent-run episodes (default excluded server-side, #677). The
1420
1523
  // daemon sets this when looking up its own rolling implement/review episode.
1421
1524
  includeEpisodes?: boolean;
1525
+ /** Read-accounting label (task 4) — omitted means the read is not
1526
+ * recorded. Callers that know their own identity should pass it. */
1527
+ consumer?: ReadConsumer;
1422
1528
  }): Promise<{ entities: unknown[] }> {
1423
1529
  // Over-fetch beyond topK so client-side filters (multi-type, memory_tier,
1424
1530
  // tags) have headroom — matches the MCP server's recall path (server.ts).
1531
+ // `consumer` is deliberately NOT forwarded on this over-fetch: the server
1532
+ // would record read-accounting for every over-fetched row, not just the
1533
+ // ones actually delivered. Instead we record the delivered set below,
1534
+ // after client-side filtering and the topK trim (mirrors the mcp-tool
1535
+ // path in server.ts).
1425
1536
  const fetchLimit = Math.max(options.topK ?? 3, 50);
1426
1537
  let entities: Array<Record<string, unknown>> = [];
1427
1538
 
@@ -1483,6 +1594,21 @@ export class HarmonyApiClient {
1483
1594
  entities = entities.slice(0, options.topK);
1484
1595
  }
1485
1596
 
1597
+ // Read accounting: record only the entities actually delivered to the
1598
+ // caller, after all client-side filtering and the topK trim — never the
1599
+ // larger over-fetched candidate pool the calls above deliberately left
1600
+ // unrecorded. Best-effort; a recording failure must not fail the recall.
1601
+ if (options.consumer) {
1602
+ const deliveredIds = entities
1603
+ .map((e) => e.id)
1604
+ .filter((id): id is string => typeof id === "string");
1605
+ if (deliveredIds.length > 0) {
1606
+ this.batchTouchMemoryEntities(deliveredIds, options.consumer).catch(
1607
+ () => {},
1608
+ );
1609
+ }
1610
+ }
1611
+
1486
1612
  return { entities };
1487
1613
  }
1488
1614
 
@@ -1496,9 +1622,16 @@ export class HarmonyApiClient {
1496
1622
 
1497
1623
  async batchTouchMemoryEntities(
1498
1624
  entityIds: string[],
1625
+ /** Read-accounting label (task 4, fix round 1). Omitted → the route keeps
1626
+ * its original increment-only `batch_touch_knowledge_entities` behaviour
1627
+ * (legacy context-assembly callers). Given → the route instead records
1628
+ * through `record_entity_reads`, the same path the search chokepoint
1629
+ * uses, so this touch is properly attributed rather than anonymous. */
1630
+ consumer?: ReadConsumer,
1499
1631
  ): Promise<{ success: boolean; count: number }> {
1500
1632
  return this.request("POST", "/memory/entities/batch-touch", {
1501
1633
  entity_ids: entityIds,
1634
+ ...(consumer ? { consumer } : {}),
1502
1635
  });
1503
1636
  }
1504
1637
 
@@ -1533,6 +1666,16 @@ export class HarmonyApiClient {
1533
1666
  tags?: string[];
1534
1667
  include_superseded?: boolean;
1535
1668
  include_episodes?: boolean;
1669
+ /** Read-accounting label (task 4) — omitted means the read is not
1670
+ * recorded (there is deliberately no server-side default: it used to
1671
+ * default to "search" and inflated access_count on every
1672
+ * `harmony_remember` write-side probe). Callers that know their own
1673
+ * identity (harmony_recall) should pass it explicitly. */
1674
+ consumer?: ReadConsumer;
1675
+ /** Per-prompt-build id (task 6b) — shared by every read row recorded
1676
+ * during one `generateCardPrompt` call, so a card can later show which
1677
+ * memories shaped its prompt. */
1678
+ assembly_id?: string;
1536
1679
  },
1537
1680
  ): Promise<{ entities: unknown[]; count: number }> {
1538
1681
  const params = new URLSearchParams();
@@ -1546,7 +1689,9 @@ export class HarmonyApiClient {
1546
1689
  // matches against the canonical `tags_normalized` column (#299).
1547
1690
  for (const tag of options?.tags ?? []) params.append("tags", tag);
1548
1691
  if (options?.include_superseded) params.set("include_superseded", "true");
1692
+ if (options?.consumer) params.set("consumer", options.consumer);
1549
1693
  if (options?.include_episodes) params.set("include_episodes", "true");
1694
+ if (options?.assembly_id) params.set("assembly_id", options.assembly_id);
1550
1695
  return this.request("GET", `/memory/search?${params.toString()}`);
1551
1696
  }
1552
1697
 
@@ -1558,12 +1703,16 @@ export class HarmonyApiClient {
1558
1703
  type?: string;
1559
1704
  limit?: number;
1560
1705
  include_episodes?: boolean;
1706
+ /** Read-accounting label (task 4) — omitted means the read is not
1707
+ * recorded. Callers that know their own identity should pass it. */
1708
+ consumer?: ReadConsumer;
1561
1709
  }): Promise<{ entities: unknown[]; count: number }> {
1562
1710
  const params = new URLSearchParams();
1563
1711
  params.set("workspace_id", options.workspace_id);
1564
1712
  if (options.project_id) params.set("project_id", options.project_id);
1565
1713
  if (options.type) params.set("type", options.type);
1566
1714
  if (options.limit !== undefined) params.set("limit", String(options.limit));
1715
+ if (options.consumer) params.set("consumer", options.consumer);
1567
1716
  if (options.include_episodes) params.set("include_episodes", "true");
1568
1717
  return this.request("GET", `/memory/index?${params.toString()}`);
1569
1718
  }
@@ -1574,12 +1723,16 @@ export class HarmonyApiClient {
1574
1723
  type?: string;
1575
1724
  limit?: number;
1576
1725
  include_episodes?: boolean;
1726
+ /** Read-accounting label (task 4) — omitted means the read is not
1727
+ * recorded. Callers that know their own identity should pass it. */
1728
+ consumer?: ReadConsumer;
1577
1729
  }): Promise<string> {
1578
1730
  const params = new URLSearchParams();
1579
1731
  params.set("workspace_id", options.workspace_id);
1580
1732
  if (options.project_id) params.set("project_id", options.project_id);
1581
1733
  if (options.type) params.set("type", options.type);
1582
1734
  if (options.limit !== undefined) params.set("limit", String(options.limit));
1735
+ if (options.consumer) params.set("consumer", options.consumer);
1583
1736
  if (options.include_episodes) params.set("include_episodes", "true");
1584
1737
  return this.requestRaw(
1585
1738
  "GET",
@@ -1668,6 +1821,9 @@ export class HarmonyApiClient {
1668
1821
  type?: string;
1669
1822
  limit?: number;
1670
1823
  include_episodes?: boolean;
1824
+ /** Read-accounting label (task 4) — omitted means the read is not
1825
+ * recorded. Callers that know their own identity should pass it. */
1826
+ consumer?: ReadConsumer;
1671
1827
  },
1672
1828
  ): Promise<string> {
1673
1829
  const params = new URLSearchParams();
@@ -1677,6 +1833,7 @@ export class HarmonyApiClient {
1677
1833
  if (options?.type) params.set("type", options.type);
1678
1834
  if (options?.limit !== undefined)
1679
1835
  params.set("limit", String(options.limit));
1836
+ if (options?.consumer) params.set("consumer", options.consumer);
1680
1837
  if (options?.include_episodes) params.set("include_episodes", "true");
1681
1838
  return this.requestRaw(
1682
1839
  "GET",
@@ -1978,18 +2135,22 @@ export class HarmonyApiClient {
1978
2135
  // Phase 0 (memory architecture v2): full context assembly removed.
1979
2136
  // Use the basic memory search path so callers still get _some_ memory
1980
2137
  // hints. Phase 1 will reintroduce a session-scoped working memory layer.
1981
- const assembledContextStr: string | undefined = undefined;
1982
- const assemblyId: string | undefined = undefined;
2138
+
2139
+ // One id per prompt build. Every read row recorded during this build carries
2140
+ // it, which is what lets a card show the memories that shaped its prompt.
2141
+ const assemblyId = randomUUID();
1983
2142
  let memories: MemoryItem[] | undefined;
1984
2143
 
1985
2144
  try {
1986
2145
  if (options.workspaceId && cardData.title) {
1987
2146
  const memoryResult = await this.searchMemoryEntities(
1988
2147
  options.workspaceId,
1989
- cardData.title,
2148
+ buildMemoryQuery(cardData.title, cardData.description),
1990
2149
  {
1991
2150
  project_id: options.projectId,
1992
2151
  limit: 5,
2152
+ consumer: "agent-prompt",
2153
+ assembly_id: assemblyId,
1993
2154
  },
1994
2155
  );
1995
2156
  if (memoryResult.entities?.length > 0) {
@@ -2015,7 +2176,6 @@ export class HarmonyApiClient {
2015
2176
  contextOptions: options.contextOptions,
2016
2177
  customConstraints: options.customConstraints,
2017
2178
  memories,
2018
- assembledContext: assembledContextStr,
2019
2179
  assemblyId,
2020
2180
  });
2021
2181
 
@@ -2148,6 +2308,9 @@ export class HarmonyApiClient {
2148
2308
  description?: string;
2149
2309
  steps?: unknown;
2150
2310
  triggerType?: string;
2311
+ /** Auto-bind rule (#892); validated server-side, passed through as-is. */
2312
+ autoBind?: unknown;
2313
+ catalogId?: string;
2151
2314
  }): Promise<{ playbook: unknown }> {
2152
2315
  return this.request("POST", "/playbooks", data);
2153
2316
  }
@@ -2160,6 +2323,9 @@ export class HarmonyApiClient {
2160
2323
  steps?: unknown;
2161
2324
  enabled?: boolean;
2162
2325
  state?: string;
2326
+ triggerType?: string;
2327
+ /** `null` clears the rule; absent leaves it untouched (#892). */
2328
+ autoBind?: unknown;
2163
2329
  },
2164
2330
  ): Promise<{ playbook: unknown }> {
2165
2331
  return this.request("PATCH", `/playbooks/${playbookId}`, updates);
package/src/cli.ts CHANGED
@@ -3,10 +3,11 @@ import { createRequire } from "node:module";
3
3
  import { program } from "commander";
4
4
  import {
5
5
  areSkillsInstalled,
6
+ findLocalConfigPath,
7
+ getActiveContext,
6
8
  getActiveProjectId,
7
9
  getActiveWorkspaceId,
8
10
  getConfigPath,
9
- getLocalConfigPath,
10
11
  hasLocalConfig,
11
12
  isConfigured,
12
13
  loadConfig,
@@ -85,7 +86,9 @@ program
85
86
  console.log("\nContext:");
86
87
 
87
88
  if (hasLocal) {
88
- console.log(` Local config: ${getLocalConfigPath()}`);
89
+ // The file that is actually READ, which since #893 may be an ancestor's
90
+ // — printing the cwd path would name a file that does not exist.
91
+ console.log(` Local config: ${findLocalConfigPath()}`);
89
92
  console.log(
90
93
  ` Workspace: ${localConfig?.workspaceId || "(not set)"}`,
91
94
  );
@@ -103,16 +106,14 @@ program
103
106
  // Show effective (active) context
104
107
  const effectiveWorkspace = getActiveWorkspaceId();
105
108
  const effectiveProject = getActiveProjectId();
106
- const wsSource = localConfig?.workspaceId
107
- ? "local"
108
- : globalConfig.activeWorkspaceId
109
- ? "global"
110
- : "";
111
- const projSource = localConfig?.projectId
112
- ? "local"
113
- : globalConfig.activeProjectId
114
- ? "global"
115
- : "";
109
+ // The local file wins as a WHOLE file (#893), so the source is the file,
110
+ // not the field: with a local config in scope BOTH ids come from it, and
111
+ // a key it omits reads as unset rather than falling through to global.
112
+ // Reporting "global" for such a field would describe a fallback that no
113
+ // longer happens.
114
+ const contextSource = hasLocal ? "local" : "global";
115
+ const wsSource = effectiveWorkspace ? contextSource : "";
116
+ const projSource = effectiveProject ? contextSource : "";
116
117
 
117
118
  console.log("\n Active (effective):");
118
119
  console.log(
@@ -121,6 +122,14 @@ program
121
122
  console.log(
122
123
  ` Project: ${effectiveProject || "(not set)"}${projSource ? ` ← ${projSource}` : ""}`,
123
124
  );
125
+
126
+ // Say it out loud when the pair does not hold together (#893). A project
127
+ // with no workspace leaves every workspace-scoped tool guessing, and this
128
+ // status output is where a person looks when one of them misbehaves.
129
+ const report = getActiveContext();
130
+ if (!report.consistent && report.note) {
131
+ console.log(`\n ⚠ ${report.note}`);
132
+ }
124
133
  } else {
125
134
  console.log("Status: Not configured\n");
126
135
  console.log("Run: npx @gethmy/mcp setup");