@merittdev/horus 0.3.0 → 0.4.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.
Files changed (2) hide show
  1. package/dist/index.cjs +294 -47
  2. package/package.json +1 -1
package/dist/index.cjs CHANGED
@@ -57239,7 +57239,7 @@ init_cjs_shims();
57239
57239
 
57240
57240
  // ../../packages/core/src/version.ts
57241
57241
  init_cjs_shims();
57242
- var HORUS_VERSION = true ? "0.3.0" : "dev";
57242
+ var HORUS_VERSION = true ? "0.4.0" : "dev";
57243
57243
  var PINNED_SOURCE_VERSION = "1.1.1";
57244
57244
 
57245
57245
  // ../../packages/core/src/evidence.ts
@@ -63804,6 +63804,11 @@ init_cjs_shims();
63804
63804
  function classifyAnomaly(kind, cmp, opts) {
63805
63805
  const spike = opts?.spike ?? 1.5;
63806
63806
  const drop = opts?.drop ?? 0.67;
63807
+ const NEAR_ZERO = 1e-9;
63808
+ if (cmp.baselineAvg <= NEAR_ZERO && cmp.currentAvg > 0) {
63809
+ const floor2 = kind === "queue" ? 1 : kind === "latency" ? 0.05 : kind === "error-rate" ? 5e-3 : 0;
63810
+ if (cmp.currentAvg < floor2) return "none";
63811
+ }
63807
63812
  if (kind === "latency" && cmp.ratio >= spike) return "latency-spike";
63808
63813
  if (kind === "queue" && (cmp.ratio >= spike || cmp.currentAvg > cmp.baselineAvg) && cmp.delta > 0) {
63809
63814
  return "queue-growth";
@@ -63947,10 +63952,10 @@ var GrafanaMetricsProvider = class {
63947
63952
  const queryPanels = async (panels) => {
63948
63953
  const results = [];
63949
63954
  for (const panel of panels) {
63950
- signal?.throwIfAborted();
63955
+ if (signal?.aborted) return results;
63951
63956
  const panelMatchSrc = hint !== void 0 && hint !== "" ? findMatchSource(panel, hint) : null;
63952
63957
  for (const rawExpr of panel.exprs) {
63953
- signal?.throwIfAborted();
63958
+ if (signal?.aborted) return results;
63954
63959
  const expr = sanitizeExpr(rawExpr);
63955
63960
  if (expr === null) continue;
63956
63961
  try {
@@ -63976,7 +63981,7 @@ var GrafanaMetricsProvider = class {
63976
63981
  ).map((f) => ({ ...f, matchSource: panelMatchSrc }));
63977
63982
  results.push(...findings2);
63978
63983
  } catch (err) {
63979
- if (signal?.aborted) throw signal.reason ?? err;
63984
+ if (signal?.aborted) return results;
63980
63985
  continue;
63981
63986
  }
63982
63987
  }
@@ -76655,6 +76660,16 @@ function correlate(evidence2) {
76655
76660
  init_cjs_shims();
76656
76661
  var PREFER = /resolver|controller|\bservice\b|route|router|handler|gateway|processor|worker|consumer|repository|usecase|use-case|endpoint/i;
76657
76662
  var DEMOTE = /\butil|helper|\bscript|backfill|legacy|migration|seeder|fixture|\bmock\b|\btest\b|\bspec\b/i;
76663
+ var TYPE_SUFFIX = /(Result|Input|Response|Payload|Args|Dto|Props|Edge|Connection)$/;
76664
+ var INTERFACE_PREFIX = /^I(?=[A-Z][a-z])/;
76665
+ var TYPE_LIKE_NAME = new RegExp(`(?:${TYPE_SUFFIX.source})|(?:${INTERFACE_PREFIX.source})`);
76666
+ function isTypeLikeName(name) {
76667
+ return TYPE_LIKE_NAME.test(name);
76668
+ }
76669
+ function executableBaseName(name) {
76670
+ const stripped = name.replace(TYPE_SUFFIX, "").replace(INTERFACE_PREFIX, "");
76671
+ return stripped !== name && stripped.length >= 4 ? stripped : null;
76672
+ }
76658
76673
  function seedRole(s) {
76659
76674
  const hay = `${s.name} ${s.filePath}`;
76660
76675
  if (/\.resolver\.|resolver/i.test(hay)) return "resolver";
@@ -76667,11 +76682,16 @@ function seedRole(s) {
76667
76682
  if (/util|helper/i.test(hay)) return "util";
76668
76683
  return "code";
76669
76684
  }
76670
- function scoreSeed(s, index2, hintTokens) {
76685
+ function scoreSeed(s, index2, hintTokens, changedFiles) {
76671
76686
  const hay = `${s.name} ${s.filePath}`.toLowerCase();
76672
76687
  let score = 0;
76673
76688
  if (PREFER.test(hay)) score += 3;
76674
76689
  if (DEMOTE.test(hay)) score -= 3;
76690
+ if (isTypeLikeName(s.name)) score -= 4;
76691
+ if (s.startLine !== void 0 && s.endLine !== void 0 && s.endLine - s.startLine <= 3) {
76692
+ score -= 3;
76693
+ }
76694
+ if (changedFiles !== void 0 && changedFiles.has(s.filePath)) score += 5;
76675
76695
  if (/\.(resolver|controller|service)\.[jt]sx?$/i.test(s.filePath)) score += 2;
76676
76696
  if (/(^|\/)scripts?\//i.test(s.filePath)) score -= 2;
76677
76697
  score += Math.max(0, 5 - index2) * 0.1;
@@ -76687,8 +76707,13 @@ function scoreSeed(s, index2, hintTokens) {
76687
76707
  }
76688
76708
  return score;
76689
76709
  }
76690
- function rankSeeds(seeds, hintTokens) {
76691
- return seeds.map((symbol, i) => ({ symbol, score: scoreSeed(symbol, i, hintTokens), role: seedRole(symbol), i })).sort((a, b2) => b2.score === a.score ? a.i - b2.i : b2.score - a.score).map(({ symbol, score, role }) => ({ symbol, score, role }));
76710
+ function rankSeeds(seeds, hintTokens, changedFiles) {
76711
+ return seeds.map((symbol, i) => ({
76712
+ symbol,
76713
+ score: scoreSeed(symbol, i, hintTokens, changedFiles),
76714
+ role: seedRole(symbol),
76715
+ i
76716
+ })).sort((a, b2) => b2.score === a.score ? a.i - b2.i : b2.score - a.score).map(({ symbol, score, role }) => ({ symbol, score, role }));
76692
76717
  }
76693
76718
 
76694
76719
  // ../../packages/engine/src/normalize.ts
@@ -77022,13 +77047,36 @@ function logWindowFrom(since) {
77022
77047
  return new Date(now - 7 * 864e5).toISOString();
77023
77048
  }
77024
77049
  function classifyLogRelevance(sigKey, sigServices, seedTerms, inputService) {
77025
- if (seedTerms.length === 0) {
77026
- return { relevanceClass: "direct", relevanceReason: "no seed context \u2014 included by default" };
77050
+ const GENERIC_TERMS = /* @__PURE__ */ new Set([
77051
+ "error",
77052
+ "errors",
77053
+ "err",
77054
+ "fail",
77055
+ "failed",
77056
+ "failing",
77057
+ "failure",
77058
+ "exception",
77059
+ "exceptions",
77060
+ "crash",
77061
+ "crashing",
77062
+ "issue",
77063
+ "problem",
77064
+ "slow",
77065
+ "timeout",
77066
+ "broken",
77067
+ "down",
77068
+ "production",
77069
+ "prod",
77070
+ "staging"
77071
+ ]);
77072
+ const domainTerms = seedTerms.filter((t) => !GENERIC_TERMS.has(t));
77073
+ if (domainTerms.length === 0) {
77074
+ return { relevanceClass: "direct", relevanceReason: "no specific seed context \u2014 included by default" };
77027
77075
  }
77028
77076
  const keyTokens = tokenize(sigKey);
77029
77077
  const serviceTokens = sigServices.flatMap((s) => tokenize(s));
77030
77078
  const combined = [...keyTokens, ...serviceTokens];
77031
- const matchingTerms = seedTerms.filter(
77079
+ const matchingTerms = domainTerms.filter(
77032
77080
  (t) => combined.some((c) => c.includes(t) || t.includes(c))
77033
77081
  );
77034
77082
  if (matchingTerms.length > 0) {
@@ -77129,11 +77177,31 @@ async function investigate(input, deps) {
77129
77177
  return ev;
77130
77178
  }
77131
77179
  const degradedNoSource = !code;
77180
+ let recentChanges;
77181
+ if (deps.repoPath && input.since) {
77182
+ try {
77183
+ recentChanges = await collectGitChanges({ repoPath: deps.repoPath, since: input.since });
77184
+ } catch {
77185
+ }
77186
+ }
77187
+ const changedFilePaths = recentChanges && recentChanges.changedFiles.length > 0 ? new Set(recentChanges.changedFiles) : void 0;
77132
77188
  const rawSeeds = code ? await code.searchSymbols(hint, 5) : [];
77133
77189
  const hintTokens = [...new Set(tokenize(hint))];
77134
- const ranked = rankSeeds(rawSeeds, hintTokens);
77190
+ const ranked = rankSeeds(rawSeeds, hintTokens, changedFilePaths);
77135
77191
  let seeds = ranked.map((r) => r.symbol);
77136
77192
  let top = seeds[0];
77193
+ if (top && code && isTypeLikeName(top.name)) {
77194
+ const base2 = executableBaseName(top.name);
77195
+ if (base2) {
77196
+ const altTop = rankSeeds(await code.searchSymbols(base2, 5), hintTokens).find(
77197
+ (r) => !isTypeLikeName(r.symbol.name)
77198
+ )?.symbol;
77199
+ if (altTop && altTop.id !== top.id) {
77200
+ seeds = [altTop, ...seeds.filter((s) => s.id !== altTop.id)];
77201
+ top = altTop;
77202
+ }
77203
+ }
77204
+ }
77137
77205
  let logReseed = null;
77138
77206
  if (!top && !degradedNoSource) {
77139
77207
  logReseed = await reseedFromLogs(hint, input, deps);
@@ -77142,6 +77210,50 @@ async function investigate(input, deps) {
77142
77210
  top = seeds[0];
77143
77211
  }
77144
77212
  }
77213
+ const seedHay = top ? `${top.name} ${top.filePath}`.toLowerCase() : "";
77214
+ const GENERIC_HINT_TOKENS = /* @__PURE__ */ new Set([
77215
+ "service",
77216
+ "services",
77217
+ "controller",
77218
+ "controllers",
77219
+ "resolver",
77220
+ "resolvers",
77221
+ "worker",
77222
+ "workers",
77223
+ "repository",
77224
+ "provider",
77225
+ "module",
77226
+ "manager",
77227
+ "util",
77228
+ "utils",
77229
+ "helper",
77230
+ "handler",
77231
+ "error",
77232
+ "errors",
77233
+ "exception",
77234
+ "exceptions",
77235
+ "fail",
77236
+ "failed",
77237
+ "failing",
77238
+ "failure",
77239
+ "throw",
77240
+ "throwing",
77241
+ "thrown",
77242
+ "fatal",
77243
+ "crash",
77244
+ "crashing",
77245
+ "timeout",
77246
+ "production",
77247
+ "prod",
77248
+ "staging",
77249
+ "application",
77250
+ "issue",
77251
+ "problem",
77252
+ "broken",
77253
+ "slow"
77254
+ ]);
77255
+ const meaningfulHintTokens = hintTokens.filter((t) => !GENERIC_HINT_TOKENS.has(t));
77256
+ const seedIsLowConfidence = top !== void 0 && logReseed === null && meaningfulHintTokens.length > 0 && !meaningfulHintTokens.some((t) => seedHay.includes(t));
77145
77257
  if (!top && !degradedNoSource) {
77146
77258
  const report2 = {
77147
77259
  id: globalThis.crypto.randomUUID(),
@@ -77166,13 +77278,6 @@ async function investigate(input, deps) {
77166
77278
  if (persistedId2) report2.id = persistedId2;
77167
77279
  return report2;
77168
77280
  }
77169
- let recentChanges;
77170
- if (deps.repoPath && input.since) {
77171
- try {
77172
- recentChanges = await collectGitChanges({ repoPath: deps.repoPath, since: input.since });
77173
- } catch {
77174
- }
77175
- }
77176
77281
  let label = input.service ?? hint;
77177
77282
  let seedLoc = "";
77178
77283
  let ctx = null;
@@ -77284,6 +77389,7 @@ async function investigate(input, deps) {
77284
77389
  const logEvIds = [];
77285
77390
  const directLogEvIds = [];
77286
77391
  const ambientLogEvIds = [];
77392
+ const directSignatures = [];
77287
77393
  const entityEvIds = [];
77288
77394
  const entitySummaries = [];
77289
77395
  let logsCollected = false;
@@ -77319,11 +77425,12 @@ async function investigate(input, deps) {
77319
77425
  }
77320
77426
  const svc = s.services.length > 0 ? ` \xB7 ${s.services.slice(0, 3).join(", ")}` : "";
77321
77427
  const tagStr = tags.length > 0 ? ` [${tags.join(", ")}]` : "";
77428
+ const msgStr = s.sampleMessage ? ` \u2014 "${s.sampleMessage.replace(/\s+/g, " ").trim().slice(0, 90)}"` : "";
77322
77429
  const ev = mkEv(
77323
77430
  "log",
77324
- `Error ${s.key}: ${s.count}x (first ${shortTs(s.firstSeen)}, last ${shortTs(s.lastSeen)})${svc}${tagStr}`.slice(
77431
+ `Error ${s.key}: ${s.count}x (first ${shortTs(s.firstSeen)}, last ${shortTs(s.lastSeen)})${svc}${tagStr}${msgStr}`.slice(
77325
77432
  0,
77326
- 180
77433
+ 220
77327
77434
  ),
77328
77435
  {
77329
77436
  signature: s.key,
@@ -77346,8 +77453,10 @@ async function investigate(input, deps) {
77346
77453
  if (s.isNew) ev.isNew = s.isNew;
77347
77454
  if (typeof s.ratio === "number" && Number.isFinite(s.ratio)) ev.ratio = s.ratio;
77348
77455
  logEvIds.push(ev.id);
77349
- if (relevanceClass === "direct") directLogEvIds.push(ev.id);
77350
- else ambientLogEvIds.push(ev.id);
77456
+ if (relevanceClass === "direct") {
77457
+ directLogEvIds.push(ev.id);
77458
+ directSignatures.push({ key: s.key, count: s.count, message: s.sampleMessage ?? "" });
77459
+ } else ambientLogEvIds.push(ev.id);
77351
77460
  }
77352
77461
  if (typeof deps.logs.aggregateErrors === "function") {
77353
77462
  const sigForEntities = analysis.signatures.find(
@@ -77400,6 +77509,8 @@ async function investigate(input, deps) {
77400
77509
  let stateAnalysis = null;
77401
77510
  const stateEvIds = [];
77402
77511
  const stateCollections = /* @__PURE__ */ new Set();
77512
+ const dataAnomalyEvIds = [];
77513
+ const dataAnomalyCollections = [];
77403
77514
  if (deps.mongo) {
77404
77515
  try {
77405
77516
  stateAnalysis = await deps.mongo.analyzeState();
@@ -77408,6 +77519,10 @@ async function investigate(input, deps) {
77408
77519
  const ev = mkEv("state", s.title, s.payload, {}, s.timestamp, s.relevance);
77409
77520
  stateEvIds.push(ev.id);
77410
77521
  stateCollections.add(s.collection);
77522
+ if (s.kind === "anomaly") {
77523
+ dataAnomalyEvIds.push(ev.id);
77524
+ dataAnomalyCollections.push(s.collection);
77525
+ }
77411
77526
  }
77412
77527
  } catch {
77413
77528
  stateAnalysis = null;
@@ -77434,6 +77549,7 @@ async function investigate(input, deps) {
77434
77549
  const queueRuntimeEvIdsByQueue = /* @__PURE__ */ new Map();
77435
77550
  const queueBacklogEvIdsByQueue = /* @__PURE__ */ new Map();
77436
77551
  const queueStarvationEvIdsByQueue = /* @__PURE__ */ new Map();
77552
+ const queueFailureSignals = [];
77437
77553
  if (deps.queue) {
77438
77554
  try {
77439
77555
  const staticNames = [...new Set(queueHits.map((e) => e.queueName))];
@@ -77457,6 +77573,18 @@ async function investigate(input, deps) {
77457
77573
  st.push(ev.id);
77458
77574
  queueStarvationEvIdsByQueue.set(s.queueName, st);
77459
77575
  }
77576
+ if (s.kind === "failed-breakdown") {
77577
+ const p = s.payload;
77578
+ if (typeof p.topReason === "string") {
77579
+ queueFailureSignals.push({
77580
+ reason: p.topReason,
77581
+ queueName: s.queueName,
77582
+ evId: ev.id,
77583
+ count: typeof p.topCount === "number" ? p.topCount : 0,
77584
+ stale: p.topStale === true
77585
+ });
77586
+ }
77587
+ }
77460
77588
  }
77461
77589
  } catch {
77462
77590
  queueRuntimeState = null;
@@ -77507,8 +77635,14 @@ async function investigate(input, deps) {
77507
77635
  const panelLower = f.panelTitle.toLowerCase();
77508
77636
  const labelVals = Object.values(f.labels).map((v) => v.toLowerCase());
77509
77637
  if (f.anomaly === "latency-spike" || f.anomaly === "error-rate-change") {
77510
- const relevant = serviceFilter.length > 0 && (panelLower.includes(serviceFilter) || labelVals.some((v) => v.includes(serviceFilter)));
77511
- if (relevant) latencyMetricEvIds.push(ev.id);
77638
+ const matchesService = serviceFilter.length > 0 && (panelLower.includes(serviceFilter) || labelVals.some((v) => v.includes(serviceFilter)));
77639
+ const hintIsPerf = /\b(latenc|slow|spike|perf|throughput|duration|p9[5-9]|timeout|response\s*time|degrad)/i.test(
77640
+ hint
77641
+ );
77642
+ const matchesHint = serviceFilter.length === 0 && hintIsPerf && hintTokens.some(
77643
+ (t) => t.length >= 4 && (panelLower.includes(t) || labelVals.some((v) => v.includes(t)))
77644
+ );
77645
+ if (matchesService || matchesHint) latencyMetricEvIds.push(ev.id);
77512
77646
  } else if (f.anomaly === "queue-growth") {
77513
77647
  const panelNorm = f.panelTitle.toLowerCase().replace(/[^a-z0-9]+/g, " ").trim();
77514
77648
  const labelNorms = Object.values(f.labels).map(
@@ -77815,10 +77949,11 @@ async function investigate(input, deps) {
77815
77949
  metadata: { blastRadius }
77816
77950
  });
77817
77951
  }
77818
- if (top && impactEv && seedEv && impact && impact.affected > 0) {
77952
+ const MIN_FANOUT_FOR_CAUSE = 3;
77953
+ if (top && impactEv && seedEv && impact && impact.affected >= MIN_FANOUT_FOR_CAUSE) {
77819
77954
  causeInputs.push({
77820
77955
  id: "cause:blast-radius",
77821
- title: `${top.name} sits on a high-fan-out path (${impact.affected} affected) and may propagate the fault`,
77956
+ title: `${top.name} has wide code reach (${impact.affected} dependent symbols) and may propagate the fault`,
77822
77957
  category: "blast-radius",
77823
77958
  sourceEvidenceIds: [impactEv.id, seedEv.id],
77824
77959
  baseScore: clamp015(0.15 + (queueHits.length > 0 ? 0.05 : 0)),
@@ -77828,16 +77963,62 @@ async function investigate(input, deps) {
77828
77963
  if (analysis !== null && analysis.signatures.length > 0 && queueHits.length > 0 && directLogEvIds.length > 0) {
77829
77964
  const firstQueue = queueHits[0];
77830
77965
  const queueLabel = firstQueue !== void 0 ? `"${firstQueue.queueName}" (${firstQueue.producerSymbol ?? "unknown"} -> ${firstQueue.workerSymbol ?? "unknown"})` : "the queue path";
77831
- const topSig = analysis.signatures[0];
77966
+ const directTotal = directSignatures.reduce((sum, d) => sum + d.count, 0);
77967
+ const directTop = directSignatures[0];
77832
77968
  causeInputs.push({
77833
77969
  id: "cause:error-correlation",
77834
- title: `Runtime errors (${analysis.totalErrors}${topSig ? `, top ${topSig.key}` : ""}) correlate with the implicated queue path ${queueLabel}`,
77970
+ title: `Runtime errors (${directTotal}${directTop ? `, top ${directTop.key}` : ""}) directly linked to the implicated queue path ${queueLabel}`,
77835
77971
  category: "error-correlation",
77836
77972
  sourceEvidenceIds: directLogEvIds.slice(0, 3),
77837
77973
  baseScore: 0.3,
77838
77974
  metadata: { blastRadius }
77839
77975
  });
77840
77976
  }
77977
+ const INFRA_RE = /\b(ENOTFOUND|ECONNREFUSED|ETIMEDOUT|EAI_AGAIN|ECONNRESET|getaddrinfo|socket hang up|connection (?:refused|reset|timed out)|network (?:error|timeout)|DNS)\b/i;
77978
+ const infraSig = directSignatures.find((d) => INFRA_RE.test(d.message));
77979
+ const queueHitNames = new Set(queueHits.map((e) => e.queueName.toLowerCase()));
77980
+ const infraQueue = queueFailureSignals.find((q) => {
77981
+ if (!INFRA_RE.test(q.reason)) return false;
77982
+ if (queueHitNames.has(q.queueName.toLowerCase())) return true;
77983
+ const qTokens = new Set(tokenize(q.queueName));
77984
+ return hintTokens.some((t) => t.length >= 3 && qTokens.has(t));
77985
+ });
77986
+ if (infraSig) {
77987
+ causeInputs.push({
77988
+ id: "cause:dependency-failure",
77989
+ title: `Dependency/network failure: ${infraSig.count}x ${infraSig.key} \u2014 "${infraSig.message.replace(/\s+/g, " ").trim().slice(0, 100)}"`,
77990
+ category: "infrastructure",
77991
+ sourceEvidenceIds: directLogEvIds.slice(0, 3),
77992
+ baseScore: 0.5,
77993
+ metadata: { blastRadius }
77994
+ });
77995
+ } else if (infraQueue) {
77996
+ causeInputs.push({
77997
+ id: "cause:dependency-failure",
77998
+ title: `Dependency/network failure on queue ${infraQueue.queueName}: ${infraQueue.count} failed job(s) \u2014 "${infraQueue.reason.replace(/\s+/g, " ").trim().slice(0, 100)}"${infraQueue.stale ? " (stale \u2014 reflects a past run)" : ""}`,
77999
+ category: "infrastructure",
78000
+ sourceEvidenceIds: [infraQueue.evId],
78001
+ // A stale-only queue failure is weaker than a live error, but still the best
78002
+ // explanation — and far better than a co-occurring stale-collection guess.
78003
+ baseScore: infraQueue.stale ? 0.34 : 0.5,
78004
+ metadata: { blastRadius }
78005
+ });
78006
+ }
78007
+ if (dataAnomalyEvIds.length > 0) {
78008
+ const collections = [...new Set(dataAnomalyCollections)];
78009
+ const example = collections[0];
78010
+ causeInputs.push({
78011
+ id: "cause:data-state-anomaly",
78012
+ // Co-occurring, NOT asserted as the root cause: the stale collection is selected by
78013
+ // hint relevance, not proven reachable from the seed's call graph, so claiming it is
78014
+ // the cause mislead on e.g. a Shopify-fulfillment error citing unrelated gaia sync logs.
78015
+ title: `Data-state anomaly (co-occurring): ${collections.length} collection(s) hold records in a failed/stale state${example ? ` (e.g. ${example})` : ""} \u2014 verify whether it relates to this failure`,
78016
+ category: "data-state-anomaly",
78017
+ sourceEvidenceIds: dataAnomalyEvIds.slice(0, 3),
78018
+ baseScore: 0.22,
78019
+ metadata: { blastRadius }
78020
+ });
78021
+ }
77841
78022
  if (latencyMetricEvIds.length > 0) {
77842
78023
  causeInputs.push({
77843
78024
  id: "cause:metric-latency",
@@ -77876,20 +78057,22 @@ async function investigate(input, deps) {
77876
78057
  evidence2,
77877
78058
  ambientLogEvIds.length > 0 ? new Set(ambientLogEvIds) : void 0
77878
78059
  );
77879
- const seedResolved = seeds.length > 0 ? 1 : 0;
78060
+ const seedResolved = seeds.length === 0 ? 0 : seedIsLowConfidence ? 0.3 : 1;
77880
78061
  const confidence = clamp015(0.5 * evidenceConfidence + 0.5 * seedResolved);
77881
78062
  const area = ctx?.community?.name ?? top?.filePath ?? (input.service ?? "runtime evidence");
77882
78063
  const topCause = rankedCauses[0];
78064
+ const headlineCause = topCause && topCause.finalScore >= 0.2 ? topCause : void 0;
77883
78065
  const banner2 = degradedNoSource ? "Runtime-only (source intelligence unavailable). " : "";
77884
78066
  const scope = top ? `resolved to ${label} (${area})` : `over runtime evidence (${area})`;
77885
- const summary = topCause ? `${banner2}Investigation of "${hint}" ${scope}. Top suspected cause: ${topCause.title}.` : `${banner2}Investigation of "${hint}" ${scope}. No dominant suspected cause emerged from the available ${degradedNoSource ? "runtime" : "structural"} evidence.`;
78067
+ 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. ` : "";
78068
+ const summary = headlineCause ? `${seedDisclaimer}${banner2}Investigation of "${hint}" ${scope}. Top suspected cause: ${headlineCause.title}.` : `${seedDisclaimer}${banner2}Investigation of "${hint}" ${scope}. No dominant suspected cause emerged from the available ${degradedNoSource ? "runtime" : "structural"} evidence.`;
77886
78069
  const nextActions = buildNextActions(top, ctx, impact, queueHits, changes, input);
77887
78070
  if (degradedNoSource) {
77888
78071
  nextActions.unshift(
77889
78072
  "Source intelligence was unavailable \u2014 run `horus index` to enable code-aware analysis, then re-run for a full investigation."
77890
78073
  );
77891
78074
  }
77892
- if (ownershipEstimate?.likelyMaintainer) {
78075
+ if (ownershipEstimate?.likelyMaintainer && !seedIsLowConfidence) {
77893
78076
  nextActions.unshift(
77894
78077
  `Route to likely maintainer: ${ownershipEstimate.likelyMaintainer} (${Math.round(ownershipEstimate.maintainerShare * 100)}% of commits to ${ownershipEstimate.file ?? top?.filePath ?? "the implicated file"})`
77895
78078
  );
@@ -77939,6 +78122,9 @@ async function investigate(input, deps) {
77939
78122
  const gapAnalysis = detectMissingEvidence(report, connectorFlags);
77940
78123
  report.gapAnalysis = gapAnalysis;
77941
78124
  report.confidence = Math.min(report.confidence, gapAnalysis.confidenceCeiling);
78125
+ if (seedIsLowConfidence) report.confidence = Math.min(report.confidence, 0.45);
78126
+ const topCauseScore = report.suspectedCauses[0]?.finalScore ?? 0;
78127
+ if (topCauseScore < 0.2) report.confidence = Math.min(report.confidence, 0.6);
77942
78128
  report.nextActions.push(...gapNextActions(gapAnalysis.gaps));
77943
78129
  report.sourceStatus = buildRuntimeSourceStatus(evidence2, connectorFlags);
77944
78130
  const persistedId = await persist(db, input, report);
@@ -79289,14 +79475,17 @@ function refineInvestigation(r, directive) {
79289
79475
  }
79290
79476
  }
79291
79477
  const recognizedList = ALL_TOPICS.join(", ");
79292
- const note2 = "No specific topic directive recognized. Recognized topics: " + recognizedList + '. Example usage: "focus on queue behavior", "ignore deployment changes".';
79478
+ const looksLikeQuestion = /\?|\b(where|which|what|how|why|when|does|do|is|are|can|should)\b/i.test(
79479
+ directive
79480
+ );
79481
+ const note2 = looksLikeQuestion ? `This question can't be answered from the saved evidence \u2014 \`horus ask\` reuses the report and does not run a new source/runtime lookup. For a fresh answer run: horus investigate "${directive.slice(0, 80)}". (To filter the saved evidence instead, use a topic: ${recognizedList}.)` : "No specific topic directive recognized. Recognized topics: " + recognizedList + '. Example usage: "focus on queue behavior", "ignore deployment changes".';
79293
79482
  return {
79294
79483
  directive,
79295
79484
  mode: "none",
79296
79485
  topics,
79297
79486
  hypotheses: r.hypotheses,
79298
79487
  suspectedCauses: r.suspectedCauses,
79299
- evidence: r.evidence,
79488
+ evidence: looksLikeQuestion ? [] : r.evidence,
79300
79489
  note: note2
79301
79490
  };
79302
79491
  }
@@ -93528,10 +93717,11 @@ async function runQueues(name, opts) {
93528
93717
  }
93529
93718
  }
93530
93719
  const rows = await listQueueEdges(db, { project, queueName: name });
93720
+ const showLive = opts.live || name !== void 0 && rows.length === 0;
93531
93721
  if (opts.json) {
93532
93722
  const topology = topologyToJson(buildQueueMap(rows));
93533
93723
  const out = { project: project ?? null, topology };
93534
- if (opts.live) out["live"] = await gatherLiveState(config2, rows, name);
93724
+ if (showLive) out["live"] = await gatherLiveState(config2, rows, name);
93535
93725
  console.log(JSON.stringify(out, null, 2));
93536
93726
  return 0;
93537
93727
  }
@@ -93540,13 +93730,15 @@ async function runQueues(name, opts) {
93540
93730
  );
93541
93731
  console.log("");
93542
93732
  if (rows.length === 0) {
93543
- console.log(import_picocolors7.default.dim(" No queue edges indexed. Run: horus index"));
93733
+ console.log(
93734
+ name !== void 0 ? import_picocolors7.default.dim(` No static topology for "${name}" \u2014 it may be dynamically registered; showing live state below.`) : import_picocolors7.default.dim(" No queue edges indexed. Run: horus index")
93735
+ );
93544
93736
  } else {
93545
93737
  const byQueue = buildQueueMap(rows);
93546
93738
  printTopology(byQueue);
93547
93739
  }
93548
93740
  console.log("");
93549
- if (opts.live) {
93741
+ if (showLive) {
93550
93742
  await runLiveMode(config2, rows, name, opts.ai ? {
93551
93743
  config: opts.config,
93552
93744
  aiModel: opts.aiModel,
@@ -94194,7 +94386,8 @@ async function runFeedbackPrompt(investigationId, horusSeconds) {
94194
94386
  investigationId,
94195
94387
  resolved: parseResolved(a1),
94196
94388
  manualEstimateMinutes: parseManualEstimate(a2),
94197
- horusSeconds
94389
+ horusSeconds,
94390
+ source: "prompt"
94198
94391
  });
94199
94392
  console.log(import_picocolors8.default.dim(" Thanks \u2014 recorded."));
94200
94393
  return true;
@@ -94512,7 +94705,9 @@ async function runInvestigate(hint, opts) {
94512
94705
  if (cloudActive && cloudSession && cloudCfg) {
94513
94706
  try {
94514
94707
  const refs = await uploadInvestigationToCloud(cloudSession.client, cloudCfg, report);
94515
- console.log(import_picocolors9.default.dim(`[cloud] investigation saved: ${refs.investigationId}`));
94708
+ const cloudNote = import_picocolors9.default.dim(`[cloud] investigation saved: ${refs.investigationId}`);
94709
+ if (format === "json") console.error(cloudNote);
94710
+ else console.log(cloudNote);
94516
94711
  } catch (err) {
94517
94712
  return reportCloudError(err);
94518
94713
  }
@@ -98330,6 +98525,27 @@ horus ask <id> "what evidence contradicts the likely root cause?"
98330
98525
  horus score <id>
98331
98526
  \`\`\`
98332
98527
 
98528
+ ## Report whether Horus helped (close the loop)
98529
+
98530
+ Once you have **acted on an investigation and know the outcome** \u2014 you verified
98531
+ whether Horus pointed at the real cause \u2014 record it. This is how Horus measures and
98532
+ improves its own accuracy, and without it the impact signal is blind to agent-driven
98533
+ runs (most of them).
98534
+
98535
+ \`\`\`bash
98536
+ horus feedback <id> --resolved yes # Horus pointed at the real cause
98537
+ horus feedback <id> --resolved partly # useful lead, but not the whole cause
98538
+ horus feedback <id> --resolved no # Horus missed the cause
98539
+ \`\`\`
98540
+
98541
+ It is non-interactive and safe to run unattended. Report **once** per investigation,
98542
+ **after** you confirm the outcome \u2014 never before, and never guess. If you can estimate
98543
+ how long the same debugging would have taken you manually, add it:
98544
+
98545
+ \`\`\`bash
98546
+ horus feedback <id> --resolved yes --manual-estimate-min 120
98547
+ \`\`\`
98548
+
98333
98549
  ## Runtime evidence
98334
98550
 
98335
98551
  Use runtime commands when the question depends on live or recent runtime behavior:
@@ -99541,14 +99757,37 @@ function installCommandTelemetry(program2) {
99541
99757
  // ../../packages/cli/src/commands/feedback.ts
99542
99758
  init_cjs_shims();
99543
99759
  var import_picocolors46 = __toESM(require_picocolors(), 1);
99544
- async function runFeedback(investigationId) {
99545
- if (!process.stdin.isTTY) {
99546
- console.error(import_picocolors46.default.yellow("horus feedback needs an interactive terminal."));
99547
- return 1;
99548
- }
99760
+ async function runFeedback(investigationId, opts = {}) {
99549
99761
  const id = investigationId?.trim();
99550
99762
  if (!id) {
99551
- console.error(import_picocolors46.default.red("Usage: horus feedback <investigationId>"));
99763
+ console.error(import_picocolors46.default.red("Usage: horus feedback <investigationId> [--resolved yes|partly|no]"));
99764
+ return 1;
99765
+ }
99766
+ if (opts.resolved !== void 0) {
99767
+ const resolved = parseResolved(opts.resolved);
99768
+ if (resolved === null) {
99769
+ console.error(import_picocolors46.default.red(`Invalid --resolved "${opts.resolved}". Use: yes | partly | no.`));
99770
+ return 1;
99771
+ }
99772
+ let manualEstimateMinutes = null;
99773
+ if (opts.manualEstimateMin !== void 0) {
99774
+ const n = Number.parseInt(opts.manualEstimateMin, 10);
99775
+ if (!Number.isFinite(n) || n < 0) {
99776
+ console.error(import_picocolors46.default.red(`Invalid --manual-estimate-min "${opts.manualEstimateMin}" (expect a non-negative integer).`));
99777
+ return 1;
99778
+ }
99779
+ manualEstimateMinutes = n;
99780
+ }
99781
+ submitFeedback({ investigationId: id, resolved, manualEstimateMinutes, horusSeconds: null, source: "flag" });
99782
+ console.log(import_picocolors46.default.dim(`Feedback recorded (${resolved}) \u2014 thanks, this helps improve Horus.`));
99783
+ return 0;
99784
+ }
99785
+ if (!process.stdin.isTTY) {
99786
+ console.error(
99787
+ import_picocolors46.default.yellow(
99788
+ "horus feedback needs an interactive terminal \u2014 or pass --resolved yes|partly|no for non-interactive (agent) use."
99789
+ )
99790
+ );
99552
99791
  return 1;
99553
99792
  }
99554
99793
  await runFeedbackPrompt(id, null);
@@ -99976,9 +100215,17 @@ Examples:
99976
100215
  horus cloud sync --dry-run # preview what would upload
99977
100216
  horus cloud sync # confirm, then upload
99978
100217
  horus cloud sync --yes # upload without prompting`);
99979
- program2.command("feedback [investigationId]").description("Leave quick impact feedback on an investigation (helps improve Horus)").action(async (investigationId) => {
99980
- process.exitCode = await runFeedback(investigationId);
99981
- });
100218
+ program2.command("feedback [investigationId]").description("Leave quick impact feedback on an investigation (helps improve Horus)").option(
100219
+ "--resolved <verdict>",
100220
+ "did Horus point at the cause? yes | partly | no \u2014 non-interactive, for agents/scripts"
100221
+ ).option(
100222
+ "--manual-estimate-min <minutes>",
100223
+ "estimated minutes this would have taken manually (optional, pair with --resolved)"
100224
+ ).action(
100225
+ async (investigationId, opts) => {
100226
+ process.exitCode = await runFeedback(investigationId, opts);
100227
+ }
100228
+ );
99982
100229
  const telemetry = program2.command("telemetry").description("Manage anonymous usage telemetry that helps improve Horus");
99983
100230
  telemetry.command("status").description("Show the effective telemetry decision, saved preferences, and install ID").action(async () => {
99984
100231
  process.exitCode = await runTelemetryStatus();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@merittdev/horus",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Local-first, source-aware production-incident investigation engine",
5
5
  "type": "module",
6
6
  "bin": {