@vibescope/mcp-server 0.5.0 → 0.5.2

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 (162) hide show
  1. package/CHANGELOG.md +84 -84
  2. package/README.md +194 -194
  3. package/dist/api-client/tasks.d.ts +1 -0
  4. package/dist/cli-init.js +21 -21
  5. package/dist/cli.js +26 -26
  6. package/dist/handlers/session.js +3 -1
  7. package/dist/handlers/tasks.js +7 -1
  8. package/dist/handlers/tool-docs.js +1216 -1216
  9. package/dist/index.js +73 -73
  10. package/dist/templates/agent-guidelines.d.ts +1 -1
  11. package/dist/templates/agent-guidelines.js +205 -205
  12. package/dist/templates/help-content.js +1621 -1621
  13. package/dist/tools/bodies-of-work.js +6 -6
  14. package/dist/tools/cloud-agents.js +22 -22
  15. package/dist/tools/milestones.js +2 -2
  16. package/dist/tools/requests.js +1 -1
  17. package/dist/tools/session.js +11 -11
  18. package/dist/tools/sprints.js +9 -9
  19. package/dist/tools/tasks.js +43 -35
  20. package/dist/tools/worktrees.js +14 -14
  21. package/dist/utils.js +11 -11
  22. package/docs/TOOLS.md +2687 -2685
  23. package/package.json +53 -53
  24. package/scripts/generate-docs.ts +212 -212
  25. package/scripts/version-bump.ts +203 -203
  26. package/src/api-client/blockers.ts +86 -86
  27. package/src/api-client/bodies-of-work.ts +194 -194
  28. package/src/api-client/chat.ts +50 -50
  29. package/src/api-client/connectors.ts +152 -152
  30. package/src/api-client/cost.ts +185 -185
  31. package/src/api-client/decisions.ts +87 -87
  32. package/src/api-client/deployment.ts +313 -313
  33. package/src/api-client/discovery.ts +81 -81
  34. package/src/api-client/fallback.ts +52 -52
  35. package/src/api-client/file-checkouts.ts +115 -115
  36. package/src/api-client/findings.ts +100 -100
  37. package/src/api-client/git-issues.ts +88 -88
  38. package/src/api-client/ideas.ts +112 -112
  39. package/src/api-client/index.ts +592 -592
  40. package/src/api-client/milestones.ts +83 -83
  41. package/src/api-client/organizations.ts +185 -185
  42. package/src/api-client/progress.ts +94 -94
  43. package/src/api-client/project.ts +181 -181
  44. package/src/api-client/requests.ts +54 -54
  45. package/src/api-client/session.ts +220 -220
  46. package/src/api-client/sprints.ts +227 -227
  47. package/src/api-client/subtasks.ts +57 -57
  48. package/src/api-client/tasks.ts +451 -450
  49. package/src/api-client/types.ts +32 -32
  50. package/src/api-client/validation.ts +60 -60
  51. package/src/api-client/worktrees.ts +53 -53
  52. package/src/api-client.test.ts +847 -847
  53. package/src/api-client.ts +2728 -2728
  54. package/src/cli-init.ts +558 -558
  55. package/src/cli.test.ts +284 -284
  56. package/src/cli.ts +204 -204
  57. package/src/handlers/__test-setup__.ts +240 -240
  58. package/src/handlers/__test-utils__.ts +89 -89
  59. package/src/handlers/blockers.test.ts +468 -468
  60. package/src/handlers/blockers.ts +172 -172
  61. package/src/handlers/bodies-of-work.test.ts +704 -704
  62. package/src/handlers/bodies-of-work.ts +526 -526
  63. package/src/handlers/chat.test.ts +185 -185
  64. package/src/handlers/chat.ts +101 -101
  65. package/src/handlers/cloud-agents.test.ts +438 -438
  66. package/src/handlers/cloud-agents.ts +156 -156
  67. package/src/handlers/connectors.test.ts +834 -834
  68. package/src/handlers/connectors.ts +229 -229
  69. package/src/handlers/cost.test.ts +462 -462
  70. package/src/handlers/cost.ts +285 -285
  71. package/src/handlers/decisions.test.ts +382 -382
  72. package/src/handlers/decisions.ts +153 -153
  73. package/src/handlers/deployment.test.ts +551 -551
  74. package/src/handlers/deployment.ts +570 -570
  75. package/src/handlers/discovery.test.ts +206 -206
  76. package/src/handlers/discovery.ts +433 -433
  77. package/src/handlers/fallback.test.ts +537 -537
  78. package/src/handlers/fallback.ts +194 -194
  79. package/src/handlers/file-checkouts.test.ts +750 -750
  80. package/src/handlers/file-checkouts.ts +185 -185
  81. package/src/handlers/findings.test.ts +633 -633
  82. package/src/handlers/findings.ts +239 -239
  83. package/src/handlers/git-issues.test.ts +631 -631
  84. package/src/handlers/git-issues.ts +136 -136
  85. package/src/handlers/ideas.test.ts +644 -644
  86. package/src/handlers/ideas.ts +207 -207
  87. package/src/handlers/index.ts +93 -93
  88. package/src/handlers/milestones.test.ts +475 -475
  89. package/src/handlers/milestones.ts +180 -180
  90. package/src/handlers/organizations.test.ts +826 -826
  91. package/src/handlers/organizations.ts +315 -315
  92. package/src/handlers/progress.test.ts +269 -269
  93. package/src/handlers/progress.ts +77 -77
  94. package/src/handlers/project.test.ts +546 -546
  95. package/src/handlers/project.ts +245 -245
  96. package/src/handlers/requests.test.ts +303 -303
  97. package/src/handlers/requests.ts +99 -99
  98. package/src/handlers/roles.test.ts +305 -305
  99. package/src/handlers/roles.ts +219 -219
  100. package/src/handlers/session.test.ts +998 -998
  101. package/src/handlers/session.ts +1107 -1105
  102. package/src/handlers/sprints.test.ts +732 -732
  103. package/src/handlers/sprints.ts +537 -537
  104. package/src/handlers/tasks.test.ts +931 -931
  105. package/src/handlers/tasks.ts +1144 -1137
  106. package/src/handlers/tool-categories.test.ts +66 -66
  107. package/src/handlers/tool-docs.test.ts +511 -511
  108. package/src/handlers/tool-docs.ts +1595 -1595
  109. package/src/handlers/types.test.ts +259 -259
  110. package/src/handlers/types.ts +176 -176
  111. package/src/handlers/validation.test.ts +582 -582
  112. package/src/handlers/validation.ts +164 -164
  113. package/src/handlers/version.ts +63 -63
  114. package/src/index.test.ts +674 -674
  115. package/src/index.ts +884 -884
  116. package/src/setup.test.ts +243 -243
  117. package/src/setup.ts +410 -410
  118. package/src/templates/agent-guidelines.ts +233 -233
  119. package/src/templates/help-content.ts +1751 -1751
  120. package/src/token-tracking.test.ts +463 -463
  121. package/src/token-tracking.ts +167 -167
  122. package/src/tools/blockers.ts +122 -122
  123. package/src/tools/bodies-of-work.ts +283 -283
  124. package/src/tools/chat.ts +72 -72
  125. package/src/tools/cloud-agents.ts +101 -101
  126. package/src/tools/connectors.ts +191 -191
  127. package/src/tools/cost.ts +111 -111
  128. package/src/tools/decisions.ts +111 -111
  129. package/src/tools/deployment.ts +455 -455
  130. package/src/tools/discovery.ts +76 -76
  131. package/src/tools/fallback.ts +111 -111
  132. package/src/tools/features.ts +154 -154
  133. package/src/tools/file-checkouts.ts +145 -145
  134. package/src/tools/findings.ts +101 -101
  135. package/src/tools/git-issues.ts +130 -130
  136. package/src/tools/ideas.ts +162 -162
  137. package/src/tools/index.ts +145 -145
  138. package/src/tools/milestones.ts +118 -118
  139. package/src/tools/organizations.ts +224 -224
  140. package/src/tools/persona-templates.ts +25 -25
  141. package/src/tools/progress.ts +73 -73
  142. package/src/tools/project.ts +210 -210
  143. package/src/tools/requests.ts +68 -68
  144. package/src/tools/roles.ts +112 -112
  145. package/src/tools/session.ts +181 -181
  146. package/src/tools/sprints.ts +298 -298
  147. package/src/tools/tasks.ts +583 -575
  148. package/src/tools/tools.test.ts +222 -222
  149. package/src/tools/types.ts +9 -9
  150. package/src/tools/validation.ts +75 -75
  151. package/src/tools/version.ts +34 -34
  152. package/src/tools/worktrees.ts +66 -66
  153. package/src/tools.test.ts +416 -416
  154. package/src/utils.test.ts +1014 -1014
  155. package/src/utils.ts +586 -586
  156. package/src/validators.test.ts +223 -223
  157. package/src/validators.ts +249 -249
  158. package/src/version.ts +162 -162
  159. package/tsconfig.json +16 -16
  160. package/vitest.config.ts +14 -14
  161. package/dist/tools.d.ts +0 -2
  162. package/dist/tools.js +0 -3602
