diff --git a/packages/mcp-server/src/plugin-system/beads-plugin.ts b/packages/mcp-server/src/plugin-system/beads-plugin.ts index 3e2b423f..55617f64 100644 --- a/packages/mcp-server/src/plugin-system/beads-plugin.ts +++ b/packages/mcp-server/src/plugin-system/beads-plugin.ts @@ -345,7 +345,7 @@ export class BeadsPlugin implements IPlugin { * This hook ensures the plan has the proper structure to receive them. */ private async handleAfterPlanFileCreated( - _context: PluginHookContext, + context: PluginHookContext, planFilePath: string, content: string ): Promise { @@ -354,30 +354,45 @@ export class BeadsPlugin implements IPlugin { contentLength: content.length, }); - // Transform standard plan file to beads-optimized format: - // 1. Replace markdown checkbox tasks with beads CLI reference - // 2. Add beads-phase-id placeholders after phase headers - // 3. Update footer to mention beads CLI - - let transformed = content; - - // Replace task checkbox sections with beads CLI reference - // Match "### Tasks\n- [ ] *Tasks will be added..." or similar patterns - transformed = transformed.replace( - /### Tasks\n- \[ \] \*Tasks will be added as they are identified\*\n\n### Completed\n- \[x\] Created development plan file/g, - '\n### Tasks\n\n*Tasks managed via `bd` CLI*' - ); + // Build the set of phase headers from the state machine so we can + // identify which `## Heading` lines are workflow phases (vs. sections + // like "## Goal" or "## Key Decisions"). + const phaseHeaders = context.stateMachine + ? new Set( + Object.keys(context.stateMachine.states).map( + phase => `## ${this.capitalizePhase(phase)}` + ) + ) + : new Set(); + + if (phaseHeaders.size === 0) { + this.logger.debug( + 'BeadsPlugin: No state machine phases available, skipping plan file transformation' + ); + return content; + } - transformed = transformed.replace( - /### Tasks\n- \[ \] \*To be added when this phase becomes active\*\n\n### Completed\n\*None yet\*/g, - '\n### Tasks\n\n*Tasks managed via `bd` CLI*' - ); + // Walk the file line by line. After every phase header that is not + // already followed by a beads-phase-id comment, inject the TBD + // placeholder. This is robust against LLM-modified plan files + // (entrance-criteria sections, custom task lists, etc.) because it + // does not depend on exact surrounding text. + const lines = content.split('\n'); + const result: string[] = []; + + for (let i = 0; i < lines.length; i++) { + result.push(lines[i]); + + if (phaseHeaders.has(lines[i].trim())) { + // Only insert if the very next line is not already a beads comment. + const nextLine = lines[i + 1] ?? ''; + if (!nextLine.includes('beads-phase-id:')) { + result.push(''); + } + } + } - // Update footer to mention beads CLI - transformed = transformed.replace( - /\*This plan is maintained by the LLM\. Tool responses provide guidance on which section to focus on and what tasks to work on\.\*/, - '*This plan is maintained by the LLM and uses beads CLI for task management. Tool responses provide guidance on which bd commands to use for task management.*' - ); + const transformed = result.join('\n'); this.logger.debug('BeadsPlugin: Plan file transformed for beads', { planFilePath, diff --git a/packages/mcp-server/test/unit/beads-plugin-behavioral.test.ts b/packages/mcp-server/test/unit/beads-plugin-behavioral.test.ts index 95ad039c..c94eed76 100644 --- a/packages/mcp-server/test/unit/beads-plugin-behavioral.test.ts +++ b/packages/mcp-server/test/unit/beads-plugin-behavioral.test.ts @@ -203,7 +203,7 @@ Test all functionality`; // ============================================================================ describe('Test Suite B: Hook Basic Functionality', () => { - it('B1: should handle afterPlanFileCreated without modifications', async () => { + it('B1: should leave content unchanged when it has no matching phase headers', async () => { const plugin = new BeadsPlugin({ projectPath: testProjectPath }); const context = createMockContext(); const planContent = 'test plan content'; @@ -217,6 +217,57 @@ Test all functionality`; expect(result).toBe(planContent); }); + + it('B2: should insert beads-phase-id placeholder after phase headers', async () => { + const plugin = new BeadsPlugin({ projectPath: testProjectPath }); + const context = createMockContext(); + // Generic plan file content WITHOUT placeholders (as generated by PlanManager) + const planContent = `# Project Plan\n\n## Explore\n### Tasks\n- [ ] *Tasks will be added*\n\n## Plan\n### Tasks\n- [ ] *To be added*\n`; + + const hooks = plugin.getHooks(); + const result = await hooks.afterPlanFileCreated?.( + context, + testPlanFilePath, + planContent + ); + + expect(result).toContain('## Explore\n'); + expect(result).toContain('## Plan\n'); + }); + + it('B3: should not duplicate placeholder when already present', async () => { + const plugin = new BeadsPlugin({ projectPath: testProjectPath }); + const context = createMockContext(); + const planContent = `# Project Plan\n\n## Explore\n\n### Tasks\n`; + + const hooks = plugin.getHooks(); + const result = await hooks.afterPlanFileCreated?.( + context, + testPlanFilePath, + planContent + ); + + // Should not insert a second placeholder + const matches = result?.match(/beads-phase-id:/g) ?? []; + expect(matches.length).toBe(1); + expect(result).toContain('beads-phase-id: existing-1'); + }); + + it('B4: should add placeholder even when phase has entrance criteria between header and tasks', async () => { + const plugin = new BeadsPlugin({ projectPath: testProjectPath }); + const context = createMockContext(); + // Simulates a plan file where the LLM added entrance criteria + const planContent = `# Project Plan\n\n## Plan\n\n### Phase Entrance Criteria:\n- [ ] Explored enough\n\n### Tasks\n- [ ] *To be added*\n`; + + const hooks = plugin.getHooks(); + const result = await hooks.afterPlanFileCreated?.( + context, + testPlanFilePath, + planContent + ); + + expect(result).toContain('## Plan\n'); + }); }); // ============================================================================