@vibedx/vibekit 0.8.8 → 0.10.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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vibedx/vibekit",
3
- "version": "0.8.8",
3
+ "version": "0.10.0",
4
4
  "description": "A powerful CLI tool for managing development tickets and project workflows",
5
5
  "main": "index.js",
6
6
  "type": "module",
@@ -8,7 +8,8 @@
8
8
  "index.js",
9
9
  "src/",
10
10
  "assets/",
11
- "skills/"
11
+ "skills/",
12
+ "vibekit-plugin/"
12
13
  ],
13
14
  "bin": {
14
15
  "vibe": "index.js"
@@ -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
+ });