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/bin.js +44 -16
- package/build.js +44 -0
- package/index.html +1835 -216
- package/package.json +7 -10
- package/src/cli/cli.js +362 -0
- package/src/cli/init.js +123 -0
- package/src/cli/status.js +150 -0
- package/src/cli/sync.js +194 -0
- package/src/cli/theme.js +142 -0
- package/src/client/app.js +266 -0
- package/src/client/board.js +157 -0
- package/src/client/core.js +331 -0
- package/src/client/editor.js +318 -0
- package/src/client/history.js +137 -0
- package/src/client/metrics.js +38 -0
- package/src/client/milestones.js +77 -0
- package/src/client/notes.js +183 -0
- package/src/client/overview.js +104 -0
- package/src/client/panel.js +637 -0
- package/src/client/styles.css +471 -0
- package/src/client/table.js +111 -0
- package/src/client/template.html +144 -0
- package/src/client/themes.js +261 -0
- package/src/client/workspace.js +164 -0
- package/src/core/agent-scanner.js +260 -0
- package/{config.js → src/core/config.js} +27 -2
- package/src/core/history.js +130 -0
- package/{scanner.js → src/core/scanner.js} +141 -21
- package/{yaml.js → src/core/yaml.js} +5 -1
- package/{api.js → src/server/api.js} +150 -9
- package/{server.js → src/server/server.js} +105 -32
- package/{watcher.js → src/server/watcher.js} +40 -9
- package/init.js +0 -109
- /package/{workspace.js → src/core/workspace.js} +0 -0
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* mdboard — Agent configuration scanner
|
|
3
|
+
*
|
|
4
|
+
* Scans projectDir for agent/AI configuration files and returns
|
|
5
|
+
* suggestions for autocomplete in the AI properties UI.
|
|
6
|
+
*
|
|
7
|
+
* Supported agents:
|
|
8
|
+
* Claude Code, Codex CLI, Gemini CLI, Cursor, VS Code Copilot,
|
|
9
|
+
* Windsurf, Cline, KiloCode, OpenClaw
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const fs = require('fs');
|
|
13
|
+
const path = require('path');
|
|
14
|
+
|
|
15
|
+
function safeReadFile(filePath) {
|
|
16
|
+
try { return fs.readFileSync(filePath, 'utf-8'); } catch { return null; }
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function safeDirEntries(dir) {
|
|
20
|
+
try { return fs.readdirSync(dir).filter(e => !e.startsWith('.')); } catch { return []; }
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function safeDirEntriesRecursive(dir, prefix) {
|
|
24
|
+
const results = [];
|
|
25
|
+
for (const entry of safeDirEntries(dir)) {
|
|
26
|
+
const full = path.join(dir, entry);
|
|
27
|
+
const rel = prefix ? prefix + '/' + entry : entry;
|
|
28
|
+
try {
|
|
29
|
+
if (fs.statSync(full).isDirectory()) {
|
|
30
|
+
if (fs.existsSync(path.join(full, 'SKILL.md'))) {
|
|
31
|
+
results.push(rel);
|
|
32
|
+
}
|
|
33
|
+
results.push(...safeDirEntriesRecursive(full, rel));
|
|
34
|
+
} else if (entry.endsWith('.md')) {
|
|
35
|
+
results.push(rel);
|
|
36
|
+
}
|
|
37
|
+
} catch { /* skip */ }
|
|
38
|
+
}
|
|
39
|
+
return results;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Push value to array if not already present */
|
|
43
|
+
function pushUnique(arr, value) {
|
|
44
|
+
if (arr.indexOf(value) === -1) arr.push(value);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Collect .md files from a directory into an array (non-recursive) */
|
|
48
|
+
function collectMdFiles(dir, arr, prefix) {
|
|
49
|
+
for (const entry of safeDirEntries(dir)) {
|
|
50
|
+
if (entry.endsWith('.md') || entry.endsWith('.mdc') || entry.endsWith('.agent.md')) {
|
|
51
|
+
pushUnique(arr, prefix ? prefix + '/' + entry : entry);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Collect all files from a directory into an array (non-recursive) */
|
|
57
|
+
function collectAllFiles(dir, arr, prefix) {
|
|
58
|
+
for (const entry of safeDirEntries(dir)) {
|
|
59
|
+
const full = path.join(dir, entry);
|
|
60
|
+
try {
|
|
61
|
+
if (!fs.statSync(full).isDirectory()) {
|
|
62
|
+
pushUnique(arr, prefix ? prefix + '/' + entry : entry);
|
|
63
|
+
}
|
|
64
|
+
} catch { /* skip */ }
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Extract mcpServers keys from a JSON file */
|
|
69
|
+
function extractMcpServersFromJson(filePath, arr) {
|
|
70
|
+
const content = safeReadFile(filePath);
|
|
71
|
+
if (!content) return;
|
|
72
|
+
try {
|
|
73
|
+
const parsed = JSON.parse(content);
|
|
74
|
+
const servers = parsed.mcpServers || parsed;
|
|
75
|
+
if (servers && typeof servers === 'object' && !Array.isArray(servers)) {
|
|
76
|
+
for (const key of Object.keys(servers)) {
|
|
77
|
+
if (key !== '$schema' && key !== 'mcpServers') pushUnique(arr, key);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
} catch { /* invalid JSON */ }
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Check if a file exists and push to context */
|
|
84
|
+
function checkContextFile(projectDir, relativePath, arr) {
|
|
85
|
+
if (fs.existsSync(path.join(projectDir, relativePath))) {
|
|
86
|
+
pushUnique(arr, relativePath);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Scan projectDir for agent configuration files across all supported agents.
|
|
92
|
+
*
|
|
93
|
+
* @param {string} projectDir - Root of the repo
|
|
94
|
+
* @returns {{ skills: string[], agents: string[], mcps: string[], commands: string[], context: string[] }}
|
|
95
|
+
*/
|
|
96
|
+
function scanAgentConfigs(projectDir) {
|
|
97
|
+
const result = { skills: [], agents: [], mcps: [], commands: [], context: [] };
|
|
98
|
+
|
|
99
|
+
// ─── SKILLS ───────────────────────────────────────────────
|
|
100
|
+
|
|
101
|
+
// Claude Code: .claude/skills/ — .md files and subdirs with SKILL.md
|
|
102
|
+
result.skills = safeDirEntriesRecursive(path.join(projectDir, '.claude', 'skills'), '');
|
|
103
|
+
|
|
104
|
+
// Codex CLI: .codex/skills/, .agents/skills/
|
|
105
|
+
for (const dir of ['.codex/skills', '.agents/skills']) {
|
|
106
|
+
const entries = safeDirEntriesRecursive(path.join(projectDir, ...dir.split('/')), '');
|
|
107
|
+
for (const e of entries) pushUnique(result.skills, e);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// ─── AGENTS ───────────────────────────────────────────────
|
|
111
|
+
|
|
112
|
+
// Claude Code: .claude/agents/*.md
|
|
113
|
+
collectMdFiles(path.join(projectDir, '.claude', 'agents'), result.agents, '');
|
|
114
|
+
|
|
115
|
+
// Cursor: .cursor/agents/*.md
|
|
116
|
+
collectMdFiles(path.join(projectDir, '.cursor', 'agents'), result.agents, '');
|
|
117
|
+
|
|
118
|
+
// VS Code Copilot: .github/agents/*.md
|
|
119
|
+
collectMdFiles(path.join(projectDir, '.github', 'agents'), result.agents, '');
|
|
120
|
+
|
|
121
|
+
// ─── MCP SERVERS ──────────────────────────────────────────
|
|
122
|
+
|
|
123
|
+
// Claude Code: .mcp.json (project-level)
|
|
124
|
+
extractMcpServersFromJson(path.join(projectDir, '.mcp.json'), result.mcps);
|
|
125
|
+
|
|
126
|
+
// Claude Code: .claude/settings.local.json — enabledMcpjsonServers
|
|
127
|
+
const settingsJson = safeReadFile(path.join(projectDir, '.claude', 'settings.local.json'));
|
|
128
|
+
if (settingsJson) {
|
|
129
|
+
try {
|
|
130
|
+
const parsed = JSON.parse(settingsJson);
|
|
131
|
+
if (Array.isArray(parsed.enabledMcpjsonServers)) {
|
|
132
|
+
for (const srv of parsed.enabledMcpjsonServers) {
|
|
133
|
+
if (typeof srv === 'string') pushUnique(result.mcps, srv);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
} catch { /* invalid JSON */ }
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Cursor: .cursor/mcp.json
|
|
140
|
+
extractMcpServersFromJson(path.join(projectDir, '.cursor', 'mcp.json'), result.mcps);
|
|
141
|
+
|
|
142
|
+
// VS Code Copilot: .vscode/mcp.json
|
|
143
|
+
extractMcpServersFromJson(path.join(projectDir, '.vscode', 'mcp.json'), result.mcps);
|
|
144
|
+
|
|
145
|
+
// KiloCode: .kilocode/mcp.json
|
|
146
|
+
extractMcpServersFromJson(path.join(projectDir, '.kilocode', 'mcp.json'), result.mcps);
|
|
147
|
+
|
|
148
|
+
// Gemini CLI: .gemini/settings.json — mcpServers
|
|
149
|
+
const geminiSettings = safeReadFile(path.join(projectDir, '.gemini', 'settings.json'));
|
|
150
|
+
if (geminiSettings) {
|
|
151
|
+
try {
|
|
152
|
+
const parsed = JSON.parse(geminiSettings);
|
|
153
|
+
if (parsed.mcpServers && typeof parsed.mcpServers === 'object') {
|
|
154
|
+
for (const key of Object.keys(parsed.mcpServers)) {
|
|
155
|
+
pushUnique(result.mcps, key);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
} catch { /* invalid JSON */ }
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Codex CLI: .codex/config.toml — [mcp_servers.<name>]
|
|
162
|
+
const codexConfig = safeReadFile(path.join(projectDir, '.codex', 'config.toml'));
|
|
163
|
+
if (codexConfig) {
|
|
164
|
+
const mcpMatches = codexConfig.matchAll(/\[mcp_servers\.(\w[\w-]*)\]/g);
|
|
165
|
+
for (const m of mcpMatches) pushUnique(result.mcps, m[1]);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// ─── COMMANDS ─────────────────────────────────────────────
|
|
169
|
+
|
|
170
|
+
// Claude Code: .claude/commands/
|
|
171
|
+
collectAllFiles(path.join(projectDir, '.claude', 'commands'), result.commands, '');
|
|
172
|
+
|
|
173
|
+
// Gemini CLI: .gemini/commands/ (.toml files)
|
|
174
|
+
collectAllFiles(path.join(projectDir, '.gemini', 'commands'), result.commands, '');
|
|
175
|
+
|
|
176
|
+
// VS Code Copilot: .github/prompts/ (.prompt.md files)
|
|
177
|
+
collectAllFiles(path.join(projectDir, '.github', 'prompts'), result.commands, '');
|
|
178
|
+
|
|
179
|
+
// Windsurf: .windsurf/workflows/
|
|
180
|
+
collectAllFiles(path.join(projectDir, '.windsurf', 'workflows'), result.commands, '');
|
|
181
|
+
|
|
182
|
+
// CLAUDE.md — parse dev commands section
|
|
183
|
+
const claudeMd = safeReadFile(path.join(projectDir, 'CLAUDE.md'))
|
|
184
|
+
|| safeReadFile(path.join(projectDir, '.claude', 'CLAUDE.md'));
|
|
185
|
+
if (claudeMd) {
|
|
186
|
+
const lines = claudeMd.split('\n');
|
|
187
|
+
let inCommands = false;
|
|
188
|
+
for (const line of lines) {
|
|
189
|
+
if (/^#+\s*.*(command|script|dev)/i.test(line)) {
|
|
190
|
+
inCommands = true;
|
|
191
|
+
continue;
|
|
192
|
+
}
|
|
193
|
+
if (inCommands && /^#+\s/.test(line)) {
|
|
194
|
+
inCommands = false;
|
|
195
|
+
continue;
|
|
196
|
+
}
|
|
197
|
+
if (inCommands) {
|
|
198
|
+
const match = line.match(/^[-*]\s*`([^`]+)`/);
|
|
199
|
+
if (match) pushUnique(result.commands, match[1]);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// ─── CONTEXT / RULES ─────────────────────────────────────
|
|
205
|
+
|
|
206
|
+
// Claude Code
|
|
207
|
+
checkContextFile(projectDir, 'CLAUDE.md', result.context);
|
|
208
|
+
checkContextFile(projectDir, '.claude/CLAUDE.md', result.context);
|
|
209
|
+
checkContextFile(projectDir, 'CLAUDE.local.md', result.context);
|
|
210
|
+
collectMdFiles(path.join(projectDir, '.claude', 'rules'), result.context, '.claude/rules');
|
|
211
|
+
|
|
212
|
+
// Cursor
|
|
213
|
+
checkContextFile(projectDir, '.cursorrules', result.context);
|
|
214
|
+
for (const entry of safeDirEntries(path.join(projectDir, '.cursor', 'rules'))) {
|
|
215
|
+
pushUnique(result.context, '.cursor/rules/' + entry);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// Codex CLI
|
|
219
|
+
checkContextFile(projectDir, 'AGENTS.md', result.context);
|
|
220
|
+
checkContextFile(projectDir, 'AGENTS.override.md', result.context);
|
|
221
|
+
|
|
222
|
+
// Gemini CLI
|
|
223
|
+
checkContextFile(projectDir, 'GEMINI.md', result.context);
|
|
224
|
+
|
|
225
|
+
// Windsurf
|
|
226
|
+
checkContextFile(projectDir, '.windsurfrules', result.context);
|
|
227
|
+
for (const entry of safeDirEntries(path.join(projectDir, '.windsurf', 'rules'))) {
|
|
228
|
+
pushUnique(result.context, '.windsurf/rules/' + entry);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// Cline
|
|
232
|
+
checkContextFile(projectDir, '.clinerules', result.context);
|
|
233
|
+
// .clinerules/ as directory
|
|
234
|
+
for (const entry of safeDirEntries(path.join(projectDir, '.clinerules'))) {
|
|
235
|
+
pushUnique(result.context, '.clinerules/' + entry);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// KiloCode
|
|
239
|
+
checkContextFile(projectDir, '.kilocoderules', result.context);
|
|
240
|
+
for (const entry of safeDirEntries(path.join(projectDir, '.kilocode', 'rules'))) {
|
|
241
|
+
pushUnique(result.context, '.kilocode/rules/' + entry);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// VS Code Copilot
|
|
245
|
+
checkContextFile(projectDir, '.github/copilot-instructions.md', result.context);
|
|
246
|
+
for (const entry of safeDirEntries(path.join(projectDir, '.github', 'instructions'))) {
|
|
247
|
+
pushUnique(result.context, '.github/instructions/' + entry);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// OpenClaw
|
|
251
|
+
checkContextFile(projectDir, 'SOUL.md', result.context);
|
|
252
|
+
checkContextFile(projectDir, 'IDENTITY.md', result.context);
|
|
253
|
+
checkContextFile(projectDir, 'USER.md', result.context);
|
|
254
|
+
checkContextFile(projectDir, 'TOOLS.md', result.context);
|
|
255
|
+
checkContextFile(projectDir, 'MEMORY.md', result.context);
|
|
256
|
+
|
|
257
|
+
return result;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
module.exports = { scanAgentConfigs };
|
|
@@ -13,7 +13,7 @@ const fs = require('fs');
|
|
|
13
13
|
const path = require('path');
|
|
14
14
|
const os = require('os');
|
|
15
15
|
|
|
16
|
-
const defaults = require('
|
|
16
|
+
const defaults = require('../../defaults.json');
|
|
17
17
|
|
|
18
18
|
function deepMerge(target, source) {
|
|
19
19
|
const result = { ...target };
|
|
@@ -70,4 +70,29 @@ function loadConfig(projectDir, explicitPath) {
|
|
|
70
70
|
return config;
|
|
71
71
|
}
|
|
72
72
|
|
|
73
|
-
|
|
73
|
+
function resolveTheme(projectDir, explicitPath) {
|
|
74
|
+
const candidates = [];
|
|
75
|
+
|
|
76
|
+
if (explicitPath) {
|
|
77
|
+
candidates.push(path.resolve(explicitPath));
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (projectDir) {
|
|
81
|
+
candidates.push(path.join(projectDir, 'project', 'mdboard.json'));
|
|
82
|
+
candidates.push(path.join(projectDir, 'mdboard.json'));
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const globalPath = path.join(os.homedir(), '.config', 'mdboard', 'mdboard.json');
|
|
86
|
+
candidates.push(globalPath);
|
|
87
|
+
|
|
88
|
+
for (const p of candidates) {
|
|
89
|
+
const data = tryReadJson(p);
|
|
90
|
+
if (data && data.theme) {
|
|
91
|
+
return data.theme;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
module.exports = { loadConfig, deepMerge, resolveTheme };
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* mdboard — Project History
|
|
3
|
+
*
|
|
4
|
+
* Persistent history of visited projects stored in
|
|
5
|
+
* ~/.config/mdboard/history.json. Allows switching between
|
|
6
|
+
* projects without restarting the server.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const fs = require('fs');
|
|
10
|
+
const path = require('path');
|
|
11
|
+
const os = require('os');
|
|
12
|
+
|
|
13
|
+
const CONFIG_DIR = path.join(os.homedir(), '.config', 'mdboard');
|
|
14
|
+
const HISTORY_FILE = path.join(CONFIG_DIR, 'history.json');
|
|
15
|
+
const MAX_ENTRIES = 50;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Read the history file.
|
|
19
|
+
* @returns {Array<{path:string, name:string, lastOpened:string}>}
|
|
20
|
+
*/
|
|
21
|
+
function readHistory() {
|
|
22
|
+
try {
|
|
23
|
+
const raw = fs.readFileSync(HISTORY_FILE, 'utf-8');
|
|
24
|
+
const data = JSON.parse(raw);
|
|
25
|
+
return Array.isArray(data) ? data : [];
|
|
26
|
+
} catch {
|
|
27
|
+
return [];
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Write history to disk.
|
|
33
|
+
* @param {Array} entries
|
|
34
|
+
*/
|
|
35
|
+
function writeHistory(entries) {
|
|
36
|
+
try {
|
|
37
|
+
fs.mkdirSync(CONFIG_DIR, { recursive: true });
|
|
38
|
+
fs.writeFileSync(HISTORY_FILE, JSON.stringify(entries, null, 2), 'utf-8');
|
|
39
|
+
} catch {
|
|
40
|
+
// Non-critical — silently ignore write failures
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Register (or update) a project in the history.
|
|
46
|
+
* Moves the entry to the front of the list.
|
|
47
|
+
*
|
|
48
|
+
* @param {string} projectDir - Absolute path to the project directory
|
|
49
|
+
* @param {string} name - Human-friendly project name
|
|
50
|
+
*/
|
|
51
|
+
function registerProject(projectDir, name) {
|
|
52
|
+
const absPath = path.resolve(projectDir);
|
|
53
|
+
let entries = readHistory();
|
|
54
|
+
|
|
55
|
+
// Remove existing entry for this path
|
|
56
|
+
entries = entries.filter(e => e.path !== absPath);
|
|
57
|
+
|
|
58
|
+
// Prepend new entry
|
|
59
|
+
entries.unshift({
|
|
60
|
+
path: absPath,
|
|
61
|
+
name: name || path.basename(absPath),
|
|
62
|
+
lastOpened: new Date().toISOString(),
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
// Enforce limit
|
|
66
|
+
if (entries.length > MAX_ENTRIES) {
|
|
67
|
+
entries = entries.slice(0, MAX_ENTRIES);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
writeHistory(entries);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Get the history list with a `isCurrent` flag.
|
|
75
|
+
*
|
|
76
|
+
* @param {string} currentProjectDir - The currently-active project dir
|
|
77
|
+
* @returns {Array<{path:string, name:string, lastOpened:string, isCurrent:boolean}>}
|
|
78
|
+
*/
|
|
79
|
+
function getHistory(currentProjectDir) {
|
|
80
|
+
const absCurrentDir = currentProjectDir ? path.resolve(currentProjectDir) : null;
|
|
81
|
+
const entries = readHistory();
|
|
82
|
+
|
|
83
|
+
return entries.map(e => ({
|
|
84
|
+
...e,
|
|
85
|
+
isCurrent: e.path === absCurrentDir,
|
|
86
|
+
}));
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Detect whether a directory looks like a valid mdboard project.
|
|
91
|
+
* A project has either a `project/` subdirectory or a `workspace.json`
|
|
92
|
+
* with sources.
|
|
93
|
+
*
|
|
94
|
+
* @param {string} dir - Directory to check
|
|
95
|
+
* @returns {boolean}
|
|
96
|
+
*/
|
|
97
|
+
function isValidProject(dir) {
|
|
98
|
+
try {
|
|
99
|
+
const absDir = path.resolve(dir);
|
|
100
|
+
|
|
101
|
+
// Check for project/ sub-directory
|
|
102
|
+
if (fs.existsSync(path.join(absDir, 'project'))) return true;
|
|
103
|
+
|
|
104
|
+
// Check for workspace.json with sources
|
|
105
|
+
const wsPath = path.join(absDir, 'workspace.json');
|
|
106
|
+
if (fs.existsSync(wsPath)) {
|
|
107
|
+
const raw = fs.readFileSync(wsPath, 'utf-8');
|
|
108
|
+
const ws = JSON.parse(raw);
|
|
109
|
+
if (ws && Array.isArray(ws.sources) && ws.sources.length > 0) return true;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return false;
|
|
113
|
+
} catch {
|
|
114
|
+
return false;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Remove an entry from the history.
|
|
120
|
+
*
|
|
121
|
+
* @param {string} projectPath - Absolute path to remove
|
|
122
|
+
*/
|
|
123
|
+
function removeFromHistory(projectPath) {
|
|
124
|
+
const absPath = path.resolve(projectPath);
|
|
125
|
+
let entries = readHistory();
|
|
126
|
+
entries = entries.filter(e => e.path !== absPath);
|
|
127
|
+
writeHistory(entries);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
module.exports = { readHistory, registerProject, getHistory, isValidProject, removeFromHistory };
|
|
@@ -18,7 +18,7 @@ function createModel() {
|
|
|
18
18
|
sprints: [],
|
|
19
19
|
boards: [],
|
|
20
20
|
reviews: [],
|
|
21
|
-
|
|
21
|
+
notes: [],
|
|
22
22
|
};
|
|
23
23
|
}
|
|
24
24
|
|
|
@@ -55,7 +55,7 @@ function isTaskFile(filename, config) {
|
|
|
55
55
|
* @param {string} sourcePath - Absolute path to the project/ directory
|
|
56
56
|
* @param {object} config - mdboard config object
|
|
57
57
|
* @param {object} sourceMeta - { name, label, color, type, readonly }
|
|
58
|
-
* @returns {object} - { project, milestones, epics, tasks, sprints, boards, reviews
|
|
58
|
+
* @returns {object} - { project, milestones, epics, tasks, sprints, boards, reviews }
|
|
59
59
|
*/
|
|
60
60
|
function scanSource(sourcePath, config, sourceMeta) {
|
|
61
61
|
const result = {
|
|
@@ -66,7 +66,7 @@ function scanSource(sourcePath, config, sourceMeta) {
|
|
|
66
66
|
sprints: [],
|
|
67
67
|
boards: [],
|
|
68
68
|
reviews: [],
|
|
69
|
-
|
|
69
|
+
notes: [],
|
|
70
70
|
};
|
|
71
71
|
|
|
72
72
|
if (!fs.existsSync(sourcePath)) return result;
|
|
@@ -99,12 +99,6 @@ function scanSource(sourcePath, config, sourceMeta) {
|
|
|
99
99
|
result.project = tag({ ...parsed.frontmatter, content: parsed.content, _file: 'PROJECT.md' });
|
|
100
100
|
}
|
|
101
101
|
|
|
102
|
-
const metricsMd = safeReadFile(path.join(sourcePath, 'metrics.md'));
|
|
103
|
-
if (metricsMd) {
|
|
104
|
-
const parsed = parseFrontmatter(metricsMd);
|
|
105
|
-
result.metrics = tag({ ...parsed.frontmatter, content: parsed.content, _file: 'metrics.md' });
|
|
106
|
-
}
|
|
107
|
-
|
|
108
102
|
const msDir = config.entities.milestone.dir;
|
|
109
103
|
const epicDir = config.entities.epic.dir;
|
|
110
104
|
const taskDir = config.entities.task.dir;
|
|
@@ -200,6 +194,30 @@ function scanSource(sourcePath, config, sourceMeta) {
|
|
|
200
194
|
}
|
|
201
195
|
}
|
|
202
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
|
+
),
|
|
216
|
+
}));
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
203
221
|
return result;
|
|
204
222
|
}
|
|
205
223
|
|
|
@@ -260,7 +278,7 @@ function mergeResults(model, results) {
|
|
|
260
278
|
model.sprints.push(...r.sprints);
|
|
261
279
|
model.boards.push(...r.boards);
|
|
262
280
|
model.reviews.push(...r.reviews);
|
|
263
|
-
if (r.
|
|
281
|
+
if (r.notes) model.notes.push(...r.notes);
|
|
264
282
|
}
|
|
265
283
|
}
|
|
266
284
|
|
|
@@ -322,6 +340,7 @@ function createTask(sourcePath, config, data, existingTasks) {
|
|
|
322
340
|
if (data.assigned) fm.assigned = data.assigned;
|
|
323
341
|
if (data.sprint) fm.sprint = data.sprint;
|
|
324
342
|
if (data.links) fm.links = data.links;
|
|
343
|
+
if (data.ai) fm.ai = data.ai;
|
|
325
344
|
fm.created = new Date().toISOString().split('T')[0];
|
|
326
345
|
|
|
327
346
|
const yaml = serializeYaml(fm);
|
|
@@ -360,6 +379,7 @@ function createMilestone(sourcePath, config, data, existingMilestones) {
|
|
|
360
379
|
};
|
|
361
380
|
if (data.deadline) fm.deadline = data.deadline;
|
|
362
381
|
if (data.tracks) fm.tracks = data.tracks;
|
|
382
|
+
if (data.ai) fm.ai = data.ai;
|
|
363
383
|
fm.created = new Date().toISOString().split('T')[0];
|
|
364
384
|
|
|
365
385
|
const yaml = serializeYaml(fm);
|
|
@@ -404,6 +424,7 @@ function createEpic(sourcePath, config, data, existingEpics) {
|
|
|
404
424
|
};
|
|
405
425
|
if (data.priority) fm.priority = data.priority;
|
|
406
426
|
if (data.dependencies) fm.dependencies = data.dependencies;
|
|
427
|
+
if (data.ai) fm.ai = data.ai;
|
|
407
428
|
fm.created = new Date().toISOString().split('T')[0];
|
|
408
429
|
|
|
409
430
|
const yaml = serializeYaml(fm);
|
|
@@ -460,7 +481,8 @@ function createSprint(sourcePath, config, data, existingSprints) {
|
|
|
460
481
|
}
|
|
461
482
|
|
|
462
483
|
/**
|
|
463
|
-
*
|
|
484
|
+
* Delete an item by removing its file.
|
|
485
|
+
* Git history preserves the file for recovery if needed.
|
|
464
486
|
*
|
|
465
487
|
* @param {string} sourcePath - Absolute path to project/ dir
|
|
466
488
|
* @param {object} item - The item with _file property
|
|
@@ -472,20 +494,118 @@ function archiveItem(sourcePath, item) {
|
|
|
472
494
|
const srcPath = path.join(sourcePath, item._file);
|
|
473
495
|
if (!fs.existsSync(srcPath)) throw new Error('Source file not found: ' + item._file);
|
|
474
496
|
|
|
475
|
-
|
|
476
|
-
|
|
497
|
+
fs.unlinkSync(srcPath);
|
|
498
|
+
return true;
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
/**
|
|
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 }
|
|
508
|
+
*/
|
|
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
|
+
}
|
|
477
527
|
|
|
478
|
-
const
|
|
479
|
-
const
|
|
480
|
-
const
|
|
481
|
-
const
|
|
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');
|
|
482
534
|
|
|
483
|
-
|
|
484
|
-
|
|
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);
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
return merged;
|
|
567
|
+
}
|
|
568
|
+
|
|
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
|
+
}
|
|
485
605
|
}
|
|
486
606
|
|
|
487
607
|
module.exports = {
|
|
488
|
-
createModel, scanSource, computeProgress, isTaskFile,
|
|
608
|
+
createModel, scanSource, computeProgress, computeAgentProps, isTaskFile,
|
|
489
609
|
safeReadFile, safeDirEntries, updateMarkdownFile, mergeResults,
|
|
490
|
-
getNextId, createTask, createMilestone, createEpic, createSprint, archiveItem
|
|
610
|
+
getNextId, createTask, createMilestone, createEpic, createSprint, createNote, archiveItem
|
|
491
611
|
};
|
|
@@ -117,7 +117,11 @@ function serializeYaml(obj) {
|
|
|
117
117
|
} else if (typeof value === 'object') {
|
|
118
118
|
lines.push(key + ':');
|
|
119
119
|
for (const [k, v] of Object.entries(value)) {
|
|
120
|
-
|
|
120
|
+
if (Array.isArray(v)) {
|
|
121
|
+
lines.push(' ' + k + ': ' + (v.length === 0 ? '[]' : '[' + v.map(sv => serializeValue(sv)).join(', ') + ']'));
|
|
122
|
+
} else {
|
|
123
|
+
lines.push(' ' + k + ': ' + serializeValue(v));
|
|
124
|
+
}
|
|
121
125
|
}
|
|
122
126
|
} else {
|
|
123
127
|
lines.push(key + ': ' + serializeValue(value));
|