jettypod 4.4.120 → 4.4.121

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 (208) hide show
  1. package/.env +2 -1
  2. package/Cargo.lock +6450 -0
  3. package/Cargo.toml +35 -0
  4. package/README.md +5 -1
  5. package/TAURI-MIGRATION-PLAN.md +840 -0
  6. package/apps/dashboard/app/connect-claude/page.tsx +5 -6
  7. package/apps/dashboard/app/decision/[id]/page.tsx +54 -49
  8. package/apps/dashboard/app/demo/gates/page.tsx +3 -5
  9. package/apps/dashboard/app/design-system/page.tsx +1 -1
  10. package/apps/dashboard/app/globals.css +74 -2
  11. package/apps/dashboard/app/install-claude/page.tsx +3 -5
  12. package/apps/dashboard/app/login/page.tsx +17 -20
  13. package/apps/dashboard/app/page.tsx +101 -48
  14. package/apps/dashboard/app/settings/page.tsx +60 -12
  15. package/apps/dashboard/app/signup/page.tsx +14 -17
  16. package/apps/dashboard/app/subscribe/page.tsx +0 -2
  17. package/apps/dashboard/app/tests/page.tsx +37 -4
  18. package/apps/dashboard/app/welcome/page.tsx +12 -15
  19. package/apps/dashboard/app/work/[id]/page.tsx +90 -75
  20. package/apps/dashboard/app/work/[id]/proof/page.tsx +1489 -0
  21. package/apps/dashboard/components/AppShell.tsx +70 -61
  22. package/apps/dashboard/components/CardMenu.tsx +0 -1
  23. package/apps/dashboard/components/ClaudePanel.tsx +541 -283
  24. package/apps/dashboard/components/ClaudePanelInput.tsx +23 -4
  25. package/apps/dashboard/components/ConnectClaudeScreen.tsx +1 -5
  26. package/apps/dashboard/components/CopyableId.tsx +1 -2
  27. package/apps/dashboard/components/DetailReviewActions.tsx +11 -20
  28. package/apps/dashboard/components/DragContext.tsx +132 -62
  29. package/apps/dashboard/components/DraggableCard.tsx +3 -5
  30. package/apps/dashboard/components/DropZone.tsx +5 -6
  31. package/apps/dashboard/components/EditableDetailDescription.tsx +6 -12
  32. package/apps/dashboard/components/EditableDetailTitle.tsx +6 -13
  33. package/apps/dashboard/components/EditableTitle.tsx +0 -1
  34. package/apps/dashboard/components/ElapsedTimer.tsx +15 -3
  35. package/apps/dashboard/components/EpicGroup.tsx +100 -70
  36. package/apps/dashboard/components/GateCard.tsx +0 -1
  37. package/apps/dashboard/components/GateChoiceCard.tsx +1 -2
  38. package/apps/dashboard/components/InstallClaudeScreen.tsx +1 -5
  39. package/apps/dashboard/components/JettyLoader.tsx +0 -1
  40. package/apps/dashboard/components/KanbanBoard.tsx +319 -173
  41. package/apps/dashboard/components/KanbanCard.tsx +341 -107
  42. package/apps/dashboard/components/LazyCard.tsx +62 -0
  43. package/apps/dashboard/components/LazyMarkdown.tsx +0 -1
  44. package/apps/dashboard/components/MainNav.tsx +24 -25
  45. package/apps/dashboard/components/MessageBlock.tsx +93 -16
  46. package/apps/dashboard/components/ModeStartCard.tsx +0 -1
  47. package/apps/dashboard/components/OnboardingWelcome.tsx +0 -1
  48. package/apps/dashboard/components/PlaceholderCard.tsx +0 -1
  49. package/apps/dashboard/components/ProjectSwitcher.tsx +20 -20
  50. package/apps/dashboard/components/PrototypeTimeline.tsx +47 -26
  51. package/apps/dashboard/components/RealTimeKanbanWrapper.tsx +308 -223
  52. package/apps/dashboard/components/RealTimeTestsWrapper.tsx +303 -160
  53. package/apps/dashboard/components/ReviewFooter.tsx +12 -14
  54. package/apps/dashboard/components/SessionList.tsx +0 -1
  55. package/apps/dashboard/components/SubscribeContent.tsx +40 -11
  56. package/apps/dashboard/components/TestTree.tsx +1 -2
  57. package/apps/dashboard/components/TipCard.tsx +2 -4
  58. package/apps/dashboard/components/Toast.tsx +0 -1
  59. package/apps/dashboard/components/TypeIcon.tsx +7 -8
  60. package/apps/dashboard/components/ViewModeToolbar.tsx +104 -0
  61. package/apps/dashboard/components/WaveCompletionAnimation.tsx +5 -17
  62. package/apps/dashboard/components/WelcomeScreen.tsx +2 -6
  63. package/apps/dashboard/components/WorkItemHeader.tsx +0 -1
  64. package/apps/dashboard/components/WorkItemTree.tsx +2 -4
  65. package/apps/dashboard/components/settings/AccountSection.tsx +27 -13
  66. package/apps/dashboard/components/settings/AiContextSection.tsx +89 -0
  67. package/apps/dashboard/components/settings/ContextDocumentsSection.tsx +317 -0
  68. package/apps/dashboard/components/settings/EnvVarsSection.tsx +20 -73
  69. package/apps/dashboard/components/settings/GeneralSection.tsx +137 -26
  70. package/apps/dashboard/components/settings/ProjectStackSection.tsx +948 -0
  71. package/apps/dashboard/components/settings/SettingsLayout.tsx +0 -1
  72. package/apps/dashboard/components/ui/Button.tsx +1 -1
  73. package/apps/dashboard/components/ui/Input.tsx +1 -1
  74. package/apps/dashboard/components.json +1 -1
  75. package/apps/dashboard/contexts/ClaudeSessionContext.tsx +611 -358
  76. package/apps/dashboard/contexts/ConnectionStatusContext.tsx +0 -1
  77. package/apps/dashboard/contexts/UsageContext.tsx +62 -31
  78. package/apps/dashboard/dev.sh +35 -0
  79. package/apps/dashboard/eslint.config.mjs +9 -9
  80. package/apps/dashboard/hooks/useWebSocket.ts +138 -83
  81. package/apps/dashboard/index.html +73 -0
  82. package/apps/dashboard/lib/data-bridge.ts +722 -0
  83. package/apps/dashboard/lib/db.ts +69 -1302
  84. package/apps/dashboard/lib/environment-config.ts +173 -0
  85. package/apps/dashboard/lib/environment-verification.ts +119 -0
  86. package/apps/dashboard/lib/kanban-utils.ts +226 -26
  87. package/apps/dashboard/lib/proof-run.ts +495 -0
  88. package/apps/dashboard/lib/proof-scenario-runner.ts +346 -0
  89. package/apps/dashboard/lib/service-recovery.ts +326 -0
  90. package/apps/dashboard/lib/session-state-machine.ts +1 -0
  91. package/apps/dashboard/lib/session-state-utils.ts +0 -164
  92. package/apps/dashboard/lib/session-stream-manager.ts +253 -122
  93. package/apps/dashboard/lib/stream-manager-registry.ts +46 -6
  94. package/apps/dashboard/lib/tauri-bridge.ts +102 -0
  95. package/apps/dashboard/lib/tauri.ts +106 -0
  96. package/apps/dashboard/lib/utils.ts +3 -3
  97. package/apps/dashboard/next-env.d.ts +1 -1
  98. package/apps/dashboard/package.json +21 -33
  99. package/apps/dashboard/public/bug-icon.png +0 -0
  100. package/apps/dashboard/public/buoy-icon.png +0 -0
  101. package/apps/dashboard/public/in-flight-seagull.png +0 -0
  102. package/apps/dashboard/public/pier-icon.png +0 -0
  103. package/apps/dashboard/public/star-icon.png +0 -0
  104. package/apps/dashboard/public/wrench-icon.png +0 -0
  105. package/apps/dashboard/scripts/tauri-build.js +228 -0
  106. package/apps/dashboard/scripts/upload-tauri-to-r2.js +125 -0
  107. package/apps/dashboard/src/main.tsx +12 -0
  108. package/apps/dashboard/src/router.tsx +107 -0
  109. package/apps/dashboard/src/vite-env.d.ts +1 -0
  110. package/apps/dashboard/tsconfig.json +7 -12
  111. package/apps/dashboard/tsconfig.tsbuildinfo +1 -1
  112. package/apps/dashboard/vite.config.ts +33 -0
  113. package/apps/update-server/src/index.ts +167 -30
  114. package/claude-hooks/global-guardrails.js +14 -13
  115. package/crates/jettypod-cli/Cargo.toml +19 -0
  116. package/crates/jettypod-cli/src/commands.rs +1249 -0
  117. package/crates/jettypod-cli/src/main.rs +595 -0
  118. package/crates/jettypod-core/Cargo.toml +26 -0
  119. package/crates/jettypod-core/build.rs +98 -0
  120. package/crates/jettypod-core/migrations/V1__baseline.sql +197 -0
  121. package/crates/jettypod-core/migrations/V2__work_items_indexes.sql +6 -0
  122. package/crates/jettypod-core/migrations/V3__qa_steps.sql +2 -0
  123. package/crates/jettypod-core/src/auth.rs +294 -0
  124. package/crates/jettypod-core/src/config.rs +397 -0
  125. package/crates/jettypod-core/src/db/mod.rs +507 -0
  126. package/crates/jettypod-core/src/db/recovery.rs +114 -0
  127. package/crates/jettypod-core/src/db/startup.rs +101 -0
  128. package/crates/jettypod-core/src/db/validate.rs +149 -0
  129. package/crates/jettypod-core/src/error.rs +76 -0
  130. package/crates/jettypod-core/src/git.rs +458 -0
  131. package/crates/jettypod-core/src/lib.rs +20 -0
  132. package/crates/jettypod-core/src/sessions.rs +625 -0
  133. package/crates/jettypod-core/src/skills.rs +556 -0
  134. package/crates/jettypod-core/src/work.rs +1086 -0
  135. package/crates/jettypod-core/src/worktree.rs +628 -0
  136. package/crates/jettypod-core/src/ws.rs +767 -0
  137. package/cucumber-test.cjs +6 -0
  138. package/jettypod.js +96 -4
  139. package/lib/bdd-preflight.js +96 -0
  140. package/lib/merge-lock.js +111 -253
  141. package/lib/migrations/030-rejection-round-columns.js +54 -0
  142. package/lib/migrations/031-session-isolation-index.js +17 -0
  143. package/lib/work-commands/index.js +58 -16
  144. package/lib/work-tracking/index.js +108 -8
  145. package/package.json +1 -1
  146. package/skills-templates/bug-mode/SKILL.md +43 -1
  147. package/skills-templates/chore-mode/SKILL.md +40 -1
  148. package/skills-templates/design-system-selection/SKILL.md +273 -0
  149. package/skills-templates/epic-planning/SKILL.md +14 -0
  150. package/skills-templates/feature-planning/SKILL.md +90 -1
  151. package/skills-templates/production-mode/SKILL.md +20 -0
  152. package/skills-templates/simple-improvement/SKILL.md +39 -2
  153. package/skills-templates/speed-mode/SKILL.md +10 -15
  154. package/skills-templates/stable-mode/SKILL.md +47 -0
  155. package/apps/dashboard/README.md +0 -36
  156. package/apps/dashboard/app/api/claude/[workItemId]/message/route.ts +0 -446
  157. package/apps/dashboard/app/api/claude/[workItemId]/pin/route.ts +0 -24
  158. package/apps/dashboard/app/api/claude/[workItemId]/route.ts +0 -280
  159. package/apps/dashboard/app/api/claude/sessions/[sessionId]/content/route.ts +0 -52
  160. package/apps/dashboard/app/api/claude/sessions/[sessionId]/message/route.ts +0 -525
  161. package/apps/dashboard/app/api/claude/sessions/[sessionId]/pin/route.ts +0 -24
  162. package/apps/dashboard/app/api/claude/sessions/cleanup/route.ts +0 -34
  163. package/apps/dashboard/app/api/claude/sessions/route.ts +0 -184
  164. package/apps/dashboard/app/api/decisions/[id]/route.ts +0 -25
  165. package/apps/dashboard/app/api/internal/set-project/route.ts +0 -17
  166. package/apps/dashboard/app/api/kanban/route.ts +0 -15
  167. package/apps/dashboard/app/api/settings/env-vars/route.ts +0 -125
  168. package/apps/dashboard/app/api/settings/general/route.ts +0 -21
  169. package/apps/dashboard/app/api/tests/route.ts +0 -9
  170. package/apps/dashboard/app/api/tests/run/route.ts +0 -82
  171. package/apps/dashboard/app/api/tests/run/stream/route.ts +0 -71
  172. package/apps/dashboard/app/api/tests/undefined/route.ts +0 -9
  173. package/apps/dashboard/app/api/usage/route.ts +0 -17
  174. package/apps/dashboard/app/api/work/[id]/description/route.ts +0 -21
  175. package/apps/dashboard/app/api/work/[id]/epic/route.ts +0 -21
  176. package/apps/dashboard/app/api/work/[id]/order/route.ts +0 -21
  177. package/apps/dashboard/app/api/work/[id]/route.ts +0 -35
  178. package/apps/dashboard/app/api/work/[id]/status/route.ts +0 -63
  179. package/apps/dashboard/app/api/work/[id]/title/route.ts +0 -21
  180. package/apps/dashboard/app/layout.tsx +0 -55
  181. package/apps/dashboard/components/UpgradeBanner.tsx +0 -30
  182. package/apps/dashboard/electron/ipc-handlers.js +0 -1026
  183. package/apps/dashboard/electron/main.js +0 -2306
  184. package/apps/dashboard/electron/preload.js +0 -125
  185. package/apps/dashboard/electron/session-manager.js +0 -163
  186. package/apps/dashboard/electron-builder.config.js +0 -357
  187. package/apps/dashboard/hooks/useClaudeSessions.ts +0 -299
  188. package/apps/dashboard/lib/backlog-parser.ts +0 -50
  189. package/apps/dashboard/lib/claude-process-manager.ts +0 -529
  190. package/apps/dashboard/lib/db-bridge.ts +0 -283
  191. package/apps/dashboard/lib/prototypes.ts +0 -202
  192. package/apps/dashboard/lib/test-results-db.ts +0 -307
  193. package/apps/dashboard/lib/tests.ts +0 -282
  194. package/apps/dashboard/next.config.js +0 -66
  195. package/apps/dashboard/postcss.config.mjs +0 -7
  196. package/apps/dashboard/public/bug-icon.svg +0 -9
  197. package/apps/dashboard/public/buoy-icon.svg +0 -9
  198. package/apps/dashboard/public/file.svg +0 -1
  199. package/apps/dashboard/public/globe.svg +0 -1
  200. package/apps/dashboard/public/in-flight-seagull.svg +0 -9
  201. package/apps/dashboard/public/next.svg +0 -1
  202. package/apps/dashboard/public/pier-icon.svg +0 -14
  203. package/apps/dashboard/public/star-icon.svg +0 -9
  204. package/apps/dashboard/public/vercel.svg +0 -1
  205. package/apps/dashboard/public/window.svg +0 -1
  206. package/apps/dashboard/public/wrench-icon.svg +0 -9
  207. package/apps/dashboard/scripts/download-node.js +0 -104
  208. package/apps/dashboard/scripts/upload-to-r2.js +0 -89
