popilot 0.5.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (171) hide show
  1. package/adapters/codex/.codex/commands/_domain.md.hbs +33 -0
  2. package/adapters/codex/.codex/commands/analytics.md.hbs +55 -0
  3. package/adapters/codex/.codex/commands/daily.md.hbs +301 -0
  4. package/adapters/codex/.codex/commands/dev.md.hbs +62 -0
  5. package/adapters/codex/.codex/commands/gtm.md +82 -0
  6. package/adapters/codex/.codex/commands/handoff.md +259 -0
  7. package/adapters/codex/.codex/commands/market.md +120 -0
  8. package/adapters/codex/.codex/commands/metrics.md +123 -0
  9. package/adapters/codex/.codex/commands/oscar-loop.md +436 -0
  10. package/adapters/codex/.codex/commands/party.md +85 -0
  11. package/adapters/codex/.codex/commands/plan.md +43 -0
  12. package/adapters/codex/.codex/commands/research.md +203 -0
  13. package/adapters/codex/.codex/commands/retro.md +68 -0
  14. package/adapters/codex/.codex/commands/save.md +440 -0
  15. package/adapters/codex/.codex/commands/sessions.md +139 -0
  16. package/adapters/codex/.codex/commands/sprint.md +106 -0
  17. package/adapters/codex/.codex/commands/start.md +396 -0
  18. package/adapters/codex/.codex/commands/strategy.md +41 -0
  19. package/adapters/codex/.codex/commands/task.md +220 -0
  20. package/adapters/codex/.codex/commands/tracking.md +116 -0
  21. package/adapters/codex/.codex/commands/validate.md +58 -0
  22. package/adapters/codex/AGENTS.md.hbs +210 -0
  23. package/adapters/codex/manifest.yaml +36 -0
  24. package/adapters/gemini/.gemini/commands/_domain.md.hbs +33 -0
  25. package/adapters/gemini/.gemini/commands/analytics.md.hbs +55 -0
  26. package/adapters/gemini/.gemini/commands/daily.md.hbs +301 -0
  27. package/adapters/gemini/.gemini/commands/dev.md.hbs +62 -0
  28. package/adapters/gemini/.gemini/commands/gtm.md +82 -0
  29. package/adapters/gemini/.gemini/commands/handoff.md +259 -0
  30. package/adapters/gemini/.gemini/commands/market.md +120 -0
  31. package/adapters/gemini/.gemini/commands/metrics.md +123 -0
  32. package/adapters/gemini/.gemini/commands/oscar-loop.md +436 -0
  33. package/adapters/gemini/.gemini/commands/party.md +85 -0
  34. package/adapters/gemini/.gemini/commands/plan.md +43 -0
  35. package/adapters/gemini/.gemini/commands/research.md +203 -0
  36. package/adapters/gemini/.gemini/commands/retro.md +68 -0
  37. package/adapters/gemini/.gemini/commands/save.md +440 -0
  38. package/adapters/gemini/.gemini/commands/sessions.md +139 -0
  39. package/adapters/gemini/.gemini/commands/sprint.md +106 -0
  40. package/adapters/gemini/.gemini/commands/start.md +396 -0
  41. package/adapters/gemini/.gemini/commands/strategy.md +41 -0
  42. package/adapters/gemini/.gemini/commands/task.md +220 -0
  43. package/adapters/gemini/.gemini/commands/tracking.md +116 -0
  44. package/adapters/gemini/.gemini/commands/validate.md +58 -0
  45. package/adapters/gemini/GEMINI.md.hbs +210 -0
  46. package/adapters/gemini/manifest.yaml +36 -0
  47. package/bin/cli.mjs +215 -4
  48. package/lib/doctor.mjs +38 -1
  49. package/lib/hydrate.mjs +15 -0
  50. package/lib/industry-presets.mjs +135 -0
  51. package/lib/scaffold.mjs +5 -0
  52. package/lib/setup-wizard.mjs +71 -2
  53. package/package.json +1 -1
  54. package/scaffold/.context/agents/TEMPLATE.md +14 -0
  55. package/scaffold/.context/agents/analyst.md.hbs +3 -3
  56. package/scaffold/.context/agents/developer.md.hbs +5 -5
  57. package/scaffold/.context/agents/gtm-strategist.md.hbs +3 -3
  58. package/scaffold/.context/agents/handoff-specialist.md.hbs +18 -18
  59. package/scaffold/.context/agents/market-researcher.md.hbs +6 -6
  60. package/scaffold/.context/agents/orchestrator.md.hbs +8 -8
  61. package/scaffold/.context/agents/planner.md.hbs +6 -6
  62. package/scaffold/.context/agents/qa.md.hbs +5 -5
  63. package/scaffold/.context/agents/researcher.md.hbs +33 -6
  64. package/scaffold/.context/agents/strategist.md.hbs +8 -8
  65. package/scaffold/.context/agents/tracking-governor.md.hbs +2 -2
  66. package/scaffold/.context/project.yaml.example +25 -0
  67. package/scaffold/mcp-pm/package.json +19 -0
  68. package/scaffold/mcp-pm/src/api-client.ts +69 -0
  69. package/scaffold/mcp-pm/src/index.ts +660 -0
  70. package/scaffold/mcp-pm/tsconfig.json +14 -0
  71. package/scaffold/pm-api/package.json +21 -0
  72. package/scaffold/pm-api/sql/schema-core.sql +331 -0
  73. package/scaffold/pm-api/sql/schema-docs.sql +25 -0
  74. package/scaffold/pm-api/sql/schema-meetings.sql +17 -0
  75. package/scaffold/pm-api/sql/schema-rewards.sql +16 -0
  76. package/scaffold/pm-api/src/auth.ts +28 -0
  77. package/scaffold/pm-api/src/blockchain/adapter.ts +20 -0
  78. package/scaffold/pm-api/src/blockchain/tron.ts +62 -0
  79. package/scaffold/pm-api/src/db/adapter.ts +36 -0
  80. package/scaffold/pm-api/src/db/turso.ts +147 -0
  81. package/scaffold/pm-api/src/index.ts +114 -0
  82. package/scaffold/pm-api/src/mcp-tools/dashboard.ts +40 -0
  83. package/scaffold/pm-api/src/mcp-tools/epic.ts +67 -0
  84. package/scaffold/pm-api/src/mcp-tools/event.ts +89 -0
  85. package/scaffold/pm-api/src/mcp-tools/index.ts +11 -0
  86. package/scaffold/pm-api/src/mcp-tools/initiative.ts +51 -0
  87. package/scaffold/pm-api/src/mcp-tools/memo.ts +164 -0
  88. package/scaffold/pm-api/src/mcp-tools/notification.ts +37 -0
  89. package/scaffold/pm-api/src/mcp-tools/retro.ts +183 -0
  90. package/scaffold/pm-api/src/mcp-tools/sprint.ts +204 -0
  91. package/scaffold/pm-api/src/mcp-tools/standup.ts +136 -0
  92. package/scaffold/pm-api/src/mcp-tools/story.ts +230 -0
  93. package/scaffold/pm-api/src/mcp-tools/task.ts +187 -0
  94. package/scaffold/pm-api/src/mcp-tools/utils.ts +83 -0
  95. package/scaffold/pm-api/src/mcp.ts +871 -0
  96. package/scaffold/pm-api/src/nudge.ts +283 -0
  97. package/scaffold/pm-api/src/routes/auth.ts +32 -0
  98. package/scaffold/pm-api/src/routes/v2-activity.ts +27 -0
  99. package/scaffold/pm-api/src/routes/v2-admin.ts +165 -0
  100. package/scaffold/pm-api/src/routes/v2-dashboard.ts +189 -0
  101. package/scaffold/pm-api/src/routes/v2-docs.ts +34 -0
  102. package/scaffold/pm-api/src/routes/v2-initiatives.ts +118 -0
  103. package/scaffold/pm-api/src/routes/v2-kickoff.ts +265 -0
  104. package/scaffold/pm-api/src/routes/v2-meetings.ts +324 -0
  105. package/scaffold/pm-api/src/routes/v2-memos.ts +257 -0
  106. package/scaffold/pm-api/src/routes/v2-nav.ts +260 -0
  107. package/scaffold/pm-api/src/routes/v2-notifications.ts +79 -0
  108. package/scaffold/pm-api/src/routes/v2-page-content.ts +35 -0
  109. package/scaffold/pm-api/src/routes/v2-pm.ts +380 -0
  110. package/scaffold/pm-api/src/routes/v2-policy.ts +58 -0
  111. package/scaffold/pm-api/src/routes/v2-retro.ts +221 -0
  112. package/scaffold/pm-api/src/routes/v2-rewards.ts +132 -0
  113. package/scaffold/pm-api/src/routes/v2-scenarios.ts +48 -0
  114. package/scaffold/pm-api/src/routes/v2-search.ts +32 -0
  115. package/scaffold/pm-api/src/routes/v2-standup.ts +127 -0
  116. package/scaffold/pm-api/src/routes/v2-user.ts +38 -0
  117. package/scaffold/pm-api/src/types.ts +11 -0
  118. package/scaffold/pm-api/src/utils/activity.ts +22 -0
  119. package/scaffold/pm-api/src/utils/admin.ts +9 -0
  120. package/scaffold/pm-api/src/utils/agent-notify.ts +62 -0
  121. package/scaffold/pm-api/src/utils/assignee.ts +69 -0
  122. package/scaffold/pm-api/src/utils/db.ts +45 -0
  123. package/scaffold/pm-api/src/utils/initiative.ts +23 -0
  124. package/scaffold/pm-api/src/utils/sprint-lifecycle.ts +96 -0
  125. package/scaffold/pm-api/tsconfig.json +15 -0
  126. package/scaffold/pm-api/wrangler.toml.hbs +11 -0
  127. package/scaffold/spec-site/package-lock.json +40 -0
  128. package/scaffold/spec-site/package.json +4 -1
  129. package/scaffold/spec-site/src/api/types.ts +6 -0
  130. package/scaffold/spec-site/src/components/AppHeader.vue +429 -55
  131. package/scaffold/spec-site/src/components/MemberSelect.vue +48 -0
  132. package/scaffold/spec-site/src/components/NotificationDropdown.vue +116 -0
  133. package/scaffold/spec-site/src/components/SearchModal.vue +102 -0
  134. package/scaffold/spec-site/src/components/VelocityChart.vue +77 -0
  135. package/scaffold/spec-site/src/composables/pmTypes.ts +15 -2
  136. package/scaffold/spec-site/src/composables/useDashboard.ts +221 -0
  137. package/scaffold/spec-site/src/composables/useMediaQuery.ts +28 -0
  138. package/scaffold/spec-site/src/composables/useNotification.ts +200 -0
  139. package/scaffold/spec-site/src/composables/usePmStore.ts +48 -1
  140. package/scaffold/spec-site/src/composables/useRetro.ts +6 -0
  141. package/scaffold/spec-site/src/composables/useStandup.ts +201 -0
  142. package/scaffold/spec-site/src/composables/useTheme.ts +37 -0
  143. package/scaffold/spec-site/src/composables/useUser.ts +19 -1
  144. package/scaffold/spec-site/src/features.ts +108 -0
  145. package/scaffold/spec-site/src/pages/AdminPage.vue +299 -0
  146. package/scaffold/spec-site/src/pages/DashboardPage.vue +650 -0
  147. package/scaffold/spec-site/src/pages/DocsHub.vue +157 -0
  148. package/scaffold/spec-site/src/pages/InboxPage.vue +156 -0
  149. package/scaffold/spec-site/src/pages/MeetingsPage.vue +294 -0
  150. package/scaffold/spec-site/src/pages/MyPage.vue +343 -0
  151. package/scaffold/spec-site/src/pages/RewardsPage.vue +266 -0
  152. package/scaffold/spec-site/src/pages/board/BoardAdmin.vue +422 -0
  153. package/scaffold/spec-site/src/pages/board/BoardEpicSection.vue +54 -0
  154. package/scaffold/spec-site/src/pages/board/BoardPage.vue +884 -0
  155. package/scaffold/spec-site/src/pages/board/BoardStoryCard.vue +67 -0
  156. package/scaffold/spec-site/src/pages/board/BoardTaskItem.vue +52 -0
  157. package/scaffold/spec-site/src/pages/board/MyTasksPage.vue +202 -0
  158. package/scaffold/spec-site/src/pages/board/SprintClose.vue +167 -0
  159. package/scaffold/spec-site/src/pages/board/SprintColumn.vue +49 -0
  160. package/scaffold/spec-site/src/pages/board/SprintKickoff.vue +389 -0
  161. package/scaffold/spec-site/src/pages/board/StatusBadge.vue +52 -0
  162. package/scaffold/spec-site/src/pages/board/StoryDetailPanel.vue +495 -0
  163. package/scaffold/spec-site/src/pages/board/TaskCard.vue +42 -0
  164. package/scaffold/spec-site/src/pages/retro/RetroCard.vue +36 -2
  165. package/scaffold/spec-site/src/pages/retro/RetroHeader.vue +82 -66
  166. package/scaffold/spec-site/src/pages/retro/RetroPage.vue +47 -18
  167. package/scaffold/spec-site/src/pages/standup/StandupEntryCard.vue +551 -0
  168. package/scaffold/spec-site/src/pages/standup/StandupForm.vue +68 -0
  169. package/scaffold/spec-site/src/pages/standup/StandupList.vue +71 -0
  170. package/scaffold/spec-site/src/pages/standup/StandupPage.vue +225 -0
  171. package/scaffold/spec-site/src/router.ts +141 -0
