mdboard 1.2.0 → 1.3.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/package.json CHANGED
@@ -1,23 +1,20 @@
1
1
  {
2
2
  "name": "mdboard",
3
- "version": "1.2.0",
3
+ "version": "1.3.0",
4
4
  "description": "Git-based project management dashboard. Reads markdown files with YAML frontmatter and serves a visual kanban board, table, milestones, and metrics views.",
5
- "main": "./server.js",
5
+ "main": "./src/server/server.js",
6
6
  "bin": {
7
7
  "mdboard": "bin.js"
8
8
  },
9
+ "scripts": {
10
+ "build": "node build.js"
11
+ },
9
12
  "files": [
10
13
  "bin.js",
11
- "server.js",
14
+ "build.js",
12
15
  "index.html",
13
- "init.js",
14
- "config.js",
15
16
  "defaults.json",
16
- "scanner.js",
17
- "watcher.js",
18
- "api.js",
19
- "yaml.js",
20
- "workspace.js"
17
+ "src/"
21
18
  ],
22
19
  "keywords": [
23
20
  "project-management",
package/src/cli/cli.js ADDED
@@ -0,0 +1,362 @@
1
+ /**
2
+ * mdboard — CLI utilities + CRUD handlers
3
+ *
4
+ * Provides buildModel() for shared use by status/sync/CRUD,
5
+ * parseFlags() for CLI argument parsing, and
6
+ * create/update/delete handlers with reference cleanup.
7
+ */
8
+
9
+ const fs = require('fs');
10
+ const path = require('path');
11
+ const { loadConfig } = require('../core/config');
12
+ const { loadWorkspace } = require('../core/workspace');
13
+ const {
14
+ createModel, scanSource, computeProgress, mergeResults,
15
+ updateMarkdownFile, createTask, createMilestone, createEpic,
16
+ createSprint, archiveItem,
17
+ } = require('../core/scanner');
18
+ const { parseFrontmatter, serializeYaml } = require('../core/yaml');
19
+
20
+ /**
21
+ * Build the full project model from disk.
22
+ *
23
+ * @param {string} projectDir - Workspace root directory
24
+ * @returns {{ model, config, projectPath, workspace, sources }}
25
+ */
26
+ function buildModel(projectDir) {
27
+ const projectPath = path.join(projectDir, 'project');
28
+ const config = loadConfig(projectDir, process.env.MDBOARD_CONFIG);
29
+ const wsPath = process.env.MDBOARD_WORKSPACE || null;
30
+ const workspace = loadWorkspace(projectDir, wsPath);
31
+ const sources = workspace ? workspace.sources : [];
32
+
33
+ const model = createModel();
34
+
35
+ if (workspace && sources.length > 0) {
36
+ const results = [];
37
+ for (const source of sources) {
38
+ const sourcePath = source._resolvedPath;
39
+ if (!sourcePath) continue;
40
+ const readonly = source.readonly != null ? source.readonly : (source.type === 'remote');
41
+ const meta = {
42
+ name: source.name,
43
+ label: source.label || source.name,
44
+ color: source.color || null,
45
+ type: source.type,
46
+ readonly,
47
+ };
48
+ results.push(scanSource(sourcePath, config, meta));
49
+ }
50
+ mergeResults(model, results);
51
+ } else {
52
+ mergeResults(model, [scanSource(projectPath, config, {})]);
53
+ }
54
+
55
+ computeProgress(model, config.completedStatus);
56
+
57
+ return { model, config, projectPath, workspace, sources };
58
+ }
59
+
60
+ /**
61
+ * Parse CLI flags and positional arguments.
62
+ *
63
+ * @param {string[]} args - Raw CLI arguments
64
+ * @returns {{ flags: object, positional: string[] }}
65
+ */
66
+ const GLOBAL_FLAGS = new Set(['project', 'config', 'workspace']);
67
+
68
+ function parseFlags(args) {
69
+ const flags = {};
70
+ const positional = [];
71
+
72
+ for (let i = 0; i < args.length; i++) {
73
+ if (args[i].startsWith('--')) {
74
+ const key = args[i].slice(2);
75
+ const next = args[i + 1];
76
+ if (GLOBAL_FLAGS.has(key)) {
77
+ // Skip global flags (consumed by resolveProjectDir / bin.js)
78
+ if (next && !next.startsWith('--')) i++;
79
+ continue;
80
+ }
81
+ if (next && !next.startsWith('--')) {
82
+ flags[key] = next;
83
+ i++;
84
+ } else {
85
+ flags[key] = true;
86
+ }
87
+ } else {
88
+ positional.push(args[i]);
89
+ }
90
+ }
91
+
92
+ return { flags, positional };
93
+ }
94
+
95
+ /**
96
+ * Resolve the project directory from args.
97
+ *
98
+ * @param {string[]} args - Raw CLI arguments
99
+ * @returns {string}
100
+ */
101
+ function resolveProjectDir(args) {
102
+ for (let i = 0; i < args.length; i++) {
103
+ if (args[i] === '--project' && args[i + 1]) {
104
+ return path.resolve(args[i + 1]);
105
+ }
106
+ }
107
+ return process.cwd();
108
+ }
109
+
110
+ // ═══════════════════════════════════════════════════════════════════════
111
+ // CRUD handlers
112
+ // ═══════════════════════════════════════════════════════════════════════
113
+
114
+ /**
115
+ * mdboard create <entity> <title> [flags]
116
+ */
117
+ function handleCreate(projectDir, args) {
118
+ const { flags, positional } = parseFlags(args);
119
+ const entity = positional[0];
120
+ const title = positional.slice(1).join(' ');
121
+
122
+ if (!entity) {
123
+ console.log(`
124
+ mdboard create — Create a new entity
125
+
126
+ Usage:
127
+ mdboard create milestone <title>
128
+ mdboard create epic <title> --milestone <slug>
129
+ mdboard create task <title> --milestone <slug> --epic <slug> [--priority p] [--points n] [--sprint id]
130
+ mdboard create sprint --milestone <slug> [--goal "text"]
131
+ `);
132
+ process.exit(1);
133
+ }
134
+
135
+ const { model, config, projectPath } = buildModel(projectDir);
136
+
137
+ switch (entity) {
138
+ case 'milestone': {
139
+ if (!title) { console.error(' Error: title is required'); process.exit(1); }
140
+ const result = createMilestone(projectPath, config, { title }, model.milestones);
141
+ console.log(` Created milestone ${result.id}: ${result.file}`);
142
+ break;
143
+ }
144
+
145
+ case 'epic': {
146
+ if (!title) { console.error(' Error: title is required'); process.exit(1); }
147
+ if (!flags.milestone) { console.error(' Error: --milestone is required'); process.exit(1); }
148
+ const result = createEpic(projectPath, config, {
149
+ title,
150
+ milestone: flags.milestone,
151
+ }, model.epics);
152
+ console.log(` Created epic ${result.id}: ${result.file}`);
153
+ break;
154
+ }
155
+
156
+ case 'task': {
157
+ if (!title) { console.error(' Error: title is required'); process.exit(1); }
158
+ if (!flags.milestone) { console.error(' Error: --milestone is required'); process.exit(1); }
159
+ if (!flags.epic) { console.error(' Error: --epic is required'); process.exit(1); }
160
+ const data = {
161
+ title,
162
+ milestone: flags.milestone,
163
+ epic: flags.epic,
164
+ };
165
+ if (flags.priority) data.priority = flags.priority;
166
+ if (flags.points) data.points = parseInt(flags.points, 10);
167
+ if (flags.sprint) data.sprint = flags.sprint;
168
+ if (flags.assigned) data.assigned = flags.assigned;
169
+ if (flags.status) data.status = flags.status;
170
+ const result = createTask(projectPath, config, data, model.tasks);
171
+ console.log(` Created task ${result.id}: ${result.file}`);
172
+ break;
173
+ }
174
+
175
+ case 'sprint': {
176
+ if (!flags.milestone) { console.error(' Error: --milestone is required'); process.exit(1); }
177
+ const data = { milestone: flags.milestone };
178
+ if (flags.goal) data.goal = flags.goal;
179
+ if (flags.status) data.status = flags.status;
180
+ const result = createSprint(projectPath, config, data, model.sprints);
181
+ console.log(` Created sprint ${result.id}: ${result.file}`);
182
+ break;
183
+ }
184
+
185
+ default:
186
+ console.error(` Error: unknown entity "${entity}". Use: milestone, epic, task, sprint`);
187
+ process.exit(1);
188
+ }
189
+ }
190
+
191
+ /**
192
+ * mdboard update <entity> <id> [--key value ...]
193
+ */
194
+ function handleUpdate(projectDir, args) {
195
+ const { flags, positional } = parseFlags(args);
196
+ const entity = positional[0];
197
+ const id = positional[1];
198
+
199
+ if (!entity || !id) {
200
+ console.log(`
201
+ mdboard update — Update an entity's frontmatter
202
+
203
+ Usage:
204
+ mdboard update task TASK-005 --status in-progress --assigned john
205
+ mdboard update milestone MS-001 --status active
206
+ mdboard update epic EPIC-002 --priority high
207
+ mdboard update sprint SP-003 --status active --goal "New goal"
208
+ `);
209
+ process.exit(1);
210
+ }
211
+
212
+ const { model, projectPath } = buildModel(projectDir);
213
+
214
+ const collectionMap = {
215
+ task: 'tasks',
216
+ milestone: 'milestones',
217
+ epic: 'epics',
218
+ sprint: 'sprints',
219
+ };
220
+
221
+ const collection = collectionMap[entity];
222
+ if (!collection) {
223
+ console.error(` Error: unknown entity "${entity}". Use: task, milestone, epic, sprint`);
224
+ process.exit(1);
225
+ }
226
+
227
+ const items = model[collection];
228
+ const item = items.find(x => x.id === id) || items.find(x => x._originalId === id);
229
+
230
+ if (!item) {
231
+ console.error(` Error: ${entity} "${id}" not found`);
232
+ process.exit(1);
233
+ }
234
+
235
+ if (Object.keys(flags).length === 0) {
236
+ console.error(' Error: no fields to update. Use --key value flags.');
237
+ process.exit(1);
238
+ }
239
+
240
+ // Convert numeric fields
241
+ const updates = { ...flags };
242
+ if (updates.points) updates.points = parseInt(updates.points, 10);
243
+ if (updates.planned_points) updates.planned_points = parseInt(updates.planned_points, 10);
244
+
245
+ updateMarkdownFile(projectPath, item._file, updates);
246
+ console.log(` Updated ${entity} ${id}: ${item._file}`);
247
+ }
248
+
249
+ /**
250
+ * mdboard delete <entity> <id>
251
+ */
252
+ function handleDelete(projectDir, args) {
253
+ const { positional } = parseFlags(args);
254
+ const entity = positional[0];
255
+ const id = positional[1];
256
+
257
+ if (!entity || !id) {
258
+ console.log(`
259
+ mdboard delete — Delete an entity
260
+
261
+ Usage:
262
+ mdboard delete task TASK-005
263
+ mdboard delete sprint SP-003
264
+ mdboard delete epic EPIC-002
265
+ mdboard delete milestone MS-001
266
+ `);
267
+ process.exit(1);
268
+ }
269
+
270
+ const { model, config, projectPath } = buildModel(projectDir);
271
+
272
+ const collectionMap = {
273
+ task: 'tasks',
274
+ milestone: 'milestones',
275
+ epic: 'epics',
276
+ sprint: 'sprints',
277
+ };
278
+
279
+ const collection = collectionMap[entity];
280
+ if (!collection) {
281
+ console.error(` Error: unknown entity "${entity}". Use: task, milestone, epic, sprint`);
282
+ process.exit(1);
283
+ }
284
+
285
+ const items = model[collection];
286
+ const item = items.find(x => x.id === id) || items.find(x => x._originalId === id);
287
+
288
+ if (!item) {
289
+ console.error(` Error: ${entity} "${id}" not found`);
290
+ process.exit(1);
291
+ }
292
+
293
+ archiveItem(projectPath, item);
294
+ console.log(` Deleted ${entity} ${id}: ${item._file}`);
295
+
296
+ // Clean up references
297
+ const cleaned = cleanReferences(projectPath, model, config, entity, item);
298
+ if (cleaned > 0) {
299
+ console.log(` Cleaned ${cleaned} reference${cleaned > 1 ? 's' : ''}.`);
300
+ }
301
+ }
302
+
303
+ /**
304
+ * Clean up dangling references after deleting an entity.
305
+ *
306
+ * @param {string} projectPath - Absolute path to project/ dir
307
+ * @param {object} model - Current model
308
+ * @param {object} config - Config object
309
+ * @param {string} entity - Entity type deleted (task, sprint, epic)
310
+ * @param {object} deletedItem - The deleted item
311
+ * @returns {number} - Number of references cleaned
312
+ */
313
+ function cleanReferences(projectPath, model, config, entity, deletedItem) {
314
+ let cleaned = 0;
315
+ const deletedId = deletedItem.id;
316
+
317
+ if (entity === 'task') {
318
+ // Remove task from sprint.features[]
319
+ for (const sprint of model.sprints) {
320
+ if (sprint.features && Array.isArray(sprint.features) && sprint.features.includes(deletedId)) {
321
+ const newFeatures = sprint.features.filter(f => f !== deletedId);
322
+ updateMarkdownFile(projectPath, sprint._file, { features: newFeatures });
323
+ cleaned++;
324
+ }
325
+ }
326
+ }
327
+
328
+ if (entity === 'sprint') {
329
+ // Clear task.sprint references
330
+ for (const task of model.tasks) {
331
+ if (task.sprint === deletedId) {
332
+ updateMarkdownFile(projectPath, task._file, { sprint: null });
333
+ cleaned++;
334
+ }
335
+ }
336
+ }
337
+
338
+ if (entity === 'epic') {
339
+ // Clean epic.dependencies[] references
340
+ for (const epic of model.epics) {
341
+ if (epic.dependencies && Array.isArray(epic.dependencies) && epic.dependencies.includes(deletedId)) {
342
+ const newDeps = epic.dependencies.filter(d => d !== deletedId);
343
+ updateMarkdownFile(projectPath, epic._file, { dependencies: newDeps });
344
+ cleaned++;
345
+ }
346
+ }
347
+ // Warn about orphaned tasks
348
+ const orphanedTasks = model.tasks.filter(t =>
349
+ t._epic === deletedItem._dir && t._milestone === deletedItem._milestone
350
+ );
351
+ if (orphanedTasks.length > 0) {
352
+ console.log(` Warning: ${orphanedTasks.length} task(s) under deleted epic may be orphaned.`);
353
+ }
354
+ }
355
+
356
+ return cleaned;
357
+ }
358
+
359
+ module.exports = {
360
+ buildModel, parseFlags, resolveProjectDir,
361
+ handleCreate, handleUpdate, handleDelete, cleanReferences,
362
+ };
@@ -0,0 +1,123 @@
1
+ #!/usr/bin/env node
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const readline = require('readline');
6
+ const { loadConfig } = require('../core/config');
7
+
8
+ const cwd = process.cwd();
9
+ const projectPath = path.join(cwd, 'project');
10
+ const today = new Date().toISOString().split('T')[0];
11
+ const config = loadConfig(cwd);
12
+
13
+ if (fs.existsSync(projectPath)) {
14
+ console.log(`\n project/ already exists at ${projectPath}`);
15
+ console.log(' Skipping init to avoid overwriting existing data.\n');
16
+ process.exit(0);
17
+ }
18
+
19
+ const nameArg = (process.env.MDBOARD_INIT_NAME || '').trim();
20
+
21
+ if (nameArg) {
22
+ scaffold(nameArg);
23
+ } else {
24
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
25
+ rl.question(' Workspace name: ', function (answer) {
26
+ rl.close();
27
+ const name = answer.trim() || path.basename(cwd);
28
+ scaffold(name);
29
+ });
30
+ }
31
+
32
+ function scaffold(name) {
33
+ const msDir = config.entities.milestone.dir;
34
+
35
+ // Create directories
36
+ fs.mkdirSync(projectPath, { recursive: true });
37
+ fs.mkdirSync(path.join(projectPath, msDir), { recursive: true });
38
+ // PROJECT.md
39
+ fs.writeFileSync(path.join(projectPath, 'PROJECT.md'), `---
40
+ name: "${name}"
41
+ description: ""
42
+ created: ${today}
43
+ ---
44
+
45
+ # ${name}
46
+
47
+ ## Overview
48
+
49
+ Describe your project here.
50
+
51
+ ## Objectives
52
+
53
+ -
54
+
55
+ ## Scope
56
+
57
+ -
58
+
59
+ `, 'utf-8');
60
+
61
+ // mdboard.json — example config
62
+ fs.writeFileSync(path.join(projectPath, 'mdboard.json'), `{
63
+ "entities": {
64
+ "task": {
65
+ "singular": "Task",
66
+ "plural": "Tasks",
67
+ "prefix": "TASK"
68
+ }
69
+ }
70
+ }
71
+ `, 'utf-8');
72
+
73
+ // mdboard.css — example custom theme
74
+ fs.writeFileSync(path.join(projectPath, 'mdboard.css'), `/* mdboard custom theme
75
+ Override any CSS variable from the dashboard.
76
+ Example:
77
+ :root {
78
+ --bg: #FFFFFF;
79
+ --surface: #F8F9FA;
80
+ --text: #212529;
81
+ --accent: #0969DA;
82
+ }
83
+ */
84
+ `, 'utf-8');
85
+
86
+ // workspace.json — default workspace with current project as source
87
+ const wsPath = path.join(cwd, 'workspace.json');
88
+ if (!fs.existsSync(wsPath)) {
89
+ fs.writeFileSync(wsPath, JSON.stringify({
90
+ name: name,
91
+ sources: [
92
+ { name: name.toLowerCase().replace(/\s+/g, '-'), label: name, path: ".", color: "#5B6EF5" }
93
+ ],
94
+ settings: {}
95
+ }, null, 2) + '\n', 'utf-8');
96
+ }
97
+
98
+ console.log(`
99
+ mdboard init — workspace "${name}" scaffolded
100
+
101
+ Created:
102
+ project/PROJECT.md
103
+ project/${msDir}/
104
+ project/mdboard.json
105
+ project/mdboard.css
106
+ workspace.json
107
+
108
+ Next steps:
109
+ 1. Edit project/PROJECT.md with your project details
110
+ 2. Create milestones and epics under project/${msDir}/
111
+ 3. Customize project/mdboard.json for your workflow
112
+ 4. Run \`mdboard\` to start the dashboard
113
+
114
+ Add sources to workspace.json to manage multiple repos:
115
+ {
116
+ "name": "${name}",
117
+ "sources": [
118
+ { "name": "api", "path": "../api-repo", "icon": "🔧" },
119
+ { "name": "web", "path": "../web-repo", "icon": "🌐" }
120
+ ]
121
+ }
122
+ `);
123
+ }
@@ -0,0 +1,150 @@
1
+ /**
2
+ * mdboard — Status generator
3
+ *
4
+ * Generates project/status.md with computed project status
5
+ * from the real model on disk.
6
+ *
7
+ * Usage: mdboard status
8
+ */
9
+
10
+ const fs = require('fs');
11
+ const path = require('path');
12
+ const { buildModel } = require('./cli');
13
+
14
+ /**
15
+ * Generate project/status.md
16
+ *
17
+ * @param {string} projectDir - Workspace root directory
18
+ */
19
+ function generateStatus(projectDir) {
20
+ const { model, config, projectPath } = buildModel(projectDir);
21
+
22
+ const projectName = (model.project && model.project.name) || path.basename(projectDir);
23
+ const completedStatus = config.completedStatus;
24
+ const now = new Date().toISOString();
25
+
26
+ const lines = [];
27
+
28
+ // Frontmatter
29
+ lines.push('---');
30
+ lines.push(`generated: ${now}`);
31
+ lines.push('---');
32
+ lines.push('');
33
+ lines.push(`# Project Status: ${projectName}`);
34
+ lines.push('');
35
+ lines.push('> Auto-generated by `mdboard status`. Do not edit manually.');
36
+ lines.push('');
37
+
38
+ // Summary
39
+ const totalTasks = model.tasks.length;
40
+ const doneTasks = model.tasks.filter(t => t.status === completedStatus).length;
41
+ const donePercent = totalTasks > 0 ? Math.round((doneTasks / totalTasks) * 100) : 0;
42
+ const totalPoints = model.tasks.reduce((sum, t) => sum + (t.points || 0), 0);
43
+ const donePoints = model.tasks.filter(t => t.status === completedStatus)
44
+ .reduce((sum, t) => sum + (t.points || 0), 0);
45
+ const pointsPercent = totalPoints > 0 ? Math.round((donePoints / totalPoints) * 100) : 0;
46
+ const activeMilestones = model.milestones.filter(m => m.status === 'active');
47
+ const activeSprint = model.sprints.find(s => s.status === 'active');
48
+
49
+ lines.push('## Summary');
50
+ lines.push('');
51
+ lines.push('| Metric | Value |');
52
+ lines.push('|--------|-------|');
53
+ lines.push(`| Milestones | ${model.milestones.length} (${activeMilestones.length} active) |`);
54
+ lines.push(`| Tasks | ${totalTasks} total, ${doneTasks} done (${donePercent}%) |`);
55
+ lines.push(`| Points | ${donePoints}/${totalPoints} (${pointsPercent}%) |`);
56
+ lines.push(`| Active Sprint | ${activeSprint ? activeSprint.id : 'none'} |`);
57
+ lines.push('');
58
+
59
+ // Milestones
60
+ if (model.milestones.length > 0) {
61
+ lines.push('## Milestones');
62
+ lines.push('');
63
+ for (const ms of model.milestones) {
64
+ const msEpics = model.epics.filter(e => e._milestone === ms._dir);
65
+ const msTasks = model.tasks.filter(t => t._milestone === ms._dir);
66
+ const msDone = msTasks.filter(t => t.status === completedStatus).length;
67
+ lines.push(`### ${ms.id}: ${ms.title || ms._dir} [${ms.status}] (${ms._progress || 0}%)`);
68
+ lines.push(`- File: [${ms._file}](${ms._file})`);
69
+ lines.push(`- Epics: ${msEpics.length}, Tasks: ${msDone}/${msTasks.length} done`);
70
+ lines.push('');
71
+ }
72
+ }
73
+
74
+ // Active Sprint
75
+ if (activeSprint) {
76
+ lines.push('## Active Sprint');
77
+ lines.push('');
78
+ lines.push(`### ${activeSprint.id}`);
79
+ lines.push(`- File: [${activeSprint._file}](${activeSprint._file})`);
80
+ if (activeSprint.goal) {
81
+ lines.push(`- Goal: ${activeSprint.goal}`);
82
+ }
83
+
84
+ const sprintFeatureIds = activeSprint.features || [];
85
+ const sprintTasks = sprintFeatureIds.length > 0
86
+ ? model.tasks.filter(t => sprintFeatureIds.includes(t.id))
87
+ : model.tasks.filter(t => t.sprint === activeSprint.id);
88
+
89
+ if (sprintTasks.length > 0) {
90
+ lines.push('- Tasks:');
91
+ for (const t of sprintTasks) {
92
+ lines.push(` - ${t.id} [${t.status}] ${t.title || ''} -> [${t._file}](${t._file})`);
93
+ }
94
+ }
95
+ lines.push('');
96
+ }
97
+
98
+ // Tasks by status
99
+ const statusGroups = {};
100
+ for (const task of model.tasks) {
101
+ const s = task.status || 'unknown';
102
+ if (!statusGroups[s]) statusGroups[s] = [];
103
+ statusGroups[s].push(task);
104
+ }
105
+
106
+ const statusOrder = Object.keys(statusGroups).sort((a, b) => {
107
+ // Put completed last, in-progress first
108
+ if (a === completedStatus) return 1;
109
+ if (b === completedStatus) return -1;
110
+ if (a === 'in-progress') return -1;
111
+ if (b === 'in-progress') return 1;
112
+ return a.localeCompare(b);
113
+ });
114
+
115
+ if (statusOrder.length > 0) {
116
+ lines.push('## Tasks by Status');
117
+ lines.push('');
118
+
119
+ for (const status of statusOrder) {
120
+ const tasks = statusGroups[status];
121
+ lines.push(`### ${capitalize(status)} (${tasks.length})`);
122
+ lines.push('');
123
+ lines.push('| ID | Title | Epic | Priority | File |');
124
+ lines.push('|----|-------|------|----------|------|');
125
+ for (const t of tasks) {
126
+ const title = t.title || '';
127
+ const epic = t._epic || '';
128
+ const priority = t.priority || '';
129
+ lines.push(`| ${t.id} | ${title} | ${epic} | ${priority} | [${t._file}](${t._file}) |`);
130
+ }
131
+ lines.push('');
132
+ }
133
+ }
134
+
135
+ const content = lines.join('\n') + '\n';
136
+
137
+ // Write to project/status.md
138
+ const statusPath = path.join(projectPath, 'status.md');
139
+ fs.mkdirSync(projectPath, { recursive: true });
140
+ fs.writeFileSync(statusPath, content, 'utf-8');
141
+
142
+ console.log(` mdboard status — Generated ${path.relative(projectDir, statusPath)}`);
143
+ console.log(` ${totalTasks} tasks, ${model.milestones.length} milestones, ${model.sprints.length} sprints`);
144
+ }
145
+
146
+ function capitalize(str) {
147
+ return str.replace(/(^|-)(\w)/g, (_, sep, c) => (sep ? ' ' : '') + c.toUpperCase());
148
+ }
149
+
150
+ module.exports = { generateStatus };