From b9d0e0b04d1b2047e23547628fee3cb6f6b97617 Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Sun, 5 Apr 2026 14:55:31 -0700 Subject: [PATCH] feat: inject active instincts into session start context --- scripts/hooks/session-start.js | 151 +++++++++++++++++++++++++++++++- tests/integration/hooks.test.js | 81 +++++++++++++++++ 2 files changed, 231 insertions(+), 1 deletion(-) diff --git a/scripts/hooks/session-start.js b/scripts/hooks/session-start.js index 93b3c508..342ff43c 100644 --- a/scripts/hooks/session-start.js +++ b/scripts/hooks/session-start.js @@ -10,6 +10,7 @@ */ const { + getClaudeDir, getSessionsDir, getSessionSearchDirs, getLearnedSkillsDir, @@ -27,6 +28,9 @@ const { detectProjectType } = require('../lib/project-detect'); const path = require('path'); const fs = require('fs'); +const INSTINCT_CONFIDENCE_THRESHOLD = 0.7; +const MAX_INJECTED_INSTINCTS = 6; + /** * Resolve a filesystem path to its canonical (real) form. * @@ -155,10 +159,151 @@ function selectMatchingSession(sessions, cwd, currentProject) { return null; } +function parseInstinctFile(content) { + const instincts = []; + let current = null; + let inFrontmatter = false; + let contentLines = []; + + for (const line of String(content).split('\n')) { + if (line.trim() === '---') { + if (inFrontmatter) { + inFrontmatter = false; + } else { + if (current && current.id) { + current.content = contentLines.join('\n').trim(); + instincts.push(current); + } + current = {}; + contentLines = []; + inFrontmatter = true; + } + continue; + } + + if (inFrontmatter) { + const separatorIndex = line.indexOf(':'); + if (separatorIndex === -1) continue; + const key = line.slice(0, separatorIndex).trim(); + let value = line.slice(separatorIndex + 1).trim(); + if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) { + value = value.slice(1, -1); + } + if (key === 'confidence') { + const parsed = Number.parseFloat(value); + current[key] = Number.isFinite(parsed) ? parsed : 0.5; + } else { + current[key] = value; + } + } else if (current) { + contentLines.push(line); + } + } + + if (current && current.id) { + current.content = contentLines.join('\n').trim(); + instincts.push(current); + } + + return instincts; +} + +function readInstinctsFromDir(directory, scope) { + if (!directory || !fs.existsSync(directory)) return []; + + const entries = fs.readdirSync(directory, { withFileTypes: true }) + .filter(entry => entry.isFile() && /\.(ya?ml|md)$/i.test(entry.name)) + .sort((left, right) => left.name.localeCompare(right.name)); + + const instincts = []; + for (const entry of entries) { + const filePath = path.join(directory, entry.name); + try { + const parsed = parseInstinctFile(fs.readFileSync(filePath, 'utf8')); + for (const instinct of parsed) { + instincts.push({ + ...instinct, + _scopeLabel: scope, + _sourceFile: filePath, + }); + } + } catch (error) { + log(`[SessionStart] Warning: failed to parse instinct file ${filePath}: ${error.message}`); + } + } + + return instincts; +} + +function extractInstinctAction(content) { + const actionMatch = String(content || '').match(/## Action\s*\n+([\s\S]+?)(?:\n## |\n---|$)/); + const actionBlock = (actionMatch ? actionMatch[1] : String(content || '')).trim(); + const firstLine = actionBlock + .split('\n') + .map(line => line.trim()) + .find(Boolean); + + return firstLine || ''; +} + +function summarizeActiveInstincts(observerContext) { + const homunculusDir = path.join(getClaudeDir(), 'homunculus'); + const globalDirs = [ + { dir: path.join(homunculusDir, 'instincts', 'personal'), scope: 'global' }, + { dir: path.join(homunculusDir, 'instincts', 'inherited'), scope: 'global' }, + ]; + const projectDirs = observerContext.isGlobal ? [] : [ + { dir: path.join(observerContext.projectDir, 'instincts', 'personal'), scope: 'project' }, + { dir: path.join(observerContext.projectDir, 'instincts', 'inherited'), scope: 'project' }, + ]; + + const scopedInstincts = [ + ...projectDirs.flatMap(({ dir, scope }) => readInstinctsFromDir(dir, scope)), + ...globalDirs.flatMap(({ dir, scope }) => readInstinctsFromDir(dir, scope)), + ]; + + const deduped = new Map(); + for (const instinct of scopedInstincts) { + if (!instinct.id || instinct.confidence < INSTINCT_CONFIDENCE_THRESHOLD) continue; + const existing = deduped.get(instinct.id); + if (!existing || (existing._scopeLabel !== 'project' && instinct._scopeLabel === 'project')) { + deduped.set(instinct.id, instinct); + } + } + + const ranked = Array.from(deduped.values()) + .map(instinct => ({ + ...instinct, + action: extractInstinctAction(instinct.content), + })) + .filter(instinct => instinct.action) + .sort((left, right) => { + if (right.confidence !== left.confidence) return right.confidence - left.confidence; + if (left._scopeLabel !== right._scopeLabel) return left._scopeLabel === 'project' ? -1 : 1; + return String(left.id).localeCompare(String(right.id)); + }) + .slice(0, MAX_INJECTED_INSTINCTS); + + if (ranked.length === 0) { + return ''; + } + + log(`[SessionStart] Injecting ${ranked.length} instinct(s) into session context`); + + const lines = ranked.map(instinct => { + const scope = instinct._scopeLabel === 'project' ? 'project' : 'global'; + const confidence = `${Math.round(instinct.confidence * 100)}%`; + return `- [${scope} ${confidence}] ${instinct.action}`; + }); + + return `Active instincts:\n${lines.join('\n')}`; +} + async function main() { const sessionsDir = getSessionsDir(); const learnedDir = getLearnedSkillsDir(); const additionalContextParts = []; + const observerContext = resolveProjectContext(); // Ensure directories exist ensureDir(sessionsDir); @@ -166,7 +311,6 @@ async function main() { const observerSessionId = resolveSessionId(); if (observerSessionId) { - const observerContext = resolveProjectContext(); writeSessionLease(observerContext, observerSessionId, { hook: 'SessionStart', projectRoot: observerContext.projectRoot @@ -176,6 +320,11 @@ async function main() { log('[SessionStart] No CLAUDE_SESSION_ID available; skipping observer lease registration'); } + const instinctSummary = summarizeActiveInstincts(observerContext); + if (instinctSummary) { + additionalContextParts.push(instinctSummary); + } + // Check for recent session files (last 7 days) const recentSessions = dedupeRecentSessions(getSessionSearchDirs()); diff --git a/tests/integration/hooks.test.js b/tests/integration/hooks.test.js index a5132a1d..0c0e06f3 100644 --- a/tests/integration/hooks.test.js +++ b/tests/integration/hooks.test.js @@ -7,6 +7,7 @@ */ const assert = require('assert'); +const crypto = require('crypto'); const path = require('path'); const fs = require('fs'); const os = require('os'); @@ -179,6 +180,26 @@ function cleanupTestDir(testDir) { fs.rmSync(testDir, { recursive: true, force: true }); } +function writeInstinctFile(filePath, entries) { + const body = entries.map(entry => `--- +id: ${entry.id} +trigger: "${entry.trigger}" +confidence: ${entry.confidence} +domain: ${entry.domain || 'general'} +scope: ${entry.scope} +--- + +## Action +${entry.action} + +## Evidence +${entry.evidence || 'Learned from repeated observations.'} +`).join('\n'); + + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, body); +} + function getHookCommandByDescription(hooks, lifecycle, descriptionText) { const hookGroup = hooks.hooks[lifecycle]?.find( entry => entry.description && entry.description.includes(descriptionText) @@ -333,6 +354,66 @@ async function runTests() { } })) passed++; else failed++; + if (await asyncTest('session-start injects high-confidence instincts into additionalContext', async () => { + const testDir = createTestDir(); + const projectDir = path.join(testDir, 'project'); + fs.mkdirSync(projectDir, { recursive: true }); + + try { + const projectId = crypto.createHash('sha256').update(projectDir).digest('hex').slice(0, 12); + const homunculusDir = path.join(testDir, '.claude', 'homunculus'); + const projectInstinctDir = path.join(homunculusDir, 'projects', projectId, 'instincts', 'personal'); + const globalInstinctDir = path.join(homunculusDir, 'instincts', 'inherited'); + + writeInstinctFile(path.join(projectInstinctDir, 'project-instincts.yaml'), [ + { + id: 'project-tests-first', + trigger: 'when changing tests', + confidence: 0.9, + scope: 'project', + action: 'Run the targeted *.test.js file first, then widen to node tests/run-all.js.', + }, + { + id: 'project-low-confidence', + trigger: 'when guessing', + confidence: 0.4, + scope: 'project', + action: 'This should never be injected.', + }, + ]); + + writeInstinctFile(path.join(globalInstinctDir, 'global-instincts.yaml'), [ + { + id: 'global-validation', + trigger: 'when editing hooks', + confidence: 0.82, + scope: 'global', + action: 'Keep hook scripts, tests, and docs aligned in the same change set.', + }, + ]); + + const result = await runHookWithInput( + path.join(scriptsDir, 'session-start.js'), + {}, + { + HOME: testDir, + CLAUDE_PROJECT_DIR: projectDir, + } + ); + + assert.strictEqual(result.code, 0, 'SessionStart should exit 0'); + const payload = getSessionStartPayload(result.stdout); + const additionalContext = payload.hookSpecificOutput.additionalContext; + + assert.ok(additionalContext.includes('Active instincts:'), 'Should inject instinct summary into additionalContext'); + assert.ok(additionalContext.includes('[project 90%] Run the targeted *.test.js file first, then widen to node tests/run-all.js.'), 'Should include project-scoped instinct'); + assert.ok(additionalContext.includes('[global 82%] Keep hook scripts, tests, and docs aligned in the same change set.'), 'Should include global instinct'); + assert.ok(!additionalContext.includes('This should never be injected.'), 'Should exclude low-confidence instincts'); + } finally { + cleanupTestDir(testDir); + } + })) passed++; else failed++; + if (await asyncTest('session-end-marker removes the last lease and stops the observer process', async () => { const testDir = createTestDir(); const projectDir = path.join(testDir, 'project');