OverviewFilesCommitsIssuesDocsReleasesPeers

knowledge-ratchet / loop-briefing.js

279 lines · javascript ref:
#!/usr/bin/env node

// Loop Briefing — Knowledge Ratchet Phase 2
//
// Runs on SessionStart. Checks the working directory and loads
// relevant past loops as a briefing so Claude enters the session
// aware of past failure patterns.
//
// Output goes to stderr so Claude sees it as context.

const fs = require('fs')
const path = require('path')
const { classifyProject } = require('./classify-project')
const { computeQuality, QUALITY_RANK } = require('./rule-extractor')

const LOOPS_FILE = path.join(__dirname, 'data', 'loops.json')
const RULES_FILE = path.join(__dirname, 'data', 'learned-rules.json')
const SHADOW_LOG = path.join(__dirname, 'data', 'briefing-shadow.jsonl')

// classifyProject is imported from classify-project.js (shared with
// loop-detector.js). It always returns a non-empty string — either a
// specific project name or 'unknown' as the explicit fallback. Previously,
// the local version returned null for unknown projects and this file
// exited early without a briefing. Now we only exit if the classified
// project has no loops in the corpus.

function main () {
  // Only inject on first prompt of session — check if briefing already delivered
  const BRIEFING_FLAG = '/tmp/claude-briefing-delivered.flag'
  if (fs.existsSync(BRIEFING_FLAG)) {
    // Check if flag is from current session (less than 1 hour old)
    try {
      const age = Date.now() - fs.statSync(BRIEFING_FLAG).mtimeMs
      if (age < 3600000) return // already briefed this session
    } catch {}
  }

  const cwd = process.cwd()
  const project = classifyProject(cwd)
  // classifyProject always returns a string — 'unknown' is valid but rare
  // now that the classifier walks git roots. We still exit if no loops
  // exist for this project name (nothing to brief from).

  if (!fs.existsSync(LOOPS_FILE)) return

  let loopsData
  try {
    loopsData = JSON.parse(fs.readFileSync(LOOPS_FILE, 'utf8'))
  } catch { return }

  const projectLoops = (loopsData.loops || []).filter(l => l.project === project)
  if (projectLoops.length === 0) return

  // Recent loops (last 10, sorted by date desc)
  const recent = [...projectLoops]
    .sort((a, b) => (b.date || '').localeCompare(a.date || ''))
    .slice(0, 10)

  // Most common failure types
  const byType = {}
  for (const l of projectLoops) {
    byType[l.type] = (byType[l.type] || 0) + 1
  }
  const topTypes = Object.entries(byType).sort((a, b) => b[1] - a[1]).slice(0, 3)

  // Unresolved loops (still-open problems)
  const unresolved = projectLoops.filter(l => !l.resolved)

  // Danger files (most often edited during loops)
  const dangerFiles = {}
  for (const l of projectLoops) {
    for (const f of (l.filesTried || [])) {
      const base = path.basename(f)
      dangerFiles[base] = (dangerFiles[base] || 0) + 1
    }
  }
  const topDangerFiles = Object.entries(dangerFiles).sort((a, b) => b[1] - a[1]).slice(0, 5)

  // Build briefing
  const lines = [
    '',
    `LOOP BRIEFING — ${project} (${projectLoops.length} past loops)`,
    '═'.repeat(50),
    '',
    `Top failure types: ${topTypes.map(([t, c]) => `${t} (${c})`).join(', ')}`,
    `Danger files: ${topDangerFiles.map(([f, c]) => `${f} (${c}x)`).join(', ')}`,
  ]

  if (unresolved.length > 0) {
    lines.push(``)
    lines.push(`UNRESOLVED (${unresolved.length}):`)
    for (const l of unresolved.slice(0, 3)) {
      lines.push(`  ✗ ${l.date} [${l.type}] "${l.corrections[0]?.slice(0, 80)}"`)
    }
  }

  lines.push('')
  lines.push('RECENT LOOPS:')
  for (const l of recent.slice(0, 5)) {
    const status = l.resolved ? '✓' : '✗'
    lines.push(`  ${status} ${l.date} [${l.type}] ${l.duration} turns, ${l.correctionCount} corr`)
    lines.push(`    "${l.corrections[0]?.slice(0, 90)}"`)
    if (l.resolved && l.resolution.files.length > 0) {
      lines.push(`    fix: ${l.resolution.files.map(f => path.basename(f)).slice(0, 3).join(', ')}`)
    }
  }

  // ─── Cross-project similar loops (Change 3) ───
  // Surface resolved loops from OTHER projects whose filesTried overlap with
  // this project's danger files. The goal: a rule learned in dc-terrain about
  // MEMORY.md fumbling surfaces when working in hypercore-kb, because they're
  // the same class of problem.
  //
  // Note: loop-detector's findSimilarLoops exists but its scoring expects a
  // rich current fingerprint (≥2 file overlap to clear threshold for cross-
  // project). At SessionStart we only have project danger-file history, which
  // typically yields 1-file overlaps with other-project loops. So we do an
  // inline match with a lower threshold (≥1 overlap) specific to this use
  // case, rather than hammer findSimilarLoops's threshold to fit.
  //
  // Matching uses raw basenames (no extension stripping) since danger files
  // often have meaningful extensions like .md that loop-detector's
  // buildFingerprint preserves.
  const dangerBasenamesSet = new Set(Object.keys(dangerFiles))
  const crossProjectMatches = []
  for (const l of (loopsData.loops || [])) {
    if (l.project === project) continue
    if (!l.resolved) continue
    const lBasenames = (l.filesTried || []).map(f => path.basename(f))
    const overlapFiles = lBasenames.filter(f => dangerBasenamesSet.has(f))
    if (overlapFiles.length >= 1) {
      crossProjectMatches.push({ loop: l, overlap: overlapFiles.length, overlapFiles })
    }
  }
  crossProjectMatches.sort((a, b) => {
    // Primary: more file overlap is better
    if (b.overlap !== a.overlap) return b.overlap - a.overlap
    // Tiebreak: longer loops = higher severity
    return (b.loop.duration || 0) - (a.loop.duration || 0)
  })
  const topCross = crossProjectMatches.slice(0, 3)

  if (topCross.length > 0) {
    lines.push('')
    lines.push(`CROSS-PROJECT SIMILAR LOOPS (${crossProjectMatches.length} by danger-file overlap, top 3):`)
    for (const m of topCross) {
      const l = m.loop
      lines.push(`  ${l.date} [${l.project}/${l.type}] ${l.duration} turns, shared: ${m.overlapFiles.slice(0, 3).join(', ')}`)
      lines.push(`    "${(l.corrections[0] || '').slice(0, 90)}"`)
      if (l.resolution?.files?.length > 0) {
        lines.push(`    fix: ${l.resolution.files.map(f => path.basename(f)).slice(0, 3).join(', ')}`)
      }
    }
  }

  // Pre-declare shadow harness collectors. These are populated inside
  // the rules-loading block below and logged at the end of main().
  let shadowBaselineIds = []
  let shadowNewIds = []
  let shadowProjectRuleCount = 0
  let shadowNeedsInvestigationCount = 0

  // Load learned rules for this project.
  //
  // Quality-aware injection (Change 2):
  //   - rules are sorted by (quality tier, loop duration) so the most
  //     actionable and severe come first
  //   - only `specific` and `partial` rules are injected as directives;
  //     `needs-investigation` rules are counted but held back because
  //     they have no concrete symptom or fix target and would pollute
  //     the briefing with noise
  //   - if ALL rules for this project are `needs-investigation`, we show
  //     a "caution" note instead of directives — preserves the signal
  //     that something is known to go wrong here without pretending we
  //     have guidance
  //   - the quality field is read from the rule when present and computed
  //     on the fly as a fallback (for rules written before Change 2 that
  //     haven't been refreshed by rule-extractor's backfill yet)
  if (fs.existsSync(RULES_FILE)) {
    try {
      const rulesData = JSON.parse(fs.readFileSync(RULES_FILE, 'utf8'))
      const projectRules = (rulesData.rules || [])
        .filter(r => r.trigger.project === project)
        .map(r => ({ ...r, quality: r.quality || computeQuality(r) }))

      const sortedRules = [...projectRules].sort((a, b) => {
        const qa = QUALITY_RANK[a.quality] ?? 1
        const qb = QUALITY_RANK[b.quality] ?? 1
        if (qa !== qb) return qa - qb
        return b.source.duration - a.source.duration
      })

      const injectable = sortedRules.filter(r => r.quality !== 'needs-investigation')
      const needsInvestigationCount = sortedRules.length - injectable.length

      // ─── Shadow harness collection (Change 6) ───
      // Compute the BASELINE rule set — what the pre-Change-2 briefing
      // would have injected — so we can log the diff for longitudinal
      // comparison. Baseline = filter by project exact match, sort by
      // source.duration (severity proxy), take top 5. No quality filter,
      // no cross-project, matching the pre-Change-2 behavior in order to
      // measure what the quality filter and quality reorder changed.
      const shadowBaselineRules = [...projectRules]
        .sort((a, b) => (b.source?.duration || 0) - (a.source?.duration || 0))
        .slice(0, 5)
      shadowBaselineIds = shadowBaselineRules.map(r => r.id)
      shadowNewIds = injectable.slice(0, 5).map(r => r.id)
      shadowProjectRuleCount = projectRules.length
      shadowNeedsInvestigationCount = needsInvestigationCount

      if (injectable.length > 0) {
        const header = needsInvestigationCount > 0
          ? `LEARNED RULES (${injectable.length} actionable, ${needsInvestigationCount} need investigation):`
          : `LEARNED RULES (${injectable.length} from past loops):`
        lines.push('')
        lines.push(header)
        for (const r of injectable.slice(0, 5)) {
          lines.push(`  When: ${r.trigger.symptom}`)
          lines.push(`    Do: ${r.action.description.slice(0, 100)}`)
          lines.push(`    Don't: ${r.antipattern.description.slice(0, 100)}`)
        }
      } else if (needsInvestigationCount > 0) {
        // All known rules for this project are needs-investigation.
        // Preserve the "historical friction exists here" signal without
        // injecting rules that would be misleading as directives.
        lines.push('')
        lines.push(`⚠ ${needsInvestigationCount} historical patterns exist for ${project}, but none are`)
        lines.push(`  actionable yet (no concrete fix targets). Proceed carefully.`)
      }
    } catch {}
  }

  lines.push('')
  lines.push('BEFORE EDITING: Read the target file fully. Check loop history.')
  lines.push('If you feel like fumbling, STOP and enter planning mode.')
  lines.push('═'.repeat(50))
  lines.push('')

  // ─── Shadow harness log (Change 6) ───
  // Append one JSONL record capturing what this briefing injected vs
  // what a pre-Change-2 baseline briefing would have injected. Read
  // longitudinally to answer: "is the quality filter removing rules
  // that would have helped?" (false negatives) and "is quality
  // reordering surfacing rules that wouldn't have been seen?"
  // (effective promotions). This does not block injection — it's a
  // side-effect log for measurement.
  try {
    const overlap = shadowNewIds.filter(id => shadowBaselineIds.includes(id))
    const removedByNew = shadowBaselineIds.filter(id => !shadowNewIds.includes(id))
    const addedByNew = shadowNewIds.filter(id => !shadowBaselineIds.includes(id))
    const shadowEntry = {
      timestamp: new Date().toISOString(),
      project,
      cwd,
      counts: {
        projectRules: shadowProjectRuleCount,
        injected: shadowNewIds.length,
        baseline: shadowBaselineIds.length,
        needsInvestigation: shadowNeedsInvestigationCount,
        crossProjectSurfaced: topCross.length,
        crossProjectTotal: crossProjectMatches.length
      },
      overlap: overlap.length,
      removedByNew, // rules baseline would have shown but we suppressed
      addedByNew    // rules we surfaced by quality reordering
    }
    fs.appendFileSync(SHADOW_LOG, JSON.stringify(shadowEntry) + '\n')
  } catch {}

  // Mark as delivered so we don't repeat
  fs.writeFileSync(BRIEFING_FLAG, new Date().toISOString())

  // Output as additionalContext JSON — Claude sees this as system context
  const output = { additionalContext: lines.join('\n') }
  process.stdout.write(JSON.stringify(output))
}

main()