@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.
Files changed (30) hide show
  1. package/Dev/scripts/install-php-rules.sh +1 -1
  2. package/Dev/scripts/validate-skills-spec.sh +121 -0
  3. package/README.md +13 -11
  4. package/cli/index.js +6 -0
  5. package/cli/kanban/client/index.html +17 -0
  6. package/cli/kanban/client/src/App.svelte +106 -0
  7. package/cli/kanban/client/src/app.css +175 -0
  8. package/cli/kanban/client/src/lib/router.svelte.js +19 -0
  9. package/cli/kanban/client/src/lib/store.svelte.js +132 -0
  10. package/cli/kanban/client/src/main.js +6 -0
  11. package/cli/kanban/client/src/views/BacklogView.svelte +344 -0
  12. package/cli/kanban/client/src/views/BurndownView.svelte +189 -0
  13. package/cli/kanban/client/src/views/DepsView.svelte +334 -0
  14. package/cli/kanban/client/src/views/DocsView.svelte +451 -0
  15. package/cli/kanban/client/src/views/KanbanView.svelte +227 -0
  16. package/cli/kanban/client/vite.config.js +21 -0
  17. package/cli/kanban/server/app.js +201 -0
  18. package/cli/kanban/server/middleware/security.js +53 -0
  19. package/cli/kanban/server/services/event-bus.js +33 -0
  20. package/cli/kanban/server/services/file-scanner.js +113 -0
  21. package/cli/kanban/server/services/file-watcher.js +68 -0
  22. package/cli/kanban/server/services/file-writer.js +107 -0
  23. package/cli/kanban/server/services/frontmatter.js +55 -0
  24. package/cli/kanban/server/services/repository.js +173 -0
  25. package/cli/kanban/server/services/sprint-cache.js +208 -0
  26. package/cli/kanban/server/services/state-machine.js +156 -0
  27. package/cli/kanban/shared/schemas.js +127 -0
  28. package/cli/lib/help.js +4 -0
  29. package/cli/lib/kanban.js +103 -0
  30. 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}`)