#!/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
})