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