envseed 0.1.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.
@@ -0,0 +1,897 @@
1
+ #!/usr/bin/env node
2
+
3
+ import fs from 'node:fs';
4
+ import path from 'node:path';
5
+ import https from 'node:https';
6
+
7
+ const DATA_DIR = path.join(process.env.HOME, '.propensity-monitor', 'data');
8
+ const INSTALL_DIR = path.join(process.env.HOME, '.propensity-monitor');
9
+ const CLAUDE_SETTINGS = path.join(process.env.HOME, '.claude', 'settings.json');
10
+
11
+ // ── ANSI helpers ────────────────────────────────────────────────────────────
12
+
13
+ const C = {
14
+ reset: '\x1b[0m',
15
+ bold: '\x1b[1m',
16
+ dim: '\x1b[2m',
17
+ red: '\x1b[31m',
18
+ yellow: '\x1b[33m',
19
+ green: '\x1b[32m',
20
+ cyan: '\x1b[36m',
21
+ gray: '\x1b[90m',
22
+ };
23
+
24
+ function colorForCategory(cat) {
25
+ if (cat === 'critical') return C.red;
26
+ if (cat === 'high') return C.yellow;
27
+ if (cat === 'medium') return C.cyan;
28
+ return C.dim;
29
+ }
30
+
31
+ // ── Helpers ─────────────────────────────────────────────────────────────────
32
+
33
+ function today() {
34
+ return new Date().toISOString().split('T')[0];
35
+ }
36
+
37
+ function readJsonl(filePath) {
38
+ if (!fs.existsSync(filePath)) return [];
39
+ return fs.readFileSync(filePath, 'utf8')
40
+ .split('\n')
41
+ .filter(line => line.trim())
42
+ .map(line => { try { return JSON.parse(line); } catch { return null; } })
43
+ .filter(Boolean);
44
+ }
45
+
46
+ function readJson(filePath) {
47
+ try { return JSON.parse(fs.readFileSync(filePath, 'utf8')); } catch { return null; }
48
+ }
49
+
50
+ function parseArgs(args) {
51
+ const opts = {};
52
+ for (let i = 0; i < args.length; i++) {
53
+ if (args[i].startsWith('--')) {
54
+ const key = args[i].replace(/^--/, '');
55
+ const next = args[i + 1];
56
+ if (next && !next.startsWith('--')) {
57
+ opts[key] = next;
58
+ i++;
59
+ } else {
60
+ opts[key] = true;
61
+ }
62
+ } else {
63
+ opts._positional = opts._positional || [];
64
+ opts._positional.push(args[i]);
65
+ }
66
+ }
67
+ return opts;
68
+ }
69
+
70
+ function shortTime(ts) {
71
+ return ts.replace('T', ' ').replace(/\.\d+Z$/, '');
72
+ }
73
+
74
+ function shortSession(id) {
75
+ return id ? id.substring(0, 12) : 'unknown';
76
+ }
77
+
78
+ // ── Commands ────────────────────────────────────────────────────────────────
79
+
80
+ function showAlerts(args) {
81
+ const opts = parseArgs(args);
82
+ const date = opts.date || today();
83
+ const last = parseInt(opts.last || '20', 10);
84
+
85
+ const file = path.join(DATA_DIR, 'alerts', `${date}.jsonl`);
86
+ const events = readJsonl(file);
87
+
88
+ if (events.length === 0) {
89
+ console.log(`No alerts for ${date}.`);
90
+ return;
91
+ }
92
+
93
+ const shown = events.slice(-last);
94
+ console.log(`${C.bold}Alerts for ${date}${C.reset} (showing ${shown.length} of ${events.length}):\n`);
95
+
96
+ for (const e of shown) {
97
+ const color = colorForCategory(e.risk?.category);
98
+ const cat = (e.risk?.category || 'none').toUpperCase().padEnd(8);
99
+ const time = shortTime(e.timestamp);
100
+ const sid = shortSession(e.session_id);
101
+ const tool = e.tool_name || e.hook_event_name || '';
102
+
103
+ console.log(`${color}${C.bold}${cat}${C.reset} ${C.dim}${time}${C.reset} ${C.cyan}${sid}${C.reset} ${tool}`);
104
+
105
+ for (const f of (e.risk?.factors || [])) {
106
+ if (f.score >= 2) {
107
+ console.log(` ${color}${f.name}${C.reset}: ${f.detail}`);
108
+ }
109
+ }
110
+
111
+ if (e.session_state) {
112
+ const ss = e.session_state;
113
+ console.log(` ${C.dim}Session: ${ss.toolCallCount} tools, ${ss.toolCallsSinceLastPrompt} since last prompt${C.reset}`);
114
+ }
115
+ console.log();
116
+ }
117
+ }
118
+
119
+ function showEvents(args) {
120
+ const opts = parseArgs(args);
121
+ const date = opts.date || today();
122
+ const last = parseInt(opts.last || '50', 10);
123
+ const minRisk = parseInt(opts['min-risk'] || '0', 10);
124
+
125
+ const file = path.join(DATA_DIR, 'events', `${date}.jsonl`);
126
+ let events = readJsonl(file);
127
+
128
+ if (minRisk > 0) {
129
+ events = events.filter(e => (e.risk?.score || 0) >= minRisk);
130
+ }
131
+
132
+ if (events.length === 0) {
133
+ console.log(`No events for ${date}${minRisk > 0 ? ` with risk >= ${minRisk}` : ''}.`);
134
+ return;
135
+ }
136
+
137
+ const shown = events.slice(-last);
138
+ console.log(`${C.bold}Events for ${date}${C.reset} (${shown.length} of ${events.length}):\n`);
139
+
140
+ for (const e of shown) {
141
+ const color = colorForCategory(e.risk?.category);
142
+ const score = e.risk?.score || 0;
143
+ const scoreStr = score > 0 ? `${color}[${score}]${C.reset} ` : `${C.dim}[0]${C.reset} `;
144
+ const time = shortTime(e.timestamp);
145
+ const tool = e.tool_name || e.hook_event_name || '';
146
+ const sid = shortSession(e.session_id);
147
+
148
+ let detail = '';
149
+ if (e.risk?.factors?.length > 0) {
150
+ detail = ` ${C.dim}(${e.risk.factors.map(f => f.name).join(', ')})${C.reset}`;
151
+ }
152
+
153
+ console.log(`${scoreStr}${C.dim}${time}${C.reset} ${C.cyan}${sid}${C.reset} ${tool}${detail}`);
154
+ }
155
+ }
156
+
157
+ function showSessions(args) {
158
+ const opts = parseArgs(args);
159
+ const minRisk = parseInt(opts['min-risk'] || '0', 10);
160
+
161
+ const index = readJson(path.join(DATA_DIR, 'index.json')) || {};
162
+ let entries = Object.entries(index);
163
+
164
+ if (minRisk > 0) {
165
+ entries = entries.filter(([, v]) => (v.maxRisk || 0) >= minRisk);
166
+ }
167
+
168
+ entries.sort((a, b) => (b[1].date || '').localeCompare(a[1].date || ''));
169
+
170
+ if (entries.length === 0) {
171
+ console.log('No sessions recorded yet.');
172
+ return;
173
+ }
174
+
175
+ console.log(`${C.bold}${'SESSION'.padEnd(14)} ${'DATE'.padEnd(12)} ${'RISK'.padEnd(6)} ${'TOOLS'.padEnd(7)} ${'ALERTS'.padEnd(8)} CWD${C.reset}`);
176
+
177
+ for (const [id, v] of entries) {
178
+ const color = v.maxRisk >= 3 ? C.red : v.maxRisk >= 2 ? C.yellow : v.maxRisk >= 1 ? C.cyan : C.dim;
179
+ const sid = shortSession(id);
180
+ const cwdShort = (v.cwd || '').replace(process.env.HOME, '~');
181
+
182
+ console.log(
183
+ `${C.cyan}${sid}${C.reset} ${C.dim}${(v.date || '').padEnd(12)}${C.reset} ` +
184
+ `${color}${String(v.maxRisk || 0).padEnd(6)}${C.reset} ` +
185
+ `${String(v.toolCalls || 0).padEnd(7)} ` +
186
+ `${v.alerts > 0 ? C.red : C.dim}${String(v.alerts || 0).padEnd(8)}${C.reset} ` +
187
+ `${C.dim}${cwdShort}${C.reset}`
188
+ );
189
+ }
190
+ }
191
+
192
+ function showSession(args) {
193
+ const opts = parseArgs(args);
194
+ const sessionId = opts._positional?.[0];
195
+
196
+ if (!sessionId) {
197
+ console.error('Usage: propensity-monitor session <session-id>');
198
+ process.exit(1);
199
+ }
200
+
201
+ // Find full session ID from prefix
202
+ const index = readJson(path.join(DATA_DIR, 'index.json')) || {};
203
+ const fullId = Object.keys(index).find(k => k.startsWith(sessionId));
204
+
205
+ if (!fullId) {
206
+ console.error(`Session not found: ${sessionId}`);
207
+ process.exit(1);
208
+ }
209
+
210
+ const sessionData = readJson(path.join(DATA_DIR, 'sessions', `${fullId}.json`));
211
+
212
+ if (sessionData) {
213
+ console.log(`${C.bold}Session: ${fullId}${C.reset}`);
214
+ console.log(` Started: ${sessionData.started_at || 'unknown'}`);
215
+ console.log(` CWD: ${sessionData.cwd || 'unknown'}`);
216
+ console.log(` Tool calls: ${sessionData.toolCallCount}`);
217
+ console.log(` Prompts: ${sessionData.userPromptCount}`);
218
+ console.log(` Max risk: ${sessionData.maxRiskScore}`);
219
+ console.log(` Alerts: ${sessionData.alertCount}`);
220
+
221
+ if (sessionData.riskFactorCounts && Object.keys(sessionData.riskFactorCounts).length > 0) {
222
+ console.log(`\n ${C.bold}Risk factors:${C.reset}`);
223
+ const sorted = Object.entries(sessionData.riskFactorCounts).sort((a, b) => b[1] - a[1]);
224
+ for (const [name, count] of sorted) {
225
+ console.log(` ${name}: ${count}`);
226
+ }
227
+ }
228
+
229
+ if (sessionData.toolNameCounts && Object.keys(sessionData.toolNameCounts).length > 0) {
230
+ console.log(`\n ${C.bold}Tool usage:${C.reset}`);
231
+ const sorted = Object.entries(sessionData.toolNameCounts).sort((a, b) => b[1] - a[1]);
232
+ for (const [name, count] of sorted) {
233
+ console.log(` ${name}: ${count}`);
234
+ }
235
+ }
236
+ }
237
+
238
+ // Show events timeline
239
+ const date = index[fullId]?.date;
240
+ if (date) {
241
+ const file = path.join(DATA_DIR, 'events', `${date}.jsonl`);
242
+ const events = readJsonl(file).filter(e => e.session_id === fullId);
243
+
244
+ if (events.length > 0) {
245
+ console.log(`\n ${C.bold}Timeline (${events.length} events):${C.reset}\n`);
246
+ for (const e of events) {
247
+ const color = colorForCategory(e.risk?.category);
248
+ const score = e.risk?.score || 0;
249
+ const time = shortTime(e.timestamp).split(' ')[1] || '';
250
+ const tool = e.tool_name || e.hook_event_name || '';
251
+ const factors = (e.risk?.factors || []).map(f => f.name).join(', ');
252
+
253
+ console.log(
254
+ ` ${C.dim}${time}${C.reset} ${score > 0 ? color : C.dim}[${score}]${C.reset} ${tool}` +
255
+ (factors ? ` ${C.dim}(${factors})${C.reset}` : '')
256
+ );
257
+ }
258
+ }
259
+ }
260
+ }
261
+
262
+ function tailEvents(args) {
263
+ const opts = parseArgs(args);
264
+ const minRisk = parseInt(opts['min-risk'] || '0', 10);
265
+ const file = path.join(DATA_DIR, 'events', `${today()}.jsonl`);
266
+
267
+ console.log(`${C.bold}Tailing events...${C.reset} (Ctrl+C to stop)\n`);
268
+
269
+ let lastSize = 0;
270
+ try { lastSize = fs.statSync(file).size; } catch {}
271
+
272
+ const check = () => {
273
+ try {
274
+ const stat = fs.statSync(file);
275
+ if (stat.size > lastSize) {
276
+ const buf = Buffer.alloc(stat.size - lastSize);
277
+ const fd = fs.openSync(file, 'r');
278
+ fs.readSync(fd, buf, 0, buf.length, lastSize);
279
+ fs.closeSync(fd);
280
+ lastSize = stat.size;
281
+
282
+ const lines = buf.toString('utf8').split('\n').filter(l => l.trim());
283
+ for (const line of lines) {
284
+ try {
285
+ const e = JSON.parse(line);
286
+ if ((e.risk?.score || 0) < minRisk) continue;
287
+
288
+ const color = colorForCategory(e.risk?.category);
289
+ const score = e.risk?.score || 0;
290
+ const time = shortTime(e.timestamp);
291
+ const tool = e.tool_name || e.hook_event_name || '';
292
+ const sid = shortSession(e.session_id);
293
+ const factors = (e.risk?.factors || []).map(f => f.name).join(', ');
294
+
295
+ console.log(
296
+ `${score > 0 ? color : C.dim}[${score}]${C.reset} ${C.dim}${time}${C.reset} ` +
297
+ `${C.cyan}${sid}${C.reset} ${tool}` +
298
+ (factors ? ` ${C.dim}(${factors})${C.reset}` : '')
299
+ );
300
+ } catch {}
301
+ }
302
+ }
303
+ } catch {}
304
+ };
305
+
306
+ setInterval(check, 500);
307
+ }
308
+
309
+ function showStats(args) {
310
+ const opts = parseArgs(args);
311
+ const date = opts.date;
312
+
313
+ // Collect events
314
+ const eventsDir = path.join(DATA_DIR, 'events');
315
+ let allEvents = [];
316
+
317
+ if (date) {
318
+ allEvents = readJsonl(path.join(eventsDir, `${date}.jsonl`));
319
+ } else {
320
+ // All dates
321
+ try {
322
+ const files = fs.readdirSync(eventsDir).filter(f => f.endsWith('.jsonl')).sort();
323
+ for (const f of files) {
324
+ allEvents.push(...readJsonl(path.join(eventsDir, f)));
325
+ }
326
+ } catch {}
327
+ }
328
+
329
+ if (allEvents.length === 0) {
330
+ console.log('No events recorded yet.');
331
+ return;
332
+ }
333
+
334
+ // Compute stats
335
+ const sessions = new Set(allEvents.map(e => e.session_id).filter(Boolean));
336
+ const tools = {};
337
+ const riskDist = { 0: 0, 1: 0, 2: 0, 3: 0 };
338
+ const factorCounts = {};
339
+
340
+ for (const e of allEvents) {
341
+ if (e.tool_name) tools[e.tool_name] = (tools[e.tool_name] || 0) + 1;
342
+ const score = e.risk?.score || 0;
343
+ riskDist[score] = (riskDist[score] || 0) + 1;
344
+ for (const f of (e.risk?.factors || [])) {
345
+ factorCounts[f.name] = (factorCounts[f.name] || 0) + 1;
346
+ }
347
+ }
348
+
349
+ console.log(`${C.bold}Statistics${date ? ` for ${date}` : ' (all time)'}${C.reset}\n`);
350
+ console.log(` Total events: ${allEvents.length}`);
351
+ console.log(` Total sessions: ${sessions.size}`);
352
+
353
+ console.log(`\n ${C.bold}Risk distribution:${C.reset}`);
354
+ console.log(` ${C.red}critical (3): ${riskDist[3] || 0}${C.reset} (${((riskDist[3] || 0) / allEvents.length * 100).toFixed(1)}%)`);
355
+ console.log(` ${C.yellow}high (2): ${riskDist[2] || 0}${C.reset} (${((riskDist[2] || 0) / allEvents.length * 100).toFixed(1)}%)`);
356
+ console.log(` ${C.cyan}medium (1): ${riskDist[1] || 0}${C.reset} (${((riskDist[1] || 0) / allEvents.length * 100).toFixed(1)}%)`);
357
+ console.log(` ${C.dim}none (0): ${riskDist[0] || 0}${C.reset} (${((riskDist[0] || 0) / allEvents.length * 100).toFixed(1)}%)`);
358
+
359
+ if (Object.keys(factorCounts).length > 0) {
360
+ console.log(`\n ${C.bold}Top risk factors:${C.reset}`);
361
+ const sorted = Object.entries(factorCounts).sort((a, b) => b[1] - a[1]);
362
+ for (const [name, count] of sorted.slice(0, 15)) {
363
+ console.log(` ${name}: ${count}`);
364
+ }
365
+ }
366
+
367
+ if (Object.keys(tools).length > 0) {
368
+ console.log(`\n ${C.bold}Tool usage:${C.reset}`);
369
+ const sorted = Object.entries(tools).sort((a, b) => b[1] - a[1]);
370
+ for (const [name, count] of sorted.slice(0, 10)) {
371
+ console.log(` ${name}: ${count}`);
372
+ }
373
+ }
374
+ }
375
+
376
+ function searchEvents(args) {
377
+ const opts = parseArgs(args);
378
+ const pattern = opts._positional?.[0];
379
+ const date = opts.date;
380
+ const last = parseInt(opts.last || '30', 10);
381
+
382
+ if (!pattern) {
383
+ console.error('Usage: propensity-monitor search <pattern> [--date YYYY-MM-DD]');
384
+ process.exit(1);
385
+ }
386
+
387
+ const re = new RegExp(pattern, 'i');
388
+ const eventsDir = path.join(DATA_DIR, 'events');
389
+ const matches = [];
390
+
391
+ const files = date
392
+ ? [`${date}.jsonl`]
393
+ : (fs.readdirSync(eventsDir).filter(f => f.endsWith('.jsonl')).sort());
394
+
395
+ for (const f of files) {
396
+ const events = readJsonl(path.join(eventsDir, f));
397
+ for (const e of events) {
398
+ const str = JSON.stringify(e);
399
+ if (re.test(str)) matches.push(e);
400
+ }
401
+ }
402
+
403
+ if (matches.length === 0) {
404
+ console.log(`No events matching: ${pattern}`);
405
+ return;
406
+ }
407
+
408
+ const shown = matches.slice(-last);
409
+ console.log(`${C.bold}Search: ${pattern}${C.reset} (${shown.length} of ${matches.length} matches):\n`);
410
+
411
+ for (const e of shown) {
412
+ const color = colorForCategory(e.risk?.category);
413
+ const score = e.risk?.score || 0;
414
+ const time = shortTime(e.timestamp);
415
+ const tool = e.tool_name || e.hook_event_name || '';
416
+ console.log(`${score > 0 ? color : C.dim}[${score}]${C.reset} ${C.dim}${time}${C.reset} ${tool}`);
417
+
418
+ // Show matching content
419
+ if (e.tool_input_summary?.command) {
420
+ console.log(` ${C.dim}cmd: ${e.tool_input_summary.command.substring(0, 100)}${C.reset}`);
421
+ }
422
+ if (e.tool_input_summary?.file_path) {
423
+ console.log(` ${C.dim}file: ${e.tool_input_summary.file_path}${C.reset}`);
424
+ }
425
+ }
426
+ }
427
+
428
+ function exportData(args) {
429
+ const opts = parseArgs(args);
430
+ const date = opts.date;
431
+ const minRisk = parseInt(opts['min-risk'] || '0', 10);
432
+ const format = opts.format || 'jsonl';
433
+
434
+ const eventsDir = path.join(DATA_DIR, 'events');
435
+ let allEvents = [];
436
+
437
+ if (date) {
438
+ allEvents = readJsonl(path.join(eventsDir, `${date}.jsonl`));
439
+ } else {
440
+ try {
441
+ const files = fs.readdirSync(eventsDir).filter(f => f.endsWith('.jsonl')).sort();
442
+ for (const f of files) {
443
+ allEvents.push(...readJsonl(path.join(eventsDir, f)));
444
+ }
445
+ } catch {}
446
+ }
447
+
448
+ if (minRisk > 0) {
449
+ allEvents = allEvents.filter(e => (e.risk?.score || 0) >= minRisk);
450
+ }
451
+
452
+ if (format === 'json') {
453
+ process.stdout.write(JSON.stringify(allEvents, null, 2) + '\n');
454
+ } else {
455
+ for (const e of allEvents) {
456
+ process.stdout.write(JSON.stringify(e) + '\n');
457
+ }
458
+ }
459
+ }
460
+
461
+ function showStatus() {
462
+ console.log(`${C.bold}propensity-monitor status${C.reset}\n`);
463
+
464
+ // Check install dir
465
+ const dirExists = fs.existsSync(INSTALL_DIR);
466
+ console.log(` Install dir: ${dirExists ? C.green + 'OK' : C.red + 'MISSING'}${C.reset} ${INSTALL_DIR}`);
467
+
468
+ // Check data dir
469
+ const dataExists = fs.existsSync(DATA_DIR);
470
+ console.log(` Data dir: ${dataExists ? C.green + 'OK' : C.red + 'MISSING'}${C.reset} ${DATA_DIR}`);
471
+
472
+ // Check hooks in settings
473
+ let hooksOk = false;
474
+ try {
475
+ const settings = JSON.parse(fs.readFileSync(CLAUDE_SETTINGS, 'utf8'));
476
+ const events = ['PreToolUse', 'PostToolUse', 'UserPromptSubmit', 'SessionStart', 'Stop'];
477
+ const registered = events.filter(e => {
478
+ const hooks = settings.hooks?.[e] || [];
479
+ return hooks.some(h => {
480
+ if (h.command?.includes('propensity-monitor')) return true;
481
+ if (h.hooks) return h.hooks.some(hh => hh.command?.includes('propensity-monitor'));
482
+ return false;
483
+ });
484
+ });
485
+ hooksOk = registered.length === events.length;
486
+ console.log(` Hooks: ${hooksOk ? C.green + 'OK' : C.yellow + 'PARTIAL'}${C.reset} (${registered.length}/${events.length} events)`);
487
+ if (!hooksOk) {
488
+ const missing = events.filter(e => !registered.includes(e));
489
+ console.log(` Missing: ${missing.join(', ')}`);
490
+ }
491
+ } catch {
492
+ console.log(` Hooks: ${C.red}ERROR${C.reset} (cannot read ${CLAUDE_SETTINGS})`);
493
+ }
494
+
495
+ // Check event count
496
+ try {
497
+ const eventsDir = path.join(DATA_DIR, 'events');
498
+ const files = fs.readdirSync(eventsDir).filter(f => f.endsWith('.jsonl'));
499
+ let total = 0;
500
+ for (const f of files) {
501
+ const content = fs.readFileSync(path.join(eventsDir, f), 'utf8');
502
+ total += content.split('\n').filter(l => l.trim()).length;
503
+ }
504
+ console.log(` Events: ${total} across ${files.length} day(s)`);
505
+ } catch {
506
+ console.log(` Events: ${C.dim}none yet${C.reset}`);
507
+ }
508
+
509
+ // Check alerts
510
+ try {
511
+ const alertsDir = path.join(DATA_DIR, 'alerts');
512
+ const files = fs.readdirSync(alertsDir).filter(f => f.endsWith('.jsonl'));
513
+ let total = 0;
514
+ for (const f of files) {
515
+ const content = fs.readFileSync(path.join(alertsDir, f), 'utf8');
516
+ total += content.split('\n').filter(l => l.trim()).length;
517
+ }
518
+ console.log(` Alerts: ${total > 0 ? C.red : C.dim}${total}${C.reset}`);
519
+ } catch {
520
+ console.log(` Alerts: ${C.dim}none${C.reset}`);
521
+ }
522
+ }
523
+
524
+ // ── Incident Commands ───────────────────────────────────────────────────────
525
+
526
+ const INCIDENTS_DIR = path.join(DATA_DIR, 'incidents');
527
+
528
+ function showIncidents(args) {
529
+ const opts = parseArgs(args);
530
+ const last = parseInt(opts.last || '20', 10);
531
+
532
+ if (!fs.existsSync(INCIDENTS_DIR)) {
533
+ console.log('No incidents logged yet.');
534
+ return;
535
+ }
536
+
537
+ const entries = fs.readdirSync(INCIDENTS_DIR)
538
+ .filter(f => fs.statSync(path.join(INCIDENTS_DIR, f)).isDirectory())
539
+ .sort()
540
+ .reverse()
541
+ .slice(0, last);
542
+
543
+ if (entries.length === 0) {
544
+ console.log('No incidents logged yet.');
545
+ return;
546
+ }
547
+
548
+ console.log(`${C.bold}${'INCIDENT'.padEnd(28)} ${'STATUS'.padEnd(12)} ${'SIMS'.padEnd(10)} CWD${C.reset}`);
549
+
550
+ for (const id of entries) {
551
+ const meta = readJson(path.join(INCIDENTS_DIR, id, 'metadata.json'));
552
+ const status = readJson(path.join(INCIDENTS_DIR, id, 'status.json'));
553
+
554
+ const cwdShort = (meta?.cwd || '').replace(process.env.HOME, '~');
555
+ const totalPlanned = status?.totalPlanned || 0;
556
+ const completed = status?.completed || 0;
557
+ const failed = status?.failed || 0;
558
+
559
+ let statusStr, statusColor;
560
+ if (status?.finished) {
561
+ statusStr = 'done';
562
+ statusColor = C.green;
563
+ } else if (status?.shuttingDown) {
564
+ statusStr = 'stopping';
565
+ statusColor = C.yellow;
566
+ } else if (status?.simulationsStarted) {
567
+ statusStr = 'running';
568
+ statusColor = C.cyan;
569
+ } else if (status?.error) {
570
+ statusStr = 'error';
571
+ statusColor = C.red;
572
+ } else {
573
+ statusStr = 'archived';
574
+ statusColor = C.dim;
575
+ }
576
+
577
+ const simStr = totalPlanned > 0 ? `${completed}/${totalPlanned}${failed > 0 ? ` (${C.red}${failed}err${C.reset})` : ''}` : '-';
578
+
579
+ console.log(
580
+ `${C.cyan}${id.padEnd(28)}${C.reset} ${statusColor}${statusStr.padEnd(12)}${C.reset} ${simStr.padEnd(10)} ${C.dim}${cwdShort}${C.reset}`
581
+ );
582
+ }
583
+ }
584
+
585
+ function showIncident(args) {
586
+ const opts = parseArgs(args);
587
+ const incidentId = opts._positional?.[0];
588
+ const subCmd = opts._positional?.[1];
589
+
590
+ if (!incidentId) {
591
+ console.error('Usage: propensity-monitor incident <id> [simulations|upload]');
592
+ process.exit(1);
593
+ }
594
+
595
+ // Find incident (support prefix match)
596
+ let fullId = incidentId;
597
+ if (!fs.existsSync(path.join(INCIDENTS_DIR, incidentId))) {
598
+ try {
599
+ const match = fs.readdirSync(INCIDENTS_DIR).find(d => d.startsWith(incidentId));
600
+ if (match) fullId = match;
601
+ else { console.error(`Incident not found: ${incidentId}`); process.exit(1); }
602
+ } catch { console.error(`Incident not found: ${incidentId}`); process.exit(1); }
603
+ }
604
+
605
+ const incidentDir = path.join(INCIDENTS_DIR, fullId);
606
+ const meta = readJson(path.join(incidentDir, 'metadata.json'));
607
+ const status = readJson(path.join(incidentDir, 'status.json'));
608
+
609
+ if (subCmd === 'simulations') {
610
+ return showIncidentSimulations(incidentDir, fullId, status);
611
+ }
612
+
613
+ if (subCmd === 'upload') {
614
+ console.log('Re-uploading is handled by: node ~/.propensity-monitor/lib/log-incident.mjs');
615
+ console.log(`Incident dir: ${incidentDir}`);
616
+ return;
617
+ }
618
+
619
+ console.log(`${C.bold}Incident: ${fullId}${C.reset}\n`);
620
+
621
+ if (meta) {
622
+ console.log(` Session: ${meta.sessionId || 'unknown'}`);
623
+ console.log(` CWD: ${meta.cwd || 'unknown'}`);
624
+ console.log(` Timestamp: ${meta.timestamp || 'unknown'}`);
625
+ console.log(` Notes: ${meta.userNotes || '(none)'}`);
626
+ console.log(` Transcripts: ${(meta.transcriptFiles || []).length} file(s)`);
627
+ console.log(` Assessments: ${meta.assessmentCount || 0}`);
628
+ console.log(` Dir snapshot: ${meta.dirSnapshotSizeMB ? meta.dirSnapshotSizeMB + ' MB' : 'none'}`);
629
+ }
630
+
631
+ if (status) {
632
+ console.log(`\n ${C.bold}Simulation Status:${C.reset}`);
633
+ console.log(` Started: ${status.started || 'unknown'}`);
634
+ console.log(` Finished: ${status.finished || 'in progress'}`);
635
+ console.log(` Planned: ${status.totalPlanned || 0}`);
636
+ console.log(` Completed: ${C.green}${status.completed || 0}${C.reset}`);
637
+ console.log(` Failed: ${(status.failed || 0) > 0 ? C.red : C.dim}${status.failed || 0}${C.reset}`);
638
+ console.log(` Running: ${status.running || 0}`);
639
+ if (status.s3Uploaded) console.log(` S3: ${C.green}uploaded${C.reset}`);
640
+ if (status.error) console.log(` Error: ${C.red}${status.error}${C.reset}`);
641
+ }
642
+
643
+ console.log(`\n ${C.dim}View simulations: propensity-monitor incident ${fullId} simulations${C.reset}`);
644
+ console.log(` ${C.dim}Local path: ${incidentDir}${C.reset}`);
645
+ }
646
+
647
+ function showIncidentSimulations(incidentDir, fullId, status) {
648
+ const simsDir = path.join(incidentDir, 'simulations');
649
+
650
+ if (!status?.simulations || status.simulations.length === 0) {
651
+ console.log('No simulations recorded yet.');
652
+ return;
653
+ }
654
+
655
+ console.log(`${C.bold}Simulations for ${fullId}${C.reset}\n`);
656
+ console.log(`${C.bold}${'ID'.padEnd(10)} ${'PERSONA'.padEnd(22)} ${'STATUS'.padEnd(12)} ${'TURNS'.padEnd(7)} DURATION${C.reset}`);
657
+
658
+ for (const sim of status.simulations) {
659
+ const statusColor = sim.status === 'completed' ? C.green
660
+ : sim.status === 'running' ? C.cyan
661
+ : sim.status === 'failed' ? C.red
662
+ : C.dim;
663
+
664
+ const dur = sim.duration ? `${sim.duration}s` : '-';
665
+ console.log(
666
+ `${C.cyan}${(sim.id || '').padEnd(10)}${C.reset} ` +
667
+ `${(sim.persona || '').padEnd(22)} ` +
668
+ `${statusColor}${(sim.status || 'pending').padEnd(12)}${C.reset} ` +
669
+ `${String(sim.turns || 0).padEnd(7)} ` +
670
+ `${C.dim}${dur}${C.reset}`
671
+ );
672
+ }
673
+
674
+ // Show detailed results if available
675
+ if (fs.existsSync(simsDir)) {
676
+ const simDirs = fs.readdirSync(simsDir).filter(d => {
677
+ const resultPath = path.join(simsDir, d, 'result.json');
678
+ return fs.existsSync(resultPath);
679
+ });
680
+
681
+ const failedSims = simDirs.filter(d => {
682
+ const result = readJson(path.join(simsDir, d, 'result.json'));
683
+ return result && result.exitReason !== 'completed';
684
+ });
685
+
686
+ if (failedSims.length > 0) {
687
+ console.log(`\n${C.bold}${C.red}Failed simulations:${C.reset}`);
688
+ for (const d of failedSims) {
689
+ const result = readJson(path.join(simsDir, d, 'result.json'));
690
+ console.log(` ${C.cyan}${d}${C.reset}: ${result?.error || result?.exitReason || 'unknown'}`);
691
+ }
692
+ }
693
+ }
694
+ }
695
+
696
+ function toggleEnabled(enable) {
697
+ const configPath = path.join(INSTALL_DIR, 'config.json');
698
+ let config = {};
699
+ try { config = JSON.parse(fs.readFileSync(configPath, 'utf8')); } catch {}
700
+ config.enabled = enable;
701
+ fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n');
702
+ console.log(`propensity-monitor ${enable ? C.green + 'enabled' : C.red + 'disabled'}${C.reset}`);
703
+ }
704
+
705
+ function turnOn() { toggleEnabled(true); }
706
+ function turnOff() { toggleEnabled(false); }
707
+
708
+ async function startDashboard(args) {
709
+ const opts = parseArgs(args);
710
+ const port = opts.port || '3456';
711
+ const dashboardScript = path.join(INSTALL_DIR, 'bin', 'dashboard.mjs');
712
+ if (!fs.existsSync(dashboardScript)) {
713
+ console.error('Dashboard not installed. Run install.sh to update.');
714
+ process.exit(1);
715
+ }
716
+ const { spawnSync } = await import('child_process');
717
+ spawnSync('node', [dashboardScript, '--port', port], { stdio: 'inherit' });
718
+ }
719
+
720
+ // GitHub Device Flow client ID — set after creating the OAuth App
721
+ const GITHUB_CLIENT_ID = process.env.ENVSEED_GITHUB_CLIENT_ID || 'Ov23lid2fKxyN7lOd9qv';
722
+
723
+ function httpsRequest(options, body) {
724
+ return new Promise((resolve, reject) => {
725
+ const req = https.request(options, (res) => {
726
+ let data = '';
727
+ res.on('data', (chunk) => { data += chunk; });
728
+ res.on('end', () => resolve({ statusCode: res.statusCode, body: data }));
729
+ });
730
+ req.on('error', reject);
731
+ if (body) req.write(body);
732
+ req.end();
733
+ });
734
+ }
735
+
736
+ function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
737
+
738
+ async function registerCommand() {
739
+ const config = readJson(path.join(INSTALL_DIR, 'config.json')) || {};
740
+
741
+ if (config.apiKey) {
742
+ console.log(`Already registered with API key: ${config.apiKey.substring(0, 8)}...`);
743
+ console.log(`To re-register, remove apiKey from ${INSTALL_DIR}/config.json`);
744
+ return;
745
+ }
746
+
747
+ const clientId = config.githubClientId || GITHUB_CLIENT_ID;
748
+ if (!clientId) {
749
+ console.error('No GitHub client ID configured.');
750
+ console.error(`Set githubClientId in ${INSTALL_DIR}/config.json`);
751
+ process.exit(1);
752
+ }
753
+
754
+ const uploadEndpoint = config.uploadEndpoint;
755
+ if (!uploadEndpoint) {
756
+ console.error('No upload endpoint configured.');
757
+ console.error(`Set uploadEndpoint in ${INSTALL_DIR}/config.json`);
758
+ process.exit(1);
759
+ }
760
+
761
+ // Step 1: Request device code
762
+ console.log('Starting GitHub authentication...');
763
+ const codeRes = await httpsRequest({
764
+ hostname: 'github.com',
765
+ path: '/login/device/code',
766
+ method: 'POST',
767
+ headers: {
768
+ 'Content-Type': 'application/json',
769
+ Accept: 'application/json',
770
+ },
771
+ }, JSON.stringify({ client_id: clientId, scope: 'read:user' }));
772
+
773
+ const codeData = JSON.parse(codeRes.body);
774
+ if (!codeData.device_code) {
775
+ console.error('Failed to start device flow:', codeData);
776
+ process.exit(1);
777
+ }
778
+
779
+ console.log('');
780
+ console.log(` Visit: ${C.bold}${codeData.verification_uri}${C.reset}`);
781
+ console.log(` Enter code: ${C.bold}${C.green}${codeData.user_code}${C.reset}`);
782
+ console.log('');
783
+ console.log('Waiting for authorization...');
784
+
785
+ // Step 2: Poll for access token
786
+ const interval = (codeData.interval || 5) * 1000;
787
+ let githubToken = null;
788
+
789
+ for (let i = 0; i < 60; i++) {
790
+ await sleep(interval);
791
+
792
+ const tokenRes = await httpsRequest({
793
+ hostname: 'github.com',
794
+ path: '/login/oauth/access_token',
795
+ method: 'POST',
796
+ headers: {
797
+ 'Content-Type': 'application/json',
798
+ Accept: 'application/json',
799
+ },
800
+ }, JSON.stringify({
801
+ client_id: clientId,
802
+ device_code: codeData.device_code,
803
+ grant_type: 'urn:ietf:params:oauth:grant-type:device_code',
804
+ }));
805
+
806
+ const tokenData = JSON.parse(tokenRes.body);
807
+
808
+ if (tokenData.access_token) {
809
+ githubToken = tokenData.access_token;
810
+ break;
811
+ }
812
+ if (tokenData.error === 'authorization_pending') continue;
813
+ if (tokenData.error === 'slow_down') {
814
+ await sleep(5000);
815
+ continue;
816
+ }
817
+ if (tokenData.error === 'expired_token') {
818
+ console.error('Authorization timed out. Please try again.');
819
+ process.exit(1);
820
+ }
821
+ if (tokenData.error) {
822
+ console.error(`GitHub error: ${tokenData.error_description || tokenData.error}`);
823
+ process.exit(1);
824
+ }
825
+ }
826
+
827
+ if (!githubToken) {
828
+ console.error('Timed out waiting for authorization.');
829
+ process.exit(1);
830
+ }
831
+
832
+ // Step 3: Exchange GitHub token for envseed API key
833
+ console.log('Registering with envseed...');
834
+ const regRes = await httpsRequest({
835
+ hostname: new URL(uploadEndpoint).hostname,
836
+ path: '/register',
837
+ method: 'POST',
838
+ headers: {
839
+ 'Content-Type': 'application/json',
840
+ },
841
+ }, JSON.stringify({ githubToken }));
842
+
843
+ if (regRes.statusCode !== 200) {
844
+ console.error(`Registration failed: ${regRes.body}`);
845
+ process.exit(1);
846
+ }
847
+
848
+ const regData = JSON.parse(regRes.body);
849
+ config.apiKey = regData.apiKey;
850
+ fs.writeFileSync(path.join(INSTALL_DIR, 'config.json'), JSON.stringify(config, null, 2) + '\n');
851
+
852
+ console.log('');
853
+ console.log(`${C.green}${C.bold}Registered as @${regData.githubUser}. Your garden is ready.${C.reset}`);
854
+ console.log(`API key saved to ${INSTALL_DIR}/config.json`);
855
+ }
856
+
857
+ function showHelp() {
858
+ console.log(`${C.bold}envseed${C.reset} (propensity-monitor) — cultivate AI safety evals from real Claude Code sessions
859
+
860
+ ${C.bold}Usage:${C.reset}
861
+ envseed <command> [options]
862
+
863
+ ${C.bold}Setup:${C.reset}
864
+ register Authenticate via GitHub and get API key
865
+ status Check installation health
866
+
867
+ ${C.bold}Commands:${C.reset}
868
+ on Enable monitoring
869
+ off Disable monitoring
870
+ alerts [--date YYYY-MM-DD] [--last N] Show critical events
871
+ events [--date YYYY-MM-DD] [--last N] [--min-risk N] Show all events
872
+ sessions [--min-risk N] List sessions with risk summary
873
+ session <session-id> Detailed session view
874
+ tail [--min-risk N] Live-tail events
875
+ stats [--date YYYY-MM-DD] Aggregate statistics
876
+ search <pattern> [--date YYYY-MM-DD] [--last N] Search events by pattern
877
+ export [--date YYYY-MM-DD] [--min-risk N] [--format jsonl|json] Export for eval pipeline
878
+ dashboard [--port 3456] Open web dashboard
879
+ incidents [--last N] List logged incidents
880
+ incident <id> [simulations|upload] View incident detail or simulations
881
+ help Show this help
882
+ `);
883
+ }
884
+
885
+ // ── Main ────────────────────────────────────────────────────────────────────
886
+
887
+ const COMMANDS = { on: turnOn, off: turnOff, dashboard: startDashboard, alerts: showAlerts, events: showEvents, sessions: showSessions, session: showSession, tail: tailEvents, stats: showStats, search: searchEvents, export: exportData, incidents: showIncidents, incident: showIncident, status: showStatus, register: registerCommand, help: showHelp };
888
+
889
+ const [command, ...args] = process.argv.slice(2);
890
+ // Default: show status if installed, help if not
891
+ const effectiveCommand = command || (fs.existsSync(INSTALL_DIR) ? 'status' : 'help');
892
+ const handler = COMMANDS[effectiveCommand];
893
+ if (!handler) {
894
+ console.error(`Unknown command: ${command}. Run 'propensity-monitor help' for usage.`);
895
+ process.exit(1);
896
+ }
897
+ Promise.resolve(handler(args)).catch(err => { console.error(err.message); process.exit(1); });