OverviewFilesCommitsIssuesDocsReleasesPeers

knowledge-ratchet / preflight-review.js

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

// Pre-flight review hook for shared module edits
// Triggered by PreToolUse on Edit/Write to shared modules
// Gathers downstream impact, relevant principles, and recent corrections
// Returns a distilled briefing for the user before the edit proceeds

const fs = require('fs')
const path = require('path')

const STATE_FILE = '/tmp/claude-preflight-state.json'
const PRINCIPLES_FILE = path.join(__dirname, 'active-principles.md')
const CORRECTIONS_LOG = path.join(__dirname, 'corrections.log')
const PROJECT_MAPS_FILE = path.join(__dirname, 'data', 'project-maps.json')

// AtomSpace structural queries (optional — graceful fallback if CogServer down)
let atomspaceQuery = null
let fireLog = null
try { atomspaceQuery = require('./atomspace/query') } catch {}
try { fireLog = require('./atomspace/fire-log') } catch {}

// Shared modules and their downstream consumers
// This is the map of files where edits need pre-flight review
const SHARED_MODULE_MAP = {
  'multiwriter/shared-notebook.js': {
    description: 'Autobase shared notebook — apply functions, batch processing',
    downstream: [
      'hyper-fleeky/hypernote/lib/multiwriter/shared-notebook.js',
      'hyper-fleeky/pear-git/lib/autobase/',
      'hyper-fleeky/hyper-overlay/lib/protocol.js'
    ]
  },
  'multiwriter/registry-apply.js': {
    description: 'Autobase registry apply — entry ordering, namespace boundaries',
    downstream: [
      'hyper-fleeky/hypernote/lib/multiwriter/registry-apply.js',
      'hyper-fleeky/pear-git/lib/autobase/',
      'hyper-fleeky/hyper-social-web/lib/autobase/'
    ]
  },
  'multiwriter/notebook-apply.js': {
    description: 'Autobase notebook apply — note CRUD operations',
    downstream: [
      'hyper-fleeky/hypernote/lib/multiwriter/notebook-apply.js',
      'hyper-fleeky/pear-git/lib/autobase/'
    ]
  },
  'multiwriter/manager.js': {
    description: 'Multiwriter lifecycle — Autobase creation, writer management',
    downstream: [
      'hyper-fleeky/hypernote/lib/multiwriter/manager.js',
      'hyper-fleeky/pear-git/lib/autobase/',
      'hyper-fleeky/hyper-overlay/lib/registry.js'
    ]
  },
  'lib/auth': {
    description: 'Authentication — challenge-response, identity, bearer tokens',
    downstream: [
      'hyper-fleeky/hypernote/lib/auth/',
      'hyper-fleeky/pear-git/lib/auth/',
      'hyper-fleeky/hyper-social-web/lib/auth/'
    ]
  },
  'lib/replication.js': {
    description: 'Hyperswarm replication — peer discovery, core sharing',
    downstream: [
      'hyper-fleeky/hypernote/lib/replication.js',
      'hyper-fleeky/pear-git/lib/replication.js',
      'hyper-fleeky/hyper-overlay/lib/replication.js'
    ]
  },
  // pear-git danger files — 58 loops, most corrections of any project
  'pear-git/pear-git-server.js': {
    description: 'DANGER: Main pear-git server — 12 fix cycles. Autobase init, repo management, HTTP routes. Read ENTIRE file before editing.',
    downstream: ['production server on tabertron + stortron'],
    loopHistory: '58 loops, top failures: audit-demanded (68), stop-commanded (34), same-error-repeated (28)'
  },
  'pear-git/lib/repos.js': {
    description: 'DANGER: Repo registry and bare repo management. Claude has repeatedly broken repo creation flow.',
    downstream: ['pear-git-server.js', 'repo-registry.js']
  },
  'pear-git/lib/repo-registry.js': {
    description: 'DANGER: Autobase-backed repo registry. Past corruption from wrong writer keys.',
    downstream: ['pear-git-server.js']
  },
  'pear-git/lib/autobase/': {
    description: 'DANGER: Autobase apply functions. 20 autobase corruption incidents. NEVER pass serverKey to addWriter.',
    downstream: ['pear-git-server.js', 'identity system']
  },
  'pear-git/lib/identity': {
    description: 'DANGER: Identity + crypto. 13 identity/crypto loop corrections. Key format assumptions have been wrong repeatedly.',
    downstream: ['pear-git-server.js', 'auth system']
  }
}

