// Shared project classifier.
// Single source of truth for mapping a working directory (+ optional file list)
// to a project name. Used by loop-detector.js (at loop extraction time) and
// loop-briefing.js (at SessionStart).
//
// Strategy, in order:
// 1. Hardcoded substring match — preserves backwards compatibility with
// historical classifications AND handles names that don't match a
// directory basename (e.g., "nvidia-rtx" when the path contains "nvidia").
// 2. Walk parent directories from cwd looking for .git. Use that directory's
// basename. If the basename is too generic (e.g., "repos", "src"), walk
// up one more level.
// 3. Fall back to 'unknown'.
//
// Before this file existed, classifyProject was duplicated in loop-detector.js
// and loop-briefing.js with a hardcoded ~11-project allowlist, which left
// ~16% of loops and ~11% of rules in the 'unknown' bucket — including all
// work done on hypercore-kb, opencog-kb, hyper-deploy, and several other
// directories that didn't happen to be on the list.
const fs = require('fs')
const path = require('path')
// Generic directory names that are never useful project identifiers on
// their own. If the git root directory has one of these as its basename,
// walk up one more level to get the real project name.
const GENERIC_BASENAMES = new Set([
'repos', 'src', 'code', 'build', 'dist', 'lib', 'project', 'projects',
'work', 'workspace', 'tmp', 'temp', 'test', 'tests', 'app', 'apps'
])
// Hardcoded substring matches. Preserves backwards compatibility with
// historical loops.json / learned-rules.json entries. These must match
// the set of names used in those files.
function matchHardcoded (haystack) {
const lower = haystack.toLowerCase()
// Order matters: more specific before more general.
if (lower.includes('dc-terrain') || lower.includes('dc_terrain')) return 'dc-terrain'
if (lower.includes('hyper-tps')) return 'hyper-tps'
if (lower.includes('hypernote')) return 'hypernote'
if (lower.includes('pear-git')) return 'pear-git'
if (lower.includes('blender-bridge') || lower.includes('blender_bridge')) return 'blender-bridge'
if (lower.includes('proc-nodes') || lower.includes('proc_nodes')) return 'proc-nodes'
if (lower.includes('hyper-overlay')) return 'hyper-overlay'
if (lower.includes('hyper-social')) return 'hyper-social'
if (lower.includes('nvidia') || lower.includes('rtx')) return 'nvidia-rtx'
if (lower.includes('nubis') || lower.includes('clouds')) return 'nubis'
if (lower.includes('godot')) return 'godot'
return null
}
// Walk parent directories from startDir, return the first one containing
// a .git entry (file or directory), or null if none found before root.
function findGitRoot (startDir) {
if (!startDir) return null
let dir
try {
dir = path.resolve(startDir)
} catch {
return null
}
while (dir && dir !== path.dirname(dir)) {
try {
if (fs.existsSync(path.join(dir, '.git'))) return dir
} catch {}
dir = path.dirname(dir)
}
return null
}
// Derive a project name from a git root directory.
// If the basename is generic (like "repos"), walk up one more level.
function nameFromGitRoot (gitRoot) {
if (!gitRoot) return null
const name = path.basename(gitRoot)
if (!GENERIC_BASENAMES.has(name)) return name
const parentDir = path.dirname(gitRoot)
if (!parentDir || parentDir === path.dirname(parentDir)) return name
const parentName = path.basename(parentDir)
return (parentName && !GENERIC_BASENAMES.has(parentName)) ? parentName : name
}
// Main entry point. Returns a project name string, or 'unknown' as the
// explicit fallback. Never returns null or undefined.
function classifyProject (cwd, files = []) {
// Strategy 1: hardcoded substring match (backwards compat + specific names)
const haystack = (cwd || '') + ' ' + ((files || []).join(' '))
const hardcoded = matchHardcoded(haystack)
if (hardcoded) return hardcoded
// Strategy 2: walk parents to find .git, use that dir's basename
const gitRoot = findGitRoot(cwd)
if (gitRoot) {
const name = nameFromGitRoot(gitRoot)
if (name) return name
}
return 'unknown'
}
module.exports = {
classifyProject,
matchHardcoded,
findGitRoot,
nameFromGitRoot,
GENERIC_BASENAMES
}
// ─── Self-test ──────────────────────────────────────────────────────────
// Run `node classify-project.js` to verify classification against known cwds.
if (require.main === module) {
const cases = [
// Previously bucketed 'unknown' — should now classify properly
{ cwd: '/storage/projects/code/hypercore-kb/hyper-fleeky/hyper-deploy', expect: 'hyper-deploy' },
{ cwd: '/storage/projects/code/hypercore-kb/opencog-kb/repos', expect: 'opencog-kb' },
{ cwd: '/storage/projects/code/hypercore-kb', expect: 'hypercore-kb' },
// knowledge-ratchet is its own git repo nested inside hypercore-kb
{ cwd: '/storage/projects/code/hypercore-kb/knowledge-ratchet', expect: 'knowledge-ratchet' },
// Previously classified via hardcoded list — must still match
{ cwd: '/storage/projects/code/godot/dc-terrain/src', expect: 'dc-terrain' },
{ cwd: '/home/user/hyper-tps/game', expect: 'hyper-tps' },
{ cwd: '/any/path/with/pear-git/in/it', expect: 'pear-git' },
{ cwd: '/storage/projects/code/hypercore-kb/hyper-fleeky/hypernote', expect: 'hypernote' },
{ cwd: '/storage/projects/code/hypercore-kb/hyper-fleeky/hyper-overlay', expect: 'hyper-overlay' },
// Edge cases
{ cwd: '', expect: 'unknown' },
{ cwd: '/tmp', expect: 'unknown' },
{ cwd: null, expect: 'unknown' }
]
let passed = 0
let failed = 0
for (const { cwd, expect } of cases) {
const got = classifyProject(cwd)
const ok = got === expect
if (ok) passed++
else failed++
const mark = ok ? '✓' : '✗'
console.log(`${mark} classifyProject(${JSON.stringify(cwd)}) => ${JSON.stringify(got)} ${ok ? '' : `(expected ${JSON.stringify(expect)})`}`)
}
console.log(`\n${passed}/${passed + failed} passed`)
if (failed > 0) process.exit(1)
}