@merittdev/horus 0.7.0 → 0.8.1

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.
Files changed (2) hide show
  1. package/dist/index.cjs +354 -52
  2. package/package.json +1 -1
package/dist/index.cjs CHANGED
@@ -65880,8 +65880,8 @@ init_cjs_shims();
65880
65880
 
65881
65881
  // ../../packages/core/src/version.ts
65882
65882
  init_cjs_shims();
65883
- var HORUS_VERSION = true ? "0.7.0" : "dev";
65884
- var PINNED_SOURCE_VERSION = "1.0.7";
65883
+ var HORUS_VERSION = true ? "0.8.1" : "dev";
65884
+ var PINNED_SOURCE_VERSION = "1.4.0";
65885
65885
 
65886
65886
  // ../../packages/core/src/evidence.ts
65887
65887
  init_cjs_shims();
@@ -70979,6 +70979,24 @@ async function getSourceVersion() {
70979
70979
  return null;
70980
70980
  }
70981
70981
  }
70982
+ var SourceVersionMismatchError = class extends Error {
70983
+ constructor(installed, pinned) {
70984
+ super(
70985
+ `horus-source ${installed} is installed but Horus is pinned to ${pinned}. A drifted backend builds a graph this CLI cannot map and can corrupt the index (e.g. duplicate-primary-key failures during "Running initial index"). Install the pinned version: pip install 'horus-source==${pinned}'`
70986
+ );
70987
+ this.installed = installed;
70988
+ this.pinned = pinned;
70989
+ this.name = "SourceVersionMismatchError";
70990
+ }
70991
+ installed;
70992
+ pinned;
70993
+ };
70994
+ async function assertSourceVersionPinned() {
70995
+ const installed = await getSourceVersion();
70996
+ if (installed !== null && installed !== PINNED_SOURCE_VERSION) {
70997
+ throw new SourceVersionMismatchError(installed, PINNED_SOURCE_VERSION);
70998
+ }
70999
+ }
70982
71000
  function isAnalyzed(root) {
70983
71001
  return (0, import_node_fs3.existsSync)((0, import_node_path3.join)(root, ".horus", "source"));
70984
71002
  }
@@ -71001,6 +71019,21 @@ function readSourceHostUrl(root) {
71001
71019
  return null;
71002
71020
  }
71003
71021
  }
