jettypod 4.4.115 → 4.4.118

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 (73) hide show
  1. package/.env +7 -0
  2. package/apps/dashboard/app/api/claude/[workItemId]/message/route.ts +25 -9
  3. package/apps/dashboard/app/api/claude/sessions/[sessionId]/message/route.ts +7 -3
  4. package/apps/dashboard/app/api/tests/run/stream/route.ts +13 -1
  5. package/apps/dashboard/app/api/usage/route.ts +17 -0
  6. package/apps/dashboard/app/connect-claude/page.tsx +24 -0
  7. package/apps/dashboard/app/install-claude/page.tsx +8 -6
  8. package/apps/dashboard/app/login/page.tsx +229 -0
  9. package/apps/dashboard/app/page.tsx +5 -3
  10. package/apps/dashboard/app/settings/page.tsx +2 -0
  11. package/apps/dashboard/app/subscribe/page.tsx +11 -0
  12. package/apps/dashboard/app/welcome/page.tsx +23 -0
  13. package/apps/dashboard/components/AppShell.tsx +51 -9
  14. package/apps/dashboard/components/CardMenu.tsx +14 -5
  15. package/apps/dashboard/components/ClaudePanel.tsx +65 -9
  16. package/apps/dashboard/components/ConnectClaudeScreen.tsx +223 -0
  17. package/apps/dashboard/components/DragContext.tsx +73 -64
  18. package/apps/dashboard/components/DraggableCard.tsx +6 -46
  19. package/apps/dashboard/components/GateCard.tsx +21 -0
  20. package/apps/dashboard/components/InstallClaudeScreen.tsx +132 -30
  21. package/apps/dashboard/components/KanbanBoard.tsx +173 -56
  22. package/apps/dashboard/components/PlaceholderCard.tsx +9 -19
  23. package/apps/dashboard/components/ProjectSwitcher.tsx +28 -0
  24. package/apps/dashboard/components/RealTimeKanbanWrapper.tsx +34 -3
  25. package/apps/dashboard/components/RealTimeTestsWrapper.tsx +30 -2
  26. package/apps/dashboard/components/SubscribeContent.tsx +191 -0
  27. package/apps/dashboard/components/TipCard.tsx +176 -0
  28. package/apps/dashboard/components/UpgradeBanner.tsx +29 -0
  29. package/apps/dashboard/components/WelcomeScreen.tsx +14 -4
  30. package/apps/dashboard/components/settings/AccountSection.tsx +163 -0
  31. package/apps/dashboard/contexts/ClaudeSessionContext.tsx +292 -29
  32. package/apps/dashboard/contexts/UsageContext.tsx +131 -0
  33. package/apps/dashboard/contexts/usageHelpers.js +9 -0
  34. package/apps/dashboard/electron/ipc-handlers.js +220 -114
  35. package/apps/dashboard/electron/main.js +415 -37
  36. package/apps/dashboard/electron/preload.js +23 -4
  37. package/apps/dashboard/electron/session-manager.js +141 -0
  38. package/apps/dashboard/electron-builder.config.js +3 -5
  39. package/apps/dashboard/lib/claude-process-manager.ts +6 -4
  40. package/apps/dashboard/lib/db-bridge.ts +32 -0
  41. package/apps/dashboard/lib/db.ts +159 -13
  42. package/apps/dashboard/lib/session-state-machine.ts +3 -0
  43. package/apps/dashboard/lib/session-stream-manager.ts +76 -13
  44. package/apps/dashboard/lib/tests.ts +3 -1
  45. package/apps/dashboard/next.config.js +19 -14
  46. package/apps/dashboard/package.json +3 -1
  47. package/apps/dashboard/scripts/upload-to-r2.js +89 -0
  48. package/apps/dashboard/tsconfig.tsbuildinfo +1 -0
  49. package/apps/update-server/package.json +16 -0
  50. package/apps/update-server/schema.sql +31 -0
  51. package/apps/update-server/src/index.ts +1074 -0
  52. package/apps/update-server/tsconfig.json +16 -0
  53. package/apps/update-server/wrangler.toml +35 -0
  54. package/docs/bdd-guidance.md +390 -0
  55. package/jettypod.js +5 -4
  56. package/lib/migrations/027-plan-at-creation-column.js +31 -0
  57. package/lib/migrations/028-ready-for-review-column.js +27 -0
  58. package/lib/schema.js +3 -1
  59. package/lib/seed-onboarding.js +100 -68
  60. package/lib/work-commands/index.js +43 -13
  61. package/lib/work-tracking/index.js +46 -27
  62. package/package.json +1 -1
  63. package/skills-templates/bug-mode/SKILL.md +5 -11
  64. package/skills-templates/request-routing/SKILL.md +24 -11
  65. package/skills-templates/simple-improvement/SKILL.md +35 -19
  66. package/skills-templates/stable-mode/SKILL.md +5 -6
  67. package/templates/bdd-guidance.md +139 -0
  68. package/templates/bdd-scaffolding/wait.js +18 -0
  69. package/templates/bdd-scaffolding/world.js +19 -0
  70. package/.jettypod-backup/work.db +0 -0
  71. package/apps/dashboard/app/access-code/page.tsx +0 -110
  72. package/lib/discovery-checkpoint.js +0 -123
  73. package/skills-templates/project-discovery/SKILL.md +0 -372
