@aci-metrics/score 0.0.1

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/aci-score.js ADDED
@@ -0,0 +1,861 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * aci-score.js
4
+ *
5
+ * ACI Local Scorer - Tier 1
6
+ * Analyzes Claude Code transcripts and generates an ACI score preview.
7
+ *
8
+ * Usage:
9
+ * node aci-score.js [project-path]
10
+ * node aci-score.js ~/.claude/projects/my-project/
11
+ * node aci-score.js (scans default ~/.claude/projects/)
12
+ *
13
+ * Milestone 1: Deterministic analysis only (no AI inference)
14
+ */
15
+
16
+ var fs = require('fs');
17
+ var path = require('path');
18
+ var os = require('os');
19
+ var zlib = require('zlib');
20
+ var readline = require('readline');
21
+
22
+ // ============================================================================
23
+ // CONFIGURATION
24
+ // ============================================================================
25
+
26
+ var DEFAULT_CLAUDE_PATH = path.join(os.homedir(), '.claude', 'projects');
27
+
28
+ // ============================================================================
29
+ // SECRET SCRUBBING (from clean-transcripts.js)
30
+ // ============================================================================
31
+
32
+ var SECRET_PATTERNS = [
33
+ { name: 'OpenAI API Key', pattern: /sk-[a-zA-Z0-9]{32,}/g },
34
+ { name: 'Anthropic API Key', pattern: /sk-ant-[a-zA-Z0-9\-_]{32,}/g },
35
+ { name: 'GitHub Token', pattern: /gh[pousr]_[a-zA-Z0-9]{36,}/g },
36
+ { name: 'GitHub Fine-Grained Token', pattern: /github_pat_[a-zA-Z0-9]{22}_[a-zA-Z0-9]{59}/g },
37
+ { name: 'AWS Access Key', pattern: /AKIA[A-Z0-9]{16}/g },
38
+ { name: 'AWS Secret Key', pattern: /(?:aws_secret_access_key|secret_access_key|aws_secret)[\s]*[=:][\s]*["']?([A-Za-z0-9\/+=]{40})["']?/gi },
39
+ { name: 'Google API Key', pattern: /AIza[a-zA-Z0-9\-_]{35}/g },
40
+ { name: 'Slack Token', pattern: /xox[baprs]-[a-zA-Z0-9\-]{10,}/g },
41
+ { name: 'Stripe Key', pattern: /sk_live_[a-zA-Z0-9]{24,}/g },
42
+ { name: 'Private Key', pattern: /-----BEGIN\s+(?:RSA\s+)?PRIVATE\s+KEY-----[\s\S]*?-----END\s+(?:RSA\s+)?PRIVATE\s+KEY-----/g },
43
+ { name: 'Bearer Token', pattern: /Bearer\s+[a-zA-Z0-9\-_\.]{20,}/gi },
44
+ { name: 'JWT Token', pattern: /eyJ[a-zA-Z0-9\-_]+\.eyJ[a-zA-Z0-9\-_]+\.[a-zA-Z0-9\-_]+/g },
45
+ { name: 'DB Connection String', pattern: /(?:mongodb|postgres|mysql|redis):\/\/[^:]+:[^@]+@[^\s]+/gi },
46
+ ];
47
+
48
+ function scrubSecrets(text) {
49
+ if (typeof text !== 'string') return { text: text, count: 0 };
50
+ var count = 0;
51
+ var scrubbed = text;
52
+ for (var i = 0; i < SECRET_PATTERNS.length; i++) {
53
+ SECRET_PATTERNS[i].pattern.lastIndex = 0;
54
+ var matches = scrubbed.match(SECRET_PATTERNS[i].pattern);
55
+ if (matches) {
56
+ count += matches.length;
57
+ scrubbed = scrubbed.replace(SECRET_PATTERNS[i].pattern, '[REDACTED]');
58
+ }
59
+ }
60
+ return { text: scrubbed, count: count };
61
+ }
62
+
63
+ // ============================================================================
64
+ // CLI OUTPUT HELPERS
65
+ // ============================================================================
66
+
67
+ var COLORS = {
68
+ reset: '\x1b[0m',
69
+ bright: '\x1b[1m',
70
+ dim: '\x1b[2m',
71
+ green: '\x1b[32m',
72
+ yellow: '\x1b[33m',
73
+ blue: '\x1b[34m',
74
+ cyan: '\x1b[36m',
75
+ white: '\x1b[37m',
76
+ };
77
+
78
+ function log(msg) {
79
+ console.log(msg);
80
+ }
81
+
82
+ function status(msg) {
83
+ process.stdout.write(COLORS.cyan + ' → ' + COLORS.reset + msg + '\n');
84
+ }
85
+
86
+ function success(msg) {
87
+ process.stdout.write(COLORS.green + ' ✓ ' + COLORS.reset + msg + '\n');
88
+ }
89
+
90
+ function warn(msg) {
91
+ process.stdout.write(COLORS.yellow + ' ⚠ ' + COLORS.reset + msg + '\n');
92
+ }
93
+
94
+ function header(msg) {
95
+ log('');
96
+ log(COLORS.bright + msg + COLORS.reset);
97
+ }
98
+
99
+ // ============================================================================
100
+ // USER INPUT PROMPTS
101
+ // ============================================================================
102
+
103
+ function askQuestion(question) {
104
+ var rl = readline.createInterface({
105
+ input: process.stdin,
106
+ output: process.stdout
107
+ });
108
+
109
+ return new Promise(function(resolve) {
110
+ rl.question(question, function(answer) {
111
+ rl.close();
112
+ resolve(answer.trim());
113
+ });
114
+ });
115
+ }
116
+
117
+ function getTranscriptModTime(filepath) {
118
+ try {
119
+ var stat = fs.statSync(filepath);
120
+ return stat.mtime;
121
+ } catch (e) {
122
+ return new Date(0);
123
+ }
124
+ }
125
+
126
+ function filterTranscriptsBySelection(transcriptFiles, selection) {
127
+ // Sort by modification time (newest first)
128
+ var sorted = transcriptFiles.slice().sort(function(a, b) {
129
+ return getTranscriptModTime(b) - getTranscriptModTime(a);
130
+ });
131
+
132
+ switch (selection) {
133
+ case '1':
134
+ // All transcripts
135
+ return sorted;
136
+
137
+ case '2':
138
+ // Last 20
139
+ return sorted.slice(0, 20);
140
+
141
+ case '3':
142
+ // Last week
143
+ var oneWeekAgo = new Date();
144
+ oneWeekAgo.setDate(oneWeekAgo.getDate() - 7);
145
+ return sorted.filter(function(f) {
146
+ return getTranscriptModTime(f) >= oneWeekAgo;
147
+ });
148
+
149
+ case '4':
150
+ // Custom range - for now, same as all (will prompt separately)
151
+ return sorted;
152
+
153
+ default:
154
+ return sorted;
155
+ }
156
+ }
157
+
158
+ // ============================================================================
159
+ // REPORT-ID GENERATION
160
+ // ============================================================================
161
+
162
+ function generateReportId() {
163
+ var chars = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz23456789';
164
+ var segments = [];
165
+ for (var s = 0; s < 6; s++) {
166
+ var segment = '';
167
+ for (var c = 0; c < 4; c++) {
168
+ segment += chars.charAt(Math.floor(Math.random() * chars.length));
169
+ }
170
+ segments.push(segment);
171
+ }
172
+ return segments.join('-');
173
+ }
174
+
175
+ // ============================================================================
176
+ // TRANSCRIPT DISCOVERY
177
+ // ============================================================================
178
+
179
+ function findProjects(basePath) {
180
+ var projects = [];
181
+
182
+ if (!fs.existsSync(basePath)) {
183
+ return projects;
184
+ }
185
+
186
+ var entries = fs.readdirSync(basePath);
187
+
188
+ for (var i = 0; i < entries.length; i++) {
189
+ var entry = entries[i];
190
+ var entryPath = path.join(basePath, entry);
191
+ var stat = fs.statSync(entryPath);
192
+
193
+ if (stat.isDirectory()) {
194
+ // Check for JSONL files in this directory
195
+ var files = fs.readdirSync(entryPath);
196
+ var jsonlFiles = files.filter(function(f) {
197
+ return f.endsWith('.jsonl');
198
+ });
199
+
200
+ if (jsonlFiles.length > 0) {
201
+ projects.push({
202
+ name: entry,
203
+ path: entryPath,
204
+ files: jsonlFiles.map(function(f) {
205
+ return path.join(entryPath, f);
206
+ })
207
+ });
208
+ }
209
+ }
210
+ }
211
+
212
+ return projects;
213
+ }
214
+
215
+ function findTranscripts(inputPath) {
216
+ var stat;
217
+ try {
218
+ stat = fs.statSync(inputPath);
219
+ } catch (e) {
220
+ return [];
221
+ }
222
+
223
+ if (stat.isDirectory()) {
224
+ // Check if it's a project directory with JSONL files
225
+ var files = fs.readdirSync(inputPath);
226
+ var jsonlFiles = files.filter(function(f) {
227
+ return f.endsWith('.jsonl');
228
+ });
229
+
230
+ if (jsonlFiles.length > 0) {
231
+ return jsonlFiles.map(function(f) {
232
+ return path.join(inputPath, f);
233
+ });
234
+ }
235
+
236
+ // Otherwise, treat it as ~/.claude/projects/ and find all projects
237
+ var projects = findProjects(inputPath);
238
+ var allFiles = [];
239
+ for (var i = 0; i < projects.length; i++) {
240
+ allFiles = allFiles.concat(projects[i].files);
241
+ }
242
+ return allFiles;
243
+ } else if (inputPath.endsWith('.jsonl')) {
244
+ return [inputPath];
245
+ }
246
+
247
+ return [];
248
+ }
249
+
250
+ // ============================================================================
251
+ // TRANSCRIPT PARSING
252
+ // ============================================================================
253
+
254
+ function parseTranscript(filepath) {
255
+ var messages = [];
256
+ var content = fs.readFileSync(filepath, 'utf8');
257
+ var lines = content.split('\n');
258
+ var secretsFound = 0;
259
+
260
+ for (var i = 0; i < lines.length; i++) {
261
+ var line = lines[i].trim();
262
+ if (!line) continue;
263
+
264
+ try {
265
+ var raw = JSON.parse(line);
266
+ var msg = {
267
+ type: raw.type || raw.role || 'unknown',
268
+ ts: raw.ts || raw.timestamp || raw.created_at || null,
269
+ contentLength: 0
270
+ };
271
+
272
+ // Calculate content length (for metrics)
273
+ var contentStr = '';
274
+ if (typeof raw.content === 'string') {
275
+ contentStr = raw.content;
276
+ } else if (raw.content) {
277
+ contentStr = JSON.stringify(raw.content);
278
+ } else if (raw.message) {
279
+ contentStr = typeof raw.message === 'string' ? raw.message : JSON.stringify(raw.message);
280
+ }
281
+
282
+ var scrubResult = scrubSecrets(contentStr);
283
+ secretsFound += scrubResult.count;
284
+ msg.contentLength = scrubResult.text.length;
285
+
286
+ if (msg.ts) {
287
+ messages.push(msg);
288
+ }
289
+ } catch (e) {
290
+ // Skip malformed lines
291
+ }
292
+ }
293
+
294
+ return { messages: messages, secretsFound: secretsFound };
295
+ }
296
+
297
+ // ============================================================================
298
+ // METRICS CALCULATION (Deterministic Layer)
299
+ // ============================================================================
300
+
301
+ function calculateMetrics(transcriptFiles) {
302
+ var allMessages = [];
303
+ var totalSecretsFound = 0;
304
+ var sessionCount = transcriptFiles.length;
305
+
306
+ for (var i = 0; i < transcriptFiles.length; i++) {
307
+ var result = parseTranscript(transcriptFiles[i]);
308
+ allMessages = allMessages.concat(result.messages);
309
+ totalSecretsFound += result.secretsFound;
310
+ }
311
+
312
+ // Sort by timestamp
313
+ allMessages.sort(function(a, b) {
314
+ return new Date(a.ts) - new Date(b.ts);
315
+ });
316
+
317
+ // Calculate basic metrics
318
+ var userMessages = allMessages.filter(function(m) {
319
+ return m.type === 'user' || m.type === 'human';
320
+ });
321
+ var assistantMessages = allMessages.filter(function(m) {
322
+ return m.type === 'assistant' || m.type === 'ai';
323
+ });
324
+
325
+ // Time range
326
+ var firstTs = allMessages.length > 0 ? new Date(allMessages[0].ts) : null;
327
+ var lastTs = allMessages.length > 0 ? new Date(allMessages[allMessages.length - 1].ts) : null;
328
+
329
+ // Calculate total activity duration (rough estimate based on session gaps)
330
+ var totalActivityMinutes = 0;
331
+ if (allMessages.length > 1) {
332
+ var prevTs = firstTs;
333
+ for (var i = 1; i < allMessages.length; i++) {
334
+ var currTs = new Date(allMessages[i].ts);
335
+ var gapMinutes = (currTs - prevTs) / 1000 / 60;
336
+ // Only count gaps under 30 minutes as "active"
337
+ if (gapMinutes < 30) {
338
+ totalActivityMinutes += gapMinutes;
339
+ }
340
+ prevTs = currTs;
341
+ }
342
+ }
343
+
344
+ // Token estimation (rough: 4 chars per token)
345
+ var totalChars = allMessages.reduce(function(sum, m) {
346
+ return sum + m.contentLength;
347
+ }, 0);
348
+ var estimatedTokens = Math.round(totalChars / 4);
349
+
350
+ return {
351
+ sessionCount: sessionCount,
352
+ promptCount: userMessages.length,
353
+ responseCount: assistantMessages.length,
354
+ totalMessages: allMessages.length,
355
+ firstTimestamp: firstTs,
356
+ lastTimestamp: lastTs,
357
+ activityHours: Math.round(totalActivityMinutes / 60 * 10) / 10,
358
+ estimatedTokens: estimatedTokens,
359
+ secretsRedacted: totalSecretsFound
360
+ };
361
+ }
362
+
363
+ // ============================================================================
364
+ // SUBMISSION FILE CREATION (gzipped JSON)
365
+ // ============================================================================
366
+
367
+ function createSubmissionFile(reportId, metrics, transcriptFiles, outputDir) {
368
+ // Parse all transcripts and collect sessions
369
+ var sessions = [];
370
+ var totalSecretsRedacted = 0;
371
+ var mergedToolCounts = {};
372
+
373
+ for (var i = 0; i < transcriptFiles.length; i++) {
374
+ var filepath = transcriptFiles[i];
375
+ var parsed = parseTranscriptForSubmission(filepath);
376
+
377
+ sessions.push(parsed.session);
378
+ totalSecretsRedacted += parsed.secretsRedacted;
379
+
380
+ // Merge tool counts
381
+ for (var tool in parsed.toolCounts) {
382
+ mergedToolCounts[tool] = (mergedToolCounts[tool] || 0) + parsed.toolCounts[tool];
383
+ }
384
+ }
385
+
386
+ // Calculate aggregate token metrics from sessions
387
+ var totalTokensIn = 0;
388
+ var totalTokensOut = 0;
389
+ var totalCacheRead = 0;
390
+ var promptCount = 0;
391
+ var responseCount = 0;
392
+
393
+ for (var i = 0; i < sessions.length; i++) {
394
+ var session = sessions[i];
395
+ for (var j = 0; j < session.messages.length; j++) {
396
+ var msg = session.messages[j];
397
+ if (msg.role === 'user') {
398
+ promptCount++;
399
+ } else if (msg.role === 'assistant') {
400
+ responseCount++;
401
+ if (msg.tokens) {
402
+ totalTokensIn += msg.tokens.in || 0;
403
+ totalTokensOut += msg.tokens.out || 0;
404
+ totalCacheRead += msg.tokens.cacheRead || 0;
405
+ }
406
+ }
407
+ }
408
+ }
409
+
410
+ // Build submission payload
411
+ var submission = {
412
+ reportId: reportId,
413
+ version: '1.0.0',
414
+ generatedAt: new Date().toISOString(),
415
+
416
+ sessions: sessions,
417
+
418
+ metrics: {
419
+ sessionCount: sessions.length,
420
+ promptCount: promptCount,
421
+ responseCount: responseCount,
422
+ totalTokensIn: totalTokensIn,
423
+ totalTokensOut: totalTokensOut,
424
+ totalCacheRead: totalCacheRead,
425
+ toolCounts: mergedToolCounts,
426
+ activityMinutes: metrics.activityHours * 60,
427
+ secretsRedacted: totalSecretsRedacted,
428
+ firstTimestamp: metrics.firstTimestamp ? metrics.firstTimestamp.toISOString() : null,
429
+ lastTimestamp: metrics.lastTimestamp ? metrics.lastTimestamp.toISOString() : null
430
+ }
431
+ };
432
+
433
+ // Convert to JSON (pretty-printed) and compress with gzip
434
+ var jsonData = JSON.stringify(submission, null, 2);
435
+ var compressed = zlib.gzipSync(Buffer.from(jsonData, 'utf8'));
436
+
437
+ // Write compressed file
438
+ var outputPath = path.join(outputDir, reportId + '.aci.gz');
439
+ fs.writeFileSync(outputPath, compressed);
440
+
441
+ // Return path and stats
442
+ return {
443
+ path: outputPath,
444
+ uncompressedSize: jsonData.length,
445
+ compressedSize: compressed.length,
446
+ secretsRedacted: totalSecretsRedacted
447
+ };
448
+ }
449
+
450
+ /**
451
+ * Recursively scrub secrets from content (handles strings, arrays, objects)
452
+ */
453
+ function scrubContentDeep(content) {
454
+ if (typeof content === 'string') {
455
+ return scrubSecrets(content);
456
+ }
457
+ if (Array.isArray(content)) {
458
+ var totalCount = 0;
459
+ var scrubbedArray = content.map(function(item) {
460
+ var result = scrubContentDeep(item);
461
+ totalCount += result.count;
462
+ return result.text;
463
+ });
464
+ return { text: scrubbedArray, count: totalCount };
465
+ }
466
+ if (content && typeof content === 'object') {
467
+ var totalCount = 0;
468
+ var scrubbedObj = {};
469
+ for (var key in content) {
470
+ if (content.hasOwnProperty(key)) {
471
+ var result = scrubContentDeep(content[key]);
472
+ scrubbedObj[key] = result.text;
473
+ totalCount += result.count;
474
+ }
475
+ }
476
+ return { text: scrubbedObj, count: totalCount };
477
+ }
478
+ return { text: content, count: 0 };
479
+ }
480
+
481
+ /**
482
+ * Parse a content block (text, thinking, tool_use, tool_result)
483
+ */
484
+ function parseContentBlock(block, secretsCounter) {
485
+ if (!block || !block.type) return null;
486
+
487
+ if (block.type === 'text') {
488
+ var scrubbed = scrubSecrets(block.text || '');
489
+ secretsCounter.count += scrubbed.count;
490
+ return { type: 'text', text: scrubbed.text };
491
+ }
492
+
493
+ if (block.type === 'thinking') {
494
+ var scrubbed = scrubSecrets(block.thinking || '');
495
+ secretsCounter.count += scrubbed.count;
496
+ return {
497
+ type: 'thinking',
498
+ length: (block.thinking || '').length,
499
+ text: scrubbed.text
500
+ };
501
+ }
502
+
503
+ if (block.type === 'tool_use') {
504
+ var scrubbedInput = scrubContentDeep(block.input || {});
505
+ secretsCounter.count += scrubbedInput.count;
506
+ return {
507
+ type: 'tool_use',
508
+ name: block.name,
509
+ input: scrubbedInput.text
510
+ };
511
+ }
512
+
513
+ if (block.type === 'tool_result') {
514
+ var scrubbedContent = scrubContentDeep(block.content || '');
515
+ secretsCounter.count += scrubbedContent.count;
516
+ return {
517
+ type: 'tool_result',
518
+ success: !block.is_error,
519
+ content: scrubbedContent.text
520
+ };
521
+ }
522
+
523
+ return null;
524
+ }
525
+
526
+ /**
527
+ * Parse transcript for submission with full schema
528
+ */
529
+ function parseTranscriptForSubmission(filepath) {
530
+ var fileContent = fs.readFileSync(filepath, 'utf8');
531
+ var lines = fileContent.split('\n');
532
+ var filename = path.basename(filepath);
533
+
534
+ // Session metadata (extracted from first relevant message)
535
+ var session = {
536
+ id: null,
537
+ slug: null,
538
+ start: null,
539
+ end: null,
540
+ cwd: null,
541
+ branch: null,
542
+ model: null,
543
+ clientVersion: null,
544
+ permissionMode: null,
545
+ messages: []
546
+ };
547
+
548
+ var secretsCounter = { count: 0 };
549
+ var lastUserTimestamp = null;
550
+ var toolCounts = {};
551
+
552
+ for (var i = 0; i < lines.length; i++) {
553
+ var line = lines[i].trim();
554
+ if (!line) continue;
555
+
556
+ try {
557
+ var raw = JSON.parse(line);
558
+
559
+ // Skip noise entries
560
+ if (raw.type === 'queue-operation' ||
561
+ raw.type === 'file-history-snapshot' ||
562
+ raw.type === 'progress') {
563
+ continue;
564
+ }
565
+
566
+ // Only process user/assistant messages
567
+ if (raw.type !== 'user' && raw.type !== 'assistant') {
568
+ continue;
569
+ }
570
+
571
+ var timestamp = raw.timestamp || raw.ts || null;
572
+ if (!timestamp) continue;
573
+
574
+ // Extract session metadata from first message
575
+ if (!session.id && raw.sessionId) {
576
+ session.id = raw.sessionId.split('-')[0]; // Short ID
577
+ }
578
+ if (!session.slug && raw.slug) {
579
+ session.slug = raw.slug;
580
+ }
581
+ if (!session.cwd && raw.cwd) {
582
+ session.cwd = raw.cwd;
583
+ }
584
+ if (!session.branch && raw.gitBranch) {
585
+ session.branch = raw.gitBranch;
586
+ }
587
+ if (!session.clientVersion && raw.version) {
588
+ session.clientVersion = raw.version;
589
+ }
590
+ if (!session.permissionMode && raw.permissionMode) {
591
+ session.permissionMode = raw.permissionMode;
592
+ }
593
+
594
+ // Track time range
595
+ if (!session.start) {
596
+ session.start = timestamp;
597
+ }
598
+ session.end = timestamp;
599
+
600
+ // Build message object
601
+ var msg = {
602
+ role: raw.type,
603
+ timestamp: timestamp
604
+ };
605
+
606
+ // For assistant messages, extract model and tokens
607
+ if (raw.type === 'assistant' && raw.message) {
608
+ if (!session.model && raw.message.model) {
609
+ session.model = raw.message.model;
610
+ }
611
+
612
+ // Calculate response time from last user message
613
+ if (lastUserTimestamp) {
614
+ var responseMs = new Date(timestamp) - new Date(lastUserTimestamp);
615
+ msg.responseTime = Math.round(responseMs / 100) / 10; // seconds, 1 decimal
616
+ }
617
+
618
+ // Extract token usage
619
+ if (raw.message.usage) {
620
+ msg.tokens = {
621
+ in: raw.message.usage.input_tokens || 0,
622
+ out: raw.message.usage.output_tokens || 0
623
+ };
624
+ if (raw.message.usage.cache_read_input_tokens) {
625
+ msg.tokens.cacheRead = raw.message.usage.cache_read_input_tokens;
626
+ }
627
+ }
628
+ }
629
+
630
+ // Track last user timestamp for responseTime calculation
631
+ if (raw.type === 'user') {
632
+ lastUserTimestamp = timestamp;
633
+ }
634
+
635
+ // Parse content blocks
636
+ var contentSource = raw.message ? raw.message.content : null;
637
+ if (Array.isArray(contentSource)) {
638
+ msg.content = [];
639
+ for (var j = 0; j < contentSource.length; j++) {
640
+ var parsed = parseContentBlock(contentSource[j], secretsCounter);
641
+ if (parsed) {
642
+ msg.content.push(parsed);
643
+
644
+ // Track tool usage
645
+ if (parsed.type === 'tool_use' && parsed.name) {
646
+ toolCounts[parsed.name] = (toolCounts[parsed.name] || 0) + 1;
647
+ }
648
+ }
649
+ }
650
+ }
651
+
652
+ session.messages.push(msg);
653
+
654
+ } catch (e) {
655
+ // Skip malformed lines
656
+ }
657
+ }
658
+
659
+ return {
660
+ filename: filename,
661
+ session: session,
662
+ secretsRedacted: secretsCounter.count,
663
+ toolCounts: toolCounts
664
+ };
665
+ }
666
+
667
+ // ============================================================================
668
+ // CLI REPORT OUTPUT
669
+ // ============================================================================
670
+
671
+ function formatNumber(n) {
672
+ if (n >= 1000000000) return (n / 1000000000).toFixed(1) + ' B';
673
+ if (n >= 1000000) return (n / 1000000).toFixed(1) + ' M';
674
+ if (n >= 1000) return (n / 1000).toFixed(1) + ' k';
675
+ return String(n);
676
+ }
677
+
678
+ function formatBytes(bytes) {
679
+ if (bytes >= 1073741824) return (bytes / 1073741824).toFixed(1) + ' GB';
680
+ if (bytes >= 1048576) return (bytes / 1048576).toFixed(1) + ' MB';
681
+ if (bytes >= 1024) return (bytes / 1024).toFixed(1) + ' KB';
682
+ return bytes + ' B';
683
+ }
684
+
685
+ function formatDate(d) {
686
+ if (!d) return '--';
687
+ var months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
688
+ return months[d.getMonth()] + ' ' + d.getDate() + ', ' + d.getFullYear();
689
+ }
690
+
691
+ function progressBar(value, max, width) {
692
+ var filled = Math.round((value / max) * width);
693
+ var empty = width - filled;
694
+ return '█'.repeat(filled) + '░'.repeat(empty);
695
+ }
696
+
697
+ function printReport(reportId, metrics) {
698
+ var line = '═'.repeat(72);
699
+ var thinLine = '─'.repeat(72);
700
+
701
+ log('');
702
+ log(line);
703
+ log(' AI COLLABORATION INDEX (ACI) - Estimate');
704
+ log('');
705
+ log(' Report-ID: ' + reportId);
706
+ log(' Generated: ' + formatDate(new Date()));
707
+ log(line);
708
+ log('');
709
+
710
+ // Placeholder scores (will be calculated by AI layer later)
711
+ log(' ACI SCORE (estimate) ' + COLORS.dim + '--- (AI analysis pending)' + COLORS.reset);
712
+ log('');
713
+ log(' Velocity ' + COLORS.dim + '--- ' + progressBar(0, 160, 16) + COLORS.reset);
714
+ log(' Quality ' + COLORS.dim + '--- ' + progressBar(0, 160, 16) + COLORS.reset);
715
+ log(' Integration ' + COLORS.dim + '--- ' + progressBar(0, 160, 16) + COLORS.reset);
716
+ log(' Literacy ' + COLORS.dim + '--- ' + progressBar(0, 160, 16) + COLORS.reset);
717
+ log('');
718
+ log(thinLine);
719
+ log(' RAW METRICS');
720
+ log(thinLine);
721
+ log('');
722
+
723
+ var timeRange = formatDate(metrics.firstTimestamp) + ' – ' + formatDate(metrics.lastTimestamp);
724
+
725
+ log(' Timerange: ' + timeRange);
726
+ log(' Activity: ' + metrics.activityHours + ' h');
727
+ log(' Sessions: ' + metrics.sessionCount);
728
+ log(' Prompts: ' + formatNumber(metrics.promptCount));
729
+ log(' Responses: ' + formatNumber(metrics.responseCount));
730
+ log(' Tokens (est): ' + formatNumber(metrics.estimatedTokens));
731
+ log('');
732
+ log(' ' + COLORS.dim + 'Additional metrics require AI analysis' + COLORS.reset);
733
+ log('');
734
+ log(thinLine);
735
+ log(' DETECTED PATTERNS');
736
+ log(thinLine);
737
+ log('');
738
+ log(' ' + COLORS.dim + '(Pending AI analysis)' + COLORS.reset);
739
+ log('');
740
+ log(thinLine);
741
+ log(' TIPS & NEXT-STEPS');
742
+ log(thinLine);
743
+ log('');
744
+ log(' ' + COLORS.dim + '(Pending AI analysis)' + COLORS.reset);
745
+ log('');
746
+ log(line);
747
+ log(' © 2026 ACI Metrics | aci-metrics.com/terms');
748
+ log(line);
749
+ }
750
+
751
+ // ============================================================================
752
+ // MAIN
753
+ // ============================================================================
754
+
755
+ async function main() {
756
+ var inputPath = process.argv[2] || DEFAULT_CLAUDE_PATH;
757
+
758
+ log('');
759
+ log(COLORS.bright + ' ACI Local Scorer v1.0.0' + COLORS.reset);
760
+ log(COLORS.dim + ' Milestone 1: Deterministic Analysis' + COLORS.reset);
761
+ log('');
762
+
763
+ // Step 1: Find transcripts
764
+ header(' SCANNING FOR TRANSCRIPTS');
765
+ status('Looking in: ' + inputPath);
766
+
767
+ var allTranscriptFiles = findTranscripts(inputPath);
768
+
769
+ if (allTranscriptFiles.length === 0) {
770
+ warn('No transcript files found');
771
+ log('');
772
+ log(' Expected location: ~/.claude/projects/<project-name>/*.jsonl');
773
+ log(' Or specify a path: node aci-score.js /path/to/transcripts');
774
+ log('');
775
+ process.exit(1);
776
+ }
777
+
778
+ success('Found ' + allTranscriptFiles.length + ' transcript file(s)');
779
+
780
+ // Step 2: Ask user which transcripts to include
781
+ log('');
782
+ log(' ' + COLORS.bright + 'Which transcripts would you like to analyze?' + COLORS.reset);
783
+ log('');
784
+ log(' 1. All (' + allTranscriptFiles.length + ' files)');
785
+ log(' 2. Last 20 files');
786
+ log(' 3. Last 7 days');
787
+ log(' 4. Custom date range');
788
+ log('');
789
+
790
+ var selection = await askQuestion(' Enter choice (1-4): ');
791
+
792
+ // Handle custom date range
793
+ var transcriptFiles;
794
+ if (selection === '4') {
795
+ log('');
796
+ var daysInput = await askQuestion(' How many days back? ');
797
+ var days = parseInt(daysInput, 10);
798
+ if (isNaN(days) || days < 1) {
799
+ warn('Invalid input, using all transcripts');
800
+ transcriptFiles = allTranscriptFiles;
801
+ } else {
802
+ var cutoffDate = new Date();
803
+ cutoffDate.setDate(cutoffDate.getDate() - days);
804
+ transcriptFiles = allTranscriptFiles.slice().sort(function(a, b) {
805
+ return getTranscriptModTime(b) - getTranscriptModTime(a);
806
+ }).filter(function(f) {
807
+ return getTranscriptModTime(f) >= cutoffDate;
808
+ });
809
+ }
810
+ } else {
811
+ transcriptFiles = filterTranscriptsBySelection(allTranscriptFiles, selection);
812
+ }
813
+
814
+ if (transcriptFiles.length === 0) {
815
+ warn('No transcripts match the selected criteria');
816
+ process.exit(1);
817
+ }
818
+
819
+ log('');
820
+ success('Selected ' + transcriptFiles.length + ' transcript(s) for analysis');
821
+
822
+ // Step 3: Parse and calculate metrics
823
+ header(' ANALYZING TRANSCRIPTS');
824
+ status('Parsing messages and scrubbing secrets...');
825
+
826
+ var metrics = calculateMetrics(transcriptFiles);
827
+
828
+ success('Processed ' + metrics.totalMessages + ' messages');
829
+ if (metrics.secretsRedacted > 0) {
830
+ warn('Redacted ' + metrics.secretsRedacted + ' potential secret(s)');
831
+ }
832
+
833
+ // Step 4: Generate Report ID
834
+ header(' GENERATING REPORT');
835
+ var reportId = generateReportId();
836
+ status('Report-ID: ' + reportId);
837
+
838
+ // Step 5: Create submission file (gzipped)
839
+ status('Creating compressed submission file...');
840
+ var outputDir = process.cwd();
841
+ var submission = createSubmissionFile(reportId, metrics, transcriptFiles, outputDir);
842
+ var compressionRatio = Math.round((1 - submission.compressedSize / submission.uncompressedSize) * 100);
843
+ success('Created: ' + path.basename(submission.path) + ' (' + formatBytes(submission.compressedSize) + ', ' + compressionRatio + '% smaller)');
844
+
845
+ // Step 6: Print CLI report
846
+ printReport(reportId, metrics);
847
+
848
+ log('');
849
+ log(' ● Generated submission file: ' + path.basename(submission.path));
850
+ log(' ' + COLORS.dim + 'Size: ' + formatBytes(submission.compressedSize) + ' (compressed from ' + formatBytes(submission.uncompressedSize) + ')' + COLORS.reset);
851
+ log(' ' + COLORS.dim + 'Upload to aci-metrics.com for full analysis' + COLORS.reset);
852
+ log('');
853
+ log(' ' + COLORS.dim + 'Note: AI-powered scoring requires model setup.' + COLORS.reset);
854
+ log(' ' + COLORS.dim + 'See: local-scorer/README.md' + COLORS.reset);
855
+ log('');
856
+ }
857
+
858
+ main().catch(function(err) {
859
+ console.error('Error:', err.message);
860
+ process.exit(1);
861
+ });