From 8bd14eecfa615cec47270203f7dfd356e927db3e Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 1 Apr 2026 19:40:22 +0000 Subject: [PATCH] fix(beads): robustly insert phase-id placeholders in plan files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous handleAfterPlanFileCreated relied on two narrow regex patterns that matched the exact task-section text produced by PlanManager. This broke whenever the plan file had been modified by the LLM (e.g. entrance-criteria sections added) or was created by a different template – so no placeholder was ever inserted. updatePlanFileWithPhaseTaskIds then found nothing to replace, leaving extractPhaseTaskIdFromPlanFile unable to return a task ID, and the instructions showed the literal placeholder text. Replace the regex approach with a line-by-line scan that uses the state machine's phase list to identify which ## headings are workflow phases and injects the TBD placeholder immediately after each one – unless a beads-phase-id comment is already present on the next line. This is robust against any plan file content. Also expand the behavioral test suite with three new cases (B2-B4) covering fresh plan files, idempotent behaviour, and LLM-modified files with entrance criteria. https://claude.ai/code/session_01MyfZvrv4Cfk4icMKE7i5Wz --- .../src/plugin-system/beads-plugin.ts | 61 ++++++++++++------- .../test/unit/beads-plugin-behavioral.test.ts | 53 +++++++++++++++- 2 files changed, 90 insertions(+), 24 deletions(-) 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'); + }); }); // ============================================================================