@@ -0,0 +1,230 @@
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 toolListStories(args: Record<string, unknown>): Promise<ToolResult> {
5
+ const isBacklog = (args.sprint as string)?.toLowerCase() === 'backlog'
6
+ const sprint = isBacklog ? null : await resolveSprint(args.sprint as string | undefined)
7
+ if (!isBacklog && !sprint) return err('Please specify a sprint.')
8
+
9
+ let sql = `SELECT s.id, s.title, s.status, s.priority, s.assignee, s.area, s.story_points,
10
+ e.id as epic_id, e.title as epic_title
11
+ FROM pm_stories s LEFT JOIN pm_epics e ON s.epic_id = e.id`
12
+ const sqlArgs: (string | number)[] = []
13
+
14
+ if (isBacklog) {
15
+ sql += ' WHERE s.sprint IS NULL'
16
+ } else {
17
+ sql += ' WHERE s.sprint = ?'
18
+ sqlArgs.push(sprint!)
19
+ }
20
+
21
+ if (args.epic_id !== undefined) { sql += ' AND s.epic_id = ?'; sqlArgs.push(args.epic_id as number) }
22
+ if (args.status) { sql += ' AND s.status = ?'; sqlArgs.push(args.status as string) }
23
+ if (args.assignee) { sql += ' AND s.assignee LIKE ?'; sqlArgs.push(`%${args.assignee as string}%`) }
24
+ sql += ' ORDER BY e.title, s.sort_order'
25
+
26
+ const result = await query<{
27
+ id: number; title: string; status: string; priority: string; assignee: string | null
28
+ area: string; story_points: number | null; epic_id: number | null; epic_title: string | null
29
+ }>(sql, sqlArgs)
30
+ if (result.error) return err(result.error)
31
+ const label = isBacklog ? 'Backlog' : sprint!.toUpperCase()
32
+ if (result.rows.length === 0) return text(`No stories in ${label}.`)
33
+
34
+ const statusIcon: Record<string, string> = { draft: '📝', backlog: '📋', ready: '🟡', 'in-progress': '🔵', review: '🟣', done: '✅' }
35
+ const priorityIcon: Record<string, string> = { low: '⬇️', medium: '➡️', high: '⬆️', critical: '🔴' }
36
+
37
+ const lines = [`📖 ${label} Stories (${result.rows.length})`, '─────────────']
38
+ let lastEpic: string | null = null
39
+ for (const s of result.rows) {
40
+ const epic = s.epic_title ?? '(No epic)'
41
+ if (epic !== lastEpic) { lines.push(`\n🏷 ${epic}`); lastEpic = epic }
42
+ const pts = s.story_points ? ` ${s.story_points}pt` : ''
43
+ lines.push(` ${statusIcon[s.status] ?? '⬜'} [S${s.id}] ${s.title} ${priorityIcon[s.priority] ?? ''}${pts} (${s.assignee ?? 'Unassigned'})`)
44
+ }
45
+ return text(lines.join('\n'))
46
+ }
47
+
48
+ export async function toolListBacklog(args: Record<string, unknown>): Promise<ToolResult> {
49
+ let sql = `SELECT s.id, s.title, s.status, s.priority, s.assignee, s.area, s.story_points,
50
+ e.id as epic_id, e.title as epic_title
51
+ FROM pm_stories s LEFT JOIN pm_epics e ON s.epic_id = e.id WHERE s.sprint IS NULL`
52
+ const sqlArgs: (string | number)[] = []
53
+
54
+ if (args.epic_id !== undefined) { sql += ' AND s.epic_id = ?'; sqlArgs.push(args.epic_id as number) }
55
+ if (args.status) { sql += ' AND s.status = ?'; sqlArgs.push(args.status as string) }
56
+ if (args.assignee) { sql += ' AND s.assignee LIKE ?'; sqlArgs.push(`%${args.assignee as string}%`) }
57
+ sql += ' ORDER BY e.title, s.sort_order'
58
+
59
+ const result = await query<{
60
+ id: number; title: string; status: string; priority: string; assignee: string | null
61
+ area: string; story_points: number | null; epic_id: number | null; epic_title: string | null
62
+ }>(sql, sqlArgs)
63
+ if (result.error) return err(result.error)
64
+ if (result.rows.length === 0) return text('No stories in backlog.')
65
+
66
+ const statusIcon: Record<string, string> = { draft: '📝', backlog: '📋', ready: '🟡', 'in-progress': '🔵', review: '🟣', done: '✅' }
67
+ const priorityIcon: Record<string, string> = { low: '⬇️', medium: '➡️', high: '⬆️', critical: '🔴' }
68
+
69
+ const lines = [`📦 Backlog Stories (${result.rows.length})`, '─────────────']
70
+ let lastEpic: string | null = null
71
+ for (const s of result.rows) {
72
+ const epic = s.epic_title ?? '(No epic)'
73
+ if (epic !== lastEpic) { lines.push(`\n🏷 ${epic}`); lastEpic = epic }
74
+ const pts = s.story_points ? ` ${s.story_points}pt` : ''
75
+ lines.push(` ${statusIcon[s.status] ?? '⬜'} [S${s.id}] ${s.title} ${priorityIcon[s.priority] ?? ''}${pts} (${s.assignee ?? 'Unassigned'})`)
76
+ }
77
+ return text(lines.join('\n'))
78
+ }
79
+
80
+ export async function toolAddStory(user: string, args: Record<string, unknown>): Promise<ToolResult> {
81
+ const title = args.title as string
82
+ const sprintArg = args.sprint as string | undefined
83
+ const isBacklog = !sprintArg || sprintArg.toLowerCase() === 'backlog'
84
+ const sprint = isBacklog ? null : await resolveSprint(sprintArg)
85
+
86
+ const epicId = (args.epic_id as number) ?? null
87
+ const description = (args.description as string) ?? null
88
+ const acceptanceCriteria = (args.acceptance_criteria as string) ?? null
89
+ const assignee = (args.assignee as string) ?? null
90
+ const assigneeErr = await validateAssignee(assignee)
91
+ if (assigneeErr) return err(assigneeErr)
92
+ const assigneeId = assignee ? await resolveMemberId(assignee) : null
93
+ const priority = (args.priority as string) || 'medium'
94
+ const area = (args.area as string) || 'FE'
95
+ const storyPoints = (args.story_points as number) ?? null
96
+
97
+ const maxResult = sprint
98
+ ? await query<{ max_order: number | null }>('SELECT MAX(sort_order) as max_order FROM pm_stories WHERE sprint = ?', [sprint])
99
+ : await query<{ max_order: number | null }>('SELECT MAX(sort_order) as max_order FROM pm_stories WHERE sprint IS NULL')
100
+ const sortOrder = (maxResult.rows[0]?.max_order ?? -1) + 1
101
+
102
+ const epicUid = epicId ? `pm:${epicId}` : 'pm:0'
103
+
104
+ const result = await execute(
105
+ 'INSERT INTO pm_stories (epic_id, epic_uid, sprint, title, description, acceptance_criteria, assignee, assignee_id, status, priority, area, story_points, sort_order, start_date, due_date) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
106
+ [epicId, epicUid, sprint, title, description, acceptanceCriteria, assignee, assigneeId, 'backlog', priority, area, storyPoints, sortOrder, (args.start_date as string) ?? null, (args.due_date as string) ?? null],
107
+ )
108
+ if (result.error) return err(result.error)
109
+ const idResult = await query<{ id: number }>('SELECT last_insert_rowid() as id')
110
+ const newId = idResult.rows[0]?.id ?? '?'
111
+
112
+ // Notify assignee(s)
113
+ if (assignee) {
114
+ for (const a of assignee.split(',').map(s => s.trim()).filter(Boolean)) {
115
+ await notify(a, 'story_assigned', `Story assigned: ${title}`, `${user} assigned story [S${newId}].`, 'story', String(newId), 'board', user)
116
+ }
117
+ }
118
+
119
+ const epicLabel = epicId ? ` (Epic #${epicId})` : ''
120
+ const sprintLabel = sprint ? sprint.toUpperCase() : 'Backlog'
121
+ return text(`✅ Story created${epicLabel}: ${title} (ID: ${newId}, ${sprintLabel})`)
122
+ }
123
+
124
+ export async function toolUpdateStory(user: string, args: Record<string, unknown>): Promise<ToolResult> {
125
+ const storyId = args.story_id as number
126
+
127
+ // Get current story for notification comparison
128
+ const current = await query<{ assignee: string | null; title: string }>(
129
+ 'SELECT assignee, title FROM pm_stories WHERE id = ?', [storyId],
130
+ )
131
+ if (current.error) return err(current.error)
132
+ if (current.rows.length === 0) return err(`Story #${storyId} not found.`)
133
+ const oldAssignee = current.rows[0].assignee
134
+ const storyTitle = current.rows[0].title
135
+
136
+ if (args.assignee !== undefined) {
137
+ const assigneeErr = await validateAssignee(args.assignee as string | null)
138
+ if (assigneeErr) return err(assigneeErr)
139
+ }
140
+
141
+ // Normalize sprint='backlog' to NULL
142
+ if (args.sprint !== undefined) {
143
+ const sv = args.sprint as string | null
144
+ if (sv === null || (typeof sv === 'string' && sv.toLowerCase() === 'backlog')) {
145
+ args.sprint = null
146
+ }
147
+ }
148
+
149
+ const fieldMap: Record<string, string> = {
150
+ title: 'title', description: 'description', acceptance_criteria: 'acceptance_criteria',
151
+ assignee: 'assignee', status: 'status', priority: 'priority', area: 'area',
152
+ story_points: 'story_points', figma_url: 'figma_url', epic_id: 'epic_id', sprint: 'sprint',
153
+ }
154
+ const sets: string[] = []
155
+ const sqlArgs: (string | number | null)[] = []
156
+
157
+ for (const [key, col] of Object.entries(fieldMap)) {
158
+ if (args[key] !== undefined) {
159
+ sets.push(`${col} = ?`)
160
+ sqlArgs.push(args[key] as string | number | null)
161
+ }
162
+ }
163
+
164
+ // Also update assignee_id when assignee changes
165
+ if (args.assignee !== undefined) {
166
+ const newAssignee = args.assignee as string | null
167
+ const newAssigneeId = newAssignee ? await resolveMemberId(newAssignee) : null
168
+ sets.push('assignee_id = ?')
169
+ sqlArgs.push(newAssigneeId)
170
+ }
171
+
172
+ if (sets.length === 0) return text('No fields to update.')
173
+
174
+ sets.push('updated_at = CURRENT_TIMESTAMP')
175
+ sqlArgs.push(storyId)
176
+ const result = await execute(`UPDATE pm_stories SET ${sets.join(', ')} WHERE id = ?`, sqlArgs)
177
+ if (result.error) return err(result.error)
178
+ if (result.rowsAffected === 0) return err(`Story #${storyId} not found.`)
179
+
180
+ // Notify new assignee(s) on assignment change
181
+ if (args.assignee !== undefined) {
182
+ const newAssignee = args.assignee as string | null
183
+ if (newAssignee) {
184
+ const oldSet = new Set((oldAssignee ?? '').split(',').map(s => s.trim()).filter(Boolean))
185
+ for (const a of newAssignee.split(',').map(s => s.trim()).filter(Boolean)) {
186
+ if (!oldSet.has(a)) {
187
+ await notify(a, 'story_assigned', `Story assigned: ${storyTitle}`, `${user} assigned story [S${storyId}].`, 'story', String(storyId), 'board', user)
188
+ }
189
+ }
190
+ }
191
+ }
192
+
193
+ // Agent webhook notification (on status change)
194
+ if (args.status !== undefined) {
195
+ const assignee = (args.assignee as string) ?? oldAssignee
196
+ if (assignee) {
197
+ const { notifyByName } = await import('../utils/agent-notify.js')
198
+ await notifyByName(assignee, `📌 Status changed`, `S${storyId}: ${storyTitle}\nStatus: ${args.status}`)
199
+ }
200
+ }
201
+
202
+ return text(`✅ Story #${storyId} updated`)
203
+ }
204
+
205
+ export async function toolDeleteStory(args: Record<string, unknown>): Promise<ToolResult> {
206
+ const storyId = args.story_id as number
207
+ const r1 = await execute('DELETE FROM pm_tasks WHERE story_id = ?', [storyId])
208
+ if (r1.error) return err(r1.error)
209
+ const r2 = await execute('DELETE FROM pm_stories WHERE id = ?', [storyId])
210
+ if (r2.error) return err(r2.error)
211
+ if (r2.rowsAffected === 0) return err(`Story #${storyId} not found.`)
212
+ return text(`✅ Story #${storyId} deleted (${r1.rowsAffected} tasks also deleted)`)
213
+ }
214
+
215
+ export async function toolAssignStory(args: Record<string, unknown>): Promise<ToolResult> {
216
+ const storyId = args.story_id as number
217
+ const sprintId = args.sprint_id as string
218
+ if (!storyId || !sprintId) return err('story_id, sprint_id required')
219
+ const r = await execute('UPDATE pm_stories SET sprint = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?', [sprintId, storyId])
220
+ if (r.error) return err(r.error)
221
+ return text(`✅ S${storyId} → ${sprintId} assigned`)
222
+ }
223
+
224
+ export async function toolUnassignStory(args: Record<string, unknown>): Promise<ToolResult> {
225
+ const storyId = args.story_id as number
226
+ if (!storyId) return err('story_id required')
227
+ const r = await execute('UPDATE pm_stories SET sprint = NULL, updated_at = CURRENT_TIMESTAMP WHERE id = ?', [storyId])
228
+ if (r.error) return err(r.error)
229
+ return text(`✅ S${storyId} → backlog unassigned`)
230
+ }
@@ -0,0 +1,187 @@
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 toolListTasks(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
+ const status = args.status as string | undefined
8
+
9
+ let sql = `SELECT t.id as task_id, t.title as task_title, t.status as task_status, s.id as story_id, s.title as story_title, e.id as epic_id, e.title as epic_title 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 t.assignee = ? AND s.sprint = ?`
10
+ const sqlArgs: (string | number)[] = [user, sprint]
11
+ if (status) { sql += ' AND t.status = ?'; sqlArgs.push(status) }
12
+ sql += ' ORDER BY e.title, s.sort_order, t.sort_order'
13
+
14
+ const result = await query<{ task_id: number; task_title: string; task_status: string; story_id: number; story_title: string; epic_id: number | null; epic_title: string | null }>(sql, sqlArgs)
15
+ if (result.error) return err(result.error)
16
+ if (result.rows.length === 0) return text(`No tasks found for ${user}.`)
17
+
18
+ const statusIcon: Record<string, string> = { todo: '⬜', 'in-progress': '🔵', done: '✅' }
19
+ const tree = new Map<string, Map<string, Array<{ id: number; title: string; status: string }>>>()
20
+ for (const r of result.rows) {
21
+ const ek = r.epic_title ?? '(No epic)'
22
+ if (!tree.has(ek)) tree.set(ek, new Map())
23
+ const em = tree.get(ek)!
24
+ const sk = `[S${r.story_id}] ${r.story_title}`
25
+ if (!em.has(sk)) em.set(sk, [])
26
+ em.get(sk)!.push({ id: r.task_id, title: r.task_title, status: r.task_status })
27
+ }
28
+
29
+ const lines: string[] = [`📋 ${user}'s Tasks (${sprint.toUpperCase()})`, '─────────────']
30
+ for (const [epicTitle, stories] of tree) {
31
+ lines.push(`\n🏷 ${epicTitle}`)
32
+ for (const [storyTitle, tasks] of stories) {
33
+ lines.push(` 📖 ${storyTitle}`)
34
+ for (const t of tasks) lines.push(` ${statusIcon[t.status] ?? '⬜'} [T${t.id}] ${t.title}`)
35
+ }
36
+ }
37
+ return text(lines.join('\n'))
38
+ }
39
+
40
+ export async function toolGetTask(args: Record<string, unknown>): Promise<ToolResult> {
41
+ const taskId = args.task_id as number
42
+ const taskResult = await query<{ id: number; story_id: number; title: string; assignee: string | null; status: string; description: string | null; sort_order: number; created_at: string; updated_at: string }>('SELECT * FROM pm_tasks WHERE id = ?', [taskId])
43
+ if (taskResult.error) return err(taskResult.error)
44
+ if (taskResult.rows.length === 0) return err(`Task #${taskId} not found.`)
45
+
46
+ const t = taskResult.rows[0]
47
+ const [storyResult, siblingsResult] = await Promise.all([
48
+ query<{ id: number; title: string; description: string | null; acceptance_criteria: string | null; assignee: string | null; status: string; sprint: string }>('SELECT id, title, description, acceptance_criteria, assignee, status, sprint FROM pm_stories WHERE id = ?', [t.story_id]),
49
+ query<{ id: number; title: string; status: string; assignee: string | null }>('SELECT id, title, status, assignee FROM pm_tasks WHERE story_id = ? ORDER BY sort_order', [t.story_id]),
50
+ ])
51
+
52
+ const si: Record<string, string> = { todo: '⬜', 'in-progress': '🔵', done: '✅' }
53
+ const s = storyResult.rows[0]
54
+ const lines = [`📋 Task #${t.id}: ${t.title}`, '─────────────', `Status: ${si[t.status] ?? '⬜'} ${t.status}`, `Assignee: ${t.assignee ?? 'Unassigned'}`, t.description ? `Description: ${t.description}` : '', `Created: ${t.created_at} | Modified: ${t.updated_at}`].filter(Boolean)
55
+ if (s) {
56
+ lines.push('', `📖 Parent Story [S${s.id}]: ${s.title}`, ` Sprint: ${s.sprint} | Status: ${s.status} | Assignee: ${s.assignee ?? 'Unassigned'}`)
57
+ if (s.description) lines.push(` Description: ${s.description}`)
58
+ if (s.acceptance_criteria) lines.push(` AC: ${s.acceptance_criteria}`)
59
+ }
60
+ if (siblingsResult.rows.length > 0) {
61
+ lines.push('', '👥 Sibling Tasks:')
62
+ for (const sb of siblingsResult.rows) {
63
+ const marker = sb.id === taskId ? ' ← current' : ''
64
+ lines.push(` ${si[sb.status] ?? '⬜'} [T${sb.id}] ${sb.title} (${sb.assignee ?? 'Unassigned'})${marker}`)
65
+ }
66
+ }
67
+ return text(lines.join('\n'))
68
+ }
69
+
70
+ export async function toolUpdateTaskStatus(args: Record<string, unknown>): Promise<ToolResult> {
71
+ const taskId = args.task_id as number
72
+ const status = args.status as string
73
+ const result = await execute('UPDATE pm_tasks SET status = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?', [status, taskId])
74
+ if (result.error) return err(result.error)
75
+ if (result.rowsAffected === 0) return err(`Task #${taskId} not found.`)
76
+ return text(`✅ Task #${taskId} → ${status}`)
77
+ }
78
+
79
+ export async function toolUpdateTask(user: string, args: Record<string, unknown>): Promise<ToolResult> {
80
+ const taskId = args.task_id as number
81
+
82
+ // Get current for notification
83
+ const current = await query<{ assignee: string | null; title: string }>(
84
+ 'SELECT assignee, title FROM pm_tasks WHERE id = ?', [taskId],
85
+ )
86
+ if (current.error) return err(current.error)
87
+ if (current.rows.length === 0) return err(`Task #${taskId} not found.`)
88
+ const oldAssignee = current.rows[0].assignee
89
+ const taskTitle = current.rows[0].title
90
+
91
+ if (args.assignee !== undefined) {
92
+ const assigneeErr = await validateAssignee(args.assignee as string | null)
93
+ if (assigneeErr) return err(assigneeErr)
94
+ }
95
+
96
+ const fieldMap: Record<string, string> = { title: 'title', assignee: 'assignee', status: 'status', description: 'description', story_points: 'story_points' }
97
+ const sets: string[] = []
98
+ const sqlArgs: (string | number | null)[] = []
99
+
100
+ for (const [key, col] of Object.entries(fieldMap)) {
101
+ if (args[key] !== undefined) {
102
+ sets.push(`${col} = ?`)
103
+ sqlArgs.push(args[key] as string | number | null)
104
+ }
105
+ }
106
+
107
+ // Also update assignee_id when assignee changes
108
+ if (args.assignee !== undefined) {
109
+ const newAssignee = args.assignee as string | null
110
+ const newAssigneeId = newAssignee ? await resolveMemberId(newAssignee) : null
111
+ sets.push('assignee_id = ?')
112
+ sqlArgs.push(newAssigneeId)
113
+ }
114
+
115
+ if (sets.length === 0) return text('No fields to update.')
116
+
117
+ sets.push('updated_at = CURRENT_TIMESTAMP')
118
+ sqlArgs.push(taskId)
119
+ const result = await execute(`UPDATE pm_tasks SET ${sets.join(', ')} WHERE id = ?`, sqlArgs)
120
+ if (result.error) return err(result.error)
121
+
122
+ // Auto-sum story SP when task SP changes
123
+ if (args.story_points !== undefined) {
124
+ const storyResult = await query<{ story_id: number }>('SELECT story_id FROM pm_tasks WHERE id = ?', [taskId])
125
+ if (!storyResult.error && storyResult.rows.length > 0) {
126
+ const sid = storyResult.rows[0].story_id
127
+ const spResult = await query<{ total: number | null }>('SELECT SUM(story_points) as total FROM pm_tasks WHERE story_id = ?', [sid])
128
+ if (!spResult.error && spResult.rows.length > 0) {
129
+ await execute('UPDATE pm_stories SET story_points = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?', [spResult.rows[0].total ?? 0, sid])
130
+ }
131
+ }
132
+ }
133
+
134
+ // Notify on assignee change
135
+ if (args.assignee !== undefined) {
136
+ const newAssignee = args.assignee as string | null
137
+ if (newAssignee && newAssignee !== oldAssignee) {
138
+ await notify(newAssignee, 'task_assigned', `Task assigned: ${taskTitle}`, `${user} assigned task [T${taskId}].`, 'task', String(taskId), 'board', user)
139
+ }
140
+ }
141
+
142
+ return text(`✅ Task #${taskId} updated`)
143
+ }
144
+
145
+ export async function toolAddTask(user: string, args: Record<string, unknown>): Promise<ToolResult> {
146
+ const storyId = args.story_id as number
147
+ const title = args.title as string
148
+ const assignee = (args.assignee as string) || user
149
+ const assigneeErr = await validateAssignee(assignee)
150
+ if (assigneeErr) return err(assigneeErr)
151
+ const assigneeId = await resolveMemberId(assignee)
152
+ const description = args.description as string | undefined
153
+
154
+ const storyPoints = (args.story_points as number) ?? null
155
+
156
+ const maxResult = await query<{ max_order: number | null }>('SELECT MAX(sort_order) as max_order FROM pm_tasks WHERE story_id = ?', [storyId])
157
+ const sortOrder = (maxResult.rows[0]?.max_order ?? -1) + 1
158
+ const result = await execute('INSERT INTO pm_tasks (story_id, title, assignee, assignee_id, description, sort_order, story_points) VALUES (?, ?, ?, ?, ?, ?, ?)', [storyId, title, assignee, assigneeId, description ?? null, sortOrder, storyPoints])
159
+ if (result.error) return err(result.error)
160
+
161
+ const idResult = await query<{ id: number }>('SELECT last_insert_rowid() as id')
162
+ const newId = idResult.rows[0]?.id ?? '?'
163
+
164
+ // Auto-sum story SP
165
+ if (storyPoints != null) {
166
+ const spResult = await query<{ total: number | null }>('SELECT SUM(story_points) as total FROM pm_tasks WHERE story_id = ?', [storyId])
167
+ if (!spResult.error && spResult.rows.length > 0) {
168
+ await execute('UPDATE pm_stories SET story_points = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?', [spResult.rows[0].total ?? 0, storyId])
169
+ }
170
+ }
171
+
172
+ // Notify assignee
173
+ if (assignee !== user) {
174
+ await notify(assignee, 'task_assigned', `Task assigned: ${title}`, `${user} assigned task [T${newId}].`, 'task', String(newId), 'board', user)
175
+ }
176
+
177
+ const spLabel = storyPoints != null ? ` ${storyPoints}SP` : ''
178
+ return text(`✅ Task added (Story #${storyId}): ${title}${spLabel} (ID: ${newId})`)
179
+ }
180
+
181
+ export async function toolDeleteTask(args: Record<string, unknown>): Promise<ToolResult> {
182
+ const taskId = args.task_id as number
183
+ const result = await execute('DELETE FROM pm_tasks WHERE id = ?', [taskId])
184
+ if (result.error) return err(result.error)
185
+ if (result.rowsAffected === 0) return err(`Task #${taskId} not found.`)
186
+ return text(`✅ Task #${taskId} deleted`)
187
+ }
@@ -0,0 +1,83 @@
1
+ import { query, execute } from '../db/adapter.js'
2
+
3
+ export type ToolResult = { content: Array<{ type: string; text: string }>; isError?: boolean }
4
+
5
+ export function today(): string {
6
+ const d = new Date()
7
+ const y = d.getFullYear()
8
+ const m = String(d.getMonth() + 1).padStart(2, '0')
9
+ const day = String(d.getDate()).padStart(2, '0')
10
+ return `${y}-${m}-${day}`
11
+ }
12
+
13
+ export function text(t: string): ToolResult {
14
+ return { content: [{ type: 'text', text: t }] }
15
+ }
16
+
17
+ export function err(msg: string): ToolResult {
18
+ return text(`Error: ${msg}`)
19
+ }
20
+
21
+ export async function getActiveSprint(): Promise<string | null> {
22
+ const result = await query<{ id: string }>('SELECT id FROM nav_sprints WHERE active = 1 LIMIT 1')
23
+ return result.rows?.[0]?.id ?? null
24
+ }
25
+
26
+ export async function resolveSprint(arg?: string): Promise<string | null> {
27
+ return arg || await getActiveSprint()
28
+ }
29
+
30
+ export async function notify(
31
+ userName: string, type: string, title: string, body: string,
32
+ sourceType: string, sourceId: string | number, pageId: string, actor: string,
33
+ ): Promise<void> {
34
+ if (userName === actor) return
35
+ await execute(
36
+ 'INSERT INTO notifications (user_name, type, title, body, source_type, source_id, page_id, actor) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
37
+ [userName, type, title, body, sourceType, String(sourceId), pageId, actor],
38
+ )
39
+ }
40
+
41
+ export { validateAssignee, resolveMemberId } from '../utils/assignee.js'
42
+
43
+ const RATE_LIMIT = 30
44
+ const emitRateMap = new Map<string, number[]>()
45
+
46
+ export function checkRateLimit(user: string): boolean {
47
+ const now = Date.now()
48
+ const window = 60_000
49
+ if (emitRateMap.size > 100) {
50
+ for (const [k, v] of emitRateMap) {
51
+ const filtered = v.filter(t => now - t < window)
52
+ if (filtered.length === 0) emitRateMap.delete(k)
53
+ else emitRateMap.set(k, filtered)
54
+ }
55
+ }
56
+ const timestamps = (emitRateMap.get(user) ?? []).filter(t => now - t < window)
57
+ if (timestamps.length >= RATE_LIMIT) return false
58
+ timestamps.push(now)
59
+ emitRateMap.set(user, timestamps)
60
+ return true
61
+ }
62
+
63
+ export async function emitAgentEvent(
64
+ eventType: string, sourceAgent: string, targetAgent: string,
65
+ targetUser: string, payload: Record<string, unknown>,
66
+ ): Promise<void> {
67
+ try {
68
+ const expiresAt = new Date(Date.now() + 24 * 3600_000).toISOString()
69
+ await execute(
70
+ `INSERT INTO agent_events (event_type, source_agent, target_agent, target_user, payload, expires_at)
71
+ VALUES (?, ?, ?, ?, ?, ?)`,
72
+ [eventType, sourceAgent, targetAgent, targetUser, JSON.stringify(payload), expiresAt],
73
+ )
74
+ } catch {
75
+ // fire-and-forget
76
+ }
77
+ }
78
+
79
+ export function progressBar(done: number, total: number, width = 10): string {
80
+ if (total === 0) return '░'.repeat(width)
81
+ const filled = Math.round((done / total) * width)
82
+ return '█'.repeat(filled) + '░'.repeat(width - filled)
83
+ }