@ezmodo/mcp-server 0.19.1 → 0.20.1

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.
@@ -40,12 +40,18 @@ export const ENDPOINT_MAP = {
40
40
  'mcpProposePlanChange': { route: 'mcp/v1/epics/proposals', method: 'POST' },
41
41
  'mcpReviewPlanProposal': { route: 'mcp/v1/epics/proposals/review', method: 'POST' },
42
42
  'mcpAddEpicComment': { route: 'mcp/v1/epics/comments', method: 'POST' },
43
+ // Editors (E-259 #2802): who the owner lets change the plan.
44
+ 'mcpManageEpicEditors': { route: 'mcp/v1/epics/editors', method: 'POST' },
43
45
  // E-237 #2382: the epic is the fifth consumer of the grounding engine.
44
46
  'mcpGenerateEpicHowItWorks': { route: 'mcp/v1/epics/generate-how-it-works', method: 'POST' },
45
47
  'mcpApplyEpicHowItWorks': { route: 'mcp/v1/epics/apply-how-it-works', method: 'POST' },
46
48
 
47
49
  // Tasks
48
50
  'mcpCreateTask': { route: 'mcp/v1/tasks', method: 'POST' },
51
+ // Claims (E-259 #2747): manage_task action "claim" / "release".
52
+ 'mcpClaimTask': { route: 'mcp/v1/tasks/claim', method: 'POST' },
53
+ // Suggested edits on someone else's claimed task (E-259 #2809).
54
+ 'mcpTaskSuggestedEdits': { route: 'mcp/v1/tasks/suggested-edits', method: 'POST' },
49
55
  'mcpBulkCreateTasks': { route: 'mcp/v1/tasks/bulk', method: 'POST' },
50
56
  'mcpUpdateTask': { route: 'mcp/v1/tasks', method: 'PUT' },
51
57
  'mcpCompleteTask': { route: 'mcp/v1/tasks/complete', method: 'POST' },
package/handlers/epics.js CHANGED
@@ -5,35 +5,18 @@
5
5
  * Project-First Hierarchy: Epics belong to projects (required),
6
6
  * with optional milestone linking.
7
7
  *
8
- * Auto-assignment: When creating epics, automatically applies matching tags
9
- * based on content analysis against the local project cache.
8
+ * Tags: only the tagIds the caller passes are applied, in the create request
9
+ * itself. Keyword matches against the local project cache come back as
10
+ * `suggestedTags` (see lib/auto-assign.js for why they are not applied).
10
11
  */
11
12
 
12
13
  import { callZephlyAPI } from '../lib/http-client.js';
13
14
  import { resolveEpicAutoAssign } from '../lib/auto-assign.js';
15
+ import { suggestTags } from '../lib/suggested-tags.js';
14
16
  import { buildEpicUrl } from '../lib/web-url.js';
15
- import { getLogger } from '../lib/logger.js';
16
17
  import { attachLinks } from '../lib/links-at-create.js';
17
18
  import { normalizeChangedFiles } from '../lib/changed-files.js';
18
19
 
19
- /**
20
- * Apply tags to a newly created entity via bulkTagEntities.
21
- * Non-fatal — logs errors but doesn't throw.
22
- */
23
- async function applyAutoTags(organizationId, entityType, entityId, tagIds) {
24
- if (!organizationId || !tagIds?.length || !entityId) return;
25
- try {
26
- await callZephlyAPI('mcpBulkTagEntities', {
27
- organizationId,
28
- tagIds,
29
- entities: [{ entityType, entityId }],
30
- operation: 'add',
31
- });
32
- } catch (err) {
33
- getLogger().warn('Auto-tag failed', { entityType, entityId, error: err.message });
34
- }
35
- }
36
-
37
20
  /**
38
21
  * Flatten the nested-task result into the fields an agent acts on (#2247).
39
22
  *
@@ -79,12 +62,23 @@ export async function manageEpic(args) {
79
62
  case 'update': return updateEpic(params);
80
63
  case 'generate_how_it_works': return generateEpicHowItWorks(params);
81
64
  case 'apply_how_it_works': return applyEpicHowItWorks(params);
65
+ case 'add_editor': return manageEpicEditor('add', params);
66
+ case 'remove_editor': return manageEpicEditor('remove', params);
82
67
  default: throw new Error(
83
- `Unknown action: ${action}. Expected create, update, generate_how_it_works, or apply_how_it_works.`,
68
+ `Unknown action: ${action}. Expected create, update, generate_how_it_works, apply_how_it_works, ` +
69
+ 'add_editor or remove_editor.',
84
70
  );
85
71
  }
86
72
  }
87
73
 
74
+ // Choose who else may change an epic's plan (E-259 #2802). The server decides
75
+ // who may do this; the tool only checks it was asked something answerable.
76
+ async function manageEpicEditor(action, { epicId, editorUserId }) {
77
+ if (!epicId) throw new Error(`epicId is required to ${action} an editor`);
78
+ if (!editorUserId) throw new Error(`editorUserId is required to ${action} an editor`);
79
+ return callZephlyAPI('mcpManageEpicEditors', { epicId, userId: editorUserId, action });
80
+ }
81
+
88
82
  // (Re)generate the epic's grounded, source-attributed "how it works" living
89
83
  // description (E-237 #2382). An epic's reality is its tasks — their status,
90
84
  // captured decisions and linked commits — and its intent is the epic description
@@ -117,7 +111,7 @@ async function createEpic(args) {
117
111
 
118
112
  // An epic with its breakdown goes to the composing endpoint (#2247) so the
119
113
  // whole thing is one request; without tasks nothing changes. Everything below
120
- // this line — auto-tags, links, web URL — acts on the EPIC and so is identical
114
+ // this line — suggested tags, links, web URL — acts on the EPIC and so is identical
121
115
  // either way.
122
116
  const result = tasks?.length
123
117
  ? await callZephlyAPI('mcpCreateEpicWithTasks', {
@@ -132,18 +126,10 @@ async function createEpic(args) {
132
126
 
133
127
  if (tasks?.length) summarizeNestedTasks(result);
134
128
 
135
- // Auto-apply matching tags (non-fatal)
136
- const autoAssign = await resolveEpicAutoAssign(title, description);
137
- if (autoAssign?.matchedTags?.length && result?.epicId) {
138
- await applyAutoTags(
139
- autoAssign.organizationId,
140
- 'epic',
141
- result.epicId,
142
- autoAssign.matchedTags.map((t) => t.id),
143
- );
144
- result.autoAssigned = {
145
- tags: autoAssign.matchedTags.map((t) => t.name),
146
- };
129
+ // Suggest, never apply, keyword-matched tags (#2830).
130
+ const suggestedTags = suggestTags(await resolveEpicAutoAssign(title, description), createArgs.tagIds);
131
+ if (suggestedTags.length && result?.epicId) {
132
+ result.suggestedTags = suggestedTags;
147
133
  }
148
134
 
149
135
  // Attach create-time links (E-225) — best effort, never fails the create.
@@ -231,6 +217,8 @@ export async function getEpicActivity(args) {
231
217
  const params = { epicId: args.epicId };
232
218
  if (args.since) params.since = args.since;
233
219
  if (args.markSeen === false) params.markSeen = 'false';
220
+ // Live mode (#2748): the API waits for news, up to its own cap.
221
+ if (args.waitSeconds > 0) params.waitSeconds = Math.min(Math.floor(args.waitSeconds), 25);
234
222
  return callZephlyAPI('mcpGetEpicActivity', params);
235
223
  }
236
224
 
@@ -258,53 +246,53 @@ export async function managePlanProposal(args = {}) {
258
246
  const { action } = args;
259
247
 
260
248
  switch (action) {
261
- case 'propose': {
262
- if (!args.epicId) throw new Error('epicId is required to suggest a change');
263
- if (!args.plan && !args.ops) {
264
- throw new Error('Send the plan you want (or the individual changes) to suggest a change');
265
- }
266
- return callZephlyAPI('mcpProposePlanChange', {
267
- epicId: args.epicId,
268
- plan: args.plan,
269
- ops: args.ops,
270
- title: args.title,
271
- rationale: args.rationale,
272
- });
249
+ case 'propose': {
250
+ if (!args.epicId) throw new Error('epicId is required to suggest a change');
251
+ if (!args.plan && !args.ops) {
252
+ throw new Error('Send the plan you want (or the individual changes) to suggest a change');
273
253
  }
254
+ return callZephlyAPI('mcpProposePlanChange', {
255
+ epicId: args.epicId,
256
+ plan: args.plan,
257
+ ops: args.ops,
258
+ title: args.title,
259
+ rationale: args.rationale,
260
+ });
261
+ }
274
262
 
275
- case 'list': {
276
- if (!args.epicId) throw new Error('epicId is required to list proposals');
277
- const params = { epicId: args.epicId };
278
- if (args.status) params.status = args.status;
279
- return callZephlyAPI('mcpListPlanProposals', params);
280
- }
263
+ case 'list': {
264
+ if (!args.epicId) throw new Error('epicId is required to list proposals');
265
+ const params = { epicId: args.epicId };
266
+ if (args.status) params.status = args.status;
267
+ return callZephlyAPI('mcpListPlanProposals', params);
268
+ }
281
269
 
282
- case 'get': {
283
- if (!args.proposalId) throw new Error('proposalId is required');
284
- return callZephlyAPI('mcpListPlanProposals', { proposalId: args.proposalId });
285
- }
270
+ case 'get': {
271
+ if (!args.proposalId) throw new Error('proposalId is required');
272
+ return callZephlyAPI('mcpListPlanProposals', { proposalId: args.proposalId });
273
+ }
286
274
 
287
- case 'review': {
288
- if (!args.proposalId) throw new Error('proposalId is required to answer a proposal');
289
- if (!args.accept?.length && !args.reject?.length) {
290
- throw new Error('Say which changes you are taking (accept) and which you are not (reject)');
291
- }
292
- return callZephlyAPI('mcpReviewPlanProposal', {
293
- proposalId: args.proposalId,
294
- accept: args.accept || [],
295
- reject: args.reject || [],
296
- note: args.note,
297
- });
275
+ case 'review': {
276
+ if (!args.proposalId) throw new Error('proposalId is required to answer a proposal');
277
+ if (!args.accept?.length && !args.reject?.length) {
278
+ throw new Error('Say which changes you are taking (accept) and which you are not (reject)');
298
279
  }
280
+ return callZephlyAPI('mcpReviewPlanProposal', {
281
+ proposalId: args.proposalId,
282
+ accept: args.accept || [],
283
+ reject: args.reject || [],
284
+ note: args.note,
285
+ });
286
+ }
299
287
 
300
- case 'withdraw': {
301
- if (!args.proposalId) throw new Error('proposalId is required to take back a proposal');
302
- return callZephlyAPI('mcpReviewPlanProposal', { proposalId: args.proposalId, withdraw: true });
303
- }
288
+ case 'withdraw': {
289
+ if (!args.proposalId) throw new Error('proposalId is required to take back a proposal');
290
+ return callZephlyAPI('mcpReviewPlanProposal', { proposalId: args.proposalId, withdraw: true });
291
+ }
304
292
 
305
- default:
306
- throw new Error(
307
- `Unknown action "${action}". Use propose, list, get, review or withdraw.`
308
- );
293
+ default:
294
+ throw new Error(
295
+ `Unknown action "${action}". Use propose, list, get, review or withdraw.`
296
+ );
309
297
  }
310
298
  }
package/handlers/tasks.js CHANGED
@@ -7,12 +7,14 @@
7
7
  * Code links are derived: the files a task touches (changedFiles / linkedFiles)
8
8
  * resolve to the features that own those paths (E-258). Agents name the feature
9
9
  * the work advances through `links`.
10
- * Tags are auto-assigned based on content analysis against the local project cache.
10
+ * Tags: only the tagIds the caller passes are applied; keyword matches against
11
+ * the local project cache come back as `suggestedTags` (see lib/auto-assign.js).
11
12
  */
12
13
 
13
14
  import { previewEntityLinks, partitionProposals, attachSuggestionIds } from '../lib/autolink.js';
14
15
  import { callZephlyAPI } from '../lib/http-client.js';
15
16
  import { resolveTaskAutoAssign } from '../lib/auto-assign.js';
17
+ import { suggestTags } from '../lib/suggested-tags.js';
16
18
  import { buildTaskUrl } from '../lib/web-url.js';
17
19
  import { getLogger } from '../lib/logger.js';
18
20
  import { writeActiveSession, clearActiveSession } from '../lib/active-session.js';
@@ -21,24 +23,6 @@ import { getContext } from './context-manifest.js';
21
23
  import { getCommitFiles, getRepositoryRoot } from '../lib/git-helpers.js';
22
24
  import { normalizeChangedFiles } from '../lib/changed-files.js';
23
25
 
24
- /**
25
- * Apply tags to a newly created entity via bulkTagEntities.
26
- * Non-fatal — logs errors but doesn't throw.
27
- */
28
- async function applyAutoTags(organizationId, entityType, entityId, tagIds) {
29
- if (!organizationId || !tagIds?.length || !entityId) return;
30
- try {
31
- await callZephlyAPI('mcpBulkTagEntities', {
32
- organizationId,
33
- tagIds,
34
- entities: [{ entityType, entityId }],
35
- operation: 'add',
36
- });
37
- } catch (err) {
38
- getLogger().warn('Auto-tag failed', { entityType, entityId, error: err.message });
39
- }
40
- }
41
-
42
26
  /**
43
27
  * Dispatch manage_task actions to the appropriate handler
44
28
  */
@@ -54,10 +38,35 @@ export async function manageTask(args) {
54
38
  case 'get_commits': return getTaskCommits(params);
55
39
  case 'generate_how_it_works': return generateTaskHowItWorks(params);
56
40
  case 'apply_how_it_works': return applyTaskHowItWorks(params);
41
+ case 'claim': return claimTask(params, false);
42
+ case 'release': return claimTask(params, true);
43
+ case 'list_suggested_edits': return suggestedEdits(params, false);
44
+ case 'answer_suggested_edit': return suggestedEdits(params, true);
57
45
  default: throw new Error(`Unknown action: ${action}`);
58
46
  }
59
47
  }
