Skip to content

Commit a5c51a3

Browse files
committed
feat(cli): add plan command for GitHub issue analysis
Implements `dev plan <issue>` CLI command: **Usage:** ```bash dev plan 123 # Generate plan dev plan 123 --json # JSON output dev plan 123 --markdown # Markdown output dev plan 123 --simple # High-level (4-8 tasks) dev plan 123 --no-explorer # Skip code search ``` **Features:** - Fetches GitHub issue via gh CLI - Parses acceptance criteria and requirements - Breaks down into actionable tasks - Optionally finds relevant code with Explorer - Estimates effort per task - Outputs pretty, JSON, or markdown format **Exports:** - Added all Planner utilities to subagents package exports - CLI imports utilities directly from @lytics/dev-agent-subagents **Output formats:** - Pretty: Colorful terminal output with emojis - JSON: Machine-readable for tool integration - Markdown: Copy-paste to issue comments Ready for testing ✅
1 parent 053a1ef commit a5c51a3

File tree

5 files changed

+281
-1
lines changed

5 files changed

+281
-1
lines changed

packages/cli/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
},
2727
"dependencies": {
2828
"@lytics/dev-agent-core": "workspace:*",
29+
"@lytics/dev-agent-subagents": "workspace:*",
2930
"chalk": "^5.3.0",
3031
"ora": "^8.0.1"
3132
},

packages/cli/src/cli.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { cleanCommand } from './commands/clean.js';
66
import { exploreCommand } from './commands/explore.js';
77
import { indexCommand } from './commands/index.js';
88
import { initCommand } from './commands/init.js';
9+
import { planCommand } from './commands/plan.js';
910
import { searchCommand } from './commands/search.js';
1011
import { statsCommand } from './commands/stats.js';
1112
import { updateCommand } from './commands/update.js';
@@ -22,6 +23,7 @@ program.addCommand(initCommand);
2223
program.addCommand(indexCommand);
2324
program.addCommand(searchCommand);
2425
program.addCommand(exploreCommand);
26+
program.addCommand(planCommand);
2527
program.addCommand(updateCommand);
2628
program.addCommand(statsCommand);
2729
program.addCommand(cleanCommand);

packages/cli/src/commands/plan.ts

