@vibedx/vibekit 0.8.3 → 0.8.7

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
@@ -20,7 +20,7 @@
20
20
 
21
21
  **A CLI tool to help you vibe your code better** ✨
22
22
  *Vibe your development workflow*
23
- _vibekit uses vibekit to develop vibekit - we vibin!_ 🔄
23
+ _vibekit uses vibekit to develop vibekit._ 🔄
24
24
 
25
25
  </div>
26
26
 
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', 'pr'
27
+ 'status', 'stats', 'pr'
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.3",
3
+ "version": "0.8.7",
4
4
  "description": "A powerful CLI tool for managing development tickets and project workflows",
5
5
  "main": "index.js",
6
6
  "type": "module",
@@ -86,8 +86,12 @@ vibe new "Add dark mode" --assignee bob --author alice -n
86
86
  - `--status open|in_progress|review|done` (default: open)
87
87
  - `--assignee <username>` — who works on it
88
88
  - `--author <username>` — who created it
89
+ - `-d` / `--description "text"` — pre-fill the Description section
90
+ - `--acceptance-criteria "- [ ] criterion 1\n- [ ] criterion 2"` — pre-fill Acceptance Criteria
89
91
  - `-n` / `--no-interactive` — skip AI enhancement prompt
90
92
 
93
+ **AI agents should always provide `--description` and `--acceptance-criteria` when creating tickets.** This ensures tickets are actionable immediately without needing `vibe refine`. Fill in as much detail as possible based on the task context.
94
+
91
95
  ## Ticket Structure
92
96
 
93
97
  Tickets live in `.vibe/tickets/` as markdown files with YAML frontmatter:
