@merittdev/horus 0.2.2 → 0.2.3

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 +270 -153
  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.2.2" : "dev";
57242
+ var HORUS_VERSION = true ? "0.2.3" : "dev";
57243
57243
  var PINNED_AXON_VERSION = "1.0.7";
57244
57244
  var PINNED_SOURCE_VERSION = PINNED_AXON_VERSION;
57245
57245
 
@@ -77014,6 +77014,8 @@ function detectEntityFields(context, limit = 2) {
77014
77014
  }
77015
77015
  async function reseedFromLogs(hint, input, deps) {
77016
77016
  if (!deps.logs || typeof deps.logs.searchLogs !== "function") return null;
77017
+ if (!deps.code) return null;
77018
+ const code = deps.code;
77017
77019
  try {
77018
77020
  const from = logWindowFrom(input.since);
77019
77021
  const records = await deps.logs.searchLogs({
@@ -77026,7 +77028,7 @@ async function reseedFromLogs(hint, input, deps) {
77026
77028
  if (records.length === 0) return null;
77027
77029
  const candidates = [...new Set(records.map((r) => r.component).filter((c) => !!c))];
77028
77030
  for (const name of candidates) {
77029
- const syms = await deps.code.searchSymbols(name, 5);
77031
+ const syms = await code.searchSymbols(name, 5);
77030
77032
  if (syms.length > 0) {
77031
77033
  const rankedSeeds = rankSeeds(syms, [...new Set(tokenize(name))]);
77032
77034
  const first = records[0];
@@ -77077,20 +77079,21 @@ async function investigate(input, deps) {
77077
77079
  evidence2.push(ev);
77078
77080
  return ev;
77079
77081
  }
77080
- const rawSeeds = await code.searchSymbols(hint, 5);
77082
+ const degradedNoSource = !code;
77083
+ const rawSeeds = code ? await code.searchSymbols(hint, 5) : [];
77081
77084
  const hintTokens = [...new Set(tokenize(hint))];
77082
77085
  const ranked = rankSeeds(rawSeeds, hintTokens);
77083
77086
  let seeds = ranked.map((r) => r.symbol);
77084
77087
  let top = seeds[0];
77085
77088
  let logReseed = null;
77086
- if (!top) {
77089
+ if (!top && !degradedNoSource) {
77087
77090
  logReseed = await reseedFromLogs(hint, input, deps);
77088
77091
  if (logReseed !== null) {
77089
77092
  seeds = logReseed.seeds;
77090
77093
  top = seeds[0];
77091
77094
  }
77092
77095
  }
77093
- if (!top) {
77096
+ if (!top && !degradedNoSource) {
77094
77097
  const report2 = {
77095
77098
  id: globalThis.crypto.randomUUID(),
77096
77099
  input,
@@ -77114,31 +77117,6 @@ async function investigate(input, deps) {
77114
77117
  if (persistedId2) report2.id = persistedId2;
77115
77118
  return report2;
77116
77119
  }
77117
- const label = top.name === "constructor" ? `${(top.id.split(":").pop() ?? "").replace(/\.constructor$/, "") || top.name} (constructor)` : top.name;
77118
- const [ctx, impact, flows] = await Promise.all([
77119
- code.context(top.id),
77120
- code.impact(top.id, 2),
77121
- code.flowsFor(top.id)
77122
- ]);
77123
- const edges = await listQueueEdges(db, { project: input.repo });
77124
- const symbolNames = /* @__PURE__ */ new Set([
77125
- top.name,
77126
- ...ctx.callers.map((s) => s.name),
77127
- ...ctx.callees.map((s) => s.name)
77128
- ]);
77129
- const queueHits = edges.filter((e) => {
77130
- const bySymbol = e.producerSymbol !== null && symbolNames.has(e.producerSymbol) || e.workerSymbol !== null && symbolNames.has(e.workerSymbol);
77131
- const byFile = e.producerFile === top.filePath || e.workerFile === top.filePath;
77132
- return bySymbol || byFile;
77133
- });
77134
- let changes = null;
77135
- if (input.since !== void 0 && looksDiffable(input.since)) {
77136
- try {
77137
- changes = await code.detectChanges({ base: input.since, compare: "HEAD" });
77138
- } catch {
77139
- changes = null;
77140
- }
77141
- }
77142
77120
  let recentChanges;
77143
77121
  if (deps.repoPath && input.since) {
77144
77122
  try {
@@ -77146,62 +77124,97 @@ async function investigate(input, deps) {
77146
77124
  } catch {
77147
77125
  }
77148
77126
  }
77149
- const seedLoc = formatSymbolLocation(top.filePath, top.startLine, top.endLine);
77150
- const seedEv = mkEv(
77151
- "symbol",
77152
- `Seed symbol ${top.name} (${seedLoc})`,
77153
- { symbol: top, snippet: ctx.snippet ?? null },
77154
- top.startLine !== void 0 && top.startLine > 0 ? { symbolId: top.id, file: top.filePath, line: top.startLine } : { symbolId: top.id, file: top.filePath }
77155
- );
77127
+ let label = input.service ?? hint;
77128
+ let seedLoc = "";
77129
+ let ctx = null;
77130
+ let impact = null;
77131
+ let flows = [];
77132
+ let seedEv = null;
77133
+ let impactEv = null;
77156
77134
  const flowEvIds = [];
77157
- for (const flow of flows) {
77158
- const ev = mkEv(
77159
- "flow",
77160
- `Flow "${flow.name}" (${flow.steps.length} step(s))`,
77161
- { flowId: flow.id, name: flow.name, steps: flow.steps.map((s) => s.name) },
77162
- { symbolId: top.id, file: top.filePath }
77163
- );
77164
- flowEvIds.push(ev.id);
77165
- }
77166
- const impactEv = mkEv(
77167
- "impact",
77168
- `Impact of ${top.name}: ${impact.affected} affected symbol(s)`,
77169
- { affected: impact.affected },
77170
- { symbolId: top.id, file: top.filePath }
77171
- );
77135
+ let queueHits = [];
77172
77136
  const queueEvByName = /* @__PURE__ */ new Map();
77173
- for (const edge of queueHits) {
77174
- const producer = edge.producerSymbol ?? "unknown-producer";
77175
- const worker = edge.workerSymbol ?? "unknown-worker";
77176
- const ev = mkEv(
77177
- "queue-edge",
77178
- `Queue "${edge.queueName}": ${producer} -> ${worker}`,
77179
- {
77180
- queueName: edge.queueName,
77181
- producerSymbol: edge.producerSymbol,
77182
- producerFile: edge.producerFile,
77183
- workerSymbol: edge.workerSymbol,
77184
- workerFile: edge.workerFile,
77185
- source: edge.source
77186
- },
77187
- { queueName: edge.queueName }
77188
- );
77189
- const list = queueEvByName.get(edge.queueName) ?? [];
77190
- list.push(ev.id);
77191
- queueEvByName.set(edge.queueName, list);
77192
- }
77137
+ let changes = null;
77193
77138
  let changeEvId = null;
77194
- if (changes) {
77195
- const addedN = changes.added.length;
77196
- const removedN = changes.removed.length;
77197
- const modifiedN = changes.modified.length;
77198
- const ev = mkEv(
77199
- "commit",
77200
- `Change range ${input.since}..HEAD: +${addedN} -${removedN} ~${modifiedN} symbol(s)`,
77201
- { added: addedN, removed: removedN, modified: modifiedN },
77202
- {}
77139
+ if (top && code) {
77140
+ label = top.name === "constructor" ? `${(top.id.split(":").pop() ?? "").replace(/\.constructor$/, "") || top.name} (constructor)` : top.name;
77141
+ [ctx, impact, flows] = await Promise.all([
77142
+ code.context(top.id),
77143
+ code.impact(top.id, 2),
77144
+ code.flowsFor(top.id)
77145
+ ]);
77146
+ const edges = await listQueueEdges(db, { project: input.repo });
77147
+ const symbolNames = /* @__PURE__ */ new Set([
77148
+ top.name,
77149
+ ...ctx.callers.map((s) => s.name),
77150
+ ...ctx.callees.map((s) => s.name)
77151
+ ]);
77152
+ queueHits = edges.filter((e) => {
77153
+ const bySymbol = e.producerSymbol !== null && symbolNames.has(e.producerSymbol) || e.workerSymbol !== null && symbolNames.has(e.workerSymbol);
77154
+ const byFile = e.producerFile === top.filePath || e.workerFile === top.filePath;
77155
+ return bySymbol || byFile;
77156
+ });
77157
+ if (input.since !== void 0 && looksDiffable(input.since)) {
77158
+ try {
77159
+ changes = await code.detectChanges({ base: input.since, compare: "HEAD" });
77160
+ } catch {
77161
+ changes = null;
77162
+ }
77163
+ }
77164
+ seedLoc = formatSymbolLocation(top.filePath, top.startLine, top.endLine);
77165
+ seedEv = mkEv(
77166
+ "symbol",
77167
+ `Seed symbol ${top.name} (${seedLoc})`,
77168
+ { symbol: top, snippet: ctx.snippet ?? null },
77169
+ top.startLine !== void 0 && top.startLine > 0 ? { symbolId: top.id, file: top.filePath, line: top.startLine } : { symbolId: top.id, file: top.filePath }
77203
77170
  );
77204
- changeEvId = ev.id;
77171
+ for (const flow of flows) {
77172
+ const ev = mkEv(
77173
+ "flow",
77174
+ `Flow "${flow.name}" (${flow.steps.length} step(s))`,
77175
+ { flowId: flow.id, name: flow.name, steps: flow.steps.map((s) => s.name) },
77176
+ { symbolId: top.id, file: top.filePath }
77177
+ );
77178
+ flowEvIds.push(ev.id);
77179
+ }
77180
+ impactEv = mkEv(
77181
+ "impact",
77182
+ `Impact of ${top.name}: ${impact.affected} affected symbol(s)`,
77183
+ { affected: impact.affected },
77184
+ { symbolId: top.id, file: top.filePath }
77185
+ );
77186
+ for (const edge of queueHits) {
77187
+ const producer = edge.producerSymbol ?? "unknown-producer";
77188
+ const worker = edge.workerSymbol ?? "unknown-worker";
77189
+ const ev = mkEv(
77190
+ "queue-edge",
77191
+ `Queue "${edge.queueName}": ${producer} -> ${worker}`,
77192
+ {
77193
+ queueName: edge.queueName,
77194
+ producerSymbol: edge.producerSymbol,
77195
+ producerFile: edge.producerFile,
77196
+ workerSymbol: edge.workerSymbol,
77197
+ workerFile: edge.workerFile,
77198
+ source: edge.source
77199
+ },
77200
+ { queueName: edge.queueName }
77201
+ );
77202
+ const list = queueEvByName.get(edge.queueName) ?? [];
77203
+ list.push(ev.id);
77204
+ queueEvByName.set(edge.queueName, list);
77205
+ }
77206
+ if (changes) {
77207
+ const addedN = changes.added.length;
77208
+ const removedN = changes.removed.length;
77209
+ const modifiedN = changes.modified.length;
77210
+ const ev = mkEv(
77211
+ "commit",
77212
+ `Change range ${input.since}..HEAD: +${addedN} -${removedN} ~${modifiedN} symbol(s)`,
77213
+ { added: addedN, removed: removedN, modified: modifiedN },
77214
+ {}
77215
+ );
77216
+ changeEvId = ev.id;
77217
+ }
77205
77218
  }
77206
77219
  if (changes === null && recentChanges !== void 0 && recentChanges.commits.length > 0) {
77207
77220
  const { commits, changedFiles, totalInsertions, totalDeletions } = recentChanges;
@@ -77235,10 +77248,8 @@ async function investigate(input, deps) {
77235
77248
  const from = logWindowFrom(input.since);
77236
77249
  analysis = await deps.logs.analyzeErrors({ service: input.service, from });
77237
77250
  logsCollected = true;
77238
- const seedBase = top.filePath.split("/").pop() ?? "";
77239
- const logSeedTerms = [
77240
- .../* @__PURE__ */ new Set([...tokenize(hint), ...tokenize(top.name), ...tokenize(seedBase)])
77241
- ];
77251
+ const seedBase = top ? top.filePath.split("/").pop() ?? "" : "";
77252
+ const logSeedTerms = top ? [.../* @__PURE__ */ new Set([...tokenize(hint), ...tokenize(top.name), ...tokenize(seedBase)])] : [...new Set(tokenize(hint))];
77242
77253
  for (const s of analysis.signatures.slice(0, 15)) {
77243
77254
  const { relevanceClass, relevanceReason } = classifyLogRelevance(
77244
77255
  s.key,
@@ -77486,10 +77497,10 @@ async function investigate(input, deps) {
77486
77497
  }
77487
77498
  }
77488
77499
  let ownershipEstimate = null;
77489
- if (deps.repoPath) {
77500
+ if (deps.repoPath && top && code) {
77490
77501
  try {
77491
77502
  ownershipEstimate = await estimateOwnership(top.name, {
77492
- code: deps.code,
77503
+ code,
77493
77504
  repoPath: deps.repoPath,
77494
77505
  symbol: top
77495
77506
  });
@@ -77515,14 +77526,16 @@ async function investigate(input, deps) {
77515
77526
  const validated = validateHypotheses(hyps, evidence2);
77516
77527
  const causeChains = buildCauseChains(validated, evidence2, graph, label);
77517
77528
  const findings2 = [];
77518
- findings2.push({
77519
- kind: "observation",
77520
- title: `Seed resolves to ${label} at ${seedLoc}`,
77521
- detail: top.signature ?? void 0,
77522
- confidence: 1,
77523
- evidenceIds: [seedEv.id]
77524
- });
77525
- if (logReseed !== null) {
77529
+ if (top && seedEv) {
77530
+ findings2.push({
77531
+ kind: "observation",
77532
+ title: `Seed resolves to ${label} at ${seedLoc}`,
77533
+ detail: top.signature ?? void 0,
77534
+ confidence: 1,
77535
+ evidenceIds: [seedEv.id]
77536
+ });
77537
+ }
77538
+ if (logReseed !== null && seedEv) {
77526
77539
  findings2.push({
77527
77540
  kind: "observation",
77528
77541
  title: logReseed.note,
@@ -77531,7 +77544,7 @@ async function investigate(input, deps) {
77531
77544
  evidenceIds: [seedEv.id]
77532
77545
  });
77533
77546
  }
77534
- if (ranked.length > 1) {
77547
+ if (ranked.length > 1 && seedEv) {
77535
77548
  const candidates = ranked.slice(0, 4).map((r) => `${r.symbol.name} [${r.role}]`).join(", ");
77536
77549
  findings2.push({
77537
77550
  kind: "observation",
@@ -77550,7 +77563,7 @@ async function investigate(input, deps) {
77550
77563
  evidenceIds: flowEvIds
77551
77564
  });
77552
77565
  }
77553
- if (impact.affected > 0) {
77566
+ if (top && impactEv && impact && impact.affected > 0) {
77554
77567
  findings2.push({
77555
77568
  kind: "observation",
77556
77569
  title: `Changing ${top.name} impacts ${impact.affected} symbol(s) (blast radius)`,
@@ -77701,7 +77714,7 @@ async function investigate(input, deps) {
77701
77714
  });
77702
77715
  }
77703
77716
  const causeInputs = [];
77704
- const blastRadius = impact.affected;
77717
+ const blastRadius = impact?.affected ?? 0;
77705
77718
  if (queueRuntimeState !== null) {
77706
77719
  for (const q of queueRuntimeState.queues) {
77707
77720
  const isStarved = q.waiting >= 10 && q.active === 0;
@@ -77730,26 +77743,25 @@ async function investigate(input, deps) {
77730
77743
  id: `cause:queue-path:${queueName}`,
77731
77744
  title: `The ${queueName} processing path (${producer} -> ${worker}) is implicated`,
77732
77745
  category: "queue-path",
77733
- sourceEvidenceIds: [...evIds, impactEv.id],
77746
+ sourceEvidenceIds: [...evIds, ...impactEv ? [impactEv.id] : []],
77734
77747
  baseScore: 0.35,
77735
77748
  metadata: { blastRadius }
77736
77749
  });
77737
77750
  }
77738
77751
  if (changeEvId) {
77739
77752
  const gitChangedFiles = recentChanges?.changedFiles ?? [];
77740
- const seedInChanges = gitChangedFiles.length > 0 && gitChangedFiles.some(
77741
- (f) => f === top.filePath || top.filePath.endsWith(f) || f.endsWith(top.filePath)
77742
- );
77753
+ const seedFile = top?.filePath;
77754
+ const seedInChanges = seedFile !== void 0 && gitChangedFiles.length > 0 && gitChangedFiles.some((f) => f === seedFile || seedFile.endsWith(f) || f.endsWith(seedFile));
77743
77755
  causeInputs.push({
77744
77756
  id: "cause:deployment-regression",
77745
- title: `Recent change to ${top.name} in ${input.since}..HEAD may have introduced the regression`,
77757
+ title: top ? `Recent change to ${top.name} in ${input.since}..HEAD may have introduced the regression` : `Recent changes in ${input.since}..HEAD may have introduced the regression`,
77746
77758
  category: "deployment-regression",
77747
- sourceEvidenceIds: [changeEvId, seedEv.id],
77759
+ sourceEvidenceIds: [changeEvId, ...seedEv ? [seedEv.id] : []],
77748
77760
  baseScore: clamp015((seedInChanges ? 0.45 : 0.25) + (queueHits.length > 0 ? 0.05 : 0)),
77749
77761
  metadata: { blastRadius }
77750
77762
  });
77751
77763
  }
77752
- if (impact.affected > 0) {
77764
+ if (top && impactEv && seedEv && impact && impact.affected > 0) {
77753
77765
  causeInputs.push({
77754
77766
  id: "cause:blast-radius",
77755
77767
  title: `${top.name} sits on a high-fan-out path (${impact.affected} affected) and may propagate the fault`,
@@ -77812,13 +77824,20 @@ async function investigate(input, deps) {
77812
77824
  );
77813
77825
  const seedResolved = seeds.length > 0 ? 1 : 0;
77814
77826
  const confidence = clamp015(0.5 * evidenceConfidence + 0.5 * seedResolved);
77815
- const area = ctx.community?.name ?? top.filePath;
77827
+ const area = ctx?.community?.name ?? top?.filePath ?? (input.service ?? "runtime evidence");
77816
77828
  const topCause = rankedCauses[0];
77817
- const summary = topCause ? `Investigation of "${hint}" resolved to ${label} (${area}). Top suspected cause: ${topCause.title}.` : `Investigation of "${hint}" resolved to ${label} (${area}). No dominant suspected cause emerged from the available structural evidence.`;
77829
+ const banner = degradedNoSource ? "Runtime-only (source intelligence unavailable). " : "";
77830
+ const scope = top ? `resolved to ${label} (${area})` : `over runtime evidence (${area})`;
77831
+ const summary = topCause ? `${banner}Investigation of "${hint}" ${scope}. Top suspected cause: ${topCause.title}.` : `${banner}Investigation of "${hint}" ${scope}. No dominant suspected cause emerged from the available ${degradedNoSource ? "runtime" : "structural"} evidence.`;
77818
77832
  const nextActions = buildNextActions(top, ctx, impact, queueHits, changes, input);
77833
+ if (degradedNoSource) {
77834
+ nextActions.unshift(
77835
+ "Source intelligence was unavailable \u2014 run `horus index` to enable code-aware analysis, then re-run for a full investigation."
77836
+ );
77837
+ }
77819
77838
  if (ownershipEstimate?.likelyMaintainer) {
77820
77839
  nextActions.unshift(
77821
- `Route to likely maintainer: ${ownershipEstimate.likelyMaintainer} (${Math.round(ownershipEstimate.maintainerShare * 100)}% of commits to ${ownershipEstimate.file ?? top.filePath})`
77840
+ `Route to likely maintainer: ${ownershipEstimate.likelyMaintainer} (${Math.round(ownershipEstimate.maintainerShare * 100)}% of commits to ${ownershipEstimate.file ?? top?.filePath ?? "the implicated file"})`
77822
77841
  );
77823
77842
  }
77824
77843
  const report = {
@@ -77839,7 +77858,8 @@ async function investigate(input, deps) {
77839
77858
  nextActions,
77840
77859
  ownership: ownershipEstimate,
77841
77860
  causeChains: causeChains.length > 0 ? causeChains : void 0,
77842
- ...recentChanges !== void 0 ? { recentChanges } : {}
77861
+ ...recentChanges !== void 0 ? { recentChanges } : {},
77862
+ ...degradedNoSource ? { degraded: { sourceIntelligence: false, reason: "source-intelligence host unreachable" } } : {}
77843
77863
  };
77844
77864
  const connectorFlags = deps.connectors ? {
77845
77865
  ...deps.connectors,
@@ -77892,16 +77912,20 @@ function buildNextActions(top, _ctx, impact, queueHits, changes, input) {
77892
77912
  actions.push(`Inspect logs for worker ${worker} on queue ${edge.queueName}`);
77893
77913
  actions.push(`Check depth/failures of queue ${edge.queueName}`);
77894
77914
  }
77895
- if (impact.affected > 0) {
77915
+ if (top && impact && impact.affected > 0) {
77896
77916
  actions.push(`Review impact set of ${top.name} (${impact.affected} affected symbol(s))`);
77897
77917
  }
77898
- if (changes && input.since !== void 0) {
77899
- actions.push(`Diff recent commits touching ${top.filePath} in ${input.since}..HEAD`);
77900
- } else {
77901
- actions.push(`Diff recent commits touching ${top.filePath}`);
77918
+ if (top) {
77919
+ if (changes && input.since !== void 0) {
77920
+ actions.push(`Diff recent commits touching ${top.filePath} in ${input.since}..HEAD`);
77921
+ } else {
77922
+ actions.push(`Diff recent commits touching ${top.filePath}`);
77923
+ }
77902
77924
  }
77903
77925
  if (actions.length === 0) {
77904
- actions.push(`Inspect the source of ${top.name} at ${top.filePath}`);
77926
+ actions.push(
77927
+ top ? `Inspect the source of ${top.name} at ${top.filePath}` : input.service ? `Inspect runtime logs and metrics for service "${input.service}"` : "Inspect runtime logs and metrics for the affected service"
77928
+ );
77905
77929
  }
77906
77930
  return actions;
77907
77931
  }
@@ -93796,6 +93820,40 @@ async function listCloudInvestigations(client, cfg) {
93796
93820
  return result.investigations;
93797
93821
  }
93798
93822
 
93823
+ // ../../packages/cli/src/lib/ensure-host.ts
93824
+ init_cjs_shims();
93825
+ function parseHostPort(hostUrl) {
93826
+ try {
93827
+ const port = parseInt(new URL(hostUrl).port, 10);
93828
+ return Number.isNaN(port) ? null : port;
93829
+ } catch {
93830
+ return null;
93831
+ }
93832
+ }
93833
+ function ensureHostReasonHint(reason) {
93834
+ switch (reason) {
93835
+ case "source-unavailable":
93836
+ return "horus-source is not installed \u2014 install it: pip install horus-source";
93837
+ case "not-analyzed":
93838
+ return "this repo has not been indexed yet \u2014 run: horus index";
93839
+ case "bad-url":
93840
+ return "the configured source host URL is not a valid URL";
93841
+ default:
93842
+ return "the host did not become healthy \u2014 run: horus index";
93843
+ }
93844
+ }
93845
+ async function ensureSourceHost(root, hostUrl, opts = {}) {
93846
+ if (await isHostHealthy(hostUrl)) return { ok: true, hostUrl };
93847
+ if (!await sourceAvailable()) return { ok: false, reason: "source-unavailable" };
93848
+ if (!isAnalyzed(root)) return { ok: false, reason: "not-analyzed" };
93849
+ const port = parseHostPort(hostUrl);
93850
+ if (port === null) return { ok: false, reason: "bad-url" };
93851
+ startHost(root, port);
93852
+ if (await waitForHost(hostUrl, opts.timeoutMs ?? 2e4)) return { ok: true, hostUrl };
93853
+ removeSpawnedHostRecord(root);
93854
+ return { ok: false, reason: "unhealthy" };
93855
+ }
93856
+
93799
93857
  // ../../packages/cli/src/commands/investigate.ts
93800
93858
  function extractEvidenceExcerpt(e) {
93801
93859
  if (!e.payload || typeof e.payload !== "object") return void 0;
@@ -93933,15 +93991,27 @@ async function runInvestigate(hint, opts) {
93933
93991
  );
93934
93992
  return 1;
93935
93993
  }
93936
- const health = await code.health();
93937
- if (!health.ok) {
93938
- const sourceUrl = renv.repositories[0]?.sourceHostUrl ?? renv.repositories[0]?.axonHostUrl;
93994
+ const sourceUrl = renv.repositories[0]?.sourceHostUrl ?? renv.repositories[0]?.axonHostUrl;
93995
+ let health = await code.health();
93996
+ if (!health.ok && sourceUrl) {
93939
93997
  console.error(
93940
- import_picocolors8.default.red(
93941
- `Source-intelligence host unreachable for ${renv.project}/${renv.env}` + (sourceUrl ? ` (${sourceUrl}) \u2014 run: horus index` : " \u2014 run: horus index")
93998
+ import_picocolors8.default.yellow(`Source-intelligence host unreachable (${sourceUrl}) \u2014 attempting to start it\u2026`)
93999
+ );
94000
+ const healed = await ensureSourceHost(renv.path, sourceUrl);
94001
+ if (healed.ok) {
94002
+ console.error(import_picocolors8.default.green(`Source-intelligence host is up at ${healed.hostUrl}.`));
94003
+ health = await code.health();
94004
+ } else {
94005
+ console.error(import_picocolors8.default.dim(` ${ensureHostReasonHint(healed.reason)}`));
94006
+ }
94007
+ }
94008
+ const runtimeOnly = !health.ok;
94009
+ if (runtimeOnly) {
94010
+ console.error(
94011
+ import_picocolors8.default.yellow(
94012
+ `Proceeding in runtime-only mode \u2014 no source intelligence. Run ${import_picocolors8.default.bold("horus index")} for a full (code-aware) investigation.`
93942
94013
  )
93943
94014
  );
93944
- return 1;
93945
94015
  }
93946
94016
  const logs = logsForEnv(renv);
93947
94017
  const mongo = mongoForEnv(renv);
@@ -93954,7 +94024,9 @@ async function runInvestigate(hint, opts) {
93954
94024
  const report = await investigate(
93955
94025
  { hint, repo: renv.project, since: opts.since, service },
93956
94026
  {
93957
- code,
94027
+ // Runtime-only degrade (HOR-319 layer-2): pass no source provider so the engine
94028
+ // skips seed resolution + structural evidence and builds from runtime evidence.
94029
+ code: runtimeOnly ? null : code,
93958
94030
  db,
93959
94031
  logs,
93960
94032
  mongo,
@@ -94026,6 +94098,10 @@ async function runInvestigate(hint, opts) {
94026
94098
  return reportCloudError(err);
94027
94099
  }
94028
94100
  }
94101
+ if (format !== "json") {
94102
+ console.log(import_picocolors8.default.dim(`
94103
+ Ask a follow-up: horus ask ${report.id} "<question>"`));
94104
+ }
94029
94105
  } finally {
94030
94106
  await sql2.end();
94031
94107
  if (mongo) await mongo.close();
@@ -94874,10 +94950,27 @@ function renderAnswer(report, directive, opts) {
94874
94950
  }
94875
94951
  return 0;
94876
94952
  }
94953
+ async function lookupLocalInvestigation(id, configPath) {
94954
+ const { db, sql: sql2 } = createDb(await resolveDbUrl(configPath));
94955
+ try {
94956
+ const row = await getInvestigation(db, id);
94957
+ if (!row) return { kind: "not-found" };
94958
+ if (!row.report) return { kind: "no-report" };
94959
+ return { kind: "found", report: migrateReport(row.report) };
94960
+ } catch (err) {
94961
+ const code = typeof err === "object" && err !== null && "code" in err ? String(err.code) : void 0;
94962
+ if (code === "22P02") return { kind: "not-found" };
94963
+ return { kind: "error", message: err instanceof Error ? err.message : String(err) };
94964
+ } finally {
94965
+ await sql2.end();
94966
+ }
94967
+ }
94877
94968
  async function runAsk(id, directive, opts) {
94878
94969
  const repoRoot = repoRootOrCwd();
94879
94970
  const cloudCfg = readCloudConfig(repoRoot);
94880
94971
  if (isCloudActive(cloudCfg)) {
94972
+ const local2 = await lookupLocalInvestigation(id, opts.config);
94973
+ if (local2.kind === "found") return renderAnswer(local2.report, directive, opts);
94881
94974
  const session = authedClient();
94882
94975
  if (!session) {
94883
94976
  console.error(
@@ -94888,47 +94981,40 @@ async function runAsk(id, directive, opts) {
94888
94981
  return 1;
94889
94982
  }
94890
94983
  try {
94891
- const report = await fetchInvestigationReportFromCloud(
94892
- session.client,
94893
- cloudCfg,
94894
- id
94984
+ const report = await fetchInvestigationReportFromCloud(session.client, cloudCfg, id);
94985
+ if (report) return renderAnswer(report, directive, opts);
94986
+ console.error(
94987
+ import_picocolors21.default.red(
94988
+ `Investigation ${id} has no saved report locally or in Horus Cloud. Run ${import_picocolors21.default.bold("horus investigate")} first.`
94989
+ )
94895
94990
  );
94896
- if (!report) {
94991
+ return 1;
94992
+ } catch (err) {
94993
+ if (err instanceof CloudError && err.status === 404) {
94897
94994
  console.error(
94898
94995
  import_picocolors21.default.red(
94899
- `Cloud investigation ${id} has no saved report. Run this investigation with ${import_picocolors21.default.bold("horus investigate")} first.`
94996
+ `No investigation found for "${id}" locally or in Horus Cloud. Use the id printed at the top of \`horus investigate\` output.`
94900
94997
  )
94901
94998
  );
94902
94999
  return 1;
94903
95000
  }
94904
- return renderAnswer(report, directive, opts);
94905
- } catch (err) {
94906
95001
  return reportCloudError(err);
94907
95002
  }
94908
95003
  }
94909
- const { db, sql: sql2 } = createDb(await resolveDbUrl(opts.config));
94910
- try {
94911
- const row = await getInvestigation(db, id);
94912
- if (!row) {
94913
- console.error(import_picocolors21.default.red("No investigation found: " + id));
94914
- return 1;
94915
- }
94916
- if (!row.report) {
95004
+ const local = await lookupLocalInvestigation(id, opts.config);
95005
+ switch (local.kind) {
95006
+ case "found":
95007
+ return renderAnswer(local.report, directive, opts);
95008
+ case "no-report":
94917
95009
  console.error(import_picocolors21.default.red("Investigation " + id + " has no stored report."));
94918
95010
  return 1;
94919
- }
94920
- const report = migrateReport(row.report);
94921
- return renderAnswer(report, directive, opts);
94922
- } catch (err) {
94923
- const code = typeof err === "object" && err !== null && "code" in err ? String(err.code) : void 0;
94924
- if (code === "22P02") {
95011
+ case "error":
95012
+ console.error(import_picocolors21.default.red(local.message));
95013
+ return 1;
95014
+ case "not-found":
95015
+ default:
94925
95016
  console.error(import_picocolors21.default.red("No investigation found: " + id));
94926
95017
  return 1;
94927
- }
94928
- console.error(import_picocolors21.default.red(err instanceof Error ? err.message : String(err)));
94929
- return 1;
94930
- } finally {
94931
- await sql2.end();
94932
95018
  }
94933
95019
  }
94934
95020
 
@@ -95102,6 +95188,14 @@ function levelColor(level, text2) {
95102
95188
  if (level === "warn") return import_picocolors24.default.yellow(text2);
95103
95189
  return import_picocolors24.default.dim(text2);
95104
95190
  }
95191
+ function matchServiceLabel(requested, candidates) {
95192
+ if (candidates.includes(requested)) return { kind: "unique", matched: requested };
95193
+ const needle = requested.toLowerCase();
95194
+ const subs = candidates.filter((c) => c.toLowerCase().includes(needle));
95195
+ if (subs.length === 1 && subs[0] !== void 0) return { kind: "unique", matched: subs[0] };
95196
+ if (subs.length > 1) return { kind: "ambiguous", candidates: subs };
95197
+ return { kind: "none" };
95198
+ }
95105
95199
  async function runLogs(service, opts) {
95106
95200
  try {
95107
95201
  const config2 = await loadConfig(opts.config, { name: opts.name });
@@ -95279,9 +95373,32 @@ async function runLogs(service, opts) {
95279
95373
  if (analysis.signatures.length === 0 && resolvedService !== void 0) {
95280
95374
  const broader = await logs.analyzeErrors({ from, text: opts.grep, broadText: opts.grep !== void 0 });
95281
95375
  if (broader.signatures.length > 0) {
95282
- analysis = broader;
95283
- broadeningNote = `No errors found for service "${resolvedService}" \u2014 showing all services`;
95284
- scopeService = void 0;
95376
+ const match = matchServiceLabel(resolvedService, broader.affectedServices);
95377
+ if (match.kind === "unique" && match.matched !== void 0 && match.matched !== resolvedService) {
95378
+ const scoped = await logs.analyzeErrors({
95379
+ service: match.matched,
95380
+ from,
95381
+ text: opts.grep,
95382
+ broadText: opts.grep !== void 0
95383
+ });
95384
+ if (scoped.signatures.length > 0) {
95385
+ analysis = scoped;
95386
+ scopeService = match.matched;
95387
+ broadeningNote = `No exact match for service "${resolvedService}" \u2014 matched "${match.matched}"`;
95388
+ } else {
95389
+ analysis = broader;
95390
+ scopeService = void 0;
95391
+ broadeningNote = `No errors found for service "${resolvedService}" \u2014 showing all services`;
95392
+ }
95393
+ } else if (match.kind === "ambiguous" && match.candidates !== void 0) {
95394
+ analysis = broader;
95395
+ scopeService = void 0;
95396
+ broadeningNote = `"${resolvedService}" matches multiple services: ${match.candidates.join(", ")} \u2014 showing all; re-run with a full label`;
95397
+ } else {
95398
+ analysis = broader;
95399
+ scopeService = void 0;
95400
+ broadeningNote = `No errors found for service "${resolvedService}" \u2014 showing all services`;
95401
+ }
95285
95402
  }
95286
95403
  }
95287
95404
  if (opts.json) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@merittdev/horus",
3
- "version": "0.2.2",
3
+ "version": "0.2.3",
4
4
  "description": "Local-first, source-aware production-incident investigation engine",
5
5
  "type": "module",
6
6
  "bin": {