@gethmy/mcp 3.2.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/README.md +41 -2
- package/dist/cli.js +1188 -157
- package/dist/index.js +805 -42
- package/dist/lib/api-client.js +3 -1
- package/dist/run-hook-cli.js +742 -0
- package/package.json +4 -3
- package/src/api-client.ts +57 -1
- package/src/auto-session.ts +33 -0
- package/src/cli.ts +104 -0
- package/src/comment-session.ts +149 -0
- package/src/hook-install.ts +388 -0
- package/src/plan-task-link.ts +130 -0
- package/src/run-event-forwarder.ts +363 -0
- package/src/run-hook-cli.ts +55 -0
- package/src/run-hook-main.ts +159 -0
- package/src/run-hook.ts +203 -0
- package/src/run-redaction.ts +461 -0
- package/src/run-state.ts +679 -0
- package/src/server.ts +342 -34
- package/src/tui/setup.ts +3 -0
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,7 +73,19 @@ 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";
|
|
84
|
+
import {
|
|
85
|
+
beginHookTimeline,
|
|
86
|
+
endHookTimeline,
|
|
87
|
+
stopAllRunEventForwarders,
|
|
88
|
+
} from "./run-event-forwarder.js";
|
|
73
89
|
import { stripSkillPreamble } from "./skills.js";
|
|
74
90
|
|
|
75
91
|
// --- Signed-upload handshake (artifacts & card attachments) ---
|
|
@@ -357,6 +373,18 @@ interface MemorySessionState {
|
|
|
357
373
|
// without a round-trip. May be undefined if the start endpoint did not
|
|
358
374
|
// return an id (older clients) — `scope: 'session'` will then refuse.
|
|
359
375
|
agentSessionId?: string;
|
|
376
|
+
/**
|
|
377
|
+
* The tenant this session was recorded under (`ToolDeps.getScopeId`) —
|
|
378
|
+
* `undefined` on stdio, where one process serves one user.
|
|
379
|
+
*
|
|
380
|
+
* `memorySessions` is keyed by card id ALONE and lives at module scope, so on
|
|
381
|
+
* the hosted transport (`remote.ts`, one process for every user) a lookup can
|
|
382
|
+
* hand back another user's session for the same card. Anything that CLAIMS
|
|
383
|
+
* the tracked session must compare this first — see `comment-session.ts`
|
|
384
|
+
* (#1035), where claiming a stranger's id means a 403 rather than a wrong
|
|
385
|
+
* name on a comment.
|
|
386
|
+
*/
|
|
387
|
+
scopeId?: string;
|
|
360
388
|
memoryReadCount: number;
|
|
361
389
|
pendingActions: { action: string; ts: string }[];
|
|
362
390
|
allActions: { action: string; ts: string }[];
|
|
@@ -489,12 +517,14 @@ function initMemorySession(
|
|
|
489
517
|
agentIdentifier: string,
|
|
490
518
|
agentName: string,
|
|
491
519
|
agentSessionId?: string,
|
|
520
|
+
scopeId?: string,
|
|
492
521
|
): void {
|
|
493
522
|
memorySessions.set(cardId, {
|
|
494
523
|
cardId,
|
|
495
524
|
agentIdentifier,
|
|
496
525
|
agentName,
|
|
497
526
|
agentSessionId,
|
|
527
|
+
scopeId,
|
|
498
528
|
memoryReadCount: 0,
|
|
499
529
|
pendingActions: [],
|
|
500
530
|
allActions: [],
|
|
@@ -660,6 +690,16 @@ export const TOOLS = {
|
|
|
660
690
|
description:
|
|
661
691
|
"Plan ID to link this card to (optional). Links the card to that plan via its plan_id.",
|
|
662
692
|
},
|
|
693
|
+
planTaskId: {
|
|
694
|
+
type: "string",
|
|
695
|
+
description:
|
|
696
|
+
"Id of the plan CRITERION this card is created to fulfil (optional; requires `planId`). " +
|
|
697
|
+
"Sets both directions at once: the card's plan_id, and the criterion's card_id — the " +
|
|
698
|
+
"return leg a card outcome needs to reach the plan. Read the ids from harmony_get_plan's " +
|
|
699
|
+
"`tasks`. A criterion that is not in the named plan refuses the whole call, so no card is " +
|
|
700
|
+
"created on a false premise; a failure to write the return leg AFTER the card exists " +
|
|
701
|
+
"keeps the card and reports it in `planTask` instead.",
|
|
702
|
+
},
|
|
663
703
|
attachments: {
|
|
664
704
|
type: "array",
|
|
665
705
|
description:
|
|
@@ -2326,6 +2366,44 @@ export const TOOLS = {
|
|
|
2326
2366
|
},
|
|
2327
2367
|
},
|
|
2328
2368
|
|
|
2369
|
+
harmony_link_plan_task: {
|
|
2370
|
+
description:
|
|
2371
|
+
"Point a plan CRITERION at the card that fulfils it, and/or set the criterion's status. " +
|
|
2372
|
+
"A plan task is a success criterion, not a work item: `cards.plan_id` says which plan a card " +
|
|
2373
|
+
"belongs to, and this sets the return leg the plan needs to show real progress instead of " +
|
|
2374
|
+
"guessing from board-column names. Use it to repair a link, to re-point a criterion at a " +
|
|
2375
|
+
"different card, or to mark a criterion `completed` once its card actually delivered it. " +
|
|
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.",
|
|
2380
|
+
inputSchema: {
|
|
2381
|
+
type: "object",
|
|
2382
|
+
properties: {
|
|
2383
|
+
planId: { type: "string", description: "Plan ID owning the criterion" },
|
|
2384
|
+
taskId: {
|
|
2385
|
+
type: "string",
|
|
2386
|
+
description:
|
|
2387
|
+
"Criterion id, from harmony_get_plan's `tasks`. Must belong to `planId`.",
|
|
2388
|
+
},
|
|
2389
|
+
cardId: {
|
|
2390
|
+
type: "string",
|
|
2391
|
+
description:
|
|
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.",
|
|
2395
|
+
},
|
|
2396
|
+
status: {
|
|
2397
|
+
type: "string",
|
|
2398
|
+
enum: ["pending", "in_progress", "completed"],
|
|
2399
|
+
description:
|
|
2400
|
+
"Criterion status. Set `completed` only when the card demonstrably delivered it.",
|
|
2401
|
+
},
|
|
2402
|
+
},
|
|
2403
|
+
required: ["planId", "taskId"],
|
|
2404
|
+
},
|
|
2405
|
+
},
|
|
2406
|
+
|
|
2329
2407
|
// ============ PLAYBOOK TOOLS (Method/Loop layer) ============
|
|
2330
2408
|
|
|
2331
2409
|
harmony_list_playbook: {
|
|
@@ -2435,6 +2513,25 @@ export const TOOLS = {
|
|
|
2435
2513
|
},
|
|
2436
2514
|
},
|
|
2437
2515
|
|
|
2516
|
+
harmony_delete_playbook: {
|
|
2517
|
+
// The description carries the cascade because that is where the model
|
|
2518
|
+
// reads it (card #856). Deprecating is the reversible verb and is named
|
|
2519
|
+
// first on purpose: it is almost always the right one, and it is already
|
|
2520
|
+
// available through harmony_update_playbook.
|
|
2521
|
+
description:
|
|
2522
|
+
"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).",
|
|
2523
|
+
inputSchema: {
|
|
2524
|
+
type: "object",
|
|
2525
|
+
properties: {
|
|
2526
|
+
playbookId: {
|
|
2527
|
+
type: "string",
|
|
2528
|
+
description: "Playbook ID to delete (UUID)",
|
|
2529
|
+
},
|
|
2530
|
+
},
|
|
2531
|
+
required: ["playbookId"],
|
|
2532
|
+
},
|
|
2533
|
+
},
|
|
2534
|
+
|
|
2438
2535
|
// ============ ONBOARDING TOOLS ============
|
|
2439
2536
|
harmony_signup: {
|
|
2440
2537
|
description:
|
|
@@ -2819,7 +2916,12 @@ async function resolveColumnByName(
|
|
|
2819
2916
|
return col;
|
|
2820
2917
|
}
|
|
2821
2918
|
|
|
2822
|
-
|
|
2919
|
+
/**
|
|
2920
|
+
* Exported for tests (card #1038). A pure helper proves its own signature and nothing
|
|
2921
|
+
* about the call site — the two-direction plan link is only real if THIS switch performs
|
|
2922
|
+
* both writes, so the wiring needs a test that drives the tool by name.
|
|
2923
|
+
*/
|
|
2924
|
+
export async function handleToolCall(
|
|
2823
2925
|
name: string,
|
|
2824
2926
|
args: Record<string, unknown>,
|
|
2825
2927
|
deps: ToolDeps,
|
|
@@ -2877,17 +2979,66 @@ async function handleToolCall(
|
|
|
2877
2979
|
)
|
|
2878
2980
|
.parse(args.attachments)
|
|
2879
2981
|
: [];
|
|
2982
|
+
// Plan criterion ↔ card, both directions in one call (card #1038).
|
|
2983
|
+
const planId = args.planId
|
|
2984
|
+
? z.string().uuid().parse(args.planId)
|
|
2985
|
+
: undefined;
|
|
2986
|
+
const planTaskId = args.planTaskId
|
|
2987
|
+
? z.string().uuid().parse(args.planTaskId)
|
|
2988
|
+
: undefined;
|
|
2989
|
+
if (planTaskId && !planId) {
|
|
2990
|
+
throw new Error(
|
|
2991
|
+
"planTaskId requires planId: no route resolves a plan from a criterion id alone. " +
|
|
2992
|
+
"Pass the plan the criterion belongs to — harmony_get_plan returns both.",
|
|
2993
|
+
);
|
|
2994
|
+
}
|
|
2995
|
+
// Refuse BEFORE the card exists. A criterion that is not in the named plan means the
|
|
2996
|
+
// caller is pointing at the wrong thing, and a card created on that premise is worse
|
|
2997
|
+
// than no card. A failure AFTER the card exists is handled the other way round below.
|
|
2998
|
+
let criterion: PlanTaskRow | undefined;
|
|
2999
|
+
if (planTaskId && planId) {
|
|
3000
|
+
const { tasks } = await client.getPlan(planId);
|
|
3001
|
+
const found = findPlanTask(tasks, planTaskId);
|
|
3002
|
+
if (!found.ok) throw new Error(found.reason);
|
|
3003
|
+
criterion = found.task;
|
|
3004
|
+
}
|
|
3005
|
+
|
|
2880
3006
|
const result = await client.createCard(projectId, {
|
|
2881
3007
|
title,
|
|
2882
3008
|
columnId: args.columnId as string | undefined,
|
|
2883
3009
|
description: args.description as string | undefined,
|
|
2884
3010
|
priority: args.priority as string | undefined,
|
|
2885
3011
|
assigneeId: args.assigneeId as string | undefined,
|
|
2886
|
-
planId
|
|
3012
|
+
planId,
|
|
2887
3013
|
});
|
|
2888
3014
|
|
|
3015
|
+
const newCardId = (result.card as { id?: string } | null)?.id;
|
|
3016
|
+
|
|
3017
|
+
// The return leg. Never throws: the card is the expensive artifact, and a missing
|
|
3018
|
+
// link is repairable with harmony_link_plan_task. Reported, never swallowed.
|
|
3019
|
+
let planTask: PlanTaskLinkReport | undefined;
|
|
3020
|
+
if (criterion && planId) {
|
|
3021
|
+
if (!newCardId) {
|
|
3022
|
+
planTask = unlinkedReport(
|
|
3023
|
+
planId,
|
|
3024
|
+
criterion,
|
|
3025
|
+
new Error("no card id was returned to link against"),
|
|
3026
|
+
);
|
|
3027
|
+
} else {
|
|
3028
|
+
try {
|
|
3029
|
+
await client.updatePlanTask(planId, criterion.id, {
|
|
3030
|
+
cardId: newCardId,
|
|
3031
|
+
});
|
|
3032
|
+
planTask = linkedReport(planId, criterion, newCardId);
|
|
3033
|
+
} catch (err) {
|
|
3034
|
+
planTask = unlinkedReport(planId, criterion, err);
|
|
3035
|
+
}
|
|
3036
|
+
}
|
|
3037
|
+
}
|
|
3038
|
+
const planTaskField = planTask ? { planTask } : {};
|
|
3039
|
+
|
|
2889
3040
|
if (attachments.length === 0) {
|
|
2890
|
-
return { success: true, ...result };
|
|
3041
|
+
return { success: true, ...result, ...planTaskField };
|
|
2891
3042
|
}
|
|
2892
3043
|
|
|
2893
3044
|
// Attach reference files (e.g. a prompt screenshot) to the freshly
|
|
@@ -2895,11 +3046,11 @@ async function handleToolCall(
|
|
|
2895
3046
|
// runs only after createCard returns. A bad attachment must never lose
|
|
2896
3047
|
// the card — per-file failures are captured and reported alongside the
|
|
2897
3048
|
// successes rather than throwing out the whole create.
|
|
2898
|
-
|
|
2899
|
-
if (!cardId) {
|
|
3049
|
+
if (!newCardId) {
|
|
2900
3050
|
return {
|
|
2901
3051
|
success: true,
|
|
2902
3052
|
...result,
|
|
3053
|
+
...planTaskField,
|
|
2903
3054
|
attachmentWarning:
|
|
2904
3055
|
"Card created, but attachments were skipped: no card id was returned to upload against.",
|
|
2905
3056
|
};
|
|
@@ -2907,7 +3058,7 @@ async function handleToolCall(
|
|
|
2907
3058
|
const attachmentResults = await Promise.all(
|
|
2908
3059
|
attachments.map(async (file) => {
|
|
2909
3060
|
try {
|
|
2910
|
-
const uploaded = await attachFileToCard(client,
|
|
3061
|
+
const uploaded = await attachFileToCard(client, newCardId, file);
|
|
2911
3062
|
return { ok: true as const, attachment: uploaded.attachment };
|
|
2912
3063
|
} catch (err) {
|
|
2913
3064
|
return {
|
|
@@ -2918,7 +3069,12 @@ async function handleToolCall(
|
|
|
2918
3069
|
}
|
|
2919
3070
|
}),
|
|
2920
3071
|
);
|
|
2921
|
-
return {
|
|
3072
|
+
return {
|
|
3073
|
+
success: true,
|
|
3074
|
+
...result,
|
|
3075
|
+
...planTaskField,
|
|
3076
|
+
attachments: attachmentResults,
|
|
3077
|
+
};
|
|
2922
3078
|
}
|
|
2923
3079
|
|
|
2924
3080
|
case "harmony_update_card": {
|
|
@@ -3700,38 +3856,54 @@ async function handleToolCall(
|
|
|
3700
3856
|
args.replyToId !== undefined
|
|
3701
3857
|
? z.string().uuid().parse(args.replyToId)
|
|
3702
3858
|
: undefined;
|
|
3703
|
-
// Name our OWN session
|
|
3704
|
-
// attribution is exact rather than inferred.
|
|
3859
|
+
// Name our OWN session, or say plainly that we hold none (#1035).
|
|
3705
3860
|
//
|
|
3706
|
-
//
|
|
3707
|
-
//
|
|
3708
|
-
//
|
|
3709
|
-
//
|
|
3710
|
-
//
|
|
3711
|
-
//
|
|
3712
|
-
//
|
|
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.
|
|
3861
|
+
// The server no longer infers either way: the `card_id` + `user_id`
|
|
3862
|
+
// fallback in `createComment` is gone, because it was true of every
|
|
3863
|
+
// concurrent run of the same account and a second run inherited the
|
|
3864
|
+
// first one's attribution (measured on card #1029). So this call is now
|
|
3865
|
+
// the whole answer, and `chooseCommentSession` is where it is decided —
|
|
3866
|
+
// read that module for why an absent declaration is an ANSWER here and
|
|
3867
|
+
// was a guess there.
|
|
3720
3868
|
//
|
|
3721
|
-
//
|
|
3722
|
-
//
|
|
3723
|
-
//
|
|
3724
|
-
//
|
|
3725
|
-
//
|
|
3726
|
-
|
|
3869
|
+
// Two declarations, never an observation: the session THIS process
|
|
3870
|
+
// started (`memorySessions`, the interactive `/hmy` path, checked against
|
|
3871
|
+
// the caller's tenant), and the one the daemon put in this process's
|
|
3872
|
+
// environment for the run it serves (the daemon path, which needs one
|
|
3873
|
+
// because `harmony_start_agent_session` is denied on a daemon run).
|
|
3874
|
+
//
|
|
3875
|
+
// Note the explicit branch on the server accepts any of the caller's own
|
|
3876
|
+
// sessions on the card regardless of `ended_at`. That is deliberate and
|
|
3877
|
+
// it is what replaced #771's swept-session grace window: a run the
|
|
3878
|
+
// silence sweep cut off still names its own id and its last report is
|
|
3879
|
+
// still a run artifact, with no time bound, because naming the session is
|
|
3880
|
+
// knowledge where the window was a guess.
|
|
3881
|
+
const sessionChoice = chooseCommentSession({
|
|
3882
|
+
cardId,
|
|
3883
|
+
tracked: getMemorySession(cardId),
|
|
3884
|
+
callerScopeId: deps.getScopeId?.(),
|
|
3885
|
+
declared: readDeclaredRunSession(),
|
|
3886
|
+
});
|
|
3727
3887
|
const result = await client.addComment(cardId, body, {
|
|
3728
3888
|
commentType,
|
|
3729
3889
|
supersedesId,
|
|
3730
3890
|
confirmsId,
|
|
3731
3891
|
replyToId,
|
|
3732
|
-
agentSessionId
|
|
3892
|
+
agentSessionId:
|
|
3893
|
+
sessionChoice.kind === "session"
|
|
3894
|
+
? sessionChoice.agentSessionId
|
|
3895
|
+
: undefined,
|
|
3733
3896
|
});
|
|
3734
|
-
return {
|
|
3897
|
+
return {
|
|
3898
|
+
success: true,
|
|
3899
|
+
...result,
|
|
3900
|
+
// Said out loud rather than left to be inferred from the row: a
|
|
3901
|
+
// sessionless comment is a legal shape (#1033) and a degraded one, and
|
|
3902
|
+
// an agent that expected its run to be named should be able to see
|
|
3903
|
+
// that it was not.
|
|
3904
|
+
sessionAttribution:
|
|
3905
|
+
sessionChoice.kind === "session" ? sessionChoice.source : "none",
|
|
3906
|
+
};
|
|
3735
3907
|
}
|
|
3736
3908
|
|
|
3737
3909
|
case "harmony_get_comments": {
|
|
@@ -4091,7 +4263,25 @@ async function handleToolCall(
|
|
|
4091
4263
|
// backend session id so working-memory writes (`scope: 'session'`) bind
|
|
4092
4264
|
// to the same `card_agent_context` row that progress/end calls target.
|
|
4093
4265
|
const agentSessionId = (result.session as { id?: string } | null)?.id;
|
|
4094
|
-
|
|
4266
|
+
// The scope is recorded WITH the session, so a later read can tell "mine"
|
|
4267
|
+
// from "the same card, a different user on this hosted process" (#1035).
|
|
4268
|
+
initMemorySession(
|
|
4269
|
+
cardId,
|
|
4270
|
+
agentIdentifier,
|
|
4271
|
+
agentName,
|
|
4272
|
+
agentSessionId,
|
|
4273
|
+
deps.getScopeId?.(),
|
|
4274
|
+
);
|
|
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
|
+
});
|
|
4095
4285
|
|
|
4096
4286
|
return {
|
|
4097
4287
|
success: true,
|
|
@@ -4207,6 +4397,11 @@ async function handleToolCall(
|
|
|
4207
4397
|
await flushMemoryActions(client, cardId);
|
|
4208
4398
|
cleanupMemorySession(cardId);
|
|
4209
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
|
+
|
|
4210
4405
|
// End the session — tolerate failure (e.g., session already ended or not found).
|
|
4211
4406
|
// Typed off the client so the `ended`/`reason` discriminator (#769) reaches the
|
|
4212
4407
|
// tool payload by contract, not by accident of the spread below. Left absent
|
|
@@ -5249,7 +5444,11 @@ async function handleToolCall(
|
|
|
5249
5444
|
}
|
|
5250
5445
|
|
|
5251
5446
|
case "harmony_get_plan": {
|
|
5252
|
-
let result: {
|
|
5447
|
+
let result: {
|
|
5448
|
+
plan: unknown;
|
|
5449
|
+
tasks: unknown[];
|
|
5450
|
+
foreign_criteria?: unknown[];
|
|
5451
|
+
} | null = null;
|
|
5253
5452
|
|
|
5254
5453
|
if (args.planId) {
|
|
5255
5454
|
const planId = z.string().uuid().parse(args.planId);
|
|
@@ -5257,7 +5456,14 @@ async function handleToolCall(
|
|
|
5257
5456
|
} else if (args.cardId) {
|
|
5258
5457
|
const cardId = z.string().uuid().parse(args.cardId);
|
|
5259
5458
|
result = await client.getPlanByCardId(cardId);
|
|
5260
|
-
|
|
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
|
+
) {
|
|
5261
5467
|
return {
|
|
5262
5468
|
success: true,
|
|
5263
5469
|
plan: null,
|
|
@@ -5265,6 +5471,27 @@ async function handleToolCall(
|
|
|
5265
5471
|
message: "No plan linked to this card",
|
|
5266
5472
|
};
|
|
5267
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
|
+
}
|
|
5268
5495
|
} else {
|
|
5269
5496
|
throw new Error("Either planId or cardId must be provided");
|
|
5270
5497
|
}
|
|
@@ -5273,6 +5500,11 @@ async function handleToolCall(
|
|
|
5273
5500
|
success: true,
|
|
5274
5501
|
plan: result.plan,
|
|
5275
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
|
+
: {}),
|
|
5276
5508
|
};
|
|
5277
5509
|
}
|
|
5278
5510
|
|
|
@@ -5297,6 +5529,54 @@ async function handleToolCall(
|
|
|
5297
5529
|
return { success: true, plan: result.plan };
|
|
5298
5530
|
}
|
|
5299
5531
|
|
|
5532
|
+
case "harmony_link_plan_task": {
|
|
5533
|
+
// The return leg on its own (card #1038): repair a link, re-point a criterion, or
|
|
5534
|
+
// mark one delivered. `harmony_create_card` writes it at birth; this is every other
|
|
5535
|
+
// moment, and it is what a completion path calls to close the loop.
|
|
5536
|
+
const planId = z.string().uuid().parse(args.planId);
|
|
5537
|
+
const taskId = z.string().uuid().parse(args.taskId);
|
|
5538
|
+
const cardId = args.cardId
|
|
5539
|
+
? z.string().uuid().parse(args.cardId)
|
|
5540
|
+
: undefined;
|
|
5541
|
+
const status = args.status
|
|
5542
|
+
? z.enum(["pending", "in_progress", "completed"]).parse(args.status)
|
|
5543
|
+
: undefined;
|
|
5544
|
+
if (!cardId && !status) {
|
|
5545
|
+
throw new Error("Nothing to do: pass cardId, status, or both.");
|
|
5546
|
+
}
|
|
5547
|
+
|
|
5548
|
+
// Same fail-closed read as the create path — the criterion has to be in THIS plan.
|
|
5549
|
+
// The route scopes its update by plan_id too, but a rejection that names the
|
|
5550
|
+
// mistake beats a zero-row update surfacing as an opaque database error.
|
|
5551
|
+
const { tasks } = await client.getPlan(planId);
|
|
5552
|
+
const found = findPlanTask(tasks, taskId);
|
|
5553
|
+
if (!found.ok) throw new Error(found.reason);
|
|
5554
|
+
|
|
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
|
+
});
|
|
5563
|
+
return {
|
|
5564
|
+
success: true,
|
|
5565
|
+
planTask: {
|
|
5566
|
+
planId,
|
|
5567
|
+
taskId,
|
|
5568
|
+
criterion: found.task.content ?? null,
|
|
5569
|
+
...(cardId
|
|
5570
|
+
? linkedReport(planId, found.task, cardId)
|
|
5571
|
+
: { linked: found.task.card_id != null }),
|
|
5572
|
+
...(cardId && result.cardPlanAdopted
|
|
5573
|
+
? { cardPlanAdopted: true }
|
|
5574
|
+
: {}),
|
|
5575
|
+
...(status ? { status } : {}),
|
|
5576
|
+
},
|
|
5577
|
+
};
|
|
5578
|
+
}
|
|
5579
|
+
|
|
5300
5580
|
case "harmony_advance_plan": {
|
|
5301
5581
|
// Simplified: just archive the plan
|
|
5302
5582
|
const planId = z.string().uuid().parse(args.planId);
|
|
@@ -5435,6 +5715,26 @@ async function handleToolCall(
|
|
|
5435
5715
|
};
|
|
5436
5716
|
}
|
|
5437
5717
|
|
|
5718
|
+
case "harmony_delete_playbook": {
|
|
5719
|
+
const playbookId = z.string().uuid().parse(args.playbookId);
|
|
5720
|
+
// No `confirm` argument, deliberately. Every other destructive tool here
|
|
5721
|
+
// (harmony_delete_card, harmony_delete_column — which deletes its cards)
|
|
5722
|
+
// takes none, and a flag the caller chooses is not a bound: the model
|
|
5723
|
+
// that decided to delete also supplies the confirmation. What actually
|
|
5724
|
+
// holds is the route's creator-or-admin check, the description above
|
|
5725
|
+
// pointing at deprecate, and this reply naming what was unbound.
|
|
5726
|
+
const result = await client.deletePlaybook(playbookId);
|
|
5727
|
+
// The count is the server's exact one; the list is its capped sample. Do
|
|
5728
|
+
// not derive the count from the list — that silently under-reports the
|
|
5729
|
+
// blast radius on exactly the playbook where it matters most.
|
|
5730
|
+
return {
|
|
5731
|
+
success: true,
|
|
5732
|
+
playbook: result.playbook,
|
|
5733
|
+
unboundCardCount: result.unboundCardCount,
|
|
5734
|
+
unboundCards: result.unboundCards,
|
|
5735
|
+
};
|
|
5736
|
+
}
|
|
5737
|
+
|
|
5438
5738
|
// Deprecated (#612) — see harmony_run_playbook above.
|
|
5439
5739
|
case "harmony_save_card_as_playbook":
|
|
5440
5740
|
return deprecatedRemovedToolResult("harmony_save_card_as_playbook");
|
|
@@ -5616,6 +5916,14 @@ export class HarmonyMCPServer {
|
|
|
5616
5916
|
} catch {
|
|
5617
5917
|
// Best-effort
|
|
5618
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
|
+
}
|
|
5619
5927
|
destroyAutoSession();
|
|
5620
5928
|
process.exit(exitCode);
|
|
5621
5929
|
};
|
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",
|