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.
@@ -30,6 +30,20 @@ const briefingDefinition = {
30
30
  };
31
31
 
32
32
  async function briefingHandler(args, apiClient) {
33
+ try {
34
+ const response = await apiClient.axiosInstance.get('/agent/briefing', {
35
+ params: {
36
+ scope: args.scope,
37
+ goal_id: args.goal_id,
38
+ plan_id: args.plan_id,
39
+ recent_window_hours: args.recent_window_hours,
40
+ },
41
+ });
42
+ return formatResponse(response.data);
43
+ } catch {
44
+ // Fall back to the pre-facade fan-out for self-hosted older APIs.
45
+ }
46
+
33
47
  const recentHours = typeof args.recent_window_hours === 'number' ? args.recent_window_hours : 24;
34
48
  const recentSinceMs = Date.now() - recentHours * 3600 * 1000;
35
49
 
@@ -252,6 +266,19 @@ async function goalStateHandler(args, apiClient) {
252
266
  direct_downstream_count: t.direct_downstream_count || 0,
253
267
  }));
254
268
 
269
+ // Surface the goal's linked plans + tasks. The underlying GET /goals/:id
270
+ // already returns the `links` array; the previous handler discarded it,
271
+ // so quality.actionability could report "26 plans linked" while the
272
+ // response refused to name a single one. Callers had no read-side way
273
+ // to enumerate the plans served by a goal short of REST.
274
+ const links = safeArray(goal.links);
275
+ const linked_plans = links
276
+ .filter((l) => (l.linkedType || l.linked_type) === 'plan')
277
+ .map((l) => ({ id: l.linkedId || l.linked_id, link_id: l.id }));
278
+ const linked_tasks = links
279
+ .filter((l) => (l.linkedType || l.linked_type) === 'task')
280
+ .map((l) => ({ id: l.linkedId || l.linked_id, link_id: l.id }));
281
+
255
282
  return formatResponse({
256
283
  as_of: asOf(),
257
284
  goal: {
@@ -261,6 +288,8 @@ async function goalStateHandler(args, apiClient) {
261
288
  owner_id: goal.ownerId || goal.owner_id, success_criteria: goal.successCriteria || goal.success_criteria,
262
289
  promoted_at: goal.promotedAt || goal.promoted_at,
263
290
  },
291
+ linked_plans,
292
+ linked_tasks,
264
293
  quality: {
265
294
  score: quality.score, dimensions: quality.dimensions,
266
295
  suggestions: quality.suggestions, last_assessed_at: quality.as_of,
@@ -348,6 +377,79 @@ async function recallKnowledgeHandler(args, apiClient) {
348
377
  return formatResponse(out);
349
378
  }
350
379
 
380
+ // ─────────────────────────────────────────────────────────────────────────
381
+ // list_plans — list workspace plans with optional filters.
382
+ // Counterpart to list_goals; previously the only ways to find a plan
383
+ // were knowing the UUID a priori, parsing briefing.recent_activity,
384
+ // or calling task_context on a known node — all bad.
385
+ // ─────────────────────────────────────────────────────────────────────────
386
+
387
+ const listPlansDefinition = {
388
+ name: 'list_plans',
389
+ description:
390
+ 'List plans with optional filters by status, visibility, or text query. ' +
391
+ 'Returns id, title, status, visibility, last update, and link counts so ' +
392
+ 'you can pick a plan to operate on without round-tripping briefing.',
393
+ inputSchema: {
394
+ type: 'object',
395
+ properties: {
396
+ filter: {
397
+ type: 'object',
398
+ properties: {
399
+ status: { type: 'array', items: { type: 'string' } },
400
+ visibility: { type: 'array', items: { type: 'string', enum: ['private', 'unlisted', 'public'] } },
401
+ query: { type: 'string', description: 'Substring match on title (case-insensitive)' },
402
+ limit: { type: 'integer', default: 50 },
403
+ },
404
+ },
405
+ },
406
+ },
407
+ };
408
+
409
+ async function listPlansHandler(args, apiClient) {
410
+ const filter = args.filter || {};
411
+ try {
412
+ const raw = await apiClient.plans.getPlans();
413
+ let plans = Array.isArray(raw) ? raw : safeArray(raw.plans || raw);
414
+
415
+ if (filter.status?.length) plans = plans.filter((p) => filter.status.includes(p.status));
416
+ if (filter.visibility?.length) plans = plans.filter((p) => filter.visibility.includes(p.visibility));
417
+ if (filter.query) {
418
+ const q = filter.query.toLowerCase();
419
+ plans = plans.filter((p) => (p.title || '').toLowerCase().includes(q));
420
+ }
421
+ plans = plans.slice(0, filter.limit || 50);
422
+
423
+ const summary = plans.reduce(
424
+ (acc, p) => {
425
+ acc[p.status] = (acc[p.status] || 0) + 1;
426
+ acc.total += 1;
427
+ return acc;
428
+ },
429
+ { total: 0 },
430
+ );
431
+
432
+ return formatResponse({
433
+ as_of: asOf(),
434
+ summary,
435
+ plans: plans.map((p) => ({
436
+ id: p.id,
437
+ title: p.title,
438
+ status: p.status,
439
+ visibility: p.visibility,
440
+ owner_id: p.owner_id || p.ownerId,
441
+ updated_at: p.updated_at || p.updatedAt,
442
+ // Surface progress + tether info when the API decorates the rows;
443
+ // listPlans on the v2 API now bulk-loads stats + goal_tethers.
444
+ progress: p.progress ?? p.stats?.percentage,
445
+ goal_tethers: p.goal_tethers,
446
+ })),
447
+ });
448
+ } catch (err) {
449
+ return errorResponse('upstream_unavailable', `list_plans failed: ${err.response?.data?.error || err.message}`);
450
+ }
451
+ }
452
+
351
453
  // ─────────────────────────────────────────────────────────────────────────
352
454
  // search — universal text search.
353
455
  // ─────────────────────────────────────────────────────────────────────────
@@ -376,13 +478,32 @@ const searchDefinition = {
376
478
 
377
479
  async function searchHandler(args, apiClient) {
378
480
  const { query, scope = 'global', scope_id, filters = {} } = args;
481
+ const limit = filters.limit || 20;
379
482
  try {
380
483
  let result;
381
- const limit = filters.limit || 20;
382
- if (scope === 'global') result = await apiClient.search.global(query, { limit, ...filters });
383
- else if (scope === 'plans') result = await apiClient.search.plans(query, limit);
384
- else if (scope === 'plan') result = await apiClient.search.inPlan(scope_id, query, limit);
385
- else if (scope === 'node') result = await apiClient.search.inNode(scope_id, query, limit);
484
+ // Map MCP scopes onto the actual api-client surface. The handler
485
+ // previously called apiClient.search.{global,plans,inPlan,inNode}
486
+ // none of those methods exist, so every invocation 500'd. The
487
+ // real api-client exposes globalSearch + searchPlan only; for
488
+ // 'plans' and 'node' we filter the global result client-side
489
+ // instead of hitting a (non-existent) per-bucket endpoint.
490
+ if (scope === 'plan') {
491
+ if (!scope_id) return errorResponse('invalid_arg', 'search scope=plan requires scope_id');
492
+ result = await apiClient.search.searchPlan(scope_id, query);
493
+ } else {
494
+ const global = await apiClient.search.globalSearch(query);
495
+ const all = Array.isArray(global?.results) ? global.results : [];
496
+ const matchScope = (r) => {
497
+ if (scope === 'global') return true;
498
+ if (scope === 'plans') return r.type === 'plan';
499
+ if (scope === 'node') return r.type === 'node' && (!scope_id || r.plan_id === scope_id);
500
+ return true;
501
+ };
502
+ const matchType = (r) => !filters.type || r.type === filters.type;
503
+ const matchStatus = (r) => !filters.status || r.status === filters.status;
504
+ const filtered = all.filter((r) => matchScope(r) && matchType(r) && matchStatus(r)).slice(0, limit);
505
+ result = { results: filtered, count: filtered.length, query, scope };
506
+ }
386
507
  return formatResponse({ as_of: asOf(), ...(result || {}) });
387
508
  } catch (err) {
388
509
  return errorResponse('upstream_unavailable', `Search failed: ${err.response?.data?.error || err.message}`);
@@ -437,6 +558,7 @@ module.exports = {
437
558
  taskContextDefinition,
438
559
  goalStateDefinition,
439
560
  recallKnowledgeDefinition,
561
+ listPlansDefinition,
440
562
  searchDefinition,
441
563
  planAnalysisDefinition,
442
564
  ],
@@ -445,6 +567,7 @@ module.exports = {
445
567
  task_context: taskContextHandler,
446
568
  goal_state: goalStateHandler,
447
569
  recall_knowledge: recallKnowledgeHandler,
570
+ list_plans: listPlansHandler,
448
571
  search: searchHandler,
449
572
  plan_analysis: planAnalysisHandler,
450
573
  },
@@ -1,8 +1,9 @@
1
1
  /**
2
2
  * BDI desires — goal management.
3
3
  *
4
- * 2 tools: list_goals (with health rollup), update_goal (atomic, subsumes
5
- * link/unlink and achiever changes).
4
+ * 3 tools: list_goals (with health rollup), update_goal (atomic, subsumes
5
+ * link/unlink and achiever changes), derive_subgoal (propose a sub-goal
6
+ * under an existing parent; mandatory parent — top-level goals stay UI-only).
6
7
  */
7
8
 
8
9
  const { asOf, formatResponse, errorResponse, safeArray } = require('./_shared');
@@ -154,10 +155,110 @@ async function updateGoalHandler(args, apiClient) {
154
155
  return formatResponse({ as_of: asOf(), goal_id, applied_changes: applied, failures, goal });
155
156
  }
156
157
 
158
+ // ─────────────────────────────────────────────────────────────────────────
159
+ // derive_subgoal — propose a sub-goal under an existing parent.
160
+ // Top-level goals stay UI-only (strategic direction is human-set).
161
+ // ─────────────────────────────────────────────────────────────────────────
162
+
163
+ const VALID_GOAL_TYPES = ['outcome', 'constraint', 'metric', 'principle'];
164
+ const VALID_STATUSES = ['draft', 'active', 'achieved', 'paused', 'abandoned', 'archived'];
165
+
166
+ const deriveSubgoalDefinition = {
167
+ name: 'derive_subgoal',
168
+ description:
169
+ "Propose a sub-goal under an existing parent goal. parent_goal_id is " +
170
+ "mandatory — agents cannot create top-level goals (strategic direction is " +
171
+ "human-set). Defaults to status='active' for human-directed creation; pass " +
172
+ "status='draft' for autonomous loops so a human can review before promotion. " +
173
+ "Drafts surface in the dashboard pending queue.",
174
+ inputSchema: {
175
+ type: 'object',
176
+ properties: {
177
+ parent_goal_id: {
178
+ type: 'string',
179
+ description: "Required. The parent goal this sub-goal contributes to.",
180
+ },
181
+ title: { type: 'string' },
182
+ description: { type: 'string', description: "Optional extended description, appended after rationale." },
183
+ rationale: {
184
+ type: 'string',
185
+ description: "Why this sub-goal is needed to achieve the parent. Becomes the description; surfaces in human review.",
186
+ },
187
+ type: {
188
+ type: 'string',
189
+ enum: VALID_GOAL_TYPES,
190
+ default: 'outcome',
191
+ },
192
+ status: {
193
+ type: 'string',
194
+ enum: VALID_STATUSES,
195
+ default: 'active',
196
+ description: "Default 'active' for human-directed creation. Pass 'draft' when acting autonomously without explicit user direction.",
197
+ },
198
+ success_criteria: {
199
+ type: 'array',
200
+ items: { type: 'string' },
201
+ description: "Concrete, observable conditions that mark this sub-goal achieved.",
202
+ },
203
+ priority: { type: 'integer', default: 0 },
204
+ },
205
+ required: ['parent_goal_id', 'title', 'rationale'],
206
+ },
207
+ };
208
+
209
+ async function deriveSubgoalHandler(args, apiClient) {
210
+ const { parent_goal_id, title, description, rationale, type = 'outcome', status = 'active', success_criteria, priority } = args;
211
+
212
+ // Verify parent exists and inherit organization scope.
213
+ let parent;
214
+ try {
215
+ parent = await apiClient.goals.get(parent_goal_id);
216
+ } catch (err) {
217
+ return errorResponse('not_found', `Parent goal ${parent_goal_id} not found or not accessible: ${err.message}`);
218
+ }
219
+
220
+ // Compose description: rationale is primary; optional description appended.
221
+ const composedDescription = description
222
+ ? `${rationale}\n\n${description}`
223
+ : rationale;
224
+
225
+ const payload = {
226
+ title,
227
+ description: composedDescription,
228
+ type,
229
+ status,
230
+ parentGoalId: parent_goal_id,
231
+ organizationId: parent.organization_id || parent.organizationId || undefined,
232
+ };
233
+ if (success_criteria) payload.successCriteria = { criteria: success_criteria };
234
+ if (typeof priority === 'number') payload.priority = priority;
235
+
236
+ let goal;
237
+ try {
238
+ goal = await apiClient.goals.create(payload);
239
+ } catch (err) {
240
+ const upstream = err.response?.data?.error || err.message;
241
+ return errorResponse('create_failed', `Failed to create sub-goal: ${upstream}`);
242
+ }
243
+
244
+ return formatResponse({
245
+ as_of: asOf(),
246
+ goal_id: goal.id,
247
+ parent_goal_id,
248
+ title: goal.title,
249
+ status: goal.status,
250
+ is_draft: goal.status === 'draft',
251
+ next_step: goal.status === 'draft'
252
+ ? "Sub-goal created as draft. It will surface in the dashboard pending queue for human review. Promote via update_goal({status: 'active'}) once approved."
253
+ : "Sub-goal active. Link plans to it via update_goal({add_linked_plans: [...]}).",
254
+ });
255
+ }
256
+
157
257
  module.exports = {
158
- definitions: [listGoalsDefinition, updateGoalDefinition],
258
+ definitions: [listGoalsDefinition, updateGoalDefinition, deriveSubgoalDefinition],
159
259
  handlers: {
160
260
  list_goals: listGoalsHandler,
161
261
  update_goal: updateGoalHandler,
262
+ derive_subgoal: deriveSubgoalHandler,
162
263
  },
163
264
  };