@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,917 @@
1
+ /**
2
+ * Git Context Handlers
3
+ * Handler functions for git repository detection and project context management
4
+ */
5
+
6
+ import { execSync } from 'child_process';
7
+ import fs from 'fs/promises';
8
+ import path from 'path';
9
+ import { calculateGitMatchConfidence } from '../lib/git-utils.js';
10
+ import { isCacheFresh } from '../lib/local-cache.js';
11
+ import {
12
+ CURRENT_REPO_CONFIG_DIR,
13
+ LEGACY_REPO_CONFIG_DIR,
14
+ findRepoConfigPath,
15
+ getWriteRepoConfigDirName,
16
+ } from '../lib/repo-config-dir.js';
17
+ // Re-export for backward compatibility (tests import from here)
18
+ export { isCacheFresh };
19
+ import { getProject } from './projects.js';
20
+ import { listRepositories } from './github.js';
21
+ import { getOrganization } from './organizations.js';
22
+ import { listComponents } from './components.js';
23
+ import { getLogger } from '../lib/logger.js';
24
+ import { listTags } from './tags.js';
25
+ import { CONFIG } from '../config/index.js';
26
+
27
+ /**
28
+ * Idempotently add the project config directory to the repo's `.gitignore`.
29
+ * Returns true if `.gitignore` was modified.
30
+ */
31
+ async function ensureConfigDirGitignored(workingDirectory, configDirName, enabled) {
32
+ if (!enabled) return false;
33
+ try {
34
+ const gitignorePath = path.join(workingDirectory, '.gitignore');
35
+ let gitignoreContent = '';
36
+ try {
37
+ gitignoreContent = await fs.readFile(gitignorePath, 'utf-8');
38
+ } catch {
39
+ // .gitignore doesn't exist yet — will be created below.
40
+ }
41
+
42
+ if (gitignoreContent.includes(`${configDirName}/`)) return false;
43
+
44
+ const newContent =
45
+ gitignoreContent.trim() +
46
+ `\n\n# ezmodo project context\n${configDirName}/\n`;
47
+ await fs.writeFile(gitignorePath, newContent, 'utf-8');
48
+ return true;
49
+ } catch (err) {
50
+ console.error('Warning: Failed to update .gitignore:', err);
51
+ return false;
52
+ }
53
+ }
54
+
55
+ /**
56
+ * Build the response fields that report a leftover legacy config directory.
57
+ *
58
+ * We write `.ezmodo/` unconditionally, so a repo that had `.zephly/` now has
59
+ * both. Readers prefer the new one, so nothing breaks — but silently leaving a
60
+ * stale directory behind is how the transition never ends. Telling the caller
61
+ * makes the cleanup a visible next step rather than a mystery.
62
+ */
63
+ function legacyConfigNotice(legacyConfigPath) {
64
+ if (!legacyConfigPath) return {};
65
+ return {
66
+ legacyConfigPath,
67
+ legacyConfigWarning:
68
+ `Wrote ${CURRENT_REPO_CONFIG_DIR}/config.json. This repo also still has a legacy `
69
+ + `${LEGACY_REPO_CONFIG_DIR}/ directory at ${legacyConfigPath}, which is now unused. `
70
+ + 'Run `ezmodo migrate-config` (or delete it) to finish moving off it.',
71
+ };
72
+ }
73
+
74
+ /**
75
+ * Fetch lightweight component summaries (id, name, description) for a project.
76
+ * Filtered to kind='area' (E-168): this list is the coarse codebase-area set
77
+ * agents use to pick a task's componentId, and it must match the web task
78
+ * picker (which also filters to area). Without the filter, UI-inventory rows
79
+ * (screen/page/component) would leak into task routing. Returns an empty array
80
+ * on failure (non-fatal).
81
+ */
82
+ async function fetchComponentSummaries(projectId) {
83
+ try {
84
+ const result = await listComponents({ projectId, kind: 'area' });
85
+ if (!result?.components) return [];
86
+ return result.components.map((c) => ({
87
+ id: c.id,
88
+ name: c.name,
89
+ description: c.description || '',
90
+ }));
91
+ } catch {
92
+ return [];
93
+ }
94
+ }
95
+
96
+ /**
97
+ * Fetch lightweight tag summaries (id, name, color, category) for an organization.
98
+ * Returns an empty array on failure (non-fatal).
99
+ */
100
+ async function fetchTagSummaries(organizationId) {
101
+ try {
102
+ const result = await listTags({ organizationId });
103
+ if (!result?.tags) return [];
104
+ return result.tags.map((t) => ({
105
+ id: t.id,
106
+ name: t.name,
107
+ color: t.color || '',
108
+ category: t.category || 'custom',
109
+ description: t.description || '',
110
+ }));
111
+ } catch {
112
+ return [];
113
+ }
114
+ }
115
+
116
+ /**
117
+ * Generate the CLAUDE.md work tracking section with project-specific context
118
+ */
119
+ export function generateClaudeMdSection({
120
+ projectName, projectId, orgSlug, organizationId, environment, projects,
121
+ }) {
122
+ let projectContext;
123
+ if (projects && projects.length > 0) {
124
+ const projectLines = projects.map((p) => ` - ${p.name} (${p.projectId}): \`${p.path}/\``).join('\n');
125
+ projectContext = `- Organization: ${orgSlug} (ID: ${organizationId})\n`
126
+ + `- Projects:\n${projectLines}\n`
127
+ + `- Environment: ${environment}`;
128
+ } else {
129
+ projectContext = `- Organization: ${orgSlug} (ID: ${organizationId})\n`
130
+ + `- Project: ${projectName} (${projectId})\n`
131
+ + `- Environment: ${environment}`;
132
+ }
133
+
134
+ return `## Work Tracking with ezmodo MCP
135
+
136
+ **IMPORTANT:** This repository uses EzModo to track development work. **Every piece of work MUST have an EzModo task.**
137
+
138
+ **Project Context:**
139
+ ${projectContext}
140
+
141
+ ### 1. Session Initialization
142
+
143
+ 1. Check \`.ezmodo/config.json\` (or legacy \`.zephly/config.json\`) exists
144
+ 2. Call \`get_current_project_context()\` to load project context — cache the \`projectId\` for the session
145
+ 3. If the user specifies an existing task or epic to work on,
146
+ search for it (\`search_tasks\` / \`search_epics\`) and resume using the Context Recovery steps in section 3a
147
+ 4. Otherwise, **always create an ezmodo task** before starting any work (see creation rules below)
148
+
149
+ ### 2. Task & Epic Creation Rules
150
+
151
+ **Always create a task** for every piece of work —
152
+ no exceptions unless the user explicitly says to work on an existing task.
153
+
154
+ **Before creating any task or epic**, gather codebase context:
155
+ - Call \`get_context\` with a keyword query matching the work topic
156
+ (e.g., \`query: "rate limiting manifest"\`)
157
+ - Review returned files to understand which files exist, what patterns
158
+ are used, and what dependencies are involved
159
+ - Use this to write **specific** descriptions and steps that reference
160
+ actual file paths, function names, and existing patterns
161
+
162
+ **What makes a good task:**
163
+ - Description explains **why** (the problem/goal), **where** (specific
164
+ files/endpoints), and **how** (approach, referencing existing patterns)
165
+ - Steps reference specific files, not vague instructions
166
+ - Steps follow existing codebase patterns discovered from \`get_context\`
167
+
168
+ **Single-scope work** (bug fix, small feature, config change, docs update):
169
+ - Create a **task** with \`manage_task action:"create"\`:
170
+ - \`projectId\` from cached context
171
+ - \`componentId\` — pick the single most relevant component from the
172
+ project context. Each task belongs to exactly one component.
173
+ Get the list via \`get_current_project_context()\`.
174
+ - Descriptive \`title\` and \`description\` (informed by \`get_context\`)
175
+ - \`steps\` array with actionable steps referencing specific files
176
+ - \`priority\` based on context (low / medium / high / urgent)
177
+ - \`status: "in_progress"\` (since work starts immediately)
178
+
179
+ **Multi-scope work** (feature spanning multiple files/areas, large refactor):
180
+ - Call \`get_context\` for each area to understand the full scope
181
+ - Create an **epic** first with \`manage_epic action:"create"\`:
182
+ - \`projectId\`, \`title\`, \`description\` (include which layers/services
183
+ are affected based on context queries)
184
+ - \`status: "active"\`
185
+ - Then create **child tasks** for each logical unit of work, each
186
+ linked to the epic via \`epicId\`
187
+ - Each child task should reference specific files from context queries
188
+ - Order tasks by dependency (use \`get_context\` with
189
+ \`entityType: "file"\` to understand dependency chains)
190
+
191
+ ### 3. During Work
192
+
193
+ **Step tracking — update after each step completes:**
194
+ - After completing each implementation step, immediately call
195
+ \`manage_task action:"update"\` with \`toggleStep: {stepId: "step_0", completed: true}\`
196
+ - Do not batch step updates — toggle each step as soon as
197
+ the work for that step is done
198
+ - If you discover a step needs to be added, call \`manage_task action:"update"\`
199
+ with \`addStep\` before doing the work
200
+
201
+ **Knowledge capture — add knowledge frequently, not just at the end:**
202
+ - Call \`manage_task action:"update"\` with \`addKnowledge\` after any of these events:
203
+ - **Root cause found:**
204
+ \`{type: "fact", content: "Bug caused by X in Y", tags: ["root-cause"]}\`
205
+ - **Architecture/design decision:**
206
+ \`{type: "decision", content: "Chose A over B because...", tags: ["design"]}\`
207
+ - **Key file or pattern discovered:**
208
+ \`{type: "reference", content: "Handlers in api/handlers/", tags: ["codebase"]}\`
209
+ - **Before each commit:**
210
+ \`{type: "context", content: "Changed X, Y, Z to implement...", tags: ["progress"]}\`
211
+ - **Unexpected blocker or workaround:**
212
+ \`{type: "fact", content: "Worked around X by doing Y", tags: ["blocker"]}\`
213
+ - Keep knowledge items concise but specific — include file paths,
214
+ function names, and error messages
215
+
216
+ **Context preservation — proactively save progress:**
217
+ - If the conversation is getting long (many tool calls, large code
218
+ reads), proactively call \`manage_task action:"update"\` with \`addKnowledge\` containing:
219
+ \`{type: "context", content: "Progress summary: completed steps 1-3,
220
+ working on step 4. Key files modified: ...",
221
+ tags: ["progress-checkpoint"]}\` so a new session can resume
222
+ - If new sub-work is discovered mid-task, create additional tasks
223
+ (linked to the epic if applicable)
224
+
225
+ ### 3a. Context Recovery (Resuming a Task)
226
+
227
+ When resuming a task that was started in a previous session:
228
+ 1. Call \`get_task\` with the \`taskId\` to load full task state
229
+ 2. Review which steps are already completed (skip those)
230
+ 3. Read all \`knowledge\` items to understand what was discovered and decided
231
+ 4. Look for \`progress-checkpoint\` tagged knowledge for the latest status summary
232
+ 5. Continue from where the previous session left off
233
+
234
+ ### 4. Commit Linking
235
+
236
+ After every git commit, call \`manage_task action:"link_commit"\` with:
237
+ - \`taskId\` of the active task
238
+ - \`sha\` — the commit hash
239
+ - \`message\` — the commit message
240
+ - \`author\` — the commit author
241
+ - \`branch\` — the current branch name
242
+
243
+ ### 5. Completion
244
+
245
+ After finishing work on a task:
246
+
247
+ 1. **Check \`autoGenerateTestCases\`** from the project context
248
+ (returned by \`get_current_project_context\` during session init).
249
+ If \`false\`, skip to step 3.
250
+
251
+ 2. **Create test cases** for the completed work:
252
+ - You (the agent) write the test cases yourself — you have full context of the changes you just made
253
+ - Call \`manage_test_case action:"create"\` for each test case with:
254
+ - \`taskId\` — the task being worked on
255
+ - \`title\` — concise test case name
256
+ - \`description\` — what this test verifies
257
+ - \`category\` — e.g., \`"functional"\`, \`"regression"\`, \`"edge-case"\`
258
+ - \`priority\` — \`"critical"\`, \`"high"\`, \`"medium"\`, or \`"low"\`
259
+ - \`steps\` — array of \`{instruction, expectedResult}\` objects
260
+ - \`preconditions\` — any setup required (optional)
261
+ - Aim for 3-6 test cases covering: happy path, edge cases, and error handling
262
+
263
+ 3. **Submit for review** — Call \`manage_task action:"update"\` with \`status: "in_review"\`
264
+ and include \`completionNotes\` summarizing what was accomplished
265
+
266
+ 4. **Do NOT call \`manage_task action:"complete"\`** — the human reviewer will complete the task after verifying test cases pass
267
+
268
+ 5. If working under an epic, update epic status when all child tasks are complete
269
+ `;
270
+ }
271
+
272
+ /**
273
+ * Update or create CLAUDE.md with the work tracking section.
274
+ * Returns true if the file was updated, false if skipped (already has section).
275
+ * Non-fatal — logs warnings on error.
276
+ */
277
+ async function updateClaudeMd(workingDirectory, sectionContent, projectName) {
278
+ const claudeMdPath = path.join(workingDirectory, 'CLAUDE.md');
279
+ try {
280
+ let content = '';
281
+ try {
282
+ content = await fs.readFile(claudeMdPath, 'utf-8');
283
+ } catch {
284
+ // File doesn't exist — will create new
285
+ }
286
+
287
+ // Idempotency has to recognise the pre-rebrand heading too: a repo
288
+ // initialised before the rename would otherwise get a SECOND section
289
+ // appended, differing only in the product name.
290
+ if (
291
+ content.includes('## Work Tracking with ezmodo MCP')
292
+ || content.includes('## Work Tracking with Zephly MCP')
293
+ ) {
294
+ return false; // Already has section, skip (idempotent)
295
+ }
296
+
297
+ if (content) {
298
+ // Append to existing file
299
+ const newLine = content.endsWith('\n') ? '' : '\n';
300
+ await fs.writeFile(claudeMdPath, content + newLine + '\n' + sectionContent, 'utf-8');
301
+ } else {
302
+ // Create new file
303
+ await fs.writeFile(claudeMdPath, `# ${projectName || 'Project'}\n\n${sectionContent}`, 'utf-8');
304
+ }
305
+ return true;
306
+ } catch (err) {
307
+ getLogger().warn('Failed to update CLAUDE.md', { error: err.message || String(err) });
308
+ return false;
309
+ }
310
+ }
311
+
312
+ /**
313
+ * Read a project's git remote from the fields the API actually returns.
314
+ *
315
+ * The Go model serializes `gitUrl` and `gitContext.repositoryUrl`
316
+ * (api/internal/core/projects/models.go). This used to read `gitRepositoryUrl`,
317
+ * a Firestore-era name that exists nowhere in the API — so the match loop never
318
+ * had a URL to compare and every project fell through it. That was invisible
319
+ * while the response-envelope guard above returned first: the function had two
320
+ * independent reasons to find nothing, and fixing one only uncovered the other.
321
+ *
322
+ * `gitRepositoryUrl` is kept last as a courtesy to any caller still passing the
323
+ * old shape, not because anything produces it.
324
+ */
325
+ function projectRepositoryUrl(project) {
326
+ return project?.gitUrl
327
+ || project?.gitContext?.repositoryUrl
328
+ || project?.gitRepositoryUrl
329
+ || null;
330
+ }
331
+
332
+ /**
333
+ * Fetch a project's linked git repositories, each carrying the `repoId` that
334
+ * manage_pull_request needs.
335
+ *
336
+ * Attached to every match so the hop from "I am in this checkout" to "here is
337
+ * the id I open a PR against" is one call. It was previously no calls at all:
338
+ * detect_git_repository resolved a project and stopped, and the id lives
339
+ * nowhere else an agent can reach. Non-fatal — a project with no GitHub
340
+ * integration is the normal case, not an error.
341
+ */
342
+ async function fetchLinkedRepositories(projectId) {
343
+ try {
344
+ const result = await listRepositories({ projectId });
345
+ return Array.isArray(result?.repositories) ? result.repositories : [];
346
+ } catch {
347
+ return [];
348
+ }
349
+ }
350
+
351
+ export async function detectGitRepository(args) {
352
+ const { workingDirectory = process.cwd() } = args;
353
+
354
+ try {
355
+ // Check if directory is a git repo
356
+ try {
357
+ execSync('git rev-parse --is-inside-work-tree', {
358
+ cwd: workingDirectory,
359
+ stdio: 'pipe',
360
+ });
361
+ } catch {
362
+ // Not a git repo
363
+ return {
364
+ success: true,
365
+ isGitRepository: false,
366
+ message: 'Not a git repository',
367
+ };
368
+ }
369
+
370
+ // Get git remote URL
371
+ let remoteUrl = null;
372
+ try {
373
+ remoteUrl = execSync('git config --get remote.origin.url', {
374
+ cwd: workingDirectory,
375
+ encoding: 'utf-8',
376
+ stdio: 'pipe',
377
+ }).trim();
378
+ } catch {
379
+ // No remote configured
380
+ return {
381
+ success: true,
382
+ isGitRepository: true,
383
+ hasRemote: false,
384
+ message: 'Git repository has no remote configured',
385
+ };
386
+ }
387
+
388
+ // Get current branch
389
+ let currentBranch = null;
390
+ try {
391
+ currentBranch = execSync('git branch --show-current', {
392
+ cwd: workingDirectory,
393
+ encoding: 'utf-8',
394
+ stdio: 'pipe',
395
+ }).trim();
396
+ } catch {
397
+ // Could not get branch
398
+ }
399
+
400
+ // Get all accessible projects.
401
+ //
402
+ // callZephlyAPI unwraps the Go API's {success, data} envelope and hands back
403
+ // `data` alone, so the response here is `{projects: [...]}` with no `success`
404
+ // flag. This used to test `projectsResult.success`, which is therefore always
405
+ // undefined -- every call took the failure branch and reported "Failed to
406
+ // fetch accessible projects" while holding the projects it had just fetched.
407
+ // Test the shape that actually arrives, and let a genuine API failure throw
408
+ // so its own message reaches the caller instead of a generic one.
409
+ let projects;
410
+ try {
411
+ const projectsResult = await getProject({});
412
+ projects = projectsResult?.projects;
413
+ } catch (err) {
414
+ return {
415
+ success: true,
416
+ isGitRepository: true,
417
+ hasRemote: true,
418
+ remoteUrl,
419
+ currentBranch,
420
+ matches: [],
421
+ message: `Failed to fetch accessible projects: ${err.message}`,
422
+ };
423
+ }
424
+
425
+ if (!Array.isArray(projects)) {
426
+ return {
427
+ success: true,
428
+ isGitRepository: true,
429
+ hasRemote: true,
430
+ remoteUrl,
431
+ currentBranch,
432
+ matches: [],
433
+ message: 'Failed to fetch accessible projects',
434
+ };
435
+ }
436
+
437
+ // Match against project git URLs
438
+ const matches = [];
439
+ for (const project of projects) {
440
+ const projectGitUrl = projectRepositoryUrl(project);
441
+ if (projectGitUrl) {
442
+ const confidence = calculateGitMatchConfidence(remoteUrl, projectGitUrl);
443
+
444
+ if (confidence > 0.4) {
445
+ matches.push({
446
+ project: {
447
+ id: project.id,
448
+ name: project.name,
449
+ slug: project.slug,
450
+ organizationId: project.organizationId,
451
+ orgSlug: project.orgSlug,
452
+ orgName: project.orgName,
453
+ gitRepositoryUrl: projectGitUrl,
454
+ gitBranch: project.gitContext?.defaultBranch || project.gitBranch,
455
+ },
456
+ confidence,
457
+ reason: confidence === 1.0 ? 'Exact URL match' :
458
+ confidence >= 0.9 ? 'Same repository, different protocol' :
459
+ confidence >= 0.5 ? 'Same repository name' :
460
+ 'Partial match',
461
+ });
462
+ }
463
+ }
464
+ }
465
+
466
+ // Sort by confidence (highest first)
467
+ matches.sort((a, b) => b.confidence - a.confidence);
468
+
469
+ // Attach each matched project's linked repositories, so the caller leaves
470
+ // with the repoId rather than a project id and a dead end.
471
+ await Promise.all(matches.map(async (match) => {
472
+ match.repositories = await fetchLinkedRepositories(match.project.id);
473
+ }));
474
+
475
+ return {
476
+ success: true,
477
+ isGitRepository: true,
478
+ hasRemote: true,
479
+ remoteUrl,
480
+ currentBranch,
481
+ matches,
482
+ matchCount: matches.length,
483
+ };
484
+ } catch (error) {
485
+ throw new Error(`Failed to detect git repository: ${error.message}`);
486
+ }
487
+ }
488
+
489
+ export async function getCurrentProjectContext(args) {
490
+ const { workingDirectory = process.cwd() } = args;
491
+
492
+ try {
493
+ // Walk up directory tree looking for a project config file
494
+ // (`.ezmodo/config.json` preferred, `.zephly/config.json` legacy).
495
+ const configPath = await findRepoConfigPath(workingDirectory);
496
+ if (configPath) {
497
+ const currentDir = path.dirname(path.dirname(configPath));
498
+
499
+ try {
500
+ const content = await fs.readFile(configPath, 'utf-8');
501
+ const config = JSON.parse(content);
502
+
503
+ // For monorepos, determine which project based on relative path
504
+ let activeProject = null;
505
+ if (config.projects && config.projects.length > 0) {
506
+ // Find which project path matches
507
+ const relativePath = path.relative(currentDir, workingDirectory);
508
+ for (const project of config.projects) {
509
+ if (relativePath.startsWith(project.path)) {
510
+ activeProject = project;
511
+ break;
512
+ }
513
+ }
514
+
515
+ // If no match, default to first project
516
+ if (!activeProject) {
517
+ activeProject = {
518
+ id: config.projectId,
519
+ name: config.projectName,
520
+ path: '.',
521
+ };
522
+ }
523
+ } else {
524
+ activeProject = {
525
+ id: config.projectId,
526
+ name: config.projectName,
527
+ path: '.',
528
+ };
529
+ }
530
+
531
+ const isMonorepo = !!(config.projects && config.projects.length > 0);
532
+
533
+ // Cached path: if config data is fresh, skip API calls
534
+ if (isCacheFresh(config.lastUpdatedAt)) {
535
+ const validation = {
536
+ projectExists: true,
537
+ userHasAccess: true,
538
+ organizationExists: true,
539
+ cached: true,
540
+ warnings: [],
541
+ };
542
+
543
+ return {
544
+ success: true,
545
+ found: true,
546
+ projectId: activeProject.id,
547
+ projectName: activeProject.name,
548
+ orgSlug: config.orgSlug,
549
+ organizationId: config.organizationId,
550
+ isMonorepo,
551
+ workingDirectory: currentDir,
552
+ configPath,
553
+ allProjects: config.projects || null,
554
+ components: config.components || [],
555
+ tags: config.tags || [],
556
+ autoGenerateTestCases: config.settings?.aiConfig?.autoGenerateTestCases || false,
557
+ organizeResponseMode: config.settings?.aiConfig?.organizeResponseMode || 'raw_snapshot',
558
+ // The words this project's type uses (E-107). Cached alongside
559
+ // components and tags because it changes about as often, and an
560
+ // agent needs it on every session, not on a second round trip.
561
+ // Absent means plain English — a project type with no template.
562
+ projectType: config.projectType || null,
563
+ terminology: config.terminology || null,
564
+ validation,
565
+ };
566
+ }
567
+
568
+ // Stale/missing cache — refresh from server
569
+ const validation = {
570
+ projectExists: true,
571
+ userHasAccess: true,
572
+ organizationExists: true,
573
+ cached: false,
574
+ warnings: [],
575
+ };
576
+
577
+ let projectSettings = null;
578
+ let projectType = null;
579
+ let terminology = null;
580
+ try {
581
+ // Check if project exists and user has access
582
+ const ctx = await getProject({ projectId: activeProject.id });
583
+ projectSettings = ctx?.settings || null;
584
+ // E-107: the project's type and the vocabulary it speaks, so an agent
585
+ // writes "Campaign" on a marketing project. Both may be absent — an
586
+ // unrecognised type has no template, which means plain English.
587
+ projectType = ctx?.type || null;
588
+ terminology = ctx?.terminology || null;
589
+ } catch (error) {
590
+ const errorMsg = error.message || String(error);
591
+ if (errorMsg.includes('404') || errorMsg.includes('not found')) {
592
+ validation.projectExists = false;
593
+ validation.warnings.push('Project no longer exists');
594
+ } else if (errorMsg.includes('403') || errorMsg.includes('access') || errorMsg.includes('permission')) {
595
+ validation.userHasAccess = false;
596
+ validation.warnings.push('You no longer have access to this project');
597
+ } else {
598
+ validation.warnings.push('Could not validate project access');
599
+ }
600
+ }
601
+
602
+ // Check if organization exists
603
+ let orgName = config.orgName || null;
604
+ try {
605
+ const orgs = await getOrganization({ mode: 'list' });
606
+ const org = orgs.organizations?.find((o) => o.id === config.organizationId);
607
+ if (!org) {
608
+ validation.organizationExists = false;
609
+ validation.warnings.push('Organization no longer accessible');
610
+ } else {
611
+ orgName = org.name;
612
+ }
613
+ } catch {
614
+ validation.warnings.push('Could not validate organization access');
615
+ }
616
+
617
+ // Refresh components and tags lists
618
+ const [components, tags] = await Promise.all([
619
+ fetchComponentSummaries(activeProject.id),
620
+ fetchTagSummaries(config.organizationId),
621
+ ]);
622
+
623
+ // Update config on disk with fresh data (non-fatal)
624
+ if (validation.projectExists && validation.organizationExists) {
625
+ try {
626
+ config.orgName = orgName;
627
+ config.lastUpdatedAt = new Date().toISOString();
628
+ config.components = components;
629
+ config.tags = tags;
630
+ config.projectType = projectType;
631
+ config.terminology = terminology;
632
+ config.settings = {
633
+ ...config.settings,
634
+ aiConfig: projectSettings?.aiConfig || config.settings?.aiConfig || null,
635
+ };
636
+ await fs.writeFile(configPath, JSON.stringify(config, null, 2), 'utf-8');
637
+ } catch {
638
+ // Non-fatal — config update failed, will refresh next time
639
+ }
640
+ }
641
+
642
+ return {
643
+ success: true,
644
+ found: true,
645
+ projectId: activeProject.id,
646
+ projectName: activeProject.name,
647
+ orgSlug: config.orgSlug,
648
+ organizationId: config.organizationId,
649
+ isMonorepo,
650
+ workingDirectory: currentDir,
651
+ configPath,
652
+ allProjects: config.projects || null,
653
+ components,
654
+ tags,
655
+ autoGenerateTestCases: projectSettings?.aiConfig?.autoGenerateTestCases || false,
656
+ organizeResponseMode: projectSettings?.aiConfig?.organizeResponseMode || 'raw_snapshot',
657
+ projectType,
658
+ terminology,
659
+ validation,
660
+ };
661
+ } catch {
662
+ // Config file resolved but unreadable/malformed — treat as not found.
663
+ }
664
+ }
665
+
666
+ // No config found
667
+ return {
668
+ success: true,
669
+ found: false,
670
+ message: `No ${CURRENT_REPO_CONFIG_DIR}/config.json (or legacy ${LEGACY_REPO_CONFIG_DIR}/config.json) found. Use initialize_project_context to create one.`,
671
+ };
672
+ } catch (error) {
673
+ throw new Error(`Failed to get current project context: ${error.message}`);
674
+ }
675
+ }
676
+
677
+ export async function initializeProjectContext(args) {
678
+ const {
679
+ projectId,
680
+ organizationId,
681
+ workingDirectory = process.cwd(),
682
+ addToGitignore = true,
683
+ addClaudeMd = true,
684
+ monorepoProjects = null,
685
+ } = args;
686
+
687
+ try {
688
+ // Step 1: Check if config already exists. Writes ALWAYS go to `.ezmodo/`.
689
+ //
690
+ // This used to reuse a legacy `.zephly/` when the repo had one, which meant
691
+ // an agent running this tool on an un-migrated repo kept the old directory
692
+ // alive instead of moving off it — the tool was extending the very layout
693
+ // the rebrand is retiring. We read whatever exists (readers dual-check, new
694
+ // wins) and write the new location, leaving the legacy directory in place
695
+ // for `ezmodo migrate-config` to clean up.
696
+ const existingConfigPath = await findRepoConfigPath(workingDirectory);
697
+ const configDirName = getWriteRepoConfigDirName();
698
+ const configDir = path.join(workingDirectory, configDirName);
699
+ const configPath = path.join(configDir, 'config.json');
700
+
701
+ const legacyConfigPath =
702
+ existingConfigPath && existingConfigPath !== configPath ? existingConfigPath : null;
703
+
704
+ let existingConfig = null;
705
+ try {
706
+ // Seed from the legacy config when that's the only one there, so a repo
707
+ // being moved over keeps its settings instead of starting from scratch.
708
+ const readFrom = existingConfigPath ?? configPath;
709
+ const existingContent = await fs.readFile(readFrom, 'utf-8');
710
+ existingConfig = JSON.parse(existingContent);
711
+ } catch {
712
+ // Config doesn't exist, which is fine
713
+ }
714
+
715
+ // Step 2: Handle monorepo initialization (no projectId needed, just organizationId + monorepoProjects)
716
+ if (!projectId && monorepoProjects && monorepoProjects.length > 0 && organizationId) {
717
+ // Monorepo mode: write config directly without requiring a single projectId
718
+ const orgs = await getOrganization({ mode: 'list' });
719
+
720
+ if (!orgs || !orgs.organizations) {
721
+ throw new Error('Failed to fetch organizations. Please check your API key permissions.');
722
+ }
723
+
724
+ const org = orgs.organizations.find((o) => o.id === organizationId);
725
+ if (!org) {
726
+ throw new Error(`Organization ${organizationId} not found or not accessible`);
727
+ }
728
+
729
+ // Build monorepo config
730
+ const config = {
731
+ organizationId,
732
+ orgSlug: org.slug,
733
+ orgName: org.name,
734
+ isMonorepo: true,
735
+ projects: monorepoProjects.map((p) => ({
736
+ projectId: p.projectId,
737
+ name: p.name,
738
+ path: p.path,
739
+ })),
740
+ environment: CONFIG.environment,
741
+ lastUpdatedAt: new Date().toISOString(),
742
+ };
743
+
744
+ // Create config directory and write config
745
+ await fs.mkdir(configDir, { recursive: true });
746
+ await fs.writeFile(configPath, JSON.stringify(config, null, 2), 'utf-8');
747
+
748
+ // Update .gitignore if requested
749
+ let gitignoreUpdated = await ensureConfigDirGitignored(workingDirectory, configDirName, addToGitignore);
750
+
751
+ // Update CLAUDE.md if requested
752
+ let claudeMdUpdated = false;
753
+ if (addClaudeMd) {
754
+ const section = generateClaudeMdSection({
755
+ orgSlug: org.slug,
756
+ organizationId,
757
+ environment: CONFIG.environment,
758
+ projects: config.projects,
759
+ });
760
+ claudeMdUpdated = await updateClaudeMd(workingDirectory, section, null);
761
+ }
762
+
763
+ return {
764
+ success: true,
765
+ configPath,
766
+ isMonorepo: true,
767
+ projectCount: monorepoProjects.length,
768
+ projects: config.projects,
769
+ orgSlug: org.slug,
770
+ organizationId,
771
+ gitignoreUpdated,
772
+ claudeMdUpdated,
773
+ existingConfig,
774
+ ...legacyConfigNotice(legacyConfigPath),
775
+ };
776
+ }
777
+
778
+ // Step 3: If no projectId provided, try git detection then fetch available projects (interactive mode)
779
+ if (!projectId) {
780
+ // Try to detect git repository and match to projects
781
+ const gitDetection = await detectGitRepository({ workingDirectory });
782
+
783
+ // Get all available projects
784
+ const projectsResult = await getProject({});
785
+ const orgs = await getOrganization({ mode: 'list' });
786
+
787
+ if (!orgs || !orgs.organizations) {
788
+ throw new Error('Failed to fetch organizations. Please check your API key permissions.');
789
+ }
790
+
791
+ const response = {
792
+ success: true,
793
+ mode: 'interactive',
794
+ existingConfig,
795
+ organizations: orgs.organizations,
796
+ projects: projectsResult.projects || [],
797
+ };
798
+
799
+ // If we found git matches, include them as suggestions
800
+ if (gitDetection.isGitRepository && gitDetection.matches && gitDetection.matches.length > 0) {
801
+ response.gitDetected = true;
802
+ response.gitRemoteUrl = gitDetection.remoteUrl;
803
+ response.gitMatches = gitDetection.matches;
804
+ response.message = `Found ${gitDetection.matchCount} project(s) `
805
+ + 'matching this git repository. Select one or choose from all projects.';
806
+ } else if (gitDetection.isGitRepository) {
807
+ response.gitDetected = true;
808
+ response.gitRemoteUrl = gitDetection.remoteUrl;
809
+ response.gitMatches = [];
810
+ response.message = 'Git repository detected but no matching ezmodo projects found.'
811
+ + ' Select from available projects.';
812
+ } else {
813
+ response.gitDetected = false;
814
+ response.message = 'No projectId provided. Select a project from the list.';
815
+ }
816
+
817
+ response.nextStep = 'Call initialize_project_context with projectId'
818
+ + ' and organizationId, or with organizationId and'
819
+ + ' monorepoProjects for monorepo setup';
820
+ return response;
821
+ }
822
+
823
+ // Step 3: Fetch project details
824
+ const projectContext = await getProject({ projectId });
825
+
826
+ // Step 4: Get organization details
827
+ const orgs = await getOrganization({ mode: 'list' });
828
+
829
+ if (!orgs || !orgs.organizations) {
830
+ throw new Error('Failed to fetch organizations. Please check your API key permissions.');
831
+ }
832
+
833
+ const org = orgs.organizations.find((o) => o.id === organizationId);
834
+
835
+ if (!org) {
836
+ throw new Error(`Organization ${organizationId} not found or not accessible`);
837
+ }
838
+
839
+ // Step 5: Fetch components and tags
840
+ const [components, tags] = await Promise.all([
841
+ fetchComponentSummaries(projectId),
842
+ fetchTagSummaries(organizationId),
843
+ ]);
844
+
845
+ // Step 6: Build config
846
+ const config = {
847
+ projectId,
848
+ organizationId,
849
+ orgSlug: org.slug,
850
+ orgName: org.name,
851
+ // Use slug if available, fallback to ID
852
+ projectSlug: projectContext.project?.slug || projectContext.slug || projectId,
853
+ projectName: projectContext.project?.name || projectContext.name || 'Unknown Project',
854
+ environment: CONFIG.environment, // staging, production, or dev
855
+ lastUpdatedAt: new Date().toISOString(),
856
+ components,
857
+ tags,
858
+ settings: {
859
+ aiConfig: projectContext.settings?.aiConfig || projectContext.project?.settings?.aiConfig || null,
860
+ },
861
+ };
862
+
863
+ // Add monorepo config if provided
864
+ if (monorepoProjects && monorepoProjects.length > 0) {
865
+ config.projects = monorepoProjects.map((p) => ({
866
+ id: p.projectId,
867
+ name: p.name,
868
+ path: p.path,
869
+ }));
870
+ }
871
+
872
+ // Step 6: Create config directory
873
+ await fs.mkdir(configDir, { recursive: true });
874
+
875
+ // Step 7: Write config file
876
+ await fs.writeFile(
877
+ configPath,
878
+ JSON.stringify(config, null, 2),
879
+ 'utf-8'
880
+ );
881
+
882
+ // Step 8: Update .gitignore if requested
883
+ let gitignoreUpdated = await ensureConfigDirGitignored(workingDirectory, configDirName, addToGitignore);
884
+
885
+ // Step 9: Update CLAUDE.md if requested
886
+ let claudeMdUpdated = false;
887
+ if (addClaudeMd) {
888
+ // Extract autoGenerateTestCases from project settings
889
+ const section = generateClaudeMdSection({
890
+ projectName: config.projectName,
891
+ projectId: config.projectId,
892
+ orgSlug: config.orgSlug,
893
+ organizationId: config.organizationId,
894
+ environment: config.environment,
895
+ });
896
+ claudeMdUpdated = await updateClaudeMd(workingDirectory, section, config.projectName);
897
+ }
898
+
899
+ return {
900
+ success: true,
901
+ configPath,
902
+ projectInfo: {
903
+ projectId: config.projectId,
904
+ projectName: config.projectName,
905
+ orgSlug: config.orgSlug,
906
+ organizationId: config.organizationId,
907
+ },
908
+ isMonorepo: !!monorepoProjects && monorepoProjects.length > 0,
909
+ gitignoreUpdated,
910
+ claudeMdUpdated,
911
+ existingConfig,
912
+ ...legacyConfigNotice(legacyConfigPath),
913
+ };
914
+ } catch (error) {
915
+ throw new Error(`Failed to initialize project context: ${error.message}`);
916
+ }
917
+ }