@gethmy/mcp 3.3.0 → 3.4.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
@@ -81,6 +81,11 @@ import {
81
81
  unlinkedReport,
82
82
  } from "./plan-task-link.js";
83
83
  import { collectPlaybookMetricWarnings } from "./playbook-metric-warnings.js";
84
+ import {
85
+ beginHookTimeline,
86
+ endHookTimeline,
87
+ stopAllRunEventForwarders,
88
+ } from "./run-event-forwarder.js";
84
89
  import { stripSkillPreamble } from "./skills.js";
85
90
 
86
91
  // --- Signed-upload handshake (artifacts & card attachments) ---
@@ -2368,7 +2373,10 @@ export const TOOLS = {
2368
2373
  "belongs to, and this sets the return leg the plan needs to show real progress instead of " +
2369
2374
  "guessing from board-column names. Use it to repair a link, to re-point a criterion at a " +
2370
2375
  "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.",
2376
+ "Leave a criterion the card did NOT deliver open — an open criterion is the signal. " +
2377
+ "Linking a card that belongs to no plan yet also adopts it into this one (reported as " +
2378
+ "`cardPlanAdopted`), and linking a card that already belongs to a DIFFERENT plan is " +
2379
+ "refused — move it with harmony_update_card first, so the two directions cannot drift.",
2372
2380
  inputSchema: {
2373
2381
  type: "object",
2374
2382
  properties: {
@@ -2381,7 +2389,9 @@ export const TOOLS = {
2381
2389
  cardId: {
2382
2390
  type: "string",
2383
2391
  description:
2384
- "Card that fulfils this criterion. Must live in the plan's own project.",
2392
+ "Card that fulfils this criterion. Must live in the plan's own project, and " +
2393
+ "must belong to this plan or to no plan yet — a card already in another plan " +
2394
+ "is refused rather than silently re-pointed.",
2385
2395
  },
2386
2396
  status: {
2387
2397
  type: "string",
@@ -4263,6 +4273,16 @@ export async function handleToolCall(
4263
4273
  deps.getScopeId?.(),
4264
4274
  );
4265
4275
 
4276
+ // Publish the session on disk and start draining the `PostToolUse` hook's
4277
+ // spool (#874). This is what gives an MCP session a tool-call timeline
4278
+ // instead of one row per progress checkpoint. It no-ops on a daemon run
4279
+ // and on any failure — see `beginHookTimeline`.
4280
+ beginHookTimeline({
4281
+ cardId,
4282
+ agentSessionId,
4283
+ getClient: () => client,
4284
+ });
4285
+
4266
4286
  return {
4267
4287
  success: true,
4268
4288
  assignedTo,
@@ -4377,6 +4397,11 @@ export async function handleToolCall(
4377
4397
  await flushMemoryActions(client, cardId);
4378
4398
  cleanupMemorySession(cardId);
4379
4399
 
4400
+ // Drain the hook spool and unpublish while the session row still accepts
4401
+ // appends (#874). Ordered before `endAgentSession` for that reason: the
4402
+ // last tool calls of a run are the ones a reader most wants.
4403
+ await endHookTimeline(cardId);
4404
+
4380
4405
  // End the session — tolerate failure (e.g., session already ended or not found).
4381
4406
  // Typed off the client so the `ended`/`reason` discriminator (#769) reaches the
4382
4407
  // tool payload by contract, not by accident of the spread below. Left absent
@@ -5419,7 +5444,11 @@ export async function handleToolCall(
5419
5444
  }
5420
5445
 
5421
5446
  case "harmony_get_plan": {
5422
- let result: { plan: unknown; tasks: unknown[] } | null = null;
5447
+ let result: {
5448
+ plan: unknown;
5449
+ tasks: unknown[];
5450
+ foreign_criteria?: unknown[];
5451
+ } | null = null;
5423
5452
 
5424
5453
  if (args.planId) {
5425
5454
  const planId = z.string().uuid().parse(args.planId);
@@ -5427,7 +5456,14 @@ export async function handleToolCall(
5427
5456
  } else if (args.cardId) {
5428
5457
  const cardId = z.string().uuid().parse(args.cardId);
5429
5458
  result = await client.getPlanByCardId(cardId);
5430
- if (!result) {
5459
+ // Three distinct answers, and they must stay distinct. The route
5460
+ // normalises "nothing to report" to `{plan: null, tasks: []}` rather
5461
+ // than a null body, so a truthiness check alone cannot tell the clean
5462
+ // no-plan card from the divergent one.
5463
+ if (
5464
+ !result ||
5465
+ (result.plan == null && !result.foreign_criteria?.length)
5466
+ ) {
5431
5467
  return {
5432
5468
  success: true,
5433
5469
  plan: null,
@@ -5435,6 +5471,27 @@ export async function handleToolCall(
5435
5471
  message: "No plan linked to this card",
5436
5472
  };
5437
5473
  }
5474
+ // The card belongs to no plan, yet some criterion elsewhere names it (#1054).
5475
+ // Reporting that as "no plan linked" is the silence this card was filed for,
5476
+ // one level up from the daemon: a person asking which plan a card serves must
5477
+ // be told the two directions disagree, and which end to repair.
5478
+ // `result.foreign_criteria?.length` is the whole predicate: the route
5479
+ // always answers with an object, so `result.plan == null` is also true
5480
+ // for the ordinary "this card is in no plan" case. Without the second
5481
+ // half, every plan-less card was told plan criteria point at it and
5482
+ // handed a repair instruction for a problem it did not have — the
5483
+ // card's own failure mode inverted.
5484
+ if (result.plan == null && result.foreign_criteria?.length) {
5485
+ return {
5486
+ success: true,
5487
+ plan: null,
5488
+ tasks: [],
5489
+ divergentCriteria: result.foreign_criteria ?? [],
5490
+ message:
5491
+ "This card belongs to no plan, but plan criteria point at it. " +
5492
+ "Repair it with harmony_update_card (planId) or harmony_link_plan_task.",
5493
+ };
5494
+ }
5438
5495
  } else {
5439
5496
  throw new Error("Either planId or cardId must be provided");
5440
5497
  }
@@ -5443,6 +5500,11 @@ export async function handleToolCall(
5443
5500
  success: true,
5444
5501
  plan: result.plan,
5445
5502
  tasks: result.tasks,
5503
+ // Criteria naming this card from a plan it does not belong to — a data error,
5504
+ // reported rather than folded into `tasks` (#1054). Omitted when there are none.
5505
+ ...(result.foreign_criteria?.length
5506
+ ? { divergentCriteria: result.foreign_criteria }
5507
+ : {}),
5446
5508
  };
5447
5509
  }
5448
5510
 
@@ -5490,7 +5552,14 @@ export async function handleToolCall(
5490
5552
  const found = findPlanTask(tasks, taskId);
5491
5553
  if (!found.ok) throw new Error(found.reason);
5492
5554
 
5493
- await client.updatePlanTask(planId, taskId, { cardId, status });
5555
+ // Linking also maintains the card's own `plan_id` server-side (#1054) — see the
5556
+ // "which direction carries the truth" note in `_shared/plan-task-card-scope.ts`.
5557
+ // A card that belongs to a DIFFERENT plan is refused there, so this call can throw
5558
+ // where it used to succeed and leave the two columns disagreeing.
5559
+ const result = await client.updatePlanTask(planId, taskId, {
5560
+ cardId,
5561
+ status,
5562
+ });
5494
5563
  return {
5495
5564
  success: true,
5496
5565
  planTask: {
@@ -5500,6 +5569,9 @@ export async function handleToolCall(
5500
5569
  ...(cardId
5501
5570
  ? linkedReport(planId, found.task, cardId)
5502
5571
  : { linked: found.task.card_id != null }),
5572
+ ...(cardId && result.cardPlanAdopted
5573
+ ? { cardPlanAdopted: true }
5574
+ : {}),
5503
5575
  ...(status ? { status } : {}),
5504
5576
  },
5505
5577
  };
@@ -5844,6 +5916,14 @@ export class HarmonyMCPServer {
5844
5916
  } catch {
5845
5917
  // Best-effort
5846
5918
  }
5919
+ try {
5920
+ // Drain any tool calls the hook spooled but the timer had not posted,
5921
+ // and remove the on-disk pointers so a later hook cannot route to a
5922
+ // session this process took with it (#874).
5923
+ await stopAllRunEventForwarders();
5924
+ } catch {
5925
+ // Best-effort
5926
+ }
5847
5927
  destroyAutoSession();
5848
5928
  process.exit(exitCode);
5849
5929
  };