mdboard 1.3.0 → 2.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 (53) hide show
  1. package/bin.js +117 -59
  2. package/index.html +2161 -1579
  3. package/package.json +7 -5
  4. package/presets/kanban/api.json +91 -0
  5. package/presets/kanban/cli.json +69 -0
  6. package/presets/kanban/docs.json +29 -0
  7. package/presets/kanban/entities.json +128 -0
  8. package/presets/kanban/structure.json +15 -0
  9. package/presets/kanban/ui.json +86 -0
  10. package/presets/scrum/api.json +98 -0
  11. package/presets/scrum/cli.json +120 -0
  12. package/presets/scrum/docs.json +43 -0
  13. package/presets/scrum/entities.json +268 -0
  14. package/presets/scrum/structure.json +32 -0
  15. package/presets/scrum/ui.json +201 -0
  16. package/presets/shape-up/api.json +40 -0
  17. package/presets/shape-up/cli.json +44 -0
  18. package/presets/shape-up/docs.json +32 -0
  19. package/presets/shape-up/entities.json +140 -0
  20. package/presets/shape-up/structure.json +28 -0
  21. package/presets/shape-up/ui.json +114 -0
  22. package/src/cli/cli.js +186 -210
  23. package/src/cli/config.js +234 -0
  24. package/src/cli/init.js +128 -76
  25. package/src/cli/preset.js +849 -0
  26. package/src/cli/skill.js +417 -0
  27. package/src/cli/status.js +126 -96
  28. package/src/core/config.js +491 -38
  29. package/src/core/history.js +17 -1
  30. package/src/core/scanner.js +373 -463
  31. package/src/core/workspace.js +0 -15
  32. package/src/server/api.js +464 -741
  33. package/src/server/server.js +105 -130
  34. package/build.js +0 -44
  35. package/defaults.json +0 -43
  36. package/src/cli/sync.js +0 -194
  37. package/src/cli/theme.js +0 -142
  38. package/src/client/app.js +0 -266
  39. package/src/client/board.js +0 -157
  40. package/src/client/core.js +0 -331
  41. package/src/client/editor.js +0 -318
  42. package/src/client/history.js +0 -137
  43. package/src/client/metrics.js +0 -38
  44. package/src/client/milestones.js +0 -77
  45. package/src/client/notes.js +0 -183
  46. package/src/client/overview.js +0 -104
  47. package/src/client/panel.js +0 -637
  48. package/src/client/styles.css +0 -471
  49. package/src/client/table.js +0 -111
  50. package/src/client/template.html +0 -144
  51. package/src/client/themes.js +0 -261
  52. package/src/client/workspace.js +0 -164
  53. package/src/core/agent-scanner.js +0 -260
@@ -1,74 +1,80 @@
1
1
  /**
2
- * mdboard — File scanner and in-memory model
2
+ * mdboard — Dynamic file scanner and in-memory model
3
3
  *
4
- * Scans project directories for markdown files and builds
5
- * the in-memory model. Supports multi-source scanning.
4
+ * Scans project directories based on config-driven hierarchy.
5
+ * No hardcoded entity types everything comes from entities.json + structure.json.
6
6
  */
7
7
 
8
+ 'use strict';
9
+
8
10
  const fs = require('fs');
9
11
  const path = require('path');
10
12
  const { parseFrontmatter, serializeYaml } = require('./yaml');
13
+ const config = require('./config');
11
14
 
12
- function createModel() {
13
- return {
14
- project: null,
15
- milestones: [],
16
- epics: [],
17
- tasks: [],
18
- sprints: [],
19
- boards: [],
20
- reviews: [],
21
- notes: [],
22
- };
23
- }
15
+ // --- Utilities ---
24
16
 
25
17
  function safeReadFile(filePath) {
26
- try {
27
- return fs.readFileSync(filePath, 'utf-8');
28
- } catch {
29
- return null;
30
- }
18
+ try { return fs.readFileSync(filePath, 'utf-8'); } catch { return null; }
31
19
  }
32
20
 
33
21
  function safeDirEntries(dir) {
34
- try {
35
- return fs.readdirSync(dir).filter(e => !e.startsWith('.'));
36
- } catch {
37
- return [];
22
+ try { return fs.readdirSync(dir).filter(e => !e.startsWith('.')); } catch { return []; }
23
+ }
24
+
25
+ function slugify(text) {
26
+ return text.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '') || 'untitled';
27
+ }
28
+
29
+ /**
30
+ * Check if a filename matches an entity's file pattern.
31
+ * Supports "README.md" (directory entity) and "PREFIX-NNN.md" (file entity).
32
+ */
33
+ function matchesFilePattern(filename, entityDef) {
34
+ const pattern = entityDef.file || 'README.md';
35
+ if (pattern === 'README.md') return filename === 'README.md';
36
+ if (pattern === 'PREFIX-NNN.md') {
37
+ if (!filename.endsWith('.md')) return false;
38
+ const prefix = entityDef.prefix;
39
+ return prefix && filename.startsWith(prefix + '-');
38
40
  }
41
+ return filename === pattern;
39
42
  }
40
43
 