@@ -1,1342 +1,109 @@
1
- // Dashboard database access layer
2
- // Reads from JettyPod SQLite database
1
+ // Database types for the dashboard UI layer
2
+ // Server-side implementation moved to Tauri IPC (data-bridge.ts)
3
+ // WorkItem matches the shape components expect (with .type field).
4
+ // data-bridge.ts WorkItemData has .type (matching Rust serde rename).
3
5
 
4
- import Database from 'better-sqlite3';
5
- import path from 'path';
6
- import fs from 'fs';
7
- import { execSync } from 'child_process';
8
-
9
- // eslint-disable-next-line @typescript-eslint/no-require-imports
10
- const { runMigrations } = require('./run-migrations');
11
- // eslint-disable-next-line @typescript-eslint/no-require-imports
12
- const { ensureSchema } = require('../../../lib/schema');
13
-
14
- // Types matching JettyPod schema
15
6
  export interface WorkItem {
16
7
  id: number;
17
- type: 'epic' | 'feature' | 'chore' | 'bug';
8
+ type: string;
18
9
  title: string;
19
10
  description: string | null;
20
- status: 'backlog' | 'todo' | 'in_progress' | 'done' | 'cancelled';
11
+ status: string;
21
12
  parent_id: number | null;
22
13
  epic_id: number | null;
23
14
  branch_name: string | null;
24
- mode: 'speed' | 'stable' | 'production' | null;
15
+ mode: string | null;
25
16
  phase: string | null;
26
17
  completed_at: string | null;
27
18
  rejection_reason: string | null;
28
- rejected_at: string | null;
29
19
  ready_for_review: number;
20
+ rejection_count: number;
21
+ rejection_round: number | null;
22
+ rejection_history: string | null;
23
+ needs_discovery?: number;
30
24
  created_at: string;
31
25
  display_order: number | null;
32
- conversational: number;
26
+ conversational?: number;
27
+ current_step?: number | null;
28
+ total_steps?: number | null;
33
29
  children?: WorkItem[];
34
30
  chores?: WorkItem[];
35
31
  bugs?: WorkItem[];
36
- current_step?: number | null;
37
- total_steps?: number | null;
38
- }
39
-
40
- export interface Decision {
41
- id: number;
42
- work_item_id: number;
43
- aspect: string;
44
- decision: string;
45
- rationale: string;
46
- created_at: string;
47
- }
48
-
49
- export function getProjectRoot(): string | null {
50
- // Use JETTYPOD_PROJECT_PATH if set (passed by Electron main process)
51
- if (process.env.JETTYPOD_PROJECT_PATH) {
52
- return process.env.JETTYPOD_PROJECT_PATH;
53
- }
54
-
55
- // In packaged Electron app, JETTYPOD_PROJECT_PATH is the only source of truth.
56
- // Don't fall back to git rev-parse - if no project is selected, return null.
57
- // This prevents the app from accidentally using whatever git repo the user
58
- // happens to run the app from.
59
- // JETTYPOD_IS_PACKAGED is set by electron/main.js in production mode.
60
- if (process.env.JETTYPOD_IS_PACKAGED) {
61
- return null;
62
- }
63
-
64
- // Development mode only: fall back to git rev-parse for convenience
65
- try {
66
- return execSync('git rev-parse --show-toplevel', { encoding: 'utf-8' }).trim();
67
- } catch {
68
- return null;
69
- }
70
- }
71
-
72
- function getDbPath(): string | null {
73
- const projectRoot = getProjectRoot();
74
- if (!projectRoot) {
75
- return null; // No project selected
76
- }
77
- return path.join(projectRoot, '.jettypod', 'work.db');
78
- }
79
-
80
- // Check if a project is currently selected
81
- export function hasProject(): boolean {
82
- return getProjectRoot() !== null;
83
- }
84
-
85
- export function getProjectName(): string {
86
- const projectRoot = getProjectRoot();
87
- if (!projectRoot) {
88
- return 'No Project'; // Pre-project state (welcome/install screens)
89
- }
90
- const configPath = path.join(projectRoot, '.jettypod', 'config.json');
91
- if (fs.existsSync(configPath)) {
92
- const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
93
- return config.name || 'Untitled Project';
94
- }
95
- return 'Untitled Project';
96
- }
97
-
98
- // Ensure critical columns exist via ALTER TABLE, catching "duplicate column" errors.
99
- // Handles databases where migrations were falsely recorded as applied
100
- // (the shim was missing db.serialize(), so migrations using it silently failed).
101
- function ensureColumns(db: Database.Database): void {
102
- const tryAddTo = (table: string, col: string, type: string) => {
103
- try {
104
- db.exec(`ALTER TABLE ${table} ADD COLUMN ${col} ${type}`);
105
- } catch {
106
- // Column already exists - expected for healthy databases
107
- }
108
- };
109
- try {
110
- tryAddTo('work_items', 'display_order', 'INTEGER DEFAULT NULL');
111
- tryAddTo('work_items', 'rejection_reason', 'TEXT');
112
- tryAddTo('work_items', 'rejected_at', 'TEXT');
113
- tryAddTo('work_items', 'conversational', 'INTEGER DEFAULT 0');
114
- tryAddTo('work_items', 'plan_at_creation', 'TEXT DEFAULT NULL');
115
- tryAddTo('work_items', 'ready_for_review', 'INTEGER DEFAULT 0');
116
- } catch {
117
- // Table might not exist yet - ensureSchema will handle it
118
- }
119
-
120
- // claude_sessions columns from migrations 022-024 (used db.serialize which was missing)
121
- try {
122
- tryAddTo('claude_sessions', 'session_title', 'TEXT');
123
- tryAddTo('claude_sessions', 'content', 'TEXT');
124
- } catch {
125
- // Table might not exist yet - migration 021 will create it
126
- }
127
-
128
- // Clear falsely-recorded migrations that used db.serialize() so they re-run properly.
129
- // Only clear if we detect the column was actually missing (i.e. we just added it).
130
- try {
131
- const cols = db.prepare("PRAGMA table_info(claude_sessions)").all() as Array<{ name: string }>;
132
- const hasSessionTitle = cols.some(c => c.name === 'session_title');
133
- if (hasSessionTitle) {
134
- // Check if work_item_id is still NOT NULL (migration 022 should make it nullable)
135
- const workItemCol = cols.find(c => c.name === 'work_item_id') as { name: string; notnull: number } | undefined;
136
- if (workItemCol && workItemCol.notnull === 1) {
137
- // Column was added by our ALTER TABLE but migration 022 didn't actually run
138
- // (it recreates the table with nullable work_item_id). Clear so it re-runs.
139
- db.exec("DELETE FROM migrations WHERE id IN ('022-independent-sessions', '023-session-content-column', '024-session-orphaned-status')");
140
- }
141
- }
142
- } catch {
143
- // migrations table might not exist yet
144
- }
145
- }
146
-
147
- // Check if a project folder has no implementation code
148
- export function isBlankProject(projectPath: string): boolean {
149
- const IMPL_PATTERNS = [
150
- /\.js$/, /\.ts$/, /\.jsx$/, /\.tsx$/, /\.mjs$/, /\.cjs$/,
151
- /\.py$/, /\.rb$/, /\.go$/, /\.rs$/, /\.java$/, /\.kt$/,
152
- /\.c$/, /\.cpp$/, /\.h$/, /\.swift$/, /\.php$/,
153
- /^src\//, /^lib\//, /^app\//,
154
- /^package\.json$/, /^Cargo\.toml$/, /^requirements\.txt$/,
155
- /^Gemfile$/, /^go\.mod$/, /^pom\.xml$/, /^build\.gradle$/,
156
- ];
157
- const IGNORE = [/^\.git\//, /^\.jettypod\//, /^\.claude\//, /^node_modules\//];
158
-
159
- function scan(dir: string, baseDir: string): boolean {
160
- if (!fs.existsSync(dir)) return false;
161
- let entries: fs.Dirent[];
162
- try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return false; }
163
- for (const entry of entries) {
164
- const rel = path.relative(baseDir, path.join(dir, entry.name));
165
- if (IGNORE.some(p => p.test(rel) || p.test(entry.name))) continue;
166
- if (entry.isDirectory()) {
167
- if (IMPL_PATTERNS.some(p => p.test(rel + '/'))) return true;
168
- if (scan(path.join(dir, entry.name), baseDir)) return true;
169
- } else {
170
- if (IMPL_PATTERNS.some(p => p.test(rel))) return true;
171
- }
172
- }
173
- return false;
174
- }
175
-
176
- return !scan(projectPath, projectPath);
177
- }
178
-
179
- // Check if any onboarding chore has been started (moved out of backlog)
180
- export function hasOnboardingStarted(): boolean {
181
- try {
182
- const db = getDb();
183
- const result = db.prepare(`
184
- SELECT COUNT(*) as count FROM work_items
185
- WHERE parent_id = (SELECT id FROM work_items WHERE type = 'epic' AND title = 'Project Planning')
186
- AND status != 'backlog'
187
- `).get() as { count: number } | undefined;
188
- return (result?.count ?? 0) > 0;
189
- } catch {
190
- return false;
191
- }
192
- }
193
-
194
- // Seed onboarding epic into a blank project's database if not already present
195
- function seedOnboardingIfBlank(db: Database.Database, dbPath: string): void {
196
- try {
197
- const projectPath = path.dirname(path.dirname(dbPath)); // .jettypod/work.db -> project root
198
- if (!isBlankProject(projectPath)) return;
199
-
200
- const existing = db.prepare("SELECT id FROM work_items WHERE type = 'epic' AND title = 'Project Planning'").get();
201
- if (existing) return;
202
-
203
- const insert = db.prepare(
204
- 'INSERT INTO work_items (type, title, description, parent_id, status, created_at, conversational) VALUES (?, ?, ?, ?, ?, ?, ?)'
205
- );
206
- const now = new Date().toISOString();
207
-
208
- const epicResult = insert.run('epic', 'Project Planning',
209
- 'Get your project set up and planned. Work through these chores one at a time — each one is a short conversation.', null, 'backlog', now, 0);
210
- const epicId = epicResult.lastInsertRowid;
211
-
212
- const chores = [
213
- { title: 'Align on the user journey', desc: 'Help the user define what their product does.\n\nCLAUDE SESSION GUIDANCE:\nOpen with: "What do users do in this product?"\n\nOUTCOME:\n- A clear description of the core user journey' },
214
- { title: 'Explore UX approaches', desc: 'Help the user decide how the product should feel.\n\nCONTEXT FROM PREVIOUS CHORE:\nRead previous decisions first.\n\nCLAUDE SESSION GUIDANCE:\nPresent 3 UX approaches.\n\nOUTCOME:\n- 3 UX options compared\n- A winner chosen' },
215
- { title: 'Choose a tech stack', desc: 'Help the user pick the right tech stack.\n\nCONTEXT FROM PREVIOUS CHORES:\nRead previous decisions first.\n\nCLAUDE SESSION GUIDANCE:\nPresent 3 tech stack options.\n\nOUTCOME:\n- A tech stack chosen with rationale' },
216
- { title: 'Break the project into epics', desc: 'Break the project into buildable phases.\n\nCONTEXT FROM PREVIOUS CHORES:\nRead all previous decisions.\n\nCLAUDE SESSION GUIDANCE:\nPropose 3-5 epics.\n\nOUTCOME:\n- 3-5 epics created in the backlog' },
217
- ];
218
-
219
- for (const chore of chores) {
220
- insert.run('chore', chore.title, chore.desc, epicId, 'backlog', now, 1);
221
- }
222
- } catch (err) {
223
- console.error('Could not seed onboarding:', err instanceof Error ? err.message : err);
224
- }
225
- }
226
-
227
- // Singleton database connection - reused across all operations
228
- let cachedDb: Database.Database | null = null;
229
- let cachedDbPath: string | null = null;
230
-
231
- function getDb(): Database.Database {
232
- const dbPath = getDbPath();
233
-
234
- // No project selected - can't access database
235
- if (!dbPath) {
236
- throw new Error('No project selected. Please select a project first.');
237
- }
238
-
239
- // If project path changed, close old connection and reconnect
240
- if (cachedDb && cachedDbPath !== dbPath) {
241
- cachedDb.close();
242
- cachedDb = null;
243
- cachedDbPath = null;
244
- }
245
-
246
- if (!cachedDb) {
247
- // Auto-create .jettypod directory if missing (handles projects without one)
248
- const dbDir = path.dirname(dbPath);
249
- if (!fs.existsSync(dbDir)) {
250
- fs.mkdirSync(dbDir, { recursive: true });
251
- }
252
- try {
253
- cachedDb = new Database(dbPath);
254
- cachedDbPath = dbPath;
255
- // Enable WAL mode for better concurrent performance
256
- cachedDb.pragma('journal_mode = WAL');
257
- // Enable foreign key constraints - required for ON DELETE CASCADE/SET NULL to work
258
- cachedDb.pragma('foreign_keys = ON');
259
- // Ensure core tables exist (handles empty/old databases)
260
- ensureSchema(cachedDb);
261
- // Defense-in-depth: ensure critical columns exist even if migrations
262
- // were falsely recorded as applied (run-migrations.js has a known
263
- // sync/async bug where Promise rejections aren't caught synchronously)
264
- ensureColumns(cachedDb);
265
- // Run pending migrations to handle old project databases
266
- try {
267
- runMigrations(cachedDb);
268
- } catch (err) {
269
- console.error('Failed to run migrations:', err);
270
- }
271
- // Seed onboarding epic for blank projects (no implementation code detected)
272
- seedOnboardingIfBlank(cachedDb, dbPath);
273
- } catch (err) {
274
- // Clean up failed connection to avoid caching a broken db
275
- if (cachedDb) {
276
- try { cachedDb.close(); } catch { /* ignore close errors */ }
277
- }
278
- cachedDb = null;
279
- cachedDbPath = null;
280
- const message = err instanceof Error ? err.message : String(err);
281
- throw new Error(`Database error: ${message}`);
282
- }
283
- }
284
- return cachedDb;
285
- }
286
-
287
- // Close database connection - call when switching projects or shutting down
288
- export function closeDb(): void {
289
- if (cachedDb) {
290
- cachedDb.close();
291
- cachedDb = null;
292
- cachedDbPath = null;
293
- }
294
- }
295
-
296
- // For backwards compatibility - now returns same connection as getDb()
297
- // SQLite with WAL mode handles reads and writes on same connection safely
298
- function getWriteDb(): Database.Database {
299
- return getDb();
300
- }
301
-
302
- export function getAllWorkItems(): WorkItem[] {
303
- const db = getDb();
304
- const items = db.prepare(`
305
- SELECT id, type, title, description, status, parent_id, epic_id,
306
- branch_name, mode, phase, completed_at, created_at, conversational
307
- FROM work_items
308
- ORDER BY id
309
- `).all() as WorkItem[];
310
- return items;
311
- }
312
-
313
- export function getWorkItem(id: number): WorkItem | null {
314
- const db = getDb();
315
- const item = db.prepare(`
316
- SELECT id, type, title, description, status, parent_id, epic_id,
317
- branch_name, mode, phase, completed_at, created_at, conversational,
318
- ready_for_review, rejection_reason
319
- FROM work_items
320
- WHERE id = ?
321
- `).get(id) as WorkItem | undefined;
322
- return item || null;
323
- }
324
-
325
- export function createWorkItem(
326
- type: 'epic' | 'feature' | 'chore' | 'bug',
327
- title: string,
328
- description?: string,
329
- parentId?: number
330
- ): WorkItem {
331
- const db = getWriteDb();
332
- const now = new Date().toISOString();
333
- const result = db.prepare(
334
- 'INSERT INTO work_items (type, title, description, parent_id, status, created_at, conversational) VALUES (?, ?, ?, ?, ?, ?, ?)'
335
- ).run(type, title, description || null, parentId || null, 'backlog', now, 0);
336
- return getWorkItem(result.lastInsertRowid as number)!;
337
- }
338
-
339
- export function getChildWorkItems(parentId: number): WorkItem[] {
340
- const db = getDb();
341
- const items = db.prepare(`
342
- SELECT id, type, title, description, status, parent_id, epic_id,
343
- branch_name, mode, phase, completed_at, created_at, conversational
344
- FROM work_items
345
- WHERE parent_id = ?
346
- ORDER BY id
347
- `).all(parentId) as WorkItem[];
348
- return items;
349
- }
350
-
351
- export function getDecisionsForWorkItem(workItemId: number): Decision[] {
352
- const db = getDb();
353
- const decisions = db.prepare(`
354
- SELECT id, work_item_id, aspect, decision, rationale, created_at
355
- FROM discovery_decisions
356
- WHERE work_item_id = ?
357
- ORDER BY created_at
358
- `).all(workItemId) as Decision[];
359
- return decisions;
360
- }
361
-
362
- export function getDecision(id: number): (Decision & { work_item_title?: string }) | null {
363
- const db = getDb();
364
- if (id > 0) {
365
- // Regular decision from discovery_decisions table
366
- const decision = db.prepare(`
367
- SELECT d.id, d.work_item_id, d.aspect, d.decision, d.rationale, d.created_at,
368
- w.title as work_item_title
369
- FROM discovery_decisions d
370
- LEFT JOIN work_items w ON d.work_item_id = w.id
371
- WHERE d.id = ?
372
- `).get(id) as (Decision & { work_item_title?: string }) | undefined;
373
- return decision || null;
374
- } else {
375
- // Negative ID = UX decision from work_items table (id * -1)
376
- const workItemId = Math.abs(id);
377
- const decision = db.prepare(`
378
- SELECT
379
- id * -1 as id,
380
- id as work_item_id,
381
- 'UX Approach' as aspect,
382
- discovery_winner as decision,
383
- discovery_rationale as rationale,
384
- COALESCE(discovery_completed_at, created_at) as created_at,
385
- title as work_item_title
386
- FROM work_items
387
- WHERE id = ? AND discovery_winner IS NOT NULL
388
- `).get(workItemId) as (Decision & { work_item_title?: string }) | undefined;
389
- return decision || null;
390
- }
391
- }
392
-
393
- // Build a tree structure from flat work items
394
- export function buildWorkItemTree(items: WorkItem[]): WorkItem[] {
395
- const itemMap = new Map<number, WorkItem>();
396
- const roots: WorkItem[] = [];
397
-
398
- // First pass: create map and initialize children arrays
399
- for (const item of items) {
400
- itemMap.set(item.id, { ...item, children: [] });
401
- }
402
-
403
- // Second pass: build tree structure
404
- for (const item of items) {
405
- const node = itemMap.get(item.id)!;
406
- if (item.parent_id && itemMap.has(item.parent_id)) {
407
- const parent = itemMap.get(item.parent_id)!;
408
- parent.children!.push(node);
409
- } else {
410
- roots.push(node);
411
- }
412
- }
413
-
414
- return roots;
415
- }
416
-
417
- export function getBacklogTree(): WorkItem[] {
418
- const items = getAllWorkItems();
419
- return buildWorkItemTree(items);
420
- }
421
-
422
- export function getActiveWork(): WorkItem[] {
423
- const db = getDb();
424
- const items = db.prepare(`
425
- SELECT id, type, title, description, status, parent_id, epic_id,
426
- branch_name, mode, phase, completed_at, created_at, conversational
427
- FROM work_items
428
- WHERE status = 'in_progress'
429
- ORDER BY id
430
- `).all() as WorkItem[];
431
- return items;
432
- }
433
-
434
- export function getRecentlyCompleted(limit: number = 10): WorkItem[] {
435
- const db = getDb();
436
- const items = db.prepare(`
437
- SELECT id, type, title, description, status, parent_id, epic_id,
438
- branch_name, mode, phase, completed_at, created_at, conversational
439
- FROM work_items
440
- WHERE status = 'done'
441
- ORDER BY completed_at DESC
442
- LIMIT ?
443
- `).all(limit) as WorkItem[];
444
- return items;
445
- }
446
-
447
- // Get items for Kanban view - features, standalone chores, bugs (not feature children)
448
- export interface KanbanGroup {
449
- epicId: number | null;
450
- epicTitle: string | null;
451
- items: WorkItem[];
452
- }
453
-
454
- export interface InFlightItem extends WorkItem {
455
- epicTitle: string | null;
456
- }
457
-
458
- export interface KanbanData {
459
- inFlight: InFlightItem[];
460
- backlog: Map<string, KanbanGroup>;
461
- done: Map<string, KanbanGroup>;
462
- }
463
-
464
- export function getKanbanData(doneLimit: number = 50): KanbanData {
465
- const db = getDb();
466
- // Get all epics for lookup
467
- const epics = db.prepare(`
468
- SELECT id, title FROM work_items WHERE type = 'epic'
469
- `).all() as { id: number; title: string }[];
470
- const epicMap = new Map(epics.map(e => [e.id, e.title]));
471
-
472
- // Get all chores that belong to features (for chore expansion)
473
- const featureChores = db.prepare(`
474
- SELECT c.id, c.type, c.title, c.description, c.status, c.parent_id, c.epic_id,
475
- c.branch_name, c.mode, c.phase, c.completed_at, c.created_at, c.conversational,
476
- wc.current_step, wc.total_steps
477
- FROM work_items c
478
- INNER JOIN work_items f ON c.parent_id = f.id
479
- LEFT JOIN workflow_checkpoints wc ON wc.work_item_id = c.id
480
- WHERE c.type = 'chore' AND f.type = 'feature'
481
- ORDER BY c.id
482
- `).all() as WorkItem[];
483
-
484
- // Group chores by parent feature ID
485
- const choresByFeature = new Map<number, WorkItem[]>();
486
- for (const chore of featureChores) {
487
- if (chore.parent_id) {
488
- const existing = choresByFeature.get(chore.parent_id) || [];
489
- existing.push(chore);
490
- choresByFeature.set(chore.parent_id, existing);
491
- }
492
- }
493
-
494
- // Get all bugs that belong to features (for bug expansion)
495
- const featureBugs = db.prepare(`
496
- SELECT b.id, b.type, b.title, b.description, b.status, b.parent_id, b.epic_id,
497
- b.branch_name, b.mode, b.phase, b.completed_at, b.created_at, b.conversational
498
- FROM work_items b
499
- INNER JOIN work_items f ON b.parent_id = f.id
500
- WHERE b.type = 'bug' AND f.type = 'feature'
501
- ORDER BY b.id
502
- `).all() as WorkItem[];
503
-
504
- // Group bugs by parent feature ID
505
- const bugsByFeature = new Map<number, WorkItem[]>();
506
- for (const bug of featureBugs) {
507
- if (bug.parent_id) {
508
- const existing = bugsByFeature.get(bug.parent_id) || [];
509
- existing.push(bug);
510
- bugsByFeature.set(bug.parent_id, existing);
511
- }
512
- }
513
-
514
- // Get kanban-eligible items:
515
- // - Features (type = 'feature')
516
- // - Chores that are NOT children of features (parent is null, or parent is an epic)
517
- // - Bugs if they exist
518
- const allItems = db.prepare(`
519
- SELECT w.id, w.type, w.title, w.description, w.status, w.parent_id, w.epic_id,
520
- w.branch_name, w.mode, w.phase, w.completed_at, w.created_at, w.conversational, w.display_order,
521
- w.ready_for_review,
522
- p.type as parent_type,
523
- wc.current_step, wc.total_steps
524
- FROM work_items w
525
- LEFT JOIN work_items p ON w.parent_id = p.id
526
- LEFT JOIN workflow_checkpoints wc ON wc.work_item_id = w.id
527
- WHERE w.type IN ('feature', 'chore', 'bug')
528
- AND (w.parent_id IS NULL OR p.type = 'epic')
529
- ORDER BY COALESCE(w.display_order, w.id)
530
- `).all() as (WorkItem & { parent_type: string | null })[];
531
-
532
- const inFlight: InFlightItem[] = [];
533
- const backlogGroups = new Map<string, KanbanGroup>();
534
- const doneGroups = new Map<string, KanbanGroup>();
535
-
536
- // Helper to get or create group
537
- function getGroup(groups: Map<string, KanbanGroup>, item: WorkItem): KanbanGroup {
538
- const epicId = item.parent_id || item.epic_id;
539
- const key = epicId ? `epic-${epicId}` : 'ungrouped';
540
-
541
- if (!groups.has(key)) {
542
- groups.set(key, {
543
- epicId: epicId,
544
- epicTitle: epicId ? epicMap.get(epicId) || null : null,
545
- items: []
546
- });
547
- }
548
- return groups.get(key)!;
549
- }
550
-
551
- for (const item of allItems) {
552
- // Strip parent_type from the item
553
- const { parent_type, ...cleanItem } = item;
554
-
555
- // Attach chores and bugs to features
556
- if (cleanItem.type === 'feature') {
557
- cleanItem.chores = choresByFeature.get(cleanItem.id) || [];
558
- cleanItem.bugs = bugsByFeature.get(cleanItem.id) || [];
559
- }
560
-
561
- if (cleanItem.status === 'in_progress') {
562
- const epicId = cleanItem.parent_id || cleanItem.epic_id;
563
- const epicTitle = epicId ? epicMap.get(epicId) || null : null;
564
- inFlight.push({ ...cleanItem, epicTitle });
565
- } else if (cleanItem.status === 'backlog' || cleanItem.status === 'todo' || cleanItem.status === null) {
566
- const group = getGroup(backlogGroups, cleanItem);
567
- group.items.push(cleanItem);
568
- } else if (cleanItem.status === 'done') {
569
- const epicId = cleanItem.parent_id || cleanItem.epic_id;
570
- if (epicId) {
571
- // Epic children group together under their epic
572
- const group = getGroup(doneGroups, cleanItem);
573
- group.items.push(cleanItem);
574
- } else {
575
- // Standalone items get their own group for chronological interleaving
576
- const key = `item-${cleanItem.id}`;
577
- doneGroups.set(key, {
578
- epicId: null,
579
- epicTitle: null,
580
- items: [cleanItem]
581
- });
582
- }
583
- }
584
- }
585
-
586
- // Sort in-flight: review items first (ascending by id), then non-review (ascending by id)
587
- inFlight.sort((a, b) => {
588
- if (a.ready_for_review && !b.ready_for_review) return -1;
589
- if (!a.ready_for_review && b.ready_for_review) return 1;
590
- return a.id - b.id;
591
- });
592
-
593
- // Always ensure an ungrouped entry exists in backlog for drag-drop target
594
- if (!backlogGroups.has('ungrouped')) {
595
- backlogGroups.set('ungrouped', {
596
- epicId: null,
597
- epicTitle: null,
598
- items: []
599
- });
600
- }
601
-
602
- // Sort done items by completed_at DESC (newest first) within each group
603
- for (const [, group] of doneGroups) {
604
- group.items.sort((a, b) => {
605
- const dateA = a.completed_at ? new Date(a.completed_at).getTime() : 0;
606
- const dateB = b.completed_at ? new Date(b.completed_at).getTime() : 0;
607
- return dateB - dateA; // DESC order
608
- });
609
- }
610
-
611
- // Sort epic groups by most recent completion date (newest first)
612
- const sortedDoneGroups = new Map(
613
- Array.from(doneGroups.entries()).sort(([, groupA], [, groupB]) => {
614
- const mostRecentA = groupA.items[0]?.completed_at
615
- ? new Date(groupA.items[0].completed_at).getTime()
616
- : 0;
617
- const mostRecentB = groupB.items[0]?.completed_at
618
- ? new Date(groupB.items[0].completed_at).getTime()
619
- : 0;
620
- return mostRecentB - mostRecentA; // DESC order
621
- })
622
- );
623
-
624
- // Limit done items per group to ensure every group gets representation
625
- const perGroupLimit = doneLimit;
626
- const limitedDoneGroups = new Map<string, KanbanGroup>();
627
- for (const [key, group] of sortedDoneGroups) {
628
- if (group.items.length > perGroupLimit) {
629
- limitedDoneGroups.set(key, { ...group, items: group.items.slice(0, perGroupLimit) });
630
- } else {
631
- limitedDoneGroups.set(key, group);
632
- }
633
- }
634
-
635
- return { inFlight, backlog: backlogGroups, done: limitedDoneGroups };
636
- }
637
-
638
- export function updateWorkItemTitle(id: number, title: string): boolean {
639
- const db = getWriteDb();
640
- const result = db.prepare(`
641
- UPDATE work_items SET title = ? WHERE id = ?
642
- `).run(title, id);
643
- return result.changes > 0;
644
- }
645
-
646
- export function updateWorkItemDescription(id: number, description: string): boolean {
647
- const db = getWriteDb();
648
- const result = db.prepare(`
649
- UPDATE work_items SET description = ? WHERE id = ?
650
- `).run(description, id);
651
- return result.changes > 0;
652
- }
653
-
654
- export function updateWorkItemStatus(id: number, status: string, rejectionReason?: string): boolean {
655
- const db = getWriteDb();
656
- // Normalize rejection reason: treat empty/whitespace-only as no rejection
657
- const normalizedRejection = rejectionReason?.trim() || undefined;
658
- const completedAt = status === 'done' ? new Date().toISOString() : null;
659
- const rejectedAt = normalizedRejection ? new Date().toISOString() : null;
660
- // Clear ready_for_review on accept or reject
661
- const readyForReview = (status === 'done' || normalizedRejection) ? 0 : undefined;
662
- const result = readyForReview !== undefined
663
- ? db.prepare(`
664
- UPDATE work_items SET status = ?, completed_at = ?, rejection_reason = ?, rejected_at = ?, ready_for_review = ? WHERE id = ?
665
- `).run(status, completedAt, normalizedRejection ?? null, rejectedAt, readyForReview, id)
666
- : db.prepare(`
667
- UPDATE work_items SET status = ?, completed_at = ?, rejection_reason = ?, rejected_at = ? WHERE id = ?
668
- `).run(status, completedAt, normalizedRejection ?? null, rejectedAt, id);
669
-
670
- // When work item is cancelled, mark any linked sessions as orphaned
671
- if (status === 'cancelled' && result.changes > 0) {
672
- orphanSessionsByWorkItem(id);
673
- }
674
-
675
- // When a chore is marked done, check if all sibling chores are done
676
- // and set ready_for_review on the parent feature.
677
- // Edge cases: no chores (length 0) → no auto-set; cancelled chores ≠ done → no auto-set
678
- if (status === 'done' && result.changes > 0) {
679
- const item = db.prepare('SELECT parent_id, type FROM work_items WHERE id = ?').get(id) as { parent_id: number | null; type: string } | undefined;
680
- if (item?.parent_id && item.type === 'chore') {
681
- const parent = db.prepare('SELECT id, type FROM work_items WHERE id = ?').get(item.parent_id) as { id: number; type: string } | undefined;
682
- if (parent?.type === 'feature') {
683
- const siblings = db.prepare('SELECT status FROM work_items WHERE parent_id = ? AND type = ?').all(parent.id, 'chore') as { status: string }[];
684
- const allDone = siblings.length > 0 && siblings.every(s => s.status === 'done');
685
- if (allDone) {
686
- db.prepare('UPDATE work_items SET ready_for_review = 1 WHERE id = ?').run(parent.id);
687
- }
688
- }
689
- }
690
- }
691
-
692
- return result.changes > 0;
693
- }
694
-
695
- export function updateWorkItemOrder(id: number, displayOrder: number): boolean {
696
- const db = getWriteDb();
697
- const result = db.prepare(`
698
- UPDATE work_items SET display_order = ? WHERE id = ?
699
- `).run(displayOrder, id);
700
- return result.changes > 0;
701
- }
702
-
703
- export function updateWorkItemEpic(id: number, epicId: number | null): boolean {
704
- const db = getWriteDb();
705
- // Update parent_id for features/chores to assign them to an epic
706
- const result = db.prepare(`
707
- UPDATE work_items SET parent_id = ? WHERE id = ? AND type IN ('feature', 'chore', 'bug')
708
- `).run(epicId, id);
709
- return result.changes > 0;
710
32
  }