71022
+ function readSourceHostPid(root) {
71023
+ const p5 = (0, import_node_path3.join)(root, ".horus", "source", "host.json");
71024
+ if (!(0, import_node_fs3.existsSync)(p5)) return null;
71025
+ try {
71026
+ const j3 = JSON.parse((0, import_node_fs3.readFileSync)(p5, "utf8"));
71027
+ if (typeof j3.pid !== "number" || !Number.isInteger(j3.pid) || j3.pid <= 0) return null;
71028
+ return {
71029
+ pid: j3.pid,
71030
+ port: typeof j3.port === "number" ? j3.port : NaN,
71031
+ repoPath: typeof j3.repo_path === "string" ? j3.repo_path : ""
71032
+ };
71033
+ } catch {
71034
+ return null;
71035
+ }
71036
+ }
71004
71037
  async function isHostHealthy(hostUrl) {
71005
71038
  try {
71006
71039
  const res = await fetch(`${hostUrl}/api/health`, {
@@ -71139,7 +71172,9 @@ var SourceCodeProvider = class {
71139
71172
  const exactSymbols = exactRows.map((row) => ({
71140
71173
  id: String(row[0] ?? ""),
71141
71174
  name: String(row[1] ?? ""),
71142
- filePath: String(row[2] ?? "")
71175
+ filePath: String(row[2] ?? ""),
71176
+ score: 1
71177
+ // an exact-name Cypher hit is authoritative
71143
71178
  }));
71144
71179
  const exactIds = new Set(exactSymbols.map((s5) => s5.id));
71145
71180
  const ql = query.toLowerCase();
@@ -71154,7 +71189,7 @@ var SourceCodeProvider = class {
71154
71189
  semantic.sort((a2, b5) => b5.rank - a2.rank || b5.r.score - a2.r.score);
71155
71190
  const combined = [
71156
71191
  ...exactSymbols,
71157
- ...semantic.map(({ r }) => ({ id: r.nodeId, name: r.name, filePath: r.filePath }))
71192
+ ...semantic.map(({ r }) => ({ id: r.nodeId, name: r.name, filePath: r.filePath, score: r.score }))
71158
71193
  ];
71159
71194
  return this.hydrateLines(combined.slice(0, limit));
71160
71195
  }
@@ -71210,6 +71245,7 @@ var SourceCodeProvider = class {
71210
71245
  const isDead = Boolean(node?.[8]);
71211
71246
  const content = node?.[9];
71212
71247
  const snippet = typeof content === "string" ? content.slice(0, 600) : void 0;
71248
+ const sourceBody = typeof content === "string" ? content.slice(0, 2e4) : void 0;
71213
71249
  const callees = calleeRows.map((row) => this.cypherRowToSymbol(row, 0, 1, 2, 3, 4));
71214
71250
  const callers = callerRows.map((row) => this.cypherRowToSymbol(row, 0, 1, 2, 3, 4));
71215
71251
  const usesType = usesTypeRows.map((row) => this.cypherRowToSymbol(row, 0, 1, 2, 3));
@@ -71236,6 +71272,7 @@ var SourceCodeProvider = class {
71236
71272
  return {
71237
71273
  symbol,
71238
71274
  snippet,
71275
+ ...sourceBody !== void 0 ? { sourceBody } : {},
71239
71276
  callers,
71240
71277
  callees,
71241
71278
  imports,
@@ -71780,8 +71817,12 @@ function shortTs(iso) {
71780
71817
  }
71781
71818
  function buildErrorAnalysisBody(q2, field = "event_code", mapping = MERITT_FIELD_MAPPING) {
71782
71819
  const filters = [];
71783
- const minLevel = q2.level !== void 0 && levelToValue(q2.level) > 50 ? q2.level : "error";
71784
- filters.push(buildLevelFilter(mapping, minLevel));
71820
+ if (q2.eventCode === void 0) {
71821
+ const minLevel = q2.level !== void 0 && levelToValue(q2.level) > 50 ? q2.level : "error";
71822
+ filters.push(buildLevelFilter(mapping, minLevel));
71823
+ } else if (q2.level !== void 0 && levelToValue(q2.level) > 50) {
71824
+ filters.push(buildLevelFilter(mapping, q2.level));
71825
+ }
71785
71826
  if (q2.from !== void 0 || q2.to !== void 0) {
71786
71827
  const rangeClause = {};
71787
71828
  if (q2.from !== void 0) rangeClause["gte"] = q2.from;
@@ -89233,18 +89274,58 @@ async function createLocalDb(opts) {
89233
89274
  } catch {
89234
89275
  }
89235
89276
  assertEmbeddedAssetsPresent();
89236
- const client = new Ve3(dataDir);
89237
- await client.waitReady;
89238
- await applyEmbeddedMigrations(client);
89239
- const db = drizzle2(client, { schema: schema_exports });
89240
- return {
89241
- db,
89242
- sql: {
89243
- end: async () => {
89244
- await client.close();
89277
+ const releaseLock = await acquireDbLock(dataDir);
89278
+ try {
89279
+ const client = new Ve3(dataDir);
89280
+ await client.waitReady;
89281
+ await applyEmbeddedMigrations(client);
89282
+ const db = drizzle2(client, { schema: schema_exports });
89283
+ return {
89284
+ db,
89285
+ sql: {
89286
+ end: async () => {
89287
+ await client.close();
89288
+ releaseLock();
89289
+ }
89245
89290
  }
89246
- }
89291
+ };
89292
+ } catch (e) {
89293
+ releaseLock();
89294
+ throw e;
89295
+ }
89296
+ }
89297
+ async function acquireDbLock(dataDir) {
89298
+ const lockPath = `${dataDir}.lock`;
89299
+ const STALE_MS = 6e4;
89300
+ const TIMEOUT_MS = 3e4;
89301
+ const start2 = Date.now();
89302
+ const noop3 = () => {
89247
89303
  };
89304
+ for (; ; ) {
89305
+ try {
89306
+ const fd = (0, import_node_fs4.openSync)(lockPath, "wx");
89307
+ (0, import_node_fs4.writeSync)(fd, `${process.pid} ${Date.now()}`);
89308
+ (0, import_node_fs4.closeSync)(fd);
89309
+ return () => {
89310
+ try {
89311
+ (0, import_node_fs4.unlinkSync)(lockPath);
89312
+ } catch {
89313
+ }
89314
+ };
89315
+ } catch (e) {
89316
+ if (e.code !== "EEXIST") return noop3;
89317
+ try {
89318
+ if (Date.now() - (0, import_node_fs4.statSync)(lockPath).mtimeMs > STALE_MS) {
89319
+ (0, import_node_fs4.unlinkSync)(lockPath);
89320
+ continue;
89321
+ }
89322
+ } catch {
89323
+ continue;
89324
+ }
89325
+ if (Date.now() - start2 > TIMEOUT_MS) return noop3;
89326
+ await new Promise((r) => setTimeout(r, 100));
89327
+ }
89328
+ }
89248
89329
  }
89249
89330
  var EMBEDDED_PGLITE_ASSETS = ["pglite.wasm", "pglite.data", "initdb.wasm"];
89250
89331
  function assertEmbeddedAssetsPresent() {
@@ -89841,6 +89922,34 @@ function renderReport(r) {
89841
89922
  if (r.input.repo) lines.push(`Repo: ${r.input.repo}`);
89842
89923
  if (r.input.service) lines.push(`Service: ${r.input.service}`);
89843
89924
  if (r.input.since) lines.push(`Since: ${r.input.since}`);
89925
+ if (r.behavioral) {
89926
+ lines.push("");
89927
+ lines.push("## Summary");
89928
+ lines.push(r.summary);
89929
+ lines.push("");
89930
+ lines.push(`## Flow${r.behavioral.entry ? ` \u2014 ${r.behavioral.entry.name}` : ""}`);
89931
+ if (r.behavioral.steps.length === 0) {
89932
+ lines.push("(no execution flow could be reconstructed from the source graph)");
89933
+ } else {
89934
+ r.behavioral.steps.slice(0, 20).forEach((s5, i3) => {
89935
+ lines.push(` ${i3 + 1}. ${s5.name} (${formatSymbolLocation(s5.filePath, s5.startLine, s5.endLine)})`);
89936
+ });
89937
+ if (r.behavioral.steps.length > 20) lines.push(` \u2026 and ${r.behavioral.steps.length - 20} more`);
89938
+ }
89939
+ if (r.behavioral.externalCalls.length > 0 || r.behavioral.persistence.length > 0) lines.push("");
89940
+ if (r.behavioral.externalCalls.length > 0) lines.push(`External calls: ${r.behavioral.externalCalls.join(", ")}`);
89941
+ if (r.behavioral.persistence.length > 0) lines.push(`Persistence: ${r.behavioral.persistence.join(", ")}`);
89942
+ lines.push("");
89943
+ if (r.nextActions.length > 0) {
89944
+ lines.push("## Next actions");
89945
+ for (const a2 of r.nextActions) lines.push(`- ${a2}`);
89946
+ lines.push("");
89947
+ }
89948
+ lines.push(
89949
+ '\u2014 Behavioral walkthrough, not a fault hunt. Phrase it as a symptom (e.g. "X failing") for an incident investigation.'
89950
+ );
89951
+ return lines.join("\n");
89952
+ }
89844
89953
  lines.push(`Confidence: ${r.confidence.toFixed(2)}`);
89845
89954
  const caveat = runtimeSourceCaveat(r);
89846
89955
  if (caveat) lines.push(` \u21B3 ${caveat}`);
@@ -91795,19 +91904,21 @@ function seedRole(s5) {
91795
91904
  if (/util|helper/i.test(hay)) return "util";
91796
91905
  return "code";
91797
91906
  }
91798
- function scoreSeed(s5, index2, hintTokens, changedFiles) {
91907
+ function scoreSeed(s5, index2, hintTokens, changedFiles, hintHasCode) {
91799
91908
  const hay = `${s5.name} ${s5.filePath}`.toLowerCase();
91800
91909
  let score = 0;
91801
- if (PREFER.test(hay)) score += 3;
91910
+ if (PREFER.test(hay)) score += hintHasCode ? 0 : 3;
91802
91911
  if (DEMOTE.test(hay)) score -= 3;
91803
91912
  if (isTypeLikeName(s5.name)) score -= 4;
91804
91913
  if (s5.startLine !== void 0 && s5.endLine !== void 0 && s5.endLine - s5.startLine <= 3) {
91805
91914
  score -= 3;
91806
91915
  }
91807
91916
  if (changedFiles !== void 0 && changedFiles.has(s5.filePath)) score += 5;
91808
- if (/\.(resolver|controller|service)\.[jt]sx?$/i.test(s5.filePath)) score += 2;
91917
+ if (/\.(resolver|controller|service)\.[jt]sx?$/i.test(s5.filePath)) score += hintHasCode ? 0 : 2;
91809
91918
  if (/(^|\/)scripts?\//i.test(s5.filePath)) score -= 2;
91810
- score += Math.max(0, 5 - index2) * 0.1;
91919
+ score += Math.max(0, 5 - index2) * (hintHasCode ? 0.8 : 0.1);
91920
+ if (s5.score !== void 0) score += s5.score * (hintHasCode ? 8 : 1.5);
91921
+ if (/\.[jt]sx?$/i.test(s5.name)) score -= 5;
91811
91922
  if (hintTokens !== void 0 && hintTokens.length > 0) {
91812
91923
  let hintBoost = 0;
91813
91924
  for (const tok of hintTokens) {
@@ -91820,10 +91931,10 @@ function scoreSeed(s5, index2, hintTokens, changedFiles) {
91820
91931
  }
91821
91932
  return score;
91822
91933
  }
91823
- function rankSeeds(seeds, hintTokens, changedFiles) {
91934
+ function rankSeeds(seeds, hintTokens, changedFiles, hintHasCode) {
91824
91935
  return seeds.map((symbol, i3) => ({
91825
91936
  symbol,
91826
- score: scoreSeed(symbol, i3, hintTokens, changedFiles),
91937
+ score: scoreSeed(symbol, i3, hintTokens, changedFiles, hintHasCode),
91827
91938
  role: seedRole(symbol),
91828
91939
  i: i3
91829
91940
  })).sort((a2, b5) => b5.score === a2.score ? a2.i - b5.i : b5.score - a2.score).map(({ symbol, score, role }) => ({ symbol, score, role }));
@@ -92307,6 +92418,51 @@ function looksExplanatory(hint) {
92307
92418
  }
92308
92419
  return false;
92309
92420
  }
92421
+ function looksPerformance(hint) {
92422
+ return /\b(slow|slowness|sluggish|laggy|\blag\b|latency|perf|performance|throughput|degraded|degradation|response\s*time|p9[59])\b/i.test(
92423
+ hint
92424
+ );
92425
+ }
92426
+ function buildBehavioralWalkthrough(hint, top, ctx, flows) {
92427
+ const shortFile = (f5) => f5.replace(/^.*?((?:src|app|lib|packages)\/.*)$/, "$1");
92428
+ const isNoise = (s5) => /logger|logging|\.log\b|telemetry/i.test(s5.filePath) || /^(forcontext|getlogger|child|debug|info|warn|error|log|trace|tracer)$/i.test(s5.name);
92429
+ const flow = flows.slice().sort((a2, b5) => b5.steps.length - a2.steps.length)[0];
92430
+ const rawSteps = flow && flow.steps.length > 1 ? flow.steps : top ? [top, ...ctx?.callees ?? []] : ctx?.callees ?? [];
92431
+ const steps = rawSteps.filter((s5) => !isNoise(s5));
92432
+ const entry2 = steps[0] ?? top ?? null;
92433
+ const dedup = (xs) => {
92434
+ const seen = /* @__PURE__ */ new Set();
92435
+ return xs.filter((s5) => {
92436
+ const k3 = `${s5.name}:${s5.filePath}`;
92437
+ if (seen.has(k3)) return false;
92438
+ seen.add(k3);
92439
+ return true;
92440
+ });
92441
+ };
92442
+ const PERSIST_RE = /(^|[._])(save|insert|upsert|persist|bulkwrite|find|findone|findmany|aggregate|delete|remove)\b|repositor|\brepo\b|\.model|entity|prisma|mongoose|typeorm|knex|\bdao\b|datasource/i;
92443
+ const EXTERNAL_RE = /(^|[._])(request|publish|enqueue|dispatch|emit|notify)\b|http|axios|\bapi\b|webhook|\bqueue\b|processor|producer|gateway|\.client\b/i;
92444
+ const pool = dedup([...steps, ...ctx?.callees ?? []]).filter((s5) => !isNoise(s5));
92445
+ const persistence = dedup(pool.filter((s5) => PERSIST_RE.test(s5.name) || PERSIST_RE.test(s5.filePath))).slice(0, 6).map((s5) => `${s5.name} (${shortFile(s5.filePath)})`);
92446
+ const persistNames = new Set(persistence.map((p5) => p5.split(" (")[0]));
92447
+ const externalCalls = dedup(
92448
+ pool.filter((s5) => (EXTERNAL_RE.test(s5.name) || EXTERNAL_RE.test(s5.filePath)) && !persistNames.has(s5.name))
92449
+ ).slice(0, 6).map((s5) => `${s5.name} (${shortFile(s5.filePath)})`);
92450
+ const lines = [];
92451
+ if (entry2) {
92452
+ const loc = entry2.startLine ? `${shortFile(entry2.filePath)}:${entry2.startLine}` : shortFile(entry2.filePath);
92453
+ lines.push(`Entry point: \`${entry2.name}\` (${loc}).`);
92454
+ }
92455
+ if (steps.length > 1) {
92456
+ const chain = steps.slice(0, 12).map((s5) => s5.name).join(" \u2192 ");
92457
+ lines.push(`Flow: ${chain}${steps.length > 12 ? " \u2192 \u2026" : ""}${flow ? ` (${flow.steps.length} steps)` : ""}.`);
92458
+ } else if (entry2) {
92459
+ lines.push(`No multi-step execution flow was indexed for \`${entry2.name}\` \u2014 showing its direct calls.`);
92460
+ }
92461
+ if (externalCalls.length > 0) lines.push(`Calls out to: ${externalCalls.join(", ")}.`);
92462
+ if (persistence.length > 0) lines.push(`Persists via: ${persistence.join(", ")}.`);
92463
+ if (lines.length === 0) lines.push("No execution flow could be reconstructed for this query from the source graph.");
92464
+ return { question: hint, entry: entry2, steps, externalCalls, persistence, narrative: lines.join("\n") };
92465
+ }
92310
92466
  function formatRegressionCitation(recentChanges, changes, seedFile) {
92311
92467
  const matchesSeed = (f5) => seedFile === void 0 || f5 === seedFile || seedFile.endsWith(f5) || f5.endsWith(seedFile);
92312
92468
  const all = recentChanges?.commits ?? [];
@@ -92366,13 +92522,14 @@ async function investigate(input, deps) {
92366
92522
  const changedFilePaths = recentChanges && recentChanges.changedFiles.length > 0 ? new Set(recentChanges.changedFiles) : void 0;
92367
92523
  const rawSeeds = code ? await code.searchSymbols(hint, 5) : [];
92368
92524
  const hintTokens = [...new Set(tokenize(hint))];
92369
- const ranked = rankSeeds(rawSeeds, hintTokens, changedFilePaths);
92525
+ const hintHasCode = /\b(?=[A-Z0-9_]*[0-9_])[A-Z][A-Z0-9_]{3,}\b/.test(hint);
92526
+ const ranked = rankSeeds(rawSeeds, hintTokens, changedFilePaths, hintHasCode);
92370
92527
  let seeds = ranked.map((r) => r.symbol);
92371
92528
  let top = seeds[0];
92372
92529
  if (top && code && isTypeLikeName(top.name)) {
92373
92530
  const base2 = executableBaseName(top.name);
92374
92531
  if (base2) {
92375
- const altTop = rankSeeds(await code.searchSymbols(base2, 5), hintTokens).find(
92532
+ const altTop = rankSeeds(await code.searchSymbols(base2, 5), hintTokens, void 0, hintHasCode).find(
92376
92533
  (r) => !isTypeLikeName(r.symbol.name)
92377
92534
  )?.symbol;
92378
92535
  if (altTop && altTop.id !== top.id) {
@@ -92432,7 +92589,8 @@ async function investigate(input, deps) {
92432
92589
  "slow"
92433
92590
  ]);
92434
92591
  const meaningfulHintTokens = hintTokens.filter((t2) => !GENERIC_HINT_TOKENS.has(t2));
92435
- const seedIsLowConfidence = top !== void 0 && logReseed === null && meaningfulHintTokens.length > 0 && !meaningfulHintTokens.some((t2) => seedHay.includes(t2));
92592
+ const seedIsLowConfidence = top !== void 0 && logReseed === null && (top.score ?? 0) < 0.5 && // a strong exact-content/colocated source match is authoritative (gap 6)
92593
+ meaningfulHintTokens.length > 0 && !meaningfulHintTokens.some((t2) => seedHay.includes(t2));
92436
92594
  if (!top && !degradedNoSource) {
92437
92595
  const report2 = {
92438
92596
  id: globalThis.crypto.randomUUID(),
@@ -92569,6 +92727,8 @@ async function investigate(input, deps) {
92569
92727
  const directLogEvIds = [];
92570
92728
  const ambientLogEvIds = [];
92571
92729
  const directSignatures = [];
92730
+ const directEvIdByKey = /* @__PURE__ */ new Map();
92731
+ const seedEmittedJoins = [];
92572
92732
  const entityEvIds = [];
92573
92733
  const entitySummaries = [];
92574
92734
  let logsCollected = false;
@@ -92643,13 +92803,17 @@ async function investigate(input, deps) {
92643
92803
  if (relevanceClass === "direct") {
92644
92804
  directLogEvIds.push(ev.id);
92645
92805
  directSignatures.push({ key: s5.key, count: s5.count, message: s5.sampleMessage ?? "" });
92806
+ if (!directEvIdByKey.has(s5.key)) directEvIdByKey.set(s5.key, ev.id);
92646
92807
  } else ambientLogEvIds.push(ev.id);
92647
92808
  }
92648
- const seedSnippet = ctx?.snippet !== void 0 && ctx.snippet !== null ? ctx.snippet : void 0;
92809
+ const seedSnippet = (ctx?.sourceBody !== void 0 && ctx.sourceBody !== null ? ctx.sourceBody : void 0) ?? (ctx?.snippet !== void 0 && ctx.snippet !== null ? ctx.snippet : void 0);
92649
92810
  const sourceCodes = extractEventCodes(input.hint, top?.name, seedSnippet);
92811
+ const seedEmittedCodes = new Set(
92812
+ seedSnippet !== void 0 ? extractEventCodes(seedSnippet) : []
92813
+ );
92650
92814
  const joinedCodes = new Set(directSignatures.map((d3) => d3.key));
92651
92815
  for (const code2 of sourceCodes) {
92652
- if (joinedCodes.has(code2)) continue;
92816
+ const alreadyJoined = joinedCodes.has(code2);
92653
92817
  joinedCodes.add(code2);
92654
92818
  try {
92655
92819
  const codeAnalysis = await deps.logs.analyzeErrors({
@@ -92659,6 +92823,21 @@ async function investigate(input, deps) {
92659
92823
  });
92660
92824
  const sig = codeAnalysis.signatures.find((s5) => s5.key === code2);
92661
92825
  if (sig === void 0 || sig.count <= 0) continue;
92826
+ if (alreadyJoined) {
92827
+ if (seedEmittedCodes.has(code2)) {
92828
+ const existingEvId = directEvIdByKey.get(code2);
92829
+ if (existingEvId !== void 0) {
92830
+ seedEmittedJoins.push({
92831
+ code: code2,
92832
+ count: sig.count,
92833
+ message: sig.sampleMessage ?? "",
92834
+ evId: existingEvId,
92835
+ isNew: sig.isNew ?? false
92836
+ });
92837
+ }
92838
+ }
92839
+ continue;
92840
+ }
92662
92841
  const links = top !== void 0 ? top.startLine !== void 0 && top.startLine > 0 ? { symbolId: top.id, file: top.filePath, line: top.startLine } : { symbolId: top.id, file: top.filePath } : {};
92663
92842
  const ev = mkEv(
92664
92843
  "log",
@@ -92677,7 +92856,8 @@ async function investigate(input, deps) {
92677
92856
  sampleMessage: sig.sampleMessage ?? null,
92678
92857
  relevanceClass: "direct",
92679
92858
  relevanceReason: `exact event_code match to the source signal (${code2})`,
92680
- crossSignalJoin: true
92859
+ crossSignalJoin: true,
92860
+ seedEmitted: seedEmittedCodes.has(code2)
92681
92861
  },
92682
92862
  links,
92683
92863
  sig.lastSeen || void 0,
@@ -92692,6 +92872,16 @@ async function investigate(input, deps) {
92692
92872
  count: sig.count,
92693
92873
  message: sig.sampleMessage ?? ""
92694
92874
  });
92875
+ if (!directEvIdByKey.has(code2)) directEvIdByKey.set(code2, ev.id);
92876
+ if (seedEmittedCodes.has(code2)) {
92877
+ seedEmittedJoins.push({
92878
+ code: code2,
92879
+ count: sig.count,
92880
+ message: sig.sampleMessage ?? "",
92881
+ evId: ev.id,
92882
+ isNew: sig.isNew ?? false
92883
+ });
92884
+ }
92695
92885
  } catch {
92696
92886
  }
92697
92887
  }
@@ -93277,6 +93467,29 @@ async function investigate(input, deps) {
93277
93467
  metadata: { blastRadius }
93278
93468
  });
93279
93469
  }
93470
+ if (seedEmittedJoins.length > 0 && top && seedEv) {
93471
+ const ordered = [...seedEmittedJoins].sort((a2, b5) => b5.count - a2.count);
93472
+ const lead = ordered[0];
93473
+ if (lead !== void 0) {
93474
+ const sourceEvidenceIds = [
93475
+ .../* @__PURE__ */ new Set([...ordered.map((j3) => j3.evId), seedEv.id])
93476
+ ];
93477
+ const volumeBoost = Math.min(0.1, Math.log10(Math.max(1, lead.count)) * 0.025);
93478
+ const baseScore = clamp015(0.6 + volumeBoost);
93479
+ const msgClause = lead.message ? ` \u2014 "${lead.message.replace(/\s+/g, " ").trim().slice(0, 90)}"` : "";
93480
+ causeInputs.push({
93481
+ id: "cause:seed-emitted-error",
93482
+ title: `Runtime error ${lead.code} (${lead.count}x in Elasticsearch) is raised by ${top.name} \u2014 the likely failure${msgClause}`.slice(
93483
+ 0,
93484
+ 220
93485
+ ),
93486
+ category: "error-correlation",
93487
+ sourceEvidenceIds,
93488
+ baseScore,
93489
+ metadata: { blastRadius, seedEmitted: true, code: lead.code, count: lead.count }
93490
+ });
93491
+ }
93492
+ }
93280
93493
  if (analysis !== null && analysis.signatures.length > 0 && queueHits.length > 0 && directLogEvIds.length > 0) {
93281
93494
  const firstQueue = queueHits[0];
93282
93495
  const queueLabel = firstQueue !== void 0 ? `"${firstQueue.queueName}" (${firstQueue.producerSymbol ?? "unknown"} -> ${firstQueue.workerSymbol ?? "unknown"})` : "the queue path";
@@ -93395,16 +93608,34 @@ async function investigate(input, deps) {
93395
93608
  const seedDisclaimer = seedIsLowConfidence && top ? `\u26A0 No symbol closely matched "${hint}" \u2014 "${top.name}" is a low-confidence closest match (semantic). Refine with an exact symbol or error code to target precisely. ` : "";
93396
93609
  let summary = headlineCause ? headlineLinked ? `${seedDisclaimer}${banner2}Investigation of "${hint}" ${scope}. Top suspected cause: ${headlineCause.title}.` : `${seedDisclaimer}${banner2}Investigation of "${hint}" ${scope}. No cause is structurally linked to the seed; the strongest co-occurring signal (a lead to verify, not a diagnosis) is: ${headlineCause.title}.` : `${seedDisclaimer}${banner2}Investigation of "${hint}" ${scope}. No dominant suspected cause emerged from the available ${degradedNoSource ? "runtime" : "structural"} evidence.`;
93397
93610
  const hasIncidentSignal = directLogEvIds.length > 0 || latencyMetricEvIds.length > 0 || queueMetricEvIds.length > 0 || dataAnomalyEvIds.length > 0 || queueFailureSignals.length > 0 || findings2.some((f5) => f5.kind === "anomaly") || headlineCause !== void 0;
93398
- const explanatoryHint = looksExplanatory(hint) && !hasIncidentSignal;
93611
+ const explanatoryHint = looksExplanatory(hint);
93612
+ let behavioral;
93399
93613
  if (explanatoryHint) {
93400
- const explainTarget = top?.name ?? input.service ?? hint;
93401
- summary = `${seedDisclaimer}${banner2}"${hint}" reads as a "how does it work" question and no incident signal was found (no error signatures, anomalies, or failing queues). For a focused answer, run \`horus explain ${explainTarget}\`. ${top ? `Investigated ${label} (${area}); the` : "The"} incident sections below are empty by design \u2014 this is not a fault hunt.`;
93614
+ let flowPool = flows;
93615
+ const richestSeedFlow = flows.slice().sort((a2, b5) => b5.steps.length - a2.steps.length)[0];
93616
+ if (code && (richestSeedFlow === void 0 || richestSeedFlow.steps.length <= 1) && ctx?.callers?.length) {
93617
+ const ENTRY_RE = /controller|resolver|route|handler|command|consumer|listener|gateway|cron|scheduler|webhook|middleware/i;
93618
+ const entryCallers = ctx.callers.filter((c4) => ENTRY_RE.test(c4.name) || ENTRY_RE.test(c4.filePath)).slice(0, 3);
93619
+ for (const c4 of entryCallers) {
93620
+ try {
93621
+ flowPool = flowPool.concat(await code.flowsFor(c4.id));
93622
+ } catch {
93623
+ }
93624
+ }
93625
+ }
93626
+ behavioral = buildBehavioralWalkthrough(hint, top, ctx ?? void 0, flowPool);
93627
+ const noisy = hasIncidentSignal ? ' (note: live error signals also exist in this codebase \u2014 rephrase as a symptom like "X failing" for a fault hunt)' : "";
93628
+ summary = `${seedDisclaimer}${banner2}"${hint}" reads as a "how does it work" question \u2014 here is the code path${noisy}:
93629
+
93630
+ ` + behavioral.narrative;
93631
+ }
93632
+ if (!explanatoryHint && looksPerformance(hint) && deps.metrics == null && seedIsLowConfidence) {
93633
+ summary = `${banner2}"${hint}" reads as a latency/performance question, but no metrics connector (Grafana) is configured \u2014 response-time and error-rate trends can't be assessed from source structure alone${top ? ` (the closest structural match, ${top.name}, is a low-confidence guess, not a diagnosis)` : ""}. Connect a metrics source (\`horus connect grafana\`) and re-run, or name the specific slow operation.`;
93402
93634
  }
93403
93635
  const nextActions = buildNextActions(top, ctx, impact, queueHits, changes, input);
93404
- if (explanatoryHint) {
93405
- const explainTarget = top?.name ?? input.service ?? hint;
93636
+ if (explanatoryHint && behavioral?.entry) {
93406
93637
  nextActions.unshift(
93407
- `This looks like a behavioral question \u2014 run \`horus explain ${explainTarget}\` for a focused walkthrough of how it works.`
93638
+ `Trace the full call graph of the entry point: \`horus explain ${behavioral.entry.name}\`.`
93408
93639
  );
93409
93640
  }
93410
93641
  if (degradedNoSource) {
@@ -93436,6 +93667,7 @@ async function investigate(input, deps) {
93436
93667
  ownership: ownershipEstimate,
93437
93668
  causeChains: causeChains.length > 0 ? causeChains : void 0,
93438
93669
  ...recentChanges !== void 0 ? { recentChanges } : {},
93670
+ ...behavioral !== void 0 ? { behavioral } : {},
93439
93671
  ...degradedNoSource ? { degraded: { sourceIntelligence: false, reason: "source-intelligence host unreachable" } } : {}
93440
93672
  };
93441
93673
  const connectorFlags = deps.connectors ? {
@@ -96906,6 +97138,12 @@ async function runIndex(opts) {
96906
97138
  console.error(import_picocolors3.default.red("horus-source not found on PATH. Install it: pip install horus-source"));
96907
97139
  return 1;
96908
97140
  }
97141
+ try {
97142
+ await assertSourceVersionPinned();
97143
+ } catch (err2) {
97144
+ console.error(import_picocolors3.default.red(` ${err2.message}`));
97145
+ return 1;
97146
+ }
96909
97147
  if (!isAnalyzed(root)) {
96910
97148
  console.log(import_picocolors3.default.dim(" analyzing with source-intelligence backend (first time \u2014 this can take a while)\u2026"));
96911
97149
  try {
@@ -109867,6 +110105,8 @@ function ensureHostReasonHint(reason) {
109867
110105
  switch (reason) {
109868
110106
  case "source-unavailable":
109869
110107
  return "horus-source is not installed \u2014 install it: pip install horus-source";
110108
+ case "version-mismatch":
110109
+ return "the installed horus-source backend does not match the version Horus is pinned to \u2014 reinstall the pinned version (run `horus status` to see it)";
109870
110110
  case "not-analyzed":
109871
110111
  return "this repo has not been indexed yet \u2014 run: horus index";
109872
110112
  case "bad-url":
@@ -109878,6 +110118,11 @@ function ensureHostReasonHint(reason) {
109878
110118
  async function ensureSourceHost(root, hostUrl, opts = {}) {
109879
110119
  if (await isHostHealthy(hostUrl)) return { ok: true, hostUrl };
109880
110120
  if (!await sourceAvailable()) return { ok: false, reason: "source-unavailable" };
110121
+ try {
110122
+ await assertSourceVersionPinned();
110123
+ } catch {
110124
+ return { ok: false, reason: "version-mismatch" };
110125
+ }
109881
110126
  if (!isAnalyzed(root)) return { ok: false, reason: "not-analyzed" };
109882
110127
  const port = parseHostPort(hostUrl);
109883
110128
  if (port === null) return { ok: false, reason: "bad-url" };
@@ -113331,16 +113576,28 @@ async function runStop(opts) {
113331
113576
  }
113332
113577
  }
113333
113578
  async function stopHost(root, hostUrl) {
113334
- const alive = await isHostHealthy(hostUrl);
113335
- if (!alive) {
113336
- console.log(import_picocolors36.default.dim(`Host ${hostUrl} is already stopped.`));
113337
- return 0;
113338
- }
113339
113579
  const port = extractPort(hostUrl);
113340
113580
  if (port === null) {
113341
113581
  console.error(import_picocolors36.default.red(`Cannot determine port from host URL: ${hostUrl}`));
113342
113582
  return 1;
113343
113583
  }
113584
+ const healthy = await isHostHealthy(hostUrl);
113585
+ if (!healthy) {
113586
+ const owned = readSpawnedHost(root);
113587
+ const wrapperAlive = owned !== null && await getProcessInfo(owned.pid) !== null;
113588
+ if (!wrapperAlive) {
113589
+ const res = await stopBackendServerPid(root, port);
113590
+ if (res === "failed") return 1;
113591
+ if (res !== "stopped") console.log(import_picocolors36.default.dim(`Host ${hostUrl} is already stopped.`));
113592
+ await cleanupSpawnedRecord(root);
113593
+ return 0;
113594
+ }
113595
+ console.log(
113596
+ import_picocolors36.default.dim(
113597
+ `Host ${hostUrl} is unreachable but pid ${owned.pid} is still running \u2014 terminating a stuck host.`
113598
+ )
113599
+ );
113600
+ }
113344
113601
  const spawned = readSpawnedHost(root);
113345
113602
  if (spawned === null) {
113346
113603
  console.error(
@@ -113373,21 +113630,18 @@ async function stopHost(root, hostUrl) {
113373
113630
  }
113374
113631
  const info2 = await getProcessInfo(spawned.pid);
113375
113632
  if (info2 === null) {
113376
- console.log(import_picocolors36.default.dim(`Process pid ${spawned.pid} is no longer running \u2014 already stopped.`));
113377
- try {
113378
- await unlinkAsync((0, import_node_path18.join)(root, HORUS_DIR, SPAWNED_HOST_FILE2));
113379
- } catch {
113633
+ const res = await stopBackendServerPid(root, port);
113634
+ if (res === "failed") return 1;
113635
+ if (res !== "stopped") {
113636
+ console.log(import_picocolors36.default.dim(`Process pid ${spawned.pid} is no longer running \u2014 already stopped.`));
113380
113637
  }
113638
+ await cleanupSpawnedRecord(root);
113381
113639
  return 0;
113382
113640
  }
113383
- const portStr = String(port);
113384
- const hostPortRe = new RegExp(
113385
- `(?:^|\\s)(?:\\S*/)?horus-source\\s+host\\s+--port(?:=|\\s+)${portStr}(?=\\s|$)`
113386
- );
113387
- if (!hostPortRe.test(info2.args)) {
113641
+ if (!hostArgvRegex(port).test(info2.args)) {
113388
113642
  console.error(
113389
113643
  import_picocolors36.default.red(
113390
- `Pid ${spawned.pid} args do not match "horus-source host --port ${portStr}". Got: "${info2.args.slice(0, 120)}". Aborting for safety.`
113644
+ `Pid ${spawned.pid} args do not match "horus-source host --port ${port}". Got: "${info2.args.slice(0, 120)}". Aborting for safety.`
113391
113645
  )
113392
113646
  );
113393
113647
  return 1;
@@ -113440,11 +113694,57 @@ async function stopHost(root, hostUrl) {
113440
113694
  `${import_picocolors36.default.green("\u2713")} Stopped source-intelligence host ` + import_picocolors36.default.dim(`(pid ${spawned.pid}, port ${port})`) + ` for ${root}`
113441
113695
  );
113442
113696
  }
113697
+ if (await stopBackendServerPid(root, port) === "failed") return 1;
113698
+ await cleanupSpawnedRecord(root);
113699
+ return 0;
113700
+ }
113701
+ function hostArgvRegex(port) {
113702
+ return new RegExp(
113703
+ `(?:^|\\s)(?:\\S*/)?horus-source\\s+host\\s+--port(?:=|\\s+)${String(port)}(?=\\s|$)`
113704
+ );
113705
+ }
113706
+ async function cleanupSpawnedRecord(root) {
113443
113707
  try {
113444
113708
  await unlinkAsync((0, import_node_path18.join)(root, HORUS_DIR, SPAWNED_HOST_FILE2));
113445
113709
  } catch {
113446
113710
  }
113447
- return 0;
113711
+ }
113712
+ async function stopBackendServerPid(root, port) {
113713
+ const rec = readSourceHostPid(root);
113714
+ if (!rec) return "none";
113715
+ if (Number.isFinite(rec.port) && rec.port !== port) return "none";
113716
+ if (rec.repoPath && rec.repoPath !== root) return "none";
113717
+ const info2 = await getProcessInfo(rec.pid);
113718
+ if (info2 === null) return "none";
113719
+ if (!hostArgvRegex(port).test(info2.args)) {
113720
+ console.error(
113721
+ import_picocolors36.default.red(
113722
+ `Backend host pid ${rec.pid} args do not match "horus-source host --port ${port}". Not signalling \u2014 possible stale source/host.json or PID reuse.`
113723
+ )
113724
+ );
113725
+ return "failed";
113726
+ }
113727
+ try {
113728
+ process.kill(rec.pid, "SIGTERM");
113729
+ } catch (err2) {
113730
+ if (err2.code === "ESRCH") return "stopped";
113731
+ console.error(import_picocolors36.default.red(`Failed to signal pid ${rec.pid}: ${err2.message}`));
113732
+ return "failed";
113733
+ }
113734
+ const deadline = Date.now() + STOP_WAIT_MS;
113735
+ while (Date.now() < deadline) {
113736
+ await sleep(STOP_POLL_MS);
113737
+ if (await getProcessInfo(rec.pid) === null) {
113738
+ console.log(
113739
+ `${import_picocolors36.default.green("\u2713")} Stopped source-intelligence host ` + import_picocolors36.default.dim(`(pid ${rec.pid}, port ${port})`) + ` for ${root}`
113740
+ );
113741
+ return "stopped";
113742
+ }
113743
+ }
113744
+ console.error(
113745
+ import_picocolors36.default.red(`Host pid ${rec.pid} did not exit within ${STOP_WAIT_MS / 1e3}s after SIGTERM.`)
113746
+ );
113747
+ return "failed";
113448
113748
  }
113449
113749
  async function stopAll() {
113450
113750
  const registry2 = readRegistry();
@@ -113458,8 +113758,10 @@ async function stopAll() {
113458
113758
  for (const [name2, entry2] of projects2) {
113459
113759
  const hostUrl = readSourceHostUrl(entry2.root);
113460
113760
  if (!hostUrl) continue;
113461
- const alive = await isHostHealthy(hostUrl);
113462
- if (!alive) continue;
113761
+ const healthy = await isHostHealthy(hostUrl);
113762
+ if (!healthy && readSpawnedHost(entry2.root) === null && readSourceHostPid(entry2.root) === null) {
113763
+ continue;
113764
+ }
113463
113765
  console.log(` Stopping ${import_picocolors36.default.bold(name2)} ${import_picocolors36.default.dim(`(${hostUrl})`)}`);
113464
113766
  const code = await stopHost(entry2.root, hostUrl);
113465
113767
  if (code === 0) stopped++;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@merittdev/horus",
3
- "version": "0.7.0",
3
+ "version": "0.8.1",
4
4
  "description": "Local-first, source-aware production-incident investigation engine",
5
5
  "type": "module",
6
6
  "bin": {