@eltonssouza/development-utility-kit 1.0.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 (137) hide show
  1. package/.claude/agents/analyst.md +198 -0
  2. package/.claude/agents/backend-developer.md +126 -0
  3. package/.claude/agents/brain-keeper.md +229 -0
  4. package/.claude/agents/code-reviewer.md +181 -0
  5. package/.claude/agents/database-engineer.md +94 -0
  6. package/.claude/agents/devops-engineer.md +141 -0
  7. package/.claude/agents/frontend-developer.md +97 -0
  8. package/.claude/agents/gate-keeper.md +118 -0
  9. package/.claude/agents/migrator.md +291 -0
  10. package/.claude/agents/mobile-developer.md +80 -0
  11. package/.claude/agents/n8n-specialist.md +94 -0
  12. package/.claude/agents/product-owner.md +115 -0
  13. package/.claude/agents/qa-engineer.md +232 -0
  14. package/.claude/agents/release-engineer.md +204 -0
  15. package/.claude/agents/scaffold.md +87 -0
  16. package/.claude/agents/security-engineer.md +199 -0
  17. package/.claude/agents/sprint-runner.md +44 -0
  18. package/.claude/agents/stack-resolver.md +84 -0
  19. package/.claude/agents/tech-lead.md +182 -0
  20. package/.claude/agents/update-template.md +54 -0
  21. package/.claude/agents/ux-designer.md +118 -0
  22. package/.claude/settings.json +44 -0
  23. package/.claude/skills/README.md +332 -0
  24. package/.claude/skills/active-project/SKILL.md +129 -0
  25. package/.claude/skills/api-integration-test/SKILL.md +64 -0
  26. package/.claude/skills/auto-test-guard/SKILL.md +237 -0
  27. package/.claude/skills/auto-test-guard/resources/backend-tests.md +20 -0
  28. package/.claude/skills/auto-test-guard/resources/e2e-tests.md +24 -0
  29. package/.claude/skills/auto-test-guard/resources/execution-report.md +49 -0
  30. package/.claude/skills/auto-test-guard/resources/frontend-tests.md +18 -0
  31. package/.claude/skills/auto-test-guard/resources/initial-setup.md +108 -0
  32. package/.claude/skills/auto-test-guard/resources/run-suite.md +48 -0
  33. package/.claude/skills/auto-test-guard/resources/senior-gate.md +19 -0
  34. package/.claude/skills/brain-keeper/SKILL.md +60 -0
  35. package/.claude/skills/brain-keeper/obsidian/app.json +9 -0
  36. package/.claude/skills/brain-keeper/obsidian/appearance.json +4 -0
  37. package/.claude/skills/brain-keeper/obsidian/core-plugins.json +20 -0
  38. package/.claude/skills/brain-keeper/obsidian/daily-notes.json +5 -0
  39. package/.claude/skills/brain-keeper/obsidian/graph.json +32 -0
  40. package/.claude/skills/brain-keeper/obsidian/snippets/folder-colors.css +90 -0
  41. package/.claude/skills/brain-keeper/obsidian/templates.json +5 -0
  42. package/.claude/skills/brain-keeper/templates/README.md +51 -0
  43. package/.claude/skills/brain-keeper/templates/adr.md +40 -0
  44. package/.claude/skills/brain-keeper/templates/bug.md +35 -0
  45. package/.claude/skills/brain-keeper/templates/daily.md +38 -0
  46. package/.claude/skills/brain-keeper/templates/feature.md +62 -0
  47. package/.claude/skills/brain-keeper/templates/meeting.md +34 -0
  48. package/.claude/skills/brain-keeper/templates/tech-debt.md +21 -0
  49. package/.claude/skills/caveman/SKILL.md +187 -0
  50. package/.claude/skills/create-stack-pack/SKILL.md +281 -0
  51. package/.claude/skills/grill-me/SKILL.md +79 -0
  52. package/.claude/skills/honcho-memory/SKILL.md +207 -0
  53. package/.claude/skills/honcho-memory/docs/api-endpoints-verified.md +75 -0
  54. package/.claude/skills/honcho-memory/hooks/on-prompt-submit.js +221 -0
  55. package/.claude/skills/honcho-memory/hooks/on-stop.js +193 -0
  56. package/.claude/skills/honcho-memory/lib/honcho-client.js +363 -0
  57. package/.claude/skills/honcho-memory/lib/memory-injector.js +93 -0
  58. package/.claude/skills/honcho-memory/package.json +32 -0
  59. package/.claude/skills/honcho-memory/scripts/cli.js +370 -0
  60. package/.claude/skills/honcho-memory/scripts/setup.js +109 -0
  61. package/.claude/skills/honcho-memory/tests/t001-api-endpoints-verified.test.js +89 -0
  62. package/.claude/skills/honcho-memory/tests/t002-structure.test.js +97 -0
  63. package/.claude/skills/honcho-memory/tests/t003-honcho-client.test.js +162 -0
  64. package/.claude/skills/honcho-memory/tests/t004-soft-delete.test.js +259 -0
  65. package/.claude/skills/honcho-memory/tests/t005-memory-injector.test.js +175 -0
  66. package/.claude/skills/honcho-memory/tests/t006-on-prompt-submit.test.js +215 -0
  67. package/.claude/skills/honcho-memory/tests/t007-on-stop.test.js +165 -0
  68. package/.claude/skills/honcho-memory/tests/t008-cli.test.js +214 -0
  69. package/.claude/skills/honcho-memory/tests/t009-setup.test.js +232 -0
  70. package/.claude/skills/honcho-memory/tests/t010-skill-md.test.js +114 -0
  71. package/.claude/skills/honcho-memory/tests/t011-settings-hooks.test.js +105 -0
  72. package/.claude/skills/honcho-memory/tests/t012-docs-update.test.js +106 -0
  73. package/.claude/skills/honcho-memory/tests/t013-smoke-e2e.test.js +90 -0
  74. package/.claude/skills/pair-debug/SKILL.md +288 -0
  75. package/.claude/skills/prd-ready-check/SKILL.md +58 -0
  76. package/.claude/skills/project-manager/SKILL.md +167 -0
  77. package/.claude/skills/quality-standards/SKILL.md +201 -0
  78. package/.claude/skills/quick-feature/SKILL.md +264 -0
  79. package/.claude/skills/run-sprint/SKILL.md +342 -0
  80. package/.claude/skills/scaffold/SKILL.md +58 -0
  81. package/.claude/skills/stack-discovery/SKILL.md +159 -0
  82. package/.claude/skills/test-coverage-auditor/SKILL.md +59 -0
  83. package/.claude/skills/to-issues/SKILL.md +163 -0
  84. package/.claude/skills/to-prd/SKILL.md +130 -0
  85. package/.claude/skills/update-template/SKILL.md +254 -0
  86. package/.claude/stacks/CODEOWNERS +30 -0
  87. package/.claude/stacks/README.md +88 -0
  88. package/.claude/stacks/_template.md +116 -0
  89. package/.claude/stacks/java/spring-boot-3.md +376 -0
  90. package/.claude/stacks/java/spring-boot-4.md +438 -0
  91. package/.claude/stacks/typescript/angular-18.md +420 -0
  92. package/.claude/stacks/typescript/angular-19.md +397 -0
  93. package/.claude/stacks/typescript/angular-21.md +494 -0
  94. package/CLAUDE.md +453 -0
  95. package/README.md +391 -0
  96. package/bin/cli.js +773 -0
  97. package/bin/lib/backup.js +62 -0
  98. package/bin/lib/detect-stack.js +476 -0
  99. package/bin/lib/help.js +233 -0
  100. package/bin/lib/identity.js +108 -0
  101. package/bin/lib/local-dir.js +69 -0
  102. package/bin/lib/manifest.js +236 -0
  103. package/bin/lib/sync-all.js +394 -0
  104. package/bin/lib/version-check.js +398 -0
  105. package/dashboard/db.js +199 -0
  106. package/dashboard/package.json +22 -0
  107. package/dashboard/public/app.js +709 -0
  108. package/dashboard/public/content/docs/agents-reference.en.md +911 -0
  109. package/dashboard/public/content/docs/architecture-overview.en.md +260 -0
  110. package/dashboard/public/content/docs/autonomy-matrix.en.md +186 -0
  111. package/dashboard/public/content/docs/git-flow.en.md +525 -0
  112. package/dashboard/public/content/docs/honcho-memory.en.md +394 -0
  113. package/dashboard/public/content/docs/hooks-reference.en.md +420 -0
  114. package/dashboard/public/content/docs/pipeline.en.md +400 -0
  115. package/dashboard/public/content/docs/quality-gate.en.md +315 -0
  116. package/dashboard/public/content/docs/skills-reference.en.md +500 -0
  117. package/dashboard/public/content/docs/stack-rules.en.md +362 -0
  118. package/dashboard/public/content/docs/troubleshooting.en.md +637 -0
  119. package/dashboard/public/content/manifest.json +102 -0
  120. package/dashboard/public/content/manual/backend.en.md +1138 -0
  121. package/dashboard/public/content/manual/existing-project.en.md +831 -0
  122. package/dashboard/public/content/manual/frontend.en.md +1065 -0
  123. package/dashboard/public/content/manual/fullstack.en.md +1508 -0
  124. package/dashboard/public/content/manual/mobile.en.md +866 -0
  125. package/dashboard/public/index.html +108 -0
  126. package/dashboard/public/style.css +610 -0
  127. package/dashboard/public/vendor/marked.min.js +69 -0
  128. package/dashboard/rtk.js +143 -0
  129. package/dashboard/server-app.js +403 -0
  130. package/dashboard/server.js +104 -0
  131. package/dashboard/test/sprint1.test.js +406 -0
  132. package/dashboard/test/sprint2.test.js +571 -0
  133. package/dashboard/test/sprint3.test.js +560 -0
  134. package/package.json +33 -0
  135. package/scripts/hooks/subagent-telemetry.sh +14 -0
  136. package/scripts/hooks/telemetry-writer.js +250 -0
  137. package/scripts/latest-versions.json +56 -0
