popilot 0.6.0 → 0.8.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 (165) 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-notification-server/package.json +18 -0
  9. package/scaffold/mcp-notification-server/src/index.ts +275 -0
  10. package/scaffold/mcp-notification-server/src/turso-client.ts +142 -0
  11. package/scaffold/mcp-notification-server/tsconfig.json +14 -0
  12. package/scaffold/mcp-pm/package.json +19 -0
  13. package/scaffold/mcp-pm/src/api-client.ts +69 -0
  14. package/scaffold/mcp-pm/src/index.ts +660 -0
  15. package/scaffold/mcp-pm/tsconfig.json +14 -0
  16. package/scaffold/pm-api/package.json +21 -0
  17. package/scaffold/pm-api/sql/001-memo-v2.sql +49 -0
  18. package/scaffold/pm-api/sql/002-notifications.sql +18 -0
  19. package/scaffold/pm-api/sql/003-content.sql +66 -0
  20. package/scaffold/pm-api/sql/004-agent-events.sql +21 -0
  21. package/scaffold/pm-api/sql/005-epic-sprint-decoupling.sql +6 -0
  22. package/scaffold/pm-api/sql/schema-core.sql +331 -0
  23. package/scaffold/pm-api/sql/schema-docs.sql +25 -0
  24. package/scaffold/pm-api/sql/schema-meetings.sql +17 -0
  25. package/scaffold/pm-api/sql/schema-rewards.sql +16 -0
  26. package/scaffold/pm-api/src/auth.ts +28 -0
  27. package/scaffold/pm-api/src/blockchain/adapter.ts +20 -0
  28. package/scaffold/pm-api/src/blockchain/tron.ts +62 -0
  29. package/scaffold/pm-api/src/db/adapter.ts +36 -0
  30. package/scaffold/pm-api/src/db/turso.ts +147 -0
  31. package/scaffold/pm-api/src/index.ts +114 -0
  32. package/scaffold/pm-api/src/mcp-tools/dashboard.ts +40 -0
  33. package/scaffold/pm-api/src/mcp-tools/epic.ts +67 -0
  34. package/scaffold/pm-api/src/mcp-tools/event.ts +89 -0
  35. package/scaffold/pm-api/src/mcp-tools/index.ts +11 -0
  36. package/scaffold/pm-api/src/mcp-tools/initiative.ts +51 -0
  37. package/scaffold/pm-api/src/mcp-tools/memo.ts +164 -0
  38. package/scaffold/pm-api/src/mcp-tools/notification.ts +37 -0
  39. package/scaffold/pm-api/src/mcp-tools/retro.ts +183 -0
  40. package/scaffold/pm-api/src/mcp-tools/sprint.ts +204 -0
  41. package/scaffold/pm-api/src/mcp-tools/standup.ts +136 -0
  42. package/scaffold/pm-api/src/mcp-tools/story.ts +230 -0
  43. package/scaffold/pm-api/src/mcp-tools/task.ts +187 -0
  44. package/scaffold/pm-api/src/mcp-tools/utils.ts +83 -0
  45. package/scaffold/pm-api/src/mcp.ts +871 -0
  46. package/scaffold/pm-api/src/nudge.ts +283 -0
  47. package/scaffold/pm-api/src/routes/auth.ts +32 -0
  48. package/scaffold/pm-api/src/routes/v2-activity.ts +27 -0
  49. package/scaffold/pm-api/src/routes/v2-admin.ts +165 -0
  50. package/scaffold/pm-api/src/routes/v2-dashboard.ts +189 -0
  51. package/scaffold/pm-api/src/routes/v2-docs.ts +34 -0
  52. package/scaffold/pm-api/src/routes/v2-initiatives.ts +118 -0
  53. package/scaffold/pm-api/src/routes/v2-kickoff.ts +265 -0
  54. package/scaffold/pm-api/src/routes/v2-meetings.ts +324 -0
  55. package/scaffold/pm-api/src/routes/v2-memos.ts +257 -0
  56. package/scaffold/pm-api/src/routes/v2-nav.ts +260 -0
  57. package/scaffold/pm-api/src/routes/v2-notifications.ts +79 -0
  58. package/scaffold/pm-api/src/routes/v2-page-content.ts +35 -0
  59. package/scaffold/pm-api/src/routes/v2-pm.ts +380 -0
  60. package/scaffold/pm-api/src/routes/v2-policy.ts +58 -0
  61. package/scaffold/pm-api/src/routes/v2-retro.ts +221 -0
  62. package/scaffold/pm-api/src/routes/v2-rewards.ts +132 -0
  63. package/scaffold/pm-api/src/routes/v2-scenarios.ts +48 -0
  64. package/scaffold/pm-api/src/routes/v2-search.ts +32 -0
  65. package/scaffold/pm-api/src/routes/v2-standup.ts +127 -0
  66. package/scaffold/pm-api/src/routes/v2-user.ts +38 -0
  67. package/scaffold/pm-api/src/types.ts +11 -0
  68. package/scaffold/pm-api/src/utils/activity.ts +22 -0
  69. package/scaffold/pm-api/src/utils/admin.ts +9 -0
  70. package/scaffold/pm-api/src/utils/agent-notify.ts +62 -0
  71. package/scaffold/pm-api/src/utils/assignee.ts +69 -0
  72. package/scaffold/pm-api/src/utils/db.ts +45 -0
  73. package/scaffold/pm-api/src/utils/initiative.ts +23 -0
  74. package/scaffold/pm-api/src/utils/retro-link.ts +32 -0
  75. package/scaffold/pm-api/src/utils/sprint-lifecycle.ts +96 -0
  76. package/scaffold/pm-api/tsconfig.json +15 -0
  77. package/scaffold/pm-api/wrangler.toml.hbs +11 -0
  78. package/scaffold/spec-site/package-lock.json +892 -0
  79. package/scaffold/spec-site/package.json +15 -1
  80. package/scaffold/spec-site/src/api/types.ts +6 -0
  81. package/scaffold/spec-site/src/components/AppHeader.vue +429 -55
  82. package/scaffold/spec-site/src/components/AuthGate.vue +117 -0
  83. package/scaffold/spec-site/src/components/BurndownChart.vue +78 -0
  84. package/scaffold/spec-site/src/components/DocComments.vue +137 -0
  85. package/scaffold/spec-site/src/components/DocEditor.vue +118 -0
  86. package/scaffold/spec-site/src/components/DocExportBar.vue +110 -0
  87. package/scaffold/spec-site/src/components/DocsSidebar.vue +309 -0
  88. package/scaffold/spec-site/src/components/EmptyState.vue +30 -0
  89. package/scaffold/spec-site/src/components/ErrorBanner.vue +38 -0
  90. package/scaffold/spec-site/src/components/Icon.vue +58 -0
  91. package/scaffold/spec-site/src/components/MemberSelect.vue +48 -0
  92. package/scaffold/spec-site/src/components/MemoChecklist.vue +88 -0
  93. package/scaffold/spec-site/src/components/MemoGraph.vue +75 -0
  94. package/scaffold/spec-site/src/components/MemoItem.vue +353 -0
  95. package/scaffold/spec-site/src/components/MemoRelations.vue +101 -0
  96. package/scaffold/spec-site/src/components/MemoTimeline.vue +53 -0
  97. package/scaffold/spec-site/src/components/MentionInput.vue +174 -0
  98. package/scaffold/spec-site/src/components/NotificationDropdown.vue +116 -0
  99. package/scaffold/spec-site/src/components/PriorityBadge.vue +23 -0
  100. package/scaffold/spec-site/src/components/SearchModal.vue +102 -0
  101. package/scaffold/spec-site/src/components/SlashCommand.ts +123 -0
  102. package/scaffold/spec-site/src/components/StateDisplay.vue +54 -0
  103. package/scaffold/spec-site/src/components/TreeNode.vue +82 -0
  104. package/scaffold/spec-site/src/components/UserAvatar.vue +24 -0
  105. package/scaffold/spec-site/src/components/VelocityChart.vue +77 -0
  106. package/scaffold/spec-site/src/composables/navTypes.ts +3 -0
  107. package/scaffold/spec-site/src/composables/pmTypes.ts +15 -2
  108. package/scaffold/spec-site/src/composables/useBottomSheet.ts +103 -0
  109. package/scaffold/spec-site/src/composables/useDashboard.ts +221 -0
  110. package/scaffold/spec-site/src/composables/useMediaQuery.ts +28 -0
  111. package/scaffold/spec-site/src/composables/useMemo.ts +39 -0
  112. package/scaffold/spec-site/src/composables/useNotification.ts +200 -0
  113. package/scaffold/spec-site/src/composables/usePmStore.ts +48 -1
  114. package/scaffold/spec-site/src/composables/useRetro.ts +6 -0
  115. package/scaffold/spec-site/src/composables/useStandup.ts +201 -0
  116. package/scaffold/spec-site/src/composables/useTheme.ts +37 -0
  117. package/scaffold/spec-site/src/composables/useTurso.ts +17 -0
  118. package/scaffold/spec-site/src/composables/useUser.ts +19 -1
  119. package/scaffold/spec-site/src/composables/useViewport.ts +26 -0
  120. package/scaffold/spec-site/src/features.ts +108 -0
  121. package/scaffold/spec-site/src/mockup/ComponentPalette.vue +61 -0
  122. package/scaffold/spec-site/src/mockup/MockupCanvas.vue +459 -0
  123. package/scaffold/spec-site/src/mockup/PropertyPanel.vue +217 -0
  124. package/scaffold/spec-site/src/mockup/componentCatalog.ts +68 -0
  125. package/scaffold/spec-site/src/mockup/useScenarios.ts +67 -0
  126. package/scaffold/spec-site/src/pages/AdminPage.vue +299 -0
  127. package/scaffold/spec-site/src/pages/DashboardPage.vue +650 -0
  128. package/scaffold/spec-site/src/pages/DocsEditor.vue +119 -0
  129. package/scaffold/spec-site/src/pages/DocsHub.vue +157 -0
  130. package/scaffold/spec-site/src/pages/DocsPage.vue +444 -0
  131. package/scaffold/spec-site/src/pages/InboxPage.vue +156 -0
  132. package/scaffold/spec-site/src/pages/MeetingsPage.vue +294 -0
  133. package/scaffold/spec-site/src/pages/MemosPage.vue +857 -0
  134. package/scaffold/spec-site/src/pages/MockupEditorPage.vue +611 -0
  135. package/scaffold/spec-site/src/pages/MockupListPage.vue +121 -0
  136. package/scaffold/spec-site/src/pages/MockupViewerPage.vue +199 -0
  137. package/scaffold/spec-site/src/pages/MyPage.vue +343 -0
  138. package/scaffold/spec-site/src/pages/NotificationSettingsPage.vue +59 -0
  139. package/scaffold/spec-site/src/pages/RewardsPage.vue +266 -0
  140. package/scaffold/spec-site/src/pages/SprintAdmin.vue +521 -0
  141. package/scaffold/spec-site/src/pages/SprintTimeline.vue +159 -0
  142. package/scaffold/spec-site/src/pages/board/BoardAdmin.vue +422 -0
  143. package/scaffold/spec-site/src/pages/board/BoardEpicSection.vue +54 -0
  144. package/scaffold/spec-site/src/pages/board/BoardPage.vue +884 -0
  145. package/scaffold/spec-site/src/pages/board/BoardStoryCard.vue +67 -0
  146. package/scaffold/spec-site/src/pages/board/BoardTaskItem.vue +52 -0
  147. package/scaffold/spec-site/src/pages/board/KanbanBoard.vue +93 -0
  148. package/scaffold/spec-site/src/pages/board/MyTasksPage.vue +202 -0
  149. package/scaffold/spec-site/src/pages/board/SprintClose.vue +167 -0
  150. package/scaffold/spec-site/src/pages/board/SprintColumn.vue +49 -0
  151. package/scaffold/spec-site/src/pages/board/SprintKickoff.vue +389 -0
  152. package/scaffold/spec-site/src/pages/board/StatusBadge.vue +52 -0
  153. package/scaffold/spec-site/src/pages/board/StoryDetailPanel.vue +495 -0
  154. package/scaffold/spec-site/src/pages/board/TaskCard.vue +42 -0
  155. package/scaffold/spec-site/src/pages/retro/RetroCard.vue +36 -2
  156. package/scaffold/spec-site/src/pages/retro/RetroHeader.vue +82 -66
  157. package/scaffold/spec-site/src/pages/retro/RetroPage.vue +47 -18
  158. package/scaffold/spec-site/src/pages/standup/StandupEntryCard.vue +551 -0
  159. package/scaffold/spec-site/src/pages/standup/StandupForm.vue +68 -0
  160. package/scaffold/spec-site/src/pages/standup/StandupList.vue +71 -0
  161. package/scaffold/spec-site/src/pages/standup/StandupPage.vue +225 -0
  162. package/scaffold/spec-site/src/router.ts +141 -0
  163. package/scaffold/spec-site/src/styles/buttons.css +124 -0
  164. package/scaffold/spec-site/src/utils/parseMentions.ts +56 -0
  165. package/scaffold/spec-site/src/utils/timezone.ts +18 -0
