Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 38 additions & 23 deletions packages/mcp-server/src/plugin-system/beads-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> {
Expand All @@ -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,
'<!-- beads-phase-id: TBD -->\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<string>();

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,
'<!-- beads-phase-id: TBD -->\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('<!-- beads-phase-id: TBD -->');
}
}
}

// 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,
Expand Down
53 changes: 52 additions & 1 deletion packages/mcp-server/test/unit/beads-plugin-behavioral.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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<!-- beads-phase-id: TBD -->');
expect(result).toContain('## Plan\n<!-- beads-phase-id: TBD -->');
});

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<!-- beads-phase-id: existing-1 -->\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<!-- beads-phase-id: TBD -->');
});
});

// ============================================================================
Expand Down
Loading