@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/dist/cli.js +389 -141
- package/dist/index.js +356 -118
- package/dist/lib/api-client.js +137 -96
- 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 +171 -5
- package/src/cli.ts +21 -12
- package/src/config.ts +201 -27
- package/src/playbook-metric-warnings.ts +56 -0
- package/src/prompt-builder.ts +9 -120
- package/src/read-consumer.ts +16 -0
- package/src/remote.ts +34 -6
- package/src/server.ts +195 -23
- 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 {
|
|
@@ -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
|
+
}
|
package/src/prompt-builder.ts
CHANGED
|
@@ -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
|
-
|
|
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 (
|
|
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 =
|
|
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
|
-
*
|
|
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
|
-
//
|
|
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
|
|
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
|
|
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
|
-
| "
|
|
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.
|