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