@grainulation/barn 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 (50) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/LICENSE +21 -0
  3. package/README.md +87 -0
  4. package/bin/barn.js +98 -0
  5. package/lib/index.js +93 -0
  6. package/lib/server.js +368 -0
  7. package/package.json +52 -0
  8. package/public/grainulation-tokens.css +321 -0
  9. package/public/index.html +907 -0
  10. package/templates/README.md +48 -0
  11. package/templates/adr.html +223 -0
  12. package/templates/adr.json +29 -0
  13. package/templates/brief.html +297 -0
  14. package/templates/brief.json +26 -0
  15. package/templates/certificate.html +247 -0
  16. package/templates/certificate.json +23 -0
  17. package/templates/changelog.html +239 -0
  18. package/templates/changelog.json +19 -0
  19. package/templates/ci-workflow.yml +52 -0
  20. package/templates/comparison.html +248 -0
  21. package/templates/comparison.json +21 -0
  22. package/templates/conflict-map.html +240 -0
  23. package/templates/conflict-map.json +19 -0
  24. package/templates/dashboard.html +515 -0
  25. package/templates/dashboard.json +22 -0
  26. package/templates/email-digest.html +178 -0
  27. package/templates/email-digest.json +18 -0
  28. package/templates/evidence-matrix.html +232 -0
  29. package/templates/evidence-matrix.json +21 -0
  30. package/templates/explainer.html +342 -0
  31. package/templates/explainer.json +23 -0
  32. package/templates/handoff.html +343 -0
  33. package/templates/handoff.json +24 -0
  34. package/templates/one-pager.html +248 -0
  35. package/templates/one-pager.json +22 -0
  36. package/templates/postmortem.html +303 -0
  37. package/templates/postmortem.json +20 -0
  38. package/templates/rfc.html +199 -0
  39. package/templates/rfc.json +32 -0
  40. package/templates/risk-register.html +231 -0
  41. package/templates/risk-register.json +22 -0
  42. package/templates/slide-deck.html +239 -0
  43. package/templates/slide-deck.json +23 -0
  44. package/templates/template.schema.json +25 -0
  45. package/templates/wiki-page.html +222 -0
  46. package/templates/wiki-page.json +23 -0
  47. package/tools/README.md +31 -0
  48. package/tools/build-pdf.js +43 -0
  49. package/tools/detect-sprints.js +292 -0
  50. package/tools/generate-manifest.js +237 -0
