popilot 0.2.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 (136) hide show
  1. package/README.md +372 -0
  2. package/adapters/claude-code/.claude/commands/_domain.md.hbs +32 -0
  3. package/adapters/claude-code/.claude/commands/analytics.md.hbs +55 -0
  4. package/adapters/claude-code/.claude/commands/daily.md.hbs +301 -0
  5. package/adapters/claude-code/.claude/commands/dev.md.hbs +62 -0
  6. package/adapters/claude-code/.claude/commands/handoff.md +258 -0
  7. package/adapters/claude-code/.claude/commands/market.md +120 -0
  8. package/adapters/claude-code/.claude/commands/metrics.md +123 -0
  9. package/adapters/claude-code/.claude/commands/oscar-loop.md +436 -0
  10. package/adapters/claude-code/.claude/commands/party.md +85 -0
  11. package/adapters/claude-code/.claude/commands/plan.md +43 -0
  12. package/adapters/claude-code/.claude/commands/research.md +203 -0
  13. package/adapters/claude-code/.claude/commands/retro.md +68 -0
  14. package/adapters/claude-code/.claude/commands/save.md +440 -0
  15. package/adapters/claude-code/.claude/commands/sessions.md +139 -0
  16. package/adapters/claude-code/.claude/commands/sprint.md +106 -0
  17. package/adapters/claude-code/.claude/commands/start.md +368 -0
  18. package/adapters/claude-code/.claude/commands/strategy.md +41 -0
  19. package/adapters/claude-code/.claude/commands/task.md +220 -0
  20. package/adapters/claude-code/.claude/commands/tracking.md +116 -0
  21. package/adapters/claude-code/.claude/commands/validate.md +58 -0
  22. package/adapters/claude-code/CLAUDE.md.hbs +208 -0
  23. package/adapters/claude-code/manifest.yaml +36 -0
  24. package/bin/cli.mjs +218 -0
  25. package/lib/adapter.mjs +68 -0
  26. package/lib/doctor.mjs +161 -0
  27. package/lib/hydrate.mjs +421 -0
  28. package/lib/prompt.mjs +78 -0
  29. package/lib/scaffold.mjs +155 -0
  30. package/lib/setup-wizard.mjs +331 -0
  31. package/lib/template-engine.mjs +164 -0
  32. package/lib/yaml-lite.mjs +476 -0
  33. package/package.json +30 -0
  34. package/scaffold/.context/.secrets.yaml.example +20 -0
  35. package/scaffold/.context/WORKFLOW.md.hbs +332 -0
  36. package/scaffold/.context/agents/TEMPLATE.md +115 -0
  37. package/scaffold/.context/agents/analyst.md.hbs +362 -0
  38. package/scaffold/.context/agents/developer.md.hbs +390 -0
  39. package/scaffold/.context/agents/handoff-specialist.md.hbs +292 -0
  40. package/scaffold/.context/agents/market-researcher.md.hbs +288 -0
  41. package/scaffold/.context/agents/ollie.md +323 -0
  42. package/scaffold/.context/agents/operations.md.hbs +293 -0
  43. package/scaffold/.context/agents/orchestrator.md.hbs +434 -0
  44. package/scaffold/.context/agents/planner.md.hbs +405 -0
  45. package/scaffold/.context/agents/qa.md.hbs +409 -0
  46. package/scaffold/.context/agents/researcher.md.hbs +330 -0
  47. package/scaffold/.context/agents/sage.md +349 -0
  48. package/scaffold/.context/agents/strategist.md.hbs +339 -0
  49. package/scaffold/.context/agents/tracking-governor.md.hbs +291 -0
  50. package/scaffold/.context/agents/validator.md.hbs +365 -0
  51. package/scaffold/.context/integrations/_registry.yaml +38 -0
  52. package/scaffold/.context/integrations/providers/channel_io.yaml +38 -0
  53. package/scaffold/.context/integrations/providers/corti.yaml +203 -0
  54. package/scaffold/.context/integrations/providers/ga4.yaml +116 -0
  55. package/scaffold/.context/integrations/providers/intercom.yaml +47 -0
  56. package/scaffold/.context/integrations/providers/linear.yaml +46 -0
  57. package/scaffold/.context/integrations/providers/mixpanel.yaml +73 -0
  58. package/scaffold/.context/integrations/providers/notebooklm.yaml +74 -0
  59. package/scaffold/.context/integrations/providers/notion.yaml +129 -0
  60. package/scaffold/.context/integrations/providers/prod_db.yaml +183 -0
  61. package/scaffold/.context/oscar/workflows/multi-agent.md +82 -0
  62. package/scaffold/.context/oscar/workflows/ollie-sage.md +128 -0
  63. package/scaffold/.context/oscar/workflows/session-git.md +71 -0
  64. package/scaffold/.context/oscar/workflows/setup.md +663 -0
  65. package/scaffold/.context/oscar/workflows/tracking.md +118 -0
  66. package/scaffold/.context/project.yaml.example +102 -0
  67. package/scaffold/.context/templates/dev-guide.md +217 -0
  68. package/scaffold/.context/templates/epic-spec.md +225 -0
  69. package/scaffold/.context/templates/guardrail.md +94 -0
  70. package/scaffold/.context/templates/handoff-checklist.md +197 -0
  71. package/scaffold/.context/templates/prd.md +80 -0
  72. package/scaffold/.context/templates/retrospective.md +78 -0
  73. package/scaffold/.context/templates/screen-spec.md +714 -0
  74. package/scaffold/.context/templates/sprint-plan.md +72 -0
  75. package/scaffold/.context/templates/sprint-status.yaml +109 -0
  76. package/scaffold/.context/templates/story-v2.md +228 -0
  77. package/scaffold/.context/templates/validation-report.md +99 -0
  78. package/scaffold/.gitignore.append +7 -0
  79. package/scaffold/spec-site/env.d.ts +7 -0
  80. package/scaffold/spec-site/index.html +14 -0
  81. package/scaffold/spec-site/package.json +20 -0
  82. package/scaffold/spec-site/src/App.vue +27 -0
  83. package/scaffold/spec-site/src/assets/icons/menu/ic_ads.svg +10 -0
  84. package/scaffold/spec-site/src/assets/icons/menu/ic_ads_on.svg +10 -0
  85. package/scaffold/spec-site/src/assets/icons/menu/ic_board.svg +14 -0
  86. package/scaffold/spec-site/src/assets/icons/menu/ic_board_on.svg +14 -0
  87. package/scaffold/spec-site/src/assets/icons/menu/ic_dashboard.svg +21 -0
  88. package/scaffold/spec-site/src/assets/icons/menu/ic_dashboard_on.svg +21 -0
  89. package/scaffold/spec-site/src/assets/icons/menu/ic_pricing.svg +20 -0
  90. package/scaffold/spec-site/src/assets/icons/menu/ic_pricing_on.svg +20 -0
  91. package/scaffold/spec-site/src/assets/icons/menu/ic_store.svg +11 -0
  92. package/scaffold/spec-site/src/assets/icons/menu/ic_store_on.svg +11 -0
  93. package/scaffold/spec-site/src/components/Accordion.vue +108 -0
  94. package/scaffold/spec-site/src/components/AppHeader.vue +304 -0
  95. package/scaffold/spec-site/src/components/Badge.vue +25 -0
  96. package/scaffold/spec-site/src/components/CoachingCard.vue +112 -0
  97. package/scaffold/spec-site/src/components/MemoSidebar.vue +239 -0
  98. package/scaffold/spec-site/src/components/MockupShell.vue +100 -0
  99. package/scaffold/spec-site/src/components/RuleTable.vue +99 -0
  100. package/scaffold/spec-site/src/components/ScenarioSwitcher.vue +103 -0
  101. package/scaffold/spec-site/src/components/SpecNav.vue +26 -0
  102. package/scaffold/spec-site/src/components/SpecSection.vue +59 -0
  103. package/scaffold/spec-site/src/components/SummaryGrid.vue +39 -0
  104. package/scaffold/spec-site/src/components/VersionBadge.vue +38 -0
  105. package/scaffold/spec-site/src/composables/useActiveSection.ts +53 -0
  106. package/scaffold/spec-site/src/composables/useMemo.ts +138 -0
  107. package/scaffold/spec-site/src/composables/useRetro.ts +313 -0
  108. package/scaffold/spec-site/src/composables/useScenario.ts +43 -0
  109. package/scaffold/spec-site/src/composables/useScenarioStore.ts +102 -0
  110. package/scaffold/spec-site/src/composables/useTurso.ts +160 -0
  111. package/scaffold/spec-site/src/composables/useUser.ts +25 -0
  112. package/scaffold/spec-site/src/data/navigation.ts +59 -0
  113. package/scaffold/spec-site/src/data/types.ts +90 -0
  114. package/scaffold/spec-site/src/data/wireframeRegistry.ts +25 -0
  115. package/scaffold/spec-site/src/layouts/SplitPaneLayout.vue +79 -0
  116. package/scaffold/spec-site/src/main.ts +10 -0
  117. package/scaffold/spec-site/src/pages/IndexPage.vue +66 -0
  118. package/scaffold/spec-site/src/pages/PolicyDetail.vue +215 -0
  119. package/scaffold/spec-site/src/pages/PolicyIndex.vue +74 -0
  120. package/scaffold/spec-site/src/pages/retro/RetroActions.vue +191 -0
  121. package/scaffold/spec-site/src/pages/retro/RetroBoard.vue +192 -0
  122. package/scaffold/spec-site/src/pages/retro/RetroCard.vue +131 -0
  123. package/scaffold/spec-site/src/pages/retro/RetroHeader.vue +287 -0
  124. package/scaffold/spec-site/src/pages/retro/RetroPage.vue +178 -0
  125. package/scaffold/spec-site/src/pages/shared/NoContentPlaceholder.vue +34 -0
  126. package/scaffold/spec-site/src/pages/shared/PlaceholderContent.vue +22 -0
  127. package/scaffold/spec-site/src/pages/shared/PlaceholderSpecPanel.vue +16 -0
  128. package/scaffold/spec-site/src/pages/shared/PolicyFallback.vue +145 -0
  129. package/scaffold/spec-site/src/pages/wireframe/WireframeShell.vue +151 -0
  130. package/scaffold/spec-site/src/router.ts +85 -0
  131. package/scaffold/spec-site/src/styles/base.css +21 -0
  132. package/scaffold/spec-site/src/styles/split-pane.css +143 -0
  133. package/scaffold/spec-site/src/styles/variables.css +47 -0
  134. package/scaffold/spec-site/src/utils/markdown.ts +197 -0
  135. package/scaffold/spec-site/tsconfig.json +20 -0
  136. package/scaffold/spec-site/vite.config.ts +18 -0
