portawhip 0.1.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 (105) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/LICENSE +21 -0
  3. package/README.md +162 -0
  4. package/SECURITY.md +15 -0
  5. package/VISION.md +281 -0
  6. package/adapters/hooks/hook-stub.mjs +21 -0
  7. package/adapters/hooks/hook-stub.test.mjs +43 -0
  8. package/adapters/hooks/universal-hook.mjs +348 -0
  9. package/adapters/hooks/universal-hook.test.mjs +294 -0
  10. package/adapters/instructions/generate.mjs +114 -0
  11. package/core/fixtures/bad-recipe.yaml +5 -0
  12. package/core/fixtures/capability-graph.json +10 -0
  13. package/core/fixtures/skill-with-metadata/SKILL.md +15 -0
  14. package/core/registry/capability-docs.mjs +145 -0
  15. package/core/registry/capability-graph-compiler.mjs +90 -0
  16. package/core/registry/capability-graph.mjs +53 -0
  17. package/core/registry/capability-kind.mjs +9 -0
  18. package/core/registry/cli-enrich.mjs +316 -0
  19. package/core/registry/cli-enrich.test.mjs +119 -0
  20. package/core/registry/discover.mjs +379 -0
  21. package/core/registry/enrich.mjs +213 -0
  22. package/core/registry/enrich.test.mjs +53 -0
  23. package/core/registry/eval-harvest.mjs +93 -0
  24. package/core/registry/eval-harvest.test.mjs +64 -0
  25. package/core/registry/registry.mjs +197 -0
  26. package/core/router/concept-vector.mjs +91 -0
  27. package/core/router/concept-vector.test.mjs +36 -0
  28. package/core/router/curated-trigger.test.mjs +28 -0
  29. package/core/router/dense-embedder.mjs +242 -0
  30. package/core/router/fusion.mjs +18 -0
  31. package/core/router/hybrid-router.mjs +375 -0
  32. package/core/router/intent-evidence.mjs +60 -0
  33. package/core/router/prompt-hygiene.mjs +31 -0
  34. package/core/router/route-entry.mjs +74 -0
  35. package/core/router/router-cli.mjs +218 -0
  36. package/core/router/router-cli.test.mjs +25 -0
  37. package/core/router/router-eval.mjs +166 -0
  38. package/core/router/router-live.test.mjs +89 -0
  39. package/core/router/router.test.mjs +838 -0
  40. package/core/router/scorer.mjs +72 -0
  41. package/core/router/sparse-retriever.mjs +79 -0
  42. package/core/router/tokenize.mjs +42 -0
  43. package/core/state/bundle-state.mjs +124 -0
  44. package/core/state/bundle-state.test.mjs +175 -0
  45. package/core/state/config.mjs +115 -0
  46. package/core/state/feedback.mjs +129 -0
  47. package/core/state/feedback.test.mjs +181 -0
  48. package/core/state/runtime-root.test.mjs +22 -0
  49. package/core/state/stack-detect.mjs +102 -0
  50. package/core/state/stack-detect.test.mjs +64 -0
  51. package/core/surface/config-sync-backends.mjs +224 -0
  52. package/core/surface/connector-targets.mjs +205 -0
  53. package/core/surface/discover-hooks.mjs +151 -0
  54. package/core/surface/discover-hooks.test.mjs +63 -0
  55. package/core/surface/discover-surface.test.mjs +49 -0
  56. package/core/surface/extra-hosts.mjs +48 -0
  57. package/core/surface/extra-hosts.test.mjs +24 -0
  58. package/core/surface/hook-targets.mjs +102 -0
  59. package/core/surface/surface-copy-targets.mjs +37 -0
  60. package/core/surface/surface-inventory.mjs +106 -0
  61. package/core/surface/surface-matrix.mjs +133 -0
  62. package/core/surface/surface-matrix.test.mjs +90 -0
  63. package/docs/router-eval-set.jsonl +38 -0
  64. package/hooks.manifest.yaml +16 -0
  65. package/package.json +101 -0
  66. package/recipe.yaml +247 -0
  67. package/recipes/foundry.yaml +27 -0
  68. package/recipes/manifest.yaml +18 -0
  69. package/recipes/roles/backend-data.yaml +26 -0
  70. package/recipes/roles/coding.yaml +26 -0
  71. package/recipes/roles/frontend.yaml +14 -0
  72. package/recipes/roles/research.yaml +31 -0
  73. package/recipes/roles/secure.yaml +27 -0
  74. package/router.config.yaml +120 -0
  75. package/scripts/bundles.mjs +131 -0
  76. package/scripts/doctor.mjs +117 -0
  77. package/scripts/embedded-hooks.mjs +27 -0
  78. package/scripts/hosts.mjs +60 -0
  79. package/scripts/link/link-connectors.mjs +190 -0
  80. package/scripts/link/link-connectors.test.mjs +111 -0
  81. package/scripts/link/link-hooks.mjs +259 -0
  82. package/scripts/link/link-hooks.test.mjs +112 -0
  83. package/scripts/link/link-surfaces.mjs +188 -0
  84. package/scripts/link/link-surfaces.test.mjs +76 -0
  85. package/scripts/load.mjs +143 -0
  86. package/scripts/package-contract.test.mjs +26 -0
  87. package/scripts/surface-inventory.mjs +6 -0
  88. package/scripts/sync/agents-surface.mjs +55 -0
  89. package/scripts/sync/agents-surface.test.mjs +18 -0
  90. package/scripts/sync/auto-sync.mjs +135 -0
  91. package/scripts/sync/auto-sync.test.mjs +41 -0
  92. package/scripts/sync/import-surfaces.mjs +331 -0
  93. package/scripts/sync/import-surfaces.test.mjs +106 -0
  94. package/scripts/sync/sync-config.mjs +230 -0
  95. package/scripts/sync/sync-config.test.mjs +141 -0
  96. package/scripts/sync/sync-surfaces.mjs +229 -0
  97. package/scripts/sync/sync-surfaces.test.mjs +66 -0
  98. package/scripts/tui-actions.mjs +59 -0
  99. package/scripts/tui-actions.test.mjs +53 -0
  100. package/scripts/tui.mjs +782 -0
  101. package/scripts/uninstall-all.mjs +39 -0
  102. package/server/mcp-server.mjs +129 -0
  103. package/server/mcp-server.test.mjs +175 -0
  104. package/skills/portawhip/SKILL.md +80 -0
  105. package/surface-matrix.yaml +93 -0