// Read principles if they exist
function loadPrinciples () {
  if (!fs.existsSync(PRINCIPLES_FILE)) return ['(no principles file yet)']
  return fs.readFileSync(PRINCIPLES_FILE, 'utf8')
    .split('\n')
    .filter(line => line.startsWith('- '))
    .map(line => line.slice(2))
}

// Read recent corrections if log exists
function loadRecentCorrections (filePath) {
  if (!fs.existsSync(CORRECTIONS_LOG)) return []
  const log = fs.readFileSync(CORRECTIONS_LOG, 'utf8')
  return log.split('\n---\n')
    .filter(entry => entry.includes(filePath) || entry.includes(path.basename(filePath)))
    .slice(-3) // last 3 relevant corrections
}

// Check which shared module pattern matches this file
async function findSharedModule (filePath) {
  // First check hardcoded map (manually curated danger files)
  for (const [pattern, info] of Object.entries(SHARED_MODULE_MAP)) {
    if (filePath.includes(pattern)) {
      return { pattern, ...info }
    }
  }

  // Skip non-production paths — tests, examples, demos aren't shared module risk
  const lowerPath = filePath.toLowerCase()
  if (/\/(tests?|examples?|demos?|fixtures?|__tests__|__mocks__)\//.test(lowerPath)) {
    return null
  }

  // Fallback: check autodiscovered project map for HIGH-danger subsystems only.
  // Medium is too common (18/28 subsystems) and blocks routine development.
  if (fs.existsSync(PROJECT_MAPS_FILE)) {
    try {
      const maps = JSON.parse(fs.readFileSync(PROJECT_MAPS_FILE, 'utf8'))
      for (const [projectName, projectMap] of Object.entries(maps.projects || {})) {
        if (!filePath.includes(projectName) && !filePath.startsWith(projectMap.root)) continue

        // Find which subsystem this file belongs to
        for (const [subName, subData] of Object.entries(projectMap.subsystems || {})) {
          const matchesFile = (subData.files || []).some(f =>
            filePath.endsWith(f) || filePath.endsWith('/' + f)
          )
          if (matchesFile && subData.dangerLevel === 'high') {
            return {
              pattern: `${projectName}/${subName}`,
              description: `Auto-detected: ${projectName}/${subName} subsystem (${subData.fileCount} files, ${subData.loopCount} past loops, danger=${subData.dangerLevel})`,
              downstream: subData.dependedBy || [],
              loopHistory: subData.loopCount > 0
                ? `${subData.loopCount} loops, types: ${Object.entries(subData.loopTypes || {}).map(([t, c]) => t + '(' + c + ')').join(', ')}`
                : null,
            }
          }
        }
      }
    } catch {}
  }

  // Final fallback: query AtomSpace for structural risk.
  // Require genuine danger signal: 3+ past loops or high danger with downstream consumers.
  // A single past loop is routine development, not a blocking signal.
  if (atomspaceQuery) {
    try {
      const br = await atomspaceQuery.blastRadius(filePath)
      if (br) {
        const isGenuineRisk = br.loops.length >= 3 ||
          (br.dangerLevel === 'high' && br.consumers.length > 0) ||
          (br.loops.length >= 1 && br.consumers.length >= 3)
        if (isGenuineRisk) {
          return {
            pattern: br.file,
            description: `AtomSpace: ${br.loops.length} past loops, danger=${br.dangerLevel}, ${br.consumers.length} downstream consumers`,
            downstream: br.consumers.slice(0, 5).map(c => c.replace('file:', '')),
            loopHistory: br.loops.length > 0 ? `${br.loops.length} loops linked in AtomSpace` : null
          }
        }
      }
    } catch {}
  }

  return null
}

