sigmap 8.28.1 → 8.30.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/gen-context.js CHANGED
@@ -4623,47 +4623,12 @@ __factories["./src/eval/runner"] = function(module, exports) {
4623
4623
  * @returns {Map<string, string[]>}
4624
4624
  */
4625
4625
  function buildSigIndex(cwd) {
4626
- const contextPath = path.join(cwd, '.github', 'copilot-instructions.md');
4627
- const index = new Map();
4628
-
4629
- if (!fs.existsSync(contextPath)) return index;
4630
-
4631
- const content = fs.readFileSync(contextPath, 'utf8');
4632
- const lines = content.split('\n');
4633
-
4634
- let currentFile = null;
4635
- let inBlock = false;
4636
- let sigs = [];
4637
-
4638
- for (const line of lines) {
4639
- // Section header: ### path/to/file.js
4640
- const headerMatch = line.match(/^###\s+(\S+\.\w+)\s*$/);
4641
- if (headerMatch) {
4642
- if (currentFile !== null) {
4643
- index.set(currentFile, sigs);
4644
- }
4645
- currentFile = headerMatch[1];
4646
- sigs = [];
4647
- inBlock = false;
4648
- continue;
4649
- }
4650
-
4651
- if (line.startsWith('```')) {
4652
- inBlock = !inBlock;
4653
- continue;
4654
- }
4655
-
4656
- if (inBlock && currentFile && line.trim()) {
4657
- sigs.push(line.trim());
4658
- }
4659
- }
4660
-
4661
- // Flush last file
4662
- if (currentFile !== null) {
4663
- index.set(currentFile, sigs);
4664
- }
4665
-
4666
- return index;
4626
+ // Delegate to the production index builder. This used to parse
4627
+ // .github/copilot-instructions.md directly a second parallel implementation
4628
+ // that saw only the token-BUDGETED view, ignored the other adapters, the
4629
+ // hot-cold/per-module strategy splits, and the complete retrieval index. The
4630
+ // corpus therefore scored a smaller index than `sigmap ask` actually uses.
4631
+ return __require('./src/retrieval/ranker').buildSigIndex(cwd);
4667
4632
  }
4668
4633
 
4669
4634
  // ---------------------------------------------------------------------------
@@ -4681,12 +4646,13 @@ __factories["./src/eval/runner"] = function(module, exports) {
4681
4646
  * @param {number} topK
4682
4647
  * @returns {{ file: string, score: number, sigs: string[] }[]}
4683
4648
  */
4684
- function rank(query, index, topK = 10) {
4685
- const candidates = [];
4686
- for (const [file, sigs] of index.entries()) {
4687
- candidates.push({ file, sigs });
4688
- }
4689
- return bm25rank(query, candidates).slice(0, topK);
4649
+ function rank(query, index, topK = 10, opts = {}) {
4650
+ // Measure the ranker users actually hit. This used to call bm25rank directly,
4651
+ // which meant the corpus scored a parallel implementation with no penalties,
4652
+ // graph boost, recency or learned weights — so no ranking regression in
4653
+ // src/retrieval/ranker.js could ever show up in the benchmark numbers.
4654
+ const { rank: prodRank } = __require('./src/retrieval/ranker');
4655
+ return prodRank(query, index, Object.assign({ topK }, opts)).slice(0, topK);
4690
4656
  }
4691
4657
 
4692
4658
  // ---------------------------------------------------------------------------
@@ -4773,11 +4739,14 @@ __factories["./src/eval/runner"] = function(module, exports) {
4773
4739
 
4774
4740
  // Build index once (re-used across all tasks in the same repo)
4775
4741
  const index = buildSigIndex(cwd);
4742
+ // Import graph built once too — the hop-1/hop-2 boost is part of what ships.
4743
+ let graph = null;
4744
+ try { graph = __require('./src/graph/builder').buildFromCwd(cwd); } catch (_) {}
4776
4745
 
4777
4746
  const taskResults = [];
4778
4747
  for (const task of tasks) {
4779
- const ranked = rank(task.query, index, topK).map((r) => r.file);
4780
- const topResult = rank(task.query, index, topK);
4748
+ const topResult = rank(task.query, index, topK, { cwd, graph, learned: opts.learned });
4749
+ const ranked = topResult.map((r) => r.file);
4781
4750
  const tokens = topResult.reduce((sum, r) => sum + estimateTokens(r.sigs), 0);
4782
4751
 
4783
4752
  const { hitAtK, reciprocalRank, precisionAtK } = __require('./src/eval/scorer');
@@ -6455,6 +6424,19 @@ __factories["./src/extractors/html"] = function(module, exports) {
6455
6424
  __factories["./src/extractors/java"] = function(module, exports) {
6456
6425
 
6457
6426
  const { lineAt, withAnchor } = __require('./src/extractors/line-anchor');
6427
+ const { capWithNotice, capMembersWithNotice } = __require('./src/util/truncate');
6428
+
6429
+ // Class bodies are scanned to this many characters. Generated JVM sources
6430
+ // (MyBatis/JPA entities) routinely run past 10KB, so the ceiling only guards
6431
+ // against pathological input rather than trimming ordinary classes.
6432
+ const MAX_CLASS_BODY_CHARS = 200000;
6433
+
6434
+ // Per-class member ceiling. Sits above the default `maxSigsPerFile` so the
6435
+ // caller's configured budget governs the output rather than this file.
6436
+ const MAX_MEMBERS_PER_CLASS = 120;
6437
+
6438
+ // Per-file signature ceiling, likewise above the configured default.
6439
+ const MAX_SIGS_PER_FILE = 200;
6458
6440
 
6459
6441
  /**
6460
6442
  * Extract signatures from Java source code.
@@ -6482,17 +6464,20 @@ __factories["./src/extractors/java"] = function(module, exports) {
6482
6464
  const block = extractBlock(stripped, bodyStart);
6483
6465
  sigs.push(hinted(withAnchor(`${m[1]} ${m[2]}`, lineAt(stripped, m.index), lineAt(stripped, bodyStart + block.length)), m[2]));
6484
6466
  for (const meth of extractMembers(block)) {
6485
- sigs.push(hinted(withAnchor(` ${meth.text}`, lineAt(stripped, bodyStart + meth.declIdx), lineAt(stripped, bodyStart + meth.endIdx)), meth.name));
6467
+ // The disclosure marker carries no offsets; anchor it at the class body.
6468
+ const declIdx = meth.declIdx || 0;
6469
+ const endIdx = meth.endIdx || 0;
6470
+ sigs.push(hinted(withAnchor(` ${meth.text}`, lineAt(stripped, bodyStart + declIdx), lineAt(stripped, bodyStart + endIdx)), meth.name));
6486
6471
  }
6487
6472
  }
6488
6473
 
6489
- return sigs.slice(0, 25);
6474
+ return capWithNotice(sigs, MAX_SIGS_PER_FILE, 'signatures');
6490
6475
  }
6491
6476
 
6492
6477
  function extractBlock(src, startIndex) {
6493
6478
  let depth = 1;
6494
6479
  let i = startIndex;
6495
- const end = Math.min(src.length, startIndex + 5000);
6480
+ const end = Math.min(src.length, startIndex + MAX_CLASS_BODY_CHARS);
6496
6481
  while (i < end && depth > 0) {
6497
6482
  if (src[i] === '{') depth++;
6498
6483
  else if (src[i] === '}') depth--;
@@ -6514,7 +6499,7 @@ __factories["./src/extractors/java"] = function(module, exports) {
6514
6499
  endIdx: m.index + m[0].length,
6515
6500
  });
6516
6501
  }
6517
- return members.slice(0, 8);
6502
+ return capMembersWithNotice(members, MAX_MEMBERS_PER_CLASS);
6518
6503
  }
6519
6504
 
6520
6505
  function normalizeParams(params) {
@@ -11345,10 +11330,11 @@ __factories["./src/graph/builder"] = function(module, exports) {
11345
11330
  const fs = require('fs');
11346
11331
  const path = require('path');
11347
11332
 
11348
- // Normalize paths for cross-platform consistency (Windows uses backslashes, Unix uses forward slashes)
11349
- // Use lowercase to enable case-insensitive lookups on case-sensitive Windows filesystems
11333
+ // Cross-platform node key. Delegates to the ONE shared definition so this graph
11334
+ // and the call-graph cannot drift apart again (see src/graph/path-key.js).
11335
+ const { graphKey } = __require('./src/graph/path-key');
11350
11336
  function normalizePath(p) {
11351
- return path.normalize(p).toLowerCase();
11337
+ return graphKey(p);
11352
11338
  }
11353
11339
 
11354
11340
  // ---------------------------------------------------------------------------
@@ -11877,7 +11863,8 @@ __factories["./src/graph/call-graph"] = function(module, exports) {
11877
11863
  'synchronized',
11878
11864
  ]);
11879
11865
 
11880
- function normalizePath(p) { return path.normalize(p).toLowerCase(); }
11866
+ const { graphKey } = __require('./src/graph/path-key');
11867
+ function normalizePath(p) { return graphKey(p); }
11881
11868
  function toRel(cwd, f) { return path.relative(cwd, f).replace(/\\/g, '/'); }
11882
11869
  function symId(cwd, absFile, name) { return `${toRel(cwd, absFile)}#${name}`; }
11883
11870
 
@@ -12309,8 +12296,11 @@ __factories["./src/graph/call-graph"] = function(module, exports) {
12309
12296
  for (const calleeId of calleeIds) {
12310
12297
  const calleeDef = graph.defs.get(calleeId);
12311
12298
  if (!calleeDef || calleeDef.file === callerDef.file) continue;
12312
- const a = path.resolve(cwd, callerDef.file);
12313
- const b = path.resolve(cwd, calleeDef.file);
12299
+ // Keyed through graphKey so the file-level call graph shares ONE key space
12300
+ // with the import graph. Previously this kept case while builder.js
12301
+ // lowercased, so a lookup correct for one silently missed on the other.
12302
+ const a = graphKey(path.resolve(cwd, callerDef.file));
12303
+ const b = graphKey(path.resolve(cwd, calleeDef.file));
12314
12304
  add(a, b);
12315
12305
  add(b, a);
12316
12306
  }
@@ -12716,6 +12706,36 @@ __factories["./src/graph/impact"] = function(module, exports) {
12716
12706
 
12717
12707
  };
12718
12708
 
12709
+ // ── ./src/graph/path-key ──
12710
+ __factories["./src/graph/path-key"] = function(module, exports) {
12711
+
12712
+ /**
12713
+ * The single definition of a graph node key.
12714
+ *
12715
+ * src/graph/builder.js and src/graph/call-graph.js each used to normalise paths
12716
+ * their own way — builder lowercased, call-graph did not — so a lookup written
12717
+ * for one silently missed on the other. That divergence disabled the import
12718
+ * boost on every repo whose path contains an uppercase letter, and later caused
12719
+ * a "fix" for one graph to break the other. Both now key through this function,
12720
+ * so there is one convention rather than two conventions and a convention.
12721
+ *
12722
+ * Lowercasing keeps lookups stable across case-insensitive filesystems (macOS,
12723
+ * Windows), where the same file legitimately arrives spelled two ways.
12724
+ *
12725
+ * Zero-dependency, pure, bundle-safe.
12726
+ */
12727
+
12728
+ const path = require('path');
12729
+
12730
+ /** Canonical key for a filesystem path used as a graph node. */
12731
+ function graphKey(p) {
12732
+ return path.normalize(String(p)).toLowerCase();
12733
+ }
12734
+
12735
+ module.exports = { graphKey };
12736
+
12737
+ };
12738
+
12719
12739
  // ── ./src/health/scorer ──
12720
12740
  __factories["./src/health/scorer"] = function(module, exports) {
12721
12741
 
@@ -15246,6 +15266,7 @@ __factories["./src/mcp/install"] = function(module, exports) {
15246
15266
 
15247
15267
  // Config shapes the supported clients use.
15248
15268
  // - 'json' → { mcpServers: { sigmap: { command, args } } }
15269
+ // - 'vscode'→ { servers: { sigmap: { type: 'stdio', command, args } } }
15249
15270
  // - 'zed' → { context_servers: { sigmap: { command: { path, args } } } }
15250
15271
  // - 'yaml' → Codex CLI ~/.codex/config.yaml (mcpServers block, appended)
15251
15272
  const CLIENTS = {
@@ -15254,7 +15275,7 @@ __factories["./src/mcp/install"] = function(module, exports) {
15254
15275
  windsurf: { label: 'Windsurf', format: 'json', scope: 'both',
15255
15276
  project: ['.windsurf', 'mcp.json'],
15256
15277
  global: ['.codeium', 'windsurf', 'mcp_config.json'] },
15257
- vscode: { label: 'VS Code', format: 'json', scope: 'project', project: ['.vscode', 'mcp.json'] },
15278
+ vscode: { label: 'VS Code', format: 'vscode', scope: 'project', project: ['.vscode', 'mcp.json'] },
15258
15279
  opencode: { label: 'OpenCode', format: 'json', scope: 'both',
15259
15280
  project: ['opencode.json'],
15260
15281
  global: ['.config', 'opencode', 'config.json'] },
@@ -15309,6 +15330,31 @@ __factories["./src/mcp/install"] = function(module, exports) {
15309
15330
  return 'installed';
15310
15331
  }
15311
15332
 
15333
+ /**
15334
+ * Install into VS Code's `.vscode/mcp.json`, which keys servers under `servers`
15335
+ * (not `mcpServers`) and expects an explicit transport `type`. A config written
15336
+ * by an older SigMap under `mcpServers` is migrated rather than left in place,
15337
+ * so re-running repairs it instead of leaving two entries VS Code cannot read.
15338
+ */
15339
+ function _installVscode(filePath, scriptPath) {
15340
+ let settings = {};
15341
+ if (fs.existsSync(filePath)) {
15342
+ try { settings = JSON.parse(fs.readFileSync(filePath, 'utf8')) || {}; }
15343
+ catch (_) { settings = {}; }
15344
+ }
15345
+ const stale = settings.mcpServers && settings.mcpServers.sigmap;
15346
+ if (stale) {
15347
+ delete settings.mcpServers.sigmap;
15348
+ if (Object.keys(settings.mcpServers).length === 0) delete settings.mcpServers;
15349
+ }
15350
+ if (!settings.servers) settings.servers = {};
15351
+ if (settings.servers.sigmap && !stale) return 'already';
15352
+ settings.servers.sigmap = { type: 'stdio', command: 'node', args: serverArgs(scriptPath) };
15353
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
15354
+ fs.writeFileSync(filePath, JSON.stringify(settings, null, 2) + '\n');
15355
+ return stale ? 'updated' : 'installed';
15356
+ }
15357
+
15312
15358
  /** Install into Zed's `context_servers` config (create file/dir if absent). */
15313
15359
  function _installZed(filePath, scriptPath) {
15314
15360
  let settings = {};
@@ -15361,7 +15407,8 @@ __factories["./src/mcp/install"] = function(module, exports) {
15361
15407
  const filePath = resolveTarget(spec, cwd, home, opts.global);
15362
15408
 
15363
15409
  let status;
15364
- if (spec.format === 'zed') status = _installZed(filePath, scriptPath);
15410
+ if (spec.format === 'vscode') status = _installVscode(filePath, scriptPath);
15411
+ else if (spec.format === 'zed') status = _installZed(filePath, scriptPath);
15365
15412
  else if (spec.format === 'yaml') status = _installYaml(filePath, scriptPath);
15366
15413
  else status = _installJson(filePath, scriptPath);
15367
15414
 
@@ -15393,7 +15440,7 @@ __factories["./src/mcp/server"] = function(module, exports) {
15393
15440
 
15394
15441
  const SERVER_INFO = {
15395
15442
  name: 'sigmap',
15396
- version: '8.28.1',
15443
+ version: '8.30.0',
15397
15444
  description: 'SigMap MCP server — code signatures on demand',
15398
15445
  };
15399
15446
 
@@ -16357,6 +16404,33 @@ __factories["./src/retrieval/bm25"] = function(module, exports) {
16357
16404
  // the literal query token always outranks a synonym-only match.
16358
16405
  const EXPANSION_WEIGHT = 0.15;
16359
16406
 
16407
+ // Module-doc prose is indexed as a `# module: ...` pseudo-signature (index-only,
16408
+ // see src/retrieval/module-doc.js). Per token it is a weaker relevance signal
16409
+ // than a real signature — descriptive rather than definitional — so it is scored
16410
+ // as its own BM25F field rather than pooled with the code terms.
16411
+ const MODULE_DOC_RE = /^#\s*(module|docs):/;
16412
+
16413
+ // Line anchors are metadata, not content, and this ranker is documented as
16414
+ // anchor-invariant. The previous strip was end-anchored, so it only fired when
16415
+ // the anchor was the last thing on the line — but extractors append a doc hint
16416
+ // AFTER it ("... :27-59 # Compute a normalized centrality score"). 27% of
16417
+ // signatures therefore leaked their line numbers into the term space as tokens
16418
+ // like "27" and "59": 840 junk terms, inflating document length for exactly the
16419
+ // well-documented files, which BM25 then penalised via length normalisation.
16420
+ const ANCHOR_RE = /\s*:\d+(?:-\d+)?(?=\s|$)/g;
16421
+
16422
+ function stripAnchor(line) {
16423
+ return String(line).replace(ANCHOR_RE, '');
16424
+ }
16425
+ // Tuned on the 30-task leak-free corpus. The 0.5-0.8 band is flat
16426
+ // (hit@5 63.3-66.7%, easy MRR steady at 0.825); adjacent values swing by up to
16427
+ // 6.7pp, which at 30 tasks is literally two tasks — noise, not signal. 0.6 is
16428
+ // chosen from the middle of that band rather than at its peak, because a
16429
+ // per-token weight below 1 is the principled position (prose is descriptive,
16430
+ // a signature is definitional) and picking the argmax of a 30-task sweep is
16431
+ // how you overfit a benchmark.
16432
+ const DOC_WEIGHT = 0.6;
16433
+
16360
16434
  // Build a stemmed lookup: stem(member) → Set of the group's other stemmed members.
16361
16435
  const EXPANSIONS = (() => {
16362
16436
  const map = new Map();
@@ -16400,22 +16474,45 @@ __factories["./src/retrieval/bm25"] = function(module, exports) {
16400
16474
  * @param {{ file: string, sigs: string[] }[]} candidates
16401
16475
  * @returns {Array<object & { score: number }>}
16402
16476
  */
16403
- function bm25rank(query, candidates) {
16477
+ function bm25rank(query, candidates, opts) {
16404
16478
  if (!Array.isArray(candidates) || candidates.length === 0) return [];
16405
16479
 
16406
16480
  const k1 = 1.5;
16407
16481
  const b = 0.75;
16408
16482
 
16483
+ const docWeight = (opts && typeof opts.docWeight === 'number') ? opts.docWeight : DOC_WEIGHT;
16484
+
16409
16485
  const docs = candidates.map((c) => {
16410
16486
  const pathToks = tokenize(c.file || '');
16411
16487
  // Ranking is anchor-invariant: `:start-end` line anchors are metadata,
16412
16488
  // not content — strip them before tokenizing so adding anchors to an
16413
16489
  // extractor never shifts BM25 length normalization or token counts.
16414
- const toks = tokenize((c.sigs || []).map((s) => String(s).replace(/\s*:\d+(?:-\d+)?\s*$/, '')).join(' '));
16415
- for (let i = 0; i < PATH_BOOST; i++) toks.push(...pathToks);
16490
+ // BM25F-style fields. Module-doc prose and code signatures are different
16491
+ // kinds of evidence and must not share one term-frequency pool: prose is
16492
+ // ~30% of all indexed tokens, and a short file with a long header (few
16493
+ // signatures, lots of description) otherwise wins unrelated queries purely
16494
+ // through length normalisation.
16495
+ const docLines = [];
16496
+ const codeLines = [];
16497
+ for (const line of (c.sigs || [])) (MODULE_DOC_RE.test(line) ? docLines : codeLines).push(line);
16498
+ // TRIED AND REJECTED: splitting the declared symbol NAME into its own
16499
+ // weighted BM25F field, on the IR prior that a name is a "title" and params
16500
+ // are "body". Swept 1.0-4.0. hit@5 on the mined corpus rose 60.9% -> 65.2%,
16501
+ // which is a single task crossing the rank-5 line — over 113 combined tasks
16502
+ // hit@1 fell 52.2% -> 51.3%, hit@3 fell 66.4% -> 65.5%, hit@10 was identical
16503
+ // and MRR dropped. It moves correct answers DOWN and happens to nudge one
16504
+ // past a cutoff. A hit@5-only view would have shipped this.
16505
+ const codeToks = tokenize(codeLines.map((x) => stripAnchor(x)).join(' '));
16506
+ const docToks = tokenize(docLines.join(' '));
16416
16507
  const tf = new Map();
16417
- for (const t of toks) tf.set(t, (tf.get(t) || 0) + 1);
16418
- return { cand: c, tf, len: toks.length };
16508
+ const addField = (toks, weight) => { for (const t of toks) tf.set(t, (tf.get(t) || 0) + weight); };
16509
+ addField(codeToks, 1);
16510
+ addField(pathToks, PATH_BOOST);
16511
+ addField(docToks, docWeight);
16512
+ // Length accumulates with the SAME weights, or a field's influence leaks
16513
+ // back in through the normalisation term.
16514
+ const len = codeToks.length + (PATH_BOOST * pathToks.length) + (docWeight * docToks.length);
16515
+ return { cand: c, tf, len };
16419
16516
  });
16420
16517
 
16421
16518
  const N = docs.length || 1;
@@ -16444,7 +16541,7 @@ __factories["./src/retrieval/bm25"] = function(module, exports) {
16444
16541
  .sort((a, c) => c.score - a.score || String(a.file).localeCompare(String(c.file)));
16445
16542
  }
16446
16543
 
16447
- module.exports = { tokenize, stem, bm25rank, PATH_BOOST, STOP, expandQuery, EXPANSIONS, EXPANSION_WEIGHT };
16544
+ module.exports = { tokenize, stem, bm25rank, PATH_BOOST, STOP, expandQuery, EXPANSIONS, EXPANSION_WEIGHT, DOC_WEIGHT, MODULE_DOC_RE, stripAnchor };
16448
16545
 
16449
16546
  };
16450
16547
 
@@ -16507,6 +16604,130 @@ __factories["./src/retrieval/enrich-from-maps"] = function(module, exports) {
16507
16604
 
16508
16605
  };
16509
16606
 
16607
+ // ── ./src/retrieval/module-doc ──
16608
+ __factories["./src/retrieval/module-doc"] = function(module, exports) {
16609
+
16610
+ /**
16611
+ * Module-level documentation extractor (retrieval only).
16612
+ *
16613
+ * Signatures describe a file's SHAPE — names, params, return types. A module's
16614
+ * leading comment describes its PURPOSE, in prose, using the words a person
16615
+ * would actually search with. That prose is the bridge between a behavioural
16616
+ * query ("what fraction of the repo made it into the output") and the code
16617
+ * that implements it (`coverageScore(cwd, fileEntries, config)`), which shares
16618
+ * not one token with the query.
16619
+ *
16620
+ * This text is added to the RETRIEVAL INDEX ONLY — never to the generated
16621
+ * context file. The prompt artifact is token-budgeted and prose is expensive
16622
+ * there; the index is not injected into any prompt, so it can afford the words
16623
+ * that make a file findable.
16624
+ *
16625
+ * Zero-dependency, pure, bundle-safe.
16626
+ */
16627
+
16628
+ // Enough to characterise a module without letting one verbose header dominate
16629
+ // BM25 length normalisation for the whole corpus.
16630
+ const MAX_CHARS = 400;
16631
+ const MAX_SCAN_LINES = 60;
16632
+
16633
+ // Legal boilerplate is high-frequency noise: it appears in many files, shares no
16634
+ // vocabulary with real queries, and would flatten idf across the corpus.
16635
+ const BOILERPLATE = /\b(copyright|licensed under|SPDX-License|all rights reserved|permission is hereby granted)\b/i;
16636
+
16637
+ const BLOCK_LANGS = new Set(['js', 'jsx', 'ts', 'tsx', 'java', 'go', 'rs', 'kt', 'swift', 'scala', 'cs', 'php', 'dart', 'c', 'cpp', 'h']);
16638
+ const HASH_LANGS = new Set(['py', 'rb', 'r', 'sh', 'yml', 'yaml', 'toml']);
16639
+
16640
+ function _extOf(filePath) {
16641
+ const m = String(filePath).match(/\.([A-Za-z0-9]+)$/);
16642
+ return m ? m[1].toLowerCase() : '';
16643
+ }
16644
+
16645
+ /** Strip comment furniture, JSDoc tags, and markup from one raw comment line. */
16646
+ function _cleanLine(line) {
16647
+ return String(line)
16648
+ .replace(/^\s*[/*#-]+\s?/, '') // leading // /* * # ---
16649
+ .replace(/\*+\/\s*$/, '') // trailing */
16650
+ .replace(/^\s*@\w+.*$/, '') // @param / @returns tag lines
16651
+ .replace(/[*_`]/g, '') // markdown emphasis / code ticks
16652
+ .trim();
16653
+ }
16654
+
16655
+ /**
16656
+ * Extract a module's leading documentation prose.
16657
+ *
16658
+ * @param {string} src file contents
16659
+ * @param {string} filePath used only to pick a comment syntax
16660
+ * @returns {string} collapsed prose, capped, or '' when there is none
16661
+ */
16662
+ function extractModuleDoc(src, filePath) {
16663
+ if (!src || typeof src !== 'string') return '';
16664
+ const ext = _extOf(filePath);
16665
+ const lines = src.split('\n', MAX_SCAN_LINES);
16666
+
16667
+ const collected = [];
16668
+ let inBlock = false;
16669
+ let started = false;
16670
+
16671
+ for (const raw of lines) {
16672
+ const line = raw.trim();
16673
+ if (!started) {
16674
+ // Skip preamble that precedes the real header comment.
16675
+ if (!line) continue;
16676
+ if (line.startsWith('#!')) continue; // shebang
16677
+ if (/^['"]use strict['"];?$/.test(line)) continue;
16678
+ if (/^(package|import|from|using|#include)\b/.test(line)) continue;
16679
+ }
16680
+
16681
+ if (BLOCK_LANGS.has(ext) || ext === '') {
16682
+ if (!inBlock && line.startsWith('/*')) { inBlock = true; started = true; }
16683
+ if (inBlock) {
16684
+ const cleaned = _cleanLine(line);
16685
+ if (cleaned) collected.push(cleaned);
16686
+ if (line.includes('*/')) break;
16687
+ continue;
16688
+ }
16689
+ if (line.startsWith('//')) { // run of // lines
16690
+ started = true;
16691
+ const cleaned = _cleanLine(line);
16692
+ if (cleaned) collected.push(cleaned);
16693
+ continue;
16694
+ }
16695
+ if (started || collected.length) break;
16696
+ break; // first real code — no header
16697
+ }
16698
+
16699
+ if (HASH_LANGS.has(ext)) {
16700
+ if (/^("""|''')/.test(line)) { // python docstring
16701
+ started = true; inBlock = !inBlock;
16702
+ const cleaned = _cleanLine(line.replace(/^("""|''')/, '').replace(/("""|''')$/, ''));
16703
+ if (cleaned) collected.push(cleaned);
16704
+ if (!inBlock) break;
16705
+ continue;
16706
+ }
16707
+ if (inBlock) { const c = _cleanLine(line); if (c) collected.push(c); continue; }
16708
+ if (line.startsWith('#')) { started = true; const c = _cleanLine(line); if (c) collected.push(c); continue; }
16709
+ if (collected.length) break;
16710
+ break;
16711
+ }
16712
+ break;
16713
+ }
16714
+
16715
+ const text = collected.join(' ').replace(/\s+/g, ' ').trim();
16716
+ if (!text || BOILERPLATE.test(text)) return '';
16717
+ if (text.length <= MAX_CHARS) return text;
16718
+ return text.slice(0, MAX_CHARS).replace(/\s+\S*$/, ''); // never cut mid-word
16719
+ }
16720
+
16721
+ /** Render as an index-only pseudo-signature, or '' when there is no doc. */
16722
+ function moduleDocSig(src, filePath) {
16723
+ const doc = extractModuleDoc(src, filePath);
16724
+ return doc ? `# module: ${doc}` : '';
16725
+ }
16726
+
16727
+ module.exports = { extractModuleDoc, moduleDocSig, MAX_CHARS };
16728
+
16729
+ };
16730
+
16510
16731
  // ── ./src/retrieval/ranker ──
16511
16732
  __factories["./src/retrieval/ranker"] = function(module, exports) {
16512
16733
 
@@ -16529,7 +16750,7 @@ __factories["./src/retrieval/ranker"] = function(module, exports) {
16529
16750
 
16530
16751
  const { loadWeights } = __require('./src/learning/weights');
16531
16752
  const { tokenize, STOP_WORDS } = __require('./src/retrieval/tokenizer');
16532
- const { bm25rank } = __require('./src/retrieval/bm25');
16753
+ const { bm25rank, MODULE_DOC_RE } = __require('./src/retrieval/bm25');
16533
16754
 
16534
16755
  // ---------------------------------------------------------------------------
16535
16756
  // Default weights
@@ -16553,17 +16774,28 @@ __factories["./src/retrieval/ranker"] = function(module, exports) {
16553
16774
  // Max additive prior for import-graph centrality (opt-in retrieval.centralityBlend)
16554
16775
  const CENTRALITY_BLEND_WEIGHT = 0.3;
16555
16776
 
16556
- // Intent-specific weight adjustments
16557
- const INTENT_WEIGHTS = {
16558
- search: DEFAULT_WEIGHTS,
16559
- debug: { ...DEFAULT_WEIGHTS, exactToken: 1.2, pathMatch: 0.6 },
16560
- explain: { ...DEFAULT_WEIGHTS, symbolMatch: 0.8, pathMatch: 0.9 },
16561
- refactor: { ...DEFAULT_WEIGHTS, symbolMatch: 0.9, exactToken: 0.8 },
16562
- review: { ...DEFAULT_WEIGHTS, pathMatch: 1.0, exactToken: 0.9 },
16563
- test: { ...DEFAULT_WEIGHTS, exactToken: 0.7, symbolMatch: 0.4 },
16564
- integrate: { ...DEFAULT_WEIGHTS, graphBoost: 0.7, pathMatch: 1.1 },
16565
- navigate: { ...DEFAULT_WEIGHTS, pathMatch: 1.2, exactToken: 0.9 },
16566
- };
16777
+ // Per-intent weight profiles were removed in favour of a single weight set.
16778
+ // They were provably inert: scoreFile's score was discarded by rank(), so the
16779
+ // profiles only ever reached the explain table. Once the signal WAS wired into
16780
+ // the score (see SIGNAL_BLEND below), a sweep over the leak-free hard corpus
16781
+ // showed intent-specific profiles produced byte-identical metrics to the flat
16782
+ // DEFAULT_WEIGHTS at every blend value — so they earn nothing and are gone.
16783
+ // `detectIntent` is retained: it is still reported to the user and is the right
16784
+ // hook for shaping OUTPUT depth later.
16785
+
16786
+ // How much the weighted keyword/symbol/path signal modulates the BM25 base.
16787
+ // Multiplicative and bounded, so it can only reorder files that already match —
16788
+ // it can never lift a zero-BM25 file into the results. Tuned on the leak-free
16789
+ // corpus: 0.5 gave hit@5 50.0% -> 56.7% and MRR 0.419 -> 0.447; higher values
16790
+ // held hit@5 but degraded MRR.
16791
+ const SIGNAL_BLEND = 0.5;
16792
+
16793
+ // TRIED AND REJECTED: a same-line co-occurrence bonus, on the theory that a file
16794
+ // declaring `parseAuthToken` should outrank one mentioning `parseAuth` and
16795
+ // `token` on separate lines. Swept 0.15-1.0: hit@5 did not move on either the
16796
+ // 90-task authored corpus or the 32-task mined one, and MRR degraded
16797
+ // monotonically as the weight rose. Signatures are short and dense enough that
16798
+ // BM25's bag already captures this. Not reinstated without new evidence.
16567
16799
 
16568
16800
  // Penalty multipliers for negative signals
16569
16801
  const PENALTY_SIGNALS = {
@@ -16571,14 +16803,69 @@ __factories["./src/retrieval/ranker"] = function(module, exports) {
16571
16803
  generatedCode: 0.3, // dist/build/.next in path
16572
16804
  docsFile: 0.2, // docs/doc/README in path
16573
16805
  nodeModules: 0.0, // node_modules (zero score)
16806
+ dataHolder: 0.3, // generated POJO/entity: almost entirely accessors
16574
16807
  };
16575
16808
 
16576
- function _computePenalty(filePath) {
16809
+ // A file whose members are overwhelmingly trivial accessors is a data holder,
16810
+ // not logic. Path-based detection cannot see these: generated JPA/MyBatis
16811
+ // entities live in ordinary source trees. They match a query on any column
16812
+ // name they happen to carry (`getNote`/`setNote` matches "note" as strongly as
16813
+ // the service that actually implements order notes), so on an entity-heavy
16814
+ // repo they crowd real code out of the top results.
16815
+ const ACCESSOR_RE = /^\s*(get|set|is)[A-Z]\w*\s*\(/;
16816
+ const DATA_HOLDER_RATIO = 0.8;
16817
+ const DATA_HOLDER_MIN_MEMBERS = 6;
16818
+
16819
+ // Query terms that mean the penalised category IS the target. Read from the
16820
+ // query tokens directly, NOT via detectIntent: that classifier is first-match-
16821
+ // wins over its pattern object, and `debug` precedes `test`, so "fix the failing
16822
+ // test" classifies as debug and never reaches the test branch.
16823
+ const WANTS_TESTS = new Set(['test', 'tests', 'spec', 'specs', 'unit', 'integration', 'e2e', 'assertion', 'assert', 'mock', 'fixture', 'coverage', 'testing']);
16824
+ const WANTS_DOCS = new Set(['doc', 'docs', 'documentation', 'readme', 'changelog', 'guide', 'tutorial']);
16825
+ const WANTS_MODELS = new Set(['entity', 'entities', 'model', 'models', 'pojo', 'dto', 'bean', 'getter', 'getters', 'setter', 'setters', 'accessor', 'accessors', 'field', 'fields', 'column', 'columns', 'schema']);
16826
+
16827
+ /** Which penalised categories the query is explicitly asking for. */
16828
+ function _queryWants(queryTokens) {
16829
+ const wants = { tests: false, docs: false, models: false };
16830
+ for (const t of queryTokens || []) {
16831
+ if (WANTS_TESTS.has(t)) wants.tests = true;
16832
+ if (WANTS_DOCS.has(t)) wants.docs = true;
16833
+ if (WANTS_MODELS.has(t)) wants.models = true;
16834
+ }
16835
+ return wants;
16836
+ }
16837
+
16838
+ /**
16839
+ * True when a file's members are overwhelmingly trivial accessors — a generated
16840
+ * entity or POJO rather than logic. Type declarations are excluded from the
16841
+ * ratio so a small class is not misjudged by its own `class X` line.
16842
+ */
16843
+ function _isDataHolder(sigs) {
16844
+ if (!Array.isArray(sigs)) return false;
16845
+ const members = sigs.filter((line) => /^\s/.test(line) || !/^(class|interface|enum|struct|function|module\.exports)\b/.test(line));
16846
+ if (members.length < DATA_HOLDER_MIN_MEMBERS) return false;
16847
+ const accessors = members.filter((line) => ACCESSOR_RE.test(line)).length;
16848
+ return accessors / members.length >= DATA_HOLDER_RATIO;
16849
+ }
16850
+
16851
+ function _computePenalty(filePath, wants, sigs) {
16577
16852
  const pathLower = filePath.toLowerCase();
16578
16853
  if (pathLower.includes('node_modules')) return PENALTY_SIGNALS.nodeModules;
16579
- if (/(^|\/)(test|tests|spec|__tests__|e2e)($|\/)/.test(pathLower)) return PENALTY_SIGNALS.testFile;
16854
+ // A penalty must never fire on the very thing the user asked for. Before
16855
+ // this, "write tests for the ranker" multiplied every test file by 0.4 —
16856
+ // the query and the penalty were pulling in opposite directions.
16857
+ if (/(^|\/)(test|tests|spec|__tests__|e2e)($|\/)/.test(pathLower) || /\.(test|spec)\./.test(pathLower)) {
16858
+ return (wants && wants.tests) ? 1.0 : PENALTY_SIGNALS.testFile;
16859
+ }
16580
16860
  if (/(^|\/)(dist|build|\.next|\.nuxt|out|\.venv|venv)($|\/)/.test(pathLower)) return PENALTY_SIGNALS.generatedCode;
16581
- if (/(^|\/)(docs|doc|readme|changelog)($|\/)/.test(pathLower)) return PENALTY_SIGNALS.docsFile;
16861
+ if (/(^|\/)(docs|doc|readme|changelog)($|\/)/.test(pathLower)) {
16862
+ return (wants && wants.docs) ? 1.0 : PENALTY_SIGNALS.docsFile;
16863
+ }
16864
+ // Content-based, and last: a data holder is still a real source file, so it
16865
+ // is only demoted once the path-based categories have had their say.
16866
+ if (_isDataHolder(sigs)) {
16867
+ return (wants && wants.models) ? 1.0 : PENALTY_SIGNALS.dataHolder;
16868
+ }
16582
16869
  return 1.0;
16583
16870
  }
16584
16871
 
@@ -16597,6 +16884,26 @@ __factories["./src/retrieval/ranker"] = function(module, exports) {
16597
16884
  }
16598
16885
 
16599
16886
  // Common utility paths that should be treated as hubs regardless of fanout
16887
+ // The graph builders disagree on key case: src/graph/builder.js lowercases every
16888
+ // node (normalizePath), while src/graph/call-graph.js keys by a case-preserving
16889
+ // path.resolve. Assuming either one breaks the other, so every graph lookup in
16890
+ // this file probes both forms — the same thing the centrality blend already does.
16891
+ function _graphKeys(p) {
16892
+ const norm = require('path').normalize(p);
16893
+ const lower = norm.toLowerCase();
16894
+ return lower === norm ? [norm] : [norm, lower];
16895
+ }
16896
+ function _graphGet(map, absPath) {
16897
+ for (const k of _graphKeys(absPath)) {
16898
+ const hit = map.get(k);
16899
+ if (hit !== undefined) return hit;
16900
+ }
16901
+ return undefined;
16902
+ }
16903
+ function _registerKeys(map, absPath, value) {
16904
+ for (const k of _graphKeys(absPath)) if (!map.has(k)) map.set(k, value);
16905
+ }
16906
+
16600
16907
  function _isHub(filePath) {
16601
16908
  return /\/(utils|helpers|shared|common|constants|types|interfaces|index|zzz|globals)\.(ts|tsx|js|jsx|r|R)$/.test(filePath)
16602
16909
  || filePath.endsWith('/index.ts') || filePath.endsWith('/index.js')
@@ -16612,14 +16919,18 @@ __factories["./src/retrieval/ranker"] = function(module, exports) {
16612
16919
  * @param {object} weights
16613
16920
  * @returns {{ score: number, signals: { exactToken: number, symbolMatch: number, prefixMatch: number, pathMatch: number, penalty: number } }}
16614
16921
  */
16615
- function scoreFile(filePath, sigs, queryTokens, weights) {
16922
+ function scoreFile(filePath, sigs, queryTokens, weights, wants) {
16616
16923
  if (!sigs || sigs.length === 0) return { score: 0, signals: { exactToken: 0, symbolMatch: 0, prefixMatch: 0, pathMatch: 0, penalty: 1.0 } };
16617
16924
 
16618
16925
  const w = weights || DEFAULT_WEIGHTS;
16619
- const signals = { exactToken: 0, symbolMatch: 0, prefixMatch: 0, pathMatch: 0, penalty: _computePenalty(filePath) };
16620
-
16621
- // Build token set from all signatures
16622
- const sigText = sigs.join(' ');
16926
+ const signals = { exactToken: 0, symbolMatch: 0, prefixMatch: 0, pathMatch: 0, penalty: _computePenalty(filePath, wants, sigs) };
16927
+
16928
+ // Module-doc prose is excluded here on purpose. This signal measures overlap
16929
+ // with DECLARED IDENTIFIERS; prose relevance is BM25's job, where it is scored
16930
+ // as its own weighted field. Letting descriptive text inflate the identifier
16931
+ // signal double-counts it and measurably degraded MRR.
16932
+ const codeSigs = sigs.filter((line) => !MODULE_DOC_RE.test(line));
16933
+ const sigText = codeSigs.join(' ');
16623
16934
  const sigTokenSet = new Set(tokenize(sigText));
16624
16935
 
16625
16936
  // Build token set from the file path
@@ -16637,7 +16948,7 @@ __factories["./src/retrieval/ranker"] = function(module, exports) {
16637
16948
  signals.exactToken += bonus;
16638
16949
 
16639
16950
  // Bonus: appears directly in a function/class/method name line
16640
- const nameLineMatch = sigs.some((sig) => {
16951
+ const nameLineMatch = codeSigs.some((sig) => {
16641
16952
  const nt = tokenize(sig.replace(/[^a-zA-Z0-9_\s]/g, ' '));
16642
16953
  return nt.includes(qt);
16643
16954
  });
@@ -16699,13 +17010,18 @@ __factories["./src/retrieval/ranker"] = function(module, exports) {
16699
17010
  const graph = (opts && opts.graph && opts.graph.forward instanceof Map) ? opts.graph : null;
16700
17011
  const cwd = (opts && opts.cwd) || null;
16701
17012
 
16702
- // Detect query intent and get appropriate weights
17013
+ // Intent is reported to the user and shapes output depth; it no longer
17014
+ // selects scoring weights (see SIGNAL_BLEND).
16703
17015
  const intent = detectIntent(query);
16704
- const intentWeights = INTENT_WEIGHTS[intent] || DEFAULT_WEIGHTS;
16705
- const weights = (opts && opts.weights) ? Object.assign({}, intentWeights, opts.weights) : intentWeights;
16706
- const learnedWeights = opts && opts.cwd ? loadWeights(opts.cwd) : null;
17016
+ const weights = (opts && opts.weights) ? Object.assign({}, DEFAULT_WEIGHTS, opts.weights) : DEFAULT_WEIGHTS;
17017
+ // Learned per-file multipliers are a LOCAL, evolving signal (.context/weights.json).
17018
+ // Benchmarks and CI gates must opt out via { learned: false }, or a developer's
17019
+ // local learned state silently changes the score and CI stops being reproducible.
17020
+ const useLearned = !(opts && opts.learned === false);
17021
+ const learnedWeights = opts && opts.cwd && useLearned ? loadWeights(opts.cwd) : null;
16707
17022
 
16708
17023
  const queryTokens = tokenize(query);
17024
+ const queryWants = _queryWants(queryTokens);
16709
17025
  if (queryTokens.length === 0) {
16710
17026
  // Empty query: return top-K by file count (most signatures = most useful)
16711
17027
  const all = [];
@@ -16722,18 +17038,32 @@ __factories["./src/retrieval/ranker"] = function(module, exports) {
16722
17038
  // are matched. The existing negative-signal penalty and recency/graph/learned
16723
17039
  // boosts are layered on top; the per-token signals stay for the explain table.
16724
17040
  const bm25Scores = new Map();
16725
- for (const c of bm25rank(query, [...sigIndex.entries()].map(([file, sigs]) => ({ file, sigs })))) {
17041
+ for (const c of bm25rank(query, [...sigIndex.entries()].map(([file, sigs]) => ({ file, sigs })), opts)) {
16726
17042
  bm25Scores.set(c.file, c.score);
16727
17043
  }
16728
17044
 
16729
- const scored = [];
17045
+ // Two passes: scoreFile's weighted signal needs the max across the corpus to
17046
+ // normalise against, so collect first, then combine.
17047
+ const prescored = [];
17048
+ let maxSignal = 0;
16730
17049
  for (const [file, sigs] of sigIndex.entries()) {
16731
- const result = scoreFile(file, sigs, queryTokens, weights);
17050
+ const result = scoreFile(file, sigs, queryTokens, weights, queryWants);
17051
+ if (result.score > maxSignal) maxSignal = result.score;
17052
+ prescored.push({ file, sigs, result });
17053
+ }
17054
+
17055
+ const scored = [];
17056
+ for (const { file, sigs, result } of prescored) {
16732
17057
  const penalty = result.signals.penalty;
16733
17058
  const base = bm25Scores.get(file) || 0;
16734
- let score = base * penalty;
17059
+ // Blend the weighted keyword/symbol/path signal into the BM25 base. This
17060
+ // was previously computed and thrown away — `result.score` was never read,
17061
+ // which silently made DEFAULT_WEIGHTS and every intent profile dead config.
17062
+ const signalNorm = maxSignal > 0 ? result.score / maxSignal : 0;
17063
+ let score = base * penalty * (1 + SIGNAL_BLEND * signalNorm);
16735
17064
  const signals = result.signals;
16736
17065
  signals.bm25 = base;
17066
+ signals.signalBlend = signalNorm;
16737
17067
 
16738
17068
  // Recency boost
16739
17069
  if (recencySet && recencySet.has(file) && score > 0) {
@@ -16763,44 +17093,46 @@ __factories["./src/retrieval/ranker"] = function(module, exports) {
16763
17093
  // Hub suppression: files with high fanout (>20%) are not boosted
16764
17094
  if (graph && cwd) {
16765
17095
  const path = require('path');
16766
- // Build maps for relative absolute path conversion and index lookup
16767
- const relToIdx = new Map();
16768
- const absToRel = new Map();
17096
+ // Every graph node is keyed by `path.normalize(p).toLowerCase()` (see
17097
+ // normalizePath in src/graph/builder.js and src/graph/call-graph.js).
17098
+ // Lookups MUST use the same key space: a bare path.resolve() preserves
17099
+ // case, so on any repo whose absolute path contains an uppercase letter
17100
+ // every .get() missed and this entire block was silently inert.
17101
+ const keyToIdx = new Map();
16769
17102
  for (let i = 0; i < scored.length; i++) {
16770
- relToIdx.set(scored[i].file, i);
16771
- const abs = path.resolve(cwd, scored[i].file);
16772
- absToRel.set(abs, scored[i].file);
17103
+ _registerKeys(keyToIdx, path.resolve(cwd, scored[i].file), i);
16773
17104
  }
16774
17105
 
16775
17106
  const hubs = _computeHubs(graph);
16776
- const hop1Files = new Set(); // track which files received hop1 boost
17107
+ const hop1Files = new Set(); // normalised keys that received a hop1 boost
17108
+ const hop1Seeds = []; // original (un-normalised) paths, for hop-2 lookup
16777
17109
 
16778
17110
  // Hop 1: direct neighbors of scored files
16779
17111
  for (const entry of scored) {
16780
17112
  if (entry.score <= 0) continue;
16781
- const abs = path.resolve(cwd, entry.file);
16782
- const neighbors = graph.forward.get(abs) || [];
17113
+ const neighbors = _graphGet(graph.forward, path.resolve(cwd, entry.file)) || [];
16783
17114
  for (const neighborAbs of neighbors) {
16784
- if (_isHub(neighborAbs) || hubs.has(neighborAbs)) continue;
16785
- const neighborRel = path.relative(cwd, neighborAbs).replace(/\\/g, '/');
16786
- const idx = relToIdx.get(neighborRel);
17115
+ const nk = path.normalize(neighborAbs);
17116
+ if (_isHub(nk) || hubs.has(nk) || hubs.has(nk.toLowerCase())) continue;
17117
+ const idx = _graphGet(keyToIdx, nk);
16787
17118
  if (idx !== undefined) {
16788
17119
  scored[idx].score += GRAPH_BOOST_AMOUNTS.hop1;
16789
17120
  scored[idx].signals.graphBoost = (scored[idx].signals.graphBoost || 0) + GRAPH_BOOST_AMOUNTS.hop1;
16790
- hop1Files.add(neighborAbs);
17121
+ hop1Files.add(nk);
17122
+ hop1Seeds.push(neighborAbs);
16791
17123
  }
16792
17124
  }
16793
17125
  }
16794
17126
 
16795
17127
  // Hop 2: neighbors of hop1 files (only if they didn't get a direct score)
16796
- for (const hop1File of hop1Files) {
16797
- if (!absToRel.has(hop1File)) continue; // skip files not in index
16798
- const neighbors = graph.forward.get(hop1File) || [];
17128
+ for (const hop1Key of hop1Seeds) {
17129
+ if (_graphGet(keyToIdx, hop1Key) === undefined) continue; // skip files not in index
17130
+ const neighbors = _graphGet(graph.forward, hop1Key) || [];
16799
17131
  for (const neighborAbs of neighbors) {
16800
- if (_isHub(neighborAbs) || hubs.has(neighborAbs)) continue;
16801
- if (hop1Files.has(neighborAbs)) continue; // skip already hop1-boosted
16802
- const neighborRel = path.relative(cwd, neighborAbs).replace(/\\/g, '/');
16803
- const idx = relToIdx.get(neighborRel);
17132
+ const nk = path.normalize(neighborAbs);
17133
+ if (_isHub(nk) || hubs.has(nk) || hubs.has(nk.toLowerCase())) continue;
17134
+ if (hop1Files.has(nk)) continue; // skip already hop1-boosted
17135
+ const idx = _graphGet(keyToIdx, nk);
16804
17136
  if (idx !== undefined && scored[idx].score > 0) {
16805
17137
  // Only boost files that have some baseline score (not noise)
16806
17138
  scored[idx].score += GRAPH_BOOST_AMOUNTS.hop2;
@@ -16817,16 +17149,17 @@ __factories["./src/retrieval/ranker"] = function(module, exports) {
16817
17149
  const callGraph = (opts && opts.callGraph && opts.callGraph.forward instanceof Map) ? opts.callGraph : null;
16818
17150
  if (callGraph && cwd) {
16819
17151
  const path = require('path');
16820
- const relToIdx = new Map();
16821
- for (let i = 0; i < scored.length; i++) relToIdx.set(scored[i].file, i);
17152
+ // buildCallFileGraph keys by a CASE-PRESERVING path.resolve, unlike the
17153
+ // import graph builder which lowercases hence the dual-form probe.
17154
+ const keyToIdx = new Map();
17155
+ for (let i = 0; i < scored.length; i++) _registerKeys(keyToIdx, path.resolve(cwd, scored[i].file), i);
16822
17156
  const hubs = _computeHubs(callGraph);
16823
17157
  const seeds = scored.filter((e) => e.score > 0).map((e) => e.file);
16824
17158
  for (const file of seeds) {
16825
- const abs = path.resolve(cwd, file);
16826
- for (const neighborAbs of (callGraph.forward.get(abs) || [])) {
16827
- if (_isHub(neighborAbs) || hubs.has(neighborAbs)) continue;
16828
- const neighborRel = path.relative(cwd, neighborAbs).replace(/\\/g, '/');
16829
- const idx = relToIdx.get(neighborRel);
17159
+ for (const neighborAbs of (_graphGet(callGraph.forward, path.resolve(cwd, file)) || [])) {
17160
+ const nk = path.normalize(neighborAbs);
17161
+ if (_isHub(nk) || hubs.has(nk) || hubs.has(nk.toLowerCase())) continue;
17162
+ const idx = _graphGet(keyToIdx, nk);
16830
17163
  if (idx !== undefined && scored[idx].file !== file) {
16831
17164
  scored[idx].score += GRAPH_BOOST_AMOUNTS.callHop;
16832
17165
  scored[idx].signals.callGraphBoost = (scored[idx].signals.callGraphBoost || 0) + GRAPH_BOOST_AMOUNTS.callHop;
@@ -17000,6 +17333,18 @@ __factories["./src/retrieval/ranker"] = function(module, exports) {
17000
17333
  }
17001
17334
  } catch (_) {}
17002
17335
  _mergeSigIndex(index, _buildSigIndexFromCache(cwd));
17336
+
17337
+ // The complete retrieval index (written by generate before applyTokenBudget)
17338
+ // takes precedence: it is the only source containing files the budget dropped,
17339
+ // and full signatures for files the budget collapsed to line anchors. It is
17340
+ // merged as the BASE rather than on top because _mergeSigIndex only replaces
17341
+ // when the source has MORE signatures — a collapsed entry has the same count
17342
+ // as its full form, so merging the other way would keep the anchors.
17343
+ try {
17344
+ const full = __require('./src/retrieval/sig-index-store').readFullIndex(cwd);
17345
+ if (full.size > 0) return _mergeSigIndex(full, index);
17346
+ } catch (_) { /* absent → budgeted view is still served */ }
17347
+
17003
17348
  return index;
17004
17349
  }
17005
17350
 
@@ -17125,25 +17470,155 @@ __factories["./src/retrieval/ranker"] = function(module, exports) {
17125
17470
  // ---------------------------------------------------------------------------
17126
17471
  // Intent detection — 7 intents
17127
17472
  // ---------------------------------------------------------------------------
17473
+ // Nouns carry an optional plural: `\btest\b` does not match "tests", so
17474
+ // "write unit tests for the ranker" matched NO intent at all and fell through
17475
+ // to the 'search' default.
17128
17476
  const INTENT_PATTERNS = {
17129
- debug: /\b(bug|fix|error|crash|exception|broken|failing|issue|problem|regression)\b/i,
17477
+ debug: /\b(bugs?|fix(es|ed)?|errors?|crash(es)?|exceptions?|broken|failing|failures?|issues?|problems?|regressions?)\b/i,
17130
17478
  explain: /\b(explain|how does|what is|understand|overview|architecture|describe|walk me|teach)\b/i,
17131
- refactor: /\b(refactor|restructure|redesign|clean up|extract|move|rename|simplify|optimize)\b/i,
17479
+ refactor: /\b(refactor|restructure|redesign|clean up|extract|move|rename|simplify|optimi[sz]e)\b/i,
17132
17480
  review: /\b(review|check|audit|security|pr|pull request|assess|validate)\b/i,
17133
- test: /\b(test|unit test|integration test|testing|spec|assert|mock)\b/i,
17134
- integrate:/\b(import|integrate|connect|wire|bind|require|export|depend|graph)\b|require[ds]\b/i,
17481
+ test: /\b(tests?|unit tests?|integration tests?|testing|specs?|assert(ion)?s?|mocks?|fixtures?)\b/i,
17482
+ integrate:/\b(imports?|integrate|connect|wire|bind|requires?|exports?|depends?|dependenc(y|ies)|graph)\b/i,
17135
17483
  navigate: /\b(find|locate|where|search|look for|show me|navigate|browse|list)\b/i,
17136
17484
  };
17137
17485
 
17138
- function detectIntent(query) {
17139
- if (!query || typeof query !== 'string') return 'search';
17486
+ /**
17487
+ * Every intent whose pattern matches, strongest first.
17488
+ *
17489
+ * A real request is routinely multi-intent — "fix the failing test" is both a
17490
+ * debug task and a test task — and reporting one label discards that. Worse,
17491
+ * the single-label version returned the FIRST key in INTENT_PATTERNS order, so
17492
+ * `debug` permanently shadowed `test`: no query containing "fix" or "failing"
17493
+ * could ever be labelled a test, no matter how test-shaped it was.
17494
+ *
17495
+ * Ranked by how many distinct terms each pattern matched, so the dominant
17496
+ * intent leads; ties fall back to declaration order for determinism.
17497
+ *
17498
+ * @param {string} query
17499
+ * @returns {string[]} matched intents, never empty (defaults to ['search'])
17500
+ */
17501
+ function detectIntents(query) {
17502
+ if (!query || typeof query !== 'string') return ['search'];
17503
+ const scored = [];
17504
+ let order = 0;
17140
17505
  for (const [intent, re] of Object.entries(INTENT_PATTERNS)) {
17141
- if (re.test(query)) return intent;
17506
+ const hits = query.match(new RegExp(re.source, re.flags.includes('g') ? re.flags : re.flags + 'g'));
17507
+ if (hits && hits.length) {
17508
+ scored.push({ intent, hits: new Set(hits.map((h) => h.toLowerCase())).size, order: order });
17509
+ }
17510
+ order++;
17511
+ }
17512
+ if (scored.length === 0) return ['search'];
17513
+ scored.sort((a, b) => (b.hits - a.hits) || (a.order - b.order));
17514
+ return scored.map((s) => s.intent);
17515
+ }
17516
+
17517
+ /** Primary intent. Kept for callers that want a single label. */
17518
+ function detectIntent(query) {
17519
+ return detectIntents(query)[0];
17520
+ }
17521
+
17522
+ module.exports = { rank, buildSigIndex, scoreFile, _queryWants, _isDataHolder, detectIntents, formatRankTable, formatRankJSON, DEFAULT_WEIGHTS, GRAPH_BOOST_AMOUNTS, CENTRALITY_BLEND_WEIGHT, detectIntent };
17523
+
17524
+ };
17525
+
17526
+ // ── ./src/retrieval/sig-index-store ──
17527
+ __factories["./src/retrieval/sig-index-store"] = function(module, exports) {
17528
+
17529
+ /**
17530
+ * Complete, unbudgeted signature index for retrieval.
17531
+ *
17532
+ * WHY THIS EXISTS
17533
+ * ---------------
17534
+ * The generated context file (CLAUDE.md / AGENTS.md / copilot-instructions.md)
17535
+ * is a BUDGETED VIEW: `applyTokenBudget` drops and collapses files so the
17536
+ * artifact stays under `maxTokens`, because it is injected into every prompt.
17537
+ *
17538
+ * `buildSigIndex` used to parse that same artifact to build the ranker's index,
17539
+ * so retrieval inherited the prompt budget. Every file the budget dropped became
17540
+ * permanently unreachable by `sigmap ask` — no ranking change can surface a file
17541
+ * that is not in the index. On this repo that was 53 of 155 source files (34%),
17542
+ * and restoring them moved hit@5 from 50% to 90% on the retrieval corpus.
17543
+ *
17544
+ * The two artifacts have opposite requirements — the prompt file wants to be
17545
+ * small, the index wants to be complete — so they are now separate. This store
17546
+ * is written by `generate` BEFORE the budget is applied, and lives under
17547
+ * `.context/` (gitignored, never injected into a prompt).
17548
+ *
17549
+ * Zero-dependency, bundle-safe (fs + path only).
17550
+ */
17551
+
17552
+ const fs = require('fs');
17553
+ const path = require('path');
17554
+
17555
+ const INDEX_DIR = '.context';
17556
+ const INDEX_FILE = 'sig-index.json';
17557
+ const SCHEMA = 1;
17558
+
17559
+ /** Absolute path to the retrieval index artifact. */
17560
+ function indexPath(cwd) {
17561
+ return path.join(cwd, INDEX_DIR, INDEX_FILE);
17562
+ }
17563
+
17564
+ /**
17565
+ * Persist the complete signature index.
17566
+ *
17567
+ * @param {string} cwd
17568
+ * @param {Array<{filePath: string, sigs: string[]}>} fileEntries - every
17569
+ * extracted entry, BEFORE applyTokenBudget has dropped or collapsed any.
17570
+ * @param {{ version?: string }} [opts]
17571
+ * @returns {{ path: string, files: number }}
17572
+ */
17573
+ function writeFullIndex(cwd, fileEntries, opts = {}) {
17574
+ const files = {};
17575
+ let count = 0;
17576
+ for (const e of fileEntries || []) {
17577
+ if (!e || !e.filePath || !Array.isArray(e.sigs) || e.sigs.length === 0) continue;
17578
+ const rel = path.relative(cwd, e.filePath).replace(/\\/g, '/');
17579
+ if (!rel || rel.startsWith('..')) continue;
17580
+ files[rel] = e.sigs;
17581
+ count++;
17142
17582
  }
17143
- return 'search';
17583
+
17584
+ const out = indexPath(cwd);
17585
+ fs.mkdirSync(path.dirname(out), { recursive: true });
17586
+ // Write-then-rename so a concurrent `ask` never observes a half-written index.
17587
+ const tmp = `${out}.tmp`;
17588
+ fs.writeFileSync(tmp, JSON.stringify({
17589
+ schema: SCHEMA,
17590
+ sigmapVersion: opts.version || null,
17591
+ generated: new Date().toISOString(),
17592
+ files,
17593
+ }), 'utf8');
17594
+ fs.renameSync(tmp, out);
17595
+ return { path: out, files: count };
17596
+ }
17597
+
17598
+ /**
17599
+ * Load the complete signature index, or an empty Map when absent/unreadable.
17600
+ *
17601
+ * Deliberately NOT version-busted (unlike .sigmap-cache.json): a stale but
17602
+ * complete index still retrieves the right files, whereas discarding it drops
17603
+ * retrieval back to the budgeted view — the exact failure this store exists to
17604
+ * prevent. Staleness is handled by re-running generate or by cache/freshen.
17605
+ *
17606
+ * @param {string} cwd
17607
+ * @returns {Map<string, string[]>}
17608
+ */
17609
+ function readFullIndex(cwd) {
17610
+ const index = new Map();
17611
+ try {
17612
+ const data = JSON.parse(fs.readFileSync(indexPath(cwd), 'utf8'));
17613
+ if (!data || data.schema !== SCHEMA || !data.files) return index;
17614
+ for (const [rel, sigs] of Object.entries(data.files)) {
17615
+ if (Array.isArray(sigs) && sigs.length > 0) index.set(rel, sigs);
17616
+ }
17617
+ } catch (_) { /* absent or corrupt → caller falls back to the context file */ }
17618
+ return index;
17144
17619
  }
17145
17620
 
17146
- module.exports = { rank, buildSigIndex, scoreFile, formatRankTable, formatRankJSON, DEFAULT_WEIGHTS, GRAPH_BOOST_AMOUNTS, CENTRALITY_BLEND_WEIGHT, detectIntent };
17621
+ module.exports = { writeFullIndex, readFullIndex, indexPath, SCHEMA, INDEX_FILE };
17147
17622
 
17148
17623
  };
17149
17624
 
@@ -18337,6 +18812,24 @@ __factories["./src/skills/skills"] = function(module, exports) {
18337
18812
  '6. **Watch the budget.** Check the `get_budget` MCP tool or `sigmap budget` (estimates from SigMap\'s local ledger — no LLM calls). Near the budget: summarize-then-drop older context instead of accumulating, and prefer terse output.',
18338
18813
  ].join('\n'),
18339
18814
  },
18815
+ 'sigmap-task': {
18816
+ title: 'SigMap task loop',
18817
+ kind: 'prompt',
18818
+ description: 'Do a coding task grounded in SigMap: look up before reading, edit by line anchor, verify before reporting.',
18819
+ argumentHint: 'the change you want, in plain words',
18820
+ body: [
18821
+ 'Work through these steps **in order**. Do not open any file before step 2.',
18822
+ 'Every command runs from the integrated terminal — do not ask the user to run them for you.',
18823
+ '',
18824
+ '1. **Look up, do not search.** `npx sigmap ask "<the task>"` — this writes `.context/query-context.md`.',
18825
+ '2. **Read the map.** `cat .context/query-context.md`. It ranks the relevant files and lists their signatures with `:start-end` line anchors — a few hundred tokens where the same files read whole are tens of thousands. Say which files it surfaced before continuing. If nothing relevant appears, re-run step 1 with different wording; fall back to search only after two attempts, and say so.',
18826
+ '3. **Open only the anchored ranges.** A signature ending `:425-425` means read line 425, not the whole file. Never read a file in full when you hold an anchor for it.',
18827
+ '4. **Make the change.** Follow the conventions visible in the signatures — same layering, same response wrapper, same annotation style. Add no dependencies.',
18828
+ '5. **Verify before reporting.** Write what you changed to `.sigmap-notes.md`, naming every file by its **full repository-relative path** (a bare filename is reported as fake), then run `npx sigmap verify-ai-output .sigmap-notes.md`. It checks every name against the real index, offline, with no model call. Fix anything it flags and re-run before you reply.',
18829
+ '6. **Refresh the map.** `npx sigmap` — your edits made it stale.',
18830
+ '7. **Report.** The files you changed, the ranges you actually read, the step-1 token count, and the step-5 verify result. Say so if you fell back to searching or if verify flagged something.',
18831
+ ].join('\n'),
18832
+ },
18340
18833
  'sigmap-config-optimizer': {
18341
18834
  title: 'SigMap config optimizer',
18342
18835
  description: 'Playbook for getting a correct SigMap config on any repo: detect with sigmap tune, review the per-change reasons, apply, validate.',
@@ -18361,7 +18854,9 @@ __factories["./src/skills/skills"] = function(module, exports) {
18361
18854
  windsurf: { label: 'Windsurf', parent: ['.windsurf'],
18362
18855
  target: (cwd, skill) => path.join(cwd, '.windsurf', 'rules', `${skill}.md`) },
18363
18856
  copilot: { label: 'GitHub Copilot', parent: ['.github'],
18364
- target: (cwd, skill) => path.join(cwd, '.github', 'instructions', `${skill}.instructions.md`) },
18857
+ target: (cwd, skill) => (SKILLS[skill] && SKILLS[skill].kind === 'prompt'
18858
+ ? path.join(cwd, '.github', 'prompts', `${skill}.prompt.md`)
18859
+ : path.join(cwd, '.github', 'instructions', `${skill}.instructions.md`)) },
18365
18860
  codex: { label: 'Codex CLI (AGENTS.md)', parent: ['AGENTS.md'],
18366
18861
  target: (cwd) => path.join(cwd, 'AGENTS.md'), inject: true },
18367
18862
  };
@@ -18382,6 +18877,10 @@ __factories["./src/skills/skills"] = function(module, exports) {
18382
18877
  return `---\ndescription: ${skill.description}\nalwaysApply: false\n---\n\n${body}`;
18383
18878
  }
18384
18879
  if (client === 'copilot') {
18880
+ if (skill.kind === 'prompt') {
18881
+ return `---\nname: ${skillName}\nagent: 'agent'\ndescription: ${skill.description}\n`
18882
+ + `argument-hint: ${skill.argumentHint}\n---\n\n${body}`;
18883
+ }
18385
18884
  return `---\napplyTo: "**"\n---\n\n${body}`;
18386
18885
  }
18387
18886
  return body; // windsurf: plain markdown
@@ -21235,7 +21734,7 @@ function __tryGit(args, opts = {}) {
21235
21734
  catch (_) { return ''; }
21236
21735
  }
21237
21736
 
21238
- const VERSION = '8.28.1';
21737
+ const VERSION = '8.30.0';
21239
21738
  const MARKER = '\n\n## Auto-generated signatures\n<!-- Updated by gen-context.js -->\n';
21240
21739
 
21241
21740
  function requireSourceOrBundled(key) {
@@ -21372,10 +21871,70 @@ function buildFileList(cwd, config) {
21372
21871
  const found = walkDir(abs, config.exclude, config.maxDepth);
21373
21872
  files.push(...found);
21374
21873
  }
21874
+ files.push(...declaredEntrypoints(cwd, config, files));
21375
21875
  // Deduplicate
21376
21876
  return [...new Set(files)];
21377
21877
  }
21378
21878
 
21879
+ /**
21880
+ * Source files a project declares as its own entrypoints in package.json
21881
+ * (`main` and every `bin` target), when they live outside srcDirs.
21882
+ *
21883
+ * A CLI's entrypoint is usually at the repo root, so a srcDirs of [src] left
21884
+ * it unindexed and therefore unreachable by `sigmap ask` — on this repo that
21885
+ * was gen-context.js, which holds the whole generator pipeline. Declared
21886
+ * entrypoints are load-bearing by definition, so they are always scanned.
21887
+ */
21888
+ function collectTestEntries(cwd, config, existing) {
21889
+ const TEST_ROOTS = ['test', 'tests', '__tests__', 'spec', 'e2e'];
21890
+ const have = new Set((existing || []).map((e) => e.filePath));
21891
+ const out = [];
21892
+ let moduleDocSig = null;
21893
+ try { ({ moduleDocSig } = requireSourceOrBundled('./src/retrieval/module-doc')); } catch (_) {}
21894
+ for (const root of TEST_ROOTS) {
21895
+ const abs = path.join(cwd, root);
21896
+ if (!fs.existsSync(abs)) continue;
21897
+ let files = [];
21898
+ try { files = walkDir(abs, config.exclude, config.maxDepth); } catch (_) { continue; }
21899
+ for (const fp of files) {
21900
+ if (have.has(fp)) continue;
21901
+ let src = '';
21902
+ try { src = fs.readFileSync(fp, 'utf8'); } catch (_) { continue; }
21903
+ let sigs = [];
21904
+ try {
21905
+ const { extractFile } = requireSourceOrBundled('./src/extractors/dispatch');
21906
+ sigs = extractFile(fp, src) || [];
21907
+ } catch (_) { continue; }
21908
+ if (!sigs.length) continue;
21909
+ const doc = moduleDocSig ? moduleDocSig(src, fp) : '';
21910
+ out.push({ filePath: fp, sigs: doc ? [doc, ...sigs] : sigs });
21911
+ }
21912
+ }
21913
+ return out;
21914
+ }
21915
+
21916
+ function declaredEntrypoints(cwd, config, existing) {
21917
+ const out = [];
21918
+ try {
21919
+ const pkg = JSON.parse(fs.readFileSync(path.join(cwd, 'package.json'), 'utf8'));
21920
+ const have = new Set(existing);
21921
+ const refs = [];
21922
+ if (typeof pkg.main === 'string') refs.push(pkg.main);
21923
+ if (typeof pkg.bin === 'string') refs.push(pkg.bin);
21924
+ else if (pkg.bin && typeof pkg.bin === 'object') refs.push(...Object.values(pkg.bin).filter((v) => typeof v === 'string'));
21925
+ for (const ref of refs) {
21926
+ const abs = path.resolve(cwd, ref);
21927
+ if (have.has(abs) || out.includes(abs)) continue;
21928
+ const rel = path.relative(cwd, abs);
21929
+ if (!rel || rel.startsWith('..')) continue; // outside the repo
21930
+ if ((config.exclude || []).some((x) => rel.split(path.sep).includes(x))) continue;
21931
+ if (!fs.existsSync(abs) || !fs.statSync(abs).isFile()) continue;
21932
+ out.push(abs);
21933
+ }
21934
+ } catch (_) { /* no package.json, or unreadable → nothing to add */ }
21935
+ return out;
21936
+ }
21937
+
21379
21938
  // ---------------------------------------------------------------------------
21380
21939
  // Extractor loader (lazy, cached)
21381
21940
  // ---------------------------------------------------------------------------
@@ -22647,6 +23206,60 @@ function runGenerate(cwd, config, reportMode, reportJson = false) {
22647
23206
 
22648
23207
  let result;
22649
23208
  if (!reportMode) {
23209
+ // Retrieval index (complete, unbudgeted) — written BEFORE applyTokenBudget
23210
+ // and before the strategy split, so it holds every extracted file for every
23211
+ // strategy. The generated context file is a budgeted VIEW for prompt
23212
+ // injection; the ranker must not inherit that budget or files dropped to fit
23213
+ // maxTokens become permanently unreachable by `sigmap ask`.
23214
+ try {
23215
+ const __store = requireSourceOrBundled('./src/retrieval/sig-index-store');
23216
+ // Honour --terse: `sigmap ask` renders .context/query-context.md straight
23217
+ // from these signatures, and that IS prompt-bound, so the user's format
23218
+ // choice has to survive into the index. Terse encoding preserves line
23219
+ // anchors byte-exactly, so get_lines still resolves.
23220
+ // Index-only enrichment: a module's leading comment describes its PURPOSE
23221
+ // in prose, which is the vocabulary a behavioural query actually uses.
23222
+ // Signatures carry shape, not intent — `coverageScore(cwd, fileEntries, config)`
23223
+ // shares no token with "what fraction of the repo made it into the output",
23224
+ // but that file's header says exactly that. Added to the retrieval index
23225
+ // ONLY: the prompt artifact is token-budgeted, the index is not.
23226
+ let __entries = fileEntries;
23227
+ try {
23228
+ // TRIED AND REJECTED: also indexing every per-symbol doc sentence
23229
+ // untruncated (src/retrieval/doc-text.js). 39% of extractor doc hints are
23230
+ // cut at 60 chars, so recovering them looked like free vocabulary. It is
23231
+ // not: train hit@5 fell 75.6% -> 73.3% at every docWeight from 0.2 to 1.0,
23232
+ // and the mined corpus never moved off 62.5%. The MODULE HEADER is the
23233
+ // high-signal prose — it states the file's purpose. Per-symbol sentences
23234
+ // describe internal helpers, so they broaden what each file matches
23235
+ // without making any file a better answer.
23236
+ const { moduleDocSig } = requireSourceOrBundled('./src/retrieval/module-doc');
23237
+ __entries = __entries.map((e) => {
23238
+ let src = e.content;
23239
+ if (typeof src !== 'string') { try { src = fs.readFileSync(e.filePath, 'utf8'); } catch (_) { src = ''; } }
23240
+ const doc = moduleDocSig(src, e.filePath);
23241
+ return doc ? Object.assign({}, e, { sigs: [doc, ...e.sigs] }) : e;
23242
+ });
23243
+ } catch (_) { /* enrichment is best-effort */ }
23244
+ if (config && config.terse) {
23245
+ try {
23246
+ const { encodeTerseSigs } = requireSourceOrBundled('./src/format/terse');
23247
+ __entries = fileEntries.map((e) => Object.assign({}, e, { sigs: encodeTerseSigs(e.sigs) }));
23248
+ } catch (_) { /* terse unavailable → index full signatures */ }
23249
+ }
23250
+ // Test files: indexed, never rendered into the prompt. They were the one
23251
+ // whole category `sigmap ask` could not reach at all — srcDirs excludes
23252
+ // them, so "where are the tests for X" had no answer at any rank. The
23253
+ // prompt artifact stays clean because this list never reaches formatOutput.
23254
+ try {
23255
+ __entries = __entries.concat(collectTestEntries(cwd, config, __entries));
23256
+ } catch (_) { /* best-effort */ }
23257
+ const __w = __store.writeFullIndex(cwd, __entries, { version: VERSION });
23258
+ if (process.argv.includes('--verbose')) {
23259
+ console.warn(`[sigmap] retrieval index: ${__w.files} file(s) → ${path.relative(cwd, __w.path)}`);
23260
+ }
23261
+ } catch (_) { /* non-fatal: ranker falls back to parsing the context file */ }
23262
+
22650
23263
  if (strategy === 'per-module') {
22651
23264
  result = runPerModuleStrategy(cwd, configWithBudget, fileEntries, inputTokenTotal);
22652
23265
  } else if (strategy === 'hot-cold') {
@@ -23333,14 +23946,12 @@ function getRawTokenCount(cwd, config) {
23333
23946
  return total;
23334
23947
  }
23335
23948
 
23336
- function getIntentWeights(intent) {
23949
+ // Intent no longer selects scoring weights — the per-intent multipliers were
23950
+ // measured to have zero effect on ranking (see src/retrieval/ranker.js).
23951
+ // Kept as a single call site so the ask handler keeps one weights source.
23952
+ function getIntentWeights(_intent) {
23337
23953
  const { DEFAULT_WEIGHTS } = requireSourceOrBundled('./src/retrieval/ranker');
23338
- const base = Object.assign({}, DEFAULT_WEIGHTS);
23339
- if (intent === 'debug') return Object.assign({}, base, { recencyBoost: base.recencyBoost * 1.5 });
23340
- if (intent === 'explain') return Object.assign({}, base, { symbolMatch: base.symbolMatch * 1.5 });
23341
- if (intent === 'refactor') return Object.assign({}, base, { pathMatch: base.pathMatch * 1.5 });
23342
- if (intent === 'review') return Object.assign({}, base, { exactToken: base.exactToken * 1.3 });
23343
- return base;
23954
+ return Object.assign({}, DEFAULT_WEIGHTS);
23344
23955
  }
23345
23956
 
23346
23957
  function extractQuerySymbols(query) {
@@ -23515,7 +24126,7 @@ function main() {
23515
24126
  process.exit(1);
23516
24127
  }
23517
24128
 
23518
- const { detectIntent, buildSigIndex, rank } = requireSourceOrBundled('./src/retrieval/ranker');
24129
+ const { detectIntent, detectIntents, buildSigIndex, rank } = requireSourceOrBundled('./src/retrieval/ranker');
23519
24130
  const { coverageScore } = requireSourceOrBundled('./src/analysis/coverage-score');
23520
24131
  const { loadSession, saveSession, mergeSessionContext } = requireSourceOrBundled('./src/session/memory');
23521
24132
  const { detectWorkspaces, inferPackage, scopeToPackage } = requireSourceOrBundled('./src/workspace/detector');
@@ -23692,7 +24303,7 @@ function main() {
23692
24303
  console.log([
23693
24304
  bar,
23694
24305
  ` sigmap ask "${query}"`,
23695
- ` Intent : ${intent}`,
24306
+ ` Intent : ${detectIntents(query).join(', ')}`,
23696
24307
  ` Context : ${ctxTok.toLocaleString()} tokens → ${path.relative(cwd, outPath)}`,
23697
24308
  ` Coverage : ${coveragePct}%`,
23698
24309
  ` Risk : ${riskLevel}`,