@@ -0,0 +1,114 @@
1
+ #!/usr/bin/env node
2
+ // Phase 2 step 3: idempotent insert/remove of a one-liner instruction block
3
+ // into a host's instruction file (CLAUDE.md / AGENTS.md / GEMINI.md), so the
4
+ // model knows to call the harness-router MCP server before starting a task.
5
+ // Marker comments make this reversible — never touches the rest of the file.
6
+
7
+ import { readFileSync, writeFileSync, existsSync } from "node:fs";
8
+
9
+ const START = "<!-- harness-router:start -->";
10
+ const END = "<!-- harness-router:end -->";
11
+
12
+ // Plain wording — safe default for hosts with no lazy tool-loading concept
13
+ // (Codex/AGENTS.md verified 8/8 route() calls with this text alone).
14
+ export const GENERIC_BLOCK = `${START}
15
+ Before starting a task, call \`route(task summary)\` on the harness-router MCP
16
+ server and follow any returned pointers. Pass only the positively requested action
17
+ and its direct object; do not copy the raw prompt. Drop chit-chat, venting,
18
+ background, and any rejected, negated, or hypothetical option. If a request is
19
+ buried in chat, route only the request; if the message names several distinct
20
+ actions, call route once per action.
21
+ Example: "ugh CI is flaky again, anyway find where we parse the auth token" ->
22
+ route("find the code that parses the auth token").
23
+ An empty result is normal and means nothing relevant is installed — proceed without it.
24
+ ${END}`;
25
+
26
+ // Claude Code defers MCP tool schemas behind ToolSearch until looked up by
27
+ // name — confirmed live: harness-router's route/list_all showed as deferred
28
+ // at session start and were never called (0/8 in Phase 2 verify), because
29
+ // the generic wording never told the model to look them up first.
30
+ export const CLAUDE_CODE_BLOCK = `${START}
31
+ Before starting a task, call \`route(task summary)\` on the harness-router MCP
32
+ server and follow any returned pointers. Pass only the positively requested action
33
+ and its direct object; do not copy the raw prompt. Drop chit-chat, venting,
34
+ background, and any rejected, negated, or hypothetical option. If a request is
35
+ buried in chat, route only the request; if the message names several distinct
36
+ actions, call route once per action.
37
+ Example: "ugh CI is flaky again, anyway find where we parse the auth token" ->
38
+ route("find the code that parses the auth token").
39
+ If \`route\`/\`list_all\` show up as deferred/pending tools rather than directly
40
+ callable, first call ToolSearch with query
41
+ "select:mcp__harness-router__route,mcp__harness-router__list_all" to load them,
42
+ then call route(). An empty result from route() is normal and means nothing
43
+ relevant is installed — proceed without it.
44
+ ${END}`;
45
+
46
+ export const CURSOR_RULE_BLOCK = `---
47
+ description: Route tasks through the project harness-router before starting work
48
+ alwaysApply: true
49
+ ---
50
+
51
+ ${GENERIC_BLOCK}`;
52
+
53
+ // Windsurf workspace rules live under .windsurf/rules/*.md and pick an
54
+ // activation mode via the `trigger` frontmatter field; always_on = injected
55
+ // into every request in the workspace. This is a dedicated harness-owned
56
+ // file (see owned:true in connector-targets), so it is written whole, not
57
+ // marker-upserted — the frontmatter must stay the first bytes of the file.
58
+ export const WINDSURF_RULE_BLOCK = `---
59
+ trigger: always_on
60
+ ---
61
+
62
+ ${GENERIC_BLOCK}`;
63
+
64
+ export function blockForVariant(variant = "generic") {
65
+ if (variant === "claude-code") return CLAUDE_CODE_BLOCK;
66
+ if (variant === "cursor-rule") return CURSOR_RULE_BLOCK;
67
+ if (variant === "windsurf-rule") return WINDSURF_RULE_BLOCK;
68
+ return GENERIC_BLOCK;
69
+ }
70
+
71
+ export function upsertBlock(targetPath, block = GENERIC_BLOCK) {
72
+ const before = existsSync(targetPath) ? readFileSync(targetPath, "utf8") : "";
73
+ const startIdx = before.indexOf(START);
74
+ const endIdx = before.indexOf(END);
75
+ const after =
76
+ startIdx !== -1 && endIdx !== -1
77
+ ? before.slice(0, startIdx) + block + before.slice(endIdx + END.length)
78
+ : before.length > 0
79
+ ? `${before.trimEnd()}\n\n${block}\n`
80
+ : `${block}\n`;
81
+ writeFileSync(targetPath, after);
82
+ return after !== before;
83
+ }
84
+
85
+ export function removeBlock(targetPath) {
86
+ if (!existsSync(targetPath)) return false;
87
+ const content = readFileSync(targetPath, "utf8");
88
+ const startIdx = content.indexOf(START);
89
+ const endIdx = content.indexOf(END);
90
+ if (startIdx === -1 || endIdx === -1) return false;
91
+ const before = content.slice(0, startIdx).replace(/\n+$/, "\n");
92
+ const after = content.slice(endIdx + END.length).replace(/^\n+/, "");
93
+ writeFileSync(targetPath, `${before}${after}`);
94
+ return true;
95
+ }
96
+
97
+ function main() {
98
+ const [, , command, targetPath, variant] = process.argv;
99
+ if (!targetPath || !["install", "remove"].includes(command)) {
100
+ console.error(
101
+ "usage: generate.mjs <install|remove> <path-to-CLAUDE.md-or-similar> [claude-code|generic]",
102
+ );
103
+ process.exitCode = 1;
104
+ return;
105
+ }
106
+ const block = blockForVariant(variant);
107
+ const changed = command === "install" ? upsertBlock(targetPath, block) : removeBlock(targetPath);
108
+ console.log(`${command} ${targetPath}: ${changed ? "changed" : "no-op"}`);
109
+ }
110
+
111
+ import { pathToFileURL } from "node:url";
112
+
113
+ const isMain = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
114
+ if (isMain) main();
@@ -0,0 +1,5 @@
1
+ - id: broken-entry
2
+ type: skill
3
+ source: github:example/example
4
+ route:
5
+ description: "missing triggers array"
@@ -0,0 +1,10 @@
1
+ {
2
+ "edges": [
3
+ {
4
+ "from": "database-migrations",
5
+ "to": "postgres-patterns",
6
+ "type": "related_skill",
7
+ "weight": 1
8
+ }
9
+ ]
10
+ }
@@ -0,0 +1,15 @@
1
+ ---
2
+ name: viewport-audit
3
+ description: Inspect responsive viewport failures and layout overflow.
4
+ ---
5
+
6
+ # Viewport Audit
7
+
8
+ ## When to Use
9
+
10
+ Use this skill when a task mentions clipped labels, mobile toolbar wrapping,
11
+ responsive overflow, or viewport-specific layout checks.
12
+
13
+ ## Related
14
+
15
+ Pair with Playwright visual checks when browser rendering is available.
@@ -0,0 +1,145 @@
1
+ import { existsSync, readFileSync, statSync } from "node:fs";
2
+ import { join } from "node:path";
3
+
4
+ function uniq(values) {
5
+ return [...new Set(values.filter((v) => typeof v === "string" && v.trim() !== ""))];
6
+ }
7
+
8
+ function skillFileFor(path) {
9
+ if (!path || !existsSync(path)) return null;
10
+ const stat = statSync(path);
11
+ if (stat.isFile()) return path;
12
+ if (!stat.isDirectory()) return null;
13
+ const skillFile = join(path, "SKILL.md");
14
+ return existsSync(skillFile) ? skillFile : null;
15
+ }
16
+
17
+ function parseFrontmatter(text) {
18
+ const match = String(text ?? "").match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?/);
19
+ if (!match) return { data: {}, body: text };
20
+ const data = {};
21
+ for (const line of match[1].split(/\r?\n/)) {
22
+ const field = line.match(/^([A-Za-z0-9_-]+):\s*(.*)$/);
23
+ if (!field) continue;
24
+ data[field[1]] = field[2].replace(/^["']|["']$/g, "").trim();
25
+ }
26
+ return { data, body: text.slice(match[0].length) };
27
+ }
28
+
29
+ function extractHeadings(body, max = 12) {
30
+ const headings = [];
31
+ for (const line of String(body ?? "").split(/\r?\n/)) {
32
+ const match = line.match(/^#{1,3}\s+(.+)$/);
33
+ if (!match) continue;
34
+ headings.push(match[1].replace(/\s+#$/, "").trim());
35
+ if (headings.length >= max) break;
36
+ }
37
+ return headings;
38
+ }
39
+
40
+ function sectionByHeading(body, patterns, maxChars = 1200) {
41
+ const lines = String(body ?? "").split(/\r?\n/);
42
+ const chunks = [];
43
+ for (let i = 0; i < lines.length; i += 1) {
44
+ const heading = lines[i].match(/^(#{1,3})\s+(.+)$/);
45
+ if (!heading) continue;
46
+ const title = heading[2].toLowerCase();
47
+ if (!patterns.some((pattern) => pattern.test(title))) continue;
48
+ const level = heading[1].length;
49
+ const collected = [];
50
+ for (let j = i + 1; j < lines.length; j += 1) {
51
+ const nextHeading = lines[j].match(/^(#{1,3})\s+/);
52
+ if (nextHeading && nextHeading[1].length <= level) break;
53
+ collected.push(lines[j]);
54
+ }
55
+ const text = collected
56
+ .join("\n")
57
+ .replace(/```[\s\S]*?```/g, " ")
58
+ .replace(/[`*_>\[\]()]/g, " ")
59
+ .replace(/\s+/g, " ")
60
+ .trim();
61
+ if (text) chunks.push(text);
62
+ }
63
+ return chunks.join("\n").slice(0, maxChars);
64
+ }
65
+
66
+ function readSkillMetadata(path) {
67
+ try {
68
+ const skillFile = skillFileFor(path);
69
+ if (!skillFile) return {};
70
+ const text = readFileSync(skillFile, "utf8");
71
+ const { data, body } = parseFrontmatter(text);
72
+ return {
73
+ skillFile,
74
+ frontmatterName: data.name ?? null,
75
+ frontmatterDescription: data.description ?? null,
76
+ headings: extractHeadings(body),
77
+ activation: sectionByHeading(body, [
78
+ /when to use/,
79
+ /when to activate/,
80
+ /activation/,
81
+ /use when/,
82
+ /trigger/,
83
+ /routing/,
84
+ ]),
85
+ related: sectionByHeading(body, [/related/, /coordination/, /handoff/], 600),
86
+ };
87
+ } catch {
88
+ return {};
89
+ }
90
+ }
91
+
92
+ export function pointerFor(entry) {
93
+ // Every type:cli entry in this project installs via mise (scripts/load.mjs's
94
+ // loadCli always dispatches through it), so a bare command name only
95
+ // resolves if the invoking shell happens to have `mise activate` wired in
96
+ // - not guaranteed, and not something this project can set up on someone
97
+ // else's machine (VISION.md: cross-OS by detection, not a per-machine
98
+ // setup step). `mise exec --` always works, on every OS, with zero setup.
99
+ // Found live 2026-07-05: a bundle-installed CLI worked via mise but the
100
+ // bare command was "not found" in a shell with no mise activation.
101
+ if (entry.type === "cli" && entry.source) return `mise exec -- ${entry.source}`;
102
+ return entry.path ?? entry.source ?? null;
103
+ }
104
+
105
+ export function buildCapabilityDocs(index) {
106
+ return index.entries
107
+ .filter((entry) => entry.route)
108
+ .map((entry) => {
109
+ const triggers = Array.isArray(entry.route.triggers) ? entry.route.triggers : [];
110
+ const metadata = entry.type === "skill" ? readSkillMetadata(entry.path) : {};
111
+ const fields = {
112
+ id: entry.id,
113
+ type: entry.type,
114
+ origin: entry.origin,
115
+ pointer: pointerFor(entry),
116
+ source: entry.source ?? null,
117
+ path: entry.path ?? null,
118
+ description: entry.route.description,
119
+ triggers,
120
+ frontmatterName: metadata.frontmatterName ?? null,
121
+ frontmatterDescription: metadata.frontmatterDescription ?? null,
122
+ headings: metadata.headings ?? [],
123
+ activation: metadata.activation ?? null,
124
+ related: metadata.related ?? null,
125
+ readyMarker: entry.route.readyMarker ?? null,
126
+ readyHint: entry.route.readyHint ?? null,
127
+ action: entry.route.action ?? null,
128
+ };
129
+ const text = uniq([
130
+ entry.id,
131
+ entry.type,
132
+ entry.origin,
133
+ entry.route.description,
134
+ ...triggers,
135
+ entry.source,
136
+ entry.path,
137
+ metadata.frontmatterName,
138
+ metadata.frontmatterDescription,
139
+ ...(metadata.headings ?? []),
140
+ metadata.activation,
141
+ metadata.related,
142
+ ]).join("\n");
143
+ return { ...fields, text };
144
+ });
145
+ }
@@ -0,0 +1,90 @@
1
+ import { mkdirSync, writeFileSync } from "node:fs";
2
+ import { dirname } from "node:path";
3
+ import { buildCapabilityDocs } from "./capability-docs.mjs";
4
+ import { capabilityKind } from "./capability-kind.mjs";
5
+ import { tokenize } from "../router/tokenize.mjs";
6
+
7
+ const GRAPH_STOPWORDS = new Set([
8
+ "agent",
9
+ "agents",
10
+ "code",
11
+ "data",
12
+ "file",
13
+ "files",
14
+ "project",
15
+ "task",
16
+ "tool",
17
+ "tools",
18
+ "use",
19
+ "user",
20
+ "workflow",
21
+ ]);
22
+
23
+ function tokenSet(doc) {
24
+ const tokens = new Set(tokenize(doc.text).filter((token) => !GRAPH_STOPWORDS.has(token)));
25
+ for (const token of tokenize(doc.id)) tokens.add(token);
26
+ return tokens;
27
+ }
28
+
29
+ function overlapScore(left, right) {
30
+ let score = 0;
31
+ for (const token of left.tokens) {
32
+ if (right.tokens.has(token)) score += 1;
33
+ }
34
+ const rightId = right.doc.id.toLowerCase();
35
+ const leftId = left.doc.id.toLowerCase();
36
+ if (String(left.doc.text ?? "").toLowerCase().includes(rightId)) score += 4;
37
+ if (String(right.doc.text ?? "").toLowerCase().includes(leftId)) score += 4;
38
+ return score;
39
+ }
40
+
41
+ function edgeType(from, to) {
42
+ const fromKind = capabilityKind(from.type);
43
+ const toKind = capabilityKind(to.type);
44
+ if (fromKind === "skill" && toKind === "tool") return "skill_uses_tool";
45
+ if (fromKind === "tool" && toKind === "skill") return "tool_supports_skill";
46
+ if (fromKind === "skill" && toKind === "skill") return "related_skill";
47
+ return "related_tool";
48
+ }
49
+
50
+ export function compileCapabilityGraph(
51
+ index,
52
+ { maxEdgesPerNode = 3, minScore = 3 } = {},
53
+ ) {
54
+ const docs = buildCapabilityDocs(index);
55
+ const prepared = docs.map((doc) => ({ doc, tokens: tokenSet(doc) }));
56
+ const edges = [];
57
+
58
+ for (const source of prepared) {
59
+ const scored = [];
60
+ for (const target of prepared) {
61
+ if (source.doc.id === target.doc.id) continue;
62
+ const score = overlapScore(source, target);
63
+ if (score >= minScore) {
64
+ scored.push({
65
+ from: source.doc.id,
66
+ to: target.doc.id,
67
+ type: edgeType(source.doc, target.doc),
68
+ weight: Math.min(1, Number((score / 8).toFixed(3))),
69
+ score,
70
+ });
71
+ }
72
+ }
73
+ scored
74
+ .sort((a, b) => b.score - a.score || a.to.localeCompare(b.to))
75
+ .slice(0, maxEdgesPerNode)
76
+ .forEach(({ score, ...edge }) => edges.push(edge));
77
+ }
78
+
79
+ return {
80
+ generatedAt: new Date().toISOString(),
81
+ nodeCount: docs.length,
82
+ edgeCount: edges.length,
83
+ edges,
84
+ };
85
+ }
86
+
87
+ export function writeCapabilityGraph(path, graph) {
88
+ mkdirSync(dirname(path), { recursive: true });
89
+ writeFileSync(path, `${JSON.stringify(graph, null, 2)}\n`);
90
+ }
@@ -0,0 +1,53 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+
3
+ export function loadCapabilityGraph(path) {
4
+ if (!path || !existsSync(path)) return { edges: [] };
5
+ const graph = JSON.parse(readFileSync(path, "utf8"));
6
+ return { edges: Array.isArray(graph.edges) ? graph.edges : [] };
7
+ }
8
+
9
+ function edgeWeight(edge) {
10
+ return typeof edge.weight === "number" ? edge.weight : 1;
11
+ }
12
+
13
+ function buildNeighborMap(graph) {
14
+ const neighbors = new Map();
15
+ for (const edge of graph.edges ?? []) {
16
+ if (!edge.from || !edge.to) continue;
17
+ const list = neighbors.get(edge.from) ?? [];
18
+ list.push({ id: edge.to, type: edge.type ?? "related", weight: edgeWeight(edge) });
19
+ neighbors.set(edge.from, list);
20
+ }
21
+ return neighbors;
22
+ }
23
+
24
+ export function expandWithGraph(candidates, docs, graph, { boost = 0.25 } = {}) {
25
+ if (!candidates.length || !graph?.edges?.length) return candidates;
26
+
27
+ const docsById = new Map(docs.map((doc) => [doc.id, doc]));
28
+ const neighbors = buildNeighborMap(graph);
29
+ const merged = new Map(candidates.map((candidate) => [candidate.id, { ...candidate }]));
30
+
31
+ for (const candidate of candidates) {
32
+ for (const neighbor of neighbors.get(candidate.id) ?? []) {
33
+ const doc = docsById.get(neighbor.id);
34
+ if (!doc) continue;
35
+ const score = candidate.score * boost * neighbor.weight;
36
+ const current = merged.get(neighbor.id);
37
+ if (current) {
38
+ current.graphSeen = true;
39
+ } else {
40
+ merged.set(neighbor.id, {
41
+ id: neighbor.id,
42
+ doc,
43
+ score,
44
+ graphBoosted: true,
45
+ graphSource: candidate.id,
46
+ graphEdgeType: neighbor.type,
47
+ });
48
+ }
49
+ }
50
+ }
51
+
52
+ return [...merged.values()].sort((a, b) => b.score - a.score || a.id.localeCompare(b.id));
53
+ }
@@ -0,0 +1,9 @@
1
+ export function capabilityKind(type) {
2
+ if (type === "mcp" || type === "cli") return "tool";
3
+ return type;
4
+ }
5
+
6
+ export function matchesSuggestKind(type, suggest = "any") {
7
+ if (!suggest || suggest === "any") return true;
8
+ return capabilityKind(type) === suggest;
9
+ }