@@ -13,26 +13,20 @@ import { getTicketsDir, getConfig, getNextTicketId, createSlug } from '../../uti
13
13
  function createSampleTicket(title, description, priority = "medium", status = "open") {
14
14
  const configPath = path.join(process.cwd(), ".vibe", "config.yml");
15
15
  const templatePath = path.join(process.cwd(), ".vibe", ".templates", "default.md");
16
-
16
+
17
17
  if (!fs.existsSync(configPath) || !fs.existsSync(templatePath)) {
18
18
  console.error("❌ Missing config.yml or default.md template.");
19
19
  return false;
20
20
  }
21
-
21
+
22
22
  const config = yaml.load(fs.readFileSync(configPath, "utf-8"));
23
23
  const template = fs.readFileSync(templatePath, "utf-8");
24
24
  const ticketDir = path.join(process.cwd(), config.tickets?.path || ".vibe/tickets");
25
-
26
- const files = fs.readdirSync(ticketDir);
27
- const ticketNumbers = files
28
- .map(f => f.match(/^TKT-(\\d+)/))
29
- .filter(Boolean)
30
- .map(match => parseInt(match[1], 10));
31
- const nextId = Math.max(0, ...ticketNumbers) + 1;
32
- const paddedId = String(nextId).padStart(3, "0");
25
+
26
+ const ticketId = getNextTicketId();
27
+ const paddedId = ticketId.replace('TKT-', '');
33
28
  const now = new Date().toISOString();
34
-
35
- const ticketId = `TKT-${paddedId}`;
29
+
36
30
  const slug = title.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
37
31
  const filename = `${ticketId}-${slug}.md`;
38
32
 
@@ -34,6 +34,8 @@ function parseArguments(args) {
34
34
  let status = DEFAULT_STATUS;
35
35
  let assignee = '';
36
36
  let author = '';
37
+ let description = '';
38
+ let acceptanceCriteria = '';
37
39
  let noInteractive = false;
38
40
 
39
41
  for (let i = 0; i < args.length; i++) {
@@ -51,6 +53,12 @@ function parseArguments(args) {
51
53
  } else if (arg === '--author' && i + 1 < args.length) {
52
54
  author = args[i + 1];
53
55
  i++;
56
+ } else if ((arg === '--description' || arg === '-d') && i + 1 < args.length) {
57
+ description = args[i + 1];
58
+ i++;
59
+ } else if (arg === '--acceptance-criteria' && i + 1 < args.length) {
60
+ acceptanceCriteria = args[i + 1];
61
+ i++;
54
62
  } else if (arg === '--no-interactive' || arg === '-n') {
55
63
  noInteractive = true;
56
64
  } else if (!arg.startsWith('--')) {
@@ -64,7 +72,7 @@ function parseArguments(args) {
64
72
  throw new Error('Please provide a title for the new ticket.');
65
73
  }
66
74
 
67
- return { title, priority, status, assignee, author, noInteractive };
75
+ return { title, priority, status, assignee, author, description, acceptanceCriteria, noInteractive };
68
76
  }
69
77
 
70
78
  /**
@@ -103,11 +111,11 @@ function createTicketContent(template, ticketData) {
103
111
  if (typeof template !== 'string') {
104
112
  throw new Error('Template must be a string');
105
113
  }
106
-
107
- const { ticketId, title, slug, priority, status, assignee, author, timestamp } = ticketData;
114
+
115
+ const { ticketId, title, slug, priority, status, assignee, author, description, acceptanceCriteria, timestamp } = ticketData;
108
116
  const paddedId = ticketId.replace('TKT-', '');
109
117
 
110
- return template
118
+ let content = template
111
119
  .replace(/{id}/g, paddedId)
112
120
  .replace(/{title}/g, title)
113
121
  .replace(/{slug}/g, slug)
@@ -116,6 +124,22 @@ function createTicketContent(template, ticketData) {
116
124
  .replace(/^status: .*$/m, `status: ${status}`)
117
125
  .replace(/^assignee: .*$/m, `assignee: "${assignee || ''}"`)
118
126
  .replace(/^author: .*$/m, `author: "${author || ''}"`);
127
+
128
+ if (description) {
129
+ content = content.replace(
130
+ /## Description\n\n<!-- .+? -->/s,
131
+ `## Description\n\n${description}`
132
+ );
133
+ }
134
+
135
+ if (acceptanceCriteria) {
136
+ content = content.replace(
137
+ /## Acceptance Criteria\n\n<!-- .+? -->/s,
138
+ `## Acceptance Criteria\n\n${acceptanceCriteria}`
139
+ );
140
+ }
141
+
142
+ return content;
119
143
  }
120
144
 
121
145
  /**
@@ -206,7 +230,7 @@ async function handleFileRename(originalPath, ticketDir) {
206
230
  async function newCommand(args) {
207
231
  try {
208
232
  // Parse and validate arguments
209
- const { title, priority, status, assignee, author, noInteractive } = parseArguments(args);
233
+ const { title, priority, status, assignee, author, description, acceptanceCriteria, noInteractive } = parseArguments(args);
210
234
 
211
235
  // Check required files and paths
212
236
  const configPath = path.join(process.cwd(), '.vibe', 'config.yml');
@@ -251,6 +275,8 @@ async function newCommand(args) {
251
275
  status: validatedOptions.status,
252
276
  assignee,
253
277
  author,
278
+ description,
279
+ acceptanceCriteria,
254
280
  timestamp
255
281
  };
256
282
 
@@ -116,10 +116,14 @@ function updateTicketStatus(ticket, worktreePath) {
116
116
  const now = new Date().toISOString();
117
117
  let updatedContent = ticket.content;
118
118
 
119
- if (updatedContent.match(/^worktree_path: .+$/m)) {
120
- updatedContent = updatedContent.replace(/^worktree_path: .+$/m, `worktree_path: "${worktreePath}"`);
119
+ if (worktreePath) {
120
+ if (updatedContent.match(/^worktree_path: .+$/m)) {
121
+ updatedContent = updatedContent.replace(/^worktree_path: .+$/m, `worktree_path: "${worktreePath}"`);
122
+ } else {
123
+ updatedContent = updatedContent.replace(/^(updated_at: .+)$/m, `$1\nworktree_path: "${worktreePath}"`);
124
+ }
121
125
  } else {
122
- updatedContent = updatedContent.replace(/^(updated_at: .+)$/m, `$1\nworktree_path: "${worktreePath}"`);
126
+ updatedContent = updatedContent.replace(/^worktree_path: .+\n?/m, '');
123
127
  }
124
128
 
125
129
  updatedContent = updatedContent
@@ -0,0 +1,117 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import yaml from 'js-yaml';
4
+ import { getTicketsDir } from '../../utils/index.js';
5
+
6
+ function parseTickets(ticketsDir) {
7
+ if (!fs.existsSync(ticketsDir)) {
8
+ return [];
9
+ }
10
+
11
+ const files = fs.readdirSync(ticketsDir).filter(f => f.endsWith('.md'));
12
+ const tickets = [];
13
+
14
+ for (const file of files) {
15
+ try {
16
+ const content = fs.readFileSync(path.join(ticketsDir, file), 'utf-8');
17
+ const match = content.match(/^---\n([\s\S]*?)\n---/);
18
+ if (match) {
19
+ const fm = yaml.load(match[1]);
20
+ tickets.push({
21
+ id: fm.id || 'Unknown',
22
+ title: fm.title || 'Untitled',
23
+ status: fm.status || 'open',
24
+ priority: fm.priority || 'medium',
25
+ assignee: fm.assignee || fm.owner || '',
26
+ created_at: fm.created_at || null,
27
+ });
28
+ }
29
+ } catch {
30
+ // skip unparseable tickets
31
+ }
32
+ }
33
+
34
+ return tickets;
35
+ }
36
+
37
+ function bar(count, max, width = 20) {
38
+ if (max === 0) return '';
39
+ const filled = Math.round((count / max) * width);
40
+ return '█'.repeat(filled) + '░'.repeat(width - filled);
41
+ }
42
+
43
+ function statsCommand(args) {
44
+ const ticketsDir = getTicketsDir();
45
+ const tickets = parseTickets(ticketsDir);
46
+
47
+ if (tickets.length === 0) {
48
+ console.log('📭 No tickets found. Create one with: vibe new "title"');
49
+ return;
50
+ }
51
+
52
+ const byStatus = {};
53
+ const byPriority = {};
54
+ const byAssignee = {};
55
+
56
+ for (const t of tickets) {
57
+ byStatus[t.status] = (byStatus[t.status] || 0) + 1;
58
+ byPriority[t.priority] = (byPriority[t.priority] || 0) + 1;
59
+ if (t.assignee) {
60
+ byAssignee[t.assignee] = (byAssignee[t.assignee] || 0) + 1;
61
+ }
62
+ }
63
+
64
+ const total = tickets.length;
65
+ const done = byStatus['done'] || 0;
66
+ const inProgress = byStatus['in_progress'] || 0;
67
+ const open = byStatus['open'] || 0;
68
+
69
+ console.log('\n📊 VibeKit Stats\n');
70
+ console.log(` Total tickets: ${total}`);
71
+ console.log(` Completion: ${done}/${total} (${total > 0 ? Math.round((done / total) * 100) : 0}%)\n`);
72
+
73
+ // Status breakdown
74
+ console.log(' Status');
75
+ const statusOrder = ['open', 'in_progress', 'review', 'done'];
76
+ const statusLabels = { open: '🔵 open', in_progress: '🟡 in_progress', review: '🔵 review', done: '🟢 done' };
77
+ const allStatuses = [...new Set([...statusOrder, ...Object.keys(byStatus)])];
78
+ const maxStatusCount = Math.max(...Object.values(byStatus));
79
+
80
+ for (const s of allStatuses) {
81
+ const count = byStatus[s] || 0;
82
+ if (count === 0) continue;
83
+ const label = statusLabels[s] || `⚪ ${s}`;
84
+ console.log(` ${label.padEnd(18)} ${bar(count, maxStatusCount, 15)} ${count}`);
85
+ }
86
+
87
+ // Priority breakdown
88
+ if (Object.keys(byPriority).length > 0) {
89
+ console.log('\n Priority');
90
+ const priorityOrder = ['critical', 'high', 'medium', 'low'];
91
+ const priorityLabels = { critical: '🔴 critical', high: '🟠 high', medium: '🟡 medium', low: '🟢 low' };
92
+ const allPriorities = [...new Set([...priorityOrder, ...Object.keys(byPriority)])];
93
+ const maxPriorityCount = Math.max(...Object.values(byPriority));
94
+
95
+ for (const p of allPriorities) {
96
+ const count = byPriority[p] || 0;
97
+ if (count === 0) continue;
98
+ const label = priorityLabels[p] || `⚪ ${p}`;
99
+ console.log(` ${label.padEnd(18)} ${bar(count, maxPriorityCount, 15)} ${count}`);
100
+ }
101
+ }
102
+
103
+ // Assignee breakdown
104
+ if (Object.keys(byAssignee).length > 0) {
105
+ console.log('\n Assignees');
106
+ const maxAssigneeCount = Math.max(...Object.values(byAssignee));
107
+ const sorted = Object.entries(byAssignee).sort((a, b) => b[1] - a[1]);
108
+
109
+ for (const [name, count] of sorted) {
110
+ console.log(` ${name.padEnd(18)} ${bar(count, maxAssigneeCount, 15)} ${count}`);
111
+ }
112
+ }
113
+
114
+ console.log('');
115
+ }
116
+
117
+ export default statsCommand;
@@ -0,0 +1,115 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from '@jest/globals';
2
+ import {
3
+ createTempDir,
4
+ cleanupTempDir,
5
+ mockConsole,
6
+ mockProcessCwd,
7
+ createMockVibeProject
8
+ } from '../../utils/test-helpers.js';
9
+ import statsCommand from './index.js';
10
+
11
+ describe('stats command', () => {
12
+ let tempDir;
13
+ let consoleMock;
14
+ let restoreCwd;
15
+
16
+ beforeEach(() => {
17
+ tempDir = createTempDir('stats-test');
18
+ consoleMock = mockConsole();
19
+ restoreCwd = mockProcessCwd(tempDir);
20
+ });
21
+
22
+ afterEach(() => {
23
+ consoleMock.restore();
24
+ restoreCwd();
25
+ cleanupTempDir(tempDir);
26
+ });
27
+
28
+ it('should show empty message when no tickets exist', () => {
29
+ createMockVibeProject(tempDir);
30
+
31
+ statsCommand([]);
32
+
33
+ expect(consoleMock.logs.log.some(l => l.includes('No tickets found'))).toBe(true);
34
+ });
35
+
36
+ it('should show total count and completion percentage', () => {
37
+ createMockVibeProject(tempDir, {
38
+ withTickets: [
39
+ { id: 'TKT-001', title: 'Open task', status: 'open', priority: 'high', slug: 'open-task' },
40
+ { id: 'TKT-002', title: 'Done task', status: 'done', priority: 'medium', slug: 'done-task' },
41
+ { id: 'TKT-003', title: 'WIP task', status: 'in_progress', priority: 'low', slug: 'wip-task' },
42
+ ]
43
+ });
44
+
45
+ statsCommand([]);
46
+
47
+ const output = consoleMock.logs.log.join('\n');
48
+ expect(output).toContain('Total tickets: 3');
49
+ expect(output).toContain('1/3');
50
+ expect(output).toContain('33%');
51
+ });
52
+
53
+ it('should show status breakdown', () => {
54
+ createMockVibeProject(tempDir, {
55
+ withTickets: [
56
+ { id: 'TKT-001', title: 'A', status: 'open', priority: 'medium', slug: 'a' },
57
+ { id: 'TKT-002', title: 'B', status: 'open', priority: 'medium', slug: 'b' },
58
+ { id: 'TKT-003', title: 'C', status: 'done', priority: 'high', slug: 'c' },
59
+ ]
60
+ });
61
+
62
+ statsCommand([]);
63
+
64
+ const output = consoleMock.logs.log.join('\n');
65
+ expect(output).toContain('Status');
66
+ expect(output).toContain('open');
67
+ expect(output).toContain('done');
68
+ });
69
+
70
+ it('should show priority breakdown', () => {
71
+ createMockVibeProject(tempDir, {
72
+ withTickets: [
73
+ { id: 'TKT-001', title: 'A', status: 'open', priority: 'critical', slug: 'a' },
74
+ { id: 'TKT-002', title: 'B', status: 'open', priority: 'low', slug: 'b' },
75
+ ]
76
+ });
77
+
78
+ statsCommand([]);
79
+
80
+ const output = consoleMock.logs.log.join('\n');
81
+ expect(output).toContain('Priority');
82
+ expect(output).toContain('critical');
83
+ expect(output).toContain('low');
84
+ });
85
+
86
+ it('should show assignee breakdown when tickets have assignees', () => {
87
+ createMockVibeProject(tempDir, {
88
+ withTickets: [
89
+ { id: 'TKT-001', title: 'A', status: 'open', priority: 'medium', slug: 'a', assignee: 'alice' },
90
+ { id: 'TKT-002', title: 'B', status: 'open', priority: 'medium', slug: 'b', assignee: 'alice' },
91
+ { id: 'TKT-003', title: 'C', status: 'done', priority: 'medium', slug: 'c', assignee: 'bob' },
92
+ ]
93
+ });
94
+
95
+ statsCommand([]);
96
+
97
+ const output = consoleMock.logs.log.join('\n');
98
+ expect(output).toContain('Assignees');
99
+ expect(output).toContain('alice');
100
+ expect(output).toContain('bob');
101
+ });
102
+
103
+ it('should handle tickets with no assignees gracefully', () => {
104
+ createMockVibeProject(tempDir, {
105
+ withTickets: [
106
+ { id: 'TKT-001', title: 'A', status: 'open', priority: 'medium', slug: 'a' },
107
+ ]
108
+ });
109
+
110
+ statsCommand([]);
111
+
112
+ const output = consoleMock.logs.log.join('\n');
113
+ expect(output).not.toContain('Assignees');
114
+ });
115
+ });
package/src/utils/git.js CHANGED
@@ -49,7 +49,7 @@ function branchExistsLocally(branchName) {
49
49
  */
50
50
  function branchExistsRemotely(branchName) {
51
51
  try {
52
- const result = execSync(`git ls-remote --heads origin ${branchName}`, { encoding: 'utf-8' });
52
+ const result = execSync(`git ls-remote --heads origin ${branchName}`, { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'ignore'] });
53
53
  return result.trim() !== '';
54
54
  } catch (error) {
55
55
  return false;
@@ -158,7 +158,7 @@ function getMainWorktreeRoot() {
158
158
 
159
159
  function getRepoName() {
160
160
  try {
161
- const remoteUrl = execSync('git remote get-url origin', { encoding: 'utf-8' }).trim();
161
+ const remoteUrl = execSync('git remote get-url origin', { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'ignore'] }).trim();
162
162
  // Handle git@github.com:org/repo.git
163
163
  let match = remoteUrl.match(/[:/]([^/]+?)(?:\.git)?$/);
164
164
  if (match) return match[1];
@@ -134,7 +134,7 @@ describe('git utilities', () => {
134
134
 
135
135
  // Assert
136
136
  expect(result).toBe(true);
137
- expect(mockExecSync).toHaveBeenCalledWith('git ls-remote --heads origin feature/test', { encoding: 'utf-8' });
137
+ expect(mockExecSync).toHaveBeenCalledWith('git ls-remote --heads origin feature/test', { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'ignore'] });
138
138
  });
139
139
 
140
140
  it('should return false when branch does not exist remotely', () => {
@@ -219,6 +219,7 @@ Always use \`vibe start\` to start working on this ticket and \`vibe close\` to
219
219
  if (ticket.slug !== undefined) frontmatterLines.push(`slug: ${ticket.slug}`);
220
220
  if (ticket.status !== undefined) frontmatterLines.push(`status: ${ticket.status}`);
221
221
  if (ticket.priority !== undefined) frontmatterLines.push(`priority: ${ticket.priority}`);
222
+ if (ticket.assignee !== undefined) frontmatterLines.push(`assignee: ${ticket.assignee}`);
222
223
  if (ticket.created_at !== undefined) frontmatterLines.push(`created_at: ${ticket.created_at}`);
223
224
  if (ticket.updated_at !== undefined) frontmatterLines.push(`updated_at: ${ticket.updated_at}`);
224
225