popilot 0.6.0 → 0.7.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 (112) hide show
  1. package/bin/cli.mjs +204 -2
  2. package/lib/doctor.mjs +38 -1
  3. package/lib/hydrate.mjs +15 -0
  4. package/lib/scaffold.mjs +5 -0
  5. package/lib/setup-wizard.mjs +35 -2
  6. package/package.json +1 -1
  7. package/scaffold/.context/project.yaml.example +19 -0
  8. package/scaffold/mcp-pm/package.json +19 -0
  9. package/scaffold/mcp-pm/src/api-client.ts +69 -0
  10. package/scaffold/mcp-pm/src/index.ts +660 -0
  11. package/scaffold/mcp-pm/tsconfig.json +14 -0
  12. package/scaffold/pm-api/package.json +21 -0
  13. package/scaffold/pm-api/sql/schema-core.sql +331 -0
  14. package/scaffold/pm-api/sql/schema-docs.sql +25 -0
  15. package/scaffold/pm-api/sql/schema-meetings.sql +17 -0
  16. package/scaffold/pm-api/sql/schema-rewards.sql +16 -0
  17. package/scaffold/pm-api/src/auth.ts +28 -0
  18. package/scaffold/pm-api/src/blockchain/adapter.ts +20 -0
  19. package/scaffold/pm-api/src/blockchain/tron.ts +62 -0
  20. package/scaffold/pm-api/src/db/adapter.ts +36 -0
  21. package/scaffold/pm-api/src/db/turso.ts +147 -0
  22. package/scaffold/pm-api/src/index.ts +114 -0
  23. package/scaffold/pm-api/src/mcp-tools/dashboard.ts +40 -0
  24. package/scaffold/pm-api/src/mcp-tools/epic.ts +67 -0
  25. package/scaffold/pm-api/src/mcp-tools/event.ts +89 -0
  26. package/scaffold/pm-api/src/mcp-tools/index.ts +11 -0
  27. package/scaffold/pm-api/src/mcp-tools/initiative.ts +51 -0
  28. package/scaffold/pm-api/src/mcp-tools/memo.ts +164 -0
  29. package/scaffold/pm-api/src/mcp-tools/notification.ts +37 -0
  30. package/scaffold/pm-api/src/mcp-tools/retro.ts +183 -0
  31. package/scaffold/pm-api/src/mcp-tools/sprint.ts +204 -0
  32. package/scaffold/pm-api/src/mcp-tools/standup.ts +136 -0
  33. package/scaffold/pm-api/src/mcp-tools/story.ts +230 -0
  34. package/scaffold/pm-api/src/mcp-tools/task.ts +187 -0
  35. package/scaffold/pm-api/src/mcp-tools/utils.ts +83 -0
  36. package/scaffold/pm-api/src/mcp.ts +871 -0
  37. package/scaffold/pm-api/src/nudge.ts +283 -0
  38. package/scaffold/pm-api/src/routes/auth.ts +32 -0
  39. package/scaffold/pm-api/src/routes/v2-activity.ts +27 -0
  40. package/scaffold/pm-api/src/routes/v2-admin.ts +165 -0
  41. package/scaffold/pm-api/src/routes/v2-dashboard.ts +189 -0
  42. package/scaffold/pm-api/src/routes/v2-docs.ts +34 -0
  43. package/scaffold/pm-api/src/routes/v2-initiatives.ts +118 -0
  44. package/scaffold/pm-api/src/routes/v2-kickoff.ts +265 -0
  45. package/scaffold/pm-api/src/routes/v2-meetings.ts +324 -0
  46. package/scaffold/pm-api/src/routes/v2-memos.ts +257 -0
  47. package/scaffold/pm-api/src/routes/v2-nav.ts +260 -0
  48. package/scaffold/pm-api/src/routes/v2-notifications.ts +79 -0
  49. package/scaffold/pm-api/src/routes/v2-page-content.ts +35 -0
  50. package/scaffold/pm-api/src/routes/v2-pm.ts +380 -0
  51. package/scaffold/pm-api/src/routes/v2-policy.ts +58 -0
  52. package/scaffold/pm-api/src/routes/v2-retro.ts +221 -0
  53. package/scaffold/pm-api/src/routes/v2-rewards.ts +132 -0
  54. package/scaffold/pm-api/src/routes/v2-scenarios.ts +48 -0
  55. package/scaffold/pm-api/src/routes/v2-search.ts +32 -0
  56. package/scaffold/pm-api/src/routes/v2-standup.ts +127 -0
  57. package/scaffold/pm-api/src/routes/v2-user.ts +38 -0
  58. package/scaffold/pm-api/src/types.ts +11 -0
  59. package/scaffold/pm-api/src/utils/activity.ts +22 -0
  60. package/scaffold/pm-api/src/utils/admin.ts +9 -0
  61. package/scaffold/pm-api/src/utils/agent-notify.ts +62 -0
  62. package/scaffold/pm-api/src/utils/assignee.ts +69 -0
  63. package/scaffold/pm-api/src/utils/db.ts +45 -0
  64. package/scaffold/pm-api/src/utils/initiative.ts +23 -0
  65. package/scaffold/pm-api/src/utils/sprint-lifecycle.ts +96 -0
  66. package/scaffold/pm-api/tsconfig.json +15 -0
  67. package/scaffold/pm-api/wrangler.toml.hbs +11 -0
  68. package/scaffold/spec-site/package-lock.json +40 -0
  69. package/scaffold/spec-site/package.json +4 -1
  70. package/scaffold/spec-site/src/api/types.ts +6 -0
  71. package/scaffold/spec-site/src/components/AppHeader.vue +429 -55
  72. package/scaffold/spec-site/src/components/MemberSelect.vue +48 -0
  73. package/scaffold/spec-site/src/components/NotificationDropdown.vue +116 -0
  74. package/scaffold/spec-site/src/components/SearchModal.vue +102 -0
  75. package/scaffold/spec-site/src/components/VelocityChart.vue +77 -0
  76. package/scaffold/spec-site/src/composables/pmTypes.ts +15 -2
  77. package/scaffold/spec-site/src/composables/useDashboard.ts +221 -0
  78. package/scaffold/spec-site/src/composables/useMediaQuery.ts +28 -0
  79. package/scaffold/spec-site/src/composables/useNotification.ts +200 -0
  80. package/scaffold/spec-site/src/composables/usePmStore.ts +48 -1
  81. package/scaffold/spec-site/src/composables/useRetro.ts +6 -0
  82. package/scaffold/spec-site/src/composables/useStandup.ts +201 -0
  83. package/scaffold/spec-site/src/composables/useTheme.ts +37 -0
  84. package/scaffold/spec-site/src/composables/useUser.ts +19 -1
  85. package/scaffold/spec-site/src/features.ts +108 -0
  86. package/scaffold/spec-site/src/pages/AdminPage.vue +299 -0
  87. package/scaffold/spec-site/src/pages/DashboardPage.vue +650 -0
  88. package/scaffold/spec-site/src/pages/DocsHub.vue +157 -0
  89. package/scaffold/spec-site/src/pages/InboxPage.vue +156 -0
  90. package/scaffold/spec-site/src/pages/MeetingsPage.vue +294 -0
  91. package/scaffold/spec-site/src/pages/MyPage.vue +343 -0
  92. package/scaffold/spec-site/src/pages/RewardsPage.vue +266 -0
  93. package/scaffold/spec-site/src/pages/board/BoardAdmin.vue +422 -0
  94. package/scaffold/spec-site/src/pages/board/BoardEpicSection.vue +54 -0
  95. package/scaffold/spec-site/src/pages/board/BoardPage.vue +884 -0
  96. package/scaffold/spec-site/src/pages/board/BoardStoryCard.vue +67 -0
  97. package/scaffold/spec-site/src/pages/board/BoardTaskItem.vue +52 -0
  98. package/scaffold/spec-site/src/pages/board/MyTasksPage.vue +202 -0
  99. package/scaffold/spec-site/src/pages/board/SprintClose.vue +167 -0
  100. package/scaffold/spec-site/src/pages/board/SprintColumn.vue +49 -0
  101. package/scaffold/spec-site/src/pages/board/SprintKickoff.vue +389 -0
  102. package/scaffold/spec-site/src/pages/board/StatusBadge.vue +52 -0
  103. package/scaffold/spec-site/src/pages/board/StoryDetailPanel.vue +495 -0
  104. package/scaffold/spec-site/src/pages/board/TaskCard.vue +42 -0
  105. package/scaffold/spec-site/src/pages/retro/RetroCard.vue +36 -2
  106. package/scaffold/spec-site/src/pages/retro/RetroHeader.vue +82 -66
  107. package/scaffold/spec-site/src/pages/retro/RetroPage.vue +47 -18
  108. package/scaffold/spec-site/src/pages/standup/StandupEntryCard.vue +551 -0
  109. package/scaffold/spec-site/src/pages/standup/StandupForm.vue +68 -0
  110. package/scaffold/spec-site/src/pages/standup/StandupList.vue +71 -0
  111. package/scaffold/spec-site/src/pages/standup/StandupPage.vue +225 -0
  112. package/scaffold/spec-site/src/router.ts +141 -0
