@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
@@ -0,0 +1,292 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * detect-sprints.js — Git-based sprint detection
4
+ *
5
+ * Scans a repo for sprint indicators (claims.json files) and determines
6
+ * which sprint is "active" using filesystem + git heuristics:
7
+ *
8
+ * 1. Find all claims.json files (root + examples/ subdirs)
9
+ * 2. Read meta.phase — "archived" sprints are inactive
10
+ * 3. Query git log for most recent commit touching each claims.json
11
+ * 4. Rank by: non-archived > most recent git activity > initiated date
12
+ *
13
+ * Returns a list of sprints with status (active/archived/example).
14
+ * Works without any config file — pure filesystem + git.
15
+ *
16
+ * Usage:
17
+ * node detect-sprints.js # Human-readable output
18
+ * node detect-sprints.js --json # Machine-readable JSON
19
+ * node detect-sprints.js --active # Print only the active sprint path
20
+ * node detect-sprints.js --root /path # Scan a specific directory
21
+ *
22
+ * Zero npm dependencies (Node built-in only).
23
+ */
24
+
25
+ import { readFileSync, existsSync, readdirSync } from 'node:fs';
26
+ import { join, basename } from 'node:path';
27
+ import { execFileSync } from 'node:child_process';
28
+ import { fileURLToPath } from 'node:url';
29
+
30
+ const __filename = fileURLToPath(import.meta.url);
31
+
32
+ // ─── CLI args (only parsed when run directly) ────────────────────────────────
33
+
34
+ let ROOT = process.cwd();
35
+
36
+ /** Parse CLI args — only called when this file is the entry point. */
37
+ function parseCLIArgs() {
38
+ const args = process.argv.slice(2);
39
+ const i = args.indexOf('--root');
40
+ if (i !== -1 && args[i + 1]) ROOT = args[i + 1];
41
+ return args;
42
+ }
43
+
44
+ // ─── Helpers ─────────────────────────────────────────────────────────────────
45
+
46
+ /** Safely parse JSON from a file path; returns null on failure. */
47
+ function loadJSON(filePath) {
48
+ try {
49
+ return JSON.parse(readFileSync(filePath, 'utf8'));
50
+ } catch {
51
+ return null;
52
+ }
53
+ }
54
+
55
+ /**
56
+ * Get the ISO timestamp of the most recent git commit touching a file.
57
+ * Returns null if file is untracked or git is unavailable.
58
+ */
59
+ function lastGitCommitDate(filePath) {
60
+ try {
61
+ const result = execFileSync('git', [
62
+ 'log', '-1', '--format=%aI', '--', filePath
63
+ ], { cwd: ROOT, timeout: 5000, stdio: ['ignore', 'pipe', 'pipe'] });
64
+ const dateStr = result.toString().trim();
65
+ return dateStr || null;
66
+ } catch {
67
+ return null;
68
+ }
69
+ }
70
+
71
+ /**
72
+ * Count git commits touching a file (proxy for activity level).
73
+ */
74
+ function gitCommitCount(filePath) {
75
+ try {
76
+ const result = execFileSync('git', [
77
+ 'rev-list', '--count', 'HEAD', '--', filePath
78
+ ], { cwd: ROOT, timeout: 5000, stdio: ['ignore', 'pipe', 'pipe'] });
79
+ return parseInt(result.toString().trim(), 10) || 0;
80
+ } catch {
81
+ return 0;
82
+ }
83
+ }
84
+
85
+ /**
86
+ * Derive a slug from the sprint's path or question.
87
+ */
88
+ function deriveName(sprintPath, meta) {
89
+ if (sprintPath !== '.') {
90
+ return basename(sprintPath);
91
+ }
92
+ if (meta?.question) {
93
+ return meta.question
94
+ .toLowerCase()
95
+ .replace(/[^a-z0-9\s]/g, '')
96
+ .split(/\s+/)
97
+ .slice(0, 4)
98
+ .join('-');
99
+ }
100
+ return 'current';
101
+ }
102
+
103
+ // ─── Scanner ─────────────────────────────────────────────────────────────────
104
+
105
+ /** Find all sprint roots (directories containing claims.json). */
106
+ function findSprintRoots() {
107
+ const roots = [];
108
+
109
+ const rootClaims = join(ROOT, 'claims.json');
110
+ if (existsSync(rootClaims)) {
111
+ roots.push({ claimsPath: rootClaims, sprintPath: '.' });
112
+ }
113
+
114
+ const examplesDir = join(ROOT, 'examples');
115
+ if (existsSync(examplesDir)) {
116
+ try {
117
+ for (const entry of readdirSync(examplesDir, { withFileTypes: true })) {
118
+ if (!entry.isDirectory()) continue;
119
+ const claimsPath = join(examplesDir, entry.name, 'claims.json');
120
+ if (existsSync(claimsPath)) {
121
+ roots.push({
122
+ claimsPath,
123
+ sprintPath: join('examples', entry.name),
124
+ });
125
+ }
126
+ }
127
+ } catch { /* skip if unreadable */ }
128
+ }
129
+
130
+ return roots;
131
+ }
132
+
133
+ // ─── Sprint Analysis ─────────────────────────────────────────────────────────
134
+
135
+ function analyzeSprint(root) {
136
+ const claims = loadJSON(root.claimsPath);
137
+ if (!claims) return null;
138
+
139
+ const meta = claims.meta || {};
140
+ const claimsList = claims.claims || [];
141
+
142
+ const lastCommit = lastGitCommitDate(root.claimsPath);
143
+ const commitCount = gitCommitCount(root.claimsPath);
144
+
145
+ const phase = meta.phase || 'unknown';
146
+ const isArchived = phase === 'archived' || phase === 'complete';
147
+ const isExample = root.sprintPath.startsWith('examples/') || root.sprintPath.startsWith('examples\\');
148
+
149
+ let status;
150
+ if (isArchived) {
151
+ status = 'archived';
152
+ } else if (isExample) {
153
+ status = 'example';
154
+ } else {
155
+ status = 'candidate';
156
+ }
157
+
158
+ return {
159
+ name: deriveName(root.sprintPath, meta),
160
+ path: root.sprintPath,
161
+ question: meta.question || '',
162
+ phase,
163
+ initiated: meta.initiated || null,
164
+ last_git_activity: lastCommit,
165
+ git_commit_count: commitCount,
166
+ claims_count: claimsList.length,
167
+ active_claims: claimsList.filter(c => c.status === 'active').length,
168
+ status,
169
+ };
170
+ }
171
+
172
+ /**
173
+ * Detect all sprints and determine which is active.
174
+ *
175
+ * Ranking (highest to lowest priority):
176
+ * 1. Non-archived, non-example sprints (root-level candidates)
177
+ * 2. Most recent git commit touching claims.json
178
+ * 3. Most recent meta.initiated date
179
+ * 4. Highest claim count (tiebreaker)
180
+ */
181
+ export function detectSprints(rootDir) {
182
+ if (rootDir) ROOT = rootDir;
183
+ const roots = findSprintRoots();
184
+ const sprints = roots.map(analyzeSprint).filter(Boolean);
185
+
186
+ const candidates = sprints.filter(s => s.status === 'candidate');
187
+ const others = sprints.filter(s => s.status !== 'candidate');
188
+
189
+ candidates.sort((a, b) => {
190
+ const dateA = a.last_git_activity ? new Date(a.last_git_activity).getTime() : 0;
191
+ const dateB = b.last_git_activity ? new Date(b.last_git_activity).getTime() : 0;
192
+ if (dateB !== dateA) return dateB - dateA;
193
+
194
+ const initA = a.initiated ? new Date(a.initiated).getTime() : 0;
195
+ const initB = b.initiated ? new Date(b.initiated).getTime() : 0;
196
+ if (initB !== initA) return initB - initA;
197
+
198
+ return b.claims_count - a.claims_count;
199
+ });
200
+
201
+ let active = null;
202
+ if (candidates.length > 0) {
203
+ candidates[0].status = 'active';
204
+ active = candidates[0];
205
+ }
206
+
207
+ if (!active && others.length > 0) {
208
+ const nonArchived = others.filter(s => s.status !== 'archived');
209
+ if (nonArchived.length > 0) {
210
+ nonArchived.sort((a, b) => {
211
+ const dateA = a.last_git_activity ? new Date(a.last_git_activity).getTime() : 0;
212
+ const dateB = b.last_git_activity ? new Date(b.last_git_activity).getTime() : 0;
213
+ return dateB - dateA;
214
+ });
215
+ nonArchived[0].status = 'active';
216
+ active = nonArchived[0];
217
+ }
218
+ }
219
+
220
+ const allSprints = [...candidates, ...others].sort((a, b) => {
221
+ if (a.status === 'active' && b.status !== 'active') return -1;
222
+ if (b.status === 'active' && a.status !== 'active') return 1;
223
+ const dateA = a.last_git_activity ? new Date(a.last_git_activity).getTime() : 0;
224
+ const dateB = b.last_git_activity ? new Date(b.last_git_activity).getTime() : 0;
225
+ return dateB - dateA;
226
+ });
227
+
228
+ return { active, sprints: allSprints };
229
+ }
230
+
231
+ // ─── CLI ─────────────────────────────────────────────────────────────────────
232
+
233
+ if (process.argv[1] === __filename) {
234
+ const args = parseCLIArgs();
235
+ if (args.includes('--help') || args.includes('-h')) {
236
+ console.log(`detect-sprints — git-based sprint detection (no config required)
237
+
238
+ Usage:
239
+ barn detect-sprints Human-readable sprint list
240
+ barn detect-sprints --json Machine-readable JSON output
241
+ barn detect-sprints --active Print only the active sprint path
242
+ barn detect-sprints --root PATH Scan a specific directory
243
+
244
+ Detects sprints from claims.json files in a repo. Determines the active
245
+ sprint using git commit history and metadata — no config pointer needed.`);
246
+ process.exit(0);
247
+ }
248
+
249
+ const t0 = performance.now();
250
+ const result = detectSprints();
251
+ const elapsed = (performance.now() - t0).toFixed(1);
252
+
253
+ if (args.includes('--json')) {
254
+ console.log(JSON.stringify(result, null, 2));
255
+ process.exit(0);
256
+ }
257
+
258
+ if (args.includes('--active')) {
259
+ if (result.active) {
260
+ console.log(result.active.path);
261
+ } else {
262
+ console.error('No active sprint detected.');
263
+ process.exit(1);
264
+ }
265
+ process.exit(0);
266
+ }
267
+
268
+ // Human-readable output
269
+ console.log(`Sprint Detection (${elapsed}ms)`);
270
+ console.log('='.repeat(50));
271
+ console.log(`Found ${result.sprints.length} sprint(s)\n`);
272
+
273
+ for (const sprint of result.sprints) {
274
+ const icon = sprint.status === 'active' ? '>>>' : ' ';
275
+ const statusTag = sprint.status.toUpperCase().padEnd(8);
276
+ console.log(`${icon} [${statusTag}] ${sprint.name}`);
277
+ console.log(` Path: ${sprint.path}`);
278
+ console.log(` Phase: ${sprint.phase}`);
279
+ console.log(` Claims: ${sprint.claims_count} total, ${sprint.active_claims} active`);
280
+ console.log(` Initiated: ${sprint.initiated || 'unknown'}`);
281
+ console.log(` Last git: ${sprint.last_git_activity || 'untracked'}`);
282
+ console.log(` Commits: ${sprint.git_commit_count}`);
283
+ console.log(` Question: ${sprint.question.slice(0, 80)}${sprint.question.length > 80 ? '...' : ''}`);
284
+ console.log();
285
+ }
286
+
287
+ if (result.active) {
288
+ console.log(`Active sprint: ${result.active.path} (${result.active.name})`);
289
+ } else {
290
+ console.log('No active sprint detected.');
291
+ }
292
+ }
@@ -0,0 +1,237 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * generate-manifest.js — Build wheat-manifest.json topic map
4
+ *
5
+ * Reads claims.json, compilation.json, and scans the repo directory structure
6
+ * to produce a topic-map manifest. Zero npm dependencies.
7
+ *
8
+ * Usage:
9
+ * barn generate-manifest # Write wheat-manifest.json
10
+ * barn generate-manifest --root /path # Target a specific repo
11
+ * barn generate-manifest --out custom-name.json # Custom output path
12
+ */
13
+
14
+ import { readFileSync, writeFileSync, readdirSync, statSync, existsSync } from 'node:fs';
15
+ import { join, relative, resolve, basename, sep } from 'node:path';
16
+ import { execFileSync } from 'node:child_process';
17
+ import { fileURLToPath } from 'node:url';
18
+ import { detectSprints } from './detect-sprints.js';
19
+
20
+ // ─── CLI args ────────────────────────────────────────────────────────────────
21
+
22
+ const args = process.argv.slice(2);
23
+
24
+ function arg(name, fallback) {
25
+ const i = args.indexOf(`--${name}`);
26
+ return i !== -1 && args[i + 1] ? args[i + 1] : fallback;
27
+ }
28
+
29
+ const ROOT = arg('root', process.cwd());
30
+ const OUT_PATH = join(ROOT, arg('out', 'wheat-manifest.json'));
31
+
32
+ // ─── Helpers ─────────────────────────────────────────────────────────────────
33
+
34
+ function loadJSON(path) {
35
+ try {
36
+ return JSON.parse(readFileSync(path, 'utf8'));
37
+ } catch {
38
+ return null;
39
+ }
40
+ }
41
+
42
+ /** Recursively list files under dir, returning paths relative to rootDir. */
43
+ function walk(dir, filter, rootDir) {
44
+ const results = [];
45
+ if (!existsSync(dir)) return results;
46
+ const base = rootDir || dir;
47
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
48
+ const full = join(dir, entry.name);
49
+ if (entry.isDirectory()) {
50
+ if (entry.name.startsWith('.') || entry.name === 'node_modules') continue;
51
+ results.push(...walk(full, filter, base));
52
+ } else {
53
+ const rel = relative(base, full).split(sep).join('/');
54
+ if (!filter || filter(rel, entry.name)) results.push(rel);
55
+ }
56
+ }
57
+ return results;
58
+ }
59
+
60
+ /** Determine file type from its path. */
61
+ function classifyFile(relPath) {
62
+ if (relPath.startsWith('prototypes/')) return 'prototype';
63
+ if (relPath.startsWith('research/')) return 'research';
64
+ if (relPath.startsWith('output/')) return 'output';
65
+ if (relPath.startsWith('evidence/')) return 'evidence';
66
+ if (relPath.startsWith('templates/')) return 'template';
67
+ if (relPath.startsWith('examples/')) return 'example';
68
+ if (relPath.startsWith('test/')) return 'test';
69
+ if (relPath.startsWith('docs/')) return 'docs';
70
+ if (relPath.endsWith('.json')) return 'config';
71
+ if (relPath.endsWith('.js') || relPath.endsWith('.mjs')) return 'script';
72
+ if (relPath.endsWith('.md')) return 'docs';
73
+ return 'other';
74
+ }
75
+
76
+ /** Compute highest evidence tier from a list of claims. */
77
+ function highestEvidence(claims) {
78
+ const tiers = ['stated', 'web', 'documented', 'tested', 'production'];
79
+ let max = 0;
80
+ for (const c of claims) {
81
+ const idx = tiers.indexOf(c.evidence);
82
+ if (idx > max) max = idx;
83
+ }
84
+ return tiers[max];
85
+ }
86
+
87
+ // ─── Exported function ───────────────────────────────────────────────────────
88
+
89
+ /**
90
+ * Generate a wheat-manifest object from a claims.json path.
91
+ *
92
+ * @param {string} claimsPath — absolute path to claims.json
93
+ * @param {object} [opts] — optional overrides
94
+ * @param {string} [opts.root] — repo root directory (defaults to dirname of claimsPath)
95
+ * @returns {object} — the manifest object (not written to disk)
96
+ */
97
+ export function generateManifest(claimsPath, opts = {}) {
98
+ const root = opts.root || join(claimsPath, '..');
99
+ const claims = loadJSON(claimsPath);
100
+ if (!claims) {
101
+ throw new Error(`claims.json not found or invalid at ${claimsPath}`);
102
+ }
103
+
104
+ const compilationPath = join(root, 'compilation.json');
105
+ const compilation = loadJSON(compilationPath);
106
+
107
+ // 1. Build topic map from claims
108
+ const topicMap = {};
109
+ for (const claim of claims.claims) {
110
+ const topic = claim.topic;
111
+ if (!topicMap[topic]) {
112
+ topicMap[topic] = { claims: [], files: new Set(), sprint: 'current', evidence_level: 'stated' };
113
+ }
114
+ topicMap[topic].claims.push(claim.id);
115
+ }
116
+
117
+ for (const topic of Object.keys(topicMap)) {
118
+ const topicClaims = claims.claims.filter(c => c.topic === topic);
119
+ topicMap[topic].evidence_level = highestEvidence(topicClaims);
120
+ }
121
+
122
+ // 2. Scan directories for files
123
+ const scanDirs = ['research', 'prototypes', 'output', 'evidence', 'templates', 'test', 'docs'];
124
+ const allFiles = {};
125
+
126
+ for (const dir of scanDirs) {
127
+ const files = walk(join(root, dir), null, root);
128
+ for (const f of files) {
129
+ allFiles[f] = { topics: [], type: classifyFile(f) };
130
+ }
131
+ }
132
+
133
+ // Root-level scripts/configs
134
+ try {
135
+ for (const entry of readdirSync(root)) {
136
+ if (entry.startsWith('.') || entry === 'node_modules') continue;
137
+ const full = join(root, entry);
138
+ try {
139
+ if (statSync(full).isFile()) {
140
+ const type = classifyFile(entry);
141
+ if (type !== 'other') {
142
+ allFiles[entry] = { topics: [], type };
143
+ }
144
+ }
145
+ } catch { /* skip */ }
146
+ }
147
+ } catch { /* skip */ }
148
+
149
+ // 3. Map files to topics via claim artifacts
150
+ for (const [filePath, fileInfo] of Object.entries(allFiles)) {
151
+ for (const claim of claims.claims) {
152
+ if (claim.source?.artifact && filePath.includes(claim.source.artifact.replace(/^.*[/\\]prototypes[/\\]/, 'prototypes/'))) {
153
+ if (!fileInfo.topics.includes(claim.topic)) {
154
+ fileInfo.topics.push(claim.topic);
155
+ }
156
+ }
157
+ }
158
+
159
+ for (const topic of fileInfo.topics) {
160
+ if (topicMap[topic]) {
161
+ topicMap[topic].files.add(filePath);
162
+ }
163
+ }
164
+ }
165
+
166
+ // 4. Convert Sets to arrays
167
+ for (const topic of Object.keys(topicMap)) {
168
+ topicMap[topic].files = [...topicMap[topic].files].sort();
169
+ }
170
+
171
+ // 5. Detect sprints
172
+ const sprintResult = detectSprints(root);
173
+ const sprints = {};
174
+ for (const s of (sprintResult.sprints || [])) {
175
+ sprints[s.name] = {
176
+ question: s.question || '',
177
+ phase: s.phase || 'unknown',
178
+ claims_count: s.claims_count || 0,
179
+ active_claims: s.active_claims || 0,
180
+ path: s.path,
181
+ status: s.status,
182
+ last_git_activity: s.last_git_activity,
183
+ git_commit_count: s.git_commit_count,
184
+ };
185
+ }
186
+
187
+ // 6. Build manifest
188
+ const topicFiles = {};
189
+ for (const [path, info] of Object.entries(allFiles)) {
190
+ if (info.topics.length > 0) {
191
+ topicFiles[path] = info;
192
+ }
193
+ }
194
+
195
+ return {
196
+ schema_version: '1.0',
197
+ generated: new Date().toISOString(),
198
+ generator: '@grainulation/barn generate-manifest',
199
+ claims_hash: compilation?.claims_hash || null,
200
+ topics: topicMap,
201
+ sprints,
202
+ files: topicFiles,
203
+ };
204
+ }
205
+
206
+ // ─── CLI ─────────────────────────────────────────────────────────────────────
207
+
208
+ // Only run CLI logic when executed directly (not imported)
209
+ const isMain = process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url);
210
+ if (isMain) {
211
+ if (args.includes('--help') || args.includes('-h')) {
212
+ console.log(`generate-manifest — build wheat-manifest.json topic map
213
+
214
+ Usage:
215
+ barn generate-manifest Write wheat-manifest.json
216
+ barn generate-manifest --root /path Target a specific repo
217
+ barn generate-manifest --out custom-name.json Custom output path
218
+
219
+ Reads claims.json and scans the repo to produce a topic map manifest
220
+ that gives AI tools a single file describing the sprint state.`);
221
+ process.exit(0);
222
+ }
223
+
224
+ const t0 = performance.now();
225
+ const manifest = generateManifest(join(ROOT, 'claims.json'), { root: ROOT });
226
+
227
+ writeFileSync(OUT_PATH, JSON.stringify(manifest, null, 2) + '\n');
228
+ const elapsed = (performance.now() - t0).toFixed(1);
229
+
230
+ const topicCount = Object.keys(manifest.topics).length;
231
+ const fileCount = Object.keys(manifest.files).length;
232
+ const sprintCount = Object.keys(manifest.sprints).length;
233
+ const sizeBytes = Buffer.byteLength(JSON.stringify(manifest, null, 2));
234
+
235
+ console.log(`wheat-manifest.json generated in ${elapsed}ms`);
236
+ console.log(` Topics: ${topicCount} | Files: ${fileCount} | Sprints: ${sprintCount} | Size: ${(sizeBytes / 1024).toFixed(1)}KB`);
237
+ }