@@ -0,0 +1,380 @@
1
+ import { Hono } from 'hono'
2
+ import { buildUpdateQuery } from '../utils/db.js'
3
+ import type { AppEnv } from '../types.js'
4
+ import { query, execute } from '../db/adapter.js'
5
+ import { queryOrThrow, executeOrThrow } from '../utils/db.js'
6
+
7
+ const app = new Hono<AppEnv>()
8
+
9
+ import { validateAssignee } from '../utils/assignee.js'
10
+
11
+ // Auto-sync task SP sum to story SP
12
+ async function syncStorySP(storyId: number) {
13
+ const { rows } = await queryOrThrow<{ total: number | null }>(
14
+ 'SELECT SUM(story_points) as total FROM pm_tasks WHERE story_id = ?',
15
+ [storyId],
16
+ )
17
+ if (rows.length > 0) {
18
+ const total = rows[0].total ?? 0
19
+ await execute('UPDATE pm_stories SET story_points = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?', [total, storyId])
20
+ }
21
+ }
22
+
23
+ // GET /epics
24
+ app.get('/epics', async (c) => {
25
+ const { rows } = await queryOrThrow('SELECT * FROM pm_epics ORDER BY title')
26
+ return c.json({ epics: rows })
27
+ })
28
+
29
+ // GET /data
30
+ app.get('/data', async (c) => {
31
+ const sprint = c.req.query('sprint')
32
+
33
+ if (sprint) {
34
+ // sprint=backlog -> stories where sprint IS NULL
35
+ const storiesResult = sprint === 'backlog'
36
+ ? await query("SELECT * FROM pm_stories WHERE sprint IS NULL AND status NOT IN ('done', 'cancelled')")
37
+ : await query('SELECT * FROM pm_stories WHERE sprint = ?', [sprint])
38
+ if (storiesResult.error) return c.json({ error: storiesResult.error }, 500)
39
+
40
+ if (storiesResult.rows.length === 0) {
41
+ return c.json({ stories: [], tasks: [] })
42
+ }
43
+
44
+ const storyIds = (storiesResult.rows as Array<{ id: number }>).map(s => s.id)
45
+ const placeholders = storyIds.map(() => '?').join(', ')
46
+ const tasksResult = await query(
47
+ `SELECT * FROM pm_tasks WHERE story_id IN (${placeholders})`,
48
+ storyIds,
49
+ )
50
+ if (tasksResult.error) return c.json({ error: tasksResult.error }, 500)
51
+ return c.json({ stories: storiesResult.rows, tasks: tasksResult.rows })
52
+ }
53
+
54
+ const [storiesResult, tasksResult] = await Promise.all([
55
+ query('SELECT * FROM pm_stories'),
56
+ query('SELECT * FROM pm_tasks'),
57
+ ])
58
+ if (storiesResult.error) return c.json({ error: storiesResult.error }, 500)
59
+ if (tasksResult.error) return c.json({ error: tasksResult.error }, 500)
60
+ return c.json({ stories: storiesResult.rows, tasks: tasksResult.rows })
61
+ })
62
+
63
+ // POST /epics
64
+ app.post('/epics', async (c) => {
65
+ const body = await c.req.json<{
66
+ title: string; description?: string; status?: string; owner?: string
67
+ sortOrder?: number; originSprint?: string
68
+ }>()
69
+ const { rowsAffected } = await executeOrThrow(
70
+ 'INSERT INTO pm_epics (title, description, status, owner, sort_order, origin_sprint) VALUES (?, ?, ?, ?, ?, ?)',
71
+ [
72
+ body.title, body.description ?? null, body.status ?? 'active', body.owner ?? null,
73
+ body.sortOrder ?? 0, body.originSprint ?? null,
74
+ ],
75
+ )
76
+ return c.json({ ok: true }, 201)
77
+ })
78
+
79
+ // PATCH /epics/:id
80
+ app.patch('/epics/:id', async (c) => {
81
+ const id = Number(c.req.param('id'))
82
+ const body = await c.req.json<Record<string, unknown>>()
83
+
84
+ const fieldMap: Record<string, string> = {
85
+ title: 'title', description: 'description', status: 'status', owner: 'owner',
86
+ sortOrder: 'sort_order', originSprint: 'origin_sprint', category: 'category', badge: 'badge',
87
+ }
88
+ const sets: string[] = []
89
+ const args: (string | number | null)[] = []
90
+
91
+ for (const [key, col] of Object.entries(fieldMap)) {
92
+ if (body[key] !== undefined) {
93
+ sets.push(`${col} = ?`)
94
+ args.push(body[key] as string | number | null)
95
+ }
96
+ }
97
+ if (sets.length === 0) return c.json({ ok: true })
98
+
99
+ sets.push('updated_at = CURRENT_TIMESTAMP')
100
+ args.push(id)
101
+ const { rowsAffected } = await executeOrThrow(`UPDATE pm_epics SET ${sets.join(', ')} WHERE id = ?`, args)
102
+ return c.json({ ok: true })
103
+ })
104
+
105
+ // DELETE /epics/:id
106
+ app.delete('/epics/:id', async (c) => {
107
+ const id = Number(c.req.param('id'))
108
+ const r1 = await execute('UPDATE pm_stories SET epic_id = NULL WHERE epic_id = ?', [id])
109
+ if (r1.error) return c.json({ error: r1.error }, 500)
110
+ const r2 = await execute('DELETE FROM pm_epics WHERE id = ?', [id])
111
+ if (r2.error) return c.json({ error: r2.error }, 500)
112
+ return c.json({ ok: true })
113
+ })
114
+
115
+ // POST /stories
116
+ app.post('/stories', async (c) => {
117
+ const body = await c.req.json<{
118
+ epicId?: number; sprint?: string | null; title: string; description?: string
119
+ acceptanceCriteria?: string; assignee?: string; status?: string
120
+ priority?: string; area?: string; storyPoints?: number; sortOrder?: number
121
+ }>()
122
+
123
+ const assigneeErr = await validateAssignee(body.assignee)
124
+ if (assigneeErr) return c.json({ error: assigneeErr }, 400)
125
+
126
+ const epicUid = body.epicId ? `pm:${body.epicId}` : `pm:0`
127
+
128
+ const { rowsAffected } = await executeOrThrow(
129
+ `INSERT INTO pm_stories (epic_id, epic_uid, sprint, title, description, acceptance_criteria, assignee, status, priority, area, story_points, sort_order, start_date, due_date)
130
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
131
+ [
132
+ body.epicId ?? null, epicUid, body.sprint ?? null, body.title,
133
+ body.description ?? null, body.acceptanceCriteria ?? null,
134
+ body.assignee ?? null, body.status ?? 'todo',
135
+ body.priority ?? null, body.area ?? null,
136
+ body.storyPoints ?? null, body.sortOrder ?? 0,
137
+ (body as any).startDate ?? null, (body as any).dueDate ?? null,
138
+ ],
139
+ )
140
+ return c.json({ ok: true }, 201)
141
+ })
142
+
143
+ // PATCH /stories/:id
144
+ app.patch('/stories/:id', async (c) => {
145
+ const id = Number(c.req.param('id'))
146
+ const body = await c.req.json<Record<string, unknown>>()
147
+
148
+ if (body.assignee !== undefined) {
149
+ const assigneeErr = await validateAssignee(body.assignee as string | null)
150
+ if (assigneeErr) return c.json({ error: assigneeErr }, 400)
151
+ }
152
+
153
+ const fieldMap: Record<string, string> = {
154
+ epicId: 'epic_id', sprint: 'sprint', title: 'title',
155
+ description: 'description', acceptanceCriteria: 'acceptance_criteria',
156
+ assignee: 'assignee', status: 'status', priority: 'priority',
157
+ area: 'area', storyPoints: 'story_points', figmaUrl: 'figma_url', sortOrder: 'sort_order',
158
+ startDate: 'start_date', dueDate: 'due_date',
159
+ }
160
+ const sets: string[] = []
161
+ const args: (string | number | null)[] = []
162
+
163
+ for (const [key, col] of Object.entries(fieldMap)) {
164
+ if (body[key] !== undefined) {
165
+ sets.push(`${col} = ?`)
166
+ args.push(body[key] as string | number | null)
167
+ }
168
+ }
169
+ if (sets.length === 0) return c.json({ ok: true })
170
+
171
+ sets.push('updated_at = CURRENT_TIMESTAMP')
172
+ args.push(id)
173
+ const { rowsAffected } = await executeOrThrow(`UPDATE pm_stories SET ${sets.join(', ')} WHERE id = ?`, args)
174
+
175
+ // Activity log
176
+ if (body.status !== undefined) {
177
+ const { logActivity } = await import('../utils/activity.js')
178
+ const userName = c.get('userName') || 'unknown'
179
+ await logActivity(userName, 'story_status_change', 'story', id, body.title as string || `SID:${id}`, { status: body.status })
180
+ }
181
+
182
+ return c.json({ ok: true })
183
+ })
184
+
185
+ // DELETE /stories/:id
186
+ app.delete('/stories/:id', async (c) => {
187
+ const id = Number(c.req.param('id'))
188
+ const r1 = await execute('DELETE FROM pm_tasks WHERE story_id = ?', [id])
189
+ if (r1.error) return c.json({ error: r1.error }, 500)
190
+ const r2 = await execute('DELETE FROM pm_stories WHERE id = ?', [id])
191
+ if (r2.error) return c.json({ error: r2.error }, 500)
192
+ return c.json({ ok: true })
193
+ })
194
+
195
+ // POST /tasks
196
+ app.post('/tasks', async (c) => {
197
+ const body = await c.req.json<{
198
+ storyId: number; title: string; assignee?: string
199
+ description?: string; sortOrder?: number; storyPoints?: number
200
+ }>()
201
+ const assigneeErr = await validateAssignee(body.assignee)
202
+ if (assigneeErr) return c.json({ error: assigneeErr }, 400)
203
+ const { rowsAffected } = await executeOrThrow(
204
+ 'INSERT INTO pm_tasks (story_id, title, assignee, description, sort_order, story_points, start_date, due_date) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
205
+ [body.storyId, body.title, body.assignee ?? null, body.description ?? null, body.sortOrder ?? 0, body.storyPoints ?? null, (body as any).startDate ?? null, (body as any).dueDate ?? null],
206
+ )
207
+
208
+ // Auto-sync story SP
209
+ if (body.storyPoints != null) {
210
+ await syncStorySP(body.storyId)
211
+ }
212
+
213
+ return c.json({ ok: true }, 201)
214
+ })
215
+
216
+ // PATCH /tasks/:id
217
+ app.patch('/tasks/:id', async (c) => {
218
+ const id = Number(c.req.param('id'))
219
+ const body = await c.req.json<Record<string, unknown>>()
220
+
221
+ if (body.assignee !== undefined) {
222
+ const assigneeErr = await validateAssignee(body.assignee as string | null)
223
+ if (assigneeErr) return c.json({ error: assigneeErr }, 400)
224
+ }
225
+
226
+ const fieldMap: Record<string, string> = {
227
+ title: 'title', assignee: 'assignee', status: 'status', description: 'description',
228
+ storyPoints: 'story_points', startDate: 'start_date', dueDate: 'due_date',
229
+ }
230
+ const sets: string[] = []
231
+ const args: (string | number | null)[] = []
232
+
233
+ for (const [key, col] of Object.entries(fieldMap)) {
234
+ if (body[key] !== undefined) {
235
+ sets.push(`${col} = ?`)
236
+ args.push(body[key] as string | number | null)
237
+ }
238
+ }
239
+ if (sets.length === 0) return c.json({ ok: true })
240
+
241
+ sets.push('updated_at = CURRENT_TIMESTAMP')
242
+ args.push(id)
243
+ const { rowsAffected } = await executeOrThrow(`UPDATE pm_tasks SET ${sets.join(', ')} WHERE id = ?`, args)
244
+
245
+ // Auto-sync story SP on SP change
246
+ if (body.storyPoints !== undefined) {
247
+ const storyResult = await query<{ story_id: number }>('SELECT story_id FROM pm_tasks WHERE id = ?', [id])
248
+ if (!storyResult.error && storyResult.rows.length > 0) {
249
+ await syncStorySP(storyResult.rows[0].story_id)
250
+ }
251
+ }
252
+
253
+ return c.json({ ok: true })
254
+ })
255
+
256
+ // DELETE /tasks/:id
257
+ app.delete('/tasks/:id', async (c) => {
258
+ const id = Number(c.req.param('id'))
259
+ const { rowsAffected } = await executeOrThrow('DELETE FROM pm_tasks WHERE id = ?', [id])
260
+ return c.json({ ok: true })
261
+ })
262
+
263
+ // ── PR <-> Story link ──
264
+
265
+ type PrEntry = { prNumber: number; prUrl: string; prTitle: string; status: string }
266
+
267
+ async function linkPrToStory(
268
+ storyId: number,
269
+ pr: { prNumber: number; prUrl: string; prTitle: string; status?: string },
270
+ ): Promise<{ ok: boolean; error?: string; relatedPrs: PrEntry[] }> {
271
+ const existing = await query('SELECT related_prs FROM pm_stories WHERE id = ?', [storyId])
272
+ if (!existing.rows?.length) return { ok: false, error: 'Story not found', relatedPrs: [] }
273
+
274
+ const current: PrEntry[] = JSON.parse((existing.rows[0] as any).related_prs || '[]')
275
+ const entry: PrEntry = { prNumber: pr.prNumber, prUrl: pr.prUrl, prTitle: pr.prTitle, status: pr.status || 'open' }
276
+ const idx = current.findIndex(p => p.prNumber === pr.prNumber)
277
+ if (idx >= 0) current[idx] = entry
278
+ else current.push(entry)
279
+
280
+ await execute('UPDATE pm_stories SET related_prs = ? WHERE id = ?', [JSON.stringify(current), storyId])
281
+
282
+ if (pr.status === 'merged') {
283
+ const storyResult = await query('SELECT status FROM pm_stories WHERE id = ?', [storyId])
284
+ const storyStatus = (storyResult.rows?.[0] as any)?.status
285
+ if (storyStatus === 'review') {
286
+ await execute("UPDATE pm_stories SET status = 'qa' WHERE id = ?", [storyId])
287
+ }
288
+ await execute(
289
+ "UPDATE memos_v2 SET status = 'resolved', resolved_by = 'system', resolved_at = CURRENT_TIMESTAMP WHERE status = 'open' AND content LIKE ?",
290
+ [`%SID:${storyId}%`],
291
+ )
292
+ }
293
+
294
+ // Activity log
295
+ if (pr.status === 'merged') {
296
+ const { logActivity } = await import('../utils/activity.js')
297
+ await logActivity('system', 'pr_merged', 'story', storyId, pr.prTitle)
298
+ }
299
+
300
+ return { ok: true, relatedPrs: current }
301
+ }
302
+
303
+ // POST /stories/:id/link-pr
304
+ app.post('/stories/:id/link-pr', async (c) => {
305
+ const storyId = Number(c.req.param('id'))
306
+ const body = await c.req.json<{ prNumber: number; prUrl: string; prTitle: string; status?: string }>()
307
+ const result = await linkPrToStory(storyId, body)
308
+ if (!result.ok) return c.json({ error: result.error }, 404)
309
+ return c.json({ ok: true, relatedPrs: result.relatedPrs })
310
+ })
311
+
312
+ // GET /stories/:id/prs
313
+ app.get('/stories/:id/prs', async (c) => {
314
+ const storyId = Number(c.req.param('id'))
315
+ const existing = await query('SELECT related_prs FROM pm_stories WHERE id = ?', [storyId])
316
+ if (!existing.rows?.length) return c.json({ error: 'Story not found' }, 404)
317
+ return c.json({ prs: JSON.parse((existing.rows[0] as any).related_prs || '[]') })
318
+ })
319
+
320
+ // POST /link-pr-by-title — auto-parse SID:xxx from PR title
321
+ app.post('/link-pr-by-title', async (c) => {
322
+ const body = await c.req.json<{ prNumber: number; prUrl: string; prTitle: string; status?: string }>()
323
+ const match = body.prTitle.match(/SID[:\s]*(\d+)/i)
324
+ if (!match) return c.json({ ok: false, message: 'No SID pattern found' })
325
+ const storyId = Number(match[1])
326
+ const result = await linkPrToStory(storyId, body)
327
+ if (!result.ok) return c.json({ ok: false, message: result.error })
328
+ return c.json({ ok: true, storyId, relatedPrs: result.relatedPrs })
329
+ })
330
+
331
+ // ── Story Comments ──
332
+
333
+ // GET /stories/:id/comments
334
+ app.get('/stories/:id/comments', async (c) => {
335
+ const storyId = Number(c.req.param('id'))
336
+ const { rows } = await queryOrThrow(
337
+ 'SELECT * FROM story_comments WHERE story_id = ? ORDER BY created_at ASC',
338
+ [storyId],
339
+ )
340
+ return c.json({ comments: rows })
341
+ })
342
+
343
+ // POST /stories/:id/comments
344
+ app.post('/stories/:id/comments', async (c) => {
345
+ const storyId = Number(c.req.param('id'))
346
+ const body = await c.req.json<{ content: string }>()
347
+ const createdBy = c.get('userName') || 'unknown'
348
+
349
+ await executeOrThrow(
350
+ 'INSERT INTO story_comments (story_id, content, created_by) VALUES (?, ?, ?)',
351
+ [storyId, body.content, createdBy],
352
+ )
353
+
354
+ // @mention webhook
355
+ const { rows: members } = await queryOrThrow<{ display_name: string }>(
356
+ "SELECT display_name FROM members WHERE is_active = 1",
357
+ )
358
+ const { notifyByName } = await import('../utils/agent-notify.js')
359
+ for (const m of members) {
360
+ if (body.content.includes(`@${m.display_name}`) && m.display_name !== createdBy) {
361
+ const preview = body.content.length > 50 ? body.content.slice(0, 50) + '...' : body.content
362
+ await notifyByName(m.display_name, `Comment: ${createdBy}`, `SID:${storyId} comment\n${preview}`)
363
+ }
364
+ }
365
+
366
+ return c.json({ ok: true }, 201)
367
+ })
368
+
369
+ // DELETE /stories/:id/comments/:commentId
370
+ app.delete('/stories/:id/comments/:commentId', async (c) => {
371
+ const commentId = Number(c.req.param('commentId'))
372
+ const userName = c.get('userName')
373
+ await executeOrThrow(
374
+ 'DELETE FROM story_comments WHERE id = ? AND created_by = ?',
375
+ [commentId, userName],
376
+ )
377
+ return c.json({ ok: true })
378
+ })
379
+
380
+ export default app
@@ -0,0 +1,58 @@
1
+ import { Hono } from 'hono'
2
+ import type { AppEnv } from '../types.js'
3
+ import { query, execute } from '../db/adapter.js'
4
+ import { queryOrThrow, executeOrThrow } from '../utils/db.js'
5
+
6
+ const app = new Hono<AppEnv>()
7
+
8
+ // GET /:sprint/:epicId
9
+ // Supports both legacy string keys (E-01) and numeric pm_epics.id (18)
10
+ app.get('/:sprint/:epicId', async (c) => {
11
+ const sprint = c.req.param('sprint')
12
+ const epicId = c.req.param('epicId')
13
+
14
+ // 1. Try exact match first (works for both "E-01" and numeric "18")
15
+ const { rows } = await queryOrThrow<{ content: string }>(
16
+ 'SELECT content FROM policy_documents WHERE sprint = ? AND epic_id = ?',
17
+ [sprint, epicId],
18
+ )
19
+ if (rows.length > 0) return c.json({ content: rows[0].content })
20
+
21
+ // 2. If epicId is numeric, resolve legacy E-XX key via story title pattern
22
+ if (/^\d+$/.test(epicId)) {
23
+ const storyResult = await query<{ title: string }>(
24
+ `SELECT title FROM pm_stories WHERE epic_id = ? AND sprint = ? LIMIT 1`,
25
+ [Number(epicId), sprint],
26
+ )
27
+ if (!storyResult.error && storyResult.rows.length > 0) {
28
+ const match = storyResult.rows[0].title.match(/^\[(E-\d+)/)
29
+ if (match) {
30
+ const legacyId = match[1]
31
+ const fallback = await query<{ content: string }>(
32
+ 'SELECT content FROM policy_documents WHERE sprint = ? AND epic_id = ?',
33
+ [sprint, legacyId],
34
+ )
35
+ if (!fallback.error && fallback.rows.length > 0) {
36
+ return c.json({ content: fallback.rows[0].content })
37
+ }
38
+ }
39
+ }
40
+ }
41
+
42
+ return c.json({ error: 'Not found' }, 404)
43
+ })
44
+
45
+ // PUT /:sprint/:epicId — upsert policy document
46
+ app.put('/:sprint/:epicId', async (c) => {
47
+ const sprint = c.req.param('sprint')
48
+ const epicId = c.req.param('epicId')
49
+ const body = await c.req.json<{ content: string }>().catch(() => ({} as { content: string }))
50
+ if (!body.content) return c.json({ error: 'Missing content' }, 400)
51
+ const { rowsAffected } = await executeOrThrow(
52
+ 'INSERT OR REPLACE INTO policy_documents (sprint, epic_id, content, updated_at) VALUES (?, ?, ?, CURRENT_TIMESTAMP)',
53
+ [sprint, epicId, body.content],
54
+ )
55
+ return c.json({ ok: true })
56
+ })
57
+
58
+ export default app
@@ -0,0 +1,221 @@
1
+ import { Hono } from 'hono'
2
+ import type { AppEnv } from '../types.js'
3
+ import { query, execute } from '../db/adapter.js'
4
+ import { queryOrThrow, executeOrThrow } from '../utils/db.js'
5
+
6
+ const app = new Hono<AppEnv>()
7
+
8
+ // GET /session
9
+ app.get('/session', async (c) => {
10
+ const sprint = c.req.query('sprint')
11
+ if (!sprint) return c.json({ error: 'sprint query param required' }, 400)
12
+
13
+ const { rows } = await queryOrThrow(
14
+ 'SELECT * FROM retro_sessions WHERE sprint = ? LIMIT 1',
15
+ [sprint],
16
+ )
17
+ return c.json({ session: rows[0] ?? null })
18
+ })
19
+
20
+ // POST /session
21
+ app.post('/session', async (c) => {
22
+ const body = await c.req.json<{ sprint: string; title: string }>()
23
+ const insertResult = await executeOrThrow(
24
+ 'INSERT INTO retro_sessions (sprint, title) VALUES (?, ?)',
25
+ [body.sprint, body.title],
26
+ )
27
+
28
+ const { rows } = await queryOrThrow(
29
+ 'SELECT * FROM retro_sessions WHERE sprint = ? LIMIT 1',
30
+ [body.sprint],
31
+ )
32
+ return c.json({ session: rows[0] ?? null })
33
+ })
34
+
35
+ // PATCH /session/:id/phase
36
+ app.patch('/session/:id/phase', async (c) => {
37
+ const id = Number(c.req.param('id'))
38
+ const body = await c.req.json<{ phase: string }>()
39
+ const { rowsAffected } = await executeOrThrow(
40
+ 'UPDATE retro_sessions SET phase = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?',
41
+ [body.phase, id],
42
+ )
43
+ return c.json({ ok: true })
44
+ })
45
+
46
+ // GET /items
47
+ app.get('/items', async (c) => {
48
+ const sessionId = c.req.query('sessionId')
49
+ const user = c.req.query('user')
50
+ if (!sessionId) return c.json({ error: 'sessionId query param required' }, 400)
51
+
52
+ const { rows } = await queryOrThrow(
53
+ `SELECT i.*, COUNT(v.item_id) as vote_count,
54
+ CASE WHEN SUM(CASE WHEN v.voter = ? THEN 1 ELSE 0 END) > 0 THEN 1 ELSE 0 END as has_voted
55
+ FROM retro_items i
56
+ LEFT JOIN retro_votes v ON v.item_id = i.id
57
+ WHERE i.session_id = ?
58
+ GROUP BY i.id`,
59
+ [user ?? '', Number(sessionId)],
60
+ )
61
+ const mapped = rows.map((r: Record<string, unknown>) => ({
62
+ ...r,
63
+ voteCount: Number(r.vote_count ?? 0),
64
+ hasVoted: Number(r.has_voted ?? 0),
65
+ }))
66
+ return c.json({ items: mapped })
67
+ })
68
+
69
+ // POST /items
70
+ app.post('/items', async (c) => {
71
+ const body = await c.req.json<{
72
+ sessionId: number; category: string; content: string; author: string
73
+ }>()
74
+ const { rowsAffected } = await executeOrThrow(
75
+ 'INSERT INTO retro_items (session_id, category, content, author) VALUES (?, ?, ?, ?)',
76
+ [body.sessionId, body.category, body.content, body.author],
77
+ )
78
+ return c.json({ ok: true })
79
+ })
80
+
81
+ // DELETE /items/:id
82
+ app.delete('/items/:id', async (c) => {
83
+ const id = Number(c.req.param('id'))
84
+ const r1 = await executeOrThrow('DELETE FROM retro_votes WHERE item_id = ?', [id])
85
+ const r2 = await executeOrThrow('DELETE FROM retro_items WHERE id = ?', [id])
86
+ return c.json({ ok: true })
87
+ })
88
+
89
+ // POST /items/:id/vote
90
+ app.post('/items/:id/vote', async (c) => {
91
+ const id = Number(c.req.param('id'))
92
+ const body = await c.req.json<{ voter: string; hasVoted: boolean }>()
93
+
94
+ if (body.hasVoted) {
95
+ const { rowsAffected } = await executeOrThrow(
96
+ 'DELETE FROM retro_votes WHERE item_id = ? AND voter = ?',
97
+ [id, body.voter],
98
+ )
99
+ } else {
100
+ const { rowsAffected } = await executeOrThrow(
101
+ 'INSERT OR IGNORE INTO retro_votes (item_id, voter) VALUES (?, ?)',
102
+ [id, body.voter],
103
+ )
104
+ }
105
+ return c.json({ ok: true })
106
+ })
107
+
108
+ // GET /actions
109
+ app.get('/actions', async (c) => {
110
+ const sessionId = c.req.query('sessionId')
111
+ if (!sessionId) return c.json({ error: 'sessionId query param required' }, 400)
112
+
113
+ const { rows } = await queryOrThrow(
114
+ 'SELECT * FROM retro_actions WHERE session_id = ? ORDER BY created_at ASC',
115
+ [Number(sessionId)],
116
+ )
117
+ return c.json({ actions: rows })
118
+ })
119
+
120
+ // POST /actions
121
+ app.post('/actions', async (c) => {
122
+ const body = await c.req.json<{
123
+ sessionId: number; content: string; assignee?: string
124
+ }>()
125
+ const { rowsAffected } = await executeOrThrow(
126
+ 'INSERT INTO retro_actions (session_id, content, assignee) VALUES (?, ?, ?)',
127
+ [body.sessionId, body.content, body.assignee ?? null],
128
+ )
129
+ return c.json({ ok: true })
130
+ })
131
+
132
+ // PATCH /actions/:id/status
133
+ app.patch('/actions/:id/status', async (c) => {
134
+ const id = Number(c.req.param('id'))
135
+ const body = await c.req.json<{ status: string }>()
136
+ const { rowsAffected } = await executeOrThrow(
137
+ 'UPDATE retro_actions SET status = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?',
138
+ [body.status, id],
139
+ )
140
+ return c.json({ ok: true })
141
+ })
142
+
143
+ // DELETE /session/:id — cascade delete
144
+ app.delete('/session/:id', async (c) => {
145
+ const id = Number(c.req.param('id'))
146
+
147
+ // Delete votes via subquery
148
+ const r1 = await executeOrThrow(
149
+ 'DELETE FROM retro_votes WHERE item_id IN (SELECT id FROM retro_items WHERE session_id = ?)',
150
+ [id],
151
+ )
152
+
153
+ const r2 = await executeOrThrow('DELETE FROM retro_actions WHERE session_id = ?', [id])
154
+
155
+ const r3 = await executeOrThrow('DELETE FROM retro_items WHERE session_id = ?', [id])
156
+
157
+ const r4 = await executeOrThrow('DELETE FROM retro_sessions WHERE id = ?', [id])
158
+
159
+ return c.json({ ok: true })
160
+ })
161
+
162
+ // POST /:sprint/complete — retro complete + action items -> backlog stories
163
+ app.post('/:sprint/complete', async (c) => {
164
+ const sprintId = c.req.param('sprint')
165
+
166
+ // 1. Check retro session
167
+ const { rows } = await queryOrThrow(
168
+ 'SELECT id, phase FROM retro_sessions WHERE sprint = ? LIMIT 1',
169
+ [sprintId],
170
+ )
171
+ if (!rows.length) return c.json({ error: 'Retro session not found' }, 404)
172
+ const sessionId = (rows[0] as { id: number }).id
173
+
174
+ // 2. Get action items
175
+ const { rows: actionRows } = await queryOrThrow<{ id: number; content: string; assignee: string | null; status: string }>(
176
+ 'SELECT id, content, assignee, status FROM retro_actions WHERE session_id = ?',
177
+ [sessionId],
178
+ )
179
+
180
+ // 3. Incomplete actions -> backlog stories
181
+ const { actionToStory, getPendingActions } = await import('../utils/retro-link.js')
182
+ const pending = getPendingActions(actionRows)
183
+ const created: Array<{ actionId: number; title: string }> = []
184
+
185
+ for (const action of pending) {
186
+ const story = actionToStory(action, sprintId)
187
+ const r = await executeOrThrow(
188
+ 'INSERT INTO pm_stories (title, description, assignee, status, sprint, priority) VALUES (?, ?, ?, ?, ?, ?)',
189
+ [story.title, story.description, story.assignee, 'backlog', null, 'medium'],
190
+ )
191
+ created.push({ actionId: action.id, title: story.title })
192
+ }
193
+
194
+ // 4. Set retro phase -> done
195
+ await executeOrThrow(
196
+ "UPDATE retro_sessions SET phase = 'done', updated_at = CURRENT_TIMESTAMP WHERE id = ?",
197
+ [sessionId],
198
+ )
199
+
200
+ return c.json({
201
+ ok: true,
202
+ sprintId,
203
+ actionsProcessed: pending.length,
204
+ storiesCreated: created,
205
+ })
206
+ })
207
+
208
+ // GET /:sprint/actions — retro action items by sprint
209
+ app.get('/:sprint/actions', async (c) => {
210
+ const sprint = c.req.param('sprint')
211
+ const { rows: sessions } = await queryOrThrow('SELECT id FROM retro_sessions WHERE sprint = ? LIMIT 1', [sprint])
212
+ if (!sessions.length) return c.json({ actions: [] })
213
+ const sessionId = (sessions[0] as any).id
214
+ const { rows } = await queryOrThrow(
215
+ 'SELECT * FROM retro_actions WHERE session_id = ? ORDER BY created_at ASC',
216
+ [sessionId],
217
+ )
218
+ return c.json({ actions: rows })
219
+ })
220
+
221
+ export default app