@grainulation/wheat 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.
- package/LICENSE +21 -0
- package/README.md +136 -0
- package/bin/wheat.js +193 -0
- package/compiler/detect-sprints.js +319 -0
- package/compiler/generate-manifest.js +280 -0
- package/compiler/wheat-compiler.js +1229 -0
- package/lib/compiler.js +35 -0
- package/lib/connect.js +418 -0
- package/lib/disconnect.js +188 -0
- package/lib/guard.js +151 -0
- package/lib/index.js +14 -0
- package/lib/init.js +457 -0
- package/lib/install-prompt.js +186 -0
- package/lib/quickstart.js +276 -0
- package/lib/serve-mcp.js +509 -0
- package/lib/server.js +391 -0
- package/lib/stats.js +184 -0
- package/lib/status.js +135 -0
- package/lib/update.js +71 -0
- package/package.json +53 -0
- package/public/index.html +1798 -0
- package/templates/claude.md +122 -0
- package/templates/commands/blind-spot.md +47 -0
- package/templates/commands/brief.md +73 -0
- package/templates/commands/calibrate.md +39 -0
- package/templates/commands/challenge.md +72 -0
- package/templates/commands/connect.md +104 -0
- package/templates/commands/evaluate.md +80 -0
- package/templates/commands/feedback.md +60 -0
- package/templates/commands/handoff.md +53 -0
- package/templates/commands/init.md +68 -0
- package/templates/commands/merge.md +51 -0
- package/templates/commands/present.md +52 -0
- package/templates/commands/prototype.md +68 -0
- package/templates/commands/replay.md +61 -0
- package/templates/commands/research.md +73 -0
- package/templates/commands/resolve.md +42 -0
- package/templates/commands/status.md +56 -0
- package/templates/commands/witness.md +79 -0
- package/templates/explainer.html +343 -0
package/lib/server.js
ADDED
|
@@ -0,0 +1,391 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* wheat serve — local HTTP server for the wheat sprint dashboard
|
|
3
|
+
*
|
|
4
|
+
* Three-column IDE-shell layout: topics | claims | detail.
|
|
5
|
+
* SSE for live updates, POST endpoint for recompilation.
|
|
6
|
+
* Zero npm dependencies (node:http only).
|
|
7
|
+
*
|
|
8
|
+
* Usage:
|
|
9
|
+
* wheat serve [--port 9092] [--dir /path/to/sprint]
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import http from 'node:http';
|
|
13
|
+
import fs from 'node:fs';
|
|
14
|
+
import path from 'node:path';
|
|
15
|
+
import { execFileSync } from 'node:child_process';
|
|
16
|
+
import { fileURLToPath } from 'node:url';
|
|
17
|
+
|
|
18
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
19
|
+
const __dirname = path.dirname(__filename);
|
|
20
|
+
|
|
21
|
+
// ── Crash handlers ──
|
|
22
|
+
process.on('uncaughtException', (err) => {
|
|
23
|
+
process.stderr.write(`[${new Date().toISOString()}] FATAL: ${err.stack || err}\n`);
|
|
24
|
+
process.exit(1);
|
|
25
|
+
});
|
|
26
|
+
process.on('unhandledRejection', (reason) => {
|
|
27
|
+
process.stderr.write(`[${new Date().toISOString()}] WARN unhandledRejection: ${reason}\n`);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
const PUBLIC_DIR = path.join(__dirname, '..', 'public');
|
|
31
|
+
|
|
32
|
+
// ── Verbose logging ──────────────────────────────────────────────────────────
|
|
33
|
+
|
|
34
|
+
const verbose = process.argv.includes('--verbose');
|
|
35
|
+
function vlog(...a) {
|
|
36
|
+
if (!verbose) return;
|
|
37
|
+
const ts = new Date().toISOString();
|
|
38
|
+
process.stderr.write(`[${ts}] wheat: ${a.join(' ')}\n`);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// ── Routes manifest ──────────────────────────────────────────────────────────
|
|
42
|
+
|
|
43
|
+
const ROUTES = [
|
|
44
|
+
{ method: 'GET', path: '/events', description: 'SSE event stream for live sprint updates' },
|
|
45
|
+
{ method: 'GET', path: '/api/state', description: 'Current sprint state (claims, compilation, sprints)' },
|
|
46
|
+
{ method: 'GET', path: '/api/claims', description: 'Claims list with optional ?topic, ?type, ?evidence, ?status filters' },
|
|
47
|
+
{ method: 'GET', path: '/api/coverage', description: 'Compilation coverage data' },
|
|
48
|
+
{ method: 'GET', path: '/api/compilation', description: 'Full compilation result' },
|
|
49
|
+
{ method: 'POST', path: '/api/compile', description: 'Trigger recompilation of claims' },
|
|
50
|
+
{ method: 'GET', path: '/api/docs', description: 'This API documentation page' },
|
|
51
|
+
];
|
|
52
|
+
|
|
53
|
+
// ── State ─────────────────────────────────────────────────────────────────────
|
|
54
|
+
|
|
55
|
+
let state = {
|
|
56
|
+
claims: [],
|
|
57
|
+
compilation: null,
|
|
58
|
+
sprints: [],
|
|
59
|
+
activeSprint: null,
|
|
60
|
+
meta: null,
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
const sseClients = new Set();
|
|
64
|
+
|
|
65
|
+
function broadcast(event) {
|
|
66
|
+
const data = `data: ${JSON.stringify(event)}\n\n`;
|
|
67
|
+
for (const res of sseClients) {
|
|
68
|
+
try { res.write(data); } catch { sseClients.delete(res); }
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// ── Data loading ──────────────────────────────────────────────────────────────
|
|
73
|
+
|
|
74
|
+
function loadClaims(root) {
|
|
75
|
+
const claimsPath = path.join(root, 'claims.json');
|
|
76
|
+
vlog('read', claimsPath);
|
|
77
|
+
if (!fs.existsSync(claimsPath)) return { meta: null, claims: [] };
|
|
78
|
+
try {
|
|
79
|
+
const data = JSON.parse(fs.readFileSync(claimsPath, 'utf8'));
|
|
80
|
+
return { meta: data.meta || null, claims: data.claims || [] };
|
|
81
|
+
} catch {
|
|
82
|
+
return { meta: null, claims: [] };
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function loadCompilation(root) {
|
|
87
|
+
const compilationPath = path.join(root, 'compilation.json');
|
|
88
|
+
vlog('read', compilationPath);
|
|
89
|
+
if (!fs.existsSync(compilationPath)) return null;
|
|
90
|
+
try {
|
|
91
|
+
return JSON.parse(fs.readFileSync(compilationPath, 'utf8'));
|
|
92
|
+
} catch {
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function loadSprints(root) {
|
|
98
|
+
try {
|
|
99
|
+
const compilerDir = path.join(__dirname, '..', 'compiler');
|
|
100
|
+
const mod = path.join(compilerDir, 'detect-sprints.js');
|
|
101
|
+
if (!fs.existsSync(mod)) return { sprints: [], active: null };
|
|
102
|
+
|
|
103
|
+
const result = execFileSync('node', [mod, '--json', '--root', root], {
|
|
104
|
+
timeout: 10000, stdio: ['ignore', 'pipe', 'pipe'],
|
|
105
|
+
});
|
|
106
|
+
const data = JSON.parse(result.toString());
|
|
107
|
+
return {
|
|
108
|
+
sprints: data.sprints || [],
|
|
109
|
+
active: (data.sprints || []).find(s => s.status === 'active') || null,
|
|
110
|
+
};
|
|
111
|
+
} catch {
|
|
112
|
+
return { sprints: [], active: null };
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function runCompile(root) {
|
|
117
|
+
try {
|
|
118
|
+
const compiler = path.join(__dirname, '..', 'compiler', 'wheat-compiler.js');
|
|
119
|
+
if (!fs.existsSync(compiler)) return null;
|
|
120
|
+
execFileSync('node', [compiler, '--root', root], {
|
|
121
|
+
timeout: 30000, stdio: ['ignore', 'pipe', 'pipe'],
|
|
122
|
+
cwd: root,
|
|
123
|
+
});
|
|
124
|
+
return loadCompilation(root);
|
|
125
|
+
} catch {
|
|
126
|
+
return loadCompilation(root);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function refreshState(viewRoot, scanRoot) {
|
|
131
|
+
const sr = scanRoot || viewRoot;
|
|
132
|
+
const sprintData = loadSprints(sr);
|
|
133
|
+
state.sprints = sprintData.sprints;
|
|
134
|
+
state.activeSprint = sprintData.active;
|
|
135
|
+
|
|
136
|
+
if (viewRoot === '__all') {
|
|
137
|
+
// Merge claims from all sprints
|
|
138
|
+
let allClaims = [];
|
|
139
|
+
let meta = null;
|
|
140
|
+
for (const s of sprintData.sprints) {
|
|
141
|
+
const sprintRoot = path.resolve(sr, s.path);
|
|
142
|
+
const d = loadClaims(sprintRoot);
|
|
143
|
+
if (s.status === 'active' && !meta) meta = d.meta;
|
|
144
|
+
for (const c of d.claims) {
|
|
145
|
+
c._sprint = s.name;
|
|
146
|
+
allClaims.push(c);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
state.meta = meta;
|
|
150
|
+
state.claims = allClaims;
|
|
151
|
+
state.compilation = loadCompilation(sr);
|
|
152
|
+
} else {
|
|
153
|
+
const claimsData = loadClaims(viewRoot);
|
|
154
|
+
state.meta = claimsData.meta;
|
|
155
|
+
state.claims = claimsData.claims;
|
|
156
|
+
state.compilation = loadCompilation(viewRoot);
|
|
157
|
+
}
|
|
158
|
+
broadcast({ type: 'state', data: state });
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// ── MIME types ────────────────────────────────────────────────────────────────
|
|
162
|
+
|
|
163
|
+
const MIME = {
|
|
164
|
+
'.html': 'text/html; charset=utf-8',
|
|
165
|
+
'.css': 'text/css; charset=utf-8',
|
|
166
|
+
'.js': 'application/javascript; charset=utf-8',
|
|
167
|
+
'.json': 'application/json; charset=utf-8',
|
|
168
|
+
'.svg': 'image/svg+xml',
|
|
169
|
+
'.png': 'image/png',
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
// ── HTTP server ───────────────────────────────────────────────────────────────
|
|
173
|
+
|
|
174
|
+
function createWheatServer(root, port, corsOrigin) {
|
|
175
|
+
let activeRoot = root;
|
|
176
|
+
const server = http.createServer((req, res) => {
|
|
177
|
+
const url = new URL(req.url, `http://localhost:${port}`);
|
|
178
|
+
|
|
179
|
+
// CORS (only when --cors is passed)
|
|
180
|
+
if (corsOrigin) {
|
|
181
|
+
res.setHeader('Access-Control-Allow-Origin', corsOrigin);
|
|
182
|
+
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
|
|
183
|
+
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
if (req.method === 'OPTIONS' && corsOrigin) {
|
|
187
|
+
res.writeHead(204); res.end(); return;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
vlog('request', req.method, url.pathname);
|
|
191
|
+
|
|
192
|
+
// ── API: docs ──
|
|
193
|
+
if (req.method === 'GET' && url.pathname === '/api/docs') {
|
|
194
|
+
const html = `<!DOCTYPE html><html><head><title>wheat API</title>
|
|
195
|
+
<style>body{font-family:system-ui;background:#0a0e1a;color:#e8ecf1;max-width:800px;margin:40px auto;padding:0 20px}
|
|
196
|
+
table{width:100%;border-collapse:collapse}th,td{padding:8px 12px;border-bottom:1px solid #1e293b;text-align:left}
|
|
197
|
+
th{color:#9ca3af}code{background:#1e293b;padding:2px 6px;border-radius:4px;font-size:13px}</style></head>
|
|
198
|
+
<body><h1>wheat API</h1><p>${ROUTES.length} endpoints</p>
|
|
199
|
+
<table><tr><th>Method</th><th>Path</th><th>Description</th></tr>
|
|
200
|
+
${ROUTES.map(r => '<tr><td><code>'+r.method+'</code></td><td><code>'+r.path+'</code></td><td>'+r.description+'</td></tr>').join('')}
|
|
201
|
+
</table></body></html>`;
|
|
202
|
+
res.writeHead(200, { 'Content-Type': 'text/html' });
|
|
203
|
+
res.end(html);
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// ── SSE ──
|
|
208
|
+
if (req.method === 'GET' && url.pathname === '/events') {
|
|
209
|
+
res.writeHead(200, {
|
|
210
|
+
'Content-Type': 'text/event-stream',
|
|
211
|
+
'Cache-Control': 'no-cache',
|
|
212
|
+
'Connection': 'keep-alive',
|
|
213
|
+
});
|
|
214
|
+
res.write(`data: ${JSON.stringify({ type: 'state', data: state })}\n\n`);
|
|
215
|
+
const heartbeat = setInterval(() => {
|
|
216
|
+
try { res.write(': heartbeat\n\n'); } catch { clearInterval(heartbeat); }
|
|
217
|
+
}, 15000);
|
|
218
|
+
sseClients.add(res);
|
|
219
|
+
vlog('sse', `client connected (${sseClients.size} total)`);
|
|
220
|
+
req.on('close', () => { clearInterval(heartbeat); sseClients.delete(res); vlog('sse', `client disconnected (${sseClients.size} total)`); });
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// ── API: state ──
|
|
225
|
+
if (req.method === 'GET' && url.pathname === '/api/state') {
|
|
226
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
227
|
+
res.end(JSON.stringify(state));
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// ── API: claims (with optional filters) ──
|
|
232
|
+
if (req.method === 'GET' && url.pathname === '/api/claims') {
|
|
233
|
+
let claims = state.claims;
|
|
234
|
+
const topic = url.searchParams.get('topic');
|
|
235
|
+
const evidence = url.searchParams.get('evidence');
|
|
236
|
+
const type = url.searchParams.get('type');
|
|
237
|
+
const status = url.searchParams.get('status');
|
|
238
|
+
if (topic) claims = claims.filter(c => c.topic === topic);
|
|
239
|
+
if (evidence) claims = claims.filter(c => c.evidence === evidence);
|
|
240
|
+
if (type) claims = claims.filter(c => c.type === type);
|
|
241
|
+
if (status) claims = claims.filter(c => c.status === status);
|
|
242
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
243
|
+
res.end(JSON.stringify(claims));
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// ── API: coverage ──
|
|
248
|
+
if (req.method === 'GET' && url.pathname === '/api/coverage') {
|
|
249
|
+
const coverage = state.compilation?.coverage || {};
|
|
250
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
251
|
+
res.end(JSON.stringify(coverage));
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// ── API: compilation ──
|
|
256
|
+
if (req.method === 'GET' && url.pathname === '/api/compilation') {
|
|
257
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
258
|
+
res.end(JSON.stringify(state.compilation));
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// ── API: compile (trigger recompilation) ──
|
|
263
|
+
if (req.method === 'POST' && url.pathname === '/api/compile') {
|
|
264
|
+
const compileRoot = activeRoot === '__all' ? root : activeRoot;
|
|
265
|
+
state.compilation = runCompile(compileRoot);
|
|
266
|
+
refreshState(activeRoot, root);
|
|
267
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
268
|
+
res.end(JSON.stringify(state));
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// ── API: switch sprint ──
|
|
273
|
+
if (req.method === 'POST' && url.pathname === '/api/switch') {
|
|
274
|
+
let body = '';
|
|
275
|
+
req.on('data', chunk => body += chunk);
|
|
276
|
+
req.on('end', () => {
|
|
277
|
+
try {
|
|
278
|
+
const { sprint } = JSON.parse(body);
|
|
279
|
+
if (sprint === '__all') {
|
|
280
|
+
activeRoot = '__all';
|
|
281
|
+
} else if (!sprint) {
|
|
282
|
+
activeRoot = root;
|
|
283
|
+
} else {
|
|
284
|
+
const s = state.sprints.find(sp => sp.name === sprint);
|
|
285
|
+
if (s) {
|
|
286
|
+
activeRoot = path.resolve(root, s.path);
|
|
287
|
+
} else {
|
|
288
|
+
activeRoot = root;
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
refreshState(activeRoot, root);
|
|
292
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
293
|
+
res.end(JSON.stringify(state));
|
|
294
|
+
} catch {
|
|
295
|
+
res.writeHead(400); res.end('bad request');
|
|
296
|
+
}
|
|
297
|
+
});
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// ── Static files ──
|
|
302
|
+
let filePath = url.pathname === '/' ? '/index.html' : url.pathname;
|
|
303
|
+
const resolved = path.resolve(PUBLIC_DIR, '.' + filePath);
|
|
304
|
+
if (!resolved.startsWith(PUBLIC_DIR)) {
|
|
305
|
+
res.writeHead(403); res.end('forbidden'); return;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
if (fs.existsSync(resolved) && fs.statSync(resolved).isFile()) {
|
|
309
|
+
const ext = path.extname(resolved);
|
|
310
|
+
res.writeHead(200, { 'Content-Type': MIME[ext] || 'application/octet-stream' });
|
|
311
|
+
res.end(fs.readFileSync(resolved));
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
res.writeHead(404); res.end('not found');
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
// ── File watching ──
|
|
319
|
+
const claimsPath = path.join(root, 'claims.json');
|
|
320
|
+
const compilationPath = path.join(root, 'compilation.json');
|
|
321
|
+
if (fs.existsSync(claimsPath)) {
|
|
322
|
+
fs.watchFile(claimsPath, { interval: 2000 }, () => refreshState(activeRoot, root));
|
|
323
|
+
}
|
|
324
|
+
if (fs.existsSync(compilationPath)) {
|
|
325
|
+
fs.watchFile(compilationPath, { interval: 2000 }, () => refreshState(activeRoot, root));
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// ── Start ──
|
|
329
|
+
refreshState(root, root);
|
|
330
|
+
|
|
331
|
+
// ── Graceful shutdown ──
|
|
332
|
+
const shutdown = (signal) => {
|
|
333
|
+
console.log(`\nwheat: ${signal} received, shutting down...`);
|
|
334
|
+
for (const res of sseClients) { try { res.end(); } catch {} }
|
|
335
|
+
sseClients.clear();
|
|
336
|
+
server.close(() => process.exit(0));
|
|
337
|
+
setTimeout(() => process.exit(1), 5000);
|
|
338
|
+
};
|
|
339
|
+
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
|
340
|
+
process.on('SIGINT', () => shutdown('SIGINT'));
|
|
341
|
+
|
|
342
|
+
server.on('error', (err) => {
|
|
343
|
+
if (err.code === 'EADDRINUSE') {
|
|
344
|
+
console.error(`\nwheat: port ${port} is already in use.`);
|
|
345
|
+
console.error(` Try: wheat serve --port ${Number(port) + 1}`);
|
|
346
|
+
console.error(` Or stop the process using port ${port}.\n`);
|
|
347
|
+
process.exit(1);
|
|
348
|
+
}
|
|
349
|
+
throw err;
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
server.listen(port, '127.0.0.1', () => {
|
|
353
|
+
vlog('listen', `port=${port}`, `root=${root}`);
|
|
354
|
+
console.log(`wheat: serving on http://localhost:${port}`);
|
|
355
|
+
console.log(` claims: ${state.claims.length} loaded`);
|
|
356
|
+
console.log(` compilation: ${state.compilation ? state.compilation.status : 'not found'}`);
|
|
357
|
+
console.log(` sprints: ${state.sprints.length} detected`);
|
|
358
|
+
if (state.activeSprint) {
|
|
359
|
+
console.log(` active: ${state.activeSprint.name} (${state.activeSprint.phase})`);
|
|
360
|
+
}
|
|
361
|
+
console.log(` root: ${root}`);
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
return server;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// ── CLI entrypoint ────────────────────────────────────────────────────────────
|
|
368
|
+
|
|
369
|
+
export async function run(targetDir, subArgs) {
|
|
370
|
+
let port = 9092;
|
|
371
|
+
const portIdx = subArgs.indexOf('--port');
|
|
372
|
+
if (portIdx !== -1 && subArgs[portIdx + 1]) {
|
|
373
|
+
port = parseInt(subArgs[portIdx + 1], 10);
|
|
374
|
+
}
|
|
375
|
+
const corsIdx = subArgs.indexOf('--cors');
|
|
376
|
+
const corsOrigin = (corsIdx !== -1 && subArgs[corsIdx + 1]) ? subArgs[corsIdx + 1] : null;
|
|
377
|
+
|
|
378
|
+
// Walk up to find project root if no claims.json in targetDir
|
|
379
|
+
let root = targetDir;
|
|
380
|
+
if (!fs.existsSync(path.join(root, 'claims.json'))) {
|
|
381
|
+
let dir = path.resolve(root);
|
|
382
|
+
for (let i = 0; i < 5; i++) {
|
|
383
|
+
const parent = path.dirname(dir);
|
|
384
|
+
if (parent === dir) break;
|
|
385
|
+
dir = parent;
|
|
386
|
+
if (fs.existsSync(path.join(dir, 'claims.json'))) { root = dir; break; }
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
createWheatServer(root, port, corsOrigin);
|
|
391
|
+
}
|
package/lib/stats.js
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* wheat stats — Local sprint statistics (self-inspection only)
|
|
3
|
+
*
|
|
4
|
+
* Scans the repo for all sprints (via detect-sprints logic) and prints
|
|
5
|
+
* aggregate statistics: claim counts by phase/type/evidence, sprint count,
|
|
6
|
+
* and repo age.
|
|
7
|
+
*
|
|
8
|
+
* LOCAL only — no phone-home, no analytics, no network calls.
|
|
9
|
+
* Zero npm dependencies.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import fs from 'fs';
|
|
13
|
+
import path from 'path';
|
|
14
|
+
|
|
15
|
+
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
|
16
|
+
|
|
17
|
+
/** Safely parse JSON from a file path; returns null on failure. */
|
|
18
|
+
function loadJSON(filePath) {
|
|
19
|
+
try {
|
|
20
|
+
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
|
21
|
+
} catch {
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Find all claims.json files in repo (root + examples/). */
|
|
27
|
+
function findAllClaims(dir) {
|
|
28
|
+
const results = [];
|
|
29
|
+
|
|
30
|
+
// Root-level claims.json
|
|
31
|
+
const rootClaims = path.join(dir, 'claims.json');
|
|
32
|
+
if (fs.existsSync(rootClaims)) {
|
|
33
|
+
results.push({ path: rootClaims, label: '.' });
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// examples/<name>/claims.json
|
|
37
|
+
const examplesDir = path.join(dir, 'examples');
|
|
38
|
+
if (fs.existsSync(examplesDir)) {
|
|
39
|
+
try {
|
|
40
|
+
for (const entry of fs.readdirSync(examplesDir, { withFileTypes: true })) {
|
|
41
|
+
if (!entry.isDirectory()) continue;
|
|
42
|
+
const claimsPath = path.join(examplesDir, entry.name, 'claims.json');
|
|
43
|
+
if (fs.existsSync(claimsPath)) {
|
|
44
|
+
results.push({ path: claimsPath, label: `examples/${entry.name}` });
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
} catch { /* skip if unreadable */ }
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return results;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// ─── Main ─────────────────────────────────────────────────────────────────────
|
|
54
|
+
|
|
55
|
+
export async function run(dir, _args) {
|
|
56
|
+
const sprintFiles = findAllClaims(dir);
|
|
57
|
+
|
|
58
|
+
if (sprintFiles.length === 0) {
|
|
59
|
+
console.log();
|
|
60
|
+
console.log(' No sprints found in this directory.');
|
|
61
|
+
console.log(' Run "wheat init" to start a research sprint.');
|
|
62
|
+
console.log();
|
|
63
|
+
process.exit(0);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Aggregate all claims across sprints
|
|
67
|
+
let totalClaims = 0;
|
|
68
|
+
let earliestDate = null;
|
|
69
|
+
const byPhase = {};
|
|
70
|
+
const byType = {};
|
|
71
|
+
const byEvidence = {};
|
|
72
|
+
const sprintSummaries = [];
|
|
73
|
+
|
|
74
|
+
for (const sf of sprintFiles) {
|
|
75
|
+
const data = loadJSON(sf.path);
|
|
76
|
+
if (!data) continue;
|
|
77
|
+
|
|
78
|
+
const meta = data.meta || {};
|
|
79
|
+
const claims = data.claims || [];
|
|
80
|
+
const active = claims.filter(c => c.status === 'active');
|
|
81
|
+
|
|
82
|
+
totalClaims += claims.length;
|
|
83
|
+
|
|
84
|
+
// Track earliest sprint initiation
|
|
85
|
+
if (meta.initiated) {
|
|
86
|
+
const d = new Date(meta.initiated);
|
|
87
|
+
if (!earliestDate || d < earliestDate) earliestDate = d;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Accumulate by phase_added
|
|
91
|
+
for (const c of claims) {
|
|
92
|
+
const phase = c.phase_added || 'unknown';
|
|
93
|
+
byPhase[phase] = (byPhase[phase] || 0) + 1;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Accumulate by type
|
|
97
|
+
for (const c of claims) {
|
|
98
|
+
byType[c.type || 'unknown'] = (byType[c.type || 'unknown'] || 0) + 1;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Accumulate by evidence tier
|
|
102
|
+
for (const c of claims) {
|
|
103
|
+
byEvidence[c.evidence || 'unknown'] = (byEvidence[c.evidence || 'unknown'] || 0) + 1;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
sprintSummaries.push({
|
|
107
|
+
label: sf.label,
|
|
108
|
+
question: (meta.question || '').slice(0, 60),
|
|
109
|
+
phase: meta.phase || 'unknown',
|
|
110
|
+
claims: claims.length,
|
|
111
|
+
active: active.length,
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// ─── Print ────────────────────────────────────────────────────────────────
|
|
116
|
+
|
|
117
|
+
console.log();
|
|
118
|
+
console.log(' \x1b[1mwheat stats\x1b[0m — local sprint statistics');
|
|
119
|
+
console.log(` ${'─'.repeat(50)}`);
|
|
120
|
+
console.log();
|
|
121
|
+
|
|
122
|
+
// Sprint count
|
|
123
|
+
console.log(` Sprints: ${sprintFiles.length}`);
|
|
124
|
+
console.log(` Claims: ${totalClaims} total`);
|
|
125
|
+
|
|
126
|
+
// Age
|
|
127
|
+
if (earliestDate) {
|
|
128
|
+
const days = Math.floor((Date.now() - earliestDate.getTime()) / 86400000);
|
|
129
|
+
console.log(` Age: ${days} days since first sprint (${earliestDate.toISOString().slice(0, 10)})`);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
console.log();
|
|
133
|
+
|
|
134
|
+
// By phase
|
|
135
|
+
console.log(' \x1b[1mClaims by phase:\x1b[0m');
|
|
136
|
+
const phaseOrder = ['define', 'research', 'prototype', 'evaluate', 'feedback'];
|
|
137
|
+
const allPhases = [...new Set([...phaseOrder, ...Object.keys(byPhase)])];
|
|
138
|
+
for (const p of allPhases) {
|
|
139
|
+
if (byPhase[p]) {
|
|
140
|
+
console.log(` ${p.padEnd(12)} ${byPhase[p]}`);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
console.log();
|
|
145
|
+
|
|
146
|
+
// By type
|
|
147
|
+
console.log(' \x1b[1mClaims by type:\x1b[0m');
|
|
148
|
+
const typeOrder = ['constraint', 'factual', 'estimate', 'risk', 'recommendation', 'feedback'];
|
|
149
|
+
const allTypes = [...new Set([...typeOrder, ...Object.keys(byType)])];
|
|
150
|
+
for (const t of allTypes) {
|
|
151
|
+
if (byType[t]) {
|
|
152
|
+
console.log(` ${t.padEnd(16)} ${byType[t]}`);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
console.log();
|
|
157
|
+
|
|
158
|
+
// By evidence tier
|
|
159
|
+
console.log(' \x1b[1mClaims by evidence:\x1b[0m');
|
|
160
|
+
const evidenceOrder = ['stated', 'web', 'documented', 'tested', 'production'];
|
|
161
|
+
const allEvidence = [...new Set([...evidenceOrder, ...Object.keys(byEvidence)])];
|
|
162
|
+
for (const e of allEvidence) {
|
|
163
|
+
if (byEvidence[e]) {
|
|
164
|
+
console.log(` ${e.padEnd(14)} ${byEvidence[e]}`);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
console.log();
|
|
169
|
+
|
|
170
|
+
// Per-sprint table
|
|
171
|
+
if (sprintFiles.length > 1) {
|
|
172
|
+
console.log(' \x1b[1mPer-sprint breakdown:\x1b[0m');
|
|
173
|
+
for (const s of sprintSummaries) {
|
|
174
|
+
console.log(` ${s.label.padEnd(30)} ${String(s.claims).padStart(4)} claims (${s.active} active) [${s.phase}]`);
|
|
175
|
+
if (s.question) {
|
|
176
|
+
console.log(` "${s.question}${s.question.length >= 60 ? '...' : ''}"`);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
console.log();
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
console.log(' No data leaves your machine. This is local self-inspection only.');
|
|
183
|
+
console.log();
|
|
184
|
+
}
|