From 0edd7235cd861ef77d4ceb37a594ae65df52624b Mon Sep 17 00:00:00 2001 From: Clawd Date: Sat, 28 Feb 2026 15:35:00 -0800 Subject: Add session-specific artifacts, CLAUDE.md, and git worktree support - Store artifacts in .claude-flow/sessions/{sessionId}/ - Each session now has isolated research.md and plan.md - Concurrent sessions no longer conflict - Add CLAUDE.md support for shared codebase documentation - Add git worktree creation on session start - Add git commit/status IPC handlers - Update all artifact APIs to be session-specific - Remove artifact clearing on new session (no longer needed) --- src/main/claude/index.ts | 91 +++++++++++++++++++---- src/main/claude/phases.ts | 62 +++++++++++----- src/main/git/worktree.ts | 179 ++++++++++++++++++++++++++++++++++++++++++++++ src/main/ipc/handlers.ts | 86 ++++++++++++++++++++-- src/main/preload.ts | 48 ++++++++++++- 5 files changed, 425 insertions(+), 41 deletions(-) create mode 100644 src/main/git/worktree.ts (limited to 'src/main') diff --git a/src/main/claude/index.ts b/src/main/claude/index.ts index 8bdcccd..4d8909b 100644 --- a/src/main/claude/index.ts +++ b/src/main/claude/index.ts @@ -1,6 +1,6 @@ import { query, type SDKMessage, type Query } from "@anthropic-ai/claude-agent-sdk"; import type { Session } from "../db/sessions"; -import { getPhaseConfig, getNextPhase, getArtifactFilename } from "./phases"; +import { getPhaseConfig, getNextPhase, getArtifactFilename, getSessionArtifactDir } from "./phases"; import type { Phase, UserPermissionMode } from "./phases"; import { getProject } from "../db/projects"; import { updateSession } from "../db/sessions"; @@ -10,10 +10,9 @@ import path from "node:path"; // Track active queries by session ID const activeQueries = new Map(); -function ensureArtifactDir(projectPath: string): void { - const dir = path.join(projectPath, ".claude-flow"); - if (!fs.existsSync(dir)) { - fs.mkdirSync(dir, { recursive: true }); +function ensureDir(dirPath: string): void { + if (!fs.existsSync(dirPath)) { + fs.mkdirSync(dirPath, { recursive: true }); } } @@ -31,7 +30,9 @@ export async function sendMessage({ const project = getProject(session.project_id); if (!project) throw new Error("Project not found"); - ensureArtifactDir(project.path); + // Ensure session artifact directory exists + const sessionDir = path.join(project.path, getSessionArtifactDir(session.id)); + ensureDir(sessionDir); const phaseConfig = getPhaseConfig( session.phase as Phase, @@ -81,8 +82,8 @@ export async function triggerReview( session: Session, onMessage: (msg: SDKMessage) => void ): Promise { - const docName = getArtifactFilename(session.phase as Phase); - const message = `I've updated .claude-flow/${docName} with annotations. Read the file, find all my inline notes (marked with // REVIEW:, // NOTE:, TODO:, or similar), address each one, and update the document accordingly. Do not implement anything yet.`; + const artifactPath = getArtifactPath(session); + const message = `I've updated ${artifactPath} with annotations. Read the file, find all my inline notes (marked with // REVIEW:, // NOTE:, TODO:, or similar), address each one, and update the document accordingly. Do not implement anything yet.`; await sendMessage({ session, message, onMessage }); } @@ -99,7 +100,63 @@ export function advancePhase(session: Session): Phase | null { } /** - * Read an artifact file from the project's .claude-flow directory + * Get the artifact path for a session and phase + */ +export function getArtifactPath(session: Session): string { + const filename = getArtifactFilename(session.phase as Phase); + return path.join(getSessionArtifactDir(session.id), filename); +} + +/** + * Read an artifact file for a session + */ +export function readSessionArtifact( + projectPath: string, + sessionId: string, + filename: string +): string | null { + const filePath = path.join(projectPath, getSessionArtifactDir(sessionId), filename); + if (fs.existsSync(filePath)) { + return fs.readFileSync(filePath, "utf-8"); + } + return null; +} + +/** + * Write an artifact file for a session + */ +export function writeSessionArtifact( + projectPath: string, + sessionId: string, + filename: string, + content: string +): void { + const dir = path.join(projectPath, getSessionArtifactDir(sessionId)); + ensureDir(dir); + fs.writeFileSync(path.join(dir, filename), content, "utf-8"); +} + +/** + * Read CLAUDE.md from project root + */ +export function readClaudeMd(projectPath: string): string | null { + const filePath = path.join(projectPath, "CLAUDE.md"); + if (fs.existsSync(filePath)) { + return fs.readFileSync(filePath, "utf-8"); + } + return null; +} + +/** + * Write CLAUDE.md to project root + */ +export function writeClaudeMd(projectPath: string, content: string): void { + const filePath = path.join(projectPath, "CLAUDE.md"); + fs.writeFileSync(filePath, content, "utf-8"); +} + +/** + * Read an artifact file from the project's .claude-flow directory (legacy path) */ export function readArtifact( projectPath: string, @@ -113,7 +170,7 @@ export function readArtifact( } /** - * Write an artifact file to the project's .claude-flow directory + * Write an artifact file to the project's .claude-flow directory (legacy path) */ export function writeArtifact( projectPath: string, @@ -121,12 +178,20 @@ export function writeArtifact( content: string ): void { const dir = path.join(projectPath, ".claude-flow"); - if (!fs.existsSync(dir)) { - fs.mkdirSync(dir, { recursive: true }); - } + ensureDir(dir); fs.writeFileSync(path.join(dir, filename), content, "utf-8"); } +/** + * Clear session artifacts + */ +export function clearSessionArtifacts(projectPath: string, sessionId: string): void { + const dir = path.join(projectPath, getSessionArtifactDir(sessionId)); + if (fs.existsSync(dir)) { + fs.rmSync(dir, { recursive: true, force: true }); + } +} + /** * Get the initial message for a phase */ diff --git a/src/main/claude/phases.ts b/src/main/claude/phases.ts index 2ea1ea8..6992047 100644 --- a/src/main/claude/phases.ts +++ b/src/main/claude/phases.ts @@ -10,6 +10,17 @@ export interface PhaseConfig { initialMessage: string; } +// Get session-specific artifact path +export function getSessionArtifactDir(sessionId: string): string { + return `.claude-flow/sessions/${sessionId}`; +} + +export function getArtifactPath(phase: Phase, sessionId: string): string { + const dir = getSessionArtifactDir(sessionId); + const filename = phase === "research" ? "research.md" : "plan.md"; + return `${dir}/${filename}`; +} + export const phaseConfigs: Record = { research: { permissionMode: "acceptEdits", @@ -19,18 +30,24 @@ export const phaseConfigs: Record = { systemPrompt: `You are in RESEARCH mode. Your ONLY job is to understand the codebase. CRITICAL RULES: -1. You MUST write ALL findings to .claude-flow/research.md — this is your PRIMARY output +1. You MUST write ALL findings to the session research.md — this is your PRIMARY output 2. DO NOT just respond in chat. The document viewer shows research.md, so write there. 3. DO NOT suggest moving to planning or implementation 4. DO NOT ask "are you ready to implement?" or similar 5. DO NOT modify any source code files 6. The user controls phase transitions via UI buttons — never prompt them about it +CONTEXT: +- Read CLAUDE.md in the project root (if it exists) for codebase overview +- This file contains general architecture info shared across all sessions +- If CLAUDE.md doesn't exist, create it with your initial findings + WORKFLOW: -1. Ask what to research (if unclear) -2. Read files thoroughly using Read, Glob, Grep -3. Write structured findings to .claude-flow/research.md -4. Keep updating research.md as you learn more +1. Read CLAUDE.md (create if missing) +2. Ask what to research (if unclear) +3. Read files thoroughly using Read, Glob, Grep +4. Write findings to session research.md +5. Update CLAUDE.md with any new general insights FORMAT for research.md: \`\`\`markdown @@ -52,8 +69,6 @@ FORMAT for research.md: [Things that need clarification] \`\`\` -When the user adds annotations (// REVIEW:, // NOTE:, TODO:) to research.md and clicks Review, address each annotation and update the document. - Remember: Your output goes in research.md, not chat. Chat is for clarifying questions only.`, }, @@ -65,18 +80,19 @@ Remember: Your output goes in research.md, not chat. Chat is for clarifying ques systemPrompt: `You are in PLANNING mode. Your ONLY job is to create an implementation plan. CRITICAL RULES: -1. You MUST write the plan to .claude-flow/plan.md — this is your PRIMARY output +1. You MUST write the plan to session plan.md — this is your PRIMARY output 2. DO NOT just respond in chat. The document viewer shows plan.md, so write there. 3. DO NOT implement anything — no code changes to source files 4. DO NOT ask "should I start implementing?" or similar 5. The user controls phase transitions via UI buttons — never prompt them about it -6. Base your plan on .claude-flow/research.md +6. Base your plan on the session research.md and CLAUDE.md WORKFLOW: -1. Read .claude-flow/research.md to understand the codebase -2. Write a detailed plan to .claude-flow/plan.md -3. Include specific code snippets showing proposed changes -4. Make the plan detailed enough that implementation is mechanical +1. Read CLAUDE.md for codebase overview +2. Read the session research.md to understand the specific task +3. Write a detailed plan to session plan.md +4. Include specific code snippets showing proposed changes +5. Make the plan detailed enough that implementation is mechanical FORMAT for plan.md: \`\`\`markdown @@ -119,23 +135,31 @@ Remember: Your output goes in plan.md, not chat. Chat is for clarifying question permissionMode: "acceptEdits", tools: ["Read", "Write", "Edit", "Bash", "Glob", "Grep"], initialMessage: - "Starting implementation. I'll follow the plan exactly and mark tasks complete as I go.", + "Starting implementation. I'll follow the plan exactly, commit as I go, and mark tasks complete.", systemPrompt: `You are in IMPLEMENTATION mode. Execute the approved plan. CRITICAL RULES: -1. Read .claude-flow/plan.md and follow it exactly +1. Read session plan.md and follow it exactly 2. Mark tasks complete in plan.md as you finish them: - [ ] → - [x] 3. DO NOT deviate from the plan without asking 4. Run tests/typecheck if available -5. Stop and ask if you encounter issues not covered by the plan +5. Make git commits as you complete logical chunks of work +6. Stop and ask if you encounter issues not covered by the plan WORKFLOW: -1. Read .claude-flow/plan.md +1. Read session plan.md 2. Execute each task in order 3. Update plan.md to mark tasks complete -4. Continue until all tasks are done +4. Make git commits with clear messages as you finish chunks +5. Continue until all tasks are done + +COMMIT GUIDELINES: +- Commit after completing logical units of work +- Use clear commit messages (e.g., "Add user authentication middleware") +- Don't commit broken or incomplete code +- Update CLAUDE.md if you discover important architecture info -If something in the plan is unclear or problematic, ask before proceeding.`, +When complete, summarize what was done and any follow-up tasks.`, }, }; diff --git a/src/main/git/worktree.ts b/src/main/git/worktree.ts new file mode 100644 index 0000000..3264e5e --- /dev/null +++ b/src/main/git/worktree.ts @@ -0,0 +1,179 @@ +import { execSync } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; + +export interface GitWorktreeInfo { + path: string; + branch: string; + commit?: string; +} + +/** + * Check if a directory is a git repository + */ +export function isGitRepo(dir: string): boolean { + try { + execSync("git rev-parse --git-dir", { cwd: dir, stdio: "pipe" }); + return true; + } catch { + return false; + } +} + +/** + * Get the default branch name (main or master) + */ +export function getDefaultBranch(dir: string): string { + try { + const branches = execSync("git branch -rl '*/HEAD'", { cwd: dir, stdio: "pipe", encoding: "utf-8" }); + const match = branches.match(/origin\/(main|master)/); + return match?.[1] || "main"; + } catch { + return "main"; + } +} + +/** + * Create a new worktree for a session + * Returns the worktree path + */ +export function createWorktree(projectPath: string, sessionId: string, baseBranch?: string): string { + if (!isGitRepo(projectPath)) { + throw new Error("Not a git repository"); + } + + const branchName = `claude-flow/${sessionId}`; + const worktreePath = path.join(projectPath, ".claude-flow", "worktrees", sessionId); + + // Ensure parent directory exists + fs.mkdirSync(path.dirname(worktreePath), { recursive: true }); + + // Create branch from base (or current HEAD) + const base = baseBranch || getDefaultBranch(projectPath); + + try { + // Create worktree with new branch + execSync( + `git worktree add -b "${branchName}" "${worktreePath}" ${base}`, + { cwd: projectPath, stdio: "pipe" } + ); + } catch (error) { + // If branch already exists, just add worktree pointing to it + try { + execSync( + `git worktree add "${worktreePath}" "${branchName}"`, + { cwd: projectPath, stdio: "pipe" } + ); + } catch { + throw new Error(`Failed to create worktree: ${error}`); + } + } + + return worktreePath; +} + +/** + * Remove a worktree + */ +export function removeWorktree(projectPath: string, sessionId: string): void { + const worktreePath = path.join(projectPath, ".claude-flow", "worktrees", sessionId); + const branchName = `claude-flow/${sessionId}`; + + try { + // Remove worktree + execSync(`git worktree remove "${worktreePath}" --force`, { cwd: projectPath, stdio: "pipe" }); + } catch { + // Worktree might not exist, that's ok + } + + try { + // Delete branch + execSync(`git branch -D "${branchName}"`, { cwd: projectPath, stdio: "pipe" }); + } catch { + // Branch might not exist, that's ok + } +} + +/** + * Get worktree info for a session + */ +export function getWorktreeInfo(projectPath: string, sessionId: string): GitWorktreeInfo | null { + const worktreePath = path.join(projectPath, ".claude-flow", "worktrees", sessionId); + + if (!fs.existsSync(worktreePath)) { + return null; + } + + try { + const branch = execSync("git rev-parse --abbrev-ref HEAD", { + cwd: worktreePath, + encoding: "utf-8" + }).trim(); + + const commit = execSync("git rev-parse --short HEAD", { + cwd: worktreePath, + encoding: "utf-8" + }).trim(); + + return { path: worktreePath, branch, commit }; + } catch { + return null; + } +} + +/** + * Commit changes in a worktree + */ +export function commitChanges( + worktreePath: string, + message: string, + files: string[] = ["."] +): void { + try { + // Stage files + execSync(`git add ${files.join(" ")}`, { cwd: worktreePath, stdio: "pipe" }); + + // Commit + execSync(`git commit -m "${message.replace(/"/g, '\\"')}"`, { cwd: worktreePath, stdio: "pipe" }); + } catch (error) { + throw new Error(`Failed to commit: ${error}`); + } +} + +/** + * Check if there are uncommitted changes + */ +export function hasUncommittedChanges(worktreePath: string): boolean { + try { + const status = execSync("git status --porcelain", { cwd: worktreePath, encoding: "utf-8" }); + return status.trim().length > 0; + } catch { + return false; + } +} + +/** + * Get diff summary of uncommitted changes + */ +export function getDiffSummary(worktreePath: string): string { + try { + return execSync("git diff --stat", { cwd: worktreePath, encoding: "utf-8" }); + } catch { + return ""; + } +} + +/** + * Get the main project path from a worktree path + */ +export function getMainRepoPath(worktreePath: string): string { + try { + const gitDir = execSync("git rev-parse --git-common-dir", { + cwd: worktreePath, + encoding: "utf-8" + }).trim(); + return path.dirname(gitDir); + } catch { + return worktreePath; + } +} diff --git a/src/main/ipc/handlers.ts b/src/main/ipc/handlers.ts index ce95f4c..bc0d024 100644 --- a/src/main/ipc/handlers.ts +++ b/src/main/ipc/handlers.ts @@ -3,6 +3,7 @@ import * as projects from "../db/projects"; import * as sessions from "../db/sessions"; import * as claude from "../claude"; import type { UserPermissionMode } from "../claude/phases"; +import * as git from "../git/worktree"; export function registerIpcHandlers(mainWindow: BrowserWindow): void { // Projects @@ -18,12 +19,42 @@ export function registerIpcHandlers(mainWindow: BrowserWindow): void { ipcMain.handle("sessions:list", (_, projectId: string) => sessions.listSessions(projectId) ); - ipcMain.handle("sessions:create", (_, projectId: string, name: string) => - sessions.createSession(projectId, name) - ); - ipcMain.handle("sessions:delete", (_, id: string) => - sessions.deleteSession(id) - ); + + ipcMain.handle("sessions:create", async (_, projectId: string, name: string) => { + const session = sessions.createSession(projectId, name); + const project = projects.getProject(projectId); + + if (project && git.isGitRepo(project.path)) { + try { + // Create git worktree for this session + git.createWorktree(project.path, session.id); + } catch (error) { + console.error("Failed to create worktree:", error); + // Continue without worktree - not fatal + } + } + + return session; + }); + + ipcMain.handle("sessions:delete", (_, id: string) => { + const session = sessions.getSession(id); + if (session) { + const project = projects.getProject(session.project_id); + if (project) { + // Clean up worktree if exists + try { + git.removeWorktree(project.path, id); + } catch { + // Worktree might not exist, that's ok + } + // Clean up session artifacts + claude.clearSessionArtifacts(project.path, id); + } + } + sessions.deleteSession(id); + }); + ipcMain.handle("sessions:get", (_, id: string) => sessions.getSession(id)); // Messages @@ -92,7 +123,31 @@ export function registerIpcHandlers(mainWindow: BrowserWindow): void { } ); - // Artifacts + // Session Artifacts (new session-specific API) + ipcMain.handle( + "artifact:readSession", + (_, projectPath: string, sessionId: string, filename: string) => { + return claude.readSessionArtifact(projectPath, sessionId, filename); + } + ); + + ipcMain.handle( + "artifact:writeSession", + (_, projectPath: string, sessionId: string, filename: string, content: string) => { + claude.writeSessionArtifact(projectPath, sessionId, filename, content); + } + ); + + // CLAUDE.md + ipcMain.handle("claudemd:read", (_, projectPath: string) => { + return claude.readClaudeMd(projectPath); + }); + + ipcMain.handle("claudemd:write", (_, projectPath: string, content: string) => { + claude.writeClaudeMd(projectPath, content); + }); + + // Legacy artifact API (for backward compatibility) ipcMain.handle( "artifact:read", (_, projectPath: string, filename: string) => { @@ -107,6 +162,23 @@ export function registerIpcHandlers(mainWindow: BrowserWindow): void { } ); + // Git + ipcMain.handle("git:isRepo", (_, projectPath: string) => { + return git.isGitRepo(projectPath); + }); + + ipcMain.handle("git:worktreeInfo", (_, projectPath: string, sessionId: string) => { + return git.getWorktreeInfo(projectPath, sessionId); + }); + + ipcMain.handle("git:commit", (_, worktreePath: string, message: string, files?: string[]) => { + git.commitChanges(worktreePath, message, files); + }); + + ipcMain.handle("git:hasChanges", (_, worktreePath: string) => { + return git.hasUncommittedChanges(worktreePath); + }); + // Dialogs ipcMain.handle("dialog:selectDirectory", async () => { const result = await dialog.showOpenDialog(mainWindow, { diff --git a/src/main/preload.ts b/src/main/preload.ts index b3e3f8b..1747763 100644 --- a/src/main/preload.ts +++ b/src/main/preload.ts @@ -3,6 +3,7 @@ import type { SDKMessage } from "@anthropic-ai/claude-agent-sdk"; import type { Project } from "./db/projects"; import type { Session, Message } from "./db/sessions"; import type { Phase, UserPermissionMode } from "./claude/phases"; +import type { GitWorktreeInfo } from "./git/worktree"; export interface ClaudeFlowAPI { // Projects @@ -31,7 +32,24 @@ export interface ClaudeFlowAPI { mode: UserPermissionMode ) => Promise; - // Artifacts + // Session Artifacts (new session-specific) + readSessionArtifact: ( + projectPath: string, + sessionId: string, + filename: string + ) => Promise; + writeSessionArtifact: ( + projectPath: string, + sessionId: string, + filename: string, + content: string + ) => Promise; + + // CLAUDE.md + readClaudeMd: (projectPath: string) => Promise; + writeClaudeMd: (projectPath: string, content: string) => Promise; + + // Legacy Artifacts (backward compat) readArtifact: ( projectPath: string, filename: string @@ -42,6 +60,12 @@ export interface ClaudeFlowAPI { content: string ) => Promise; + // Git + isGitRepo: (projectPath: string) => Promise; + getWorktreeInfo: (projectPath: string, sessionId: string) => Promise; + commitChanges: (worktreePath: string, message: string, files?: string[]) => Promise; + hasUncommittedChanges: (worktreePath: string) => Promise; + // Events onClaudeMessage: ( callback: (sessionId: string, message: SDKMessage) => void @@ -80,12 +104,32 @@ const api: ClaudeFlowAPI = { setPermissionMode: (sessionId, mode) => ipcRenderer.invoke("workflow:setPermissionMode", sessionId, mode), - // Artifacts + // Session Artifacts + readSessionArtifact: (projectPath, sessionId, filename) => + ipcRenderer.invoke("artifact:readSession", projectPath, sessionId, filename), + writeSessionArtifact: (projectPath, sessionId, filename, content) => + ipcRenderer.invoke("artifact:writeSession", projectPath, sessionId, filename, content), + + // CLAUDE.md + readClaudeMd: (projectPath) => ipcRenderer.invoke("claudemd:read", projectPath), + writeClaudeMd: (projectPath, content) => + ipcRenderer.invoke("claudemd:write", projectPath, content), + + // Legacy Artifacts readArtifact: (projectPath, filename) => ipcRenderer.invoke("artifact:read", projectPath, filename), writeArtifact: (projectPath, filename, content) => ipcRenderer.invoke("artifact:write", projectPath, filename, content), + // Git + isGitRepo: (projectPath) => ipcRenderer.invoke("git:isRepo", projectPath), + getWorktreeInfo: (projectPath, sessionId) => + ipcRenderer.invoke("git:worktreeInfo", projectPath, sessionId), + commitChanges: (worktreePath, message, files) => + ipcRenderer.invoke("git:commit", worktreePath, message, files), + hasUncommittedChanges: (worktreePath) => + ipcRenderer.invoke("git:hasChanges", worktreePath), + // Events onClaudeMessage: (callback) => { const handler = ( -- cgit v1.2.3