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,164 @@
1
+ import { query, execute } from '../db/adapter.js'
2
+ import { text, err, today, resolveSprint, notify, checkRateLimit, emitAgentEvent, validateAssignee, resolveMemberId, type ToolResult } from './utils.js'
3
+
4
+ export async function toolSendMemo(user: string, args: Record<string, unknown>): Promise<ToolResult> {
5
+ const toUserRaw = args.to_user as string
6
+ const recipients = toUserRaw.split(',').map(s => s.trim()).filter(Boolean)
7
+ const assignedTo = recipients.join(',')
8
+ const content = args.content as string
9
+ const pageId = (args.page_id as string) || 'home'
10
+ const memoType = (args.memo_type as string) || 'memo'
11
+ const preview = content.length > 50 ? content.slice(0, 50) + '…' : content
12
+
13
+ // Parse [D-XX] tags → related_decisions
14
+ const decisionMatches = content.match(/\[D-\d+\]/g)
15
+ const relatedDecisions = decisionMatches ? JSON.stringify([...new Set(decisionMatches.map(m => m.slice(1, -1)))]) : null
16
+ const reviewRequired = (args.review_required as boolean) ? 1 : 0
17
+ const title = (args.title as string) ?? null
18
+ const supersedesId = (args.supersedes_id as number) ?? null
19
+
20
+ // Decision type requires title
21
+ if (memoType === 'decision' && !title) {
22
+ return err('Decision type memos require a title.')
23
+ }
24
+
25
+ // Supersede: update previous memo status
26
+ if (supersedesId) {
27
+ await execute('UPDATE memos_v2 SET status = ? WHERE id = ?', ['superseded', supersedesId])
28
+ }
29
+
30
+ // Resolve member IDs
31
+ const createdById = await resolveMemberId(user)
32
+ const assignedToId = recipients.length === 1 ? await resolveMemberId(recipients[0]) : null
33
+
34
+ const result = await execute(
35
+ 'INSERT INTO memos_v2 (page_id, content, memo_type, created_by, created_by_id, assigned_to, assigned_to_id, related_decisions, review_required, title, supersedes_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
36
+ [pageId, content, memoType, user, createdById, assignedTo, assignedToId, relatedDecisions, reviewRequired, title, supersedesId],
37
+ )
38
+ if (result.error) return err(result.error)
39
+
40
+ const idResult = await query<{ id: number }>('SELECT last_insert_rowid() as id')
41
+ const memoId = idResult.rows[0]?.id ?? 0
42
+
43
+ // Notify each recipient
44
+ for (const r of recipients) {
45
+ await notify(r, 'memo_received', `New memo: ${user}`, preview, 'memo', memoId, pageId, user)
46
+ }
47
+
48
+ // Auto-emit agent events
49
+ for (const r of recipients) {
50
+ await emitAgentEvent('memo_assigned', user, r, r, {
51
+ memo_id: memoId, from: user, preview, page_id: pageId,
52
+ })
53
+ }
54
+
55
+ // Agent webhook notification
56
+ const { notifyByName } = await import('../utils/agent-notify.js')
57
+ for (const r of recipients) {
58
+ await notifyByName(r, `📋 New memo: ${user}`, `${preview}\n\nMemo #${memoId} | ${memoType}`)
59
+ }
60
+
61
+ return text(`✅ Memo sent: ${user} → ${assignedTo}`)
62
+ }
63
+
64
+ export async function toolListMemos(user: string, args: Record<string, unknown>): Promise<ToolResult> {
65
+ let sql = "SELECT * FROM memos_v2 WHERE (assigned_to = ? OR assigned_to LIKE ? OR assigned_to LIKE ? OR assigned_to LIKE ?)"
66
+ const sqlArgs: (string | number)[] = [user, `${user},%`, `%,${user},%`, `%,${user}`]
67
+ if (args.unread_only) { sql += " AND status = 'open'" }
68
+ sql += ' ORDER BY created_at DESC LIMIT 20'
69
+
70
+ const result = await query<{ id: number; page_id: string; content: string; memo_type: string; status: string; created_by: string; assigned_to: string | null; created_at: string }>(sql, sqlArgs)
71
+ if (result.error) return err(result.error)
72
+ if (result.rows.length === 0) return text('📩 No memos found.')
73
+
74
+ const lines = [`📩 ${user}'s Memos`, '─────────────']
75
+ for (const m of result.rows) {
76
+ const icon = m.status === 'open' ? '📩' : m.status === 'resolved' ? '✅' : '📖'
77
+ const preview = m.content.length > 60 ? m.content.slice(0, 60) + '…' : m.content
78
+ lines.push(`${icon} [M${m.id}] ${m.created_by} (${m.page_id}): ${preview}`)
79
+ }
80
+ return text(lines.join('\n'))
81
+ }
82
+
83
+ export async function toolReadMemo(args: Record<string, unknown>): Promise<ToolResult> {
84
+ const memoId = args.memo_id as number
85
+ const result = await query<{ id: number; page_id: string; content: string; memo_type: string; status: string; created_by: string; assigned_to: string | null; resolved_by: string | null; resolved_at: string | null; created_at: string }>('SELECT * FROM memos_v2 WHERE id = ?', [memoId])
86
+ if (result.error) return err(result.error)
87
+ if (result.rows.length === 0) return err(`Memo #${memoId} not found.`)
88
+
89
+ const m = result.rows[0]
90
+
91
+ const repliesResult = await query<{ id: number; content: string; created_by: string; created_at: string }>('SELECT * FROM memo_replies WHERE memo_id = ? ORDER BY created_at ASC', [memoId])
92
+
93
+ const lines = [`📩 Memo #${m.id}`, '─────────────', `From: ${m.created_by} → ${m.assigned_to ?? 'all'}`, `Page: ${m.page_id}`, `Status: ${m.status}`, `Date: ${m.created_at}`, '', m.content]
94
+ if (m.resolved_by) lines.push('', `✅ Resolved: ${m.resolved_by} (${m.resolved_at})`)
95
+ if (!repliesResult.error && repliesResult.rows.length > 0) {
96
+ lines.push('', '💬 Replies:')
97
+ for (const r of repliesResult.rows) lines.push(` ${r.created_by} (${r.created_at}): ${r.content}`)
98
+ }
99
+ return text(lines.join('\n'))
100
+ }
101
+
102
+ export async function toolReplyMemo(user: string, args: Record<string, unknown>): Promise<ToolResult> {
103
+ const memoId = args.memo_id as number
104
+ const content = args.content as string
105
+ const reviewType = (args.review_type as string) || 'comment'
106
+ const result = await execute(
107
+ 'INSERT INTO memo_replies (memo_id, content, created_by, review_type) VALUES (?, ?, ?, ?)',
108
+ [memoId, content, user, reviewType],
109
+ )
110
+ if (result.error) return err(result.error)
111
+
112
+ // Notify memo author
113
+ const memoResult = await query<{ created_by: string; assigned_to: string | null }>(
114
+ 'SELECT created_by, assigned_to FROM memos_v2 WHERE id = ?', [memoId],
115
+ )
116
+ if (!memoResult.error && memoResult.rows.length > 0) {
117
+ const memo = memoResult.rows[0]
118
+ const preview = content.length > 50 ? content.slice(0, 50) + '…' : content
119
+ // Notify author
120
+ await notify(memo.created_by, 'memo_reply', `Memo reply: ${user}`, preview, 'memo', memoId, 'home', user)
121
+ // If assigned_to is different from author and from replier, notify them too
122
+ if (memo.assigned_to && memo.assigned_to !== memo.created_by && memo.assigned_to !== user) {
123
+ await notify(memo.assigned_to, 'memo_reply', `Memo reply: ${user}`, preview, 'memo', memoId, 'home', user)
124
+ }
125
+ // Auto-emit agent event
126
+ await emitAgentEvent('memo_replied', user, memo.created_by, memo.created_by, {
127
+ memo_id: memoId, reply_by: user, preview,
128
+ })
129
+ }
130
+
131
+ return text(`✅ Reply sent to Memo #${memoId}`)
132
+ }
133
+
134
+ export async function toolResolveMemo(user: string, args: Record<string, unknown>): Promise<ToolResult> {
135
+ const memoId = args.memo_id as number
136
+ const result = await execute(
137
+ `UPDATE memos_v2 SET status = 'resolved', resolved_by = ?, resolved_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP WHERE id = ?`,
138
+ [user, memoId],
139
+ )
140
+ if (result.error) return err(result.error)
141
+ if (result.rowsAffected === 0) return err(`Memo #${memoId} not found.`)
142
+
143
+ // Notify memo author
144
+ const memoResult = await query<{ created_by: string }>(
145
+ 'SELECT created_by FROM memos_v2 WHERE id = ?', [memoId],
146
+ )
147
+ if (!memoResult.error && memoResult.rows.length > 0) {
148
+ await notify(memoResult.rows[0].created_by, 'memo_resolved', `Memo resolved: ${user}`, `${user} resolved Memo #${memoId}.`, 'memo', memoId, 'home', user)
149
+ // Auto-emit agent event
150
+ await emitAgentEvent('memo_resolved', user, memoResult.rows[0].created_by, memoResult.rows[0].created_by, {
151
+ memo_id: memoId, resolved_by: user,
152
+ })
153
+ }
154
+
155
+ return text(`✅ Memo #${memoId} resolved`)
156
+ }
157
+
158
+ export async function toolRejectMemo(args: Record<string, unknown>): Promise<ToolResult> {
159
+ const memoId = args.memo_id as number
160
+ if (!memoId) return err('memo_id required')
161
+ const r = await execute("UPDATE memos_v2 SET status = 'open', resolved_by = NULL, resolved_at = NULL, updated_at = CURRENT_TIMESTAMP WHERE id = ?", [memoId])
162
+ if (r.error) return err(r.error)
163
+ return text(`✅ Memo #${memoId} rejected (reopened)`)
164
+ }
@@ -0,0 +1,37 @@
1
+ import { query, execute } from '../db/adapter.js'
2
+ import { text, err, today, resolveSprint, notify, checkRateLimit, emitAgentEvent, validateAssignee, resolveMemberId, type ToolResult } from './utils.js'
3
+
4
+ export async function toolCheckNotifications(user: string, args: Record<string, unknown>): Promise<ToolResult> {
5
+ const unreadOnly = args.unread_only as boolean | undefined
6
+ let sql = 'SELECT * FROM notifications WHERE user_name = ?'
7
+ const sqlArgs: (string | number)[] = [user]
8
+ if (unreadOnly) { sql += ' AND is_read = 0' }
9
+ sql += ' ORDER BY created_at DESC LIMIT 30'
10
+
11
+ const result = await query<{ id: number; type: string; title: string; body: string | null; source_type: string; source_id: string; actor: string; is_read: number; created_at: string }>(sql, sqlArgs)
12
+ if (result.error) return err(result.error)
13
+ if (result.rows.length === 0) return text(unreadOnly ? '🔔 No unread notifications.' : '🔔 No notifications.')
14
+
15
+ const unread = result.rows.filter(n => !n.is_read).length
16
+ const lines = [`🔔 Notifications (${unread} unread)`, '─────────────']
17
+ for (const n of result.rows) {
18
+ const icon = n.is_read ? ' ' : '🔴'
19
+ const body = n.body ? ` — ${n.body.length > 40 ? n.body.slice(0, 40) + '…' : n.body}` : ''
20
+ lines.push(`${icon} [N${n.id}] ${n.title}${body} (${n.actor}, ${n.created_at.slice(5, 16)})`)
21
+ }
22
+ return text(lines.join('\n'))
23
+ }
24
+
25
+ export async function toolMarkNotificationRead(args: Record<string, unknown>): Promise<ToolResult> {
26
+ const notifId = args.notification_id as number
27
+ const result = await execute('UPDATE notifications SET is_read = 1 WHERE id = ?', [notifId])
28
+ if (result.error) return err(result.error)
29
+ if (result.rowsAffected === 0) return err(`Notification #${notifId} not found.`)
30
+ return text(`✅ Notification #${notifId} marked as read`)
31
+ }
32
+
33
+ export async function toolMarkAllNotificationsRead(user: string): Promise<ToolResult> {
34
+ const result = await execute('UPDATE notifications SET is_read = 1 WHERE user_name = ? AND is_read = 0', [user])
35
+ if (result.error) return err(result.error)
36
+ return text(`✅ ${result.rowsAffected} notifications marked as read.`)
37
+ }
@@ -0,0 +1,183 @@
1
+ import { query, execute } from '../db/adapter.js'
2
+ import { text, err, today, resolveSprint, notify, checkRateLimit, emitAgentEvent, validateAssignee, resolveMemberId, type ToolResult } from './utils.js'
3
+
4
+ export async function toolGetRetroSession(user: string, args: Record<string, unknown>): Promise<ToolResult> {
5
+ const sprint = await resolveSprint(args.sprint as string | undefined)
6
+ if (!sprint) return err('Please specify a sprint.')
7
+
8
+ const sessionResult = await query<{ id: number; sprint: string; title: string; phase: string; created_at: string }>(
9
+ 'SELECT * FROM retro_sessions WHERE sprint = ? LIMIT 1', [sprint],
10
+ )
11
+ if (sessionResult.error) return err(sessionResult.error)
12
+ if (sessionResult.rows.length === 0) return text(`${sprint.toUpperCase()} retro session not found.`)
13
+
14
+ const session = sessionResult.rows[0]
15
+ const [itemsResult, actionsResult] = await Promise.all([
16
+ query<{ id: number; category: string; content: string; author: string; vote_count: number; has_voted: number }>(
17
+ `SELECT i.*, COUNT(v.item_id) as vote_count,
18
+ CASE WHEN SUM(CASE WHEN v.voter = ? THEN 1 ELSE 0 END) > 0 THEN 1 ELSE 0 END as has_voted
19
+ FROM retro_items i LEFT JOIN retro_votes v ON v.item_id = i.id
20
+ WHERE i.session_id = ? GROUP BY i.id ORDER BY vote_count DESC`,
21
+ [user, session.id],
22
+ ),
23
+ query<{ id: number; content: string; assignee: string | null; status: string }>(
24
+ 'SELECT * FROM retro_actions WHERE session_id = ? ORDER BY created_at ASC',
25
+ [session.id],
26
+ ),
27
+ ])
28
+
29
+ const catIcon: Record<string, string> = { keep: '💚', problem: '🔴', try: '💡' }
30
+ const lines = [
31
+ `🔄 Retrospective: ${session.title} (${sprint.toUpperCase()})`,
32
+ `Phase: ${session.phase} | Created: ${session.created_at}`,
33
+ '─────────────',
34
+ ]
35
+
36
+ if (!itemsResult.error && itemsResult.rows.length > 0) {
37
+ for (const cat of ['keep', 'problem', 'try']) {
38
+ const items = itemsResult.rows.filter(i => i.category === cat)
39
+ if (items.length > 0) {
40
+ lines.push(`\n${catIcon[cat] ?? '●'} ${cat.toUpperCase()} (${items.length} items)`)
41
+ for (const i of items) {
42
+ const voted = i.has_voted ? '★' : '☆'
43
+ lines.push(` ${voted} [R${i.id}] ${i.content} (${i.author}, ${i.vote_count} votes)`)
44
+ }
45
+ }
46
+ }
47
+ } else {
48
+ lines.push('\nNo items found.')
49
+ }
50
+
51
+ if (!actionsResult.error && actionsResult.rows.length > 0) {
52
+ const si: Record<string, string> = { todo: '⬜', 'in-progress': '🔵', done: '✅' }
53
+ lines.push('\n📌 Action Items:')
54
+ for (const a of actionsResult.rows) {
55
+ lines.push(` ${si[a.status] ?? '⬜'} [A${a.id}] ${a.content} (${a.assignee ?? 'Unassigned'})`)
56
+ }
57
+ }
58
+
59
+ return text(lines.join('\n'))
60
+ }
61
+
62
+ export async function toolAddRetroItem(user: string, args: Record<string, unknown>): Promise<ToolResult> {
63
+ const sessionId = args.session_id as number
64
+ const category = args.category as string
65
+ const content = args.content as string
66
+
67
+ const result = await execute(
68
+ 'INSERT INTO retro_items (session_id, category, content, author) VALUES (?, ?, ?, ?)',
69
+ [sessionId, category, content, user],
70
+ )
71
+ if (result.error) return err(result.error)
72
+ return text(`✅ Retro item added (${category}): ${content}`)
73
+ }
74
+
75
+ export async function toolVoteRetroItem(user: string, args: Record<string, unknown>): Promise<ToolResult> {
76
+ const itemId = args.item_id as number
77
+
78
+ // Check if already voted
79
+ const existing = await query<{ item_id: number }>(
80
+ 'SELECT item_id FROM retro_votes WHERE item_id = ? AND voter = ?', [itemId, user],
81
+ )
82
+ if (existing.rows.length > 0) {
83
+ await execute('DELETE FROM retro_votes WHERE item_id = ? AND voter = ?', [itemId, user])
84
+ return text(`✅ Vote removed: item #${itemId}`)
85
+ } else {
86
+ await execute('INSERT OR IGNORE INTO retro_votes (item_id, voter) VALUES (?, ?)', [itemId, user])
87
+ return text(`✅ Voted: item #${itemId}`)
88
+ }
89
+ }
90
+
91
+ export async function toolChangeRetroPhase(args: Record<string, unknown>): Promise<ToolResult> {
92
+ const sessionId = args.session_id as number
93
+ const phase = args.phase as string
94
+ const result = await execute(
95
+ 'UPDATE retro_sessions SET phase = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?',
96
+ [phase, sessionId],
97
+ )
98
+ if (result.error) return err(result.error)
99
+ if (result.rowsAffected === 0) return err(`Session #${sessionId} not found.`)
100
+ return text(`✅ Retro session #${sessionId} → ${phase} phase`)
101
+ }
102
+
103
+ export async function toolAddRetroAction(user: string, args: Record<string, unknown>): Promise<ToolResult> {
104
+ const sessionId = args.session_id as number
105
+ const content = args.content as string
106
+ const assignee = (args.assignee as string) ?? null
107
+
108
+ const result = await execute(
109
+ 'INSERT INTO retro_actions (session_id, content, assignee) VALUES (?, ?, ?)',
110
+ [sessionId, content, assignee],
111
+ )
112
+ if (result.error) return err(result.error)
113
+
114
+ // Notify assignee
115
+ if (assignee) {
116
+ const idResult = await query<{ id: number }>('SELECT last_insert_rowid() as id')
117
+ const actionId = idResult.rows[0]?.id ?? 0
118
+ await notify(assignee, 'retro_action', `Retro action assigned: ${content.slice(0, 30)}`, `${user} assigned retro action: ${content}`, 'retro_action', actionId, 'retro', user)
119
+ }
120
+
121
+ return text(`✅ Action added: ${content}${assignee ? ` (assignee: ${assignee})` : ''}`)
122
+ }
123
+
124
+ export async function toolUpdateRetroActionStatus(args: Record<string, unknown>): Promise<ToolResult> {
125
+ const actionId = args.action_id as number
126
+ const status = args.status as string
127
+ const result = await execute(
128
+ 'UPDATE retro_actions SET status = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?',
129
+ [status, actionId],
130
+ )
131
+ if (result.error) return err(result.error)
132
+ if (result.rowsAffected === 0) return err(`Action #${actionId} not found.`)
133
+ return text(`✅ Action #${actionId} → ${status}`)
134
+ }
135
+
136
+ export async function toolExportRetro(args: Record<string, unknown>): Promise<ToolResult> {
137
+ const sprint = await resolveSprint(args.sprint as string | undefined)
138
+ if (!sprint) return err('Please specify a sprint.')
139
+
140
+ const sessionResult = await query<{ id: number; title: string; phase: string }>(
141
+ 'SELECT id, title, phase FROM retro_sessions WHERE sprint = ? LIMIT 1', [sprint],
142
+ )
143
+ if (sessionResult.error) return err(sessionResult.error)
144
+ if (sessionResult.rows.length === 0) return err(`${sprint.toUpperCase()} retro session not found.`)
145
+
146
+ const session = sessionResult.rows[0]
147
+ const [itemsResult, actionsResult] = await Promise.all([
148
+ query<{ category: string; content: string; author: string; vote_count: number }>(
149
+ `SELECT i.category, i.content, i.author, COUNT(v.item_id) as vote_count
150
+ FROM retro_items i LEFT JOIN retro_votes v ON v.item_id = i.id
151
+ WHERE i.session_id = ? GROUP BY i.id ORDER BY i.category, vote_count DESC`,
152
+ [session.id],
153
+ ),
154
+ query<{ content: string; assignee: string | null; status: string }>(
155
+ 'SELECT content, assignee, status FROM retro_actions WHERE session_id = ? ORDER BY created_at',
156
+ [session.id],
157
+ ),
158
+ ])
159
+
160
+ const lines = [
161
+ `# ${session.title} (${sprint.toUpperCase()})`,
162
+ '',
163
+ ]
164
+
165
+ if (!itemsResult.error) {
166
+ for (const cat of ['keep', 'problem', 'try']) {
167
+ const items = itemsResult.rows.filter(i => i.category === cat)
168
+ lines.push(`## ${cat.toUpperCase()} (${items.length} items)`)
169
+ for (const i of items) lines.push(`- ${i.content} — ${i.author} (${i.vote_count} votes)`)
170
+ lines.push('')
171
+ }
172
+ }
173
+
174
+ if (!actionsResult.error && actionsResult.rows.length > 0) {
175
+ lines.push('## Action Items')
176
+ for (const a of actionsResult.rows) {
177
+ const check = a.status === 'done' ? '[x]' : '[ ]'
178
+ lines.push(`- ${check} ${a.content}${a.assignee ? ` @${a.assignee}` : ''}`)
179
+ }
180
+ }
181
+
182
+ return text(lines.join('\n'))
183
+ }
@@ -0,0 +1,204 @@
1
+ import { query, execute } from '../db/adapter.js'
2
+ import { text, err, today, resolveSprint, notify, checkRateLimit, emitAgentEvent, validateAssignee, resolveMemberId, progressBar, type ToolResult } from './utils.js'
3
+
4
+ export async function toolListSprints(): Promise<ToolResult> {
5
+ const result = await query<{ id: string; title: string; active: number; start_date: string | null; end_date: string | null }>(
6
+ 'SELECT id, title, active, start_date, end_date FROM nav_sprints ORDER BY id DESC',
7
+ )
8
+ if (result.error) return err(result.error)
9
+ if (result.rows.length === 0) return text('No sprints found.')
10
+
11
+ const lines = ['📅 Sprint List', '─────────────']
12
+ for (const s of result.rows) {
13
+ const marker = s.active ? ' ← active' : ''
14
+ const icon = s.active ? '🟢' : '⚪'
15
+ const dates = s.start_date && s.end_date ? ` (${s.start_date} ~ ${s.end_date})` : ''
16
+ lines.push(`${icon} ${s.id}: ${s.title}${dates}${marker}`)
17
+ }
18
+ return text(lines.join('\n'))
19
+ }
20
+
21
+ export async function toolActivateSprint(args: Record<string, unknown>): Promise<ToolResult> {
22
+ const sprintId = args.sprint_id as string
23
+ const r1 = await execute('UPDATE nav_sprints SET active = 0 WHERE active = 1', [])
24
+ if (r1.error) return err(r1.error)
25
+ const r2 = await execute('UPDATE nav_sprints SET active = 1 WHERE id = ?', [sprintId])
26
+ if (r2.error) return err(r2.error)
27
+ if (r2.rowsAffected === 0) return err(`Sprint '${sprintId}' not found.`)
28
+ return text(`✅ Sprint ${sprintId} activated.`)
29
+ }
30
+
31
+ export async function toolCloseSprint(args: Record<string, unknown>): Promise<ToolResult> {
32
+ const sprintId = args.sprint_id as string
33
+
34
+ // Check status
35
+ const sprint = await query<{ id: string; status: string }>('SELECT id, status FROM nav_sprints WHERE id = ?', [sprintId])
36
+ if (sprint.error) return err(sprint.error)
37
+ if (!sprint.rows.length) return err(`Sprint '${sprintId}' not found`)
38
+ if (sprint.rows[0].status !== 'active') return err(`Can only close sprints in active state (current: ${sprint.rows[0].status})`)
39
+
40
+ // Fetch stories
41
+ const stories = await query<{ id: number; title: string; sprint: string | null; status: string; story_points: number | null; assignee: string | null }>(
42
+ 'SELECT id, title, sprint, status, story_points, assignee FROM pm_stories WHERE sprint = ?', [sprintId])
43
+ if (stories.error) return err(stories.error)
44
+
45
+ const { getIncompleteStories, getCompletedStories, generateCloseSummary } = await import('../utils/sprint-lifecycle.js')
46
+ const completed = getCompletedStories(stories.rows)
47
+ const incomplete = getIncompleteStories(stories.rows)
48
+ const summary = generateCloseSummary(sprintId, stories.rows)
49
+
50
+ // Incomplete → backlog
51
+ if (incomplete.length > 0) {
52
+ const ids = incomplete.map((s: { id: number }) => s.id)
53
+ const ph = ids.map(() => '?').join(', ')
54
+ await execute(`UPDATE pm_stories SET sprint = NULL, updated_at = CURRENT_TIMESTAMP WHERE id IN (${ph})`, ids)
55
+ }
56
+
57
+ // Close
58
+ await execute(`UPDATE nav_sprints SET status = 'closed', active = 0, velocity = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?`, [summary.doneSP, sprintId])
59
+
60
+ // Create retro session
61
+ try { await execute(`INSERT OR IGNORE INTO retro_sessions (sprint, phase) VALUES (?, 'collect')`, [sprintId]) } catch (_) {}
62
+
63
+ const lines = [
64
+ `🏁 ${sprintId} Sprint closed!`,
65
+ '─────────────',
66
+ `Completed: ${completed.length} (${summary.doneSP} SP)`,
67
+ `Incomplete: ${incomplete.length} → Returned to backlog`,
68
+ `Completion rate: ${summary.completionRate}%`,
69
+ ]
70
+ if (incomplete.length > 0) {
71
+ lines.push('', 'Stories returned to backlog:')
72
+ for (const s of incomplete) lines.push(` • S${s.id}: ${s.title}`)
73
+ }
74
+ return text(lines.join('\n'))
75
+ }
76
+
77
+ export async function toolCheckinSprint(args: Record<string, unknown>): Promise<ToolResult> {
78
+ const sprintId = args.sprint_id as string
79
+ const memberIds = args.member_ids as number[]
80
+ if (!sprintId || !memberIds?.length) return err('sprint_id, member_ids required')
81
+ await execute('DELETE FROM sprint_members WHERE sprint_id = ?', [sprintId])
82
+ for (const mid of memberIds) {
83
+ await execute('INSERT OR REPLACE INTO sprint_members (sprint_id, member_id, working_days) VALUES (?, ?, 0)', [sprintId, mid])
84
+ }
85
+ await execute('UPDATE nav_sprints SET team_size = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?', [memberIds.length, sprintId])
86
+ return text(`✅ ${sprintId} check-in: ${memberIds.length} members registered`)
87
+ }
88
+
89
+ export async function toolAddAbsence(args: Record<string, unknown>): Promise<ToolResult> {
90
+ const sprintId = args.sprint_id as string
91
+ const memberId = args.member_id as number
92
+ const dates = args.dates as string[]
93
+ const reason = (args.reason as string) ?? null
94
+ if (!sprintId || !memberId || !dates?.length) return err('sprint_id, member_id, dates required')
95
+ for (const d of dates) {
96
+ await execute('INSERT OR IGNORE INTO member_absences (sprint_id, member_id, absence_date, reason) VALUES (?, ?, ?, ?)', [sprintId, memberId, d, reason])
97
+ }
98
+ return text(`✅ ${sprintId} absence registered: ${dates.length} day(s)`)
99
+ }
100
+
101
+ export async function toolGetVelocity(): Promise<ToolResult> {
102
+ const result = await query<{ sprint: string; done_sp: number; total_sp: number; story_count: number }>(
103
+ `SELECT s.sprint,
104
+ SUM(CASE WHEN s.status = 'done' THEN COALESCE(s.story_points, 0) ELSE 0 END) as done_sp,
105
+ SUM(COALESCE(s.story_points, 0)) as total_sp,
106
+ COUNT(*) as story_count
107
+ FROM pm_stories s
108
+ JOIN nav_sprints ns ON s.sprint = ns.id
109
+ WHERE ns.status = 'closed' AND s.sprint IS NOT NULL
110
+ GROUP BY s.sprint
111
+ ORDER BY ns.sort_order`)
112
+ if (result.error) return err(result.error)
113
+
114
+ const sprints = result.rows
115
+ const doneSPs = sprints.map(s => s.done_sp)
116
+ const avgVelocity = doneSPs.length ? Math.round(doneSPs.reduce((a, b) => a + b, 0) / doneSPs.length) : 0
117
+ const lastThree = doneSPs.slice(-3)
118
+ const recentAvg = lastThree.length ? Math.round(lastThree.reduce((a, b) => a + b, 0) / lastThree.length) : 0
119
+
120
+ const lines = [
121
+ '📊 Velocity Report',
122
+ '─────────────',
123
+ `Overall average: ${avgVelocity} SP (${sprints.length} sprints)`,
124
+ `Recent 3 average: ${recentAvg} SP`,
125
+ '',
126
+ 'Sprint results:',
127
+ ]
128
+ for (const s of sprints) {
129
+ lines.push(` ${s.sprint}: ${s.done_sp}/${s.total_sp} SP (${s.story_count} stories)`)
130
+ }
131
+ if (!sprints.length) lines.push(' (No completed sprints)')
132
+ return text(lines.join('\n'))
133
+ }
134
+
135
+ export async function toolKickoffSprint(args: Record<string, unknown>): Promise<ToolResult> {
136
+ const sprintId = args.sprint_id as string
137
+ const storyIds = args.story_ids as number[]
138
+ const teamMembers = (args.team_members as string[]) ?? []
139
+ const velocity = (args.velocity as number) ?? null
140
+
141
+ if (!storyIds?.length) return err('story_ids required')
142
+
143
+ // Check sprint status
144
+ const sprint = await query<{ id: string; status: string }>('SELECT id, status FROM nav_sprints WHERE id = ?', [sprintId])
145
+ if (sprint.error) return err(sprint.error)
146
+ if (!sprint.rows.length) return err(`Sprint '${sprintId}' not found`)
147
+ if (sprint.rows[0].status !== 'planning') return err(`Kickoff is only available in planning state (current: ${sprint.rows[0].status})`)
148
+
149
+ // Total SP
150
+ const placeholders = storyIds.map(() => '?').join(', ')
151
+ const stories = await query<{ id: number; title: string; story_points: number | null }>(
152
+ `SELECT id, title, story_points FROM pm_stories WHERE id IN (${placeholders})`, storyIds)
153
+ if (stories.error) return err(stories.error)
154
+ const totalSP = stories.rows.reduce((sum, s) => sum + (s.story_points ?? 0), 0)
155
+
156
+ // Assign stories
157
+ const assignResult = await execute(
158
+ `UPDATE pm_stories SET sprint = ?, updated_at = CURRENT_TIMESTAMP WHERE id IN (${placeholders})`,
159
+ [sprintId, ...storyIds])
160
+ if (assignResult.error) return err(assignResult.error)
161
+
162
+ // Activate sprint
163
+ await execute('UPDATE nav_sprints SET active = 0, status = \'closed\', updated_at = CURRENT_TIMESTAMP WHERE active = 1 AND id != ?', [sprintId])
164
+ await execute('UPDATE nav_sprints SET status = \'active\', active = 1, velocity = ?, team_size = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?',
165
+ [velocity, teamMembers.length || null, sprintId])
166
+
167
+ const lines = [
168
+ `🚀 ${sprintId} Kickoff complete!`,
169
+ `─────────────`,
170
+ `Stories: ${storyIds.length} assigned`,
171
+ `SP total: ${totalSP}${velocity ? ` / velocity: ${velocity}` : ''}`,
172
+ ]
173
+ if (velocity && totalSP > velocity) lines.push(`⚠️ SP exceeds velocity target!`)
174
+ if (teamMembers.length) lines.push(`Members: ${teamMembers.join(', ')}`)
175
+ return text(lines.join('\n'))
176
+ }
177
+
178
+ export async function toolSprintSummary(args: Record<string, unknown>): Promise<ToolResult> {
179
+ const sprint = await resolveSprint(args.sprint as string | undefined)
180
+ if (!sprint) return err('Please specify a sprint.')
181
+
182
+ const [epicResult, assigneeResult, blockerResult] = await Promise.all([
183
+ query<{ epic_title: string; total: number; done: number }>(`SELECT COALESCE(e.title, '(No epic)') as epic_title, COUNT(t.id) as total, SUM(CASE WHEN t.status = 'done' THEN 1 ELSE 0 END) as done FROM pm_tasks t JOIN pm_stories s ON t.story_id = s.id LEFT JOIN pm_epics e ON s.epic_id = e.id WHERE s.sprint = ? GROUP BY e.title ORDER BY e.title`, [sprint]),
184
+ query<{ assignee: string; total: number; done: number; in_progress: number; todo: number }>(`SELECT COALESCE(t.assignee, 'Unassigned') as assignee, COUNT(t.id) as total, SUM(CASE WHEN t.status = 'done' THEN 1 ELSE 0 END) as done, SUM(CASE WHEN t.status = 'in-progress' THEN 1 ELSE 0 END) as in_progress, SUM(CASE WHEN t.status = 'todo' THEN 1 ELSE 0 END) as todo FROM pm_tasks t JOIN pm_stories s ON t.story_id = s.id WHERE s.sprint = ? GROUP BY t.assignee ORDER BY total DESC`, [sprint]),
185
+ query<{ user_name: string; blockers: string; entry_date: string }>(`SELECT user_name, blockers, entry_date FROM pm_standup_entries WHERE sprint = ? AND blockers IS NOT NULL AND blockers != '' ORDER BY entry_date DESC LIMIT 10`, [sprint]),
186
+ ])
187
+
188
+ const lines = [`📊 ${sprint.toUpperCase()} Sprint Summary`, '─────────────']
189
+ if (!epicResult.error) for (const e of epicResult.rows) {
190
+ const pct = e.total > 0 ? Math.round((e.done / e.total) * 100) : 0
191
+ lines.push(`🏷 ${e.epic_title}: ${progressBar(e.done, e.total)} ${e.done}/${e.total} (${pct}%)`)
192
+ }
193
+ if (!assigneeResult.error && assigneeResult.rows.length > 0) {
194
+ lines.push('')
195
+ for (const a of assigneeResult.rows) lines.push(`👤 ${a.assignee}: ${a.total} tasks (done ${a.done}, in-progress ${a.in_progress}, todo ${a.todo})`)
196
+ }
197
+ if (!blockerResult.error && blockerResult.rows.length > 0) {
198
+ lines.push('', `🚧 Blockers: ${blockerResult.rows.length}`)
199
+ for (const b of blockerResult.rows) lines.push(` - [${b.entry_date}] ${b.user_name}: ${b.blockers}`)
200
+ } else {
201
+ lines.push('', '🚧 Blockers: 0')
202
+ }
203
+ return text(lines.join('\n'))
204
+ }