@@ -0,0 +1,660 @@
1
+ // Unified MCP PM Server
2
+ // Combines project management tools + notification tools into a single MCP server.
3
+ // All operations go through the PM API via HTTP (no direct DB access).
4
+
5
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
6
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
7
+ import { z } from 'zod'
8
+ import { apiGet, apiPost, apiPatch, apiPut } from './api-client.js'
9
+
10
+ let activeSprint = ''
11
+ const PM_API_URL = process.env.PM_API_URL ?? ''
12
+
13
+ async function fetchActiveSprint(): Promise<void> {
14
+ try {
15
+ const resp = await fetch(`${PM_API_URL}/api/sprint/active`, { signal: AbortSignal.timeout(5000) })
16
+ const data = await resp.json() as { sprint?: string }
17
+ if (data.sprint) activeSprint = data.sprint
18
+ } catch { /* will fail gracefully on tool calls */ }
19
+ }
20
+
21
+ const server = new McpServer({
22
+ name: 'mcp-pm',
23
+ version: '2.0.0',
24
+ })
25
+
26
+ // ── Helpers ──
27
+
28
+ function text(t: string) {
29
+ return { content: [{ type: 'text' as const, text: t }] }
30
+ }
31
+
32
+ function err(msg: string) {
33
+ return text(`Error: ${msg}`)
34
+ }
35
+
36
+ function resolveSprint(arg?: string): string | null {
37
+ return arg || activeSprint || null
38
+ }
39
+
40
+ function progressBar(done: number, total: number, width = 10): string {
41
+ if (total === 0) return '\u2591'.repeat(width)
42
+ const filled = Math.round((done / total) * width)
43
+ return '\u2588'.repeat(filled) + '\u2591'.repeat(width - filled)
44
+ }
45
+
46
+ // ── Types (API responses) ──
47
+
48
+ interface DashboardData {
49
+ user: string
50
+ sprint: string
51
+ tasks: Record<string, number>
52
+ unread_memos: number
53
+ has_standup: boolean
54
+ }
55
+
56
+ interface TaskRow {
57
+ task_id: number; task_title: string; task_status: string
58
+ story_id: number; story_title: string
59
+ epic_id: number | null; epic_title: string | null
60
+ }
61
+
62
+ interface TaskDetail {
63
+ task: {
64
+ id: number; story_id: number; title: string; assignee: string | null
65
+ status: string; description: string | null; sort_order: number
66
+ created_at: string; updated_at: string
67
+ }
68
+ story: {
69
+ id: number; title: string; description: string | null
70
+ acceptance_criteria: string | null; assignee: string | null
71
+ status: string; sprint: string
72
+ } | null
73
+ siblings: Array<{ id: number; title: string; status: string; assignee: string | null }>
74
+ }
75
+
76
+ interface StandupData {
77
+ user: string; sprint: string; date: string
78
+ entry: {
79
+ id: number; done_text: string | null; plan_text: string | null
80
+ plan_story_ids: string | null; blockers: string | null
81
+ created_at: string; updated_at: string
82
+ } | null
83
+ stories: Array<{ id: number; title: string }>
84
+ }
85
+
86
+ interface MemoRow {
87
+ id: number; from_user: string; to_user: string; content: string
88
+ story_id: number | null; is_read: number; reply: string | null
89
+ replied_by: string | null; replied_at: string | null; created_at: string
90
+ }
91
+
92
+ interface SprintData {
93
+ sprint: string
94
+ epics: Array<{ epic_title: string; total: number; done: number }>
95
+ assignees: Array<{ assignee: string; total: number; done: number; in_progress: number; todo: number }>
96
+ blockers: Array<{ user_name: string; blockers: string; entry_date: string }>
97
+ }
98
+
99
+ interface NotificationRow {
100
+ id: number; type: string; title: string; body: string | null
101
+ source_type: string; source_id: string; page_id: string
102
+ actor: string; is_read: number; created_at: string
103
+ }
104
+
105
+ interface MemoV2Row {
106
+ id: number; page_id: string; content: string; memo_type: string
107
+ created_by: string; assigned_to: string | null; created_at: string
108
+ }
109
+
110
+ // ══════════════════════════════════════════════════
111
+ // PM TOOLS
112
+ // ══════════════════════════════════════════════════
113
+
114
+ // ── Tool 1: my_dashboard ──
115
+
116
+ server.tool(
117
+ 'my_dashboard',
118
+ 'My dashboard — task status, unread memos, today\'s standup status',
119
+ {},
120
+ async () => {
121
+ const sprint = resolveSprint()
122
+ if (!sprint) return err('No active sprint found. Set PM_SPRINT env var.')
123
+
124
+ const { data, error } = await apiGet<DashboardData>('/api/dashboard', { sprint })
125
+ if (error || !data) return err(error ?? 'Unknown error')
126
+
127
+ const s = data.tasks
128
+ const lines = [
129
+ `Dashboard for ${data.user} (${sprint.toUpperCase()})`,
130
+ '─────────────',
131
+ `Tasks: todo ${s.todo ?? 0} | in-progress ${s['in-progress'] ?? 0} | done ${s.done ?? 0}`,
132
+ `Unread memos: ${data.unread_memos}`,
133
+ `Today's standup: ${data.has_standup ? 'Submitted' : 'Not submitted'}`,
134
+ ]
135
+
136
+ return text(lines.join('\n'))
137
+ },
138
+ )
139
+
140
+ // ── Tool 2: list_my_tasks ──
141
+
142
+ server.tool(
143
+ 'list_my_tasks',
144
+ 'My task list (Epic > Story > Task tree)',
145
+ {
146
+ status: z.enum(['todo', 'in-progress', 'done']).optional().describe('Filter by status'),
147
+ sprint: z.string().optional().describe('Sprint ID (default: active sprint)'),
148
+ },
149
+ async ({ status, sprint: sprintArg }) => {
150
+ const sprint = resolveSprint(sprintArg)
151
+ if (!sprint) return err('Please specify a sprint.')
152
+
153
+ const params: Record<string, string> = { sprint }
154
+ if (status) params.status = status
155
+
156
+ const { data, error } = await apiGet<{ user: string; sprint: string; tasks: TaskRow[] }>('/api/tasks', params)
157
+ if (error || !data) return err(error ?? 'Unknown error')
158
+ if (data.tasks.length === 0) return text(`No tasks found for ${data.user}.`)
159
+
160
+ const statusIcon: Record<string, string> = { todo: '[ ]', 'in-progress': '[~]', done: '[x]' }
161
+
162
+ const tree = new Map<string, Map<string, Array<{ id: number; title: string; status: string }>>>()
163
+ for (const r of data.tasks) {
164
+ const epicKey = r.epic_title ?? '(No epic)'
165
+ if (!tree.has(epicKey)) tree.set(epicKey, new Map())
166
+ const epicMap = tree.get(epicKey)!
167
+ const storyKey = `[S${r.story_id}] ${r.story_title}`
168
+ if (!epicMap.has(storyKey)) epicMap.set(storyKey, [])
169
+ epicMap.get(storyKey)!.push({ id: r.task_id, title: r.task_title, status: r.task_status })
170
+ }
171
+
172
+ const lines: string[] = [`Tasks for ${data.user} (${sprint.toUpperCase()})`, '─────────────']
173
+ for (const [epicTitle, stories] of tree) {
174
+ lines.push(`\n# ${epicTitle}`)
175
+ for (const [storyTitle, tasks] of stories) {
176
+ lines.push(` ${storyTitle}`)
177
+ for (const t of tasks) {
178
+ lines.push(` ${statusIcon[t.status] ?? '[ ]'} [T${t.id}] ${t.title}`)
179
+ }
180
+ }
181
+ }
182
+
183
+ return text(lines.join('\n'))
184
+ },
185
+ )
186
+
187
+ // ── Tool 3: get_task ──
188
+
189
+ server.tool(
190
+ 'get_task',
191
+ 'Task detail + parent story context + sibling tasks',
192
+ {
193
+ task_id: z.number().describe('Task ID'),
194
+ },
195
+ async ({ task_id }) => {
196
+ const { data, error } = await apiGet<TaskDetail>(`/api/tasks/${task_id}`)
197
+ if (error || !data) return err(error ?? 'Unknown error')
198
+
199
+ const t = data.task
200
+ const s = data.story
201
+ const statusIcon: Record<string, string> = { todo: '[ ]', 'in-progress': '[~]', done: '[x]' }
202
+
203
+ const lines = [
204
+ `Task #${t.id}: ${t.title}`,
205
+ '─────────────',
206
+ `Status: ${statusIcon[t.status] ?? '[ ]'} ${t.status}`,
207
+ `Assignee: ${t.assignee ?? 'Unassigned'}`,
208
+ t.description ? `Description: ${t.description}` : '',
209
+ `Created: ${t.created_at} | Updated: ${t.updated_at}`,
210
+ ].filter(Boolean)
211
+
212
+ if (s) {
213
+ lines.push(
214
+ '',
215
+ `Parent Story [S${s.id}]: ${s.title}`,
216
+ ` Sprint: ${s.sprint} | Status: ${s.status} | Assignee: ${s.assignee ?? 'Unassigned'}`,
217
+ )
218
+ if (s.description) lines.push(` Description: ${s.description}`)
219
+ if (s.acceptance_criteria) lines.push(` AC: ${s.acceptance_criteria}`)
220
+ }
221
+
222
+ if (data.siblings.length > 0) {
223
+ lines.push('', 'Sibling Tasks:')
224
+ for (const sb of data.siblings) {
225
+ const marker = sb.id === task_id ? ' <-- current' : ''
226
+ lines.push(` ${statusIcon[sb.status] ?? '[ ]'} [T${sb.id}] ${sb.title} (${sb.assignee ?? 'Unassigned'})${marker}`)
227
+ }
228
+ }
229
+
230
+ return text(lines.join('\n'))
231
+ },
232
+ )
233
+
234
+ // ── Tool 4: update_task_status ──
235
+
236
+ server.tool(
237
+ 'update_task_status',
238
+ 'Update task status',
239
+ {
240
+ task_id: z.number().describe('Task ID'),
241
+ status: z.enum(['todo', 'in-progress', 'done']).describe('New status'),
242
+ },
243
+ async ({ task_id, status }) => {
244
+ const { error } = await apiPatch(`/api/tasks/${task_id}/status`, { status })
245
+ if (error) return err(error)
246
+ return text(`Task #${task_id} updated to ${status}`)
247
+ },
248
+ )
249
+
250
+ // ── Tool 5: add_task ──
251
+
252
+ server.tool(
253
+ 'add_task',
254
+ 'Add a new task to a story',
255
+ {
256
+ story_id: z.number().describe('Story ID'),
257
+ title: z.string().describe('Task title'),
258
+ assignee: z.string().optional().describe('Assignee (default: token user)'),
259
+ description: z.string().optional().describe('Task description'),
260
+ },
261
+ async ({ story_id, title, assignee, description }) => {
262
+ const { data, error } = await apiPost<{ ok: boolean; story_id: number; title: string }>('/api/tasks', {
263
+ story_id, title, assignee, description,
264
+ })
265
+ if (error || !data) return err(error ?? 'Unknown error')
266
+ return text(`Task added to story #${story_id}: ${title}`)
267
+ },
268
+ )
269
+
270
+ // ── Tool 6: list_my_stories ──
271
+
272
+ server.tool(
273
+ 'list_my_stories',
274
+ 'My story list — find story IDs to reference in standups',
275
+ {
276
+ sprint: z.string().optional().describe('Sprint ID (default: active sprint)'),
277
+ },
278
+ async ({ sprint: sprintArg }) => {
279
+ const sprint = resolveSprint(sprintArg)
280
+ if (!sprint) return err('Please specify a sprint.')
281
+
282
+ const { data, error } = await apiGet<{
283
+ user: string; sprint: string
284
+ stories: Array<{ id: number; title: string; status: string; epic_title: string | null }>
285
+ }>('/api/stories', { sprint })
286
+ if (error || !data) return err(error ?? 'Unknown error')
287
+ if (data.stories.length === 0) return text(`No stories found for ${data.user}.`)
288
+
289
+ const statusIcon: Record<string, string> = { todo: '[ ]', 'in-progress': '[~]', done: '[x]' }
290
+
291
+ const lines = [`Stories for ${data.user} (${sprint.toUpperCase()})`, '─────────────']
292
+ let lastEpic = ''
293
+ for (const s of data.stories) {
294
+ const epic = s.epic_title ?? '(No epic)'
295
+ if (epic !== lastEpic) {
296
+ lines.push(`\n# ${epic}`)
297
+ lastEpic = epic
298
+ }
299
+ lines.push(` ${statusIcon[s.status] ?? '[ ]'} [S${s.id}] ${s.title}`)
300
+ }
301
+ lines.push('', 'Tip: Pass story IDs as an array to save_standup\'s plan_story_ids parameter.')
302
+
303
+ return text(lines.join('\n'))
304
+ },
305
+ )
306
+
307
+ // ── Tool 7: get_standup ──
308
+
309
+ server.tool(
310
+ 'get_standup',
311
+ 'View standup entry',
312
+ {
313
+ date: z.string().optional().describe('Date (YYYY-MM-DD, default: today)'),
314
+ sprint: z.string().optional().describe('Sprint ID (default: active sprint)'),
315
+ },
316
+ async ({ date, sprint: sprintArg }) => {
317
+ const sprint = resolveSprint(sprintArg)
318
+ if (!sprint) return err('Please specify a sprint.')
319
+
320
+ const params: Record<string, string> = { sprint }
321
+ if (date) params.date = date
322
+
323
+ const { data, error } = await apiGet<StandupData>('/api/standup', params)
324
+ if (error || !data) return err(error ?? 'Unknown error')
325
+ if (!data.entry) return text(`No standup entry for ${data.date}.`)
326
+
327
+ const e = data.entry
328
+ const lines = [
329
+ `Standup for ${data.user} (${data.date})`,
330
+ '─────────────',
331
+ `Done: ${e.done_text ?? '(none)'}`,
332
+ `Plan: ${e.plan_text ?? '(none)'}`,
333
+ ]
334
+ if (data.stories.length > 0) {
335
+ const storyLines = data.stories.map(s => ` - [S${s.id}] ${s.title}`).join('\n')
336
+ lines.push(`Planned stories:\n${storyLines}`)
337
+ }
338
+ if (e.blockers) lines.push(`Blockers: ${e.blockers}`)
339
+
340
+ return text(lines.join('\n'))
341
+ },
342
+ )
343
+
344
+ // ── Tool 8: save_standup ──
345
+
346
+ server.tool(
347
+ 'save_standup',
348
+ 'Save standup entry (upsert)',
349
+ {
350
+ done_text: z.string().optional().describe('Work completed'),
351
+ plan_text: z.string().optional().describe('Today\'s plan'),
352
+ plan_story_ids: z.array(z.number()).optional().describe('Planned story IDs array'),
353
+ blockers: z.string().optional().describe('Blockers'),
354
+ date: z.string().optional().describe('Date (YYYY-MM-DD, default: today)'),
355
+ sprint: z.string().optional().describe('Sprint ID (default: active sprint)'),
356
+ },
357
+ async ({ done_text, plan_text, plan_story_ids, blockers, date, sprint: sprintArg }) => {
358
+ const sprint = resolveSprint(sprintArg)
359
+ if (!sprint) return err('Please specify a sprint.')
360
+
361
+ const { data, error } = await apiPut<{ ok: boolean; date: string }>('/api/standup', {
362
+ sprint, done_text, plan_text, plan_story_ids, blockers, date,
363
+ })
364
+ if (error || !data) return err(error ?? 'Unknown error')
365
+ return text(`Standup saved for ${data.date}`)
366
+ },
367
+ )
368
+
369
+ // ── Tool 9: send_memo ──
370
+
371
+ server.tool(
372
+ 'send_memo',
373
+ 'Send a memo to a team member',
374
+ {
375
+ to_user: z.string().describe('Recipient name'),
376
+ content: z.string().describe('Memo content'),
377
+ story_id: z.number().optional().describe('Related story ID'),
378
+ sprint: z.string().optional().describe('Sprint ID (default: active sprint)'),
379
+ },
380
+ async ({ to_user, content, story_id, sprint: sprintArg }) => {
381
+ const sprint = resolveSprint(sprintArg)
382
+ if (!sprint) return err('Please specify a sprint.')
383
+
384
+ const { data, error } = await apiPost<{ ok: boolean; from_user: string; to_user: string }>('/api/memos', {
385
+ to_user, content, story_id, sprint,
386
+ })
387
+ if (error || !data) return err(error ?? 'Unknown error')
388
+ return text(`Memo sent: ${data.from_user} -> ${data.to_user}`)
389
+ },
390
+ )
391
+
392
+ // ── Tool 10: list_my_memos ──
393
+
394
+ server.tool(
395
+ 'list_my_memos',
396
+ 'List memos sent to me',
397
+ {
398
+ unread_only: z.boolean().optional().describe('Unread only (default: false)'),
399
+ sprint: z.string().optional().describe('Sprint ID (default: active sprint)'),
400
+ },
401
+ async ({ unread_only, sprint: sprintArg }) => {
402
+ const sprint = resolveSprint(sprintArg)
403
+ if (!sprint) return err('Please specify a sprint.')
404
+
405
+ const params: Record<string, string> = { sprint }
406
+ if (unread_only) params.unread_only = 'true'
407
+
408
+ const { data, error } = await apiGet<{ user: string; sprint: string; memos: MemoRow[] }>('/api/memos', params)
409
+ if (error || !data) return err(error ?? 'Unknown error')
410
+ if (data.memos.length === 0) return text('No memos found.')
411
+
412
+ const lines = [`Memos for ${data.user} (${sprint.toUpperCase()})`, '─────────────']
413
+ for (const m of data.memos) {
414
+ const readIcon = m.is_read ? '(read)' : '(new)'
415
+ const storyTag = m.story_id ? ` [S${m.story_id}]` : ''
416
+ const preview = m.content.length > 60 ? m.content.slice(0, 60) + '...' : m.content
417
+ lines.push(`${readIcon} [M${m.id}] ${m.from_user}${storyTag}: ${preview}`)
418
+ if (m.reply) {
419
+ const replyPreview = m.reply.length > 50 ? m.reply.slice(0, 50) + '...' : m.reply
420
+ lines.push(` -> ${m.replied_by}: ${replyPreview}`)
421
+ }
422
+ }
423
+
424
+ return text(lines.join('\n'))
425
+ },
426
+ )
427
+
428
+ // ── Tool 11: read_memo ──
429
+
430
+ server.tool(
431
+ 'read_memo',
432
+ 'Read memo detail and mark as read',
433
+ {
434
+ memo_id: z.number().describe('Memo ID'),
435
+ },
436
+ async ({ memo_id }) => {
437
+ const { data, error } = await apiPatch<{ memo: MemoRow }>(`/api/memos/${memo_id}/read`, {})
438
+ if (error || !data) return err(error ?? 'Unknown error')
439
+
440
+ const m = data.memo
441
+ const lines = [
442
+ `Memo #${m.id}`,
443
+ '─────────────',
444
+ `From: ${m.from_user} -> ${m.to_user}`,
445
+ m.story_id ? `Story: [S${m.story_id}]` : '',
446
+ `Date: ${m.created_at}`,
447
+ '',
448
+ m.content,
449
+ ].filter(Boolean)
450
+
451
+ if (m.reply) {
452
+ lines.push('', `Reply (${m.replied_by}, ${m.replied_at}):`, m.reply)
453
+ }
454
+
455
+ return text(lines.join('\n'))
456
+ },
457
+ )
458
+
459
+ // ── Tool 12: reply_memo ──
460
+
461
+ server.tool(
462
+ 'reply_memo',
463
+ 'Reply to a memo',
464
+ {
465
+ memo_id: z.number().describe('Memo ID'),
466
+ content: z.string().describe('Reply content'),
467
+ },
468
+ async ({ memo_id, content }) => {
469
+ const { error } = await apiPost(`/api/memos/${memo_id}/reply`, { content })
470
+ if (error) return err(error)
471
+ return text(`Reply sent to memo #${memo_id}`)
472
+ },
473
+ )
474
+
475
+ // ── Tool 13: sprint_summary ──
476
+
477
+ server.tool(
478
+ 'sprint_summary',
479
+ 'Sprint overview — progress by epic, workload by assignee, blockers',
480
+ {
481
+ sprint: z.string().optional().describe('Sprint ID (default: active sprint)'),
482
+ },
483
+ async ({ sprint: sprintArg }) => {
484
+ const sprint = resolveSprint(sprintArg)
485
+ if (!sprint) return err('Please specify a sprint.')
486
+
487
+ const { data, error } = await apiGet<SprintData>('/api/sprint/summary', { sprint })
488
+ if (error || !data) return err(error ?? 'Unknown error')
489
+
490
+ const lines = [`Sprint Summary: ${sprint.toUpperCase()}`, '─────────────']
491
+
492
+ for (const e of data.epics) {
493
+ const pct = e.total > 0 ? Math.round((e.done / e.total) * 100) : 0
494
+ lines.push(`# ${e.epic_title}: ${progressBar(e.done, e.total)} ${e.done}/${e.total} (${pct}%)`)
495
+ }
496
+
497
+ if (data.assignees.length > 0) {
498
+ lines.push('')
499
+ for (const a of data.assignees) {
500
+ lines.push(`${a.assignee}: ${a.total} tasks (done ${a.done}, in-progress ${a.in_progress}, todo ${a.todo})`)
501
+ }
502
+ }
503
+
504
+ if (data.blockers.length > 0) {
505
+ lines.push('', `Blockers: ${data.blockers.length}`)
506
+ for (const b of data.blockers) {
507
+ lines.push(` - [${b.entry_date}] ${b.user_name}: ${b.blockers}`)
508
+ }
509
+ } else {
510
+ lines.push('', 'Blockers: 0')
511
+ }
512
+
513
+ return text(lines.join('\n'))
514
+ },
515
+ )
516
+
517
+ // ══════════════════════════════════════════════════
518
+ // NOTIFICATION TOOLS
519
+ // ══════════════════════════════════════════════════
520
+
521
+ // ── Tool 14: check_notifications ──
522
+
523
+ server.tool(
524
+ 'check_notifications',
525
+ 'Check unread notifications for a user (max 20)',
526
+ {
527
+ user_name: z.string().describe('User name to check'),
528
+ },
529
+ async ({ user_name }) => {
530
+ const { data, error } = await apiGet<{ notifications: NotificationRow[] }>(
531
+ '/api/notifications', { user_name, unread_only: 'true', limit: '20' },
532
+ )
533
+ if (error || !data) return err(error ?? 'Unknown error')
534
+
535
+ if (data.notifications.length === 0) {
536
+ return text(`No unread notifications for ${user_name}.`)
537
+ }
538
+
539
+ const lines = data.notifications.map((n, i) => {
540
+ const icon = n.type === 'memo_assigned' ? '[memo]'
541
+ : n.type === 'reply_received' ? '[reply]'
542
+ : '[notice]'
543
+ return `${i + 1}. ${icon} [ID:${n.id}] ${n.title}\n ${n.body ?? ''}\n Page: ${n.page_id} | ${n.created_at}`
544
+ })
545
+
546
+ return text(`Unread notifications for ${user_name} (${data.notifications.length}):\n\n${lines.join('\n\n')}`)
547
+ },
548
+ )
549
+
550
+ // ── Tool 15: mark_notification_read ──
551
+
552
+ server.tool(
553
+ 'mark_notification_read',
554
+ 'Mark a notification as read',
555
+ {
556
+ notification_id: z.number().describe('Notification ID to mark as read'),
557
+ },
558
+ async ({ notification_id }) => {
559
+ const { data, error } = await apiPatch<{ ok: boolean; rows_affected: number }>(
560
+ `/api/notifications/${notification_id}/read`, {},
561
+ )
562
+ if (error || !data) return err(error ?? 'Unknown error')
563
+
564
+ return text(
565
+ data.rows_affected > 0
566
+ ? `Notification #${notification_id} marked as read.`
567
+ : `Notification #${notification_id} not found.`,
568
+ )
569
+ },
570
+ )
571
+
572
+ // ── Tool 16: check_open_memos ──
573
+
574
+ server.tool(
575
+ 'check_open_memos',
576
+ 'Check all open memos assigned to a user',
577
+ {
578
+ user_name: z.string().describe('User name to check'),
579
+ },
580
+ async ({ user_name }) => {
581
+ const { data, error } = await apiGet<{ memos: MemoV2Row[] }>(
582
+ '/api/memos/v2', { assigned_to: user_name, status: 'open' },
583
+ )
584
+ if (error || !data) return err(error ?? 'Unknown error')
585
+
586
+ if (data.memos.length === 0) {
587
+ return text(`No open memos assigned to ${user_name}.`)
588
+ }
589
+
590
+ const typeIcons: Record<string, string> = {
591
+ memo: '[memo]', decision: '[decision]', request: '[request]', backlog: '[idea]',
592
+ }
593
+
594
+ const lines = data.memos.map((m, i) => {
595
+ const icon = typeIcons[m.memo_type] ?? '[memo]'
596
+ const preview = m.content.length > 80 ? m.content.slice(0, 80) + '...' : m.content
597
+ return `${i + 1}. ${icon} [ID:${m.id}] ${m.created_by} -> ${m.assigned_to}\n ${preview}\n Page: ${m.page_id} | ${m.created_at}`
598
+ })
599
+
600
+ return text(`Open memos for ${user_name} (${data.memos.length}):\n\n${lines.join('\n\n')}`)
601
+ },
602
+ )
603
+
604
+ // ── Tool 17: create_notification ──
605
+
606
+ server.tool(
607
+ 'create_notification',
608
+ 'Create a notification for a user (triggered by memo events like replies, resolve, reopen)',
609
+ {
610
+ user_name: z.string().describe('Notification recipient'),
611
+ type: z.enum(['memo_assigned', 'memo_mention_all', 'reply_received', 'memo_resolved', 'memo_reopened'])
612
+ .describe('Notification type'),
613
+ title: z.string().describe('Notification title'),
614
+ body: z.string().optional().describe('Notification body preview (under 60 chars)'),
615
+ source_id: z.number().describe('Related memo ID'),
616
+ page_id: z.string().describe('Page ID where the memo belongs (e.g., home, diagnosis)'),
617
+ actor: z.string().describe('Name of the user who performed the action'),
618
+ },
619
+ async ({ user_name, type, title, body, source_id, page_id, actor }) => {
620
+ const { data, error } = await apiPost<{ ok: boolean }>('/api/notifications', {
621
+ user_name, type, title, body, source_type: 'memo', source_id, page_id, actor,
622
+ })
623
+ if (error || !data) return err(error ?? 'Unknown error')
624
+
625
+ return text(`Notification created: [${type}] "${title}" -> ${user_name}`)
626
+ },
627
+ )
628
+
629
+ // ── Tool 18: resolve_memo ──
630
+
631
+ server.tool(
632
+ 'resolve_memo',
633
+ 'Resolve a memo (mark as resolved) and notify the author',
634
+ {
635
+ memo_id: z.number().describe('Memo ID to resolve'),
636
+ resolved_by: z.string().describe('Name of the user resolving the memo'),
637
+ },
638
+ async ({ memo_id, resolved_by }) => {
639
+ const { data, error } = await apiPatch<{ ok: boolean; already_resolved?: boolean }>(
640
+ `/api/memos/v2/${memo_id}/resolve`, { resolved_by },
641
+ )
642
+ if (error || !data) return err(error ?? 'Unknown error')
643
+
644
+ if (data.already_resolved) {
645
+ return text(`Memo #${memo_id} is already resolved.`)
646
+ }
647
+
648
+ return text(`Memo #${memo_id} resolved by ${resolved_by}.`)
649
+ },
650
+ )
651
+
652
+ // ── Start ──
653
+
654
+ async function main() {
655
+ await fetchActiveSprint()
656
+ const transport = new StdioServerTransport()
657
+ await server.connect(transport)
658
+ }
659
+
660
+ main().catch(console.error)
@@ -0,0 +1,14 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "Node16",
5
+ "moduleResolution": "Node16",
6
+ "outDir": "dist",
7
+ "rootDir": "src",
8
+ "strict": true,
9
+ "esModuleInterop": true,
10
+ "skipLibCheck": true,
11
+ "declaration": true
12
+ },
13
+ "include": ["src"]
14
+ }
@@ -0,0 +1,21 @@
1
+ {
2
+ "name": "pm-api",
3
+ "version": "1.0.0",
4
+ "private": true,
5
+ "type": "module",
6
+ "scripts": {
7
+ "dev": "wrangler dev",
8
+ "deploy": "wrangler deploy",
9
+ "build": "tsc --noEmit",
10
+ "test": "vitest run"
11
+ },
12
+ "dependencies": {
13
+ "hono": "^4.7.0"
14
+ },
15
+ "devDependencies": {
16
+ "@cloudflare/workers-types": "^4.20250309.0",
17
+ "typescript": "^5.7.0",
18
+ "vitest": "^4.1.0",
19
+ "wrangler": "^4.0.0"
20
+ }
21
+ }