@@ -0,0 +1,53 @@
1
+ import { ref, provide, inject, nextTick } from 'vue'
2
+ import type { InjectionKey, Ref } from 'vue'
3
+
4
+ interface ActiveSectionCtx {
5
+ activeSection: Ref<string | null>
6
+ setActiveSection: (id: string) => void
7
+ clearActiveSection: () => void
8
+ }
9
+
10
+ const ActiveSectionKey: InjectionKey<ActiveSectionCtx> = Symbol('activeSection')
11
+
12
+ export function provideActiveSection() {
13
+ const activeSection = ref<string | null>(null)
14
+
15
+ function setActiveSection(id: string) {
16
+ activeSection.value = id
17
+
18
+ nextTick(() => {
19
+ // Highlight mockup area
20
+ document.querySelectorAll('[data-area]').forEach(el =>
21
+ el.classList.remove('area-active')
22
+ )
23
+ const mockupArea = document.querySelector(`[data-area="${id}"]`)
24
+ if (mockupArea) {
25
+ mockupArea.classList.add('area-active')
26
+ mockupArea.scrollIntoView({ behavior: 'smooth', block: 'center' })
27
+ }
28
+
29
+ // Scroll spec panel
30
+ const specEl = document.getElementById(`spec-section-${id}`)
31
+ if (specEl) {
32
+ const body = document.getElementById('spec-body')
33
+ if (body) body.scrollTop = 0
34
+ }
35
+ })
36
+ }
37
+
38
+ function clearActiveSection() {
39
+ activeSection.value = null
40
+ document.querySelectorAll('[data-area]').forEach(el =>
41
+ el.classList.remove('area-active')
42
+ )
43
+ }
44
+
45
+ provide(ActiveSectionKey, { activeSection, setActiveSection, clearActiveSection })
46
+ return { activeSection, setActiveSection, clearActiveSection }
47
+ }
48
+
49
+ export function useActiveSection() {
50
+ const ctx = inject(ActiveSectionKey)
51
+ if (!ctx) throw new Error('useActiveSection requires SplitPaneLayout ancestor')
52
+ return ctx
53
+ }
@@ -0,0 +1,138 @@
1
+ import { ref, computed } from 'vue'
2
+
3
+ export interface MemoItem {
4
+ id: number
5
+ text: string
6
+ author: string
7
+ ts: number
8
+ }
9
+
10
+ export function useMemo(pageId: string) {
11
+ const STORAGE_KEY = `spec-memo-${pageId}`
12
+ const memos = ref<MemoItem[]>([])
13
+ const memoCount = computed(() => memos.value.length)
14
+ const loading = ref(false)
15
+ const error = ref<string | null>(null)
16
+
17
+ // localStorage (sync, immediately available)
18
+ function loadFromLocal() {
19
+ try {
20
+ const raw = localStorage.getItem(STORAGE_KEY)
21
+ if (raw) {
22
+ const items = JSON.parse(raw) as Array<{ id: number; text: string; ts: number; author?: string }>
23
+ memos.value = items.map((m) => ({
24
+ id: m.id,
25
+ text: m.text,
26
+ author: m.author ?? '',
27
+ ts: m.ts,
28
+ }))
29
+ }
30
+ } catch (err) {
31
+ error.value = err instanceof Error ? err.message : 'Failed to read local memos'
32
+ }
33
+ }
34
+
35
+ function saveToLocal() {
36
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(memos.value))
37
+ }
38
+
39
+ // Turso dynamic load attempt (falls back to localStorage)
40
+ async function tryLoadFromTurso() {
41
+ try {
42
+ const { query } = await import('./useTurso')
43
+ const r = await query<{ id: number; content: string; author: string; created_at: string }>(
44
+ 'SELECT id, content, author, created_at FROM memos WHERE page_id = ? ORDER BY created_at DESC',
45
+ [pageId],
46
+ )
47
+ if (!r.error) {
48
+ memos.value = r.rows.map((row) => ({
49
+ id: Number(row.id),
50
+ text: String(row.content),
51
+ author: String(row.author),
52
+ ts: new Date(row.created_at + 'Z').getTime(),
53
+ }))
54
+ } else {
55
+ error.value = r.error
56
+ }
57
+ } catch (err) {
58
+ error.value = err instanceof Error ? err.message : 'Turso load failed'
59
+ // Keep localStorage fallback silently in UI behavior
60
+ }
61
+ }
62
+
63
+ async function loadMemos() {
64
+ loading.value = true
65
+ error.value = null
66
+ await tryLoadFromTurso()
67
+ loading.value = false
68
+ }
69
+
70
+ async function addMemo(text: string, author: string): Promise<boolean> {
71
+ const trimmed = text.trim()
72
+ if (!trimmed) return false
73
+
74
+ // Try Turso
75
+ try {
76
+ const { execute } = await import('./useTurso')
77
+ const r = await execute('INSERT INTO memos (page_id, content, author) VALUES (?, ?, ?)', [
78
+ pageId, trimmed, author,
79
+ ])
80
+ if (!r.error) {
81
+ await tryLoadFromTurso()
82
+ return true
83
+ }
84
+ error.value = r.error ?? 'Turso save failed'
85
+ } catch (err) {
86
+ error.value = err instanceof Error ? err.message : 'Turso save failed'
87
+ }
88
+
89
+ // localStorage fallback
90
+ memos.value.unshift({ id: Date.now(), text: trimmed, author, ts: Date.now() })
91
+ saveToLocal()
92
+ return true
93
+ }
94
+
95
+ async function deleteMemo(id: number) {
96
+ try {
97
+ const { execute } = await import('./useTurso')
98
+ const r = await execute('DELETE FROM memos WHERE id = ?', [id])
99
+ if (!r.error) {
100
+ await tryLoadFromTurso()
101
+ return
102
+ }
103
+ error.value = r.error ?? 'Turso delete failed'
104
+ } catch (err) {
105
+ error.value = err instanceof Error ? err.message : 'Turso delete failed'
106
+ }
107
+
108
+ memos.value = memos.value.filter((m) => m.id !== id)
109
+ saveToLocal()
110
+ }
111
+
112
+ async function clearAll() {
113
+ try {
114
+ const { execute } = await import('./useTurso')
115
+ const r = await execute('DELETE FROM memos WHERE page_id = ?', [pageId])
116
+ if (r.error) error.value = r.error
117
+ } catch (err) {
118
+ error.value = err instanceof Error ? err.message : 'Turso clear failed'
119
+ }
120
+ memos.value = []
121
+ saveToLocal()
122
+ }
123
+
124
+ function formatTime(ts: number): string {
125
+ const d = new Date(ts)
126
+ const mm = String(d.getMonth() + 1).padStart(2, '0')
127
+ const dd = String(d.getDate()).padStart(2, '0')
128
+ const hh = String(d.getHours()).padStart(2, '0')
129
+ const mi = String(d.getMinutes()).padStart(2, '0')
130
+ return `${mm}/${dd} ${hh}:${mi}`
131
+ }
132
+
133
+ // Immediately load from localStorage (sync), then try Turso (async)
134
+ loadFromLocal()
135
+ loadMemos()
136
+
137
+ return { memos, memoCount, loading, error, addMemo, deleteMemo, clearAll, formatTime, loadMemos }
138
+ }
@@ -0,0 +1,313 @@
1
+ import { ref, computed, onUnmounted } from 'vue'
2
+ import { query, execute } from './useTurso'
3
+
4
+ export type RetroPhase = 'write' | 'vote' | 'discuss' | 'done'
5
+ export type RetroCategory = 'keep' | 'problem' | 'try'
6
+
7
+ export interface RetroSession {
8
+ id: number
9
+ sprint: string
10
+ title: string | null
11
+ phase: RetroPhase
12
+ created_at: string
13
+ updated_at: string
14
+ }
15
+
16
+ export interface RetroItem {
17
+ id: number
18
+ session_id: number
19
+ category: RetroCategory
20
+ content: string
21
+ author: string
22
+ created_at: string
23
+ voteCount: number
24
+ hasVoted: boolean
25
+ }
26
+
27
+ export interface RetroAction {
28
+ id: number
29
+ session_id: number
30
+ content: string
31
+ assignee: string | null
32
+ status: 'pending' | 'done'
33
+ created_at: string
34
+ }
35
+
36
+ export const VOTES_PER_PERSON = 5
37
+ const POLL_INTERVAL_MS = 4000
38
+
39
+ export function useRetro(sprintId: string) {
40
+ const session = ref<RetroSession | null>(null)
41
+ const items = ref<RetroItem[]>([])
42
+ const actions = ref<RetroAction[]>([])
43
+ const loading = ref(false)
44
+ const error = ref<string | null>(null)
45
+
46
+ // -- Derived --
47
+ const keepItems = computed(() => items.value.filter((i) => i.category === 'keep'))
48
+ const problemItems = computed(() => items.value.filter((i) => i.category === 'problem'))
49
+ const tryItems = computed(() => items.value.filter((i) => i.category === 'try'))
50
+
51
+ // -- Session --
52
+ async function loadOrCreateSession() {
53
+ loading.value = true
54
+ error.value = null
55
+
56
+ const r = await query<RetroSession>(
57
+ 'SELECT * FROM retro_sessions WHERE sprint = ? LIMIT 1',
58
+ [sprintId],
59
+ )
60
+ if (r.error) {
61
+ error.value = r.error
62
+ loading.value = false
63
+ return
64
+ }
65
+
66
+ if (r.rows.length > 0) {
67
+ session.value = r.rows[0]
68
+ } else {
69
+ const ins = await execute(
70
+ 'INSERT INTO retro_sessions (sprint, title) VALUES (?, ?)',
71
+ [sprintId, `${sprintId.toUpperCase()} Retro`],
72
+ )
73
+ if (ins.error) {
74
+ error.value = ins.error
75
+ loading.value = false
76
+ return
77
+ }
78
+ const r2 = await query<RetroSession>(
79
+ 'SELECT * FROM retro_sessions WHERE sprint = ? LIMIT 1',
80
+ [sprintId],
81
+ )
82
+ session.value = r2.rows[0] ?? null
83
+ }
84
+
85
+ await refresh()
86
+ loading.value = false
87
+ }
88
+
89
+ async function setPhase(phase: RetroPhase) {
90
+ if (!session.value) return
91
+ await execute(
92
+ "UPDATE retro_sessions SET phase = ?, updated_at = datetime('now') WHERE id = ?",
93
+ [phase, session.value.id],
94
+ )
95
+ session.value = { ...session.value, phase }
96
+ }
97
+
98
+ // -- Items --
99
+ async function loadItems(currentUser: string) {
100
+ if (!session.value) return
101
+ const r = await query<Record<string, unknown>>(
102
+ `SELECT i.*,
103
+ COUNT(v.voter) as voteCount,
104
+ MAX(CASE WHEN v.voter = ? THEN 1 ELSE 0 END) as hasVoted
105
+ FROM retro_items i
106
+ LEFT JOIN retro_votes v ON v.item_id = i.id
107
+ WHERE i.session_id = ?
108
+ GROUP BY i.id
109
+ ORDER BY i.created_at ASC`,
110
+ [currentUser, session.value.id],
111
+ )
112
+ if (!r.error) {
113
+ items.value = r.rows.map((row) => ({
114
+ id: Number(row.id),
115
+ session_id: Number(row.session_id),
116
+ category: row.category as RetroCategory,
117
+ content: row.content as string,
118
+ author: row.author as string,
119
+ created_at: row.created_at as string,
120
+ voteCount: Number(row.voteCount),
121
+ hasVoted: Boolean(Number(row.hasVoted)),
122
+ }))
123
+ }
124
+ }
125
+
126
+ async function addItem(category: RetroCategory, content: string, author: string) {
127
+ if (!session.value) return
128
+ const trimmed = content.trim()
129
+ if (!trimmed) return
130
+ await execute(
131
+ 'INSERT INTO retro_items (session_id, category, content, author) VALUES (?, ?, ?, ?)',
132
+ [session.value.id, category, trimmed, author],
133
+ )
134
+ await loadItems(author)
135
+ }
136
+
137
+ async function deleteItem(itemId: number, currentUser: string) {
138
+ await execute('DELETE FROM retro_votes WHERE item_id = ?', [itemId])
139
+ await execute('DELETE FROM retro_items WHERE id = ?', [itemId])
140
+ await loadItems(currentUser)
141
+ }
142
+
143
+ // -- Votes --
144
+ const myVoteCount = computed(() => items.value.filter((i) => i.hasVoted).length)
145
+ const votesRemaining = computed(() => VOTES_PER_PERSON - myVoteCount.value)
146
+
147
+ async function toggleVote(itemId: number, currentUser: string, hasVoted: boolean) {
148
+ if (!hasVoted && votesRemaining.value <= 0) return
149
+ if (hasVoted) {
150
+ await execute('DELETE FROM retro_votes WHERE item_id = ? AND voter = ?', [
151
+ itemId,
152
+ currentUser,
153
+ ])
154
+ } else {
155
+ await execute('INSERT OR IGNORE INTO retro_votes (item_id, voter) VALUES (?, ?)', [
156
+ itemId,
157
+ currentUser,
158
+ ])
159
+ }
160
+ await loadItems(currentUser)
161
+ }
162
+
163
+ // -- Actions --
164
+ async function loadActions() {
165
+ if (!session.value) return
166
+ const r = await query<RetroAction>(
167
+ 'SELECT * FROM retro_actions WHERE session_id = ? ORDER BY created_at ASC',
168
+ [session.value.id],
169
+ )
170
+ if (!r.error) actions.value = r.rows
171
+ }
172
+
173
+ async function addAction(content: string, assignee: string | null) {
174
+ if (!session.value) return
175
+ await execute(
176
+ 'INSERT INTO retro_actions (session_id, content, assignee) VALUES (?, ?, ?)',
177
+ [session.value.id, content.trim(), assignee],
178
+ )
179
+ await loadActions()
180
+ }
181
+
182
+ async function toggleActionStatus(actionId: number, currentStatus: 'pending' | 'done') {
183
+ const next = currentStatus === 'pending' ? 'done' : 'pending'
184
+ await execute('UPDATE retro_actions SET status = ? WHERE id = ?', [next, actionId])
185
+ await loadActions()
186
+ }
187
+
188
+ // -- Reset --
189
+ async function resetSession() {
190
+ if (!session.value) return
191
+ const sid = session.value.id
192
+ await execute(
193
+ 'DELETE FROM retro_votes WHERE item_id IN (SELECT id FROM retro_items WHERE session_id = ?)',
194
+ [sid],
195
+ )
196
+ await execute('DELETE FROM retro_actions WHERE session_id = ?', [sid])
197
+ await execute('DELETE FROM retro_items WHERE session_id = ?', [sid])
198
+ await execute('DELETE FROM retro_sessions WHERE id = ?', [sid])
199
+ session.value = null
200
+ items.value = []
201
+ actions.value = []
202
+ await loadOrCreateSession()
203
+ }
204
+
205
+ // -- Export --
206
+ function exportMarkdown(): string {
207
+ if (!session.value) return ''
208
+ const s = session.value
209
+ const lines: string[] = []
210
+
211
+ lines.push(`# ${s.sprint.toUpperCase()} Sprint Retro`)
212
+ lines.push('')
213
+
214
+ const cats: { key: RetroCategory; emoji: string; label: string }[] = [
215
+ { key: 'keep', emoji: '✅', label: 'Keep' },
216
+ { key: 'problem', emoji: '🔴', label: 'Problem' },
217
+ { key: 'try', emoji: '💡', label: 'Try' },
218
+ ]
219
+
220
+ for (const cat of cats) {
221
+ const catItems = [...items.value]
222
+ .filter((i) => i.category === cat.key)
223
+ .sort((a, b) => b.voteCount - a.voteCount)
224
+
225
+ lines.push(`## ${cat.emoji} ${cat.label}`)
226
+ lines.push('')
227
+ if (catItems.length === 0) {
228
+ lines.push('- (none)')
229
+ } else {
230
+ for (const item of catItems) {
231
+ const votes = item.voteCount > 0 ? ` (👍 ${item.voteCount})` : ''
232
+ lines.push(`- ${item.content}${votes} — _${item.author}_`)
233
+ }
234
+ }
235
+ lines.push('')
236
+ }
237
+
238
+ if (actions.value.length > 0) {
239
+ lines.push('## 📋 Action Items')
240
+ lines.push('')
241
+ for (const a of actions.value) {
242
+ const check = a.status === 'done' ? '✅' : '⬜'
243
+ const assignee = a.assignee ? ` @${a.assignee}` : ''
244
+ lines.push(`- ${check} ${a.content}${assignee}`)
245
+ }
246
+ lines.push('')
247
+ }
248
+
249
+ lines.push(`---`)
250
+ lines.push(`_Generated: ${new Date().toLocaleDateString()}_`)
251
+
252
+ return lines.join('\n')
253
+ }
254
+
255
+ // -- Refresh (items + actions + session phase) --
256
+ let _currentUser = ''
257
+
258
+ async function refresh() {
259
+ if (!session.value) return
260
+ const r = await query<{ phase: string }>(
261
+ 'SELECT phase FROM retro_sessions WHERE id = ?',
262
+ [session.value.id],
263
+ )
264
+ if (r.rows[0] && r.rows[0].phase !== session.value.phase) {
265
+ session.value = { ...session.value, phase: r.rows[0].phase as RetroPhase }
266
+ }
267
+ await loadItems(_currentUser)
268
+ await loadActions()
269
+ }
270
+
271
+ // -- Polling --
272
+ let pollTimer: ReturnType<typeof setInterval> | null = null
273
+
274
+ function startPolling(currentUser: string) {
275
+ _currentUser = currentUser
276
+ stopPolling()
277
+ pollTimer = setInterval(refresh, POLL_INTERVAL_MS)
278
+ }
279
+
280
+ function stopPolling() {
281
+ if (pollTimer) {
282
+ clearInterval(pollTimer)
283
+ pollTimer = null
284
+ }
285
+ }
286
+
287
+ onUnmounted(stopPolling)
288
+
289
+ return {
290
+ session,
291
+ items,
292
+ actions,
293
+ loading,
294
+ error,
295
+ keepItems,
296
+ problemItems,
297
+ tryItems,
298
+ myVoteCount,
299
+ votesRemaining,
300
+ loadOrCreateSession,
301
+ setPhase,
302
+ addItem,
303
+ deleteItem,
304
+ toggleVote,
305
+ addAction,
306
+ toggleActionStatus,
307
+ resetSession,
308
+ exportMarkdown,
309
+ startPolling,
310
+ stopPolling,
311
+ refresh,
312
+ }
313
+ }
@@ -0,0 +1,43 @@
1
+ import { ref, provide, inject, computed } from 'vue'
2
+ import type { InjectionKey, Ref, ComputedRef } from 'vue'
3
+ import type { Scenario } from '@/data/types'
4
+
5
+ interface ScenarioCtx<T = any> {
6
+ scenarios: Ref<Scenario<T>[]>
7
+ activeScenarioId: Ref<string>
8
+ activeScenario: ComputedRef<Scenario<T> | undefined>
9
+ mockData: ComputedRef<T | undefined>
10
+ setScenario: (id: string) => void
11
+ updateScenarios: (newList: Scenario<any>[], defaultId?: string) => void
12
+ }
13
+
14
+ const ScenarioKey = '__scenario__' as unknown as InjectionKey<ScenarioCtx>
15
+
16
+ export function provideScenario<T>(scenarioList: Scenario<T>[], defaultId?: string) {
17
+ const scenarios = ref(scenarioList) as Ref<Scenario<T>[]>
18
+ const activeScenarioId = ref(defaultId ?? scenarioList[0]?.id ?? '')
19
+
20
+ const activeScenario = computed(() =>
21
+ scenarios.value.find(s => s.id === activeScenarioId.value)
22
+ )
23
+
24
+ const mockData = computed(() => activeScenario.value?.data)
25
+
26
+ function setScenario(id: string) {
27
+ activeScenarioId.value = id
28
+ }
29
+
30
+ function updateScenarios(newList: Scenario<any>[], defaultId?: string) {
31
+ scenarios.value = newList as any
32
+ activeScenarioId.value = defaultId ?? newList[0]?.id ?? ''
33
+ }
34
+
35
+ provide(ScenarioKey, { scenarios, activeScenarioId, activeScenario, mockData, setScenario, updateScenarios } as ScenarioCtx)
36
+ return { scenarios, activeScenarioId, activeScenario, mockData, setScenario, updateScenarios }
37
+ }
38
+
39
+ export function useScenario<T = any>() {
40
+ const ctx = inject(ScenarioKey)
41
+ if (!ctx) throw new Error('useScenario requires a provideScenario ancestor')
42
+ return ctx as ScenarioCtx<T>
43
+ }
@@ -0,0 +1,102 @@
1
+ import { ref } from 'vue'
2
+ import { query, execute } from './useTurso'
3
+ import type { Scenario } from '@/data/types'
4
+
5
+ export function useScenarioStore(pageId: string, sprint: string) {
6
+ const customScenarios = ref<Scenario<any>[]>([])
7
+ const loading = ref(false)
8
+ const error = ref<string | null>(null)
9
+
10
+ async function loadCustomScenarios(): Promise<Scenario<any>[]> {
11
+ loading.value = true
12
+ error.value = null
13
+ const r = await query<{
14
+ scenario_id: string
15
+ label: string
16
+ data_json: string
17
+ }>(
18
+ 'SELECT scenario_id, label, data_json FROM scenario_data WHERE page_id = ? AND sprint = ? ORDER BY created_at ASC',
19
+ [pageId, sprint],
20
+ )
21
+ loading.value = false
22
+ if (r.error) {
23
+ error.value = r.error
24
+ customScenarios.value = []
25
+ return []
26
+ }
27
+ const list = r.rows
28
+ .map((row) => {
29
+ try {
30
+ return {
31
+ id: String(row.scenario_id),
32
+ label: String(row.label),
33
+ data: JSON.parse(String(row.data_json)),
34
+ } as Scenario<any>
35
+ } catch {
36
+ return null
37
+ }
38
+ })
39
+ .filter((s): s is Scenario<any> => s !== null)
40
+ customScenarios.value = list
41
+ return list
42
+ }
43
+
44
+ async function saveScenario(scenario: Scenario<any>, author: string): Promise<boolean> {
45
+ error.value = null
46
+ const dataJson = JSON.stringify(scenario.data)
47
+ const res = await execute(
48
+ `INSERT INTO scenario_data (page_id, sprint, scenario_id, label, data_json, author)
49
+ VALUES (?, ?, ?, ?, ?, ?)
50
+ ON CONFLICT(page_id, sprint, scenario_id)
51
+ DO UPDATE SET label = ?, data_json = ?, author = ?, updated_at = datetime('now')`,
52
+ [pageId, sprint, scenario.id, scenario.label, dataJson, author, scenario.label, dataJson, author],
53
+ )
54
+ if (res.error) {
55
+ error.value = res.error
56
+ return false
57
+ }
58
+ await loadCustomScenarios()
59
+ return true
60
+ }
61
+
62
+ async function deleteScenario(scenarioId: string): Promise<boolean> {
63
+ error.value = null
64
+ const res = await execute(
65
+ 'DELETE FROM scenario_data WHERE page_id = ? AND sprint = ? AND scenario_id = ?',
66
+ [pageId, sprint, scenarioId],
67
+ )
68
+ if (res.error) {
69
+ error.value = res.error
70
+ return false
71
+ }
72
+ await loadCustomScenarios()
73
+ return true
74
+ }
75
+
76
+ async function duplicateScenario(
77
+ source: Scenario<any>,
78
+ newLabel: string,
79
+ author: string,
80
+ ): Promise<string> {
81
+ error.value = null
82
+ const newId = `custom-${Date.now()}`
83
+ const newScenario: Scenario<any> = {
84
+ id: newId,
85
+ label: newLabel,
86
+ data: structuredClone(source.data),
87
+ }
88
+ const ok = await saveScenario(newScenario, author)
89
+ if (!ok) return ''
90
+ return newId
91
+ }
92
+
93
+ return {
94
+ customScenarios,
95
+ loading,
96
+ error,
97
+ loadCustomScenarios,
98
+ saveScenario,
99
+ deleteScenario,
100
+ duplicateScenario,
101
+ }
102
+ }