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.
- package/bin/cli.mjs +204 -2
- package/lib/doctor.mjs +38 -1
- package/lib/hydrate.mjs +15 -0
- package/lib/scaffold.mjs +5 -0
- package/lib/setup-wizard.mjs +35 -2
- package/package.json +1 -1
- package/scaffold/.context/project.yaml.example +19 -0
- package/scaffold/mcp-notification-server/package.json +18 -0
- package/scaffold/mcp-notification-server/src/index.ts +275 -0
- package/scaffold/mcp-notification-server/src/turso-client.ts +142 -0
- package/scaffold/mcp-notification-server/tsconfig.json +14 -0
- package/scaffold/mcp-pm/package.json +19 -0
- package/scaffold/mcp-pm/src/api-client.ts +69 -0
- package/scaffold/mcp-pm/src/index.ts +660 -0
- package/scaffold/mcp-pm/tsconfig.json +14 -0
- package/scaffold/pm-api/package.json +21 -0
- package/scaffold/pm-api/sql/001-memo-v2.sql +49 -0
- package/scaffold/pm-api/sql/002-notifications.sql +18 -0
- package/scaffold/pm-api/sql/003-content.sql +66 -0
- package/scaffold/pm-api/sql/004-agent-events.sql +21 -0
- package/scaffold/pm-api/sql/005-epic-sprint-decoupling.sql +6 -0
- package/scaffold/pm-api/sql/schema-core.sql +331 -0
- package/scaffold/pm-api/sql/schema-docs.sql +25 -0
- package/scaffold/pm-api/sql/schema-meetings.sql +17 -0
- package/scaffold/pm-api/sql/schema-rewards.sql +16 -0
- package/scaffold/pm-api/src/auth.ts +28 -0
- package/scaffold/pm-api/src/blockchain/adapter.ts +20 -0
- package/scaffold/pm-api/src/blockchain/tron.ts +62 -0
- package/scaffold/pm-api/src/db/adapter.ts +36 -0
- package/scaffold/pm-api/src/db/turso.ts +147 -0
- package/scaffold/pm-api/src/index.ts +114 -0
- package/scaffold/pm-api/src/mcp-tools/dashboard.ts +40 -0
- package/scaffold/pm-api/src/mcp-tools/epic.ts +67 -0
- package/scaffold/pm-api/src/mcp-tools/event.ts +89 -0
- package/scaffold/pm-api/src/mcp-tools/index.ts +11 -0
- package/scaffold/pm-api/src/mcp-tools/initiative.ts +51 -0
- package/scaffold/pm-api/src/mcp-tools/memo.ts +164 -0
- package/scaffold/pm-api/src/mcp-tools/notification.ts +37 -0
- package/scaffold/pm-api/src/mcp-tools/retro.ts +183 -0
- package/scaffold/pm-api/src/mcp-tools/sprint.ts +204 -0
- package/scaffold/pm-api/src/mcp-tools/standup.ts +136 -0
- package/scaffold/pm-api/src/mcp-tools/story.ts +230 -0
- package/scaffold/pm-api/src/mcp-tools/task.ts +187 -0
- package/scaffold/pm-api/src/mcp-tools/utils.ts +83 -0
- package/scaffold/pm-api/src/mcp.ts +871 -0
- package/scaffold/pm-api/src/nudge.ts +283 -0
- package/scaffold/pm-api/src/routes/auth.ts +32 -0
- package/scaffold/pm-api/src/routes/v2-activity.ts +27 -0
- package/scaffold/pm-api/src/routes/v2-admin.ts +165 -0
- package/scaffold/pm-api/src/routes/v2-dashboard.ts +189 -0
- package/scaffold/pm-api/src/routes/v2-docs.ts +34 -0
- package/scaffold/pm-api/src/routes/v2-initiatives.ts +118 -0
- package/scaffold/pm-api/src/routes/v2-kickoff.ts +265 -0
- package/scaffold/pm-api/src/routes/v2-meetings.ts +324 -0
- package/scaffold/pm-api/src/routes/v2-memos.ts +257 -0
- package/scaffold/pm-api/src/routes/v2-nav.ts +260 -0
- package/scaffold/pm-api/src/routes/v2-notifications.ts +79 -0
- package/scaffold/pm-api/src/routes/v2-page-content.ts +35 -0
- package/scaffold/pm-api/src/routes/v2-pm.ts +380 -0
- package/scaffold/pm-api/src/routes/v2-policy.ts +58 -0
- package/scaffold/pm-api/src/routes/v2-retro.ts +221 -0
- package/scaffold/pm-api/src/routes/v2-rewards.ts +132 -0
- package/scaffold/pm-api/src/routes/v2-scenarios.ts +48 -0
- package/scaffold/pm-api/src/routes/v2-search.ts +32 -0
- package/scaffold/pm-api/src/routes/v2-standup.ts +127 -0
- package/scaffold/pm-api/src/routes/v2-user.ts +38 -0
- package/scaffold/pm-api/src/types.ts +11 -0
- package/scaffold/pm-api/src/utils/activity.ts +22 -0
- package/scaffold/pm-api/src/utils/admin.ts +9 -0
- package/scaffold/pm-api/src/utils/agent-notify.ts +62 -0
- package/scaffold/pm-api/src/utils/assignee.ts +69 -0
- package/scaffold/pm-api/src/utils/db.ts +45 -0
- package/scaffold/pm-api/src/utils/initiative.ts +23 -0
- package/scaffold/pm-api/src/utils/retro-link.ts +32 -0
- package/scaffold/pm-api/src/utils/sprint-lifecycle.ts +96 -0
- package/scaffold/pm-api/tsconfig.json +15 -0
- package/scaffold/pm-api/wrangler.toml.hbs +11 -0
- package/scaffold/spec-site/package-lock.json +892 -0
- package/scaffold/spec-site/package.json +15 -1
- package/scaffold/spec-site/src/api/types.ts +6 -0
- package/scaffold/spec-site/src/components/AppHeader.vue +429 -55
- package/scaffold/spec-site/src/components/AuthGate.vue +117 -0
- package/scaffold/spec-site/src/components/BurndownChart.vue +78 -0
- package/scaffold/spec-site/src/components/DocComments.vue +137 -0
- package/scaffold/spec-site/src/components/DocEditor.vue +118 -0
- package/scaffold/spec-site/src/components/DocExportBar.vue +110 -0
- package/scaffold/spec-site/src/components/DocsSidebar.vue +309 -0
- package/scaffold/spec-site/src/components/EmptyState.vue +30 -0
- package/scaffold/spec-site/src/components/ErrorBanner.vue +38 -0
- package/scaffold/spec-site/src/components/Icon.vue +58 -0
- package/scaffold/spec-site/src/components/MemberSelect.vue +48 -0
- package/scaffold/spec-site/src/components/MemoChecklist.vue +88 -0
- package/scaffold/spec-site/src/components/MemoGraph.vue +75 -0
- package/scaffold/spec-site/src/components/MemoItem.vue +353 -0
- package/scaffold/spec-site/src/components/MemoRelations.vue +101 -0
- package/scaffold/spec-site/src/components/MemoTimeline.vue +53 -0
- package/scaffold/spec-site/src/components/MentionInput.vue +174 -0
- package/scaffold/spec-site/src/components/NotificationDropdown.vue +116 -0
- package/scaffold/spec-site/src/components/PriorityBadge.vue +23 -0
- package/scaffold/spec-site/src/components/SearchModal.vue +102 -0
- package/scaffold/spec-site/src/components/SlashCommand.ts +123 -0
- package/scaffold/spec-site/src/components/StateDisplay.vue +54 -0
- package/scaffold/spec-site/src/components/TreeNode.vue +82 -0
- package/scaffold/spec-site/src/components/UserAvatar.vue +24 -0
- package/scaffold/spec-site/src/components/VelocityChart.vue +77 -0
- package/scaffold/spec-site/src/composables/navTypes.ts +3 -0
- package/scaffold/spec-site/src/composables/pmTypes.ts +15 -2
- package/scaffold/spec-site/src/composables/useBottomSheet.ts +103 -0
- package/scaffold/spec-site/src/composables/useDashboard.ts +221 -0
- package/scaffold/spec-site/src/composables/useMediaQuery.ts +28 -0
- package/scaffold/spec-site/src/composables/useMemo.ts +39 -0
- package/scaffold/spec-site/src/composables/useNotification.ts +200 -0
- package/scaffold/spec-site/src/composables/usePmStore.ts +48 -1
- package/scaffold/spec-site/src/composables/useRetro.ts +6 -0
- package/scaffold/spec-site/src/composables/useStandup.ts +201 -0
- package/scaffold/spec-site/src/composables/useTheme.ts +37 -0
- package/scaffold/spec-site/src/composables/useTurso.ts +17 -0
- package/scaffold/spec-site/src/composables/useUser.ts +19 -1
- package/scaffold/spec-site/src/composables/useViewport.ts +26 -0
- package/scaffold/spec-site/src/features.ts +108 -0
- package/scaffold/spec-site/src/mockup/ComponentPalette.vue +61 -0
- package/scaffold/spec-site/src/mockup/MockupCanvas.vue +459 -0
- package/scaffold/spec-site/src/mockup/PropertyPanel.vue +217 -0
- package/scaffold/spec-site/src/mockup/componentCatalog.ts +68 -0
- package/scaffold/spec-site/src/mockup/useScenarios.ts +67 -0
- package/scaffold/spec-site/src/pages/AdminPage.vue +299 -0
- package/scaffold/spec-site/src/pages/DashboardPage.vue +650 -0
- package/scaffold/spec-site/src/pages/DocsEditor.vue +119 -0
- package/scaffold/spec-site/src/pages/DocsHub.vue +157 -0
- package/scaffold/spec-site/src/pages/DocsPage.vue +444 -0
- package/scaffold/spec-site/src/pages/InboxPage.vue +156 -0
- package/scaffold/spec-site/src/pages/MeetingsPage.vue +294 -0
- package/scaffold/spec-site/src/pages/MemosPage.vue +857 -0
- package/scaffold/spec-site/src/pages/MockupEditorPage.vue +611 -0
- package/scaffold/spec-site/src/pages/MockupListPage.vue +121 -0
- package/scaffold/spec-site/src/pages/MockupViewerPage.vue +199 -0
- package/scaffold/spec-site/src/pages/MyPage.vue +343 -0
- package/scaffold/spec-site/src/pages/NotificationSettingsPage.vue +59 -0
- package/scaffold/spec-site/src/pages/RewardsPage.vue +266 -0
- package/scaffold/spec-site/src/pages/SprintAdmin.vue +521 -0
- package/scaffold/spec-site/src/pages/SprintTimeline.vue +159 -0
- package/scaffold/spec-site/src/pages/board/BoardAdmin.vue +422 -0
- package/scaffold/spec-site/src/pages/board/BoardEpicSection.vue +54 -0
- package/scaffold/spec-site/src/pages/board/BoardPage.vue +884 -0
- package/scaffold/spec-site/src/pages/board/BoardStoryCard.vue +67 -0
- package/scaffold/spec-site/src/pages/board/BoardTaskItem.vue +52 -0
- package/scaffold/spec-site/src/pages/board/KanbanBoard.vue +93 -0
- package/scaffold/spec-site/src/pages/board/MyTasksPage.vue +202 -0
- package/scaffold/spec-site/src/pages/board/SprintClose.vue +167 -0
- package/scaffold/spec-site/src/pages/board/SprintColumn.vue +49 -0
- package/scaffold/spec-site/src/pages/board/SprintKickoff.vue +389 -0
- package/scaffold/spec-site/src/pages/board/StatusBadge.vue +52 -0
- package/scaffold/spec-site/src/pages/board/StoryDetailPanel.vue +495 -0
- package/scaffold/spec-site/src/pages/board/TaskCard.vue +42 -0
- package/scaffold/spec-site/src/pages/retro/RetroCard.vue +36 -2
- package/scaffold/spec-site/src/pages/retro/RetroHeader.vue +82 -66
- package/scaffold/spec-site/src/pages/retro/RetroPage.vue +47 -18
- package/scaffold/spec-site/src/pages/standup/StandupEntryCard.vue +551 -0
- package/scaffold/spec-site/src/pages/standup/StandupForm.vue +68 -0
- package/scaffold/spec-site/src/pages/standup/StandupList.vue +71 -0
- package/scaffold/spec-site/src/pages/standup/StandupPage.vue +225 -0
- package/scaffold/spec-site/src/router.ts +141 -0
- package/scaffold/spec-site/src/styles/buttons.css +124 -0
- package/scaffold/spec-site/src/utils/parseMentions.ts +56 -0
- package/scaffold/spec-site/src/utils/timezone.ts +18 -0
|
@@ -0,0 +1,136 @@
|
|
|
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 toolGetStandup(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 d = (args.date as string) || today()
|
|
8
|
+
|
|
9
|
+
const result = await query<{ id: number; done_text: string | null; plan_text: string | null; plan_story_ids: string | null; blockers: string | null; created_at: string; updated_at: string }>('SELECT * FROM pm_standup_entries WHERE sprint = ? AND user_name = ? AND entry_date = ?', [sprint, user, d])
|
|
10
|
+
if (result.error) return err(result.error)
|
|
11
|
+
if (result.rows.length === 0) return text(`📝 No standup found for ${d}.`)
|
|
12
|
+
|
|
13
|
+
const e = result.rows[0]
|
|
14
|
+
const storyIds: number[] = e.plan_story_ids ? JSON.parse(e.plan_story_ids) : []
|
|
15
|
+
let storyTitles = ''
|
|
16
|
+
if (storyIds.length > 0) {
|
|
17
|
+
const ph = storyIds.map(() => '?').join(',')
|
|
18
|
+
const sr = await query<{ id: number; title: string }>(`SELECT id, title FROM pm_stories WHERE id IN (${ph})`, storyIds)
|
|
19
|
+
if (!sr.error) storyTitles = sr.rows.map(s => ` - [S${s.id}] ${s.title}`).join('\n')
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const lines = [`📝 ${user}'s Standup (${d})`, '─────────────', `✅ Done: ${e.done_text ?? '(none)'}`, `📌 Plan: ${e.plan_text ?? '(none)'}`]
|
|
23
|
+
if (storyTitles) lines.push(`📖 Planned Stories:\n${storyTitles}`)
|
|
24
|
+
if (e.blockers) lines.push(`🚧 Blockers: ${e.blockers}`)
|
|
25
|
+
return text(lines.join('\n'))
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export async function toolSaveStandup(user: string, args: Record<string, unknown>): Promise<ToolResult> {
|
|
29
|
+
const sprint = await resolveSprint(args.sprint as string | undefined)
|
|
30
|
+
if (!sprint) return err('Please specify a sprint.')
|
|
31
|
+
const d = (args.date as string) || today()
|
|
32
|
+
const storyIdsJson = (args.plan_story_ids as number[] | undefined)?.length ? JSON.stringify(args.plan_story_ids) : null
|
|
33
|
+
|
|
34
|
+
const result = await execute(
|
|
35
|
+
`INSERT INTO pm_standup_entries (sprint, user_name, entry_date, done_text, plan_text, plan_story_ids, blockers, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) ON CONFLICT(sprint, user_name, entry_date) DO UPDATE SET done_text = excluded.done_text, plan_text = excluded.plan_text, plan_story_ids = excluded.plan_story_ids, blockers = excluded.blockers, updated_at = CURRENT_TIMESTAMP`,
|
|
36
|
+
[sprint, user, d, (args.done_text as string) ?? null, (args.plan_text as string) ?? null, storyIdsJson, (args.blockers as string) ?? null],
|
|
37
|
+
)
|
|
38
|
+
if (result.error) return err(result.error)
|
|
39
|
+
return text(`✅ ${d} standup saved`)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export async function toolListStandupEntries(args: Record<string, unknown>): Promise<ToolResult> {
|
|
43
|
+
const sprint = await resolveSprint(args.sprint as string | undefined)
|
|
44
|
+
const date = args.date as string | undefined
|
|
45
|
+
|
|
46
|
+
let sql: string
|
|
47
|
+
const sqlArgs: (string | number)[] = []
|
|
48
|
+
|
|
49
|
+
if (sprint && date) {
|
|
50
|
+
sql = 'SELECT * FROM pm_standup_entries WHERE sprint = ? AND entry_date = ? ORDER BY user_name'
|
|
51
|
+
sqlArgs.push(sprint, date)
|
|
52
|
+
} else if (date) {
|
|
53
|
+
sql = 'SELECT * FROM pm_standup_entries WHERE entry_date = ? ORDER BY user_name'
|
|
54
|
+
sqlArgs.push(date)
|
|
55
|
+
} else if (sprint) {
|
|
56
|
+
sql = 'SELECT * FROM pm_standup_entries WHERE sprint = ? ORDER BY entry_date DESC, user_name LIMIT 50'
|
|
57
|
+
sqlArgs.push(sprint)
|
|
58
|
+
} else {
|
|
59
|
+
return err('Please specify a sprint or date.')
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const result = await query<{ user_name: string; entry_date: string; done_text: string | null; plan_text: string | null; blockers: string | null }>(sql, sqlArgs)
|
|
63
|
+
if (result.error) return err(result.error)
|
|
64
|
+
if (result.rows.length === 0) return text('No standup records found.')
|
|
65
|
+
|
|
66
|
+
const lines = ['📝 Standup Records', '─────────────']
|
|
67
|
+
for (const e of result.rows) {
|
|
68
|
+
lines.push(`\n👤 ${e.user_name} (${e.entry_date})`)
|
|
69
|
+
if (e.done_text) lines.push(` ✅ ${e.done_text}`)
|
|
70
|
+
if (e.plan_text) lines.push(` 📌 ${e.plan_text}`)
|
|
71
|
+
if (e.blockers) lines.push(` 🚧 ${e.blockers}`)
|
|
72
|
+
}
|
|
73
|
+
return text(lines.join('\n'))
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// ═══════════════════════════════════════════
|
|
77
|
+
// Standup Feedback
|
|
78
|
+
// ═══════════════════════════════════════════
|
|
79
|
+
|
|
80
|
+
export async function toolReviewStandup(user: string, args: Record<string, unknown>): Promise<ToolResult> {
|
|
81
|
+
const entryId = args.standup_entry_id as number
|
|
82
|
+
const sprint = args.sprint as string
|
|
83
|
+
const targetUser = args.target_user as string
|
|
84
|
+
const feedbackText = args.feedback_text as string
|
|
85
|
+
const reviewType = (args.review_type as string) || 'comment'
|
|
86
|
+
|
|
87
|
+
if (!['comment', 'approve', 'request_changes'].includes(reviewType)) {
|
|
88
|
+
return text('❌ review_type must be one of: comment, approve, request_changes.')
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Verify the standup entry exists and validate consistency
|
|
92
|
+
const entryCheck = await query<{ id: number; user_name: string; sprint: string }>(
|
|
93
|
+
'SELECT id, user_name, sprint FROM pm_standup_entries WHERE id = ?', [entryId],
|
|
94
|
+
)
|
|
95
|
+
if (entryCheck.rows.length === 0) return text('❌ Standup entry not found.')
|
|
96
|
+
const entry = entryCheck.rows[0]
|
|
97
|
+
if (entry.sprint !== sprint) return text(`❌ Sprint mismatch: entry belongs to ${entry.sprint} but ${sprint} was requested.`)
|
|
98
|
+
if (entry.user_name !== targetUser) return text(`❌ Target user mismatch: entry author is ${entry.user_name} but ${targetUser} was requested.`)
|
|
99
|
+
|
|
100
|
+
const result = await execute(
|
|
101
|
+
'INSERT INTO pm_standup_feedback (standup_entry_id, sprint, target_user, feedback_by, feedback_text, review_type) VALUES (?, ?, ?, ?, ?, ?)',
|
|
102
|
+
[entryId, sprint, targetUser, user, feedbackText, reviewType],
|
|
103
|
+
)
|
|
104
|
+
if (result.error) return text(`❌ ${result.error}`)
|
|
105
|
+
|
|
106
|
+
const icon = reviewType === 'approve' ? '✅' : reviewType === 'request_changes' ? '🔄' : '💬'
|
|
107
|
+
return text(`${icon} Feedback registered for ${targetUser}'s standup (${reviewType})`)
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export async function toolGetStandupFeedback(args: Record<string, unknown>): Promise<ToolResult> {
|
|
111
|
+
const entryId = args.standup_entry_id as number | undefined
|
|
112
|
+
const sprint = args.sprint as string | undefined
|
|
113
|
+
const user = args.user as string | undefined
|
|
114
|
+
|
|
115
|
+
let result
|
|
116
|
+
if (entryId) {
|
|
117
|
+
result = await query('SELECT * FROM pm_standup_feedback WHERE standup_entry_id = ? ORDER BY created_at ASC', [entryId])
|
|
118
|
+
} else if (sprint && user) {
|
|
119
|
+
result = await query('SELECT * FROM pm_standup_feedback WHERE sprint = ? AND target_user = ? ORDER BY created_at DESC LIMIT 50', [sprint, user])
|
|
120
|
+
} else {
|
|
121
|
+
return text('❌ standup_entry_id or (sprint + user) parameters required.')
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (result.error) return text(`❌ ${result.error}`)
|
|
125
|
+
if (result.rows.length === 0) return text('📝 No feedback found.')
|
|
126
|
+
|
|
127
|
+
const lines = ['📝 Standup Feedback', '─────────────']
|
|
128
|
+
for (const f of result.rows as Array<Record<string, unknown>>) {
|
|
129
|
+
const icon = f.review_type === 'approve' ? '✅' : f.review_type === 'request_changes' ? '🔄' : '💬'
|
|
130
|
+
lines.push(`${icon} [${f.review_type}] ${f.feedback_by} → ${f.target_user}`)
|
|
131
|
+
lines.push(` ${f.feedback_text}`)
|
|
132
|
+
lines.push(` ${f.created_at}`)
|
|
133
|
+
lines.push('')
|
|
134
|
+
}
|
|
135
|
+
return text(lines.join('\n'))
|
|
136
|
+
}
|
|
@@ -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
|
+
}
|