@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/README.md +2 -2
- package/dist/cli.js +209 -12
- package/dist/index.js +208 -12
- package/dist/lib/api-client.js +3 -1
- package/package.json +1 -1
- package/src/api-client.ts +31 -0
- package/src/comment-session.ts +149 -0
- package/src/plan-task-link.ts +128 -0
- package/src/server.ts +260 -32
- package/src/tui/setup.ts +3 -0
package/dist/index.js
CHANGED
|
@@ -1384,7 +1384,6 @@ var AGENT_MILESTONE_LIVENESS_MS = 30 * 60 * 1000;
|
|
|
1384
1384
|
var AGENT_SWEEP_DAEMON_MS = 30 * 60 * 1000;
|
|
1385
1385
|
var AGENT_SWEEP_INTERACTIVE_MS = 2 * 60 * 60 * 1000;
|
|
1386
1386
|
var AGENT_SWEEP_PAUSED_MS = 4 * 60 * 60 * 1000;
|
|
1387
|
-
var SWEPT_SESSION_WRITE_GRACE_MS = 60 * 60 * 1000;
|
|
1388
1387
|
var ACTIVE_STATUSES = new Set(["working", "blocked", "waiting"]);
|
|
1389
1388
|
// ../harmony-shared/dist/cardLinks.js
|
|
1390
1389
|
var LINK_TYPE_INVERSES = {
|
|
@@ -2612,6 +2611,9 @@ ${untrustedDataBlock(planContent.trim(), {
|
|
|
2612
2611
|
async updatePlaybook(playbookId, updates) {
|
|
2613
2612
|
return this.request("PATCH", `/playbooks/${playbookId}`, updates);
|
|
2614
2613
|
}
|
|
2614
|
+
async deletePlaybook(playbookId) {
|
|
2615
|
+
return this.request("DELETE", `/playbooks/${encodeURIComponent(playbookId)}`);
|
|
2616
|
+
}
|
|
2615
2617
|
}
|
|
2616
2618
|
var _promptModules = null;
|
|
2617
2619
|
async function loadPromptModules() {
|
|
@@ -2849,6 +2851,36 @@ async function autoEndSession(scope, client3, cardId, status) {
|
|
|
2849
2851
|
} catch {}
|
|
2850
2852
|
}
|
|
2851
2853
|
|
|
2854
|
+
// src/comment-session.ts
|
|
2855
|
+
var RUN_SESSION_CARD_ENV = "HARMONY_AGENT_CARD_ID";
|
|
2856
|
+
var RUN_SESSION_ID_ENV = "HARMONY_AGENT_SESSION_ID";
|
|
2857
|
+
function readDeclaredRunSession(env = process.env) {
|
|
2858
|
+
const cardId = env[RUN_SESSION_CARD_ENV]?.trim();
|
|
2859
|
+
const agentSessionId = env[RUN_SESSION_ID_ENV]?.trim();
|
|
2860
|
+
if (!cardId || !agentSessionId)
|
|
2861
|
+
return null;
|
|
2862
|
+
return { cardId, agentSessionId };
|
|
2863
|
+
}
|
|
2864
|
+
function chooseCommentSession(args) {
|
|
2865
|
+
const tracked = args.tracked;
|
|
2866
|
+
if (tracked?.agentSessionId && tracked.scopeId === args.callerScopeId) {
|
|
2867
|
+
return {
|
|
2868
|
+
kind: "session",
|
|
2869
|
+
agentSessionId: tracked.agentSessionId,
|
|
2870
|
+
source: "tracked"
|
|
2871
|
+
};
|
|
2872
|
+
}
|
|
2873
|
+
const declared = args.declared;
|
|
2874
|
+
if (declared && declared.cardId === args.cardId) {
|
|
2875
|
+
return {
|
|
2876
|
+
kind: "session",
|
|
2877
|
+
agentSessionId: declared.agentSessionId,
|
|
2878
|
+
source: "declared"
|
|
2879
|
+
};
|
|
2880
|
+
}
|
|
2881
|
+
return { kind: "sessionless" };
|
|
2882
|
+
}
|
|
2883
|
+
|
|
2852
2884
|
// src/server.ts
|
|
2853
2885
|
init_config();
|
|
2854
2886
|
|
|
@@ -3447,6 +3479,51 @@ async function onboardNewUser(params) {
|
|
|
3447
3479
|
};
|
|
3448
3480
|
}
|
|
3449
3481
|
|
|
3482
|
+
// src/plan-task-link.ts
|
|
3483
|
+
function findPlanTask(tasks, taskId) {
|
|
3484
|
+
if (!taskId) {
|
|
3485
|
+
return { ok: false, reason: "No plan task id was given." };
|
|
3486
|
+
}
|
|
3487
|
+
if (!Array.isArray(tasks)) {
|
|
3488
|
+
return {
|
|
3489
|
+
ok: false,
|
|
3490
|
+
reason: "The plan returned no readable criteria list."
|
|
3491
|
+
};
|
|
3492
|
+
}
|
|
3493
|
+
for (const row of tasks) {
|
|
3494
|
+
if (!row || typeof row !== "object")
|
|
3495
|
+
continue;
|
|
3496
|
+
const candidate = row;
|
|
3497
|
+
if (candidate.id === taskId) {
|
|
3498
|
+
return { ok: true, task: candidate };
|
|
3499
|
+
}
|
|
3500
|
+
}
|
|
3501
|
+
return {
|
|
3502
|
+
ok: false,
|
|
3503
|
+
reason: `Plan task ${taskId} is not one of this plan's criteria. ` + `Read the plan with harmony_get_plan and use an id from its \`tasks\`.`
|
|
3504
|
+
};
|
|
3505
|
+
}
|
|
3506
|
+
function linkedReport(planId, task, newCardId) {
|
|
3507
|
+
const previous = task.card_id ?? null;
|
|
3508
|
+
return {
|
|
3509
|
+
planId,
|
|
3510
|
+
taskId: task.id,
|
|
3511
|
+
linked: true,
|
|
3512
|
+
criterion: task.content ?? null,
|
|
3513
|
+
...previous && previous !== newCardId ? { replacedCardId: previous } : {}
|
|
3514
|
+
};
|
|
3515
|
+
}
|
|
3516
|
+
function unlinkedReport(planId, task, error) {
|
|
3517
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
3518
|
+
return {
|
|
3519
|
+
planId,
|
|
3520
|
+
taskId: task.id,
|
|
3521
|
+
linked: false,
|
|
3522
|
+
criterion: task.content ?? null,
|
|
3523
|
+
error: `The card was created, but the plan criterion still does not point at it: ${message}. ` + `Repair it with harmony_link_plan_task — do not create the card again.`
|
|
3524
|
+
};
|
|
3525
|
+
}
|
|
3526
|
+
|
|
3450
3527
|
// src/playbook-metric-warnings.ts
|
|
3451
3528
|
function playbookMetricWarnings(agents, steps) {
|
|
3452
3529
|
if (!Array.isArray(steps))
|
|
@@ -3944,12 +4021,13 @@ function optionalNonNegativeNumberArg(raw, field) {
|
|
|
3944
4021
|
throw new Error(`${field} must be a non-negative number.`);
|
|
3945
4022
|
return n;
|
|
3946
4023
|
}
|
|
3947
|
-
function initMemorySession(cardId, agentIdentifier, agentName, agentSessionId) {
|
|
4024
|
+
function initMemorySession(cardId, agentIdentifier, agentName, agentSessionId, scopeId) {
|
|
3948
4025
|
memorySessions.set(cardId, {
|
|
3949
4026
|
cardId,
|
|
3950
4027
|
agentIdentifier,
|
|
3951
4028
|
agentName,
|
|
3952
4029
|
agentSessionId,
|
|
4030
|
+
scopeId,
|
|
3953
4031
|
memoryReadCount: 0,
|
|
3954
4032
|
pendingActions: [],
|
|
3955
4033
|
allActions: [],
|
|
@@ -4061,6 +4139,10 @@ var TOOLS = {
|
|
|
4061
4139
|
type: "string",
|
|
4062
4140
|
description: "Plan ID to link this card to (optional). Links the card to that plan via its plan_id."
|
|
4063
4141
|
},
|
|
4142
|
+
planTaskId: {
|
|
4143
|
+
type: "string",
|
|
4144
|
+
description: "Id of the plan CRITERION this card is created to fulfil (optional; requires `planId`). " + "Sets both directions at once: the card's plan_id, and the criterion's card_id — the " + "return leg a card outcome needs to reach the plan. Read the ids from harmony_get_plan's " + "`tasks`. A criterion that is not in the named plan refuses the whole call, so no card is " + "created on a false premise; a failure to write the return leg AFTER the card exists " + "keeps the card and reports it in `planTask` instead."
|
|
4145
|
+
},
|
|
4064
4146
|
attachments: {
|
|
4065
4147
|
type: "array",
|
|
4066
4148
|
description: "Optional reference files to attach to the new card (e.g. a screenshot from the prompt). " + "Max 5MB each; PNG/JPEG/GIF/WebP/HEIC/HEIF/PDF/DOC(X)/XLS(X)/TXT/CSV. Each file's bytes " + "come from `filePath` (absolute local path the server reads, preferred) or `base64Data` " + "(small-file fallback; requires fileName). NOTE: a pasted image only attaches if your " + "harness has written it to a local file you can pass as filePath — a model cannot re-emit " + "pasted image bytes into base64Data. Per-file failures never block card creation; they are " + "reported back in the result's `attachments` array so you can retry via harmony_upload.",
|
|
@@ -5538,6 +5620,29 @@ var TOOLS = {
|
|
|
5538
5620
|
required: ["planId"]
|
|
5539
5621
|
}
|
|
5540
5622
|
},
|
|
5623
|
+
harmony_link_plan_task: {
|
|
5624
|
+
description: "Point a plan CRITERION at the card that fulfils it, and/or set the criterion's status. " + "A plan task is a success criterion, not a work item: `cards.plan_id` says which plan a card " + "belongs to, and this sets the return leg the plan needs to show real progress instead of " + "guessing from board-column names. Use it to repair a link, to re-point a criterion at a " + "different card, or to mark a criterion `completed` once its card actually delivered it. " + "Leave a criterion the card did NOT deliver open — an open criterion is the signal.",
|
|
5625
|
+
inputSchema: {
|
|
5626
|
+
type: "object",
|
|
5627
|
+
properties: {
|
|
5628
|
+
planId: { type: "string", description: "Plan ID owning the criterion" },
|
|
5629
|
+
taskId: {
|
|
5630
|
+
type: "string",
|
|
5631
|
+
description: "Criterion id, from harmony_get_plan's `tasks`. Must belong to `planId`."
|
|
5632
|
+
},
|
|
5633
|
+
cardId: {
|
|
5634
|
+
type: "string",
|
|
5635
|
+
description: "Card that fulfils this criterion. Must live in the plan's own project."
|
|
5636
|
+
},
|
|
5637
|
+
status: {
|
|
5638
|
+
type: "string",
|
|
5639
|
+
enum: ["pending", "in_progress", "completed"],
|
|
5640
|
+
description: "Criterion status. Set `completed` only when the card demonstrably delivered it."
|
|
5641
|
+
}
|
|
5642
|
+
},
|
|
5643
|
+
required: ["planId", "taskId"]
|
|
5644
|
+
}
|
|
5645
|
+
},
|
|
5541
5646
|
harmony_list_playbook: {
|
|
5542
5647
|
description: "List a workspace's playbooks (reusable process definitions). Returns each playbook's name, version, and state. Read-only.",
|
|
5543
5648
|
inputSchema: {
|
|
@@ -5632,6 +5737,19 @@ var TOOLS = {
|
|
|
5632
5737
|
required: ["playbookId"]
|
|
5633
5738
|
}
|
|
5634
5739
|
},
|
|
5740
|
+
harmony_delete_playbook: {
|
|
5741
|
+
description: "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).",
|
|
5742
|
+
inputSchema: {
|
|
5743
|
+
type: "object",
|
|
5744
|
+
properties: {
|
|
5745
|
+
playbookId: {
|
|
5746
|
+
type: "string",
|
|
5747
|
+
description: "Playbook ID to delete (UUID)"
|
|
5748
|
+
}
|
|
5749
|
+
},
|
|
5750
|
+
required: ["playbookId"]
|
|
5751
|
+
}
|
|
5752
|
+
},
|
|
5635
5753
|
harmony_signup: {
|
|
5636
5754
|
description: "Create a new user account. Returns a JWT session for subsequent authenticated calls. No API key required.",
|
|
5637
5755
|
inputSchema: {
|
|
@@ -5926,28 +6044,58 @@ async function handleToolCall(name, args, deps) {
|
|
|
5926
6044
|
fileName: z.string().optional(),
|
|
5927
6045
|
contentType: z.string().optional()
|
|
5928
6046
|
})).parse(args.attachments) : [];
|
|
6047
|
+
const planId = args.planId ? z.string().uuid().parse(args.planId) : undefined;
|
|
6048
|
+
const planTaskId = args.planTaskId ? z.string().uuid().parse(args.planTaskId) : undefined;
|
|
6049
|
+
if (planTaskId && !planId) {
|
|
6050
|
+
throw new Error("planTaskId requires planId: no route resolves a plan from a criterion id alone. " + "Pass the plan the criterion belongs to — harmony_get_plan returns both.");
|
|
6051
|
+
}
|
|
6052
|
+
let criterion;
|
|
6053
|
+
if (planTaskId && planId) {
|
|
6054
|
+
const { tasks } = await client3.getPlan(planId);
|
|
6055
|
+
const found = findPlanTask(tasks, planTaskId);
|
|
6056
|
+
if (!found.ok)
|
|
6057
|
+
throw new Error(found.reason);
|
|
6058
|
+
criterion = found.task;
|
|
6059
|
+
}
|
|
5929
6060
|
const result = await client3.createCard(projectId, {
|
|
5930
6061
|
title,
|
|
5931
6062
|
columnId: args.columnId,
|
|
5932
6063
|
description: args.description,
|
|
5933
6064
|
priority: args.priority,
|
|
5934
6065
|
assigneeId: args.assigneeId,
|
|
5935
|
-
planId
|
|
6066
|
+
planId
|
|
5936
6067
|
});
|
|
6068
|
+
const newCardId = result.card?.id;
|
|
6069
|
+
let planTask;
|
|
6070
|
+
if (criterion && planId) {
|
|
6071
|
+
if (!newCardId) {
|
|
6072
|
+
planTask = unlinkedReport(planId, criterion, new Error("no card id was returned to link against"));
|
|
6073
|
+
} else {
|
|
6074
|
+
try {
|
|
6075
|
+
await client3.updatePlanTask(planId, criterion.id, {
|
|
6076
|
+
cardId: newCardId
|
|
6077
|
+
});
|
|
6078
|
+
planTask = linkedReport(planId, criterion, newCardId);
|
|
6079
|
+
} catch (err) {
|
|
6080
|
+
planTask = unlinkedReport(planId, criterion, err);
|
|
6081
|
+
}
|
|
6082
|
+
}
|
|
6083
|
+
}
|
|
6084
|
+
const planTaskField = planTask ? { planTask } : {};
|
|
5937
6085
|
if (attachments.length === 0) {
|
|
5938
|
-
return { success: true, ...result };
|
|
6086
|
+
return { success: true, ...result, ...planTaskField };
|
|
5939
6087
|
}
|
|
5940
|
-
|
|
5941
|
-
if (!cardId) {
|
|
6088
|
+
if (!newCardId) {
|
|
5942
6089
|
return {
|
|
5943
6090
|
success: true,
|
|
5944
6091
|
...result,
|
|
6092
|
+
...planTaskField,
|
|
5945
6093
|
attachmentWarning: "Card created, but attachments were skipped: no card id was returned to upload against."
|
|
5946
6094
|
};
|
|
5947
6095
|
}
|
|
5948
6096
|
const attachmentResults = await Promise.all(attachments.map(async (file) => {
|
|
5949
6097
|
try {
|
|
5950
|
-
const uploaded = await attachFileToCard(client3,
|
|
6098
|
+
const uploaded = await attachFileToCard(client3, newCardId, file);
|
|
5951
6099
|
return { ok: true, attachment: uploaded.attachment };
|
|
5952
6100
|
} catch (err) {
|
|
5953
6101
|
return {
|
|
@@ -5957,7 +6105,12 @@ async function handleToolCall(name, args, deps) {
|
|
|
5957
6105
|
};
|
|
5958
6106
|
}
|
|
5959
6107
|
}));
|
|
5960
|
-
return {
|
|
6108
|
+
return {
|
|
6109
|
+
success: true,
|
|
6110
|
+
...result,
|
|
6111
|
+
...planTaskField,
|
|
6112
|
+
attachments: attachmentResults
|
|
6113
|
+
};
|
|
5961
6114
|
}
|
|
5962
6115
|
case "harmony_update_card": {
|
|
5963
6116
|
const cardId = z.string().uuid().parse(args.cardId);
|
|
@@ -6441,15 +6594,24 @@ ${list}
|
|
|
6441
6594
|
const supersedesId = args.supersedesId !== undefined ? z.string().uuid().parse(args.supersedesId) : undefined;
|
|
6442
6595
|
const confirmsId = args.confirmsId !== undefined ? z.string().uuid().parse(args.confirmsId) : undefined;
|
|
6443
6596
|
const replyToId = args.replyToId !== undefined ? z.string().uuid().parse(args.replyToId) : undefined;
|
|
6444
|
-
const
|
|
6597
|
+
const sessionChoice = chooseCommentSession({
|
|
6598
|
+
cardId,
|
|
6599
|
+
tracked: getMemorySession(cardId),
|
|
6600
|
+
callerScopeId: deps.getScopeId?.(),
|
|
6601
|
+
declared: readDeclaredRunSession()
|
|
6602
|
+
});
|
|
6445
6603
|
const result = await client3.addComment(cardId, body, {
|
|
6446
6604
|
commentType,
|
|
6447
6605
|
supersedesId,
|
|
6448
6606
|
confirmsId,
|
|
6449
6607
|
replyToId,
|
|
6450
|
-
agentSessionId
|
|
6608
|
+
agentSessionId: sessionChoice.kind === "session" ? sessionChoice.agentSessionId : undefined
|
|
6451
6609
|
});
|
|
6452
|
-
return {
|
|
6610
|
+
return {
|
|
6611
|
+
success: true,
|
|
6612
|
+
...result,
|
|
6613
|
+
sessionAttribution: sessionChoice.kind === "session" ? sessionChoice.source : "none"
|
|
6614
|
+
};
|
|
6453
6615
|
}
|
|
6454
6616
|
case "harmony_get_comments": {
|
|
6455
6617
|
const cardId = z.string().uuid().parse(args.cardId);
|
|
@@ -6673,7 +6835,7 @@ ${options}
|
|
|
6673
6835
|
scopeId: deps.getScopeId?.()
|
|
6674
6836
|
});
|
|
6675
6837
|
const agentSessionId = result.session?.id;
|
|
6676
|
-
initMemorySession(cardId, agentIdentifier, agentName, agentSessionId);
|
|
6838
|
+
initMemorySession(cardId, agentIdentifier, agentName, agentSessionId, deps.getScopeId?.());
|
|
6677
6839
|
return {
|
|
6678
6840
|
success: true,
|
|
6679
6841
|
assignedTo,
|
|
@@ -7374,6 +7536,30 @@ ${options}
|
|
|
7374
7536
|
const result = await client3.updatePlan(planId, updates);
|
|
7375
7537
|
return { success: true, plan: result.plan };
|
|
7376
7538
|
}
|
|
7539
|
+
case "harmony_link_plan_task": {
|
|
7540
|
+
const planId = z.string().uuid().parse(args.planId);
|
|
7541
|
+
const taskId = z.string().uuid().parse(args.taskId);
|
|
7542
|
+
const cardId = args.cardId ? z.string().uuid().parse(args.cardId) : undefined;
|
|
7543
|
+
const status = args.status ? z.enum(["pending", "in_progress", "completed"]).parse(args.status) : undefined;
|
|
7544
|
+
if (!cardId && !status) {
|
|
7545
|
+
throw new Error("Nothing to do: pass cardId, status, or both.");
|
|
7546
|
+
}
|
|
7547
|
+
const { tasks } = await client3.getPlan(planId);
|
|
7548
|
+
const found = findPlanTask(tasks, taskId);
|
|
7549
|
+
if (!found.ok)
|
|
7550
|
+
throw new Error(found.reason);
|
|
7551
|
+
await client3.updatePlanTask(planId, taskId, { cardId, status });
|
|
7552
|
+
return {
|
|
7553
|
+
success: true,
|
|
7554
|
+
planTask: {
|
|
7555
|
+
planId,
|
|
7556
|
+
taskId,
|
|
7557
|
+
criterion: found.task.content ?? null,
|
|
7558
|
+
...cardId ? linkedReport(planId, found.task, cardId) : { linked: found.task.card_id != null },
|
|
7559
|
+
...status ? { status } : {}
|
|
7560
|
+
}
|
|
7561
|
+
};
|
|
7562
|
+
}
|
|
7377
7563
|
case "harmony_advance_plan": {
|
|
7378
7564
|
const planId = z.string().uuid().parse(args.planId);
|
|
7379
7565
|
const summary = args.summary;
|
|
@@ -7461,6 +7647,16 @@ ${options}
|
|
|
7461
7647
|
...warnings.length > 0 ? { warnings } : {}
|
|
7462
7648
|
};
|
|
7463
7649
|
}
|
|
7650
|
+
case "harmony_delete_playbook": {
|
|
7651
|
+
const playbookId = z.string().uuid().parse(args.playbookId);
|
|
7652
|
+
const result = await client3.deletePlaybook(playbookId);
|
|
7653
|
+
return {
|
|
7654
|
+
success: true,
|
|
7655
|
+
playbook: result.playbook,
|
|
7656
|
+
unboundCardCount: result.unboundCardCount,
|
|
7657
|
+
unboundCards: result.unboundCards
|
|
7658
|
+
};
|
|
7659
|
+
}
|
|
7464
7660
|
case "harmony_save_card_as_playbook":
|
|
7465
7661
|
return deprecatedRemovedToolResult("harmony_save_card_as_playbook");
|
|
7466
7662
|
case "harmony_signup": {
|
package/dist/lib/api-client.js
CHANGED
|
@@ -836,7 +836,6 @@ var AGENT_MILESTONE_LIVENESS_MS = 30 * 60 * 1000;
|
|
|
836
836
|
var AGENT_SWEEP_DAEMON_MS = 30 * 60 * 1000;
|
|
837
837
|
var AGENT_SWEEP_INTERACTIVE_MS = 2 * 60 * 60 * 1000;
|
|
838
838
|
var AGENT_SWEEP_PAUSED_MS = 4 * 60 * 60 * 1000;
|
|
839
|
-
var SWEPT_SESSION_WRITE_GRACE_MS = 60 * 60 * 1000;
|
|
840
839
|
var ACTIVE_STATUSES = new Set(["working", "blocked", "waiting"]);
|
|
841
840
|
// ../harmony-shared/dist/cardLinks.js
|
|
842
841
|
var LINK_TYPE_INVERSES = {
|
|
@@ -1982,6 +1981,9 @@ ${untrustedDataBlock(planContent.trim(), {
|
|
|
1982
1981
|
async updatePlaybook(playbookId, updates) {
|
|
1983
1982
|
return this.request("PATCH", `/playbooks/${playbookId}`, updates);
|
|
1984
1983
|
}
|
|
1984
|
+
async deletePlaybook(playbookId) {
|
|
1985
|
+
return this.request("DELETE", `/playbooks/${encodeURIComponent(playbookId)}`);
|
|
1986
|
+
}
|
|
1985
1987
|
}
|
|
1986
1988
|
var _promptModules = null;
|
|
1987
1989
|
async function loadPromptModules() {
|
package/package.json
CHANGED
package/src/api-client.ts
CHANGED
|
@@ -2374,6 +2374,37 @@ export class HarmonyApiClient {
|
|
|
2374
2374
|
): Promise<{ playbook: unknown }> {
|
|
2375
2375
|
return this.request("PATCH", `/playbooks/${playbookId}`, updates);
|
|
2376
2376
|
}
|
|
2377
|
+
|
|
2378
|
+
/**
|
|
2379
|
+
* Delete a playbook (card #856). Irreversible: `playbook_versions` and
|
|
2380
|
+
* `playbook_runs` cascade with it, and every bound card is unbound.
|
|
2381
|
+
*
|
|
2382
|
+
* The unbound cards are the point of the return value — the cards that were
|
|
2383
|
+
* running this playbook stay on the board with their stage pointer cleared,
|
|
2384
|
+
* and the caller has no other way to learn which ones those were.
|
|
2385
|
+
* `unboundCardCount` is exact; `unboundCards` is a capped sample of them.
|
|
2386
|
+
*
|
|
2387
|
+
* The route refuses a caller who is neither the playbook's creator nor a
|
|
2388
|
+
* workspace owner/admin with a 403, distinct from the 404 it answers when the
|
|
2389
|
+
* playbook is already gone. `request` throws on both, so a resolved promise
|
|
2390
|
+
* means the row really was removed.
|
|
2391
|
+
*/
|
|
2392
|
+
async deletePlaybook(playbookId: string): Promise<{
|
|
2393
|
+
success: boolean;
|
|
2394
|
+
playbook: { id: string; name: string; workspace_id: string };
|
|
2395
|
+
unboundCardCount: number;
|
|
2396
|
+
unboundCards: Array<{
|
|
2397
|
+
id: string;
|
|
2398
|
+
short_id: number | null;
|
|
2399
|
+
title: string;
|
|
2400
|
+
current_stage: string | null;
|
|
2401
|
+
}>;
|
|
2402
|
+
}> {
|
|
2403
|
+
return this.request(
|
|
2404
|
+
"DELETE",
|
|
2405
|
+
`/playbooks/${encodeURIComponent(playbookId)}`,
|
|
2406
|
+
);
|
|
2407
|
+
}
|
|
2377
2408
|
}
|
|
2378
2409
|
|
|
2379
2410
|
// Shared types for generateCardPrompt to avoid inline assertions
|
|
@@ -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
|
+
}
|