liteagents 2.4.7 → 2.5.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 (37) hide show
  1. package/README.md +24 -8
  2. package/docs/LONG_TERM_MEMORY.md +449 -0
  3. package/package.json +2 -2
  4. package/packages/ampcode/AGENT.md +3 -1
  5. package/packages/ampcode/agents/context-builder.md +14 -12
  6. package/packages/ampcode/commands/docs-builder/templates.md +29 -0
  7. package/packages/ampcode/commands/docs-builder.md +54 -3
  8. package/packages/ampcode/commands/friction/friction.js +2168 -0
  9. package/packages/ampcode/commands/friction.md +139 -0
  10. package/packages/ampcode/commands/remember.md +110 -0
  11. package/packages/claude/CLAUDE.md +3 -1
  12. package/packages/claude/agents/context-builder.md +7 -4
  13. package/packages/claude/commands/friction/friction.js +2168 -0
  14. package/packages/claude/commands/friction.md +139 -0
  15. package/packages/claude/commands/remember.md +110 -0
  16. package/packages/claude/skills/docs-builder/SKILL.md +53 -2
  17. package/packages/claude/skills/docs-builder/references/templates.md +29 -0
  18. package/packages/droid/AGENTS.md +3 -1
  19. package/packages/droid/commands/docs-builder/templates.md +29 -0
  20. package/packages/droid/commands/docs-builder.md +54 -3
  21. package/packages/droid/commands/friction/friction.js +2168 -0
  22. package/packages/droid/commands/friction.md +139 -0
  23. package/packages/droid/commands/remember.md +110 -0
  24. package/packages/droid/droids/context-builder.md +15 -13
  25. package/packages/opencode/AGENTS.md +3 -1
  26. package/packages/opencode/agent/context-builder.md +14 -12
  27. package/packages/opencode/command/docs-builder/templates.md +29 -0
  28. package/packages/opencode/command/docs-builder.md +54 -3
  29. package/packages/opencode/command/friction/friction.js +2168 -0
  30. package/packages/opencode/command/friction.md +139 -0
  31. package/packages/opencode/command/remember.md +110 -0
  32. package/packages/opencode/opencode.jsonc +8 -0
  33. package/packages/subagentic-manual.md +33 -15
  34. package/packages/ampcode/README.md +0 -17
  35. package/packages/claude/README.md +0 -23
  36. package/packages/droid/README.md +0 -17
  37. package/packages/opencode/README.md +0 -17
