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.
- package/.env +4 -3
- package/Cargo.lock +6450 -0
- package/Cargo.toml +35 -0
- package/README.md +5 -1
- package/TAURI-MIGRATION-PLAN.md +840 -0
- package/apps/dashboard/app/connect-claude/page.tsx +5 -6
- package/apps/dashboard/app/decision/[id]/page.tsx +63 -58
- package/apps/dashboard/app/demo/gates/page.tsx +43 -45
- package/apps/dashboard/app/design-system/page.tsx +868 -0
- package/apps/dashboard/app/globals.css +80 -4
- package/apps/dashboard/app/install-claude/page.tsx +4 -6
- package/apps/dashboard/app/login/page.tsx +72 -54
- package/apps/dashboard/app/page.tsx +101 -48
- package/apps/dashboard/app/settings/page.tsx +61 -13
- package/apps/dashboard/app/signup/page.tsx +242 -0
- package/apps/dashboard/app/subscribe/page.tsx +0 -2
- package/apps/dashboard/app/tests/page.tsx +37 -4
- package/apps/dashboard/app/welcome/page.tsx +13 -16
- package/apps/dashboard/app/work/[id]/page.tsx +117 -118
- package/apps/dashboard/app/work/[id]/proof/page.tsx +1489 -0
- package/apps/dashboard/components/AppShell.tsx +92 -85
- package/apps/dashboard/components/CardMenu.tsx +45 -12
- package/apps/dashboard/components/ClaudePanel.tsx +771 -850
- package/apps/dashboard/components/ClaudePanelInput.tsx +43 -15
- package/apps/dashboard/components/ConnectClaudeScreen.tsx +17 -34
- package/apps/dashboard/components/CopyableId.tsx +3 -4
- package/apps/dashboard/components/DetailReviewActions.tsx +100 -0
- package/apps/dashboard/components/DragContext.tsx +134 -63
- package/apps/dashboard/components/DraggableCard.tsx +3 -5
- package/apps/dashboard/components/DropZone.tsx +6 -7
- package/apps/dashboard/components/EditableDetailDescription.tsx +7 -13
- package/apps/dashboard/components/EditableDetailTitle.tsx +6 -13
- package/apps/dashboard/components/EditableTitle.tsx +26 -7
- package/apps/dashboard/components/ElapsedTimer.tsx +66 -0
- package/apps/dashboard/components/EpicGroup.tsx +359 -0
- package/apps/dashboard/components/GateCard.tsx +79 -17
- package/apps/dashboard/components/GateChoiceCard.tsx +15 -18
- package/apps/dashboard/components/InstallClaudeScreen.tsx +15 -32
- package/apps/dashboard/components/JettyLoader.tsx +37 -0
- package/apps/dashboard/components/KanbanBoard.tsx +368 -958
- package/apps/dashboard/components/KanbanCard.tsx +740 -0
- package/apps/dashboard/components/LazyCard.tsx +62 -0
- package/apps/dashboard/components/LazyMarkdown.tsx +11 -0
- package/apps/dashboard/components/MainNav.tsx +38 -73
- package/apps/dashboard/components/MessageBlock.tsx +468 -0
- package/apps/dashboard/components/ModeStartCard.tsx +15 -16
- package/apps/dashboard/components/OnboardingWelcome.tsx +213 -0
- package/apps/dashboard/components/PlaceholderCard.tsx +3 -4
- package/apps/dashboard/components/ProjectSwitcher.tsx +30 -30
- package/apps/dashboard/components/PrototypeTimeline.tsx +72 -51
- package/apps/dashboard/components/RealTimeKanbanWrapper.tsx +406 -388
- package/apps/dashboard/components/RealTimeTestsWrapper.tsx +373 -235
- package/apps/dashboard/components/ReviewFooter.tsx +139 -0
- package/apps/dashboard/components/SessionList.tsx +19 -19
- package/apps/dashboard/components/SubscribeContent.tsx +91 -47
- package/apps/dashboard/components/TestTree.tsx +16 -16
- package/apps/dashboard/components/TipCard.tsx +16 -17
- package/apps/dashboard/components/Toast.tsx +5 -6
- package/apps/dashboard/components/TypeIcon.tsx +55 -0
- package/apps/dashboard/components/ViewModeToolbar.tsx +104 -0
- package/apps/dashboard/components/WaveCompletionAnimation.tsx +52 -65
- package/apps/dashboard/components/WelcomeScreen.tsx +19 -35
- package/apps/dashboard/components/WorkItemHeader.tsx +4 -5
- package/apps/dashboard/components/WorkItemTree.tsx +11 -32
- package/apps/dashboard/components/settings/AccountSection.tsx +55 -35
- package/apps/dashboard/components/settings/AiContextSection.tsx +89 -0
- package/apps/dashboard/components/settings/ContextDocumentsSection.tsx +317 -0
- package/apps/dashboard/components/settings/EnvVarsSection.tsx +74 -152
- package/apps/dashboard/components/settings/GeneralSection.tsx +162 -56
- package/apps/dashboard/components/settings/ProjectStackSection.tsx +948 -0
- package/apps/dashboard/components/settings/SettingsLayout.tsx +4 -5
- package/apps/dashboard/components/ui/Button.tsx +104 -0
- package/apps/dashboard/components/ui/Input.tsx +78 -0
- package/apps/dashboard/components.json +1 -1
- package/apps/dashboard/contexts/ClaudeSessionContext.tsx +711 -418
- package/apps/dashboard/contexts/ConnectionStatusContext.tsx +25 -5
- package/apps/dashboard/contexts/UsageContext.tsx +87 -32
- package/apps/dashboard/dev.sh +35 -0
- package/apps/dashboard/eslint.config.mjs +9 -9
- package/apps/dashboard/hooks/useKanbanAnimation.ts +29 -0
- package/apps/dashboard/hooks/useKanbanUndo.ts +83 -0
- package/apps/dashboard/hooks/useWebSocket.ts +138 -83
- package/apps/dashboard/index.html +73 -0
- package/apps/dashboard/lib/constants.ts +43 -0
- package/apps/dashboard/lib/data-bridge.ts +722 -0
- package/apps/dashboard/lib/db.ts +69 -1265
- package/apps/dashboard/lib/environment-config.ts +173 -0
- package/apps/dashboard/lib/environment-verification.ts +119 -0
- package/apps/dashboard/lib/kanban-utils.ts +270 -0
- package/apps/dashboard/lib/proof-run.ts +495 -0
- package/apps/dashboard/lib/proof-scenario-runner.ts +346 -0
- package/apps/dashboard/lib/run-migrations.js +27 -2
- package/apps/dashboard/lib/service-recovery.ts +326 -0
- package/apps/dashboard/lib/session-state-machine.ts +1 -0
- package/apps/dashboard/lib/session-state-utils.ts +0 -164
- package/apps/dashboard/lib/session-stream-manager.ts +308 -134
- package/apps/dashboard/lib/shadows.ts +7 -0
- package/apps/dashboard/lib/stream-manager-registry.ts +46 -6
- package/apps/dashboard/lib/tauri-bridge.ts +102 -0
- package/apps/dashboard/lib/tauri.ts +106 -0
- package/apps/dashboard/lib/utils.ts +6 -0
- package/apps/dashboard/next-env.d.ts +1 -1
- package/apps/dashboard/package.json +21 -32
- package/apps/dashboard/public/bug-icon.png +0 -0
- package/apps/dashboard/public/buoy-icon.png +0 -0
- package/apps/dashboard/public/fonts/Satoshi-Variable.woff2 +0 -0
- package/apps/dashboard/public/fonts/Satoshi-VariableItalic.woff2 +0 -0
- package/apps/dashboard/public/in-flight-seagull.png +0 -0
- package/apps/dashboard/public/jetty-icon-loading-alt.svg +11 -0
- package/apps/dashboard/public/jetty-icon-loading.svg +11 -0
- package/apps/dashboard/public/jettypod_logo.png +0 -0
- package/apps/dashboard/public/pier-icon.png +0 -0
- package/apps/dashboard/public/star-icon.png +0 -0
- package/apps/dashboard/public/wrench-icon.png +0 -0
- package/apps/dashboard/scripts/tauri-build.js +228 -0
- package/apps/dashboard/scripts/upload-tauri-to-r2.js +125 -0
- package/apps/dashboard/scripts/ws-server.js +191 -0
- package/apps/dashboard/src/main.tsx +12 -0
- package/apps/dashboard/src/router.tsx +107 -0
- package/apps/dashboard/src/vite-env.d.ts +1 -0
- package/apps/dashboard/tsconfig.json +7 -12
- package/apps/dashboard/tsconfig.tsbuildinfo +1 -1
- package/apps/dashboard/vite.config.ts +33 -0
- package/apps/update-server/src/index.ts +228 -80
- package/claude-hooks/global-guardrails.js +14 -13
- package/crates/jettypod-cli/Cargo.toml +19 -0
- package/crates/jettypod-cli/src/commands.rs +1249 -0
- package/crates/jettypod-cli/src/main.rs +595 -0
- package/crates/jettypod-core/Cargo.toml +26 -0
- package/crates/jettypod-core/build.rs +98 -0
- package/crates/jettypod-core/migrations/V1__baseline.sql +197 -0
- package/crates/jettypod-core/migrations/V2__work_items_indexes.sql +6 -0
- package/crates/jettypod-core/migrations/V3__qa_steps.sql +2 -0
- package/crates/jettypod-core/src/auth.rs +294 -0
- package/crates/jettypod-core/src/config.rs +397 -0
- package/crates/jettypod-core/src/db/mod.rs +507 -0
- package/crates/jettypod-core/src/db/recovery.rs +114 -0
- package/crates/jettypod-core/src/db/startup.rs +101 -0
- package/crates/jettypod-core/src/db/validate.rs +149 -0
- package/crates/jettypod-core/src/error.rs +76 -0
- package/crates/jettypod-core/src/git.rs +458 -0
- package/crates/jettypod-core/src/lib.rs +20 -0
- package/crates/jettypod-core/src/sessions.rs +625 -0
- package/crates/jettypod-core/src/skills.rs +556 -0
- package/crates/jettypod-core/src/work.rs +1086 -0
- package/crates/jettypod-core/src/worktree.rs +628 -0
- package/crates/jettypod-core/src/ws.rs +767 -0
- package/cucumber-test.cjs +6 -0
- package/cucumber.js +9 -3
- package/docs/COMMAND_REFERENCE.md +34 -0
- package/hooks/post-checkout +32 -75
- package/hooks/post-merge +111 -10
- package/jest.setup.js +1 -0
- package/jettypod.js +145 -116
- package/lib/bdd-preflight.js +96 -0
- package/lib/chore-taxonomy.js +33 -10
- package/lib/database.js +36 -16
- package/lib/db-watcher.js +1 -1
- package/lib/git-hooks/pre-commit +1 -1
- package/lib/jettypod-backup.js +27 -4
- package/lib/merge-lock.js +111 -253
- package/lib/migrations/027-plan-at-creation-column.js +3 -1
- package/lib/migrations/029-remove-autoincrement.js +307 -0
- package/lib/migrations/029-rename-corrupted-to-cleaned.js +149 -0
- package/lib/migrations/030-rejection-round-columns.js +54 -0
- package/lib/migrations/031-session-isolation-index.js +17 -0
- package/lib/migrations/index.js +47 -4
- package/lib/schema.js +10 -5
- package/lib/seed-onboarding.js +1 -1
- package/lib/update-command/index.js +9 -175
- package/lib/work-commands/index.js +144 -19
- package/lib/work-tracking/index.js +148 -27
- package/lib/worktree-diagnostics.js +16 -16
- package/lib/worktree-facade.js +1 -1
- package/lib/worktree-manager.js +8 -8
- package/lib/worktree-reconciler.js +5 -5
- package/package.json +9 -2
- package/scripts/ndjson-to-cucumber-json.js +152 -0
- package/scripts/postinstall.js +25 -0
- package/skills-templates/bug-mode/SKILL.md +79 -20
- package/skills-templates/bug-planning/SKILL.md +25 -29
- package/skills-templates/chore-mode/SKILL.md +171 -69
- package/skills-templates/chore-mode/verification.js +51 -10
- package/skills-templates/chore-planning/SKILL.md +47 -18
- package/skills-templates/design-system-selection/SKILL.md +273 -0
- package/skills-templates/epic-planning/SKILL.md +82 -48
- package/skills-templates/external-transition/SKILL.md +47 -47
- package/skills-templates/feature-planning/SKILL.md +173 -74
- package/skills-templates/production-mode/SKILL.md +69 -49
- package/skills-templates/request-routing/SKILL.md +4 -4
- package/skills-templates/simple-improvement/SKILL.md +74 -29
- package/skills-templates/speed-mode/SKILL.md +217 -141
- package/skills-templates/stable-mode/SKILL.md +148 -89
- package/apps/dashboard/README.md +0 -36
- package/apps/dashboard/app/api/claude/[workItemId]/message/route.ts +0 -386
- package/apps/dashboard/app/api/claude/[workItemId]/pin/route.ts +0 -24
- package/apps/dashboard/app/api/claude/[workItemId]/route.ts +0 -167
- package/apps/dashboard/app/api/claude/sessions/[sessionId]/content/route.ts +0 -52
- package/apps/dashboard/app/api/claude/sessions/[sessionId]/message/route.ts +0 -378
- package/apps/dashboard/app/api/claude/sessions/[sessionId]/pin/route.ts +0 -24
- package/apps/dashboard/app/api/claude/sessions/cleanup/route.ts +0 -34
- package/apps/dashboard/app/api/claude/sessions/route.ts +0 -184
- package/apps/dashboard/app/api/decisions/[id]/route.ts +0 -25
- package/apps/dashboard/app/api/internal/set-project/route.ts +0 -17
- package/apps/dashboard/app/api/kanban/route.ts +0 -15
- package/apps/dashboard/app/api/settings/env-vars/route.ts +0 -125
- package/apps/dashboard/app/api/settings/general/route.ts +0 -21
- package/apps/dashboard/app/api/tests/route.ts +0 -9
- package/apps/dashboard/app/api/tests/run/route.ts +0 -82
- package/apps/dashboard/app/api/tests/run/stream/route.ts +0 -71
- package/apps/dashboard/app/api/tests/undefined/route.ts +0 -9
- package/apps/dashboard/app/api/usage/route.ts +0 -17
- package/apps/dashboard/app/api/work/[id]/description/route.ts +0 -21
- package/apps/dashboard/app/api/work/[id]/epic/route.ts +0 -21
- package/apps/dashboard/app/api/work/[id]/order/route.ts +0 -21
- package/apps/dashboard/app/api/work/[id]/status/route.ts +0 -21
- package/apps/dashboard/app/api/work/[id]/title/route.ts +0 -21
- package/apps/dashboard/app/layout.tsx +0 -43
- package/apps/dashboard/components/UpgradeBanner.tsx +0 -29
- package/apps/dashboard/electron/ipc-handlers.js +0 -1028
- package/apps/dashboard/electron/main.js +0 -2124
- package/apps/dashboard/electron/preload.js +0 -123
- package/apps/dashboard/electron/session-manager.js +0 -141
- package/apps/dashboard/electron-builder.config.js +0 -357
- package/apps/dashboard/hooks/useClaudeSessions.ts +0 -299
- package/apps/dashboard/lib/claude-process-manager.ts +0 -492
- package/apps/dashboard/lib/db-bridge.ts +0 -282
- package/apps/dashboard/lib/prototypes.ts +0 -202
- package/apps/dashboard/lib/test-results-db.ts +0 -307
- package/apps/dashboard/lib/tests.ts +0 -282
- package/apps/dashboard/next.config.js +0 -50
- package/apps/dashboard/postcss.config.mjs +0 -7
- package/apps/dashboard/public/file.svg +0 -1
- package/apps/dashboard/public/globe.svg +0 -1
- package/apps/dashboard/public/next.svg +0 -1
- package/apps/dashboard/public/vercel.svg +0 -1
- package/apps/dashboard/public/window.svg +0 -1
- package/apps/dashboard/scripts/download-node.js +0 -104
- package/apps/dashboard/scripts/upload-to-r2.js +0 -89
- package/docs/bdd-guidance.md +0 -390
package/apps/dashboard/lib/db.ts
CHANGED
|
@@ -1,1305 +1,109 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
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:
|
|
8
|
+
type: string;
|
|
18
9
|
title: string;
|
|
19
10
|
description: string | null;
|
|
20
|
-
status:
|
|
11
|
+
status: string;
|
|
21
12
|
parent_id: number | null;
|
|
22
13
|
epic_id: number | null;
|
|
23
14
|
branch_name: string | null;
|
|
24
|
-
mode:
|
|
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
|
|
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
|
-
//
|
|
364
|
-
export
|
|
365
|
-
|
|
366
|
-
|
|
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
|
-
|
|
369
|
-
|
|
370
|
-
|
|
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
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
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
|
-
|
|
698
|
-
|
|
699
|
-
id: number;
|
|
54
|
+
export interface TestFeature {
|
|
55
|
+
id: string;
|
|
700
56
|
title: string;
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
updatedAt: string;
|
|
57
|
+
description: string;
|
|
58
|
+
featureFile: string;
|
|
59
|
+
scenarios: TestScenario[];
|
|
705
60
|
}
|
|
706
61
|
|
|
707
|
-
export
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
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
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
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
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
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
|
-
//
|
|
1003
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
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
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
remaining: number;
|
|
1280
|
-
allowed: boolean;
|
|
101
|
+
export interface PrototypeSummary {
|
|
102
|
+
total: number;
|
|
103
|
+
byMonth: Record<string, number>;
|
|
1281
104
|
}
|
|
1282
105
|
|
|
1283
|
-
|
|
1284
|
-
|
|
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
|
}
|