@ezmodo/mcp-server 0.13.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.
Files changed (98) hide show
  1. package/README.md +305 -0
  2. package/config/development.js +20 -0
  3. package/config/endpoint-map.js +351 -0
  4. package/config/index.js +34 -0
  5. package/config/production.js +18 -0
  6. package/config/staging.js +18 -0
  7. package/handlers/access.js +141 -0
  8. package/handlers/activity.js +112 -0
  9. package/handlers/agents.js +95 -0
  10. package/handlers/ai-intelligence.js +55 -0
  11. package/handlers/attachments.js +30 -0
  12. package/handlers/catalogs.js +169 -0
  13. package/handlers/components.js +282 -0
  14. package/handlers/context-manifest.js +1150 -0
  15. package/handlers/decisions.js +114 -0
  16. package/handlers/designs.js +118 -0
  17. package/handlers/documents.js +227 -0
  18. package/handlers/entities.js +95 -0
  19. package/handlers/epics.js +190 -0
  20. package/handlers/facts.js +62 -0
  21. package/handlers/feature-flags.js +142 -0
  22. package/handlers/features.js +137 -0
  23. package/handlers/folders.js +127 -0
  24. package/handlers/git-context.js +917 -0
  25. package/handlers/github.js +72 -0
  26. package/handlers/graph.js +23 -0
  27. package/handlers/index.js +205 -0
  28. package/handlers/links.js +156 -0
  29. package/handlers/milestones.js +131 -0
  30. package/handlers/organizations.js +14 -0
  31. package/handlers/projects.js +122 -0
  32. package/handlers/recurring-tasks.js +33 -0
  33. package/handlers/tags.js +124 -0
  34. package/handlers/tasks.js +561 -0
  35. package/handlers/testing.js +116 -0
  36. package/handlers/todos.js +43 -0
  37. package/handlers/watchers.js +54 -0
  38. package/handlers/work-templates.js +32 -0
  39. package/index.js +175 -0
  40. package/lib/active-session.js +86 -0
  41. package/lib/auto-assign.js +93 -0
  42. package/lib/autolink.js +176 -0
  43. package/lib/changed-files.js +22 -0
  44. package/lib/env.js +45 -0
  45. package/lib/git-helpers.js +553 -0
  46. package/lib/git-utils.js +73 -0
  47. package/lib/http-client.js +164 -0
  48. package/lib/links-at-create.js +94 -0
  49. package/lib/local-cache.js +140 -0
  50. package/lib/logger.js +109 -0
  51. package/lib/manifest-loader.js +182 -0
  52. package/lib/manifest-query.js +686 -0
  53. package/lib/repo-config-dir.js +118 -0
  54. package/lib/version.js +10 -0
  55. package/lib/web-url.js +69 -0
  56. package/lib/worktree-tools.js +950 -0
  57. package/package.json +62 -0
  58. package/prompts/ai-workflow-automation.js +96 -0
  59. package/prompts/index.js +39 -0
  60. package/prompts/zephly-usage-guide-content.txt +631 -0
  61. package/prompts/zephly-usage-guide.js +119 -0
  62. package/tools/access-entity-types.js +28 -0
  63. package/tools/access.js +152 -0
  64. package/tools/activity.js +38 -0
  65. package/tools/agents.js +208 -0
  66. package/tools/ai-intelligence.js +111 -0
  67. package/tools/attachments.js +92 -0
  68. package/tools/catalogs.js +341 -0
  69. package/tools/components.js +249 -0
  70. package/tools/context-manifest.js +236 -0
  71. package/tools/decisions.js +168 -0
  72. package/tools/designs.js +222 -0
  73. package/tools/documents.js +287 -0
  74. package/tools/entities.js +223 -0
  75. package/tools/epics.js +267 -0
  76. package/tools/facts.js +70 -0
  77. package/tools/feature-flags.js +300 -0
  78. package/tools/features.js +246 -0
  79. package/tools/folders.js +122 -0
  80. package/tools/git-context.js +109 -0
  81. package/tools/github.js +172 -0
  82. package/tools/graph.js +70 -0
  83. package/tools/index.js +77 -0
  84. package/tools/link-params.js +93 -0
  85. package/tools/linkable-types.js +36 -0
  86. package/tools/links.js +199 -0
  87. package/tools/milestones.js +176 -0
  88. package/tools/organizations.js +23 -0
  89. package/tools/projects.js +172 -0
  90. package/tools/recurring-tasks.js +115 -0
  91. package/tools/tags.js +219 -0
  92. package/tools/task-item-schema.js +57 -0
  93. package/tools/task-type.js +33 -0
  94. package/tools/tasks.js +680 -0
  95. package/tools/testing.js +344 -0
  96. package/tools/todos.js +69 -0
  97. package/tools/watchers.js +81 -0
  98. package/tools/work-templates.js +96 -0
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Todo Handlers
3
+ * Handler functions for personal todo MCP tools
4
+ *
5
+ * Consolidated: manageTodo dispatches create/complete/move_to_project.
6
+ * listTodos is unchanged.
7
+ *
8
+ * Todos are lightweight personal tasks in the user's todo list.
9
+ * They can be promoted to project tasks via move_to_project.
10
+ */
11
+
12
+ import { callZephlyAPI } from '../lib/http-client.js';
13
+
14
+ /**
15
+ * Dispatch manage_todo actions to the appropriate handler
16
+ */
17
+ export async function manageTodo(args) {
18
+ const { action, ...params } = args;
19
+ switch (action) {
20
+ case 'create': return createTodo(params);
21
+ case 'complete': return completeTodo(params);
22
+ case 'move_to_project': return moveTodoToProject(params);
23
+ default: throw new Error(`Unknown action: ${action}`);
24
+ }
25
+ }
26
+
27
+ export async function listTodos(args) {
28
+ return callZephlyAPI('mcpListTodos', args || {});
29
+ }
30
+
31
+ // --- Private helpers ---
32
+
33
+ async function createTodo(args) {
34
+ return callZephlyAPI('mcpCreateTodo', args);
35
+ }
36
+
37
+ async function completeTodo(args) {
38
+ return callZephlyAPI('mcpCompleteTodo', args);
39
+ }
40
+
41
+ async function moveTodoToProject(args) {
42
+ return callZephlyAPI('mcpMoveTodo', args);
43
+ }
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Watching Handlers
3
+ * Handler functions for task subscriptions and the notification inbox (E-41)
4
+ */
5
+
6
+ import { callZephlyAPI } from '../lib/http-client.js';
7
+
8
+ /**
9
+ * Watch or unwatch a task.
10
+ */
11
+ export async function manageWatch(args) {
12
+ const { action, taskId } = args;
13
+
14
+ if (action !== 'watch' && action !== 'unwatch') {
15
+ throw new Error(`Unknown action: ${action}. Expected "watch" or "unwatch".`);
16
+ }
17
+ if (!taskId) {
18
+ throw new Error('taskId is required');
19
+ }
20
+
21
+ return callZephlyAPI('mcpManageWatch', { action, taskId });
22
+ }
23
+
24
+ /**
25
+ * List the tasks the caller is watching.
26
+ */
27
+ export async function listWatched(args) {
28
+ const { organizationId, limit, offset } = args;
29
+
30
+ if (!organizationId) {
31
+ throw new Error('organizationId is required');
32
+ }
33
+
34
+ return callZephlyAPI('mcpListWatched', {
35
+ organizationId,
36
+ ...(limit ? { limit } : {}),
37
+ // offset 0 is the default, so sending it would only be query-string noise.
38
+ ...(offset ? { offset } : {}),
39
+ });
40
+ }
41
+
42
+ /**
43
+ * List the caller's in-app notifications.
44
+ */
45
+ export async function listNotifications(args = {}) {
46
+ const { unreadOnly, limit } = args;
47
+
48
+ return callZephlyAPI('mcpListNotifications', {
49
+ // Only send unreadOnly when explicitly false — the API defaults to unread,
50
+ // and sending the default back would just be noise in the query string.
51
+ ...(unreadOnly === false ? { unreadOnly: false } : {}),
52
+ ...(limit ? { limit } : {}),
53
+ });
54
+ }
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Work Template Handlers
3
+ * Handler for the E-211 task/epic template MCP tool.
4
+ *
5
+ * Thin wrappers over /api/mcp/v1/work-templates; validation, blueprint handling,
6
+ * and instantiation (task or epic+children) happen server-side in
7
+ * core/worktemplates.Service.
8
+ */
9
+
10
+ import { callZephlyAPI } from '../lib/http-client.js';
11
+
12
+ /**
13
+ * Dispatch manage_work_template actions.
14
+ */
15
+ export async function manageWorkTemplate(args) {
16
+ const { action, ...params } = args;
17
+ switch (action) {
18
+ case 'create': return callZephlyAPI('mcpCreateWorkTemplate', params);
19
+ case 'update': return callZephlyAPI('mcpUpdateWorkTemplate', params);
20
+ case 'delete': return callZephlyAPI('mcpDeleteWorkTemplate', { templateId: params.templateId });
21
+ case 'get': return callZephlyAPI('mcpGetWorkTemplate', { templateId: params.templateId });
22
+ case 'instantiate': return callZephlyAPI('mcpInstantiateWorkTemplate', params);
23
+ case 'list': {
24
+ const listParams = {};
25
+ if (params.organizationId) listParams.organizationId = params.organizationId;
26
+ if (params.kind) listParams.kind = params.kind;
27
+ return callZephlyAPI('mcpListWorkTemplates', listParams);
28
+ }
29
+ default:
30
+ throw new Error(`Unknown action: ${action}. Expected create, update, delete, list, get, or instantiate.`);
31
+ }
32
+ }
package/index.js ADDED
@@ -0,0 +1,175 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * EzModo MCP Server
5
+ *
6
+ * Provides Model Context Protocol tools for AI agents to interact with EzModo
7
+ * projects, tasks, and documentation via HTTP API.
8
+ */
9
+
10
+ import { Server } from '@modelcontextprotocol/sdk/server/index.js';
11
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
12
+ import {
13
+ CallToolRequestSchema,
14
+ ListToolsRequestSchema,
15
+ ListPromptsRequestSchema,
16
+ GetPromptRequestSchema,
17
+ } from '@modelcontextprotocol/sdk/types.js';
18
+
19
+ // Import configuration
20
+ import { CONFIG } from './config/index.js';
21
+
22
+ // Import tool definitions and handlers
23
+ import { TOOLS } from './tools/index.js';
24
+ import { HANDLERS } from './handlers/index.js';
25
+
26
+ // Import prompts
27
+ import { PROMPTS, getPromptContent } from './prompts/index.js';
28
+
29
+ import { MCP_VERSION } from './lib/version.js';
30
+ import { initLogger, getLogger } from './lib/logger.js';
31
+ import { getApiKey } from './lib/env.js';
32
+
33
+ // ============================================================
34
+ // Configuration Validation
35
+ // ============================================================
36
+
37
+ const apiKey = getApiKey();
38
+
39
+ // Initialize logger early (before any logging)
40
+ initLogger();
41
+ const log = getLogger();
42
+
43
+ if (!apiKey) {
44
+ log.error('Missing required environment variable: EZMODO_API_KEY', {
45
+ settingsUrl: CONFIG.settingsUrl,
46
+ });
47
+ console.error('ERROR: Missing required environment variable:');
48
+ console.error('- EZMODO_API_KEY (or legacy ZEPHLY_API_KEY)');
49
+ console.error('\nPlease set your ezmodo API key to use this MCP server.');
50
+ console.error(`Generate one at: ${CONFIG.settingsUrl}`);
51
+ console.error('\nOptional environment variables:');
52
+ console.error('- EZMODO_API_URL (default: production Go API URL)');
53
+ console.error('- EZMODO_ENVIRONMENT (dev/staging/production)');
54
+ console.error('\nEnvironment-specific URLs:');
55
+ console.error(' - dev: https://dev.ezmodo.com/api');
56
+ console.error(' - staging: https://staging.ezmodo.com/api');
57
+ console.error(' - production: https://ezmodo.com/api');
58
+ console.error(' - local: http://localhost:8787/api');
59
+ process.exit(1);
60
+ }
61
+
62
+ log.info('MCP server starting', {
63
+ environment: CONFIG.environment,
64
+ apiUrl: CONFIG.apiUrl,
65
+ apiKeyPrefix: apiKey.substring(0, 12),
66
+ });
67
+ console.error('🔧 ezmodo MCP Server Configuration:');
68
+ console.error(` Environment: ${CONFIG.environment} (build-time)`);
69
+ console.error(` API URL: ${CONFIG.apiUrl}`);
70
+ console.error(` API Key: ${apiKey.substring(0, 12)}...`);
71
+ console.error('');
72
+
73
+ // ============================================================
74
+ // MCP Server Setup
75
+ // ============================================================
76
+
77
+ const server = new Server(
78
+ {
79
+ name: 'ezmodo-mcp-server',
80
+ version: MCP_VERSION,
81
+ },
82
+ {
83
+ capabilities: {
84
+ tools: {},
85
+ prompts: {},
86
+ },
87
+ }
88
+ );
89
+
90
+ // Register tool handlers
91
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({
92
+ tools: TOOLS,
93
+ }));
94
+
95
+ // Register tool call handler
96
+ server.setRequestHandler(CallToolRequestSchema, async (request) => {
97
+ const { name, arguments: args } = request.params;
98
+
99
+ // Get handler function for this tool
100
+ const handler = HANDLERS[name];
101
+
102
+ if (!handler) {
103
+ throw new Error(`Unknown tool: ${name}`);
104
+ }
105
+
106
+ const start = Date.now();
107
+ try {
108
+ const result = await handler(args || {});
109
+ log.debug('Tool call succeeded', { tool: name, durationMs: Date.now() - start });
110
+ return {
111
+ content: [
112
+ {
113
+ type: 'text',
114
+ text: JSON.stringify(result, null, 2),
115
+ },
116
+ ],
117
+ };
118
+ } catch (error) {
119
+ const errMsg = error.message || String(error);
120
+ log.error('Tool call failed', { tool: name, error: errMsg, durationMs: Date.now() - start });
121
+ return {
122
+ content: [
123
+ {
124
+ type: 'text',
125
+ text: JSON.stringify({
126
+ error: error.message || String(error),
127
+ }, null, 2),
128
+ },
129
+ ],
130
+ isError: true,
131
+ };
132
+ }
133
+ });
134
+
135
+ // Register prompt handlers
136
+ server.setRequestHandler(ListPromptsRequestSchema, async () => ({
137
+ prompts: PROMPTS,
138
+ }));
139
+
140
+ server.setRequestHandler(GetPromptRequestSchema, async (request) => {
141
+ const content = getPromptContent(request.params.name);
142
+
143
+ if (!content) {
144
+ throw new Error(`Unknown prompt: ${request.params.name}`);
145
+ }
146
+
147
+ return {
148
+ messages: [
149
+ {
150
+ role: 'user',
151
+ content: {
152
+ type: 'text',
153
+ text: content,
154
+ },
155
+ },
156
+ ],
157
+ };
158
+ });
159
+
160
+ // ============================================================
161
+ // Start Server
162
+ // ============================================================
163
+
164
+ async function main() {
165
+ const transport = new StdioServerTransport();
166
+ await server.connect(transport);
167
+ log.info('MCP server running on stdio');
168
+ console.error('✅ ezmodo MCP Server running on stdio');
169
+ }
170
+
171
+ main().catch((error) => {
172
+ log.error('Fatal error in main()', { error: error.message || String(error) });
173
+ console.error('Fatal error in main():', error);
174
+ process.exit(1);
175
+ });
@@ -0,0 +1,86 @@
1
+ /**
2
+ * Active Session File Management
3
+ *
4
+ * Writes/clears <repo-config-dir>/active-session.json to communicate the
5
+ * active task between Claude Code (via MCP server) and the EzModo desktop
6
+ * app. The session file lives inside whichever config directory the repo
7
+ * already uses — `.ezmodo/` for current repos, `.zephly/` for legacy.
8
+ */
9
+
10
+ import { execSync } from 'child_process';
11
+ import fs from 'fs/promises';
12
+ import path from 'path';
13
+ import { getLogger } from './logger.js';
14
+ import { findRepoConfigDir } from './repo-config-dir.js';
15
+
16
+ /**
17
+ * Get the current git branch name, or null if not in a git repo.
18
+ */
19
+ function getCurrentBranch() {
20
+ try {
21
+ return execSync('git rev-parse --abbrev-ref HEAD', {
22
+ encoding: 'utf-8',
23
+ stdio: ['pipe', 'pipe', 'pipe'],
24
+ }).trim();
25
+ } catch {
26
+ return null;
27
+ }
28
+ }
29
+
30
+ /**
31
+ * Write the active-session.json file inside the project's config directory
32
+ * (`.ezmodo/active-session.json`, or legacy `.zephly/active-session.json` if
33
+ * that's what the repo already uses).
34
+ *
35
+ * @param {object} taskData
36
+ * @param {string} taskData.taskId
37
+ * @param {number} taskData.taskNumber
38
+ * @param {string} taskData.title
39
+ * @param {string} [taskData.epicId]
40
+ * @param {number} [taskData.epicNumber]
41
+ */
42
+ export async function writeActiveSession(taskData) {
43
+ try {
44
+ const configDir = await findRepoConfigDir(process.cwd());
45
+ if (!configDir) {
46
+ getLogger().debug('No project config directory found, skipping session file write');
47
+ return;
48
+ }
49
+
50
+ const session = {
51
+ taskId: taskData.taskId,
52
+ taskNumber: taskData.taskNumber,
53
+ title: taskData.title,
54
+ ...(taskData.epicId && { epicId: taskData.epicId }),
55
+ ...(taskData.epicNumber && { epicNumber: taskData.epicNumber }),
56
+ branch: getCurrentBranch(),
57
+ startedAt: new Date().toISOString(),
58
+ agentName: 'claude',
59
+ };
60
+
61
+ const sessionPath = path.join(configDir, 'active-session.json');
62
+ await fs.writeFile(sessionPath, JSON.stringify(session, null, 2), 'utf-8');
63
+ getLogger().info('Wrote active session file', { taskId: taskData.taskId });
64
+ } catch (err) {
65
+ getLogger().warn('Failed to write active session file', { error: err.message });
66
+ }
67
+ }
68
+
69
+ /**
70
+ * Clear (delete) the active-session.json file inside the project's config
71
+ * directory when task work ends. Operates on whichever directory the repo
72
+ * uses (`.ezmodo/` or legacy `.zephly/`).
73
+ */
74
+ export async function clearActiveSession() {
75
+ try {
76
+ const configDir = await findRepoConfigDir(process.cwd());
77
+ if (!configDir) return;
78
+
79
+ const sessionPath = path.join(configDir, 'active-session.json');
80
+ await fs.unlink(sessionPath);
81
+ getLogger().info('Cleared active session file');
82
+ } catch (err) {
83
+ if (err.code === 'ENOENT') return; // Already gone, that's fine
84
+ getLogger().warn('Failed to clear active session file', { error: err.message });
85
+ }
86
+ }
@@ -0,0 +1,93 @@
1
+ /**
2
+ * Auto-assign Utility
3
+ * Matches task/epic content against cached tags and components
4
+ * for automatic assignment during creation.
5
+ */
6
+
7
+ import { readConfig } from './local-cache.js';
8
+
9
+ /**
10
+ * Escape special regex characters in a string.
11
+ */
12
+ function escapeRegex(str) {
13
+ return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
14
+ }
15
+
16
+ /**
17
+ * Match components against text content using word boundary matching.
18
+ * @param {string} text - Combined title + description
19
+ * @param {Array} components - Array of {id, name, description}
20
+ * @returns {Array} Matching component objects
21
+ */
22
+ export function matchComponents(text, components) {
23
+ if (!text || !components?.length) return [];
24
+ return components.filter((c) => {
25
+ const regex = new RegExp(`\\b${escapeRegex(c.name)}\\b`, 'i');
26
+ return regex.test(text);
27
+ });
28
+ }
29
+
30
+ /**
31
+ * Match tags against text content using word boundary matching.
32
+ * @param {string} text - Combined title + description
33
+ * @param {Array} tags - Array of {id, name, color, category, description}
34
+ * @returns {Array} Matching tag objects
35
+ */
36
+ export function matchTags(text, tags) {
37
+ if (!text || !tags?.length) return [];
38
+ return tags.filter((t) => {
39
+ const regex = new RegExp(`\\b${escapeRegex(t.name)}\\b`, 'i');
40
+ return regex.test(text);
41
+ });
42
+ }
43
+
44
+ /**
45
+ * Resolve auto-assignment for a task being created.
46
+ * Returns { matchedTags, availableComponents, organizationId } or null if no cache available.
47
+ *
48
+ * Components are NOT auto-assigned — agents must explicitly provide componentId.
49
+ * This function returns available components so the handler can hint at them if needed.
50
+ *
51
+ * @param {string} projectId - The task's project ID
52
+ * @param {string} title - Task title
53
+ * @param {string} description - Task description
54
+ * @returns {Promise<object|null>}
55
+ */
56
+ export async function resolveTaskAutoAssign(projectId, title, description) {
57
+ const config = await readConfig();
58
+ if (!config) return null;
59
+
60
+ const text = `${title || ''} ${description || ''}`.trim();
61
+
62
+ const components = config.projectId === projectId ? (config.components || []) : [];
63
+ const tags = config.tags || [];
64
+
65
+ return {
66
+ matchedTags: text ? matchTags(text, tags) : [],
67
+ availableComponents: components.map((c) => ({ id: c.id, name: c.name })),
68
+ organizationId: config.organizationId || null,
69
+ };
70
+ }
71
+
72
+ /**
73
+ * Resolve auto-assignment for an epic being created.
74
+ * Returns { matchedTags, organizationId } or null if no cache available.
75
+ *
76
+ * @param {string} title - Epic title
77
+ * @param {string} description - Epic description
78
+ * @returns {Promise<object|null>}
79
+ */
80
+ export async function resolveEpicAutoAssign(title, description) {
81
+ const config = await readConfig();
82
+ if (!config) return null;
83
+
84
+ const text = `${title || ''} ${description || ''}`.trim();
85
+ if (!text) return null;
86
+
87
+ const tags = config.tags || [];
88
+
89
+ return {
90
+ matchedTags: matchTags(text, tags),
91
+ organizationId: config.organizationId || null,
92
+ };
93
+ }
@@ -0,0 +1,176 @@
1
+ import { callZephlyAPI } from './http-client.js';
2
+
3
+ /**
4
+ * Auto-linking helpers (E-225).
5
+ *
6
+ * The API resolves file paths to the components that own them and previews what
7
+ * the autolink engine would propose. Both are read-only, so an agent can ask
8
+ * "what does this touch?" before it creates or commits anything.
9
+ *
10
+ * Everything here is best-effort: a resolver that is unavailable, or an API
11
+ * older than E-225, must degrade to "no information" rather than failing the
12
+ * surrounding create/update. A missing link is recoverable; a failed task
13
+ * create loses the agent's work.
14
+ */
15
+
16
+ /**
17
+ * Resolve repo-relative file paths to the components that own them.
18
+ *
19
+ * @param {object} params
20
+ * @param {string} params.projectId
21
+ * @param {string[]} params.paths - repo-relative paths
22
+ * @returns {Promise<{matches: object[], unresolved: string[]}>}
23
+ */
24
+ export async function resolvePathsToComponents({ projectId, paths }) {
25
+ if (!projectId || !Array.isArray(paths) || paths.length === 0) {
26
+ return { matches: [], unresolved: [] };
27
+ }
28
+ try {
29
+ const result = await callZephlyAPI('mcpResolvePaths', { projectId, paths });
30
+ return {
31
+ matches: result?.matches || [],
32
+ unresolved: result?.unresolved || [],
33
+ };
34
+ } catch {
35
+ // An API predating E-225 has no such route. Report nothing rather than
36
+ // surfacing a 404 the agent can do nothing about.
37
+ return { matches: [], unresolved: paths };
38
+ }
39
+ }
40
+
41
+ /**
42
+ * Preview what the autolink engine would propose for an entity, writing
43
+ * nothing. This is the explicit-gate mechanism: show the proposals, let the
44
+ * caller confirm the ones it wants via manage_link (which records them as
45
+ * human-asserted).
46
+ *
47
+ * @param {object} params
48
+ * @param {string} params.projectId
49
+ * @param {string} params.subjectType - e.g. 'task'
50
+ * @param {string} params.subjectId
51
+ * @param {string[]} [params.paths]
52
+ * @param {string} [params.trigger]
53
+ * @param {string} [params.componentId]
54
+ * @param {string} [params.epicId]
55
+ * @returns {Promise<{proposals: object[]}>}
56
+ */
57
+ export async function previewEntityLinks({
58
+ projectId,
59
+ subjectType,
60
+ subjectId,
61
+ paths,
62
+ trigger,
63
+ componentId,
64
+ epicId,
65
+ }) {
66
+ if (!subjectType || !subjectId) return { proposals: [] };
67
+ try {
68
+ const result = await callZephlyAPI('mcpPreviewLinks', {
69
+ projectId,
70
+ subjectType,
71
+ subjectId,
72
+ paths,
73
+ trigger,
74
+ componentId,
75
+ epicId,
76
+ });
77
+ return { proposals: result?.proposals || [] };
78
+ } catch {
79
+ return { proposals: [] };
80
+ }
81
+ }
82
+
83
+ /**
84
+ * Split preview proposals into the two groups a caller cares about: what the
85
+ * engine would write on its own, and what it wants a human to confirm.
86
+ *
87
+ * Callers surface these differently — "already handled" vs "please look" — so
88
+ * doing the split once here keeps every handler from re-deriving it.
89
+ */
90
+ export function partitionProposals(proposals = []) {
91
+ const autoLinked = [];
92
+ const linkSuggestions = [];
93
+ for (const p of proposals) {
94
+ (p?.autoApplies ? autoLinked : linkSuggestions).push({
95
+ targetType: p.targetType,
96
+ targetId: p.targetId,
97
+ linkType: p.linkType,
98
+ rule: p.rule,
99
+ confidence: p.confidence,
100
+ evidence: p.evidence,
101
+ });
102
+ }
103
+ return { autoLinked, linkSuggestions };
104
+ }
105
+
106
+ /** Key a suggestion by what it points at — the only thing a preview proposal
107
+ * and its persisted row are guaranteed to agree on. */
108
+ function targetKey(targetType, targetId) {
109
+ return `${targetType || ''}:${targetId || ''}`;
110
+ }
111
+
112
+ /**
113
+ * Read the pending link suggestions the engine actually persisted for an entity.
114
+ *
115
+ * The API returns Go-shaped rows (`ID`, `TargetEntityType`, …); normalise here so
116
+ * no caller has to know that. Best-effort like everything else in this module:
117
+ * an unavailable queue costs the ids, not the surrounding create.
118
+ */
119
+ export async function fetchPendingLinkSuggestions({ subjectType, subjectId }) {
120
+ if (!subjectType || !subjectId) return [];
121
+ try {
122
+ const result = await callZephlyAPI('mcpListAgentSuggestions', {
123
+ entityType: subjectType,
124
+ entityId: subjectId,
125
+ action: 'link',
126
+ });
127
+ const rows = result?.suggestions || [];
128
+ return rows.map((row) => ({
129
+ suggestionId: row.ID ?? row.id ?? row.suggestionId,
130
+ targetType: row.TargetEntityType ?? row.targetEntityType ?? row.targetType,
131
+ targetId: row.TargetEntityID ?? row.targetEntityId ?? row.targetId,
132
+ linkType: row.Payload?.link_type ?? row.payload?.link_type ?? 'relates_to',
133
+ rule: row.Payload?.rule ?? row.payload?.rule,
134
+ confidence: row.Confidence ?? row.confidence,
135
+ evidence: row.Payload ?? row.payload,
136
+ })).filter((s) => s.suggestionId);
137
+ } catch {
138
+ return [];
139
+ }
140
+ }
141
+
142
+ /**
143
+ * Give each suggestion the `suggestionId` that `resolve_link_suggestions` needs.
144
+ *
145
+ * Without this an agent is told to clear its suggestions and handed nothing to
146
+ * clear them WITH — the preview that produces them is read-only, so its
147
+ * proposals carry no id (#2297).
148
+ *
149
+ * The persisted queue is also the authoritative set, not the preview: the
150
+ * engine's semantic rules land a beat after the deterministic ones, so a
151
+ * preview taken at create time can legitimately show fewer. Rows the preview
152
+ * missed are appended rather than dropped, and `partial` warns when some
153
+ * proposal has no row yet — resolvable only on a later read.
154
+ */
155
+ export async function attachSuggestionIds({ subjectType, subjectId, linkSuggestions = [] }) {
156
+ const persisted = await fetchPendingLinkSuggestions({ subjectType, subjectId });
157
+ if (persisted.length === 0) {
158
+ return { linkSuggestions, partial: linkSuggestions.length > 0 };
159
+ }
160
+
161
+ const byTarget = new Map(persisted.map((s) => [targetKey(s.targetType, s.targetId), s]));
162
+ const seen = new Set();
163
+ const merged = linkSuggestions.map((s) => {
164
+ const key = targetKey(s.targetType, s.targetId);
165
+ const match = byTarget.get(key);
166
+ if (!match) return s;
167
+ seen.add(key);
168
+ return { suggestionId: match.suggestionId, ...s };
169
+ });
170
+
171
+ for (const s of persisted) {
172
+ if (!seen.has(targetKey(s.targetType, s.targetId))) merged.push(s);
173
+ }
174
+
175
+ return { linkSuggestions: merged, partial: merged.some((s) => !s.suggestionId) };
176
+ }
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Normalize the two ways a caller can name files it touched: the E-225
3
+ * `changedFiles` string array, or the pre-existing `linkedFiles` object array.
4
+ * Explicit input always wins over auto-resolution — the agent knows better than
5
+ * a similarity search what it actually edited.
6
+ *
7
+ * Lives in lib/ rather than in the task handler because the epic-with-tasks
8
+ * create (#2247) needs it too, and a handler importing another handler would
9
+ * drag the whole task-handler dependency set into every epic test.
10
+ */
11
+ export function normalizeChangedFiles(changedFiles, linkedFiles) {
12
+ const out = [];
13
+ const seen = new Set();
14
+ for (const f of linkedFiles || []) {
15
+ const path = typeof f === 'string' ? f : f?.path;
16
+ if (path && !seen.has(path)) { seen.add(path); out.push({ path, source: f?.source || 'mcp' }); }
17
+ }
18
+ for (const path of changedFiles || []) {
19
+ if (path && !seen.has(path)) { seen.add(path); out.push({ path, source: 'mcp' }); }
20
+ }
21
+ return out;
22
+ }