@kage-core/kage-graph-mcp 2.5.3 → 2.5.5

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 (3) hide show
  1. package/dist/cli.js +32 -39
  2. package/dist/kernel.js +143 -76
  3. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -708,6 +708,22 @@ async function main() {
708
708
  }
709
709
  return;
710
710
  }
711
+ if (command === "gen-plugin-hooks") {
712
+ // Maintainer tool: regenerate plugin/hooks/* from the setupAgent("claude-code") templates
713
+ // so the plugin and the npm install path ship identical hooks (one source of truth).
714
+ const pluginDir = takeArg(args, "--plugin-dir") ?? (0, node_path_1.join)(process.cwd(), "plugin");
715
+ const result = (0, kernel_js_1.generatePluginHooks)(pluginDir);
716
+ if (args.includes("--json")) {
717
+ console.log(JSON.stringify({ plugin_dir: pluginDir, ...result }, null, 2));
718
+ return;
719
+ }
720
+ console.log(`Generated plugin hooks in ${(0, node_path_1.join)(pluginDir, "hooks")}`);
721
+ console.log(` scripts: ${result.scripts.join(", ")}`);
722
+ console.log(` events: ${result.events.join(", ")}`);
723
+ if (result.removed.length)
724
+ console.log(` removed: ${result.removed.join(", ")}`);
725
+ return;
726
+ }
711
727
  if (command === "daemon") {
712
728
  const action = args[1];
713
729
  const projectDir = projectArg(args);
@@ -1396,58 +1412,35 @@ async function main() {
1396
1412
  const path = takeArg(args, "--path");
1397
1413
  if (!path)
1398
1414
  usage();
1399
- const project = projectArg(args);
1400
- const result = (0, kernel_js_1.kageFileContext)(project, path);
1401
- // Surface any watcher nudge targeting THIS file at the edit/read moment (marks it
1402
- // surfaced so it shows once) — the highest-value trigger for a nudge. prompt-context
1403
- // still drains file-less / not-yet-touched nudges on the next prompt.
1404
- const nudges = (0, kernel_js_1.surfacePendingNudges)(project, { path });
1405
- if (args.includes("--json")) {
1406
- console.log(JSON.stringify({ ...result, nudges: nudges.items }, null, 2));
1407
- return;
1408
- }
1409
- const parts = [];
1410
- if (result.context_block)
1411
- parts.push(result.context_block);
1412
- if (nudges.block)
1413
- parts.push(nudges.block.trimStart());
1414
- if (parts.length)
1415
- console.log(parts.join("\n\n"));
1416
- // No verified packets cite this file and no nudge targets it: print nothing so hooks
1417
- // can gate on empty output.
1415
+ const result = (0, kernel_js_1.kageFileContext)(projectArg(args), path);
1416
+ if (args.includes("--json"))
1417
+ console.log(JSON.stringify(result, null, 2));
1418
+ else if (result.context_block)
1419
+ console.log(result.context_block);
1420
+ // No verified packets cite this file: print nothing so hooks can gate on empty output.
1418
1421
  return;
1419
1422
  }
1420
1423
  if (command === "prompt-context") {
1421
1424
  // Top-of-task recall for an ambient UserPromptSubmit hook: recall on the user's prompt
1422
- // and emit the memory PLUS a one-line savings receipt (what was recalled + tokens saved).
1423
- // Silent when nothing relevant is found, so hooks can gate on empty output.
1425
+ // and emit the memory. Silent when nothing relevant is found, so hooks can gate on empty.
1424
1426
  const query = takeArg(args, "--query") ?? firstPositional(args);
1425
1427
  if (!query)
1426
1428
  usage();
1427
1429
  const project = projectArg(args);
1428
1430
  const result = (0, kernel_js_1.recall)(project, query, 5, false, {});
1429
- // Surface any nudges the kage-watcher wrote (marks them surfaced so each shows once) —
1430
- // this is what makes recall/nudges felt instead of silently sitting in a file.
1431
- const nudges = (0, kernel_js_1.surfacePendingNudges)(project);
1432
1431
  if (args.includes("--json")) {
1433
- console.log(JSON.stringify({ ...result, nudges: nudges.items }, null, 2));
1432
+ console.log(JSON.stringify(result, null, 2));
1434
1433
  return;
1435
1434
  }
1436
- const parts = [];
1437
- if (result.results.length) {
1438
- // Lead with felt behavior, not a vanity/gameable token number: tell the agent these are
1439
- // verified team memories to follow. (The tokens/$ ledger lives in `kage gains`.) Keep
1440
- // only the trust-relevant stale-withheld signal here.
1441
- const plural = result.results.length === 1 ? "y" : "ies";
1442
- const withheld = result.value_receipt?.stale_withheld ?? 0;
1443
- parts.push(result.context_block);
1444
- parts.push(`_${result.results.length} verified team memor${plural} above — follow them.${withheld ? ` ${withheld} stale memor${withheld === 1 ? "y" : "ies"} withheld (code changed under them).` : ""}_`);
1445
- }
1446
- if (nudges.block)
1447
- parts.push(nudges.block.trimStart());
1448
- if (!parts.length)
1435
+ if (!result.results.length)
1449
1436
  return;
1450
- console.log(parts.join("\n\n"));
1437
+ // Lead with felt behavior, not a vanity/gameable token number: tell the agent these are
1438
+ // verified team memories to follow. (The tokens/$ ledger lives in `kage gains`.) Keep
1439
+ // only the trust-relevant stale-withheld signal here.
1440
+ const plural = result.results.length === 1 ? "y" : "ies";
1441
+ const withheld = result.value_receipt?.stale_withheld ?? 0;
1442
+ console.log(result.context_block);
1443
+ console.log(`_${result.results.length} verified team memor${plural} above — follow them.${withheld ? ` ${withheld} stale memor${withheld === 1 ? "y" : "ies"} withheld (code changed under them).` : ""}_`);
1451
1444
  return;
1452
1445
  }
1453
1446
  if (command === "memory-access") {
package/dist/kernel.js CHANGED
@@ -102,7 +102,7 @@ exports.compactProject = compactProject;
102
102
  exports.installAgentPolicy = installAgentPolicy;
103
103
  exports.createDenseEmbeddingProvider = createDenseEmbeddingProvider;
104
104
  exports.buildEmbeddingIndex = buildEmbeddingIndex;
105
- exports.surfacePendingNudges = surfacePendingNudges;
105
+ exports.isUngroundedConversationalCapture = isUngroundedConversationalCapture;
106
106
  exports.recall = recall;
107
107
  exports.recallWithEmbeddings = recallWithEmbeddings;
108
108
  exports.queryCodeGraph = queryCodeGraph;
@@ -151,6 +151,7 @@ exports.capture = capture;
151
151
  exports.createPublicCandidate = createPublicCandidate;
152
152
  exports.registryRecommendations = registryRecommendations;
153
153
  exports.setupAgent = setupAgent;
154
+ exports.generatePluginHooks = generatePluginHooks;
154
155
  exports.setupDoctor = setupDoctor;
155
156
  exports.verifyAgentActivation = verifyAgentActivation;
156
157
  exports.observe = observe;
@@ -1204,9 +1205,8 @@ function replayTokensSaved(packets, contextBlock) {
1204
1205
  }
1205
1206
  const FILE_CONTEXT_PACKET_CAP = 3;
1206
1207
  // Normalize a file path to the repo-relative form used for packet citation matching:
1207
- // absolute paths are made relative to the project root, backslashes/leading "./" are
1208
- // stripped. Returns "" for an empty/blank input. Shared by file-context citation lookup
1209
- // and edit-time nudge matching so both compare paths identically.
1208
+ // absolute paths are made relative to the project root, backslashes/leading "./" and
1209
+ // trailing slashes are stripped. Returns "" for an empty/blank input.
1210
1210
  function normalizeRepoPath(projectDir, filePath) {
1211
1211
  let rel = (filePath ?? "").trim().replace(/\\/g, "/");
1212
1212
  if (!rel)
@@ -2135,9 +2135,18 @@ function evaluateMemoryAdmission(projectDir, packet) {
2135
2135
  score -= 10;
2136
2136
  risks.push("too little context");
2137
2137
  }
2138
+ // Ungrounded conversational chatter (a path-less, repo-reference-free user outburst) is not
2139
+ // durable memory regardless of its other signals — keywords like "issue"/"before" can
2140
+ // otherwise score it as a candidate. Hard-block admission so it can never auto-promote.
2141
+ if (isUngroundedConversationalCapture(packet)) {
2142
+ score -= 45;
2143
+ risks.push("ungrounded conversational utterance, not durable knowledge");
2144
+ }
2138
2145
  const bounded = Math.max(0, Math.min(100, score));
2139
2146
  return {
2140
- admit: bounded >= 45 && risks.indexOf("session bookkeeping, not durable knowledge") === -1,
2147
+ admit: bounded >= 45
2148
+ && risks.indexOf("session bookkeeping, not durable knowledge") === -1
2149
+ && risks.indexOf("ungrounded conversational utterance, not durable knowledge") === -1,
2141
2150
  class: bounded >= 72 ? "high_signal" : bounded >= 45 ? "candidate" : "episodic_only",
2142
2151
  score: bounded,
2143
2152
  reasons,
@@ -7972,63 +7981,61 @@ function isSerializedDumpBody(body) {
7972
7981
  return isSerializedDumpTitle(t)
7973
7982
  || /<task-notification\b|<tool-use-id\b|"hookSpecificOutput"|"isImage"\s*:|"noOutputExpected"\s*:|"interrupted"\s*:\s*(true|false)/i.test(t.slice(0, 4000));
7974
7983
  }
7975
- // Surface watcher nudges: read the nudge inbox the kage-watcher writes (.agent_memory/
7976
- // nudges/pending.jsonl), return the unsurfaced ones as a prominent block, and mark them
7977
- // surfaced so each shows exactly once. This is what makes recall/nudges FELT instead of
7978
- // sitting silently in a file. Two trigger points share this: prompt-context
7979
- // (UserPromptSubmit) passes no filter and drains every pending nudge; file-context
7980
- // (PreToolUse Edit/Read) passes opts.path so only a nudge targeting the file being touched
7981
- // fires — surfacing the warning at the edit moment, the highest-value trigger. A file-less
7982
- // nudge has no edit moment, so it waits for the next prompt-context drain.
7983
- function surfacePendingNudges(projectDir, opts = {}) {
7984
- const path = (0, node_path_1.join)(projectDir, ".agent_memory", "nudges", "pending.jsonl");
7985
- if (!(0, node_fs_1.existsSync)(path))
7986
- return { block: "", items: [], count: 0 };
7987
- let raw;
7988
- try {
7989
- raw = (0, node_fs_1.readFileSync)(path, "utf8");
7990
- }
7991
- catch {
7992
- return { block: "", items: [], count: 0 };
7993
- }
7994
- const records = [];
7995
- for (const line of raw.split("\n")) {
7996
- const trimmed = line.trim();
7997
- if (!trimmed)
7998
- continue;
7999
- try {
8000
- const parsed = JSON.parse(trimmed);
8001
- if (parsed && typeof parsed === "object")
8002
- records.push(parsed);
8003
- }
8004
- catch { /* drop malformed lines */ }
8005
- }
8006
- const wantPath = opts.path !== undefined ? normalizeRepoPath(projectDir, opts.path) : undefined;
8007
- const unsurfaced = records.filter((record) => record.surfaced !== true &&
8008
- typeof record.message === "string" &&
8009
- (wantPath === undefined ||
8010
- (typeof record.file === "string" && normalizeRepoPath(projectDir, record.file) === wantPath)));
8011
- if (!unsurfaced.length)
8012
- return { block: "", items: [], count: 0 };
8013
- const items = unsurfaced.map((record) => ({
8014
- message: String(record.message),
8015
- file: typeof record.file === "string" ? record.file : undefined,
8016
- kind: typeof record.kind === "string" ? record.kind : undefined,
8017
- }));
8018
- for (const record of unsurfaced)
8019
- record.surfaced = true;
8020
- // Atomic write (tmp + rename): file-context now rewrites this inbox on every edit, so the
8021
- // window where prompt-context could read a half-written file is wider. rename is atomic, so
8022
- // a concurrent reader sees either the old or the new file, never a torn one.
8023
- try {
8024
- const tmp = `${path}.${process.pid}.tmp`;
8025
- (0, node_fs_1.writeFileSync)(tmp, `${records.map((record) => JSON.stringify(record)).join("\n")}\n`, "utf8");
8026
- (0, node_fs_1.renameSync)(tmp, path);
8027
- }
8028
- catch { /* best-effort; recall still works */ }
8029
- const block = ["", "## ⚠ Kage nudges — act on these before proceeding",
8030
- ...items.map((item) => `- ${item.file ? `${item.file}: ` : ""}${item.message}`)].join("\n");
8031
- return { block, items, count: items.length };
7984
+ // Repo grounding signals does the text point at something concrete in the codebase? These
7985
+ // three predicates are the shared vocabulary for "grounded": observationSignalScore weights
7986
+ // them into its durable-knowledge score, and the ungrounded-utterance guard uses their union
7987
+ // to decide whether a path-less capture references the repo at all. Kept as one source of
7988
+ // truth so the scorer and the guard can never drift on what "grounded" means.
7989
+ function textCitesPath(text) {
7990
+ return /\b[\w.-]+\/[\w./-]+\b/.test(text)
7991
+ || /\b[\w-]+\.(ts|tsx|js|jsx|mjs|cjs|py|go|rs|java|rb|json|ya?ml|toml|md|sh|css|html|sql)\b/i.test(text);
7992
+ }
7993
+ function textHasCodeIdentifier(text) {
7994
+ return /\b[a-z][a-z0-9]*[A-Z]\w*\b/.test(text) // camelCase
7995
+ || /\b[a-z0-9]+_[a-z0-9_]+\b/.test(text) // snake_case
7996
+ || /`[^`]+`/.test(text)
7997
+ || /\b\w+\(\)/.test(text);
7998
+ }
7999
+ function textHasCommand(text) {
8000
+ return /(^|\s)(npm|pnpm|yarn|npx|node|git|cargo|make|pytest|go|tsc|kage)\s+[\w.-]/.test(text);
8001
+ }
8002
+ function hasRepoGroundingSignal(text) {
8003
+ const t = text ?? "";
8004
+ if (!t.trim())
8005
+ return false;
8006
+ return textCitesPath(t) || textHasCodeIdentifier(t) || textHasCommand(t);
8007
+ }
8008
+ // A raw conversational user utterance: a frustrated/rhetorical/imperative chat message aimed
8009
+ // at the agent — e.g. "why are you asking me???!!! it's your job, don't stop before you...".
8010
+ // It is plain prose, so the serialized-dump guard waves it through, yet it is chatter, not
8011
+ // durable repo knowledge. Detection is deliberately narrow — a run of emphatic terminal
8012
+ // punctuation, or one of a curated set of rhetorical / second-person-at-the-assistant
8013
+ // phrases so it stays clear of normal declarative learnings (which read as statements, not
8014
+ // outbursts addressed to "you").
8015
+ function looksLikeRawUserUtterance(text) {
8016
+ const t = (text ?? "").trim();
8017
+ if (!t)
8018
+ return false;
8019
+ // Emphatic or mixed terminal punctuation ("???", "!!", "?!") common in venting chat,
8020
+ // essentially absent from curated memory prose.
8021
+ if (/[!?]{2,}/.test(t))
8022
+ return true;
8023
+ // Rhetorical questions / second-person-imperative frustration directed at the assistant.
8024
+ return /\b(why are you|why did you|why would you|why aren'?t you|are you (kidding|serious|even|really)|it'?s your job|that'?s your job|do your job|don'?t stop|stop asking|stop before you|keep going|hurry up|just do it|figure it out yourself|i (already )?told you)\b/i.test(t);
8025
+ }
8026
+ // Capture-noise guard for ungrounded chat. A packet trips it only when ALL hold: it cites zero
8027
+ // repo paths, its text carries no repo grounding signal (no path/file/identifier/command), and
8028
+ // it reads as a raw conversational user utterance. The conjunction is what keeps it safe — a
8029
+ // real ungrounded decision or convention names a symbol, file, command, or rule and so is never
8030
+ // caught. Such packets are routed to pending (not auto-approved) at capture time and withheld
8031
+ // from recall, mirroring how serialized dumps are gated.
8032
+ function isUngroundedConversationalCapture(packet) {
8033
+ if (packet.paths && packet.paths.length > 0)
8034
+ return false;
8035
+ const text = `${packet.title ?? ""}\n${packet.body ?? ""}`;
8036
+ if (hasRepoGroundingSignal(text))
8037
+ return false;
8038
+ return looksLikeRawUserUtterance(text);
8032
8039
  }
8033
8040
  // Collapse whitespace and hard-cap a value rendered inline in a context block, so one
8034
8041
  // oversized field (e.g. a graph fact whose body is a raw transcript) can never blow up
@@ -8115,7 +8122,7 @@ function recallWithVectorScores(projectDir, query, limit = 5, explain = false, i
8115
8122
  ];
8116
8123
  return { packet, score: score_breakdown.final, relevance, why_matched: unique(why).slice(0, 12), score_breakdown };
8117
8124
  })
8118
- .filter((entry) => entry.relevance > 0 && !isSerializedDumpTitle(entry.packet.title))
8125
+ .filter((entry) => entry.relevance > 0 && !isSerializedDumpTitle(entry.packet.title) && !isUngroundedConversationalCapture(entry.packet))
8119
8126
  .sort((a, b) => b.score - a.score || a.packet.title.localeCompare(b.packet.title));
8120
8127
  const scored = diversifyRecallEntries(rankedScored, limit)
8121
8128
  .map(({ relevance, ...entry }) => entry);
@@ -8127,7 +8134,7 @@ function recallWithVectorScores(projectDir, query, limit = 5, explain = false, i
8127
8134
  const { score, why } = pendingLexicalScores.get(packet.id) ?? { score: 0, why: [] };
8128
8135
  return { packet, score, why_matched: why };
8129
8136
  })
8130
- .filter((entry) => entry.score > 0)
8137
+ .filter((entry) => entry.score > 0 && !isUngroundedConversationalCapture(entry.packet))
8131
8138
  .sort((a, b) => b.score - a.score || a.packet.title.localeCompare(b.packet.title))
8132
8139
  .filter((entry) => {
8133
8140
  const key = `${entry.packet.type}:${entry.packet.title.toLowerCase()}:${entry.packet.paths.join(",")}`;
@@ -13771,6 +13778,20 @@ function capture(input) {
13771
13778
  if (missingPaths.length) {
13772
13779
  warnings.push(`Some referenced paths do not exist in this repo: ${missingPaths.join(", ")}`);
13773
13780
  }
13781
+ // Ungrounded conversational chatter — a frustrated/rhetorical user message with no cited repo
13782
+ // paths — is not durable memory. Don't hard-reject (a reviewer may still salvage it); instead
13783
+ // deny it the auto-approve fast path so it lands in the pending inbox, tagged with why. Recall
13784
+ // withholds it regardless of status. Explicit pendingReview captures already route to review.
13785
+ const ungroundedUtterance = isUngroundedConversationalCapture({
13786
+ title: input.title,
13787
+ body: input.body,
13788
+ paths: groundedPaths,
13789
+ });
13790
+ const routeToPending = Boolean(input.pendingReview) || ungroundedUtterance;
13791
+ if (ungroundedUtterance && !input.pendingReview) {
13792
+ warnings.push("Routed to pending review: this reads as an ungrounded conversational message with no cited repo paths, " +
13793
+ "not a durable learning. Cite a repo path or restate it as a reusable insight to approve it.");
13794
+ }
13774
13795
  const createdAt = nowIso();
13775
13796
  // Agent-asserted links to code-graph nodes (PRD `graph_nodes`): the agent recording the
13776
13797
  // memory knows which symbol/route/file the rule is about, so let it declare them instead
@@ -13789,9 +13810,9 @@ function capture(input) {
13789
13810
  scope: "repo",
13790
13811
  visibility: "team",
13791
13812
  sensitivity: "internal",
13792
- status: input.pendingReview ? "pending" : "approved",
13813
+ status: routeToPending ? "pending" : "approved",
13793
13814
  confidence: DEFAULT_CONFIDENCE,
13794
- tags: input.tags ?? [],
13815
+ tags: ungroundedUtterance ? unique([...(input.tags ?? []), "needs-grounding"]) : (input.tags ?? []),
13795
13816
  paths: groundedPaths,
13796
13817
  stack: input.stack ?? [],
13797
13818
  source_refs: [
@@ -13853,7 +13874,7 @@ function capture(input) {
13853
13874
  ...evaluateMemoryQuality(input.projectDir, packet),
13854
13875
  ...(contradictions.length ? { contradicts: contradictions.map((c) => c.packet_id) } : {}),
13855
13876
  };
13856
- const path = writePacket(input.projectDir, packet, input.pendingReview ? "pending" : "packets");
13877
+ const path = writePacket(input.projectDir, packet, routeToPending ? "pending" : "packets");
13857
13878
  recordMemoryAudit(input.projectDir, "capture", [packet], {
13858
13879
  type: packet.type,
13859
13880
  status: packet.status,
@@ -14414,6 +14435,58 @@ exit 0
14414
14435
  setSnippet(paths[agent] || null, universal, [`Merge this MCP stdio config into ${agent}'s MCP settings.`, "Restart the agent after updating config."]);
14415
14436
  return result;
14416
14437
  }
14438
+ // Single source of truth for Claude Code hooks. The plugin's hook scripts and hooks.json are
14439
+ // GENERATED from the exact same setupAgent("claude-code") output the npm install path writes —
14440
+ // so a hook fix lands in both channels and they can't silently drift. The hook scripts call the
14441
+ // kage CLI and never reference their own location, so the same bodies serve both the ~/.claude
14442
+ // install and the plugin; only the wiring's command path is re-targeted to ${CLAUDE_PLUGIN_ROOT}.
14443
+ // Run via `kage gen-plugin-hooks`; a unit test asserts the committed plugin/hooks match this.
14444
+ function generatePluginHooks(pluginDir) {
14445
+ const tmpHome = (0, node_fs_1.mkdtempSync)((0, node_path_1.join)((0, node_os_1.tmpdir)(), "kage-plugin-home-"));
14446
+ const tmpProject = (0, node_fs_1.mkdtempSync)((0, node_path_1.join)((0, node_os_1.tmpdir)(), "kage-plugin-proj-"));
14447
+ try {
14448
+ setupAgent("claude-code", tmpProject, { write: true, homeDir: tmpHome });
14449
+ const srcHookDir = (0, node_path_1.join)(tmpHome, ".claude", "kage", "hooks");
14450
+ const settings = JSON.parse((0, node_fs_1.readFileSync)((0, node_path_1.join)(tmpHome, ".claude", "settings.json"), "utf8"));
14451
+ const hooksOut = (0, node_path_1.join)(pluginDir, "hooks");
14452
+ (0, node_fs_1.mkdirSync)(hooksOut, { recursive: true });
14453
+ // Copy the generated hook scripts verbatim (path-agnostic — they shell out to `kage`).
14454
+ const scripts = (0, node_fs_1.readdirSync)(srcHookDir).filter((f) => f.endsWith(".sh")).sort();
14455
+ for (const file of scripts) {
14456
+ (0, node_fs_1.writeFileSync)((0, node_path_1.join)(hooksOut, file), (0, node_fs_1.readFileSync)((0, node_path_1.join)(srcHookDir, file), "utf8"), { mode: 0o755 });
14457
+ }
14458
+ // Re-target the event wiring from the install path's `~/.claude/kage/hooks/` to the plugin's
14459
+ // `${CLAUDE_PLUGIN_ROOT}/hooks/`, preserving events, matchers, and timeouts exactly.
14460
+ const events = Object.keys(settings.hooks ?? {});
14461
+ const pluginHooks = { hooks: {} };
14462
+ for (const event of events) {
14463
+ pluginHooks.hooks[event] = (settings.hooks ?? {})[event].map((entry) => ({
14464
+ ...(entry.matcher !== undefined ? { matcher: entry.matcher } : {}),
14465
+ hooks: entry.hooks.map((h) => {
14466
+ const m = h.command.match(/^bash ~\/\.claude\/kage\/hooks\/(.+)$/);
14467
+ return {
14468
+ type: h.type,
14469
+ command: m ? `bash "\${CLAUDE_PLUGIN_ROOT}/hooks/${m[1]}"` : h.command,
14470
+ ...(h.timeout !== undefined ? { timeout: h.timeout } : {}),
14471
+ };
14472
+ }),
14473
+ }));
14474
+ }
14475
+ (0, node_fs_1.writeFileSync)((0, node_path_1.join)(hooksOut, "hooks.json"), `${JSON.stringify(pluginHooks, null, 2)}\n`, "utf8");
14476
+ // Drop committed scripts the install path no longer emits (e.g. kage-prompt-context.sh,
14477
+ // now subsumed by observe.sh handling UserPromptSubmit).
14478
+ const removed = [];
14479
+ for (const stale of (0, node_fs_1.readdirSync)(hooksOut).filter((f) => f.endsWith(".sh") && !scripts.includes(f))) {
14480
+ (0, node_fs_1.unlinkSync)((0, node_path_1.join)(hooksOut, stale));
14481
+ removed.push(stale);
14482
+ }
14483
+ return { scripts, removed, events };
14484
+ }
14485
+ finally {
14486
+ (0, node_fs_1.rmSync)(tmpHome, { recursive: true, force: true });
14487
+ (0, node_fs_1.rmSync)(tmpProject, { recursive: true, force: true });
14488
+ }
14489
+ }
14417
14490
  function upsertJsonMcpServer(path, name, server) {
14418
14491
  ensureDir((0, node_path_1.dirname)(path));
14419
14492
  let config = {};
@@ -14816,19 +14889,13 @@ function observationSignalScore(observation) {
14816
14889
  const causalHits = SIGNAL_CAUSAL_MARKERS.filter((marker) => lower.includes(marker)).length;
14817
14890
  if (causalHits > 0)
14818
14891
  score += Math.min(0.35, 0.25 + (causalHits - 1) * 0.05);
14819
- const citesPath = Boolean(observation.path)
14820
- || /\b[\w.-]+\/[\w./-]+\b/.test(prose)
14821
- || /\b[\w-]+\.(ts|tsx|js|jsx|mjs|cjs|py|go|rs|java|rb|json|ya?ml|toml|md|sh|css|html|sql)\b/i.test(prose);
14892
+ const citesPath = Boolean(observation.path) || textCitesPath(prose);
14822
14893
  if (citesPath)
14823
14894
  score += 0.2;
14824
- const codeIdentifier = /\b[a-z][a-z0-9]*[A-Z]\w*\b/.test(prose) // camelCase
14825
- || /\b[a-z0-9]+_[a-z0-9_]+\b/.test(prose) // snake_case
14826
- || /`[^`]+`/.test(prose)
14827
- || /\b\w+\(\)/.test(prose);
14895
+ const codeIdentifier = textHasCodeIdentifier(prose);
14828
14896
  if (codeIdentifier)
14829
14897
  score += 0.15;
14830
- const commandLine = Boolean(observation.command)
14831
- || /(^|\s)(npm|pnpm|yarn|npx|node|git|cargo|make|pytest|go|tsc|kage)\s+[\w.-]/.test(prose);
14898
+ const commandLine = Boolean(observation.command) || textHasCommand(prose);
14832
14899
  if (commandLine)
14833
14900
  score += 0.15;
14834
14901
  const words = combined.split(/\s+/).filter(Boolean).length;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kage-core/kage-graph-mcp",
3
- "version": "2.5.3",
3
+ "version": "2.5.5",
4
4
  "description": "Team memory for coding agents: captures the decisions, runbooks, and bug fixes that get lost, verified against your code and shared via git. MCP server, zero deps, no account.",
5
5
  "main": "dist/index.js",
6
6
  "files": [