package/src/utils.ts CHANGED
@@ -1,586 +1,586 @@
1
- // ============================================================================
2
- // Utilities Module - Extracted from index.ts for testability
3
- // ============================================================================
4
-
5
- import { randomUUID } from 'crypto';
6
-
7
- // ============================================================================
8
- // Agent Persona Pool
9
- // Iconic sci-fi & fantasy character names for agents on the dashboard
10
- // ============================================================================
11
-
12
- export const AGENT_PERSONAS = [
13
- // Star Wars
14
- 'Yoda',
15
- 'Leia',
16
- 'Han',
17
- 'Luke',
18
- 'Rey',
19
- 'Finn',
20
- 'Kylo',
21
- 'Mando',
22
- 'Grogu',
23
- 'Ahsoka',
24
- // Lord of the Rings
25
- 'Frodo',
26
- 'Gandalf',
27
- 'Aragorn',
28
- 'Legolas',
29
- 'Gimli',
30
- 'Sam',
31
- 'Bilbo',
32
- 'Gollum',
33
- // Star Trek
34
- 'Spock',
35
- 'Kirk',
36
- 'Picard',
37
- 'Data',
38
- 'Worf',
39
- 'Seven',
40
- // Dune
41
- 'Paul',
42
- 'Chani',
43
- 'Duncan',
44
- 'Stilgar',
45
- 'Leto',
46
- // The Matrix
47
- 'Neo',
48
- 'Trinity',
49
- 'Morpheus',
50
- // Firefly
51
- 'Mal',
52
- 'River',
53
- 'Kaylee',
54
- 'Wash',
55
- // Game of Thrones
56
- 'Arya',
57
- 'Tyrion',
58
- 'Dany',
59
- 'Bran',
60
- // Marvel / Guardians
61
- 'Groot',
62
- 'Rocket',
63
- 'Gamora',
64
- 'Drax',
65
- 'Loki',
66
- 'Thor',
67
- // Classic Sci-Fi AIs & Characters
68
- 'Ripley',
69
- 'Jarvis',
70
- 'Cortana',
71
- 'GLaDOS',
72
- ] as const;
73
-
74
- export type AgentPersona = typeof AGENT_PERSONAS[number];
75
-
76
- // ============================================================================
77
- // Fallback Activities for Idle Agents
78
- // Suggested productive activities when no pending tasks exist
79
- // ============================================================================
80
-
81
- export const FALLBACK_ACTIVITIES = [
82
- {
83
- activity: 'feature_ideation',
84
- title: 'Generate feature ideas',
85
- description: 'Review the codebase and suggest improvements or new features. Use add_idea to record your findings.',
86
- prompt: 'Explore the codebase, identify areas for improvement, and suggest 2-3 actionable feature ideas using add_idea.',
87
- },
88
- {
89
- activity: 'feature_planning',
90
- title: 'Plan features from ideas',
91
- description: 'Take raw ideas and develop them into planned features with documentation.',
92
- prompt: 'Call get_ideas to find ideas with status "raw" or "exploring". Pick one and develop it: research requirements, identify implementation steps, write a feature spec. Use update_idea to change status to "planned" and add a doc_url if you create documentation.',
93
- },
94
- {
95
- activity: 'code_review',
96
- title: 'Code standards review',
97
- description: 'Check for inconsistencies, missing types, code smells, or patterns that could be improved.',
98
- prompt: 'Review the codebase for code quality issues. Look for missing types, inconsistent patterns, or technical debt. Log findings with add_idea or add_task.',
99
- },
100
- {
101
- activity: 'performance_audit',
102
- title: 'Performance audit',
103
- description: 'Identify potential performance bottlenecks or optimization opportunities.',
104
- prompt: 'Analyze the codebase for performance issues: N+1 queries, unnecessary re-renders, expensive operations. Create tasks for fixes with add_task.',
105
- },
106
- {
107
- activity: 'ux_review',
108
- title: 'UX review',
109
- description: 'Evaluate user flows and identify usability improvements.',
110
- prompt: 'Review the UI components and user flows. Identify confusing interactions, missing feedback, or accessibility issues. Log improvements with add_idea.',
111
- },
112
- {
113
- activity: 'cost_analysis',
114
- title: 'Cost analysis',
115
- description: 'Review infrastructure and API usage for cost optimization opportunities.',
116
- prompt: 'Analyze the codebase for expensive operations: unnecessary API calls, inefficient queries, large bundle sizes. Suggest cost-saving alternatives with add_idea.',
117
- },
118
- {
119
- activity: 'security_review',
120
- title: 'Security review',
121
- description: 'Check for common security issues: auth gaps, input validation, data exposure.',
122
- prompt: 'Review the codebase for security concerns: authentication gaps, missing input validation, sensitive data handling. Create high-priority tasks for issues found.',
123
- },
124
- {
125
- activity: 'test_coverage',
126
- title: 'Test coverage analysis',
127
- description: 'Find untested code paths and suggest test cases.',
128
- prompt: 'Identify areas of the codebase with missing or weak test coverage. Create tasks for writing tests using add_task.',
129
- },
130
- {
131
- activity: 'documentation_review',
132
- title: 'Documentation review',
133
- description: 'Identify missing or outdated documentation.',
134
- prompt: 'Review existing docs and code comments. Identify gaps, outdated information, or confusing explanations. Log improvements with add_idea.',
135
- },
136
- {
137
- activity: 'dependency_audit',
138
- title: 'Dependency audit',
139
- description: 'Review dependencies for updates, security issues, or unused packages.',
140
- prompt: 'Check package.json for outdated dependencies, security vulnerabilities, or unused packages. Create tasks for updates with add_task.',
141
- },
142
- {
143
- activity: 'validate_completed_tasks',
144
- title: 'Validate completed tasks',
145
- description: 'Review tasks completed by other agents to ensure quality.',
146
- prompt: 'Call get_tasks_awaiting_validation to find completed tasks that need review. Validate each one by checking the implementation and running tests if applicable.',
147
- },
148
- {
149
- activity: 'worktree_cleanup',
150
- title: 'Clean up stale worktrees',
151
- description: 'Find and remove git worktrees from completed or abandoned tasks.',
152
- prompt: `Clean up stale git worktrees to reclaim disk space and prevent confusion:
153
-
154
- 1. Call get_stale_worktrees(project_id) to find worktrees needing cleanup
155
- 2. For each stale worktree returned:
156
- a. Check if the worktree directory exists: ls -la <worktree_path>
157
- b. If it exists, remove it: git worktree remove <worktree_path>
158
- c. If removal fails (untracked files), use: git worktree remove --force <worktree_path>
159
- d. Call clear_worktree_path(task_id) to mark it as cleaned up
160
- 3. Run 'git worktree list' to verify cleanup
161
- 4. Log any issues encountered with add_blocker if worktrees cannot be removed
162
-
163
- This prevents disk bloat from accumulated worktrees and keeps the workspace clean.`,
164
- },
165
- ] as const;
166
-
167
- export type FallbackActivity = typeof FALLBACK_ACTIVITIES[number];
168
-
169
- /**
170
- * Get a random fallback activity
171
- */
172
- export function getRandomFallbackActivity(): FallbackActivity {
173
- const index = Math.floor(Math.random() * FALLBACK_ACTIVITIES.length);
174
- return FALLBACK_ACTIVITIES[index];
175
- }
176
-
177
- /**
178
- * Fisher-Yates shuffle with a seed for reproducible randomness
179
- */
180
- function seededShuffle<T>(array: T[], seed: number): T[] {
181
- const result = [...array];
182
- let currentSeed = seed;
183
-
184
- // Simple seeded random number generator (mulberry32)
185
- const random = () => {
186
- currentSeed = (currentSeed + 0x6D2B79F5) | 0;
187
- let t = currentSeed;
188
- t = Math.imul(t ^ (t >>> 15), t | 1);
189
- t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
190
- return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
191
- };
192
-
193
- for (let i = result.length - 1; i > 0; i--) {
194
- const j = Math.floor(random() * (i + 1));
195
- [result[i], result[j]] = [result[j], result[i]];
196
- }
197
- return result;
198
- }
199
-
200
- /**
201
- * Select an available persona from the pool
202
- * Uses time-based seeding to ensure variety across sessions while maintaining
203
- * determinism within the same minute (for retry consistency).
204
- *
205
- * @param usedPersonas Set of personas currently in use
206
- * @param instanceId The instance ID for fallback naming
207
- * @returns The selected persona name
208
- */
209
- export function selectPersona(usedPersonas: Set<string>, instanceId: string): string {
210
- const availablePersonas = AGENT_PERSONAS.filter((p) => !usedPersonas.has(p));
211
- if (availablePersonas.length === 0) {
212
- return `Agent-${instanceId.slice(0, 6)}`;
213
- }
214
-
215
- // Use current minute as seed for variety, plus some randomness for the final pick
216
- // This ensures different starting points each minute while allowing retries to work
217
- const minuteSeed = Math.floor(Date.now() / 60000);
218
- const shuffled = seededShuffle(availablePersonas, minuteSeed);
219
-
220
- // Pick randomly from the shuffled list for additional variety
221
- return shuffled[Math.floor(Math.random() * shuffled.length)];
222
- }
223
-
224
- // ============================================================================
225
- // Rate Limiter
226
- // ============================================================================
227
-
228
- export interface RateLimitResult {
229
- allowed: boolean;
230
- remaining: number;
231
- resetIn: number;
232
- }
233
-
234
- export class RateLimiter {
235
- private requests: Map<string, { count: number; resetAt: number }> = new Map();
236
- private readonly maxRequests: number;
237
- private readonly windowMs: number;
238
-
239
- constructor(maxRequests: number = 60, windowMs: number = 60000) {
240
- this.maxRequests = maxRequests;
241
- this.windowMs = windowMs;
242
- }
243
-
244
- check(key: string): RateLimitResult {
245
- const now = Date.now();
246
- const record = this.requests.get(key);
247
-
248
- // If no record or window expired, create new record
249
- if (!record || now >= record.resetAt) {
250
- this.requests.set(key, { count: 1, resetAt: now + this.windowMs });
251
- return { allowed: true, remaining: this.maxRequests - 1, resetIn: this.windowMs };
252
- }
253
-
254
- // Check if limit exceeded
255
- if (record.count >= this.maxRequests) {
256
- return { allowed: false, remaining: 0, resetIn: record.resetAt - now };
257
- }
258
-
259
- // Increment count
260
- record.count++;
261
- return { allowed: true, remaining: this.maxRequests - record.count, resetIn: record.resetAt - now };
262
- }
263
-
264
- // Clean up expired entries periodically
265
- cleanup(): void {
266
- const now = Date.now();
267
- for (const [key, record] of this.requests.entries()) {
268
- if (now >= record.resetAt) {
269
- this.requests.delete(key);
270
- }
271
- }
272
- }
273
-
274
- // Expose for testing
275
- getRequestCount(key: string): number | undefined {
276
- return this.requests.get(key)?.count;
277
- }
278
- }
279
-
280
- // ============================================================================
281
- // Task Validation Helpers
282
- // ============================================================================
283
-
284
- /**
285
- * Check if a task can be validated by the given session
286
- * @param task The task to validate
287
- * @param validatorSessionId The session trying to validate
288
- * @returns Object with canValidate boolean and reason if not allowed
289
- */
290
- export function canValidateTask(
291
- task: { status: string; validated_at: string | null; working_agent_session_id: string | null },
292
- validatorSessionId: string | null
293
- ): { canValidate: boolean; reason?: string } {
294
- if (task.status !== 'completed') {
295
- return { canValidate: false, reason: 'Can only validate completed tasks' };
296
- }
297
-
298
- if (task.validated_at) {
299
- return { canValidate: false, reason: 'Task has already been validated' };
300
- }
301
-
302
- // Note: Self-validation check would go here if we tracked who completed the task
303
- // Currently, working_agent_session_id is cleared on completion
304
-
305
- return { canValidate: true };
306
- }
307
-
308
- /**
309
- * Check if a task status transition is valid
310
- * @param currentStatus Current task status
311
- * @param newStatus Desired new status
312
- * @returns Object with isValid boolean and reason if not valid
313
- */
314
- export function isValidStatusTransition(
315
- currentStatus: string,
316
- newStatus: string
317
- ): { isValid: boolean; reason?: string } {
318
- const validTransitions: Record<string, string[]> = {
319
- pending: ['in_progress', 'cancelled'],
320
- in_progress: ['completed', 'pending', 'cancelled'],
321
- completed: ['in_progress'], // Can reopen via validation failure
322
- cancelled: ['pending'], // Can reactivate cancelled tasks
323
- };
324
-
325
- const allowed = validTransitions[currentStatus];
326
- if (!allowed) {
327
- return { isValid: false, reason: `Unknown current status: ${currentStatus}` };
328
- }
329
-
330
- if (!allowed.includes(newStatus)) {
331
- return {
332
- isValid: false,
333
- reason: `Cannot transition from '${currentStatus}' to '${newStatus}'. Valid transitions: ${allowed.join(', ')}`
334
- };
335
- }
336
-
337
- return { isValid: true };
338
- }
339
-
340
- /**
341
- * Check if a deployment status transition is valid
342
- * @param currentStatus Current deployment status
343
- * @param newStatus Desired new status
344
- * @returns Object with isValid boolean and reason if not valid
345
- */
346
- export function isValidDeploymentStatusTransition(
347
- currentStatus: string,
348
- newStatus: string
349
- ): { isValid: boolean; reason?: string } {
350
- const validTransitions: Record<string, string[]> = {
351
- pending: ['validating', 'failed'], // Can claim validation or cancel
352
- validating: ['ready', 'failed'], // Validation passes or fails
353
- ready: ['deploying', 'failed'], // Start deployment or cancel
354
- deploying: ['deployed', 'failed'], // Deployment succeeds or fails
355
- deployed: [], // Terminal state
356
- failed: [], // Terminal state (create new deployment to retry)
357
- };
358
-
359
- const allowed = validTransitions[currentStatus];
360
- if (!allowed) {
361
- return { isValid: false, reason: `Unknown current status: ${currentStatus}` };
362
- }
363
-
364
- if (!allowed.includes(newStatus)) {
365
- return {
366
- isValid: false,
367
- reason: `Cannot transition deployment from '${currentStatus}' to '${newStatus}'. Valid transitions: ${allowed.length ? allowed.join(', ') : 'none (terminal state)'}`
368
- };
369
- }
370
-
371
- return { isValid: true };
372
- }
373
-
374
- // ============================================================================
375
- // Git URL Parsing
376
- // ============================================================================
377
-
378
- /**
379
- * Extract project name from a git URL.
380
- * Handles various git URL formats:
381
- * - https://github.com/user/repo -> repo
382
- * - https://github.com/user/repo.git -> repo
383
- * - git@github.com:user/repo.git -> repo
384
- * - https://gitlab.com/user/repo -> repo
385
- * - ssh://git@github.com/user/repo.git -> repo
386
- *
387
- * @param gitUrl The git URL to parse
388
- * @returns The extracted project name, or 'my-project' if parsing fails
389
- */
390
- export function extractProjectNameFromGitUrl(gitUrl: string): string {
391
- if (!gitUrl) {
392
- return 'my-project';
393
- }
394
-
395
- // Match the last path segment, optionally removing .git suffix
396
- // Works for:
397
- // - /user/repo (https URLs)
398
- // - /user/repo.git
399
- // - :user/repo (ssh URLs like git@github.com:user/repo)
400
- // - :user/repo.git
401
- const match = gitUrl.match(/[/:]([^/:]+?)(?:\.git)?$/);
402
- if (match && match[1]) {
403
- return match[1];
404
- }
405
-
406
- return 'my-project';
407
- }
408
-
409
- /**
410
- * Normalize a git URL to a canonical format for consistent project lookup.
411
- *
412
- * Handles these variations:
413
- * - SSH format: `git@github.com:owner/repo` → `https://github.com/owner/repo`
414
- * - HTTPS with .git suffix: `https://github.com/owner/repo.git` → `https://github.com/owner/repo`
415
- * - Trailing slashes: `https://github.com/owner/repo/` → `https://github.com/owner/repo`
416
- * - Case variations: `https://GitHub.com/OWNER/Repo` → `https://github.com/owner/repo`
417
- * - HTTP protocol: `http://github.com/owner/repo` → `https://github.com/owner/repo`
418
- * - Bare domains: `github.com/owner/repo` → `https://github.com/owner/repo`
419
- *
420
- * @param gitUrl The git URL to normalize (can be null/undefined)
421
- * @returns Normalized URL in lowercase, or null if input is null/undefined/empty
422
- *
423
- * @example
424
- * normalizeGitUrl('git@github.com:Nonatomic/Vibescope.git')
425
- * // Returns: 'https://github.com/nonatomic/vibescope'
426
- *
427
- * normalizeGitUrl('https://github.com/Nonatomic/Vibescope/')
428
- * // Returns: 'https://github.com/nonatomic/vibescope'
429
- */
430
- export function normalizeGitUrl(gitUrl: string | null | undefined): string | null {
431
- if (!gitUrl || typeof gitUrl !== 'string') {
432
- return null;
433
- }
434
-
435
- let normalized = gitUrl.trim();
436
-
437
- // Empty after trim
438
- if (!normalized) {
439
- return null;
440
- }
441
-
442
- // Convert SSH format: git@github.com:owner/repo → https://github.com/owner/repo
443
- const sshMatch = normalized.match(/^git@([^:]+):(.+)$/);
444
- if (sshMatch) {
445
- normalized = `https://${sshMatch[1]}/${sshMatch[2]}`;
446
- }
447
-
448
- // Upgrade http to https
449
- if (normalized.startsWith('http://')) {
450
- normalized = normalized.replace('http://', 'https://');
451
- }
452
-
453
- // Ensure https protocol for URLs that don't have one
454
- // but look like they should (e.g., "github.com/owner/repo")
455
- if (!normalized.startsWith('https://') && !normalized.includes('://')) {
456
- normalized = `https://${normalized}`;
457
- }
458
-
459
- // Remove trailing slashes first (so .git suffix removal works for urls like "repo.git/")
460
- normalized = normalized.replace(/\/+$/, '');
461
-
462
- // Remove .git suffix
463
- normalized = normalized.replace(/\.git$/, '');
464
-
465
- // Lowercase everything (GitHub/GitLab are case-insensitive for URLs)
466
- normalized = normalized.toLowerCase();
467
-
468
- return normalized;
469
- }
470
-
471
- // ============================================================================
472
- // Error Handling Utilities
473
- // Shared error extraction and type checking functions
474
- // ============================================================================
475
-
476
- /**
477
- * Extract a human-readable message from any error type.
478
- * Handles Error objects, strings, and unknown values.
479
- *
480
- * @example
481
- * try { ... } catch (e) {
482
- * console.error(getErrorMessage(e));
483
- * }
484
- */
485
- export function getErrorMessage(error: unknown): string {
486
- if (error instanceof Error) {
487
- return error.message;
488
- }
489
- if (typeof error === 'string') {
490
- return error;
491
- }
492
- return String(error);
493
- }
494
-
495
- /**
496
- * Type guard to check if a value is an object with an 'error' property.
497
- * Useful for checking API responses that may contain application-level errors.
498
- *
499
- * @example
500
- * if (hasErrorProperty(response.data)) {
501
- * return error(getErrorMessage(response.data.error));
502
- * }
503
- */
504
- export function hasErrorProperty(value: unknown): value is { error: unknown } {
505
- return typeof value === 'object' && value !== null && 'error' in value;
506
- }
507
-
508
- /**
509
- * Extract an error message from an API response body.
510
- * Handles the common pattern where API returns { error: string } or { error: { message: string } }.
511
- * Returns null if no error is found.
512
- *
513
- * @example
514
- * const apiError = extractApiError(response.data);
515
- * if (apiError) {
516
- * return error(apiError);
517
- * }
518
- */
519
- export function extractApiError(data: unknown): string | null {
520
- if (!hasErrorProperty(data)) {
521
- return null;
522
- }
523
-
524
- const err = data.error;
525
-
526
- // Direct string error
527
- if (typeof err === 'string') {
528
- return err;
529
- }
530
-
531
- // Nested error object with message property
532
- if (typeof err === 'object' && err !== null && 'message' in err) {
533
- const msg = (err as { message: unknown }).message;
534
- if (typeof msg === 'string') {
535
- return msg;
536
- }
537
- }
538
-
539
- // Fallback for unknown error structure
540
- return 'Unknown error';
541
- }
542
-
543
- // ============================================================================
544
- // Pagination Utilities
545
- // ============================================================================
546
-
547
- /**
548
- * Default pagination limits
549
- */
550
- export const PAGINATION_LIMITS = {
551
- /** Default maximum limit for paginated results */
552
- DEFAULT_MAX_LIMIT: 50,
553
- /** Default limit when not specified */
554
- DEFAULT_LIMIT: 20,
555
- /** Smaller limit for task listings */
556
- TASK_LIMIT: 20,
557
- } as const;
558
-
559
- /**
560
- * Cap pagination parameters to safe values.
561
- * Ensures limit is between 1 and maxLimit, and offset is non-negative.
562
- *
563
- * @param limit - Requested limit (will be capped between 1 and maxLimit)
564
- * @param offset - Requested offset (will be made non-negative)
565
- * @param maxLimit - Maximum allowed limit (default: PAGINATION_LIMITS.DEFAULT_MAX_LIMIT)
566
- * @returns Object with cappedLimit and safeOffset
567
- *
568
- * @example
569
- * const { cappedLimit, safeOffset } = capPagination(limit, offset);
570
- * const { data } = await query.range(safeOffset, safeOffset + cappedLimit - 1);
571
- *
572
- * @example
573
- * // With custom max limit
574
- * const { cappedLimit, safeOffset } = capPagination(limit, offset, 20);
575
- */
576
- export function capPagination(
577
- limit: number | undefined,
578
- offset: number | undefined,
579
- maxLimit: number = PAGINATION_LIMITS.DEFAULT_MAX_LIMIT
580
- ): { cappedLimit: number; safeOffset: number } {
581
- const effectiveLimit = limit ?? PAGINATION_LIMITS.DEFAULT_LIMIT;
582
- return {
583
- cappedLimit: Math.min(Math.max(1, effectiveLimit), maxLimit),
584
- safeOffset: Math.max(0, offset ?? 0)
585
- };
586
- }
1
+ // ============================================================================
2
+ // Utilities Module - Extracted from index.ts for testability
3
+ // ============================================================================
4
+
5
+ import { randomUUID } from 'crypto';
6
+
7
+ // ============================================================================
8
+ // Agent Persona Pool
9
+ // Iconic sci-fi & fantasy character names for agents on the dashboard
10
+ // ============================================================================
11
+
12
+ export const AGENT_PERSONAS = [
13
+ // Star Wars
14
+ 'Yoda',
15
+ 'Leia',
16
+ 'Han',
17
+ 'Luke',
18
+ 'Rey',
19
+ 'Finn',
20
+ 'Kylo',
21
+ 'Mando',
22
+ 'Grogu',
23
+ 'Ahsoka',
24
+ // Lord of the Rings
25
+ 'Frodo',
26
+ 'Gandalf',
27
+ 'Aragorn',
28
+ 'Legolas',
29
+ 'Gimli',
30
+ 'Sam',
31
+ 'Bilbo',
32
+ 'Gollum',
33
+ // Star Trek
34
+ 'Spock',
35
+ 'Kirk',
36
+ 'Picard',
37
+ 'Data',
38
+ 'Worf',
39
+ 'Seven',
40
+ // Dune
41
+ 'Paul',
42
+ 'Chani',
43
+ 'Duncan',
44
+ 'Stilgar',
45
+ 'Leto',
46
+ // The Matrix
47
+ 'Neo',
48
+ 'Trinity',
49
+ 'Morpheus',
50
+ // Firefly
51
+ 'Mal',
52
+ 'River',
53
+ 'Kaylee',
54
+ 'Wash',
55
+ // Game of Thrones
56
+ 'Arya',
57
+ 'Tyrion',
58
+ 'Dany',
59
+ 'Bran',
60
+ // Marvel / Guardians
61
+ 'Groot',
62
+ 'Rocket',
63
+ 'Gamora',
64
+ 'Drax',
65
+ 'Loki',
66
+ 'Thor',
67
+ // Classic Sci-Fi AIs & Characters
68
+ 'Ripley',
69
+ 'Jarvis',
70
+ 'Cortana',
71
+ 'GLaDOS',
72
+ ] as const;
73
+
74
+ export type AgentPersona = typeof AGENT_PERSONAS[number];
75
+
76
+ // ============================================================================
77
+ // Fallback Activities for Idle Agents
78
+ // Suggested productive activities when no pending tasks exist
79
+ // ============================================================================
80
+
81
+ export const FALLBACK_ACTIVITIES = [
82
+ {
83
+ activity: 'feature_ideation',
84
+ title: 'Generate feature ideas',
85
+ description: 'Review the codebase and suggest improvements or new features. Use add_idea to record your findings.',
86
+ prompt: 'Explore the codebase, identify areas for improvement, and suggest 2-3 actionable feature ideas using add_idea.',
87
+ },
88
+ {
89
+ activity: 'feature_planning',
90
+ title: 'Plan features from ideas',
91
+ description: 'Take raw ideas and develop them into planned features with documentation.',
92
+ prompt: 'Call get_ideas to find ideas with status "raw" or "exploring". Pick one and develop it: research requirements, identify implementation steps, write a feature spec. Use update_idea to change status to "planned" and add a doc_url if you create documentation.',
93
+ },
94
+ {
95
+ activity: 'code_review',
96
+ title: 'Code standards review',
97
+ description: 'Check for inconsistencies, missing types, code smells, or patterns that could be improved.',
98
+ prompt: 'Review the codebase for code quality issues. Look for missing types, inconsistent patterns, or technical debt. Log findings with add_idea or add_task.',
99
+ },
100
+ {
101
+ activity: 'performance_audit',
102
+ title: 'Performance audit',
103
+ description: 'Identify potential performance bottlenecks or optimization opportunities.',
104
+ prompt: 'Analyze the codebase for performance issues: N+1 queries, unnecessary re-renders, expensive operations. Create tasks for fixes with add_task.',
105
+ },
106
+ {
107
+ activity: 'ux_review',
108
+ title: 'UX review',
109
+ description: 'Evaluate user flows and identify usability improvements.',
110
+ prompt: 'Review the UI components and user flows. Identify confusing interactions, missing feedback, or accessibility issues. Log improvements with add_idea.',
111
+ },
112
+ {
113
+ activity: 'cost_analysis',
114
+ title: 'Cost analysis',
115
+ description: 'Review infrastructure and API usage for cost optimization opportunities.',
116
+ prompt: 'Analyze the codebase for expensive operations: unnecessary API calls, inefficient queries, large bundle sizes. Suggest cost-saving alternatives with add_idea.',
117
+ },
118
+ {
119
+ activity: 'security_review',
120
+ title: 'Security review',
121
+ description: 'Check for common security issues: auth gaps, input validation, data exposure.',
122
+ prompt: 'Review the codebase for security concerns: authentication gaps, missing input validation, sensitive data handling. Create high-priority tasks for issues found.',
123
+ },
124
+ {
125
+ activity: 'test_coverage',
126
+ title: 'Test coverage analysis',
127
+ description: 'Find untested code paths and suggest test cases.',
128
+ prompt: 'Identify areas of the codebase with missing or weak test coverage. Create tasks for writing tests using add_task.',
129
+ },
130
+ {
131
+ activity: 'documentation_review',
132
+ title: 'Documentation review',
133
+ description: 'Identify missing or outdated documentation.',
134
+ prompt: 'Review existing docs and code comments. Identify gaps, outdated information, or confusing explanations. Log improvements with add_idea.',
135
+ },
136
+ {
137
+ activity: 'dependency_audit',
138
+ title: 'Dependency audit',
139
+ description: 'Review dependencies for updates, security issues, or unused packages.',
140
+ prompt: 'Check package.json for outdated dependencies, security vulnerabilities, or unused packages. Create tasks for updates with add_task.',
141
+ },
142
+ {
143
+ activity: 'validate_completed_tasks',
144
+ title: 'Validate completed tasks',
145
+ description: 'Review tasks completed by other agents to ensure quality.',
146
+ prompt: 'Call get_tasks_awaiting_validation to find completed tasks that need review. Validate each one by checking the implementation and running tests if applicable.',
147
+ },
148
+ {
149
+ activity: 'worktree_cleanup',
150
+ title: 'Clean up stale worktrees',
151
+ description: 'Find and remove git worktrees from completed or abandoned tasks.',
152
+ prompt: `Clean up stale git worktrees to reclaim disk space and prevent confusion:
153
+
154
+ 1. Call get_stale_worktrees(project_id) to find worktrees needing cleanup
155
+ 2. For each stale worktree returned:
156
+ a. Check if the worktree directory exists: ls -la <worktree_path>
157
+ b. If it exists, remove it: git worktree remove <worktree_path>
158
+ c. If removal fails (untracked files), use: git worktree remove --force <worktree_path>
159
+ d. Call clear_worktree_path(task_id) to mark it as cleaned up
160
+ 3. Run 'git worktree list' to verify cleanup
161
+ 4. Log any issues encountered with add_blocker if worktrees cannot be removed
162
+
163
+ This prevents disk bloat from accumulated worktrees and keeps the workspace clean.`,
164
+ },
165
+ ] as const;
166
+
167
+ export type FallbackActivity = typeof FALLBACK_ACTIVITIES[number];
168
+
169
+ /**
170
+ * Get a random fallback activity
171
+ */
172
+ export function getRandomFallbackActivity(): FallbackActivity {
173
+ const index = Math.floor(Math.random() * FALLBACK_ACTIVITIES.length);
174
+ return FALLBACK_ACTIVITIES[index];
175
+ }
176
+
177
+ /**
178
+ * Fisher-Yates shuffle with a seed for reproducible randomness
179
+ */
180
+ function seededShuffle<T>(array: T[], seed: number): T[] {
181
+ const result = [...array];
182
+ let currentSeed = seed;
183
+
184
+ // Simple seeded random number generator (mulberry32)
185
+ const random = () => {
186
+ currentSeed = (currentSeed + 0x6D2B79F5) | 0;
187
+ let t = currentSeed;
188
+ t = Math.imul(t ^ (t >>> 15), t | 1);
189
+ t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
190
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
191
+ };
192
+
193
+ for (let i = result.length - 1; i > 0; i--) {
194
+ const j = Math.floor(random() * (i + 1));
195
+ [result[i], result[j]] = [result[j], result[i]];
196
+ }
197
+ return result;
198
+ }
199
+
200
+ /**
201
+ * Select an available persona from the pool
202
+ * Uses time-based seeding to ensure variety across sessions while maintaining
203
+ * determinism within the same minute (for retry consistency).
204
+ *
205
+ * @param usedPersonas Set of personas currently in use
206
+ * @param instanceId The instance ID for fallback naming
207
+ * @returns The selected persona name
208
+ */
209
+ export function selectPersona(usedPersonas: Set<string>, instanceId: string): string {
210
+ const availablePersonas = AGENT_PERSONAS.filter((p) => !usedPersonas.has(p));
211
+ if (availablePersonas.length === 0) {
212
+ return `Agent-${instanceId.slice(0, 6)}`;
213
+ }
214
+
215
+ // Use current minute as seed for variety, plus some randomness for the final pick
216
+ // This ensures different starting points each minute while allowing retries to work
217
+ const minuteSeed = Math.floor(Date.now() / 60000);
218
+ const shuffled = seededShuffle(availablePersonas, minuteSeed);
219
+
220
+ // Pick randomly from the shuffled list for additional variety
221
+ return shuffled[Math.floor(Math.random() * shuffled.length)];
222
+ }
223
+
224
+ // ============================================================================
225
+ // Rate Limiter
226
+ // ============================================================================
227
+
228
+ export interface RateLimitResult {
229
+ allowed: boolean;
230
+ remaining: number;
231
+ resetIn: number;
232
+ }
233
+
234
+ export class RateLimiter {
235
+ private requests: Map<string, { count: number; resetAt: number }> = new Map();
236
+ private readonly maxRequests: number;
237
+ private readonly windowMs: number;
238
+
239
+ constructor(maxRequests: number = 60, windowMs: number = 60000) {
240
+ this.maxRequests = maxRequests;
241
+ this.windowMs = windowMs;
242
+ }
243
+
244
+ check(key: string): RateLimitResult {
245
+ const now = Date.now();
246
+ const record = this.requests.get(key);
247
+
248
+ // If no record or window expired, create new record
249
+ if (!record || now >= record.resetAt) {
250
+ this.requests.set(key, { count: 1, resetAt: now + this.windowMs });
251
+ return { allowed: true, remaining: this.maxRequests - 1, resetIn: this.windowMs };
252
+ }
253
+
254
+ // Check if limit exceeded
255
+ if (record.count >= this.maxRequests) {
256
+ return { allowed: false, remaining: 0, resetIn: record.resetAt - now };
257
+ }
258
+
259
+ // Increment count
260
+ record.count++;
261
+ return { allowed: true, remaining: this.maxRequests - record.count, resetIn: record.resetAt - now };
262
+ }
263
+
264
+ // Clean up expired entries periodically
265
+ cleanup(): void {
266
+ const now = Date.now();
267
+ for (const [key, record] of this.requests.entries()) {
268
+ if (now >= record.resetAt) {
269
+ this.requests.delete(key);
270
+ }
271
+ }
272
+ }
273
+
274
+ // Expose for testing
275
+ getRequestCount(key: string): number | undefined {
276
+ return this.requests.get(key)?.count;
277
+ }
278
+ }
279
+
280
+ // ============================================================================
281
+ // Task Validation Helpers
282
+ // ============================================================================
283
+
284
+ /**
285
+ * Check if a task can be validated by the given session
286
+ * @param task The task to validate
287
+ * @param validatorSessionId The session trying to validate
288
+ * @returns Object with canValidate boolean and reason if not allowed
289
+ */
290
+ export function canValidateTask(
291
+ task: { status: string; validated_at: string | null; working_agent_session_id: string | null },
292
+ validatorSessionId: string | null
293
+ ): { canValidate: boolean; reason?: string } {
294
+ if (task.status !== 'completed') {
295
+ return { canValidate: false, reason: 'Can only validate completed tasks' };
296
+ }
297
+
298
+ if (task.validated_at) {
299
+ return { canValidate: false, reason: 'Task has already been validated' };
300
+ }
301
+
302
+ // Note: Self-validation check would go here if we tracked who completed the task
303
+ // Currently, working_agent_session_id is cleared on completion
304
+
305
+ return { canValidate: true };
306
+ }
307
+
308
+ /**
309
+ * Check if a task status transition is valid
310
+ * @param currentStatus Current task status
311
+ * @param newStatus Desired new status
312
+ * @returns Object with isValid boolean and reason if not valid
313
+ */
314
+ export function isValidStatusTransition(
315
+ currentStatus: string,
316
+ newStatus: string
317
+ ): { isValid: boolean; reason?: string } {
318
+ const validTransitions: Record<string, string[]> = {
319
+ pending: ['in_progress', 'cancelled'],
320
+ in_progress: ['completed', 'pending', 'cancelled'],
321
+ completed: ['in_progress'], // Can reopen via validation failure
322
+ cancelled: ['pending'], // Can reactivate cancelled tasks
323
+ };
324
+
325
+ const allowed = validTransitions[currentStatus];
326
+ if (!allowed) {
327
+ return { isValid: false, reason: `Unknown current status: ${currentStatus}` };
328
+ }
329
+
330
+ if (!allowed.includes(newStatus)) {
331
+ return {
332
+ isValid: false,
333
+ reason: `Cannot transition from '${currentStatus}' to '${newStatus}'. Valid transitions: ${allowed.join(', ')}`
334
+ };
335
+ }
336
+
337
+ return { isValid: true };
338
+ }
339
+
340
+ /**
341
+ * Check if a deployment status transition is valid
342
+ * @param currentStatus Current deployment status
343
+ * @param newStatus Desired new status
344
+ * @returns Object with isValid boolean and reason if not valid
345
+ */
346
+ export function isValidDeploymentStatusTransition(
347
+ currentStatus: string,
348
+ newStatus: string
349
+ ): { isValid: boolean; reason?: string } {
350
+ const validTransitions: Record<string, string[]> = {
351
+ pending: ['validating', 'failed'], // Can claim validation or cancel
352
+ validating: ['ready', 'failed'], // Validation passes or fails
353
+ ready: ['deploying', 'failed'], // Start deployment or cancel
354
+ deploying: ['deployed', 'failed'], // Deployment succeeds or fails
355
+ deployed: [], // Terminal state
356
+ failed: [], // Terminal state (create new deployment to retry)
357
+ };
358
+
359
+ const allowed = validTransitions[currentStatus];
360
+ if (!allowed) {
361
+ return { isValid: false, reason: `Unknown current status: ${currentStatus}` };
362
+ }
363
+
364
+ if (!allowed.includes(newStatus)) {
365
+ return {
366
+ isValid: false,
367
+ reason: `Cannot transition deployment from '${currentStatus}' to '${newStatus}'. Valid transitions: ${allowed.length ? allowed.join(', ') : 'none (terminal state)'}`
368
+ };
369
+ }
370
+
371
+ return { isValid: true };
372
+ }
373
+
374
+ // ============================================================================
375
+ // Git URL Parsing
376
+ // ============================================================================
377
+
378
+ /**
379
+ * Extract project name from a git URL.
380
+ * Handles various git URL formats:
381
+ * - https://github.com/user/repo -> repo
382
+ * - https://github.com/user/repo.git -> repo
383
+ * - git@github.com:user/repo.git -> repo
384
+ * - https://gitlab.com/user/repo -> repo
385
+ * - ssh://git@github.com/user/repo.git -> repo
386
+ *
387
+ * @param gitUrl The git URL to parse
388
+ * @returns The extracted project name, or 'my-project' if parsing fails
389
+ */
390
+ export function extractProjectNameFromGitUrl(gitUrl: string): string {
391
+ if (!gitUrl) {
392
+ return 'my-project';
393
+ }
394
+
395
+ // Match the last path segment, optionally removing .git suffix
396
+ // Works for:
397
+ // - /user/repo (https URLs)
398
+ // - /user/repo.git
399
+ // - :user/repo (ssh URLs like git@github.com:user/repo)
400
+ // - :user/repo.git
401
+ const match = gitUrl.match(/[/:]([^/:]+?)(?:\.git)?$/);
402
+ if (match && match[1]) {
403
+ return match[1];
404
+ }
405
+
406
+ return 'my-project';
407
+ }
408
+
409
+ /**
410
+ * Normalize a git URL to a canonical format for consistent project lookup.
411
+ *
412
+ * Handles these variations:
413
+ * - SSH format: `git@github.com:owner/repo` → `https://github.com/owner/repo`
414
+ * - HTTPS with .git suffix: `https://github.com/owner/repo.git` → `https://github.com/owner/repo`
415
+ * - Trailing slashes: `https://github.com/owner/repo/` → `https://github.com/owner/repo`
416
+ * - Case variations: `https://GitHub.com/OWNER/Repo` → `https://github.com/owner/repo`
417
+ * - HTTP protocol: `http://github.com/owner/repo` → `https://github.com/owner/repo`
418
+ * - Bare domains: `github.com/owner/repo` → `https://github.com/owner/repo`
419
+ *
420
+ * @param gitUrl The git URL to normalize (can be null/undefined)
421
+ * @returns Normalized URL in lowercase, or null if input is null/undefined/empty
422
+ *
423
+ * @example
424
+ * normalizeGitUrl('git@github.com:Nonatomic/Vibescope.git')
425
+ * // Returns: 'https://github.com/nonatomic/vibescope'
426
+ *
427
+ * normalizeGitUrl('https://github.com/Nonatomic/Vibescope/')
428
+ * // Returns: 'https://github.com/nonatomic/vibescope'
429
+ */
430
+ export function normalizeGitUrl(gitUrl: string | null | undefined): string | null {
431
+ if (!gitUrl || typeof gitUrl !== 'string') {
432
+ return null;
433
+ }
434
+
435
+ let normalized = gitUrl.trim();
436
+
437
+ // Empty after trim
438
+ if (!normalized) {
439
+ return null;
440
+ }
441
+
442
+ // Convert SSH format: git@github.com:owner/repo → https://github.com/owner/repo
443
+ const sshMatch = normalized.match(/^git@([^:]+):(.+)$/);
444
+ if (sshMatch) {
445
+ normalized = `https://${sshMatch[1]}/${sshMatch[2]}`;
446
+ }
447
+
448
+ // Upgrade http to https
449
+ if (normalized.startsWith('http://')) {
450
+ normalized = normalized.replace('http://', 'https://');
451
+ }
452
+
453
+ // Ensure https protocol for URLs that don't have one
454
+ // but look like they should (e.g., "github.com/owner/repo")
455
+ if (!normalized.startsWith('https://') && !normalized.includes('://')) {
456
+ normalized = `https://${normalized}`;
457
+ }
458
+
459
+ // Remove trailing slashes first (so .git suffix removal works for urls like "repo.git/")
460
+ normalized = normalized.replace(/\/+$/, '');
461
+
462
+ // Remove .git suffix
463
+ normalized = normalized.replace(/\.git$/, '');
464
+
465
+ // Lowercase everything (GitHub/GitLab are case-insensitive for URLs)
466
+ normalized = normalized.toLowerCase();
467
+
468
+ return normalized;
469
+ }
470
+
471
+ // ============================================================================
472
+ // Error Handling Utilities
473
+ // Shared error extraction and type checking functions
474
+ // ============================================================================
475
+
476
+ /**
477
+ * Extract a human-readable message from any error type.
478
+ * Handles Error objects, strings, and unknown values.
479
+ *
480
+ * @example
481
+ * try { ... } catch (e) {
482
+ * console.error(getErrorMessage(e));
483
+ * }
484
+ */
485
+ export function getErrorMessage(error: unknown): string {
486
+ if (error instanceof Error) {
487
+ return error.message;
488
+ }
489
+ if (typeof error === 'string') {
490
+ return error;
491
+ }
492
+ return String(error);
493
+ }
494
+
495
+ /**
496
+ * Type guard to check if a value is an object with an 'error' property.
497
+ * Useful for checking API responses that may contain application-level errors.
498
+ *
499
+ * @example
500
+ * if (hasErrorProperty(response.data)) {
501
+ * return error(getErrorMessage(response.data.error));
502
+ * }
503
+ */
504
+ export function hasErrorProperty(value: unknown): value is { error: unknown } {
505
+ return typeof value === 'object' && value !== null && 'error' in value;
506
+ }
507
+
508
+ /**
509
+ * Extract an error message from an API response body.
510
+ * Handles the common pattern where API returns { error: string } or { error: { message: string } }.
511
+ * Returns null if no error is found.
512
+ *
513
+ * @example
514
+ * const apiError = extractApiError(response.data);
515
+ * if (apiError) {
516
+ * return error(apiError);
517
+ * }
518
+ */
519
+ export function extractApiError(data: unknown): string | null {
520
+ if (!hasErrorProperty(data)) {
521
+ return null;
522
+ }
523
+
524
+ const err = data.error;
525
+
526
+ // Direct string error
527
+ if (typeof err === 'string') {
528
+ return err;
529
+ }
530
+
531
+ // Nested error object with message property
532
+ if (typeof err === 'object' && err !== null && 'message' in err) {
533
+ const msg = (err as { message: unknown }).message;
534
+ if (typeof msg === 'string') {
535
+ return msg;
536
+ }
537
+ }
538
+
539
+ // Fallback for unknown error structure
540
+ return 'Unknown error';
541
+ }
542
+
543
+ // ============================================================================
544
+ // Pagination Utilities
545
+ // ============================================================================
546
+
547
+ /**
548
+ * Default pagination limits
549
+ */
550
+ export const PAGINATION_LIMITS = {
551
+ /** Default maximum limit for paginated results */
552
+ DEFAULT_MAX_LIMIT: 50,
553
+ /** Default limit when not specified */
554
+ DEFAULT_LIMIT: 20,
555
+ /** Smaller limit for task listings */
556
+ TASK_LIMIT: 20,
557
+ } as const;
558
+
559
+ /**
560
+ * Cap pagination parameters to safe values.
561
+ * Ensures limit is between 1 and maxLimit, and offset is non-negative.
562
+ *
563
+ * @param limit - Requested limit (will be capped between 1 and maxLimit)
564
+ * @param offset - Requested offset (will be made non-negative)
565
+ * @param maxLimit - Maximum allowed limit (default: PAGINATION_LIMITS.DEFAULT_MAX_LIMIT)
566
+ * @returns Object with cappedLimit and safeOffset
567
+ *
568
+ * @example
569
+ * const { cappedLimit, safeOffset } = capPagination(limit, offset);
570
+ * const { data } = await query.range(safeOffset, safeOffset + cappedLimit - 1);
571
+ *
572
+ * @example
573
+ * // With custom max limit
574
+ * const { cappedLimit, safeOffset } = capPagination(limit, offset, 20);
575
+ */
576
+ export function capPagination(
577
+ limit: number | undefined,
578
+ offset: number | undefined,
579
+ maxLimit: number = PAGINATION_LIMITS.DEFAULT_MAX_LIMIT
580
+ ): { cappedLimit: number; safeOffset: number } {
581
+ const effectiveLimit = limit ?? PAGINATION_LIMITS.DEFAULT_LIMIT;
582
+ return {
583
+ cappedLimit: Math.min(Math.max(1, effectiveLimit), maxLimit),
584
+ safeOffset: Math.max(0, offset ?? 0)
585
+ };
586
+ }