diff --git a/README.md b/README.md index b8a73daf..4e1a084e 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,7 @@ A new approach to task management with AI agents. - **Recent tasks across projects** — browse the latest completed tasks from all your projects in one place - **Deep session analysis** — detailed breakdown of what happened in each Claude session: bash commands, reasoning, subprocesses - **Smart task-to-log matching** — automatically links Claude session logs to specific tasks based on status change timestamps, even when a task moves back and forth between states +- **Solo mode** — a one-member team: a single agent that creates its own tasks, leaves comments, and shows live progress on the kanban board — saves tokens compared to a full team and can be expanded to a full team at any time - **Zero-setup onboarding** — built-in Claude Code installation and authentication, ready to go out of the box - **Built-in code editor** — edit project files with Git support and other essential features without leaving the app - **Branch strategy control** — choose via prompt whether all agents work on a single branch or each gets its own git worktree diff --git a/src/main/services/team/TeamMemberLogsFinder.ts b/src/main/services/team/TeamMemberLogsFinder.ts index 7501eaa9..4ff3b723 100644 --- a/src/main/services/team/TeamMemberLogsFinder.ts +++ b/src/main/services/team/TeamMemberLogsFinder.ts @@ -129,7 +129,7 @@ export class TeamMemberLogsFinder { const leadJsonl = path.join(projectDir, `${config.leadSessionId}.jsonl`); try { await fs.access(leadJsonl); - if (await this.fileMentionsTaskId(leadJsonl, taskId)) { + if (await this.fileMentionsTaskId(leadJsonl, teamName, taskId)) { const leadSummary = await this.parseLeadSessionSummary( leadJsonl, projectId, @@ -155,7 +155,7 @@ export class TeamMemberLogsFinder { if (!file.startsWith('agent-') || !file.endsWith('.jsonl')) continue; if (file.startsWith('agent-acompact')) continue; const filePath = path.join(subagentsDir, file); - if (!(await this.fileMentionsTaskId(filePath, taskId))) continue; + if (!(await this.fileMentionsTaskId(filePath, teamName, taskId))) continue; const attribution = await this.attributeSubagent(filePath, knownMembers); if (!attribution) continue; const summary = await this.parseSubagentSummary( @@ -465,9 +465,23 @@ export class TeamMemberLogsFinder { return { ...discovery, isLeadMember }; } - private async fileMentionsTaskId(filePath: string, taskId: string): Promise { + private async fileMentionsTaskId( + filePath: string, + teamName: string, + taskId: string + ): Promise { const escaped = taskId.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); const numericTaskId = /^\d+$/.test(taskId) ? taskId : null; + const teamEscaped = escapeRegex(teamName); + const teamPatterns: RegExp[] = [ + // Team tool inputs often include team_name + new RegExp(`"team_name"\\s*:\\s*"${teamEscaped}"`, 'i'), + // Some variants may use teamName or team + new RegExp(`"teamName"\\s*:\\s*"${teamEscaped}"`, 'i'), + new RegExp(`"team"\\s*:\\s*"${teamEscaped}"`, 'i'), + // CLI usage: node ".../teamctl.js" --team team-alpha task start 9 + new RegExp(`\\b--team\\b\\s*(?:=\\s*)?(?:"${teamEscaped}"|${teamEscaped})\\b`, 'i'), + ]; const patterns: RegExp[] = [ new RegExp(`"task_id"\\s*:\\s*"${escaped}"`, 'i'), new RegExp(`"taskId"\\s*:\\s*"${escaped}"`, 'i'), @@ -478,22 +492,22 @@ export class TeamMemberLogsFinder { new RegExp(`"taskId"\\s*:\\s*${numericTaskId}\\b`), // Support teamctl command lines (may appear in tool output). // Example: node ".../teamctl.js" --team "t" task start 10 - new RegExp( - `\\bteamctl(?:\\.js)?\\b.{0,250}\\b(?:task|review)\\b.{0,250}\\b${numericTaskId}\\b`, - 'i' - ) + new RegExp(`\\bteamctl(?:\\.js)?\\b.{0,350}\\b${numericTaskId}\\b`, 'i') ); } try { const stream = createReadStream(filePath, { encoding: 'utf8' }); const rl = readline.createInterface({ input: stream, crlfDelay: Infinity }); for await (const line of rl) { - for (const re of patterns) { - if (re.test(line)) { - rl.close(); - stream.destroy(); - return true; - } + // Require both taskId and teamName to avoid cross-team collisions when multiple + // teams share the same projectPath (task IDs are only unique per team). + const hasTaskId = patterns.some((re) => re.test(line)); + if (!hasTaskId) continue; + const hasTeam = teamPatterns.some((re) => re.test(line)); + if (hasTeam) { + rl.close(); + stream.destroy(); + return true; } } rl.close(); diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index 9b4063be..896e5aa9 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -396,6 +396,8 @@ function buildTaskStatusProtocol(teamName: string): string { return wrapInAgentBlock(`MANDATORY TASK STATUS PROTOCOL — you MUST follow this for EVERY task: 1. Use this command to mark task started: node "$HOME/.claude/tools/teamctl.js" --team "${teamName}" task start + - Start the task ONLY when you are actually beginning work on it. + - Do NOT start multiple tasks at once unless the team lead explicitly directs parallel work. 2. Use this command to mark task completed BEFORE sending your final reply: node "$HOME/.claude/tools/teamctl.js" --team "${teamName}" task complete 3. If you are asked to review and task is accepted, move it to APPROVED (not DONE): @@ -403,6 +405,7 @@ function buildTaskStatusProtocol(teamName: string): string { 4. If review fails and changes are needed: node "$HOME/.claude/tools/teamctl.js" --team "${teamName}" review request-changes --comment "" 5. NEVER skip status updates. A task is NOT done until completed status is written. + - Never "bulk-complete" a batch of tasks at the end. Update status incrementally as you work. 6. To reply to a comment on a task: node "$HOME/.claude/tools/teamctl.js" --team "${teamName}" task comment --text "" --from "" 7. When discussing a task with a teammate and you have important findings, decisions, blockers, or progress updates — record them as a task comment: @@ -451,6 +454,12 @@ function buildTeamCtlOpsInstructions(teamName: string, leadName: string): string `Internal task board tooling (teamctl.js):`, `- Use teamctl.js (via Bash) for tasks that must appear on the team board (assigned work, substantial work, or when the user explicitly asks to create a task).`, ``, + `Execution discipline (CRITICAL — prevents misleading task boards):`, + `- Start a task (move to in_progress) ONLY when you are actually beginning work on it.`, + `- Complete a task ONLY when it is truly finished (and any required verification is done).`, + `- Never bulk-move many tasks at the end of a session — update status incrementally as you work.`, + `- Record meaningful progress, decisions, and blockers as task comments so context is preserved on the board.`, + ``, `Parallelization guideline (IMPORTANT):`, `- If a task is genuinely parallelizable, split it into multiple smaller tasks owned by different members.`, ` - Prefer splitting by independent deliverables (e.g. frontend/backend, API/UI, parsing/rendering, tests/docs) rather than arbitrary slices.`, @@ -464,6 +473,8 @@ function buildTeamCtlOpsInstructions(teamName: string, leadName: string): string `- Create task: node "$HOME/.claude/tools/teamctl.js" --team "${teamName}" task create --subject "..." --description "..." --owner "" --notify --from "${leadName}"`, `- Assign/reassign owner: node "$HOME/.claude/tools/teamctl.js" --team "${teamName}" task set-owner --notify --from "${leadName}"`, `- Clear owner: node "$HOME/.claude/tools/teamctl.js" --team "${teamName}" task set-owner clear`, + `- Start task (preferred over set-status): node "$HOME/.claude/tools/teamctl.js" --team "${teamName}" task start `, + `- Complete task (preferred over set-status): node "$HOME/.claude/tools/teamctl.js" --team "${teamName}" task complete `, `- Update status: node "$HOME/.claude/tools/teamctl.js" --team "${teamName}" task set-status `, `- Create with deps (blocked work MUST be pending): node "$HOME/.claude/tools/teamctl.js" --team "${teamName}" task create --subject "..." --blocked-by 1,2 --related 3 --status pending --owner "" --notify --from "${leadName}"`, `- Link dependency: node "$HOME/.claude/tools/teamctl.js" --team "${teamName}" task link --blocked-by `, @@ -604,7 +615,15 @@ function buildProvisioningPrompt(request: TeamCreateRequest): string { `\n - ALLOWED: You may use the Task tool for regular subagents WITHOUT team_name — these are normal Claude Code helpers, not teammates.` + `\n - If teammates are added later (e.g. via UI), you may then spawn them using the Task tool with team_name + name.` + `\n - Work on tasks directly yourself. Use subagents for research and parallel work as needed.` + - `\n - IMPORTANT: Since you have no teammates, "user" is your only communication channel. Send progress updates to "user" frequently — after completing each task or significant milestone, and when starting a new task. The human cannot see your internal output, only SendMessage reaches them.` + `\n - PROGRESS REPORTING (MANDATORY): Since you have no teammates, "user" is your only communication channel.` + + `\n - SendMessage "user" at minimum: when you start a task (after marking it in_progress), when you complete a task, and when you hit a meaningful milestone/blocker/decision.` + + `\n - Avoid long silent stretches. If something is taking longer than expected, send a brief update and the next step.` + + `\n - TASK STATUS DISCIPLINE (MANDATORY):` + + `\n - Only move a task to in_progress when you are actively starting work on it.` + + `\n - Only move a task to completed when it is truly finished.` + + `\n - Never bulk-move many tasks at the end — update status incrementally as you work.` + + `\n - Default to working ONE task at a time (keep at most one task in_progress in solo mode), unless you explicitly need parallel background work (in that case explain why to "user").` + + `\n - Record meaningful progress/decisions as task comments so the task board stays accurate and high-signal.` : ''; const step3Block = isSolo @@ -723,14 +742,29 @@ function buildLaunchPrompt( `\n - ALLOWED: You may use the Task tool for regular subagents WITHOUT team_name — these are normal Claude Code helpers, not teammates.` + `\n - If teammates are added later (e.g. via UI), you may then spawn them using the Task tool with team_name + name.` + `\n - Work on tasks directly yourself. Use subagents for research and parallel work as needed.` + - `\n - IMPORTANT: Since you have no teammates, "user" is your only communication channel. Send progress updates to "user" frequently — after completing each task or significant milestone, and when starting a new task. The human cannot see your internal output, only SendMessage reaches them.` + `\n - PROGRESS REPORTING (MANDATORY): Since you have no teammates, "user" is your only communication channel.` + + `\n - SendMessage "user" at minimum: when you start a task (after marking it in_progress), when you complete a task, and when you hit a meaningful milestone/blocker/decision.` + + `\n - Avoid long silent stretches. If something is taking longer than expected, send a brief update and the next step.` + + `\n - TASK STATUS DISCIPLINE (MANDATORY):` + + `\n - Only move a task to in_progress when you are actively starting work on it.` + + `\n - Only move a task to completed when it is truly finished.` + + `\n - Never bulk-move many tasks at the end — update status incrementally as you work.` + + `\n - Default to working ONE task at a time (keep at most one task in_progress in solo mode), unless you explicitly need parallel background work (in that case explain why to "user").` + + `\n - Record meaningful progress/decisions as task comments so the task board stays accurate and high-signal.` : ''; let step2And3Block: string; if (isSolo) { step2And3Block = `2) Skip — solo team, no teammates to spawn. -3) Check the task board. Claim any unassigned pending tasks by assigning yourself ("${leadName}") as owner, then work on them directly. Mark tasks in_progress when you start and completed when done.`; +3) Execute tasks sequentially and keep the board + user updated: + - Identify the next READY task (pending, not blocked by incomplete dependencies). + - If the task is unassigned, set yourself ("${leadName}") as owner. + - BEFORE doing any work on a task: mark it started (in_progress). + - Immediately SendMessage "user" that you started task # (what you're doing + next step). + - While working: after each meaningful milestone/decision/blocker, add a task comment on #. If the milestone is user-relevant, also SendMessage "user". + - On completion: add a final task comment (what changed + how to verify), mark the task completed, then SendMessage "user" that task # is complete and what you will do next. + - Do NOT start the next task until the current task is completed (default: one task in_progress at a time).`; } else { // Build per-member task snapshots to include in each teammate's spawn prompt const memberTaskBlocks = new Map(); diff --git a/src/renderer/components/team/review/ChangeReviewDialog.tsx b/src/renderer/components/team/review/ChangeReviewDialog.tsx index 2e6ada0a..c94b27c6 100644 --- a/src/renderer/components/team/review/ChangeReviewDialog.tsx +++ b/src/renderer/components/team/review/ChangeReviewDialog.tsx @@ -76,6 +76,7 @@ export const ChangeReviewDialog = ({ rejectAllFile, applyReview, applySingleFileDecision, + removeReviewFile, editedContents, updateEditedContent, discardFileEdits, @@ -215,6 +216,48 @@ export const ChangeReviewDialog = ({ memberName, ]); + // Per-new-file accept/reject (Cursor-style) + const handleAcceptNewFile = useCallback( + (filePath: string) => { + acceptAllFile(filePath); + const view = editorViewMapRef.current.get(filePath); + if (view) { + requestAnimationFrame(() => acceptAllChunks(view)); + } + }, + [acceptAllFile] + ); + + const handleRejectNewFile = useCallback( + async (filePath: string) => { + // Mark rejected in store + update CM view immediately for feedback + rejectAllFile(filePath); + const view = editorViewMapRef.current.get(filePath); + if (view) { + requestAnimationFrame(() => rejectAllChunks(view)); + } + + // Always apply immediately: rejecting a NEW file means deleting it from disk. + const isNew = activeChangeSet?.files.find((f) => f.filePath === filePath)?.isNewFile ?? false; + if (!isNew) return; + + const result = await applySingleFileDecision(teamName, filePath, taskId, memberName); + const hasErrorForFile = !!result?.errors.some((e) => e.filePath === filePath); + if (result && !hasErrorForFile) { + removeReviewFile(filePath); + } + }, + [ + rejectAllFile, + activeChangeSet, + applySingleFileDecision, + teamName, + taskId, + memberName, + removeReviewFile, + ] + ); + // Per-file callbacks for ContinuousScrollView const handleHunkAccepted = useCallback( (filePath: string, hunkIndex: number) => { @@ -818,6 +861,8 @@ export const ChangeReviewDialog = ({ onContentChanged={handleContentChanged} onDiscard={handleDiscardFile} onSave={handleSaveFile} + onAcceptNewFile={handleAcceptNewFile} + onRejectNewFile={handleRejectNewFile} onRestoreMissingFile={handleRestoreMissingFile} onVisibleFileChange={handleVisibleFileChange} scrollContainerRef={scrollContainerRef} diff --git a/src/renderer/store/slices/changeReviewSlice.ts b/src/renderer/store/slices/changeReviewSlice.ts index c854f9e4..dbd4de05 100644 --- a/src/renderer/store/slices/changeReviewSlice.ts +++ b/src/renderer/store/slices/changeReviewSlice.ts @@ -17,6 +17,7 @@ import type { AppState } from '../types'; import type { AgentChangeSet, ApplyReviewRequest, + ApplyReviewResult, ChangeStats, FileChangeWithContent, FileReviewDecision, @@ -121,7 +122,9 @@ export interface ChangeReviewSlice { filePath: string, taskId?: string, memberName?: string - ) => Promise; + ) => Promise; + /** Remove a file from the current review set (used for rejecting new files) */ + removeReviewFile: (filePath: string) => void; invalidateChangeStats: (teamName: string) => void; // Editable diff actions @@ -754,7 +757,7 @@ export const createChangeReviewSlice: StateCreator { + set((s) => { + if (!s.activeChangeSet) return s; + const existing = s.activeChangeSet.files.find((f) => f.filePath === filePath); + if (!existing) return s; + + const nextFiles = s.activeChangeSet.files.filter((f) => f.filePath !== filePath); + const totalLinesAdded = nextFiles.reduce((sum, f) => sum + f.linesAdded, 0); + const totalLinesRemoved = nextFiles.reduce((sum, f) => sum + f.linesRemoved, 0); + + const nextHunkDecisions = { ...s.hunkDecisions }; + const prefix = `${filePath}:`; + for (const key of Object.keys(nextHunkDecisions)) { + if (key.startsWith(prefix)) delete nextHunkDecisions[key]; + } + + const nextFileDecisions = { ...s.fileDecisions }; + delete nextFileDecisions[filePath]; + + const nextFileChunkCounts = { ...s.fileChunkCounts }; + delete nextFileChunkCounts[filePath]; + + const nextFileContents = { ...s.fileContents }; + delete nextFileContents[filePath]; + + const nextFileContentsLoading = { ...s.fileContentsLoading }; + delete nextFileContentsLoading[filePath]; + + const nextEditedContents = { ...s.editedContents }; + delete nextEditedContents[filePath]; + + const nextHashes = { ...s.hunkContextHashesByFile }; + delete nextHashes[filePath]; + + const nextSelected = + s.selectedReviewFilePath === filePath + ? (nextFiles[0]?.filePath ?? null) + : s.selectedReviewFilePath; + + return { + activeChangeSet: { + ...s.activeChangeSet, + files: nextFiles, + totalFiles: nextFiles.length, + totalLinesAdded, + totalLinesRemoved, + }, + selectedReviewFilePath: nextSelected, + hunkDecisions: nextHunkDecisions, + fileDecisions: nextFileDecisions, + fileChunkCounts: nextFileChunkCounts, + fileContents: nextFileContents, + fileContentsLoading: nextFileContentsLoading, + editedContents: nextEditedContents, + hunkContextHashesByFile: nextHashes, + }; + }); + }, + // ── Editable diff actions ── updateEditedContent: (filePath: string, content: string) => { diff --git a/test/main/services/team/TeamMemberLogsFinder.test.ts b/test/main/services/team/TeamMemberLogsFinder.test.ts index 9ce05b56..15f259d8 100644 --- a/test/main/services/team/TeamMemberLogsFinder.test.ts +++ b/test/main/services/team/TeamMemberLogsFinder.test.ts @@ -304,7 +304,7 @@ describe('TeamMemberLogsFinder', () => { { type: 'tool_use', name: 'TaskUpdate', - input: { taskId: '1', status: 'in_progress' }, + input: { team_name: teamName, taskId: '1', status: 'in_progress' }, }, ], }, @@ -364,7 +364,11 @@ describe('TeamMemberLogsFinder', () => { message: { role: 'assistant', content: [ - { type: 'tool_use', name: 'TaskUpdate', input: { taskId: '10', status: 'pending' } }, + { + type: 'tool_use', + name: 'TaskUpdate', + input: { team_name: teamName, taskId: '10', status: 'pending' }, + }, ], }, }), @@ -473,4 +477,115 @@ describe('TeamMemberLogsFinder', () => { // We only want sessions that explicitly reference the task id. expect(logs).toHaveLength(0); }); + + it('findLogsForTask does not mix tasks across teams sharing a projectPath', async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'claude-team-task-cross-team-')); + setClaudeBasePathOverride(tmpDir); + + const projectPath = '/Users/test/shared-proj'; + const projectId = '-Users-test-shared-proj'; + const sessionId = 's-shared'; + + // Two teams pointing at the same project path (realistic when multiple teams work in one repo) + const teamA = 'team-a'; + const teamB = 'team-b'; + + await fs.mkdir(path.join(tmpDir, 'teams', teamA), { recursive: true }); + await fs.mkdir(path.join(tmpDir, 'teams', teamB), { recursive: true }); + + await fs.writeFile( + path.join(tmpDir, 'teams', teamA, 'config.json'), + JSON.stringify({ + name: teamA, + projectPath, + members: [ + { name: 'team-lead', agentType: 'team-lead' }, + { name: 'alice', agentType: 'general-purpose' }, + ], + }), + 'utf8' + ); + await fs.writeFile( + path.join(tmpDir, 'teams', teamB, 'config.json'), + JSON.stringify({ + name: teamB, + projectPath, + members: [ + { name: 'team-lead', agentType: 'team-lead' }, + { name: 'bob', agentType: 'general-purpose' }, + ], + }), + 'utf8' + ); + + const projectRoot = path.join(tmpDir, 'projects', projectId); + await fs.mkdir(path.join(projectRoot, sessionId, 'subagents'), { recursive: true }); + + // Team A subagent referencing taskId 9 for team-a + await fs.writeFile( + path.join(projectRoot, sessionId, 'subagents', 'agent-a1.jsonl'), + [ + JSON.stringify({ + timestamp: '2026-01-01T00:00:01.000Z', + type: 'user', + message: { + role: 'user', + content: 'You are alice, a developer on team "team-a" (team-a).', + }, + }), + JSON.stringify({ + timestamp: '2026-01-01T00:00:02.000Z', + type: 'assistant', + message: { + role: 'assistant', + content: [ + { + type: 'tool_use', + name: 'TaskUpdate', + input: { team_name: teamA, taskId: '9', status: 'in_progress' }, + }, + ], + }, + }), + ].join('\n') + '\n', + 'utf8' + ); + + // Team B subagent referencing taskId 9 for team-b (must NOT be included when querying team-a) + await fs.writeFile( + path.join(projectRoot, sessionId, 'subagents', 'agent-b1.jsonl'), + [ + JSON.stringify({ + timestamp: '2026-01-01T00:00:03.000Z', + type: 'user', + message: { role: 'user', content: 'You are bob, a developer on team "team-b" (team-b).' }, + }), + JSON.stringify({ + timestamp: '2026-01-01T00:00:04.000Z', + type: 'assistant', + message: { + role: 'assistant', + content: [ + { + type: 'tool_use', + name: 'TaskUpdate', + input: { team_name: teamB, taskId: '9', status: 'in_progress' }, + }, + ], + }, + }), + ].join('\n') + '\n', + 'utf8' + ); + + const finder = new TeamMemberLogsFinder(); + const logsForA = await finder.findLogsForTask(teamA, '9'); + + expect( + logsForA.some((l) => l.kind === 'subagent' && l.memberName?.toLowerCase() === 'alice') + ).toBe(true); + expect( + logsForA.some((l) => l.kind === 'subagent' && l.memberName?.toLowerCase() === 'bob') + ).toBe(false); + }); });