package/CHANGELOG.md ADDED
@@ -0,0 +1,13 @@
1
+ # Changelog
2
+
3
+ ## 1.0.0
4
+
5
+ Initial release.
6
+
7
+ - 17 built-in HTML templates (brief, explainer, dashboard, slide-deck, RFC, ADR, and more)
8
+ - Web template browser with tag filtering, source/preview/info tabs
9
+ - `detect-sprints` tool for finding active wheat sprints across repos
10
+ - `generate-manifest` for building wheat-manifest.json
11
+ - `build-pdf` for Markdown-to-PDF conversion
12
+ - SSE live-reload when templates change
13
+ - Zero runtime dependencies
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 grainulation contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,87 @@
1
+ # barn
2
+
3
+ Open tools for structured research. Use with wheat, or use standalone.
4
+
5
+ Barn extracts the reusable utilities from the [wheat](https://github.com/grainulation/wheat) research sprint system into a standalone package. Zero npm dependencies -- Node built-in only.
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ npm install @grainulation/barn
11
+ ```
12
+
13
+ Or use directly:
14
+
15
+ ```bash
16
+ npx @grainulation/barn detect-sprints --json
17
+ ```
18
+
19
+ ## Tools
20
+
21
+ ### detect-sprints
22
+
23
+ Find sprint directories in a repo by scanning for `claims.json` files. Uses git history to determine which sprint is active.
24
+
25
+ ```bash
26
+ barn detect-sprints # Human-readable output
27
+ barn detect-sprints --json # Machine-readable JSON
28
+ barn detect-sprints --active # Print only the active sprint path
29
+ barn detect-sprints --root /path # Scan a specific directory
30
+ ```
31
+
32
+ ### generate-manifest
33
+
34
+ Build a `wheat-manifest.json` topic map from claims, files, and git history. Gives AI tools (and humans) a single file that describes the entire sprint state.
35
+
36
+ ```bash
37
+ barn generate-manifest # Write wheat-manifest.json
38
+ barn generate-manifest --root /path # Target a specific repo
39
+ barn generate-manifest --out custom-name.json # Custom output path
40
+ ```
41
+
42
+ ### build-pdf
43
+
44
+ Convert markdown to PDF via `md-to-pdf` (invoked through npx -- no local install needed).
45
+
46
+ ```bash
47
+ barn build-pdf output/brief.md
48
+ ```
49
+
50
+ ## Templates
51
+
52
+ HTML templates for sprint artifacts. Self-contained (inline CSS/JS, no external deps), dark theme, mobile responsive.
53
+
54
+ - **adr.html** -- Architecture Decision Record
55
+ - **brief.html** -- Sprint brief / recommendation document
56
+ - **certificate.html** -- Compilation certificate
57
+ - **changelog.html** -- Sprint changelog
58
+ - **comparison.html** -- Side-by-side comparison dashboard
59
+ - **conflict-map.html** -- Claim conflict visualization
60
+ - **dashboard.html** -- Sprint status dashboard
61
+ - **email-digest.html** -- Email digest summary
62
+ - **evidence-matrix.html** -- Evidence tier matrix
63
+ - **explainer.html** -- Full-screen scroll-snap presentation
64
+ - **handoff.html** -- Knowledge transfer document
65
+ - **one-pager.html** -- Single-page executive summary
66
+ - **postmortem.html** -- Sprint postmortem
67
+ - **rfc.html** -- Request for Comments
68
+ - **risk-register.html** -- Risk tracking register
69
+ - **slide-deck.html** -- Slide deck presentation
70
+ - **wiki-page.html** -- Wiki-style documentation page
71
+
72
+ Copy templates into your project:
73
+
74
+ ```bash
75
+ cp node_modules/@grainulation/barn/templates/explainer.html ./output/
76
+ ```
77
+
78
+ ## Philosophy
79
+
80
+ - Zero npm dependencies. Node built-in modules only.
81
+ - Git as the source of truth. No config files for state that git already knows.
82
+ - Self-describing structures. New sessions understand the repo without full scans.
83
+ - Works with AI search tools (Glob, Grep, Read) out of the box.
84
+
85
+ ## License
86
+
87
+ MIT
package/bin/barn.js ADDED
@@ -0,0 +1,98 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * barn — CLI for grainulation/barn tools
4
+ *
5
+ * Usage:
6
+ * barn <command> [options]
7
+ *
8
+ * Commands:
9
+ * detect-sprints Find sprint directories in a repo
10
+ * generate-manifest Build wheat-manifest.json topic map
11
+ * build-pdf Convert markdown to PDF via npx md-to-pdf
12
+ * help Show this help message
13
+ *
14
+ * Zero npm dependencies (Node built-in only).
15
+ */
16
+
17
+ import { fileURLToPath } from 'node:url';
18
+ import { dirname, join } from 'node:path';
19
+ import { fork } from 'node:child_process';
20
+
21
+ const __dirname = dirname(fileURLToPath(import.meta.url));
22
+ const TOOLS_DIR = join(__dirname, '..', 'tools');
23
+
24
+ const LIB_DIR = join(__dirname, '..', 'lib');
25
+
26
+ // ── --version / -v ───────────────────────────────────────────────────────────
27
+ import { readFileSync } from 'node:fs';
28
+
29
+ const args = process.argv.slice(2);
30
+ const command = args[0];
31
+
32
+ if (command === '--version' || command === '-v') {
33
+ const pkg = JSON.parse(readFileSync(join(__dirname, '..', 'package.json'), 'utf8'));
34
+ console.log(pkg.version);
35
+ process.exit(0);
36
+ }
37
+
38
+ const verbose = process.argv.includes('--verbose');
39
+ function vlog(...a) {
40
+ if (!verbose) return;
41
+ const ts = new Date().toISOString();
42
+ process.stderr.write(`[${ts}] barn: ${a.join(' ')}\n`);
43
+ }
44
+ export { vlog, verbose };
45
+
46
+ const commands = {
47
+ 'detect-sprints': 'detect-sprints.js',
48
+ 'generate-manifest': 'generate-manifest.js',
49
+ 'build-pdf': 'build-pdf.js',
50
+ };
51
+
52
+ vlog('startup', `command=${command || '(none)'}`, `cwd=${process.cwd()}`);
53
+
54
+ if (!command || command === 'help' || command === '--help' || command === '-h') {
55
+ console.log(`barn — open tools for structured research
56
+
57
+ Usage:
58
+ barn <command> [options]
59
+
60
+ Commands:
61
+ serve Start the template browser UI
62
+ detect-sprints Find sprint directories in a repo
63
+ generate-manifest Build wheat-manifest.json topic map
64
+ build-pdf <file> Convert markdown to PDF via npx md-to-pdf
65
+ help Show this help message
66
+
67
+ Examples:
68
+ barn serve --port 9093 --root /path/to/repo
69
+ barn detect-sprints --json
70
+ barn detect-sprints --active
71
+ barn generate-manifest --root /path/to/repo
72
+ barn build-pdf output/brief.md
73
+
74
+ Options:
75
+ --version, -v Print version and exit
76
+ --verbose Enable verbose logging to stderr
77
+
78
+ Zero npm dependencies. Node built-in only.
79
+ https://github.com/grainulation/barn`);
80
+ process.exit(0);
81
+ }
82
+
83
+ // ── serve command (lib/server.js) ──
84
+ if (command === 'serve') {
85
+ const serverPath = join(LIB_DIR, 'server.js');
86
+ const child = fork(serverPath, args.slice(1), { stdio: 'inherit' });
87
+ child.on('exit', (code) => process.exit(code ?? 0));
88
+ process.on('SIGTERM', () => child.kill('SIGTERM'));
89
+ process.on('SIGINT', () => child.kill('SIGINT'));
90
+ } else if (commands[command]) {
91
+ const toolPath = join(TOOLS_DIR, commands[command]);
92
+ const child = fork(toolPath, args.slice(1), { stdio: 'inherit' });
93
+ child.on('exit', (code) => process.exit(code ?? 0));
94
+ } else {
95
+ console.error(`barn: unknown command: ${command}`);
96
+ console.error(`Run "barn help" for available commands.`);
97
+ process.exit(1);
98
+ }
package/lib/index.js ADDED
@@ -0,0 +1,93 @@
1
+ /**
2
+ * @grainulation/barn — public API surface
3
+ *
4
+ * Exports:
5
+ * name, version — package metadata
6
+ * loadTemplates(dir) — scan a directory for .html templates + .json sidecars
7
+ * detectSprints(root) — git-based sprint detection (re-export)
8
+ * generateManifest(opts) — build wheat-manifest.json topic map (re-export)
9
+ */
10
+
11
+ import { readFileSync, existsSync, readdirSync, statSync } from 'node:fs';
12
+ import { join, dirname } from 'node:path';
13
+ import { fileURLToPath } from 'node:url';
14
+
15
+ const __dirname = dirname(fileURLToPath(import.meta.url));
16
+ const pkg = JSON.parse(readFileSync(join(__dirname, '..', 'package.json'), 'utf8'));
17
+
18
+ export const name = pkg.name;
19
+ export const version = pkg.version;
20
+
21
+ /**
22
+ * Scan a directory for .html templates and optional .json sidecar metadata.
23
+ * Pure function — no side effects, no logging, no server state.
24
+ *
25
+ * @param {string} templatesDir — absolute path to the templates directory
26
+ * @returns {Array<object>} — array of template metadata objects
27
+ */
28
+ export function loadTemplates(templatesDir) {
29
+ const templates = [];
30
+ if (!existsSync(templatesDir)) return templates;
31
+
32
+ for (const file of readdirSync(templatesDir)) {
33
+ if (!file.endsWith('.html')) continue;
34
+ const filePath = join(templatesDir, file);
35
+ const content = readFileSync(filePath, 'utf8');
36
+ const tplName = file.replace('.html', '');
37
+
38
+ // Extract placeholders
39
+ const placeholders = [...new Set(content.match(/\{\{[A-Z_]+\}\}/g) || [])];
40
+
41
+ // Extract description from first comment
42
+ const commentMatch = content.match(/<!--\s*(.*?)\s*-->/);
43
+ let description = commentMatch ? commentMatch[1] : '';
44
+
45
+ // Count lines and size
46
+ const lines = content.split('\n').length;
47
+ const size = statSync(filePath).size;
48
+
49
+ // Detect features
50
+ const features = [];
51
+ if (content.includes('scroll-snap')) features.push('scroll-snap');
52
+ if (content.includes('@media')) features.push('responsive');
53
+ if (content.includes('var(--')) features.push('css-variables');
54
+ if (content.includes('<table')) features.push('tables');
55
+ if (content.includes('.card')) features.push('cards');
56
+ if (content.includes('.slide')) features.push('slides');
57
+
58
+ // Merge optional template.json metadata
59
+ const metaPath = join(templatesDir, tplName + '.json');
60
+ let title = tplName;
61
+ let tags = [];
62
+ let author = '';
63
+ let tplVersion = '';
64
+ let exportPresets = [];
65
+ let seedPacks = [];
66
+ let scaffoldConfig = null;
67
+ if (existsSync(metaPath)) {
68
+ try {
69
+ const meta = JSON.parse(readFileSync(metaPath, 'utf8'));
70
+ if (meta.title) title = meta.title;
71
+ if (meta.description) description = meta.description;
72
+ if (Array.isArray(meta.tags)) tags = meta.tags;
73
+ if (meta.author) author = meta.author;
74
+ if (meta.version) tplVersion = meta.version;
75
+ if (Array.isArray(meta.exportPresets)) exportPresets = meta.exportPresets;
76
+ if (Array.isArray(meta.seedPacks)) seedPacks = meta.seedPacks;
77
+ if (meta.scaffoldConfig && typeof meta.scaffoldConfig === 'object') scaffoldConfig = meta.scaffoldConfig;
78
+ } catch {
79
+ // skip malformed sidecar
80
+ }
81
+ }
82
+
83
+ templates.push({
84
+ name: tplName, file, title, placeholders, description, lines, size,
85
+ features, tags, author, version: tplVersion, exportPresets, seedPacks, scaffoldConfig,
86
+ });
87
+ }
88
+ return templates;
89
+ }
90
+
91
+ // Re-export tools
92
+ export { detectSprints } from '../tools/detect-sprints.js';
93
+ export { generateManifest } from '../tools/generate-manifest.js';
package/lib/server.js ADDED
@@ -0,0 +1,368 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * barn serve — local HTTP server for the barn UI
4
+ *
5
+ * Two-column template browser with sprint auto-detection.
6
+ * SSE for live updates, POST endpoints for actions.
7
+ * Zero npm dependencies (node:http only).
8
+ *
9
+ * Usage:
10
+ * barn serve [--port 9093] [--root /path/to/repo]
11
+ */
12
+
13
+ import { createServer } from 'node:http';
14
+ import { readFileSync, existsSync, statSync } from 'node:fs';
15
+ import { readFile, stat, readdir } from 'node:fs/promises';
16
+ import { join, resolve, extname, dirname, basename } from 'node:path';
17
+ import { fileURLToPath } from 'node:url';
18
+ import { execFile } from 'node:child_process';
19
+ import { loadTemplates as _loadTemplates } from './index.js';
20
+
21
+ const __dirname = dirname(fileURLToPath(import.meta.url));
22
+
23
+ // ── Crash handlers ──
24
+ process.on('uncaughtException', (err) => {
25
+ process.stderr.write(`[${new Date().toISOString()}] FATAL: ${err.stack || err}\n`);
26
+ process.exit(1);
27
+ });
28
+ process.on('unhandledRejection', (reason) => {
29
+ process.stderr.write(`[${new Date().toISOString()}] WARN unhandledRejection: ${reason}\n`);
30
+ });
31
+
32
+ const PKG = JSON.parse(readFileSync(join(__dirname, '..', 'package.json'), 'utf8'));
33
+ const PUBLIC_DIR = join(__dirname, '..', 'public');
34
+ const TEMPLATES_DIR = join(__dirname, '..', 'templates');
35
+ const TOOLS_DIR = join(__dirname, '..', 'tools');
36
+
37
+ // ── CLI args ──────────────────────────────────────────────────────────────────
38
+
39
+ const args = process.argv.slice(2);
40
+ function arg(name, fallback) {
41
+ const i = args.indexOf(`--${name}`);
42
+ return i !== -1 && args[i + 1] ? args[i + 1] : fallback;
43
+ }
44
+
45
+ const PORT = parseInt(arg('port', '9093'), 10);
46
+ const ROOT = resolve(arg('root', process.cwd()));
47
+ const CORS_ORIGIN = arg('cors', null);
48
+
49
+ // ── Verbose logging ──────────────────────────────────────────────────────────
50
+
51
+ const verbose = process.argv.includes('--verbose') || process.argv.includes('-v');
52
+ function vlog(...a) {
53
+ if (!verbose) return;
54
+ const ts = new Date().toISOString();
55
+ process.stderr.write(`[${ts}] barn: ${a.join(' ')}\n`);
56
+ }
57
+
58
+ // ── Routes manifest ──────────────────────────────────────────────────────────
59
+
60
+ const ROUTES = [
61
+ { method: 'GET', path: '/health', description: 'Health check (tool, version, port, uptime)' },
62
+ { method: 'GET', path: '/events', description: 'SSE event stream for live updates' },
63
+ { method: 'GET', path: '/api/state', description: 'Current state (templates, sprints, manifest)' },
64
+ { method: 'GET', path: '/api/template', description: 'Template content by ?name parameter' },
65
+ { method: 'GET', path: '/api/search', description: 'Search templates by ?q=<query> (name, description, placeholders, features)' },
66
+ { method: 'POST', path: '/api/refresh', description: 'Refresh state from disk' },
67
+ { method: 'GET', path: '/api/docs', description: 'This API documentation page' },
68
+ ];
69
+
70
+ // ── State ─────────────────────────────────────────────────────────────────────
71
+
72
+ let state = {
73
+ templates: [],
74
+ sprints: [],
75
+ activeSprint: null,
76
+ manifest: null,
77
+ };
78
+
79
+ const sseClients = new Set();
80
+
81
+ function broadcast(event) {
82
+ const data = `data: ${JSON.stringify(event)}\n\n`;
83
+ for (const res of sseClients) {
84
+ try { res.write(data); } catch { sseClients.delete(res); }
85
+ }
86
+ }
87
+
88
+ // ── Data loading ──────────────────────────────────────────────────────────────
89
+
90
+ function loadTemplates() {
91
+ vlog('read', TEMPLATES_DIR);
92
+ return _loadTemplates(TEMPLATES_DIR);
93
+ }
94
+
95
+ function loadSprints() {
96
+ const mod = join(TOOLS_DIR, 'detect-sprints.js');
97
+ if (!existsSync(mod)) return Promise.resolve({ sprints: [], active: null });
98
+
99
+ return new Promise((resolve) => {
100
+ execFile('node', [mod, '--json', '--root', ROOT], {
101
+ timeout: 10000, stdio: ['ignore', 'pipe', 'pipe'],
102
+ }, (err, stdout) => {
103
+ if (err) { resolve({ sprints: [], active: null }); return; }
104
+ try {
105
+ const data = JSON.parse(stdout);
106
+ resolve({
107
+ sprints: data.sprints || [],
108
+ active: (data.sprints || []).find(s => s.status === 'active') || null,
109
+ });
110
+ } catch {
111
+ resolve({ sprints: [], active: null });
112
+ }
113
+ });
114
+ });
115
+ }
116
+
117
+ function loadManifest() {
118
+ const manifestPath = join(ROOT, 'wheat-manifest.json');
119
+ if (!existsSync(manifestPath)) return null;
120
+ try {
121
+ return JSON.parse(readFileSync(manifestPath, 'utf8'));
122
+ } catch {
123
+ return null;
124
+ }
125
+ }
126
+
127
+ let refreshPending = null;
128
+ async function refreshState() {
129
+ if (refreshPending) return refreshPending;
130
+ refreshPending = (async () => {
131
+ state.templates = await loadTemplates();
132
+ const sprintData = await loadSprints();
133
+ state.sprints = sprintData.sprints;
134
+ state.activeSprint = sprintData.active;
135
+ state.manifest = loadManifest();
136
+ broadcast({ type: 'state', data: state });
137
+ })();
138
+ try { return await refreshPending; } finally { refreshPending = null; }
139
+ }
140
+
141
+ // ── MIME types ────────────────────────────────────────────────────────────────
142
+
143
+ const MIME = {
144
+ '.html': 'text/html; charset=utf-8',
145
+ '.css': 'text/css; charset=utf-8',
146
+ '.js': 'application/javascript; charset=utf-8',
147
+ '.json': 'application/json; charset=utf-8',
148
+ '.svg': 'image/svg+xml',
149
+ '.png': 'image/png',
150
+ };
151
+
152
+ // ── HTTP server ───────────────────────────────────────────────────────────────
153
+
154
+ const server = createServer(async (req, res) => {
155
+ const url = new URL(req.url, `http://localhost:${PORT}`);
156
+
157
+ // CORS headers (only when --cors is passed)
158
+ if (CORS_ORIGIN) {
159
+ res.setHeader('Access-Control-Allow-Origin', CORS_ORIGIN);
160
+ res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
161
+ res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
162
+ }
163
+
164
+ if (req.method === 'OPTIONS' && CORS_ORIGIN) {
165
+ res.writeHead(204);
166
+ res.end();
167
+ return;
168
+ }
169
+
170
+ vlog('request', req.method, url.pathname);
171
+
172
+ // ── Health check ──
173
+ if (req.method === 'GET' && url.pathname === '/health') {
174
+ res.writeHead(200, { 'Content-Type': 'application/json' });
175
+ res.end(JSON.stringify({ tool: 'barn', version: PKG.version, port: PORT, uptime: process.uptime() }));
176
+ return;
177
+ }
178
+
179
+ // ── API: docs ──
180
+ if (req.method === 'GET' && url.pathname === '/api/docs') {
181
+ const html = `<!DOCTYPE html><html><head><title>barn API</title>
182
+ <style>body{font-family:system-ui;background:#0a0e1a;color:#e8ecf1;max-width:800px;margin:40px auto;padding:0 20px}
183
+ table{width:100%;border-collapse:collapse}th,td{padding:8px 12px;border-bottom:1px solid #1e293b;text-align:left}
184
+ th{color:#9ca3af}code{background:#1e293b;padding:2px 6px;border-radius:4px;font-size:13px}</style></head>
185
+ <body><h1>barn API</h1><p>${ROUTES.length} endpoints</p>
186
+ <table><tr><th>Method</th><th>Path</th><th>Description</th></tr>
187
+ ${ROUTES.map(r => '<tr><td><code>'+r.method+'</code></td><td><code>'+r.path+'</code></td><td>'+r.description+'</td></tr>').join('')}
188
+ </table></body></html>`;
189
+ res.writeHead(200, { 'Content-Type': 'text/html' });
190
+ res.end(html);
191
+ return;
192
+ }
193
+
194
+ // ── SSE endpoint ──
195
+ if (req.method === 'GET' && url.pathname === '/events') {
196
+ res.writeHead(200, {
197
+ 'Content-Type': 'text/event-stream',
198
+ 'Cache-Control': 'no-cache',
199
+ 'Connection': 'keep-alive',
200
+ });
201
+ res.write(`data: ${JSON.stringify({ type: 'state', data: state })}\n\n`);
202
+ const heartbeat = setInterval(() => {
203
+ try { res.write(': heartbeat\n\n'); } catch { clearInterval(heartbeat); }
204
+ }, 15000);
205
+ sseClients.add(res);
206
+ vlog('sse', `client connected (${sseClients.size} total)`);
207
+ req.on('close', () => { clearInterval(heartbeat); sseClients.delete(res); vlog('sse', `client disconnected (${sseClients.size} total)`); });
208
+ return;
209
+ }
210
+
211
+ // ── API: state ──
212
+ if (req.method === 'GET' && url.pathname === '/api/state') {
213
+ res.writeHead(200, { 'Content-Type': 'application/json' });
214
+ res.end(JSON.stringify(state));
215
+ return;
216
+ }
217
+
218
+ // ── API: search templates ──
219
+ if (req.method === 'GET' && url.pathname === '/api/search') {
220
+ const q = (url.searchParams.get('q') || '').toLowerCase().trim();
221
+ if (!q) {
222
+ res.writeHead(200, { 'Content-Type': 'application/json' });
223
+ res.end(JSON.stringify(state.templates));
224
+ return;
225
+ }
226
+ const filtered = state.templates.filter(tpl => {
227
+ const haystack = [
228
+ tpl.name,
229
+ tpl.title,
230
+ tpl.description,
231
+ ...tpl.placeholders,
232
+ ...tpl.features,
233
+ ...tpl.tags,
234
+ ].join(' ').toLowerCase();
235
+ return haystack.includes(q);
236
+ });
237
+ res.writeHead(200, { 'Content-Type': 'application/json' });
238
+ res.end(JSON.stringify(filtered));
239
+ return;
240
+ }
241
+
242
+ // ── API: template content ──
243
+ if (req.method === 'GET' && url.pathname === '/api/template') {
244
+ const name = url.searchParams.get('name');
245
+ if (!name) { res.writeHead(400); res.end('missing name'); return; }
246
+ const filePath = resolve(TEMPLATES_DIR, name + '.html');
247
+ if (!filePath.startsWith(TEMPLATES_DIR)) { res.writeHead(403); res.end('forbidden'); return; }
248
+ if (!existsSync(filePath)) { res.writeHead(404); res.end('not found'); return; }
249
+ res.writeHead(200, { 'Content-Type': 'text/plain; charset=utf-8' });
250
+ res.end(readFileSync(filePath, 'utf8'));
251
+ return;
252
+ }
253
+
254
+ // ── API: refresh ──
255
+ if (req.method === 'POST' && url.pathname === '/api/refresh') {
256
+ await refreshState();
257
+ res.writeHead(200, { 'Content-Type': 'application/json' });
258
+ res.end(JSON.stringify(state));
259
+ return;
260
+ }
261
+
262
+ // ── Static files ──
263
+ let filePath = url.pathname === '/' ? '/index.html' : url.pathname;
264
+
265
+ // Prevent directory traversal
266
+ const resolved = resolve(PUBLIC_DIR, '.' + filePath);
267
+ if (!resolved.startsWith(PUBLIC_DIR)) {
268
+ res.writeHead(403);
269
+ res.end('forbidden');
270
+ return;
271
+ }
272
+
273
+ if (existsSync(resolved) && statSync(resolved).isFile()) {
274
+ const ext = extname(resolved);
275
+ res.writeHead(200, { 'Content-Type': MIME[ext] || 'application/octet-stream' });
276
+ res.end(readFileSync(resolved));
277
+ return;
278
+ }
279
+
280
+ res.writeHead(404);
281
+ res.end('not found');
282
+ });
283
+
284
+ // ── File watching ─────────────────────────────────────────────────────────────
285
+
286
+ // Build a fingerprint of filenames + mtimes for change detection
287
+ async function dirFingerprint(dir) {
288
+ if (!existsSync(dir)) return '';
289
+ const files = await readdir(dir);
290
+ const parts = [];
291
+ for (const f of files) {
292
+ if (!f.endsWith('.html') && !f.endsWith('.json')) continue;
293
+ try {
294
+ const s = await stat(join(dir, f));
295
+ parts.push(`${f}:${s.mtimeMs}`);
296
+ } catch { /* removed between readdir and stat */ }
297
+ }
298
+ return parts.sort().join('|');
299
+ }
300
+
301
+ async function claimsFingerprint() {
302
+ const claimsPath = join(ROOT, 'claims.json');
303
+ try {
304
+ const s = await stat(claimsPath);
305
+ return `claims:${s.mtimeMs}`;
306
+ } catch { return ''; }
307
+ }
308
+
309
+ let lastTemplatesFP = '';
310
+ let lastClaimsFP = '';
311
+
312
+ const watchInterval = setInterval(async () => {
313
+ try {
314
+ const [tFP, cFP] = await Promise.all([
315
+ dirFingerprint(TEMPLATES_DIR),
316
+ claimsFingerprint(),
317
+ ]);
318
+ if (tFP !== lastTemplatesFP || cFP !== lastClaimsFP) {
319
+ lastTemplatesFP = tFP;
320
+ lastClaimsFP = cFP;
321
+ await refreshState();
322
+ }
323
+ } catch { /* ignore polling errors */ }
324
+ }, 2000);
325
+
326
+ // ── Graceful shutdown ─────────────────────────────────────────────────────────
327
+ const shutdown = (signal) => {
328
+ console.log(`\nbarn: ${signal} received, shutting down...`);
329
+ clearInterval(watchInterval);
330
+ for (const res of sseClients) { try { res.end(); } catch {} }
331
+ sseClients.clear();
332
+ server.close(() => process.exit(0));
333
+ setTimeout(() => process.exit(1), 5000);
334
+ };
335
+ process.on('SIGTERM', () => shutdown('SIGTERM'));
336
+ process.on('SIGINT', () => shutdown('SIGINT'));
337
+
338
+ // ── Start ─────────────────────────────────────────────────────────────────────
339
+
340
+ await refreshState();
341
+ // Seed fingerprints so the poller doesn't re-trigger immediately
342
+ [lastTemplatesFP, lastClaimsFP] = await Promise.all([
343
+ dirFingerprint(TEMPLATES_DIR),
344
+ claimsFingerprint(),
345
+ ]);
346
+
347
+ server.on('error', (err) => {
348
+ if (err.code === 'EADDRINUSE') {
349
+ console.error(`barn: port ${PORT} already in use — try --port <other>`);
350
+ process.exit(1);
351
+ }
352
+ if (err.code === 'EACCES') {
353
+ console.error(`barn: permission denied for port ${PORT}`);
354
+ process.exit(1);
355
+ }
356
+ throw err;
357
+ });
358
+
359
+ server.listen(PORT, '127.0.0.1', () => {
360
+ vlog('listen', `port=${PORT}`, `root=${ROOT}`);
361
+ console.log(`barn: serving on http://localhost:${PORT}`);
362
+ console.log(` templates: ${state.templates.length} found`);
363
+ console.log(` sprints: ${state.sprints.length} detected`);
364
+ if (state.activeSprint) {
365
+ console.log(` active: ${state.activeSprint.name} (${state.activeSprint.phase})`);
366
+ }
367
+ console.log(` root: ${ROOT}`);
368
+ });