@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.
Files changed (40) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +136 -0
  3. package/bin/wheat.js +193 -0
  4. package/compiler/detect-sprints.js +319 -0
  5. package/compiler/generate-manifest.js +280 -0
  6. package/compiler/wheat-compiler.js +1229 -0
  7. package/lib/compiler.js +35 -0
  8. package/lib/connect.js +418 -0
  9. package/lib/disconnect.js +188 -0
  10. package/lib/guard.js +151 -0
  11. package/lib/index.js +14 -0
  12. package/lib/init.js +457 -0
  13. package/lib/install-prompt.js +186 -0
  14. package/lib/quickstart.js +276 -0
  15. package/lib/serve-mcp.js +509 -0
  16. package/lib/server.js +391 -0
  17. package/lib/stats.js +184 -0
  18. package/lib/status.js +135 -0
  19. package/lib/update.js +71 -0
  20. package/package.json +53 -0
  21. package/public/index.html +1798 -0
  22. package/templates/claude.md +122 -0
  23. package/templates/commands/blind-spot.md +47 -0
  24. package/templates/commands/brief.md +73 -0
  25. package/templates/commands/calibrate.md +39 -0
  26. package/templates/commands/challenge.md +72 -0
  27. package/templates/commands/connect.md +104 -0
  28. package/templates/commands/evaluate.md +80 -0
  29. package/templates/commands/feedback.md +60 -0
  30. package/templates/commands/handoff.md +53 -0
  31. package/templates/commands/init.md +68 -0
  32. package/templates/commands/merge.md +51 -0
  33. package/templates/commands/present.md +52 -0
  34. package/templates/commands/prototype.md +68 -0
  35. package/templates/commands/replay.md +61 -0
  36. package/templates/commands/research.md +73 -0
  37. package/templates/commands/resolve.md +42 -0
  38. package/templates/commands/status.md +56 -0
  39. package/templates/commands/witness.md +79 -0
  40. 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
+ }