@the-bearded-bear/claude-craft 7.35.0 → 8.1.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/Dev/scripts/install-php-rules.sh +1 -1
- package/Dev/scripts/validate-skills-spec.sh +121 -0
- package/README.md +13 -11
- package/cli/index.js +6 -0
- package/cli/kanban/client/index.html +17 -0
- package/cli/kanban/client/src/App.svelte +106 -0
- package/cli/kanban/client/src/app.css +175 -0
- package/cli/kanban/client/src/lib/router.svelte.js +19 -0
- package/cli/kanban/client/src/lib/store.svelte.js +132 -0
- package/cli/kanban/client/src/main.js +6 -0
- package/cli/kanban/client/src/views/BacklogView.svelte +344 -0
- package/cli/kanban/client/src/views/BurndownView.svelte +189 -0
- package/cli/kanban/client/src/views/DepsView.svelte +334 -0
- package/cli/kanban/client/src/views/DocsView.svelte +451 -0
- package/cli/kanban/client/src/views/KanbanView.svelte +227 -0
- package/cli/kanban/client/vite.config.js +21 -0
- package/cli/kanban/server/app.js +201 -0
- package/cli/kanban/server/middleware/security.js +53 -0
- package/cli/kanban/server/services/event-bus.js +33 -0
- package/cli/kanban/server/services/file-scanner.js +113 -0
- package/cli/kanban/server/services/file-watcher.js +68 -0
- package/cli/kanban/server/services/file-writer.js +107 -0
- package/cli/kanban/server/services/frontmatter.js +55 -0
- package/cli/kanban/server/services/repository.js +173 -0
- package/cli/kanban/server/services/sprint-cache.js +208 -0
- package/cli/kanban/server/services/state-machine.js +156 -0
- package/cli/kanban/shared/schemas.js +127 -0
- package/cli/lib/help.js +4 -0
- package/cli/lib/kanban.js +103 -0
- package/package.json +21 -3
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { readFile } from 'node:fs/promises';
|
|
3
|
+
import { scan, groupByCategory } from './file-scanner.js';
|
|
4
|
+
import { parseAndValidate } from './frontmatter.js';
|
|
5
|
+
import { StoryFrontmatterSchema, EpicFrontmatterSchema, TaskFrontmatterSchema } from '../../shared/schemas.js';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* In-memory cache of parsed project-management/ entities.
|
|
9
|
+
* Rebuilt via refresh() ; individual files can be reloaded via reloadFile(path).
|
|
10
|
+
*/
|
|
11
|
+
export class Repository {
|
|
12
|
+
constructor(rootDir) {
|
|
13
|
+
this.rootDir = path.resolve(rootDir);
|
|
14
|
+
this.stories = new Map(); // id -> { data, body, path, mtime, valid, errors? }
|
|
15
|
+
this.epics = new Map();
|
|
16
|
+
this.tasks = new Map();
|
|
17
|
+
this.docs = new Map(); // category -> [{ path, rel }]
|
|
18
|
+
this.sprints = new Map(); // sprintId -> { goal?, tasks: string[], files: [] }
|
|
19
|
+
this.filesByPath = new Map();
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async refresh() {
|
|
23
|
+
const { files } = await scan(this.rootDir);
|
|
24
|
+
this.stories.clear();
|
|
25
|
+
this.epics.clear();
|
|
26
|
+
this.tasks.clear();
|
|
27
|
+
this.docs.clear();
|
|
28
|
+
this.sprints.clear();
|
|
29
|
+
this.filesByPath.clear();
|
|
30
|
+
|
|
31
|
+
const groups = groupByCategory(files);
|
|
32
|
+
|
|
33
|
+
for (const f of groups.story ?? []) {
|
|
34
|
+
const res = await parseAndValidate(f.path, StoryFrontmatterSchema);
|
|
35
|
+
const entry = { path: f.path, mtime: f.mtime, ...res };
|
|
36
|
+
if (res.ok) {
|
|
37
|
+
this.stories.set(res.data.id, entry);
|
|
38
|
+
} else {
|
|
39
|
+
const idMatch = path.basename(f.path).match(/US-\d+/);
|
|
40
|
+
if (idMatch) this.stories.set(idMatch[0], entry);
|
|
41
|
+
}
|
|
42
|
+
this.filesByPath.set(f.path, { kind: 'story', id: entry.ok ? entry.data.id : null });
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
for (const f of groups.epic ?? []) {
|
|
46
|
+
const res = await parseAndValidate(f.path, EpicFrontmatterSchema);
|
|
47
|
+
const entry = { path: f.path, mtime: f.mtime, ...res };
|
|
48
|
+
if (res.ok) this.epics.set(res.data.id, entry);
|
|
49
|
+
this.filesByPath.set(f.path, { kind: 'epic', id: res.ok ? res.data.id : null });
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
for (const f of groups.task ?? []) {
|
|
53
|
+
const res = await parseAndValidate(f.path, TaskFrontmatterSchema);
|
|
54
|
+
const entry = { path: f.path, mtime: f.mtime, sprintId: f.sprintId, ...res };
|
|
55
|
+
if (res.ok) this.tasks.set(res.data.id, entry);
|
|
56
|
+
this.filesByPath.set(f.path, { kind: 'task', id: res.ok ? res.data.id : null });
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Docs (root + architecture)
|
|
60
|
+
for (const category of ['doc', 'architecture']) {
|
|
61
|
+
for (const f of groups[category] ?? []) {
|
|
62
|
+
const rel = path.relative(this.rootDir, f.path).split(path.sep).join('/');
|
|
63
|
+
const arr = this.docs.get(category) ?? [];
|
|
64
|
+
arr.push({ path: f.path, rel });
|
|
65
|
+
this.docs.set(category, arr);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Sprints aggregation
|
|
70
|
+
for (const f of files) {
|
|
71
|
+
if (!f.sprintId) continue;
|
|
72
|
+
const s = this.sprints.get(f.sprintId) ?? { id: f.sprintId, files: [], tasks: [] };
|
|
73
|
+
s.files.push(f);
|
|
74
|
+
if (f.category === 'task') s.tasks.push(f.path);
|
|
75
|
+
if (f.category === 'sprint-goal') s.goalPath = f.path;
|
|
76
|
+
this.sprints.set(f.sprintId, s);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
listStories({ sprintId, status, epicId } = {}) {
|
|
81
|
+
const out = [];
|
|
82
|
+
for (const [, entry] of this.stories) {
|
|
83
|
+
if (!entry.ok) continue;
|
|
84
|
+
const d = entry.data;
|
|
85
|
+
if (sprintId && d.sprint_id !== sprintId) continue;
|
|
86
|
+
if (status && d.status !== status) continue;
|
|
87
|
+
if (epicId && d.epic_id !== epicId) continue;
|
|
88
|
+
out.push({ ...d, _mtime: entry.mtime });
|
|
89
|
+
}
|
|
90
|
+
return out.sort((a, b) => a.id.localeCompare(b.id));
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
listEpics() {
|
|
94
|
+
return [...this.epics.values()].filter((e) => e.ok).map((e) => ({ ...e.data, _mtime: e.mtime }));
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
getStory(id) {
|
|
98
|
+
const entry = this.stories.get(id);
|
|
99
|
+
if (!entry || !entry.ok) return null;
|
|
100
|
+
return { ...entry.data, _mtime: entry.mtime };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
getStoryFile(id) {
|
|
104
|
+
const entry = this.stories.get(id);
|
|
105
|
+
return entry ? entry.path : null;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
listTasksForStory(usId) {
|
|
109
|
+
return [...this.tasks.values()].filter((t) => t.ok && t.data.us_id === usId).map((t) => t.data);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
listDocs() {
|
|
113
|
+
const out = [];
|
|
114
|
+
for (const [, arr] of this.docs) {
|
|
115
|
+
for (const d of arr) out.push(d);
|
|
116
|
+
}
|
|
117
|
+
return out.sort((a, b) => a.rel.localeCompare(b.rel));
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async readDoc(rel) {
|
|
121
|
+
// Caller is responsible for resolving safely
|
|
122
|
+
const known = this.listDocs().find((d) => d.rel === rel);
|
|
123
|
+
if (!known) return null;
|
|
124
|
+
return await readFile(known.path, 'utf8');
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
buildDependenciesGraph() {
|
|
128
|
+
const nodes = [];
|
|
129
|
+
const edges = [];
|
|
130
|
+
for (const s of this.listStories()) {
|
|
131
|
+
nodes.push({ id: s.id, title: s.title, status: s.status, epic_id: s.epic_id ?? null });
|
|
132
|
+
for (const dep of s.dependencies ?? []) {
|
|
133
|
+
edges.push({ from: dep, to: s.id });
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
return { nodes, edges, cycles: detectCycles(nodes, edges) };
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export function detectCycles(nodes, edges) {
|
|
141
|
+
const adj = new Map();
|
|
142
|
+
for (const n of nodes) adj.set(n.id, []);
|
|
143
|
+
for (const e of edges) {
|
|
144
|
+
if (!adj.has(e.from)) adj.set(e.from, []);
|
|
145
|
+
adj.get(e.from).push(e.to);
|
|
146
|
+
}
|
|
147
|
+
const cycles = [];
|
|
148
|
+
const WHITE = 0,
|
|
149
|
+
GRAY = 1,
|
|
150
|
+
BLACK = 2;
|
|
151
|
+
const color = new Map();
|
|
152
|
+
const stack = [];
|
|
153
|
+
|
|
154
|
+
function dfs(u) {
|
|
155
|
+
color.set(u, GRAY);
|
|
156
|
+
stack.push(u);
|
|
157
|
+
for (const v of adj.get(u) ?? []) {
|
|
158
|
+
const c = color.get(v) ?? WHITE;
|
|
159
|
+
if (c === WHITE) {
|
|
160
|
+
dfs(v);
|
|
161
|
+
} else if (c === GRAY) {
|
|
162
|
+
const idx = stack.indexOf(v);
|
|
163
|
+
cycles.push(stack.slice(idx).concat(v));
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
stack.pop();
|
|
167
|
+
color.set(u, BLACK);
|
|
168
|
+
}
|
|
169
|
+
for (const n of nodes) {
|
|
170
|
+
if ((color.get(n.id) ?? WHITE) === WHITE) dfs(n.id);
|
|
171
|
+
}
|
|
172
|
+
return cycles;
|
|
173
|
+
}
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { readFile, writeFile, mkdir } from 'node:fs/promises';
|
|
3
|
+
import yaml from 'js-yaml';
|
|
4
|
+
import { SprintStatusSchema } from '../../shared/schemas.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Load sprint status from .bmad/sprint-status.yaml
|
|
8
|
+
* @param {string} projectRoot - Parent of project-management/ directory
|
|
9
|
+
* @returns {Promise<object|null>} Parsed sprint status or null if not found
|
|
10
|
+
*/
|
|
11
|
+
export async function loadSprintStatus(projectRoot) {
|
|
12
|
+
const yamlPath = path.join(projectRoot, '.bmad', 'sprint-status.yaml');
|
|
13
|
+
try {
|
|
14
|
+
const content = await readFile(yamlPath, 'utf8');
|
|
15
|
+
const parsed = yaml.load(content);
|
|
16
|
+
const result = SprintStatusSchema.safeParse(parsed);
|
|
17
|
+
if (!result.success) {
|
|
18
|
+
return { ...parsed, _invalid: true };
|
|
19
|
+
}
|
|
20
|
+
return result.data;
|
|
21
|
+
} catch (err) {
|
|
22
|
+
if (err.code === 'ENOENT') return null;
|
|
23
|
+
throw err;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Rebuild sprint-status.yaml from current repository state
|
|
29
|
+
* @param {string} projectRoot - Parent of project-management/ directory
|
|
30
|
+
* @param {import('./repository.js').Repository} repository
|
|
31
|
+
* @returns {Promise<object|null>} Built sprint status object or null if no active sprint
|
|
32
|
+
*/
|
|
33
|
+
export async function rebuildSprintStatus(projectRoot, repository) {
|
|
34
|
+
const currentStatus = await loadSprintStatus(projectRoot);
|
|
35
|
+
const existingHistory = currentStatus?._invalid ? {} : (currentStatus?.stories ?? {});
|
|
36
|
+
|
|
37
|
+
const activeSprint = findActiveSprint(repository);
|
|
38
|
+
if (!activeSprint) return null;
|
|
39
|
+
|
|
40
|
+
const sprintGoal = await readSprintGoal(repository, activeSprint);
|
|
41
|
+
const stories = repository.listStories({ sprintId: activeSprint });
|
|
42
|
+
|
|
43
|
+
const storyMap = {};
|
|
44
|
+
for (const story of stories) {
|
|
45
|
+
const historyFromYaml = existingHistory[story.id]?.history ?? [];
|
|
46
|
+
storyMap[story.id] = {
|
|
47
|
+
title: story.title,
|
|
48
|
+
status: story.status,
|
|
49
|
+
previous_status: story.previous_status ?? '',
|
|
50
|
+
assigned_to: story.assigned_to ?? '',
|
|
51
|
+
tdd_phase: story.tdd_phase ?? '',
|
|
52
|
+
story_points: story.story_points,
|
|
53
|
+
epic_id: story.epic_id ?? '',
|
|
54
|
+
tasks: story.tasks ?? { total: 0, completed: 0, list: [] },
|
|
55
|
+
history: historyFromYaml,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const sprintStatus = {
|
|
60
|
+
version: '1.0',
|
|
61
|
+
metadata: {
|
|
62
|
+
sprint_id: activeSprint,
|
|
63
|
+
name: activeSprint,
|
|
64
|
+
start_date: '',
|
|
65
|
+
end_date: '',
|
|
66
|
+
goal: sprintGoal,
|
|
67
|
+
},
|
|
68
|
+
stories: storyMap,
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
const yamlPath = path.join(projectRoot, '.bmad', 'sprint-status.yaml');
|
|
72
|
+
await mkdir(path.dirname(yamlPath), { recursive: true });
|
|
73
|
+
await writeFile(yamlPath, yaml.dump(sprintStatus, { lineWidth: -1 }), 'utf8');
|
|
74
|
+
|
|
75
|
+
return sprintStatus;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Compute burndown chart data from sprint status and current stories
|
|
80
|
+
* @param {object} sprintStatus - Sprint status object
|
|
81
|
+
* @param {Array} storiesBySprint - Current stories in the sprint
|
|
82
|
+
* @returns {object} Burndown chart with ideal/actual lines and on_track indicator
|
|
83
|
+
*/
|
|
84
|
+
export function computeBurndown(sprintStatus, storiesBySprint) {
|
|
85
|
+
const totalPoints = storiesBySprint.reduce((sum, s) => sum + (s.story_points ?? 0), 0);
|
|
86
|
+
|
|
87
|
+
if (!sprintStatus.metadata.start_date || !sprintStatus.metadata.end_date) {
|
|
88
|
+
return {
|
|
89
|
+
ideal: [],
|
|
90
|
+
actual: [],
|
|
91
|
+
total_points: totalPoints,
|
|
92
|
+
on_track: null,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const startDate = new Date(sprintStatus.metadata.start_date);
|
|
97
|
+
const endDate = new Date(sprintStatus.metadata.end_date);
|
|
98
|
+
const totalDays = Math.ceil((endDate - startDate) / (1000 * 60 * 60 * 24));
|
|
99
|
+
|
|
100
|
+
const ideal = [];
|
|
101
|
+
for (let day = 0; day <= totalDays; day++) {
|
|
102
|
+
const date = new Date(startDate);
|
|
103
|
+
date.setDate(date.getDate() + day);
|
|
104
|
+
const remaining = totalPoints - (totalPoints / totalDays) * day;
|
|
105
|
+
ideal.push({
|
|
106
|
+
date: date.toISOString().split('T')[0],
|
|
107
|
+
points: Math.max(0, Math.round(remaining * 10) / 10),
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const actual = buildActualBurndown(sprintStatus, storiesBySprint, startDate, endDate);
|
|
112
|
+
|
|
113
|
+
let onTrack = null;
|
|
114
|
+
if (actual.length > 0 && totalPoints > 0) {
|
|
115
|
+
const lastActual = actual[actual.length - 1];
|
|
116
|
+
const sameDay = ideal.find((i) => i.date === lastActual.date);
|
|
117
|
+
if (sameDay) {
|
|
118
|
+
const diff = lastActual.points - sameDay.points;
|
|
119
|
+
const variance = diff / totalPoints;
|
|
120
|
+
if (variance <= 0.1) onTrack = 'on-track';
|
|
121
|
+
else if (variance <= 0.25) onTrack = 'at-risk';
|
|
122
|
+
else onTrack = 'behind';
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return {
|
|
127
|
+
ideal,
|
|
128
|
+
actual,
|
|
129
|
+
total_points: totalPoints,
|
|
130
|
+
on_track: onTrack,
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function findActiveSprint(repository) {
|
|
135
|
+
const allStories = repository.listStories();
|
|
136
|
+
const sprintCounts = {};
|
|
137
|
+
|
|
138
|
+
for (const story of allStories) {
|
|
139
|
+
if (!story.sprint_id) continue;
|
|
140
|
+
if (!sprintCounts[story.sprint_id]) {
|
|
141
|
+
sprintCounts[story.sprint_id] = { active: 0, all: 0 };
|
|
142
|
+
}
|
|
143
|
+
sprintCounts[story.sprint_id].all++;
|
|
144
|
+
if (['in-progress', 'review', 'ready-for-dev'].includes(story.status)) {
|
|
145
|
+
sprintCounts[story.sprint_id].active++;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const sprints = Object.entries(sprintCounts).sort((a, b) => {
|
|
150
|
+
if (a[1].active !== b[1].active) return b[1].active - a[1].active;
|
|
151
|
+
return b[0].localeCompare(a[0]);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
return sprints.length > 0 ? sprints[0][0] : null;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
async function readSprintGoal(repository, sprintId) {
|
|
158
|
+
const sprint = repository.sprints.get(sprintId);
|
|
159
|
+
if (!sprint?.goalPath) return '';
|
|
160
|
+
try {
|
|
161
|
+
const content = await readFile(sprint.goalPath, 'utf8');
|
|
162
|
+
const firstLine = content.split('\n').find((l) => l.trim() && !l.startsWith('#'));
|
|
163
|
+
return firstLine?.trim() ?? '';
|
|
164
|
+
} catch {
|
|
165
|
+
return '';
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function buildActualBurndown(sprintStatus, storiesBySprint, startDate, endDate) {
|
|
170
|
+
const doneEvents = [];
|
|
171
|
+
|
|
172
|
+
for (const story of storiesBySprint) {
|
|
173
|
+
const storyData = sprintStatus.stories[story.id];
|
|
174
|
+
if (!storyData?.history) continue;
|
|
175
|
+
|
|
176
|
+
for (const entry of storyData.history) {
|
|
177
|
+
if (entry.to === 'done') {
|
|
178
|
+
const eventDate = new Date(entry.timestamp);
|
|
179
|
+
if (eventDate >= startDate && eventDate <= endDate) {
|
|
180
|
+
doneEvents.push({
|
|
181
|
+
date: entry.timestamp.split('T')[0],
|
|
182
|
+
points: story.story_points ?? 0,
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
doneEvents.sort((a, b) => a.date.localeCompare(b.date));
|
|
190
|
+
|
|
191
|
+
const actual = [];
|
|
192
|
+
let remaining = storiesBySprint.reduce((sum, s) => sum + (s.story_points ?? 0), 0);
|
|
193
|
+
|
|
194
|
+
actual.push({
|
|
195
|
+
date: startDate.toISOString().split('T')[0],
|
|
196
|
+
points: remaining,
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
for (const event of doneEvents) {
|
|
200
|
+
remaining -= event.points;
|
|
201
|
+
actual.push({
|
|
202
|
+
date: event.date,
|
|
203
|
+
points: Math.max(0, remaining),
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
return actual;
|
|
208
|
+
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* BMAD v6 state machine for User Story status transitions.
|
|
3
|
+
* Authoritative server-side validator. UI proposes, this disposes.
|
|
4
|
+
*
|
|
5
|
+
* Allowed transitions (deny by default) :
|
|
6
|
+
* backlog -> ready-for-dev | blocked
|
|
7
|
+
* ready-for-dev -> in-progress | blocked | backlog
|
|
8
|
+
* in-progress -> review | blocked
|
|
9
|
+
* review -> done | in-progress | blocked
|
|
10
|
+
* done -> (terminal, no outgoing)
|
|
11
|
+
* blocked -> previous_status (via unblock)
|
|
12
|
+
*
|
|
13
|
+
* Gates (must pass before transition is allowed) :
|
|
14
|
+
* backlog -> ready-for-dev : invest_score === 6
|
|
15
|
+
* in-progress -> review : tasks.completed === tasks.total && tasks.total > 0
|
|
16
|
+
* review -> done : tasks.completed === tasks.total && tdd_phase in green|refactor|done
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
export const STATUSES = Object.freeze(['backlog', 'ready-for-dev', 'in-progress', 'review', 'done', 'blocked']);
|
|
20
|
+
|
|
21
|
+
const TRANSITIONS = Object.freeze({
|
|
22
|
+
backlog: ['ready-for-dev', 'blocked'],
|
|
23
|
+
'ready-for-dev': ['in-progress', 'blocked', 'backlog'],
|
|
24
|
+
'in-progress': ['review', 'blocked'],
|
|
25
|
+
review: ['done', 'in-progress', 'blocked'],
|
|
26
|
+
done: [],
|
|
27
|
+
blocked: [],
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
const GATES = Object.freeze({
|
|
31
|
+
'backlog->ready-for-dev': {
|
|
32
|
+
name: 'invest_gate',
|
|
33
|
+
check: (story) => {
|
|
34
|
+
const score = Number(story.invest_score ?? 0);
|
|
35
|
+
return {
|
|
36
|
+
passed: score >= 6,
|
|
37
|
+
missing: score >= 6 ? [] : [`invest_score must be 6/6 (got ${score})`],
|
|
38
|
+
};
|
|
39
|
+
},
|
|
40
|
+
},
|
|
41
|
+
'in-progress->review': {
|
|
42
|
+
name: 'tasks_complete_gate',
|
|
43
|
+
check: (story) => {
|
|
44
|
+
const total = Number(story.tasks?.total ?? 0);
|
|
45
|
+
const done = Number(story.tasks?.completed ?? 0);
|
|
46
|
+
const missing = [];
|
|
47
|
+
if (total <= 0) missing.push('no tasks defined (tasks.total must be > 0)');
|
|
48
|
+
if (done < total) missing.push(`${total - done} task(s) not completed`);
|
|
49
|
+
return { passed: missing.length === 0, missing };
|
|
50
|
+
},
|
|
51
|
+
},
|
|
52
|
+
'review->done': {
|
|
53
|
+
name: 'dod_gate',
|
|
54
|
+
check: (story) => {
|
|
55
|
+
const total = Number(story.tasks?.total ?? 0);
|
|
56
|
+
const done = Number(story.tasks?.completed ?? 0);
|
|
57
|
+
const tddOk = ['green', 'refactor', 'done'].includes(story.tdd_phase ?? '');
|
|
58
|
+
const missing = [];
|
|
59
|
+
if (total <= 0 || done < total) missing.push('not all tasks completed');
|
|
60
|
+
if (!tddOk) missing.push(`tdd_phase must be green|refactor|done (got "${story.tdd_phase ?? ''}")`);
|
|
61
|
+
return { passed: missing.length === 0, missing };
|
|
62
|
+
},
|
|
63
|
+
},
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* @param {string} from - current status
|
|
68
|
+
* @param {string} to - target status
|
|
69
|
+
* @returns {boolean} true if the transition is structurally allowed (gates NOT evaluated).
|
|
70
|
+
*/
|
|
71
|
+
export function isTransitionAllowed(from, to) {
|
|
72
|
+
if (!STATUSES.includes(from) || !STATUSES.includes(to)) return false;
|
|
73
|
+
if (from === to) return false;
|
|
74
|
+
if (to === 'blocked') return from !== 'blocked' && from !== 'done';
|
|
75
|
+
if (from === 'blocked') return false;
|
|
76
|
+
return (TRANSITIONS[from] ?? []).includes(to);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Validate a transition request against structure + gates.
|
|
81
|
+
* @param {object} story - story frontmatter
|
|
82
|
+
* @param {string} to - target status
|
|
83
|
+
* @param {object} [opts]
|
|
84
|
+
* @param {string} [opts.blocked_reason] - required when to === 'blocked'
|
|
85
|
+
* @returns {{ allowed: boolean, reason?: string, gate?: string, missing?: string[] }}
|
|
86
|
+
*/
|
|
87
|
+
export function validateTransition(story, to, opts = {}) {
|
|
88
|
+
const from = story.status;
|
|
89
|
+
|
|
90
|
+
if (to === 'blocked') {
|
|
91
|
+
if (from === 'blocked') return { allowed: false, reason: 'already blocked' };
|
|
92
|
+
if (!opts.blocked_reason || !opts.blocked_reason.trim()) {
|
|
93
|
+
return { allowed: false, reason: 'blocked_reason is required' };
|
|
94
|
+
}
|
|
95
|
+
return { allowed: true };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (!isTransitionAllowed(from, to)) {
|
|
99
|
+
return { allowed: false, reason: `transition ${from} -> ${to} not allowed` };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const gate = GATES[`${from}->${to}`];
|
|
103
|
+
if (gate) {
|
|
104
|
+
const result = gate.check(story);
|
|
105
|
+
if (!result.passed) {
|
|
106
|
+
return { allowed: false, gate: gate.name, missing: result.missing, reason: 'gate failed' };
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return { allowed: true };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Unblock a story : restore previous_status. Does NOT evaluate gates
|
|
114
|
+
* (the story was valid before being blocked).
|
|
115
|
+
* @param {object} story
|
|
116
|
+
* @returns {{ allowed: boolean, status?: string, reason?: string }}
|
|
117
|
+
*/
|
|
118
|
+
export function validateUnblock(story) {
|
|
119
|
+
if (story.status !== 'blocked') {
|
|
120
|
+
return { allowed: false, reason: 'story is not blocked' };
|
|
121
|
+
}
|
|
122
|
+
const prev = story.previous_status;
|
|
123
|
+
if (!prev || !STATUSES.includes(prev) || prev === 'blocked') {
|
|
124
|
+
return { allowed: false, reason: 'no valid previous_status to restore' };
|
|
125
|
+
}
|
|
126
|
+
return { allowed: true, status: prev };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Compute the frontmatter patch to apply for a given transition.
|
|
131
|
+
* @param {object} story
|
|
132
|
+
* @param {string} to
|
|
133
|
+
* @param {object} [opts]
|
|
134
|
+
* @returns {object} partial frontmatter to merge
|
|
135
|
+
*/
|
|
136
|
+
export function computeTransitionPatch(story, to, opts = {}) {
|
|
137
|
+
if (to === 'blocked') {
|
|
138
|
+
return {
|
|
139
|
+
status: 'blocked',
|
|
140
|
+
previous_status: story.status,
|
|
141
|
+
blocked_reason: opts.blocked_reason ?? '',
|
|
142
|
+
blocked_at: opts.now ?? new Date().toISOString(),
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
const patch = { status: to };
|
|
146
|
+
if (story.status === 'blocked') {
|
|
147
|
+
patch.previous_status = '';
|
|
148
|
+
patch.blocked_reason = '';
|
|
149
|
+
patch.blocked_at = '';
|
|
150
|
+
} else {
|
|
151
|
+
patch.previous_status = story.status;
|
|
152
|
+
}
|
|
153
|
+
return patch;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export const _internal = { TRANSITIONS, GATES };
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
|
|
3
|
+
export const STATUSES = ['backlog', 'ready-for-dev', 'in-progress', 'review', 'done', 'blocked'];
|
|
4
|
+
export const PRIORITIES = ['must', 'should', 'could', 'wont'];
|
|
5
|
+
export const TDD_PHASES = ['', 'red', 'green', 'refactor', 'done'];
|
|
6
|
+
export const TASK_TYPES = ['DB', 'BE', 'FE-WEB', 'FE-MOB', 'TEST', 'DOC', 'OPS', 'REV'];
|
|
7
|
+
export const TASK_STATUSES = ['to-do', 'in-progress', 'done', 'blocked'];
|
|
8
|
+
|
|
9
|
+
const idPattern = (prefix) => z.string().regex(new RegExp(`^${prefix}-\\d{3,}$`), `Expected ${prefix}-XXX`);
|
|
10
|
+
|
|
11
|
+
export const StoryStatusSchema = z.enum(STATUSES);
|
|
12
|
+
export const PrioritySchema = z.enum(PRIORITIES);
|
|
13
|
+
export const TddPhaseSchema = z.enum(TDD_PHASES);
|
|
14
|
+
|
|
15
|
+
export const HistoryEntrySchema = z.object({
|
|
16
|
+
timestamp: z.string(),
|
|
17
|
+
from: z.string(),
|
|
18
|
+
to: z.string(),
|
|
19
|
+
by: z.string().default('auto'),
|
|
20
|
+
reason: z.string().default(''),
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
export const StoryFrontmatterSchema = z
|
|
24
|
+
.object({
|
|
25
|
+
id: idPattern('US'),
|
|
26
|
+
title: z.string().min(1),
|
|
27
|
+
epic_id: idPattern('EPIC').nullable().optional(),
|
|
28
|
+
persona: z.string().optional().default(''),
|
|
29
|
+
status: StoryStatusSchema.default('backlog'),
|
|
30
|
+
previous_status: z.string().optional().default(''),
|
|
31
|
+
story_points: z.number().int().min(0).max(21).default(0),
|
|
32
|
+
priority: PrioritySchema.optional().default('should'),
|
|
33
|
+
sprint_id: z.string().nullable().optional(),
|
|
34
|
+
assigned_to: z.string().optional().default(''),
|
|
35
|
+
tdd_phase: TddPhaseSchema.optional().default(''),
|
|
36
|
+
invest_score: z.number().int().min(0).max(6).optional().default(0),
|
|
37
|
+
blocked_reason: z.string().optional().default(''),
|
|
38
|
+
blocked_at: z.string().optional().default(''),
|
|
39
|
+
traceability: z
|
|
40
|
+
.object({
|
|
41
|
+
implements: z.array(z.string()).default([]),
|
|
42
|
+
tests: z.array(z.string()).default([]),
|
|
43
|
+
})
|
|
44
|
+
.optional()
|
|
45
|
+
.default({ implements: [], tests: [] }),
|
|
46
|
+
tasks: z
|
|
47
|
+
.object({
|
|
48
|
+
total: z.number().int().min(0).default(0),
|
|
49
|
+
completed: z.number().int().min(0).default(0),
|
|
50
|
+
list: z.array(idPattern('TASK')).default([]),
|
|
51
|
+
})
|
|
52
|
+
.optional()
|
|
53
|
+
.default({ total: 0, completed: 0, list: [] }),
|
|
54
|
+
dependencies: z.array(idPattern('US')).optional().default([]),
|
|
55
|
+
})
|
|
56
|
+
.passthrough();
|
|
57
|
+
|
|
58
|
+
export const EpicFrontmatterSchema = z
|
|
59
|
+
.object({
|
|
60
|
+
id: idPattern('EPIC'),
|
|
61
|
+
title: z.string().min(1),
|
|
62
|
+
status: z.enum(['draft', 'ready', 'in-progress', 'done']).default('draft'),
|
|
63
|
+
business_value: z.enum(['high', 'medium', 'low']).optional().default('medium'),
|
|
64
|
+
estimated_sprints: z.number().int().min(0).optional().default(0),
|
|
65
|
+
})
|
|
66
|
+
.passthrough();
|
|
67
|
+
|
|
68
|
+
export const TaskFrontmatterSchema = z
|
|
69
|
+
.object({
|
|
70
|
+
id: idPattern('TASK'),
|
|
71
|
+
us_id: idPattern('US'),
|
|
72
|
+
type: z.enum(TASK_TYPES),
|
|
73
|
+
status: z.enum(TASK_STATUSES).default('to-do'),
|
|
74
|
+
estimation_hours: z.number().min(0).default(0),
|
|
75
|
+
actual_hours: z.number().min(0).optional().default(0),
|
|
76
|
+
assigned_to: z.string().optional().default(''),
|
|
77
|
+
})
|
|
78
|
+
.passthrough();
|
|
79
|
+
|
|
80
|
+
export const SprintStatusSchema = z
|
|
81
|
+
.object({
|
|
82
|
+
version: z.string().default('1.0'),
|
|
83
|
+
metadata: z.object({
|
|
84
|
+
sprint_id: z.string(),
|
|
85
|
+
name: z.string(),
|
|
86
|
+
start_date: z.string(),
|
|
87
|
+
end_date: z.string(),
|
|
88
|
+
goal: z.string().default(''),
|
|
89
|
+
}),
|
|
90
|
+
stories: z
|
|
91
|
+
.record(
|
|
92
|
+
z.object({
|
|
93
|
+
title: z.string(),
|
|
94
|
+
status: StoryStatusSchema,
|
|
95
|
+
previous_status: z.string().optional().default(''),
|
|
96
|
+
assigned_to: z.string().optional().default(''),
|
|
97
|
+
tdd_phase: TddPhaseSchema.optional().default(''),
|
|
98
|
+
story_points: z.number().int().min(0).default(0),
|
|
99
|
+
epic_id: z.string().optional().default(''),
|
|
100
|
+
tasks: z
|
|
101
|
+
.object({
|
|
102
|
+
total: z.number().int().min(0).default(0),
|
|
103
|
+
completed: z.number().int().min(0).default(0),
|
|
104
|
+
list: z.array(z.string()).default([]),
|
|
105
|
+
})
|
|
106
|
+
.optional(),
|
|
107
|
+
history: z.array(HistoryEntrySchema).optional().default([]),
|
|
108
|
+
})
|
|
109
|
+
)
|
|
110
|
+
.default({}),
|
|
111
|
+
})
|
|
112
|
+
.passthrough();
|
|
113
|
+
|
|
114
|
+
export const TransitionRequestSchema = z.object({
|
|
115
|
+
status: StoryStatusSchema,
|
|
116
|
+
blocked_reason: z.string().max(500).optional(),
|
|
117
|
+
reason: z.string().max(500).optional(),
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
export const StoryPatchSchema = z
|
|
121
|
+
.object({
|
|
122
|
+
story_points: z.number().int().min(0).max(21).optional(),
|
|
123
|
+
assigned_to: z.string().max(100).optional(),
|
|
124
|
+
priority: PrioritySchema.optional(),
|
|
125
|
+
tdd_phase: TddPhaseSchema.optional(),
|
|
126
|
+
})
|
|
127
|
+
.strict();
|
package/cli/lib/help.js
CHANGED
|
@@ -47,6 +47,7 @@ ${c.bold}Commands:${c.reset}
|
|
|
47
47
|
${c.green}update${c.reset} Refresh existing installation
|
|
48
48
|
${c.green}flatten${c.reset} Generate flattened codebase summary
|
|
49
49
|
${c.green}ralph${c.reset} Run Ralph Wiggum continuous loop
|
|
50
|
+
${c.green}kanban${c.reset} Launch local Kanban UI for project-management/
|
|
50
51
|
${c.green}help${c.reset} Show this help message
|
|
51
52
|
|
|
52
53
|
${c.bold}Options:${c.reset}
|
|
@@ -79,6 +80,9 @@ ${c.bold}Examples:${c.reset}
|
|
|
79
80
|
${c.dim}# Run Ralph continuous loop${c.reset}
|
|
80
81
|
npx @the-bearded-bear/claude-craft ralph "Implement user authentication"
|
|
81
82
|
|
|
83
|
+
${c.dim}# Launch Kanban UI for the current BMAD project${c.reset}
|
|
84
|
+
npx @the-bearded-bear/claude-craft kanban --open
|
|
85
|
+
|
|
82
86
|
${c.bold}Technologies:${c.reset}
|
|
83
87
|
${Object.entries(TECHNOLOGIES)
|
|
84
88
|
.map(([key, val]) => ` ${c.cyan}${key.padEnd(12)}${c.reset} ${val.desc}`)
|