feat: enhance task management and team member handling

- Added a new "Solo mode" feature in the README, allowing a single agent to manage tasks independently, improving token efficiency.
- Updated TeamMemberLogsFinder to include team name in task ID checks, preventing cross-team task collisions.
- Enhanced TeamProvisioningService with clearer task status protocols and execution discipline guidelines to improve task management practices.
- Introduced new callbacks in ChangeReviewDialog for accepting and rejecting new files, streamlining the review process.
- Updated changeReviewSlice to handle file rejection logic, ensuring accurate state management during reviews.

Made-with: Cursor
This commit is contained in:
iliya 2026-03-04 00:57:42 +02:00
parent 1d07d6bb96
commit dae8c50e4c
6 changed files with 293 additions and 20 deletions

View file

@ -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

View file

@ -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<boolean> {
private async fileMentionsTaskId(
filePath: string,
teamName: string,
taskId: string
): Promise<boolean> {
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();

View file

@ -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 <taskId>
- 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 <taskId>
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 <taskId> --comment "<what to fix>"
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 <taskId> --text "<your reply>" --from "<your-name>"
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 "<actual-member-name>" --notify --from "${leadName}"`,
`- Assign/reassign owner: node "$HOME/.claude/tools/teamctl.js" --team "${teamName}" task set-owner <id> <member-name> --notify --from "${leadName}"`,
`- Clear owner: node "$HOME/.claude/tools/teamctl.js" --team "${teamName}" task set-owner <id> clear`,
`- Start task (preferred over set-status): node "$HOME/.claude/tools/teamctl.js" --team "${teamName}" task start <id>`,
`- Complete task (preferred over set-status): node "$HOME/.claude/tools/teamctl.js" --team "${teamName}" task complete <id>`,
`- Update status: node "$HOME/.claude/tools/teamctl.js" --team "${teamName}" task set-status <id> <pending|in_progress|completed|deleted>`,
`- 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 "<member>" --notify --from "${leadName}"`,
`- Link dependency: node "$HOME/.claude/tools/teamctl.js" --team "${teamName}" task link <id> --blocked-by <targetId>`,
@ -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 #<id> (what you're doing + next step).
- While working: after each meaningful milestone/decision/blocker, add a task comment on #<id>. 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 #<id> 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<string, string>();

View file

@ -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}

View file

@ -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<void>;
) => Promise<ApplyReviewResult | null>;
/** 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<AppState, [], [], ChangeRevie
innerBaseCount
)
: undefined;
await api.review.applyDecisions({
const result = await api.review.applyDecisions({
teamName,
taskId,
memberName,
@ -771,12 +774,73 @@ export const createChangeReviewSlice: StateCreator<AppState, [], [], ChangeRevie
},
],
});
return result;
} catch (error) {
logger.error('applySingleFileDecision error:', error);
set({ applyError: mapReviewError(error) });
return null;
}
},
removeReviewFile: (filePath: string) => {
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) => {

View file

@ -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);
});
});