@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.
- package/README.md +305 -0
- package/config/development.js +20 -0
- package/config/endpoint-map.js +351 -0
- package/config/index.js +34 -0
- package/config/production.js +18 -0
- package/config/staging.js +18 -0
- package/handlers/access.js +141 -0
- package/handlers/activity.js +112 -0
- package/handlers/agents.js +95 -0
- package/handlers/ai-intelligence.js +55 -0
- package/handlers/attachments.js +30 -0
- package/handlers/catalogs.js +169 -0
- package/handlers/components.js +282 -0
- package/handlers/context-manifest.js +1150 -0
- package/handlers/decisions.js +114 -0
- package/handlers/designs.js +118 -0
- package/handlers/documents.js +227 -0
- package/handlers/entities.js +95 -0
- package/handlers/epics.js +190 -0
- package/handlers/facts.js +62 -0
- package/handlers/feature-flags.js +142 -0
- package/handlers/features.js +137 -0
- package/handlers/folders.js +127 -0
- package/handlers/git-context.js +917 -0
- package/handlers/github.js +72 -0
- package/handlers/graph.js +23 -0
- package/handlers/index.js +205 -0
- package/handlers/links.js +156 -0
- package/handlers/milestones.js +131 -0
- package/handlers/organizations.js +14 -0
- package/handlers/projects.js +122 -0
- package/handlers/recurring-tasks.js +33 -0
- package/handlers/tags.js +124 -0
- package/handlers/tasks.js +561 -0
- package/handlers/testing.js +116 -0
- package/handlers/todos.js +43 -0
- package/handlers/watchers.js +54 -0
- package/handlers/work-templates.js +32 -0
- package/index.js +175 -0
- package/lib/active-session.js +86 -0
- package/lib/auto-assign.js +93 -0
- package/lib/autolink.js +176 -0
- package/lib/changed-files.js +22 -0
- package/lib/env.js +45 -0
- package/lib/git-helpers.js +553 -0
- package/lib/git-utils.js +73 -0
- package/lib/http-client.js +164 -0
- package/lib/links-at-create.js +94 -0
- package/lib/local-cache.js +140 -0
- package/lib/logger.js +109 -0
- package/lib/manifest-loader.js +182 -0
- package/lib/manifest-query.js +686 -0
- package/lib/repo-config-dir.js +118 -0
- package/lib/version.js +10 -0
- package/lib/web-url.js +69 -0
- package/lib/worktree-tools.js +950 -0
- package/package.json +62 -0
- package/prompts/ai-workflow-automation.js +96 -0
- package/prompts/index.js +39 -0
- package/prompts/zephly-usage-guide-content.txt +631 -0
- package/prompts/zephly-usage-guide.js +119 -0
- package/tools/access-entity-types.js +28 -0
- package/tools/access.js +152 -0
- package/tools/activity.js +38 -0
- package/tools/agents.js +208 -0
- package/tools/ai-intelligence.js +111 -0
- package/tools/attachments.js +92 -0
- package/tools/catalogs.js +341 -0
- package/tools/components.js +249 -0
- package/tools/context-manifest.js +236 -0
- package/tools/decisions.js +168 -0
- package/tools/designs.js +222 -0
- package/tools/documents.js +287 -0
- package/tools/entities.js +223 -0
- package/tools/epics.js +267 -0
- package/tools/facts.js +70 -0
- package/tools/feature-flags.js +300 -0
- package/tools/features.js +246 -0
- package/tools/folders.js +122 -0
- package/tools/git-context.js +109 -0
- package/tools/github.js +172 -0
- package/tools/graph.js +70 -0
- package/tools/index.js +77 -0
- package/tools/link-params.js +93 -0
- package/tools/linkable-types.js +36 -0
- package/tools/links.js +199 -0
- package/tools/milestones.js +176 -0
- package/tools/organizations.js +23 -0
- package/tools/projects.js +172 -0
- package/tools/recurring-tasks.js +115 -0
- package/tools/tags.js +219 -0
- package/tools/task-item-schema.js +57 -0
- package/tools/task-type.js +33 -0
- package/tools/tasks.js +680 -0
- package/tools/testing.js +344 -0
- package/tools/todos.js +69 -0
- package/tools/watchers.js +81 -0
- 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
|
+
}
|
package/lib/autolink.js
ADDED
|
@@ -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
|
+
}
|