feat(session-start): rank injected instincts by project/stack relevance (#2466)

* feat(session-start): rank injected instincts by project/stack relevance

Instinct selection at SessionStart ranked purely by confidence, so a
high-confidence instinct about an unrelated stack could take an injection
slot from a lower-confidence instinct that is actually relevant to the
current project.

Rank by confidence + location/stack relevance instead: project-scoped
instincts, and instincts whose domain/trigger matches the detected stack
(languages/frameworks via detectProjectType, plus terraform/dbt markers),
get a small additive boost. The confidence>=threshold floor and the
injection cap are unchanged, and ranking degrades to confidence-only when
nothing matches or when ECC_INSTINCT_RELEVANCE_RANKING is set to off.

The ranking helpers live in scripts/lib/instinct-relevance.js with unit
coverage in tests/lib/, plus an end-to-end ordering test in tests/hooks/.

Completes part (b) of #2371; part (a) (configurable count + threshold)
shipped in #2413.

Fixes #2371

* refactor(session-start): drop redundant confidence tiebreaker in instinct sort

Greptile flagged that the secondary `right.confidence` comparison in
summarizeActiveInstincts' sort was dead code when relevance ranking is
disabled and, when enabled, was reached only on a floating-point tie of the
combined score — where it skipped the intended scope-label tiebreaker.

Remove it: the primary combined-score comparison already reduces to
confidence-only ordering when relevance is off, so behavior there is
unchanged; a genuine combined-score tie now falls through to the documented
scope-first, then id, order.

* test: isolate instinct relevance environment

---------

Co-authored-by: haelyra <49814733+haelyra@users.noreply.github.com>
This commit is contained in:
Gaurav Dubey
2026-08-10 21:46:24 -04:00
committed by GitHub
co-authored by haelyra
parent 3d4ef3184b
commit 5987bd4dc6
5 changed files with 496 additions and 1 deletions
+27 -1
View File
@@ -24,6 +24,11 @@ const { resolveProjectContext, writeSessionLease, resolveSessionId, getHomunculu
const { getPackageManager, getSelectionPrompt } = require('../lib/package-manager');
const { listAliases } = require('../lib/session-aliases');
const { detectProjectType } = require('../lib/project-detect');
const {
isRelevanceRankingEnabled,
detectStackKeywords,
computeRelevanceBoost,
} = require('../lib/instinct-relevance');
const path = require('path');
const fs = require('fs');
@@ -422,6 +427,20 @@ function summarizeActiveInstincts(observerContext) {
const confidenceThreshold = getInstinctConfidenceThreshold();
const maxInjected = getMaxInjectedInstincts();
// Relevance ranking (issue #2371 part b): at SessionStart there is no user
// task yet, so relevance is location/stack based. Project-scoped and
// stack-matching instincts get a small additive boost over their confidence.
// Gated by ECC_INSTINCT_RELEVANCE_RANKING (default on); when off, or when no
// stack is detected and nothing is project-scoped, every boost is 0 and the
// ranking collapses to confidence-only (unchanged behaviour).
// Detect the stack from the real project source tree (projectRoot), not the
// homunculus state dir (projectDir). In a global session projectRoot is empty,
// so detectStackKeywords falls back to process.cwd().
const relevanceEnabled = isRelevanceRankingEnabled();
const stackKeywords = relevanceEnabled
? detectStackKeywords(observerContext.projectRoot || undefined)
: new Set();
const deduped = new Map();
for (const instinct of scopedInstincts) {
if (!instinct.id || instinct.confidence < confidenceThreshold) continue;
@@ -435,10 +454,17 @@ function summarizeActiveInstincts(observerContext) {
.map(instinct => ({
...instinct,
action: extractInstinctAction(instinct.content),
_relevance: relevanceEnabled ? computeRelevanceBoost(instinct, stackKeywords) : 0,
}))
.filter(instinct => instinct.action)
.sort((left, right) => {
if (right.confidence !== left.confidence) return right.confidence - left.confidence;
// Primary: combined confidence + relevance. When relevance is off every
// _relevance is 0, so this reduces to the prior confidence-only ordering.
// Tie-breaks on a genuinely equal combined score: project scope first,
// then id (deterministic).
const leftScore = left.confidence + left._relevance;
const rightScore = right.confidence + right._relevance;
if (rightScore !== leftScore) return rightScore - leftScore;
if (left._scopeLabel !== right._scopeLabel) return left._scopeLabel === 'project' ? -1 : 1;
return String(left.id).localeCompare(String(right.id));
})
+173
View File
@@ -0,0 +1,173 @@
/**
* Instinct relevance ranking for SessionStart.
*
* At SessionStart there is no user task yet, so "relevance" is location/stack
* relevance: instincts scoped to the current project, or whose domain/trigger
* matches the detected stack, get a small additive boost on top of their
* confidence when ranking which instincts to inject. The confidence >=
* threshold floor and the injection cap are enforced by the caller; this
* module only computes the additive boost and the stack keyword set. When
* nothing is project-scoped and no stack is detected, every boost is 0 and the
* ranking degrades to confidence-only (unchanged behaviour).
*
* Resolves part (b) of:
* https://github.com/affaan-m/everything-claude-code/issues/2371
*/
const fs = require('fs');
const path = require('path');
const { detectProjectType } = require('./project-detect');
// Additive ranking boosts. These are intentionally NOT env-configurable: part
// (b) of the issue asks for relevance ranking, not more tunable knobs (part (a)
// already made the injection count + confidence threshold configurable). The
// values are chosen so a project-scoped 0.7 instinct (0.7 + 0.25 = 0.95) can
// surface above an unrelated global 0.9, and a stack-matching 0.75 instinct
// (0.75 + 0.2 = 0.95) can surface above an unrelated 0.9.
const DEFAULT_PROJECT_SCOPE_BOOST = 0.25;
const DEFAULT_STACK_MATCH_BOOST = 0.2;
/**
* Whether a file with any of the given extensions exists directly in the root
* (non-recursive, top-level only — kept cheap for a blocking SessionStart hook).
* @param {string} root - Project root directory.
* @param {string[]} extensions - Extensions to look for (e.g. ['.tf']).
* @returns {boolean}
*/
function hasFileWithExtension(root, extensions) {
try {
return fs.readdirSync(root, { withFileTypes: true }).some(
(entry) => entry.isFile() && extensions.includes(path.extname(entry.name))
);
} catch {
return false;
}
}
/**
* Whether a named file exists directly in the root.
* @param {string} root - Project root directory.
* @param {string} name - File name relative to root.
* @returns {boolean}
*/
function fileExists(root, name) {
try {
return fs.existsSync(path.join(root, name));
} catch {
return false;
}
}
/**
* Resolve whether relevance ranking is enabled. Default on; opt out by setting
* `ECC_INSTINCT_RELEVANCE_RANKING` to `off`, `false`, `0`, or `no`
* (case-insensitive). Any other value (including unset) keeps ranking on.
* @returns {boolean}
*/
function isRelevanceRankingEnabled() {
const raw = process.env.ECC_INSTINCT_RELEVANCE_RANKING;
if (raw === undefined || raw === null || raw === '') return true;
const normalized = String(raw).trim().toLowerCase();
return !['off', 'false', '0', 'no'].includes(normalized);
}
/**
* Cheap, non-recursive stack-keyword detection for the project root. Reuses
* detectProjectType (languages + frameworks) and layers the extra IaC/data
* markers issue #2371 calls out that detectProjectType does not cover
* (`*.tf` / `*.tfvars` -> terraform, `dbt_project.yml` -> dbt).
* @param {string} [projectRoot] - Defaults to process.cwd().
* @param {{languages?: string[], frameworks?: string[]}} [projectInfo] -
* Optional precomputed detectProjectType() result, to avoid a second pass.
* @returns {Set<string>} Lowercase keyword set (may be empty).
*/
function detectStackKeywords(projectRoot, projectInfo) {
const root = projectRoot || process.cwd();
const keywords = new Set();
let info = projectInfo;
if (!info) {
try {
info = detectProjectType(root);
} catch {
info = { languages: [], frameworks: [] };
}
}
for (const language of info.languages || []) keywords.add(String(language).toLowerCase());
for (const framework of info.frameworks || []) keywords.add(String(framework).toLowerCase());
if (hasFileWithExtension(root, ['.tf', '.tfvars'])) keywords.add('terraform');
if (fileExists(root, 'dbt_project.yml')) keywords.add('dbt');
return keywords;
}
/**
* Tokenize a free-text field into lowercase word tokens (split on
* non-alphanumerics). Token-set matching avoids substring false positives such
* as the keyword `go` matching the word `good`.
* @param {string} value
* @returns {string[]}
*/
function tokenize(value) {
return String(value || '')
.toLowerCase()
.split(/[^a-z0-9]+/)
.filter(Boolean);
}
/**
* Whether an instinct's domain/trigger/stack fields intersect the stack
* keywords by whole-token match.
* @param {object} instinct - Parsed instinct (frontmatter fields as properties).
* @param {Set<string>} stackKeywords
* @returns {boolean}
*/
function instinctMatchesStack(instinct, stackKeywords) {
if (!instinct || !stackKeywords || stackKeywords.size === 0) return false;
const tokens = new Set([
...tokenize(instinct.domain),
...tokenize(instinct.trigger),
...tokenize(instinct.stack),
]);
for (const keyword of stackKeywords) {
if (tokens.has(keyword)) return true;
}
return false;
}
/**
* Additive relevance boost for ranking. Deterministic and pure. A
* project-scoped instinct (location-relevant by construction) and a
* stack-matching instinct each contribute their boost; both can apply.
* @param {object} instinct - Must carry `_scopeLabel` ('project'|'global') and
* optional `domain`/`trigger`/`stack` fields.
* @param {Set<string>} stackKeywords
* @param {{projectBoost?: number, stackBoost?: number}} [opts]
* @returns {number}
*/
function computeRelevanceBoost(instinct, stackKeywords, opts) {
const options = opts || {};
const projectBoost = Number.isFinite(options.projectBoost)
? options.projectBoost
: DEFAULT_PROJECT_SCOPE_BOOST;
const stackBoost = Number.isFinite(options.stackBoost)
? options.stackBoost
: DEFAULT_STACK_MATCH_BOOST;
let boost = 0;
if (instinct && instinct._scopeLabel === 'project') boost += projectBoost;
if (instinctMatchesStack(instinct, stackKeywords)) boost += stackBoost;
return boost;
}
module.exports = {
DEFAULT_PROJECT_SCOPE_BOOST,
DEFAULT_STACK_MATCH_BOOST,
isRelevanceRankingEnabled,
detectStackKeywords,
instinctMatchesStack,
computeRelevanceBoost,
// Exported for testing.
tokenize,
};