Lines changed: 253 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,253 @@
1+
/**
2+
* Plan Command
3+
* Generate development plan from GitHub issue
4+
*/
5+
6+
import { RepositoryIndexer } from '@lytics/dev-agent-core';
7+
import chalk from 'chalk';
8+
import { Command } from 'commander';
9+
import ora from 'ora';
10+
import { loadConfig } from '../utils/config.js';
11+
import { logger } from '../utils/logger.js';
12+
13+
// Import utilities directly from dist to avoid source dependencies
14+
type Plan = {
15+
issueNumber: number;
16+
title: string;
17+
description: string;
18+
tasks: Array<{
19+
id: string;
20+
description: string;
21+
relevantCode: Array<{
22+
path: string;
23+
reason: string;
24+
score: number;
25+
}>;
26+
estimatedHours?: number;
27+
}>;
28+
totalEstimate: string;
29+
priority: string;
30+
};
31+
32+
export const planCommand = new Command('plan')
33+
.description('Generate a development plan from a GitHub issue')
34+
.argument('<issue>', 'GitHub issue number')
35+
.option('--no-explorer', 'Skip finding relevant code with Explorer')
36+
.option('--simple', 'Generate high-level plan (4-8 tasks)')
37+
.option('--json', 'Output as JSON')
38+
.option('--markdown', 'Output as markdown')
39+
.action(async (issueArg: string, options) => {
40+
const spinner = ora('Loading configuration...').start();
41+
42+
try {
43+
const issueNumber = Number.parseInt(issueArg, 10);
44+
if (Number.isNaN(issueNumber)) {
45+
spinner.fail('Invalid issue number');
46+
logger.error(`Issue number must be a number, got: ${issueArg}`);
47+
process.exit(1);
48+
return;
49+
}
50+
51+
// Load config
52+
const config = await loadConfig();
53+
if (!config) {
54+
spinner.fail('No config found');
55+
logger.error('Run "dev init" first to initialize dev-agent');
56+
process.exit(1);
57+
return;
58+
}
59+
60+
spinner.text = `Fetching issue #${issueNumber}...`;
61+
62+
// Import utilities dynamically from dist
63+
const utilsModule = await import('@lytics/dev-agent-subagents');
64+
const {
65+
fetchGitHubIssue,
66+
extractAcceptanceCriteria,
67+
inferPriority,
68+
cleanDescription,
69+
breakdownIssue,
70+
addEstimatesToTasks,
71+
calculateTotalEstimate,
72+
} = utilsModule;
73+
74+
// Fetch GitHub issue
75+
const issue = await fetchGitHubIssue(issueNumber);
76+
77+
// Parse issue content
78+
const acceptanceCriteria = extractAcceptanceCriteria(issue.body);
79+
const priority = inferPriority(issue.labels);
80+
const description = cleanDescription(issue.body);
81+
82+
spinner.text = 'Breaking down into tasks...';
83+
84+
// Break down into tasks
85+
const detailLevel = options.simple ? 'simple' : 'detailed';
86+
let tasks = breakdownIssue(issue, acceptanceCriteria, {
87+
detailLevel,
88+
maxTasks: detailLevel === 'simple' ? 8 : 15,
89+
includeEstimates: false,
90+
});
91+
92+
// Find relevant code if Explorer enabled
93+
if (options.explorer !== false) {
94+
spinner.text = 'Finding relevant code...';
95+
96+
const indexer = new RepositoryIndexer(config);
97+
await indexer.initialize();
98+
99+
for (const task of tasks) {
100+
try {
101+
const results = await indexer.search(task.description, {
102+
limit: 3,
103+
scoreThreshold: 0.6,
104+
});
105+
106+
task.relevantCode = results.map((r) => ({
107+
path: (r.metadata as { path?: string }).path || '',
108+
reason: 'Similar pattern found',
109+
score: r.score,
110+
}));
111+
} catch {
112+
// Continue without Explorer context
113+
}
114+
}
115+
116+
await indexer.close();
117+
}
118+
119+
// Add effort estimates
120+
tasks = addEstimatesToTasks(tasks);
121+
const totalEstimate = calculateTotalEstimate(tasks);
122+
123+
spinner.succeed(chalk.green('Plan generated!'));
124+
125+
const plan: Plan = {
126+
issueNumber,
127+
title: issue.title,
128+
description,
129+
tasks,
130+
totalEstimate,
131+
priority,
132+
};
133+
134+
// Output based on format
135+
if (options.json) {
136+
console.log(JSON.stringify(plan, null, 2));
137+
return;
138+
}
139+
140+
if (options.markdown) {
141+
outputMarkdown(plan);
142+
return;
143+
}
144+
145+
// Default: pretty print
146+
outputPretty(plan);
147+
} catch (error) {
148+
spinner.fail('Planning failed');
149+
logger.error((error as Error).message);
150+
151+
if ((error as Error).message.includes('not installed')) {
152+
logger.log('');
153+
logger.log(chalk.yellow('GitHub CLI is required for planning.'));
154+
logger.log('Install it:');
155+
logger.log(` ${chalk.cyan('brew install gh')} # macOS`);
156+
logger.log(` ${chalk.cyan('sudo apt install gh')} # Linux`);
157+
logger.log(` ${chalk.cyan('https://cli.github.com')} # Windows`);
158+
}
159+
160+
process.exit(1);
161+
}
162+
});
163+
164+
/**
165+
* Output plan in pretty format
166+
*/
167+
function outputPretty(plan: Plan) {
168+
logger.log('');
169+
logger.log(chalk.bold.cyan(`📋 Plan for Issue #${plan.issueNumber}: ${plan.title}`));
170+
logger.log('');
171+
172+
if (plan.description) {
173+
logger.log(chalk.gray(`${plan.description.substring(0, 200)}...`));
174+
logger.log('');
175+
}
176+
177+
logger.log(chalk.bold(`Tasks (${plan.tasks.length}):`));
178+
logger.log('');
179+
180+
for (const task of plan.tasks) {
181+
logger.log(chalk.white(`${task.id}. ☐ ${task.description}`));
182+
183+
if (task.estimatedHours) {
184+
logger.log(chalk.gray(` ⏱️ Est: ${task.estimatedHours}h`));
185+
}
186+
187+
if (task.relevantCode.length > 0) {
188+
for (const code of task.relevantCode.slice(0, 2)) {
189+
const scorePercent = (code.score * 100).toFixed(0);
190+
logger.log(chalk.gray(` 📁 ${code.path} (${scorePercent}% similar)`));
191+
}
192+
}
193+
194+
logger.log('');
195+
}
196+
197+
logger.log(chalk.bold('Summary:'));
198+
logger.log(` Priority: ${getPriorityEmoji(plan.priority)} ${plan.priority}`);
199+
logger.log(` Estimated: ⏱️ ${plan.totalEstimate}`);
200+
logger.log('');
201+
}
202+
203+
/**
204+
* Output plan in markdown format
205+
*/
206+
function outputMarkdown(plan: Plan) {
207+
console.log(`# Plan: ${plan.title} (#${plan.issueNumber})\n`);
208+
209+
if (plan.description) {
210+
console.log(`## Description\n`);
211+
console.log(`${plan.description}\n`);
212+
}
213+
214+
console.log(`## Tasks\n`);
215+
216+
for (const task of plan.tasks) {
217+
console.log(`### ${task.id}. ${task.description}\n`);
218+
219+
if (task.estimatedHours) {
220+
console.log(`- **Estimate:** ${task.estimatedHours}h`);
221+
}
222+
223+
if (task.relevantCode.length > 0) {
224+
console.log(`- **Relevant Code:**`);
225+
for (const code of task.relevantCode) {
226+
const scorePercent = (code.score * 100).toFixed(0);
227+
console.log(` - \`${code.path}\` (${scorePercent}% similar)`);
228+
}
229+
}
230+
231+
console.log('');
232+
}
233+
234+
console.log(`## Summary\n`);
235+
console.log(`- **Priority:** ${plan.priority}`);
236+
console.log(`- **Total Estimate:** ${plan.totalEstimate}\n`);
237+
}
238+
239+
/**
240+
* Get emoji for priority level
241+
*/
242+
function getPriorityEmoji(priority: string): string {
243+
switch (priority) {
244+
case 'high':
245+
return '🔴';
246+
case 'medium':
247+
return '🟡';
248+
case 'low':
249+
return '🟢';
250+
default:
251+
return '⚪';
252+
}
253+
}

packages/subagents/src/index.ts

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,8 +33,29 @@ export type {
3333
} from './explorer/types';
3434
// Logger module
3535
export { CoordinatorLogger } from './logger';
36-
// Agent modules (stubs for now)
36+
// Agent modules
3737
export { PlannerAgent } from './planner';
38+
// Planner utilities
39+
export {
40+
addEstimatesToTasks,
41+
breakdownIssue,
42+
calculateTotalEstimate,
43+
cleanDescription,
44+
estimateTaskHours,
45+
extractAcceptanceCriteria,
46+
extractEstimate,
47+
extractTechnicalRequirements,
48+
fetchGitHubIssue,
49+
formatEstimate,
50+
formatJSON,
51+
formatMarkdown,
52+
formatPretty,
53+
groupTasksByPhase,
54+
inferPriority,
55+
isGhInstalled,
56+
isGitHubRepo,
57+
validateTasks,
58+
} from './planner/utils';
3859
export { PrAgent } from './pr';
3960
// Types - Coordinator
4061
export type {

pnpm-lock.yaml

Lines changed: 3 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)