@@ -0,0 +1,141 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+ const { app } = require('electron');
4
+
5
+ const UPDATE_SERVER_URL = 'https://jettypod-update-server.spangbaryn2.workers.dev';
6
+ const HEARTBEAT_INTERVAL_MS = 4 * 60 * 60 * 1000; // 4 hours
7
+
8
+ let heartbeatInterval = null;
9
+ let updaterHeaders = {};
10
+ let log = console.log;
11
+
12
+ function setLogger(logFn) {
13
+ log = logFn;
14
+ }
15
+
16
+ function getAuthPath() {
17
+ return path.join(app.getPath('userData'), 'auth.json');
18
+ }
19
+
20
+ function readAuth() {
21
+ const authPath = getAuthPath();
22
+ if (!fs.existsSync(authPath)) return null;
23
+ try {
24
+ return JSON.parse(fs.readFileSync(authPath, 'utf-8'));
25
+ } catch {
26
+ return null;
27
+ }
28
+ }
29
+
30
+ function saveAuth(data) {
31
+ const authPath = getAuthPath();
32
+ const dir = path.dirname(authPath);
33
+ if (!fs.existsSync(dir)) {
34
+ fs.mkdirSync(dir, { recursive: true });
35
+ }
36
+ fs.writeFileSync(authPath, JSON.stringify(data, null, 2));
37
+ }
38
+
39
+ function decodeJWT(token) {
40
+ const parts = token.split('.');
41
+ if (parts.length !== 3) return null;
42
+ try {
43
+ const payload = parts[1].replace(/-/g, '+').replace(/_/g, '/');
44
+ return JSON.parse(Buffer.from(payload, 'base64').toString('utf-8'));
45
+ } catch {
46
+ return null;
47
+ }
48
+ }
49
+
50
+ function updateAutoUpdaterHeaders() {
51
+ const auth = readAuth();
52
+ if (auth && auth.token) {
53
+ updaterHeaders = { 'Authorization': `Bearer ${auth.token}` };
54
+ } else {
55
+ updaterHeaders = {};
56
+ }
57
+ return updaterHeaders;
58
+ }
59
+
60
+ async function heartbeat() {
61
+ const auth = readAuth();
62
+ if (!auth || !auth.token) {
63
+ log('[Session] No auth token for heartbeat');
64
+ return;
65
+ }
66
+
67
+ log('[Session] Heartbeat: checking /auth/me...');
68
+ try {
69
+ const response = await fetch(`${UPDATE_SERVER_URL}/auth/me`, {
70
+ headers: { 'Authorization': `Bearer ${auth.token}` },
71
+ });
72
+
73
+ if (!response.ok) {
74
+ log(`[Session] Heartbeat failed: ${response.status}`);
75
+ return;
76
+ }
77
+
78
+ const data = await response.json();
79
+
80
+ // If server returned a new token (plan changed), save it
81
+ if (data.token) {
82
+ const payload = decodeJWT(data.token);
83
+ const user = payload
84
+ ? { id: payload.sub, email: payload.email, plan: payload.plan }
85
+ : auth.user;
86
+ saveAuth({ token: data.token, user, savedAt: new Date().toISOString() });
87
+ log('[Session] Token refreshed from server');
88
+ } else if (data.user && data.user.plan !== auth.user?.plan) {
89
+ // Plan changed but no new token — update user info
90
+ saveAuth({ ...auth, user: data.user, savedAt: new Date().toISOString() });
91
+ log(`[Session] Plan updated: ${auth.user?.plan} → ${data.user.plan}`);
92
+ }
93
+
94
+ updateAutoUpdaterHeaders();
95
+ } catch (error) {
96
+ log(`[Session] Heartbeat error: ${error.message}`);
97
+ }
98
+ }
99
+
100
+ function start() {
101
+ log('[Session] Starting session manager...');
102
+ updateAutoUpdaterHeaders();
103
+
104
+ // Run first heartbeat after a short delay (don't block startup)
105
+ setTimeout(heartbeat, 5000);
106
+
107
+ // Schedule periodic heartbeats
108
+ heartbeatInterval = setInterval(heartbeat, HEARTBEAT_INTERVAL_MS);
109
+
110
+ log('[Session] Session manager started (heartbeat every 4 hours)');
111
+ return true;
112
+ }
113
+
114
+ function stop() {
115
+ if (heartbeatInterval) {
116
+ clearInterval(heartbeatInterval);
117
+ heartbeatInterval = null;
118
+ }
119
+ log('[Session] Session manager stopped');
120
+ }
121
+
122
+ function isRunning() {
123
+ return heartbeatInterval !== null;
124
+ }
125
+
126
+ function getUpdaterHeaders() {
127
+ return updaterHeaders;
128
+ }
129
+
130
+ module.exports = {
131
+ start,
132
+ stop,
133
+ heartbeat,
134
+ readAuth,
135
+ saveAuth,
136
+ updateAutoUpdaterHeaders,
137
+ isRunning,
138
+ getUpdaterHeaders,
139
+ setLogger,
140
+ getAuthPath,
141
+ };
@@ -314,12 +314,10 @@ module.exports = {
314
314
  // Rebuild native modules
315
315
  npmRebuild: true,
316
316
 
317
- // Auto-update configuration - publish to GitHub Releases
317
+ // Auto-update configuration - generic provider pointing to update server
318
318
  publish: {
319
- provider: 'github',
320
- owner: 'spangbaryn',
321
- repo: 'jettypod-source',
322
- releaseType: 'release',
319
+ provider: 'generic',
320
+ url: 'https://jettypod-update-server.spangbaryn2.workers.dev/updates',
323
321
  },
324
322
 
325
323
  // Hooks for native module handling
@@ -27,8 +27,10 @@ const pinnedSessions = new Set<string>();
27
27
  // Maximum concurrent Claude processes allowed
28
28
  const MAX_PROCESSES = 8;
29
29
 
30
- // Timeout for idle processes (30 minutes, only applies to unpinned sessions)
31
- const IDLE_TIMEOUT_MS = 30 * 60 * 1000;
30
+ // Timeout for idle processes (2 hours, only applies to unpinned sessions).
31
+ // Longer timeout reduces process respawn frequency, avoiding the cost of
32
+ // context restoration (dumping full conversation history into a single message).
33
+ const IDLE_TIMEOUT_MS = 2 * 60 * 60 * 1000;
32
34
 
33
35
  // ============================================================================
34
36
  // Auto Gate Emission
@@ -484,7 +486,7 @@ export function cleanupIdleProcesses(): number {
484
486
  return cleaned;
485
487
  }
486
488
 
487
- // Start periodic cleanup (every 5 minutes)
489
+ // Start periodic cleanup (every 15 minutes)
488
490
  setInterval(() => {
489
491
  cleanupIdleProcesses();
490
- }, 5 * 60 * 1000);
492
+ }, 15 * 60 * 1000);
@@ -19,6 +19,12 @@ interface ElectronAPI {
19
19
  error?: string;
20
20
  path?: string;
21
21
  }>;