60
48
 
49
+ /**
50
+ * Claim a task, or give it back (E-259 #2747). The server decides who may and
51
+ * words the answer; a task someone else holds comes back as an error naming
52
+ * them, which is the agent's cue to pick other work rather than retry.
53
+ */
54
+ async function claimTask({ taskId, claimNote }, release) {
55
+ if (!taskId) throw new Error(`taskId is required to ${release ? 'release' : 'claim'} a task`);
56
+ return callZephlyAPI('mcpClaimTask', release ? { taskId, release: true } : { taskId, note: claimNote });
57
+ }
58
+
59
+ /**
60
+ * Suggested edits on a claimed task (E-259 #2809): list what is waiting, or
61
+ * answer one. The server decides who may accept, reject or withdraw.
62
+ */
63
+ async function suggestedEdits({ taskId, editId, answer, note }, answering) {
64
+ if (!taskId) throw new Error('taskId is required');
65
+ if (!answering) return callZephlyAPI('mcpTaskSuggestedEdits', { taskId });
66
+ if (!editId || !answer) throw new Error('editId and answer (accept, reject or withdraw) are required');
67
+ return callZephlyAPI('mcpTaskSuggestedEdits', { taskId, editId, answer, note });
68
+ }
69
+
61
70
  /**
62
71
  * Bridge user terminology ("dashboard") to code locations by searching the
63
72
  * manifest for the task's own words. Best-effort: returns null when there is
@@ -129,18 +138,13 @@ async function createTask(args) {
129
138
  };
130
139
  }
131
140
 
132
- // Auto-apply tags (non-fatal)
133
- const autoAssign = await resolveTaskAutoAssign(projectId, title, description);
134
- if (autoAssign?.matchedTags?.length && result?.taskId) {
135
- await applyAutoTags(
136
- autoAssign.organizationId,
137
- 'task',
138
- result.taskId,
139
- autoAssign.matchedTags.map((t) => t.id),
140
- );
141
- result.autoAssigned = {
142
- tags: autoAssign.matchedTags.map((t) => t.name),
143
- };
141
+ // Suggest, never apply, keyword-matched tags (#2830).
142
+ const suggestedTags = suggestTags(
143
+ await resolveTaskAutoAssign(projectId, title, description),
144
+ createArgs.tagIds,
145
+ );
146
+ if (suggestedTags.length && result?.taskId) {
147
+ result.suggestedTags = suggestedTags;
144
148
  }
145
149
 
146
150
  if (autoContext) {
@@ -1,7 +1,13 @@
1
1
  /**
2
2
  * Auto-assign Utility
3
- * Matches task/epic content against cached tags for automatic assignment
4
- * during creation.
3
+ * Matches task/epic content against cached tags to SUGGEST tags at creation.
4
+ *
5
+ * Suggest-only, deliberately (#2830). The matcher fires on any word in the
6
+ * title or description, so it proposes tags like "notes" or "docs" on work
7
+ * that merely mentions them. It never actually applied anything before #2830
8
+ * (the API side was a stub), so applying its output would have switched on a
9
+ * noisy behaviour nobody had seen. The agent passes the tags it wants as
10
+ * `tagIds`; matches come back as `suggestedTags` for it to accept.
5
11
  */