711
33
 
712
- // Claude session types
713
- export type SessionStatus = 'active' | 'completed' | 'error' | 'orphaned';
34
+ // Re-export kanban types from data-bridge (single source of truth)
35
+ export type {
36
+ InFlightItem,
37
+ KanbanGroup,
38
+ KanbanData,
39
+ } from './data-bridge';
714
40
 
715
- export interface ClaudeSession {
716
- id: number;
717
- work_item_id: number | null;
41
+ // Test dashboard types
42
+ export interface TestScenario {
43
+ id: string;
718
44
  title: string;
719
- session_title: string | null;
720
- status: SessionStatus;
721
- started_at: string;
722
- completed_at: string | null;
723
- content: string | null;
724
- }
725
-
726
- // Conversation turn for session content
727
- // Supports user messages, assistant responses, and errors (#1000099)
728
- export interface ConversationTurn {
729
- role: 'user' | 'assistant' | 'error';
730
- content: string;
731
- timestamp: string;
45
+ status: 'pass' | 'fail' | 'pending';
46
+ duration: string;
47
+ lastRun: string | null;
48
+ error?: string;
49
+ failedStep?: string;
50
+ steps: string[];
51
+ undefinedSteps: string[];
732
52
  }
733
53
 
734
- // Extended session info for list display
735
- export interface SessionWithFeature {
736
- id: number;
54
+ export interface TestFeature {
55
+ id: string;
737
56
  title: string;
738
- featureId: number | null;
739
- featureTitle: string | null;
740
- status: SessionStatus;
741
- updatedAt: string;
742
- }
743
-
744
- export function getActiveSessions(): ClaudeSession[] {
745
- const db = getDb();
746
- const sessions = db.prepare(`
747
- SELECT id, work_item_id, title, status, started_at, completed_at
748
- FROM claude_sessions
749
- WHERE status IN ('active', 'completed')
750
- ORDER BY started_at DESC
751
- `).all() as ClaudeSession[];
752
- return sessions;
753
- }
754
-
755
- export function registerSession(workItemId: number, title: string): number {
756
- const db = getWriteDb();
757
- // Use INSERT OR REPLACE to handle existing sessions for same work item
758
- const result = db.prepare(`
759
- INSERT OR REPLACE INTO claude_sessions (work_item_id, title, status, started_at)
760
- VALUES (?, ?, 'active', datetime('now'))
761
- `).run(workItemId, title);
762
- return result.lastInsertRowid as number;
763
- }
764
-
765
- export function updateSessionStatus(workItemId: number, status: SessionStatus): boolean {
766
- const db = getWriteDb();
767
- const completedAt = status !== 'active' ? "datetime('now')" : null;
768
- const result = db.prepare(`
769
- UPDATE claude_sessions
770
- SET status = ?, completed_at = ${completedAt ? "datetime('now')" : 'NULL'}
771
- WHERE work_item_id = ?
772
- `).run(status, workItemId);
773
- return result.changes > 0;
774
- }
775
-
776
- // Mark all sessions linked to a work item as orphaned (used when work item is cancelled/deleted)
777
- export function orphanSessionsByWorkItem(workItemId: number): number {
778
- const db = getWriteDb();
779
- const result = db.prepare(`
780
- UPDATE claude_sessions
781
- SET status = 'orphaned', completed_at = datetime('now')
782
- WHERE work_item_id = ? AND status = 'active'
783
- `).run(workItemId);
784
- return result.changes;
785
- }
786
-
787
- // Cleanup stale sessions: delete orphaned, old completed, and old error sessions
788
- // Returns count of deleted sessions
789
- export function cleanupStaleSessions(retentionDays: number = 30): number {
790
- const db = getWriteDb();
791
-
792
- // Delete orphaned sessions (immediately) and old completed/error sessions (after retention period)
793
- const result = db.prepare(`
794
- DELETE FROM claude_sessions
795
- WHERE status = 'orphaned'
796
- OR (status = 'completed' AND completed_at < datetime('now', '-' || ? || ' days'))
797
- OR (status = 'error' AND completed_at < datetime('now', '-' || ? || ' days'))
798
- `).run(retentionDays, retentionDays);
799
-
800
- return result.changes;
801
- }
802
-
803
- // List all sessions with linked feature info
804
- export function listSessions(): SessionWithFeature[] {
805
- const db = getDb();
806
- const sessions = db.prepare(`
807
- SELECT
808
- cs.id,
809
- COALESCE(w.title, cs.session_title, 'Untitled Session') as title,
810
- cs.work_item_id as featureId,
811
- w.title as featureTitle,
812
- cs.status,
813
- COALESCE(cs.completed_at, cs.started_at) as updatedAt
814
- FROM claude_sessions cs
815
- LEFT JOIN work_items w ON cs.work_item_id = w.id
816
- WHERE cs.status = 'active'
817
- ORDER BY COALESCE(cs.completed_at, cs.started_at) DESC
818
- `).all() as SessionWithFeature[];
819
- return sessions;
820
- }
821
-
822
- // Count active sessions
823
- export function countActiveSessions(): number {
824
- const db = getDb();
825
- const result = db.prepare(`
826
- SELECT COUNT(*) as count FROM claude_sessions WHERE status = 'active'
827
- `).get() as { count: number };
828
- return result.count;
829
- }
830
-
831
- // Create a new unlinked session
832
- export function createSession(title: string): number {
833
- const db = getWriteDb();
834
- const result = db.prepare(`
835
- INSERT INTO claude_sessions (work_item_id, title, session_title, status, started_at)
836
- VALUES (NULL, ?, ?, 'active', datetime('now'))
837
- `).run(title, title);
838
- return result.lastInsertRowid as number;
839
- }
840
-
841
- // Check if a work item is linkable (can have a session attached)
842
- // Linkable: feature, standalone chore (no parent or epic parent), bug
843
- // NOT linkable: epic, chore under a feature (uses feature's session)
844
- export function isLinkableWorkItem(workItemId: number): { linkable: boolean; reason?: string; redirectToId?: number } {
845
- const db = getDb();
846
- const workItem = db.prepare(`
847
- SELECT w.id, w.type, w.parent_id, p.type as parent_type
848
- FROM work_items w
849
- LEFT JOIN work_items p ON w.parent_id = p.id
850
- WHERE w.id = ?
851
- `).get(workItemId) as { id: number; type: string; parent_id: number | null; parent_type: string | null } | undefined;
852
-
853
- if (!workItem) {
854
- return { linkable: false, reason: 'Work item not found' };
855
- }
856
-
857
- // Epics are never linkable
858
- if (workItem.type === 'epic') {
859
- return { linkable: false, reason: 'Epics cannot have sessions - link to a feature or chore instead' };
860
- }
861
-
862
- // Chores under features use the feature's session
863
- if (workItem.type === 'chore' && workItem.parent_type === 'feature') {
864
- return {
865
- linkable: false,
866
- reason: 'Chores under features use the feature\'s session',
867
- redirectToId: workItem.parent_id!
868
- };
869
- }
870
-
871
- // Features, bugs, and standalone chores (no parent or epic parent) are linkable
872
- return { linkable: true };
873
- }
874
-
875
- // Link a session to a work item
876
- // Only links to: features, standalone chores, bugs
877
- // Rejects: epics, chores under features
878
- export function linkSession(sessionId: number, workItemId: number): { success: boolean; error?: string; redirectToId?: number } {
879
- // First check if this work item is linkable
880
- const linkCheck = isLinkableWorkItem(workItemId);
881
- if (!linkCheck.linkable) {
882
- return {
883
- success: false,
884
- error: linkCheck.reason,
885
- redirectToId: linkCheck.redirectToId
886
- };
887
- }
888
-
889
- const db = getWriteDb();
890
- // Get the work item title
891
- const workItem = db.prepare(`SELECT title FROM work_items WHERE id = ?`).get(workItemId) as { title: string } | undefined;
892
- if (!workItem) {
893
- return { success: false, error: 'Work item not found' };
894
- }
895
-
896
- const result = db.prepare(`
897
- UPDATE claude_sessions
898
- SET work_item_id = ?, title = ?
899
- WHERE id = ? AND work_item_id IS NULL
900
- `).run(workItemId, workItem.title, sessionId);
901
-
902
- if (result.changes === 0) {
903
- return { success: false, error: 'Session already linked or not found' };
904
- }
905
-
906
- return { success: true };
907
- }
908
-
909
- // Get ACTIVE session by work item ID (for one-session-per-work-item check)
910
- // Only returns sessions with status='active', ignores completed/error sessions
911
- // Note: Does NOT include content - use getSessionContent() to load content lazily
912
- export function getActiveSessionByWorkItem(workItemId: number): ClaudeSession | null {
913
- const db = getDb();
914
- const session = db.prepare(`
915
- SELECT id, work_item_id, title, session_title, status, started_at, completed_at
916
- FROM claude_sessions
917
- WHERE work_item_id = ? AND status = 'active'
918
- `).get(workItemId) as ClaudeSession | undefined;
919
- return session || null;
920
- }
921
-
922
- // Get or create active session for a linkable work item
923
- // Returns existing active session if one exists, otherwise creates a new one
924
- // Returns null if work item is not linkable (epic, chore under feature)
925
- // Note: Does NOT include content - use getSessionContent() to load content lazily
926
- export function getOrCreateSessionForWorkItem(workItemId: number): { session: ClaudeSession | null; created: boolean; error?: string; redirectToId?: number } {
927
- // Check if work item is linkable
928
- const linkCheck = isLinkableWorkItem(workItemId);
929
- if (!linkCheck.linkable) {
930
- return {
931
- session: null,
932
- created: false,
933
- error: linkCheck.reason,
934
- redirectToId: linkCheck.redirectToId
935
- };
936
- }
937
-
938
- // Check for existing active session
939
- const existingSession = getActiveSessionByWorkItem(workItemId);
940
- if (existingSession) {
941
- return { session: existingSession, created: false };
942
- }
943
-
944
- // Create new session and link it
945
- const db = getWriteDb();
946
- // Get work item title
947
- const workItem = db.prepare(`SELECT title FROM work_items WHERE id = ?`).get(workItemId) as { title: string } | undefined;
948
- if (!workItem) {
949
- return { session: null, created: false, error: 'Work item not found' };
950
- }
951
-
952
- // Create linked session directly
953
- const result = db.prepare(`
954
- INSERT INTO claude_sessions (work_item_id, title, status, started_at)
955
- VALUES (?, ?, 'active', datetime('now'))
956
- `).run(workItemId, workItem.title);
957
-
958
- const sessionId = result.lastInsertRowid as number;
959
-
960
- // Fetch and return the created session (without content - load lazily)
961
- const newSession = db.prepare(`
962
- SELECT id, work_item_id, title, session_title, status, started_at, completed_at
963
- FROM claude_sessions
964
- WHERE id = ?
965
- `).get(sessionId) as ClaudeSession;
966
-
967
- return { session: newSession, created: true };
57
+ description: string;
58
+ featureFile: string;
59
+ scenarios: TestScenario[];
968
60
  }
969
61
 
970
- // Get session by ID
971
- export function getSession(sessionId: number): ClaudeSession | null {
972
- const db = getDb();
973
- const session = db.prepare(`
974
- SELECT id, work_item_id, title, session_title, status, started_at, completed_at
975
- FROM claude_sessions
976
- WHERE id = ?
977
- `).get(sessionId) as ClaudeSession | undefined;
978
- return session || null;
979
- }
980
-
981
- // Close a session by ID (mark as completed)
982
- export function closeSession(sessionId: number): boolean {
983
- const db = getWriteDb();
984
- const result = db.prepare(`
985
- UPDATE claude_sessions
986
- SET status = 'completed', completed_at = datetime('now')
987
- WHERE id = ?
988
- `).run(sessionId);
989
- return result.changes > 0;
990
- }
991
-
992
- // Close a session by work item ID (for work-item sessions)
993
- export function closeSessionByWorkItem(workItemId: number): boolean {
994
- const db = getWriteDb();
995
- const result = db.prepare(`
996
- UPDATE claude_sessions
997
- SET status = 'completed', completed_at = datetime('now')
998
- WHERE work_item_id = ? AND status = 'active'
999
- `).run(workItemId);
1000
- return result.changes > 0;
1001
- }
1002
-
1003
- // Get session content (conversation history) by session ID
1004
- export function getSessionContent(sessionId: number): ConversationTurn[] {
1005
- const db = getDb();
1006
- const session = db.prepare(`
1007
- SELECT content FROM claude_sessions WHERE id = ?
1008
- `).get(sessionId) as { content: string | null } | undefined;
1009
-
1010
- if (!session?.content) {
1011
- return [];
1012
- }
1013
-
1014
- try {
1015
- return JSON.parse(session.content) as ConversationTurn[];
1016
- } catch {
1017
- return [];
1018
- }
1019
- }
1020
-
1021
- // Get session content (conversation history) by work item ID
1022
- export function getSessionContentByWorkItem(workItemId: number): ConversationTurn[] {
1023
- const db = getDb();
1024
- const session = db.prepare(`
1025
- SELECT content FROM claude_sessions WHERE work_item_id = ? AND status = 'active'
1026
- `).get(workItemId) as { content: string | null } | undefined;
1027
-
1028
- if (!session?.content) {
1029
- return [];
1030
- }
1031
-
1032
- try {
1033
- return JSON.parse(session.content) as ConversationTurn[];
1034
- } catch {
1035
- return [];
1036
- }
62
+ export interface TestEpic {
63
+ id: string;
64
+ title: string;
65
+ healthBadge: { passing: number; failing: number };
66
+ features: TestFeature[];
1037
67
  }
1038
68
 
1039
- // Append a conversation turn to session content
1040
- // Uses atomic JSON operation to prevent race conditions
1041
- export function appendSessionContent(sessionId: number, turn: ConversationTurn): boolean {
1042
- const db = getWriteDb();
1043
-
1044
- // Atomic append using json_insert with $[#] path (appends to end of array)
1045
- // COALESCE handles NULL content by starting with empty array
1046
- const result = db.prepare(`
1047
- UPDATE claude_sessions
1048
- SET content = json_insert(COALESCE(content, '[]'), '$[#]', json(?))
1049
- WHERE id = ?
1050
- `).run(JSON.stringify(turn), sessionId);
1051
-
1052
- return result.changes > 0;
69
+ export interface TestSummary {
70
+ total: number;
71
+ passing: number;
72
+ failing: number;
73
+ pending: number;
74
+ lastRun: string | null;
1053
75
  }
1054
76
 
1055
- // Append a conversation turn to session content by work item ID
1056
- // Uses atomic JSON operation to prevent race conditions
1057
- export function appendSessionContentByWorkItem(workItemId: number, turn: ConversationTurn): boolean {
1058
- const db = getWriteDb();
1059
-
1060
- // Atomic append using json_insert with $[#] path (appends to end of array)
1061
- // Updates directly by work_item_id without separate SELECT
1062
- const result = db.prepare(`
1063
- UPDATE claude_sessions
1064
- SET content = json_insert(COALESCE(content, '[]'), '$[#]', json(?))
1065
- WHERE work_item_id = ? AND status = 'active'
1066
- `).run(JSON.stringify(turn), workItemId);
1067
-
1068
- return result.changes > 0;
77
+ export interface TestDashboardData {
78
+ summary: TestSummary;
79
+ epics: TestEpic[];
80
+ standaloneFeatures: TestFeature[];
1069
81
  }
1070
82
 
1071
- // Environment Variables - backed by .env files on disk
1072
- export interface EnvVar {
83
+ // Prototype dashboard types
84
+ export interface PrototypeFile {
1073
85
  name: string;
1074
- value: string;
1075
- }
1076
-
1077
- export interface ValidationResult {
1078
- valid: boolean;
1079
- error?: string;
1080
- }
1081
-
1082
- // Validate env var name - rejects empty/whitespace-only names
1083
- export function validateEnvVarName(name: string | undefined | null): ValidationResult {
1084
- if (!name || !name.trim()) {
1085
- return { valid: false, error: 'Variable name is required' };
1086
- }
1087
- return { valid: true };
1088
- }
1089
-
1090
- // Validate env var value - rejects undefined/null (empty string is valid)
1091
- export function validateEnvVarValue(value: string | undefined | null): ValidationResult {
1092
- if (value === undefined || value === null) {
1093
- return { valid: false, error: 'Variable value is required' };
1094
- }
1095
- return { valid: true };
1096
- }
1097
-
1098
- // Check if a variable name already exists in a .env file
1099
- export function checkDuplicateEnvVar(name: string, filename?: string): boolean {
1100
- const vars = getEnvVars(filename);
1101
- return vars.some(v => v.name === name);
1102
- }
1103
-
1104
- function getConfigPath(): string | null {
1105
- const projectRoot = getProjectRoot();
1106
- if (!projectRoot) {
1107
- return null;
1108
- }
1109
- return path.join(projectRoot, '.jettypod', 'config.json');
1110
- }
1111
-
1112
- function readConfig(): Record<string, unknown> {
1113
- const configPath = getConfigPath();
1114
- if (!configPath) {
1115
- return {}; // No project selected
1116
- }
1117
- if (fs.existsSync(configPath)) {
1118
- return JSON.parse(fs.readFileSync(configPath, 'utf-8'));
1119
- }
1120
- return {};
1121
- }
1122
-
1123
- function writeConfig(config: Record<string, unknown>): void {
1124
- const configPath = getConfigPath();
1125
- if (!configPath) {
1126
- throw new Error('No project selected. Cannot write config.');
1127
- }
1128
- fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
1129
- }
1130
-
1131
- // Parse .env file content into key-value pairs (skips comments and blank lines)
1132
- export function parseEnvFile(content: string): EnvVar[] {
1133
- const vars: EnvVar[] = [];
1134
- const lines = content.split('\n');
1135
- for (const line of lines) {
1136
- const trimmed = line.trim();
1137
- if (!trimmed || trimmed.startsWith('#')) continue;
1138
- const eqIndex = trimmed.indexOf('=');
1139
- if (eqIndex > 0) {
1140
- vars.push({
1141
- name: trimmed.substring(0, eqIndex),
1142
- value: trimmed.substring(eqIndex + 1),
1143
- });
1144
- }
1145
- }
1146
- return vars;
1147
- }
1148
-
1149
- // Discover .env files in the project root
1150
- export function discoverEnvFiles(): string[] {
1151
- const projectRoot = getProjectRoot();
1152
- if (!projectRoot) {
1153
- return []; // No project selected
1154
- }
1155
- const files = fs.readdirSync(projectRoot);
1156
- return files.filter((f: string) => f === '.env' || f.startsWith('.env.')).sort();
1157
- }
1158
-
1159
- // Get the currently selected .env file from config.json
1160
- export function getSelectedEnvFile(): string | null {
1161
- const config = readConfig();
1162
- return (config.selectedEnvFile as string) || null;
1163
- }
1164
-
1165
- // Persist the selected .env file in config.json
1166
- export function setSelectedEnvFile(filename: string): void {
1167
- const config = readConfig();
1168
- config.selectedEnvFile = filename;
1169
- writeConfig(config);
1170
- }
1171
-
1172
- // Create a new .env file in the project root
1173
- export function createEnvFile(filename: string = '.env'): void {
1174
- const projectRoot = getProjectRoot();
1175
- if (!projectRoot) {
1176
- throw new Error('No project selected. Cannot create env file.');
1177
- }
1178
- const filePath = path.join(projectRoot, filename);
1179
- fs.writeFileSync(filePath, '', 'utf-8');
86
+ type: string;
87
+ path: string;
1180
88
  }
1181
89
 
1182
- // Read env vars from a specific .env file on disk
1183
- export function getEnvVars(filename?: string): EnvVar[] {
1184
- const projectRoot = getProjectRoot();
1185
- if (!projectRoot) {
1186
- return []; // No project selected
1187
- }
1188
- const file = filename || getSelectedEnvFile() || '.env';
1189
- const filePath = path.join(projectRoot, file);
1190
- if (!fs.existsSync(filePath)) {
1191
- return [];
1192
- }
1193
- const content = fs.readFileSync(filePath, 'utf-8');
1194
- return parseEnvFile(content);
1195
- }
1196
-
1197
- // Add or update an env var in a .env file, preserving comments and blank lines
1198
- export function setEnvVar(name: string, value: string, filename?: string): void {
1199
- const projectRoot = getProjectRoot();
1200
- if (!projectRoot) {
1201
- throw new Error('No project selected. Cannot set env var.');
1202
- }
1203
- const file = filename || getSelectedEnvFile() || '.env';
1204
- const filePath = path.join(projectRoot, file);
1205
-
1206
- if (!fs.existsSync(filePath)) {
1207
- fs.writeFileSync(filePath, `${name}=${value}\n`, 'utf-8');
1208
- return;
1209
- }
1210
-
1211
- const content = fs.readFileSync(filePath, 'utf-8');
1212
- const lines = content.split('\n');
1213
- let found = false;
1214
- const updatedLines = lines.map(line => {
1215
- if (line.startsWith(name + '=')) {
1216
- found = true;
1217
- return `${name}=${value}`;
1218
- }
1219
- return line;
1220
- });
1221
-
1222
- if (!found) {
1223
- // Append new variable
1224
- const trimmed = updatedLines.join('\n').trimEnd();
1225
- fs.writeFileSync(filePath, trimmed + '\n' + `${name}=${value}` + '\n', 'utf-8');
1226
- } else {
1227
- fs.writeFileSync(filePath, updatedLines.join('\n'), 'utf-8');
1228
- }
1229
- }
1230
-
1231
- // Delete an env var from a .env file
1232
- export function deleteEnvVar(name: string, filename?: string): void {
1233
- const projectRoot = getProjectRoot();
1234
- if (!projectRoot) {
1235
- throw new Error('No project selected. Cannot delete env var.');
1236
- }
1237
- const file = filename || getSelectedEnvFile() || '.env';
1238
- const filePath = path.join(projectRoot, file);
1239
-
1240
- if (!fs.existsSync(filePath)) return;
1241
-
1242
- const content = fs.readFileSync(filePath, 'utf-8');
1243
- const lines = content.split('\n');
1244
- const filteredLines = lines.filter(line => !line.startsWith(name + '='));
1245
- fs.writeFileSync(filePath, filteredLines.join('\n'), 'utf-8');
1246
- }
1247
-
1248
- // General Settings - Main Branch Configuration
1249
-
1250
- function detectMainBranch(): string {
1251
- const projectRoot = getProjectRoot();
1252
- const opts = { encoding: 'utf-8' as const, stdio: ['pipe', 'pipe', 'pipe'] as ['pipe', 'pipe', 'pipe'], cwd: projectRoot || undefined };
1253
- try {
1254
- const ref = execSync('git symbolic-ref refs/remotes/origin/HEAD', opts).toString().trim();
1255
- return ref.replace('refs/remotes/origin/', '');
1256
- } catch {
1257
- try {
1258
- execSync('git rev-parse --verify main', opts);
1259
- return 'main';
1260
- } catch {
1261
- try {
1262
- execSync('git rev-parse --verify master', opts);
1263
- return 'master';
1264
- } catch {
1265
- return 'main';
1266
- }
1267
- }
1268
- }
1269
- }
1270
-
1271
- export interface MainBranchInfo {
1272
- branch: string;
1273
- source: 'configured' | 'detected';
1274
- }
1275
-
1276
- export function getMainBranch(): MainBranchInfo {
1277
- const config = readConfig();
1278
- const configured = typeof config.mainBranch === 'string' ? config.mainBranch.trim() : undefined;
1279
- if (configured) {
1280
- return { branch: configured, source: 'configured' };
1281
- }
1282
- return { branch: detectMainBranch(), source: 'detected' };
1283
- }
1284
-
1285
- export function setMainBranch(branch: string | null): void {
1286
- const config = readConfig();
1287
- const trimmed = typeof branch === 'string' ? branch.trim() : branch;
1288
- if (!trimmed) {
1289
- delete config.mainBranch;
1290
- } else {
1291
- config.mainBranch = trimmed;
1292
- }
1293
- writeConfig(config);
1294
- }
1295
-
1296
- // ==================== Usage Tracking ====================
1297
-
1298
- const FREE_WEEKLY_LIMIT = 20;
1299
-
1300
- /**
1301
- * Get the Monday 00:00:00 UTC of the current week.
1302
- * Returns format matching SQLite's CURRENT_TIMESTAMP ("YYYY-MM-DD HH:MM:SS")
1303
- * so lexicographic comparison in WHERE clauses works correctly.
1304
- */
1305
- function getCurrentWeekStart(): string {
1306
- const now = new Date();
1307
- const day = now.getUTCDay(); // 0=Sun, 1=Mon, ...
1308
- const diff = day === 0 ? 6 : day - 1; // days since Monday
1309
- const monday = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate() - diff));
1310
- return monday.toISOString().replace('T', ' ').replace('.000Z', '');
90
+ export interface Prototype {
91
+ id: string;
92
+ title: string;
93
+ date: string;
94
+ month: string;
95
+ description: string;
96
+ feature: { id: number; title: string } | null;
97
+ files: PrototypeFile[];
98
+ path: string;
1311
99
  }
1312
100
 
1313
- export interface WeeklyUsage {
1314
- used: number;
1315
- limit: number;
1316
- remaining: number;
1317
- allowed: boolean;
101
+ export interface PrototypeSummary {
102
+ total: number;
103
+ byMonth: Record<string, number>;
1318
104
  }
1319
105
 
1320
- /**
1321
- * Count work items created on the free plan during the current week.
1322
- */
1323
- export function getWeeklyUsage(): WeeklyUsage {
1324
- const db = getDb();
1325
- const weekStart = getCurrentWeekStart();
1326
- const projectRoot = getProjectRoot();
1327
-
1328
- console.log('[usage] getWeeklyUsage called', { projectRoot, weekStart });
1329
-
1330
- const row = db.prepare(
1331
- `SELECT COUNT(*) as count FROM work_items
1332
- WHERE plan_at_creation = 'free' AND created_at >= ?`
1333
- ).get(weekStart) as { count: number } | undefined;
1334
-
1335
- const used = row?.count ?? 0;
1336
- const remaining = Math.max(0, FREE_WEEKLY_LIMIT - used);
1337
- const result = { used, limit: FREE_WEEKLY_LIMIT, remaining, allowed: remaining > 0 };
1338
-
1339
- console.log('[usage] getWeeklyUsage result', { row, result });
1340
-
1341
- return result;
106
+ export interface PrototypeDashboardData {
107
+ prototypes: Prototype[];
108
+ summary: PrototypeSummary;
1342
109
  }