@vibedx/vibekit 0.8.3 → 0.8.6
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 +1 -1
- package/index.js +1 -1
- package/package.json +1 -1
- package/skills/vibekit/SKILL.md +4 -0
- package/src/commands/new/index.js +31 -5
- package/src/commands/start/index.js +7 -3
- package/src/commands/stats/index.js +117 -0
- package/src/commands/stats/index.test.js +115 -0
- package/src/utils/test-helpers.js +1 -0
package/README.md
CHANGED
package/index.js
CHANGED
package/package.json
CHANGED
package/skills/vibekit/SKILL.md
CHANGED
|
@@ -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:
|
|
@@ -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
|
-
|
|
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 (
|
|
120
|
-
|
|
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(/^
|
|
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
|
+
});
|
|
@@ -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
|
|