@vibedx/vibekit 0.8.8 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +34 -0
- package/index.js +1 -1
- package/package.json +1 -1
- package/skills/vibekit/SKILL.md +54 -0
- package/src/commands/init/index.js +2 -1
- package/src/commands/plan/index.js +283 -0
- package/src/commands/plan/index.test.js +127 -0
- package/src/commands/plan/to-ticket.js +312 -0
- package/src/commands/plan/to-ticket.test.js +149 -0
package/README.md
CHANGED
|
@@ -126,6 +126,40 @@ vibe start TKT-001 --agent # Single ticket, current directory
|
|
|
126
126
|
vibe start TKT-001 TKT-002 -w --agent # Multiple tickets in worktrees with agents
|
|
127
127
|
```
|
|
128
128
|
|
|
129
|
+
### 📋 Plans → Tickets
|
|
130
|
+
```bash
|
|
131
|
+
# Save a Claude implementation plan to .vibe/plans/, then break it into tickets.
|
|
132
|
+
# Preview the extracted tickets (nothing is written by default)
|
|
133
|
+
vibe plan to-ticket .vibe/plans/my-feature.md
|
|
134
|
+
|
|
135
|
+
# Create the tickets once the breakdown looks right
|
|
136
|
+
vibe plan to-ticket .vibe/plans/my-feature.md --auto
|
|
137
|
+
|
|
138
|
+
# Show the extracted items without creating anything
|
|
139
|
+
vibe plan to-ticket .vibe/plans/my-feature.md --dry-run
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
### 🔀 Pull Requests
|
|
143
|
+
```bash
|
|
144
|
+
# Open a GitHub PR from the current ticket branch (title/body from the ticket)
|
|
145
|
+
vibe pr
|
|
146
|
+
|
|
147
|
+
# Open PRs for specific tickets
|
|
148
|
+
vibe pr TKT-003 TKT-004
|
|
149
|
+
|
|
150
|
+
# Open PRs for all worktree branches
|
|
151
|
+
vibe pr --all
|
|
152
|
+
|
|
153
|
+
# Open as a draft
|
|
154
|
+
vibe pr --draft
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
### 🐝 Swarm (Parallel Agents)
|
|
158
|
+
```bash
|
|
159
|
+
# Spawn parallel Claude agents across multiple tickets in isolated worktrees
|
|
160
|
+
vibe swarm TKT-001 TKT-002 TKT-003
|
|
161
|
+
```
|
|
162
|
+
|
|
129
163
|
### 👥 Team Management
|
|
130
164
|
```bash
|
|
131
165
|
# List team members
|
package/index.js
CHANGED
|
@@ -24,7 +24,7 @@ const __dirname = dirname(__filename);
|
|
|
24
24
|
const AVAILABLE_COMMANDS = [
|
|
25
25
|
'init', 'new', 'close', 'list', 'get-started',
|
|
26
26
|
'start', 'link', 'unlink', 'refine', 'lint', 'review', 'team', 'skills',
|
|
27
|
-
'status', 'stats', 'pr', 'swarm'
|
|
27
|
+
'status', 'stats', 'plan', 'pr', 'swarm'
|
|
28
28
|
];
|
|
29
29
|
|
|
30
30
|
/**
|
package/package.json
CHANGED
package/skills/vibekit/SKILL.md
CHANGED
|
@@ -69,6 +69,8 @@ vibe init # Creates .vibe/ directory with config, team, templates
|
|
|
69
69
|
| `vibe lint` | Validate ticket format |
|
|
70
70
|
| `vibe lint --fix` | Auto-fix missing sections |
|
|
71
71
|
| `vibe refine <id>` | AI-enhance ticket details |
|
|
72
|
+
| `vibe plan to-ticket <file>` | Convert a saved plan into tickets (AI) |
|
|
73
|
+
| `vibe swarm` | Spawn parallel agents across open tickets in worktrees |
|
|
72
74
|
| `vibe team` | Manage team members |
|
|
73
75
|
|
|
74
76
|
## Creating Tickets (for AI agents)
|
|
@@ -232,6 +234,58 @@ vibe pr --draft
|
|
|
232
234
|
|
|
233
235
|
PR title and body are auto-populated from ticket content. Ticket status is set to `review`.
|
|
234
236
|
|
|
237
|
+
### Plans → Tickets
|
|
238
|
+
|
|
239
|
+
When you (Claude) produce an implementation plan, save it to `.vibe/plans/` as a
|
|
240
|
+
markdown file, then turn it into tickets:
|
|
241
|
+
|
|
242
|
+
```bash
|
|
243
|
+
# Preview the tickets vibekit would extract from a plan (no files written)
|
|
244
|
+
vibe plan to-ticket .vibe/plans/my-feature.md
|
|
245
|
+
|
|
246
|
+
# Create the tickets once the breakdown looks right
|
|
247
|
+
vibe plan to-ticket .vibe/plans/my-feature.md --auto
|
|
248
|
+
|
|
249
|
+
# Dry-run shows the extracted items without creating anything
|
|
250
|
+
vibe plan to-ticket .vibe/plans/my-feature.md --dry-run
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
The command sends the plan to Claude, which extracts discrete work items
|
|
254
|
+
(title, description, acceptance criteria, priority, estimate) and writes one
|
|
255
|
+
ticket per item to `.vibe/tickets/`. Default is preview-then-confirm: it shows
|
|
256
|
+
the breakdown and only writes files when you pass `--auto`. Chain it with
|
|
257
|
+
`vibe start TKT-XXX` to begin work.
|
|
258
|
+
|
|
259
|
+
### Swarming (Parallel Agents)
|
|
260
|
+
|
|
261
|
+
`vibe swarm` spins up multiple Claude agents at once, each in its own worktree,
|
|
262
|
+
to burn down a batch of tickets in parallel:
|
|
263
|
+
|
|
264
|
+
```bash
|
|
265
|
+
# Swarm all open tickets (capped by config swarm.maxAgents, default 3)
|
|
266
|
+
vibe swarm
|
|
267
|
+
|
|
268
|
+
# Limit the number of concurrent agents
|
|
269
|
+
vibe swarm --count 5
|
|
270
|
+
|
|
271
|
+
# Only swarm tickets matching a filter (frontmatter key:value)
|
|
272
|
+
vibe swarm --filter priority:high
|
|
273
|
+
|
|
274
|
+
# Use a specific base branch for the worktrees
|
|
275
|
+
vibe swarm --base main
|
|
276
|
+
|
|
277
|
+
# Check on a running swarm
|
|
278
|
+
vibe swarm status
|
|
279
|
+
```
|
|
280
|
+
|
|
281
|
+
Configure caps and timeout in `.vibe/config.yml`:
|
|
282
|
+
|
|
283
|
+
```yaml
|
|
284
|
+
swarm:
|
|
285
|
+
maxAgents: 3
|
|
286
|
+
timeout: 900 # seconds
|
|
287
|
+
```
|
|
288
|
+
|
|
235
289
|
### Monitoring Progress
|
|
236
290
|
|
|
237
291
|
```bash
|
|
@@ -105,13 +105,14 @@ async function initCommand(args) {
|
|
|
105
105
|
|
|
106
106
|
fs.mkdirSync(targetFolder, { recursive: true });
|
|
107
107
|
fs.mkdirSync(path.join(targetFolder, 'tickets'), { recursive: true });
|
|
108
|
+
fs.mkdirSync(path.join(targetFolder, 'plans'), { recursive: true });
|
|
108
109
|
fs.mkdirSync(path.join(targetFolder, '.templates'), { recursive: true });
|
|
109
110
|
|
|
110
111
|
fs.copyFileSync(configSrc, path.join(targetFolder, 'config.yml'));
|
|
111
112
|
fs.copyFileSync(templateSrc, path.join(targetFolder, '.templates', 'default.md'));
|
|
112
113
|
fs.copyFileSync(teamSrc, path.join(targetFolder, 'team.yml'));
|
|
113
114
|
|
|
114
|
-
console.log(`✅ '${targetFolder}' initialized with config, tickets/, and .templates/default.md`);
|
|
115
|
+
console.log(`✅ '${targetFolder}' initialized with config, tickets/, plans/, and .templates/default.md`);
|
|
115
116
|
} else {
|
|
116
117
|
console.log(`⚠️ Folder '${targetFolder}' already exists. Skipping .vibe creation.`);
|
|
117
118
|
}
|
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import yaml from 'js-yaml';
|
|
4
|
+
import { execSync, spawn } from 'child_process';
|
|
5
|
+
import { getTicketsDir, getConfig } from '../../utils/index.js';
|
|
6
|
+
import {
|
|
7
|
+
isGitRepository,
|
|
8
|
+
getRepoName,
|
|
9
|
+
getRepoRoot,
|
|
10
|
+
getWorktreePath,
|
|
11
|
+
createWorktree,
|
|
12
|
+
createWorktreeExistingBranch,
|
|
13
|
+
branchExistsLocally,
|
|
14
|
+
branchExistsRemotely,
|
|
15
|
+
getDefaultBaseBranch
|
|
16
|
+
} from '../../utils/git.js';
|
|
17
|
+
import toTicketCommand from './to-ticket.js';
|
|
18
|
+
|
|
19
|
+
function parseTicketIds(args) {
|
|
20
|
+
const ids = [];
|
|
21
|
+
const flags = {};
|
|
22
|
+
let i = 0;
|
|
23
|
+
while (i < args.length) {
|
|
24
|
+
const arg = args[i];
|
|
25
|
+
if (arg === '--base' && i + 1 < args.length) {
|
|
26
|
+
flags.baseBranch = args[i + 1];
|
|
27
|
+
i += 2;
|
|
28
|
+
} else if (arg === '--dry-run') {
|
|
29
|
+
flags.dryRun = true;
|
|
30
|
+
i++;
|
|
31
|
+
} else if (arg === '--no-install') {
|
|
32
|
+
flags.noInstall = true;
|
|
33
|
+
i++;
|
|
34
|
+
} else if (arg === '--prompt' && i + 1 < args.length) {
|
|
35
|
+
flags.prompt = args[i + 1];
|
|
36
|
+
i += 2;
|
|
37
|
+
} else if (arg.startsWith('--status=')) {
|
|
38
|
+
flags.statusFilter = arg.split('=')[1];
|
|
39
|
+
i++;
|
|
40
|
+
} else if (!arg.startsWith('-')) {
|
|
41
|
+
ids.push(arg);
|
|
42
|
+
i++;
|
|
43
|
+
} else {
|
|
44
|
+
i++;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return { ids, flags };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function normalizeTicketId(input) {
|
|
51
|
+
if (input.startsWith('TKT-')) return input;
|
|
52
|
+
if (/^\d+$/.test(input)) return `TKT-${input.padStart(3, '0')}`;
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function loadTicket(ticketsDir, ticketId) {
|
|
57
|
+
const files = fs.readdirSync(ticketsDir).filter(f => f.startsWith(`${ticketId}-`));
|
|
58
|
+
if (files.length === 0) return null;
|
|
59
|
+
const filePath = path.join(ticketsDir, files[0]);
|
|
60
|
+
const content = fs.readFileSync(filePath, 'utf-8');
|
|
61
|
+
const match = content.match(/^---\n([\s\S]*?)\n---/);
|
|
62
|
+
if (!match) return null;
|
|
63
|
+
const frontmatter = yaml.load(match[1]);
|
|
64
|
+
return { frontmatter, content, filePath, fileName: files[0] };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function loadTicketsByStatus(ticketsDir, status) {
|
|
68
|
+
const files = fs.readdirSync(ticketsDir).filter(f => f.endsWith('.md'));
|
|
69
|
+
const tickets = [];
|
|
70
|
+
for (const file of files) {
|
|
71
|
+
const filePath = path.join(ticketsDir, file);
|
|
72
|
+
const content = fs.readFileSync(filePath, 'utf-8');
|
|
73
|
+
const match = content.match(/^---\n([\s\S]*?)\n---/);
|
|
74
|
+
if (match) {
|
|
75
|
+
const frontmatter = yaml.load(match[1]);
|
|
76
|
+
if (frontmatter.status === status) {
|
|
77
|
+
tickets.push({ frontmatter, content, filePath, fileName: file });
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return tickets;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function getBranchName(ticket, config) {
|
|
85
|
+
const branchPrefix = config.git?.branch_prefix || '';
|
|
86
|
+
const slug = ticket.frontmatter.slug || ticket.frontmatter.id;
|
|
87
|
+
return slug.includes(ticket.frontmatter.id)
|
|
88
|
+
? `${branchPrefix}${slug}`
|
|
89
|
+
: `${branchPrefix}${ticket.frontmatter.id}-${slug}`;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function updateTicketStatus(ticket, worktreePath) {
|
|
93
|
+
const now = new Date().toISOString();
|
|
94
|
+
let updatedContent = ticket.content;
|
|
95
|
+
|
|
96
|
+
if (updatedContent.match(/^worktree_path: .+$/m)) {
|
|
97
|
+
updatedContent = updatedContent.replace(/^worktree_path: .+$/m, `worktree_path: "${worktreePath}"`);
|
|
98
|
+
} else {
|
|
99
|
+
updatedContent = updatedContent.replace(/^(updated_at: .+)$/m, `$1\nworktree_path: "${worktreePath}"`);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
updatedContent = updatedContent
|
|
103
|
+
.replace(/^status: (.+)$/m, 'status: in_progress')
|
|
104
|
+
.replace(/^updated_at: (.+)$/m, `updated_at: "${now}"`);
|
|
105
|
+
|
|
106
|
+
fs.writeFileSync(ticket.filePath, updatedContent, 'utf-8');
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async function planCommand(args) {
|
|
110
|
+
// Handle subcommands
|
|
111
|
+
if (args.length > 0 && args[0] === 'to-ticket') {
|
|
112
|
+
return toTicketCommand(args.slice(1));
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (!isGitRepository()) {
|
|
116
|
+
console.error('❌ Not in a git repository.');
|
|
117
|
+
process.exit(1);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const { ids, flags } = parseTicketIds(args);
|
|
121
|
+
|
|
122
|
+
if (ids.length === 0 && !flags.statusFilter) {
|
|
123
|
+
console.error('❌ Usage:');
|
|
124
|
+
console.error(' vibe plan <TKT-001> <TKT-002> ... Spawn parallel agents for tickets');
|
|
125
|
+
console.error(' vibe plan to-ticket <file> [--auto] [--dry-run] Convert plan to tickets');
|
|
126
|
+
console.error('');
|
|
127
|
+
console.error('Options for parallel agents:');
|
|
128
|
+
console.error(' --status=<status> Start all tickets with this status (e.g. --status=open)');
|
|
129
|
+
console.error(' --base <branch> Base branch for worktrees (default: main)');
|
|
130
|
+
console.error(' --dry-run Show what would happen without doing it');
|
|
131
|
+
console.error(' --no-install Skip npm install in worktrees');
|
|
132
|
+
console.error(' --prompt "..." Custom prompt to pass to each Claude agent');
|
|
133
|
+
console.error('');
|
|
134
|
+
console.error('Options for plan to-ticket:');
|
|
135
|
+
console.error(' --auto Automatically create tickets (shows preview by default)');
|
|
136
|
+
console.error(' --dry-run Show extracted tickets without creating them');
|
|
137
|
+
process.exit(1);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const config = getConfig();
|
|
141
|
+
const ticketsDir = getTicketsDir();
|
|
142
|
+
const repoName = getRepoName();
|
|
143
|
+
const repoRoot = getRepoRoot();
|
|
144
|
+
|
|
145
|
+
let tickets = [];
|
|
146
|
+
|
|
147
|
+
if (flags.statusFilter) {
|
|
148
|
+
tickets = loadTicketsByStatus(ticketsDir, flags.statusFilter);
|
|
149
|
+
if (tickets.length === 0) {
|
|
150
|
+
console.error(`❌ No tickets found with status: ${flags.statusFilter}`);
|
|
151
|
+
process.exit(1);
|
|
152
|
+
}
|
|
153
|
+
console.log(`📋 Found ${tickets.length} ticket(s) with status "${flags.statusFilter}"`);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
for (const id of ids) {
|
|
157
|
+
const ticketId = normalizeTicketId(id);
|
|
158
|
+
if (!ticketId) {
|
|
159
|
+
console.error(`❌ Invalid ticket ID: ${id}`);
|
|
160
|
+
process.exit(1);
|
|
161
|
+
}
|
|
162
|
+
const ticket = loadTicket(ticketsDir, ticketId);
|
|
163
|
+
if (!ticket) {
|
|
164
|
+
console.error(`❌ Ticket ${ticketId} not found.`);
|
|
165
|
+
process.exit(1);
|
|
166
|
+
}
|
|
167
|
+
if (!tickets.find(t => t.frontmatter.id === ticket.frontmatter.id)) {
|
|
168
|
+
tickets.push(ticket);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
if (tickets.length === 0) {
|
|
173
|
+
console.error('❌ No tickets to start.');
|
|
174
|
+
process.exit(1);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
console.log('');
|
|
178
|
+
console.log(`🚀 Planning ${tickets.length} ticket(s) for parallel work:\n`);
|
|
179
|
+
|
|
180
|
+
const worktreeInfos = [];
|
|
181
|
+
|
|
182
|
+
for (const ticket of tickets) {
|
|
183
|
+
const branchName = getBranchName(ticket, config);
|
|
184
|
+
const worktreePath = getWorktreePath(repoName, branchName);
|
|
185
|
+
const title = ticket.frontmatter.title || 'Untitled';
|
|
186
|
+
|
|
187
|
+
worktreeInfos.push({
|
|
188
|
+
ticket,
|
|
189
|
+
branchName,
|
|
190
|
+
worktreePath,
|
|
191
|
+
title,
|
|
192
|
+
alreadyExists: fs.existsSync(worktreePath)
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
const status = fs.existsSync(worktreePath) ? '(exists)' : '(new)';
|
|
196
|
+
console.log(` ${ticket.frontmatter.id} — ${title}`);
|
|
197
|
+
console.log(` 🌿 ${branchName} ${status}`);
|
|
198
|
+
console.log(` 📂 ${worktreePath}`);
|
|
199
|
+
console.log('');
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
if (flags.dryRun) {
|
|
203
|
+
console.log('🏁 Dry run complete. No changes made.');
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// Create worktrees
|
|
208
|
+
for (const info of worktreeInfos) {
|
|
209
|
+
if (info.alreadyExists) {
|
|
210
|
+
console.log(`✅ ${info.ticket.frontmatter.id}: Worktree already exists`);
|
|
211
|
+
continue;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
try {
|
|
215
|
+
const branchExists = branchExistsLocally(info.branchName) || branchExistsRemotely(info.branchName);
|
|
216
|
+
if (branchExists) {
|
|
217
|
+
createWorktreeExistingBranch(info.worktreePath, info.branchName);
|
|
218
|
+
} else {
|
|
219
|
+
createWorktree(info.worktreePath, info.branchName, flags.baseBranch);
|
|
220
|
+
}
|
|
221
|
+
console.log(`✅ ${info.ticket.frontmatter.id}: Worktree created`);
|
|
222
|
+
} catch (error) {
|
|
223
|
+
console.error(`❌ ${info.ticket.frontmatter.id}: Failed to create worktree — ${error.message}`);
|
|
224
|
+
continue;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
updateTicketStatus(info.ticket, info.worktreePath);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// Install dependencies in worktrees
|
|
231
|
+
if (!flags.noInstall) {
|
|
232
|
+
const pkgExists = fs.existsSync(path.join(repoRoot, 'package.json'));
|
|
233
|
+
if (pkgExists) {
|
|
234
|
+
console.log('\n📦 Installing dependencies in worktrees...');
|
|
235
|
+
for (const info of worktreeInfos) {
|
|
236
|
+
if (fs.existsSync(path.join(info.worktreePath, 'package.json'))) {
|
|
237
|
+
try {
|
|
238
|
+
execSync('npm install --silent', { cwd: info.worktreePath, stdio: 'ignore' });
|
|
239
|
+
console.log(` ✅ ${info.ticket.frontmatter.id}: npm install done`);
|
|
240
|
+
} catch (error) {
|
|
241
|
+
console.warn(` ⚠️ ${info.ticket.frontmatter.id}: npm install failed — ${error.message}`);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// Spawn Claude agents
|
|
249
|
+
console.log('\n🤖 Spawning Claude agents...\n');
|
|
250
|
+
|
|
251
|
+
const agents = [];
|
|
252
|
+
for (const info of worktreeInfos) {
|
|
253
|
+
const ticketContent = fs.readFileSync(info.ticket.filePath, 'utf-8');
|
|
254
|
+
const prompt = flags.prompt
|
|
255
|
+
? flags.prompt
|
|
256
|
+
: `You are working on ticket ${info.ticket.frontmatter.id}: ${info.title}\n\nHere is the full ticket:\n\n${ticketContent}\n\nImplement the ticket requirements. Follow the acceptance criteria. Commit your work when done.`;
|
|
257
|
+
|
|
258
|
+
try {
|
|
259
|
+
const agentProcess = spawn('claude', ['-p', prompt, '--allowedTools', 'Edit,Write,Bash,Read,Glob,Grep'], {
|
|
260
|
+
cwd: info.worktreePath,
|
|
261
|
+
stdio: 'ignore',
|
|
262
|
+
detached: true
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
agentProcess.unref();
|
|
266
|
+
agents.push({ id: info.ticket.frontmatter.id, pid: agentProcess.pid });
|
|
267
|
+
console.log(` 🤖 ${info.ticket.frontmatter.id}: Agent spawned (PID ${agentProcess.pid}) in ${info.worktreePath}`);
|
|
268
|
+
} catch (error) {
|
|
269
|
+
console.error(` ❌ ${info.ticket.frontmatter.id}: Failed to spawn agent — ${error.message}`);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
console.log('\n🏁 All agents launched!\n');
|
|
274
|
+
console.log('Monitor progress:');
|
|
275
|
+
console.log(' vibe list --status=in_progress # see active tickets');
|
|
276
|
+
console.log(' git worktree list # see all worktrees');
|
|
277
|
+
console.log('');
|
|
278
|
+
console.log('When agents finish, open PRs:');
|
|
279
|
+
console.log(' vibe pr --all # open PRs for all worktree branches');
|
|
280
|
+
console.log(` vibe pr ${tickets.map(t => t.frontmatter.id).join(' ')} # open PRs for specific tickets`);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
export default planCommand;
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { jest } from '@jest/globals';
|
|
2
|
+
import fs from 'fs';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import { execSync } from 'child_process';
|
|
5
|
+
|
|
6
|
+
// Mock dependencies
|
|
7
|
+
jest.unstable_mockModule('child_process', () => ({
|
|
8
|
+
execSync: jest.fn(),
|
|
9
|
+
spawn: jest.fn(() => ({ unref: jest.fn(), pid: 12345 }))
|
|
10
|
+
}));
|
|
11
|
+
|
|
12
|
+
let ticketIdCounter = 1;
|
|
13
|
+
jest.unstable_mockModule('../../utils/index.js', () => ({
|
|
14
|
+
getTicketsDir: jest.fn(),
|
|
15
|
+
getConfig: jest.fn(() => ({ git: { branch_prefix: 'feature/' } })),
|
|
16
|
+
createSlug: jest.fn(title => title.toLowerCase().replace(/\s+/g, '-')),
|
|
17
|
+
getNextTicketId: jest.fn(() => `TKT-${String(ticketIdCounter++).padStart(3, '0')}`)
|
|
18
|
+
}));
|
|
19
|
+
|
|
20
|
+
jest.unstable_mockModule('../../utils/git.js', () => ({
|
|
21
|
+
isGitRepository: jest.fn(() => true),
|
|
22
|
+
getRepoName: jest.fn(() => 'test-repo'),
|
|
23
|
+
getRepoRoot: jest.fn(() => '/tmp/test-repo'),
|
|
24
|
+
getWorktreePath: jest.fn((repo, branch) => `/tmp/.vibekit/worktrees/${repo}/${branch}`),
|
|
25
|
+
createWorktree: jest.fn(),
|
|
26
|
+
createWorktreeExistingBranch: jest.fn(),
|
|
27
|
+
branchExistsLocally: jest.fn(() => false),
|
|
28
|
+
branchExistsRemotely: jest.fn(() => false),
|
|
29
|
+
getDefaultBaseBranch: jest.fn(() => 'main')
|
|
30
|
+
}));
|
|
31
|
+
|
|
32
|
+
const { default: planCommand } = await import('./index.js');
|
|
33
|
+
const { getTicketsDir, getConfig } = await import('../../utils/index.js');
|
|
34
|
+
const { isGitRepository, getWorktreePath, createWorktree } = await import('../../utils/git.js');
|
|
35
|
+
const childProcess = await import('child_process');
|
|
36
|
+
|
|
37
|
+
const TICKET_CONTENT = `---
|
|
38
|
+
id: TKT-001
|
|
39
|
+
title: Add login page
|
|
40
|
+
slug: TKT-001-add-login-page
|
|
41
|
+
status: open
|
|
42
|
+
priority: high
|
|
43
|
+
assignee: dev1
|
|
44
|
+
author: pm
|
|
45
|
+
created_at: "2026-01-01T00:00:00Z"
|
|
46
|
+
updated_at: "2026-01-01T00:00:00Z"
|
|
47
|
+
---
|
|
48
|
+
|
|
49
|
+
## Description
|
|
50
|
+
Add a login page.
|
|
51
|
+
|
|
52
|
+
## Acceptance Criteria
|
|
53
|
+
- [ ] Login form exists
|
|
54
|
+
`;
|
|
55
|
+
|
|
56
|
+
describe('plan command', () => {
|
|
57
|
+
let tempDir;
|
|
58
|
+
let mockExit;
|
|
59
|
+
let mockConsoleLog;
|
|
60
|
+
let mockConsoleError;
|
|
61
|
+
|
|
62
|
+
beforeEach(() => {
|
|
63
|
+
tempDir = fs.mkdtempSync(path.join('/tmp', 'vibe-plan-test-'));
|
|
64
|
+
const ticketsDir = path.join(tempDir, '.vibe', 'tickets');
|
|
65
|
+
fs.mkdirSync(ticketsDir, { recursive: true });
|
|
66
|
+
fs.writeFileSync(path.join(ticketsDir, 'TKT-001-add-login-page.md'), TICKET_CONTENT);
|
|
67
|
+
|
|
68
|
+
getTicketsDir.mockReturnValue(ticketsDir);
|
|
69
|
+
mockExit = jest.spyOn(process, 'exit').mockImplementation(() => { throw new Error('process.exit'); });
|
|
70
|
+
mockConsoleLog = jest.spyOn(console, 'log').mockImplementation(() => {});
|
|
71
|
+
mockConsoleError = jest.spyOn(console, 'error').mockImplementation(() => {});
|
|
72
|
+
childProcess.execSync.mockImplementation(() => '');
|
|
73
|
+
|
|
74
|
+
// Worktree path doesn't exist yet
|
|
75
|
+
getWorktreePath.mockReturnValue(path.join(tempDir, 'worktree-TKT-001'));
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
afterEach(() => {
|
|
79
|
+
mockExit.mockRestore();
|
|
80
|
+
mockConsoleLog.mockRestore();
|
|
81
|
+
mockConsoleError.mockRestore();
|
|
82
|
+
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
83
|
+
jest.clearAllMocks();
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test('shows usage when no arguments provided', async () => {
|
|
87
|
+
await expect(planCommand([])).rejects.toThrow('process.exit');
|
|
88
|
+
expect(mockConsoleError).toHaveBeenCalledWith(expect.stringContaining('Usage:'));
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test('exits if not in git repo', async () => {
|
|
92
|
+
isGitRepository.mockReturnValueOnce(false);
|
|
93
|
+
await expect(planCommand(['TKT-001'])).rejects.toThrow('process.exit');
|
|
94
|
+
expect(mockConsoleError).toHaveBeenCalledWith('❌ Not in a git repository.');
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test('dry run shows plan without creating worktrees', async () => {
|
|
98
|
+
await planCommand(['TKT-001', '--dry-run']);
|
|
99
|
+
expect(mockConsoleLog).toHaveBeenCalledWith('🏁 Dry run complete. No changes made.');
|
|
100
|
+
expect(createWorktree).not.toHaveBeenCalled();
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test('creates worktree and spawns agent for ticket', async () => {
|
|
104
|
+
await planCommand(['TKT-001', '--no-install']);
|
|
105
|
+
expect(createWorktree).toHaveBeenCalled();
|
|
106
|
+
expect(childProcess.spawn).toHaveBeenCalledWith(
|
|
107
|
+
'claude',
|
|
108
|
+
expect.arrayContaining(['-p']),
|
|
109
|
+
expect.objectContaining({ stdio: 'ignore', detached: true })
|
|
110
|
+
);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test('handles --status=open filter', async () => {
|
|
114
|
+
await planCommand(['--status=open', '--dry-run']);
|
|
115
|
+
expect(mockConsoleLog).toHaveBeenCalledWith(expect.stringContaining('TKT-001'));
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test('errors on invalid ticket ID', async () => {
|
|
119
|
+
await expect(planCommand(['INVALID'])).rejects.toThrow('process.exit');
|
|
120
|
+
expect(mockConsoleError).toHaveBeenCalledWith(expect.stringContaining('Invalid ticket ID'));
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test('errors when ticket not found', async () => {
|
|
124
|
+
await expect(planCommand(['TKT-999'])).rejects.toThrow('process.exit');
|
|
125
|
+
expect(mockConsoleError).toHaveBeenCalledWith('❌ Ticket TKT-999 not found.');
|
|
126
|
+
});
|
|
127
|
+
});
|
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import { spawn } from 'child_process';
|
|
4
|
+
import yaml from 'js-yaml';
|
|
5
|
+
import { getTicketsDir, getConfig, getNextTicketId, createSlug } from '../../utils/index.js';
|
|
6
|
+
import { logger } from '../../utils/cli.js';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Parse CLI arguments for the to-ticket subcommand
|
|
10
|
+
*/
|
|
11
|
+
function parseToTicketArgs(args) {
|
|
12
|
+
let planFile = null;
|
|
13
|
+
let dryRun = false;
|
|
14
|
+
let autoCreate = false;
|
|
15
|
+
|
|
16
|
+
for (let i = 0; i < args.length; i++) {
|
|
17
|
+
const arg = args[i];
|
|
18
|
+
if (arg === '--dry-run') {
|
|
19
|
+
dryRun = true;
|
|
20
|
+
} else if (arg === '--auto') {
|
|
21
|
+
autoCreate = true;
|
|
22
|
+
} else if (!arg.startsWith('-')) {
|
|
23
|
+
planFile = arg;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
return { planFile, dryRun, autoCreate };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Read and validate a plan file
|
|
32
|
+
*/
|
|
33
|
+
function readPlanFile(planFile) {
|
|
34
|
+
if (!planFile) {
|
|
35
|
+
throw new Error('Plan file path is required');
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Resolve path relative to cwd if not absolute
|
|
39
|
+
const filePath = path.isAbsolute(planFile)
|
|
40
|
+
? planFile
|
|
41
|
+
: path.join(process.cwd(), planFile);
|
|
42
|
+
|
|
43
|
+
if (!fs.existsSync(filePath)) {
|
|
44
|
+
throw new Error(`Plan file not found: ${filePath}`);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const content = fs.readFileSync(filePath, 'utf-8');
|
|
48
|
+
return { filePath, content };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Create a prompt for Claude to extract tickets from a plan
|
|
53
|
+
*/
|
|
54
|
+
function createExtractionPrompt(planContent) {
|
|
55
|
+
return `You are a senior software engineer analyzing a project plan.
|
|
56
|
+
|
|
57
|
+
Plan content:
|
|
58
|
+
${planContent}
|
|
59
|
+
|
|
60
|
+
Extract all actionable work items from this plan and convert them to a JSON list of tickets.
|
|
61
|
+
|
|
62
|
+
For each ticket, provide:
|
|
63
|
+
- title: A clear, descriptive title (max 80 chars)
|
|
64
|
+
- description: The main work to be done
|
|
65
|
+
- acceptance_criteria: An array of 2-4 specific, measurable criteria
|
|
66
|
+
- priority: One of: low, medium, high, critical
|
|
67
|
+
- estimated_hours: Rough estimate (0.5, 1, 2, 4, 8, 16, 32)
|
|
68
|
+
|
|
69
|
+
Return ONLY a valid JSON object with this structure:
|
|
70
|
+
{
|
|
71
|
+
"tickets": [
|
|
72
|
+
{
|
|
73
|
+
"title": "...",
|
|
74
|
+
"description": "...",
|
|
75
|
+
"acceptance_criteria": ["...", "..."],
|
|
76
|
+
"priority": "medium",
|
|
77
|
+
"estimated_hours": 4
|
|
78
|
+
}
|
|
79
|
+
]
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
Ensure each ticket is focused, actionable, and independent.`;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Parse Claude envelope and extract text
|
|
87
|
+
*/
|
|
88
|
+
function parseClaudeEnvelope(raw) {
|
|
89
|
+
let envelope;
|
|
90
|
+
try {
|
|
91
|
+
envelope = JSON.parse(raw.trim());
|
|
92
|
+
} catch {
|
|
93
|
+
throw new Error('Claude returned non-JSON output');
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (envelope.is_error) {
|
|
97
|
+
throw new Error(envelope.result || 'Claude reported an error');
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const text = envelope.result ?? '';
|
|
101
|
+
if (!text.trim()) {
|
|
102
|
+
throw new Error('Claude returned an empty result');
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return text;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Extract JSON from Claude's text response
|
|
110
|
+
*/
|
|
111
|
+
function extractJsonFromResponse(text) {
|
|
112
|
+
const cleaned = text.trim();
|
|
113
|
+
|
|
114
|
+
// 1. Direct parse — Claude responded with pure JSON
|
|
115
|
+
try {
|
|
116
|
+
JSON.parse(cleaned);
|
|
117
|
+
return cleaned;
|
|
118
|
+
} catch { /* fall through */ }
|
|
119
|
+
|
|
120
|
+
// 2. Markdown code block — ```json ... ``` or ``` ... ```
|
|
121
|
+
const codeBlockMatch = cleaned.match(/```(?:json)?\s*([\s\S]*?)\s*```/);
|
|
122
|
+
if (codeBlockMatch) {
|
|
123
|
+
const inner = codeBlockMatch[1].trim();
|
|
124
|
+
try {
|
|
125
|
+
JSON.parse(inner);
|
|
126
|
+
return inner;
|
|
127
|
+
} catch { /* fall through */ }
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// 3. Loose extraction — grab first {...} block
|
|
131
|
+
const objMatch = cleaned.match(/\{[\s\S]*\}/);
|
|
132
|
+
if (objMatch) {
|
|
133
|
+
try {
|
|
134
|
+
JSON.parse(objMatch[0]);
|
|
135
|
+
return objMatch[0];
|
|
136
|
+
} catch { /* fall through */ }
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
throw new Error('No valid JSON object found in Claude response');
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Execute Claude to extract tickets from plan
|
|
144
|
+
*/
|
|
145
|
+
async function extractTicketsFromPlan(planContent) {
|
|
146
|
+
const prompt = createExtractionPrompt(planContent);
|
|
147
|
+
|
|
148
|
+
return new Promise((resolve, reject) => {
|
|
149
|
+
const env = { ...process.env };
|
|
150
|
+
delete env.ANTHROPIC_API_KEY;
|
|
151
|
+
|
|
152
|
+
const child = spawn('claude', ['--print', '--output-format', 'json'], {
|
|
153
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
154
|
+
env
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
let stdout = '';
|
|
158
|
+
let stderr = '';
|
|
159
|
+
|
|
160
|
+
child.stdout.on('data', (chunk) => {
|
|
161
|
+
stdout += chunk;
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
child.stderr.on('data', (chunk) => {
|
|
165
|
+
stderr += chunk;
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
child.on('error', (err) => {
|
|
169
|
+
reject(new Error(`Failed to spawn Claude: ${err.message}`));
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
child.on('close', (code) => {
|
|
173
|
+
if (!stdout.trim()) {
|
|
174
|
+
reject(new Error(stderr.trim() || `Claude exited with code ${code}`));
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
try {
|
|
179
|
+
const text = parseClaudeEnvelope(stdout);
|
|
180
|
+
const jsonStr = extractJsonFromResponse(text);
|
|
181
|
+
const result = JSON.parse(jsonStr);
|
|
182
|
+
resolve(result.tickets || []);
|
|
183
|
+
} catch (err) {
|
|
184
|
+
reject(new Error(`Failed to parse Claude response: ${err.message}`));
|
|
185
|
+
}
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
child.stdin.write(prompt, 'utf8');
|
|
189
|
+
child.stdin.end();
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Create a ticket in .vibe/tickets/
|
|
195
|
+
*/
|
|
196
|
+
function createTicketFile(ticketData, ticketsDir, ticketId) {
|
|
197
|
+
const slug = createSlug(ticketData.title);
|
|
198
|
+
const fileName = `${ticketId}-${slug}.md`;
|
|
199
|
+
const filePath = path.join(ticketsDir, fileName);
|
|
200
|
+
|
|
201
|
+
const acceptanceCriteria = (ticketData.acceptance_criteria || [])
|
|
202
|
+
.map(criterion => `- [ ] ${criterion}`)
|
|
203
|
+
.join('\n');
|
|
204
|
+
|
|
205
|
+
const content = `---
|
|
206
|
+
id: ${ticketId}
|
|
207
|
+
title: ${ticketData.title}
|
|
208
|
+
slug: ${slug}
|
|
209
|
+
description: ${ticketData.description || ''}
|
|
210
|
+
priority: ${ticketData.priority || 'medium'}
|
|
211
|
+
status: open
|
|
212
|
+
created_at: "${new Date().toISOString()}"
|
|
213
|
+
updated_at: "${new Date().toISOString()}"
|
|
214
|
+
estimated_hours: ${ticketData.estimated_hours || 0}
|
|
215
|
+
---
|
|
216
|
+
|
|
217
|
+
## Description
|
|
218
|
+
|
|
219
|
+
${ticketData.description || 'No description provided'}
|
|
220
|
+
|
|
221
|
+
## Acceptance Criteria
|
|
222
|
+
|
|
223
|
+
${acceptanceCriteria || '- [ ] Complete the work'}
|
|
224
|
+
|
|
225
|
+
## Notes
|
|
226
|
+
|
|
227
|
+
Generated from plan via \`vibe plan to-ticket\`.
|
|
228
|
+
`;
|
|
229
|
+
|
|
230
|
+
fs.writeFileSync(filePath, content, 'utf-8');
|
|
231
|
+
return { fileName, filePath };
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Main to-ticket subcommand
|
|
236
|
+
*/
|
|
237
|
+
async function toTicketCommand(args) {
|
|
238
|
+
try {
|
|
239
|
+
const { planFile, dryRun, autoCreate } = parseToTicketArgs(args);
|
|
240
|
+
|
|
241
|
+
// Validate plan file
|
|
242
|
+
const { filePath, content: planContent } = readPlanFile(planFile);
|
|
243
|
+
|
|
244
|
+
console.log(`📄 Reading plan: ${path.basename(filePath)}`);
|
|
245
|
+
console.log('');
|
|
246
|
+
|
|
247
|
+
// Extract tickets using Claude
|
|
248
|
+
console.log('🤖 Analyzing plan with Claude...');
|
|
249
|
+
const tickets = await extractTicketsFromPlan(planContent);
|
|
250
|
+
|
|
251
|
+
if (!tickets || tickets.length === 0) {
|
|
252
|
+
console.log('⚠️ No tickets extracted from plan.');
|
|
253
|
+
process.exit(0);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
console.log(`✅ Extracted ${tickets.length} ticket(s)\n`);
|
|
257
|
+
|
|
258
|
+
// Show extracted tickets
|
|
259
|
+
for (const ticket of tickets) {
|
|
260
|
+
console.log(`📋 ${ticket.title}`);
|
|
261
|
+
console.log(` Priority: ${ticket.priority || 'medium'}`);
|
|
262
|
+
console.log(` Est. hours: ${ticket.estimated_hours || '?'}`);
|
|
263
|
+
if (ticket.acceptance_criteria && ticket.acceptance_criteria.length > 0) {
|
|
264
|
+
console.log(` Criteria: ${ticket.acceptance_criteria.length} items`);
|
|
265
|
+
}
|
|
266
|
+
console.log('');
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
if (dryRun) {
|
|
270
|
+
console.log('🏁 Dry run complete. No tickets created.');
|
|
271
|
+
process.exit(0);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
if (!autoCreate) {
|
|
275
|
+
console.log('💡 Use --auto to create these tickets automatically.');
|
|
276
|
+
process.exit(0);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// Create tickets
|
|
280
|
+
const ticketsDir = getTicketsDir();
|
|
281
|
+
if (!fs.existsSync(ticketsDir)) {
|
|
282
|
+
fs.mkdirSync(ticketsDir, { recursive: true });
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
console.log('✨ Creating tickets...\n');
|
|
286
|
+
|
|
287
|
+
const createdTickets = [];
|
|
288
|
+
for (const ticketData of tickets) {
|
|
289
|
+
try {
|
|
290
|
+
const ticketId = getNextTicketId(ticketsDir);
|
|
291
|
+
const result = createTicketFile(ticketData, ticketsDir, ticketId);
|
|
292
|
+
createdTickets.push({ id: ticketId, ...result });
|
|
293
|
+
console.log(` ✅ ${ticketId}: ${ticketData.title}`);
|
|
294
|
+
} catch (error) {
|
|
295
|
+
console.error(` ❌ Failed to create ticket: ${error.message}`);
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
console.log('');
|
|
300
|
+
console.log(`🎉 Created ${createdTickets.length} ticket(s)`);
|
|
301
|
+
console.log('');
|
|
302
|
+
console.log('Next steps:');
|
|
303
|
+
console.log(' vibe list # See all tickets');
|
|
304
|
+
console.log(' vibe start TKT-XXX # Start working on a ticket');
|
|
305
|
+
|
|
306
|
+
} catch (error) {
|
|
307
|
+
console.error(`❌ ${error.message}`);
|
|
308
|
+
process.exit(1);
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
export default toTicketCommand;
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { jest } from '@jest/globals';
|
|
2
|
+
import fs from 'fs';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
|
|
5
|
+
let ticketIdCounter = 1;
|
|
6
|
+
jest.unstable_mockModule('../../utils/index.js', () => ({
|
|
7
|
+
getTicketsDir: jest.fn(),
|
|
8
|
+
getConfig: jest.fn(() => ({})),
|
|
9
|
+
createSlug: jest.fn(title => title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '')),
|
|
10
|
+
getNextTicketId: jest.fn(() => `TKT-${String(ticketIdCounter++).padStart(3, '0')}`)
|
|
11
|
+
}));
|
|
12
|
+
|
|
13
|
+
const mockChild = {
|
|
14
|
+
stdout: { on: jest.fn() },
|
|
15
|
+
stderr: { on: jest.fn() },
|
|
16
|
+
stdin: { write: jest.fn(), end: jest.fn() },
|
|
17
|
+
on: jest.fn()
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
jest.unstable_mockModule('child_process', () => ({
|
|
21
|
+
spawn: jest.fn(() => mockChild)
|
|
22
|
+
}));
|
|
23
|
+
|
|
24
|
+
const { default: toTicketCommand } = await import('./to-ticket.js');
|
|
25
|
+
const { getTicketsDir } = await import('../../utils/index.js');
|
|
26
|
+
const childProcess = await import('child_process');
|
|
27
|
+
|
|
28
|
+
const PLAN_CONTENT = `# Feature Plan
|
|
29
|
+
|
|
30
|
+
## Goals
|
|
31
|
+
- Build a login page
|
|
32
|
+
- Add a settings page
|
|
33
|
+
`;
|
|
34
|
+
|
|
35
|
+
const CLAUDE_TICKETS = {
|
|
36
|
+
tickets: [
|
|
37
|
+
{
|
|
38
|
+
title: 'Build login page',
|
|
39
|
+
description: 'Create the login form',
|
|
40
|
+
acceptance_criteria: ['Form renders', 'Submits credentials'],
|
|
41
|
+
priority: 'high',
|
|
42
|
+
estimated_hours: 4
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
title: 'Add settings page',
|
|
46
|
+
description: 'User settings screen',
|
|
47
|
+
acceptance_criteria: ['Settings persist'],
|
|
48
|
+
priority: 'medium',
|
|
49
|
+
estimated_hours: 2
|
|
50
|
+
}
|
|
51
|
+
]
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
// Drive the mocked Claude process: feed it a JSON envelope and close.
|
|
55
|
+
function primeClaude(resultObj, { isError = false } = {}) {
|
|
56
|
+
const envelope = JSON.stringify({
|
|
57
|
+
is_error: isError,
|
|
58
|
+
result: isError ? 'boom' : JSON.stringify(resultObj)
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
mockChild.stdout.on.mockImplementation((event, cb) => {
|
|
62
|
+
if (event === 'data') cb(Buffer.from(envelope));
|
|
63
|
+
});
|
|
64
|
+
mockChild.stderr.on.mockImplementation(() => {});
|
|
65
|
+
mockChild.on.mockImplementation((event, cb) => {
|
|
66
|
+
if (event === 'close') cb(0);
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
describe('plan to-ticket command', () => {
|
|
71
|
+
let tempDir;
|
|
72
|
+
let ticketsDir;
|
|
73
|
+
let planFile;
|
|
74
|
+
let mockExit;
|
|
75
|
+
let mockConsoleLog;
|
|
76
|
+
let mockConsoleError;
|
|
77
|
+
|
|
78
|
+
beforeEach(() => {
|
|
79
|
+
ticketIdCounter = 1;
|
|
80
|
+
tempDir = fs.mkdtempSync(path.join('/tmp', 'vibe-to-ticket-'));
|
|
81
|
+
ticketsDir = path.join(tempDir, '.vibe', 'tickets');
|
|
82
|
+
fs.mkdirSync(ticketsDir, { recursive: true });
|
|
83
|
+
planFile = path.join(tempDir, 'plan.md');
|
|
84
|
+
fs.writeFileSync(planFile, PLAN_CONTENT);
|
|
85
|
+
|
|
86
|
+
getTicketsDir.mockReturnValue(ticketsDir);
|
|
87
|
+
mockExit = jest.spyOn(process, 'exit').mockImplementation(() => { throw new Error('process.exit'); });
|
|
88
|
+
mockConsoleLog = jest.spyOn(console, 'log').mockImplementation(() => {});
|
|
89
|
+
mockConsoleError = jest.spyOn(console, 'error').mockImplementation(() => {});
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
afterEach(() => {
|
|
93
|
+
mockExit.mockRestore();
|
|
94
|
+
mockConsoleLog.mockRestore();
|
|
95
|
+
mockConsoleError.mockRestore();
|
|
96
|
+
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
97
|
+
jest.clearAllMocks();
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test('errors when plan file is missing', async () => {
|
|
101
|
+
await expect(toTicketCommand(['/tmp/does-not-exist.md'])).rejects.toThrow('process.exit');
|
|
102
|
+
expect(mockConsoleError).toHaveBeenCalledWith(expect.stringContaining('Plan file not found'));
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test('dry-run extracts tickets without creating files', async () => {
|
|
106
|
+
primeClaude(CLAUDE_TICKETS);
|
|
107
|
+
await expect(toTicketCommand([planFile, '--dry-run'])).rejects.toThrow('process.exit');
|
|
108
|
+
expect(mockConsoleLog).toHaveBeenCalledWith(expect.stringContaining('Dry run complete'));
|
|
109
|
+
expect(fs.readdirSync(ticketsDir)).toHaveLength(0);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test('preview (no --auto) does not create tickets', async () => {
|
|
113
|
+
primeClaude(CLAUDE_TICKETS);
|
|
114
|
+
await expect(toTicketCommand([planFile])).rejects.toThrow('process.exit');
|
|
115
|
+
expect(mockConsoleLog).toHaveBeenCalledWith(expect.stringContaining('--auto'));
|
|
116
|
+
expect(fs.readdirSync(ticketsDir)).toHaveLength(0);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test('--auto creates one ticket file per extracted item', async () => {
|
|
120
|
+
primeClaude(CLAUDE_TICKETS);
|
|
121
|
+
await toTicketCommand([planFile, '--auto']);
|
|
122
|
+
const files = fs.readdirSync(ticketsDir).sort();
|
|
123
|
+
expect(files).toEqual([
|
|
124
|
+
'TKT-001-build-login-page.md',
|
|
125
|
+
'TKT-002-add-settings-page.md'
|
|
126
|
+
].sort());
|
|
127
|
+
|
|
128
|
+
const first = fs.readFileSync(path.join(ticketsDir, 'TKT-001-build-login-page.md'), 'utf-8');
|
|
129
|
+
expect(first).toContain('id: TKT-001');
|
|
130
|
+
expect(first).toContain('priority: high');
|
|
131
|
+
expect(first).toContain('- [ ] Form renders');
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test('surfaces Claude errors', async () => {
|
|
135
|
+
primeClaude(null, { isError: true });
|
|
136
|
+
await expect(toTicketCommand([planFile, '--auto'])).rejects.toThrow('process.exit');
|
|
137
|
+
expect(mockConsoleError).toHaveBeenCalledWith(expect.stringContaining('boom'));
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test('spawns claude with json output format', async () => {
|
|
141
|
+
primeClaude(CLAUDE_TICKETS);
|
|
142
|
+
await toTicketCommand([planFile, '--auto']);
|
|
143
|
+
expect(childProcess.spawn).toHaveBeenCalledWith(
|
|
144
|
+
'claude',
|
|
145
|
+
expect.arrayContaining(['--print', '--output-format', 'json']),
|
|
146
|
+
expect.any(Object)
|
|
147
|
+
);
|
|
148
|
+
});
|
|
149
|
+
});
|