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.
- package/.env +7 -0
- package/apps/dashboard/app/api/claude/[workItemId]/message/route.ts +25 -9
- package/apps/dashboard/app/api/claude/sessions/[sessionId]/message/route.ts +7 -3
- package/apps/dashboard/app/api/tests/run/stream/route.ts +13 -1
- package/apps/dashboard/app/api/usage/route.ts +17 -0
- package/apps/dashboard/app/connect-claude/page.tsx +24 -0
- package/apps/dashboard/app/install-claude/page.tsx +8 -6
- package/apps/dashboard/app/login/page.tsx +229 -0
- package/apps/dashboard/app/page.tsx +5 -3
- package/apps/dashboard/app/settings/page.tsx +2 -0
- package/apps/dashboard/app/subscribe/page.tsx +11 -0
- package/apps/dashboard/app/welcome/page.tsx +23 -0
- package/apps/dashboard/components/AppShell.tsx +51 -9
- package/apps/dashboard/components/CardMenu.tsx +14 -5
- package/apps/dashboard/components/ClaudePanel.tsx +65 -9
- package/apps/dashboard/components/ConnectClaudeScreen.tsx +223 -0
- package/apps/dashboard/components/DragContext.tsx +73 -64
- package/apps/dashboard/components/DraggableCard.tsx +6 -46
- package/apps/dashboard/components/GateCard.tsx +21 -0
- package/apps/dashboard/components/InstallClaudeScreen.tsx +132 -30
- package/apps/dashboard/components/KanbanBoard.tsx +173 -56
- package/apps/dashboard/components/PlaceholderCard.tsx +9 -19
- package/apps/dashboard/components/ProjectSwitcher.tsx +28 -0
- package/apps/dashboard/components/RealTimeKanbanWrapper.tsx +34 -3
- package/apps/dashboard/components/RealTimeTestsWrapper.tsx +30 -2
- package/apps/dashboard/components/SubscribeContent.tsx +191 -0
- package/apps/dashboard/components/TipCard.tsx +176 -0
- package/apps/dashboard/components/UpgradeBanner.tsx +29 -0
- package/apps/dashboard/components/WelcomeScreen.tsx +14 -4
- package/apps/dashboard/components/settings/AccountSection.tsx +163 -0
- package/apps/dashboard/contexts/ClaudeSessionContext.tsx +292 -29
- package/apps/dashboard/contexts/UsageContext.tsx +131 -0
- package/apps/dashboard/contexts/usageHelpers.js +9 -0
- package/apps/dashboard/electron/ipc-handlers.js +220 -114
- package/apps/dashboard/electron/main.js +415 -37
- package/apps/dashboard/electron/preload.js +23 -4
- package/apps/dashboard/electron/session-manager.js +141 -0
- package/apps/dashboard/electron-builder.config.js +3 -5
- package/apps/dashboard/lib/claude-process-manager.ts +6 -4
- package/apps/dashboard/lib/db-bridge.ts +32 -0
- package/apps/dashboard/lib/db.ts +159 -13
- package/apps/dashboard/lib/session-state-machine.ts +3 -0
- package/apps/dashboard/lib/session-stream-manager.ts +76 -13
- package/apps/dashboard/lib/tests.ts +3 -1
- package/apps/dashboard/next.config.js +19 -14
- package/apps/dashboard/package.json +3 -1
- package/apps/dashboard/scripts/upload-to-r2.js +89 -0
- package/apps/dashboard/tsconfig.tsbuildinfo +1 -0
- package/apps/update-server/package.json +16 -0
- package/apps/update-server/schema.sql +31 -0
- package/apps/update-server/src/index.ts +1074 -0
- package/apps/update-server/tsconfig.json +16 -0
- package/apps/update-server/wrangler.toml +35 -0
- package/docs/bdd-guidance.md +390 -0
- package/jettypod.js +5 -4
- package/lib/migrations/027-plan-at-creation-column.js +31 -0
- package/lib/migrations/028-ready-for-review-column.js +27 -0
- package/lib/schema.js +3 -1
- package/lib/seed-onboarding.js +100 -68
- package/lib/work-commands/index.js +43 -13
- package/lib/work-tracking/index.js +46 -27
- package/package.json +1 -1
- package/skills-templates/bug-mode/SKILL.md +5 -11
- package/skills-templates/request-routing/SKILL.md +24 -11
- package/skills-templates/simple-improvement/SKILL.md +35 -19
- package/skills-templates/stable-mode/SKILL.md +5 -6
- package/templates/bdd-guidance.md +139 -0
- package/templates/bdd-scaffolding/wait.js +18 -0
- package/templates/bdd-scaffolding/world.js +19 -0
- package/.jettypod-backup/work.db +0 -0
- package/apps/dashboard/app/access-code/page.tsx +0 -110
- package/lib/discovery-checkpoint.js +0 -123
- 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 -
|
|
317
|
+
// Auto-update configuration - generic provider pointing to update server
|
|
318
318
|
publish: {
|
|
319
|
-
provider: '
|
|
320
|
-
|
|
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 (
|
|
31
|
-
|
|
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
|
|
489
|
+
// Start periodic cleanup (every 15 minutes)
|
|
488
490
|
setInterval(() => {
|
|
489
491
|
cleanupIdleProcesses();
|
|
490
|
-
},
|
|
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
|
+
|
package/apps/dashboard/lib/db.ts
CHANGED
|
@@ -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 =
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
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',
|