#!/usr/bin/env node
// Rule Extractor — Auto-Generate Rules from Resolved Loops
// Knowledge Ratchet Phase 2
//
// Reads loops.json and extracts specific, actionable rules from resolved loops.
// Each rule has:
// trigger: { project, subsystem, symptom (from corrections) }
// action: what fixed it (from resolution files + approval text)
// antipattern: what was tried and failed (from loop corrections)
// source: which loop taught this
//
// Rules accumulate in data/learned-rules.json.
// Loaded by loop-briefing.js and preflight-review.js as directives.
//
// Usage:
// node rule-extractor.js # extract rules from all resolved loops
// node rule-extractor.js --verbose # show rule details
const fs = require('fs')
const path = require('path')
const crypto = require('crypto')
const DATA_DIR = path.join(__dirname, 'data')
const LOOPS_FILE = path.join(DATA_DIR, 'loops.json')
const RULES_FILE = path.join(DATA_DIR, 'learned-rules.json')
// ─── Content-addressed rule IDs ───
// Previously: `rule-${project}-${type}-${rules.length}` — index-based,
// unstable across extraction runs (rules.length shifts when loops are
// reordered or new ones appear). This caused merge-based dedup to
// fail: the same underlying loop produced a new rule ID on every
// pipeline run, and mergeRules treated each as a new rule. Result:
// corpus inflation to ~4x the actual number of distinct loops.
//
// Fix: derive the ID from the loop's fingerprint (which is stable for
// a given loop). Same loop → same ID → merge is idempotent.
//
// The fingerprint is already generated by loop-detector.js:buildFingerprint
// and stored on every rule as source.loopFingerprint.
function ruleId (ruleOrLoop) {
const fp = ruleOrLoop.fingerprint || ruleOrLoop.source?.loopFingerprint || ''
const project = ruleOrLoop.project || ruleOrLoop.trigger?.project || 'unknown'
const type = ruleOrLoop.type || ruleOrLoop.trigger?.type || 'other'
const hash = crypto.createHash('md5').update(fp).digest('hex').slice(0, 8)
return `rule-${project}-${type}-${hash}`
}
function loadJSON (file) {
if (!fs.existsSync(file)) return null
try { return JSON.parse(fs.readFileSync(file, 'utf8')) }
catch { return null }
}
// ─── Quality Classification ───
// A rule's quality is a function of whether it has actionable content:
// specific — concrete symptom + concrete fix files
// partial — one of the two is missing (still injectable)
// needs-investigation — both antipattern and fix-files are the fallback
// values, meaning the regex extractor couldn't
// distill anything actionable. These rules are
// held back from injection by loop-briefing.js
// so they don't pollute briefings with noise.
//
// The two "fallback" strings are defined where they're produced:
// buildAntipattern returns "Slow down and verify each step" when no
// correction regex matched any pattern.
// buildActionDescription falls back to "Read the target files completely
// before making changes" when there's no fix, no approval text, and the
// loop wasn't long enough to trigger the "planning mode early" branch.
// We detect via antipattern.description (stable sentinel) and fixFiles
// length (the load-bearing signal).
const GENERIC_ANTIPATTERN = 'Slow down and verify each step'
const QUALITY_RANK = { specific: 0, partial: 1, 'needs-investigation': 2 }
function computeQuality (rule) {
const hasGenericAntipattern = rule.antipattern?.description === GENERIC_ANTIPATTERN
const hasNoFixFiles = !rule.action?.fixFiles || rule.action.fixFiles.length === 0
if (hasGenericAntipattern && hasNoFixFiles) return 'needs-investigation'
if (hasGenericAntipattern || hasNoFixFiles) return 'partial'
return 'specific'
}
// ─── Symptom Extraction ───
// Extract the core symptom from correction text
function extractSymptom (corrections) {
if (corrections.length === 0) return 'unknown'
// Use the first correction as the primary symptom
const text = corrections[0].toLowerCase()
// Try to extract a concise symptom
if (/invisible|not (visible|showing|rendering)|can.t see|don.t see/.test(text)) return 'not rendering/visible'
if (/flat|just flat|totally flat/.test(text)) return 'output is flat/empty'
if (/crash|exception|error|fail/.test(text)) return 'runtime crash/error'
if (/same|still|no change|didn.t (work|help|fix)/.test(text)) return 'fix did not work'
if (/worse|regression|broke/.test(text)) return 'change made it worse'
if (/wrong|reversed|flipped|incorrect/.test(text)) return 'output is wrong/reversed'
if (/why (are|is|did)|approach/.test(text)) return 'wrong approach/design'
if (/audit|planning|think deeper/.test(text)) return 'needs deeper analysis'
if (/stop/.test(text)) return 'going in wrong direction'
if (/corrupt/.test(text)) return 'data corruption'
// Fallback: first 60 chars of first correction
return corrections[0].slice(0, 60).toLowerCase()
}
// ─── Rule Generation ───
function extractRules (loops) {
const rules = []
const seen = new Set() // dedup by fingerprint
for (const loop of loops) {
if (!loop.resolved) continue
if (!loop.corrections || loop.corrections.length === 0) continue
if (!loop.resolution) continue
// Dedup: same fingerprint = same rule
if (seen.has(loop.fingerprint)) continue
seen.add(loop.fingerprint)
const symptom = extractSymptom(loop.corrections)
const fixFiles = (loop.resolution.files || []).map(f => path.basename(f))
const approval = loop.resolution.approval || null
// Build the rule — id is content-addressed via ruleId(loop) so the
// same loop always produces the same id across extraction runs
const rule = {
id: ruleId(loop),
trigger: {
project: loop.project,
type: loop.type,
symptom,
files: (loop.filesTried || []).slice(0, 5).map(f => path.basename(f)),
},
action: {
fixFiles,
description: buildActionDescription(symptom, fixFiles, approval, loop),
},
antipattern: {
description: buildAntipattern(loop.corrections),
corrections: loop.corrections.slice(0, 3),
},
source: {
loopFingerprint: loop.fingerprint,
date: loop.date,
duration: loop.duration,
correctionCount: loop.correctionCount,
},
}
rule.quality = computeQuality(rule)
rules.push(rule)
}
return rules
}
function buildActionDescription (symptom, fixFiles, approval, loop) {
const parts = []
if (fixFiles.length > 0) {
parts.push(`Fix was in: ${fixFiles.join(', ')}`)
}
if (approval) {
parts.push(`Resolution: "${approval.slice(0, 120)}"`)
}
if (loop.duration > 10) {
parts.push(`This took ${loop.duration} turns to resolve — enter planning mode early`)
}
if (parts.length === 0) {
parts.push('Read the target files completely before making changes')
}
return parts.join('. ')
}
function buildAntipattern (corrections) {
// Extract what NOT to do from the correction sequence
const patterns = []
if (corrections.some(c => /stop/i.test(c))) {
patterns.push('Do not continue without reading the code first')
}
if (corrections.some(c => /audit|planning/i.test(c))) {
patterns.push('Do not implement without planning — enter planning mode')
}
if (corrections.some(c => /same|still/i.test(c))) {
patterns.push('Do not apply the same approach again if it already failed')
}
if (corrections.some(c => /worse|regression|broke/i.test(c))) {
patterns.push('Verify the fix does not break existing functionality')
}
if (corrections.some(c => /corrupt/i.test(c))) {
patterns.push('Check for data corruption before and after changes')
}
return patterns.length > 0 ? patterns.join('. ') : 'Slow down and verify each step'
}
// ─── Merge with existing rules ───
function mergeRules (newRules, existingRules) {
const existingIds = new Set(existingRules.map(r => r.id))
const merged = [...existingRules]
let added = 0
for (const rule of newRules) {
if (!existingIds.has(rule.id)) {
merged.push(rule)
added++
}
}
return { merged, added }
}
// ─── Main ───
function main () {
const verbose = process.argv.includes('--verbose')
const loopsData = loadJSON(LOOPS_FILE)
if (!loopsData) {
console.log('No loops.json found. Run loop-detector.js first.')
return
}
const existingRules = loadJSON(RULES_FILE)?.rules || []
const newRules = extractRules(loopsData.loops || [])
const { merged, added } = mergeRules(newRules, existingRules)
// ─── Content-addressed dedup pass ───
// Group by loop fingerprint, collapse duplicates, and normalize all
// IDs to the content-addressed form. This handles:
// - One-shot migration: pre-Change-4 corpora have index-based IDs
// like `rule-dc-terrain-other-3` which don't survive re-extraction,
// so the corpus accumulates ~4x duplicates. This pass collapses
// them to one representative per fingerprint.
// - Defensive: future pipeline runs stay idempotent. If anything
// ever produces a duplicate again, it gets collapsed here.
// Rules without a fingerprint are kept as-is (can't dedup them safely).
// When duplicates exist, the rule with the most recent source.date wins.
const byFingerprint = new Map()
const noFingerprint = []
let duplicatesDropped = 0
for (const rule of merged) {
const fp = rule.source?.loopFingerprint
if (!fp) {
noFingerprint.push(rule)
continue
}
// Normalize id to content-addressed form
rule.id = ruleId({
project: rule.trigger.project,
type: rule.trigger.type,
fingerprint: fp
})
const existing = byFingerprint.get(fp)
if (!existing) {
byFingerprint.set(fp, rule)
} else {
duplicatesDropped++
// Tiebreak: keep the one with the most recent source.date
const existingDate = existing.source?.date || ''
const newDate = rule.source?.date || ''
if (newDate > existingDate) {
byFingerprint.set(fp, rule)
}
}
}
const deduped = [...Array.from(byFingerprint.values()), ...noFingerprint]
// ─── Drop unknown-project rules (Change 5) ───
// Rules with trigger.project === 'unknown' can never fire at runtime.
// loop-briefing.js matches rules by specific project name, and
// classify-project.js's walker either finds a .git (returning a real
// project name) or fails completely. No live session ever classifies
// as 'unknown', so these rules are unreachable by design. Dropping them
// is a one-shot migration (catches pre-Change-4 stale unknowns) AND a
// permanent filter (prevents future unknown rules from accumulating).
const filtered = deduped.filter(r => r.trigger.project !== 'unknown')
const unknownDropped = deduped.length - filtered.length
// Backfill quality on every rule in the filtered output. This catches
// pre-Change 2 rules that lack a .quality field, and also refreshes
// the tag for any rule whose antipattern or fixFiles changed since
// the last extraction.
for (const rule of filtered) {
rule.quality = computeQuality(rule)
}
// Group by project
const byProject = {}
for (const rule of filtered) {
const proj = rule.trigger.project
if (!byProject[proj]) byProject[proj] = []
byProject[proj].push(rule)
}
console.log('═'.repeat(60))
console.log(' RULE EXTRACTOR — Learned Rules from Loops')
console.log('═'.repeat(60))
console.log(` Resolved loops analyzed: ${loopsData.loops.filter(l => l.resolved).length}`)
console.log(` Rules extracted: ${newRules.length}`)
console.log(` New rules added: ${added}`)
console.log(` Pre-dedup total: ${merged.length}`)
console.log(` Duplicates dropped: ${duplicatesDropped}`)
console.log(` Unknown-project rules dropped: ${unknownDropped}`)
console.log(` Total rules: ${filtered.length}`)
console.log('\n BY PROJECT')
console.log(' ' + '-'.repeat(56))
for (const [proj, rules] of Object.entries(byProject).sort((a, b) => b[1].length - a[1].length)) {
console.log(` ${proj}: ${rules.length} rules`)
if (verbose) {
for (const r of rules.slice(0, 5)) {
console.log(` [${r.trigger.type}] "${r.trigger.symptom}"`)
console.log(` → ${r.action.description.slice(0, 100)}`)
if (r.antipattern.description) {
console.log(` ✗ ${r.antipattern.description.slice(0, 100)}`)
}
}
}
}
// Show top rules by severity (longest loops)
const bySeverity = [...filtered].sort((a, b) => b.source.duration - a.source.duration)
console.log('\n HIGHEST SEVERITY RULES (longest loops)')
console.log(' ' + '-'.repeat(56))
for (const r of bySeverity.slice(0, 10)) {
console.log(` [${r.source.duration} turns] ${r.trigger.project}/${r.trigger.type}: "${r.trigger.symptom}"`)
console.log(` → ${r.action.description.slice(0, 120)}`)
}
// Save
const report = {
generated: new Date().toISOString(),
totalRules: filtered.length,
rules: filtered,
}
fs.writeFileSync(RULES_FILE, JSON.stringify(report, null, 2))
console.log(`\nSaved: ${RULES_FILE}`)
console.log('═'.repeat(60))
}
module.exports = { extractRules, mergeRules, computeQuality, QUALITY_RANK, GENERIC_ANTIPATTERN, ruleId }
if (require.main === module) main()