@gethmy/mcp 2.23.0 → 2.25.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/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 {
@@ -0,0 +1,56 @@
1
+ import {
2
+ declaredGateMetricsFromAgents,
3
+ referencedGateMetrics,
4
+ type WorkspaceAgent,
5
+ } from "@harmony/shared";
6
+
7
+ /**
8
+ * Non-blocking warnings for a playbook write whose `custom` gates name metrics
9
+ * no reporting agent in the workspace declares (card #922). The write always
10
+ * succeeds — a metric name can legitimately precede its declaration — but the
11
+ * author learns about the mismatch NOW, not after the first bound card burns a
12
+ * stage run on it. Inert until at least one agent has reported its declared
13
+ * names (an older daemon, or none running, must never raise a false alarm).
14
+ */
15
+ export function playbookMetricWarnings(
16
+ agents: ReadonlyArray<Pick<WorkspaceAgent, "declared_gate_metrics">>,
17
+ steps: unknown,
18
+ ): string[] {
19
+ if (!Array.isArray(steps)) return [];
20
+ const declared = declaredGateMetricsFromAgents(agents);
21
+ if (!declared.known) return [];
22
+
23
+ const warnings: string[] = [];
24
+ const seen = new Set<string>();
25
+ for (const ref of referencedGateMetrics({ steps, steps_version: 2 })) {
26
+ if (declared.names.has(ref.metric) || seen.has(ref.metric)) continue;
27
+ seen.add(ref.metric);
28
+ warnings.push(
29
+ `Gate metric "${ref.metric}" (stage "${ref.stageName}") is not declared by any agent in this workspace — a stage run gating on it will hold until a daemon declares it under agent.playbooks.metrics.${ref.metric}.`,
30
+ );
31
+ }
32
+ return warnings;
33
+ }
34
+
35
+ /**
36
+ * Fetch the workspace's agents and compute the warnings, swallowing every
37
+ * error: the playbook write already succeeded, and a warning lookup must never
38
+ * turn that success into a tool failure.
39
+ */
40
+ export async function collectPlaybookMetricWarnings(
41
+ client: {
42
+ listWorkspaceAgents(
43
+ workspaceId: string,
44
+ ): Promise<{ agents: WorkspaceAgent[] }>;
45
+ },
46
+ workspaceId: string | undefined,
47
+ steps: unknown,
48
+ ): Promise<string[]> {
49
+ if (!workspaceId || !Array.isArray(steps)) return [];
50
+ try {
51
+ const { agents } = await client.listWorkspaceAgents(workspaceId);
52
+ return playbookMetricWarnings(agents, steps);
53
+ } catch {
54
+ return [];
55
+ }
56
+ }
@@ -402,8 +402,6 @@ export interface GeneratePromptOptions {
402
402
  contextOptions?: Partial<PromptContextOptions>;
403
403
  customConstraints?: string;
404
404
  memories?: MemoryData[];
405
- /** Pre-assembled context string from context assembly engine */
406
- assembledContext?: string;
407
405
  /** Assembly ID for manifest tracking */
408
406
  assemblyId?: string;
409
407
  }
@@ -414,15 +412,8 @@ export interface GeneratePromptOptions {
414
412
  export function generatePrompt(
415
413
  options: GeneratePromptOptions,
416
414
  ): GeneratedPrompt {
417
- const {
418
- card,
419
- column,
420
- variant,
421
- customConstraints,
422
- memories,
423
- assembledContext,
424
- assemblyId,
425
- } = options;
415
+ const { card, column, variant, customConstraints, memories, assemblyId } =
416
+ options;
426
417
 
427
418
  // Merge context options with defaults
428
419
  const contextOpts: PromptContextOptions = {
@@ -537,11 +528,7 @@ export function generatePrompt(
537
528
  });
538
529
 
539
530
  // Relevant memories from knowledge graph
540
- if (assembledContext) {
541
- // Use pre-assembled context from context assembly engine
542
- sections.push(`\n${assembledContext}`);
543
- } else if (memories && memories.length > 0) {
544
- // Fallback: legacy memory format
531
+ if (memories && memories.length > 0) {
545
532
  sections.push(`\n## Relevant Memories`);
546
533
  sections.push(
547
534
  `*${memories.length} memories recalled from knowledge graph:*`,
@@ -556,12 +543,7 @@ export function generatePrompt(
556
543
  }
557
544
 
558
545
  // "One Thing" synthesis — highest-leverage next action
559
- const oneThingLine = synthesizeOneThing(
560
- card,
561
- subtasks,
562
- links,
563
- assembledContext,
564
- );
546
+ const oneThingLine = synthesizeOneThing(card, subtasks, links);
565
547
  if (oneThingLine) {
566
548
  sections.push(`\n## Recommended Next Step\n${oneThingLine}`);
567
549
  }
@@ -590,9 +572,7 @@ Keep \`currentTask\` specific (e.g., "Refactoring auth middleware" not "Working
590
572
 
591
573
  const prompt = sections.join("\n");
592
574
 
593
- const memoryCount = assembledContext
594
- ? (assembledContext.match(/^### /gm) || []).length
595
- : memories?.length || 0;
575
+ const memoryCount = memories?.length ?? 0;
596
576
 
597
577
  return {
598
578
  prompt,
@@ -616,69 +596,7 @@ Keep \`currentTask\` specific (e.g., "Refactoring auth middleware" not "Working
616
596
  }
617
597
 
618
598
  /**
619
- * Extract session insights from assembled context string.
620
- * Parses session summaries, blockers, and progress data.
621
- */
622
- function extractSessionInsights(assembledContext: string): {
623
- lastSessionStatus: "completed" | "paused" | null;
624
- lastSessionTask: string | null;
625
- lastSessionProgress: number | null;
626
- blockers: string[];
627
- procedureNextStep: string | null;
628
- } {
629
- const result = {
630
- lastSessionStatus: null as "completed" | "paused" | null,
631
- lastSessionTask: null as string | null,
632
- lastSessionProgress: null as number | null,
633
- blockers: [] as string[],
634
- procedureNextStep: null as string | null,
635
- };
636
-
637
- // Find the most recent session summary with status
638
- const sessionMatches = assembledContext.match(
639
- /### Session:.*?\n([\s\S]*?)(?=\n###|\n## |\n---|\n\*Assembly|$)/g,
640
- );
641
- if (sessionMatches && sessionMatches.length > 0) {
642
- const latest = sessionMatches[0];
643
- if (/Completed work on/i.test(latest)) {
644
- result.lastSessionStatus = "completed";
645
- } else if (/Paused work on|status:\s*paused/i.test(latest)) {
646
- result.lastSessionStatus = "paused";
647
- }
648
- const taskMatch = latest.match(/Final task:\s*(.+)/);
649
- if (taskMatch) result.lastSessionTask = taskMatch[1].trim();
650
- const progressMatch = latest.match(/Progress:\s*(\d+)%/);
651
- if (progressMatch)
652
- result.lastSessionProgress = parseInt(progressMatch[1], 10);
653
- }
654
-
655
- // Extract blockers from context
656
- const blockerMatches = assembledContext.match(
657
- /(?:blocker|blocked by|blocking):\s*(.+)/gi,
658
- );
659
- if (blockerMatches) {
660
- result.blockers = blockerMatches.map((m) =>
661
- m.replace(/(?:blocker|blocked by|blocking):\s*/i, "").trim(),
662
- );
663
- }
664
-
665
- // Extract next procedure step (first uncompleted step)
666
- const stepMatches = assembledContext.match(
667
- /^\d+\.\s+(?!.*\*\*\[key step\]\*\*.*✓)(.+?)(?:\s*\*\*\[key step\]\*\*)?$/gm,
668
- );
669
- if (stepMatches && stepMatches.length > 0) {
670
- result.procedureNextStep = stepMatches[0]
671
- .replace(/^\d+\.\s+/, "")
672
- .replace(/\s*\*\*\[key step\]\*\*.*$/, "")
673
- .trim();
674
- }
675
-
676
- return result;
677
- }
678
-
679
- /**
680
- * Synthesize the single highest-leverage next action from card state
681
- * and assembled context (session history, blockers, procedures).
599
+ * Synthesize the single highest-leverage next action from card state.
682
600
  * Inspired by ArtemXTech's "One Thing" pattern in /recall.
683
601
  */
684
602
  function synthesizeOneThing(
@@ -689,7 +607,6 @@ function synthesizeOneThing(
689
607
  display_type: string;
690
608
  direction: "outgoing" | "incoming";
691
609
  }>,
692
- assembledContext?: string,
693
610
  ): string | null {
694
611
  // Priority 1: Card is already done
695
612
  if (card.done) return null;
@@ -703,25 +620,7 @@ function synthesizeOneThing(
703
620
  return `Unblock first: resolve #${blocker.target_card.short_id} "${blocker.target_card.title}" which is blocking this card.`;
704
621
  }
705
622
 
706
- // Extract session insights from assembled context
707
- const session = assembledContext
708
- ? extractSessionInsights(assembledContext)
709
- : null;
710
-
711
- // Priority 3: Blockers detected in session context
712
- if (session?.blockers && session.blockers.length > 0) {
713
- return `Resolve blocker: ${session.blockers[0]}`;
714
- }
715
-
716
- // Priority 4: Previous session was paused — resume where it left off
717
- if (session?.lastSessionStatus === "paused" && session.lastSessionTask) {
718
- const progress = session.lastSessionProgress
719
- ? ` (was ${session.lastSessionProgress}% complete)`
720
- : "";
721
- return `Resume previous session${progress}: "${session.lastSessionTask}".`;
722
- }
723
-
724
- // Priority 5: Has subtasks — find the first incomplete one
623
+ // Priority 3: Has subtasks find the first incomplete one
725
624
  if (subtasks.length > 0) {
726
625
  const completed = subtasks.filter((s) => s.completed).length;
727
626
  if (completed === subtasks.length) {
@@ -733,17 +632,7 @@ function synthesizeOneThing(
733
632
  }
734
633
  }
735
634
 
736
- // Priority 6: Procedure has a next step
737
- if (session?.procedureNextStep) {
738
- return `Follow procedure: ${session.procedureNextStep}`;
739
- }
740
-
741
- // Priority 7: Previous session completed — build on it
742
- if (session?.lastSessionStatus === "completed" && session.lastSessionTask) {
743
- return `Previous session completed ("${session.lastSessionTask}"). Review results and continue with remaining work.`;
744
- }
745
-
746
- // Priority 8: High/urgent priority with due date
635
+ // Priority 4: High/urgent priority with due date
747
636
  if (
748
637
  card.due_date &&
749
638
  (card.priority === "urgent" || card.priority === "high")
@@ -751,7 +640,7 @@ function synthesizeOneThing(
751
640
  return `High-priority task with deadline ${card.due_date}. Start implementation immediately.`;
752
641
  }
753
642
 
754
- // Priority 9: Has description — start working
643
+ // Priority 5: Has description — start working
755
644
  if (card.description) {
756
645
  return "Analyze the description, identify the approach, and begin implementation.";
757
646
  }
@@ -0,0 +1,16 @@
1
+ // Read-consumer labels for `record_entity_reads` (migration
2
+ // 20260828120000_knowledge_entity_reads.sql).
3
+ //
4
+ // Duplicated in `supabase/functions/_shared/read-consumer.ts` rather than
5
+ // shared via `packages/harmony-shared`: this package has no dependency on
6
+ // `@harmony/shared` (so it could not import the type from there), and
7
+ // `packages/mobile` DOES depend on `@harmony/shared` (so adding it there
8
+ // would drag mobile's typecheck CI into a change mobile never uses). Both
9
+ // copies must match the SQL `CHECK` constraint exactly — six values, no more.
10
+ export type ReadConsumer =
11
+ | "agent-prompt"
12
+ | "assistant"
13
+ | "mcp-tool"
14
+ | "analyze"
15
+ | "search"
16
+ | "browse";
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.