6
12
 
7
13
  import { readConfig } from './local-cache.js';
@@ -20,6 +20,7 @@ import { listPrompts, getPromptContent } from '../prompts/index.js';
20
20
  import { MCP_VERSION } from './version.js';
21
21
  import { getLogger } from './logger.js';
22
22
  import { isRemoteSafe } from './remote-tools.js';
23
+ import { annotate } from './tool-annotations.js';
23
24
  import {
24
25
  EMAIL_ALREADY_REGISTERED,
25
26
  NOT_AUTHENTICATED,
@@ -84,7 +85,7 @@ export function createServer({ surface = 'local', startSignIn = defaultStartSign
84
85
  // cannot disagree. They must agree: filtering only tools/list would leave
85
86
  // every excluded handler dispatchable by a client that guesses the name,
86
87
  // which is the failure this whole module exists to prevent.
87
- const tools = surface === 'remote' ? TOOLS.filter((tool) => isRemoteSafe(tool.name)) : TOOLS;
88
+ const tools = (surface === 'remote' ? TOOLS.filter((tool) => isRemoteSafe(tool.name)) : TOOLS).map(annotate);
88
89
  const available = new Set(tools.map((tool) => tool.name));
89
90
 
90
91
  const server = new Server(
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Suggested tags (#2830).
3
+ *
4
+ * Keyword matches from lib/auto-assign.js are returned to the agent as
5
+ * `suggestedTags` and never applied; see that file for why.
6
+ */
7
+
8
+ /**
9
+ * Turn keyword matches into the `suggestedTags` a create response carries,
10
+ * leaving out tags the caller already applied explicitly.
11
+ *
12
+ * @param {object|null} autoAssign - Result of resolveTaskAutoAssign / resolveEpicAutoAssign
13
+ * @param {string[]} [appliedTagIds] - Tag IDs sent with the create
14
+ * @returns {Array<{id: string, name: string}>}
15
+ */
16
+ export function suggestTags(autoAssign, appliedTagIds = []) {
17
+ const applied = new Set(appliedTagIds || []);
18
+ return (autoAssign?.matchedTags || [])
19
+ .filter((t) => !applied.has(t.id))
20
+ .map((t) => ({ id: t.id, name: t.name }));
21
+ }
@@ -0,0 +1,104 @@
1
+ /**
2
+ * MCP tool annotations: which tools only read (#2808).
3
+ *
4
+ * Without annotations a client must assume any tool may write or destroy data,
5
+ * which is what the MCP spec says an unannotated tool means. Claude uses the
6
+ * hint to sort a connector's tools into read-only and write/delete categories,
7
+ * so a policy that allows only read-only tools (Claude Team/Enterprise tool
8
+ * permissions) blocked EVERY tool here, reads included, while
9
+ * every tool was unannotated.
10
+ *
11
+ * A hint, not a control: the API enforces scopes and permissions whatever a
12
+ * client believes. So the list errs toward leaving a tool OUT. A write wrongly
13
+ * marked read-only would let it through a read-only policy, while a read left
14
+ * unmarked only costs the user an approval prompt.
15
+ *
16
+ * Membership was verified by tracing each handler to the endpoints it calls
17
+ * (config/endpoint-map.js): all GETs, or POSTs to query routes the API mounts
18
+ * behind OptionalReadScopeMiddleware (projects/story, organization/analyze,
19
+ * graph/*, tags/suggest, links/preview, attachments/download-url). Some have
20
+ * local side effects that change no EzModo data, and those count as reads:
21
+ * get_document caches the content into .ezmodo/docs, get_current_project_context
22
+ * and list_tags cache tags, and detect_git_repository and list_project_worktrees
23
+ * run read-only git commands.
24
+ *
25
+ * Adding a tool? If it only reads, add it here.
26
+ * __tests__/tool-annotations.test.js fails when a get_/list_/search_ tool is
27
+ * left unclassified, so a new read cannot silently default to "may write".
28
+ */
29
+
30
+ export const READ_ONLY_TOOLS = new Set([
31
+ 'detect_git_repository',
32
+ 'estimate_task',
33
+ 'evaluate_feature_flag',
34
+ 'get_access',
35
+ 'get_ai_insights',
36
+ 'get_attachment_url',
37
+ 'get_catalog',
38
+ 'get_catalog_diff',
39
+ 'get_context',
40
+ 'get_current_project_context',
41
+ 'get_decision',
42
+ 'get_design',
43
+ 'get_design_system',
44
+ 'get_document',
45
+ 'get_document_template',
46
+ 'get_epic',
47
+ 'get_epic_activity',
48
+ 'get_epic_plan',
49
+ 'get_feature',
50
+ 'get_feature_flag',
51
+ 'get_goal',
52
+ 'get_graph',
53
+ 'get_manifest_schema',
54
+ 'get_milestone',
55
+ 'get_org_areas',
56
+ 'get_organization',
57
+ 'get_project',
58
+ 'get_project_changes',
59
+ 'get_project_story',
60
+ 'get_task',
61
+ 'get_testing_summary',
62
+ 'infer_dependencies',
63
+ 'list_agent_suggestions',
64
+ 'list_attachments',
65
+ 'list_catalog_items',
66
+ 'list_catalogs',
67
+ 'list_designs',
68
+ 'list_epic_comments',
69
+ 'list_epics',
70
+ 'list_facts',
71
+ 'list_feature_flags',
72
+ 'list_folders',
73
+ 'list_links',
74
+ 'list_notifications',
75
+ 'list_org_documents',
76
+ 'list_project_worktrees',
77
+ 'list_repositories',
78
+ 'list_tags',
79
+ 'list_test_cases',
80
+ 'list_test_suites',
81
+ 'list_todos',
82
+ 'list_unmapped_paths',
83
+ 'list_watched',
84
+ 'preview_links',
85
+ 'resolve_concepts',
86
+ 'search_epics',
87
+ 'search_features',
88
+ 'search_tasks',
89
+ 'validate_manifest',
90
+ ]);
91
+
92
+ /**
93
+ * The tool definition as a client should see it: read-only tools carry
94
+ * `annotations.readOnlyHint: true`; everything else is returned unchanged.
95
+ * Never mutates the shared definition in tools/.
96
+ *
97
+ * @template {{ name: string, annotations?: object }} T
98
+ * @param {T} tool
99
+ * @returns {T}
100
+ */
101
+ export function annotate(tool) {
102
+ if (!READ_ONLY_TOOLS.has(tool.name)) return tool;
103
+ return { ...tool, annotations: { ...tool.annotations, readOnlyHint: true } };
104
+ }
package/lib/version.js CHANGED
@@ -7,4 +7,4 @@
7
7
  *
8
8
  * Update this when bumping the version in package.json.
9
9
  */
10
- export const MCP_VERSION = '0.19.1';
10
+ export const MCP_VERSION = '0.20.1';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ezmodo/mcp-server",
3
- "version": "0.19.1",
3
+ "version": "0.20.1",
4
4
  "description": "MCP server for ezmodo - AI-first project management",
5
5
  "main": "index.js",
6
6
  "type": "module",
package/tools/epics.js CHANGED
@@ -44,7 +44,8 @@ export const EPIC_TOOLS = [
44
44
  'IMPORTANT when creating: Call get_context first for each area the epic covers to discover relevant ' +
45
45
  'files and integration points. Include in the description which layers/services are affected and ' +
46
46
  'reference specific file paths. Create child tasks that each reference specific files from context queries. ' +
47
- 'Auto-applies matching tags from cached project context based on content analysis. ' +
47
+ 'Tags: pass the tag IDs you want as tagIds; the response lists keyword-matched tags you did not pass as ' +
48
+ 'suggestedTags, which are NOT applied — add any that fit with an update. ' +
48
49
  'To align an epic to an organization goal, link it to a milestone that is linked to that goal ' +
49
50
  '(set milestoneId) — epics have no direct goal field. ' +
50
51
  'BREAKING DOWN A FEATURE: pass the child tasks in the `tasks` array on create and the epic and its ' +
@@ -56,8 +57,12 @@ export const EPIC_TOOLS = [
56
57
  properties: {
57
58
  action: {
58
59
  type: 'string',
59
- enum: ['create', 'update', 'generate_how_it_works', 'apply_how_it_works'],
60
- description: 'Action to perform. "generate_how_it_works" (re)generates the epic\'s grounded, ' +
60
+ enum: ['create', 'update', 'generate_how_it_works', 'apply_how_it_works', 'add_editor', 'remove_editor'],
61
+ description: 'Action to perform. "add_editor" / "remove_editor" (epicId + editorUserId) choose who ' +
62
+ 'else may change the epic\'s plan, decide its questions and answer suggested changes (E-259). ' +
63
+ 'Only the owner, the creator or an org admin may choose; an editor may remove themselves. ' +
64
+ 'get_epic lists the current editors. ' +
65
+ '"generate_how_it_works" (re)generates the epic\'s grounded, ' +
61
66
  'source-attributed "how it works" living description from its reality — its tasks\' status ' +
62
67
  'rollup, their captured decisions and their linked commits (requires epicId; AI-quota gated). ' +
63
68
  'An epic is where intent and reality drift furthest apart: intent is a charter written once, ' +
@@ -68,7 +73,12 @@ export const EPIC_TOOLS = [
68
73
  // --- Identifiers ---
69
74
  epicId: {
70
75
  type: 'string',
71
- description: 'Epic ID (required for update, generate_how_it_works, apply_how_it_works)',
76
+ description: 'Epic ID (required for update, generate_how_it_works, apply_how_it_works, ' +
77
+ 'add_editor, remove_editor)',
78
+ },
79
+ editorUserId: {
80
+ type: 'string',
81
+ description: 'add_editor / remove_editor: the user to add or remove (must be in the organization)',
72
82
  },
73
83
  markdown: {
74
84
  type: 'string',
@@ -137,10 +147,11 @@ export const EPIC_TOOLS = [
137
147
  name: { type: 'string' },
138
148
  },
139
149
  },
140
- labels: {
150
+ tagIds: {
141
151
  type: 'array',
142
152
  items: { type: 'string' },
143
- description: 'Array of label names',
153
+ description: 'Tag IDs for categorization (get them from get_current_project_context). ' +
154
+ 'Used by create and update; on update this REPLACES the set.',
144
155
  },
145
156
  color: {
146
157
  type: 'string',
@@ -255,7 +266,8 @@ export const EPIC_TOOLS = [
255
266
  'Responses include `descriptionDocumentId` — the id of the backing rich-description Document ' +
256
267
  'when the description has been promoted to one (E-189), otherwise omitted. ' +
257
268
  'Responses also include `derivedGoalIds` — the organization goal(s) this epic aligns to, ' +
258
- 'derived via its milestone (epics have no direct goal field).',
269
+ 'derived via its milestone (epics have no direct goal field). ' +
270
+ '`editors` lists the people besides the owner who may change its plan (E-259).',
259
271
  inputSchema: {
260
272
  type: 'object',
261
273
  properties: {
@@ -390,12 +402,18 @@ export const EPIC_TOOLS = [
390
402
  name: 'get_epic_activity',
391
403
  description: 'Catch me up on an epic (E-259): what changed since YOU last looked. Returns `summary`, ' +
392
404
  'plain sentences you can relay to your person as-is, most important first: decisions waiting on ' +
393
- 'their view, comments that mention or reply to them, decisions made, new plan versions (who ' +
405
+ 'their view, open questions put to them and open objections, comments that mention or reply to ' +
406
+ 'them, decisions made, new plan versions (who ' +
394
407
  'changed what, and which AI did it for them), and tasks added, started, finished or blocked. ' +
395
408
  'The details are alongside. Their own changes are left out. Call it when you start or resume ' +
396
409
  'work on an epic other people also work on, and before changing its plan. By default this also ' +
397
410
  'marks the epic as caught up, so the next call shows only newer changes; pass markSeen:false to ' +
398
- 'look without that.',
411
+ 'look without that.\n\n' +
412
+ 'LIVE: pass waitSeconds (up to 25) to wait for something to happen instead of hearing ' +
413
+ '"nothing has changed" — the call returns as soon as someone changes the plan, comments, ' +
414
+ 'decides, or picks up or moves a task. Call it again to keep following a shared session. ' +
415
+ '`hereNow` says who else is on the epic right now, people and their AIs; calling any epic tool ' +
416
+ 'shows you there too.',
399
417
  inputSchema: {
400
418
  type: 'object',
401
419
  properties: {
@@ -409,6 +427,10 @@ export const EPIC_TOOLS = [
409
427
  type: 'boolean',
410
428
  description: 'Mark the epic as caught up after answering (default true)',
411
429
  },
430
+ waitSeconds: {
431
+ type: 'number',
432
+ description: 'Wait up to this many seconds (max 25) for something new before answering',
433
+ },
412
434
  },
413
435
  required: ['epicId'],
414
436
  },
@@ -417,12 +439,15 @@ export const EPIC_TOOLS = [
417
439
  name: 'list_epic_comments',
418
440
  description: 'Read an epic\'s discussion (E-259), oldest first. Each comment says who wrote it and, ' +
419
441
  'when an AI wrote it for them, which AI (`agentName`). Replies carry `parentId`. ' +
420
- 'Read this before planning or changing a shared epic: other people\'s questions and objections live here.',
442
+ 'Read this before planning or changing a shared epic: other people\'s questions and objections live here. ' +
443
+ 'Each comment has a `kind` (comment, question, objection, alternative); the last three stay open until ' +
444
+ '`resolvedAt` is set. Pass open:true for only the ones still waiting — check it before approving a plan.',
421
445
  inputSchema: {
422
446
  type: 'object',
423
447
  properties: {
424
448
  epicId: { type: 'string', description: 'The epic ID (required)' },
425
449
  limit: { type: 'number', description: 'Maximum comments to return (default 100, max 500)' },
450
+ open: { type: 'boolean', description: 'Only the questions, objections and alternatives not yet settled' },
426
451
  },
427
452
  required: ['epicId'],
428
453
  },
@@ -432,7 +457,11 @@ export const EPIC_TOOLS = [
432
457
  description: 'Post to an epic\'s discussion (E-259), or reply to a comment with `parentId`. ' +
433
458
  'Posted as the person whose key you use, marked as written by you. ' +
434
459
  'Mentioned people, the author you reply to and everyone following the epic are notified, ' +
435
- 'and posting makes that person follow it. Write plainly: one point per comment, readable by anyone.',
460
+ 'and posting makes that person follow it. Write plainly: one point per comment, readable by anyone.\n\n' +
461
+ 'Say what the comment is with `kind`: a `question` you need answered, an `objection` to the plan, or ' +
462
+ 'an `alternative` approach. Those stay open until settled, show up in catch me up, and an open objection ' +
463
+ 'warns whoever approves the plan. To answer one, reply with parentId and resolvesParent:true ' +
464
+ '(the person who raised it, or the epic\'s owner, may settle it; anyone may reply).',
436
465
  inputSchema: {
437
466
  type: 'object',
438
467
  properties: {
@@ -440,6 +469,15 @@ export const EPIC_TOOLS = [
440
469
  content: { type: 'string', description: 'The comment (markdown)' },
441
470
  parentId: { type: 'string', description: 'Reply to this comment' },
442
471
  mentions: { type: 'array', items: { type: 'string' }, description: 'User IDs to notify' },
472
+ kind: {
473
+ type: 'string',
474
+ enum: ['comment', 'question', 'objection', 'alternative'],
475
+ description: 'What this is (default comment). Replies are always comments.',
476
+ },
477
+ resolvesParent: {
478
+ type: 'boolean',
479
+ description: 'With parentId: this reply settles the question, objection or alternative it answers',
480
+ },
443
481
  },
444
482
  required: ['epicId', 'content'],
445
483
  },
@@ -453,7 +491,7 @@ export const EPIC_TOOLS = [
453
491
  'take some and leave others. Nothing changes until they do.\n' +
454
492
  'list: what is waiting on an epic. Open ones come first, and each change that no longer fits the ' +
455
493
  'current plan is flagged with the reason.\n' +
456
- 'review (owner, creator or org admin only): `accept` and `reject` name changes by their op id. ' +
494
+ 'review (owner, editors, creator or org admin only): `accept` and `reject` name changes by their op id. ' +
457
495
  'A change you name in neither is left for later and the proposal stays open. A change whose task ' +
458
496
  'someone has since removed is reported back as stale rather than quietly reapplied.\n' +
459
497
  'withdraw: take back a proposal you made.',
package/tools/tasks.js CHANGED
@@ -32,14 +32,51 @@ export const TASK_TOOLS = [
32
32
  properties: {
33
33
  action: {
34
34
  type: 'string',
35
- enum: ['create', 'update', 'complete', 'defer', 'link_commit', 'unlink_commit', 'get_commits', 'generate_how_it_works', 'apply_how_it_works'],
36
- description: 'Action to perform. "generate_how_it_works" (re)generates the task\'s ' +
35
+ enum: [
36
+ 'create', 'update', 'complete', 'defer', 'link_commit', 'unlink_commit', 'get_commits',
37
+ 'generate_how_it_works', 'apply_how_it_works', 'claim', 'release',
38
+ 'list_suggested_edits', 'answer_suggested_edit',
39
+ ],
40
+ description: 'Action to perform. "claim" (taskId, optional claimNote) says you are working on ' +
41
+ 'this task, so other people\'s AIs on the same epic pick different work (E-259). Claim ' +
42
+ 'BEFORE starting a task on a shared epic. It lasts 2 hours and renews while you update ' +
43
+ 'the task or link commits; claiming again renews it. If someone else holds it you are ' +
44
+ 'told who and which branch their work is on — pick another task. The answer warns about ' +
45
+ 'other in-progress tasks on the epic that touch the same files, with the branch each is on, ' +
46
+ 'so you can build on that work or keep clear of it. "release" gives it back when you stop. ' +
47
+ 'TASK RULES: on a task someone ELSE has claimed, changing its title, description, steps or ' +
48
+ 'epic, or deleting it, is sent to them as a suggested edit (the response says ' +
49
+ '`suggested: true`; anything else in the same update still applies), and changing its ' +
50
+ 'status, assignee or step progress is refused — leave a comment instead. The claimer and ' +
51
+ 'whoever runs the epic are not limited. "list_suggested_edits" (taskId) shows what is ' +
52
+ 'waiting on a task; "answer_suggested_edit" (taskId, editId, answer: accept|reject|withdraw, ' +
53
+ 'optional note) answers one — the claimer or whoever runs the epic accepts or rejects, the ' +
54
+ 'author withdraws. ' +
55
+ '"generate_how_it_works" (re)generates the task\'s ' +
37
56
  'grounded, source-attributed "how it works" living description from its reality — ' +
38
57
  'subtasks, comments, status history, commits plus a manifest pass over linked files ' +
39
58
  '(requires taskId; AI-quota gated). "apply_how_it_works" (BYO-AI) persists a summary ' +
40
59
  'YOU authored: pass markdown + sources; the server validates your cited sources against ' +
41
60
  'the real grounded context (dropping fabricated ones) before saving — no server model call.',
42
61
  },
62
+ editId: {
63
+ type: 'string',
64
+ description: 'answer_suggested_edit: the suggested edit to answer',
65
+ },
66
+ answer: {
67
+ type: 'string',
68
+ enum: ['accept', 'reject', 'withdraw'],
69
+ description: 'answer_suggested_edit: accept or reject it (the claimer or whoever runs the epic), ' +
70
+ 'or withdraw your own',
71
+ },
72
+ note: {
73
+ type: 'string',
74
+ description: 'answer_suggested_edit: what you want to say back, optional',
75
+ },
76
+ claimNote: {
77
+ type: 'string',
78
+ description: 'claim: what you are about to do, in a few words (shown to the others)',
79
+ },
43
80
  // --- Identifiers (used by most actions) ---
44
81
  taskId: {
45
82
  type: 'string',