@@ -0,0 +1,403 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * server-app.js — Express application factory (no listen call).
5
+ * Extracted from server.js so tests can require the app without starting a real server.
6
+ * server.js requires this module and calls app.listen().
7
+ */
8
+
9
+ const path = require('path');
10
+ const fs = require('fs');
11
+ const express = require('express');
12
+ const { openDb, initSchema, queryProjects, queryModels } = require('./db');
13
+ const { getRtkStats, getRtkDaily } = require('./rtk');
14
+
15
+ // ── Repo root (BR-002): always relative to this file's __dirname ──────────────
16
+ // dashboard/server-app.js -> __dirname = dashboard/ -> .. = repo root
17
+ const REPO_ROOT = path.resolve(__dirname, '..');
18
+
19
+ // ── Whitelist prefixes for /api/docs/file (BR-001) ────────────────────────────
20
+ // All paths must use forward slashes after normalize.
21
+ const WHITELIST_PREFIXES = [
22
+ '.claude/',
23
+ 'docs/brain/',
24
+ 'docs/discovery/',
25
+ 'docs/plans/',
26
+ 'docs/prd/',
27
+ 'docs/issues/',
28
+ 'dashboard/public/content/',
29
+ ];
30
+
31
+ /**
32
+ * Validate a client-supplied relative path against the whitelist.
33
+ * Returns the resolved absolute path on success, or null if rejected.
34
+ *
35
+ * Rejection criteria (BR-001):
36
+ * - contains ".." segment
37
+ * - starts with "/" (absolute Unix)
38
+ * - contains ":\\" or ":/" (Windows drive letter)
39
+ * - normalized path does not start with one of the WHITELIST_PREFIXES
40
+ * - resolved file is a symlink
41
+ *
42
+ * @param {string} rawPath — value from query string
43
+ * @returns {string|null} — absolute path or null
44
+ */
45
+ function validateDocPath(rawPath) {
46
+ if (!rawPath || typeof rawPath !== 'string') return null;
47
+
48
+ // Reject obvious traversal / absolute / drive-letter patterns
49
+ if (rawPath.includes('..')) return null;
50
+ if (rawPath.startsWith('/')) return null;
51
+ if (/[a-zA-Z]:[/\\]/.test(rawPath)) return null;
52
+
53
+ // Normalize using posix separators (forward slash) for consistent prefix check
54
+ const normalised = path.normalize(rawPath).replace(/\\/g, '/');
55
+
56
+ // Reject if normalised form still contains ".."
57
+ if (normalised.includes('..')) return null;
58
+
59
+ // Check whitelist
60
+ const allowed = WHITELIST_PREFIXES.some((prefix) => normalised.startsWith(prefix));
61
+ if (!allowed) return null;
62
+
63
+ // Resolve to absolute path
64
+ const abs = path.resolve(REPO_ROOT, normalised);
65
+
66
+ // Symlink check
67
+ try {
68
+ const lstat = fs.lstatSync(abs);
69
+ if (lstat.isSymbolicLink()) return null;
70
+ } catch {
71
+ // File does not exist — let the read fail naturally (404 is fine; 403 only for policy)
72
+ }
73
+
74
+ return abs;
75
+ }
76
+
77
+ // ── Markdown parsers for SKILL.md / agent .md / ADR .md ──────────────────────
78
+
79
+ /**
80
+ * Attempt to extract frontmatter key from a YAML block (--- ... ---).
81
+ * Falls back to null if not found.
82
+ * @param {string} content
83
+ * @param {string} key
84
+ * @returns {string|null}
85
+ */
86
+ function extractFrontmatter(content, key) {
87
+ const fmMatch = content.match(/^---\s*\n([\s\S]*?)\n---/);
88
+ if (!fmMatch) return null;
89
+ const fm = fmMatch[1];
90
+ const lineMatch = fm.match(new RegExp(`^${key}:\\s*(.+)$`, 'm'));
91
+ return lineMatch ? lineMatch[1].trim().replace(/^['"]|['"]$/g, '') : null;
92
+ }
93
+
94
+ /**
95
+ * Extract h1 heading from markdown content.
96
+ * @param {string} content
97
+ * @returns {string|null}
98
+ */
99
+ function extractH1(content) {
100
+ const m = content.match(/^#\s+(.+)$/m);
101
+ return m ? m[1].trim() : null;
102
+ }
103
+
104
+ /**
105
+ * Extract first non-empty paragraph (skipping frontmatter and headings).
106
+ * @param {string} content
107
+ * @returns {string}
108
+ */
109
+ function extractFirstParagraph(content) {
110
+ // Remove frontmatter
111
+ const withoutFm = content.replace(/^---\s*\n[\s\S]*?\n---\s*\n?/, '');
112
+ // Find first non-empty, non-heading line
113
+ for (const line of withoutFm.split('\n')) {
114
+ const t = line.trim();
115
+ if (t && !t.startsWith('#') && !t.startsWith('```')) {
116
+ return t.substring(0, 200);
117
+ }
118
+ }
119
+ return '';
120
+ }
121
+
122
+ /**
123
+ * Parse a SKILL.md file into a SkillEntry.
124
+ * D-003: tolerant parser — tries frontmatter first, then markdown headings/paragraphs.
125
+ *
126
+ * @param {string} content — raw file contents
127
+ * @param {string} filePath — absolute path
128
+ * @param {string} relPath — repo-relative path (forward slashes)
129
+ * @returns {{name: string, description: string, triggers: string[], path: string}}
130
+ */
131
+ function parseSkillMd(content, filePath, relPath) {
132
+ // Name: frontmatter "name:" > directory name (parent folder of SKILL.md)
133
+ const name =
134
+ extractFrontmatter(content, 'name') ||
135
+ extractH1(content) ||
136
+ path.basename(path.dirname(filePath));
137
+
138
+ // Description: frontmatter "description:" > first paragraph
139
+ const description =
140
+ extractFrontmatter(content, 'description') || extractFirstParagraph(content);
141
+
142
+ // Triggers: look for "## When it triggers" section or "triggers:" frontmatter
143
+ let triggers = [];
144
+ const triggersSection = content.match(/##\s+When it triggers\s*\n([\s\S]*?)(?=\n##|\n---|$)/i);
145
+ if (triggersSection) {
146
+ triggers = triggersSection[1]
147
+ .split('\n')
148
+ .map((l) => l.replace(/^[-*]\s*/, '').trim())
149
+ .filter(Boolean)
150
+ .slice(0, 5);
151
+ } else {
152
+ const fmTriggers = extractFrontmatter(content, 'triggers');
153
+ if (fmTriggers) triggers = [fmTriggers];
154
+ }
155
+
156
+ return { name, description, triggers, path: relPath };
157
+ }
158
+
159
+ /**
160
+ * Parse an agent .md file into an AgentEntry.
161
+ * @param {string} content
162
+ * @param {string} filePath
163
+ * @param {string} relPath
164
+ * @returns {{name: string, description: string, model: string, path: string}}
165
+ */
166
+ function parseAgentMd(content, filePath, relPath) {
167
+ const name =
168
+ extractFrontmatter(content, 'name') ||
169
+ extractH1(content) ||
170
+ path.basename(filePath, '.md');
171
+
172
+ const description =
173
+ extractFrontmatter(content, 'description') || extractFirstParagraph(content);
174
+
175
+ // Default model per CLAUDE.md table (D-003)
176
+ const model = extractFrontmatter(content, 'model') || 'sonnet';
177
+
178
+ return { name, description, model, path: relPath };
179
+ }
180
+
181
+ /**
182
+ * Parse an ADR .md file into an AdrEntry.
183
+ * @param {string} content
184
+ * @param {string} filePath — absolute
185
+ * @param {string} relPath — repo-relative
186
+ * @returns {{number: number, slug: string, title: string, status: string, path: string}}
187
+ */
188
+ function parseAdrMd(content, filePath, relPath) {
189
+ const filename = path.basename(filePath, '.md');
190
+ const numMatch = filename.match(/ADR-(\d+)/i);
191
+ const number = numMatch ? parseInt(numMatch[1], 10) : 0;
192
+
193
+ const slug = filename;
194
+ const title =
195
+ extractFrontmatter(content, 'title') ||
196
+ extractH1(content) ||
197
+ filename;
198
+
199
+ const status = extractFrontmatter(content, 'status') || 'Unknown';
200
+
201
+ return { number, slug, title, status, path: relPath };
202
+ }
203
+
204
+ // ── Database setup ────────────────────────────────────────────────────────────
205
+
206
+ let db;
207
+ try {
208
+ db = openDb();
209
+ initSchema(db);
210
+ } catch (err) {
211
+ process.stderr.write('Warning: could not open telemetry DB: ' + err.message + '\n');
212
+ db = null;
213
+ }
214
+
215
+ // ── Express app ───────────────────────────────────────────────────────────────
216
+
217
+ const app = express();
218
+
219
+ // CORS permissive for local dashboard
220
+ app.use((req, res, next) => {
221
+ res.setHeader('Access-Control-Allow-Origin', '*');
222
+ res.setHeader('Access-Control-Allow-Methods', 'GET, OPTIONS');
223
+ res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
224
+ next();
225
+ });
226
+
227
+ // Static frontend
228
+ app.use(express.static(path.join(__dirname, 'public')));
229
+
230
+ // ── Telemetry endpoints ───────────────────────────────────────────────────────
231
+
232
+ const THIRTY_DAYS_S = 30 * 24 * 60 * 60;
233
+
234
+ app.get('/api/projects', (req, res) => {
235
+ const sinceTs = Math.floor(Date.now() / 1000) - THIRTY_DAYS_S;
236
+ const projects = db ? queryProjects(db, sinceTs) : [];
237
+ res.json(projects);
238
+ });
239
+
240
+ app.get('/api/rtk', async (req, res) => {
241
+ try {
242
+ const [stats, daily] = await Promise.all([getRtkStats(), getRtkDaily()]);
243
+ if (!stats && !daily) {
244
+ res.json(null);
245
+ return;
246
+ }
247
+ res.json({
248
+ tokens_saved: stats ? stats.tokens_saved : 0,
249
+ savings_pct: stats ? stats.savings_pct : 0,
250
+ daily: daily || [],
251
+ });
252
+ } catch {
253
+ res.json(null);
254
+ }
255
+ });
256
+
257
+ app.get('/api/stats', async (req, res) => {
258
+ try {
259
+ const sinceTs = Math.floor(Date.now() / 1000) - THIRTY_DAYS_S;
260
+
261
+ const projectRows = db ? queryProjects(db, sinceTs) : [];
262
+ const modelRows = db ? queryModels(db) : [];
263
+
264
+ const [rtkStats, rtkDaily] = await Promise.all([getRtkStats(), getRtkDaily()]);
265
+
266
+ const rtk =
267
+ rtkStats || rtkDaily
268
+ ? {
269
+ tokens_saved: rtkStats ? rtkStats.tokens_saved : 0,
270
+ savings_pct: rtkStats ? rtkStats.savings_pct : 0,
271
+ daily: rtkDaily || [],
272
+ }
273
+ : null;
274
+
275
+ function buildModels(rows) {
276
+ const total = rows.reduce((s, r) => s + Number(r.count), 0);
277
+ const out = { opus: { count: 0, pct: 0 }, sonnet: { count: 0, pct: 0 }, haiku: { count: 0, pct: 0 } };
278
+ for (const row of rows) {
279
+ const key = ['opus', 'sonnet', 'haiku'].includes(row.model) ? row.model : null;
280
+ if (key) {
281
+ out[key].count = Number(row.count);
282
+ out[key].pct = total > 0 ? Math.round((Number(row.count) / total) * 1000) / 10 : 0;
283
+ }
284
+ }
285
+ return out;
286
+ }
287
+
288
+ res.json({
289
+ projects: projectRows,
290
+ models: buildModels(modelRows),
291
+ rtk,
292
+ });
293
+ } catch (err) {
294
+ res.status(500).json({ error: err.message });
295
+ }
296
+ });
297
+
298
+ // ── Docs endpoints (/api/docs/*) ──────────────────────────────────────────────
299
+
300
+ /**
301
+ * GET /api/docs/skills
302
+ * Reads .claude/skills/{name}/SKILL.md files from repo root.
303
+ * Returns [{name, description, triggers, path}]
304
+ */
305
+ app.get('/api/docs/skills', (req, res) => {
306
+ const skillsDir = path.join(REPO_ROOT, '.claude', 'skills');
307
+ const results = [];
308
+
309
+ try {
310
+ const entries = fs.readdirSync(skillsDir, { withFileTypes: true });
311
+ for (const entry of entries) {
312
+ if (!entry.isDirectory()) continue;
313
+ const skillFile = path.join(skillsDir, entry.name, 'SKILL.md');
314
+ if (!fs.existsSync(skillFile)) continue;
315
+ const content = fs.readFileSync(skillFile, 'utf8');
316
+ const relPath = `.claude/skills/${entry.name}/SKILL.md`;
317
+ results.push(parseSkillMd(content, skillFile, relPath));
318
+ }
319
+ res.json(results);
320
+ } catch (err) {
321
+ res.status(500).json({ error: err.message });
322
+ }
323
+ });
324
+
325
+ /**
326
+ * GET /api/docs/agents
327
+ * Reads .claude/agents/*.md files from repo root.
328
+ * Returns [{name, description, model, path}]
329
+ */
330
+ app.get('/api/docs/agents', (req, res) => {
331
+ const agentsDir = path.join(REPO_ROOT, '.claude', 'agents');
332
+ const results = [];
333
+
334
+ try {
335
+ const files = fs.readdirSync(agentsDir).filter((f) => f.endsWith('.md'));
336
+ for (const file of files) {
337
+ const abs = path.join(agentsDir, file);
338
+ const lstat = fs.lstatSync(abs);
339
+ if (lstat.isSymbolicLink()) continue; // skip symlinks
340
+ const content = fs.readFileSync(abs, 'utf8');
341
+ const relPath = `.claude/agents/${file}`;
342
+ results.push(parseAgentMd(content, abs, relPath));
343
+ }
344
+ res.json(results);
345
+ } catch (err) {
346
+ res.status(500).json({ error: err.message });
347
+ }
348
+ });
349
+
350
+ /**
351
+ * GET /api/docs/adrs
352
+ * Reads docs/brain/decisions/ADR-*.md, sorted by ADR number ascending.
353
+ * Returns [{number, slug, title, status, path}]
354
+ */
355
+ app.get('/api/docs/adrs', (req, res) => {
356
+ const decisionsDir = path.join(REPO_ROOT, 'docs', 'brain', 'decisions');
357
+ const results = [];
358
+
359
+ try {
360
+ const files = fs.readdirSync(decisionsDir).filter((f) => /^ADR-\d+/i.test(f) && f.endsWith('.md'));
361
+ for (const file of files) {
362
+ const abs = path.join(decisionsDir, file);
363
+ const lstat = fs.lstatSync(abs);
364
+ if (lstat.isSymbolicLink()) continue;
365
+ const content = fs.readFileSync(abs, 'utf8');
366
+ const relPath = `docs/brain/decisions/${file}`;
367
+ results.push(parseAdrMd(content, abs, relPath));
368
+ }
369
+ results.sort((a, b) => a.number - b.number);
370
+ res.json(results);
371
+ } catch (err) {
372
+ res.status(500).json({ error: err.message });
373
+ }
374
+ });
375
+
376
+ /**
377
+ * GET /api/docs/file?path=<relative-path>
378
+ * Returns raw markdown content of a whitelisted file.
379
+ * Returns 403 for any path outside whitelist, containing "..", absolute, drive letters, or symlinks.
380
+ */
381
+ app.get('/api/docs/file', (req, res) => {
382
+ const rawPath = req.query.path;
383
+ const absPath = validateDocPath(rawPath);
384
+
385
+ if (!absPath) {
386
+ return res.status(403).json({ error: 'Forbidden' });
387
+ }
388
+
389
+ try {
390
+ const content = fs.readFileSync(absPath, 'utf8');
391
+ res.type('text/plain').send(content);
392
+ } catch (err) {
393
+ if (err.code === 'ENOENT') {
394
+ res.status(404).json({ error: 'Not found' });
395
+ } else {
396
+ res.status(500).json({ error: err.message });
397
+ }
398
+ }
399
+ });
400
+
401
+ // ── Export for testing ────────────────────────────────────────────────────────
402
+
403
+ module.exports = { app };
@@ -0,0 +1,104 @@
1
+ 'use strict';
2
+
3
+ const path = require('path');
4
+ const net = require('net');
5
+ const { spawn } = require('child_process');
6
+ const os = require('os');
7
+ const { app } = require('./server-app');
8
+
9
+ // ── CLI args ──────────────────────────────────────────────────────────────────
10
+
11
+ const args = process.argv.slice(2);
12
+ let startPort = 4242;
13
+ let openBrowser = true;
14
+
15
+ for (let i = 0; i < args.length; i++) {
16
+ if (args[i] === '--port' && i + 1 < args.length) {
17
+ const n = parseInt(args[++i], 10);
18
+ if (!isNaN(n)) startPort = n;
19
+ } else if (args[i] === '--no-open') {
20
+ openBrowser = false;
21
+ }
22
+ }
23
+
24
+ // ── Helpers ───────────────────────────────────────────────────────────────────
25
+
26
+ /**
27
+ * Find a free TCP port starting from `start`.
28
+ * Tries up to 20 ports before giving up.
29
+ * @param {number} start
30
+ * @returns {Promise<number>}
31
+ */
32
+ function findFreePort(start) {
33
+ return new Promise((resolve, reject) => {
34
+ let port = start;
35
+ const tryPort = () => {
36
+ const server = net.createServer();
37
+ server.once('error', (err) => {
38
+ if (err.code === 'EADDRINUSE') {
39
+ port += 1;
40
+ if (port > start + 20) {
41
+ reject(new Error('Could not find a free port in range ' + start + '–' + (start + 20)));
42
+ } else {
43
+ tryPort();
44
+ }
45
+ } else {
46
+ reject(err);
47
+ }
48
+ });
49
+ server.once('listening', () => {
50
+ server.close(() => resolve(port));
51
+ });
52
+ server.listen(port, '127.0.0.1');
53
+ };
54
+ tryPort();
55
+ });
56
+ }
57
+
58
+ /**
59
+ * Open the default browser to the given URL.
60
+ */
61
+ function openUrl(url) {
62
+ const platform = os.platform();
63
+ let cmd, cmdArgs;
64
+ if (platform === 'win32') {
65
+ cmd = 'cmd';
66
+ cmdArgs = ['/c', 'start', '', url];
67
+ } else if (platform === 'darwin') {
68
+ cmd = 'open';
69
+ cmdArgs = [url];
70
+ } else {
71
+ cmd = 'xdg-open';
72
+ cmdArgs = [url];
73
+ }
74
+ spawn(cmd, cmdArgs, { detached: true, stdio: 'ignore' }).unref();
75
+ }
76
+
77
+ // ── Start ─────────────────────────────────────────────────────────────────────
78
+
79
+ (async () => {
80
+ let port;
81
+ try {
82
+ port = await findFreePort(startPort);
83
+ } catch (err) {
84
+ process.stderr.write('Error: ' + err.message + '\n');
85
+ process.exit(1);
86
+ }
87
+
88
+ const server = app.listen(port, '127.0.0.1', () => {
89
+ const url = 'http://localhost:' + port;
90
+ process.stdout.write('Dashboard running at ' + url + '\n');
91
+ if (openBrowser) {
92
+ openUrl(url);
93
+ }
94
+ });
95
+
96
+ server.on('error', (err) => {
97
+ process.stderr.write('Server error: ' + err.message + '\n');
98
+ process.exit(1);
99
+ });
100
+
101
+ // Graceful shutdown
102
+ process.on('SIGTERM', () => server.close(() => process.exit(0)));
103
+ process.on('SIGINT', () => server.close(() => process.exit(0)));
104
+ })();