OverviewFilesCommitsIssuesDocsReleasesPeers

knowledge-ratchet commits / 6d51884

Classifier recovery + rule quality injection priority
Change 1: shared project classifier with git-root walk - new classify-project.js replaces duplicated hardcoded allowlists in loop-detector.js and loop-briefing.js. Strategy is: hardcoded substring match first (backcompat + names that don't match a basename like nvidia-rtx), then walk parents for .git and use that dir's basename (skipping generics like "repos", "src"), then 'unknown' fallback. - Recovers 48 of 51 previously 'unknown' loops. New buckets populated: hypercore-kb (+40 loops), hyper-deploy (+4), hyper-identity (+2), opencog-kb (+2). - Self-test: 12/12 cases passing (see classify-project.js main block). Change 2: rule quality field + injection priority - rule-extractor.js adds computeQuality(rule) which tags each rule as specific (concrete symptom + fix files), partial (one of the two), or needs-investigation (both generic). Computed at extraction time and backfilled across all existing rules in the merged output at save time, so pre-Change-2 rules get tagged on the next pipeline run. - loop-briefing.js imports computeQuality and QUALITY_RANK. Rules are sorted by (quality tier, loop duration), needs-investigation rules are held back from the injectable list but counted in the header ("N actionable, M need investigation"). If ALL rules for a project are needs-investigation, shows a caution fallback instead of directives — preserves the "friction exists here" signal without pretending we have guidance. - After backfill on 1048 rules: 44.5% specific, 41.9% partial, 13.6% needs-investigation. 86.4% of rules are injectable, 13.6% held back. Design principle: reclassification preserves information, suppression discards it. Held back rules stay in learned-rules.json for future re-extraction (e.g., LLM-assisted) and show up in dashboard counts. Verified end-to-end by running loop-briefing.js from /hypercore-kb — briefing header reads "LEARNED RULES (9 actionable, 6 need investigation):" and the 5 injected rules all have at least one concrete signal. Not in this commit (deferred): - wiring loop-detector's findSimilarLoops into loop-briefing (Change 3) - cleanup of 108 dead 'unknown' rules (separate migration) - fix for rule-extractor.js id collisions on re-run (separate) See docs/audit-2026-04-10.md for the full audit and rationale. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
6d51884b38ee0985140e0ac725cab37ba842c0ba
by plankatron <fleeky@gmail.com> · 2026-04-11T01:56:46.000Z

Changed files (4) +239 -39

classify-project.js +143 -0
……@@ -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
21const fs = require('fs')
22const 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.
27const 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.
35function 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.
54function 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.
73function 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.
85function 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
101module.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.
111if (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}
loop-briefing.js +52 -22
……@@ -10,25 +10,18 @@
1010
1111const fs = require('fs')
1212const path = require('path')
13const { classifyProject } = require('./classify-project')
14const { computeQuality, QUALITY_RANK } = require('./rule-extractor')
1315
1416const LOOPS_FILE = path.join(__dirname, 'data', 'loops.json')
1517const RULES_FILE = path.join(__dirname, 'data', 'learned-rules.json')
1618
17function 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.
3225
3326function main () {
3427 // Only inject on first prompt of session — check if briefing already delivered
……@@ -43,7 +36,9 @@ function main () {
4336
4437 const cwd = process.cwd()
4538 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).
4742
4843 if (!fs.existsSync(LOOPS_FILE)) return
4944
……@@ -109,22 +104,57 @@ function main () {
109104 }
110105 }
111106
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)
113123 if (fs.existsSync(RULES_FILE)) {
114124 try {
115125 const rulesData = JSON.parse(fs.readFileSync(RULES_FILE, 'utf8'))
116126 const projectRules = (rulesData.rules || [])
117127 .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):`
121144 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)) {
124147 lines.push(` When: ${r.trigger.symptom}`)
125148 lines.push(` Do: ${r.action.description.slice(0, 100)}`)
126149 lines.push(` Don't: ${r.antipattern.description.slice(0, 100)}`)
127150 }
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.`)
128158 }
129159 } catch {}
130160 }
loop-detector.js +4 -16
……@@ -22,6 +22,7 @@
2222
2323const fs = require('fs')
2424const path = require('path')
25const { classifyProject } = require('./classify-project')
2526
2627const DATA_DIR = path.join(__dirname, 'data')
2728const DETAIL_FILE = path.join(DATA_DIR, 'fused-turns-detail.json')
……@@ -176,22 +177,9 @@ function finalizeLoop (raw, session) {
176177}
177178
178179// ─── Classification ───
179
180function 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.
195183
196184function classifyType (corrections) {
197185 const allText = corrections.map(c => c.text).join(' ').toLowerCase()
rule-extractor.js +40 -1
……@@ -30,6 +30,36 @@ function loadJSON (file) {
3030 catch { return null }
3131}
3232
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
52const GENERIC_ANTIPATTERN = 'Slow down and verify each step'
53const QUALITY_RANK = { specific: 0, partial: 1, 'needs-investigation': 2 }
54
55function 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
3363// ─── Symptom Extraction ───
3464// Extract the core symptom from correction text
3565
……@@ -98,6 +128,7 @@ function extractRules (loops) {
98128 correctionCount: loop.correctionCount,
99129 },
100130 }
131 rule.quality = computeQuality(rule)
101132
102133 rules.push(rule)
103134 }
……@@ -182,6 +213,14 @@ function main () {
182213 const newRules = extractRules(loopsData.loops || [])
183214 const { merged, added } = mergeRules(newRules, existingRules)
184215
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
185224 // Group by project
186225 const byProject = {}
187226 for (const rule of merged) {
……@@ -234,6 +273,6 @@ function main () {
234273 console.log('═'.repeat(60))
235274}
236275
237module.exports = { extractRules, mergeRules }
276module.exports = { extractRules, mergeRules, computeQuality, QUALITY_RANK, GENERIC_ANTIPATTERN }
238277
239278if (require.main === module) main()