agent-planner-mcp 0.9.0 → 1.0.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/AGENT_GUIDE.md +73 -10
- package/README.md +52 -50
- package/SKILL.md +155 -16
- package/package.json +1 -1
- package/src/tools/bdi/desires.js +104 -3
- package/src/tools/bdi/intentions.js +1107 -15
|
@@ -1,9 +1,16 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* BDI intentions — committed actions.
|
|
3
3
|
*
|
|
4
|
-
* v0.9.0
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* v0.9.0 baseline (execution): queue_decision, resolve_decision, update_task,
|
|
5
|
+
* claim_next_task, release_task, add_learning.
|
|
6
|
+
*
|
|
7
|
+
* v1.0.0 additions (creation, mutation, collaboration):
|
|
8
|
+
* - form_intention, extend_intention, propose_research_chain
|
|
9
|
+
* - link_intentions, unlink_intentions
|
|
10
|
+
* - update_plan, update_node, move_node, delete_plan, delete_node
|
|
11
|
+
* - share_plan, invite_member, update_member_role, remove_member
|
|
12
|
+
*
|
|
13
|
+
* See ../../../docs/MCP_v1.0_FULL_SURFACE.md for design rationale.
|
|
7
14
|
*/
|
|
8
15
|
|
|
9
16
|
const { asOf, formatResponse, errorResponse } = require('./_shared');
|
|
@@ -58,13 +65,30 @@ const queueDecisionDefinition = {
|
|
|
58
65
|
default: 'normal',
|
|
59
66
|
},
|
|
60
67
|
goal_id: { type: 'string', description: 'Optional goal this decision serves' },
|
|
68
|
+
proposed_subtasks: {
|
|
69
|
+
type: 'array',
|
|
70
|
+
description: "Tasks to materialize if the human approves. Agents propose; humans steer structure. On resolve_decision(action='approve'), these are atomically created under the given parent_id and their IDs are returned.",
|
|
71
|
+
items: {
|
|
72
|
+
type: 'object',
|
|
73
|
+
properties: {
|
|
74
|
+
parent_id: { type: 'string', description: 'Where to attach. Must be a node the agent already has access to.' },
|
|
75
|
+
title: { type: 'string' },
|
|
76
|
+
description: { type: 'string' },
|
|
77
|
+
node_type: { type: 'string', enum: ['phase', 'task', 'milestone'], default: 'task' },
|
|
78
|
+
task_mode: { type: 'string', enum: ['research', 'plan', 'implement', 'free'], default: 'free' },
|
|
79
|
+
agent_instructions: { type: 'string' },
|
|
80
|
+
acceptance_criteria: { type: 'string' },
|
|
81
|
+
},
|
|
82
|
+
required: ['parent_id', 'title'],
|
|
83
|
+
},
|
|
84
|
+
},
|
|
61
85
|
},
|
|
62
86
|
required: ['title', 'context', 'smallest_input_needed'],
|
|
63
87
|
},
|
|
64
88
|
};
|
|
65
89
|
|
|
66
90
|
async function queueDecisionHandler(args, apiClient) {
|
|
67
|
-
const { plan_id, node_id, title, context, options, recommendation, smallest_input_needed, urgency, goal_id } = args;
|
|
91
|
+
const { plan_id, node_id, title, context, options, recommendation, smallest_input_needed, urgency, goal_id, proposed_subtasks } = args;
|
|
68
92
|
|
|
69
93
|
let planId = plan_id;
|
|
70
94
|
if (!planId && node_id) {
|
|
@@ -85,7 +109,12 @@ async function queueDecisionHandler(args, apiClient) {
|
|
|
85
109
|
options: options || [],
|
|
86
110
|
recommendation: recommendation || null,
|
|
87
111
|
urgency: urgency || 'normal',
|
|
88
|
-
metadata: {
|
|
112
|
+
metadata: {
|
|
113
|
+
smallest_input_needed,
|
|
114
|
+
goal_id: goal_id || null,
|
|
115
|
+
source: 'bdi.queue_decision',
|
|
116
|
+
proposed_subtasks: Array.isArray(proposed_subtasks) ? proposed_subtasks : undefined,
|
|
117
|
+
},
|
|
89
118
|
};
|
|
90
119
|
if (node_id) body.node_id = node_id;
|
|
91
120
|
|
|
@@ -142,28 +171,70 @@ const resolveDecisionDefinition = {
|
|
|
142
171
|
|
|
143
172
|
async function resolveDecisionHandler(args, apiClient) {
|
|
144
173
|
const { decision_id, plan_id, action, message, selected_option } = args;
|
|
174
|
+
|
|
175
|
+
// Fetch the decision first so we can read proposed_subtasks if any.
|
|
176
|
+
let decision = null;
|
|
145
177
|
try {
|
|
146
|
-
|
|
178
|
+
decision = await apiClient.axiosInstance
|
|
179
|
+
.get(`/plans/${plan_id}/decisions/${decision_id}`)
|
|
180
|
+
.then((r) => r.data);
|
|
181
|
+
} catch (err) {
|
|
182
|
+
// Best-effort — if fetch fails, we still try to resolve.
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
let resolved;
|
|
186
|
+
try {
|
|
187
|
+
resolved = await apiClient.axiosInstance
|
|
147
188
|
.post(`/plans/${plan_id}/decisions/${decision_id}/resolve`, {
|
|
148
189
|
resolution: action,
|
|
149
190
|
message: message || null,
|
|
150
191
|
selected_option: selected_option || null,
|
|
151
192
|
})
|
|
152
193
|
.then((r) => r.data);
|
|
153
|
-
return formatResponse({
|
|
154
|
-
as_of: asOf(),
|
|
155
|
-
decision_id,
|
|
156
|
-
plan_id,
|
|
157
|
-
status: resolved.status || action,
|
|
158
|
-
resolved_at: resolved.resolved_at || asOf(),
|
|
159
|
-
message: resolved.message || message || null,
|
|
160
|
-
});
|
|
161
194
|
} catch (err) {
|
|
162
195
|
return errorResponse(
|
|
163
196
|
'upstream_unavailable',
|
|
164
197
|
`Failed to resolve decision: ${err.response?.data?.error || err.message}`
|
|
165
198
|
);
|
|
166
199
|
}
|
|
200
|
+
|
|
201
|
+
// On approve, materialize any proposed_subtasks atomically (best-effort per task).
|
|
202
|
+
const created = [];
|
|
203
|
+
const createFailures = [];
|
|
204
|
+
if (action === 'approve' && decision?.metadata?.proposed_subtasks?.length) {
|
|
205
|
+
for (const proposal of decision.metadata.proposed_subtasks) {
|
|
206
|
+
try {
|
|
207
|
+
const node = await apiClient.nodes.createNode(plan_id, {
|
|
208
|
+
parent_id: proposal.parent_id,
|
|
209
|
+
node_type: proposal.node_type || 'task',
|
|
210
|
+
title: proposal.title,
|
|
211
|
+
description: proposal.description,
|
|
212
|
+
status: 'not_started',
|
|
213
|
+
task_mode: proposal.task_mode || 'free',
|
|
214
|
+
agent_instructions: proposal.agent_instructions,
|
|
215
|
+
acceptance_criteria: proposal.acceptance_criteria,
|
|
216
|
+
});
|
|
217
|
+
created.push({ id: node.id || node.node?.id, title: proposal.title, parent_id: proposal.parent_id });
|
|
218
|
+
} catch (err) {
|
|
219
|
+
createFailures.push({
|
|
220
|
+
title: proposal.title,
|
|
221
|
+
parent_id: proposal.parent_id,
|
|
222
|
+
error: err.response?.data?.error || err.message,
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
return formatResponse({
|
|
229
|
+
as_of: asOf(),
|
|
230
|
+
decision_id,
|
|
231
|
+
plan_id,
|
|
232
|
+
status: resolved.status || action,
|
|
233
|
+
resolved_at: resolved.resolved_at || asOf(),
|
|
234
|
+
message: resolved.message || message || null,
|
|
235
|
+
created_subtasks: created,
|
|
236
|
+
create_failures: createFailures,
|
|
237
|
+
});
|
|
167
238
|
}
|
|
168
239
|
|
|
169
240
|
// ─────────────────────────────────────────────────────────────────────────
|
|
@@ -320,13 +391,18 @@ const claimNextTaskDefinition = {
|
|
|
320
391
|
ttl_minutes: { type: 'integer', default: 30 },
|
|
321
392
|
fresh: { type: 'boolean', default: false },
|
|
322
393
|
context_depth: { type: 'integer', enum: [1, 2, 3, 4], default: 2 },
|
|
394
|
+
dry_run: {
|
|
395
|
+
type: 'boolean',
|
|
396
|
+
default: false,
|
|
397
|
+
description: "If true, return the candidate task without claiming. Lets the caller peek before committing. No phantom claim left behind.",
|
|
398
|
+
},
|
|
323
399
|
},
|
|
324
400
|
required: ['scope'],
|
|
325
401
|
},
|
|
326
402
|
};
|
|
327
403
|
|
|
328
404
|
async function claimNextTaskHandler(args, apiClient) {
|
|
329
|
-
const { scope = {}, ttl_minutes = 30, fresh = false, context_depth = 2 } = args;
|
|
405
|
+
const { scope = {}, ttl_minutes = 30, fresh = false, context_depth = 2, dry_run = false } = args;
|
|
330
406
|
const { plan_id, goal_id } = scope;
|
|
331
407
|
|
|
332
408
|
let chosen = null;
|
|
@@ -377,6 +453,24 @@ async function claimNextTaskHandler(args, apiClient) {
|
|
|
377
453
|
const taskPlanId = chosen.plan_id || plan_id;
|
|
378
454
|
const taskId = chosen.id;
|
|
379
455
|
|
|
456
|
+
// Dry run: return candidate without claiming.
|
|
457
|
+
if (dry_run) {
|
|
458
|
+
return formatResponse({
|
|
459
|
+
as_of: asOf(),
|
|
460
|
+
candidate: {
|
|
461
|
+
id: taskId,
|
|
462
|
+
title: chosen.title,
|
|
463
|
+
status: chosen.status,
|
|
464
|
+
plan_id: taskPlanId,
|
|
465
|
+
task_mode: chosen.task_mode,
|
|
466
|
+
},
|
|
467
|
+
source,
|
|
468
|
+
claim: null,
|
|
469
|
+
dry_run: true,
|
|
470
|
+
next_action_hint: 'Call again with dry_run=false to claim, or pick a different task.',
|
|
471
|
+
});
|
|
472
|
+
}
|
|
473
|
+
|
|
380
474
|
// Claim
|
|
381
475
|
let claim = null;
|
|
382
476
|
try {
|
|
@@ -512,6 +606,976 @@ async function addLearningHandler(args, apiClient) {
|
|
|
512
606
|
}
|
|
513
607
|
}
|
|
514
608
|
|
|
609
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
610
|
+
// form_intention — create plan + initial tree atomically (v1.0).
|
|
611
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
612
|
+
|
|
613
|
+
const VALID_NODE_TYPES = ['phase', 'task', 'milestone'];
|
|
614
|
+
const VALID_TASK_MODES = ['free', 'research', 'plan', 'implement'];
|
|
615
|
+
|
|
616
|
+
function validateTreeShape(tree, depth = 0) {
|
|
617
|
+
if (!Array.isArray(tree)) {
|
|
618
|
+
return 'tree must be an array';
|
|
619
|
+
}
|
|
620
|
+
for (const node of tree) {
|
|
621
|
+
if (!node || typeof node !== 'object') return 'tree node must be an object';
|
|
622
|
+
if (!node.title) return 'tree node missing title';
|
|
623
|
+
if (node.node_type && !VALID_NODE_TYPES.includes(node.node_type)) {
|
|
624
|
+
return `invalid node_type "${node.node_type}" — must be one of ${VALID_NODE_TYPES.join(', ')}`;
|
|
625
|
+
}
|
|
626
|
+
if (node.task_mode && !VALID_TASK_MODES.includes(node.task_mode)) {
|
|
627
|
+
return `invalid task_mode "${node.task_mode}"`;
|
|
628
|
+
}
|
|
629
|
+
if (node.children) {
|
|
630
|
+
const err = validateTreeShape(node.children, depth + 1);
|
|
631
|
+
if (err) return err;
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
return null;
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
const formIntentionDefinition = {
|
|
638
|
+
name: 'form_intention',
|
|
639
|
+
description:
|
|
640
|
+
"Create a plan that achieves a goal, including an initial phase/task " +
|
|
641
|
+
"tree, in one call. Defaults to status='active' for human-directed " +
|
|
642
|
+
"creation; pass status='draft' for autonomous loops so a human can " +
|
|
643
|
+
"review before promotion. Drafts surface in the dashboard pending " +
|
|
644
|
+
"queue and auto-promote to active when work begins on any node.",
|
|
645
|
+
inputSchema: {
|
|
646
|
+
type: 'object',
|
|
647
|
+
properties: {
|
|
648
|
+
goal_id: { type: 'string', description: "Goal this plan serves." },
|
|
649
|
+
title: { type: 'string' },
|
|
650
|
+
description: { type: 'string' },
|
|
651
|
+
rationale: { type: 'string', description: "Why this plan. Surfaces in human review when status=draft." },
|
|
652
|
+
status: {
|
|
653
|
+
type: 'string',
|
|
654
|
+
enum: ['draft', 'active'],
|
|
655
|
+
default: 'active',
|
|
656
|
+
},
|
|
657
|
+
visibility: {
|
|
658
|
+
type: 'string',
|
|
659
|
+
enum: ['private', 'unlisted', 'public'],
|
|
660
|
+
default: 'private',
|
|
661
|
+
},
|
|
662
|
+
tree: {
|
|
663
|
+
type: 'array',
|
|
664
|
+
description: "Recursive tree of nodes (phases, tasks, milestones). Children nest under parents via the 'children' array.",
|
|
665
|
+
items: {
|
|
666
|
+
type: 'object',
|
|
667
|
+
properties: {
|
|
668
|
+
node_type: { type: 'string', enum: VALID_NODE_TYPES, default: 'task' },
|
|
669
|
+
title: { type: 'string' },
|
|
670
|
+
description: { type: 'string' },
|
|
671
|
+
task_mode: { type: 'string', enum: VALID_TASK_MODES, default: 'free' },
|
|
672
|
+
agent_instructions: { type: 'string' },
|
|
673
|
+
children: { type: 'array' },
|
|
674
|
+
},
|
|
675
|
+
required: ['title'],
|
|
676
|
+
},
|
|
677
|
+
},
|
|
678
|
+
},
|
|
679
|
+
required: ['goal_id', 'title', 'rationale'],
|
|
680
|
+
},
|
|
681
|
+
};
|
|
682
|
+
|
|
683
|
+
async function createSubtree(apiClient, planId, parentId, children, results) {
|
|
684
|
+
for (const child of children || []) {
|
|
685
|
+
let createdNode;
|
|
686
|
+
try {
|
|
687
|
+
const payload = {
|
|
688
|
+
node_type: child.node_type || 'task',
|
|
689
|
+
title: child.title,
|
|
690
|
+
description: child.description || '',
|
|
691
|
+
task_mode: child.task_mode || 'free',
|
|
692
|
+
};
|
|
693
|
+
if (parentId) payload.parent_id = parentId;
|
|
694
|
+
if (child.agent_instructions) payload.agent_instructions = child.agent_instructions;
|
|
695
|
+
|
|
696
|
+
const resp = await apiClient.nodes.createNode(planId, payload);
|
|
697
|
+
// createNode returns { result, created } — unwrap.
|
|
698
|
+
createdNode = resp.result || resp;
|
|
699
|
+
results.push({ id: createdNode.id, title: createdNode.title, node_type: createdNode.node_type });
|
|
700
|
+
} catch (err) {
|
|
701
|
+
results.push({ title: child.title, error: err.response?.data?.error || err.message });
|
|
702
|
+
continue;
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
if (child.children?.length && createdNode?.id) {
|
|
706
|
+
await createSubtree(apiClient, planId, createdNode.id, child.children, results);
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
async function formIntentionHandler(args, apiClient) {
|
|
712
|
+
const { goal_id, title, description, rationale, status = 'active', visibility = 'private', tree = [] } = args;
|
|
713
|
+
|
|
714
|
+
// Validate goal exists.
|
|
715
|
+
let goal;
|
|
716
|
+
try {
|
|
717
|
+
goal = await apiClient.goals.get(goal_id);
|
|
718
|
+
} catch (err) {
|
|
719
|
+
return errorResponse('not_found', `Goal ${goal_id} not found or not accessible: ${err.message}`);
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
// Validate tree shape upfront (atomic-ish — fail before partial creation).
|
|
723
|
+
const treeError = validateTreeShape(tree);
|
|
724
|
+
if (treeError) {
|
|
725
|
+
return errorResponse('tree_shape_invalid', treeError);
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
// Compose plan description (rationale + optional description).
|
|
729
|
+
const composedDescription = description ? `${rationale}\n\n${description}` : rationale;
|
|
730
|
+
|
|
731
|
+
// 1. Create plan. Visibility goes through a separate endpoint —
|
|
732
|
+
// POST /plans schema is .strict() and only accepts title, description, status, metadata.
|
|
733
|
+
let plan;
|
|
734
|
+
try {
|
|
735
|
+
plan = await apiClient.plans.createPlan({
|
|
736
|
+
title,
|
|
737
|
+
description: composedDescription,
|
|
738
|
+
status,
|
|
739
|
+
});
|
|
740
|
+
} catch (err) {
|
|
741
|
+
return errorResponse('create_failed', `Failed to create plan: ${err.response?.data?.error || err.message}`);
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
// 2. Apply non-default visibility (separate endpoint).
|
|
745
|
+
if (visibility && visibility !== 'private') {
|
|
746
|
+
try {
|
|
747
|
+
await apiClient.plans.updateVisibility(plan.id, { visibility });
|
|
748
|
+
} catch (err) {
|
|
749
|
+
// Non-fatal — plan exists with default visibility, caller can retry.
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
// 3. Link plan to goal (best-effort).
|
|
754
|
+
try {
|
|
755
|
+
await apiClient.goals.linkPlan(goal_id, plan.id);
|
|
756
|
+
} catch (err) {
|
|
757
|
+
// Non-fatal — plan exists, link can be retried by user.
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
// 3. Create tree (top-level children parent to root via omitted parent_id).
|
|
761
|
+
const nodeResults = [];
|
|
762
|
+
await createSubtree(apiClient, plan.id, null, tree, nodeResults);
|
|
763
|
+
|
|
764
|
+
return formatResponse({
|
|
765
|
+
as_of: asOf(),
|
|
766
|
+
plan_id: plan.id,
|
|
767
|
+
goal_id,
|
|
768
|
+
status: plan.status,
|
|
769
|
+
is_draft: plan.status === 'draft',
|
|
770
|
+
nodes_created: nodeResults.filter((n) => n.id).length,
|
|
771
|
+
node_failures: nodeResults.filter((n) => n.error),
|
|
772
|
+
nodes: nodeResults,
|
|
773
|
+
next_step: plan.status === 'draft'
|
|
774
|
+
? "Plan created as draft. Will surface in dashboard pending for human review. Auto-promotes to active when first task moves to in_progress."
|
|
775
|
+
: "Plan active. Claim a task with claim_next_task({plan_id}) to begin work.",
|
|
776
|
+
});
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
780
|
+
// extend_intention — add children under an existing parent (v1.0).
|
|
781
|
+
// Lightweight — does not go through the decision queue.
|
|
782
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
783
|
+
|
|
784
|
+
const extendIntentionDefinition = {
|
|
785
|
+
name: 'extend_intention',
|
|
786
|
+
description:
|
|
787
|
+
"Add children under an existing phase or task. Use when an agent has " +
|
|
788
|
+
"implicit authority to decompose work (e.g., a parent task they have " +
|
|
789
|
+
"claimed). For high-stakes structural proposals, use queue_decision " +
|
|
790
|
+
"with proposed_subtasks instead. Defaults to status='active'.",
|
|
791
|
+
inputSchema: {
|
|
792
|
+
type: 'object',
|
|
793
|
+
properties: {
|
|
794
|
+
parent_id: { type: 'string', description: "Phase or task to add children under." },
|
|
795
|
+
plan_id: { type: 'string', description: "Plan that owns the parent (auto-resolved if omitted)." },
|
|
796
|
+
rationale: { type: 'string', description: "Why these children. Stored in metadata for audit." },
|
|
797
|
+
children: {
|
|
798
|
+
type: 'array',
|
|
799
|
+
items: {
|
|
800
|
+
type: 'object',
|
|
801
|
+
properties: {
|
|
802
|
+
node_type: { type: 'string', enum: VALID_NODE_TYPES, default: 'task' },
|
|
803
|
+
title: { type: 'string' },
|
|
804
|
+
description: { type: 'string' },
|
|
805
|
+
task_mode: { type: 'string', enum: VALID_TASK_MODES, default: 'free' },
|
|
806
|
+
agent_instructions: { type: 'string' },
|
|
807
|
+
children: { type: 'array' },
|
|
808
|
+
},
|
|
809
|
+
required: ['title'],
|
|
810
|
+
},
|
|
811
|
+
},
|
|
812
|
+
},
|
|
813
|
+
required: ['parent_id', 'rationale', 'children'],
|
|
814
|
+
},
|
|
815
|
+
};
|
|
816
|
+
|
|
817
|
+
async function extendIntentionHandler(args, apiClient) {
|
|
818
|
+
const { parent_id, rationale, children = [] } = args;
|
|
819
|
+
let { plan_id } = args;
|
|
820
|
+
|
|
821
|
+
// Resolve plan_id from parent if not provided.
|
|
822
|
+
if (!plan_id) {
|
|
823
|
+
try {
|
|
824
|
+
const parent = await apiClient.axiosInstance.get(`/nodes/${parent_id}`).then((r) => r.data);
|
|
825
|
+
plan_id = parent.plan_id || parent.planId;
|
|
826
|
+
} catch (err) {
|
|
827
|
+
return errorResponse('not_found', `Could not resolve plan_id from parent ${parent_id}: ${err.message}`);
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
const treeError = validateTreeShape(children);
|
|
832
|
+
if (treeError) {
|
|
833
|
+
return errorResponse('tree_shape_invalid', treeError);
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
const nodeResults = [];
|
|
837
|
+
await createSubtree(apiClient, plan_id, parent_id, children, nodeResults);
|
|
838
|
+
|
|
839
|
+
return formatResponse({
|
|
840
|
+
as_of: asOf(),
|
|
841
|
+
plan_id,
|
|
842
|
+
parent_id,
|
|
843
|
+
rationale,
|
|
844
|
+
nodes_created: nodeResults.filter((n) => n.id).length,
|
|
845
|
+
node_failures: nodeResults.filter((n) => n.error),
|
|
846
|
+
nodes: nodeResults,
|
|
847
|
+
});
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
851
|
+
// propose_research_chain — RPI shortcut (v1.0).
|
|
852
|
+
// Creates Research → Plan → Implement under parent with two blocking edges.
|
|
853
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
854
|
+
|
|
855
|
+
const proposeResearchChainDefinition = {
|
|
856
|
+
name: 'propose_research_chain',
|
|
857
|
+
description:
|
|
858
|
+
"Create a Research → Plan → Implement triple under an existing parent " +
|
|
859
|
+
"task or phase. The Research task feeds Plan; Plan feeds Implement " +
|
|
860
|
+
"(via 'blocks' dependency edges). Use when tackling work with " +
|
|
861
|
+
"significant unknowns. Defaults to status='active'.",
|
|
862
|
+
inputSchema: {
|
|
863
|
+
type: 'object',
|
|
864
|
+
properties: {
|
|
865
|
+
parent_id: { type: 'string', description: "Parent task or phase the chain attaches to." },
|
|
866
|
+
plan_id: { type: 'string', description: "Plan that owns the parent (auto-resolved if omitted)." },
|
|
867
|
+
research_question: { type: 'string', description: "What the Research task investigates." },
|
|
868
|
+
implementation_target: { type: 'string', description: "What the Implement task ultimately produces." },
|
|
869
|
+
rationale: { type: 'string', description: "Why an RPI chain is appropriate here." },
|
|
870
|
+
},
|
|
871
|
+
required: ['parent_id', 'research_question', 'implementation_target', 'rationale'],
|
|
872
|
+
},
|
|
873
|
+
};
|
|
874
|
+
|
|
875
|
+
async function proposeResearchChainHandler(args, apiClient) {
|
|
876
|
+
const { parent_id, research_question, implementation_target, rationale } = args;
|
|
877
|
+
let { plan_id } = args;
|
|
878
|
+
|
|
879
|
+
if (!plan_id) {
|
|
880
|
+
try {
|
|
881
|
+
const parent = await apiClient.axiosInstance.get(`/nodes/${parent_id}`).then((r) => r.data);
|
|
882
|
+
plan_id = parent.plan_id || parent.planId;
|
|
883
|
+
} catch (err) {
|
|
884
|
+
return errorResponse('not_found', `Could not resolve plan_id from parent ${parent_id}: ${err.message}`);
|
|
885
|
+
}
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
const created = {};
|
|
889
|
+
const failures = [];
|
|
890
|
+
|
|
891
|
+
// 1. Create the three tasks.
|
|
892
|
+
for (const [key, spec] of [
|
|
893
|
+
['research', { title: `Research: ${research_question}`, description: research_question, task_mode: 'research' }],
|
|
894
|
+
['plan', { title: `Plan: ${implementation_target}`, description: `Plan implementation based on research findings`, task_mode: 'plan' }],
|
|
895
|
+
['implement', { title: `Implement: ${implementation_target}`, description: implementation_target, task_mode: 'implement' }],
|
|
896
|
+
]) {
|
|
897
|
+
try {
|
|
898
|
+
const resp = await apiClient.nodes.createNode(plan_id, {
|
|
899
|
+
node_type: 'task',
|
|
900
|
+
title: spec.title,
|
|
901
|
+
description: spec.description,
|
|
902
|
+
task_mode: spec.task_mode,
|
|
903
|
+
parent_id,
|
|
904
|
+
});
|
|
905
|
+
created[key] = resp.result || resp;
|
|
906
|
+
} catch (err) {
|
|
907
|
+
failures.push({ step: `create_${key}`, error: err.response?.data?.error || err.message });
|
|
908
|
+
}
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
if (failures.length) {
|
|
912
|
+
return formatResponse({
|
|
913
|
+
as_of: asOf(),
|
|
914
|
+
plan_id,
|
|
915
|
+
parent_id,
|
|
916
|
+
partial: true,
|
|
917
|
+
created: Object.fromEntries(Object.entries(created).map(([k, v]) => [k, { id: v.id, title: v.title }])),
|
|
918
|
+
failures,
|
|
919
|
+
});
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
// 2. Create the two blocking edges: research blocks plan, plan blocks implement.
|
|
923
|
+
const edges = [];
|
|
924
|
+
for (const [from, to] of [
|
|
925
|
+
[created.research.id, created.plan.id],
|
|
926
|
+
[created.plan.id, created.implement.id],
|
|
927
|
+
]) {
|
|
928
|
+
try {
|
|
929
|
+
await apiClient.axiosInstance.post('/dependencies', {
|
|
930
|
+
source_node_id: from,
|
|
931
|
+
target_node_id: to,
|
|
932
|
+
dependency_type: 'blocks',
|
|
933
|
+
});
|
|
934
|
+
edges.push({ from, to, relation: 'blocks' });
|
|
935
|
+
} catch (err) {
|
|
936
|
+
failures.push({ step: 'create_edge', error: err.response?.data?.error || err.message, from, to });
|
|
937
|
+
}
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
return formatResponse({
|
|
941
|
+
as_of: asOf(),
|
|
942
|
+
plan_id,
|
|
943
|
+
parent_id,
|
|
944
|
+
rationale,
|
|
945
|
+
research: { id: created.research.id, title: created.research.title },
|
|
946
|
+
plan: { id: created.plan.id, title: created.plan.title },
|
|
947
|
+
implement: { id: created.implement.id, title: created.implement.title },
|
|
948
|
+
edges,
|
|
949
|
+
failures,
|
|
950
|
+
next_step: "Claim the Research task with claim_next_task({plan_id}) to begin investigation.",
|
|
951
|
+
});
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
955
|
+
// link_intentions — create dependency edge between two existing tasks (v1.0).
|
|
956
|
+
// Cycle detection happens server-side; we surface the 409 cleanly.
|
|
957
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
958
|
+
|
|
959
|
+
const VALID_RELATIONS = ['blocks', 'requires', 'relates_to'];
|
|
960
|
+
|
|
961
|
+
const linkIntentionsDefinition = {
|
|
962
|
+
name: 'link_intentions',
|
|
963
|
+
description:
|
|
964
|
+
"Create a dependency edge between two existing tasks. Use to express " +
|
|
965
|
+
"discovered ordering constraints (e.g., agent realizes task B requires " +
|
|
966
|
+
"task A's output). Server rejects cycles. Both tasks must be in the " +
|
|
967
|
+
"same plan.",
|
|
968
|
+
inputSchema: {
|
|
969
|
+
type: 'object',
|
|
970
|
+
properties: {
|
|
971
|
+
from_task_id: { type: 'string' },
|
|
972
|
+
to_task_id: { type: 'string' },
|
|
973
|
+
relation: { type: 'string', enum: VALID_RELATIONS, default: 'blocks' },
|
|
974
|
+
rationale: { type: 'string', description: "Why this link. Stored in dependency metadata." },
|
|
975
|
+
},
|
|
976
|
+
required: ['from_task_id', 'to_task_id', 'rationale'],
|
|
977
|
+
},
|
|
978
|
+
};
|
|
979
|
+
|
|
980
|
+
async function linkIntentionsHandler(args, apiClient) {
|
|
981
|
+
const { from_task_id, to_task_id, relation = 'blocks', rationale } = args;
|
|
982
|
+
|
|
983
|
+
if (from_task_id === to_task_id) {
|
|
984
|
+
return errorResponse('invalid_argument', 'from_task_id and to_task_id must differ');
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
// Resolve plan_id from the source task; validate target is in the same plan.
|
|
988
|
+
let planId, fromPlan, toPlan;
|
|
989
|
+
try {
|
|
990
|
+
const fromNode = await apiClient.axiosInstance.get(`/nodes/${from_task_id}`).then((r) => r.data);
|
|
991
|
+
fromPlan = fromNode.plan_id || fromNode.planId;
|
|
992
|
+
planId = fromPlan;
|
|
993
|
+
} catch (err) {
|
|
994
|
+
return errorResponse('not_found', `from_task ${from_task_id} not found: ${err.message}`);
|
|
995
|
+
}
|
|
996
|
+
try {
|
|
997
|
+
const toNode = await apiClient.axiosInstance.get(`/nodes/${to_task_id}`).then((r) => r.data);
|
|
998
|
+
toPlan = toNode.plan_id || toNode.planId;
|
|
999
|
+
} catch (err) {
|
|
1000
|
+
return errorResponse('not_found', `to_task ${to_task_id} not found: ${err.message}`);
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
if (fromPlan !== toPlan) {
|
|
1004
|
+
return errorResponse('cross_plan_unsupported', `Both tasks must be in the same plan (from: ${fromPlan}, to: ${toPlan}). Cross-plan links require a separate API.`);
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
try {
|
|
1008
|
+
const dep = await apiClient.axiosInstance.post(`/plans/${planId}/dependencies`, {
|
|
1009
|
+
source_node_id: from_task_id,
|
|
1010
|
+
target_node_id: to_task_id,
|
|
1011
|
+
dependency_type: relation,
|
|
1012
|
+
metadata: { rationale },
|
|
1013
|
+
}).then((r) => r.data);
|
|
1014
|
+
|
|
1015
|
+
return formatResponse({
|
|
1016
|
+
as_of: asOf(),
|
|
1017
|
+
dependency_id: dep.id,
|
|
1018
|
+
plan_id: planId,
|
|
1019
|
+
from_task_id,
|
|
1020
|
+
to_task_id,
|
|
1021
|
+
relation,
|
|
1022
|
+
});
|
|
1023
|
+
} catch (err) {
|
|
1024
|
+
const status = err.response?.status;
|
|
1025
|
+
const upstream = err.response?.data?.error || err.message;
|
|
1026
|
+
if (status === 409) {
|
|
1027
|
+
return errorResponse('cycle_detected', `Edge rejected — would create a cycle: ${upstream}`);
|
|
1028
|
+
}
|
|
1029
|
+
return errorResponse('create_failed', `Failed to create dependency: ${upstream}`);
|
|
1030
|
+
}
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
1034
|
+
// unlink_intentions — remove a dependency edge (v1.0).
|
|
1035
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
1036
|
+
|
|
1037
|
+
const unlinkIntentionsDefinition = {
|
|
1038
|
+
name: 'unlink_intentions',
|
|
1039
|
+
description: "Remove a dependency edge by id.",
|
|
1040
|
+
inputSchema: {
|
|
1041
|
+
type: 'object',
|
|
1042
|
+
properties: {
|
|
1043
|
+
dependency_id: { type: 'string' },
|
|
1044
|
+
plan_id: { type: 'string', description: "Plan that owns the dependency. Required for the route." },
|
|
1045
|
+
reason: { type: 'string', description: "Why removed. Logged for audit." },
|
|
1046
|
+
},
|
|
1047
|
+
required: ['dependency_id', 'plan_id'],
|
|
1048
|
+
},
|
|
1049
|
+
};
|
|
1050
|
+
|
|
1051
|
+
async function unlinkIntentionsHandler(args, apiClient) {
|
|
1052
|
+
const { dependency_id, plan_id, reason } = args;
|
|
1053
|
+
|
|
1054
|
+
try {
|
|
1055
|
+
await apiClient.axiosInstance.delete(`/plans/${plan_id}/dependencies/${dependency_id}`);
|
|
1056
|
+
return formatResponse({
|
|
1057
|
+
as_of: asOf(),
|
|
1058
|
+
dependency_id,
|
|
1059
|
+
plan_id,
|
|
1060
|
+
reason: reason || null,
|
|
1061
|
+
removed: true,
|
|
1062
|
+
});
|
|
1063
|
+
} catch (err) {
|
|
1064
|
+
const upstream = err.response?.data?.error || err.message;
|
|
1065
|
+
if (err.response?.status === 404) {
|
|
1066
|
+
return errorResponse('not_found', `Dependency ${dependency_id} not found in plan ${plan_id}`);
|
|
1067
|
+
}
|
|
1068
|
+
return errorResponse('delete_failed', `Failed to remove dependency: ${upstream}`);
|
|
1069
|
+
}
|
|
1070
|
+
}
|
|
1071
|
+
|
|
1072
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
1073
|
+
// update_plan — edit any plan property atomically (v1.0).
|
|
1074
|
+
// Status changes route here; visibility, github linkage, metadata, etc.
|
|
1075
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
1076
|
+
|
|
1077
|
+
const updatePlanDefinition = {
|
|
1078
|
+
name: 'update_plan',
|
|
1079
|
+
description:
|
|
1080
|
+
"Edit any plan property atomically: title, description, status, " +
|
|
1081
|
+
"visibility, GitHub linkage, metadata. Use status='archived' to " +
|
|
1082
|
+
"soft-delete (recoverable via status='active' + restore=true). " +
|
|
1083
|
+
"Hard delete stays REST-only with admin auth.",
|
|
1084
|
+
inputSchema: {
|
|
1085
|
+
type: 'object',
|
|
1086
|
+
properties: {
|
|
1087
|
+
plan_id: { type: 'string' },
|
|
1088
|
+
title: { type: 'string' },
|
|
1089
|
+
description: { type: 'string' },
|
|
1090
|
+
status: {
|
|
1091
|
+
type: 'string',
|
|
1092
|
+
enum: ['draft', 'active', 'completed', 'archived'],
|
|
1093
|
+
},
|
|
1094
|
+
visibility: { type: 'string', enum: ['private', 'unlisted', 'public'] },
|
|
1095
|
+
metadata: { type: 'object', description: "Shallow-merged into existing metadata." },
|
|
1096
|
+
restore: {
|
|
1097
|
+
type: 'boolean',
|
|
1098
|
+
description: "Required when un-archiving (status: 'archived' → 'active'). Guards against accidental restoration.",
|
|
1099
|
+
default: false,
|
|
1100
|
+
},
|
|
1101
|
+
},
|
|
1102
|
+
required: ['plan_id'],
|
|
1103
|
+
},
|
|
1104
|
+
};
|
|
1105
|
+
|
|
1106
|
+
async function updatePlanHandler(args, apiClient) {
|
|
1107
|
+
const { plan_id, title, description, status, visibility, metadata, restore } = args;
|
|
1108
|
+
|
|
1109
|
+
// Guard: un-archiving requires explicit restore=true.
|
|
1110
|
+
if (status && status !== 'archived') {
|
|
1111
|
+
try {
|
|
1112
|
+
const current = await apiClient.plans.getPlan(plan_id);
|
|
1113
|
+
if (current.status === 'archived' && !restore) {
|
|
1114
|
+
return errorResponse(
|
|
1115
|
+
'restore_required',
|
|
1116
|
+
`Plan ${plan_id} is archived. Pass restore=true to un-archive.`
|
|
1117
|
+
);
|
|
1118
|
+
}
|
|
1119
|
+
} catch (err) {
|
|
1120
|
+
// If we can't fetch, fall through — the update will fail loudly anyway.
|
|
1121
|
+
}
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
const payload = {};
|
|
1125
|
+
if (title !== undefined) payload.title = title;
|
|
1126
|
+
if (description !== undefined) payload.description = description;
|
|
1127
|
+
if (status !== undefined) payload.status = status;
|
|
1128
|
+
if (metadata !== undefined) payload.metadata = metadata;
|
|
1129
|
+
|
|
1130
|
+
const applied = [];
|
|
1131
|
+
const failures = [];
|
|
1132
|
+
|
|
1133
|
+
if (Object.keys(payload).length) {
|
|
1134
|
+
try {
|
|
1135
|
+
await apiClient.plans.updatePlan(plan_id, payload);
|
|
1136
|
+
applied.push(...Object.keys(payload));
|
|
1137
|
+
} catch (err) {
|
|
1138
|
+
failures.push({ step: 'update_plan', error: err.response?.data?.error || err.message });
|
|
1139
|
+
}
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1142
|
+
if (visibility !== undefined) {
|
|
1143
|
+
try {
|
|
1144
|
+
await apiClient.plans.updateVisibility(plan_id, { visibility });
|
|
1145
|
+
applied.push('visibility');
|
|
1146
|
+
} catch (err) {
|
|
1147
|
+
failures.push({ step: 'update_visibility', error: err.response?.data?.error || err.message });
|
|
1148
|
+
}
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
let plan = null;
|
|
1152
|
+
try { plan = await apiClient.plans.getPlan(plan_id); } catch {}
|
|
1153
|
+
|
|
1154
|
+
return formatResponse({
|
|
1155
|
+
as_of: asOf(),
|
|
1156
|
+
plan_id,
|
|
1157
|
+
applied_changes: applied,
|
|
1158
|
+
failures,
|
|
1159
|
+
plan: plan ? { id: plan.id, title: plan.title, status: plan.status, visibility: plan.visibility } : null,
|
|
1160
|
+
});
|
|
1161
|
+
}
|
|
1162
|
+
|
|
1163
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
1164
|
+
// update_node — edit any node property except status (v1.0).
|
|
1165
|
+
// Status transitions route through update_task (existing tool) since they
|
|
1166
|
+
// trigger claim/log side effects.
|
|
1167
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
1168
|
+
|
|
1169
|
+
const updateNodeDefinition = {
|
|
1170
|
+
name: 'update_node',
|
|
1171
|
+
description:
|
|
1172
|
+
"Edit any node property atomically: title, description, node_type, " +
|
|
1173
|
+
"task_mode, agent_instructions, metadata. Status transitions belong " +
|
|
1174
|
+
"on update_task (which handles claim/log side effects). Rejects " +
|
|
1175
|
+
"node_type changes when the node has children.",
|
|
1176
|
+
inputSchema: {
|
|
1177
|
+
type: 'object',
|
|
1178
|
+
properties: {
|
|
1179
|
+
node_id: { type: 'string' },
|
|
1180
|
+
plan_id: { type: 'string', description: "Auto-resolved if omitted." },
|
|
1181
|
+
title: { type: 'string' },
|
|
1182
|
+
description: { type: 'string' },
|
|
1183
|
+
node_type: { type: 'string', enum: ['phase', 'task', 'milestone'] },
|
|
1184
|
+
task_mode: { type: 'string', enum: VALID_TASK_MODES },
|
|
1185
|
+
agent_instructions: { type: 'string' },
|
|
1186
|
+
metadata: { type: 'object' },
|
|
1187
|
+
},
|
|
1188
|
+
required: ['node_id'],
|
|
1189
|
+
},
|
|
1190
|
+
};
|
|
1191
|
+
|
|
1192
|
+
async function updateNodeHandler(args, apiClient) {
|
|
1193
|
+
const { node_id, title, description, node_type, task_mode, agent_instructions, metadata } = args;
|
|
1194
|
+
let { plan_id } = args;
|
|
1195
|
+
|
|
1196
|
+
if (!plan_id) {
|
|
1197
|
+
try {
|
|
1198
|
+
const node = await apiClient.axiosInstance.get(`/nodes/${node_id}`).then((r) => r.data);
|
|
1199
|
+
plan_id = node.plan_id || node.planId;
|
|
1200
|
+
} catch (err) {
|
|
1201
|
+
return errorResponse('not_found', `Could not resolve plan_id from node ${node_id}: ${err.message}`);
|
|
1202
|
+
}
|
|
1203
|
+
}
|
|
1204
|
+
|
|
1205
|
+
const payload = {};
|
|
1206
|
+
if (title !== undefined) payload.title = title;
|
|
1207
|
+
if (description !== undefined) payload.description = description;
|
|
1208
|
+
if (node_type !== undefined) payload.node_type = node_type;
|
|
1209
|
+
if (task_mode !== undefined) payload.task_mode = task_mode;
|
|
1210
|
+
if (agent_instructions !== undefined) payload.agent_instructions = agent_instructions;
|
|
1211
|
+
if (metadata !== undefined) payload.metadata = metadata;
|
|
1212
|
+
|
|
1213
|
+
if (!Object.keys(payload).length) {
|
|
1214
|
+
return errorResponse('no_changes', 'At least one field to update must be provided.');
|
|
1215
|
+
}
|
|
1216
|
+
|
|
1217
|
+
try {
|
|
1218
|
+
const updated = await apiClient.nodes.updateNode(plan_id, node_id, payload);
|
|
1219
|
+
return formatResponse({
|
|
1220
|
+
as_of: asOf(),
|
|
1221
|
+
plan_id,
|
|
1222
|
+
node_id,
|
|
1223
|
+
applied_changes: Object.keys(payload),
|
|
1224
|
+
node: updated.result || updated,
|
|
1225
|
+
});
|
|
1226
|
+
} catch (err) {
|
|
1227
|
+
return errorResponse('update_failed', `Failed to update node: ${err.response?.data?.error || err.message}`);
|
|
1228
|
+
}
|
|
1229
|
+
}
|
|
1230
|
+
|
|
1231
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
1232
|
+
// move_node — reparent a node within the same plan (v1.0).
|
|
1233
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
1234
|
+
|
|
1235
|
+
const moveNodeDefinition = {
|
|
1236
|
+
name: 'move_node',
|
|
1237
|
+
description:
|
|
1238
|
+
"Reparent a node within the same plan. Cycle-safe (server rejects " +
|
|
1239
|
+
"moves that would create a tree cycle). Optional position sets the " +
|
|
1240
|
+
"order_index among siblings.",
|
|
1241
|
+
inputSchema: {
|
|
1242
|
+
type: 'object',
|
|
1243
|
+
properties: {
|
|
1244
|
+
node_id: { type: 'string' },
|
|
1245
|
+
new_parent_id: { type: 'string' },
|
|
1246
|
+
plan_id: { type: 'string', description: "Auto-resolved if omitted." },
|
|
1247
|
+
position: { type: 'integer', description: "Optional order_index among siblings." },
|
|
1248
|
+
},
|
|
1249
|
+
required: ['node_id', 'new_parent_id'],
|
|
1250
|
+
},
|
|
1251
|
+
};
|
|
1252
|
+
|
|
1253
|
+
async function moveNodeHandler(args, apiClient) {
|
|
1254
|
+
const { node_id, new_parent_id, position } = args;
|
|
1255
|
+
let { plan_id } = args;
|
|
1256
|
+
|
|
1257
|
+
if (!plan_id) {
|
|
1258
|
+
try {
|
|
1259
|
+
const node = await apiClient.axiosInstance.get(`/nodes/${node_id}`).then((r) => r.data);
|
|
1260
|
+
plan_id = node.plan_id || node.planId;
|
|
1261
|
+
} catch (err) {
|
|
1262
|
+
return errorResponse('not_found', `Could not resolve plan_id from node ${node_id}: ${err.message}`);
|
|
1263
|
+
}
|
|
1264
|
+
}
|
|
1265
|
+
|
|
1266
|
+
const payload = { parent_id: new_parent_id };
|
|
1267
|
+
if (typeof position === 'number') payload.order_index = position;
|
|
1268
|
+
|
|
1269
|
+
try {
|
|
1270
|
+
const result = await apiClient.axiosInstance.post(
|
|
1271
|
+
`/plans/${plan_id}/nodes/${node_id}/move`,
|
|
1272
|
+
payload
|
|
1273
|
+
).then((r) => r.data);
|
|
1274
|
+
|
|
1275
|
+
return formatResponse({
|
|
1276
|
+
as_of: asOf(),
|
|
1277
|
+
plan_id,
|
|
1278
|
+
node_id,
|
|
1279
|
+
new_parent_id,
|
|
1280
|
+
position: position ?? null,
|
|
1281
|
+
node: result,
|
|
1282
|
+
});
|
|
1283
|
+
} catch (err) {
|
|
1284
|
+
return errorResponse('move_failed', `Failed to move node: ${err.response?.data?.error || err.message}`);
|
|
1285
|
+
}
|
|
1286
|
+
}
|
|
1287
|
+
|
|
1288
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
1289
|
+
// delete_plan / delete_node — soft delete via status='archived' (v1.0).
|
|
1290
|
+
// Hard delete stays REST-only with admin auth.
|
|
1291
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
1292
|
+
|
|
1293
|
+
const deletePlanDefinition = {
|
|
1294
|
+
name: 'delete_plan',
|
|
1295
|
+
description:
|
|
1296
|
+
"Soft-delete a plan by setting status='archived'. Recoverable via " +
|
|
1297
|
+
"update_plan({status: 'active', restore: true}). Hard delete is not " +
|
|
1298
|
+
"agent-callable — use REST + admin token if absolutely needed.",
|
|
1299
|
+
inputSchema: {
|
|
1300
|
+
type: 'object',
|
|
1301
|
+
properties: {
|
|
1302
|
+
plan_id: { type: 'string' },
|
|
1303
|
+
reason: { type: 'string', description: "Logged for audit." },
|
|
1304
|
+
},
|
|
1305
|
+
required: ['plan_id'],
|
|
1306
|
+
},
|
|
1307
|
+
};
|
|
1308
|
+
|
|
1309
|
+
async function deletePlanHandler(args, apiClient) {
|
|
1310
|
+
const { plan_id, reason } = args;
|
|
1311
|
+
|
|
1312
|
+
try {
|
|
1313
|
+
await apiClient.plans.updatePlan(plan_id, { status: 'archived' });
|
|
1314
|
+
return formatResponse({
|
|
1315
|
+
as_of: asOf(),
|
|
1316
|
+
plan_id,
|
|
1317
|
+
archived: true,
|
|
1318
|
+
reason: reason || null,
|
|
1319
|
+
next_step: "Plan archived. To restore: update_plan({plan_id, status: 'active', restore: true})",
|
|
1320
|
+
});
|
|
1321
|
+
} catch (err) {
|
|
1322
|
+
return errorResponse('archive_failed', `Failed to archive plan: ${err.response?.data?.error || err.message}`);
|
|
1323
|
+
}
|
|
1324
|
+
}
|
|
1325
|
+
|
|
1326
|
+
const deleteNodeDefinition = {
|
|
1327
|
+
name: 'delete_node',
|
|
1328
|
+
description:
|
|
1329
|
+
"Soft-delete a node by setting status='archived'. Cascades to children " +
|
|
1330
|
+
"by default. Recoverable via update_task({status: 'not_started'}).",
|
|
1331
|
+
inputSchema: {
|
|
1332
|
+
type: 'object',
|
|
1333
|
+
properties: {
|
|
1334
|
+
node_id: { type: 'string' },
|
|
1335
|
+
plan_id: { type: 'string', description: "Auto-resolved if omitted." },
|
|
1336
|
+
reason: { type: 'string' },
|
|
1337
|
+
cascade_children: { type: 'boolean', default: true },
|
|
1338
|
+
},
|
|
1339
|
+
required: ['node_id'],
|
|
1340
|
+
},
|
|
1341
|
+
};
|
|
1342
|
+
|
|
1343
|
+
async function deleteNodeHandler(args, apiClient) {
|
|
1344
|
+
const { node_id, reason } = args;
|
|
1345
|
+
let { plan_id } = args;
|
|
1346
|
+
// cascade_children is intentionally ignored at the MCP layer for now —
|
|
1347
|
+
// backend cascade behavior on archived status is implicit (children remain
|
|
1348
|
+
// accessible until explicitly archived themselves). When backend gains
|
|
1349
|
+
// explicit cascade-on-archive, surface it here.
|
|
1350
|
+
|
|
1351
|
+
if (!plan_id) {
|
|
1352
|
+
try {
|
|
1353
|
+
const node = await apiClient.axiosInstance.get(`/nodes/${node_id}`).then((r) => r.data);
|
|
1354
|
+
plan_id = node.plan_id || node.planId;
|
|
1355
|
+
} catch (err) {
|
|
1356
|
+
return errorResponse('not_found', `Could not resolve plan_id from node ${node_id}: ${err.message}`);
|
|
1357
|
+
}
|
|
1358
|
+
}
|
|
1359
|
+
|
|
1360
|
+
try {
|
|
1361
|
+
await apiClient.nodes.updateNode(plan_id, node_id, { status: 'archived' });
|
|
1362
|
+
return formatResponse({
|
|
1363
|
+
as_of: asOf(),
|
|
1364
|
+
plan_id,
|
|
1365
|
+
node_id,
|
|
1366
|
+
archived: true,
|
|
1367
|
+
reason: reason || null,
|
|
1368
|
+
});
|
|
1369
|
+
} catch (err) {
|
|
1370
|
+
return errorResponse('archive_failed', `Failed to archive node: ${err.response?.data?.error || err.message}`);
|
|
1371
|
+
}
|
|
1372
|
+
}
|
|
1373
|
+
|
|
1374
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
1375
|
+
// share_plan — atomic visibility + collaborator changes (v1.0).
|
|
1376
|
+
// Collaborators specified by user_id (email resolution stays UI-side for now).
|
|
1377
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
1378
|
+
|
|
1379
|
+
const VALID_COLLAB_ROLES = ['viewer', 'editor', 'admin'];
|
|
1380
|
+
|
|
1381
|
+
const sharePlanDefinition = {
|
|
1382
|
+
name: 'share_plan',
|
|
1383
|
+
description:
|
|
1384
|
+
"Atomically change a plan's visibility and add/remove collaborators in " +
|
|
1385
|
+
"one call. Collaborators are specified by user_id (email-based invites " +
|
|
1386
|
+
"stay UI-only for now). Caller must be plan owner or admin.",
|
|
1387
|
+
inputSchema: {
|
|
1388
|
+
type: 'object',
|
|
1389
|
+
properties: {
|
|
1390
|
+
plan_id: { type: 'string' },
|
|
1391
|
+
visibility: { type: 'string', enum: ['private', 'unlisted', 'public'] },
|
|
1392
|
+
add_collaborators: {
|
|
1393
|
+
type: 'array',
|
|
1394
|
+
items: {
|
|
1395
|
+
type: 'object',
|
|
1396
|
+
properties: {
|
|
1397
|
+
user_id: { type: 'string' },
|
|
1398
|
+
role: { type: 'string', enum: VALID_COLLAB_ROLES, default: 'viewer' },
|
|
1399
|
+
},
|
|
1400
|
+
required: ['user_id'],
|
|
1401
|
+
},
|
|
1402
|
+
},
|
|
1403
|
+
remove_collaborators: {
|
|
1404
|
+
type: 'array',
|
|
1405
|
+
description: "Array of user_ids to remove from the plan.",
|
|
1406
|
+
items: { type: 'string' },
|
|
1407
|
+
},
|
|
1408
|
+
},
|
|
1409
|
+
required: ['plan_id'],
|
|
1410
|
+
},
|
|
1411
|
+
};
|
|
1412
|
+
|
|
1413
|
+
async function sharePlanHandler(args, apiClient) {
|
|
1414
|
+
const { plan_id, visibility, add_collaborators = [], remove_collaborators = [] } = args;
|
|
1415
|
+
const applied = [];
|
|
1416
|
+
const failures = [];
|
|
1417
|
+
|
|
1418
|
+
if (visibility) {
|
|
1419
|
+
try {
|
|
1420
|
+
await apiClient.plans.updateVisibility(plan_id, { visibility });
|
|
1421
|
+
applied.push(`visibility:${visibility}`);
|
|
1422
|
+
} catch (err) {
|
|
1423
|
+
failures.push({ step: 'visibility', error: err.response?.data?.error || err.message });
|
|
1424
|
+
}
|
|
1425
|
+
}
|
|
1426
|
+
|
|
1427
|
+
for (const collab of add_collaborators) {
|
|
1428
|
+
try {
|
|
1429
|
+
await apiClient.axiosInstance.post(`/plans/${plan_id}/collaborators`, {
|
|
1430
|
+
user_id: collab.user_id,
|
|
1431
|
+
role: collab.role || 'viewer',
|
|
1432
|
+
});
|
|
1433
|
+
applied.push(`add:${collab.user_id}:${collab.role || 'viewer'}`);
|
|
1434
|
+
} catch (err) {
|
|
1435
|
+
failures.push({ step: `add:${collab.user_id}`, error: err.response?.data?.error || err.message });
|
|
1436
|
+
}
|
|
1437
|
+
}
|
|
1438
|
+
|
|
1439
|
+
for (const userId of remove_collaborators) {
|
|
1440
|
+
try {
|
|
1441
|
+
await apiClient.axiosInstance.delete(`/plans/${plan_id}/collaborators/${userId}`);
|
|
1442
|
+
applied.push(`remove:${userId}`);
|
|
1443
|
+
} catch (err) {
|
|
1444
|
+
failures.push({ step: `remove:${userId}`, error: err.response?.data?.error || err.message });
|
|
1445
|
+
}
|
|
1446
|
+
}
|
|
1447
|
+
|
|
1448
|
+
return formatResponse({
|
|
1449
|
+
as_of: asOf(),
|
|
1450
|
+
plan_id,
|
|
1451
|
+
applied_changes: applied,
|
|
1452
|
+
failures,
|
|
1453
|
+
});
|
|
1454
|
+
}
|
|
1455
|
+
|
|
1456
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
1457
|
+
// invite_member / update_member_role / remove_member — org membership (v1.0).
|
|
1458
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
1459
|
+
|
|
1460
|
+
const inviteMemberDefinition = {
|
|
1461
|
+
name: 'invite_member',
|
|
1462
|
+
description:
|
|
1463
|
+
"Add a user to an organization by user_id or email. Caller must be " +
|
|
1464
|
+
"org owner or admin. If email is provided and the user doesn't exist, " +
|
|
1465
|
+
"the API returns 404 (email-invite flow stays UI-only).",
|
|
1466
|
+
inputSchema: {
|
|
1467
|
+
type: 'object',
|
|
1468
|
+
properties: {
|
|
1469
|
+
organization_id: { type: 'string' },
|
|
1470
|
+
user_id: { type: 'string' },
|
|
1471
|
+
email: { type: 'string' },
|
|
1472
|
+
role: { type: 'string', enum: ['admin', 'member'], default: 'member' },
|
|
1473
|
+
},
|
|
1474
|
+
required: ['organization_id'],
|
|
1475
|
+
},
|
|
1476
|
+
};
|
|
1477
|
+
|
|
1478
|
+
async function inviteMemberHandler(args, apiClient) {
|
|
1479
|
+
const { organization_id, user_id, email, role = 'member' } = args;
|
|
1480
|
+
|
|
1481
|
+
if (!user_id && !email) {
|
|
1482
|
+
return errorResponse('invalid_argument', 'Either user_id or email must be provided.');
|
|
1483
|
+
}
|
|
1484
|
+
|
|
1485
|
+
const payload = { role };
|
|
1486
|
+
if (user_id) payload.user_id = user_id;
|
|
1487
|
+
if (email) payload.email = email;
|
|
1488
|
+
|
|
1489
|
+
try {
|
|
1490
|
+
const member = await apiClient.organizations.addMember(organization_id, payload);
|
|
1491
|
+
return formatResponse({
|
|
1492
|
+
as_of: asOf(),
|
|
1493
|
+
organization_id,
|
|
1494
|
+
member: {
|
|
1495
|
+
membership_id: member.id,
|
|
1496
|
+
user_id: member.user?.id,
|
|
1497
|
+
email: member.user?.email,
|
|
1498
|
+
role: member.role,
|
|
1499
|
+
},
|
|
1500
|
+
});
|
|
1501
|
+
} catch (err) {
|
|
1502
|
+
const upstream = err.response?.data?.error || err.message;
|
|
1503
|
+
const code = err.response?.status === 404 ? 'user_not_found' : 'invite_failed';
|
|
1504
|
+
return errorResponse(code, `Failed to invite member: ${upstream}`);
|
|
1505
|
+
}
|
|
1506
|
+
}
|
|
1507
|
+
|
|
1508
|
+
const updateMemberRoleDefinition = {
|
|
1509
|
+
name: 'update_member_role',
|
|
1510
|
+
description:
|
|
1511
|
+
"Change a member's role within an organization. Caller must be org " +
|
|
1512
|
+
"owner. Server rejects demoting the last admin.",
|
|
1513
|
+
inputSchema: {
|
|
1514
|
+
type: 'object',
|
|
1515
|
+
properties: {
|
|
1516
|
+
organization_id: { type: 'string' },
|
|
1517
|
+
membership_id: { type: 'string', description: "Membership row id (from listMembers)." },
|
|
1518
|
+
new_role: { type: 'string', enum: ['admin', 'member'] },
|
|
1519
|
+
},
|
|
1520
|
+
required: ['organization_id', 'membership_id', 'new_role'],
|
|
1521
|
+
},
|
|
1522
|
+
};
|
|
1523
|
+
|
|
1524
|
+
async function updateMemberRoleHandler(args, apiClient) {
|
|
1525
|
+
const { organization_id, membership_id, new_role } = args;
|
|
1526
|
+
|
|
1527
|
+
try {
|
|
1528
|
+
const result = await apiClient.axiosInstance.put(
|
|
1529
|
+
`/organizations/${organization_id}/members/${membership_id}/role`,
|
|
1530
|
+
{ role: new_role }
|
|
1531
|
+
).then((r) => r.data);
|
|
1532
|
+
|
|
1533
|
+
return formatResponse({
|
|
1534
|
+
as_of: asOf(),
|
|
1535
|
+
organization_id,
|
|
1536
|
+
membership_id,
|
|
1537
|
+
new_role,
|
|
1538
|
+
member: result,
|
|
1539
|
+
});
|
|
1540
|
+
} catch (err) {
|
|
1541
|
+
return errorResponse('update_role_failed', `Failed to update member role: ${err.response?.data?.error || err.message}`);
|
|
1542
|
+
}
|
|
1543
|
+
}
|
|
1544
|
+
|
|
1545
|
+
const removeMemberDefinition = {
|
|
1546
|
+
name: 'remove_member',
|
|
1547
|
+
description:
|
|
1548
|
+
"Remove a member from an organization. Caller must be org owner or " +
|
|
1549
|
+
"admin (admins cannot remove other admins). Server rejects removing " +
|
|
1550
|
+
"the org owner.",
|
|
1551
|
+
inputSchema: {
|
|
1552
|
+
type: 'object',
|
|
1553
|
+
properties: {
|
|
1554
|
+
organization_id: { type: 'string' },
|
|
1555
|
+
membership_id: { type: 'string' },
|
|
1556
|
+
reason: { type: 'string' },
|
|
1557
|
+
},
|
|
1558
|
+
required: ['organization_id', 'membership_id'],
|
|
1559
|
+
},
|
|
1560
|
+
};
|
|
1561
|
+
|
|
1562
|
+
async function removeMemberHandler(args, apiClient) {
|
|
1563
|
+
const { organization_id, membership_id, reason } = args;
|
|
1564
|
+
|
|
1565
|
+
try {
|
|
1566
|
+
await apiClient.organizations.removeMember(organization_id, membership_id);
|
|
1567
|
+
return formatResponse({
|
|
1568
|
+
as_of: asOf(),
|
|
1569
|
+
organization_id,
|
|
1570
|
+
membership_id,
|
|
1571
|
+
removed: true,
|
|
1572
|
+
reason: reason || null,
|
|
1573
|
+
});
|
|
1574
|
+
} catch (err) {
|
|
1575
|
+
return errorResponse('remove_failed', `Failed to remove member: ${err.response?.data?.error || err.message}`);
|
|
1576
|
+
}
|
|
1577
|
+
}
|
|
1578
|
+
|
|
515
1579
|
module.exports = {
|
|
516
1580
|
definitions: [
|
|
517
1581
|
queueDecisionDefinition,
|
|
@@ -520,6 +1584,20 @@ module.exports = {
|
|
|
520
1584
|
claimNextTaskDefinition,
|
|
521
1585
|
releaseTaskDefinition,
|
|
522
1586
|
addLearningDefinition,
|
|
1587
|
+
formIntentionDefinition,
|
|
1588
|
+
extendIntentionDefinition,
|
|
1589
|
+
proposeResearchChainDefinition,
|
|
1590
|
+
linkIntentionsDefinition,
|
|
1591
|
+
unlinkIntentionsDefinition,
|
|
1592
|
+
updatePlanDefinition,
|
|
1593
|
+
updateNodeDefinition,
|
|
1594
|
+
moveNodeDefinition,
|
|
1595
|
+
deletePlanDefinition,
|
|
1596
|
+
deleteNodeDefinition,
|
|
1597
|
+
sharePlanDefinition,
|
|
1598
|
+
inviteMemberDefinition,
|
|
1599
|
+
updateMemberRoleDefinition,
|
|
1600
|
+
removeMemberDefinition,
|
|
523
1601
|
],
|
|
524
1602
|
handlers: {
|
|
525
1603
|
queue_decision: queueDecisionHandler,
|
|
@@ -528,5 +1606,19 @@ module.exports = {
|
|
|
528
1606
|
claim_next_task: claimNextTaskHandler,
|
|
529
1607
|
release_task: releaseTaskHandler,
|
|
530
1608
|
add_learning: addLearningHandler,
|
|
1609
|
+
form_intention: formIntentionHandler,
|
|
1610
|
+
extend_intention: extendIntentionHandler,
|
|
1611
|
+
propose_research_chain: proposeResearchChainHandler,
|
|
1612
|
+
link_intentions: linkIntentionsHandler,
|
|
1613
|
+
unlink_intentions: unlinkIntentionsHandler,
|
|
1614
|
+
update_plan: updatePlanHandler,
|
|
1615
|
+
update_node: updateNodeHandler,
|
|
1616
|
+
move_node: moveNodeHandler,
|
|
1617
|
+
delete_plan: deletePlanHandler,
|
|
1618
|
+
delete_node: deleteNodeHandler,
|
|
1619
|
+
share_plan: sharePlanHandler,
|
|
1620
|
+
invite_member: inviteMemberHandler,
|
|
1621
|
+
update_member_role: updateMemberRoleHandler,
|
|
1622
|
+
remove_member: removeMemberHandler,
|
|
531
1623
|
},
|
|
532
1624
|
};
|