@@ -0,0 +1,2168 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Friction analysis pipeline - analyze sessions and extract antigens.
4
+ *
5
+ * Usage:
6
+ * node friction.js <sessions-directory>
7
+ * node friction.js ~/.claude/projects/-home-hamr-PycharmProjects-liteagents/
8
+ *
9
+ * Outputs (all in .factory/friction/):
10
+ * friction_analysis.json - Per-session analysis
11
+ * friction_summary.json - Aggregate stats
12
+ * friction_raw.jsonl - Raw signals
13
+ * antigen_candidates.json - Raw antigen candidates
14
+ * antigen_clusters.json - Clustered antigen patterns
15
+ * antigen_review.md - Clustered review file
16
+ */
17
+
18
+ 'use strict';
19
+
20
+ const fs = require('fs');
21
+ const path = require('path');
22
+
23
+ // =============================================================================
24
+ // EMBEDDED CONFIG (from friction_config.json)
25
+ // =============================================================================
26
+
27
+ const CONFIG = {
28
+ weights: {
29
+ exit_error: 1,
30
+ exit_success: 0,
31
+ user_curse: 5,
32
+ user_negation: 0.5,
33
+ user_intervention: 10,
34
+ tool_loop: 6,
35
+ false_success: 8,
36
+ request_interrupted: 2.5,
37
+ long_silence: 0.5,
38
+ repeated_question: 1,
39
+ compaction: 0.5,
40
+ interrupt_cascade: 5,
41
+ rapid_exit: 6,
42
+ no_resolution: 8,
43
+ session_abandoned: 10,
44
+ sibling_tool_error: 0.5,
45
+ },
46
+ thresholds: {
47
+ friction_peak: 15,
48
+ intervention_predictability: 0.50,
49
+ signal_noise_ratio: 1.5,
50
+ long_silence_minutes: 10,
51
+ },
52
+ notes: {
53
+ model: 'Threshold monitor - friction accumulates, no subtraction',
54
+ exit_error: 'Single error = noise (+1)',
55
+ exit_success: 'Zero weight - tracked as momentum only',
56
+ user_intervention: 'Gold signal - user gave up (/stash)',
57
+ user_curse: 'Reliable frustration indicator',
58
+ false_success: 'Trust violation - LLM claimed success but failed',
59
+ tool_loop: 'Agent stuck - same tool 3x',
60
+ user_negation: 'Low weight - still noisy after filtering',
61
+ request_interrupted: 'User hit Ctrl+C / Escape - impatience signal',
62
+ long_silence: 'User walked away >10 min - disengagement',
63
+ repeated_question: 'User asked same thing twice - confusion/frustration',
64
+ compaction: 'Context overflow - memory loss indicator',
65
+ interrupt_cascade: 'Multiple ESC/Ctrl+C within 60s - escalating frustration',
66
+ rapid_exit: 'Quick quit (<3 turns) after error - immediate rejection',
67
+ no_resolution: 'Errors without success - unresolved session',
68
+ session_abandoned: 'High friction at end, no clean exit - gave up silently',
69
+ sibling_tool_error: 'SDK cascade - parallel tool batch canceled when one fails',
70
+ },
71
+ };
72
+
73
+ // =============================================================================
74
+ // UTILITY FUNCTIONS
75
+ // =============================================================================
76
+
77
+ function loadConfig() {
78
+ return CONFIG;
79
+ }
80
+
81
+ function parseISODate(s) {
82
+ if (!s) return null;
83
+ try {
84
+ return new Date(s.replace('Z', '+00:00'));
85
+ } catch {
86
+ return null;
87
+ }
88
+ }
89
+
90
+ function formatDuration(minutes) {
91
+ if (minutes < 60) return `${minutes}m`;
92
+ const hours = Math.floor(minutes / 60);
93
+ const mins = minutes % 60;
94
+ return mins ? `${hours}h${mins}m` : `${hours}h`;
95
+ }
96
+
97
+ /**
98
+ * Glob-like file listing. Returns files matching a pattern in a directory.
99
+ * Supports simple *.ext matching and recursive **\/*.ext matching.
100
+ */
101
+ function globFiles(dir, pattern, recursive) {
102
+ const results = [];
103
+ if (!fs.existsSync(dir)) return results;
104
+
105
+ const ext = pattern.replace('*', '');
106
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
107
+ for (const entry of entries) {
108
+ const fullPath = path.join(dir, entry.name);
109
+ if (entry.isFile() && entry.name.endsWith(ext)) {
110
+ results.push(fullPath);
111
+ } else if (recursive && entry.isDirectory()) {
112
+ results.push(...globFiles(fullPath, pattern, true));
113
+ }
114
+ }
115
+ return results;
116
+ }
117
+
118
+ function listDirs(dir) {
119
+ if (!fs.existsSync(dir)) return [];
120
+ return fs.readdirSync(dir, { withFileTypes: true })
121
+ .filter(e => e.isDirectory())
122
+ .map(e => path.join(dir, e.name));
123
+ }
124
+
125
+ // =============================================================================
126
+ // FRICTION ANALYZE - derive_session_name
127
+ // =============================================================================
128
+
129
+ function deriveSessionName(sessionFile, metadata) {
130
+ const parent = path.basename(path.dirname(sessionFile));
131
+
132
+ let project;
133
+ if (parent.startsWith('-')) {
134
+ const prefixes = [
135
+ '-home-hamr-PycharmProjects-',
136
+ '-home-hamr-Documents-PycharmProjects-',
137
+ '-home-hamr-',
138
+ '-home-',
139
+ '-',
140
+ ];
141
+ let found = false;
142
+ for (const prefix of prefixes) {
143
+ if (parent.startsWith(prefix)) {
144
+ project = parent.slice(prefix.length);
145
+ found = true;
146
+ break;
147
+ }
148
+ }
149
+ if (!found) {
150
+ project = parent.slice(1);
151
+ }
152
+ } else {
153
+ project = parent;
154
+ }
155
+
156
+ let dateStr = '';
157
+ if (metadata.started_at) {
158
+ try {
159
+ const dt = new Date(metadata.started_at.replace('Z', '+00:00'));
160
+ if (!isNaN(dt.getTime())) {
161
+ const mm = String(dt.getMonth() + 1).padStart(2, '0');
162
+ const dd = String(dt.getDate()).padStart(2, '0');
163
+ const hh = String(dt.getHours()).padStart(2, '0');
164
+ const mi = String(dt.getMinutes()).padStart(2, '0');
165
+ dateStr = `${mm}${dd}-${hh}${mi}`;
166
+ }
167
+ } catch {
168
+ // pass
169
+ }
170
+ }
171
+
172
+ if (!dateStr) {
173
+ try {
174
+ const stat = fs.statSync(sessionFile);
175
+ const mtime = stat.mtime;
176
+ const mm = String(mtime.getMonth() + 1).padStart(2, '0');
177
+ const dd = String(mtime.getDate()).padStart(2, '0');
178
+ const hh = String(mtime.getHours()).padStart(2, '0');
179
+ const mi = String(mtime.getMinutes()).padStart(2, '0');
180
+ dateStr = `${mm}${dd}-${hh}${mi}`;
181
+ } catch {
182
+ dateStr = 'unknown';
183
+ }
184
+ }
185
+
186
+ const shortId = path.basename(sessionFile, '.jsonl').slice(0, 8);
187
+ return `${project}/${dateStr}-${shortId}`;
188
+ }
189
+
190
+ // =============================================================================
191
+ // FRICTION ANALYZE - extractToolNameFromResult
192
+ // =============================================================================
193
+
194
+ function extractToolNameFromResult(result) {
195
+ const match = result.match(/●\s+(\w+)\(/);
196
+ return match ? match[1] : 'unknown';
197
+ }
198
+
199
+ // =============================================================================
200
+ // FRICTION ANALYZE - extract_signals
201
+ // =============================================================================
202
+
203
+ function extractSignals(sessionFile) {
204
+ const signals = [];
205
+ let llmClaimedSuccess = false;
206
+ const toolHistory = [];
207
+ const metadata = {};
208
+
209
+ const raw = fs.readFileSync(sessionFile, 'utf-8');
210
+ const events = raw.split('\n')
211
+ .filter(line => line.trim())
212
+ .map(line => JSON.parse(line));
213
+
214
+ let turnCount = 0;
215
+ const userMessages = [];
216
+ let prevUserTs = null;
217
+
218
+ for (const event of events) {
219
+ if ('gitBranch' in event) metadata.git_branch = event.gitBranch;
220
+ if ('cwd' in event) metadata.cwd = event.cwd;
221
+ if ('timestamp' in event) {
222
+ if (!('started_at' in metadata)) metadata.started_at = event.timestamp;
223
+ metadata.ended_at = event.timestamp;
224
+ }
225
+
226
+ if (event.type === 'user') {
227
+ const content = (event.message || {}).content;
228
+ const ts = event.timestamp || '';
229
+
230
+ if (typeof content === 'string') {
231
+ turnCount++;
232
+ const msgKey = content.slice(0, 100).toLowerCase().trim();
233
+ userMessages.push([ts, msgKey]);
234
+
235
+ if (prevUserTs && ts) {
236
+ try {
237
+ const t1 = new Date(prevUserTs.replace('Z', '+00:00'));
238
+ const t2 = new Date(ts.replace('Z', '+00:00'));
239
+ const gapMin = (t2 - t1) / 60000;
240
+ if (gapMin > 10) {
241
+ signals.push({
242
+ ts,
243
+ source: 'user',
244
+ signal: 'long_silence',
245
+ details: `${Math.round(gapMin)} min gap`,
246
+ gap_minutes: gapMin,
247
+ });
248
+ }
249
+ } catch {
250
+ // pass
251
+ }
252
+ }
253
+ prevUserTs = ts;
254
+ }
255
+ }
256
+
257
+ // Check for compaction/summary events
258
+ if (event.type === 'summary') {
259
+ const summaryText = event.summary || '';
260
+ if (summaryText && !summaryText.toLowerCase().includes('exited')) {
261
+ const ts = event.timestamp || metadata.ended_at || '';
262
+ signals.push({
263
+ ts,
264
+ source: 'system',
265
+ signal: 'compaction',
266
+ details: summaryText.slice(0, 50),
267
+ });
268
+ }
269
+ }
270
+ }
271
+
272
+ metadata.turn_count = turnCount;
273
+ const isInteractive = turnCount > 1;
274
+
275
+ // Detect repeated questions
276
+ const seenMessages = {};
277
+ for (const [ts, msgKey] of userMessages) {
278
+ if (msgKey in seenMessages && msgKey.length > 20) {
279
+ signals.push({
280
+ ts,
281
+ source: 'user',
282
+ signal: 'repeated_question',
283
+ details: msgKey.slice(0, 50),
284
+ });
285
+ }
286
+ seenMessages[msgKey] = ts;
287
+ }
288
+
289
+ // Extract signals from events
290
+ for (const event of events) {
291
+ const ts = event.timestamp || '';
292
+
293
+ let content = null;
294
+ if (event.type === 'user') {
295
+ content = (event.message || {}).content;
296
+ } else if (event.type === 'progress') {
297
+ const data = event.data || {};
298
+ const message = data.message || {};
299
+ if (message.type === 'user') {
300
+ content = (message.message || {}).content;
301
+ }
302
+ }
303
+
304
+ if (content !== null && content !== undefined) {
305
+ // Handle list of content blocks
306
+ if (Array.isArray(content)) {
307
+ let foundText = false;
308
+ for (const block of content) {
309
+ if (typeof block === 'object' && block !== null && block.type === 'tool_result') {
310
+ const result = String(block.content || '');
311
+
312
+ if (/Exit code 137|Request interrupted|interrupted by user/i.test(result)) {
313
+ signals.push({
314
+ ts,
315
+ source: 'user',
316
+ signal: 'request_interrupted',
317
+ details: result.slice(0, 100),
318
+ });
319
+ } else if (/<tool_use_error>Sibling tool call errored<\/tool_use_error>/i.test(result)) {
320
+ signals.push({
321
+ ts,
322
+ source: 'system',
323
+ signal: 'sibling_tool_error',
324
+ details: result.slice(0, 100),
325
+ tool_name: extractToolNameFromResult(result),
326
+ });
327
+ } else if (
328
+ (/Exit code [1-9]/.test(result) && !result.includes('Exit code 137')) ||
329
+ /Traceback \(most recent|CalledProcessError/.test(result)
330
+ ) {
331
+ signals.push({
332
+ ts,
333
+ source: 'tool',
334
+ signal: 'exit_error',
335
+ details: result.slice(0, 100),
336
+ });
337
+
338
+ if (llmClaimedSuccess) {
339
+ signals.push({
340
+ ts,
341
+ source: 'llm',
342
+ signal: 'false_success',
343
+ details: 'LLM claimed success but tool failed',
344
+ });
345
+ }
346
+ llmClaimedSuccess = false;
347
+ } else if (/Exit code 0/.test(result)) {
348
+ signals.push({
349
+ ts,
350
+ source: 'tool',
351
+ signal: 'exit_success',
352
+ details: '',
353
+ });
354
+ llmClaimedSuccess = false;
355
+ }
356
+ } else if (typeof block === 'object' && block !== null && block.type === 'text') {
357
+ const text = block.text || '';
358
+ if (text) {
359
+ content = text; // Fall through to user message handling below
360
+ foundText = true;
361
+ break;
362
+ }
363
+ }
364
+ }
365
+ if (!foundText) {
366
+ content = null;
367
+ }
368
+ }
369
+
370
+ // Handle single dict tool_result (legacy format)
371
+ if (typeof content === 'object' && content !== null && !Array.isArray(content) && content.type === 'tool_result') {
372
+ const result = String(content.content || '');
373
+
374
+ if (/Exit code [1-9]|Traceback \(most recent|CalledProcessError/.test(result)) {
375
+ signals.push({
376
+ ts,
377
+ source: 'tool',
378
+ signal: 'exit_error',
379
+ details: result.slice(0, 100),
380
+ });
381
+
382
+ if (llmClaimedSuccess) {
383
+ signals.push({
384
+ ts,
385
+ source: 'llm',
386
+ signal: 'false_success',
387
+ details: 'LLM claimed success but tool failed',
388
+ });
389
+ }
390
+ } else if (/Exit code 0/.test(result)) {
391
+ signals.push({
392
+ ts,
393
+ source: 'tool',
394
+ signal: 'exit_success',
395
+ details: '',
396
+ });
397
+ }
398
+
399
+ llmClaimedSuccess = false;
400
+ }
401
+
402
+ // User messages (GOLD)
403
+ if (typeof content === 'string') {
404
+ if (content.toLowerCase().includes('/stash')) {
405
+ signals.push({
406
+ ts,
407
+ source: 'user',
408
+ signal: 'user_intervention',
409
+ details: 'stash',
410
+ });
411
+ }
412
+
413
+ if (/Request interrupted|interrupted by user/i.test(content)) {
414
+ signals.push({
415
+ ts,
416
+ source: 'user',
417
+ signal: 'request_interrupted',
418
+ details: content.slice(0, 100),
419
+ });
420
+ }
421
+
422
+ if (/\b(fuck|shit|damn)\b/i.test(content)) {
423
+ signals.push({
424
+ ts,
425
+ source: 'user',
426
+ signal: 'user_curse',
427
+ details: content.slice(0, 50),
428
+ });
429
+ }
430
+
431
+ if (isInteractive && /\b(no|didn't work|still broken)\b/i.test(content)) {
432
+ signals.push({
433
+ ts,
434
+ source: 'user',
435
+ signal: 'user_negation',
436
+ details: content.slice(0, 50),
437
+ });
438
+ }
439
+ }
440
+ }
441
+
442
+ // Assistant messages (LLM patterns)
443
+ if (event.type === 'assistant') {
444
+ const assistantContent = (event.message || {}).content || [];
445
+
446
+ if (Array.isArray(assistantContent)) {
447
+ // Success claims
448
+ const text = assistantContent
449
+ .filter(b => b.type === 'text')
450
+ .map(b => b.text || '')
451
+ .join(' ');
452
+ if (/\b(done|complete|success|✅)\b/i.test(text)) {
453
+ llmClaimedSuccess = true;
454
+ }
455
+
456
+ // Tool loops
457
+ for (const block of assistantContent) {
458
+ if (block.type === 'tool_use') {
459
+ const toolName = block.name;
460
+ const sig = JSON.stringify([toolName, JSON.stringify(block.input || {})]);
461
+ toolHistory.push(sig);
462
+
463
+ let count = 0;
464
+ for (const h of toolHistory) {
465
+ if (h === sig) count++;
466
+ }
467
+ if (count >= 3) {
468
+ signals.push({
469
+ ts,
470
+ source: 'llm',
471
+ signal: 'tool_loop',
472
+ details: `${toolName} called ${count}x`,
473
+ tool: toolName,
474
+ loop_count: count,
475
+ });
476
+ }
477
+ }
478
+ }
479
+ }
480
+ }
481
+ }
482
+
483
+ // === POST-PROCESSING SIGNALS (session-level analysis) ===
484
+
485
+ // 0. Deduplicate sibling_tool_error batches
486
+ const deduplicatedSignals = [];
487
+ let siblingBatchStart = null;
488
+ let siblingBatchCount = 0;
489
+ let siblingBatchTools = [];
490
+
491
+ for (const sig of signals) {
492
+ if (sig.signal === 'sibling_tool_error') {
493
+ const currentTs = sig.ts;
494
+
495
+ if (siblingBatchStart === null || currentTs !== siblingBatchStart) {
496
+ if (siblingBatchStart !== null) {
497
+ deduplicatedSignals.push({
498
+ ts: siblingBatchStart,
499
+ source: 'system',
500
+ signal: 'sibling_tool_error',
501
+ details: `${siblingBatchCount} sibling errors in parallel batch`,
502
+ batch_size: siblingBatchCount,
503
+ tools_affected: siblingBatchTools,
504
+ });
505
+ }
506
+ siblingBatchStart = currentTs;
507
+ siblingBatchCount = 1;
508
+ siblingBatchTools = [sig.tool_name || 'unknown'];
509
+ } else {
510
+ siblingBatchCount++;
511
+ siblingBatchTools.push(sig.tool_name || 'unknown');
512
+ }
513
+ } else {
514
+ if (siblingBatchStart !== null) {
515
+ deduplicatedSignals.push({
516
+ ts: siblingBatchStart,
517
+ source: 'system',
518
+ signal: 'sibling_tool_error',
519
+ details: `${siblingBatchCount} sibling errors in parallel batch`,
520
+ batch_size: siblingBatchCount,
521
+ tools_affected: siblingBatchTools,
522
+ });
523
+ siblingBatchStart = null;
524
+ siblingBatchCount = 0;
525
+ siblingBatchTools = [];
526
+ }
527
+ deduplicatedSignals.push(sig);
528
+ }
529
+ }
530
+
531
+ if (siblingBatchStart !== null) {
532
+ deduplicatedSignals.push({
533
+ ts: siblingBatchStart,
534
+ source: 'system',
535
+ signal: 'sibling_tool_error',
536
+ details: `${siblingBatchCount} sibling errors in parallel batch`,
537
+ batch_size: siblingBatchCount,
538
+ tools_affected: siblingBatchTools,
539
+ });
540
+ }
541
+
542
+ // Replace signals with deduplicated version
543
+ const finalSignals = deduplicatedSignals;
544
+
545
+ // 1. interrupt_cascade: 2+ request_interrupted within 60s
546
+ const interruptTimes = [];
547
+ for (const sig of finalSignals) {
548
+ if (sig.signal === 'request_interrupted' && sig.ts) {
549
+ try {
550
+ const t = new Date(sig.ts.replace('Z', '+00:00'));
551
+ if (!isNaN(t.getTime())) interruptTimes.push(t);
552
+ } catch {
553
+ // pass
554
+ }
555
+ }
556
+ }
557
+
558
+ for (let i = 1; i < interruptTimes.length; i++) {
559
+ const gapSec = (interruptTimes[i] - interruptTimes[i - 1]) / 1000;
560
+ if (gapSec <= 60) {
561
+ finalSignals.push({
562
+ ts: interruptTimes[i].toISOString(),
563
+ source: 'user',
564
+ signal: 'interrupt_cascade',
565
+ details: `${Math.round(gapSec)}s between interrupts`,
566
+ gap_seconds: gapSec,
567
+ });
568
+ }
569
+ }
570
+
571
+ // 2. Analyze signal sequence for session-end patterns
572
+ const hasErrors = finalSignals.some(s => s.signal === 'exit_error');
573
+ const hasSuccess = finalSignals.some(s => s.signal === 'exit_success');
574
+ const hasIntervention = finalSignals.some(s => s.signal === 'user_intervention');
575
+
576
+ const last5Signals = finalSignals.slice(-5).map(s => s.signal);
577
+
578
+ const frictionWeights = {
579
+ exit_error: 1,
580
+ user_curse: 5,
581
+ user_negation: 1,
582
+ tool_loop: 6,
583
+ false_success: 8,
584
+ request_interrupted: 4,
585
+ interrupt_cascade: 7,
586
+ repeated_question: 3,
587
+ };
588
+ let last5Friction = 0;
589
+ for (const s of last5Signals) {
590
+ last5Friction += frictionWeights[s] || 0;
591
+ }
592
+
593
+ // 3. rapid_exit
594
+ if (turnCount <= 3 && turnCount > 0) {
595
+ if (last5Signals.length > 0 &&
596
+ (last5Signals[last5Signals.length - 1] === 'exit_error' ||
597
+ last5Signals[last5Signals.length - 1] === 'request_interrupted')) {
598
+ finalSignals.push({
599
+ ts: metadata.ended_at || '',
600
+ source: 'session',
601
+ signal: 'rapid_exit',
602
+ details: `${turnCount} turns, ended with ${last5Signals[last5Signals.length - 1]}`,
603
+ });
604
+ }
605
+ }
606
+
607
+ // 4. no_resolution
608
+ if (hasErrors && !hasSuccess && !hasIntervention && turnCount > 1) {
609
+ const errorCount = finalSignals.filter(s => s.signal === 'exit_error').length;
610
+ finalSignals.push({
611
+ ts: metadata.ended_at || '',
612
+ source: 'session',
613
+ signal: 'no_resolution',
614
+ details: `${errorCount} errors, no success`,
615
+ });
616
+ }
617
+
618
+ // 5. session_abandoned
619
+ if (last5Friction >= 8 && !hasSuccess && !hasIntervention && turnCount > 2) {
620
+ finalSignals.push({
621
+ ts: metadata.ended_at || '',
622
+ source: 'session',
623
+ signal: 'session_abandoned',
624
+ details: `friction ${last5Friction} in last 5 signals, no resolution`,
625
+ });
626
+ }
627
+
628
+ return [finalSignals, metadata];
629
+ }
630
+
631
+ // =============================================================================
632
+ // FRICTION ANALYZE - analyze_session
633
+ // =============================================================================
634
+
635
+ function analyzeSession(sessionId, signals, metadata, config) {
636
+ const weights = config.weights;
637
+
638
+ const bySource = {};
639
+
640
+ function getSource(source) {
641
+ if (!bySource[source]) {
642
+ bySource[source] = {
643
+ total_friction: 0,
644
+ signal_count: 0,
645
+ signals: {},
646
+ };
647
+ }
648
+ return bySource[source];
649
+ }
650
+
651
+ function getSignalData(sourceObj, signalType) {
652
+ if (!sourceObj.signals[signalType]) {
653
+ sourceObj.signals[signalType] = { count: 0, total_weight: 0 };
654
+ }
655
+ return sourceObj.signals[signalType];
656
+ }
657
+
658
+ const frictionTrajectory = [];
659
+ let runningFriction = 0;
660
+ let momentum = 0;
661
+ let errorCount = 0;
662
+ let successCount = 0;
663
+
664
+ for (const sig of signals) {
665
+ const source = sig.source;
666
+ const signalType = sig.signal;
667
+ const weight = weights[signalType] || 0;
668
+
669
+ if (signalType === 'exit_success') {
670
+ successCount++;
671
+ momentum++;
672
+ } else if (signalType === 'exit_error') {
673
+ errorCount++;
674
+ }
675
+
676
+ const sourceObj = getSource(source);
677
+ sourceObj.total_friction += weight;
678
+ sourceObj.signal_count += 1;
679
+ const sigData = getSignalData(sourceObj, signalType);
680
+ sigData.count += 1;
681
+ sigData.total_weight += weight;
682
+
683
+ if (weight > 0) {
684
+ runningFriction += weight;
685
+ frictionTrajectory.push(runningFriction);
686
+ } else if (frictionTrajectory.length > 0) {
687
+ frictionTrajectory.push(frictionTrajectory[frictionTrajectory.length - 1]);
688
+ } else {
689
+ frictionTrajectory.push(0);
690
+ }
691
+ }
692
+
693
+ // Detect patterns
694
+ const patterns = [];
695
+ const peakFriction = frictionTrajectory.length > 0 ? Math.max(...frictionTrajectory) : 0;
696
+ const finalFriction = frictionTrajectory.length > 0 ? frictionTrajectory[frictionTrajectory.length - 1] : 0;
697
+
698
+ if (peakFriction >= config.thresholds.friction_peak && finalFriction < 5) {
699
+ patterns.push({
700
+ type: 'learning_moment',
701
+ friction_before: peakFriction,
702
+ friction_after: finalFriction,
703
+ });
704
+ }
705
+
706
+ // Sequence detection
707
+ const signalSeq = signals.map(s => s.signal);
708
+ for (let i = 0; i < signalSeq.length - 2; i++) {
709
+ const seq = [signalSeq[i], signalSeq[i + 1], signalSeq[i + 2]];
710
+ if (seq[0] === 'exit_error' && seq[1] === 'false_success' && seq[2] === 'user_curse') {
711
+ patterns.push({ type: 'false_success_loop', sequence: seq, count: 1 });
712
+ }
713
+ }
714
+
715
+ // Calculate duration
716
+ let durationMin = 0;
717
+ if (metadata.started_at && metadata.ended_at) {
718
+ try {
719
+ const start = new Date(metadata.started_at.replace('Z', '+00:00'));
720
+ const end = new Date(metadata.ended_at.replace('Z', '+00:00'));
721
+ if (!isNaN(start.getTime()) && !isNaN(end.getTime())) {
722
+ durationMin = Math.floor((end - start) / 60000);
723
+ }
724
+ } catch {
725
+ // pass
726
+ }
727
+ }
728
+ metadata.duration_min = durationMin;
729
+
730
+ // Error ratio
731
+ const totalToolRuns = successCount + errorCount;
732
+ const errorRatio = totalToolRuns > 0 ? errorCount / totalToolRuns : 0;
733
+
734
+ // Session quality assessment
735
+ const hasIntervention = signals.some(s => s.signal === 'user_intervention');
736
+ const hasAbandoned = signals.some(s => s.signal === 'session_abandoned');
737
+ const hasCurse = signals.some(s => s.signal === 'user_curse');
738
+ const hasFalseSuccess = signals.some(s => s.signal === 'false_success');
739
+
740
+ let quality;
741
+ if (hasIntervention || hasAbandoned) {
742
+ quality = 'BAD';
743
+ } else if (hasCurse || hasFalseSuccess) {
744
+ quality = 'FRICTION';
745
+ } else if (peakFriction >= config.thresholds.friction_peak) {
746
+ quality = 'ROUGH';
747
+ } else if (errorRatio > 0.5 && errorCount > 3) {
748
+ quality = 'ROUGH';
749
+ } else if ((metadata.turn_count || 0) <= 1) {
750
+ quality = 'ONE-SHOT';
751
+ } else {
752
+ quality = 'OK';
753
+ }
754
+
755
+ return {
756
+ session_id: sessionId,
757
+ session_metadata: metadata,
758
+ friction_summary: {
759
+ peak: peakFriction,
760
+ final: finalFriction,
761
+ total_signals: signals.length,
762
+ learning_moments: patterns.filter(p => p.type === 'learning_moment').length,
763
+ },
764
+ momentum: {
765
+ success_count: successCount,
766
+ error_count: errorCount,
767
+ error_ratio: Math.round(errorRatio * 100) / 100,
768
+ },
769
+ quality,
770
+ by_source: bySource,
771
+ friction_trajectory: frictionTrajectory,
772
+ patterns_detected: patterns,
773
+ };
774
+ }
775
+
776
+ // =============================================================================
777
+ // FRICTION ANALYZE - aggregate_sessions
778
+ // =============================================================================
779
+
780
+ function aggregateSessions(analyses, config) {
781
+ const aggregateBySource = {};
782
+ const byProject = {};
783
+ const allPatterns = [];
784
+ let highFrictionCount = 0;
785
+ let interventionCount = 0;
786
+
787
+ function getAggSource(source) {
788
+ if (!aggregateBySource[source]) {
789
+ aggregateBySource[source] = {
790
+ sessions_with_signals: 0,
791
+ total_friction: 0,
792
+ top_signals: {},
793
+ };
794
+ }
795
+ return aggregateBySource[source];
796
+ }
797
+
798
+ function getProject(proj) {
799
+ if (!byProject[proj]) {
800
+ byProject[proj] = {
801
+ total_sessions: 0,
802
+ interactive_sessions: 0,
803
+ bad_sessions: 0,
804
+ total_friction: 0,
805
+ total_duration_min: 0,
806
+ total_turns: 0,
807
+ };
808
+ }
809
+ return byProject[proj];
810
+ }
811
+
812
+ for (const analysis of analyses) {
813
+ const peak = analysis.friction_summary.peak;
814
+ const quality = analysis.quality || 'UNKNOWN';
815
+ const sessionId = analysis.session_id || '';
816
+ const metadata = analysis.session_metadata || {};
817
+
818
+ const project = sessionId.includes('/') ? sessionId.split('/')[0] : 'unknown';
819
+
820
+ const proj = getProject(project);
821
+ proj.total_sessions++;
822
+ if ((metadata.turn_count || 0) > 1) proj.interactive_sessions++;
823
+ if (quality === 'BAD') proj.bad_sessions++;
824
+ proj.total_friction += peak;
825
+ proj.total_duration_min += metadata.duration_min || 0;
826
+ proj.total_turns += metadata.turn_count || 0;
827
+
828
+ if (peak >= config.thresholds.friction_peak) highFrictionCount++;
829
+
830
+ for (const [source, data] of Object.entries(analysis.by_source || {})) {
831
+ const aggSrc = getAggSource(source);
832
+ aggSrc.sessions_with_signals++;
833
+ aggSrc.total_friction += data.total_friction;
834
+
835
+ for (const [signalType, signalData] of Object.entries(data.signals || {})) {
836
+ aggSrc.top_signals[signalType] = (aggSrc.top_signals[signalType] || 0) + signalData.count;
837
+ }
838
+ }
839
+
840
+ allPatterns.push(...analysis.patterns_detected);
841
+
842
+ // Interventions
843
+ if (analysis.by_source.user && analysis.by_source.user.signals.user_intervention) {
844
+ interventionCount++;
845
+ }
846
+ if (analysis.by_source.session && analysis.by_source.session.signals.session_abandoned) {
847
+ interventionCount++;
848
+ }
849
+ }
850
+
851
+ // Calculate metrics
852
+ const interventionPred = highFrictionCount > 0 ? interventionCount / highFrictionCount : 0;
853
+
854
+ const totalObjective = ((aggregateBySource.tool || {}).total_friction || 0) +
855
+ ((aggregateBySource.user || {}).total_friction || 0);
856
+ const totalLlm = (aggregateBySource.llm || {}).total_friction || 1;
857
+ const snr = totalLlm !== 0 ? Math.abs(totalObjective / totalLlm) : 0;
858
+
859
+ // Verdict
860
+ const thresholds = config.thresholds;
861
+ const reasons = [];
862
+ const actions = [];
863
+ let status;
864
+
865
+ if (snr < thresholds.signal_noise_ratio) {
866
+ status = 'BLOAT';
867
+ reasons.push(`Signal/noise ratio: ${snr.toFixed(1)} (threshold: ${thresholds.signal_noise_ratio})`);
868
+ } else if (interventionPred < thresholds.intervention_predictability) {
869
+ status = 'INCONCLUSIVE';
870
+ reasons.push(`Intervention predictability: ${Math.round(interventionPred * 100)}% (threshold: ${Math.round(thresholds.intervention_predictability * 100)}%)`);
871
+ } else {
872
+ status = 'USEFUL';
873
+ reasons.push(`Intervention predictability: ${Math.round(interventionPred * 100)}% (threshold: ${Math.round(thresholds.intervention_predictability * 100)}%)`);
874
+ reasons.push(`Signal/noise ratio: ${snr.toFixed(1)} (threshold: ${thresholds.signal_noise_ratio})`);
875
+
876
+ const userCurses = ((aggregateBySource.user || {}).top_signals || {}).user_curse || 0;
877
+ if (userCurses > 5) {
878
+ actions.push('Consider increasing user_curse weight (high occurrence)');
879
+ }
880
+
881
+ const falseSuccessLoops = allPatterns.filter(p => p.type === 'false_success_loop').length;
882
+ if (falseSuccessLoops > 3) {
883
+ actions.push('Create antigen for false_success pattern');
884
+ }
885
+ }
886
+
887
+ // Convert aggregateBySource to output format
888
+ const aggregateBySourceDict = {};
889
+ for (const [source, data] of Object.entries(aggregateBySource)) {
890
+ const sessionsCount = data.sessions_with_signals;
891
+ // Sort top_signals by count descending (like Python's Counter.most_common)
892
+ const sortedSignals = Object.entries(data.top_signals)
893
+ .sort((a, b) => b[1] - a[1]);
894
+ const topSignals = {};
895
+ for (const [k, v] of sortedSignals) topSignals[k] = v;
896
+
897
+ aggregateBySourceDict[source] = {
898
+ sessions_with_signals: sessionsCount,
899
+ total_friction: data.total_friction,
900
+ avg_friction_per_session: sessionsCount > 0 ? data.total_friction / sessionsCount : 0,
901
+ top_signals: topSignals,
902
+ };
903
+ }
904
+
905
+ // Common sequences (simplified, same as Python)
906
+ const commonSequences = [];
907
+
908
+ // Per-project stats
909
+ const projectStats = {};
910
+ for (const [project, data] of Object.entries(byProject)) {
911
+ const total = data.total_sessions;
912
+ const interactive = data.interactive_sessions;
913
+ projectStats[project] = {
914
+ total_sessions: total,
915
+ interactive_sessions: interactive,
916
+ bad_sessions: data.bad_sessions,
917
+ bad_rate: interactive > 0 ? Math.round(data.bad_sessions / interactive * 100) / 100 : 0,
918
+ avg_friction: total > 0 ? Math.round(data.total_friction / total * 10) / 10 : 0,
919
+ avg_duration_min: total > 0 ? Math.round(data.total_duration_min / total * 10) / 10 : 0,
920
+ avg_turns: total > 0 ? Math.round(data.total_turns / total * 10) / 10 : 0,
921
+ };
922
+ }
923
+
924
+ // Overall averages
925
+ let totalInteractive = 0, totalBad = 0, totalFriction = 0, totalDuration = 0, totalTurns = 0;
926
+ for (const d of Object.values(byProject)) {
927
+ totalInteractive += d.interactive_sessions;
928
+ totalBad += d.bad_sessions;
929
+ totalFriction += d.total_friction;
930
+ totalDuration += d.total_duration_min;
931
+ totalTurns += d.total_turns;
932
+ }
933
+ const totalSessions = analyses.length;
934
+
935
+ const overallStats = {
936
+ total_sessions: totalSessions,
937
+ interactive_sessions: totalInteractive,
938
+ bad_sessions: totalBad,
939
+ bad_rate: totalInteractive > 0 ? Math.round(totalBad / totalInteractive * 100) / 100 : 0,
940
+ avg_friction: totalSessions > 0 ? Math.round(totalFriction / totalSessions * 10) / 10 : 0,
941
+ avg_duration_min: totalSessions > 0 ? Math.round(totalDuration / totalSessions * 10) / 10 : 0,
942
+ avg_turns: totalSessions > 0 ? Math.round(totalTurns / totalSessions * 10) / 10 : 0,
943
+ };
944
+
945
+ // Time-series: daily stats
946
+ const byDay = {};
947
+ for (const analysis of analyses) {
948
+ const started = (analysis.session_metadata || {}).started_at || '';
949
+ if (started) {
950
+ try {
951
+ const dt = new Date(started.replace('Z', '+00:00'));
952
+ if (!isNaN(dt.getTime())) {
953
+ const day = dt.toISOString().slice(0, 10);
954
+ if (!byDay[day]) byDay[day] = { total: 0, interactive: 0, bad: 0, friction: 0 };
955
+ byDay[day].total++;
956
+ if ((analysis.session_metadata || {}).turn_count > 1) byDay[day].interactive++;
957
+ if (analysis.quality === 'BAD') byDay[day].bad++;
958
+ byDay[day].friction += (analysis.friction_summary || {}).peak || 0;
959
+ }
960
+ } catch {
961
+ // pass
962
+ }
963
+ }
964
+ }
965
+
966
+ const dailyStats = Object.keys(byDay).sort().map(day => {
967
+ const d = byDay[day];
968
+ return {
969
+ date: day,
970
+ total: d.total,
971
+ interactive: d.interactive,
972
+ bad: d.bad,
973
+ bad_rate: d.interactive > 0 ? Math.round(d.bad / d.interactive * 100) / 100 : 0,
974
+ avg_friction: d.total > 0 ? Math.round(d.friction / d.total * 10) / 10 : 0,
975
+ };
976
+ });
977
+
978
+ // Best and worst sessions
979
+ const interactiveAnalyses = analyses.filter(a => (a.session_metadata || {}).turn_count > 1);
980
+
981
+ let worstSession = null;
982
+ let bestSession = null;
983
+
984
+ if (interactiveAnalyses.length > 0) {
985
+ worstSession = interactiveAnalyses.reduce((max, a) =>
986
+ (a.friction_summary.peak > max.friction_summary.peak) ? a : max
987
+ );
988
+
989
+ const okSessions = interactiveAnalyses.filter(a => a.quality === 'OK');
990
+ if (okSessions.length > 0) {
991
+ bestSession = okSessions.reduce((min, a) =>
992
+ (a.friction_summary.peak < min.friction_summary.peak) ? a : min
993
+ );
994
+ } else {
995
+ bestSession = interactiveAnalyses.reduce((min, a) =>
996
+ (a.friction_summary.peak < min.friction_summary.peak) ? a : min
997
+ );
998
+ }
999
+ }
1000
+
1001
+ return {
1002
+ analyzed_at: new Date().toISOString(),
1003
+ sessions_analyzed: analyses.length,
1004
+ config_used: config,
1005
+ aggregate_by_source: aggregateBySourceDict,
1006
+ by_project: projectStats,
1007
+ overall: overallStats,
1008
+ daily_stats: dailyStats,
1009
+ best_session: bestSession ? {
1010
+ session_id: bestSession.session_id,
1011
+ quality: bestSession.quality,
1012
+ peak_friction: (bestSession.friction_summary || {}).peak || 0,
1013
+ turns: (bestSession.session_metadata || {}).turn_count || 0,
1014
+ duration_min: (bestSession.session_metadata || {}).duration_min || 0,
1015
+ } : null,
1016
+ worst_session: worstSession ? {
1017
+ session_id: worstSession.session_id,
1018
+ quality: worstSession.quality,
1019
+ peak_friction: (worstSession.friction_summary || {}).peak || 0,
1020
+ turns: (worstSession.session_metadata || {}).turn_count || 0,
1021
+ duration_min: (worstSession.session_metadata || {}).duration_min || 0,
1022
+ } : null,
1023
+ correlations: {
1024
+ high_friction_sessions: highFrictionCount,
1025
+ intervention_sessions: interventionCount,
1026
+ intervention_predictability: Math.round(interventionPred * 100) / 100,
1027
+ },
1028
+ common_sequences: commonSequences,
1029
+ verdict: { status, reasons, recommended_actions: actions },
1030
+ };
1031
+ }
1032
+
1033
+ // =============================================================================
1034
+ // FRICTION ANALYZE - print helpers
1035
+ // =============================================================================
1036
+
1037
+ function printBox(title, lines, width) {
1038
+ width = width || 60;
1039
+ const hr = '\u2500'.repeat(width - 2);
1040
+ console.log(`\u250C${hr}\u2510`);
1041
+ console.log(`\u2502 ${title.toUpperCase().padEnd(width - 4)} \u2502`);
1042
+ console.log(`\u251C${hr}\u2524`);
1043
+ for (let line of lines) {
1044
+ if (line.length > width - 4) line = line.slice(0, width - 7) + '...';
1045
+ console.log(`\u2502 ${line.padEnd(width - 4)} \u2502`);
1046
+ }
1047
+ console.log(`\u2514${hr}\u2518`);
1048
+ }
1049
+
1050
+ function printTable(headers, rows, colWidths) {
1051
+ if (!colWidths) {
1052
+ colWidths = headers.map((h, i) => {
1053
+ let max = String(h).length;
1054
+ for (const row of rows) {
1055
+ const len = String(row[i]).length;
1056
+ if (len > max) max = len;
1057
+ }
1058
+ return max + 2;
1059
+ });
1060
+ }
1061
+
1062
+ const topBorder = '\u250C' + colWidths.map(w => '\u2500'.repeat(w)).join('\u252C') + '\u2510';
1063
+ const headerLine = '\u2502' + headers.map((h, i) => ` ${String(h).padEnd(colWidths[i] - 2)} `).join('\u2502') + '\u2502';
1064
+ const sep = '\u251C' + colWidths.map(w => '\u2500'.repeat(w)).join('\u253C') + '\u2524';
1065
+
1066
+ console.log(topBorder);
1067
+ console.log(headerLine);
1068
+ console.log(sep);
1069
+
1070
+ for (const row of rows) {
1071
+ const rowLine = '\u2502' + row.map((v, i) => ` ${String(v).padEnd(colWidths[i] - 2)} `).join('\u2502') + '\u2502';
1072
+ console.log(rowLine);
1073
+ }
1074
+
1075
+ const bottomBorder = '\u2514' + colWidths.map(w => '\u2500'.repeat(w)).join('\u2534') + '\u2518';
1076
+ console.log(bottomBorder);
1077
+ }
1078
+
1079
+ // =============================================================================
1080
+ // FRICTION ANALYZE - generate_detailed_report
1081
+ // =============================================================================
1082
+
1083
+ function generateDetailedReport(outputDir, analyses, summary, config, signalCounts, multiProject) {
1084
+ const report = [];
1085
+
1086
+ report.push('# Friction Analysis - Detailed Report\n\n');
1087
+ report.push(`**Generated:** ${new Date().toISOString().replace('T', ' ').slice(0, 19)} UTC\n\n`);
1088
+ report.push(`**Sessions Analyzed:** ${analyses.length}\n`);
1089
+ report.push(`**Interactive Sessions:** ${summary.overall.interactive_sessions} (multi-turn conversations)\n`);
1090
+ report.push(`**BAD Sessions:** ${summary.overall.bad_sessions} (${Math.round(summary.overall.bad_rate * 100)}% of interactive)\n\n`);
1091
+
1092
+ // Glossary
1093
+ report.push('## Glossary\n\n');
1094
+ report.push('**Interactive Session:** A conversation with >1 turn (multi-turn dialogue). Single-turn sessions are filtered from BAD rate calculation.\n\n');
1095
+ report.push('**BAD Session:** User gave up via `/stash`, `/exit`, or silent abandonment (high friction with no resolution).\n\n');
1096
+ report.push('**Friction:** Cumulative weight of negative signals. Higher friction = more user frustration.\n\n');
1097
+ report.push('**Peak Friction:** Maximum friction reached during a session.\n\n');
1098
+ report.push('---\n\n');
1099
+
1100
+ // Executive summary
1101
+ report.push('## Executive Summary\n\n');
1102
+ const badRate = summary.overall.bad_rate;
1103
+ const overall = summary.overall;
1104
+
1105
+ if (badRate > 0.5) {
1106
+ report.push(`\u26A0\uFE0F **CRITICAL**: ${Math.round(badRate * 100)}% of interactive sessions end in failure. `);
1107
+ } else if (badRate > 0.3) {
1108
+ report.push(`\uD83D\uDFE1 **WARNING**: ${Math.round(badRate * 100)}% of interactive sessions end in failure. `);
1109
+ } else {
1110
+ report.push(`\u2705 **HEALTHY**: ${Math.round(badRate * 100)}% of interactive sessions end in failure. `);
1111
+ }
1112
+
1113
+ report.push(`Average session: ${overall.avg_turns.toFixed(1)} turns, ${overall.avg_friction.toFixed(1)} friction, ${Math.round(overall.avg_duration_min)} min.\n\n`);
1114
+
1115
+ // Top issues
1116
+ const topSignals = sortedEntries(signalCounts).slice(0, 3);
1117
+ report.push('**Top Issues:**\n');
1118
+ for (const [sig, count] of topSignals) {
1119
+ const weight = config.weights[sig] || 0;
1120
+ const total = count * weight;
1121
+ report.push(`- **${sig}** (${count} occurrences, ${Math.round(total)} total friction)\n`);
1122
+ }
1123
+ report.push('\n');
1124
+ report.push('---\n\n');
1125
+
1126
+ // Weight system
1127
+ report.push('## Friction Weight System\n\n');
1128
+ report.push('Each signal has a weight representing its severity. Friction accumulates as signals occur.\n\n');
1129
+ report.push('| Weight | Severity | Meaning |\n');
1130
+ report.push('|--------|----------|----------|\n');
1131
+ report.push('| +10 | CRITICAL | User gave up (intervention, abandonment) |\n');
1132
+ report.push('| +8 | SEVERE | LLM false claims or no progress (false_success, no_resolution) |\n');
1133
+ report.push('| +7 | HIGH | User frustration (interrupt_cascade) |\n');
1134
+ report.push('| +6 | MEDIUM | Stuck patterns (tool_loop, rapid_exit) |\n');
1135
+ report.push('| +4-5 | LOW-MEDIUM | User signals (request_interrupted, user_curse) |\n');
1136
+ report.push('| +1 | MINOR | Technical issues (exit_error, repeated_question) |\n');
1137
+ report.push('| +0.5 | NOISE | Context signals (compaction, long_silence, user_negation) |\n\n');
1138
+ report.push('---\n\n');
1139
+
1140
+ // Signal breakdown
1141
+ report.push('## Signal Breakdown\n\n');
1142
+ report.push('| Signal | Count | Weight | Total Friction | What It Means |\n');
1143
+ report.push('|--------|-------|--------|----------------|---------------|\n');
1144
+
1145
+ const signalMeanings = {
1146
+ exit_error: 'Command failed (exit code != 0)',
1147
+ compaction: 'Context overflow, conversation summarized',
1148
+ repeated_question: 'User asked same question twice',
1149
+ request_interrupted: 'User hit Ctrl+C or ESC',
1150
+ long_silence: 'User paused >10 min',
1151
+ user_negation: '"no", "didn\'t work", "still broken"',
1152
+ false_success: 'LLM claimed success after error',
1153
+ user_intervention: 'User gave up (/stash, /exit)',
1154
+ interrupt_cascade: '2+ interrupts within 60s',
1155
+ session_abandoned: 'High friction, no resolution',
1156
+ no_resolution: 'Errors without subsequent success',
1157
+ exit_success: 'Command succeeded (exit code 0)',
1158
+ tool_loop: 'Same tool called 3+ times',
1159
+ rapid_exit: '<3 turns, ends with error/interrupt',
1160
+ user_curse: 'User frustration (profanity)',
1161
+ sibling_tool_error: 'Parallel tools canceled (SDK cascade)',
1162
+ };
1163
+
1164
+ for (const [sigType, count] of sortedEntries(signalCounts)) {
1165
+ const weight = config.weights[sigType] || 0;
1166
+ const total = count * weight;
1167
+ const meaning = signalMeanings[sigType] || 'Unknown signal';
1168
+ const weightStr = weight >= 0 ? `+${weight.toFixed(1)}` : weight.toFixed(1);
1169
+ report.push(`| ${sigType} | ${count} | ${weightStr} | ${total.toFixed(1)} | ${meaning} |\n`);
1170
+ }
1171
+ report.push('\n');
1172
+
1173
+ // Pattern analysis
1174
+ report.push('## Pattern Analysis\n\n');
1175
+
1176
+ const falseSuccessCount = signalCounts.false_success || 0;
1177
+ const exitErrorCount = signalCounts.exit_error || 0;
1178
+ const interruptCount = signalCounts.request_interrupted || 0;
1179
+ const interventionCountLocal = signalCounts.user_intervention || 0;
1180
+
1181
+ report.push('### Common Failure Patterns\n\n');
1182
+
1183
+ if (falseSuccessCount > 0) {
1184
+ report.push(`**False Success Loop** (${falseSuccessCount} occurrences): LLM claims task is complete after command fails. `);
1185
+ report.push('This indicates the LLM is not checking exit codes properly.\n\n');
1186
+ }
1187
+ if (exitErrorCount > 50) {
1188
+ report.push(`**High Error Rate** (${exitErrorCount} errors): Many commands are failing. `);
1189
+ report.push('This suggests either environment issues or LLM choosing wrong approaches.\n\n');
1190
+ }
1191
+ if (interruptCount > 20) {
1192
+ report.push(`**User Interruptions** (${interruptCount} interrupts): Users frequently canceling operations. `);
1193
+ report.push('Commands may be too slow, stuck, or heading in wrong direction.\n\n');
1194
+ }
1195
+ if (interventionCountLocal > 0) {
1196
+ const interventionRate = interventionCountLocal / summary.overall.interactive_sessions;
1197
+ report.push(`**Abandonment Rate** (${Math.round(interventionRate * 100)}%): ${interventionCountLocal}/${summary.overall.interactive_sessions} interactive sessions ended with user giving up. `);
1198
+ if (interventionRate > 0.3) {
1199
+ report.push('This is CRITICAL - users are frequently giving up.\n\n');
1200
+ } else {
1201
+ report.push('This is acceptable for complex tasks.\n\n');
1202
+ }
1203
+ }
1204
+
1205
+ // Friction level breakdown
1206
+ report.push('### Friction Level Breakdown\n\n');
1207
+ const lowFriction = analyses.filter(a => a.friction_summary.peak > 0 && a.friction_summary.peak < 15);
1208
+ const mediumFriction = analyses.filter(a => a.friction_summary.peak >= 15 && a.friction_summary.peak < 50);
1209
+ const highFriction = analyses.filter(a => a.friction_summary.peak >= 50);
1210
+
1211
+ report.push(`**Low Friction (0-15):** ${lowFriction.length} sessions - Normal operation, minor errors quickly resolved\n\n`);
1212
+ report.push(`**Medium Friction (15-50):** ${mediumFriction.length} sessions - Some struggles, multiple retries, but eventually successful\n\n`);
1213
+ report.push(`**High Friction (50+):** ${highFriction.length} sessions - Severe issues, user frustration, likely gave up\n\n`);
1214
+ report.push('---\n\n');
1215
+
1216
+ // High-friction sessions
1217
+ report.push('## Top Friction Sessions\n\n');
1218
+ let topFriction = analyses.slice().sort((a, b) => b.friction_summary.peak - a.friction_summary.peak).slice(0, 20);
1219
+ topFriction = topFriction.filter(a => a.friction_summary.peak > 0);
1220
+
1221
+ if (multiProject) {
1222
+ report.push('| Project | Session | Quality | Peak | Turns | Duration | Top Signals |\n');
1223
+ report.push('|---------|---------|---------|------|-------|----------|-------------|\n');
1224
+ } else {
1225
+ report.push('| Session | Quality | Peak | Turns | Duration | Top Signals |\n');
1226
+ report.push('|---------|---------|------|-------|----------|-------------|\n');
1227
+ }
1228
+
1229
+ for (const a of topFriction) {
1230
+ const fullSid = a.session_id;
1231
+ const project = fullSid.includes('/') ? fullSid.split('/')[0] : '?';
1232
+ const sid = fullSid.includes('/') ? fullSid.split('/').slice(-1)[0] : fullSid;
1233
+ const peak = a.friction_summary.peak;
1234
+ const turns = (a.session_metadata || {}).turn_count || 0;
1235
+ const dur = (a.session_metadata || {}).duration_min || 0;
1236
+ const durStr = dur ? formatDuration(dur) : '-';
1237
+ const quality = a.quality || '?';
1238
+
1239
+ const topSigs = [];
1240
+ for (const [, data] of Object.entries(a.by_source || {})) {
1241
+ for (const [sigType, sigData] of Object.entries(data.signals || {})) {
1242
+ if (sigData.count > 0) {
1243
+ const shortName = sigType.replace('user_', '').replace('exit_', '');
1244
+ topSigs.push(`${shortName}:${sigData.count}`);
1245
+ }
1246
+ }
1247
+ }
1248
+ const sigsStr = topSigs.length > 0 ? topSigs.slice(0, 3).join(', ') : '-';
1249
+
1250
+ if (multiProject) {
1251
+ report.push(`| ${project} | ${sid} | ${quality} | ${peak} | ${turns} | ${durStr} | ${sigsStr} |\n`);
1252
+ } else {
1253
+ report.push(`| ${sid} | ${quality} | ${peak} | ${turns} | ${durStr} | ${sigsStr} |\n`);
1254
+ }
1255
+ }
1256
+ report.push('\n');
1257
+
1258
+ // Session quality breakdown
1259
+ report.push('## Session Quality Breakdown\n\n');
1260
+ const qualityCounts = {};
1261
+ for (const a of analyses) {
1262
+ const q = a.quality || 'UNKNOWN';
1263
+ qualityCounts[q] = (qualityCounts[q] || 0) + 1;
1264
+ }
1265
+ const qualityOrder = ['BAD', 'FRICTION', 'ROUGH', 'OK', 'ONE-SHOT'];
1266
+ const qualityDesc = {
1267
+ BAD: 'user gave up (/stash)',
1268
+ FRICTION: 'curse or false_success',
1269
+ ROUGH: 'high friction but completed',
1270
+ OK: 'no significant friction',
1271
+ 'ONE-SHOT': 'single turn (filtered)',
1272
+ };
1273
+
1274
+ report.push('| Quality | Count | Description |\n');
1275
+ report.push('|---------|-------|-------------|\n');
1276
+ for (const q of qualityOrder) {
1277
+ const count = qualityCounts[q] || 0;
1278
+ const desc = qualityDesc[q] || '';
1279
+ if (count > 0) {
1280
+ report.push(`| ${q} | ${count} | ${desc} |\n`);
1281
+ }
1282
+ }
1283
+ report.push('\n');
1284
+
1285
+ // Per-project stats
1286
+ if (summary.by_project && Object.keys(summary.by_project).length > 0) {
1287
+ report.push('## Per-Project Statistics\n\n');
1288
+ report.push('| Project | Interactive | BAD | BAD % | Avg Friction | Avg Turns | Avg Duration |\n');
1289
+ report.push('|---------|-------------|-----|-------|--------------|-----------|-------------|\n');
1290
+ for (const proj of Object.keys(summary.by_project).sort()) {
1291
+ const stats = summary.by_project[proj];
1292
+ const badRatePct = stats.interactive_sessions > 0 ? `${Math.round(stats.bad_rate * 100)}%` : '-';
1293
+ const dur = stats.avg_duration_min || 0;
1294
+ const durStr = dur ? formatDuration(Math.round(dur)) : '-';
1295
+ report.push(`| ${proj} | ${stats.interactive_sessions} | ${stats.bad_sessions} | ${badRatePct} | ${stats.avg_friction.toFixed(1)} | ${stats.avg_turns.toFixed(1)} | ${durStr} |\n`);
1296
+ }
1297
+ report.push('\n');
1298
+ }
1299
+
1300
+ // Recommendations
1301
+ report.push('## Recommendations\n\n');
1302
+ const recommendations = [];
1303
+
1304
+ if (falseSuccessCount > 10) {
1305
+ recommendations.push('**High Priority:** Add CLAUDE.md rule to verify exit codes before claiming success');
1306
+ }
1307
+ if (interruptCount > 20) {
1308
+ recommendations.push('**High Priority:** Commands timing out or stuck - review for heavy operations that need optimization');
1309
+ }
1310
+ if ((signalCounts.tool_loop || 0) > 3) {
1311
+ recommendations.push('**Medium Priority:** Add CLAUDE.md rule to detect and break out of tool loops');
1312
+ }
1313
+ if (interventionCountLocal / summary.overall.interactive_sessions > 0.4) {
1314
+ recommendations.push('**Critical:** >40% abandonment rate - major UX issues, review antigens for patterns');
1315
+ }
1316
+ if ((signalCounts.repeated_question || 0) > 20) {
1317
+ recommendations.push('**Medium Priority:** Many repeated questions - LLM not understanding user intent or context issues');
1318
+ }
1319
+
1320
+ if (recommendations.length > 0) {
1321
+ recommendations.forEach((rec, i) => report.push(`${i + 1}. ${rec}\n\n`));
1322
+ } else {
1323
+ report.push('No critical issues detected. Continue monitoring.\n\n');
1324
+ }
1325
+
1326
+ report.push('---\n\n');
1327
+
1328
+ // Daily trend
1329
+ if (summary.daily_stats && summary.daily_stats.length > 0) {
1330
+ report.push('## Daily Trend (Last 14 Days)\n\n');
1331
+ report.push('| Date | Interactive | BAD | Rate | Trend |\n');
1332
+ report.push('|------|-------------|-----|------|-------|\n');
1333
+ for (const day of summary.daily_stats.slice(-14)) {
1334
+ const dayBadRate = day.interactive > 0 ? day.bad_rate : 0;
1335
+ const badRatePct = day.interactive > 0 ? `${Math.round(dayBadRate * 100)}%` : '-';
1336
+ const barLen = Math.round(dayBadRate * 10);
1337
+ const bar = '\u2588'.repeat(barLen) + '\u2591'.repeat(10 - barLen);
1338
+ report.push(`| ${day.date} | ${day.interactive} | ${day.bad} | ${badRatePct} | ${bar} |\n`);
1339
+ }
1340
+ report.push('\n');
1341
+ }
1342
+
1343
+ // Write report
1344
+ fs.writeFileSync(path.join(outputDir, 'report.md'), report.join(''));
1345
+ }
1346
+
1347
+ /**
1348
+ * Sort object entries by value descending (like Python Counter.most_common).
1349
+ */
1350
+ function sortedEntries(obj) {
1351
+ return Object.entries(obj).sort((a, b) => b[1] - a[1]);
1352
+ }
1353
+
1354
+ // =============================================================================
1355
+ // FRICTION ANALYZE - main
1356
+ // =============================================================================
1357
+
1358
+ function analyzeMain(sessionsDir) {
1359
+ const inputPath = sessionsDir;
1360
+ const config = loadConfig();
1361
+
1362
+ // Find sessions
1363
+ let sessionFiles = [];
1364
+ let stat;
1365
+ try {
1366
+ stat = fs.statSync(inputPath);
1367
+ } catch {
1368
+ console.log(`No sessions found in ${inputPath}`);
1369
+ return 1;
1370
+ }
1371
+
1372
+ if (stat.isFile()) {
1373
+ sessionFiles = [inputPath];
1374
+ } else {
1375
+ // Try direct session files first
1376
+ sessionFiles = globFiles(inputPath, '*.jsonl', false)
1377
+ .filter(f => !path.basename(f).includes('sessions-index'));
1378
+
1379
+ // If no direct session files, check subdirectories
1380
+ if (sessionFiles.length === 0) {
1381
+ const projectDirs = listDirs(inputPath)
1382
+ .filter(d => !path.basename(d).startsWith('.'));
1383
+
1384
+ for (const projDir of projectDirs) {
1385
+ const projSessions = globFiles(projDir, '*.jsonl', false);
1386
+ if (projSessions.length > 0) {
1387
+ sessionFiles = [];
1388
+ for (const pd of projectDirs) {
1389
+ const files = globFiles(pd, '*.jsonl', false)
1390
+ .filter(f => !path.basename(f).includes('sessions-index'));
1391
+ sessionFiles.push(...files);
1392
+ }
1393
+ break;
1394
+ }
1395
+ }
1396
+ }
1397
+ }
1398
+
1399
+ if (sessionFiles.length === 0) {
1400
+ console.log(`No sessions found in ${inputPath}`);
1401
+ return 1;
1402
+ }
1403
+
1404
+ // Create output dir
1405
+ const outputDir = '.factory/friction';
1406
+ fs.mkdirSync(outputDir, { recursive: true });
1407
+
1408
+ // Process each session
1409
+ const analyses = [];
1410
+ const allSignals = [];
1411
+ const errors = [];
1412
+
1413
+ const projectParents = new Set(sessionFiles.map(f => path.basename(path.dirname(f))));
1414
+ const multiProject = projectParents.size > 1;
1415
+
1416
+ if (multiProject) {
1417
+ console.log(`Found sessions from ${projectParents.size} projects\n`);
1418
+ }
1419
+
1420
+ for (const sessionFile of sessionFiles) {
1421
+ try {
1422
+ const [signals, metadata] = extractSignals(sessionFile);
1423
+ const sessionName = deriveSessionName(sessionFile, metadata);
1424
+
1425
+ for (const sig of signals) {
1426
+ sig.session = sessionName;
1427
+ allSignals.push(sig);
1428
+ }
1429
+
1430
+ const analysis = analyzeSession(sessionName, signals, metadata, config);
1431
+ analyses.push(analysis);
1432
+ } catch (e) {
1433
+ errors.push([path.basename(sessionFile).slice(0, 12), String(e).slice(0, 40)]);
1434
+ }
1435
+ }
1436
+
1437
+ // Write consolidated raw signals
1438
+ const rawLines = allSignals.map(sig => JSON.stringify(sig)).join('\n') + (allSignals.length > 0 ? '\n' : '');
1439
+ fs.writeFileSync(path.join(outputDir, 'friction_raw.jsonl'), rawLines);
1440
+
1441
+ // Write consolidated analysis
1442
+ fs.writeFileSync(path.join(outputDir, 'friction_analysis.json'), JSON.stringify(analyses, null, 2));
1443
+
1444
+ if (analyses.length === 0) {
1445
+ console.log('\nNo sessions could be analyzed');
1446
+ return 1;
1447
+ }
1448
+
1449
+ // Aggregate
1450
+ const summary = aggregateSessions(analyses, config);
1451
+ fs.writeFileSync(path.join(outputDir, 'friction_summary.json'), JSON.stringify(summary, null, 2));
1452
+
1453
+ // === CONCISE TERMINAL OUTPUT ===
1454
+ console.log();
1455
+ console.log('='.repeat(60));
1456
+ console.log('FRICTION ANALYSIS');
1457
+ console.log('='.repeat(60));
1458
+ console.log();
1459
+
1460
+ const agg = summary.aggregate_by_source;
1461
+ const corr = summary.correlations;
1462
+ const overall = summary.overall;
1463
+
1464
+ const interactive = overall.interactive_sessions;
1465
+ const badCount = overall.bad_sessions;
1466
+ const badRateVal = overall.bad_rate;
1467
+
1468
+ const projectsCount = Object.keys(summary.by_project || {}).length;
1469
+ console.log(`Analyzed: ${analyses.length} sessions (${interactive} interactive*) from ${projectsCount} project${projectsCount !== 1 ? 's' : ''}`);
1470
+ console.log(' *interactive = multi-turn conversations (>1 turn)');
1471
+
1472
+ const emoji = badRateVal > 0.5 ? '\uD83D\uDD34' : badRateVal > 0.3 ? '\uD83D\uDFE1' : '\u2705';
1473
+ console.log(`BAD Rate: ${Math.round(badRateVal * 100)}% (${badCount}/${interactive} interactive) ${emoji}`);
1474
+ console.log();
1475
+
1476
+ // Top signals
1477
+ const signalCounts = {};
1478
+ for (const sourceData of Object.values(agg)) {
1479
+ for (const [sigType, count] of Object.entries(sourceData.top_signals || {})) {
1480
+ signalCounts[sigType] = (signalCounts[sigType] || 0) + count;
1481
+ }
1482
+ }
1483
+
1484
+ if (Object.keys(signalCounts).length > 0) {
1485
+ console.log('Top Signals:');
1486
+ const topSigs = sortedEntries(signalCounts).slice(0, 5);
1487
+ for (const [sigType, count] of topSigs) {
1488
+ const weight = config.weights[sigType] || 0;
1489
+ const totalFriction = count * weight;
1490
+ const sign = totalFriction >= 0 ? '+' : '';
1491
+ console.log(` ${sigType.padEnd(20)} ${String(count).padStart(3)} (${sign}${Math.round(totalFriction)} friction)`);
1492
+ }
1493
+ console.log();
1494
+ }
1495
+
1496
+ // Per-project stats
1497
+ if (summary.by_project && Object.keys(summary.by_project).length > 0) {
1498
+ console.log('Per-Project:');
1499
+ for (const proj of Object.keys(summary.by_project).sort()) {
1500
+ const stats = summary.by_project[proj];
1501
+ const projBadRate = stats.bad_rate;
1502
+ const projEmoji = projBadRate > 0.5 ? '\uD83D\uDD34' : projBadRate > 0.3 ? '\uD83D\uDFE1' : '\u2705';
1503
+ const badPct = stats.interactive_sessions > 0 ? `${Math.round(projBadRate * 100)}%` : '-';
1504
+
1505
+ const projSessions = analyses.filter(a => a.session_id.startsWith(`${proj}/`));
1506
+ const interactiveSessions = projSessions.filter(a => (a.session_metadata || {}).turn_count > 1);
1507
+ if (interactiveSessions.length > 0) {
1508
+ const frictions = interactiveSessions.map(a => a.friction_summary.peak).sort((a, b) => a - b);
1509
+ const median = frictions[Math.floor(frictions.length / 2)];
1510
+ console.log(` ${proj.padEnd(12)} ${badPct.padStart(4)} BAD (${stats.bad_sessions}/${stats.interactive_sessions}) median: ${median.toFixed(1)} ${projEmoji}`);
1511
+ } else {
1512
+ console.log(` ${proj.padEnd(12)} ${badPct.padStart(4)} BAD (${stats.bad_sessions}/${stats.interactive_sessions}) ${projEmoji}`);
1513
+ }
1514
+ }
1515
+ console.log();
1516
+ }
1517
+
1518
+ // Best and worst
1519
+ if (summary.best_session && summary.worst_session) {
1520
+ const ws = summary.worst_session;
1521
+ const bs = summary.best_session;
1522
+ console.log('Session Extremes:');
1523
+
1524
+ const wsId = multiProject ? ws.session_id : (ws.session_id.includes('/') ? ws.session_id.split('/').slice(-1)[0] : ws.session_id);
1525
+ console.log(` WORST: ${wsId} peak=${ws.peak_friction} turns=${ws.turns}`);
1526
+
1527
+ const bsId = multiProject ? bs.session_id : (bs.session_id.includes('/') ? bs.session_id.split('/').slice(-1)[0] : bs.session_id);
1528
+ console.log(` BEST: ${bsId} peak=${bs.peak_friction} turns=${bs.turns}`);
1529
+ console.log();
1530
+ }
1531
+
1532
+ // Last 2 weeks trend
1533
+ if (summary.daily_stats && summary.daily_stats.length > 0) {
1534
+ console.log('Last 2 Weeks:');
1535
+ for (const day of summary.daily_stats.slice(-14)) {
1536
+ if (day.interactive === 0) continue;
1537
+ const dayBadRate = day.bad_rate;
1538
+ const barLen = Math.round(dayBadRate * 10);
1539
+ const bar = '\u2588'.repeat(barLen) + '\u2591'.repeat(10 - barLen);
1540
+ console.log(` ${day.date} ${String(day.interactive).padStart(2)} sessions ${String(day.bad).padStart(2)} BAD ${bar} ${Math.round(dayBadRate * 100)}%`);
1541
+ }
1542
+ console.log();
1543
+ }
1544
+
1545
+ // Verdict
1546
+ const verdict = summary.verdict;
1547
+ const statusEmoji = { USEFUL: '\u2713', INCONCLUSIVE: '?', BLOAT: '\u2717' };
1548
+ const statusVal = verdict.status;
1549
+ console.log(`Verdict: ${statusEmoji[statusVal] || '?'} ${statusVal}`);
1550
+
1551
+ const predictability = corr.intervention_predictability || 0;
1552
+ console.log(` Intervention predictability: ${Math.round(predictability * 100)}%`);
1553
+ if ('signal_noise_ratio' in summary) {
1554
+ console.log(` Signal/noise ratio: ${summary.signal_noise_ratio.toFixed(1)}`);
1555
+ }
1556
+ console.log();
1557
+
1558
+ // Generate detailed report
1559
+ generateDetailedReport(outputDir, analyses, summary, config, signalCounts, multiProject);
1560
+
1561
+ // Output files
1562
+ console.log('Outputs:');
1563
+ console.log(' \uD83D\uDCCA .factory/friction/report.md (detailed analysis)');
1564
+ console.log(' \uD83D\uDCCB .factory/friction/antigen_review.md (clustered failure patterns)');
1565
+ console.log(` \uD83D\uDCC1 .factory/friction/*.json (raw data: ${allSignals.length} signals, ${analyses.length} sessions)`);
1566
+ console.log();
1567
+
1568
+ console.log('Next: Review .factory/friction/report.md');
1569
+ console.log('='.repeat(60));
1570
+
1571
+ if (errors.length > 0) {
1572
+ console.log(`\n\u26A0 ${errors.length} sessions failed to parse`);
1573
+ }
1574
+
1575
+ return statusVal === 'USEFUL' ? 0 : 1;
1576
+ }
1577
+
1578
+ // =============================================================================
1579
+ // ANTIGEN EXTRACT - find_session_file
1580
+ // =============================================================================
1581
+
1582
+ function findSessionFile(sessionsDir, sessionId) {
1583
+ let projectName = null;
1584
+ let shortId;
1585
+
1586
+ if (sessionId.includes('/')) {
1587
+ projectName = sessionId.split('/')[0];
1588
+ shortId = sessionId.split('/').slice(-1)[0].split('-').slice(-1)[0];
1589
+ } else {
1590
+ shortId = sessionId;
1591
+ }
1592
+
1593
+ const sessionsPath = sessionsDir;
1594
+
1595
+ // First try direct search
1596
+ const directFiles = globFiles(sessionsPath, '*.jsonl', false);
1597
+ for (const f of directFiles) {
1598
+ if (path.basename(f).includes(shortId)) return f;
1599
+ }
1600
+
1601
+ // Search in subdirectories for project match
1602
+ if (projectName) {
1603
+ const subdirs = listDirs(sessionsPath);
1604
+ for (const subdir of subdirs) {
1605
+ if (path.basename(subdir).endsWith(projectName)) {
1606
+ const files = globFiles(subdir, '*.jsonl', false);
1607
+ for (const f of files) {
1608
+ if (path.basename(f).includes(shortId)) return f;
1609
+ }
1610
+ }
1611
+ }
1612
+ }
1613
+
1614
+ // Fallback: recursive search
1615
+ const allFiles = globFiles(sessionsPath, '*.jsonl', true);
1616
+ for (const f of allFiles) {
1617
+ if (path.basename(f).includes(shortId) && !path.basename(f).includes('sessions-index')) {
1618
+ return f;
1619
+ }
1620
+ }
1621
+
1622
+ return null;
1623
+ }
1624
+
1625
+ // =============================================================================
1626
+ // ANTIGEN EXTRACT - extract helpers
1627
+ // =============================================================================
1628
+
1629
+ function extractContextWindow(sessionFile, anchorTs, windowSize) {
1630
+ windowSize = windowSize || 5;
1631
+
1632
+ const raw = fs.readFileSync(sessionFile, 'utf-8');
1633
+ const events = raw.split('\n').filter(l => l.trim()).map(l => JSON.parse(l));
1634
+
1635
+ const turns = [];
1636
+ for (const event of events) {
1637
+ if (event.type === 'user' || event.type === 'assistant') {
1638
+ const ts = event.timestamp || '';
1639
+ turns.push({ ts, type: event.type, event });
1640
+ }
1641
+ }
1642
+
1643
+ // Find anchor position
1644
+ let anchorIdx = null;
1645
+ for (let i = 0; i < turns.length; i++) {
1646
+ if (turns[i].ts === anchorTs) {
1647
+ anchorIdx = i;
1648
+ break;
1649
+ }
1650
+ }
1651
+
1652
+ // If exact match not found, find closest before
1653
+ if (anchorIdx === null) {
1654
+ for (let i = 0; i < turns.length; i++) {
1655
+ if (turns[i].ts && anchorTs && turns[i].ts <= anchorTs) {
1656
+ anchorIdx = i;
1657
+ }
1658
+ }
1659
+ }
1660
+
1661
+ if (anchorIdx === null) return [];
1662
+
1663
+ const startIdx = Math.max(0, anchorIdx - windowSize);
1664
+ return turns.slice(startIdx, anchorIdx + 1);
1665
+ }
1666
+
1667
+ function extractFilesFromTurn(event) {
1668
+ const files = new Set();
1669
+ const content = (event.message || {}).content || '';
1670
+ const filePattern = /[\w/.-]+\.(?:py|js|ts|md|json|yaml|yml)/g;
1671
+
1672
+ if (Array.isArray(content)) {
1673
+ for (const block of content) {
1674
+ if (typeof block === 'object' && block !== null) {
1675
+ if (block.type === 'tool_use') {
1676
+ const inp = block.input || {};
1677
+ if (inp.file_path) files.add(inp.file_path);
1678
+ if (inp.path) files.add(inp.path);
1679
+ if (inp.command) {
1680
+ const matches = inp.command.match(filePattern) || [];
1681
+ for (const m of matches) files.add(m);
1682
+ }
1683
+ } else if (block.type === 'tool_result') {
1684
+ const text = String(block.content || '');
1685
+ const matches = text.match(filePattern) || [];
1686
+ for (const m of matches) files.add(m);
1687
+ } else if (block.type === 'text') {
1688
+ const text = block.text || '';
1689
+ const matches = text.match(filePattern) || [];
1690
+ for (const m of matches) files.add(m);
1691
+ }
1692
+ }
1693
+ }
1694
+ } else if (typeof content === 'string') {
1695
+ const matches = content.match(filePattern) || [];
1696
+ for (const m of matches) files.add(m);
1697
+ }
1698
+
1699
+ return files;
1700
+ }
1701
+
1702
+ function extractToolsFromTurn(event) {
1703
+ const tools = [];
1704
+ const content = (event.message || {}).content || '';
1705
+
1706
+ if (Array.isArray(content)) {
1707
+ for (const block of content) {
1708
+ if (typeof block === 'object' && block !== null) {
1709
+ if (block.type === 'tool_use') {
1710
+ const toolName = block.name || 'unknown';
1711
+ tools.push({ tool: toolName, action: 'call' });
1712
+ } else if (block.type === 'tool_result') {
1713
+ const result = String(block.content || '');
1714
+ if (result.includes('Exit code 0')) {
1715
+ tools.push({ tool: 'result', action: 'success' });
1716
+ } else if (/Exit code [1-9]|Traceback|Error/.test(result)) {
1717
+ tools.push({ tool: 'result', action: 'error' });
1718
+ }
1719
+ }
1720
+ }
1721
+ }
1722
+ }
1723
+
1724
+ return tools;
1725
+ }
1726
+
1727
+ function extractErrorsFromTurn(event) {
1728
+ const errors = [];
1729
+ const content = (event.message || {}).content || '';
1730
+
1731
+ if (Array.isArray(content)) {
1732
+ for (const block of content) {
1733
+ if (typeof block === 'object' && block !== null && block.type === 'tool_result') {
1734
+ const result = String(block.content || '');
1735
+ if (/Exit code [1-9]|Traceback|Error|error:/i.test(result)) {
1736
+ const lines = result.split('\n');
1737
+ for (const line of lines.slice(0, 5)) {
1738
+ if (/Error|error:|Traceback|Exit code [1-9]/i.test(line)) {
1739
+ errors.push(line.trim().slice(0, 200));
1740
+ break;
1741
+ }
1742
+ }
1743
+ }
1744
+ }
1745
+ }
1746
+ }
1747
+
1748
+ return errors;
1749
+ }
1750
+
1751
+ function extractUserMessage(event) {
1752
+ const content = (event.message || {}).content || '';
1753
+
1754
+ let text = '';
1755
+ if (typeof content === 'string') {
1756
+ text = content;
1757
+ } else if (Array.isArray(content)) {
1758
+ for (const block of content) {
1759
+ if (typeof block === 'object' && block !== null && block.type === 'text') {
1760
+ text = block.text || '';
1761
+ break;
1762
+ }
1763
+ }
1764
+ }
1765
+
1766
+ // Filter out system-injected markup (not real user messages)
1767
+ if (!text) return '';
1768
+ const trimmed = text.trim();
1769
+ if (trimmed.startsWith('<local-command-caveat>')) return '';
1770
+ if (trimmed.startsWith('<command-message>')) return '';
1771
+ if (trimmed.startsWith('<command-name>')) return '';
1772
+ if (trimmed.startsWith('<system-reminder>')) return '';
1773
+ if (trimmed.startsWith('<local-command-stdout>')) return '';
1774
+
1775
+ return text.slice(0, 500);
1776
+ }
1777
+
1778
+ // =============================================================================
1779
+ // ANTIGEN EXTRACT - analyze_bad_session
1780
+ // =============================================================================
1781
+
1782
+ function analyzeBadSession(sessionFile, analysis, signals) {
1783
+ const sessionId = analysis.session_id;
1784
+
1785
+ const anchorSignals = [
1786
+ 'user_intervention',
1787
+ 'session_abandoned',
1788
+ 'false_success',
1789
+ 'interrupt_cascade',
1790
+ ];
1791
+
1792
+ let anchors = signals.filter(s => s.session === sessionId && anchorSignals.includes(s.signal));
1793
+
1794
+ if (anchors.length === 0) {
1795
+ const sessionSignals = signals.filter(s => s.session === sessionId);
1796
+ if (sessionSignals.length > 0) {
1797
+ anchors = [sessionSignals[sessionSignals.length - 1]];
1798
+ }
1799
+ }
1800
+
1801
+ const candidates = [];
1802
+
1803
+ for (const anchor of anchors) {
1804
+ const anchorTs = anchor.ts || '';
1805
+ const anchorSignal = anchor.signal || 'unknown';
1806
+
1807
+ const window = extractContextWindow(sessionFile, anchorTs, 5);
1808
+ if (window.length === 0) continue;
1809
+
1810
+ const allFiles = new Set();
1811
+ const allTools = [];
1812
+ const allErrors = [];
1813
+ const userMessagesArr = [];
1814
+
1815
+ for (const turn of window) {
1816
+ const event = turn.event;
1817
+ for (const f of extractFilesFromTurn(event)) allFiles.add(f);
1818
+ allTools.push(...extractToolsFromTurn(event));
1819
+ allErrors.push(...extractErrorsFromTurn(event));
1820
+ if (turn.type === 'user') {
1821
+ const msg = extractUserMessage(event);
1822
+ if (msg && !msg.startsWith('[Request interrupted')) {
1823
+ userMessagesArr.push(msg);
1824
+ }
1825
+ }
1826
+ }
1827
+
1828
+ // Build tool sequence string
1829
+ const toolSeq = [];
1830
+ for (const t of allTools) {
1831
+ if (t.action === 'call') {
1832
+ toolSeq.push(t.tool);
1833
+ } else if (t.action === 'error') {
1834
+ if (toolSeq.length > 0) toolSeq[toolSeq.length - 1] += ':error';
1835
+ } else if (t.action === 'success') {
1836
+ if (toolSeq.length > 0) toolSeq[toolSeq.length - 1] += ':ok';
1837
+ }
1838
+ }
1839
+
1840
+ // Extract keywords
1841
+ const keywords = new Set();
1842
+ for (const msg of userMessagesArr) {
1843
+ const words = (msg.toLowerCase().match(/\b[a-z]{4,}\b/g) || []).slice(0, 20);
1844
+ for (const w of words) keywords.add(w);
1845
+ }
1846
+
1847
+ const common = new Set([
1848
+ 'this', 'that', 'with', 'from', 'have', 'what', 'when', 'where',
1849
+ 'which', 'there', 'their', 'would', 'could', 'should', 'about',
1850
+ 'been', 'were', 'they', 'them', 'then', 'than', 'these', 'those',
1851
+ 'some', 'into', 'only', 'other', 'also', 'just', 'more', 'very',
1852
+ 'here', 'after', 'before', 'being', 'doing', 'make', 'made',
1853
+ 'like', 'want', 'need', 'file', 'code',
1854
+ ]);
1855
+ for (const c of common) keywords.delete(c);
1856
+
1857
+ const candidate = {
1858
+ session_id: sessionId,
1859
+ anchor_signal: anchorSignal,
1860
+ anchor_ts: anchorTs,
1861
+ peak_friction: (analysis.friction_summary || {}).peak || 0,
1862
+ turns_in_window: window.length,
1863
+ files: Array.from(allFiles).sort().slice(0, 10),
1864
+ tool_sequence: toolSeq.slice(0, 15),
1865
+ errors: allErrors.slice(0, 5),
1866
+ keywords: Array.from(keywords).sort().slice(0, 15),
1867
+ user_context: userMessagesArr.slice(0, 3),
1868
+ inhibitory_instruction: '# TODO: Write prevention instruction based on pattern above',
1869
+ };
1870
+
1871
+ candidates.push(candidate);
1872
+ }
1873
+
1874
+ return candidates;
1875
+ }
1876
+
1877
+ // =============================================================================
1878
+ // ANTIGEN EXTRACT - clusterCandidates
1879
+ // =============================================================================
1880
+
1881
+ function clusterCandidates(allCandidates) {
1882
+ const signalWeights = {
1883
+ user_intervention: 10,
1884
+ session_abandoned: 10,
1885
+ false_success: 8,
1886
+ no_resolution: 8,
1887
+ interrupt_cascade: 5,
1888
+ tool_loop: 6,
1889
+ rapid_exit: 6,
1890
+ };
1891
+
1892
+ const clusterMap = {};
1893
+
1894
+ for (const c of allCandidates) {
1895
+ // Normalize tool sequence: strip :error/:ok suffixes for grouping
1896
+ const toolNorm = c.tool_sequence
1897
+ .map(t => t.replace(/:error|:ok/g, ''))
1898
+ .join(',') || '(none)';
1899
+ const key = c.anchor_signal + '|' + toolNorm;
1900
+
1901
+ if (!(key in clusterMap)) {
1902
+ clusterMap[key] = {
1903
+ anchor_signal: c.anchor_signal,
1904
+ tool_pattern: toolNorm,
1905
+ count: 0,
1906
+ sessions: {},
1907
+ contexts: [],
1908
+ errors: [],
1909
+ files: {},
1910
+ keywords: {},
1911
+ peaks: [],
1912
+ };
1913
+ }
1914
+
1915
+ const cl = clusterMap[key];
1916
+ cl.count++;
1917
+ cl.sessions[c.session_id] = true;
1918
+ cl.peaks.push(c.peak_friction);
1919
+
1920
+ // Collect unique user contexts (up to 5 per cluster, deduplicated)
1921
+ if (c.user_context.length > 0 && cl.contexts.length < 5) {
1922
+ for (const ctx of c.user_context.slice(0, 1)) {
1923
+ if (ctx.length > 10 && !cl.contexts.includes(ctx)) cl.contexts.push(ctx);
1924
+ }
1925
+ }
1926
+
1927
+ // Collect unique errors (up to 5 per cluster)
1928
+ if (c.errors.length > 0 && cl.errors.length < 5) {
1929
+ for (const err of c.errors.slice(0, 1)) {
1930
+ if (!cl.errors.includes(err)) cl.errors.push(err);
1931
+ }
1932
+ }
1933
+
1934
+ // Tally files and keywords
1935
+ for (const f of c.files) cl.files[f] = (cl.files[f] || 0) + 1;
1936
+ for (const kw of c.keywords) cl.keywords[kw] = (cl.keywords[kw] || 0) + 1;
1937
+ }
1938
+
1939
+ // Score and sort clusters
1940
+ const clusters = Object.values(clusterMap).map(cl => {
1941
+ const weight = signalWeights[cl.anchor_signal] || 1;
1942
+ const peaks = cl.peaks.sort((a, b) => a - b);
1943
+ return {
1944
+ anchor_signal: cl.anchor_signal,
1945
+ tool_pattern: cl.tool_pattern,
1946
+ count: cl.count,
1947
+ score: cl.count * weight,
1948
+ sessions: Object.keys(cl.sessions).length,
1949
+ median_peak: peaks[Math.floor(peaks.length / 2)],
1950
+ max_peak: peaks[peaks.length - 1],
1951
+ contexts: cl.contexts,
1952
+ errors: cl.errors,
1953
+ top_files: sortedEntries(cl.files).slice(0, 5).map(([f]) => f),
1954
+ top_keywords: sortedEntries(cl.keywords).slice(0, 10).map(([k]) => k),
1955
+ };
1956
+ });
1957
+
1958
+ clusters.sort((a, b) => b.score - a.score);
1959
+ return clusters;
1960
+ }
1961
+
1962
+ // =============================================================================
1963
+ // ANTIGEN EXTRACT - main
1964
+ // =============================================================================
1965
+
1966
+ function extractMain(sessionsDir) {
1967
+ // Load friction analysis
1968
+ const analysisFile = '.factory/friction/friction_analysis.json';
1969
+ if (!fs.existsSync(analysisFile)) {
1970
+ console.log('Error: Run friction analysis first to generate .factory/friction/friction_analysis.json');
1971
+ return 1;
1972
+ }
1973
+
1974
+ const analyses = JSON.parse(fs.readFileSync(analysisFile, 'utf-8'));
1975
+
1976
+ // Load raw signals
1977
+ const rawFile = '.factory/friction/friction_raw.jsonl';
1978
+ let signals = [];
1979
+ if (fs.existsSync(rawFile)) {
1980
+ const rawContent = fs.readFileSync(rawFile, 'utf-8');
1981
+ signals = rawContent.split('\n').filter(l => l.trim()).map(l => JSON.parse(l));
1982
+ }
1983
+
1984
+ // Find BAD sessions
1985
+ const badSessions = analyses.filter(a => a.quality === 'BAD');
1986
+
1987
+ if (badSessions.length === 0) {
1988
+ console.log('No BAD sessions found. Nothing to extract.');
1989
+ return 0;
1990
+ }
1991
+
1992
+ console.log(`Extracting antigens from ${badSessions.length} BAD sessions...\n`);
1993
+
1994
+ // Extract antigens
1995
+ const allCandidates = [];
1996
+ const failed = [];
1997
+
1998
+ const sortedBad = badSessions.slice().sort((a, b) =>
1999
+ ((b.friction_summary || {}).peak || 0) - ((a.friction_summary || {}).peak || 0)
2000
+ );
2001
+
2002
+ for (const analysis of sortedBad) {
2003
+ const sessionId = analysis.session_id;
2004
+ const sessionFile = findSessionFile(sessionsDir, sessionId);
2005
+
2006
+ if (!sessionFile) {
2007
+ failed.push(sessionId);
2008
+ continue;
2009
+ }
2010
+
2011
+ const candidates = analyzeBadSession(sessionFile, analysis, signals);
2012
+ allCandidates.push(...candidates);
2013
+ }
2014
+
2015
+ // Cluster candidates by (anchor_signal, tool_pattern)
2016
+ const clusters = clusterCandidates(allCandidates);
2017
+
2018
+ // Terminal output
2019
+ console.log(`\u2713 ${allCandidates.length} raw candidates \u2192 ${clusters.length} clusters`);
2020
+ const top5 = clusters.slice(0, 5);
2021
+ for (const cl of top5) {
2022
+ console.log(` ${String(cl.count).padStart(3)}x ${cl.anchor_signal} | ${cl.tool_pattern} (${cl.sessions} sessions, score: ${cl.score})`);
2023
+ }
2024
+
2025
+ if (failed.length > 0) {
2026
+ console.log(`\n\u26A0 Could not find session files for ${failed.length} sessions`);
2027
+ }
2028
+ console.log();
2029
+
2030
+ // Save outputs
2031
+ const outputDir = '.factory/friction';
2032
+ fs.mkdirSync(outputDir, { recursive: true });
2033
+
2034
+ // Raw candidates (kept for debugging)
2035
+ fs.writeFileSync(path.join(outputDir, 'antigen_candidates.json'), JSON.stringify(allCandidates, null, 2));
2036
+
2037
+ // Clustered output (primary artifact)
2038
+ fs.writeFileSync(path.join(outputDir, 'antigen_clusters.json'), JSON.stringify(clusters, null, 2));
2039
+
2040
+ // Clustered review markdown (top 25)
2041
+ const maxClusters = 25;
2042
+ const reviewClusters = clusters.slice(0, maxClusters);
2043
+ const reviewLines = [];
2044
+
2045
+ reviewLines.push('# Friction Antigen Clusters\n\n');
2046
+ reviewLines.push(`Generated: ${new Date().toISOString()}\n`);
2047
+ reviewLines.push(`BAD sessions: ${badSessions.length} | Raw candidates: ${allCandidates.length} | Clusters: ${clusters.length}\n\n`);
2048
+
2049
+ // Summary table
2050
+ reviewLines.push('## Cluster Summary\n\n');
2051
+ reviewLines.push('| # | Signal | Tool Pattern | Count | Sessions | Score | Median Peak |\n');
2052
+ reviewLines.push('|---|--------|-------------|-------|----------|-------|-------------|\n');
2053
+ reviewClusters.forEach((cl, idx) => {
2054
+ reviewLines.push(`| ${idx + 1} | ${cl.anchor_signal} | ${cl.tool_pattern} | ${cl.count} | ${cl.sessions} | ${cl.score} | ${cl.median_peak} |\n`);
2055
+ });
2056
+ reviewLines.push('\n---\n\n');
2057
+
2058
+ // Detailed clusters
2059
+ reviewClusters.forEach((cl, idx) => {
2060
+ reviewLines.push(`## Cluster ${idx + 1}: ${cl.anchor_signal} | ${cl.tool_pattern}\n\n`);
2061
+ reviewLines.push(`**Occurrences:** ${cl.count} across ${cl.sessions} sessions | **Score:** ${cl.score} | **Median peak:** ${cl.median_peak} | **Max peak:** ${cl.max_peak}\n\n`);
2062
+
2063
+ if (cl.contexts.length > 0) {
2064
+ reviewLines.push('### User Context (what the user said)\n\n');
2065
+ for (const ctx of cl.contexts.slice(0, 3)) {
2066
+ const truncated = ctx.length > 300 ? ctx.slice(0, 300) + '...' : ctx;
2067
+ reviewLines.push(`> ${truncated}\n\n`);
2068
+ }
2069
+ }
2070
+
2071
+ if (cl.errors.length > 0) {
2072
+ reviewLines.push('### Errors\n\n');
2073
+ reviewLines.push('```\n');
2074
+ for (const err of cl.errors.slice(0, 3)) {
2075
+ reviewLines.push(`${err}\n`);
2076
+ }
2077
+ reviewLines.push('```\n\n');
2078
+ }
2079
+
2080
+ if (cl.top_files.length > 0) {
2081
+ reviewLines.push('### Files involved\n\n');
2082
+ for (const f of cl.top_files) {
2083
+ reviewLines.push(`- \`${f}\`\n`);
2084
+ }
2085
+ reviewLines.push('\n');
2086
+ }
2087
+
2088
+ if (cl.top_keywords.length > 0) {
2089
+ reviewLines.push(`**Keywords:** ${cl.top_keywords.join(', ')}\n\n`);
2090
+ }
2091
+
2092
+ reviewLines.push('---\n\n');
2093
+ });
2094
+
2095
+ fs.writeFileSync(path.join(outputDir, 'antigen_review.md'), reviewLines.join(''));
2096
+ console.log('Output: .factory/friction/antigen_review.md\n');
2097
+
2098
+ return 0;
2099
+ }
2100
+
2101
+ // =============================================================================
2102
+ // PIPELINE ENTRY POINT
2103
+ // =============================================================================
2104
+
2105
+ function main() {
2106
+ if (process.argv.length < 3) {
2107
+ console.log(`
2108
+ Friction analysis pipeline - analyze sessions and extract antigens.
2109
+
2110
+ Usage:
2111
+ node friction.js <sessions-directory>
2112
+ node friction.js ~/.claude/projects/-home-hamr-PycharmProjects-liteagents/
2113
+
2114
+ Outputs (all in .factory/friction/):
2115
+ friction_analysis.json - Per-session analysis
2116
+ friction_summary.json - Aggregate stats
2117
+ friction_raw.jsonl - Raw signals
2118
+ antigen_candidates.json - Raw antigen candidates
2119
+ antigen_clusters.json - Clustered antigen patterns
2120
+ antigen_review.md - Clustered review file
2121
+ `);
2122
+ return 1;
2123
+ }
2124
+
2125
+ const sessionsDir = process.argv[2];
2126
+
2127
+ if (!fs.existsSync(sessionsDir)) {
2128
+ console.log(`Error: ${sessionsDir} does not exist`);
2129
+ return 1;
2130
+ }
2131
+
2132
+ console.log('='.repeat(60));
2133
+ console.log(' FRICTION ANALYSIS PIPELINE');
2134
+ console.log('='.repeat(60));
2135
+
2136
+ // Step 1: Analyze sessions
2137
+ console.log('\n[1/2] Analyzing sessions...\n');
2138
+ analyzeMain(sessionsDir);
2139
+
2140
+ // Check if analysis produced output
2141
+ const analysisFile = '.factory/friction/friction_analysis.json';
2142
+ if (!fs.existsSync(analysisFile)) {
2143
+ console.log('\nNo analysis output. Check session directory.');
2144
+ return 1;
2145
+ }
2146
+
2147
+ // Step 2: Extract antigens
2148
+ console.log('\n' + '='.repeat(60));
2149
+ console.log('\n[2/2] Extracting antigens from BAD sessions...\n');
2150
+ extractMain(sessionsDir);
2151
+
2152
+ // Final summary
2153
+ console.log('\n' + '='.repeat(60));
2154
+ console.log(' DONE');
2155
+ console.log('='.repeat(60));
2156
+
2157
+ const reviewFile = '.factory/friction/antigen_review.md';
2158
+ if (fs.existsSync(reviewFile)) {
2159
+ console.log('\nReview your antigens:');
2160
+ console.log(` cat ${reviewFile}`);
2161
+ console.log('\nOr feed to LLM:');
2162
+ console.log(` cat ${reviewFile} | claude "write CLAUDE.md rules to prevent these patterns"`);
2163
+ }
2164
+
2165
+ return 0;
2166
+ }
2167
+
2168
+ process.exit(main());