diff --git a/src/workflow/plans/planLinker.test.ts b/src/workflow/plans/planLinker.test.ts new file mode 100644 index 00000000..a216b7d4 --- /dev/null +++ b/src/workflow/plans/planLinker.test.ts @@ -0,0 +1,74 @@ +import * as fs from 'fs-extra'; +import * as os from 'os'; +import * as path from 'path'; + +import { createPlanLinker } from './planLinker'; + +describe('PlanLinker tracking migration', () => { + let tempDir: string; + + beforeEach(async () => { + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'plan-linker-')); + }); + + afterEach(async () => { + await fs.remove(tempDir); + }); + + it('preserves step shape when updatePhase touches legacy tracking', async () => { + const linker = createPlanLinker(tempDir, undefined, false); + const planSlug = 'legacy-plan'; + const planPath = path.join(tempDir, '.context', 'plans', `${planSlug}.md`); + const trackingPath = path.join( + tempDir, + '.context', + 'workflow', + 'plan-tracking', + `${planSlug}.json`, + ); + + await fs.ensureDir(path.dirname(planPath)); + await fs.writeFile( + planPath, + [ + '# Legacy Plan', + '', + '### Phase 1 - Validation', + '', + '1. Confirm syncMarkdown works', + '', + ].join('\n'), + 'utf-8', + ); + + await linker.linkPlan(planSlug); + + await fs.ensureDir(path.dirname(trackingPath)); + await fs.writeJson( + trackingPath, + { + phases: { + 'phase-1': { + status: 'completed', + updatedAt: '2026-05-05T00:00:00.000Z', + }, + }, + progress: 100, + }, + { spaces: 2 }, + ); + + await expect( + linker.updatePlanPhase(planSlug, 'phase-1', 'completed'), + ).resolves.toBe(true); + await expect(linker.syncPlanMarkdown(planSlug)).resolves.toBe(true); + + const tracking = await fs.readJson(trackingPath); + expect(tracking.phases['phase-1'].steps).toEqual([]); + expect(tracking.lastUpdated).toEqual(expect.any(String)); + + const syncedPlan = await fs.readFile(planPath, 'utf-8'); + expect(syncedPlan).toContain('## Execution History'); + expect(syncedPlan).not.toContain('Last updated: undefined'); + }); +}); diff --git a/src/workflow/plans/planLinker.ts b/src/workflow/plans/planLinker.ts index 08e7a92b..af845625 100644 --- a/src/workflow/plans/planLinker.ts +++ b/src/workflow/plans/planLinker.ts @@ -226,32 +226,44 @@ export class PlanLinker { status: StatusType ): Promise { const trackingFile = path.join(this.workflowPath, 'plan-tracking', `${planSlug}.json`); + const now = new Date().toISOString(); - let tracking: Record = {}; - if (await fs.pathExists(trackingFile)) { - const content = await fs.readFile(trackingFile, 'utf-8'); - try { - tracking = JSON.parse(content) || {}; - } catch { - tracking = {}; - } + let tracking = await this.loadPlanTracking(planSlug); + if (!tracking) { + tracking = { + planSlug, + progress: 0, + phases: {}, + decisions: [], + lastUpdated: now, + }; } // Update phase tracking - if (!tracking.phases) { - tracking.phases = {}; - } - (tracking.phases as Record)[phaseId] = { + const existingPhase = tracking.phases[phaseId]; + tracking.phases[phaseId] = { + ...(existingPhase ?? {}), + phaseId, status, - updatedAt: new Date().toISOString(), + steps: existingPhase?.steps ?? [], }; + if (status === 'in_progress' && !tracking.phases[phaseId].startedAt) { + tracking.phases[phaseId].startedAt = now; + } + + if (status === 'completed') { + tracking.phases[phaseId].completedAt = now; + } + + tracking.lastUpdated = now; + // Calculate progress const plan = await this.getLinkedPlan(planSlug); if (plan) { const totalPhases = plan.phases.length; const completedPhases = plan.phases.filter(p => - (tracking.phases as Record)?.[p.id]?.status === 'completed' + tracking.phases[p.id]?.status === 'completed' ).length; tracking.progress = totalPhases > 0 ? Math.round((completedPhases / totalPhases) * 100) : 0; } @@ -645,32 +657,37 @@ export class PlanLinker { try { const content = await fs.readFile(trackingFile, 'utf-8'); const data = JSON.parse(content); - - // Migrate old format to new format if needed - if (!data.phases || typeof data.phases !== 'object') { - // Old format had phases as simple status objects - const migratedPhases: Record = {}; - if (data.phases) { - for (const [phaseId, phaseData] of Object.entries(data.phases as Record)) { - migratedPhases[phaseId] = { - phaseId, - status: phaseData.status as StatusType, - startedAt: phaseData.updatedAt, - completedAt: phaseData.status === 'completed' ? phaseData.updatedAt : undefined, - steps: [], - }; - } + const now = new Date().toISOString(); + + const migratedPhases: Record = {}; + if (data.phases && typeof data.phases === 'object') { + for (const [phaseId, rawPhase] of Object.entries(data.phases)) { + const phaseData = rawPhase as Partial & { + updatedAt?: string; + }; + const status = phaseData.status ?? 'pending'; + + migratedPhases[phaseId] = { + ...phaseData, + phaseId: phaseData.phaseId ?? phaseId, + status, + startedAt: phaseData.startedAt ?? phaseData.updatedAt, + completedAt: + phaseData.completedAt ?? + (status === 'completed' ? phaseData.updatedAt : undefined), + steps: Array.isArray(phaseData.steps) ? phaseData.steps : [], + }; } - return { - planSlug, - progress: data.progress || 0, - phases: migratedPhases, - decisions: data.decisions || [], - lastUpdated: data.lastUpdated || new Date().toISOString(), - }; } - return data as PlanExecutionTracking; + return { + ...data, + planSlug: data.planSlug ?? planSlug, + progress: typeof data.progress === 'number' ? data.progress : 0, + phases: migratedPhases, + decisions: Array.isArray(data.decisions) ? data.decisions : [], + lastUpdated: data.lastUpdated ?? now, + }; } catch { return null; }