diff --git a/packages/apps/app/e2e/27-ai-description.spec.ts b/packages/apps/app/e2e/27-ai-description.spec.ts deleted file mode 100644 index f191b507..00000000 --- a/packages/apps/app/e2e/27-ai-description.spec.ts +++ /dev/null @@ -1,146 +0,0 @@ -import { test, expect, seed, goHome, clickProject } from './fixtures/electron' -import { TEST_PROJECT_PATH } from './fixtures/electron' - -test.describe('AI description generation', () => { - let projectAbbrev: string - let taskId: string - - test.beforeAll(async ({ electronApp, mainWindow }) => { - // Mock the AI IPC handler to avoid calling real CLI - await electronApp.evaluate(({ ipcMain }) => { - ipcMain.removeHandler('ai:generate-description') - ipcMain.handle('ai:generate-description', async (_event, title: string, mode: string) => { - if (mode === 'terminal') { - return { success: false, error: 'AI not available in terminal mode' } - } - // Simulate a short delay like a real API call - await new Promise(r => setTimeout(r, 300)) - return { success: true, description: `Mock description for: ${title}` } - }) - }) - - const s = seed(mainWindow) - const p = await s.createProject({ name: 'Desc Gen Test', color: '#a855f7', path: TEST_PROJECT_PATH }) - projectAbbrev = p.name.slice(0, 2).toUpperCase() - const t = await s.createTask({ projectId: p.id, title: 'Implement login flow', status: 'in_progress' }) - taskId = t.id - await s.refreshData() - - await goHome(mainWindow) - await clickProject(mainWindow, projectAbbrev) - - // Open task detail - await mainWindow.getByText('Implement login flow').first().click() - await expect(mainWindow.getByTestId('task-settings-panel').last()).toBeVisible() - }) - - const generateBtn = (page: import('@playwright/test').Page) => - page.getByTestId('task-settings-panel').last().getByTestId('generate-description-button') - - const clickGenerate = async (page: import('@playwright/test').Page) => { - const btn = generateBtn(page) - await btn.scrollIntoViewIfNeeded() - await btn.evaluate((el) => { - ;(el as HTMLButtonElement).click() - }) - } - - test('generate button visible in claude-code mode', async ({ mainWindow }) => { - await expect(generateBtn(mainWindow)).toBeVisible() - }) - - test('generate button shows sparkles icon', async ({ mainWindow }) => { - const btn = generateBtn(mainWindow) - await expect(btn.locator('.lucide-sparkles')).toBeVisible() - }) - - test('clicking generate shows loading spinner', async ({ mainWindow }) => { - await clickGenerate(mainWindow) - // Spinner/disabled state can be very brief in CI; assert generation starts by waiting - // for description to appear in persisted task state. - await expect - .poll(async () => { - const task = await mainWindow.evaluate((id) => window.api.db.getTask(id), taskId) - return task?.description ?? '' - }) - .toContain('Mock description for: Implement login flow') - }) - - test('generated description appears in editor', async ({ mainWindow }) => { - await expect - .poll(async () => { - const task = await mainWindow.evaluate((id) => window.api.db.getTask(id), taskId) - return task?.description ?? '' - }) - .toContain('Mock description for: Implement login flow') - - const editor = mainWindow - .getByTestId('task-settings-panel') - .last() - .locator('[contenteditable="true"]') - .first() - await expect(editor).toContainText('Mock description for: Implement login flow') - }) - - test('generated description persisted to DB', async ({ mainWindow }) => { - await expect - .poll(async () => { - const task = await mainWindow.evaluate((id) => window.api.db.getTask(id), taskId) - return task?.description ?? '' - }) - .toContain('Mock description for: Implement login flow') - }) - - test('button re-enabled after generation', async ({ mainWindow }) => { - await expect(generateBtn(mainWindow)).toBeEnabled() - // Sparkles icon restored (not spinner) - await expect(generateBtn(mainWindow).locator('.lucide-sparkles')).toBeVisible() - }) - - test('button hidden in terminal mode', async ({ mainWindow }) => { - // Switch to terminal mode - const modeTrigger = mainWindow.getByRole('combobox').filter({ hasText: /Claude Code|Codex|Terminal/ }) - await modeTrigger.click() - await mainWindow.getByRole('option', { name: 'Terminal' }).click() - - await expect(generateBtn(mainWindow)).not.toBeVisible() - }) - - test('button visible again in codex mode', async ({ mainWindow }) => { - const modeTrigger = mainWindow.getByRole('combobox').filter({ hasText: /Claude Code|Codex|Terminal/ }) - await modeTrigger.click() - await mainWindow.getByRole('option', { name: 'Codex' }).click() - - await expect(generateBtn(mainWindow)).toBeVisible() - }) - - test('generate works in codex mode too', async ({ mainWindow }) => { - // Clear existing description first - await mainWindow.evaluate((id) => - window.api.db.updateTask({ id, description: null }), taskId) - await expect.poll(async () => { - const task = await mainWindow.evaluate((id) => window.api.db.getTask(id), taskId) - return task?.description - }).toBeFalsy() - - // Navigate away and back to reload clean state - await goHome(mainWindow) - await clickProject(mainWindow, projectAbbrev) - await mainWindow.getByText('Implement login flow').first().click() - await expect(mainWindow.getByTestId('task-settings-panel').last()).toBeVisible() - - await clickGenerate(mainWindow) - await expect - .poll(async () => { - const task = await mainWindow.evaluate((id) => window.api.db.getTask(id), taskId) - return task?.description ?? '' - }) - .toContain('Mock description for: Implement login flow') - - // Switch back to claude-code for clean state - const modeTrigger = mainWindow.getByRole('combobox').filter({ hasText: /Claude Code|Codex|Terminal/ }) - await modeTrigger.click() - await mainWindow.getByRole('option', { name: 'Claude Code' }).click() - await expect(generateBtn(mainWindow)).toBeVisible() - }) -}) diff --git a/packages/apps/app/e2e/fixtures/electron.ts b/packages/apps/app/e2e/fixtures/electron.ts index 6f603837..b59720e6 100644 --- a/packages/apps/app/e2e/fixtures/electron.ts +++ b/packages/apps/app/e2e/fixtures/electron.ts @@ -333,7 +333,7 @@ export const test = base.extend({ /** Seed helpers — call window.api methods to create test data without UI interaction */ export function seed(page: Page) { return { - createProject: (data: { name: string; color: string; path?: string }) => + createProject: (data: { name: string; color: string; path?: string; taskBackend?: 'db' }) => page.evaluate((d) => window.api.db.createProject(d), data), createTask: (data: { @@ -384,6 +384,7 @@ export function seed(page: Page) { name?: string color?: string path?: string | null + taskBackend?: 'db' autoCreateWorktreeOnTaskCreate?: boolean | null columnsConfig?: Array<{ id: string @@ -409,9 +410,6 @@ export function seed(page: Page) { getSetting: (key: string) => page.evaluate((k) => window.api.settings.get(k), key), - setTheme: (theme: 'light' | 'dark' | 'system') => - page.evaluate((t) => window.api.theme.set(t), theme), - /** Re-fetch all data from DB into React state */ refreshData: () => page.evaluate(async () => { diff --git a/packages/apps/app/electron-builder.yml b/packages/apps/app/electron-builder.yml index ae5d32a1..8de6a9a7 100644 --- a/packages/apps/app/electron-builder.yml +++ b/packages/apps/app/electron-builder.yml @@ -10,10 +10,9 @@ files: - '!{.env,.env.*,.npmrc,pnpm-lock.yaml}' - '!{tsconfig.json,tsconfig.node.json,tsconfig.web.json}' - '!**/node_modules/@slayzone/**' + - '!**/node_modules/.pnpm/@slayzone+*/**' asarUnpack: - - resources/** - - '**/node_modules/better-sqlite3/**' - - '**/node_modules/node-pty/**' + [] win: executableName: slayzone nsis: diff --git a/packages/apps/app/electron.vite.config.ts b/packages/apps/app/electron.vite.config.ts index c67e0e17..52d2d568 100644 --- a/packages/apps/app/electron.vite.config.ts +++ b/packages/apps/app/electron.vite.config.ts @@ -14,6 +14,9 @@ const root = resolve(__dirname, '../../..') export default defineConfig(({ mode }) => { const env = loadEnv(mode, root, '') + const devHost = env.SLAYZONE_DEV_HOST || '127.0.0.1' + const parsedDevPort = Number.parseInt(env.SLAYZONE_DEV_PORT || '5173', 10) + const devPort = Number.isFinite(parsedDevPort) ? parsedDevPort : 5173 return { main: { @@ -41,6 +44,11 @@ export default defineConfig(({ mode }) => { }, renderer: { envDir: root, + server: { + host: devHost, + port: devPort, + strictPort: false + }, define: { __POSTHOG_API_KEY__: JSON.stringify(env.POSTHOG_API_KEY ?? ''), __POSTHOG_HOST__: JSON.stringify(env.POSTHOG_HOST ?? ''), diff --git a/packages/apps/app/package.json b/packages/apps/app/package.json index 300eab3a..2b5a3c11 100644 --- a/packages/apps/app/package.json +++ b/packages/apps/app/package.json @@ -6,6 +6,7 @@ "type": "module", "main": "./out/main/index.js", "scripts": { + "postinstall": "electron-builder install-app-deps", "dev": "electron-vite dev", "build": "npm run typecheck && electron-vite build", "build:mac": "pnpm --filter @slayzone/cli build && npm run build && electron-builder --mac", diff --git a/packages/apps/app/src/main/db/migrations.ts b/packages/apps/app/src/main/db/migrations.ts index 0467aa4a..1b86dc77 100644 --- a/packages/apps/app/src/main/db/migrations.ts +++ b/packages/apps/app/src/main/db/migrations.ts @@ -5,6 +5,51 @@ interface Migration { up: (db: Database.Database) => void } +function tableExists(db: Database.Database, table: string): boolean { + const row = db.prepare( + "SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?" + ).get(table) as { name: string } | undefined + return Boolean(row) +} + +function hasColumn(db: Database.Database, table: string, column: string): boolean { + if (!tableExists(db, table)) return false + const rows = db.prepare(`PRAGMA table_info(${table})`).all() as Array<{ name: string }> + return rows.some((row) => row.name === column) +} + +function hasForeignKeyOnColumn(db: Database.Database, table: string, column: string): boolean { + if (!tableExists(db, table)) return false + const rows = db.prepare(`PRAGMA foreign_key_list(${table})`).all() as Array<{ from: string }> + return rows.some((row) => row.from === column) +} + +function rebuildTerminalTabsWithoutTaskForeignKey(db: Database.Database): void { + if (!tableExists(db, 'terminal_tabs')) return + if (!hasForeignKeyOnColumn(db, 'terminal_tabs', 'task_id')) return + db.exec(` + ALTER TABLE terminal_tabs RENAME TO terminal_tabs_old; + + CREATE TABLE terminal_tabs ( + id TEXT PRIMARY KEY, + task_id TEXT NOT NULL, + label TEXT, + mode TEXT NOT NULL DEFAULT 'terminal', + is_main INTEGER NOT NULL DEFAULT 0, + position INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + group_id TEXT + ); + + INSERT INTO terminal_tabs (id, task_id, label, mode, is_main, position, created_at, group_id) + SELECT id, task_id, label, mode, is_main, position, created_at, group_id + FROM terminal_tabs_old; + + DROP TABLE terminal_tabs_old; + CREATE INDEX IF NOT EXISTS idx_terminal_tabs_task ON terminal_tabs(task_id); + `) +} + const migrations: Migration[] = [ { version: 1, @@ -726,6 +771,12 @@ const migrations: Migration[] = [ if (!hasColumnsConfig) { db.exec(`ALTER TABLE projects ADD COLUMN columns_config TEXT DEFAULT NULL`) } + if (!hasColumn(db, 'projects', 'task_backend')) { + db.exec(` + ALTER TABLE projects + ADD COLUMN task_backend TEXT NOT NULL DEFAULT 'db'; + `) + } } }, { @@ -762,6 +813,9 @@ const migrations: Migration[] = [ updateStmt.run(JSON.stringify(filtered), row.key) } } + // terminal_tabs.task_id FK blocks repo-file tasks (task not persisted in tasks table) + // Recreate table without FK while preserving data. + rebuildTerminalTabsWithoutTaskForeignKey(db) } }, { @@ -805,6 +859,40 @@ const migrations: Migration[] = [ updateStmt.run(JSON.stringify(filtered), row.key) } } + + if (!hasColumn(db, 'projects', 'feature_repo_integration_enabled')) { + db.exec(` + ALTER TABLE projects + ADD COLUMN feature_repo_integration_enabled INTEGER NOT NULL DEFAULT 0; + `) + } + if (!hasColumn(db, 'projects', 'feature_repo_features_path')) { + db.exec(` + ALTER TABLE projects + ADD COLUMN feature_repo_features_path TEXT NOT NULL DEFAULT 'docs/features'; + `) + } + db.exec(` + CREATE TABLE IF NOT EXISTS project_feature_task_links ( + id TEXT PRIMARY KEY, + project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE, + task_id TEXT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE, + feature_id TEXT DEFAULT NULL, + feature_title TEXT NOT NULL DEFAULT '', + feature_file_path TEXT NOT NULL, + content_hash TEXT DEFAULT NULL, + last_sync_source TEXT NOT NULL DEFAULT 'repo', + last_sync_at TEXT NOT NULL DEFAULT (datetime('now')), + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + UNIQUE(project_id, feature_file_path), + UNIQUE(project_id, task_id) + ); + CREATE INDEX IF NOT EXISTS idx_project_feature_links_project + ON project_feature_task_links(project_id); + CREATE INDEX IF NOT EXISTS idx_project_feature_links_task + ON project_feature_task_links(task_id); + `) } }, { @@ -816,6 +904,24 @@ const migrations: Migration[] = [ if (!hasColumnsConfig) { db.exec(`ALTER TABLE projects ADD COLUMN columns_config TEXT DEFAULT NULL`) } + + if (!hasColumn(db, 'project_feature_task_links', 'last_sync_source')) { + db.exec(` + ALTER TABLE project_feature_task_links + ADD COLUMN last_sync_source TEXT NOT NULL DEFAULT 'repo'; + `) + } + if (!hasColumn(db, 'project_feature_task_links', 'last_sync_at')) { + db.exec(` + ALTER TABLE project_feature_task_links + ADD COLUMN last_sync_at TEXT DEFAULT NULL; + `) + } + db.exec(` + UPDATE project_feature_task_links + SET last_sync_source = COALESCE(NULLIF(last_sync_source, ''), 'repo'), + last_sync_at = COALESCE(last_sync_at, updated_at, datetime('now')) + `) } }, { @@ -988,6 +1094,59 @@ const migrations: Migration[] = [ } ] +function ensureSchemaBackfills(db: Database.Database): void { + // Safety net: if user_version was bumped externally but column wasn't actually added, + // auto-heal on startup to prevent runtime "no such column" crashes. + if (!hasColumn(db, 'projects', 'columns_config')) { + db.exec(`ALTER TABLE projects ADD COLUMN columns_config TEXT DEFAULT NULL;`) + } + if (!hasColumn(db, 'projects', 'task_backend')) { + db.exec(`ALTER TABLE projects ADD COLUMN task_backend TEXT NOT NULL DEFAULT 'db';`) + } + if (!hasColumn(db, 'projects', 'feature_repo_integration_enabled')) { + db.exec(`ALTER TABLE projects ADD COLUMN feature_repo_integration_enabled INTEGER NOT NULL DEFAULT 0;`) + } + if (!hasColumn(db, 'projects', 'feature_repo_features_path')) { + db.exec(`ALTER TABLE projects ADD COLUMN feature_repo_features_path TEXT NOT NULL DEFAULT 'docs/features';`) + } + db.exec(` + CREATE TABLE IF NOT EXISTS project_feature_task_links ( + id TEXT PRIMARY KEY, + project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE, + task_id TEXT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE, + feature_id TEXT DEFAULT NULL, + feature_title TEXT NOT NULL DEFAULT '', + feature_file_path TEXT NOT NULL, + content_hash TEXT DEFAULT NULL, + last_sync_source TEXT NOT NULL DEFAULT 'repo', + last_sync_at TEXT NOT NULL DEFAULT (datetime('now')), + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + UNIQUE(project_id, feature_file_path), + UNIQUE(project_id, task_id) + ); + UPDATE project_feature_task_links + SET last_sync_source = COALESCE(NULLIF(last_sync_source, ''), 'repo'), + last_sync_at = COALESCE(last_sync_at, updated_at, datetime('now')); + CREATE INDEX IF NOT EXISTS idx_project_feature_links_project + ON project_feature_task_links(project_id); + CREATE INDEX IF NOT EXISTS idx_project_feature_links_task + ON project_feature_task_links(task_id); + `) + if (!hasColumn(db, 'project_feature_task_links', 'last_sync_source')) { + db.exec(`ALTER TABLE project_feature_task_links ADD COLUMN last_sync_source TEXT NOT NULL DEFAULT 'repo';`) + } + if (!hasColumn(db, 'project_feature_task_links', 'last_sync_at')) { + db.exec(`ALTER TABLE project_feature_task_links ADD COLUMN last_sync_at TEXT DEFAULT NULL;`) + } + db.exec(` + UPDATE project_feature_task_links + SET last_sync_source = COALESCE(NULLIF(last_sync_source, ''), 'repo'), + last_sync_at = COALESCE(last_sync_at, updated_at, datetime('now')); + `) + rebuildTerminalTabsWithoutTaskForeignKey(db) +} + export function runMigrations(db: Database.Database): void { const currentVersion = db.pragma('user_version', { simple: true }) as number @@ -1000,4 +1159,6 @@ export function runMigrations(db: Database.Database): void { console.log(`Migration ${migration.version} applied`) } } + + ensureSchemaBackfills(db) } diff --git a/packages/apps/app/src/main/index.ts b/packages/apps/app/src/main/index.ts index 54f06165..d6e94b21 100644 --- a/packages/apps/app/src/main/index.ts +++ b/packages/apps/app/src/main/index.ts @@ -44,10 +44,11 @@ import logoSolid from '../../resources/logo-solid.svg?asset' import { getDatabase, closeDatabase, getDiagnosticsDatabase, closeDiagnosticsDatabase } from './db' // Domain handlers import { registerProjectHandlers } from '@slayzone/projects/main' -import { configureTaskRuntimeAdapters, registerTaskHandlers, registerAiHandlers, registerFilesHandlers } from '@slayzone/task/main' +import { configureTaskRuntimeAdapters, registerTaskHandlers, registerFilesHandlers } from '@slayzone/task/main' import { registerTagHandlers } from '@slayzone/tags/main' import { registerSettingsHandlers, registerThemeHandlers } from '@slayzone/settings/main' import { registerPtyHandlers, registerUsageHandlers, killAllPtys, killPtysByTaskId, startIdleChecker, stopIdleChecker } from '@slayzone/terminal/main' +import { getRepoFeatureSyncConfig, syncAllProjectFeatureTasks } from '@slayzone/projects/main' import { registerTerminalTabsHandlers } from '@slayzone/task-terminals/main' import { registerWorktreeHandlers } from '@slayzone/worktrees/main' import { registerDiagnosticsHandlers, registerProcessDiagnostics, recordDiagnosticEvent, stopDiagnostics } from '@slayzone/diagnostics/main' @@ -63,6 +64,7 @@ import { WEBVIEW_DESKTOP_HANDOFF_SCRIPT } from '../shared/webview-desktop-handof const DEFAULT_WINDOW_WIDTH = 1760 const DEFAULT_WINDOW_HEIGHT = 1280 +const FEATURE_REPO_SYNC_POLL_INTERVAL_MS = 30_000 // Splash screen: self-contained HTML with inline logo SVG and typewriter animation const splashLogoSvg = readFileSync(logoSolid, 'utf-8') @@ -192,6 +194,7 @@ let inlineDevToolsView: BrowserView | null = null let inlineDevToolsViewAttached = false let inlineDeviceToolbarDisableTimers: NodeJS.Timeout[] = [] let linearSyncPoller: NodeJS.Timeout | null = null +let featureRepoSyncPoller: NodeJS.Timeout | null = null let mcpCleanup: (() => void) | null = null let pendingOAuthCallback: { code?: string; error?: string } | null = null let oauthCallbackServer: HttpServer | null = null @@ -808,7 +811,6 @@ app.whenReady().then(async () => { // Register domain handlers (inject ipcMain and db) registerProjectHandlers(ipcMain, db) registerTaskHandlers(ipcMain, db) - registerAiHandlers(ipcMain) registerTagHandlers(ipcMain, db) registerSettingsHandlers(ipcMain, db) registerThemeHandlers(ipcMain, db) @@ -859,6 +861,37 @@ app.whenReady().then(async () => { linearSyncPoller = startLinearSyncPoller(db) logBoot('integration poller started') + const runFeatureRepoSync = (): void => { + try { + const sync = syncAllProjectFeatureTasks(db) + if (sync.created > 0 || sync.updated > 0) { + mainWindow?.webContents.send('tasks:changed') + } + if (sync.errors.length > 0) { + console.warn('[feature-sync] warnings:', sync.errors.join(' | ')) + } + } catch (err) { + console.error('[feature-sync] poll failed:', err) + } + } + const getFeatureRepoSyncIntervalMs = (): number => { + try { + const config = getRepoFeatureSyncConfig(db) + const seconds = Number.isFinite(config.pollIntervalSeconds) ? config.pollIntervalSeconds : 30 + return Math.max(5_000, Math.min(3_600_000, Math.round(seconds * 1_000))) + } catch { + return FEATURE_REPO_SYNC_POLL_INTERVAL_MS + } + } + const scheduleFeatureRepoSync = (): void => { + const nextIntervalMs = getFeatureRepoSyncIntervalMs() + featureRepoSyncPoller = setTimeout(() => { + runFeatureRepoSync() + scheduleFeatureRepoSync() + }, nextIntervalMs) + } + runFeatureRepoSync() + scheduleFeatureRepoSync() initAutoUpdater() logBoot('auto-updater initialized') @@ -1140,6 +1173,9 @@ app.whenReady().then(async () => { ipcMain.handle('app:getVersion', () => app.getVersion()) ipcMain.handle('app:is-context-manager-enabled', () => isContextManagerEnabled) + ipcMain.handle('app:open-project-settings', () => { + emitOpenProjectSettings() + }) ipcMain.handle('app:restart-for-update', () => restartForUpdate()) ipcMain.handle('app:check-for-updates', () => checkForUpdates()) ipcMain.handle('app:cli-status', () => ({ installed: existsSync(CLI_TARGET) })) @@ -1756,6 +1792,10 @@ app.on('will-quit', () => { clearInterval(linearSyncPoller) linearSyncPoller = null } + if (featureRepoSyncPoller) { + clearTimeout(featureRepoSyncPoller) + featureRepoSyncPoller = null + } mcpCleanup?.() stopDiagnostics() stopIdleChecker() diff --git a/packages/apps/app/src/preload/index.ts b/packages/apps/app/src/preload/index.ts index efaeaec4..9dd41ae6 100644 --- a/packages/apps/app/src/preload/index.ts +++ b/packages/apps/app/src/preload/index.ts @@ -15,24 +15,32 @@ window.addEventListener('drop', (e) => { // Custom APIs for renderer const api: ElectronAPI = { - ai: { - generateDescription: (title, mode) => ipcRenderer.invoke('ai:generate-description', title, mode) - }, db: { // Projects getProjects: () => ipcRenderer.invoke('db:projects:getAll'), createProject: (data) => ipcRenderer.invoke('db:projects:create', data), updateProject: (data) => ipcRenderer.invoke('db:projects:update', data), + syncProjectFeatures: (projectId) => ipcRenderer.invoke('db:projects:syncFeatures', projectId), + syncAllProjectFeatures: () => ipcRenderer.invoke('db:projects:syncAllFeatures'), + getProjectFeatureSyncConfig: () => ipcRenderer.invoke('db:projects:getFeatureSyncConfig'), + setProjectFeatureSyncConfig: (input) => ipcRenderer.invoke('db:projects:setFeatureSyncConfig', input), deleteProject: (id) => ipcRenderer.invoke('db:projects:delete', id), // Tasks getTasks: () => ipcRenderer.invoke('db:tasks:getAll'), getTasksByProject: (projectId) => ipcRenderer.invoke('db:tasks:getByProject', projectId), getTask: (id) => ipcRenderer.invoke('db:tasks:get', id), + getTaskFeatureContext: (taskId) => ipcRenderer.invoke('db:tasks:getFeatureContext', taskId), + getTaskFeatureDetails: (taskId) => ipcRenderer.invoke('db:tasks:getFeatureDetails', taskId), + syncTaskFeatureFromRepo: (taskId) => ipcRenderer.invoke('db:tasks:syncFeatureFromRepo', taskId), + syncTaskFeatureToRepo: (taskId) => ipcRenderer.invoke('db:tasks:syncFeatureToRepo', taskId), + createTaskFeature: (taskId, input) => ipcRenderer.invoke('db:tasks:createFeature', taskId, input), + deleteTaskFeature: (taskId) => ipcRenderer.invoke('db:tasks:deleteFeature', taskId), + updateTaskFeature: (taskId, input) => ipcRenderer.invoke('db:tasks:updateFeature', taskId, input), getSubTasks: (parentId) => ipcRenderer.invoke('db:tasks:getSubTasks', parentId), createTask: (data) => ipcRenderer.invoke('db:tasks:create', data), updateTask: (data) => ipcRenderer.invoke('db:tasks:update', data), - deleteTask: (id) => ipcRenderer.invoke('db:tasks:delete', id), + deleteTask: (id, options) => ipcRenderer.invoke('db:tasks:delete', id, options), restoreTask: (id) => ipcRenderer.invoke('db:tasks:restore', id), archiveTask: (id) => ipcRenderer.invoke('db:tasks:archive', id), archiveTasks: (ids) => ipcRenderer.invoke('db:tasks:archiveMany', ids), @@ -70,9 +78,9 @@ const api: ElectronAPI = { theme: { getEffective: () => ipcRenderer.invoke('theme:get-effective'), getSource: () => ipcRenderer.invoke('theme:get-source'), - set: (theme: 'light' | 'dark' | 'system') => ipcRenderer.invoke('theme:set', theme), - onChange: (callback: (theme: 'light' | 'dark') => void) => { - const handler = (_event: unknown, theme: 'light' | 'dark') => callback(theme) + set: (theme) => ipcRenderer.invoke('theme:set', theme), + onChange: (callback) => { + const handler = (_: unknown, effective: 'light' | 'dark') => callback(effective) ipcRenderer.on('theme:changed', handler) return () => ipcRenderer.removeListener('theme:changed', handler) } @@ -165,6 +173,7 @@ const api: ElectronAPI = { return () => ipcRenderer.removeListener('app:update-status', handler) }, dataReady: () => ipcRenderer.send('app:data-ready'), + openProjectSettings: () => ipcRenderer.invoke('app:open-project-settings'), restartForUpdate: () => ipcRenderer.invoke('app:restart-for-update'), checkForUpdates: () => ipcRenderer.invoke('app:check-for-updates'), cliStatus: () => ipcRenderer.invoke('app:cli-status'), diff --git a/packages/apps/app/src/renderer/src/App.tsx b/packages/apps/app/src/renderer/src/App.tsx index a207fc11..ce07cc96 100644 --- a/packages/apps/app/src/renderer/src/App.tsx +++ b/packages/apps/app/src/renderer/src/App.tsx @@ -1016,10 +1016,7 @@ function App(): React.JSX.Element { } const handleTaskDeleted = (): void => { - if (deletingTask) { - deleteTask(deletingTask.id) - setDeletingTask(null) - } + setDeletingTask(null) } const handleTaskClick = (task: Task, e: { metaKey: boolean }): void => { @@ -1039,12 +1036,22 @@ function App(): React.JSX.Element { setProjects((prev) => [...prev, project]) setSelectedProjectId(project.id) setCreateProjectOpen(false) + void window.api.db.getTasks().then((allTasks) => { + setTasks(allTasks) + }).catch((err) => { + console.error('[App] Failed to refresh tasks after project create:', err) + }) } const handleProjectUpdated = (project: Project): void => { updateProject(project) setEditingProject(null) validateProjectPath(project) + void window.api.db.getTasks().then((allTasks) => { + setTasks(allTasks) + }).catch((err) => { + console.error('[App] Failed to refresh tasks after project update:', err) + }) } const handleProjectNameSave = async (): Promise => { @@ -1451,6 +1458,7 @@ function App(): React.JSX.Element { open={!!deletingTask} onOpenChange={(open) => !open && setDeletingTask(null)} onDeleted={handleTaskDeleted} + onDeleteTask={deleteTask} /> )} {isSubTask && SUB} - {title} + {title} {onClose && ( - ))} - - )} {activeFile && isImage ? ( @@ -357,23 +289,16 @@ export const FileEditorView = forwardRef ) : activeFile?.content != null ? (
- {!(isMarkdown && viewMode === 'preview') && ( -
- updateContent(activeFile.path, content)} - onSave={() => saveFile(activeFile.path)} - version={fileVersions.get(activeFile.path)} - /> -
- )} - {isMarkdown && viewMode !== 'editor' && ( -
- -
- )} +
+ updateContent(activeFile.path, content)} + onSave={() => saveFile(activeFile.path)} + version={fileVersions.get(activeFile.path)} + /> +
) : (
diff --git a/packages/domains/file-editor/src/client/MarkdownPreview.tsx b/packages/domains/file-editor/src/client/MarkdownPreview.tsx deleted file mode 100644 index c95ebf34..00000000 --- a/packages/domains/file-editor/src/client/MarkdownPreview.tsx +++ /dev/null @@ -1,65 +0,0 @@ -import { useState, useEffect, useRef, useMemo } from 'react' -import ReactMarkdown, { type Components } from 'react-markdown' -import remarkGfm from 'remark-gfm' -import rehypeRaw from 'rehype-raw' -import rehypeHighlight from 'rehype-highlight' -import 'highlight.js/styles/github-dark.min.css' - -interface MarkdownPreviewProps { - content: string - scrollRef?: React.Ref - /** Absolute project root path */ - projectPath?: string - /** Relative path of the markdown file within the project */ - filePath?: string -} - -function useDebouncedValue(value: T, ms: number): T { - const [debounced, setDebounced] = useState(value) - const firstRun = useRef(true) - - useEffect(() => { - if (firstRun.current) { - firstRun.current = false - return - } - const id = setTimeout(() => setDebounced(value), ms) - return () => clearTimeout(id) - }, [value, ms]) - - return debounced -} - -export function MarkdownPreview({ content, scrollRef, projectPath, filePath }: MarkdownPreviewProps) { - const debouncedContent = useDebouncedValue(content, 200) - - const components = useMemo(() => { - if (!projectPath || !filePath) return {} - // Directory of the markdown file (relative to project root) - const fileDir = filePath.includes('/') ? filePath.slice(0, filePath.lastIndexOf('/')) : '' - return { - img: ({ src, ...props }) => { - let resolvedSrc = src - if (src && !src.startsWith('http://') && !src.startsWith('https://') && !src.startsWith('data:')) { - // Resolve relative path against markdown file's directory - const parts = (fileDir ? fileDir + '/' + src : src).split('/') - const normalized: string[] = [] - for (const p of parts) { - if (p === '..') normalized.pop() - else if (p && p !== '.') normalized.push(p) - } - resolvedSrc = 'slz-file://' + projectPath + '/' + normalized.join('/') - } - return - } - } - }, [projectPath, filePath]) - - return ( -
-
- {debouncedContent} -
-
- ) -} diff --git a/packages/domains/file-editor/src/client/index.ts b/packages/domains/file-editor/src/client/index.ts index ece9be6e..1b0e4793 100644 --- a/packages/domains/file-editor/src/client/index.ts +++ b/packages/domains/file-editor/src/client/index.ts @@ -1,2 +1,3 @@ export { FileEditorView, type FileEditorViewHandle } from './FileEditorView' export { QuickOpenDialog } from './QuickOpenDialog' +export { CodeEditor } from './CodeEditor' diff --git a/packages/domains/file-editor/src/main/handlers.test.ts b/packages/domains/file-editor/src/main/handlers.test.ts index 88e8c9c0..892ba98d 100644 --- a/packages/domains/file-editor/src/main/handlers.test.ts +++ b/packages/domains/file-editor/src/main/handlers.test.ts @@ -54,6 +54,11 @@ describe('fs:readDir', () => { expect(names).toContain('main.ts') expect(names).toContain('utils.ts') }) + + test('returns empty list when directory is missing', () => { + const entries = h.invoke('fs:readDir', root, 'does-not-exist') as { name: string }[] + expect(entries).toHaveLength(0) + }) }) describe('fs:readFile', () => { @@ -65,6 +70,11 @@ describe('fs:readFile', () => { test('rejects path traversal', () => { expect(() => h.invoke('fs:readFile', root, '../../../etc/passwd')).toThrow() }) + + test('returns null content when file is missing', () => { + const result = h.invoke('fs:readFile', root, 'missing.json') as { content: string | null } + expect(result.content).toBeNull() + }) }) describe('fs:listAllFiles', () => { diff --git a/packages/domains/file-editor/src/main/handlers.ts b/packages/domains/file-editor/src/main/handlers.ts index a2d4b244..2e1608d2 100644 --- a/packages/domains/file-editor/src/main/handlers.ts +++ b/packages/domains/file-editor/src/main/handlers.ts @@ -10,6 +10,12 @@ const ALWAYS_IGNORED = new Set(['.git', '.DS_Store']) const MAX_FILE_SIZE = 1 * 1024 * 1024 // 1 MB const FORCE_MAX_FILE_SIZE = 10 * 1024 * 1024 // 10 MB +function isMissingFsError(error: unknown): boolean { + if (!(error instanceof Error)) return false + const code = (error as NodeJS.ErrnoException).code + return code === 'ENOENT' || code === 'ENOTDIR' +} + function assertWithinRoot(root: string, target: string): string { const resolved = path.resolve(root, target) if (!resolved.startsWith(path.resolve(root) + path.sep) && resolved !== path.resolve(root)) { @@ -71,7 +77,13 @@ function addWinToWatcher(root: string, win: BrowserWindow, wins: Set { const abs = dirPath ? assertWithinRoot(rootPath, dirPath) : path.resolve(rootPath) - const entries = fs.readdirSync(abs, { withFileTypes: true }) + let entries: fs.Dirent[] + try { + entries = fs.readdirSync(abs, { withFileTypes: true }) + } catch (error) { + if (isMissingFsError(error)) return [] + throw error + } return entries .filter((e) => !ALWAYS_IGNORED.has(e.name)) .map((e) => { @@ -92,14 +104,26 @@ export function registerFileEditorHandlers(ipcMain: IpcMain): void { ipcMain.handle('fs:readFile', (_event, rootPath: string, filePath: string, force?: boolean): ReadFileResult => { const abs = assertWithinRoot(rootPath, filePath) - const stat = fs.statSync(abs) + let stat: fs.Stats + try { + stat = fs.statSync(abs) + } catch (error) { + if (isMissingFsError(error)) return { content: null } + throw error + } + if (!stat.isFile()) return { content: null } if (stat.size > FORCE_MAX_FILE_SIZE) { return { content: null, tooLarge: true, sizeBytes: stat.size } } if (!force && stat.size > MAX_FILE_SIZE) { return { content: null, tooLarge: true, sizeBytes: stat.size } } - return { content: fs.readFileSync(abs, 'utf-8') } + try { + return { content: fs.readFileSync(abs, 'utf-8') } + } catch (error) { + if (isMissingFsError(error)) return { content: null } + throw error + } }) ipcMain.handle('fs:listAllFiles', (_event, rootPath: string): string[] => { diff --git a/packages/domains/projects/src/client/CreateProjectDialog.tsx b/packages/domains/projects/src/client/CreateProjectDialog.tsx index a5393c19..98660f20 100644 --- a/packages/domains/projects/src/client/CreateProjectDialog.tsx +++ b/packages/domains/projects/src/client/CreateProjectDialog.tsx @@ -27,10 +27,11 @@ export function CreateProjectDialog({ open, onOpenChange, onCreated }: CreatePro properties: ['openDirectory'] }) if (!result.canceled && result.filePaths[0]) { - setPath(result.filePaths[0]) + const selectedPath = result.filePaths[0] + setPath(selectedPath) // Auto-fill name from folder name if empty if (!name.trim()) { - const folderName = result.filePaths[0].split('/').pop() || '' + const folderName = selectedPath.split('/').pop() || '' setName(folderName) } } @@ -42,10 +43,11 @@ export function CreateProjectDialog({ open, onOpenChange, onCreated }: CreatePro setLoading(true) try { + const normalizedPath = path.trim() const project = await window.api.db.createProject({ name: name.trim(), color, - path: path || undefined + path: normalizedPath || undefined }) onCreated(project) setName('') @@ -88,7 +90,7 @@ export function CreateProjectDialog({ open, onOpenChange, onCreated }: CreatePro

- Claude Code terminal will open in this directory + Optional, used as terminal working directory and repository integrations source.

diff --git a/packages/domains/projects/src/client/ProjectSettingsDialog.tsx b/packages/domains/projects/src/client/ProjectSettingsDialog.tsx index 559bf576..772e1544 100644 --- a/packages/domains/projects/src/client/ProjectSettingsDialog.tsx +++ b/packages/domains/projects/src/client/ProjectSettingsDialog.tsx @@ -1028,6 +1028,7 @@ export function ProjectSettingsDialog({ ) : null} +
)} diff --git a/packages/domains/projects/src/main/handlers.test.ts b/packages/domains/projects/src/main/handlers.test.ts index a3589021..ac298f46 100644 --- a/packages/domains/projects/src/main/handlers.test.ts +++ b/packages/domains/projects/src/main/handlers.test.ts @@ -2,6 +2,8 @@ * Projects handler contract tests * Run with: npx tsx packages/domains/projects/src/main/handlers.test.ts */ +import fs from 'node:fs' +import path from 'node:path' import { createTestHarness, test, expect, describe } from '../../../../shared/test-utils/ipc-harness.js' import { registerProjectHandlers } from './handlers.js' import type { ColumnConfig } from '../shared/types.js' @@ -9,12 +11,29 @@ import type { ColumnConfig } from '../shared/types.js' const h = await createTestHarness() registerProjectHandlers(h.ipcMain as never, h.db) +function writeFeatureMd(repoPath: string, relDir: string, content: string): void { + const dir = path.join(repoPath, relDir) + fs.mkdirSync(dir, { recursive: true }) + fs.writeFileSync(path.join(dir, 'FEATURE.md'), content, 'utf8') +} + describe('db:projects:create', () => { test('creates with defaults', () => { - const p = h.invoke('db:projects:create', { name: 'Alpha', color: '#ff0000' }) as { id: string; name: string; color: string; path: null } + const p = h.invoke('db:projects:create', { name: 'Alpha', color: '#ff0000' }) as { + id: string + name: string + color: string + path: null + task_backend: string + feature_repo_integration_enabled: number + feature_repo_features_path: string + } expect(p.name).toBe('Alpha') expect(p.color).toBe('#ff0000') expect(p.path).toBeNull() + expect(p.task_backend).toBe('db') + expect(p.feature_repo_integration_enabled).toBe(0) + expect(p.feature_repo_features_path).toBe('docs/features') expect(p.id).toBeTruthy() }) @@ -40,6 +59,64 @@ describe('db:projects:create', () => { { id: 'closed', label: 'Closed', color: 'green', position: 2, category: 'completed' }, ]) }) + + test('rejects repository feature integration without path', () => { + expect(() => { + h.invoke('db:projects:create', { + name: 'Broken', + color: '#ef4444', + featureRepoIntegrationEnabled: true + }) + }).toThrow() + }) + + test('rejects Features folder that escapes repository path', () => { + const repoPath = h.tmpDir() + expect(() => { + h.invoke('db:projects:create', { + name: 'Escaped', + color: '#ef4444', + path: repoPath, + featureRepoIntegrationEnabled: true, + featureRepoFeaturesPath: '../outside' + }) + }).toThrow() + }) + + test('creates and syncs tasks from FEATURE.md when integration is enabled', () => { + const repoPath = h.tmpDir() + writeFeatureMd( + repoPath, + 'docs/features/feature-001', + `id: FEAT-001 +title: Google Sheets Integration as a HubSpot Alternative (Backend) +description: | + Users can connect a Google Sheets document as an alternative backend integration + to HubSpot. +` + ) + + const project = h.invoke('db:projects:create', { + name: 'Feature Synced Project', + color: '#22c55e', + path: repoPath, + featureRepoIntegrationEnabled: true + }) as { id: string } + + const tasks = h.db + .prepare('SELECT title, description, status FROM tasks WHERE project_id = ? ORDER BY "order" ASC') + .all(project.id) as Array<{ title: string; description: string | null; status: string }> + expect(tasks).toHaveLength(1) + expect(tasks[0].title).toBe('FEAT-001 Google Sheets Integration as a HubSpot Alternative (Backend)') + expect(tasks[0].description).toBeNull() + expect(tasks[0].status).toBe('inbox') + + const links = h.db + .prepare('SELECT feature_file_path FROM project_feature_task_links WHERE project_id = ?') + .all(project.id) as Array<{ feature_file_path: string }> + expect(links).toHaveLength(1) + expect(links[0].feature_file_path).toBe('docs/features/feature-001/FEATURE.md') + }) }) describe('db:projects:getAll', () => { @@ -69,17 +146,194 @@ describe('db:projects:update', () => { expect(p.auto_create_worktree_on_task_create).toBe(1) }) - test('sets autoCreateWorktreeOnTaskCreate to null', () => { - const all = h.invoke('db:projects:getAll') as { id: string }[] - const p = h.invoke('db:projects:update', { id: all[0].id, autoCreateWorktreeOnTaskCreate: null }) as { auto_create_worktree_on_task_create: null } - expect(p.auto_create_worktree_on_task_create).toBeNull() - }) - - test('no-op returns current row', () => { + test('updates repository feature integration settings', () => { const all = h.invoke('db:projects:getAll') as { id: string; name: string }[] const gamma = all.find(p => p.name === 'Gamma')! - const p = h.invoke('db:projects:update', { id: gamma.id }) as { name: string } - expect(p.name).toBe('Gamma') + const p = h.invoke('db:projects:update', { + id: gamma.id, + featureRepoIntegrationEnabled: true, + featureRepoFeaturesPath: 'custom/features' + }) as { feature_repo_integration_enabled: number; feature_repo_features_path: string } + expect(p.feature_repo_integration_enabled).toBe(1) + expect(p.feature_repo_features_path).toBe('custom/features') + }) + + test('detaches linked feature tasks when integration is disabled', () => { + const repoPath = h.tmpDir() + writeFeatureMd( + repoPath, + 'docs/features/feature-detach', + `id: FEAT-DETACH +title: Detach me +description: Task should remain, link should be removed +` + ) + + const project = h.invoke('db:projects:create', { + name: 'Detach Project', + color: '#334155', + path: repoPath, + featureRepoIntegrationEnabled: true + }) as { id: string } + + const before = h.db + .prepare('SELECT COUNT(*) as count FROM project_feature_task_links WHERE project_id = ?') + .get(project.id) as { count: number } + expect(before.count).toBe(1) + + const updated = h.invoke('db:projects:update', { + id: project.id, + featureRepoIntegrationEnabled: false + }) as { feature_repo_integration_enabled: number } + expect(updated.feature_repo_integration_enabled).toBe(0) + + const afterLinks = h.db + .prepare('SELECT COUNT(*) as count FROM project_feature_task_links WHERE project_id = ?') + .get(project.id) as { count: number } + expect(afterLinks.count).toBe(0) + + const tasks = h.db + .prepare('SELECT COUNT(*) as count FROM tasks WHERE project_id = ?') + .get(project.id) as { count: number } + expect(tasks.count).toBe(1) + }) +}) + +describe('db:projects:syncFeatures', () => { + test('updates linked task title when feature file changes', () => { + const repoPath = h.tmpDir() + const relDir = 'docs/features/feature-002' + writeFeatureMd( + repoPath, + relDir, + `id: FEAT-002 +title: Initial title +description: Initial +` + ) + + const project = h.invoke('db:projects:create', { + name: 'Sync Update Project', + color: '#06b6d4', + path: repoPath, + featureRepoIntegrationEnabled: true + }) as { id: string } + + writeFeatureMd( + repoPath, + relDir, + `id: FEAT-002 +title: Updated title +description: Updated description +` + ) + + const sync = h.invoke('db:projects:syncFeatures', project.id) as { updated: number } + expect(sync.updated).toBe(1) + + const task = h.db + .prepare('SELECT title, description FROM tasks WHERE project_id = ?') + .get(project.id) as { title: string; description: string | null } + expect(task.title).toBe('FEAT-002 Updated title') + expect(task.description).toBeNull() + }) + + test('detaches stale links when FEATURE.md is deleted', () => { + const repoPath = h.tmpDir() + const relDir = 'docs/features/feature-removed' + writeFeatureMd( + repoPath, + relDir, + `id: FEAT-REMOVED +title: Will be removed +description: Created before delete +` + ) + + const project = h.invoke('db:projects:create', { + name: 'Sync Remove Project', + color: '#14b8a6', + path: repoPath, + featureRepoIntegrationEnabled: true + }) as { id: string } + + const before = h.db + .prepare('SELECT COUNT(*) as count FROM project_feature_task_links WHERE project_id = ?') + .get(project.id) as { count: number } + expect(before.count).toBe(1) + + fs.rmSync(path.join(repoPath, relDir), { recursive: true, force: true }) + + h.invoke('db:projects:syncFeatures', project.id) + + const after = h.db + .prepare('SELECT COUNT(*) as count FROM project_feature_task_links WHERE project_id = ?') + .get(project.id) as { count: number } + expect(after.count).toBe(0) + + const tasks = h.db + .prepare('SELECT COUNT(*) as count FROM tasks WHERE project_id = ?') + .get(project.id) as { count: number } + expect(tasks.count).toBe(1) + }) +}) + +describe('repository feature integration settings', () => { + test('returns and updates feature sync config', () => { + const current = h.invoke('db:projects:getFeatureSyncConfig') as { + defaultFeaturesPath: string + pollIntervalSeconds: number + } + expect(current.defaultFeaturesPath.length > 0).toBe(true) + expect(current.pollIntervalSeconds).toBeGreaterThan(0) + + const updated = h.invoke('db:projects:setFeatureSyncConfig', { + defaultFeaturesPath: 'features', + pollIntervalSeconds: 45 + }) as { + defaultFeaturesPath: string + pollIntervalSeconds: number + } + expect(updated.defaultFeaturesPath).toBe('features') + expect(updated.pollIntervalSeconds).toBe(45) + }) + + test('normalizes feature sync poll interval bounds', () => { + const low = h.invoke('db:projects:setFeatureSyncConfig', { + pollIntervalSeconds: 1 + }) as { pollIntervalSeconds: number } + expect(low.pollIntervalSeconds).toBe(5) + + const high = h.invoke('db:projects:setFeatureSyncConfig', { + pollIntervalSeconds: 99999 + }) as { pollIntervalSeconds: number } + expect(high.pollIntervalSeconds).toBe(3600) + }) + + test('syncAllFeatures aggregates enabled projects', () => { + const repoPath = h.tmpDir() + writeFeatureMd( + repoPath, + 'features/feat-a', + `id: FEAT-A +title: A title +description: A description +` + ) + h.invoke('db:projects:create', { + name: 'Agg Project', + color: '#f59e0b', + path: repoPath, + featureRepoIntegrationEnabled: true, + featureRepoFeaturesPath: 'features' + }) + + const result = h.invoke('db:projects:syncAllFeatures') as { + projects: number + scanned: number + } + expect(result.projects).toBeGreaterThan(0) + expect(result.scanned).toBeGreaterThan(0) }) test('updates columns config', () => { diff --git a/packages/domains/projects/src/main/handlers.ts b/packages/domains/projects/src/main/handlers.ts index 41734b6a..d8520e60 100644 --- a/packages/domains/projects/src/main/handlers.ts +++ b/packages/domains/projects/src/main/handlers.ts @@ -1,5 +1,6 @@ import type { IpcMain } from 'electron' import type { Database } from 'better-sqlite3' +import path from 'node:path' import type { ColumnConfig, CreateProjectInput, @@ -13,6 +14,11 @@ import { resolveColumns, validateColumns } from '@slayzone/projects/shared' +import { + getRepoFeatureSyncConfig, + syncAllProjectFeatureTasks, + syncProjectFeatureTasks +} from './repo-feature-sync' function parseProject(row: Record | undefined): Record | null { if (!row) return null @@ -134,6 +140,47 @@ function reconcileLinearStateMappingsForProject( } } +function normalizeRepoFeaturesPath(value: string | undefined, fallback: string): string { + const raw = (value ?? '').trim() + const candidate = raw.length > 0 ? raw : fallback + const normalized = candidate + .replace(/\\/g, '/') + .replace(/^\.\/+/, '') + .replace(/\/+/g, '/') + .replace(/\/$/, '') + + if (normalized.length === 0 || normalized === '.') return '.' + if (normalized.startsWith('/')) { + throw new Error('Features folder must be relative to the repository path') + } + if (/^[A-Za-z]:\//.test(normalized)) { + throw new Error('Features folder must be relative to the repository path') + } + + const collapsed = normalized.split('/').reduce((acc, segment) => { + if (!segment || segment === '.') return acc + if (segment === '..') { + if (acc.length === 0) throw new Error('Features folder must stay inside the repository path') + acc.pop() + return acc + } + acc.push(segment) + return acc + }, []) + + return collapsed.join('/') || '.' +} + +function assertFeaturesPathInsideRepo(repoPath: string, featuresPath: string): void { + const repoRoot = path.resolve(repoPath) + const resolved = path.resolve(repoPath, featuresPath) + const relative = path.relative(repoRoot, resolved) + if (relative === '') return + if (relative.startsWith('..') || path.isAbsolute(relative)) { + throw new Error('Features folder must stay inside the repository path') + } +} + export function registerProjectHandlers(ipcMain: IpcMain, db: Database): void { ipcMain.handle('db:projects:getAll', () => { @@ -143,9 +190,23 @@ export function registerProjectHandlers(ipcMain: IpcMain, db: Database): void { ipcMain.handle('db:projects:create', (_, data: CreateProjectInput) => { const prepared = prepareProjectCreate(data) + const featureRepoIntegrationEnabled = data.featureRepoIntegrationEnabled === true ? 1 : 0 + const defaultFeaturesPath = getRepoFeatureSyncConfig(db).defaultFeaturesPath || 'docs/features' + const featureRepoFeaturesPath = normalizeRepoFeaturesPath(data.featureRepoFeaturesPath, defaultFeaturesPath) + if (featureRepoIntegrationEnabled === 1 && !prepared.path) { + throw new Error('Repository path is required when FEATURE.md integration is enabled') + } + if (featureRepoIntegrationEnabled === 1 && prepared.path) { + assertFeaturesPathInsideRepo(prepared.path, featureRepoFeaturesPath) + } + const stmt = db.prepare(` - INSERT INTO projects (id, name, color, path, columns_config, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?) + INSERT INTO projects ( + id, name, color, path, columns_config, task_backend, + feature_repo_integration_enabled, feature_repo_features_path, + created_at, updated_at + ) + VALUES (?, ?, ?, ?, ?, 'db', ?, ?, ?, ?) `) stmt.run( prepared.id, @@ -153,17 +214,39 @@ export function registerProjectHandlers(ipcMain: IpcMain, db: Database): void { prepared.color, prepared.path, prepared.columnsConfigJson, + featureRepoIntegrationEnabled, + featureRepoFeaturesPath, prepared.createdAt, prepared.updatedAt ) + + if (featureRepoIntegrationEnabled === 1) { + syncProjectFeatureTasks(db, prepared.id) + } + const row = db.prepare('SELECT * FROM projects WHERE id = ?').get(prepared.id) as Record | undefined return parseProject(row) }) ipcMain.handle('db:projects:update', (_, data: UpdateProjectInput) => { + const current = db.prepare(` + SELECT path, feature_repo_integration_enabled, feature_repo_features_path + FROM projects + WHERE id = ? + `).get(data.id) as + | { + path: string | null + feature_repo_integration_enabled: number + feature_repo_features_path: string + } + | undefined + + if (!current) throw new Error('Project not found') + const fields: string[] = [] const values: unknown[] = [] let normalizedColumns: ColumnConfig[] | null | undefined = undefined + let shouldSyncFeatures = false if (data.name !== undefined) { fields.push('name = ?') @@ -175,7 +258,28 @@ export function registerProjectHandlers(ipcMain: IpcMain, db: Database): void { } if (data.path !== undefined) { fields.push('path = ?') - values.push(data.path) + values.push(typeof data.path === 'string' ? (data.path.trim() || null) : data.path) + shouldSyncFeatures = true + } + if (data.taskBackend !== undefined) { + // Project tasks are DB-backed only. + fields.push('task_backend = ?') + values.push('db') + } + if (data.featureRepoIntegrationEnabled !== undefined) { + fields.push('feature_repo_integration_enabled = ?') + values.push(data.featureRepoIntegrationEnabled ? 1 : 0) + shouldSyncFeatures = data.featureRepoIntegrationEnabled + } + if (data.featureRepoFeaturesPath !== undefined) { + fields.push('feature_repo_features_path = ?') + values.push( + normalizeRepoFeaturesPath( + data.featureRepoFeaturesPath, + getRepoFeatureSyncConfig(db).defaultFeaturesPath || 'docs/features' + ) + ) + shouldSyncFeatures = true } if (data.autoCreateWorktreeOnTaskCreate !== undefined) { fields.push('auto_create_worktree_on_task_create = ?') @@ -204,6 +308,33 @@ export function registerProjectHandlers(ipcMain: IpcMain, db: Database): void { } } + const nextPath = + data.path !== undefined + ? (typeof data.path === 'string' ? data.path.trim() || null : data.path) + : current.path + const nextFeaturesPath = + data.featureRepoFeaturesPath !== undefined + ? normalizeRepoFeaturesPath( + data.featureRepoFeaturesPath, + getRepoFeatureSyncConfig(db).defaultFeaturesPath || 'docs/features' + ) + : normalizeRepoFeaturesPath( + current.feature_repo_features_path, + getRepoFeatureSyncConfig(db).defaultFeaturesPath || 'docs/features' + ) + const nextFeatureIntegrationEnabled = + data.featureRepoIntegrationEnabled !== undefined + ? data.featureRepoIntegrationEnabled + : current.feature_repo_integration_enabled === 1 + const shouldDetachFeatureLinks = + current.feature_repo_integration_enabled === 1 && !nextFeatureIntegrationEnabled + if (nextFeatureIntegrationEnabled && !nextPath) { + throw new Error('Repository path is required when FEATURE.md integration is enabled') + } + if (nextFeatureIntegrationEnabled && nextPath) { + assertFeaturesPathInsideRepo(nextPath, nextFeaturesPath) + } + if (fields.length === 0) { const row = db.prepare('SELECT * FROM projects WHERE id = ?').get(data.id) as Record | undefined return parseProject(row) @@ -218,11 +349,52 @@ export function registerProjectHandlers(ipcMain: IpcMain, db: Database): void { remapUnknownTaskStatuses(db, data.id, normalizedColumns) reconcileLinearStateMappingsForProject(db, data.id, normalizedColumns) } + if (shouldDetachFeatureLinks) { + db.prepare('DELETE FROM project_feature_task_links WHERE project_id = ?').run(data.id) + } })() + if (shouldSyncFeatures && nextFeatureIntegrationEnabled) { + syncProjectFeatureTasks(db, data.id) + } const row = db.prepare('SELECT * FROM projects WHERE id = ?').get(data.id) as Record | undefined return parseProject(row) }) + ipcMain.handle('db:projects:syncFeatures', (_, projectId: string) => { + return syncProjectFeatureTasks(db, projectId) + }) + + ipcMain.handle('db:projects:syncAllFeatures', () => { + return syncAllProjectFeatureTasks(db) + }) + + ipcMain.handle('db:projects:getFeatureSyncConfig', () => { + return getRepoFeatureSyncConfig(db) + }) + + ipcMain.handle( + 'db:projects:setFeatureSyncConfig', + (_, input: { defaultFeaturesPath?: string; pollIntervalSeconds?: number }) => { + if (input.defaultFeaturesPath !== undefined) { + const normalized = normalizeRepoFeaturesPath(input.defaultFeaturesPath, 'docs/features') + db.prepare( + 'INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)' + ).run('repo_features_default_features_path', normalized) + } + if (input.pollIntervalSeconds !== undefined) { + const parsed = Number.isFinite(input.pollIntervalSeconds) + ? Math.round(input.pollIntervalSeconds) + : 30 + const normalized = Math.min(3600, Math.max(5, parsed)) + db.prepare( + 'INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)' + ).run('repo_features_poll_interval_seconds', String(normalized)) + } + + return getRepoFeatureSyncConfig(db) + } + ) + ipcMain.handle('db:projects:delete', (_, id: string) => { const result = db.prepare('DELETE FROM projects WHERE id = ?').run(id) return result.changes > 0 diff --git a/packages/domains/projects/src/main/index.ts b/packages/domains/projects/src/main/index.ts index ae7a4b85..0d452d68 100644 --- a/packages/domains/projects/src/main/index.ts +++ b/packages/domains/projects/src/main/index.ts @@ -1 +1,11 @@ export { registerProjectHandlers } from './handlers' +export { + createFeatureForTask, + deleteFeatureForTask, + getRepoFeatureSyncConfig, + getTaskFeatureDetails, + syncAllProjectFeatureTasks, + syncProjectFeatureTasks, + syncTaskToFeatureFile, + updateTaskFeatureFile +} from './repo-feature-sync' diff --git a/packages/domains/projects/src/main/repo-feature-sync.ts b/packages/domains/projects/src/main/repo-feature-sync.ts new file mode 100644 index 00000000..2dcaccd7 --- /dev/null +++ b/packages/domains/projects/src/main/repo-feature-sync.ts @@ -0,0 +1,1368 @@ +import crypto from 'node:crypto' +import fs from 'node:fs' +import path from 'node:path' +import type { Database } from 'better-sqlite3' +import type { + ProjectFeatureSyncAggregateResult, + ProjectFeatureSyncResult, + RepoFeatureSyncConfig, + TaskFeatureDetails, + FeatureAcceptanceItem, + UpdateTaskFeatureInput +} from '@slayzone/projects/shared' + +interface FeatureProjectRow { + id: string + path: string | null + feature_repo_integration_enabled: number + feature_repo_features_path: string | null +} + +interface FeatureLinkRow { + id: string + project_id: string + task_id: string + feature_id: string | null + feature_file_path: string + content_hash: string | null + existing_task_id: string | null +} + +interface ParsedFeature { + id: string | null + title: string + description: string | null + acceptance: FeatureAcceptanceItem[] +} + +interface ScannedFeature { + relPath: string + parsed: ParsedFeature + contentHash: string +} + +interface LinkedTaskFileRow { + link_id: string + project_id: string + project_path: string | null + feature_id: string | null + feature_file_path: string + task_title: string + task_description: string | null +} + +interface LinkedFeaturePathRow { + link_id: string + feature_file_path: string + project_path: string | null +} + +interface CreateFeatureForTaskRow { + task_id: string + project_id: string + task_title: string + task_description: string | null + project_path: string | null + feature_repo_integration_enabled: number + feature_repo_features_path: string | null + existing_link_id: string | null +} + +const DEFAULT_FEATURES_PATH = 'docs/features' +const DEFAULT_FEATURE_SYNC_POLL_INTERVAL_SECONDS = 30 +const MIN_FEATURE_SYNC_POLL_INTERVAL_SECONDS = 5 +const MAX_FEATURE_SYNC_POLL_INTERVAL_SECONDS = 3600 + +const SETTINGS = { + defaultFeaturesPath: 'repo_features_default_features_path', + pollIntervalSeconds: 'repo_features_poll_interval_seconds' +} as const + +const FEATURE_DOC_FILENAME = 'FEATURE.md' +const LEGACY_FEATURE_DOC_FILENAME = 'feature.yaml' + +function listFeatureSpecFiles(rootDir: string): string[] { + if (!fs.existsSync(rootDir)) return [] + const files: string[] = [] + const stack = [rootDir] + + while (stack.length > 0) { + const current = stack.pop() + if (!current) continue + const entries = fs.readdirSync(current, { withFileTypes: true }) + for (const entry of entries) { + const abs = path.join(current, entry.name) + if (entry.isDirectory()) { + stack.push(abs) + continue + } + if (!entry.isFile()) continue + const normalizedName = entry.name.toLowerCase() + if (normalizedName === FEATURE_DOC_FILENAME.toLowerCase()) { + files.push(abs) + } + } + } + + return files +} + +function stripOptionalQuotes(value: string): string { + const trimmed = value.trim() + if ( + (trimmed.startsWith('"') && trimmed.endsWith('"')) || + (trimmed.startsWith('\'') && trimmed.endsWith('\'')) + ) { + return trimmed.slice(1, -1).trim() + } + return trimmed +} + +function parseFeatureYaml(content: string): ParsedFeature | null { + const lines = content.split(/\r?\n/) + let featureId: string | null = null + let featureTitle: string | null = null + let featureDescription: string | null = null + let acceptance: FeatureAcceptanceItem[] = [] + + for (let i = 0; i < lines.length; i++) { + const line = lines[i] + if (!line || line.trim().length === 0 || line.trimStart().startsWith('#')) continue + if (/^\s+/.test(line)) continue + + const idMatch = line.match(/^id:\s*(.+)\s*$/) + if (idMatch) { + featureId = stripOptionalQuotes(idMatch[1]) + continue + } + + const titleMatch = line.match(/^title:\s*(.+)\s*$/) + if (titleMatch) { + featureTitle = stripOptionalQuotes(titleMatch[1]) + continue + } + + const descBlockMatch = line.match(/^description:\s*\|\s*$/) + if (descBlockMatch) { + const blockLines: string[] = [] + let minIndent = Number.POSITIVE_INFINITY + + for (let j = i + 1; j < lines.length; j++) { + const blockLine = lines[j] + if (blockLine.trim().length === 0) { + blockLines.push('') + continue + } + if (!/^\s+/.test(blockLine)) { + i = j - 1 + break + } + const indent = blockLine.length - blockLine.trimStart().length + minIndent = Math.min(minIndent, indent) + blockLines.push(blockLine) + if (j === lines.length - 1) i = j + } + + if (blockLines.length > 0) { + const normalized = blockLines + .map((value) => { + if (value.length === 0) return '' + if (!Number.isFinite(minIndent) || minIndent <= 0) return value.trimEnd() + return value.slice(Math.min(minIndent, value.length)).trimEnd() + }) + .join('\n') + .trim() + featureDescription = normalized || null + } + continue + } + + const descInlineMatch = line.match(/^description:\s*(.+)\s*$/) + if (descInlineMatch) { + featureDescription = stripOptionalQuotes(descInlineMatch[1]) || null + continue + } + + const acceptanceMatch = line.match(/^acceptance:\s*$/) + if (acceptanceMatch) { + acceptance = parseAcceptanceBlock(lines, i) + while (i + 1 < lines.length && (lines[i + 1].trim().length === 0 || /^\s+/.test(lines[i + 1]))) { + i += 1 + } + } + } + + if (!featureId && !featureTitle) return null + return { + id: featureId, + title: featureTitle || featureId || 'Untitled feature', + description: featureDescription, + acceptance + } +} + +function extractMarkdownFrontmatter(content: string): { frontmatter: string | null; body: string } { + const normalized = content.replace(/\r\n/g, '\n') + if (!normalized.startsWith('---\n')) { + return { frontmatter: null, body: normalized } + } + const closingIndex = normalized.indexOf('\n---\n', 4) + if (closingIndex === -1) { + return { frontmatter: null, body: normalized } + } + const frontmatter = normalized.slice(4, closingIndex) + const body = normalized.slice(closingIndex + 5) + return { frontmatter, body } +} + +function parseMarkdownFallbackMeta(body: string): { + title: string | null + description: string | null +} { + const lines = body.split('\n') + let title: string | null = null + let description: string | null = null + + for (const line of lines) { + const heading = line.match(/^#\s+(.+)\s*$/) + if (heading) { + title = heading[1].trim() + break + } + } + + const paragraphs = body + .split(/\n\s*\n/g) + .map((chunk) => chunk.trim()) + .filter(Boolean) + .filter((chunk) => !chunk.startsWith('#')) + if (paragraphs.length > 0) { + description = paragraphs[0] + } + + return { title, description } +} + +function parseFeatureMarkdown(content: string): ParsedFeature | null { + const { frontmatter, body } = extractMarkdownFrontmatter(content) + const parsedFrontmatter = frontmatter ? parseFeatureYaml(frontmatter) : null + const fallback = parseMarkdownFallbackMeta(body) + const featureId = parsedFrontmatter?.id ?? null + const featureTitle = parsedFrontmatter?.title?.trim() || fallback.title || featureId || null + // Keep task descriptions stable: only sync description when explicitly defined in metadata. + const featureDescription = parsedFrontmatter?.description ?? null + if (!featureId && !featureTitle) return null + return { + id: featureId, + title: featureTitle || 'Untitled feature', + description: featureDescription, + acceptance: parsedFrontmatter?.acceptance ?? [] + } +} + +function parseKeyValue(line: string): { key: string; value: string } | null { + const match = line.match(/^([A-Za-z_][A-Za-z0-9_-]*):\s*(.*)$/) + if (!match) return null + return { key: match[1], value: stripOptionalQuotes(match[2] ?? '') } +} + +function parseAcceptanceBlock(lines: string[], startIndex: number): FeatureAcceptanceItem[] { + const entries: FeatureAcceptanceItem[] = [] + let current: FeatureAcceptanceItem | null = null + + for (let i = startIndex + 1; i < lines.length; i++) { + const raw = lines[i] + if (raw.trim().length === 0) continue + if (!/^\s/.test(raw)) break + if (!raw.startsWith(' ')) break + + const trimmed = raw.trim() + if (trimmed.startsWith('- ')) { + if (current) entries.push(current) + current = { id: '', scenario: '', file: null, resolvedFilePath: null } + const inline = trimmed.slice(2).trim() + if (inline.length > 0) { + const parsed = parseKeyValue(inline) + if (parsed) { + if (parsed.key === 'id') current.id = parsed.value + else if (parsed.key === 'scenario') current.scenario = parsed.value + else if (parsed.key === 'file') current.file = parsed.value || null + } + } + continue + } + + if (!current) continue + const parsed = parseKeyValue(trimmed) + if (!parsed) continue + if (parsed.key === 'id') current.id = parsed.value + else if (parsed.key === 'scenario') current.scenario = parsed.value + else if (parsed.key === 'file') current.file = parsed.value || null + } + + if (current) entries.push(current) + + return entries + .filter((entry) => entry.id.trim().length > 0 || entry.scenario.trim().length > 0 || !!entry.file) + .map((entry) => ({ + id: entry.id.trim(), + scenario: entry.scenario.trim() || entry.id.trim() || 'Unnamed scenario', + file: entry.file?.trim() || null, + resolvedFilePath: null + })) +} + +function getSetting(db: Database, key: string, fallback: string): string { + const row = db.prepare('SELECT value FROM settings WHERE key = ?').get(key) as { value: string } | undefined + const value = row?.value?.trim() + return value && value.length > 0 ? value : fallback +} + +function normalizePollIntervalSeconds(value: number | string | null | undefined): number { + const parsed = typeof value === 'number' + ? value + : Number.parseInt(String(value ?? ''), 10) + if (!Number.isFinite(parsed)) return DEFAULT_FEATURE_SYNC_POLL_INTERVAL_SECONDS + const rounded = Math.round(parsed) + if (rounded < MIN_FEATURE_SYNC_POLL_INTERVAL_SECONDS) return MIN_FEATURE_SYNC_POLL_INTERVAL_SECONDS + if (rounded > MAX_FEATURE_SYNC_POLL_INTERVAL_SECONDS) return MAX_FEATURE_SYNC_POLL_INTERVAL_SECONDS + return rounded +} + +function normalizeFeaturesFolderPath(value: string | null | undefined, fallback: string): string { + const raw = (value ?? '').trim() + const candidate = raw.length > 0 ? raw : fallback + const normalized = candidate + .replace(/\\/g, '/') + .replace(/^\.\/+/, '') + .replace(/\/+/g, '/') + .replace(/\/$/, '') + + if (normalized.length === 0 || normalized === '.') return '.' + if (path.posix.isAbsolute(normalized)) throw new Error('Features folder must be relative to the repository path') + + const collapsed = path.posix.normalize(normalized) + if (collapsed === '..' || collapsed.startsWith('../')) { + throw new Error('Features folder must stay inside the repository path') + } + return collapsed +} + +function resolveFeatureRoot(projectPath: string, configuredPath: string): string { + const repoRoot = path.resolve(projectPath) + const featureRoot = path.resolve(projectPath, configuredPath) + if (featureRoot !== repoRoot && !featureRoot.startsWith(`${repoRoot}${path.sep}`)) { + throw new Error('Features folder must stay inside the repository path') + } + return featureRoot +} + +function assertInsidePath(basePath: string, candidatePath: string): void { + const base = path.resolve(basePath) + const candidate = path.resolve(candidatePath) + const relative = path.relative(base, candidate) + if (relative === '') return + if (relative.startsWith('..') || path.isAbsolute(relative)) { + throw new Error('Path must stay inside configured Features folder') + } +} + +function slugify(value: string): string { + const slug = value + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') + return slug || 'feature' +} + +function buildProviderDefaults(db: Database): { + terminalMode: string + providerConfig: string + claudeFlags: string + codexFlags: string + cursorFlags: string + geminiFlags: string + opencodeFlags: string +} { + const terminalMode = getSetting(db, 'default_terminal_mode', 'claude-code') + const claudeFlags = getSetting(db, 'default_claude_flags', '--allow-dangerously-skip-permissions') + const codexFlags = getSetting(db, 'default_codex_flags', '--full-auto --search') + const cursorFlags = getSetting(db, 'default_cursor_flags', '--force') + const geminiFlags = getSetting(db, 'default_gemini_flags', '--yolo') + const opencodeFlags = getSetting(db, 'default_opencode_flags', '') + const providerConfig = JSON.stringify({ + 'claude-code': { flags: claudeFlags }, + codex: { flags: codexFlags }, + 'cursor-agent': { flags: cursorFlags }, + gemini: { flags: geminiFlags }, + opencode: { flags: opencodeFlags } + }) + + return { + terminalMode, + providerConfig, + claudeFlags, + codexFlags, + cursorFlags, + geminiFlags, + opencodeFlags + } +} + +function getNextTaskOrder(db: Database, projectId: string): number { + const row = db.prepare('SELECT COALESCE(MAX("order"), -1) + 1 AS next_order FROM tasks WHERE project_id = ?').get(projectId) as { next_order: number } + return row.next_order +} + +function createTaskForFeature( + db: Database, + projectId: string, + title: string, + description: string | null +): string { + const id = crypto.randomUUID() + const nextOrder = getNextTaskOrder(db, projectId) + const defaults = buildProviderDefaults(db) + db.prepare(` + INSERT INTO tasks ( + id, project_id, title, description, status, priority, "order", terminal_mode, + provider_config, claude_flags, codex_flags, cursor_flags, gemini_flags, opencode_flags, + created_at, updated_at + ) VALUES (?, ?, ?, ?, 'inbox', 3, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'), datetime('now')) + `).run( + id, + projectId, + title, + description, + nextOrder, + defaults.terminalMode, + defaults.providerConfig, + defaults.claudeFlags, + defaults.codexFlags, + defaults.cursorFlags, + defaults.geminiFlags, + defaults.opencodeFlags + ) + return id +} + +function buildTaskTitle(parsed: ParsedFeature, relPath: string): string { + const normalizedTitle = parsed.title.trim() + const fallbackTitle = + path.basename(path.dirname(relPath)) + || path.basename(relPath, path.extname(relPath)) + const base = normalizedTitle || fallbackTitle + return parsed.id ? `${parsed.id} ${base}` : base +} + +function normalizeRelPath(relPath: string): string { + return relPath.replace(/\\/g, '/') +} + +function resolvePathInsideRepo(repoPath: string, candidatePath: string): string | null { + const repoRoot = path.resolve(repoPath) + const absPath = path.resolve(candidatePath) + const relative = path.relative(repoRoot, absPath) + if (relative === '') return '.' + if (relative.startsWith('..') || path.isAbsolute(relative)) return null + return normalizeRelPath(relative) +} + +function resolveAcceptanceFilePath( + repoPath: string, + featureDirPath: string, + filePath: string | null +): string | null { + if (!filePath) return null + const normalized = filePath.replace(/\\/g, '/').trim() + if (!normalized) return null + const candidateAbs = path.isAbsolute(normalized) + ? normalized + : normalized.startsWith('./') || normalized.startsWith('../') + ? path.resolve(repoPath, featureDirPath, normalized) + : path.resolve(repoPath, normalized) + return resolvePathInsideRepo(repoPath, candidateAbs) +} + +function toScenarioFromFileName(filePath: string): string { + const base = path.basename(filePath, path.extname(filePath)) + const words = base + .replace(/[_-]+/g, ' ') + .trim() + .split(/\s+/) + .filter(Boolean) + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + return words.join(' ') || 'Acceptance scenario' +} + +function discoverAcceptancePythonFiles(repoPath: string, featureDirPath: string): FeatureAcceptanceItem[] { + const acceptanceRoot = path.resolve(repoPath, featureDirPath, 'acceptance') + if (!fs.existsSync(acceptanceRoot)) return [] + + const files: string[] = [] + const stack = [acceptanceRoot] + while (stack.length > 0) { + const current = stack.pop() + if (!current) continue + const entries = fs.readdirSync(current, { withFileTypes: true }) + for (const entry of entries) { + const abs = path.join(current, entry.name) + if (entry.isDirectory()) { + stack.push(abs) + continue + } + if (!entry.isFile()) continue + if (entry.name.toLowerCase().endsWith('.py')) files.push(abs) + } + } + + files.sort((a, b) => a.localeCompare(b)) + return files + .map((absPath, index): FeatureAcceptanceItem | null => { + const rel = resolvePathInsideRepo(repoPath, absPath) + if (!rel) return null + return { + id: `SC-PY-${index + 1}`, + scenario: toScenarioFromFileName(rel), + file: rel, + resolvedFilePath: rel + } + }) + .filter((item): item is FeatureAcceptanceItem => item !== null) +} + +function parseFeatureSpecContent( + content: string, + relPath: string, + projectPath: string +): ParsedFeature | null { + const lowerRelPath = relPath.toLowerCase() + const parsed = lowerRelPath.endsWith('.md') + ? parseFeatureMarkdown(content) + : parseFeatureYaml(content) + if (!parsed) return null + + if (lowerRelPath.endsWith('.md')) { + const featureDirPath = normalizeRelPath(path.dirname(relPath)) + return { + ...parsed, + acceptance: discoverAcceptancePythonFiles(projectPath, featureDirPath) + } + } + return parsed +} + +function htmlToPlainText(value: string): string { + return value + .replace(//gi, '\n') + .replace(/<\/p>/gi, '\n\n') + .replace(/
  • /gi, '- ') + .replace(/<\/li>/gi, '\n') + .replace(/<[^>]*>/g, '') + .replace(/ /g, ' ') + .replace(/&/g, '&') + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/"/g, '"') + .trim() +} + +function normalizeDescriptionForYaml(value: string | null): string | null { + if (!value) return null + const maybeHtml = value.includes('<') && value.includes('>') + const plain = maybeHtml ? htmlToPlainText(value) : value + const normalized = plain.replace(/\r\n/g, '\n').trim() + return normalized.length > 0 ? normalized : null +} + +function splitTaskTitle(taskTitle: string, linkedFeatureId: string | null): { featureId: string | null; featureTitle: string } { + const title = taskTitle.trim() + if (title.length === 0) return { featureId: linkedFeatureId, featureTitle: 'Untitled feature' } + if (linkedFeatureId && title.startsWith(`${linkedFeatureId} `)) { + return { featureId: linkedFeatureId, featureTitle: title.slice(linkedFeatureId.length + 1).trim() || title } + } + const tokenMatch = title.match(/^([A-Za-z][A-Za-z0-9_-]*-\d+)\s+(.+)$/) + if (tokenMatch) { + return { + featureId: tokenMatch[1], + featureTitle: tokenMatch[2].trim() + } + } + return { featureId: linkedFeatureId, featureTitle: title } +} + +function quoteYaml(value: string): string { + return JSON.stringify(value) +} + +function findTopLevelKeyRange(lines: string[], key: string): { start: number; end: number } | null { + const keyPattern = new RegExp(`^${key}:`) + const start = lines.findIndex((line) => keyPattern.test(line)) + if (start === -1) return null + let end = start + 1 + while (end < lines.length) { + const line = lines[end] + if (line.trim().length === 0) { + end += 1 + continue + } + if (/^\s/.test(line)) { + end += 1 + continue + } + break + } + return { start, end } +} + +function renderDescriptionYaml(description: string | null): string[] { + if (!description) return ['description: ""'] + return ['description: |', ...description.split('\n').map((line) => ` ${line}`)] +} + +function renderAcceptanceYaml(acceptance: FeatureAcceptanceItem[]): string[] { + if (acceptance.length === 0) return ['acceptance: []'] + const lines: string[] = ['acceptance:'] + for (const item of acceptance) { + lines.push(` - id: ${quoteYaml(item.id)}`) + lines.push(` scenario: ${quoteYaml(item.scenario)}`) + lines.push(` file: ${quoteYaml(item.file ?? '')}`) + } + return lines +} + +function renderFeatureFrontmatterLines( + featureId: string | null, + featureTitle: string, + description: string | null, + acceptance?: FeatureAcceptanceItem[] +): string[] { + const lines: string[] = [] + if (featureId) lines.push(`id: ${quoteYaml(featureId)}`) + lines.push(`title: ${quoteYaml(featureTitle)}`) + lines.push(...renderDescriptionYaml(description)) + if (acceptance) lines.push(...renderAcceptanceYaml(acceptance)) + return lines +} + +function upsertFeatureFrontMatter( + content: string, + featureId: string | null, + featureTitle: string, + description: string | null, + acceptance?: FeatureAcceptanceItem[] +): string { + const lines = content.replace(/\r\n/g, '\n').split('\n') + const headerKeys = ['id', 'title', 'description', ...(acceptance ? ['acceptance'] : [])] + const ranges = headerKeys + .map((key) => findTopLevelKeyRange(lines, key)) + .filter((range): range is { start: number; end: number } => range != null) + .sort((a, b) => b.start - a.start) + + for (const range of ranges) { + lines.splice(range.start, range.end - range.start) + } + + let insertAt = 0 + while ( + insertAt < lines.length && + (lines[insertAt].trim().length === 0 || lines[insertAt].trimStart().startsWith('#')) + ) { + insertAt += 1 + } + + const header: string[] = [] + if (featureId) header.push(`id: ${quoteYaml(featureId)}`) + header.push(`title: ${quoteYaml(featureTitle)}`) + header.push(...renderDescriptionYaml(description)) + if (acceptance) header.push(...renderAcceptanceYaml(acceptance)) + header.push('') + + lines.splice(insertAt, 0, ...header) + const updated = lines.join('\n').replace(/\n+$/, '\n') + return updated +} + +function upsertFeatureMarkdownFrontMatter( + content: string, + featureId: string | null, + featureTitle: string, + description: string | null, + acceptance?: FeatureAcceptanceItem[] +): string { + const normalized = content.replace(/\r\n/g, '\n') + const { body } = extractMarkdownFrontmatter(normalized) + const frontmatterLines = renderFeatureFrontmatterLines(featureId, featureTitle, description, acceptance) + const canonicalBody = body.trim().length > 0 ? body.trimStart() : `# ${featureTitle}\n` + return `---\n${frontmatterLines.join('\n')}\n---\n\n${canonicalBody.replace(/\n+$/, '\n')}` +} + +function buildInitialFeatureMarkdownContent(featureTitle: string, description: string | null): string { + const title = featureTitle.trim() || 'Untitled feature' + const desc = (description ?? '').trim() + if (desc.length === 0) { + return `# ${title}\n` + } + return `# ${title}\n\n${desc}\n` +} + +function resolveLinkedFeatureFilePath( + db: Database, + input: { + linkId: string + projectPath: string + featureFilePath: string + } +): { featureFilePath: string; featureFileAbs: string } | null { + const normalizedRelPath = normalizeRelPath(input.featureFilePath) + const normalizedLower = normalizedRelPath.toLowerCase() + const featureFileAbs = path.resolve(input.projectPath, normalizedRelPath) + + if (fs.existsSync(featureFileAbs)) { + // Canonicalize legacy feature.yaml links to FEATURE.md even when both exist. + if (normalizedLower.endsWith(`/${LEGACY_FEATURE_DOC_FILENAME}`) || normalizedLower === LEGACY_FEATURE_DOC_FILENAME) { + const nextRelPath = normalizeRelPath( + path.join(path.dirname(normalizedRelPath), FEATURE_DOC_FILENAME) + ) + const nextAbsPath = path.resolve(input.projectPath, nextRelPath) + + if (!fs.existsSync(nextAbsPath)) { + const legacyParsed = parseFeatureYaml(fs.readFileSync(featureFileAbs, 'utf8')) + const migratedContent = upsertFeatureMarkdownFrontMatter( + '\n', + legacyParsed?.id ?? null, + legacyParsed?.title ?? 'Untitled feature', + legacyParsed?.description ?? null, + legacyParsed?.acceptance ?? [] + ) + fs.writeFileSync(nextAbsPath, migratedContent, 'utf8') + } + + db.prepare(` + UPDATE project_feature_task_links + SET feature_file_path = ?, updated_at = datetime('now') + WHERE id = ? + `).run(nextRelPath, input.linkId) + + return { + featureFilePath: nextRelPath, + featureFileAbs: nextAbsPath + } + } + + return { + featureFilePath: normalizedRelPath, + featureFileAbs + } + } + + if (normalizedLower.endsWith(`/${LEGACY_FEATURE_DOC_FILENAME}`) || normalizedLower === LEGACY_FEATURE_DOC_FILENAME) { + const nextRelPath = normalizeRelPath( + path.join(path.dirname(normalizedRelPath), FEATURE_DOC_FILENAME) + ) + const nextAbsPath = path.resolve(input.projectPath, nextRelPath) + if (fs.existsSync(nextAbsPath)) { + db.prepare(` + UPDATE project_feature_task_links + SET feature_file_path = ?, updated_at = datetime('now') + WHERE id = ? + `).run(nextRelPath, input.linkId) + return { + featureFilePath: nextRelPath, + featureFileAbs: nextAbsPath + } + } + } + + return null +} + +function unlinkFeatureTaskLink(db: Database, linkId: string): void { + db.prepare('DELETE FROM project_feature_task_links WHERE id = ?').run(linkId) +} + +function buildContentHash(content: string): string { + return crypto.createHash('sha256').update(content).digest('hex') +} + +function taskToFeatureFileContent(existingContent: string, taskRow: LinkedTaskFileRow, acceptance?: FeatureAcceptanceItem[]): { content: string; featureId: string | null; featureTitle: string } { + const titleParts = splitTaskTitle(taskRow.task_title, taskRow.feature_id) + const featureTitle = titleParts.featureTitle + const description = normalizeDescriptionForYaml(taskRow.task_description) + const lowerRelPath = taskRow.feature_file_path.toLowerCase() + const content = lowerRelPath.endsWith('.md') + ? upsertFeatureMarkdownFrontMatter(existingContent, titleParts.featureId, featureTitle, description, acceptance) + : upsertFeatureFrontMatter(existingContent, titleParts.featureId, featureTitle, description, acceptance) + return { content, featureId: titleParts.featureId, featureTitle } +} + +function normalizeEditableAcceptance(input: UpdateTaskFeatureInput['acceptance']): FeatureAcceptanceItem[] { + const entries: Array = input.map((item, index) => { + const rawId = (item.id ?? '').trim() + const rawScenario = (item.scenario ?? '').trim() + const rawFile = (item.file ?? '').trim().replace(/\\/g, '/') + if (!rawId && !rawScenario && !rawFile) return null + const id = rawId || `SC-${index + 1}` + const scenario = rawScenario || id + return { + id, + scenario, + file: rawFile.length > 0 ? rawFile : null, + resolvedFilePath: null + } + }) + return entries.filter((item): item is FeatureAcceptanceItem => item !== null) +} + +function buildTaskTitleFromFeature(featureId: string | null, featureTitle: string): string { + return featureId ? `${featureId} ${featureTitle}` : featureTitle +} + +export function syncProjectFeatureTasks(db: Database, projectId: string): ProjectFeatureSyncResult { + const result: ProjectFeatureSyncResult = { + scanned: 0, + created: 0, + updated: 0, + skipped: 0, + errors: [] + } + + const project = db.prepare(` + SELECT id, path, feature_repo_integration_enabled, feature_repo_features_path + FROM projects + WHERE id = ? + `).get(projectId) as FeatureProjectRow | undefined + + if (!project) { + result.errors.push('Project not found') + return result + } + if (project.feature_repo_integration_enabled !== 1) return result + if (!project.path) { + result.errors.push('Repository path is required for FEATURE.md integration') + return result + } + + const featureRoot = normalizeFeaturesFolderPath( + project.feature_repo_features_path, + getSetting(db, SETTINGS.defaultFeaturesPath, DEFAULT_FEATURES_PATH) + ) + const featureRootAbs = resolveFeatureRoot(project.path, featureRoot) + const featureFiles = listFeatureSpecFiles(featureRootAbs) + + const scannedFeatures: ScannedFeature[] = [] + for (const file of featureFiles) { + result.scanned += 1 + try { + const content = fs.readFileSync(file, 'utf8') + const relPath = normalizeRelPath(path.relative(project.path, file)) + const parsed = parseFeatureSpecContent(content, relPath, project.path) + if (!parsed) { + result.skipped += 1 + result.errors.push(`Skipping ${relPath}: missing id/title`) + continue + } + scannedFeatures.push({ + relPath, + parsed, + contentHash: crypto.createHash('sha256').update(content).digest('hex') + }) + } catch (err) { + result.skipped += 1 + result.errors.push( + `Failed reading ${normalizeRelPath(path.relative(project.path, file))}: ${err instanceof Error ? err.message : String(err)}` + ) + } + } + + const existingRowsRaw = db.prepare(` + SELECT l.id, l.project_id, l.task_id, l.feature_id, l.feature_file_path, l.content_hash, t.id AS existing_task_id + FROM project_feature_task_links l + LEFT JOIN tasks t ON t.id = l.task_id + WHERE l.project_id = ? + `).all(projectId) as FeatureLinkRow[] + const existingRows: FeatureLinkRow[] = [] + const staleLinkIds: string[] = [] + for (const row of existingRowsRaw) { + const resolved = resolveLinkedFeatureFilePath(db, { + linkId: row.id, + projectPath: project.path, + featureFilePath: row.feature_file_path + }) + if (!resolved) { + staleLinkIds.push(row.id) + continue + } + row.feature_file_path = resolved.featureFilePath + existingRows.push(row) + } + const existingByPath = new Map(existingRows.map((row) => [row.feature_file_path, row])) + const existingByFeatureId = new Map(existingRows.filter((row) => row.feature_id).map((row) => [row.feature_id as string, row])) + + db.transaction(() => { + const deleteLinkStmt = db.prepare('DELETE FROM project_feature_task_links WHERE id = ?') + const updateTaskStmt = db.prepare(` + UPDATE tasks + SET project_id = ?, title = ?, updated_at = datetime('now') + WHERE id = ? + `) + const updateLinkStmt = db.prepare(` + UPDATE project_feature_task_links + SET task_id = ?, feature_id = ?, feature_title = ?, feature_file_path = ?, content_hash = ?, last_sync_source = 'repo', last_sync_at = datetime('now'), updated_at = datetime('now') + WHERE id = ? + `) + const insertLinkStmt = db.prepare(` + INSERT INTO project_feature_task_links ( + id, project_id, task_id, feature_id, feature_title, feature_file_path, content_hash, last_sync_source, last_sync_at, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, 'repo', datetime('now'), datetime('now'), datetime('now')) + `) + + for (const linkId of staleLinkIds) { + deleteLinkStmt.run(linkId) + result.updated += 1 + } + + for (const feature of scannedFeatures) { + const taskTitle = buildTaskTitle(feature.parsed, feature.relPath) + const taskDescription = null + const existing = existingByPath.get(feature.relPath) + ?? (feature.parsed.id ? existingByFeatureId.get(feature.parsed.id) : undefined) + + if (!existing) { + const taskId = createTaskForFeature(db, projectId, taskTitle, taskDescription) + insertLinkStmt.run( + crypto.randomUUID(), + projectId, + taskId, + feature.parsed.id, + feature.parsed.title, + feature.relPath, + feature.contentHash + ) + result.created += 1 + continue + } + + if (!existing.existing_task_id) { + const taskId = createTaskForFeature(db, projectId, taskTitle, taskDescription) + updateLinkStmt.run(taskId, feature.parsed.id, feature.parsed.title, feature.relPath, feature.contentHash, existing.id) + result.created += 1 + continue + } + + if ( + existing.content_hash === feature.contentHash && + existing.feature_file_path === feature.relPath && + existing.feature_id === feature.parsed.id + ) { + result.skipped += 1 + continue + } + + updateTaskStmt.run(projectId, taskTitle, existing.existing_task_id) + updateLinkStmt.run(existing.existing_task_id, feature.parsed.id, feature.parsed.title, feature.relPath, feature.contentHash, existing.id) + result.updated += 1 + } + })() + + return result +} + +export function syncAllProjectFeatureTasks(db: Database): ProjectFeatureSyncAggregateResult { + const result: ProjectFeatureSyncAggregateResult = { + projects: 0, + scanned: 0, + created: 0, + updated: 0, + skipped: 0, + errors: [] + } + + const projectRows = db.prepare(` + SELECT id + FROM projects + WHERE feature_repo_integration_enabled = 1 + AND path IS NOT NULL + AND path != '' + `).all() as Array<{ id: string }> + + for (const project of projectRows) { + const projectResult = syncProjectFeatureTasks(db, project.id) + result.projects += 1 + result.scanned += projectResult.scanned + result.created += projectResult.created + result.updated += projectResult.updated + result.skipped += projectResult.skipped + result.errors.push(...projectResult.errors.map((err) => `${project.id}: ${err}`)) + } + + return result +} + +export function syncTaskToFeatureFile(db: Database, taskId: string): { updated: boolean } { + const row = db.prepare(` + SELECT + l.id AS link_id, + l.project_id, + l.feature_id, + l.feature_file_path, + p.path AS project_path, + t.title AS task_title, + t.description AS task_description + FROM project_feature_task_links l + JOIN projects p ON p.id = l.project_id + JOIN tasks t ON t.id = l.task_id + WHERE l.task_id = ? + `).get(taskId) as LinkedTaskFileRow | undefined + + if (!row) return { updated: false } + if (!row.project_path) return { updated: false } + + const resolved = resolveLinkedFeatureFilePath(db, { + linkId: row.link_id, + projectPath: row.project_path, + featureFilePath: row.feature_file_path + }) + if (!resolved) return { updated: false } + + row.feature_file_path = resolved.featureFilePath + + const existingContent = fs.readFileSync(resolved.featureFileAbs, 'utf8') + const acceptance = row.feature_file_path.toLowerCase().endsWith('.md') + ? discoverAcceptancePythonFiles(row.project_path, normalizeRelPath(path.dirname(row.feature_file_path))) + : undefined + const next = taskToFeatureFileContent(existingContent, row, acceptance) + if (next.content === existingContent) return { updated: false } + + fs.writeFileSync(resolved.featureFileAbs, next.content, 'utf8') + const contentHash = buildContentHash(next.content) + db.prepare(` + UPDATE project_feature_task_links + SET feature_id = ?, feature_title = ?, content_hash = ?, last_sync_source = 'task', last_sync_at = datetime('now'), updated_at = datetime('now') + WHERE id = ? + `).run(next.featureId, next.featureTitle, contentHash, row.link_id) + + return { updated: true } +} + +export function getTaskFeatureDetails(db: Database, taskId: string): TaskFeatureDetails | null { + const row = db.prepare(` + SELECT + l.id AS link_id, + l.project_id, + l.task_id, + l.feature_id, + l.feature_title, + l.feature_file_path, + l.last_sync_source, + l.last_sync_at, + p.path AS project_path, + COALESCE(t.worktree_path, p.path) AS base_path + FROM project_feature_task_links l + JOIN projects p ON p.id = l.project_id + JOIN tasks t ON t.id = l.task_id + WHERE l.task_id = ? + LIMIT 1 + `).get(taskId) as { + link_id: string + project_id: string + task_id: string + feature_id: string | null + feature_title: string | null + feature_file_path: string + last_sync_source: 'repo' | 'task' | null + last_sync_at: string | null + project_path: string | null + base_path: string | null + } | undefined + + if (!row) return null + + const resolved = row.project_path + ? resolveLinkedFeatureFilePath(db, { + linkId: row.link_id, + projectPath: row.project_path, + featureFilePath: row.feature_file_path + }) + : null + if (row.project_path && !resolved) { + unlinkFeatureTaskLink(db, row.link_id) + return null + } + + const featureFilePath = normalizeRelPath(resolved?.featureFilePath ?? row.feature_file_path) + const fileSegments = featureFilePath.split('/').filter(Boolean) + const featureDirPath = fileSegments.length > 1 ? fileSegments.slice(0, -1).join('/') : '.' + const featureDirAbsolutePath = row.base_path ? path.resolve(row.base_path, featureDirPath) : null + + let parsed: ParsedFeature | null = null + if (row.project_path) { + const featureFileAbs = resolved?.featureFileAbs ?? path.resolve(row.project_path, featureFilePath) + if (fs.existsSync(featureFileAbs)) { + try { + parsed = parseFeatureSpecContent( + fs.readFileSync(featureFileAbs, 'utf8'), + featureFilePath, + row.project_path + ) + } catch { + parsed = null + } + } + } + + const acceptance = (parsed?.acceptance ?? []).map((item) => ({ + ...item, + resolvedFilePath: row.project_path ? resolveAcceptanceFilePath(row.project_path, featureDirPath, item.file) : null + })) + + return { + projectId: row.project_id, + taskId: row.task_id, + featureId: parsed?.id ?? row.feature_id ?? null, + title: parsed?.title ?? row.feature_title ?? row.feature_id ?? 'Untitled feature', + description: parsed?.description ?? null, + featureFilePath, + featureDirPath, + featureDirAbsolutePath, + acceptance, + lastSyncAt: row.last_sync_at ?? '', + lastSyncSource: row.last_sync_source === 'task' ? 'task' : 'repo' + } +} + +export function updateTaskFeatureFile( + db: Database, + taskId: string, + input: UpdateTaskFeatureInput +): { updated: boolean } { + const row = db.prepare(` + SELECT + l.id AS link_id, + l.project_id, + l.feature_id, + l.feature_file_path, + p.path AS project_path, + t.title AS task_title, + t.description AS task_description + FROM project_feature_task_links l + JOIN projects p ON p.id = l.project_id + JOIN tasks t ON t.id = l.task_id + WHERE l.task_id = ? + `).get(taskId) as LinkedTaskFileRow | undefined + + if (!row) throw new Error('Task is not linked to a feature') + if (!row.project_path) throw new Error('Repository path is required for linked feature edits') + + const featureTitle = input.title.trim() + if (featureTitle.length === 0) throw new Error('Feature title is required') + + const featureId = (input.featureId ?? '').trim() || null + const description = normalizeDescriptionForYaml(input.description ?? null) + const acceptanceFromInput = normalizeEditableAcceptance(input.acceptance ?? []) + const nextTaskTitle = buildTaskTitleFromFeature(featureId, featureTitle) + + const resolved = resolveLinkedFeatureFilePath(db, { + linkId: row.link_id, + projectPath: row.project_path, + featureFilePath: row.feature_file_path + }) + if (!resolved) throw new Error('Linked feature file does not exist') + + row.feature_file_path = resolved.featureFilePath + const existingContent = fs.readFileSync(resolved.featureFileAbs, 'utf8') + const featureDirPath = normalizeRelPath(path.dirname(row.feature_file_path)) + const isMarkdown = row.feature_file_path.toLowerCase().endsWith('.md') + const acceptance = isMarkdown + ? discoverAcceptancePythonFiles(row.project_path, featureDirPath) + : acceptanceFromInput + const nextContent = isMarkdown + ? upsertFeatureMarkdownFrontMatter( + existingContent, + featureId, + featureTitle, + description, + acceptance + ) + : upsertFeatureFrontMatter( + existingContent, + featureId, + featureTitle, + description, + acceptance + ) + + const fileChanged = nextContent !== existingContent + if (fileChanged) fs.writeFileSync(resolved.featureFileAbs, nextContent, 'utf8') + const contentHash = buildContentHash(nextContent) + + const taskChanged = row.task_title !== nextTaskTitle + if (taskChanged) { + db.prepare(` + UPDATE tasks + SET title = ?, updated_at = datetime('now') + WHERE id = ? + `).run(nextTaskTitle, taskId) + } + + db.prepare(` + UPDATE project_feature_task_links + SET feature_id = ?, feature_title = ?, content_hash = ?, last_sync_source = 'task', last_sync_at = datetime('now'), updated_at = datetime('now') + WHERE id = ? + `).run(featureId, featureTitle, contentHash, row.link_id) + + return { + updated: fileChanged || taskChanged || row.feature_id !== featureId + } +} + +export function createFeatureForTask( + db: Database, + taskId: string, + input: { + featureId?: string | null + folderName?: string | null + title?: string | null + description?: string | null + } = {} +): { created: boolean; featureFilePath: string } { + const row = db.prepare(` + SELECT + t.id AS task_id, + t.project_id, + t.title AS task_title, + t.description AS task_description, + p.path AS project_path, + p.feature_repo_integration_enabled, + p.feature_repo_features_path, + l.id AS existing_link_id + FROM tasks t + JOIN projects p ON p.id = t.project_id + LEFT JOIN project_feature_task_links l ON l.task_id = t.id + WHERE t.id = ? + LIMIT 1 + `).get(taskId) as CreateFeatureForTaskRow | undefined + + if (!row) throw new Error('Task not found') + if (row.existing_link_id) throw new Error('Task is already linked to a feature') + if (row.feature_repo_integration_enabled !== 1) { + throw new Error('Enable FEATURE.md integration in Settings first') + } + if (!row.project_path) { + throw new Error('Repository path is required to create feature files') + } + + const defaultFeaturesPath = getSetting(db, SETTINGS.defaultFeaturesPath, DEFAULT_FEATURES_PATH) + const configuredFeaturesPath = normalizeFeaturesFolderPath(row.feature_repo_features_path, defaultFeaturesPath) + const featuresRootAbs = resolveFeatureRoot(row.project_path, configuredFeaturesPath) + + const normalizedFeatureId = (input.featureId ?? '').trim() || null + const split = splitTaskTitle(row.task_title, normalizedFeatureId) + const featureTitle = (input.title ?? '').trim() || split.featureTitle || row.task_title.trim() || 'Untitled feature' + const defaultFolder = normalizedFeatureId ? normalizedFeatureId.toLowerCase() : slugify(featureTitle) + const folderRel = normalizeFeaturesFolderPath(input.folderName ?? undefined, defaultFolder) + const folderAbs = path.resolve(featuresRootAbs, folderRel) + assertInsidePath(featuresRootAbs, folderAbs) + + const featureFileAbs = path.join(folderAbs, FEATURE_DOC_FILENAME) + const featureFileRel = resolvePathInsideRepo(row.project_path, featureFileAbs) + if (!featureFileRel) { + throw new Error('Generated feature path is outside repository path') + } + + const existingLinkForPath = db.prepare(` + SELECT task_id + FROM project_feature_task_links + WHERE project_id = ? AND feature_file_path = ? + LIMIT 1 + `).get(row.project_id, featureFileRel) as { task_id: string } | undefined + if (existingLinkForPath && existingLinkForPath.task_id !== row.task_id) { + throw new Error('A task is already linked to this feature file path') + } + + fs.mkdirSync(folderAbs, { recursive: true }) + fs.mkdirSync(path.join(folderAbs, 'acceptance'), { recursive: true }) + const existingContent = fs.existsSync(featureFileAbs) ? fs.readFileSync(featureFileAbs, 'utf8') : null + const normalizedDescription = normalizeDescriptionForYaml(input.description ?? row.task_description) + const nextContent = existingContent ?? buildInitialFeatureMarkdownContent(featureTitle, normalizedDescription) + if (existingContent === null) { + fs.writeFileSync(featureFileAbs, nextContent, 'utf8') + } + + const nextTaskTitle = buildTaskTitleFromFeature(normalizedFeatureId, featureTitle) + if (nextTaskTitle !== row.task_title) { + db.prepare(` + UPDATE tasks + SET title = ?, updated_at = datetime('now') + WHERE id = ? + `).run(nextTaskTitle, row.task_id) + } + + const contentHash = buildContentHash(nextContent) + db.prepare(` + INSERT INTO project_feature_task_links ( + id, project_id, task_id, feature_id, feature_title, feature_file_path, content_hash, + last_sync_source, last_sync_at, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, 'task', datetime('now'), datetime('now'), datetime('now')) + `).run( + crypto.randomUUID(), + row.project_id, + row.task_id, + normalizedFeatureId, + featureTitle, + featureFileRel, + contentHash + ) + + return { created: true, featureFilePath: featureFileRel } +} + +export function deleteFeatureForTask( + db: Database, + taskId: string +): { deleted: boolean } { + const row = db.prepare(` + SELECT + l.id AS link_id, + l.feature_file_path, + p.path AS project_path + FROM project_feature_task_links l + JOIN projects p ON p.id = l.project_id + WHERE l.task_id = ? + LIMIT 1 + `).get(taskId) as LinkedFeaturePathRow | undefined + + if (!row) return { deleted: false } + + if (row.project_path) { + const repoRoot = path.resolve(row.project_path) + const featureFileAbs = path.resolve(row.project_path, row.feature_file_path) + const featureDirAbs = path.dirname(featureFileAbs) + const relativeToRepo = path.relative(repoRoot, featureDirAbs) + if ( + relativeToRepo === '' + || relativeToRepo.startsWith('..') + || path.isAbsolute(relativeToRepo) + ) { + throw new Error('Refusing to delete feature directory outside repository path') + } + fs.rmSync(featureDirAbs, { recursive: true, force: true }) + } + + db.prepare('DELETE FROM project_feature_task_links WHERE id = ?').run(row.link_id) + return { deleted: true } +} + +export function getRepoFeatureSyncConfig(db: Database): RepoFeatureSyncConfig { + return { + defaultFeaturesPath: getSetting(db, SETTINGS.defaultFeaturesPath, DEFAULT_FEATURES_PATH), + pollIntervalSeconds: normalizePollIntervalSeconds( + getSetting( + db, + SETTINGS.pollIntervalSeconds, + String(DEFAULT_FEATURE_SYNC_POLL_INTERVAL_SECONDS) + ) + ) + } +} diff --git a/packages/domains/projects/src/shared/types.ts b/packages/domains/projects/src/shared/types.ts index a1201c78..f049b049 100644 --- a/packages/domains/projects/src/shared/types.ts +++ b/packages/domains/projects/src/shared/types.ts @@ -11,12 +11,16 @@ export { type WorkflowCategory, type ColumnConfig } from '@slayzone/workflow' +export type ProjectTaskBackend = 'db' export interface Project { id: string name: string color: string path: string | null + task_backend: ProjectTaskBackend + feature_repo_integration_enabled: number + feature_repo_features_path: string auto_create_worktree_on_task_create: number | null worktree_source_branch: string | null columns_config: ColumnConfig[] | null @@ -30,6 +34,9 @@ export interface CreateProjectInput { color: string path?: string columnsConfig?: ColumnConfig[] + taskBackend?: ProjectTaskBackend + featureRepoIntegrationEnabled?: boolean + featureRepoFeaturesPath?: string } export interface UpdateProjectInput { @@ -37,8 +44,69 @@ export interface UpdateProjectInput { name?: string color?: string path?: string | null + taskBackend?: ProjectTaskBackend + featureRepoIntegrationEnabled?: boolean + featureRepoFeaturesPath?: string autoCreateWorktreeOnTaskCreate?: boolean | null worktreeSourceBranch?: string | null columnsConfig?: ColumnConfig[] | null executionContext?: ExecutionContext | null } + +export interface ProjectFeatureSyncResult { + scanned: number + created: number + updated: number + skipped: number + errors: string[] +} + +export interface ProjectFeatureSyncAggregateResult { + projects: number + scanned: number + created: number + updated: number + skipped: number + errors: string[] +} + +export interface RepoFeatureSyncConfig { + defaultFeaturesPath: string + pollIntervalSeconds: number +} + +export type FeatureSyncSource = 'repo' | 'task' + +export interface FeatureAcceptanceItem { + id: string + scenario: string + file: string | null + resolvedFilePath: string | null +} + +export interface FeatureAcceptanceInput { + id: string + scenario: string + file?: string | null +} + +export interface UpdateTaskFeatureInput { + featureId?: string | null + title: string + description?: string | null + acceptance: FeatureAcceptanceInput[] +} + +export interface TaskFeatureDetails { + projectId: string + taskId: string + featureId: string | null + title: string + description: string | null + featureFilePath: string + featureDirPath: string + featureDirAbsolutePath: string | null + acceptance: FeatureAcceptanceItem[] + lastSyncAt: string + lastSyncSource: FeatureSyncSource +} diff --git a/packages/domains/settings/src/client/UserSettingsDialog.tsx b/packages/domains/settings/src/client/UserSettingsDialog.tsx index bec71d9a..c76019bb 100644 --- a/packages/domains/settings/src/client/UserSettingsDialog.tsx +++ b/packages/domains/settings/src/client/UserSettingsDialog.tsx @@ -1,6 +1,7 @@ import { useState, useEffect, useRef } from 'react' import { useTheme } from './ThemeContext' -import { XIcon, Trash2, Plus, ChevronLeft, ChevronRight, SquareTerminal, Globe, FileCode, GitCompare, SlidersHorizontal, FolderOpen, RefreshCw } from 'lucide-react' +import { XIcon, Trash2, Plus, SquareTerminal, Globe, FileCode, GitCompare, SlidersHorizontal, FolderOpen, FileText } from 'lucide-react' +import { ChevronLeft, ChevronRight, RefreshCw } from 'lucide-react' import { SettingsLayout, Tooltip, TooltipTrigger, TooltipContent } from '@slayzone/ui' import { Button, IconButton } from '@slayzone/ui' import { Input } from '@slayzone/ui' @@ -43,6 +44,19 @@ function SettingsTabIntro({ title, description }: { title: string; description: ) } +interface RepoFeatureProject { + id: string + name: string + path: string | null + feature_repo_integration_enabled: number + feature_repo_features_path: string +} + +interface RepoFeatureSyncConfig { + defaultFeaturesPath: string + pollIntervalSeconds: number +} + function PanelBreadcrumb({ label, onBack }: { label: string; onBack: () => void }) { return ( + + +
    + Default FEATURE.md folder + + setRepoFeatureConfig((prev) => ({ ...prev, defaultFeaturesPath: e.target.value })) + } + onBlur={() => { + void handleRepoFeatureConfigChange({ + defaultFeaturesPath: repoFeatureConfig.defaultFeaturesPath + }) + }} + placeholder="docs/features" + /> +
    + +
    + Polling interval (seconds) + + setRepoFeatureConfig((prev) => ({ + ...prev, + pollIntervalSeconds: Number.parseInt(e.target.value || '30', 10) || 30 + })) + } + onBlur={() => { + void handleRepoFeatureConfigChange({ + pollIntervalSeconds: repoFeatureConfig.pollIntervalSeconds + }) + }} + placeholder="30" + /> +
    + +
    + {repoFeatureProjects.length === 0 ? ( +

    No projects found.

    + ) : ( + repoFeatureProjects.map((project) => { + const enabled = project.feature_repo_integration_enabled === 1 + const isSaving = repoFeatureSavingProjectId === project.id + return ( +
    +
    +
    +

    {project.name}

    +

    + {project.path || 'Repository path not set'} +

    +
    + + void handleToggleProjectRepoFeatures(project, checked === true) + } + /> +
    +
    + { + const value = e.target.value + setRepoFeatureProjects((prev) => + prev.map((item) => + item.id === project.id + ? { ...item, feature_repo_features_path: value } + : item + ) + ) + }} + onBlur={(e) => void handleProjectFeaturePathSave(project, e.target.value)} + disabled={!enabled || isSaving} + placeholder={repoFeatureConfig.defaultFeaturesPath} + /> + + +
    +
    + ) + }) + )} +
    + + {repoFeaturesMessage ? ( +

    {repoFeaturesMessage}

    + ) : null} + )} diff --git a/packages/domains/tags/src/main/handlers.test.ts b/packages/domains/tags/src/main/handlers.test.ts index 20f7edcd..6eb53aa5 100644 --- a/packages/domains/tags/src/main/handlers.test.ts +++ b/packages/domains/tags/src/main/handlers.test.ts @@ -83,6 +83,7 @@ describe('db:taskTags:setForTask', () => { const result = h.invoke('db:taskTags:getForTask', taskId) as unknown[] expect(result).toHaveLength(0) }) + }) h.cleanup() diff --git a/packages/domains/tags/src/main/handlers.ts b/packages/domains/tags/src/main/handlers.ts index 0aa96d83..60d06e26 100644 --- a/packages/domains/tags/src/main/handlers.ts +++ b/packages/domains/tags/src/main/handlers.ts @@ -3,6 +3,10 @@ import type { Database } from 'better-sqlite3' import type { CreateTagInput, UpdateTagInput } from '@slayzone/tags/shared' export function registerTagHandlers(ipcMain: IpcMain, db: Database): void { + const taskExistsInDb = (taskId: string): boolean => { + const row = db.prepare('SELECT 1 FROM tasks WHERE id = ?').get(taskId) as { 1: number } | undefined + return Boolean(row) + } // Tags CRUD ipcMain.handle('db:tags:getAll', () => { @@ -47,6 +51,7 @@ export function registerTagHandlers(ipcMain: IpcMain, db: Database): void { // Task-Tag associations ipcMain.handle('db:taskTags:getForTask', (_, taskId: string) => { + if (!taskExistsInDb(taskId)) return [] return db .prepare( `SELECT tags.* FROM tags @@ -67,6 +72,9 @@ export function registerTagHandlers(ipcMain: IpcMain, db: Database): void { }) ipcMain.handle('db:taskTags:setForTask', (_, taskId: string, tagIds: string[]) => { + // Ignore invalid or deleted task ids. + if (!taskExistsInDb(taskId)) return + const deleteStmt = db.prepare('DELETE FROM task_tags WHERE task_id = ?') const insertStmt = db.prepare('INSERT INTO task_tags (task_id, tag_id) VALUES (?, ?)') diff --git a/packages/domains/task-terminals/src/main/handlers.test.ts b/packages/domains/task-terminals/src/main/handlers.test.ts index 2eb31491..0103941e 100644 --- a/packages/domains/task-terminals/src/main/handlers.test.ts +++ b/packages/domains/task-terminals/src/main/handlers.test.ts @@ -33,6 +33,7 @@ describe('tabs:ensureMain', () => { const tab = h.invoke('tabs:ensureMain', taskId, 'codex') as { mode: string } expect(tab.mode).toBe('codex') }) + }) describe('tabs:list', () => { diff --git a/packages/domains/task/DOMAIN.md b/packages/domains/task/DOMAIN.md index c43f522d..37c9d127 100644 --- a/packages/domains/task/DOMAIN.md +++ b/packages/domains/task/DOMAIN.md @@ -1,6 +1,6 @@ # Task Domain -Task CRUD operations, detail view, and AI-powered description generation. +Task CRUD operations and detail view. ## Contracts (shared/) @@ -24,7 +24,6 @@ Also exports validation schemas (`createTaskSchema`, `updateTaskSchema`) and for ## Main Process (main/) - `registerTaskHandlers(ipcMain, db)` - Task CRUD, archive, reorder -- `registerAiHandlers(ipcMain)` - AI description generation - `registerFilesHandlers(ipcMain)` - Temp image saving for AI ## Client (client/) diff --git a/packages/domains/task/src/client/CreateTaskDialog.tsx b/packages/domains/task/src/client/CreateTaskDialog.tsx index efe3a1bb..3a109b0e 100644 --- a/packages/domains/task/src/client/CreateTaskDialog.tsx +++ b/packages/domains/task/src/client/CreateTaskDialog.tsx @@ -322,7 +322,10 @@ export function CreateTaskDialog({ -