22
+ newProject: () => Promise<{
23
+ success: boolean;
24
+ canceled?: boolean;
25
+ error?: string;
26
+ path?: string;
27
+ }>;
22
28
  getRecent: () => Promise<RecentProject[]>;
23
29
  openRecent: (path: string) => Promise<{
24
30
  success: boolean;
@@ -32,6 +38,11 @@ interface ElectronAPI {
32
38
  error?: string;
33
39
  }>;
34
40
  isInstalled: () => Promise<boolean>;
41
+ isAuthenticated: () => Promise<boolean>;
42
+ login: () => Promise<{
43
+ success: boolean;
44
+ error?: string;
45
+ }>;
35
46
  update: () => Promise<{
36
47
  success: boolean;
37
48
  error?: string;
@@ -48,6 +59,25 @@ interface ElectronAPI {
48
59
  activatedAt?: string;
49
60
  }>;
50
61
  };
62
+ auth: {
63
+ loginWithGoogle: () => Promise<void>;
64
+ saveToken: (token: string, user: { email: string; plan?: string }) => Promise<void>;
65
+ getStatus: () => Promise<{
66
+ authenticated: boolean;
67
+ token?: string;
68
+ user?: { email: string; plan?: string };
69
+ }>;
70
+ getToken: () => Promise<string | null>;
71
+ logout: () => Promise<void>;
72
+ };
73
+ subscription: {
74
+ createCheckout: (plan: string) => Promise<{ success: boolean; error?: string }>;
75
+ activate: (customerId: string) => Promise<{ success: boolean; error?: string }>;
76
+ getStatus: () => Promise<{ active: boolean; customerId?: string; activatedAt?: string }>;
77
+ };
78
+ billing: {
79
+ openCustomerPortal: () => Promise<{ success: boolean; error?: string }>;
80
+ };
51
81
  shell: {
52
82
  openPath: (filePath: string) => Promise<string>;
53
83
  };
@@ -123,6 +153,7 @@ export type {
123
153
  SessionWithFeature,
124
154
  SessionStatus,
125
155
  EnvVar,
156
+ WeeklyUsage,
126
157
  } from './db';
127
158
 
128
159
  // ==================== Kanban ====================
@@ -248,3 +279,4 @@ export function deleteEnvVar(name: string): void {
248
279
  export function getProjectName(): string {
249
280
  return directDb.getProjectName();
250
281
  }
282
+
@@ -26,8 +26,10 @@ export interface WorkItem {
26
26
  completed_at: string | null;
27
27
  rejection_reason: string | null;
28
28
  rejected_at: string | null;
29
+ ready_for_review: number;
29
30
  created_at: string;
30
31
  display_order: number | null;
32
+ conversational: number;
31
33
  children?: WorkItem[];
32
34
  chores?: WorkItem[];
33
35
  bugs?: WorkItem[];
@@ -44,7 +46,7 @@ export interface Decision {
44
46
  created_at: string;
45
47
  }
46
48
 
47
- function getProjectRoot(): string | null {
49
+ export function getProjectRoot(): string | null {
48
50
  // Use JETTYPOD_PROJECT_PATH if set (passed by Electron main process)
49
51
  if (process.env.JETTYPOD_PROJECT_PATH) {
50
52
  return process.env.JETTYPOD_PROJECT_PATH;
@@ -108,6 +110,9 @@ function ensureColumns(db: Database.Database): void {
108
110
  tryAddTo('work_items', 'display_order', 'INTEGER DEFAULT NULL');
109
111
  tryAddTo('work_items', 'rejection_reason', 'TEXT');
110
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');
111
116
  } catch {
112
117
  // Table might not exist yet - ensureSchema will handle it
113
118
  }
@@ -139,6 +144,71 @@ function ensureColumns(db: Database.Database): void {
139
144
  }
140
145
  }
141
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
+
142
212
  // Singleton database connection - reused across all operations
143
213
  let cachedDb: Database.Database | null = null;
144
214
  let cachedDbPath: string | null = null;
@@ -183,6 +253,8 @@ function getDb(): Database.Database {
183
253
  } catch (err) {
184
254
  console.error('Failed to run migrations:', err);
185
255
  }
256
+ // Seed onboarding epic for blank projects (no implementation code detected)
257
+ seedOnboardingIfBlank(cachedDb, dbPath);
186
258
  } catch (err) {
187
259
  // Clean up failed connection to avoid caching a broken db
188
260
  if (cachedDb) {
@@ -216,7 +288,7 @@ export function getAllWorkItems(): WorkItem[] {
216
288
  const db = getDb();
217
289
  const items = db.prepare(`
218
290
  SELECT id, type, title, description, status, parent_id, epic_id,
219
- branch_name, mode, phase, completed_at, created_at
291
+ branch_name, mode, phase, completed_at, created_at, conversational
220
292
  FROM work_items
221
293
  ORDER BY id
222
294
  `).all() as WorkItem[];
@@ -227,7 +299,7 @@ export function getWorkItem(id: number): WorkItem | null {
227
299
  const db = getDb();
228
300
  const item = db.prepare(`
229
301
  SELECT id, type, title, description, status, parent_id, epic_id,
230
- branch_name, mode, phase, completed_at, created_at
302
+ branch_name, mode, phase, completed_at, created_at, conversational
231
303
  FROM work_items
232
304
  WHERE id = ?
233
305
  `).get(id) as WorkItem | undefined;
@@ -238,7 +310,7 @@ export function getChildWorkItems(parentId: number): WorkItem[] {
238
310
  const db = getDb();
239
311
  const items = db.prepare(`
240
312
  SELECT id, type, title, description, status, parent_id, epic_id,
241
- branch_name, mode, phase, completed_at, created_at
313
+ branch_name, mode, phase, completed_at, created_at, conversational
242
314
  FROM work_items
243
315
  WHERE parent_id = ?
244
316
  ORDER BY id
@@ -321,7 +393,7 @@ export function getActiveWork(): WorkItem[] {
321
393
  const db = getDb();
322
394
  const items = db.prepare(`
323
395
  SELECT id, type, title, description, status, parent_id, epic_id,
324
- branch_name, mode, phase, completed_at, created_at
396
+ branch_name, mode, phase, completed_at, created_at, conversational
325
397
  FROM work_items
326
398
  WHERE status = 'in_progress'
327
399
  ORDER BY id
@@ -333,7 +405,7 @@ export function getRecentlyCompleted(limit: number = 10): WorkItem[] {
333
405
  const db = getDb();
334
406
  const items = db.prepare(`
335
407
  SELECT id, type, title, description, status, parent_id, epic_id,
336
- branch_name, mode, phase, completed_at, created_at
408
+ branch_name, mode, phase, completed_at, created_at, conversational
337
409
  FROM work_items
338
410
  WHERE status = 'done'
339
411
  ORDER BY completed_at DESC
@@ -370,7 +442,7 @@ export function getKanbanData(doneLimit: number = 50): KanbanData {
370
442
  // Get all chores that belong to features (for chore expansion)
371
443
  const featureChores = db.prepare(`
372
444
  SELECT c.id, c.type, c.title, c.description, c.status, c.parent_id, c.epic_id,
373
- c.branch_name, c.mode, c.phase, c.completed_at, c.created_at,
445
+ c.branch_name, c.mode, c.phase, c.completed_at, c.created_at, c.conversational,
374
446
  wc.current_step, wc.total_steps
375
447
  FROM work_items c
376
448
  INNER JOIN work_items f ON c.parent_id = f.id
@@ -392,7 +464,7 @@ export function getKanbanData(doneLimit: number = 50): KanbanData {
392
464
  // Get all bugs that belong to features (for bug expansion)
393
465
  const featureBugs = db.prepare(`
394
466
  SELECT b.id, b.type, b.title, b.description, b.status, b.parent_id, b.epic_id,
395
- b.branch_name, b.mode, b.phase, b.completed_at, b.created_at
467
+ b.branch_name, b.mode, b.phase, b.completed_at, b.created_at, b.conversational
396
468
  FROM work_items b
397
469
  INNER JOIN work_items f ON b.parent_id = f.id
398
470
  WHERE b.type = 'bug' AND f.type = 'feature'
@@ -415,7 +487,8 @@ export function getKanbanData(doneLimit: number = 50): KanbanData {
415
487
  // - Bugs if they exist
416
488
  const allItems = db.prepare(`
417
489
  SELECT w.id, w.type, w.title, w.description, w.status, w.parent_id, w.epic_id,
418
- w.branch_name, w.mode, w.phase, w.completed_at, w.created_at, w.display_order,
490
+ w.branch_name, w.mode, w.phase, w.completed_at, w.created_at, w.conversational, w.display_order,
491
+ w.ready_for_review,
419
492
  p.type as parent_type,
420
493
  wc.current_step, wc.total_steps
421
494
  FROM work_items w
@@ -543,17 +616,42 @@ export function updateWorkItemDescription(id: number, description: string): bool
543
616
 
544
617
  export function updateWorkItemStatus(id: number, status: string, rejectionReason?: string): boolean {
545
618
  const db = getWriteDb();
619
+ // Normalize rejection reason: treat empty/whitespace-only as no rejection
620
+ const normalizedRejection = rejectionReason?.trim() || undefined;
546
621
  const completedAt = status === 'done' ? new Date().toISOString() : null;
547
- const rejectedAt = rejectionReason ? new Date().toISOString() : null;
548
- const result = db.prepare(`
549
- UPDATE work_items SET status = ?, completed_at = ?, rejection_reason = ?, rejected_at = ? WHERE id = ?
550
- `).run(status, completedAt, rejectionReason ?? null, rejectedAt, id);
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);
551
632
 
552
633
  // When work item is cancelled, mark any linked sessions as orphaned
553
634
  if (status === 'cancelled' && result.changes > 0) {
554
635
  orphanSessionsByWorkItem(id);
555
636
  }
556
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
+
557
655
  return result.changes > 0;
558
656
  }
559
657
 
@@ -1157,3 +1255,51 @@ export function setMainBranch(branch: string | null): void {
1157
1255
  }
1158
1256
  writeConfig(config);
1159
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', '');
1274
+ }
1275
+
1276
+ export interface WeeklyUsage {
1277
+ used: number;
1278
+ limit: number;
1279
+ remaining: number;
1280
+ allowed: boolean;
1281
+ }
1282
+
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;
1305
+ }
@@ -43,6 +43,7 @@ export type SessionState = 'idle' | 'connecting' | 'streaming' | 'done' | 'error
43
43
  /** All possible state transition events */
44
44
  export type SessionEvent =
45
45
  | 'SEND' // User sends a message
46
+ | 'QUEUE' // User sends a message while streaming — queued for later
46
47
  | 'CONNECTED' // Connection established, streaming begins
47
48
  | 'COMPLETE' // Stream completed successfully
48
49
  | 'ERROR' // An error occurred
@@ -82,11 +83,13 @@ const TRANSITIONS: Record<SessionState, Partial<Record<SessionEvent, SessionStat
82
83
  CLEAR: 'idle',
83
84
  },
84
85
  connecting: {
86
+ QUEUE: 'connecting', // Queue message while connecting (stays in same state)
85
87
  CONNECTED: 'streaming',
86
88
  ERROR: 'error',
87
89
  STOP: 'idle',
88
90
  },
89
91
  streaming: {
92
+ QUEUE: 'streaming', // Queue message while streaming (stays in same state)
90
93
  COMPLETE: 'done',
91
94
  ERROR: 'error',
92
95
  STOP: 'idle',