Lab 2:摩擦點偵測器(Day 17-19)

要找出高摩擦 session,分類摩擦原因,輸出改善建議

2-1 摩擦偵測模組

新增 lib/friction.mjs

import { extractToolCalls } from './analytics.mjs';

const FRICTION_TYPES = {
  F1: { name: '工具執行失敗', severity: 'high' },
  F2: { name: '重複修改同一檔案', severity: 'medium' },
  F3: { name: 'Session 過長', severity: 'medium' },
  F4: { name: 'Token 燃耗過高', severity: 'medium' },
  F7: { name: '/compact 觸發', severity: 'low' },
};

/**
 * 偵測單一 session 的摩擦點
 * @returns { type, severity, detail, recommendation }[]
 */
export function detectFrictions(messages) {
  const frictions = [];
  const turns = messages.filter(m => m.type === 'assistant').length;
  const toolCalls = extractToolCalls(messages);

  // F1:工具執行失敗
  let toolErrors = 0;
  const errorMessages = [];
  for (const msg of messages) {
    if (msg.type !== 'user') continue;
    for (const c of msg.message?.content ?? []) {
      if (c.type === 'tool_result' && c.is_error) {
        toolErrors++;
        errorMessages.push(String(c.content ?? '').slice(0, 80));
      }
    }
  }
  if (toolErrors > 0) {
    frictions.push({
      type: 'F1',
      severity: toolErrors >= 5 ? 'high' : 'medium',
      detail: `${toolErrors} 次工具錯誤`,
      examples: errorMessages.slice(0, 3),
      recommendation: '在 CLAUDE.md 中補充路徑規範和常見錯誤處理方式',
    });
  }

  // F2:重複修改同一檔案(3 次以上)
  const editCounts = {};
  for (const call of toolCalls) {
    if (['Edit', 'Write', 'MultiEdit'].includes(call.name)) {
      const fp = call.input?.file_path ?? 'unknown';
      editCounts[fp] = (editCounts[fp] ?? 0) + 1;
    }
  }
  const repeatedFiles = Object.entries(editCounts)
    .filter(([, n]) => n >= 3)
    .sort(([, a], [, b]) => b - a);

  if (repeatedFiles.length > 0) {
    frictions.push({
      type: 'F2',
      severity: 'medium',
      detail: `${repeatedFiles.length} 個檔案被重複修改 3+ 次`,
      examples: repeatedFiles.slice(0, 3).map(([f, n]) => `${f} (${n}次)`),
      recommendation: '提問時一次說清楚所有需求,或使用 MultiEdit 批量修改',
    });
  }

  // F3:Session 過長
  if (turns > 50) {
    frictions.push({
      type: 'F3',
      severity: 'medium',
      detail: `Session 長達 ${turns} 輪(建議上限 30 輪)`,
      examples: [],
      recommendation: '將大任務拆成多個獨立 session,每個 session 只處理一個子任務',
    });
  }

  // F4:Token 燃耗過高
  let totalTokens = 0;
  for (const msg of messages) {
    if (msg.type !== 'assistant') continue;
    const u = msg.message?.usage;
    if (u) totalTokens += (u.input_tokens ?? 0) + (u.output_tokens ?? 0);
  }
  const burnRate = turns > 0 ? totalTokens / turns : 0;
  if (burnRate > 20000) {
    frictions.push({
      type: 'F4',
      severity: 'medium',
      detail: `每輪平均 ${burnRate.toFixed(0)} tokens(過高)`,
      examples: [],
      recommendation: '使用 /compact 壓縮 context,或拆分 session',
    });
  }

  // F7:/compact 觸發
  const summaries = messages.filter(m => m.type === 'summary').length;
  if (summaries > 0) {
    frictions.push({
      type: 'F7',
      severity: 'low',
      detail: `觸發 ${summaries} 次 /compact`,
      examples: [],
      recommendation: '考慮在任務中途主動拆分 session,而非等到 context 撐滿',
    });
  }

  return frictions;
}

/**
 * 分析多個 sessions,統計摩擦類型頻率
 */
export function frictionSummary(sessionFrictions) {
  const typeCounts = {};
  for (const { frictions } of sessionFrictions) {
    for (const f of frictions) {
      typeCounts[f.type] = (typeCounts[f.type] ?? 0) + 1;
    }
  }
  return Object.entries(typeCounts)
    .map(([type, count]) => ({ type, ...FRICTION_TYPES[type], count }))
    .sort((a, b) => b.count - a.count);
}

2-2 摩擦偵測報告

新增 friction-report.mjs

import { scanProjects, scanProjectSessions } from './lib/scanner.mjs';
import { parseJsonl } from './lib/parser.mjs';
import { detectFrictions, frictionSummary } from './lib/friction.mjs';
import { calcEfficiencyMetrics } from './lib/efficiency.mjs';

const DAYS = parseInt(process.argv[2] ?? '30', 10);
const cutoff = new Date();
cutoff.setDate(cutoff.getDate() - DAYS);

