@gethmy/mcp 3.1.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/README.md +2 -2
- package/dist/cli.js +250 -15
- package/dist/index.js +249 -15
- package/dist/lib/api-client.js +42 -4
- package/package.json +1 -1
- package/src/api-client.ts +64 -2
- package/src/comment-session.ts +149 -0
- package/src/plan-task-link.ts +128 -0
- package/src/server.ts +263 -10
- package/src/tui/setup.ts +3 -0
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which session an agent comment names — and when it names none (card #1035).
|
|
3
|
+
*
|
|
4
|
+
* ## The question this replaces
|
|
5
|
+
*
|
|
6
|
+
* `POST /cards/:id/comments` used to answer "which session?" itself when the
|
|
7
|
+
* caller sent none: it selected the caller's sessions on the card by `card_id`
|
|
8
|
+
* + `user_id`. That predicate is true of **every concurrent run of the same
|
|
9
|
+
* account**, so a second run inherited the first one's attribution — measured
|
|
10
|
+
* on card #1029, where two findings came back carrying a neighbouring `/hmy`
|
|
11
|
+
* session's id. The server could not do better, because `user_id` is the only
|
|
12
|
+
* thing it has and the daemon's API key resolves to the same user as its
|
|
13
|
+
* launcher's own sessions.
|
|
14
|
+
*
|
|
15
|
+
* So the answer moved to the only place that can know it: the process making
|
|
16
|
+
* the call. This module is that decision, kept pure so it is a table test
|
|
17
|
+
* rather than a behaviour reconstructed from a live run.
|
|
18
|
+
*
|
|
19
|
+
* ## Two sources, and why absence is now an ANSWER rather than a guess
|
|
20
|
+
*
|
|
21
|
+
* A caller may hold a session two ways, and both are DECLARATIONS rather than
|
|
22
|
+
* observations:
|
|
23
|
+
*
|
|
24
|
+
* - `tracked` — this process called `harmony_start_agent_session` and kept the
|
|
25
|
+
* id (`memorySessions`). It is the interactive `/hmy` path.
|
|
26
|
+
* - `declared` — the daemon put the run's id in this process's environment
|
|
27
|
+
* (`harmonyMcpServer` in `@gethmy/harness`, and `motor-driver.ts` for a stage
|
|
28
|
+
* run). It is the daemon path, which needs one because
|
|
29
|
+
* `harmony_start_agent_session` is DENIED on a daemon run
|
|
30
|
+
* (`STAGE_DAEMON_OWNED_TOOLS`) — the daemon owns the lifecycle, so the agent
|
|
31
|
+
* has nothing of its own to track.
|
|
32
|
+
*
|
|
33
|
+
* Neither is an inference about the ACCOUNT, and that is the whole change.
|
|
34
|
+
* The old question — "does this user have a session on this card?" — has an
|
|
35
|
+
* ambiguous negative, because a process that tracks nothing may still belong to
|
|
36
|
+
* a user with three live runs. The new question — "was a session declared to
|
|
37
|
+
* THIS process for THIS card?" — has a definite negative: nothing declared
|
|
38
|
+
* means this caller holds no session it may claim, which is exactly the
|
|
39
|
+
* sessionless comment #1033 made legal. There is no third "I do not know"
|
|
40
|
+
* state left for the server to guess at.
|
|
41
|
+
*
|
|
42
|
+
* ## The scope check on `tracked` is load-bearing
|
|
43
|
+
*
|
|
44
|
+
* `memorySessions` is keyed by card id alone and lives at module scope, so on
|
|
45
|
+
* the hosted HTTP transport (`remote.ts`, one process serving every user) a
|
|
46
|
+
* lookup can return a DIFFERENT user's session for the same card. Claiming it
|
|
47
|
+
* is not merely wrong attribution — harmony-api verifies an explicit
|
|
48
|
+
* `agentSessionId` against `user_id` and answers 403 — so the tracked source
|
|
49
|
+
* carries the scope it was recorded under and is used only when that matches
|
|
50
|
+
* the caller's. A mismatch is treated as "not mine", never as "theirs".
|
|
51
|
+
*
|
|
52
|
+
* ## The card check on `declared` is load-bearing too
|
|
53
|
+
*
|
|
54
|
+
* A daemon run comments on its own card and, since #1033, on neighbouring ones
|
|
55
|
+
* — that is how a finding reaches a card the run does not hold. The run's
|
|
56
|
+
* session belongs to ONE card, so the declaration carries the card id and is
|
|
57
|
+
* used only on that card. On any other card the same run is genuinely
|
|
58
|
+
* sessionless, and says so.
|
|
59
|
+
*/
|
|
60
|
+
|
|
61
|
+
/** A run's own session, as the daemon declared it to this process. */
|
|
62
|
+
export interface DeclaredRunSession {
|
|
63
|
+
/** `cards.id` — the card the run holds its session on. */
|
|
64
|
+
cardId: string;
|
|
65
|
+
/** `card_agent_context.id` — the run's own session row. */
|
|
66
|
+
agentSessionId: string;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** The session this process tracks for a card, and the tenant it belongs to. */
|
|
70
|
+
export interface TrackedCommentSession {
|
|
71
|
+
/** `card_agent_context.id`, captured when this process started the session. */
|
|
72
|
+
agentSessionId?: string;
|
|
73
|
+
/**
|
|
74
|
+
* The tenant the session was recorded under (`ToolDeps.getScopeId`).
|
|
75
|
+
* `undefined` on stdio, where the process serves exactly one user.
|
|
76
|
+
*/
|
|
77
|
+
scopeId?: string;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** What `harmony_add_comment` should send for `agentSessionId`. */
|
|
81
|
+
export type CommentSessionChoice =
|
|
82
|
+
| {
|
|
83
|
+
kind: "session";
|
|
84
|
+
agentSessionId: string;
|
|
85
|
+
/** Which declaration named it — for the tool result, so the caller can see. */
|
|
86
|
+
source: "tracked" | "declared";
|
|
87
|
+
}
|
|
88
|
+
| { kind: "sessionless" };
|
|
89
|
+
|
|
90
|
+
/** The two environment keys the daemon declares a run's session through. */
|
|
91
|
+
export const RUN_SESSION_CARD_ENV = "HARMONY_AGENT_CARD_ID";
|
|
92
|
+
export const RUN_SESSION_ID_ENV = "HARMONY_AGENT_SESSION_ID";
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* The run this MCP process was started for, if the daemon declared one.
|
|
96
|
+
*
|
|
97
|
+
* Read per call rather than latched at startup: the value cannot change under a
|
|
98
|
+
* stdio server (one process per run), and re-reading keeps this a pure function
|
|
99
|
+
* of its input, which is what lets the tests drive it with a synthetic
|
|
100
|
+
* environment instead of `process.env`.
|
|
101
|
+
*
|
|
102
|
+
* BOTH keys or nothing. A card id with no session is a run that declared
|
|
103
|
+
* nothing usable, and a session id with no card cannot be bounded to the card
|
|
104
|
+
* it belongs to — claiming it on every card is the borrowing this replaces.
|
|
105
|
+
*/
|
|
106
|
+
export function readDeclaredRunSession(
|
|
107
|
+
env: Record<string, string | undefined> = process.env,
|
|
108
|
+
): DeclaredRunSession | null {
|
|
109
|
+
const cardId = env[RUN_SESSION_CARD_ENV]?.trim();
|
|
110
|
+
const agentSessionId = env[RUN_SESSION_ID_ENV]?.trim();
|
|
111
|
+
if (!cardId || !agentSessionId) return null;
|
|
112
|
+
return { cardId, agentSessionId };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Decide what a new agent comment on `cardId` is attributed to.
|
|
117
|
+
*
|
|
118
|
+
* `tracked` wins over `declared` when both name a session for the card. They
|
|
119
|
+
* cannot disagree in practice — a daemon run may not open a session of its own
|
|
120
|
+
* — but if one ever did, the session this process opened is the one it can
|
|
121
|
+
* prove it owns.
|
|
122
|
+
*/
|
|
123
|
+
export function chooseCommentSession(args: {
|
|
124
|
+
cardId: string;
|
|
125
|
+
tracked?: TrackedCommentSession | undefined;
|
|
126
|
+
/** The caller's tenant (`ToolDeps.getScopeId`); `undefined` on stdio. */
|
|
127
|
+
callerScopeId?: string | undefined;
|
|
128
|
+
declared?: DeclaredRunSession | null;
|
|
129
|
+
}): CommentSessionChoice {
|
|
130
|
+
const tracked = args.tracked;
|
|
131
|
+
if (tracked?.agentSessionId && tracked.scopeId === args.callerScopeId) {
|
|
132
|
+
return {
|
|
133
|
+
kind: "session",
|
|
134
|
+
agentSessionId: tracked.agentSessionId,
|
|
135
|
+
source: "tracked",
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const declared = args.declared;
|
|
140
|
+
if (declared && declared.cardId === args.cardId) {
|
|
141
|
+
return {
|
|
142
|
+
kind: "session",
|
|
143
|
+
agentSessionId: declared.agentSessionId,
|
|
144
|
+
source: "declared",
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return { kind: "sessionless" };
|
|
149
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Plan criterion ↔ card linking (card #1038, plan "Harmony SDLC — ein Rückgrat").
|
|
3
|
+
*
|
|
4
|
+
* A `plan_task` row is a plan's **success criterion**, not a work item. `cards.plan_id`
|
|
5
|
+
* has always carried the plan → card direction; `plan_tasks.card_id` is the return leg
|
|
6
|
+
* ("Linked card ID if task has been converted to a card", 20260127100000). It shipped
|
|
7
|
+
* with the table and no client ever wrote it, so a card outcome had nowhere to land and
|
|
8
|
+
* plan progress fell back to matching board-column NAMES (`src/lib/planProgress.ts`).
|
|
9
|
+
*
|
|
10
|
+
* Everything here is pure and total, because the interesting cases are the ones a live
|
|
11
|
+
* call cannot cheaply produce: a criterion that belongs to a different plan, a malformed
|
|
12
|
+
* task list off the wire, and the half-written state where the card exists but the return
|
|
13
|
+
* leg did not get written.
|
|
14
|
+
*
|
|
15
|
+
* WHY NOTHING HERE RESOLVES A PLAN FROM A TASK ID ALONE: there is no
|
|
16
|
+
* `GET /plan-tasks/:id` route, and adding one to save the caller a field would be a new
|
|
17
|
+
* server surface for a lookup the caller already has. `planId` is therefore required
|
|
18
|
+
* alongside `planTaskId`, which also makes the two impossible to contradict — the plan is
|
|
19
|
+
* named once and used for both the card's `plan_id` and the criterion's scope.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/** A `plan_tasks` row as it comes back from `GET /plans/:id`. */
|
|
23
|
+
export interface PlanTaskRow {
|
|
24
|
+
id: string;
|
|
25
|
+
plan_id?: string | null;
|
|
26
|
+
card_id?: string | null;
|
|
27
|
+
content?: string | null;
|
|
28
|
+
status?: string | null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export type PlanTaskLookup =
|
|
32
|
+
| { ok: true; task: PlanTaskRow }
|
|
33
|
+
| { ok: false; reason: string };
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Find `taskId` among a plan's criteria.
|
|
37
|
+
*
|
|
38
|
+
* Fails CLOSED on every shape it does not recognise. The caller uses this to decide
|
|
39
|
+
* whether to create a card at all, so "I could not read the list" must never be reported
|
|
40
|
+
* as "the criterion is fine" — a card created against a criterion that does not exist is
|
|
41
|
+
* a card created on a false premise.
|
|
42
|
+
*/
|
|
43
|
+
export function findPlanTask(tasks: unknown, taskId: string): PlanTaskLookup {
|
|
44
|
+
if (!taskId) {
|
|
45
|
+
return { ok: false, reason: "No plan task id was given." };
|
|
46
|
+
}
|
|
47
|
+
if (!Array.isArray(tasks)) {
|
|
48
|
+
return {
|
|
49
|
+
ok: false,
|
|
50
|
+
reason: "The plan returned no readable criteria list.",
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
for (const row of tasks) {
|
|
55
|
+
if (!row || typeof row !== "object") continue;
|
|
56
|
+
const candidate = row as PlanTaskRow;
|
|
57
|
+
if (candidate.id === taskId) {
|
|
58
|
+
return { ok: true, task: candidate };
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return {
|
|
63
|
+
ok: false,
|
|
64
|
+
reason:
|
|
65
|
+
`Plan task ${taskId} is not one of this plan's criteria. ` +
|
|
66
|
+
`Read the plan with harmony_get_plan and use an id from its \`tasks\`.`,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** What happened to the return leg after a card was created. */
|
|
71
|
+
export interface PlanTaskLinkReport {
|
|
72
|
+
planId: string;
|
|
73
|
+
taskId: string;
|
|
74
|
+
/** True once `plan_tasks.card_id` points at the new card. */
|
|
75
|
+
linked: boolean;
|
|
76
|
+
/** The criterion text, echoed so the caller sees what the card now answers for. */
|
|
77
|
+
criterion?: string | null;
|
|
78
|
+
/** Present only when `linked` is false. */
|
|
79
|
+
error?: string;
|
|
80
|
+
/** Set when the criterion already pointed at a different card. */
|
|
81
|
+
replacedCardId?: string | null;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Build the report for a criterion whose return leg was written.
|
|
86
|
+
*
|
|
87
|
+
* `replacedCardId` is reported rather than refused: re-pointing a criterion at a new card
|
|
88
|
+
* is legitimate (the first card was abandoned, or the criterion moved), and a silent
|
|
89
|
+
* overwrite is the part that would be wrong.
|
|
90
|
+
*/
|
|
91
|
+
export function linkedReport(
|
|
92
|
+
planId: string,
|
|
93
|
+
task: PlanTaskRow,
|
|
94
|
+
newCardId: string,
|
|
95
|
+
): PlanTaskLinkReport {
|
|
96
|
+
const previous = task.card_id ?? null;
|
|
97
|
+
return {
|
|
98
|
+
planId,
|
|
99
|
+
taskId: task.id,
|
|
100
|
+
linked: true,
|
|
101
|
+
criterion: task.content ?? null,
|
|
102
|
+
...(previous && previous !== newCardId ? { replacedCardId: previous } : {}),
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Build the report for a return leg that did NOT get written.
|
|
108
|
+
*
|
|
109
|
+
* The card is deliberately kept. A card without its return leg is repairable with
|
|
110
|
+
* `harmony_link_plan_task`; a card thrown away because a second write failed is not.
|
|
111
|
+
* The message says exactly that, so the caller does not retry the create.
|
|
112
|
+
*/
|
|
113
|
+
export function unlinkedReport(
|
|
114
|
+
planId: string,
|
|
115
|
+
task: PlanTaskRow,
|
|
116
|
+
error: unknown,
|
|
117
|
+
): PlanTaskLinkReport {
|
|
118
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
119
|
+
return {
|
|
120
|
+
planId,
|
|
121
|
+
taskId: task.id,
|
|
122
|
+
linked: false,
|
|
123
|
+
criterion: task.content ?? null,
|
|
124
|
+
error:
|
|
125
|
+
`The card was created, but the plan criterion still does not point at it: ${message}. ` +
|
|
126
|
+
`Repair it with harmony_link_plan_task — do not create the card again.`,
|
|
127
|
+
};
|
|
128
|
+
}
|
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:
|
|
@@ -1316,7 +1351,7 @@ export const TOOLS = {
|
|
|
1316
1351
|
// Comment operations
|
|
1317
1352
|
harmony_add_comment: {
|
|
1318
1353
|
description:
|
|
1319
|
-
"Post a comment on a card as the agent — converse with the human in the open: report progress, ask a question, record a decision, or note a finding, instead of editing the card description. Set supersedesId to correct an earlier comment, confirmsId to reaffirm one. To answer an open question, reply to it with replyToId.",
|
|
1354
|
+
"Post a comment on a card as the agent — converse with the human in the open: report progress, ask a question, record a decision, or note a finding, instead of editing the card description. Works on ANY card you can see, including one you hold no session on, so you can leave a finding on a neighbouring card without claiming it. Set supersedesId to correct an earlier comment, confirmsId to reaffirm one. To answer an open question, reply to it with replyToId.",
|
|
1320
1355
|
inputSchema: {
|
|
1321
1356
|
type: "object",
|
|
1322
1357
|
properties: {
|
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
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,
|
|
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 {
|
|
3062
|
+
return {
|
|
3063
|
+
success: true,
|
|
3064
|
+
...result,
|
|
3065
|
+
...planTaskField,
|
|
3066
|
+
attachments: attachmentResults,
|
|
3067
|
+
};
|
|
2922
3068
|
}
|
|
2923
3069
|
|
|
2924
3070
|
case "harmony_update_card": {
|
|
@@ -3700,13 +3846,54 @@ async function handleToolCall(
|
|
|
3700
3846
|
args.replyToId !== undefined
|
|
3701
3847
|
? z.string().uuid().parse(args.replyToId)
|
|
3702
3848
|
: undefined;
|
|
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.
|
|
3858
|
+
//
|
|
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).
|
|
3864
|
+
//
|
|
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
|
+
});
|
|
3703
3877
|
const result = await client.addComment(cardId, body, {
|
|
3704
3878
|
commentType,
|
|
3705
3879
|
supersedesId,
|
|
3706
3880
|
confirmsId,
|
|
3707
3881
|
replyToId,
|
|
3882
|
+
agentSessionId:
|
|
3883
|
+
sessionChoice.kind === "session"
|
|
3884
|
+
? sessionChoice.agentSessionId
|
|
3885
|
+
: undefined,
|
|
3708
3886
|
});
|
|
3709
|
-
return {
|
|
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
|
+
};
|
|
3710
3897
|
}
|
|
3711
3898
|
|
|
3712
3899
|
case "harmony_get_comments": {
|
|
@@ -4066,7 +4253,15 @@ async function handleToolCall(
|
|
|
4066
4253
|
// backend session id so working-memory writes (`scope: 'session'`) bind
|
|
4067
4254
|
// to the same `card_agent_context` row that progress/end calls target.
|
|
4068
4255
|
const agentSessionId = (result.session as { id?: string } | null)?.id;
|
|
4069
|
-
|
|
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
|
+
);
|
|
4070
4265
|
|
|
4071
4266
|
return {
|
|
4072
4267
|
success: true,
|
|
@@ -5272,6 +5467,44 @@ async function handleToolCall(
|
|
|
5272
5467
|
return { success: true, plan: result.plan };
|
|
5273
5468
|
}
|
|
5274
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
|
+
|
|
5275
5508
|
case "harmony_advance_plan": {
|
|
5276
5509
|
// Simplified: just archive the plan
|
|
5277
5510
|
const planId = z.string().uuid().parse(args.planId);
|
|
@@ -5410,6 +5643,26 @@ async function handleToolCall(
|
|
|
5410
5643
|
};
|
|
5411
5644
|
}
|
|
5412
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
|
+
|
|
5413
5666
|
// Deprecated (#612) — see harmony_run_playbook above.
|
|
5414
5667
|
case "harmony_save_card_as_playbook":
|
|
5415
5668
|
return deprecatedRemovedToolResult("harmony_save_card_as_playbook");
|