agent-planner-mcp 0.9.1 → 1.1.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 +74 -50
- package/SKILL.md +125 -17
- package/package.json +2 -1
- package/src/api-client.js +46 -2
- package/src/setup.js +5 -2
- package/src/tools/bdi/beliefs.js +128 -5
- package/src/tools/bdi/desires.js +104 -3
- package/src/tools/bdi/intentions.js +1077 -4
- package/src/tools/bdi/utility.js +1 -1
|
@@ -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');
|
|
@@ -274,15 +281,37 @@ const updateTaskDefinition = {
|
|
|
274
281
|
type: 'string',
|
|
275
282
|
description: 'Optional: also write a knowledge episode (recommended on completion)',
|
|
276
283
|
},
|
|
284
|
+
session_id: {
|
|
285
|
+
type: 'string',
|
|
286
|
+
description: 'Optional work-session id returned by claim_next_task. Uses the agent-loop completion/block endpoint when status is completed or blocked.',
|
|
287
|
+
},
|
|
288
|
+
decision: {
|
|
289
|
+
type: 'object',
|
|
290
|
+
description: 'Optional decision to queue when blocking a session through the agent-loop endpoint.',
|
|
291
|
+
},
|
|
277
292
|
},
|
|
278
293
|
required: ['task_id'],
|
|
279
294
|
},
|
|
280
295
|
};
|
|
281
296
|
|
|
282
297
|
async function updateTaskHandler(args, apiClient) {
|
|
283
|
-
const { task_id, status, log_message, add_learning, release_claim } = args;
|
|
298
|
+
const { task_id, status, log_message, add_learning, release_claim, session_id, decision } = args;
|
|
284
299
|
let planId = args.plan_id;
|
|
285
300
|
|
|
301
|
+
if (session_id && (status === 'completed' || status === 'blocked')) {
|
|
302
|
+
try {
|
|
303
|
+
const path = status === 'blocked' ? 'block' : 'complete';
|
|
304
|
+
const response = await apiClient.axiosInstance.post(`/agent/work-sessions/${session_id}/${path}`, {
|
|
305
|
+
summary: log_message,
|
|
306
|
+
learning: add_learning ? { content: add_learning } : undefined,
|
|
307
|
+
decision,
|
|
308
|
+
});
|
|
309
|
+
return formatResponse(response.data);
|
|
310
|
+
} catch {
|
|
311
|
+
// Fall back to legacy fan-out for older APIs or if the session was not found.
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
286
315
|
// Resolve plan_id from task if not provided.
|
|
287
316
|
if (!planId) {
|
|
288
317
|
try {
|
|
@@ -398,6 +427,21 @@ async function claimNextTaskHandler(args, apiClient) {
|
|
|
398
427
|
const { scope = {}, ttl_minutes = 30, fresh = false, context_depth = 2, dry_run = false } = args;
|
|
399
428
|
const { plan_id, goal_id } = scope;
|
|
400
429
|
|
|
430
|
+
try {
|
|
431
|
+
const response = await apiClient.axiosInstance.post('/agent/work-sessions', {
|
|
432
|
+
plan_id,
|
|
433
|
+
goal_id,
|
|
434
|
+
ttl_minutes,
|
|
435
|
+
fresh,
|
|
436
|
+
dry_run,
|
|
437
|
+
depth: context_depth,
|
|
438
|
+
agent_id: 'mcp-agent',
|
|
439
|
+
});
|
|
440
|
+
return formatResponse(response.data);
|
|
441
|
+
} catch {
|
|
442
|
+
// Fall back to the pre-facade fan-out for self-hosted older APIs.
|
|
443
|
+
}
|
|
444
|
+
|
|
401
445
|
let chosen = null;
|
|
402
446
|
let source = null;
|
|
403
447
|
|
|
@@ -599,6 +643,1007 @@ async function addLearningHandler(args, apiClient) {
|
|
|
599
643
|
}
|
|
600
644
|
}
|
|
601
645
|
|
|
646
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
647
|
+
// form_intention — create plan + initial tree atomically (v1.0).
|
|
648
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
649
|
+
|
|
650
|
+
const VALID_NODE_TYPES = ['phase', 'task', 'milestone'];
|
|
651
|
+
const VALID_TASK_MODES = ['free', 'research', 'plan', 'implement'];
|
|
652
|
+
|
|
653
|
+
function validateTreeShape(tree, depth = 0) {
|
|
654
|
+
if (!Array.isArray(tree)) {
|
|
655
|
+
return 'tree must be an array';
|
|
656
|
+
}
|
|
657
|
+
for (const node of tree) {
|
|
658
|
+
if (!node || typeof node !== 'object') return 'tree node must be an object';
|
|
659
|
+
if (!node.title) return 'tree node missing title';
|
|
660
|
+
if (node.node_type && !VALID_NODE_TYPES.includes(node.node_type)) {
|
|
661
|
+
return `invalid node_type "${node.node_type}" — must be one of ${VALID_NODE_TYPES.join(', ')}`;
|
|
662
|
+
}
|
|
663
|
+
if (node.task_mode && !VALID_TASK_MODES.includes(node.task_mode)) {
|
|
664
|
+
return `invalid task_mode "${node.task_mode}"`;
|
|
665
|
+
}
|
|
666
|
+
if (node.children) {
|
|
667
|
+
const err = validateTreeShape(node.children, depth + 1);
|
|
668
|
+
if (err) return err;
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
return null;
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
const formIntentionDefinition = {
|
|
675
|
+
name: 'form_intention',
|
|
676
|
+
description:
|
|
677
|
+
"Create a plan that achieves a goal, including an initial phase/task " +
|
|
678
|
+
"tree, in one call. Defaults to status='active' for human-directed " +
|
|
679
|
+
"creation; pass status='draft' for autonomous loops so a human can " +
|
|
680
|
+
"review before promotion. Drafts surface in the dashboard pending " +
|
|
681
|
+
"queue and auto-promote to active when work begins on any node.",
|
|
682
|
+
inputSchema: {
|
|
683
|
+
type: 'object',
|
|
684
|
+
properties: {
|
|
685
|
+
goal_id: { type: 'string', description: "Goal this plan serves." },
|
|
686
|
+
title: { type: 'string' },
|
|
687
|
+
description: { type: 'string' },
|
|
688
|
+
rationale: { type: 'string', description: "Why this plan. Surfaces in human review when status=draft." },
|
|
689
|
+
status: {
|
|
690
|
+
type: 'string',
|
|
691
|
+
enum: ['draft', 'active'],
|
|
692
|
+
default: 'active',
|
|
693
|
+
},
|
|
694
|
+
visibility: {
|
|
695
|
+
type: 'string',
|
|
696
|
+
enum: ['private', 'unlisted', 'public'],
|
|
697
|
+
default: 'private',
|
|
698
|
+
},
|
|
699
|
+
tree: {
|
|
700
|
+
type: 'array',
|
|
701
|
+
description: "Recursive tree of nodes (phases, tasks, milestones). Children nest under parents via the 'children' array.",
|
|
702
|
+
items: {
|
|
703
|
+
type: 'object',
|
|
704
|
+
properties: {
|
|
705
|
+
node_type: { type: 'string', enum: VALID_NODE_TYPES, default: 'task' },
|
|
706
|
+
title: { type: 'string' },
|
|
707
|
+
description: { type: 'string' },
|
|
708
|
+
task_mode: { type: 'string', enum: VALID_TASK_MODES, default: 'free' },
|
|
709
|
+
agent_instructions: { type: 'string' },
|
|
710
|
+
children: { type: 'array' },
|
|
711
|
+
},
|
|
712
|
+
required: ['title'],
|
|
713
|
+
},
|
|
714
|
+
},
|
|
715
|
+
},
|
|
716
|
+
required: ['goal_id', 'title', 'rationale'],
|
|
717
|
+
},
|
|
718
|
+
};
|
|
719
|
+
|
|
720
|
+
async function createSubtree(apiClient, planId, parentId, children, results) {
|
|
721
|
+
for (const child of children || []) {
|
|
722
|
+
let createdNode;
|
|
723
|
+
try {
|
|
724
|
+
const payload = {
|
|
725
|
+
node_type: child.node_type || 'task',
|
|
726
|
+
title: child.title,
|
|
727
|
+
description: child.description || '',
|
|
728
|
+
task_mode: child.task_mode || 'free',
|
|
729
|
+
};
|
|
730
|
+
if (parentId) payload.parent_id = parentId;
|
|
731
|
+
if (child.agent_instructions) payload.agent_instructions = child.agent_instructions;
|
|
732
|
+
|
|
733
|
+
const resp = await apiClient.nodes.createNode(planId, payload);
|
|
734
|
+
// createNode returns { result, created } — unwrap.
|
|
735
|
+
createdNode = resp.result || resp;
|
|
736
|
+
results.push({ id: createdNode.id, title: createdNode.title, node_type: createdNode.node_type });
|
|
737
|
+
} catch (err) {
|
|
738
|
+
results.push({ title: child.title, error: err.response?.data?.error || err.message });
|
|
739
|
+
continue;
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
if (child.children?.length && createdNode?.id) {
|
|
743
|
+
await createSubtree(apiClient, planId, createdNode.id, child.children, results);
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
async function formIntentionHandler(args, apiClient) {
|
|
749
|
+
const { goal_id, title, description, rationale, status = 'active', visibility = 'private', tree = [] } = args;
|
|
750
|
+
|
|
751
|
+
if (apiClient.agentLoop?.createIntention) {
|
|
752
|
+
const treeError = validateTreeShape(tree);
|
|
753
|
+
if (treeError) {
|
|
754
|
+
return errorResponse('tree_shape_invalid', treeError);
|
|
755
|
+
}
|
|
756
|
+
try {
|
|
757
|
+
const result = await apiClient.agentLoop.createIntention({
|
|
758
|
+
goal_id,
|
|
759
|
+
title,
|
|
760
|
+
description,
|
|
761
|
+
rationale,
|
|
762
|
+
status,
|
|
763
|
+
visibility,
|
|
764
|
+
tree,
|
|
765
|
+
});
|
|
766
|
+
return formatResponse({
|
|
767
|
+
...result,
|
|
768
|
+
plan_id: result.plan?.id || result.plan_id,
|
|
769
|
+
goal_id,
|
|
770
|
+
status: result.plan?.status || status,
|
|
771
|
+
is_draft: (result.plan?.status || status) === 'draft',
|
|
772
|
+
nodes_created: Array.isArray(result.tree) ? result.tree.length : undefined,
|
|
773
|
+
next_step: (result.plan?.status || 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
|
+
} catch {
|
|
778
|
+
// Fall through to the legacy multi-call path for older/self-hosted APIs.
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
// Validate goal exists.
|
|
783
|
+
let goal;
|
|
784
|
+
try {
|
|
785
|
+
goal = await apiClient.goals.get(goal_id);
|
|
786
|
+
} catch (err) {
|
|
787
|
+
return errorResponse('not_found', `Goal ${goal_id} not found or not accessible: ${err.message}`);
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
// Validate tree shape upfront (atomic-ish — fail before partial creation).
|
|
791
|
+
const treeError = validateTreeShape(tree);
|
|
792
|
+
if (treeError) {
|
|
793
|
+
return errorResponse('tree_shape_invalid', treeError);
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
// Compose plan description (rationale + optional description).
|
|
797
|
+
const composedDescription = description ? `${rationale}\n\n${description}` : rationale;
|
|
798
|
+
|
|
799
|
+
// 1. Create plan. Visibility goes through a separate endpoint —
|
|
800
|
+
// POST /plans schema is .strict() and only accepts title, description, status, metadata.
|
|
801
|
+
let plan;
|
|
802
|
+
try {
|
|
803
|
+
plan = await apiClient.plans.createPlan({
|
|
804
|
+
title,
|
|
805
|
+
description: composedDescription,
|
|
806
|
+
status,
|
|
807
|
+
});
|
|
808
|
+
} catch (err) {
|
|
809
|
+
return errorResponse('create_failed', `Failed to create plan: ${err.response?.data?.error || err.message}`);
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
// 2. Apply non-default visibility (separate endpoint).
|
|
813
|
+
if (visibility && visibility !== 'private') {
|
|
814
|
+
try {
|
|
815
|
+
await apiClient.plans.updateVisibility(plan.id, { visibility });
|
|
816
|
+
} catch (err) {
|
|
817
|
+
// Non-fatal — plan exists with default visibility, caller can retry.
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
// 3. Link plan to goal (best-effort).
|
|
822
|
+
try {
|
|
823
|
+
await apiClient.goals.linkPlan(goal_id, plan.id);
|
|
824
|
+
} catch (err) {
|
|
825
|
+
// Non-fatal — plan exists, link can be retried by user.
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
// 3. Create tree (top-level children parent to root via omitted parent_id).
|
|
829
|
+
const nodeResults = [];
|
|
830
|
+
await createSubtree(apiClient, plan.id, null, tree, nodeResults);
|
|
831
|
+
|
|
832
|
+
return formatResponse({
|
|
833
|
+
as_of: asOf(),
|
|
834
|
+
plan_id: plan.id,
|
|
835
|
+
goal_id,
|
|
836
|
+
status: plan.status,
|
|
837
|
+
is_draft: plan.status === 'draft',
|
|
838
|
+
nodes_created: nodeResults.filter((n) => n.id).length,
|
|
839
|
+
node_failures: nodeResults.filter((n) => n.error),
|
|
840
|
+
nodes: nodeResults,
|
|
841
|
+
next_step: plan.status === 'draft'
|
|
842
|
+
? "Plan created as draft. Will surface in dashboard pending for human review. Auto-promotes to active when first task moves to in_progress."
|
|
843
|
+
: "Plan active. Claim a task with claim_next_task({plan_id}) to begin work.",
|
|
844
|
+
});
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
848
|
+
// extend_intention — add children under an existing parent (v1.0).
|
|
849
|
+
// Lightweight — does not go through the decision queue.
|
|
850
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
851
|
+
|
|
852
|
+
const extendIntentionDefinition = {
|
|
853
|
+
name: 'extend_intention',
|
|
854
|
+
description:
|
|
855
|
+
"Add children under an existing phase or task. Use when an agent has " +
|
|
856
|
+
"implicit authority to decompose work (e.g., a parent task they have " +
|
|
857
|
+
"claimed). For high-stakes structural proposals, use queue_decision " +
|
|
858
|
+
"with proposed_subtasks instead. Defaults to status='active'.",
|
|
859
|
+
inputSchema: {
|
|
860
|
+
type: 'object',
|
|
861
|
+
properties: {
|
|
862
|
+
parent_id: { type: 'string', description: "Phase or task to add children under." },
|
|
863
|
+
plan_id: { type: 'string', description: "Plan that owns the parent (auto-resolved if omitted)." },
|
|
864
|
+
rationale: { type: 'string', description: "Why these children. Stored in metadata for audit." },
|
|
865
|
+
children: {
|
|
866
|
+
type: 'array',
|
|
867
|
+
items: {
|
|
868
|
+
type: 'object',
|
|
869
|
+
properties: {
|
|
870
|
+
node_type: { type: 'string', enum: VALID_NODE_TYPES, default: 'task' },
|
|
871
|
+
title: { type: 'string' },
|
|
872
|
+
description: { type: 'string' },
|
|
873
|
+
task_mode: { type: 'string', enum: VALID_TASK_MODES, default: 'free' },
|
|
874
|
+
agent_instructions: { type: 'string' },
|
|
875
|
+
children: { type: 'array' },
|
|
876
|
+
},
|
|
877
|
+
required: ['title'],
|
|
878
|
+
},
|
|
879
|
+
},
|
|
880
|
+
},
|
|
881
|
+
required: ['parent_id', 'rationale', 'children'],
|
|
882
|
+
},
|
|
883
|
+
};
|
|
884
|
+
|
|
885
|
+
async function extendIntentionHandler(args, apiClient) {
|
|
886
|
+
const { parent_id, rationale, children = [] } = args;
|
|
887
|
+
let { plan_id } = args;
|
|
888
|
+
|
|
889
|
+
// Resolve plan_id from parent if not provided.
|
|
890
|
+
if (!plan_id) {
|
|
891
|
+
try {
|
|
892
|
+
const parent = await apiClient.axiosInstance.get(`/nodes/${parent_id}`).then((r) => r.data);
|
|
893
|
+
plan_id = parent.plan_id || parent.planId;
|
|
894
|
+
} catch (err) {
|
|
895
|
+
return errorResponse('not_found', `Could not resolve plan_id from parent ${parent_id}: ${err.message}`);
|
|
896
|
+
}
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
const treeError = validateTreeShape(children);
|
|
900
|
+
if (treeError) {
|
|
901
|
+
return errorResponse('tree_shape_invalid', treeError);
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
const nodeResults = [];
|
|
905
|
+
await createSubtree(apiClient, plan_id, parent_id, children, nodeResults);
|
|
906
|
+
|
|
907
|
+
return formatResponse({
|
|
908
|
+
as_of: asOf(),
|
|
909
|
+
plan_id,
|
|
910
|
+
parent_id,
|
|
911
|
+
rationale,
|
|
912
|
+
nodes_created: nodeResults.filter((n) => n.id).length,
|
|
913
|
+
node_failures: nodeResults.filter((n) => n.error),
|
|
914
|
+
nodes: nodeResults,
|
|
915
|
+
});
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
919
|
+
// propose_research_chain — RPI shortcut (v1.0).
|
|
920
|
+
// Creates Research → Plan → Implement under parent with two blocking edges.
|
|
921
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
922
|
+
|
|
923
|
+
const proposeResearchChainDefinition = {
|
|
924
|
+
name: 'propose_research_chain',
|
|
925
|
+
description:
|
|
926
|
+
"Create a Research → Plan → Implement triple under an existing parent " +
|
|
927
|
+
"task or phase. The Research task feeds Plan; Plan feeds Implement " +
|
|
928
|
+
"(via 'blocks' dependency edges). Use when tackling work with " +
|
|
929
|
+
"significant unknowns. Defaults to status='active'.",
|
|
930
|
+
inputSchema: {
|
|
931
|
+
type: 'object',
|
|
932
|
+
properties: {
|
|
933
|
+
parent_id: { type: 'string', description: "Parent task or phase the chain attaches to." },
|
|
934
|
+
plan_id: { type: 'string', description: "Plan that owns the parent (auto-resolved if omitted)." },
|
|
935
|
+
research_question: { type: 'string', description: "What the Research task investigates." },
|
|
936
|
+
implementation_target: { type: 'string', description: "What the Implement task ultimately produces." },
|
|
937
|
+
rationale: { type: 'string', description: "Why an RPI chain is appropriate here." },
|
|
938
|
+
},
|
|
939
|
+
required: ['parent_id', 'research_question', 'implementation_target', 'rationale'],
|
|
940
|
+
},
|
|
941
|
+
};
|
|
942
|
+
|
|
943
|
+
async function proposeResearchChainHandler(args, apiClient) {
|
|
944
|
+
const { parent_id, research_question, implementation_target, rationale } = args;
|
|
945
|
+
let { plan_id } = args;
|
|
946
|
+
|
|
947
|
+
if (!plan_id) {
|
|
948
|
+
try {
|
|
949
|
+
const parent = await apiClient.axiosInstance.get(`/nodes/${parent_id}`).then((r) => r.data);
|
|
950
|
+
plan_id = parent.plan_id || parent.planId;
|
|
951
|
+
} catch (err) {
|
|
952
|
+
return errorResponse('not_found', `Could not resolve plan_id from parent ${parent_id}: ${err.message}`);
|
|
953
|
+
}
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
const created = {};
|
|
957
|
+
const failures = [];
|
|
958
|
+
|
|
959
|
+
// 1. Create the three tasks.
|
|
960
|
+
for (const [key, spec] of [
|
|
961
|
+
['research', { title: `Research: ${research_question}`, description: research_question, task_mode: 'research' }],
|
|
962
|
+
['plan', { title: `Plan: ${implementation_target}`, description: `Plan implementation based on research findings`, task_mode: 'plan' }],
|
|
963
|
+
['implement', { title: `Implement: ${implementation_target}`, description: implementation_target, task_mode: 'implement' }],
|
|
964
|
+
]) {
|
|
965
|
+
try {
|
|
966
|
+
const resp = await apiClient.nodes.createNode(plan_id, {
|
|
967
|
+
node_type: 'task',
|
|
968
|
+
title: spec.title,
|
|
969
|
+
description: spec.description,
|
|
970
|
+
task_mode: spec.task_mode,
|
|
971
|
+
parent_id,
|
|
972
|
+
});
|
|
973
|
+
created[key] = resp.result || resp;
|
|
974
|
+
} catch (err) {
|
|
975
|
+
failures.push({ step: `create_${key}`, error: err.response?.data?.error || err.message });
|
|
976
|
+
}
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
if (failures.length) {
|
|
980
|
+
return formatResponse({
|
|
981
|
+
as_of: asOf(),
|
|
982
|
+
plan_id,
|
|
983
|
+
parent_id,
|
|
984
|
+
partial: true,
|
|
985
|
+
created: Object.fromEntries(Object.entries(created).map(([k, v]) => [k, { id: v.id, title: v.title }])),
|
|
986
|
+
failures,
|
|
987
|
+
});
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
// 2. Create the two blocking edges: research blocks plan, plan blocks implement.
|
|
991
|
+
const edges = [];
|
|
992
|
+
for (const [from, to] of [
|
|
993
|
+
[created.research.id, created.plan.id],
|
|
994
|
+
[created.plan.id, created.implement.id],
|
|
995
|
+
]) {
|
|
996
|
+
try {
|
|
997
|
+
await apiClient.axiosInstance.post('/dependencies', {
|
|
998
|
+
source_node_id: from,
|
|
999
|
+
target_node_id: to,
|
|
1000
|
+
dependency_type: 'blocks',
|
|
1001
|
+
});
|
|
1002
|
+
edges.push({ from, to, relation: 'blocks' });
|
|
1003
|
+
} catch (err) {
|
|
1004
|
+
failures.push({ step: 'create_edge', error: err.response?.data?.error || err.message, from, to });
|
|
1005
|
+
}
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
return formatResponse({
|
|
1009
|
+
as_of: asOf(),
|
|
1010
|
+
plan_id,
|
|
1011
|
+
parent_id,
|
|
1012
|
+
rationale,
|
|
1013
|
+
research: { id: created.research.id, title: created.research.title },
|
|
1014
|
+
plan: { id: created.plan.id, title: created.plan.title },
|
|
1015
|
+
implement: { id: created.implement.id, title: created.implement.title },
|
|
1016
|
+
edges,
|
|
1017
|
+
failures,
|
|
1018
|
+
next_step: "Claim the Research task with claim_next_task({plan_id}) to begin investigation.",
|
|
1019
|
+
});
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
1023
|
+
// link_intentions — create dependency edge between two existing tasks (v1.0).
|
|
1024
|
+
// Cycle detection happens server-side; we surface the 409 cleanly.
|
|
1025
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
1026
|
+
|
|
1027
|
+
const VALID_RELATIONS = ['blocks', 'requires', 'relates_to'];
|
|
1028
|
+
|
|
1029
|
+
const linkIntentionsDefinition = {
|
|
1030
|
+
name: 'link_intentions',
|
|
1031
|
+
description:
|
|
1032
|
+
"Create a dependency edge between two existing tasks. Use to express " +
|
|
1033
|
+
"discovered ordering constraints (e.g., agent realizes task B requires " +
|
|
1034
|
+
"task A's output). Server rejects cycles. Both tasks must be in the " +
|
|
1035
|
+
"same plan.",
|
|
1036
|
+
inputSchema: {
|
|
1037
|
+
type: 'object',
|
|
1038
|
+
properties: {
|
|
1039
|
+
from_task_id: { type: 'string' },
|
|
1040
|
+
to_task_id: { type: 'string' },
|
|
1041
|
+
relation: { type: 'string', enum: VALID_RELATIONS, default: 'blocks' },
|
|
1042
|
+
rationale: { type: 'string', description: "Why this link. Stored in dependency metadata." },
|
|
1043
|
+
},
|
|
1044
|
+
required: ['from_task_id', 'to_task_id', 'rationale'],
|
|
1045
|
+
},
|
|
1046
|
+
};
|
|
1047
|
+
|
|
1048
|
+
async function linkIntentionsHandler(args, apiClient) {
|
|
1049
|
+
const { from_task_id, to_task_id, relation = 'blocks', rationale } = args;
|
|
1050
|
+
|
|
1051
|
+
if (from_task_id === to_task_id) {
|
|
1052
|
+
return errorResponse('invalid_argument', 'from_task_id and to_task_id must differ');
|
|
1053
|
+
}
|
|
1054
|
+
|
|
1055
|
+
// Resolve plan_id from the source task; validate target is in the same plan.
|
|
1056
|
+
let planId, fromPlan, toPlan;
|
|
1057
|
+
try {
|
|
1058
|
+
const fromNode = await apiClient.axiosInstance.get(`/nodes/${from_task_id}`).then((r) => r.data);
|
|
1059
|
+
fromPlan = fromNode.plan_id || fromNode.planId;
|
|
1060
|
+
planId = fromPlan;
|
|
1061
|
+
} catch (err) {
|
|
1062
|
+
return errorResponse('not_found', `from_task ${from_task_id} not found: ${err.message}`);
|
|
1063
|
+
}
|
|
1064
|
+
try {
|
|
1065
|
+
const toNode = await apiClient.axiosInstance.get(`/nodes/${to_task_id}`).then((r) => r.data);
|
|
1066
|
+
toPlan = toNode.plan_id || toNode.planId;
|
|
1067
|
+
} catch (err) {
|
|
1068
|
+
return errorResponse('not_found', `to_task ${to_task_id} not found: ${err.message}`);
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
if (fromPlan !== toPlan) {
|
|
1072
|
+
return errorResponse('cross_plan_unsupported', `Both tasks must be in the same plan (from: ${fromPlan}, to: ${toPlan}). Cross-plan links require a separate API.`);
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1075
|
+
try {
|
|
1076
|
+
const dep = await apiClient.axiosInstance.post(`/plans/${planId}/dependencies`, {
|
|
1077
|
+
source_node_id: from_task_id,
|
|
1078
|
+
target_node_id: to_task_id,
|
|
1079
|
+
dependency_type: relation,
|
|
1080
|
+
metadata: { rationale },
|
|
1081
|
+
}).then((r) => r.data);
|
|
1082
|
+
|
|
1083
|
+
return formatResponse({
|
|
1084
|
+
as_of: asOf(),
|
|
1085
|
+
dependency_id: dep.id,
|
|
1086
|
+
plan_id: planId,
|
|
1087
|
+
from_task_id,
|
|
1088
|
+
to_task_id,
|
|
1089
|
+
relation,
|
|
1090
|
+
});
|
|
1091
|
+
} catch (err) {
|
|
1092
|
+
const status = err.response?.status;
|
|
1093
|
+
const upstream = err.response?.data?.error || err.message;
|
|
1094
|
+
if (status === 409) {
|
|
1095
|
+
return errorResponse('cycle_detected', `Edge rejected — would create a cycle: ${upstream}`);
|
|
1096
|
+
}
|
|
1097
|
+
return errorResponse('create_failed', `Failed to create dependency: ${upstream}`);
|
|
1098
|
+
}
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
1102
|
+
// unlink_intentions — remove a dependency edge (v1.0).
|
|
1103
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
1104
|
+
|
|
1105
|
+
const unlinkIntentionsDefinition = {
|
|
1106
|
+
name: 'unlink_intentions',
|
|
1107
|
+
description: "Remove a dependency edge by id.",
|
|
1108
|
+
inputSchema: {
|
|
1109
|
+
type: 'object',
|
|
1110
|
+
properties: {
|
|
1111
|
+
dependency_id: { type: 'string' },
|
|
1112
|
+
plan_id: { type: 'string', description: "Plan that owns the dependency. Required for the route." },
|
|
1113
|
+
reason: { type: 'string', description: "Why removed. Logged for audit." },
|
|
1114
|
+
},
|
|
1115
|
+
required: ['dependency_id', 'plan_id'],
|
|
1116
|
+
},
|
|
1117
|
+
};
|
|
1118
|
+
|
|
1119
|
+
async function unlinkIntentionsHandler(args, apiClient) {
|
|
1120
|
+
const { dependency_id, plan_id, reason } = args;
|
|
1121
|
+
|
|
1122
|
+
try {
|
|
1123
|
+
await apiClient.axiosInstance.delete(`/plans/${plan_id}/dependencies/${dependency_id}`);
|
|
1124
|
+
return formatResponse({
|
|
1125
|
+
as_of: asOf(),
|
|
1126
|
+
dependency_id,
|
|
1127
|
+
plan_id,
|
|
1128
|
+
reason: reason || null,
|
|
1129
|
+
removed: true,
|
|
1130
|
+
});
|
|
1131
|
+
} catch (err) {
|
|
1132
|
+
const upstream = err.response?.data?.error || err.message;
|
|
1133
|
+
if (err.response?.status === 404) {
|
|
1134
|
+
return errorResponse('not_found', `Dependency ${dependency_id} not found in plan ${plan_id}`);
|
|
1135
|
+
}
|
|
1136
|
+
return errorResponse('delete_failed', `Failed to remove dependency: ${upstream}`);
|
|
1137
|
+
}
|
|
1138
|
+
}
|
|
1139
|
+
|
|
1140
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
1141
|
+
// update_plan — edit any plan property atomically (v1.0).
|
|
1142
|
+
// Status changes route here; visibility, github linkage, metadata, etc.
|
|
1143
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
1144
|
+
|
|
1145
|
+
const updatePlanDefinition = {
|
|
1146
|
+
name: 'update_plan',
|
|
1147
|
+
description:
|
|
1148
|
+
"Edit any plan property atomically: title, description, status, " +
|
|
1149
|
+
"visibility, GitHub linkage, metadata. Use status='archived' to " +
|
|
1150
|
+
"soft-delete (recoverable via status='active' + restore=true). " +
|
|
1151
|
+
"Hard delete stays REST-only with admin auth.",
|
|
1152
|
+
inputSchema: {
|
|
1153
|
+
type: 'object',
|
|
1154
|
+
properties: {
|
|
1155
|
+
plan_id: { type: 'string' },
|
|
1156
|
+
title: { type: 'string' },
|
|
1157
|
+
description: { type: 'string' },
|
|
1158
|
+
status: {
|
|
1159
|
+
type: 'string',
|
|
1160
|
+
enum: ['draft', 'active', 'completed', 'archived'],
|
|
1161
|
+
},
|
|
1162
|
+
visibility: { type: 'string', enum: ['private', 'unlisted', 'public'] },
|
|
1163
|
+
metadata: { type: 'object', description: "Shallow-merged into existing metadata." },
|
|
1164
|
+
restore: {
|
|
1165
|
+
type: 'boolean',
|
|
1166
|
+
description: "Required when un-archiving (status: 'archived' → 'active'). Guards against accidental restoration.",
|
|
1167
|
+
default: false,
|
|
1168
|
+
},
|
|
1169
|
+
},
|
|
1170
|
+
required: ['plan_id'],
|
|
1171
|
+
},
|
|
1172
|
+
};
|
|
1173
|
+
|
|
1174
|
+
async function updatePlanHandler(args, apiClient) {
|
|
1175
|
+
const { plan_id, title, description, status, visibility, metadata, restore } = args;
|
|
1176
|
+
|
|
1177
|
+
// Guard: un-archiving requires explicit restore=true.
|
|
1178
|
+
if (status && status !== 'archived') {
|
|
1179
|
+
try {
|
|
1180
|
+
const current = await apiClient.plans.getPlan(plan_id);
|
|
1181
|
+
if (current.status === 'archived' && !restore) {
|
|
1182
|
+
return errorResponse(
|
|
1183
|
+
'restore_required',
|
|
1184
|
+
`Plan ${plan_id} is archived. Pass restore=true to un-archive.`
|
|
1185
|
+
);
|
|
1186
|
+
}
|
|
1187
|
+
} catch (err) {
|
|
1188
|
+
// If we can't fetch, fall through — the update will fail loudly anyway.
|
|
1189
|
+
}
|
|
1190
|
+
}
|
|
1191
|
+
|
|
1192
|
+
const payload = {};
|
|
1193
|
+
if (title !== undefined) payload.title = title;
|
|
1194
|
+
if (description !== undefined) payload.description = description;
|
|
1195
|
+
if (status !== undefined) payload.status = status;
|
|
1196
|
+
if (metadata !== undefined) payload.metadata = metadata;
|
|
1197
|
+
|
|
1198
|
+
const applied = [];
|
|
1199
|
+
const failures = [];
|
|
1200
|
+
|
|
1201
|
+
if (Object.keys(payload).length) {
|
|
1202
|
+
try {
|
|
1203
|
+
await apiClient.plans.updatePlan(plan_id, payload);
|
|
1204
|
+
applied.push(...Object.keys(payload));
|
|
1205
|
+
} catch (err) {
|
|
1206
|
+
failures.push({ step: 'update_plan', error: err.response?.data?.error || err.message });
|
|
1207
|
+
}
|
|
1208
|
+
}
|
|
1209
|
+
|
|
1210
|
+
if (visibility !== undefined) {
|
|
1211
|
+
try {
|
|
1212
|
+
await apiClient.plans.updateVisibility(plan_id, { visibility });
|
|
1213
|
+
applied.push('visibility');
|
|
1214
|
+
} catch (err) {
|
|
1215
|
+
failures.push({ step: 'update_visibility', error: err.response?.data?.error || err.message });
|
|
1216
|
+
}
|
|
1217
|
+
}
|
|
1218
|
+
|
|
1219
|
+
let plan = null;
|
|
1220
|
+
try { plan = await apiClient.plans.getPlan(plan_id); } catch {}
|
|
1221
|
+
|
|
1222
|
+
return formatResponse({
|
|
1223
|
+
as_of: asOf(),
|
|
1224
|
+
plan_id,
|
|
1225
|
+
applied_changes: applied,
|
|
1226
|
+
failures,
|
|
1227
|
+
plan: plan ? { id: plan.id, title: plan.title, status: plan.status, visibility: plan.visibility } : null,
|
|
1228
|
+
});
|
|
1229
|
+
}
|
|
1230
|
+
|
|
1231
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
1232
|
+
// update_node — edit any node property except status (v1.0).
|
|
1233
|
+
// Status transitions route through update_task (existing tool) since they
|
|
1234
|
+
// trigger claim/log side effects.
|
|
1235
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
1236
|
+
|
|
1237
|
+
const updateNodeDefinition = {
|
|
1238
|
+
name: 'update_node',
|
|
1239
|
+
description:
|
|
1240
|
+
"Edit any node property atomically: title, description, node_type, " +
|
|
1241
|
+
"task_mode, agent_instructions, metadata. Status transitions belong " +
|
|
1242
|
+
"on update_task (which handles claim/log side effects). Rejects " +
|
|
1243
|
+
"node_type changes when the node has children.",
|
|
1244
|
+
inputSchema: {
|
|
1245
|
+
type: 'object',
|
|
1246
|
+
properties: {
|
|
1247
|
+
node_id: { type: 'string' },
|
|
1248
|
+
plan_id: { type: 'string', description: "Auto-resolved if omitted." },
|
|
1249
|
+
title: { type: 'string' },
|
|
1250
|
+
description: { type: 'string' },
|
|
1251
|
+
node_type: { type: 'string', enum: ['phase', 'task', 'milestone'] },
|
|
1252
|
+
task_mode: { type: 'string', enum: VALID_TASK_MODES },
|
|
1253
|
+
agent_instructions: { type: 'string' },
|
|
1254
|
+
metadata: { type: 'object' },
|
|
1255
|
+
},
|
|
1256
|
+
required: ['node_id'],
|
|
1257
|
+
},
|
|
1258
|
+
};
|
|
1259
|
+
|
|
1260
|
+
async function updateNodeHandler(args, apiClient) {
|
|
1261
|
+
const { node_id, title, description, node_type, task_mode, agent_instructions, metadata } = args;
|
|
1262
|
+
let { plan_id } = args;
|
|
1263
|
+
|
|
1264
|
+
if (!plan_id) {
|
|
1265
|
+
try {
|
|
1266
|
+
const node = await apiClient.axiosInstance.get(`/nodes/${node_id}`).then((r) => r.data);
|
|
1267
|
+
plan_id = node.plan_id || node.planId;
|
|
1268
|
+
} catch (err) {
|
|
1269
|
+
return errorResponse('not_found', `Could not resolve plan_id from node ${node_id}: ${err.message}`);
|
|
1270
|
+
}
|
|
1271
|
+
}
|
|
1272
|
+
|
|
1273
|
+
const payload = {};
|
|
1274
|
+
if (title !== undefined) payload.title = title;
|
|
1275
|
+
if (description !== undefined) payload.description = description;
|
|
1276
|
+
if (node_type !== undefined) payload.node_type = node_type;
|
|
1277
|
+
if (task_mode !== undefined) payload.task_mode = task_mode;
|
|
1278
|
+
if (agent_instructions !== undefined) payload.agent_instructions = agent_instructions;
|
|
1279
|
+
if (metadata !== undefined) payload.metadata = metadata;
|
|
1280
|
+
|
|
1281
|
+
if (!Object.keys(payload).length) {
|
|
1282
|
+
return errorResponse('no_changes', 'At least one field to update must be provided.');
|
|
1283
|
+
}
|
|
1284
|
+
|
|
1285
|
+
try {
|
|
1286
|
+
const updated = await apiClient.nodes.updateNode(plan_id, node_id, payload);
|
|
1287
|
+
return formatResponse({
|
|
1288
|
+
as_of: asOf(),
|
|
1289
|
+
plan_id,
|
|
1290
|
+
node_id,
|
|
1291
|
+
applied_changes: Object.keys(payload),
|
|
1292
|
+
node: updated.result || updated,
|
|
1293
|
+
});
|
|
1294
|
+
} catch (err) {
|
|
1295
|
+
return errorResponse('update_failed', `Failed to update node: ${err.response?.data?.error || err.message}`);
|
|
1296
|
+
}
|
|
1297
|
+
}
|
|
1298
|
+
|
|
1299
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
1300
|
+
// move_node — reparent a node within the same plan (v1.0).
|
|
1301
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
1302
|
+
|
|
1303
|
+
const moveNodeDefinition = {
|
|
1304
|
+
name: 'move_node',
|
|
1305
|
+
description:
|
|
1306
|
+
"Reparent a node within the same plan. Cycle-safe (server rejects " +
|
|
1307
|
+
"moves that would create a tree cycle). Optional position sets the " +
|
|
1308
|
+
"order_index among siblings.",
|
|
1309
|
+
inputSchema: {
|
|
1310
|
+
type: 'object',
|
|
1311
|
+
properties: {
|
|
1312
|
+
node_id: { type: 'string' },
|
|
1313
|
+
new_parent_id: { type: 'string' },
|
|
1314
|
+
plan_id: { type: 'string', description: "Auto-resolved if omitted." },
|
|
1315
|
+
position: { type: 'integer', description: "Optional order_index among siblings." },
|
|
1316
|
+
},
|
|
1317
|
+
required: ['node_id', 'new_parent_id'],
|
|
1318
|
+
},
|
|
1319
|
+
};
|
|
1320
|
+
|
|
1321
|
+
async function moveNodeHandler(args, apiClient) {
|
|
1322
|
+
const { node_id, new_parent_id, position } = args;
|
|
1323
|
+
let { plan_id } = args;
|
|
1324
|
+
|
|
1325
|
+
if (!plan_id) {
|
|
1326
|
+
try {
|
|
1327
|
+
const node = await apiClient.axiosInstance.get(`/nodes/${node_id}`).then((r) => r.data);
|
|
1328
|
+
plan_id = node.plan_id || node.planId;
|
|
1329
|
+
} catch (err) {
|
|
1330
|
+
return errorResponse('not_found', `Could not resolve plan_id from node ${node_id}: ${err.message}`);
|
|
1331
|
+
}
|
|
1332
|
+
}
|
|
1333
|
+
|
|
1334
|
+
const payload = { parent_id: new_parent_id };
|
|
1335
|
+
if (typeof position === 'number') payload.order_index = position;
|
|
1336
|
+
|
|
1337
|
+
try {
|
|
1338
|
+
const result = await apiClient.axiosInstance.post(
|
|
1339
|
+
`/plans/${plan_id}/nodes/${node_id}/move`,
|
|
1340
|
+
payload
|
|
1341
|
+
).then((r) => r.data);
|
|
1342
|
+
|
|
1343
|
+
return formatResponse({
|
|
1344
|
+
as_of: asOf(),
|
|
1345
|
+
plan_id,
|
|
1346
|
+
node_id,
|
|
1347
|
+
new_parent_id,
|
|
1348
|
+
position: position ?? null,
|
|
1349
|
+
node: result,
|
|
1350
|
+
});
|
|
1351
|
+
} catch (err) {
|
|
1352
|
+
return errorResponse('move_failed', `Failed to move node: ${err.response?.data?.error || err.message}`);
|
|
1353
|
+
}
|
|
1354
|
+
}
|
|
1355
|
+
|
|
1356
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
1357
|
+
// delete_plan / delete_node — soft delete via status='archived' (v1.0).
|
|
1358
|
+
// Hard delete stays REST-only with admin auth.
|
|
1359
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
1360
|
+
|
|
1361
|
+
const deletePlanDefinition = {
|
|
1362
|
+
name: 'delete_plan',
|
|
1363
|
+
description:
|
|
1364
|
+
"Soft-delete a plan by setting status='archived'. Recoverable via " +
|
|
1365
|
+
"update_plan({status: 'active', restore: true}). Hard delete is not " +
|
|
1366
|
+
"agent-callable — use REST + admin token if absolutely needed.",
|
|
1367
|
+
inputSchema: {
|
|
1368
|
+
type: 'object',
|
|
1369
|
+
properties: {
|
|
1370
|
+
plan_id: { type: 'string' },
|
|
1371
|
+
reason: { type: 'string', description: "Logged for audit." },
|
|
1372
|
+
},
|
|
1373
|
+
required: ['plan_id'],
|
|
1374
|
+
},
|
|
1375
|
+
};
|
|
1376
|
+
|
|
1377
|
+
async function deletePlanHandler(args, apiClient) {
|
|
1378
|
+
const { plan_id, reason } = args;
|
|
1379
|
+
|
|
1380
|
+
try {
|
|
1381
|
+
await apiClient.plans.updatePlan(plan_id, { status: 'archived' });
|
|
1382
|
+
return formatResponse({
|
|
1383
|
+
as_of: asOf(),
|
|
1384
|
+
plan_id,
|
|
1385
|
+
archived: true,
|
|
1386
|
+
reason: reason || null,
|
|
1387
|
+
next_step: "Plan archived. To restore: update_plan({plan_id, status: 'active', restore: true})",
|
|
1388
|
+
});
|
|
1389
|
+
} catch (err) {
|
|
1390
|
+
return errorResponse('archive_failed', `Failed to archive plan: ${err.response?.data?.error || err.message}`);
|
|
1391
|
+
}
|
|
1392
|
+
}
|
|
1393
|
+
|
|
1394
|
+
const deleteNodeDefinition = {
|
|
1395
|
+
name: 'delete_node',
|
|
1396
|
+
description:
|
|
1397
|
+
"Soft-delete a node by setting status='archived'. Cascades to children " +
|
|
1398
|
+
"by default. Recoverable via update_task({status: 'not_started'}).",
|
|
1399
|
+
inputSchema: {
|
|
1400
|
+
type: 'object',
|
|
1401
|
+
properties: {
|
|
1402
|
+
node_id: { type: 'string' },
|
|
1403
|
+
plan_id: { type: 'string', description: "Auto-resolved if omitted." },
|
|
1404
|
+
reason: { type: 'string' },
|
|
1405
|
+
cascade_children: { type: 'boolean', default: true },
|
|
1406
|
+
},
|
|
1407
|
+
required: ['node_id'],
|
|
1408
|
+
},
|
|
1409
|
+
};
|
|
1410
|
+
|
|
1411
|
+
async function deleteNodeHandler(args, apiClient) {
|
|
1412
|
+
const { node_id, reason } = args;
|
|
1413
|
+
let { plan_id } = args;
|
|
1414
|
+
// cascade_children is intentionally ignored at the MCP layer for now —
|
|
1415
|
+
// backend cascade behavior on archived status is implicit (children remain
|
|
1416
|
+
// accessible until explicitly archived themselves). When backend gains
|
|
1417
|
+
// explicit cascade-on-archive, surface it here.
|
|
1418
|
+
|
|
1419
|
+
if (!plan_id) {
|
|
1420
|
+
try {
|
|
1421
|
+
const node = await apiClient.axiosInstance.get(`/nodes/${node_id}`).then((r) => r.data);
|
|
1422
|
+
plan_id = node.plan_id || node.planId;
|
|
1423
|
+
} catch (err) {
|
|
1424
|
+
return errorResponse('not_found', `Could not resolve plan_id from node ${node_id}: ${err.message}`);
|
|
1425
|
+
}
|
|
1426
|
+
}
|
|
1427
|
+
|
|
1428
|
+
try {
|
|
1429
|
+
await apiClient.nodes.updateNode(plan_id, node_id, { status: 'archived' });
|
|
1430
|
+
return formatResponse({
|
|
1431
|
+
as_of: asOf(),
|
|
1432
|
+
plan_id,
|
|
1433
|
+
node_id,
|
|
1434
|
+
archived: true,
|
|
1435
|
+
reason: reason || null,
|
|
1436
|
+
});
|
|
1437
|
+
} catch (err) {
|
|
1438
|
+
return errorResponse('archive_failed', `Failed to archive node: ${err.response?.data?.error || err.message}`);
|
|
1439
|
+
}
|
|
1440
|
+
}
|
|
1441
|
+
|
|
1442
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
1443
|
+
// share_plan — atomic visibility + collaborator changes (v1.0).
|
|
1444
|
+
// Collaborators specified by user_id (email resolution stays UI-side for now).
|
|
1445
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
1446
|
+
|
|
1447
|
+
const VALID_COLLAB_ROLES = ['viewer', 'editor', 'admin'];
|
|
1448
|
+
|
|
1449
|
+
const sharePlanDefinition = {
|
|
1450
|
+
name: 'share_plan',
|
|
1451
|
+
description:
|
|
1452
|
+
"Atomically change a plan's visibility and add/remove collaborators in " +
|
|
1453
|
+
"one call. Collaborators are specified by user_id (email-based invites " +
|
|
1454
|
+
"stay UI-only for now). Caller must be plan owner or admin.",
|
|
1455
|
+
inputSchema: {
|
|
1456
|
+
type: 'object',
|
|
1457
|
+
properties: {
|
|
1458
|
+
plan_id: { type: 'string' },
|
|
1459
|
+
visibility: { type: 'string', enum: ['private', 'unlisted', 'public'] },
|
|
1460
|
+
add_collaborators: {
|
|
1461
|
+
type: 'array',
|
|
1462
|
+
items: {
|
|
1463
|
+
type: 'object',
|
|
1464
|
+
properties: {
|
|
1465
|
+
user_id: { type: 'string' },
|
|
1466
|
+
role: { type: 'string', enum: VALID_COLLAB_ROLES, default: 'viewer' },
|
|
1467
|
+
},
|
|
1468
|
+
required: ['user_id'],
|
|
1469
|
+
},
|
|
1470
|
+
},
|
|
1471
|
+
remove_collaborators: {
|
|
1472
|
+
type: 'array',
|
|
1473
|
+
description: "Array of user_ids to remove from the plan.",
|
|
1474
|
+
items: { type: 'string' },
|
|
1475
|
+
},
|
|
1476
|
+
},
|
|
1477
|
+
required: ['plan_id'],
|
|
1478
|
+
},
|
|
1479
|
+
};
|
|
1480
|
+
|
|
1481
|
+
async function sharePlanHandler(args, apiClient) {
|
|
1482
|
+
const { plan_id, visibility, add_collaborators = [], remove_collaborators = [] } = args;
|
|
1483
|
+
const applied = [];
|
|
1484
|
+
const failures = [];
|
|
1485
|
+
|
|
1486
|
+
if (visibility) {
|
|
1487
|
+
try {
|
|
1488
|
+
await apiClient.plans.updateVisibility(plan_id, { visibility });
|
|
1489
|
+
applied.push(`visibility:${visibility}`);
|
|
1490
|
+
} catch (err) {
|
|
1491
|
+
failures.push({ step: 'visibility', error: err.response?.data?.error || err.message });
|
|
1492
|
+
}
|
|
1493
|
+
}
|
|
1494
|
+
|
|
1495
|
+
for (const collab of add_collaborators) {
|
|
1496
|
+
try {
|
|
1497
|
+
await apiClient.axiosInstance.post(`/plans/${plan_id}/collaborators`, {
|
|
1498
|
+
user_id: collab.user_id,
|
|
1499
|
+
role: collab.role || 'viewer',
|
|
1500
|
+
});
|
|
1501
|
+
applied.push(`add:${collab.user_id}:${collab.role || 'viewer'}`);
|
|
1502
|
+
} catch (err) {
|
|
1503
|
+
failures.push({ step: `add:${collab.user_id}`, error: err.response?.data?.error || err.message });
|
|
1504
|
+
}
|
|
1505
|
+
}
|
|
1506
|
+
|
|
1507
|
+
for (const userId of remove_collaborators) {
|
|
1508
|
+
try {
|
|
1509
|
+
await apiClient.axiosInstance.delete(`/plans/${plan_id}/collaborators/${userId}`);
|
|
1510
|
+
applied.push(`remove:${userId}`);
|
|
1511
|
+
} catch (err) {
|
|
1512
|
+
failures.push({ step: `remove:${userId}`, error: err.response?.data?.error || err.message });
|
|
1513
|
+
}
|
|
1514
|
+
}
|
|
1515
|
+
|
|
1516
|
+
return formatResponse({
|
|
1517
|
+
as_of: asOf(),
|
|
1518
|
+
plan_id,
|
|
1519
|
+
applied_changes: applied,
|
|
1520
|
+
failures,
|
|
1521
|
+
});
|
|
1522
|
+
}
|
|
1523
|
+
|
|
1524
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
1525
|
+
// invite_member / update_member_role / remove_member — org membership (v1.0).
|
|
1526
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
1527
|
+
|
|
1528
|
+
const inviteMemberDefinition = {
|
|
1529
|
+
name: 'invite_member',
|
|
1530
|
+
description:
|
|
1531
|
+
"Add a user to an organization by user_id or email. Caller must be " +
|
|
1532
|
+
"org owner or admin. If email is provided and the user doesn't exist, " +
|
|
1533
|
+
"the API returns 404 (email-invite flow stays UI-only).",
|
|
1534
|
+
inputSchema: {
|
|
1535
|
+
type: 'object',
|
|
1536
|
+
properties: {
|
|
1537
|
+
organization_id: { type: 'string' },
|
|
1538
|
+
user_id: { type: 'string' },
|
|
1539
|
+
email: { type: 'string' },
|
|
1540
|
+
role: { type: 'string', enum: ['admin', 'member'], default: 'member' },
|
|
1541
|
+
},
|
|
1542
|
+
required: ['organization_id'],
|
|
1543
|
+
},
|
|
1544
|
+
};
|
|
1545
|
+
|
|
1546
|
+
async function inviteMemberHandler(args, apiClient) {
|
|
1547
|
+
const { organization_id, user_id, email, role = 'member' } = args;
|
|
1548
|
+
|
|
1549
|
+
if (!user_id && !email) {
|
|
1550
|
+
return errorResponse('invalid_argument', 'Either user_id or email must be provided.');
|
|
1551
|
+
}
|
|
1552
|
+
|
|
1553
|
+
const payload = { role };
|
|
1554
|
+
if (user_id) payload.user_id = user_id;
|
|
1555
|
+
if (email) payload.email = email;
|
|
1556
|
+
|
|
1557
|
+
try {
|
|
1558
|
+
const member = await apiClient.organizations.addMember(organization_id, payload);
|
|
1559
|
+
return formatResponse({
|
|
1560
|
+
as_of: asOf(),
|
|
1561
|
+
organization_id,
|
|
1562
|
+
member: {
|
|
1563
|
+
membership_id: member.id,
|
|
1564
|
+
user_id: member.user?.id,
|
|
1565
|
+
email: member.user?.email,
|
|
1566
|
+
role: member.role,
|
|
1567
|
+
},
|
|
1568
|
+
});
|
|
1569
|
+
} catch (err) {
|
|
1570
|
+
const upstream = err.response?.data?.error || err.message;
|
|
1571
|
+
const code = err.response?.status === 404 ? 'user_not_found' : 'invite_failed';
|
|
1572
|
+
return errorResponse(code, `Failed to invite member: ${upstream}`);
|
|
1573
|
+
}
|
|
1574
|
+
}
|
|
1575
|
+
|
|
1576
|
+
const updateMemberRoleDefinition = {
|
|
1577
|
+
name: 'update_member_role',
|
|
1578
|
+
description:
|
|
1579
|
+
"Change a member's role within an organization. Caller must be org " +
|
|
1580
|
+
"owner. Server rejects demoting the last admin.",
|
|
1581
|
+
inputSchema: {
|
|
1582
|
+
type: 'object',
|
|
1583
|
+
properties: {
|
|
1584
|
+
organization_id: { type: 'string' },
|
|
1585
|
+
membership_id: { type: 'string', description: "Membership row id (from listMembers)." },
|
|
1586
|
+
new_role: { type: 'string', enum: ['admin', 'member'] },
|
|
1587
|
+
},
|
|
1588
|
+
required: ['organization_id', 'membership_id', 'new_role'],
|
|
1589
|
+
},
|
|
1590
|
+
};
|
|
1591
|
+
|
|
1592
|
+
async function updateMemberRoleHandler(args, apiClient) {
|
|
1593
|
+
const { organization_id, membership_id, new_role } = args;
|
|
1594
|
+
|
|
1595
|
+
try {
|
|
1596
|
+
const result = await apiClient.axiosInstance.put(
|
|
1597
|
+
`/organizations/${organization_id}/members/${membership_id}/role`,
|
|
1598
|
+
{ role: new_role }
|
|
1599
|
+
).then((r) => r.data);
|
|
1600
|
+
|
|
1601
|
+
return formatResponse({
|
|
1602
|
+
as_of: asOf(),
|
|
1603
|
+
organization_id,
|
|
1604
|
+
membership_id,
|
|
1605
|
+
new_role,
|
|
1606
|
+
member: result,
|
|
1607
|
+
});
|
|
1608
|
+
} catch (err) {
|
|
1609
|
+
return errorResponse('update_role_failed', `Failed to update member role: ${err.response?.data?.error || err.message}`);
|
|
1610
|
+
}
|
|
1611
|
+
}
|
|
1612
|
+
|
|
1613
|
+
const removeMemberDefinition = {
|
|
1614
|
+
name: 'remove_member',
|
|
1615
|
+
description:
|
|
1616
|
+
"Remove a member from an organization. Caller must be org owner or " +
|
|
1617
|
+
"admin (admins cannot remove other admins). Server rejects removing " +
|
|
1618
|
+
"the org owner.",
|
|
1619
|
+
inputSchema: {
|
|
1620
|
+
type: 'object',
|
|
1621
|
+
properties: {
|
|
1622
|
+
organization_id: { type: 'string' },
|
|
1623
|
+
membership_id: { type: 'string' },
|
|
1624
|
+
reason: { type: 'string' },
|
|
1625
|
+
},
|
|
1626
|
+
required: ['organization_id', 'membership_id'],
|
|
1627
|
+
},
|
|
1628
|
+
};
|
|
1629
|
+
|
|
1630
|
+
async function removeMemberHandler(args, apiClient) {
|
|
1631
|
+
const { organization_id, membership_id, reason } = args;
|
|
1632
|
+
|
|
1633
|
+
try {
|
|
1634
|
+
await apiClient.organizations.removeMember(organization_id, membership_id);
|
|
1635
|
+
return formatResponse({
|
|
1636
|
+
as_of: asOf(),
|
|
1637
|
+
organization_id,
|
|
1638
|
+
membership_id,
|
|
1639
|
+
removed: true,
|
|
1640
|
+
reason: reason || null,
|
|
1641
|
+
});
|
|
1642
|
+
} catch (err) {
|
|
1643
|
+
return errorResponse('remove_failed', `Failed to remove member: ${err.response?.data?.error || err.message}`);
|
|
1644
|
+
}
|
|
1645
|
+
}
|
|
1646
|
+
|
|
602
1647
|
module.exports = {
|
|
603
1648
|
definitions: [
|
|
604
1649
|
queueDecisionDefinition,
|
|
@@ -607,6 +1652,20 @@ module.exports = {
|
|
|
607
1652
|
claimNextTaskDefinition,
|
|
608
1653
|
releaseTaskDefinition,
|
|
609
1654
|
addLearningDefinition,
|
|
1655
|
+
formIntentionDefinition,
|
|
1656
|
+
extendIntentionDefinition,
|
|
1657
|
+
proposeResearchChainDefinition,
|
|
1658
|
+
linkIntentionsDefinition,
|
|
1659
|
+
unlinkIntentionsDefinition,
|
|
1660
|
+
updatePlanDefinition,
|
|
1661
|
+
updateNodeDefinition,
|
|
1662
|
+
moveNodeDefinition,
|
|
1663
|
+
deletePlanDefinition,
|
|
1664
|
+
deleteNodeDefinition,
|
|
1665
|
+
sharePlanDefinition,
|
|
1666
|
+
inviteMemberDefinition,
|
|
1667
|
+
updateMemberRoleDefinition,
|
|
1668
|
+
removeMemberDefinition,
|
|
610
1669
|
],
|
|
611
1670
|
handlers: {
|
|
612
1671
|
queue_decision: queueDecisionHandler,
|
|
@@ -615,5 +1674,19 @@ module.exports = {
|
|
|
615
1674
|
claim_next_task: claimNextTaskHandler,
|
|
616
1675
|
release_task: releaseTaskHandler,
|
|
617
1676
|
add_learning: addLearningHandler,
|
|
1677
|
+
form_intention: formIntentionHandler,
|
|
1678
|
+
extend_intention: extendIntentionHandler,
|
|
1679
|
+
propose_research_chain: proposeResearchChainHandler,
|
|
1680
|
+
link_intentions: linkIntentionsHandler,
|
|
1681
|
+
unlink_intentions: unlinkIntentionsHandler,
|
|
1682
|
+
update_plan: updatePlanHandler,
|
|
1683
|
+
update_node: updateNodeHandler,
|
|
1684
|
+
move_node: moveNodeHandler,
|
|
1685
|
+
delete_plan: deletePlanHandler,
|
|
1686
|
+
delete_node: deleteNodeHandler,
|
|
1687
|
+
share_plan: sharePlanHandler,
|
|
1688
|
+
invite_member: inviteMemberHandler,
|
|
1689
|
+
update_member_role: updateMemberRoleHandler,
|
|
1690
|
+
remove_member: removeMemberHandler,
|
|
618
1691
|
},
|
|
619
1692
|
};
|