const sessionFrictions = [];

for (const proj of scanProjects()) {
  for (const session of scanProjectSessions(proj)) {
    if (new Date(session.first_message_time) < cutoff) continue;
    const messages = parseJsonl(session.file_path);
    const frictions = detectFrictions(messages);
    const metrics = calcEfficiencyMetrics(messages);
    if (frictions.length > 0) {
      sessionFrictions.push({ session, messages, frictions, metrics });
    }
  }
}

console.log(`\n 摩擦點分析報告(過去 ${DAYS} 天)\n`);
console.log(`  有摩擦的 session:${sessionFrictions.length} 個\n`);

// 摩擦類型統計
const summary = frictionSummary(sessionFrictions);
console.log('摩擦類型排行:\n');
for (const item of summary) {
  const bar = '█'.repeat(Math.min(item.count, 20));
  console.log(`  [${item.type}] ${item.name.padEnd(16)} ${bar} ${item.count} 次`);
}

// 最高摩擦 sessions
const ranked = sessionFrictions
  .sort((a, b) => b.frictions.length - a.frictions.length)
  .slice(0, 5);

console.log('\n\n 摩擦最多的 Sessions:\n');
for (const { session, frictions, metrics } of ranked) {
  const date = new Date(session.first_message_time).toLocaleDateString();
  const summary = (session.summary ?? '(無摘要)').slice(0, 50);
  console.log(`  [${date}] 效率分 ${metrics.efficiency_score.toFixed(0)} | ${frictions.length} 個摩擦點`);
  console.log(`           ${summary}`);
  for (const f of frictions) {
    console.log(`           → [${f.type}] ${f.detail}`);
    if (f.examples.length > 0) {
      console.log(`                  例:${f.examples[0]}`);
    }
  }
  console.log();
}

// 改善建議
const recommendations = new Set();
for (const { frictions } of sessionFrictions) {
  for (const f of frictions) {
    recommendations.add(`[${f.type}] ${f.recommendation}`);
  }
}

console.log(' 改善建議:\n');
for (const rec of recommendations) {
  console.log(`  • ${rec}`);
}

開始執行

node friction-report.mjs # 過去 30 天

D:\Git\mathTalk\class\Claude-Code-Analytics\src>node friction-report.mjs

 摩擦點分析報告(過去 30 天)

  有摩擦的 session:99 個

摩擦類型排行:

  [F2] 重複修改同一檔案         ████████████████████ 89 次
  [F1] 工具執行失敗           ████████████████████ 80 次
  [F3] Session 過長       ████████████████████ 75 次


 摩擦最多的 Sessions:

  [2026/6/10] 效率分 0 | 3 個摩擦點
           (無摘要)
           → [F1] 1 次工具錯誤
                  例:File does not exist. Note: your current working directory is D:\OlgCase\RS\HSLR_
           → [F2] 1 個檔案被重複修改 3+ 次
                  例:unknown (10次)
           → [F3] Session 長達 127 輪(建議上限 30 輪)

  [2026/6/9] 效率分 0 | 3 個摩擦點
           (無摘要)
           → [F1] 4 次工具錯誤
                  例:<tool_use_error>File has been modified since read, either by the user or by a li
           → [F2] 1 個檔案被重複修改 3+ 次
                  例:unknown (56次)
           → [F3] Session 長達 385 輪(建議上限 30 輪)

  [2026/6/8] 效率分 0 | 3 個摩擦點
           (無摘要)
           → [F1] 8 次工具錯誤
                  例:Exit code 2
total 312
drwxr-xr-x 1 joker0820 1049089     0 Jun  8 17:31 .
drwxr-
           → [F2] 1 個檔案被重複修改 3+ 次
                  例:unknown (8次)
           → [F3] Session 長達 172 輪(建議上限 30 輪)

  [2026/6/8] 效率分 0 | 3 個摩擦點
           (無摘要)
           → [F1] 9 次工具錯誤
                  例:Exit code 1
Traceback (most recent call last):
  File "<stdin>", line 3, in <mo
           → [F2] 1 個檔案被重複修改 3+ 次
                  例:unknown (32次)
           → [F3] Session 長達 501 輪(建議上限 30 輪)

  [2026/6/10] 效率分 0 | 3 個摩擦點
           (無摘要)
           → [F1] 11 次工具錯誤
                  例:Ripgrep search timed out after 20 seconds. The search may have matched files but
           → [F2] 1 個檔案被重複修改 3+ 次
                  例:unknown (146次)
           → [F3] Session 長達 879 輪(建議上限 30 輪)

 改善建議:

  • [F1] 在 CLAUDE.md 中補充路徑規範和常見錯誤處理方式
  • [F2] 提問時一次說清楚所有需求,或使用 MultiEdit 批量修改
  • [F3] 將大任務拆成多個獨立 session,每個 session 只處理一個子任務

無標籤

關注作者:

新增評論