41
- function isTaskFile(filename, config) {
42
- if (!filename.endsWith('.md')) return false;
43
- const prefix = config.entities.task.prefix;
44
- if (filename.startsWith(prefix + '-')) return true;
45
- const legacy = config.entities.task.legacyPrefixes || [];
46
- for (const lp of legacy) {
47
- if (filename.startsWith(lp + '-')) return true;
44
+ /**
45
+ * Check if an entity uses directory-based storage (README.md inside a named dir).
46
+ */
47
+ function isDirEntity(entityDef) {
48
+ return (entityDef.file || 'README.md') === 'README.md';
49
+ }
50
+
51
+ // --- Model ---
52
+
53
+ /**
54
+ * Create an empty model. Collections are keyed by entity type.
55
+ * @param {object} cfg - Loaded config
56
+ * @returns {object}
57
+ */
58
+ function createModel(cfg) {
59
+ const model = { project: null, entities: {} };
60
+ for (const type of config.getEntityTypes(cfg)) {
61
+ model.entities[type] = [];
48
62
  }
49
- return false;
63
+ return model;
50
64
  }
51
65
 
66
+ // --- Scanner ---
67
+
52
68
  /**
53
- * Scan a single source directory and return all items found.
69
+ * Scan a single source directory and return all entities found.
54
70
  *
55
71
  * @param {string} sourcePath - Absolute path to the project/ directory
56
- * @param {object} config - mdboard config object
57
- * @param {object} sourceMeta - { name, label, color, type, readonly }
58
- * @returns {object} - { project, milestones, epics, tasks, sprints, boards, reviews }
72
+ * @param {object} cfg - Loaded config
73
+ * @param {object} [sourceMeta] - { name, label, color, type, readonly }
74
+ * @returns {object} - { project, entities: { cycle: [...], bet: [...], ... } }
59
75
  */
60
- function scanSource(sourcePath, config, sourceMeta) {
61
- const result = {
62
- project: null,
63
- milestones: [],
64
- epics: [],
65
- tasks: [],
66
- sprints: [],
67
- boards: [],
68
- reviews: [],
69
- notes: [],
70
- };
71
-
76
+ function scanSource(sourcePath, cfg, sourceMeta) {
77
+ const result = createModel(cfg);
72
78
  if (!fs.existsSync(sourcePath)) return result;
73
79
 
74
80
  const meta = sourceMeta || {};
@@ -93,201 +99,230 @@ function scanSource(sourcePath, config, sourceMeta) {
93
99
  return item;
94
100
  }
95
101
 
102
+ // Scan PROJECT.md
96
103
  const projectMd = safeReadFile(path.join(sourcePath, 'PROJECT.md'));
97
104
  if (projectMd) {
98
105
  const parsed = parseFrontmatter(projectMd);
99
106
  result.project = tag({ ...parsed.frontmatter, content: parsed.content, _file: 'PROJECT.md' });
100
107
  }
101
108
 
102
- const msDir = config.entities.milestone.dir;
103
- const epicDir = config.entities.epic.dir;
104
- const taskDir = config.entities.task.dir;
105
- const sprintDir = config.entities.sprint.dir;
106
-
107
- const milestonesDir = path.join(sourcePath, msDir);
108
- if (fs.existsSync(milestonesDir)) {
109
- for (const ms of safeDirEntries(milestonesDir)) {
110
- const msPath = path.join(milestonesDir, ms);
111
- if (!fs.statSync(msPath).isDirectory()) continue;
112
-
113
- const msReadme = safeReadFile(path.join(msPath, 'README.md'));
114
- if (msReadme) {
115
- const parsed = parseFrontmatter(msReadme);
116
- result.milestones.push(qualifyId(tag({
117
- ...parsed.frontmatter, content: parsed.content,
118
- _dir: ms, _file: `${msDir}/${ms}/README.md`
119
- })));
120
- }
109
+ // Scan hierarchy entities
110
+ const hierarchy = config.getHierarchy(cfg);
111
+ scanHierarchyLevel(sourcePath, hierarchy, [], result, cfg, tag, qualifyId);
112
+
113
+ // Scan standalone entities
114
+ const standalone = (cfg.structure && cfg.structure.standalone) || {};
115
+ for (const [type, def] of Object.entries(standalone)) {
116
+ scanStandaloneEntity(sourcePath, type, def, result, cfg, tag, qualifyId);
117
+ }
118
+
119
+ return result;
120
+ }
121
+
122
+ /**
123
+ * Recursively scan a level of the hierarchy.
124
+ *
125
+ * @param {string} basePath - Current filesystem path
126
+ * @param {object} levelDef - Hierarchy level definition { entityType: { dir, children } }
127
+ * @param {object[]} ancestors - Array of { type, slug } for parent context
128
+ * @param {object} result - Model being built
129
+ * @param {object} cfg - Config
130
+ * @param {function} tag - Source tagger
131
+ * @param {function} qualifyId - ID qualifier
132
+ */
133
+ function scanHierarchyLevel(basePath, levelDef, ancestors, result, cfg, tag, qualifyId) {
134
+ for (const [type, def] of Object.entries(levelDef)) {
135
+ const entityDef = config.getEntity(cfg, type);
136
+ if (!entityDef) continue;
137
+
138
+ const entityDir = path.join(basePath, def.dir);
139
+ if (!fs.existsSync(entityDir)) continue;
140
+
141
+ if (isDirEntity(entityDef)) {
142
+ // Directory entity: each subdirectory is an instance
143
+ for (const slug of safeDirEntries(entityDir)) {
144
+ const instanceDir = path.join(entityDir, slug);
145
+ if (!fs.statSync(instanceDir).isDirectory()) continue;
146
+
147
+ const content = safeReadFile(path.join(instanceDir, 'README.md'));
148
+ if (!content) continue;
149
+
150
+ const parsed = parseFrontmatter(content);
151
+ const relFile = buildRelFile(ancestors, def.dir, slug, 'README.md');
152
+
153
+ const item = qualifyId(tag({
154
+ ...parsed.frontmatter,
155
+ _contentLength: parsed.content ? parsed.content.length : 0,
156
+ _type: type,
157
+ _dir: slug,
158
+ _file: relFile,
159
+ }));
121
160
 
122
- const epicsDir = path.join(msPath, epicDir);
123
- if (fs.existsSync(epicsDir)) {
124
- for (const epic of safeDirEntries(epicsDir)) {
125
- const epicPath = path.join(epicsDir, epic);
126
- if (!fs.statSync(epicPath).isDirectory()) continue;
127
-
128
- const epicReadme = safeReadFile(path.join(epicPath, 'README.md'));
129
- if (epicReadme) {
130
- const parsed = parseFrontmatter(epicReadme);
131
- result.epics.push(qualifyId(tag({
132
- ...parsed.frontmatter, content: parsed.content,
133
- _dir: epic, _milestone: ms,
134
- _file: `${msDir}/${ms}/${epicDir}/${epic}/README.md`
135
- })));
136
- }
137
-
138
- const backlogDir = path.join(epicPath, taskDir);
139
- if (fs.existsSync(backlogDir)) {
140
- for (const feat of safeDirEntries(backlogDir)) {
141
- if (!isTaskFile(feat, config)) continue;
142
-
143
- const featContent = safeReadFile(path.join(backlogDir, feat));
144
- if (featContent) {
145
- const parsed = parseFrontmatter(featContent);
146
- result.tasks.push(qualifyId(tag({
147
- ...parsed.frontmatter, content: parsed.content,
148
- _filename: feat, _epic: epic, _milestone: ms,
149
- _file: `${msDir}/${ms}/${epicDir}/${epic}/${taskDir}/${feat}`,
150
- })));
151
- }
152
- }
153
- }
161
+ // Store ancestor references
162
+ for (const anc of ancestors) {
163
+ item['_' + anc.type] = anc.slug;
154
164
  }
155
- }
156
165
 
157
- const sprintsDir = path.join(msPath, sprintDir);
158
- if (fs.existsSync(sprintsDir)) {
159
- for (const sp of safeDirEntries(sprintsDir)) {
160
- const spPath = path.join(sprintsDir, sp);
161
- if (!fs.statSync(spPath).isDirectory()) continue;
162
-
163
- const planMd = safeReadFile(path.join(spPath, 'plan.md'));
164
- if (planMd) {
165
- const parsed = parseFrontmatter(planMd);
166
- result.sprints.push(qualifyId(tag({
167
- ...parsed.frontmatter, content: parsed.content,
168
- _dir: sp, _milestone: ms,
169
- _file: `${msDir}/${ms}/${sprintDir}/${sp}/plan.md`
170
- })));
171
- }
172
-
173
- const boardMd = safeReadFile(path.join(spPath, 'board.md'));
174
- if (boardMd) {
175
- const parsed = parseFrontmatter(boardMd);
176
- result.boards.push(tag({
177
- ...parsed.frontmatter, content: parsed.content,
178
- _dir: sp, _milestone: ms,
179
- _file: `${msDir}/${ms}/${sprintDir}/${sp}/board.md`
180
- }));
181
- }
182
-
183
- const reviewMd = safeReadFile(path.join(spPath, 'review.md'));
184
- if (reviewMd) {
185
- const parsed = parseFrontmatter(reviewMd);
186
- result.reviews.push(tag({
187
- ...parsed.frontmatter, content: parsed.content,
188
- _dir: sp, _milestone: ms,
189
- _file: `${msDir}/${ms}/${sprintDir}/${sp}/review.md`
190
- }));
191
- }
166
+ result.entities[type].push(item);
167
+
168
+ // Recurse into children
169
+ if (def.children) {
170
+ const childAncestors = [...ancestors, { type, slug, dir: def.dir }];
171
+ scanHierarchyLevel(instanceDir, def.children, childAncestors, result, cfg, tag, qualifyId);
192
172
  }
193
173
  }
194
- }
195
- }
196
-
197
- // ── Notes: scan project/notes/*.md ──
198
- const notesDir = path.join(sourcePath, 'notes');
199
- if (fs.existsSync(notesDir)) {
200
- for (const file of safeDirEntries(notesDir)) {
201
- if (!file.endsWith('.md')) continue;
202
- const noteContent = safeReadFile(path.join(notesDir, file));
203
- if (noteContent) {
204
- const parsed = parseFrontmatter(noteContent);
205
- const slug = file.replace(/\.md$/, '');
206
- result.notes.push(tag({
207
- id: slug,
208
- title: parsed.frontmatter.title || slug,
209
- created: parsed.frontmatter.created || null,
210
- updated: parsed.frontmatter.updated || null,
211
- content: parsed.content,
212
- _file: 'notes/' + file,
213
- ...Object.fromEntries(
214
- Object.entries(parsed.frontmatter).filter(([k]) => k !== 'title' && k !== 'created' && k !== 'updated')
215
- ),
174
+ } else {
175
+ // File entity: each matching file in the dir is an instance
176
+ for (const file of safeDirEntries(entityDir)) {
177
+ if (!matchesFilePattern(file, entityDef)) continue;
178
+
179
+ const content = safeReadFile(path.join(entityDir, file));
180
+ if (!content) continue;
181
+
182
+ const parsed = parseFrontmatter(content);
183
+ const relFile = buildRelFile(ancestors, def.dir, null, file);
184
+
185
+ const item = qualifyId(tag({
186
+ ...parsed.frontmatter,
187
+ _contentLength: parsed.content ? parsed.content.length : 0,
188
+ _type: type,
189
+ _filename: file,
190
+ _file: relFile,
216
191
  }));
192
+
193
+ for (const anc of ancestors) {
194
+ item['_' + anc.type] = anc.slug;
195
+ }
196
+
197
+ result.entities[type].push(item);
217
198
  }
218
199
  }
219
200
  }
220
-
221
- return result;
222
201
  }
223
202
 
224
- function computeProgress(model, completedStatus) {
225
- for (const epic of model.epics) {
226
- const epicTasks = model.tasks.filter(f =>
227
- f._epic === epic._dir && f._milestone === epic._milestone &&
228
- (!epic._source || f._source === epic._source)
229
- );
230
- const done = epicTasks.filter(f => f.status === completedStatus).length;
231
- epic._featureCount = epicTasks.length;
232
- epic._completedCount = done;
233
- epic._progress = epicTasks.length > 0 ? Math.round((done / epicTasks.length) * 100) : 0;
234
- epic._totalPoints = epicTasks.reduce((sum, f) => sum + (f.points || 0), 0);
235
- }
236
-
237
- for (const ms of model.milestones) {
238
- const msTasks = model.tasks.filter(f =>
239
- f._milestone === ms._dir &&
240
- (!ms._source || f._source === ms._source)
241
- );
242
- const done = msTasks.filter(f => f.status === completedStatus).length;
243
- ms._featureCount = msTasks.length;
244
- ms._completedCount = done;
245
- ms._progress = msTasks.length > 0 ? Math.round((done / msTasks.length) * 100) : 0;
203
+ /**
204
+ * Scan a standalone entity directory (flat, not in hierarchy).
205
+ */
206
+ function scanStandaloneEntity(sourcePath, type, def, result, cfg, tag, qualifyId) {
207
+ const entityDef = config.getEntity(cfg, type);
208
+ if (!entityDef) return;
209
+
210
+ const dir = path.join(sourcePath, def.dir);
211
+ if (!fs.existsSync(dir)) return;
212
+
213
+ if (isDirEntity(entityDef)) {
214
+ for (const slug of safeDirEntries(dir)) {
215
+ const instanceDir = path.join(dir, slug);
216
+ if (!fs.statSync(instanceDir).isDirectory()) continue;
217
+
218
+ const content = safeReadFile(path.join(instanceDir, 'README.md'));
219
+ if (!content) continue;
220
+
221
+ const parsed = parseFrontmatter(content);
222
+ result.entities[type].push(qualifyId(tag({
223
+ ...parsed.frontmatter,
224
+ _contentLength: parsed.content ? parsed.content.length : 0,
225
+ _type: type,
226
+ _dir: slug,
227
+ _file: def.dir + '/' + slug + '/README.md',
228
+ })));
229
+ }
230
+ } else {
231
+ for (const file of safeDirEntries(dir)) {
232
+ if (!matchesFilePattern(file, entityDef)) continue;
233
+
234
+ const content = safeReadFile(path.join(dir, file));
235
+ if (!content) continue;
236
+
237
+ const parsed = parseFrontmatter(content);
238
+ const slug = file.replace(/\.md$/, '');
239
+
240
+ result.entities[type].push(qualifyId(tag({
241
+ id: parsed.frontmatter.id || slug,
242
+ title: parsed.frontmatter.title || slug,
243
+ ...parsed.frontmatter,
244
+ _contentLength: parsed.content ? parsed.content.length : 0,
245
+ _type: type,
246
+ _filename: file,
247
+ _file: def.dir + '/' + file,
248
+ })));
249
+ }
246
250
  }
247
251
  }
248
252
 
249
- function updateMarkdownFile(projectPath, relFile, updates) {
250
- const filePath = path.join(projectPath, relFile);
251
- if (!fs.existsSync(filePath)) throw new Error('File not found: ' + relFile);
252
-
253
- const raw = fs.readFileSync(filePath, 'utf-8');
254
- const parsed = parseFrontmatter(raw);
255
-
256
- const newFm = { ...parsed.frontmatter };
257
- let body = parsed.content;
258
-
259
- for (const [k, v] of Object.entries(updates)) {
260
- if (k === 'content') { body = v; continue; }
261
- if (k.startsWith('_')) continue;
262
- newFm[k] = v;
253
+ /**
254
+ * Build a relative file path from ancestors.
255
+ * Each ancestor has { type, slug, dir } where dir is the hierarchy directory name.
256
+ */
257
+ function buildRelFile(ancestors, dir, slug, filename) {
258
+ const parts = [];
259
+ for (const anc of ancestors) {
260
+ parts.push(anc.dir); // hierarchy dir name (e.g. "cycles")
261
+ parts.push(anc.slug); // instance slug (e.g. "cycle-01")
263
262
  }
264
-
265
- const yaml = serializeYaml(newFm);
266
- fs.writeFileSync(filePath, '---\n' + yaml + '\n---\n\n' + (body || '') + '\n', 'utf-8');
263
+ parts.push(dir);
264
+ if (slug) parts.push(slug);
265
+ parts.push(filename);
266
+ return parts.join('/');
267
267
  }
268
268
 
269
+ // --- Progress ---
270
+
269
271
  /**
270
- * Merge results from multiple scanSource calls into a single model.
272
+ * Compute progress for all entities that have children.
273
+ * Walks the hierarchy bottom-up: leaf entities contribute to parent progress.
274
+ *
275
+ * @param {object} model - Scanned model
276
+ * @param {object} cfg - Config
271
277
  */
272
- function mergeResults(model, results) {
273
- for (const r of results) {
274
- if (r.project && !model.project) model.project = r.project;
275
- model.milestones.push(...r.milestones);
276
- model.epics.push(...r.epics);
277
- model.tasks.push(...r.tasks);
278
- model.sprints.push(...r.sprints);
279
- model.boards.push(...r.boards);
280
- model.reviews.push(...r.reviews);
281
- if (r.notes) model.notes.push(...r.notes);
278
+ function computeProgress(model, cfg) {
279
+ const flat = config.flattenHierarchy(cfg);
280
+ const completedStatuses = (cfg.entities && cfg.entities.completedStatuses) || [];
281
+
282
+ // Find the leaf entity type (deepest in hierarchy, not standalone)
283
+ const hierarchyEntities = flat.filter(e => !e.standalone);
284
+ if (hierarchyEntities.length === 0) return;
285
+
286
+ const leafType = hierarchyEntities[hierarchyEntities.length - 1].type;
287
+ const leafItems = model.entities[leafType] || [];
288
+
289
+ // For each non-leaf hierarchy entity, compute progress from its leaf descendants
290
+ for (const entry of hierarchyEntities) {
291
+ if (entry.type === leafType) continue;
292
+
293
+ const items = model.entities[entry.type] || [];
294
+ for (const item of items) {
295
+ // Find leaf items that descend from this entity
296
+ const descendants = leafItems.filter(leaf => {
297
+ return leaf['_' + entry.type] === item._dir &&
298
+ (!item._source || leaf._source === item._source);
299
+ });
300
+
301
+ const total = descendants.length;
302
+ const done = descendants.filter(d => {
303
+ const status = d.status || '';
304
+ return completedStatuses.includes(status);
305
+ }).length;
306
+
307
+ item._childCount = total;
308
+ item._completedCount = done;
309
+ item._progress = total > 0 ? Math.round((done / total) * 100) : 0;
310
+ item._totalPoints = descendants.reduce((sum, d) => sum + (d.points || 0), 0);
311
+ item._completedPoints = descendants
312
+ .filter(d => completedStatuses.includes(d.status || ''))
313
+ .reduce((sum, d) => sum + (d.points || 0), 0);
314
+ }
282
315
  }
283
316
  }
284
317
 
318
+ // --- CRUD ---
319
+
285
320
  /**
286
321
  * Get the next auto-incremented ID for an entity type.
287
322
  *
288
- * @param {object[]} items - Existing items array
289
- * @param {string} prefix - ID prefix (e.g. "TASK", "MS", "EPIC", "SP")
290
- * @returns {string} - Next ID like "TASK-004"
323
+ * @param {object[]} items - Existing items
324
+ * @param {string} prefix - ID prefix (e.g. "TASK", "CYC", "BET")
325
+ * @returns {string}
291
326
  */
292
327
  function getNextId(items, prefix) {
293
328
  let max = 0;
@@ -304,308 +339,183 @@ function getNextId(items, prefix) {
304
339
  }
305
340
 
306
341
  /**
307
- * Create a new task markdown file.
342
+ * Create a new entity.
343
+ * Generic function that replaces createMilestone, createEpic, createTask, createSprint.
308
344
  *
309
345
  * @param {string} sourcePath - Absolute path to project/ dir
310
- * @param {object} config - mdboard config
311
- * @param {object} data - { title, status, priority, points, assigned, sprint, milestone, epic, content }
312
- * @param {object[]} existingTasks - Current tasks for ID generation
313
- * @returns {object} - { id, file }
346
+ * @param {object} cfg - Loaded config
347
+ * @param {string} type - Entity type
348
+ * @param {object} data - Entity data (title + field values + parent slugs)
349
+ * @param {object[]} existingItems - Current items of this type for ID generation
350
+ * @returns {object} - { id, slug, file }
314
351
  */
315
- function createTask(sourcePath, config, data, existingTasks) {
316
- const prefix = config.entities.task.prefix;
317
- const id = getNextId(existingTasks, prefix);
318
- const filename = id + '.md';
319
-
320
- const msDir = config.entities.milestone.dir;
321
- const epicDir = config.entities.epic.dir;
322
- const taskDir = config.entities.task.dir;
323
-
324
- if (!data.milestone || !data.epic) {
325
- throw new Error('milestone and epic are required to create a task');
352
+ function createEntity(sourcePath, cfg, type, data, existingItems) {
353
+ const entityDef = config.getEntity(cfg, type);
354
+ if (!entityDef) throw new Error('Unknown entity type: ' + type);
355
+
356
+ const prefix = entityDef.prefix;
357
+ const id = getNextId(existingItems, prefix);
358
+
359
+ // Build ancestor ids map from data
360
+ const ancestors = config.getAncestors(cfg, type);
361
+ const ids = {};
362
+ for (const anc of ancestors) {
363
+ if (!data[anc]) {
364
+ throw new Error(anc + ' is required to create a ' + type);
365
+ }
366
+ ids[anc] = data[anc];
326
367
  }
327
368
 
328
- const dir = path.join(sourcePath, msDir, data.milestone, epicDir, data.epic, taskDir);
329
- if (!fs.existsSync(dir)) {
330
- fs.mkdirSync(dir, { recursive: true });
331
- }
369
+ // resolveEntityPath includes root (e.g. "project/cycles/..."), but sourcePath
370
+ // already points to project/, so strip the root prefix
371
+ const root = config.getRoot(cfg);
372
+ const relPath = config.resolveEntityPath(cfg, type, ids).replace(new RegExp('^' + root + '/'), '');
373
+ const dirPath = path.join(sourcePath, relPath);
332
374
 
333
- const fm = {
334
- id: id,
335
- title: data.title || 'New Task',
336
- status: data.status || 'backlog',
337
- };
338
- if (data.priority) fm.priority = data.priority;
339
- if (data.points != null) fm.points = data.points;
340
- if (data.assigned) fm.assigned = data.assigned;
341
- if (data.sprint) fm.sprint = data.sprint;
342
- if (data.links) fm.links = data.links;
343
- if (data.ai) fm.ai = data.ai;
344
- fm.created = new Date().toISOString().split('T')[0];
375
+ if (isDirEntity(entityDef)) {
376
+ // Directory entity
377
+ const slug = slugify(data.title || id);
378
+ const instanceDir = path.join(dirPath, slug);
345
379
 
346
- const yaml = serializeYaml(fm);
347
- const content = data.content || '';
348
- const filePath = path.join(dir, filename);
349
- fs.writeFileSync(filePath, '---\n' + yaml + '\n---\n\n' + content + '\n', 'utf-8');
380
+ if (fs.existsSync(instanceDir)) {
381
+ throw new Error(entityDef.singular + ' directory already exists: ' + slug);
382
+ }
383
+ fs.mkdirSync(instanceDir, { recursive: true });
384
+
385
+ // Create child directories
386
+ const children = config.getChildren(cfg, type);
387
+ for (const childType of children) {
388
+ const childEntry = config.flattenHierarchy(cfg).find(e => e.type === childType);
389
+ if (childEntry) {
390
+ fs.mkdirSync(path.join(instanceDir, childEntry.dir), { recursive: true });
391
+ }
392
+ }
350
393
 
351
- const relFile = `${msDir}/${data.milestone}/${epicDir}/${data.epic}/${taskDir}/${filename}`;
352
- return { id, file: relFile };
353
- }
394
+ const fm = buildFrontmatter(cfg, type, id, data);
395
+ const yaml = serializeYaml(fm);
396
+ const content = data.content || '';
397
+ fs.writeFileSync(
398
+ path.join(instanceDir, 'README.md'),
399
+ '---\n' + yaml + '\n---\n\n' + content + '\n',
400
+ 'utf-8'
401
+ );
354
402
 
355
- /**
356
- * Create a new milestone directory with README.md.
357
- *
358
- * @param {string} sourcePath - Absolute path to project/ dir
359
- * @param {object} config - mdboard config
360
- * @param {object} data - { title, status, deadline, content, tracks }
361
- * @param {object[]} existingMilestones - Current milestones for ID generation
362
- * @returns {object} - { id, dir, file }
363
- */
364
- function createMilestone(sourcePath, config, data, existingMilestones) {
365
- const id = getNextId(existingMilestones, 'MS');
366
- const slug = (data.title || id).toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '');
367
- const msDir = config.entities.milestone.dir;
368
- const dirPath = path.join(sourcePath, msDir, slug);
369
-
370
- if (fs.existsSync(dirPath)) {
371
- throw new Error('Milestone directory already exists: ' + slug);
372
- }
373
- fs.mkdirSync(dirPath, { recursive: true });
374
-
375
- const fm = {
376
- id: id,
377
- title: data.title || 'New Milestone',
378
- status: data.status || 'planned',
379
- };
380
- if (data.deadline) fm.deadline = data.deadline;
381
- if (data.tracks) fm.tracks = data.tracks;
382
- if (data.ai) fm.ai = data.ai;
383
- fm.created = new Date().toISOString().split('T')[0];
403
+ const relFile = relPath + '/' + slug + '/README.md';
404
+ return { id, slug, file: relFile };
405
+
406
+ } else {
407
+ // File entity
408
+ const filename = id + '.md';
409
+ if (!fs.existsSync(dirPath)) {
410
+ fs.mkdirSync(dirPath, { recursive: true });
411
+ }
384
412
 
385
- const yaml = serializeYaml(fm);
386
- const content = data.content || '';
387
- const filePath = path.join(dirPath, 'README.md');
388
- fs.writeFileSync(filePath, '---\n' + yaml + '\n---\n\n' + content + '\n', 'utf-8');
413
+ const fm = buildFrontmatter(cfg, type, id, data);
414
+ const yaml = serializeYaml(fm);
415
+ const content = data.content || '';
416
+ fs.writeFileSync(
417
+ path.join(dirPath, filename),
418
+ '---\n' + yaml + '\n---\n\n' + content + '\n',
419
+ 'utf-8'
420
+ );
389
421
 
390
- const relFile = `${msDir}/${slug}/README.md`;
391
- return { id, dir: slug, file: relFile };
422
+ const relFile = relPath + '/' + filename;
423
+ return { id, slug: id, file: relFile };
424
+ }
392
425
  }
393
426
 
394
427
  /**
395
- * Create a new epic directory with README.md and backlog/.
396
- *
397
- * @param {string} sourcePath - Absolute path to project/ dir
398
- * @param {object} config - mdboard config
399
- * @param {object} data - { title, status, priority, milestone, content }
400
- * @param {object[]} existingEpics - Current epics for ID generation
401
- * @returns {object} - { id, dir, file }
428
+ * Build frontmatter object from entity fields config and provided data.
402
429
  */
403
- function createEpic(sourcePath, config, data, existingEpics) {
404
- if (!data.milestone) throw new Error('milestone is required to create an epic');
405
-
406
- const id = getNextId(existingEpics, 'EPIC');
407
- const slug = (data.title || id).toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '');
430
+ function buildFrontmatter(cfg, type, id, data) {
431
+ const fields = config.getFields(cfg, type);
432
+ const fm = { id };
408
433
 
409
- const msDir = config.entities.milestone.dir;
410
- const epicDir = config.entities.epic.dir;
411
- const taskDir = config.entities.task.dir;
434
+ if (data.title) fm.title = data.title;
412
435
 
413
- const dirPath = path.join(sourcePath, msDir, data.milestone, epicDir, slug);
414
- if (fs.existsSync(dirPath)) {
415
- throw new Error('Epic directory already exists: ' + slug);
436
+ for (const [name, def] of Object.entries(fields)) {
437
+ if (data[name] !== undefined) {
438
+ fm[name] = data[name];
439
+ } else if (def.default !== undefined) {
440
+ fm[name] = def.default;
441
+ }
416
442
  }
417
- fs.mkdirSync(dirPath, { recursive: true });
418
- fs.mkdirSync(path.join(dirPath, taskDir), { recursive: true });
419
-
420
- const fm = {
421
- id: id,
422
- title: data.title || 'New Epic',
423
- status: data.status || 'active',
424
- };
425
- if (data.priority) fm.priority = data.priority;
426
- if (data.dependencies) fm.dependencies = data.dependencies;
427
- if (data.ai) fm.ai = data.ai;
428
- fm.created = new Date().toISOString().split('T')[0];
429
443
 
430
- const yaml = serializeYaml(fm);
431
- const content = data.content || '';
432
- const filePath = path.join(dirPath, 'README.md');
433
- fs.writeFileSync(filePath, '---\n' + yaml + '\n---\n\n' + content + '\n', 'utf-8');
434
-
435
- const relFile = `${msDir}/${data.milestone}/${epicDir}/${slug}/README.md`;
436
- return { id, dir: slug, file: relFile };
444
+ fm.created = new Date().toISOString().split('T')[0];
445
+ return fm;
437
446
  }
438
447
 
439
448
  /**
440
- * Create a new sprint directory with plan.md.
449
+ * Update an entity's markdown file.
441
450
  *
442
451
  * @param {string} sourcePath - Absolute path to project/ dir
443
- * @param {object} config - mdboard config
444
- * @param {object} data - { goal, status, start_date, end_date, milestone, planned_points, features, content }
445
- * @param {object[]} existingSprints - Current sprints for ID generation
446
- * @returns {object} - { id, dir, file }
452
+ * @param {string} relFile - Relative file path
453
+ * @param {object} updates - Fields to update
447
454
  */
448
- function createSprint(sourcePath, config, data, existingSprints) {
449
- if (!data.milestone) throw new Error('milestone is required to create a sprint');
455
+ function updateEntity(sourcePath, relFile, updates) {
456
+ const filePath = path.join(sourcePath, relFile);
457
+ if (!fs.existsSync(filePath)) throw new Error('File not found: ' + relFile);
450
458
 
451
- const id = getNextId(existingSprints, 'SP');
452
- const slug = id.toLowerCase();
459
+ const raw = fs.readFileSync(filePath, 'utf-8');
460
+ const parsed = parseFrontmatter(raw);
453
461
 
454
- const msDir = config.entities.milestone.dir;
455
- const sprintDir = config.entities.sprint.dir;
462
+ const newFm = { ...parsed.frontmatter };
463
+ let body = parsed.content;
456
464
 
457
- const dirPath = path.join(sourcePath, msDir, data.milestone, sprintDir, slug);
458
- if (fs.existsSync(dirPath)) {
459
- throw new Error('Sprint directory already exists: ' + slug);
465
+ for (const [k, v] of Object.entries(updates)) {
466
+ if (k === 'content') { body = v; continue; }
467
+ if (k.startsWith('_')) continue;
468
+ newFm[k] = v;
460
469
  }
461
- fs.mkdirSync(dirPath, { recursive: true });
462
-
463
- const fm = {
464
- id: id,
465
- status: data.status || 'planned',
466
- };
467
- if (data.goal) fm.goal = data.goal;
468
- if (data.start_date) fm.start_date = data.start_date;
469
- if (data.end_date) fm.end_date = data.end_date;
470
- if (data.planned_points != null) fm.planned_points = data.planned_points;
471
- if (data.features) fm.features = data.features;
472
- fm.created = new Date().toISOString().split('T')[0];
473
-
474
- const yaml = serializeYaml(fm);
475
- const content = data.content || '';
476
- const filePath = path.join(dirPath, 'plan.md');
477
- fs.writeFileSync(filePath, '---\n' + yaml + '\n---\n\n' + content + '\n', 'utf-8');
478
470
 
479
- const relFile = `${msDir}/${data.milestone}/${sprintDir}/${slug}/plan.md`;
480
- return { id, dir: slug, file: relFile };
471
+ const yaml = serializeYaml(newFm);
472
+ fs.writeFileSync(filePath, '---\n' + yaml + '\n---\n\n' + (body || '') + '\n', 'utf-8');
481
473
  }
482
474
 
483
475
  /**
484
- * Delete an item by removing its file.
485
- * Git history preserves the file for recovery if needed.
476
+ * Delete (archive) an entity.
486
477
  *
487
478
  * @param {string} sourcePath - Absolute path to project/ dir
488
479
  * @param {object} item - The item with _file property
489
480
  * @returns {boolean}
490
481
  */
491
- function archiveItem(sourcePath, item) {
482
+ function deleteEntity(sourcePath, item) {
492
483
  if (!item || !item._file) throw new Error('Item has no file path');
493
-
494
484
  const srcPath = path.join(sourcePath, item._file);
495
- if (!fs.existsSync(srcPath)) throw new Error('Source file not found: ' + item._file);
496
-
485
+ if (!fs.existsSync(srcPath)) throw new Error('File not found: ' + item._file);
497
486
  fs.unlinkSync(srcPath);
498
487
  return true;
499
488
  }
500
489
 
501
490
  /**
502
- * Create a new note markdown file in notes/.
503
- *
504
- * @param {string} sourcePath - Absolute path to project/ dir
505
- * @param {object} data - { title, content }
506
- * @param {object[]} existingNotes - Current notes to check slug uniqueness
507
- * @returns {object} - { id, file }
491
+ * Merge results from multiple scanSource calls into a single model.
508
492
  */
509
- function createNote(sourcePath, data, existingNotes) {
510
- const title = data.title || 'Untitled';
511
- let slug = title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '');
512
- if (!slug) slug = 'note';
513
-
514
- // Ensure unique slug
515
- const existingIds = new Set(existingNotes.map(n => n.id || n._originalId));
516
- let finalSlug = slug;
517
- let counter = 1;
518
- while (existingIds.has(finalSlug)) {
519
- finalSlug = slug + '-' + counter;
520
- counter++;
521
- }
522
-
523
- const dir = path.join(sourcePath, 'notes');
524
- if (!fs.existsSync(dir)) {
525
- fs.mkdirSync(dir, { recursive: true });
526
- }
527
-
528
- const today = new Date().toISOString().split('T')[0];
529
- const fm = { title: title, created: today, updated: today };
530
- const yaml = serializeYaml(fm);
531
- const content = data.content || '';
532
- const filePath = path.join(dir, finalSlug + '.md');
533
- fs.writeFileSync(filePath, '---\n' + yaml + '\n---\n\n' + content + '\n', 'utf-8');
534
-
535
- return { id: finalSlug, file: 'notes/' + finalSlug + '.md' };
536
- }
537
-
538
- // ── Agentic AI properties ──────────────────────────────────────────
539
-
540
- const AI_FIELDS = ['skills', 'agents', 'mcps', 'commands', 'context'];
541
-
542
- function extractAiProps(item) {
543
- const result = {};
544
- if (!item || !item.ai || typeof item.ai !== 'object') return result;
545
- for (const field of AI_FIELDS) {
546
- const val = item.ai[field];
547
- if (val != null) {
548
- result[field] = Array.isArray(val) ? val : [val];
549
- }
550
- }
551
- return result;
552
- }
553
-
554
- function mergeAiProps(layers) {
555
- const merged = {};
556
- for (const layer of layers) {
557
- for (const field of AI_FIELDS) {
558
- const arr = layer[field];
559
- if (!arr || arr.length === 0) continue;
560
- if (!merged[field]) merged[field] = [];
561
- for (const v of arr) {
562
- if (merged[field].indexOf(v) === -1) merged[field].push(v);
493
+ function mergeResults(model, results) {
494
+ for (const r of results) {
495
+ if (r.project && !model.project) model.project = r.project;
496
+ for (const type of Object.keys(model.entities)) {
497
+ if (r.entities[type]) {
498
+ model.entities[type].push(...r.entities[type]);
563
499
  }
564
500
  }
565
501
  }
566
- return merged;
567
502
  }
568
503
 
569
- function computeAgentProps(model) {
570
- const projectAi = model.project ? extractAiProps(model.project) : {};
571
-
572
- for (const ms of model.milestones) {
573
- const msOwn = extractAiProps(ms);
574
- const msResolved = mergeAiProps([projectAi, msOwn]);
575
- ms._ai = Object.keys(msResolved).length > 0 ? msResolved : null;
576
- ms._aiOwn = Object.keys(msOwn).length > 0 ? msOwn : null;
577
- }
578
-
579
- for (const epic of model.epics) {
580
- const epicOwn = extractAiProps(epic);
581
- const parentMs = model.milestones.find(m =>
582
- m._dir === epic._milestone && (!epic._source || m._source === epic._source)
583
- );
584
- const msAi = parentMs ? extractAiProps(parentMs) : {};
585
- const epicResolved = mergeAiProps([projectAi, msAi, epicOwn]);
586
- epic._ai = Object.keys(epicResolved).length > 0 ? epicResolved : null;
587
- epic._aiOwn = Object.keys(epicOwn).length > 0 ? epicOwn : null;
588
- }
589
-
590
- for (const task of model.tasks) {
591
- const taskOwn = extractAiProps(task);
592
- const parentEpic = model.epics.find(e =>
593
- e._dir === task._epic && e._milestone === task._milestone &&
594
- (!task._source || e._source === task._source)
595
- );
596
- const parentMs = model.milestones.find(m =>
597
- m._dir === task._milestone && (!task._source || m._source === task._source)
598
- );
599
- const msAi = parentMs ? extractAiProps(parentMs) : {};
600
- const epicAi = parentEpic ? extractAiProps(parentEpic) : {};
601
- const taskResolved = mergeAiProps([projectAi, msAi, epicAi, taskOwn]);
602
- task._ai = Object.keys(taskResolved).length > 0 ? taskResolved : null;
603
- task._aiOwn = Object.keys(taskOwn).length > 0 ? taskOwn : null;
604
- }
605
- }
504
+ // --- Export ---
606
505
 
607
506
  module.exports = {
608
- createModel, scanSource, computeProgress, computeAgentProps, isTaskFile,
609
- safeReadFile, safeDirEntries, updateMarkdownFile, mergeResults,
610
- getNextId, createTask, createMilestone, createEpic, createSprint, createNote, archiveItem
507
+ createModel,
508
+ scanSource,
509
+ computeProgress,
510
+ mergeResults,
511
+ getNextId,
512
+ createEntity,
513
+ updateEntity,
514
+ deleteEntity,
515
+ // Utilities
516
+ safeReadFile,
517
+ safeDirEntries,
518
+ slugify,
519
+ matchesFilePattern,
520
+ isDirEntity,
611
521
  };