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,34 @@
1
+ import { Hono } from 'hono'
2
+ import type { AppEnv } from '../types.js'
3
+ import { queryOrThrow, executeOrThrow } from '../utils/db.js'
4
+
5
+ const app = new Hono<AppEnv>()
6
+
7
+ // GET / - document list
8
+ app.get('/', async (c) => {
9
+ const { rows } = await queryOrThrow('SELECT id, title, SUBSTR(content, 1, 100) as summary, created_by, updated_at FROM docs ORDER BY title')
10
+ return c.json({ docs: rows })
11
+ })
12
+
13
+ // GET /:id - document detail
14
+ app.get('/:id', async (c) => {
15
+ const id = c.req.param('id')
16
+ const { rows } = await queryOrThrow('SELECT * FROM docs WHERE id = ?', [id])
17
+ if (!rows.length) return c.json({ error: 'Document not found' }, 404)
18
+ return c.json({ doc: rows[0] })
19
+ })
20
+
21
+ // PUT /:id - create/update document
22
+ app.put('/:id', async (c) => {
23
+ const id = c.req.param('id')
24
+ const body = await c.req.json<{ title: string; content: string }>()
25
+ const createdBy = c.get('userName') || 'unknown'
26
+ await executeOrThrow(
27
+ `INSERT INTO docs (id, title, content, created_by) VALUES (?, ?, ?, ?)
28
+ ON CONFLICT(id) DO UPDATE SET title = excluded.title, content = excluded.content, updated_at = CURRENT_TIMESTAMP`,
29
+ [id, body.title, body.content, createdBy],
30
+ )
31
+ return c.json({ ok: true })
32
+ })
33
+
34
+ export default app
@@ -0,0 +1,118 @@
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
+ import { canTransition, validateCreate, type InitiativeStatus } from '../utils/initiative.js'
6
+
7
+ const app = new Hono<AppEnv>()
8
+
9
+ // GET / — initiative list
10
+ app.get('/', async (c) => {
11
+ const status = c.req.query('status')
12
+ const limit = Number(c.req.query('limit') ?? '50')
13
+
14
+ let sql = 'SELECT * FROM initiatives'
15
+ const args: (string | number)[] = []
16
+ if (status) {
17
+ sql += ' WHERE status = ?'
18
+ args.push(status)
19
+ }
20
+ sql += ' ORDER BY created_at DESC LIMIT ?'
21
+ args.push(limit)
22
+
23
+ const { rows } = await queryOrThrow(sql, args)
24
+ return c.json({ initiatives: rows })
25
+ })
26
+
27
+ // GET /:id — detail
28
+ app.get('/:id', async (c) => {
29
+ const id = Number(c.req.param('id'))
30
+ const { rows } = await queryOrThrow('SELECT * FROM initiatives WHERE id = ?', [id])
31
+ if (!rows.length) return c.json({ error: 'Initiative not found' }, 404)
32
+ return c.json({ initiative: rows[0] })
33
+ })
34
+
35
+ // POST / — create
36
+ app.post('/', async (c) => {
37
+ const body = await c.req.json<{
38
+ title: string; content: string; decider?: string
39
+ source_context?: string; sourceContext?: string
40
+ }>()
41
+ const author = c.get('userName')
42
+ const err = validateCreate(body.title, body.content, author)
43
+ if (err) return c.json({ error: err }, 400)
44
+ const sourceContext = body.source_context ?? body.sourceContext ?? null
45
+
46
+ const { rowsAffected } = await executeOrThrow(
47
+ `INSERT INTO initiatives (title, content, author, decider, source_context) VALUES (?, ?, ?, ?, ?)`,
48
+ [body.title, body.content, author, body.decider ?? null, sourceContext],
49
+ )
50
+ const { logActivity } = await import('../utils/activity.js')
51
+ await logActivity(c.get('userName') || 'unknown', 'initiative_created', 'initiative', 'new', body.title || '')
52
+
53
+ return c.json({ ok: true }, 201)
54
+ })
55
+
56
+ // PATCH /:id/status — status transition
57
+ app.patch('/:id/status', async (c) => {
58
+ const id = Number(c.req.param('id'))
59
+ const body = await c.req.json<{ status: InitiativeStatus; decision_note?: string; decisionNote?: string }>()
60
+
61
+ const current = await query<{ status: string }>('SELECT status FROM initiatives WHERE id = ?', [id])
62
+ if (!current.rows.length) return c.json({ error: 'Initiative not found' }, 404)
63
+
64
+ const from = current.rows[0].status as InitiativeStatus
65
+ if (!canTransition(from, body.status)) {
66
+ return c.json({ error: `Cannot transition ${from} → ${body.status}` }, 400)
67
+ }
68
+
69
+ const decidedAt = ['approved', 'rejected'].includes(body.status) ? 'CURRENT_TIMESTAMP' : 'NULL'
70
+ await execute(
71
+ `UPDATE initiatives SET status = ?, decision_note = ?, decided_at = ${decidedAt}, updated_at = CURRENT_TIMESTAMP WHERE id = ?`,
72
+ [body.status, body.decision_note ?? body.decisionNote ?? null, id],
73
+ )
74
+ return c.json({ ok: true })
75
+ })
76
+
77
+ // DELETE /:id
78
+ app.delete('/:id', async (c) => {
79
+ const id = Number(c.req.param('id'))
80
+ const { rowsAffected } = await executeOrThrow('DELETE FROM initiatives WHERE id = ?', [id])
81
+ return c.json({ ok: true })
82
+ })
83
+
84
+ // POST /:id/convert-to-epic — initiative -> epic conversion
85
+ app.post('/:id/convert-to-epic', async (c) => {
86
+ const id = Number(c.req.param('id'))
87
+ const initiative = await query<{ id: number; title: string; content: string; status: string }>(
88
+ 'SELECT id, title, content, status FROM initiatives WHERE id = ?', [id],
89
+ )
90
+ if (!initiative.rows.length) return c.json({ error: 'Initiative not found' }, 404)
91
+ if (initiative.rows[0].status !== 'approved') return c.json({ error: 'Only approved initiatives can be converted' }, 400)
92
+
93
+ const ini = initiative.rows[0]
94
+ const { rowsAffected } = await executeOrThrow(
95
+ 'INSERT INTO pm_epics (title, description, status, owner) VALUES (?, ?, ?, ?)',
96
+ [ini.title, `Created from initiative #${ini.id}.\n\n${ini.content}`, 'active', c.get('userName')],
97
+ )
98
+ return c.json({ ok: true, epicTitle: ini.title })
99
+ })
100
+
101
+ // POST /:id/convert-to-story — initiative -> story conversion
102
+ app.post('/:id/convert-to-story', async (c) => {
103
+ const id = Number(c.req.param('id'))
104
+ const initiative = await query<{ id: number; title: string; content: string; status: string }>(
105
+ 'SELECT id, title, content, status FROM initiatives WHERE id = ?', [id],
106
+ )
107
+ if (!initiative.rows.length) return c.json({ error: 'Initiative not found' }, 404)
108
+ if (initiative.rows[0].status !== 'approved') return c.json({ error: 'Only approved initiatives can be converted' }, 400)
109
+
110
+ const ini = initiative.rows[0]
111
+ const { rowsAffected } = await executeOrThrow(
112
+ 'INSERT INTO pm_stories (title, description, status, priority) VALUES (?, ?, ?, ?)',
113
+ [`[Initiative] ${ini.title}`, `Created from initiative #${ini.id}.\n\n${ini.content}`, 'backlog', 'medium'],
114
+ )
115
+ return c.json({ ok: true, storyTitle: `[Initiative] ${ini.title}` })
116
+ })
117
+
118
+ export default app
@@ -0,0 +1,265 @@
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
+ /** Count working days (excluding Sat/Sun) */
9
+ function countWorkingDays(startDate: string, endDate: string): number {
10
+ const start = new Date(startDate)
11
+ const end = new Date(endDate)
12
+ let count = 0
13
+ const d = new Date(start)
14
+ while (d <= end) {
15
+ const day = d.getDay()
16
+ if (day !== 0 && day !== 6) count++
17
+ d.setDate(d.getDate() + 1)
18
+ }
19
+ return count
20
+ }
21
+
22
+ // POST /create — create sprint (planning state)
23
+ app.post('/create', async (c) => {
24
+ const body = await c.req.json<{
25
+ id: string; label: string; theme?: string
26
+ startDate: string; endDate: string
27
+ }>()
28
+ if (!body.id || !body.startDate || !body.endDate) {
29
+ return c.json({ error: 'id, startDate, endDate required' }, 400)
30
+ }
31
+ const { rowsAffected } = await executeOrThrow(
32
+ `INSERT INTO nav_sprints (id, label, theme, start_date, end_date, status, sort_order)
33
+ VALUES (?, ?, ?, ?, ?, 'planning', 0)`,
34
+ [body.id, body.label || body.id.toUpperCase(), body.theme ?? null, body.startDate, body.endDate],
35
+ )
36
+
37
+ const totalWorkingDays = countWorkingDays(body.startDate, body.endDate)
38
+ return c.json({ ok: true, sprintId: body.id, totalWorkingDays })
39
+ })
40
+
41
+ // POST /:id/checkin — team member check-in
42
+ app.post('/:id/checkin', async (c) => {
43
+ const sprintId = c.req.param('id')
44
+ const body = await c.req.json<{ memberIds: number[] }>()
45
+ if (!body.memberIds?.length) return c.json({ error: 'memberIds required' }, 400)
46
+
47
+ // Get sprint dates
48
+ const sprint = await query<{ start_date: string; end_date: string; status: string }>(
49
+ 'SELECT start_date, end_date, status FROM nav_sprints WHERE id = ?', [sprintId],
50
+ )
51
+ if (!sprint.rows.length) return c.json({ error: 'Sprint not found' }, 404)
52
+ if (sprint.rows[0].status !== 'planning') {
53
+ return c.json({ error: 'Check-in only available in planning state' }, 400)
54
+ }
55
+
56
+ const totalDays = countWorkingDays(sprint.rows[0].start_date, sprint.rows[0].end_date)
57
+
58
+ // Reset existing check-ins + register new
59
+ await execute('DELETE FROM sprint_members WHERE sprint_id = ?', [sprintId])
60
+
61
+ for (const memberId of body.memberIds) {
62
+ // Get absence count for this member
63
+ const absences = await query<{ cnt: number }>(
64
+ 'SELECT COUNT(*) as cnt FROM member_absences WHERE sprint_id = ? AND member_id = ?',
65
+ [sprintId, memberId],
66
+ )
67
+ const absenceCount = absences.rows[0]?.cnt ?? 0
68
+ const workingDays = Math.max(0, totalDays - absenceCount)
69
+
70
+ await execute(
71
+ 'INSERT OR REPLACE INTO sprint_members (sprint_id, member_id, working_days) VALUES (?, ?, ?)',
72
+ [sprintId, memberId, workingDays],
73
+ )
74
+ }
75
+
76
+ // Team total working days = velocity
77
+ const teamResult = await query<{ total: number }>(
78
+ 'SELECT SUM(working_days) as total FROM sprint_members WHERE sprint_id = ?', [sprintId],
79
+ )
80
+ const velocity = teamResult.rows[0]?.total ?? 0
81
+ await execute(
82
+ 'UPDATE nav_sprints SET velocity = ?, team_size = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?',
83
+ [velocity, body.memberIds.length, sprintId],
84
+ )
85
+
86
+ return c.json({ ok: true, velocity, teamSize: body.memberIds.length, totalWorkingDays: totalDays })
87
+ })
88
+
89
+ // POST /:id/absence — register absence
90
+ app.post('/:id/absence', async (c) => {
91
+ const sprintId = c.req.param('id')
92
+ const body = await c.req.json<{
93
+ memberId: number; dates: string[]; reason?: string
94
+ }>()
95
+ if (!body.memberId || !body.dates?.length) return c.json({ error: 'memberId, dates required' }, 400)
96
+
97
+ for (const date of body.dates) {
98
+ await execute(
99
+ 'INSERT OR IGNORE INTO member_absences (sprint_id, member_id, absence_date, reason) VALUES (?, ?, ?, ?)',
100
+ [sprintId, body.memberId, date, body.reason ?? null],
101
+ )
102
+ }
103
+
104
+ // Recalculate working_days
105
+ const sprint = await query<{ start_date: string; end_date: string }>(
106
+ 'SELECT start_date, end_date FROM nav_sprints WHERE id = ?', [sprintId],
107
+ )
108
+ if (sprint.rows.length) {
109
+ const totalDays = countWorkingDays(sprint.rows[0].start_date, sprint.rows[0].end_date)
110
+ const absences = await query<{ cnt: number }>(
111
+ 'SELECT COUNT(*) as cnt FROM member_absences WHERE sprint_id = ? AND member_id = ?',
112
+ [sprintId, body.memberId],
113
+ )
114
+ const workingDays = Math.max(0, totalDays - (absences.rows[0]?.cnt ?? 0))
115
+ await execute(
116
+ 'UPDATE sprint_members SET working_days = ? WHERE sprint_id = ? AND member_id = ?',
117
+ [workingDays, sprintId, body.memberId],
118
+ )
119
+
120
+ // Recalculate velocity
121
+ const teamResult = await query<{ total: number }>(
122
+ 'SELECT SUM(working_days) as total FROM sprint_members WHERE sprint_id = ?', [sprintId],
123
+ )
124
+ await execute('UPDATE nav_sprints SET velocity = ? WHERE id = ?', [teamResult.rows[0]?.total ?? 0, sprintId])
125
+ }
126
+
127
+ return c.json({ ok: true })
128
+ })
129
+
130
+ // DELETE /:id/absence — delete absence
131
+ app.delete('/:id/absence', async (c) => {
132
+ const sprintId = c.req.param('id')
133
+ const body = await c.req.json<{ memberId: number; date: string }>()
134
+ await execute(
135
+ 'DELETE FROM member_absences WHERE sprint_id = ? AND member_id = ? AND absence_date = ?',
136
+ [sprintId, body.memberId, body.date],
137
+ )
138
+ return c.json({ ok: true })
139
+ })
140
+
141
+ // GET /:id/plan — kickoff plan status (checked-in members + absences + velocity)
142
+ app.get('/:id/plan', async (c) => {
143
+ const sprintId = c.req.param('id')
144
+
145
+ const [sprint, members, absences, stories] = await Promise.all([
146
+ query<{ id: string; status: string; start_date: string; end_date: string; velocity: number | null; team_size: number | null }>(
147
+ 'SELECT * FROM nav_sprints WHERE id = ?', [sprintId]),
148
+ query<{ member_id: number; working_days: number; display_name: string }>(
149
+ `SELECT sm.member_id, sm.working_days, m.display_name
150
+ FROM sprint_members sm JOIN members m ON sm.member_id = m.id
151
+ WHERE sm.sprint_id = ?`, [sprintId]),
152
+ query<{ member_id: number; absence_date: string; reason: string | null }>(
153
+ 'SELECT member_id, absence_date, reason FROM member_absences WHERE sprint_id = ?', [sprintId]),
154
+ query<{ id: number; title: string; story_points: number | null; assignee: string | null }>(
155
+ 'SELECT id, title, story_points, assignee FROM pm_stories WHERE sprint = ?', [sprintId]),
156
+ ])
157
+
158
+ if (!sprint.rows.length) return c.json({ error: 'Sprint not found' }, 404)
159
+ const s = sprint.rows[0]
160
+ const totalWorkingDays = countWorkingDays(s.start_date, s.end_date)
161
+ const totalSP = (stories.rows as Array<{ story_points: number | null }>).reduce((sum, st) => sum + (st.story_points ?? 0), 0)
162
+
163
+ return c.json({
164
+ sprint: s,
165
+ totalWorkingDays,
166
+ members: members.rows,
167
+ absences: absences.rows,
168
+ stories: stories.rows,
169
+ totalSP,
170
+ velocity: s.velocity ?? 0,
171
+ })
172
+ })
173
+
174
+ // GET /:id/close-preview — close preview
175
+ app.get('/:id/close-preview', async (c) => {
176
+ const sprintId = c.req.param('id')
177
+
178
+ const sprint = await query<{ id: string; status: string; start_date: string; end_date: string; velocity: number | null }>(
179
+ 'SELECT id, status, start_date, end_date, velocity FROM nav_sprints WHERE id = ?', [sprintId],
180
+ )
181
+ if (!sprint.rows.length) return c.json({ error: 'Sprint not found' }, 404)
182
+
183
+ const stories = await query<{ id: number; title: string; sprint: string | null; status: string; story_points: number | null; assignee: string | null }>(
184
+ 'SELECT id, title, sprint, status, story_points, assignee FROM pm_stories WHERE sprint = ?', [sprintId],
185
+ )
186
+
187
+ const { generateCloseSummary, aggregateVelocity, getIncompleteStories } = await import('../utils/sprint-lifecycle.js')
188
+ const summary = generateCloseSummary(sprintId, stories.rows)
189
+ const velocity = aggregateVelocity(sprintId, stories.rows)
190
+ const incomplete = getIncompleteStories(stories.rows)
191
+
192
+ return c.json({
193
+ sprint: sprint.rows[0],
194
+ summary,
195
+ velocity,
196
+ incompleteStories: incomplete.map(s => ({ id: s.id, title: s.title, status: s.status, story_points: s.story_points, assignee: s.assignee })),
197
+ })
198
+ })
199
+
200
+ // POST /:id/close — close sprint
201
+ app.post('/:id/close', async (c) => {
202
+ const sprintId = c.req.param('id')
203
+
204
+ // 1. Check sprint status
205
+ const sprint = await query<{ id: string; status: string }>(
206
+ 'SELECT id, status FROM nav_sprints WHERE id = ?', [sprintId],
207
+ )
208
+ if (!sprint.rows.length) return c.json({ error: 'Sprint not found' }, 404)
209
+ if (sprint.rows[0].status !== 'active') {
210
+ return c.json({ error: `Close only available in active state (current: ${sprint.rows[0].status})` }, 400)
211
+ }
212
+
213
+ // 2. Get all sprint stories
214
+ const stories = await query<{
215
+ id: number; title: string; status: string; story_points: number | null; assignee: string | null
216
+ }>('SELECT id, title, status, story_points, assignee FROM pm_stories WHERE sprint = ?', [sprintId])
217
+ if (stories.error) return c.json({ error: stories.error }, 500)
218
+
219
+ const allStories = stories.rows
220
+ const completed = allStories.filter(s => s.status === 'done')
221
+ const incomplete = allStories.filter(s => s.status !== 'done')
222
+
223
+ // 3. Move incomplete stories to backlog
224
+ if (incomplete.length > 0) {
225
+ const ids = incomplete.map(s => s.id)
226
+ const placeholders = ids.map(() => '?').join(', ')
227
+ await execute(
228
+ `UPDATE pm_stories SET sprint = NULL, updated_at = CURRENT_TIMESTAMP WHERE id IN (${placeholders})`,
229
+ ids,
230
+ )
231
+ }
232
+
233
+ // 4. Save velocity history
234
+ const totalDoneSP = completed.reduce((sum, s) => sum + (s.story_points ?? 0), 0)
235
+ const totalSP = allStories.reduce((sum, s) => sum + (s.story_points ?? 0), 0)
236
+ await execute(
237
+ `UPDATE nav_sprints SET status = 'closed', active = 0, velocity = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?`,
238
+ [totalDoneSP, sprintId],
239
+ )
240
+
241
+ // 5. Auto-create retro session
242
+ try {
243
+ await execute(
244
+ `INSERT OR IGNORE INTO retro_sessions (sprint, phase) VALUES (?, 'collect')`,
245
+ [sprintId],
246
+ )
247
+ } catch (_) { /* Ignore if retro table doesn't exist */ }
248
+
249
+ return c.json({
250
+ ok: true,
251
+ summary: {
252
+ sprintId,
253
+ completedCount: completed.length,
254
+ incompleteCount: incomplete.length,
255
+ totalStories: allStories.length,
256
+ doneSP: totalDoneSP,
257
+ totalSP,
258
+ completionRate: totalSP > 0 ? Math.round((totalDoneSP / totalSP) * 100) : 0,
259
+ movedToBacklog: incomplete.map(s => ({ id: s.id, title: s.title })),
260
+ },
261
+ })
262
+ })
263
+
264
+ export { countWorkingDays }
265
+ export default app