diff --git a/src/services/OrchestrationLogService.ts b/src/services/OrchestrationLogService.ts index 30f26e4..0c0f085 100644 --- a/src/services/OrchestrationLogService.ts +++ b/src/services/OrchestrationLogService.ts @@ -267,13 +267,25 @@ export class OrchestrationLogService { // Assign to first participant from the log entry const assignee = entry.participants[0] ?? 'unknown'; + // Check if any outcome mentions this issue with a completion signal + let isCompleted = false; + if (entry.outcomes) { + for (const outcome of entry.outcomes) { + if (outcome.includes(`#${taskId}`) && this.isCompletionSignal(outcome)) { + isCompleted = true; + break; + } + } + } + tasks.push({ id: taskId, title: `Issue #${taskId}`, description: entry.summary ?? undefined, - status: 'in_progress', + status: isCompleted ? 'completed' : 'in_progress', assignee, startedAt: parseDateAsLocal(entry.date), + completedAt: isCompleted ? parseDateAsLocal(entry.date) : undefined, }); } } diff --git a/src/services/SquadDataProvider.ts b/src/services/SquadDataProvider.ts index c11d6bc..0039678 100644 --- a/src/services/SquadDataProvider.ts +++ b/src/services/SquadDataProvider.ts @@ -101,9 +101,12 @@ export class SquadDataProvider { members = roster.members.map(member => { const logStatus = memberStates.get(member.name) ?? 'idle'; const currentTask = tasks.find(t => t.assignee === member.name && t.status === 'in_progress'); - // Override 'working' to 'idle' if the member has no in-progress tasks - // (all their work is completed — they shouldn't show as spinning) - const status = (logStatus === 'working' && !currentTask) ? 'idle' : logStatus; + const memberTasks = tasks.filter(t => t.assignee === member.name); + // Override 'working' to 'idle' ONLY if the member has tasks but none are in-progress + // (all their work is completed — they shouldn't show as spinning). + // If they have NO tasks at all, trust the log status (Copilot Chat scenario). + const hasTasksButNoneActive = memberTasks.length > 0 && !currentTask; + const status = (logStatus === 'working' && hasTasksButNoneActive) ? 'idle' : logStatus; return { name: member.name, role: member.role, @@ -119,7 +122,10 @@ export class SquadDataProvider { members = agentMembers.map(member => { const logStatus = memberStates.get(member.name) ?? 'idle'; const currentTask = tasks.find(t => t.assignee === member.name && t.status === 'in_progress'); - const status = (logStatus === 'working' && !currentTask) ? 'idle' : logStatus; + const memberTasks = tasks.filter(t => t.assignee === member.name); + // Override 'working' to 'idle' ONLY if the member has tasks but none are in-progress + const hasTasksButNoneActive = memberTasks.length > 0 && !currentTask; + const status = (logStatus === 'working' && hasTasksButNoneActive) ? 'idle' : logStatus; return { name: member.name, role: member.role, @@ -140,7 +146,10 @@ export class SquadDataProvider { for (const name of memberNames) { const logStatus = memberStates.get(name) ?? 'idle'; const currentTask = tasks.find(t => t.assignee === name && t.status === 'in_progress'); - const status = (logStatus === 'working' && !currentTask) ? 'idle' : logStatus; + const memberTasks = tasks.filter(t => t.assignee === name); + // Override 'working' to 'idle' ONLY if the member has tasks but none are in-progress + const hasTasksButNoneActive = memberTasks.length > 0 && !currentTask; + const status = (logStatus === 'working' && hasTasksButNoneActive) ? 'idle' : logStatus; members.push({ name, role: 'Squad Member', diff --git a/src/test/suite/orchestrationLogService.test.ts b/src/test/suite/orchestrationLogService.test.ts index 018e717..99e8c9b 100644 --- a/src/test/suite/orchestrationLogService.test.ts +++ b/src/test/suite/orchestrationLogService.test.ts @@ -8,6 +8,7 @@ import * as assert from 'assert'; import * as path from 'path'; import * as fs from 'fs'; import { OrchestrationLogService } from '../../services/OrchestrationLogService'; +import { OrchestrationLogEntry } from '../../models'; const TEST_FIXTURES_ROOT = path.resolve(__dirname, '../../../test-fixtures'); @@ -131,6 +132,90 @@ suite('OrchestrationLogService — Table Format Extraction', () => { }); }); + suite('getActiveTasks() — relatedIssues completion signal detection (issue #63)', () => { + test('relatedIssues with "Closed #NN" outcome → task status = completed', () => { + const entries: OrchestrationLogEntry[] = [ + { + timestamp: '2026-03-01T00:00:00Z', + date: '2026-03-01', + topic: 'fixed-bug', + participants: ['Alice'], + summary: 'Fixed authentication bug', + relatedIssues: ['#42'], + outcomes: ['Closed #42 — fixed the authentication bug'], + }, + ]; + + const tasks = service.getActiveTasks(entries); + + const task42 = tasks.find(t => t.id === '42'); + assert.ok(task42, 'Should find task #42'); + assert.strictEqual(task42!.status, 'completed', 'Task should be completed due to "Closed #42" signal'); + assert.ok(task42!.completedAt, 'Task should have completedAt timestamp'); + }); + + test('relatedIssues with "Fixed #NN" outcome → task status = completed', () => { + const entries: OrchestrationLogEntry[] = [ + { + timestamp: '2026-03-02T00:00:00Z', + date: '2026-03-02', + topic: 'bug-fix', + participants: ['Bob'], + summary: 'Bug fix', + relatedIssues: ['#99'], + outcomes: ['Resolved #99 in latest commit'], + }, + ]; + + const tasks = service.getActiveTasks(entries); + + const task99 = tasks.find(t => t.id === '99'); + assert.ok(task99, 'Should find task #99'); + assert.strictEqual(task99!.status, 'completed', 'Task should be completed due to "Resolved" signal'); + }); + + test('relatedIssues with "Working on #NN" outcome → task status = in_progress', () => { + const entries: OrchestrationLogEntry[] = [ + { + timestamp: '2026-03-03T00:00:00Z', + date: '2026-03-03', + topic: 'active-work', + participants: ['Carol'], + summary: 'Active work', + relatedIssues: ['#77'], + outcomes: ['Working on #77 — implementing new feature'], + }, + ]; + + const tasks = service.getActiveTasks(entries); + + const task77 = tasks.find(t => t.id === '77'); + assert.ok(task77, 'Should find task #77'); + assert.strictEqual(task77!.status, 'in_progress', 'Task should be in_progress without completion signal'); + assert.strictEqual(task77!.completedAt, undefined, 'Task should not have completedAt timestamp'); + }); + + test('relatedIssues without matching outcomes → task status = in_progress', () => { + const entries: OrchestrationLogEntry[] = [ + { + timestamp: '2026-03-04T00:00:00Z', + date: '2026-03-04', + topic: 'start-feature', + participants: ['Dave'], + summary: 'Started investigating the issue', + relatedIssues: ['#55'], + }, + ]; + + const tasks = service.getActiveTasks(entries); + + const task55 = tasks.find(t => t.id === '55'); + assert.ok(task55, 'Should find task #55'); + assert.strictEqual(task55!.status, 'in_progress', 'Task should be in_progress by default'); + assert.strictEqual(task55!.completedAt, undefined, 'Task should not have completedAt timestamp'); + }); + }); + suite('parseLogFile() — table-format integration', () => { let tempDir: string; diff --git a/src/test/suite/squadDataProviderExtended.test.ts b/src/test/suite/squadDataProviderExtended.test.ts index ce5d081..c2eade5 100644 --- a/src/test/suite/squadDataProviderExtended.test.ts +++ b/src/test/suite/squadDataProviderExtended.test.ts @@ -153,7 +153,7 @@ suite('SquadDataProvider — Extended Coverage', () => { // ─── getSquadMembers() — Status Override Logic ────────────────────── - suite('getSquadMembers() — working-to-idle override', () => { + suite('getSquadMembers() — working-to-idle override (issue #63)', () => { test('member shown as idle when log says working but no in-progress tasks', async () => { // Create team.md fs.writeFileSync(path.join(tempDir, '.ai-team', 'team.md'), [ @@ -167,7 +167,7 @@ suite('SquadDataProvider — Extended Coverage', () => { ].join('\n')); // Create log that marks Alice as participant (most recent = working) - // but with completed tasks only + // but with completed tasks only (issue reference with completion signal) const logDir = path.join(tempDir, '.ai-team', 'orchestration-log'); fs.mkdirSync(logDir, { recursive: true }); fs.writeFileSync(path.join(logDir, '2026-03-01-completed.md'), [ @@ -175,6 +175,9 @@ suite('SquadDataProvider — Extended Coverage', () => { '', '**Participants:** Alice', '', + '## Outcomes', + '- Closed #42 — fixed the bug', + '', '## Summary', 'All tasks completed.', ].join('\n')); @@ -187,5 +190,96 @@ suite('SquadDataProvider — Extended Coverage', () => { // Alice should be idle because no in-progress tasks exist assert.strictEqual(alice!.status, 'idle', 'Should be idle when no in-progress tasks'); }); + + test('member stays working when log says working and has no tasks at all (Copilot Chat)', async () => { + fs.writeFileSync(path.join(tempDir, '.ai-team', 'team.md'), [ + '# Team', + '', + '## Members', + '', + '| Name | Role | Charter | Status |', + '|------|------|---------|--------|', + '| Bob | Dev | `.ai-team/agents/bob/charter.md` | ✅ Active |', + ].join('\n')); + + // Create log that marks Bob as participant but NO tasks or issue refs at all + const logDir = path.join(tempDir, '.ai-team', 'orchestration-log'); + fs.mkdirSync(logDir, { recursive: true }); + fs.writeFileSync(path.join(logDir, '2026-03-01-chat.md'), [ + '# Copilot Chat Session', + '', + '**Participants:** Bob', + '', + '## Summary', + 'Answered questions about the codebase.', + ].join('\n')); + + const provider = new SquadDataProvider(tempDir, '.ai-team'); + const members = await provider.getSquadMembers(); + + const bob = members.find(m => m.name === 'Bob'); + assert.ok(bob, 'Should find Bob'); + // Bob has no tasks at all, so should stay "working" (Copilot Chat scenario) + assert.strictEqual(bob!.status, 'working', 'Should stay working when no tasks exist'); + }); + + test('member stays working when log says working and has in-progress tasks', async () => { + fs.writeFileSync(path.join(tempDir, '.ai-team', 'team.md'), [ + '# Team', + '', + '## Members', + '', + '| Name | Role | Charter | Status |', + '|------|------|---------|--------|', + '| Carol | Dev | `.ai-team/agents/carol/charter.md` | ✅ Active |', + ].join('\n')); + + // Create log that marks Carol as participant with in-progress task + const logDir = path.join(tempDir, '.ai-team', 'orchestration-log'); + fs.mkdirSync(logDir, { recursive: true }); + fs.writeFileSync(path.join(logDir, '2026-03-01-in-progress.md'), [ + '# Active Work', + '', + '**Participants:** Carol', + '', + '## Related Issues', + '- #99', + '', + '## Summary', + 'Working on new feature.', + ].join('\n')); + + const provider = new SquadDataProvider(tempDir, '.ai-team'); + const members = await provider.getSquadMembers(); + + const carol = members.find(m => m.name === 'Carol'); + assert.ok(carol, 'Should find Carol'); + // Carol has an in-progress task, should stay "working" + assert.strictEqual(carol!.status, 'working', 'Should stay working with in-progress tasks'); + }); + + test('member not in logs shows as idle', async () => { + fs.writeFileSync(path.join(tempDir, '.ai-team', 'team.md'), [ + '# Team', + '', + '## Members', + '', + '| Name | Role | Charter | Status |', + '|------|------|---------|--------|', + '| Dave | Dev | `.ai-team/agents/dave/charter.md` | ✅ Active |', + ].join('\n')); + + // Create log directory but no entries for Dave + const logDir = path.join(tempDir, '.ai-team', 'orchestration-log'); + fs.mkdirSync(logDir, { recursive: true }); + + const provider = new SquadDataProvider(tempDir, '.ai-team'); + const members = await provider.getSquadMembers(); + + const dave = members.find(m => m.name === 'Dave'); + assert.ok(dave, 'Should find Dave'); + // Dave not in any logs, should be "idle" + assert.strictEqual(dave!.status, 'idle', 'Should be idle when not in logs'); + }); }); });