@gethmy/mcp 3.2.0 → 3.3.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/server.ts CHANGED
@@ -28,6 +28,10 @@ import {
28
28
  trackActivity,
29
29
  untrack,
30
30
  } from "./auto-session.js";
31
+ import {
32
+ chooseCommentSession,
33
+ readDeclaredRunSession,
34
+ } from "./comment-session.js";
31
35
  import {
32
36
  describeActiveContext,
33
37
  getActiveProjectId,
@@ -69,6 +73,13 @@ import {
69
73
  } from "./memory-session.js";
70
74
  import { lintTags, normalizeTags } from "./memory-tags.js";
71
75
  import { onboardNewUser } from "./onboard.js";
76
+ import {
77
+ findPlanTask,
78
+ linkedReport,
79
+ type PlanTaskLinkReport,
80
+ type PlanTaskRow,
81
+ unlinkedReport,
82
+ } from "./plan-task-link.js";
72
83
  import { collectPlaybookMetricWarnings } from "./playbook-metric-warnings.js";
73
84
  import { stripSkillPreamble } from "./skills.js";
74
85
 
@@ -357,6 +368,18 @@ interface MemorySessionState {
357
368
  // without a round-trip. May be undefined if the start endpoint did not
358
369
  // return an id (older clients) — `scope: 'session'` will then refuse.
359
370
  agentSessionId?: string;
371
+ /**
372
+ * The tenant this session was recorded under (`ToolDeps.getScopeId`) —
373
+ * `undefined` on stdio, where one process serves one user.
374
+ *
375
+ * `memorySessions` is keyed by card id ALONE and lives at module scope, so on
376
+ * the hosted transport (`remote.ts`, one process for every user) a lookup can
377
+ * hand back another user's session for the same card. Anything that CLAIMS
378
+ * the tracked session must compare this first — see `comment-session.ts`
379
+ * (#1035), where claiming a stranger's id means a 403 rather than a wrong
380
+ * name on a comment.
381
+ */
382
+ scopeId?: string;
360
383
  memoryReadCount: number;
361
384
  pendingActions: { action: string; ts: string }[];
362
385
  allActions: { action: string; ts: string }[];
@@ -489,12 +512,14 @@ function initMemorySession(
489
512
  agentIdentifier: string,
490
513
  agentName: string,
491
514
  agentSessionId?: string,
515
+ scopeId?: string,
492
516
  ): void {
493
517
  memorySessions.set(cardId, {
494
518
  cardId,
495
519
  agentIdentifier,
496
520
  agentName,
497
521
  agentSessionId,
522
+ scopeId,
498
523
  memoryReadCount: 0,
499
524
  pendingActions: [],
500
525
  allActions: [],
@@ -660,6 +685,16 @@ export const TOOLS = {
660
685
  description:
661
686
  "Plan ID to link this card to (optional). Links the card to that plan via its plan_id.",
662
687
  },
688
+ planTaskId: {
689
+ type: "string",
690
+ description:
691
+ "Id of the plan CRITERION this card is created to fulfil (optional; requires `planId`). " +
692
+ "Sets both directions at once: the card's plan_id, and the criterion's card_id — the " +
693
+ "return leg a card outcome needs to reach the plan. Read the ids from harmony_get_plan's " +
694
+ "`tasks`. A criterion that is not in the named plan refuses the whole call, so no card is " +
695
+ "created on a false premise; a failure to write the return leg AFTER the card exists " +
696
+ "keeps the card and reports it in `planTask` instead.",
697
+ },
663
698
  attachments: {
664
699
  type: "array",
665
700
  description:
@@ -2326,6 +2361,39 @@ export const TOOLS = {
2326
2361
  },
2327
2362
  },
2328
2363
 
2364
+ harmony_link_plan_task: {
2365
+ description:
2366
+ "Point a plan CRITERION at the card that fulfils it, and/or set the criterion's status. " +
2367
+ "A plan task is a success criterion, not a work item: `cards.plan_id` says which plan a card " +
2368
+ "belongs to, and this sets the return leg the plan needs to show real progress instead of " +
2369
+ "guessing from board-column names. Use it to repair a link, to re-point a criterion at a " +
2370
+ "different card, or to mark a criterion `completed` once its card actually delivered it. " +
2371
+ "Leave a criterion the card did NOT deliver open — an open criterion is the signal.",
2372
+ inputSchema: {
2373
+ type: "object",
2374
+ properties: {
2375
+ planId: { type: "string", description: "Plan ID owning the criterion" },
2376
+ taskId: {
2377
+ type: "string",
2378
+ description:
2379
+ "Criterion id, from harmony_get_plan's `tasks`. Must belong to `planId`.",
2380
+ },
2381
+ cardId: {
2382
+ type: "string",
2383
+ description:
2384
+ "Card that fulfils this criterion. Must live in the plan's own project.",
2385
+ },
2386
+ status: {
2387
+ type: "string",
2388
+ enum: ["pending", "in_progress", "completed"],
2389
+ description:
2390
+ "Criterion status. Set `completed` only when the card demonstrably delivered it.",
2391
+ },
2392
+ },
2393
+ required: ["planId", "taskId"],
2394
+ },
2395
+ },
2396
+
2329
2397
  // ============ PLAYBOOK TOOLS (Method/Loop layer) ============
2330
2398
 
2331
2399
  harmony_list_playbook: {
@@ -2435,6 +2503,25 @@ export const TOOLS = {
2435
2503
  },
2436
2504
  },
2437
2505
 
2506
+ harmony_delete_playbook: {
2507
+ // The description carries the cascade because that is where the model
2508
+ // reads it (card #856). Deprecating is the reversible verb and is named
2509
+ // first on purpose: it is almost always the right one, and it is already
2510
+ // available through harmony_update_playbook.
2511
+ description:
2512
+ "Permanently delete a playbook. IRREVERSIBLE and cascading: its version snapshots and run history are deleted with it, and every card currently running it is unbound — the card stays on the board and keeps its column, but loses its playbook, its pinned version and its stage pointer, so the agent daemon stops treating it as a stage card. Prefer harmony_update_playbook with state='deprecated' unless the playbook should never have existed: a deprecated playbook stays applicable to in-flight cards and is only hidden from new applies. Requires the playbook's creator or a workspace owner/admin — and an ARMED playbook (triggerType 'auto') takes an owner/admin even from its creator, because removing it stops the workspace's automation for everyone. Anyone else is refused and the playbook is left alone. Returns unboundCardCount (the exact number of cards it unbound) and unboundCards (up to 50 of them by id, short_id, title and the stage each one lost).",
2513
+ inputSchema: {
2514
+ type: "object",
2515
+ properties: {
2516
+ playbookId: {
2517
+ type: "string",
2518
+ description: "Playbook ID to delete (UUID)",
2519
+ },
2520
+ },
2521
+ required: ["playbookId"],
2522
+ },
2523
+ },
2524
+
2438
2525
  // ============ ONBOARDING TOOLS ============
2439
2526
  harmony_signup: {
2440
2527
  description:
@@ -2819,7 +2906,12 @@ async function resolveColumnByName(
2819
2906
  return col;
2820
2907
  }
2821
2908
 
2822
- async function handleToolCall(
2909
+ /**
2910
+ * Exported for tests (card #1038). A pure helper proves its own signature and nothing
2911
+ * about the call site — the two-direction plan link is only real if THIS switch performs
2912
+ * both writes, so the wiring needs a test that drives the tool by name.
2913
+ */
2914
+ export async function handleToolCall(
2823
2915
  name: string,
2824
2916
  args: Record<string, unknown>,
2825
2917
  deps: ToolDeps,
@@ -2877,17 +2969,66 @@ async function handleToolCall(
2877
2969
  )
2878
2970
  .parse(args.attachments)
2879
2971
  : [];
2972
+ // Plan criterion ↔ card, both directions in one call (card #1038).
2973
+ const planId = args.planId
2974
+ ? z.string().uuid().parse(args.planId)
2975
+ : undefined;
2976
+ const planTaskId = args.planTaskId
2977
+ ? z.string().uuid().parse(args.planTaskId)
2978
+ : undefined;
2979
+ if (planTaskId && !planId) {
2980
+ throw new Error(
2981
+ "planTaskId requires planId: no route resolves a plan from a criterion id alone. " +
2982
+ "Pass the plan the criterion belongs to — harmony_get_plan returns both.",
2983
+ );
2984
+ }
2985
+ // Refuse BEFORE the card exists. A criterion that is not in the named plan means the
2986
+ // caller is pointing at the wrong thing, and a card created on that premise is worse
2987
+ // than no card. A failure AFTER the card exists is handled the other way round below.
2988
+ let criterion: PlanTaskRow | undefined;
2989
+ if (planTaskId && planId) {
2990
+ const { tasks } = await client.getPlan(planId);
2991
+ const found = findPlanTask(tasks, planTaskId);
2992
+ if (!found.ok) throw new Error(found.reason);
2993
+ criterion = found.task;
2994
+ }
2995
+
2880
2996
  const result = await client.createCard(projectId, {
2881
2997
  title,
2882
2998
  columnId: args.columnId as string | undefined,
2883
2999
  description: args.description as string | undefined,
2884
3000
  priority: args.priority as string | undefined,
2885
3001
  assigneeId: args.assigneeId as string | undefined,
2886
- planId: args.planId as string | undefined,
3002
+ planId,
2887
3003
  });
2888
3004
 
3005
+ const newCardId = (result.card as { id?: string } | null)?.id;
3006
+
3007
+ // The return leg. Never throws: the card is the expensive artifact, and a missing
3008
+ // link is repairable with harmony_link_plan_task. Reported, never swallowed.
3009
+ let planTask: PlanTaskLinkReport | undefined;
3010
+ if (criterion && planId) {
3011
+ if (!newCardId) {
3012
+ planTask = unlinkedReport(
3013
+ planId,
3014
+ criterion,
3015
+ new Error("no card id was returned to link against"),
3016
+ );
3017
+ } else {
3018
+ try {
3019
+ await client.updatePlanTask(planId, criterion.id, {
3020
+ cardId: newCardId,
3021
+ });
3022
+ planTask = linkedReport(planId, criterion, newCardId);
3023
+ } catch (err) {
3024
+ planTask = unlinkedReport(planId, criterion, err);
3025
+ }
3026
+ }
3027
+ }
3028
+ const planTaskField = planTask ? { planTask } : {};
3029
+
2889
3030
  if (attachments.length === 0) {
2890
- return { success: true, ...result };
3031
+ return { success: true, ...result, ...planTaskField };
2891
3032
  }
2892
3033
 
2893
3034
  // Attach reference files (e.g. a prompt screenshot) to the freshly
@@ -2895,11 +3036,11 @@ async function handleToolCall(
2895
3036
  // runs only after createCard returns. A bad attachment must never lose
2896
3037
  // the card — per-file failures are captured and reported alongside the
2897
3038
  // successes rather than throwing out the whole create.
2898
- const cardId = (result.card as { id?: string } | null)?.id;
2899
- if (!cardId) {
3039
+ if (!newCardId) {
2900
3040
  return {
2901
3041
  success: true,
2902
3042
  ...result,
3043
+ ...planTaskField,
2903
3044
  attachmentWarning:
2904
3045
  "Card created, but attachments were skipped: no card id was returned to upload against.",
2905
3046
  };
@@ -2907,7 +3048,7 @@ async function handleToolCall(
2907
3048
  const attachmentResults = await Promise.all(
2908
3049
  attachments.map(async (file) => {
2909
3050
  try {
2910
- const uploaded = await attachFileToCard(client, cardId, file);
3051
+ const uploaded = await attachFileToCard(client, newCardId, file);
2911
3052
  return { ok: true as const, attachment: uploaded.attachment };
2912
3053
  } catch (err) {
2913
3054
  return {
@@ -2918,7 +3059,12 @@ async function handleToolCall(
2918
3059
  }
2919
3060
  }),
2920
3061
  );
2921
- return { success: true, ...result, attachments: attachmentResults };
3062
+ return {
3063
+ success: true,
3064
+ ...result,
3065
+ ...planTaskField,
3066
+ attachments: attachmentResults,
3067
+ };
2922
3068
  }
2923
3069
 
2924
3070
  case "harmony_update_card": {
@@ -3700,38 +3846,54 @@ async function handleToolCall(
3700
3846
  args.replyToId !== undefined
3701
3847
  ? z.string().uuid().parse(args.replyToId)
3702
3848
  : undefined;
3703
- // Name our OWN session on this card when this process started one, so the
3704
- // attribution is exact rather than inferred.
3849
+ // Name our OWN session, or say plainly that we hold none (#1035).
3850
+ //
3851
+ // The server no longer infers either way: the `card_id` + `user_id`
3852
+ // fallback in `createComment` is gone, because it was true of every
3853
+ // concurrent run of the same account and a second run inherited the
3854
+ // first one's attribution (measured on card #1029). So this call is now
3855
+ // the whole answer, and `chooseCommentSession` is where it is decided —
3856
+ // read that module for why an absent declaration is an ANSWER here and
3857
+ // was a guess there.
3705
3858
  //
3706
- // LIMIT, so nobody reads more into this than it does: when this is
3707
- // undefined the server does NOT post sessionless — it falls back to "any
3708
- // session this user has on this card" (`createComment` in
3709
- // supabase/functions/harmony-api/handler.ts), which is true of every
3710
- // concurrent run of the same account. So the borrowed attribution
3711
- // measured on card #1029 is still reachable, and closing it needs the
3712
- // daemon to name its session too, at which point that fallback can go.
3713
- // Until then it must stay: a daemon-run agent cannot call
3714
- // `harmony_start_agent_session` (the daemon owns the lifecycle and
3715
- // disables it), and the session id is not in its environment either, so
3716
- // this lookup is always empty there. Declaring "sessionless" on an empty
3717
- // lookup would strip the session from EVERY daemon agent comment and
3718
- // `isDaemonAuthoredComment` would stop recognising the daemon's own
3719
- // contract and handoff artifacts.
3859
+ // Two declarations, never an observation: the session THIS process
3860
+ // started (`memorySessions`, the interactive `/hmy` path, checked against
3861
+ // the caller's tenant), and the one the daemon put in this process's
3862
+ // environment for the run it serves (the daemon path, which needs one
3863
+ // because `harmony_start_agent_session` is denied on a daemon run).
3720
3864
  //
3721
- // Also note the explicit branch accepts any of the caller's sessions on
3722
- // the card regardless of `ended_at`, where the implicit one applies
3723
- // `pickAgentCommentSession`'s swept-session grace window (#771). Naming
3724
- // our own session is the stronger evidence this process IS that run
3725
- // so the wider window is intended here.
3726
- const agentSessionId = getMemorySession(cardId)?.agentSessionId;
3865
+ // Note the explicit branch on the server accepts any of the caller's own
3866
+ // sessions on the card regardless of `ended_at`. That is deliberate and
3867
+ // it is what replaced #771's swept-session grace window: a run the
3868
+ // silence sweep cut off still names its own id and its last report is
3869
+ // still a run artifact, with no time bound, because naming the session is
3870
+ // knowledge where the window was a guess.
3871
+ const sessionChoice = chooseCommentSession({
3872
+ cardId,
3873
+ tracked: getMemorySession(cardId),
3874
+ callerScopeId: deps.getScopeId?.(),
3875
+ declared: readDeclaredRunSession(),
3876
+ });
3727
3877
  const result = await client.addComment(cardId, body, {
3728
3878
  commentType,
3729
3879
  supersedesId,
3730
3880
  confirmsId,
3731
3881
  replyToId,
3732
- agentSessionId,
3882
+ agentSessionId:
3883
+ sessionChoice.kind === "session"
3884
+ ? sessionChoice.agentSessionId
3885
+ : undefined,
3733
3886
  });
3734
- return { success: true, ...result };
3887
+ return {
3888
+ success: true,
3889
+ ...result,
3890
+ // Said out loud rather than left to be inferred from the row: a
3891
+ // sessionless comment is a legal shape (#1033) and a degraded one, and
3892
+ // an agent that expected its run to be named should be able to see
3893
+ // that it was not.
3894
+ sessionAttribution:
3895
+ sessionChoice.kind === "session" ? sessionChoice.source : "none",
3896
+ };
3735
3897
  }
3736
3898
 
3737
3899
  case "harmony_get_comments": {
@@ -4091,7 +4253,15 @@ async function handleToolCall(
4091
4253
  // backend session id so working-memory writes (`scope: 'session'`) bind
4092
4254
  // to the same `card_agent_context` row that progress/end calls target.
4093
4255
  const agentSessionId = (result.session as { id?: string } | null)?.id;
4094
- initMemorySession(cardId, agentIdentifier, agentName, agentSessionId);
4256
+ // The scope is recorded WITH the session, so a later read can tell "mine"
4257
+ // from "the same card, a different user on this hosted process" (#1035).
4258
+ initMemorySession(
4259
+ cardId,
4260
+ agentIdentifier,
4261
+ agentName,
4262
+ agentSessionId,
4263
+ deps.getScopeId?.(),
4264
+ );
4095
4265
 
4096
4266
  return {
4097
4267
  success: true,
@@ -5297,6 +5467,44 @@ async function handleToolCall(
5297
5467
  return { success: true, plan: result.plan };
5298
5468
  }
5299
5469
 
5470
+ case "harmony_link_plan_task": {
5471
+ // The return leg on its own (card #1038): repair a link, re-point a criterion, or
5472
+ // mark one delivered. `harmony_create_card` writes it at birth; this is every other
5473
+ // moment, and it is what a completion path calls to close the loop.
5474
+ const planId = z.string().uuid().parse(args.planId);
5475
+ const taskId = z.string().uuid().parse(args.taskId);
5476
+ const cardId = args.cardId
5477
+ ? z.string().uuid().parse(args.cardId)
5478
+ : undefined;
5479
+ const status = args.status
5480
+ ? z.enum(["pending", "in_progress", "completed"]).parse(args.status)
5481
+ : undefined;
5482
+ if (!cardId && !status) {
5483
+ throw new Error("Nothing to do: pass cardId, status, or both.");
5484
+ }
5485
+
5486
+ // Same fail-closed read as the create path — the criterion has to be in THIS plan.
5487
+ // The route scopes its update by plan_id too, but a rejection that names the
5488
+ // mistake beats a zero-row update surfacing as an opaque database error.
5489
+ const { tasks } = await client.getPlan(planId);
5490
+ const found = findPlanTask(tasks, taskId);
5491
+ if (!found.ok) throw new Error(found.reason);
5492
+
5493
+ await client.updatePlanTask(planId, taskId, { cardId, status });
5494
+ return {
5495
+ success: true,
5496
+ planTask: {
5497
+ planId,
5498
+ taskId,
5499
+ criterion: found.task.content ?? null,
5500
+ ...(cardId
5501
+ ? linkedReport(planId, found.task, cardId)
5502
+ : { linked: found.task.card_id != null }),
5503
+ ...(status ? { status } : {}),
5504
+ },
5505
+ };
5506
+ }
5507
+
5300
5508
  case "harmony_advance_plan": {
5301
5509
  // Simplified: just archive the plan
5302
5510
  const planId = z.string().uuid().parse(args.planId);
@@ -5435,6 +5643,26 @@ async function handleToolCall(
5435
5643
  };
5436
5644
  }
5437
5645
 
5646
+ case "harmony_delete_playbook": {
5647
+ const playbookId = z.string().uuid().parse(args.playbookId);
5648
+ // No `confirm` argument, deliberately. Every other destructive tool here
5649
+ // (harmony_delete_card, harmony_delete_column — which deletes its cards)
5650
+ // takes none, and a flag the caller chooses is not a bound: the model
5651
+ // that decided to delete also supplies the confirmation. What actually
5652
+ // holds is the route's creator-or-admin check, the description above
5653
+ // pointing at deprecate, and this reply naming what was unbound.
5654
+ const result = await client.deletePlaybook(playbookId);
5655
+ // The count is the server's exact one; the list is its capped sample. Do
5656
+ // not derive the count from the list — that silently under-reports the
5657
+ // blast radius on exactly the playbook where it matters most.
5658
+ return {
5659
+ success: true,
5660
+ playbook: result.playbook,
5661
+ unboundCardCount: result.unboundCardCount,
5662
+ unboundCards: result.unboundCards,
5663
+ };
5664
+ }
5665
+
5438
5666
  // Deprecated (#612) — see harmony_run_playbook above.
5439
5667
  case "harmony_save_card_as_playbook":
5440
5668
  return deprecatedRemovedToolResult("harmony_save_card_as_playbook");
package/src/tui/setup.ts CHANGED
@@ -106,6 +106,9 @@ const SAFE_HARMONY_TOOLS = [
106
106
  "harmony_create_plan",
107
107
  "harmony_update_plan",
108
108
  "harmony_advance_plan",
109
+ // Points a plan criterion at the card that fulfils it, or marks it delivered.
110
+ // Non-destructive and re-pointable, and the completion path calls it per card.
111
+ "harmony_link_plan_task",
109
112
  "harmony_remember",
110
113
  "harmony_relate",
111
114
  "harmony_update_memory",