| … | … | @@ -0,0 +1,143 @@ |
| 1 | // Shared project classifier. | |
| 2 | // Single source of truth for mapping a working directory (+ optional file list) | |
| 3 | // to a project name. Used by loop-detector.js (at loop extraction time) and | |
| 4 | // loop-briefing.js (at SessionStart). | |
| 5 | // | |
| 6 | // Strategy, in order: | |
| 7 | // 1. Hardcoded substring match — preserves backwards compatibility with | |
| 8 | // historical classifications AND handles names that don't match a | |
| 9 | // directory basename (e.g., "nvidia-rtx" when the path contains "nvidia"). | |
| 10 | // 2. Walk parent directories from cwd looking for .git. Use that directory's | |
| 11 | // basename. If the basename is too generic (e.g., "repos", "src"), walk | |
| 12 | // up one more level. | |
| 13 | // 3. Fall back to 'unknown'. | |
| 14 | // | |
| 15 | // Before this file existed, classifyProject was duplicated in loop-detector.js | |
| 16 | // and loop-briefing.js with a hardcoded ~11-project allowlist, which left | |
| 17 | // ~16% of loops and ~11% of rules in the 'unknown' bucket — including all | |
| 18 | // work done on hypercore-kb, opencog-kb, hyper-deploy, and several other | |
| 19 | // directories that didn't happen to be on the list. | |
| 20 | ||
| 21 | const fs = require('fs') | |
| 22 | const path = require('path') | |
| 23 | ||
| 24 | // Generic directory names that are never useful project identifiers on | |
| 25 | // their own. If the git root directory has one of these as its basename, | |
| 26 | // walk up one more level to get the real project name. | |
| 27 | const GENERIC_BASENAMES = new Set([ | |
| 28 | 'repos', 'src', 'code', 'build', 'dist', 'lib', 'project', 'projects', | |
| 29 | 'work', 'workspace', 'tmp', 'temp', 'test', 'tests', 'app', 'apps' | |
| 30 | ]) | |
| 31 | ||
| 32 | // Hardcoded substring matches. Preserves backwards compatibility with | |
| 33 | // historical loops.json / learned-rules.json entries. These must match | |
| 34 | // the set of names used in those files. | |
| 35 | function matchHardcoded (haystack) { | |
| 36 | const lower = haystack.toLowerCase() | |
| 37 | // Order matters: more specific before more general. | |
| 38 | if (lower.includes('dc-terrain') || lower.includes('dc_terrain')) return 'dc-terrain' | |
| 39 | if (lower.includes('hyper-tps')) return 'hyper-tps' | |
| 40 | if (lower.includes('hypernote')) return 'hypernote' | |
| 41 | if (lower.includes('pear-git')) return 'pear-git' | |
| 42 | if (lower.includes('blender-bridge') || lower.includes('blender_bridge')) return 'blender-bridge' | |
| 43 | if (lower.includes('proc-nodes') || lower.includes('proc_nodes')) return 'proc-nodes' | |
| 44 | if (lower.includes('hyper-overlay')) return 'hyper-overlay' | |
| 45 | if (lower.includes('hyper-social')) return 'hyper-social' | |
| 46 | if (lower.includes('nvidia') || lower.includes('rtx')) return 'nvidia-rtx' | |
| 47 | if (lower.includes('nubis') || lower.includes('clouds')) return 'nubis' | |
| 48 | if (lower.includes('godot')) return 'godot' | |
| 49 | return null | |
| 50 | } | |
| 51 | ||
| 52 | // Walk parent directories from startDir, return the first one containing | |
| 53 | // a .git entry (file or directory), or null if none found before root. | |
| 54 | function findGitRoot (startDir) { | |
| 55 | if (!startDir) return null | |
| 56 | let dir | |
| 57 | try { | |
| 58 | dir = path.resolve(startDir) | |
| 59 | } catch { | |
| 60 | return null | |
| 61 | } | |
| 62 | while (dir && dir !== path.dirname(dir)) { | |
| 63 | try { | |
| 64 | if (fs.existsSync(path.join(dir, '.git'))) return dir | |
| 65 | } catch {} | |
| 66 | dir = path.dirname(dir) | |
| 67 | } | |
| 68 | return null | |
| 69 | } | |
| 70 | ||
| 71 | // Derive a project name from a git root directory. | |
| 72 | // If the basename is generic (like "repos"), walk up one more level. | |
| 73 | function nameFromGitRoot (gitRoot) { | |
| 74 | if (!gitRoot) return null | |
| 75 | const name = path.basename(gitRoot) | |
| 76 | if (!GENERIC_BASENAMES.has(name)) return name | |
| 77 | const parentDir = path.dirname(gitRoot) | |
| 78 | if (!parentDir || parentDir === path.dirname(parentDir)) return name | |
| 79 | const parentName = path.basename(parentDir) | |
| 80 | return (parentName && !GENERIC_BASENAMES.has(parentName)) ? parentName : name | |
| 81 | } | |
| 82 | ||
| 83 | // Main entry point. Returns a project name string, or 'unknown' as the | |
| 84 | // explicit fallback. Never returns null or undefined. | |
| 85 | function classifyProject (cwd, files = []) { | |
| 86 | // Strategy 1: hardcoded substring match (backwards compat + specific names) | |
| 87 | const haystack = (cwd || '') + ' ' + ((files || []).join(' ')) | |
| 88 | const hardcoded = matchHardcoded(haystack) | |
| 89 | if (hardcoded) return hardcoded | |
| 90 | ||
| 91 | // Strategy 2: walk parents to find .git, use that dir's basename | |
| 92 | const gitRoot = findGitRoot(cwd) | |
| 93 | if (gitRoot) { | |
| 94 | const name = nameFromGitRoot(gitRoot) | |
| 95 | if (name) return name | |
| 96 | } | |
| 97 | ||
| 98 | return 'unknown' | |
| 99 | } | |
| 100 | ||
| 101 | module.exports = { | |
| 102 | classifyProject, | |
| 103 | matchHardcoded, | |
| 104 | findGitRoot, | |
| 105 | nameFromGitRoot, | |
| 106 | GENERIC_BASENAMES | |
| 107 | } | |
| 108 | ||
| 109 | // ─── Self-test ────────────────────────────────────────────────────────── | |
| 110 | // Run `node classify-project.js` to verify classification against known cwds. | |
| 111 | if (require.main === module) { | |
| 112 | const cases = [ | |
| 113 | // Previously bucketed 'unknown' — should now classify properly | |
| 114 | { cwd: '/storage/projects/code/hypercore-kb/hyper-fleeky/hyper-deploy', expect: 'hyper-deploy' }, | |
| 115 | { cwd: '/storage/projects/code/hypercore-kb/opencog-kb/repos', expect: 'opencog-kb' }, | |
| 116 | { cwd: '/storage/projects/code/hypercore-kb', expect: 'hypercore-kb' }, | |
| 117 | // knowledge-ratchet is its own git repo nested inside hypercore-kb | |
| 118 | { cwd: '/storage/projects/code/hypercore-kb/knowledge-ratchet', expect: 'knowledge-ratchet' }, | |
| 119 | // Previously classified via hardcoded list — must still match | |
| 120 | { cwd: '/storage/projects/code/godot/dc-terrain/src', expect: 'dc-terrain' }, | |
| 121 | { cwd: '/home/user/hyper-tps/game', expect: 'hyper-tps' }, | |
| 122 | { cwd: '/any/path/with/pear-git/in/it', expect: 'pear-git' }, | |
| 123 | { cwd: '/storage/projects/code/hypercore-kb/hyper-fleeky/hypernote', expect: 'hypernote' }, | |
| 124 | { cwd: '/storage/projects/code/hypercore-kb/hyper-fleeky/hyper-overlay', expect: 'hyper-overlay' }, | |
| 125 | // Edge cases | |
| 126 | { cwd: '', expect: 'unknown' }, | |
| 127 | { cwd: '/tmp', expect: 'unknown' }, | |
| 128 | { cwd: null, expect: 'unknown' } | |
| 129 | ] | |
| 130 | ||
| 131 | let passed = 0 | |
| 132 | let failed = 0 | |
| 133 | for (const { cwd, expect } of cases) { | |
| 134 | const got = classifyProject(cwd) | |
| 135 | const ok = got === expect | |
| 136 | if (ok) passed++ | |
| 137 | else failed++ | |
| 138 | const mark = ok ? '✓' : '✗' | |
| 139 | console.log(`${mark} classifyProject(${JSON.stringify(cwd)}) => ${JSON.stringify(got)} ${ok ? '' : `(expected ${JSON.stringify(expect)})`}`) | |
| 140 | } | |
| 141 | console.log(`\n${passed}/${passed + failed} passed`) | |
| 142 | if (failed > 0) process.exit(1) | |
| 143 | } |
| … | … | @@ -10,25 +10,18 @@ |
| 10 | 10 | |
| 11 | 11 | const fs = require('fs') |
| 12 | 12 | const path = require('path') |
| 13 | const { classifyProject } = require('./classify-project') | |
| 14 | const { computeQuality, QUALITY_RANK } = require('./rule-extractor') | |
| 13 | 15 | |
| 14 | 16 | const LOOPS_FILE = path.join(__dirname, 'data', 'loops.json') |
| 15 | 17 | const RULES_FILE = path.join(__dirname, 'data', 'learned-rules.json') |
| 16 | 18 | |
| 17 | function classifyProject (cwd) { | |
| 18 | const lower = cwd.toLowerCase() | |
| 19 | if (lower.includes('pear-git')) return 'pear-git' | |
| 20 | if (lower.includes('dc-terrain') || lower.includes('dc_terrain')) return 'dc-terrain' | |
| 21 | if (lower.includes('hyper-tps')) return 'hyper-tps' | |
| 22 | if (lower.includes('hypernote')) return 'hypernote' | |
| 23 | if (lower.includes('blender-bridge') || lower.includes('blender_bridge')) return 'blender-bridge' | |
| 24 | if (lower.includes('proc-nodes')) return 'proc-nodes' | |
| 25 | if (lower.includes('hyper-overlay')) return 'hyper-overlay' | |
| 26 | if (lower.includes('hyper-social')) return 'hyper-social' | |
| 27 | if (lower.includes('nvidia') || lower.includes('rtx')) return 'nvidia-rtx' | |
| 28 | if (lower.includes('nubis') || lower.includes('clouds')) return 'nubis' | |
| 29 | if (lower.includes('godot')) return 'godot' | |
| 30 | return null | |
| 31 | } | |
| 19 | // classifyProject is imported from classify-project.js (shared with | |
| 20 | // loop-detector.js). It always returns a non-empty string — either a | |
| 21 | // specific project name or 'unknown' as the explicit fallback. Previously, | |
| 22 | // the local version returned null for unknown projects and this file | |
| 23 | // exited early without a briefing. Now we only exit if the classified | |
| 24 | // project has no loops in the corpus. | |
| 32 | 25 | |
| 33 | 26 | function main () { |
| 34 | 27 | // Only inject on first prompt of session — check if briefing already delivered |
| … | … | @@ -43,7 +36,9 @@ function main () { |
| 43 | 36 | |
| 44 | 37 | const cwd = process.cwd() |
| 45 | 38 | const project = classifyProject(cwd) |
| 46 | if (!project) return // not a known project — no briefing needed | |
| 39 | // classifyProject always returns a string — 'unknown' is valid but rare | |
| 40 | // now that the classifier walks git roots. We still exit if no loops | |
| 41 | // exist for this project name (nothing to brief from). | |
| 47 | 42 | |
| 48 | 43 | if (!fs.existsSync(LOOPS_FILE)) return |
| 49 | 44 | |
| … | … | @@ -109,22 +104,57 @@ function main () { |
| 109 | 104 | } |
| 110 | 105 | } |
| 111 | 106 | |
| 112 | // Load learned rules for this project | |
| 107 | // Load learned rules for this project. | |
| 108 | // | |
| 109 | // Quality-aware injection (Change 2): | |
| 110 | // - rules are sorted by (quality tier, loop duration) so the most | |
| 111 | // actionable and severe come first | |
| 112 | // - only `specific` and `partial` rules are injected as directives; | |
| 113 | // `needs-investigation` rules are counted but held back because | |
| 114 | // they have no concrete symptom or fix target and would pollute | |
| 115 | // the briefing with noise | |
| 116 | // - if ALL rules for this project are `needs-investigation`, we show | |
| 117 | // a "caution" note instead of directives — preserves the signal | |
| 118 | // that something is known to go wrong here without pretending we | |
| 119 | // have guidance | |
| 120 | // - the quality field is read from the rule when present and computed | |
| 121 | // on the fly as a fallback (for rules written before Change 2 that | |
| 122 | // haven't been refreshed by rule-extractor's backfill yet) | |
| 113 | 123 | if (fs.existsSync(RULES_FILE)) { |
| 114 | 124 | try { |
| 115 | 125 | const rulesData = JSON.parse(fs.readFileSync(RULES_FILE, 'utf8')) |
| 116 | 126 | const projectRules = (rulesData.rules || []) |
| 117 | 127 | .filter(r => r.trigger.project === project) |
| 118 | .sort((a, b) => b.source.duration - a.source.duration) | |
| 119 | ||
| 120 | if (projectRules.length > 0) { | |
| 128 | .map(r => ({ ...r, quality: r.quality || computeQuality(r) })) | |
| 129 | ||
| 130 | const sortedRules = [...projectRules].sort((a, b) => { | |
| 131 | const qa = QUALITY_RANK[a.quality] ?? 1 | |
| 132 | const qb = QUALITY_RANK[b.quality] ?? 1 | |
| 133 | if (qa !== qb) return qa - qb | |
| 134 | return b.source.duration - a.source.duration | |
| 135 | }) | |
| 136 | ||
| 137 | const injectable = sortedRules.filter(r => r.quality !== 'needs-investigation') | |
| 138 | const needsInvestigationCount = sortedRules.length - injectable.length | |
| 139 | ||
| 140 | if (injectable.length > 0) { | |
| 141 | const header = needsInvestigationCount > 0 | |
| 142 | ? `LEARNED RULES (${injectable.length} actionable, ${needsInvestigationCount} need investigation):` | |
| 143 | : `LEARNED RULES (${injectable.length} from past loops):` | |
| 121 | 144 | lines.push('') |
| 122 | lines.push(`LEARNED RULES (${projectRules.length} from past loops):`) | |
| 123 | for (const r of projectRules.slice(0, 5)) { | |
| 145 | lines.push(header) | |
| 146 | for (const r of injectable.slice(0, 5)) { | |
| 124 | 147 | lines.push(` When: ${r.trigger.symptom}`) |
| 125 | 148 | lines.push(` Do: ${r.action.description.slice(0, 100)}`) |
| 126 | 149 | lines.push(` Don't: ${r.antipattern.description.slice(0, 100)}`) |
| 127 | 150 | } |
| 151 | } else if (needsInvestigationCount > 0) { | |
| 152 | // All known rules for this project are needs-investigation. | |
| 153 | // Preserve the "historical friction exists here" signal without | |
| 154 | // injecting rules that would be misleading as directives. | |
| 155 | lines.push('') | |
| 156 | lines.push(`⚠ ${needsInvestigationCount} historical patterns exist for ${project}, but none are`) | |
| 157 | lines.push(` actionable yet (no concrete fix targets). Proceed carefully.`) | |
| 128 | 158 | } |
| 129 | 159 | } catch {} |
| 130 | 160 | } |
| … | … | @@ -22,6 +22,7 @@ |
| 22 | 22 | |
| 23 | 23 | const fs = require('fs') |
| 24 | 24 | const path = require('path') |
| 25 | const { classifyProject } = require('./classify-project') | |
| 25 | 26 | |
| 26 | 27 | const DATA_DIR = path.join(__dirname, 'data') |
| 27 | 28 | const DETAIL_FILE = path.join(DATA_DIR, 'fused-turns-detail.json') |
| … | … | @@ -176,22 +177,9 @@ function finalizeLoop (raw, session) { |
| 176 | 177 | } |
| 177 | 178 | |
| 178 | 179 | // ─── Classification ─── |
| 179 | ||
| 180 | function classifyProject (cwd, files) { | |
| 181 | const all = (cwd + ' ' + files.join(' ')).toLowerCase() | |
| 182 | if (all.includes('dc-terrain') || all.includes('dc_terrain')) return 'dc-terrain' | |
| 183 | if (all.includes('hyper-tps')) return 'hyper-tps' | |
| 184 | if (all.includes('hypernote')) return 'hypernote' | |
| 185 | if (all.includes('pear-git')) return 'pear-git' | |
| 186 | if (all.includes('blender-bridge') || all.includes('blender_bridge')) return 'blender-bridge' | |
| 187 | if (all.includes('proc-nodes') || all.includes('proc_nodes')) return 'proc-nodes' | |
| 188 | if (all.includes('hyper-overlay')) return 'hyper-overlay' | |
| 189 | if (all.includes('hyper-social')) return 'hyper-social' | |
| 190 | if (all.includes('nvidia') || all.includes('rtx')) return 'nvidia-rtx' | |
| 191 | if (all.includes('nubis') || all.includes('clouds')) return 'nubis' | |
| 192 | if (all.includes('godot')) return 'godot' | |
| 193 | return 'unknown' | |
| 194 | } | |
| 180 | // classifyProject is imported from classify-project.js (shared with | |
| 181 | // loop-briefing.js). It uses hardcoded substring matching first for | |
| 182 | // backwards compatibility, then walks parent dirs to find .git. | |
| 195 | 183 | |
| 196 | 184 | function classifyType (corrections) { |
| 197 | 185 | const allText = corrections.map(c => c.text).join(' ').toLowerCase() |
| … | … | @@ -30,6 +30,36 @@ function loadJSON (file) { |
| 30 | 30 | catch { return null } |
| 31 | 31 | } |
| 32 | 32 | |
| 33 | // ─── Quality Classification ─── | |
| 34 | // A rule's quality is a function of whether it has actionable content: | |
| 35 | // specific — concrete symptom + concrete fix files | |
| 36 | // partial — one of the two is missing (still injectable) | |
| 37 | // needs-investigation — both antipattern and fix-files are the fallback | |
| 38 | // values, meaning the regex extractor couldn't | |
| 39 | // distill anything actionable. These rules are | |
| 40 | // held back from injection by loop-briefing.js | |
| 41 | // so they don't pollute briefings with noise. | |
| 42 | // | |
| 43 | // The two "fallback" strings are defined where they're produced: | |
| 44 | // buildAntipattern returns "Slow down and verify each step" when no | |
| 45 | // correction regex matched any pattern. | |
| 46 | // buildActionDescription falls back to "Read the target files completely | |
| 47 | // before making changes" when there's no fix, no approval text, and the | |
| 48 | // loop wasn't long enough to trigger the "planning mode early" branch. | |
| 49 | // We detect via antipattern.description (stable sentinel) and fixFiles | |
| 50 | // length (the load-bearing signal). | |
| 51 | ||
| 52 | const GENERIC_ANTIPATTERN = 'Slow down and verify each step' | |
| 53 | const QUALITY_RANK = { specific: 0, partial: 1, 'needs-investigation': 2 } | |
| 54 | ||
| 55 | function computeQuality (rule) { | |
| 56 | const hasGenericAntipattern = rule.antipattern?.description === GENERIC_ANTIPATTERN | |
| 57 | const hasNoFixFiles = !rule.action?.fixFiles || rule.action.fixFiles.length === 0 | |
| 58 | if (hasGenericAntipattern && hasNoFixFiles) return 'needs-investigation' | |
| 59 | if (hasGenericAntipattern || hasNoFixFiles) return 'partial' | |
| 60 | return 'specific' | |
| 61 | } | |
| 62 | ||
| 33 | 63 | // ─── Symptom Extraction ─── |
| 34 | 64 | // Extract the core symptom from correction text |
| 35 | 65 | |
| … | … | @@ -98,6 +128,7 @@ function extractRules (loops) { |
| 98 | 128 | correctionCount: loop.correctionCount, |
| 99 | 129 | }, |
| 100 | 130 | } |
| 131 | rule.quality = computeQuality(rule) | |
| 101 | 132 | |
| 102 | 133 | rules.push(rule) |
| 103 | 134 | } |
| … | … | @@ -182,6 +213,14 @@ function main () { |
| 182 | 213 | const newRules = extractRules(loopsData.loops || []) |
| 183 | 214 | const { merged, added } = mergeRules(newRules, existingRules) |
| 184 | 215 | |
| 216 | // Backfill quality on every rule in the merged output. This catches | |
| 217 | // pre-Change 2 rules that lack a .quality field, and also refreshes | |
| 218 | // the tag for any rule whose antipattern or fixFiles changed since | |
| 219 | // the last extraction. | |
| 220 | for (const rule of merged) { | |
| 221 | rule.quality = computeQuality(rule) | |
| 222 | } | |
| 223 | ||
| 185 | 224 | // Group by project |
| 186 | 225 | const byProject = {} |
| 187 | 226 | for (const rule of merged) { |
| … | … | @@ -234,6 +273,6 @@ function main () { |
| 234 | 273 | console.log('═'.repeat(60)) |
| 235 | 274 | } |
| 236 | 275 | |
| 237 | module.exports = { extractRules, mergeRules } | |
| 276 | module.exports = { extractRules, mergeRules, computeQuality, QUALITY_RANK, GENERIC_ANTIPATTERN } | |
| 238 | 277 | |
| 239 | 278 | if (require.main === module) main() |