// Read session state (what has been reviewed already this session)
function loadState () {
  if (!fs.existsSync(STATE_FILE)) return { reviewed: [], session: Date.now() }
  try {
    return JSON.parse(fs.readFileSync(STATE_FILE, 'utf8'))
  } catch {
    return { reviewed: [], session: Date.now() }
  }
}

function saveState (state) {
  fs.writeFileSync(STATE_FILE, JSON.stringify(state, null, 2))
}

// Main hook logic
async function main () {
  let input = ''
  for await (const chunk of process.stdin) {
    input += chunk
  }

  const data = JSON.parse(input)
  const toolName = data.tool_name
  const toolInput = data.tool_input || {}
  const filePath = toolInput.file_path || ''

  // Only review Edit and Write to shared modules
  if (toolName !== 'Edit' && toolName !== 'Write') {
    process.exit(0)
  }

  const sharedModule = await findSharedModule(filePath)
  if (!sharedModule) {
    process.exit(0) // not a shared module, allow
  }

  // Check if already reviewed this file this session
  const state = loadState()
  if (state.reviewed.includes(filePath)) {
    process.exit(0) // already reviewed, allow
  }

  // Gather context
  const principles = loadPrinciples()
  const corrections = loadRecentCorrections(filePath)
  const downstream = sharedModule.downstream

  // AtomSpace structural + semantic + contrast context (optional, non-blocking)
  let structural = null
  let fileCtx = null
  let unfamiliarity = null
  let resolutions = null
  let contrast = null
  if (atomspaceQuery) {
    try { structural = await atomspaceQuery.blastRadiusEnhanced(filePath) } catch {}
    try { fileCtx = await atomspaceQuery.fileContext(filePath) } catch {}
    try { unfamiliarity = await atomspaceQuery.unfamiliarityScore(filePath) } catch {}
    if (structural && structural.loops.length > 0) {
      try { resolutions = await atomspaceQuery.findResolution(filePath) } catch {}
    }
    // Contrast-derived meta-cognition: "last time this was the fix / last time this was wasted effort"
    try { contrast = await atomspaceQuery.contrastGuidance(filePath) } catch {}
  }

  // Live session context (streaming observability)
  let liveSession = null
  let preferredApproaches = null
  if (atomspaceQuery) {
    try { liveSession = await atomspaceQuery.liveSessionContext(filePath) } catch {}
    // Preferred-approach from success memory (Phase 2.3)
    try { preferredApproaches = await atomspaceQuery.preferredApproachFor(filePath) } catch {}
  }

  // Build the briefing
  const briefing = []
  briefing.push('PRE-FLIGHT REVIEW — SHARED MODULE EDIT')
  briefing.push('═'.repeat(50))
  briefing.push('')
  briefing.push(`FILE: ${filePath}`)
  briefing.push(`TYPE: ${sharedModule.description}`)
  briefing.push('')

  briefing.push('DOWNSTREAM IMPACT')
  briefing.push('─'.repeat(30))
  for (const dep of downstream) {
    briefing.push(`  → ${dep}`)
  }
  briefing.push(`  ${downstream.length} project(s) share this pattern`)

  // Add AtomSpace context if available
  if (fileCtx || structural || unfamiliarity) {
    briefing.push('')
    briefing.push('ATOMSPACE CONTEXT')
    briefing.push('─'.repeat(30))

    // File classification — language/domain/role
    if (fileCtx) {
      const parts = []
      if (fileCtx.language !== 'unknown') parts.push(`language=${fileCtx.language}`)
      if (fileCtx.domain !== 'unknown') parts.push(`domain=${fileCtx.domain}`)
      if (fileCtx.role !== 'unknown') parts.push(`role=${fileCtx.role}`)
      if (fileCtx.isExportedAPI) parts.push(`exports=${fileCtx.exports} (public API)`)
      if (parts.length) briefing.push(`  File: ${parts.join(', ')}`)
    }

    // Unfamiliarity signal
    if (unfamiliarity && unfamiliarity.familiarity !== 'unknown') {
      const warn = (unfamiliarity.familiarity === 'unfamiliar' || unfamiliarity.familiarity === 'never-visited')
      briefing.push(`  Module: ${unfamiliarity.module} (${unfamiliarity.sessions} sessions — ${unfamiliarity.familiarity})${warn ? ' ⚠' : ''}`)
    }

    // Structural blast radius
    if (structural) {
      if (structural.consumers.length) {
        briefing.push(`  ${structural.consumers.length} files import from this file`)
        for (const c of structural.consumers.slice(0, 3)) {
          briefing.push(`    ← ${c.replace('file:', '')}`)
        }
      }
      if (structural.loops.length) {
        briefing.push(`  ${structural.loops.length} past correction loops on this file`)
      }
      if (structural.rules.length) {
        const hw = structural.rulesHighWisdom || 0
        const lw = structural.rulesLowWisdom || 0
        const wisdomNote = hw > 0 ? ` (${hw} high-wisdom)` : (lw > 0 ? ` (${lw} stale-referents)` : '')
        briefing.push(`  ${structural.rules.length} learned rules apply${wisdomNote}`)
      }
      if (typeof structural.riskScore === 'number') {
        briefing.push(`  Combined risk score: ${structural.riskScore}`)
      }
    }

    // Past resolutions for similar loops
    if (resolutions && resolutions.matches.length > 0) {
      briefing.push('')
      briefing.push('  SIMILAR PAST RESOLUTIONS')
      for (const m of resolutions.matches.slice(0, 3)) {
        briefing.push(`    [${m.type || '?'}] fix was in: ${m.resolutionFiles.slice(0, 2).join(', ')}`)
      }
    }

    // Live session observability — what happened THIS session
    if (liveSession && liveSession.sessionId) {
      const hasLiveSignal = liveSession.editsForThisFile > 0 ||
                            liveSession.correctionsNearThisFile > 0 ||
                            liveSession.totalSessionCorrections > 2
      if (hasLiveSignal) {
        briefing.push('')
        briefing.push('  THIS SESSION (live)')
        if (liveSession.editsForThisFile > 0) {
          briefing.push(`    You've edited this file ${liveSession.editsForThisFile}x this session`)
        }
        if (liveSession.correctionsNearThisFile > 0) {
          briefing.push(`    ⚠ ${liveSession.correctionsNearThisFile} corrections occurred while editing this file`)
          for (const c of liveSession.recentCorrections.slice(0, 2)) {
            briefing.push(`      turn ${c.turn}: "${c.text.slice(0, 80)}..."`)
          }
        }
        if (liveSession.totalSessionCorrections > 2) {
          briefing.push(`    Session has ${liveSession.totalSessionCorrections} corrections total — slow down`)
        }
      }
    }

    // Preferred-approach from past successes (Phase 2.3)
    if (preferredApproaches && preferredApproaches.length > 0) {
      briefing.push('')
      briefing.push('  ★ PAST SUCCESSFUL APPROACHES')
      for (const p of preferredApproaches.slice(0, 2)) {
        briefing.push(`    [${p.shape} ×${p.successCount}] ${p.id.replace('preferred:', '').split('::')[0]}`)
        if (p.sampleApproval) briefing.push(`      e.g. "${p.sampleApproval}"`)
      }
    }

    // Contrast-derived meta-cognition (highest-value signal)
    if (contrast) {
      const hasSignal = (contrast.worksFor && contrast.worksFor.length > 0) ||
                        (contrast.failedFor && contrast.failedFor.length > 0) ||
                        (contrast.patterns && contrast.patterns.length > 0)
      if (hasSignal) {
        briefing.push('')
        briefing.push('  META-COGNITION (contrast-derived)')
        if (contrast.worksFor.length > 0) {
          briefing.push(`    ✓ This file FIXED ${contrast.worksFor.length} past loops — it's usually the right place`)
        }
        if (contrast.failedFor.length > 0) {
          briefing.push(`    ✗ This file was WASTED EFFORT in ${contrast.failedFor.length} past loops — fix was elsewhere`)
        }
        // Cross-loop patterns
        for (const p of (contrast.patterns || []).slice(0, 1)) {
          briefing.push(`    Pattern [${p.context}] (${p.loopCount} loops):`)
          if (p.tryFirst.length) {
            briefing.push(`      TRY: ${p.tryFirst.slice(0, 3).join(', ')}`)
          }
          if (p.avoidFirst.length) {
            briefing.push(`      AVOID: ${p.avoidFirst.slice(0, 3).join(', ')}`)
          }
        }
      }
    }
  }
  briefing.push('')

  if (principles.length > 0) {
    briefing.push('ACTIVE PRINCIPLES')
    briefing.push('─'.repeat(30))
    for (const p of principles) {
      briefing.push(`  • ${p}`)
    }
    briefing.push('')
  }

  if (corrections.length > 0) {
    briefing.push('PAST CORRECTIONS ON THIS FILE')
    briefing.push('─'.repeat(30))
    for (const c of corrections) {
      briefing.push(`  ⚠ ${c.trim().split('\n')[0]}`)
    }
    briefing.push('')
  }

  // Describe the proposed change
  if (toolName === 'Edit') {
    const oldStr = (toolInput.old_string || '').slice(0, 200)
    const newStr = (toolInput.new_string || '').slice(0, 200)
    briefing.push('PROPOSED CHANGE')
    briefing.push('─'.repeat(30))
    briefing.push(`  - ${oldStr}${oldStr.length >= 200 ? '...' : ''}`)
    briefing.push(`  + ${newStr}${newStr.length >= 200 ? '...' : ''}`)
    briefing.push('')
  }

  briefing.push('─'.repeat(50))
  briefing.push('Review the above. This edit will be BLOCKED until you')
  briefing.push('confirm. Tell Claude to proceed or adjust the approach.')
  briefing.push('─'.repeat(50))

  // Log fired rules/meta atoms for utility tracking + live dashboard
  if (fireLog) {
    try {
      const sessionId = (() => {
        try { return require('fs').readFileSync('/tmp/claude-atomspace-session', 'utf8').trim() } catch { return null }
      })()
      const contextExcerpt = `edit ${filePath}`
      // Log rules that would be shown
      if (structural && structural.rules) {
        for (const r of structural.rules.slice(0, 5)) {
          fireLog.logFire({
            session: sessionId,
            hook: 'preflight',
            atom: r,
            context: contextExcerpt,
            trigger: filePath
          })
        }
      }
      // Log meta atoms from contrast guidance
      if (contrast) {
        for (const m of (contrast.worksFor || []).slice(0, 3)) {
          fireLog.logFire({
            session: sessionId,
            hook: 'preflight',
            atom: `meta:loop:${m.loopId}`,
            atomType: 'meta-loop',
            context: contextExcerpt,
            trigger: filePath
          })
        }
        for (const p of (contrast.patterns || []).slice(0, 2)) {
          fireLog.logFire({
            session: sessionId,
            hook: 'preflight',
            atom: `meta:pattern:${p.context}`,
            atomType: 'pattern',
            context: contextExcerpt,
            trigger: filePath
          })
        }
      }
    } catch {}
  }

  // Mark as reviewed for this session (won't block again on same file)
  // The next edit to this file will pass through — if the user overrides,
  // that's tracked by the override-tracker for principle demotion
  state.reviewed.push(filePath)
  state.lastBlockedPrinciples = principles
  saveState(state)

  // Block with briefing — Claude receives this and presents to user
  process.stderr.write(briefing.join('\n'))
  process.exit(2)
}

main().catch(err => {
  process.stderr.write(`Pre-flight hook error: ${err.message}`)
  process.exit(0) // fail open — don't block on hook errors
})