diff --git a/CHANGELOG.md b/CHANGELOG.md index 59b0f32..47c1cdc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,29 @@ ## [Unreleased] +## [Unshipped - Phase 09] - High-Signal Search + Decision Card + +Cleaned up the edit decision card and sharpened search ranking for exact-name queries. + +### Added + +- **Definition-first ranking (SEARCH-01)**: For exact-name queries (PascalCase/camelCase), the file that *defines* a symbol now ranks above files that merely use it. Symbol-level dedup ensures multiple methods from the same class don't clog the top slots. +- **Smart snippets with scope headers (SEARCH-02)**: When `includeSnippets: true`, code chunks from symbol-aware analysis include a scope comment header (`// ClassName.methodName`) before the snippet, giving structural context without extra disk reads. +- **Clean decision card (PREF-01-04)**: The preflight response for `intent="edit"|"refactor"|"migrate"` is now a decision card: `ready`, `nextAction` (if not ready), `warnings`, `patterns` (do/avoid capped at 3), `bestExample` (top golden file), `impact` (caller coverage + top files), and `whatWouldHelp`. Internal fields like `evidenceLock`, `riskLevel`, `confidence` are no longer exposed. +- **Impact coverage gating (PREF-02)**: When result files have known callers (from import graph), the card shows caller coverage: "X/Y callers in results". Low coverage (< 40% with > 3 total callers) triggers an epistemic stress alert. +- **whatWouldHelp recommendations (PREF-03)**: When `ready=false`, concrete next steps appear: search more specifically, call `get_team_patterns`, search for uncovered callers, or check memories. Each is actionable in 1-2 sentences. + +### Changed + +- **Preflight shape**: `{ ready, reason?, ... }` → `{ ready, nextAction?, warnings?, patterns?, bestExample?, impact?, whatWouldHelp? }`. `reason` renamed to `nextAction` for clarity. No breaking changes to `ready` (stays top-level). + +### Fixed + +- Agents no longer parse unstable internal fields. Preflight output is stable by design. +- Snippets now include scope context, reducing ambiguity for symbol-heavy edits. + +## [Unreleased] + ### Added - **Index versioning (Phase 06)**: Index artifacts are versioned via `index-meta.json`. Mixed-version indexes are never served; version mismatches or corruption trigger automatic rebuild. diff --git a/MOTIVATION.md b/MOTIVATION.md index dadf959..c83fcd8 100644 --- a/MOTIVATION.md +++ b/MOTIVATION.md @@ -49,7 +49,7 @@ Correct the agent once. Record the decision. From then on, it surfaces in search ### Evidence gating -Before an edit, the agent gets a curated "preflight" check from three sources (code, patterns, memories). If evidence is thin or contradictory, the response tells the AI Agent to look for more evidence with a concrete next step. This is the difference between "confident assumption" and "informed decision." +Before an edit, the response includes a decision card. `ready: true` means there's enough evidence from the codebase, patterns, and team memory to proceed. `ready: false` comes with `whatWouldHelp` — specific searches to run, specific files to check, or calls to `get_team_patterns` that would close the gap. The card also surfaces caller coverage: if you're editing a function that five files import but only two of them appear in your results, you know which ones you haven't looked at yet (`coverage: "2/5 callers in results"`). This is the difference between "confident assumption" and "informed decision." ### Guardrails via frozen eval + regressions diff --git a/README.md b/README.md index 2d0dc55..1132224 100644 --- a/README.md +++ b/README.md @@ -122,14 +122,30 @@ This is where it all comes together. One call returns: - **Relationships** per result: `importedByCount` and `hasTests` (condensed) + **hints** (capped ranked callers, consumers, tests) - **Related memories**: up to 3 team decisions, gotchas, and failures matched to the query - **Search quality**: `ok` or `low_confidence` with confidence score and `hint` when low -- **Preflight**: `ready` (boolean) + `reason` when evidence is thin. Pass `intent="edit"` to get the full preflight card. If search quality is low, `ready` is always `false`. +- **Preflight**: `ready` (boolean) with decision card when `intent="edit"|"refactor"|"migrate"`. Shows `nextAction` (if not ready), `warnings`, `patterns` (do/avoid), `bestExample`, `impact` (caller coverage), and `whatWouldHelp` (next steps). If search quality is low, `ready` is always `false`. Snippets are opt-in (`includeSnippets: true`). Default output is lean — if the agent wants code, it calls `read_file`. ```json { "searchQuality": { "status": "ok", "confidence": 0.72 }, - "preflight": { "ready": true }, + "preflight": { + "ready": false, + "nextAction": "2 of 5 callers aren't in results — search for src/app.module.ts", + "patterns": { + "do": ["HttpInterceptorFn — 97%", "standalone components — 84%"], + "avoid": ["constructor injection — 3% (declining)"] + }, + "bestExample": "src/auth/auth.interceptor.ts", + "impact": { + "coverage": "3/5 callers in results", + "files": ["src/app.module.ts", "src/boot.ts"] + }, + "whatWouldHelp": [ + "Search for src/app.module.ts to cover the main caller", + "Call get_team_patterns for auth/ injection patterns" + ] + }, "results": [ { "file": "src/auth/auth.interceptor.ts:1-20", @@ -171,7 +187,7 @@ Record a decision once. It surfaces automatically in search results and prefligh | Tool | What it does | | ------------------------------ | ------------------------------------------------------------------------------------------- | -| `search_codebase` | Hybrid search with enrichment + preflight + ranked relationship hints. Pass `intent="edit"` for edit readiness check. | +| `search_codebase` | Hybrid search + decision card. Pass `intent="edit"` to get `ready`, `nextAction`, patterns, caller coverage, and `whatWouldHelp`. | | `get_team_patterns` | Pattern frequencies, golden files, conflict detection | | `get_symbol_references` | Find concrete references to a symbol (usageCount + top snippets + confidence + completeness) | | `remember` | Record a convention, decision, gotcha, or failure | diff --git a/docs/capabilities.md b/docs/capabilities.md index d776bdc..79fba51 100644 --- a/docs/capabilities.md +++ b/docs/capabilities.md @@ -10,7 +10,7 @@ Technical reference for what `codebase-context` ships today. For the user-facing | Tool | Input | Output | | ----------------------- | ----------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `search_codebase` | `query`, optional `intent`, `limit`, `filters`, `includeSnippets` | Ranked results (`file`, `summary`, `score`, `type`, `trend`, `patternWarning`, `relationships`, `hints`) + `searchQuality` (with `hint` when low confidence) + `preflight` ({ready, reason}). Hints capped at 3 per category. | +| `search_codebase` | `query`, optional `intent`, `limit`, `filters`, `includeSnippets` | Ranked results (`file`, `summary`, `score`, `type`, `trend`, `patternWarning`, `relationships`, `hints`) + `searchQuality` + decision card (`ready`, `nextAction`, `patterns`, `bestExample`, `impact`, `whatWouldHelp`) when `intent="edit"`. Hints capped at 3 per category. | | `get_team_patterns` | optional `category` | Pattern frequencies, trends, golden files, conflicts | | `get_symbol_references` | `symbol`, optional `limit` | Concrete symbol usage evidence: `usageCount` + top usage snippets + `confidence` ("syntactic") + `isComplete` boolean | | `remember` | `type`, `category`, `memory`, `reason` | Persists to `.codebase-context/memory.json` | @@ -34,11 +34,13 @@ Ordered by execution: 2. **Query expansion** — bounded domain term expansion for conceptual queries. 3. **Dual retrieval** — keyword (Fuse.js) + semantic (local embeddings or OpenAI). 4. **RRF fusion** — Reciprocal Rank Fusion (k=60) across all retrieval channels. -5. **Structure-aware boosting** — import centrality, composition root boost, path overlap, definition demotion for action queries. -6. **Contamination control** — test file filtering for non-test queries. -7. **File deduplication** — best chunk per file. -8. **Stage-2 reranking** — cross-encoder (`Xenova/ms-marco-MiniLM-L-6-v2`) triggers when the score between the top files are very close. CPU-only, top-10 bounded. -9. **Result enrichment** — compact type (`componentType:layer`), pattern momentum (`trend` Rising/Declining only, Stable omitted), `patternWarning`, condensed relationships (`importedByCount`/`hasTests`), structured hints (capped callers/consumers/tests ranked by frequency), related memories (capped to 3), search quality assessment with `hint` when low confidence. +5. **Definition-first boost** — for EXACT_NAME intent, results matching the symbol name get +15% score boost (e.g., defining file ranks above using files). +6. **Structure-aware boosting** — import centrality, composition root boost, path overlap, definition demotion for action queries. +7. **Contamination control** — test file filtering for non-test queries. +8. **File deduplication** — best chunk per file. +9. **Symbol-level deduplication** — within each `symbolPath` group, keep only the highest-scoring chunk (prevents duplicate methods from same class clogging results). +10. **Stage-2 reranking** — cross-encoder (`Xenova/ms-marco-MiniLM-L-6-v2`) triggers when the score between the top files are very close. CPU-only, top-10 bounded. +11. **Result enrichment** — compact type (`componentType:layer`), pattern momentum (`trend` Rising/Declining only, Stable omitted), `patternWarning`, condensed relationships (`importedByCount`/`hasTests`), structured hints (capped callers/consumers/tests ranked by frequency), scope header for symbol-aware snippets (`// ClassName.methodName`), related memories (capped to 3), search quality assessment with `hint` when low confidence. ### Defaults @@ -47,29 +49,56 @@ Ordered by execution: - **Embedding model**: Granite (`ibm-granite/granite-embedding-30m-english`, 8192 token context) via `@huggingface/transformers` v3 - **Vector DB**: LanceDB with cosine distance -## Preflight (Edit Intent) - -Returned as `preflight` when search `intent` is `edit`, `refactor`, or `migrate`. Also returned for default searches when intelligence is available. - -Output: `{ ready: boolean, reason?: string }` - -- `ready`: whether evidence is sufficient to proceed with edits -- `reason`: when `ready` is false, explains why (e.g., "Search quality is low", "Insufficient pattern evidence") +## Decision Card (Edit Intent) + +Returned as `preflight` when search `intent` is `edit`, `refactor`, or `migrate`. + +**Output shape:** + +```typescript +{ + ready: boolean; + nextAction?: string; // Only when ready=false; what to search for next + warnings?: string[]; // Failure memories (capped at 3) + patterns?: { + do: string[]; // Top 3 preferred patterns with adoption % + avoid: string[]; // Top 3 declining patterns + }; + bestExample?: string; // Top 1 golden file (path format) + impact?: { + coverage: string; // "X/Y callers in results" + files: string[]; // Top 3 impact candidates (files importing results) + }; + whatWouldHelp?: string[]; // Concrete next steps (max 4) when ready=false +} +``` + +**Fields explained:** + +- `ready`: boolean, whether evidence is sufficient to proceed +- `nextAction`: actionable reason why `ready=false` (e.g., "2 of 5 callers missing") +- `warnings`: failure memories from team (auto-surfaces past mistakes) +- `patterns.do`: patterns the team is adopting, ranked by adoption % +- `patterns.avoid`: declining patterns, ranked by % (useful for migrations) +- `bestExample`: exemplar file for the area under edit +- `impact.coverage`: shows caller visibility ("3/5 callers in results" means 2 callers weren't searched yet) +- `impact.files`: which files import the results (helps find blind spots) +- `whatWouldHelp`: specific next searches, tool calls, or files to check that would close evidence gaps ### How `ready` is determined 1. **Evidence triangulation** — scores code match (45%), pattern alignment (30%), and memory support (25%). Needs combined score ≥ 40 to pass. -2. **Epistemic stress check** — if pattern conflicts, stale memories, or thin evidence are detected, `ready` is set to false with an abstain signal. -3. **Search quality gate** — if `searchQuality.status` is `low_confidence`, `ready` is forced to false regardless of evidence scores. This prevents the "confidently wrong" problem where evidence counts look good but retrieval quality is poor. +2. **Epistemic stress check** — if pattern conflicts, stale memories, thin evidence, or low caller coverage are detected, `ready` is set to false. +3. **Search quality gate** — if `searchQuality.status` is `low_confidence`, `ready` is forced to false regardless of evidence scores. This prevents the "confidently wrong" problem. -### Internal analysis (not in output, used to compute `ready`) +### Internal signals (not in output, feed `ready` computation) -- Risk level from circular deps + impact breadth + failure memories +- Risk level from circular deps, impact breadth, and failure memories - Preferred/avoid patterns from team pattern analysis -- Golden files by pattern density -- Impact candidates from import graph -- Failure warnings from related memories +- Golden files ranked by pattern density +- Caller coverage from import graph (X of Y callers appearing in results) - Pattern conflicts when two patterns in the same category are both > 20% adoption +- Confidence decay of related memories ## Memory System diff --git a/src/core/search.ts b/src/core/search.ts index 6605494..2f03e6a 100644 --- a/src/core/search.ts +++ b/src/core/search.ts @@ -693,6 +693,21 @@ export class CodebaseSearcher { }) .sort((a, b) => b.score - a.score); + // SEARCH-01: Definition-first boost for EXACT_NAME intent + // Boost results where symbolName matches query (case-insensitive) + if (intent === 'EXACT_NAME') { + const queryNormalized = query.toLowerCase(); + for (const result of scoredResults) { + const symbolName = result.metadata?.symbolName; + if (symbolName && symbolName.toLowerCase() === queryNormalized) { + result.score *= 1.15; // +15% boost for definition + } + } + // Re-sort after boost + scoredResults.sort((a, b) => b.score - a.score); + } + + // File-level deduplication const seenFiles = new Set(); const deduped: SearchResult[] = []; for (const result of scoredResults) { @@ -702,7 +717,36 @@ export class CodebaseSearcher { deduped.push(result); if (deduped.length >= limit) break; } - const finalResults = deduped; + + // SEARCH-01: Symbol-level deduplication + // Within each symbol group (symbolPath), keep only the highest-scoring chunk + const seenSymbols = new Map(); + const symbolDeduped: SearchResult[] = []; + for (const result of deduped) { + const symbolPath = result.metadata?.symbolPath; + if (!symbolPath) { + // No symbol info, keep as-is + symbolDeduped.push(result); + continue; + } + + const symbolPathKey = Array.isArray(symbolPath) ? symbolPath.join('.') : String(symbolPath); + const existing = seenSymbols.get(symbolPathKey); + if (!existing || result.score > existing.score) { + if (existing) { + // Replace lower-scoring version + const idx = symbolDeduped.indexOf(existing); + if (idx >= 0) { + symbolDeduped[idx] = result; + } + } else { + symbolDeduped.push(result); + } + seenSymbols.set(symbolPathKey, result); + } + } + + const finalResults = symbolDeduped; if ( isNonTestQuery && diff --git a/src/preflight/evidence-lock.ts b/src/preflight/evidence-lock.ts index a1c56d6..167ef7c 100644 --- a/src/preflight/evidence-lock.ts +++ b/src/preflight/evidence-lock.ts @@ -25,6 +25,7 @@ export interface EvidenceLock { gaps?: string[]; nextAction?: string; epistemicStress?: EpistemicStress; + whatWouldHelp?: string[]; } interface PatternConflict { @@ -41,6 +42,8 @@ interface BuildEvidenceLockInput { patternConflicts?: PatternConflict[]; /** When search quality is low_confidence, evidence lock MUST block edits. */ searchQualityStatus?: 'ok' | 'low_confidence'; + /** Impact coverage: number of known callers covered by results */ + impactCoverage?: { covered: number; total: number }; } function strengthFactor(strength: EvidenceStrength): number { @@ -162,6 +165,17 @@ export function buildEvidenceLock(input: BuildEvidenceLockInput): EvidenceLock { stressTriggers.push('Insufficient evidence: most evidence sources are empty'); } + // Trigger: low caller coverage + if ( + input.impactCoverage && + input.impactCoverage.total > 3 && + input.impactCoverage.covered / input.impactCoverage.total < 0.4 + ) { + stressTriggers.push( + `Low caller coverage: only ${input.impactCoverage.covered} of ${input.impactCoverage.total} callers appear in results` + ); + } + let epistemicStress: EpistemicStress | undefined; if (stressTriggers.length > 0) { const level: EpistemicStress['level'] = @@ -195,6 +209,41 @@ export function buildEvidenceLock(input: BuildEvidenceLockInput): EvidenceLock { (!epistemicStress || !epistemicStress.abstain) && input.searchQualityStatus !== 'low_confidence'; + // Generate whatWouldHelp recommendations + const whatWouldHelp: string[] = []; + if (!readyToEdit) { + // Code evidence weak/missing + if (codeStrength === 'weak' || codeStrength === 'missing') { + whatWouldHelp.push( + 'Search with a more specific query targeting the implementation files' + ); + } + + // Pattern evidence missing + if (patternsStrength === 'missing') { + whatWouldHelp.push('Call get_team_patterns to see what patterns apply to this area'); + } + + // Low caller coverage with many callers + if ( + input.impactCoverage && + input.impactCoverage.total > 3 && + input.impactCoverage.covered / input.impactCoverage.total < 0.4 + ) { + const uncoveredCallers = input.impactCoverage.total - input.impactCoverage.covered; + if (uncoveredCallers > 0) { + whatWouldHelp.push( + `Search specifically for uncovered callers to check ${Math.min(2, uncoveredCallers)} more files` + ); + } + } + + // Memory evidence missing + failure warnings + if (memoriesStrength === 'missing' && input.failureWarnings.length > 0) { + whatWouldHelp.push('Review related memories with get_memory to understand past issues'); + } + } + return { mode: 'triangulated', status, @@ -203,6 +252,7 @@ export function buildEvidenceLock(input: BuildEvidenceLockInput): EvidenceLock { sources, ...(gaps.length > 0 && { gaps }), ...(nextAction && { nextAction }), - ...(epistemicStress && { epistemicStress }) + ...(epistemicStress && { epistemicStress }), + ...(whatWouldHelp.length > 0 && { whatWouldHelp: whatWouldHelp.slice(0, 4) }) }; } diff --git a/src/tools/search-codebase.ts b/src/tools/search-codebase.ts index da6afbe..be696ce 100644 --- a/src/tools/search-codebase.ts +++ b/src/tools/search-codebase.ts @@ -440,15 +440,15 @@ export async function handle( if (intent && preflightIntents.includes(intent) && intelligence) { try { // --- Avoid / Prefer patterns --- - const avoidPatterns: any[] = []; - const preferredPatterns: any[] = []; + const avoidPatternsList: any[] = []; + const preferredPatternsList: any[] = []; const patterns = intelligence.patterns || {}; for (const [category, data] of Object.entries(patterns)) { // Primary pattern = preferred if Rising or Stable if (data.primary) { const p = data.primary; if (p.trend === 'Rising' || p.trend === 'Stable') { - preferredPatterns.push({ + preferredPatternsList.push({ pattern: p.name, category, adoption: p.frequency, @@ -462,7 +462,7 @@ export async function handle( if (data.alsoDetected) { for (const alt of data.alsoDetected) { if (alt.trend === 'Declining') { - avoidPatterns.push({ + avoidPatternsList.push({ pattern: alt.name, category, adoption: alt.frequency, @@ -478,6 +478,24 @@ export async function handle( const resultPaths = results.map((r) => r.filePath); const impactCandidates = computeImpactCandidates(resultPaths); + // PREF-02: Compute impact coverage (callers of result files that appear in results) + const callerFiles = resultPaths.flatMap((p) => { + const importers: string[] = []; + for (const [dep, importerList] of reverseImports) { + if (dep.endsWith(p) || p.endsWith(dep)) { + importers.push(...importerList); + } + } + return importers; + }); + const uniqueCallers = new Set(callerFiles); + const callersCovered = Array.from(uniqueCallers).filter((f) => + resultPaths.some((rp) => f.endsWith(rp) || rp.endsWith(f)) + ).length; + const callersTotal = uniqueCallers.size; + const impactCoverage = + callersTotal > 0 ? { covered: callersCovered, total: callersTotal } : undefined; + // --- Risk level (based on circular deps + impact breadth) --- let riskLevel: 'low' | 'medium' | 'high' = 'low'; let cycleCount = 0; @@ -526,8 +544,8 @@ export async function handle( })) .slice(0, 3); - const preferredPatternsForOutput = preferredPatterns.slice(0, 5); - const avoidPatternsForOutput = avoidPatterns.slice(0, 5); + const preferredPatternsForOutput = preferredPatternsList.slice(0, 5); + const avoidPatternsForOutput = avoidPatternsList.slice(0, 5); // --- Pattern conflicts (split decisions within categories) --- const patternConflicts: Array<{ @@ -562,59 +580,89 @@ export async function handle( relatedMemories, failureWarnings, patternConflicts, - searchQualityStatus: searchQuality.status + searchQualityStatus: searchQuality.status, + impactCoverage }); - // Bump risk if there are active failure memories for this area - if (failureWarnings.length > 0 && riskLevel === 'low') { - riskLevel = 'medium'; + // Build clean decision card (PREF-01 to PREF-04) + interface DecisionCard { + ready: boolean; + nextAction?: string; + warnings?: string[]; + patterns?: { + do?: string[]; + avoid?: string[]; + }; + bestExample?: string; + impact?: { + coverage?: string; + files?: string[]; + }; + whatWouldHelp?: string[]; } - // If evidence triangulation is weak, avoid claiming low risk - if (evidenceLock.status === 'block' && riskLevel === 'low') { - riskLevel = 'medium'; + const decisionCard: DecisionCard = { + ready: evidenceLock.readyToEdit + }; + + // Add nextAction if not ready + if (!decisionCard.ready && evidenceLock.nextAction) { + decisionCard.nextAction = evidenceLock.nextAction; } - // If epistemic stress says abstain, bump risk - if (evidenceLock.epistemicStress?.abstain && riskLevel === 'low') { - riskLevel = 'medium'; + // Add warnings from failure memories (capped at 3) + if (failureWarnings.length > 0) { + decisionCard.warnings = failureWarnings.slice(0, 3).map((w) => w.memory); } - preflight = { - intent, - riskLevel, - confidence, - evidenceLock, - ...(preferredPatternsForOutput.length > 0 && { - preferredPatterns: preferredPatternsForOutput - }), - ...(avoidPatternsForOutput.length > 0 && { - avoidPatterns: avoidPatternsForOutput - }), - ...(goldenFiles.length > 0 && { goldenFiles }), - ...(impactCandidates.length > 0 && { - impactCandidates: impactCandidates.slice(0, 10) - }), - ...(cycleCount > 0 && { circularDependencies: cycleCount }), - ...(failureWarnings.length > 0 && { failureWarnings }) - }; + // Add patterns (do/avoid, capped at 3 each, with adoption %) + const doPatterns = preferredPatternsForOutput.slice(0, 3).map((p) => `${p.pattern} — ${p.frequency || 'N/A'}`); + const avoidPatterns = avoidPatternsForOutput.slice(0, 3).map((p) => `${p.pattern} — ${p.frequency || 'N/A'} (declining)`); + if (doPatterns.length > 0 || avoidPatterns.length > 0) { + decisionCard.patterns = { + ...(doPatterns.length > 0 && { do: doPatterns }), + ...(avoidPatterns.length > 0 && { avoid: avoidPatterns }) + }; + } + + // Add bestExample (top 1 golden file) + if (goldenFiles.length > 0) { + decisionCard.bestExample = `${goldenFiles[0].file}`; + } + + // Add impact (coverage + top 3 files) + if (impactCoverage || impactCandidates.length > 0) { + const impactObj: { coverage?: string; files?: string[] } = {}; + if (impactCoverage) { + impactObj.coverage = `${impactCoverage.covered}/${impactCoverage.total} callers in results`; + } + if (impactCandidates.length > 0) { + impactObj.files = impactCandidates.slice(0, 3); + } + if (Object.keys(impactObj).length > 0) { + decisionCard.impact = impactObj; + } + } + + // Add whatWouldHelp from evidenceLock + if (evidenceLock.whatWouldHelp && evidenceLock.whatWouldHelp.length > 0) { + decisionCard.whatWouldHelp = evidenceLock.whatWouldHelp; + } + + preflight = decisionCard; } catch { // Preflight construction failed — skip preflight, don't fail the search } } - // For edit/refactor/migrate: return full preflight card (risk, patterns, impact, etc.). - // For explore or lite-only: return flattened { ready, reason }. + // For edit/refactor/migrate: return clean decision card. + // For explore or lite-only: return lightweight { ready, reason }. let preflightPayload: { ready: boolean; reason?: string } | Record | undefined; if (preflight) { - const el = preflight.evidenceLock; - // Full card per tool schema; add top-level ready/reason for backward compatibility - preflightPayload = { - ...preflight, - ready: el?.readyToEdit ?? false, - ...(el && !el.readyToEdit && el.nextAction && { reason: el.nextAction }) - }; + // preflight is already a clean decision card (DecisionCard type) + preflightPayload = preflight; } else if (editPreflight) { + // Lite preflight for explore intent const el = editPreflight.evidenceLock; preflightPayload = { ready: el?.readyToEdit ?? false, @@ -622,6 +670,36 @@ export async function handle( }; } + // Helper: Build scope header for symbol-aware chunks (SEARCH-02) + function buildScopeHeader(metadata: any): string | null { + // Try symbolPath first (most reliable for AST-based symbols) + if (metadata?.symbolPath && Array.isArray(metadata.symbolPath)) { + return metadata.symbolPath.join('.'); + } + // Fallback: className + functionName + if (metadata?.className && metadata?.functionName) { + return `${metadata.className}.${metadata.functionName}`; + } + // Class only + if (metadata?.className) { + return metadata.className; + } + // Function only + if (metadata?.functionName) { + return metadata.functionName; + } + return null; + } + + function enrichSnippetWithScope(snippet: string | undefined, metadata: any): string | undefined { + if (!snippet) return undefined; + const scopeHeader = buildScopeHeader(metadata); + if (scopeHeader) { + return `// ${scopeHeader}\n${snippet}`; + } + return snippet; + } + return { content: [ { @@ -640,6 +718,9 @@ export async function handle( ...(preflightPayload && { preflight: preflightPayload }), results: results.map((r) => { const relationshipsAndHints = buildRelationshipHints(r); + const enrichedSnippet = includeSnippets + ? enrichSnippetWithScope(r.snippet, r.metadata) + : undefined; return { file: `${r.filePath}:${r.startLine}-${r.endLine}`, @@ -650,7 +731,7 @@ export async function handle( ...(r.patternWarning && { patternWarning: r.patternWarning }), ...(relationshipsAndHints.relationships && { relationships: relationshipsAndHints.relationships }), ...(relationshipsAndHints.hints && { hints: relationshipsAndHints.hints }), - ...(includeSnippets && r.snippet && { snippet: r.snippet }) + ...(enrichedSnippet && { snippet: enrichedSnippet }) }; }), totalResults: results.length,