@heytherevibin/skillforge 0.7.0 → 0.10.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.
@@ -1,4 +1,4 @@
1
- """Write project-local Skillforge bootstrap files (.cursor/rules, docs PRD, CLAUDE.md block)."""
1
+ """Write project-local Skillforge bootstrap files (.cursor, .claude/commands, PRD, CLAUDE.md)."""
2
2
  from __future__ import annotations
3
3
 
4
4
  import re
@@ -30,7 +30,12 @@ def materialize_project_files(
30
30
  *,
31
31
  merge: bool = True,
32
32
  ) -> dict[str, Any]:
33
- """Create or update Cursor rule, PRD stub, and CLAUDE.md section. merge=False overwrites rule file only."""
33
+ """Create or update Cursor rule + command, Claude Code command, PRD stub, and CLAUDE.md section.
34
+
35
+ merge=False skips overwriting existing `.cursor/rules/skillforge.mdc`,
36
+ `.cursor/commands/skillforge.md`, and `.claude/commands/skillforge.md` if they already exist
37
+ (other files still update).
38
+ """
34
39
  root = _safe_root(project_root)
35
40
  written: list[str] = []
36
41
 
@@ -51,9 +56,11 @@ alwaysApply: false
51
56
 
52
57
  # Skillforge
53
58
 
54
- When the user invokes **Skillforge**, **`/skillforge`**, or needs deep SKILL.md guidance for this codebase:
59
+ When the user invokes **Skillforge**, types **`/skillforge`** (Cursor or **Claude Code** project command), or needs deep SKILL.md guidance for this codebase:
55
60
 
56
61
  1. Call **route_skills** with **`project_root`** set to this workspace root (so learning and SQLite live in **`.skillforge/`** here), the user's task, and optional **session_id** (reuse within a thread for reroute stats). If the host sets **`SKILLFORGE_PROJECT_ROOT`**, you can omit **project_root** on each call.
62
+
63
+ If **`SKILLFORGE_ROUTER_MODE=host`**: first **`route_skills`** without **`picked_names`** (shortlist only); then call again with **`picked_names`** for the chosen catalog ids before continuing.
57
64
  2. Inject the returned skill bodies into context before continuing.
58
65
  3. To refresh project files, call **materialize_project** with **project_root** set to this workspace root and **skill_names** from the last **route_skills** result.
59
66
 
@@ -67,6 +74,65 @@ When the user invokes **Skillforge**, **`/skillforge`**, or needs deep SKILL.md
67
74
  cursor_rule.write_text(mdc_body, encoding="utf-8")
68
75
  written.append(str(cursor_rule.relative_to(root)))
69
76
 
77
+ cursor_cmd = root / ".cursor" / "commands" / "skillforge.md"
78
+ _assert_under(root, cursor_cmd)
79
+ cursor_cmd.parent.mkdir(parents=True, exist_ok=True)
80
+ cmd_body = f"""# Skillforge — route SKILL.md context (MCP)
81
+
82
+ The user chose the **`/skillforge`** project command. Use the **skillforge** MCP server.
83
+
84
+ ## Do this
85
+
86
+ 1. **`route_skills`**: pass **`project_root`** as this workspace root (absolute path) so SQLite lives in **`.skillforge/`** here. Pass the **current user task** as **`prompt`**. Reuse **`session_id`** across turns in the same thread when the MCP returns one.
87
+
88
+ - **`SKILLFORGE_ROUTER_MODE=host`**: call once **without** **`picked_names`** (shortlist in the response); then call again with **`picked_names`** (exact catalog ids) to load skill context.
89
+ - Optional: pass **`conversation`** when recent turns should influence routing.
90
+
91
+ 2. **Use the returned skill text** in your answer (summarize or follow the SKILL.md guidance as appropriate).
92
+
93
+ 3. Optionally **`materialize_project`** with the same **`project_root`** and **`skill_names`** from **`route_skills`** to refresh **`.cursor/rules`**, **`.cursor/commands`**, **`.claude/commands`**, and **docs/SKILLFORGE-PRD.md**.
94
+
95
+ ## Skills last materialized for this project
96
+
97
+ {skills_md}
98
+ """
99
+ if cursor_cmd.exists() and not merge:
100
+ pass
101
+ else:
102
+ cursor_cmd.write_text(cmd_body, encoding="utf-8")
103
+ written.append(str(cursor_cmd.relative_to(root)))
104
+
105
+ claude_cmd = root / ".claude" / "commands" / "skillforge.md"
106
+ _assert_under(root, claude_cmd)
107
+ claude_cmd.parent.mkdir(parents=True, exist_ok=True)
108
+ cc_body = f"""---
109
+ description: Use Skillforge MCP route_skills for this repo. Invoke when the user runs /skillforge or needs routed SKILL.md context.
110
+ ---
111
+
112
+ # Skillforge — route SKILL.md context (MCP)
113
+
114
+ Project-local **`/skillforge`** for **Claude Code**. Use the **skillforge** MCP server.
115
+
116
+ ## Do this
117
+
118
+ 1. **`route_skills`**: pass **`project_root`** as this workspace root (absolute path). Pass the **current user task** as **`prompt`**. Reuse **`session_id`** when returned.
119
+
120
+ - **`SKILLFORGE_ROUTER_MODE=host`**: shortlist first, then **`picked_names`**.
121
+
122
+ 2. **Use the returned skill text** in your answer.
123
+
124
+ 3. Optionally **`materialize_project`** to refresh this file and **`.cursor`** files.
125
+
126
+ ## Skills last materialized for this project
127
+
128
+ {skills_md}
129
+ """
130
+ if claude_cmd.exists() and not merge:
131
+ pass
132
+ else:
133
+ claude_cmd.write_text(cc_body, encoding="utf-8")
134
+ written.append(str(claude_cmd.relative_to(root)))
135
+
70
136
  docs_dir = root / "docs"
71
137
  docs_dir.mkdir(parents=True, exist_ok=True)
72
138
  prd = docs_dir / "SKILLFORGE-PRD.md"
@@ -82,6 +148,8 @@ Scaffold for goals and milestones. Re-run **materialize_project** after major ro
82
148
  ## How to run Skillforge here
83
149
 
84
150
  - **MCP**: configure the `skillforge` server (e.g. `npx -y @heytherevibin/skillforge mcp`). No API key required for embedding-only routing.
151
+ - **Cursor**: use **`/skillforge`** (**`.cursor/commands/skillforge.md`**) to steer the agent through **route_skills** for this workspace.
152
+ - **Claude Code**: use **`/skillforge`** (**`.claude/commands/skillforge.md`**) the same way.
85
153
  - **session_id**: reuse the same value across **route_skills** calls in one conversation thread.
86
154
  - Re-bootstrap this project after new skills: **materialize_project** again.
87
155
 
@@ -110,7 +178,7 @@ Use [Skillforge](https://www.npmjs.com/package/@heytherevibin/skillforge) for **
110
178
 
111
179
  - Call **route_skills** for the current task; reuse **session_id** within a thread.
112
180
  - See **docs/SKILLFORGE-PRD.md** for the skill list and runbook.
113
- - Map **`/skillforge`** in agent rules to the MCP tools above.
181
+ - In Cursor, **`/skillforge`** is **`.cursor/commands/skillforge.md`**; in **Claude Code**, **`.claude/commands/skillforge.md`** (after **materialize_project**).
114
182
 
115
183
  **Routed skills (last materialize):** {skill_list}
116
184
 
@@ -4,6 +4,9 @@ Versioned ``_meta`` with ``sources[]`` and ``budget``. Schema **1.1** adds
4
4
  per-chunk ``sources`` when ``context_items`` (RAG chunks) are returned from Phase 1.
5
5
  Schema **1.2** adds ``kind: file`` sources and project chunk char counts in ``budget``.
6
6
  Schema **1.4** adds optional ``context_redaction`` (hit counts when scrubbing is on).
7
+ Schema **1.5** adds optional ``route_quality`` (shortlist margins, hybrid diagnostics, policy/session).
8
+ Schema **1.6** adds optional ``feedback_effect`` (per-pick learned weights / thumbs / uses used in ranking).
9
+ Schema **1.7** adds optional ``routing_overlay`` (project exclude/boost/notes audit for embedding shortlist).
7
10
  """
8
11
  from __future__ import annotations
9
12
 
@@ -18,7 +21,7 @@ class _SkillBody(Protocol):
18
21
  body: str
19
22
 
20
23
 
21
- MCP_RESPONSE_SCHEMA_VERSION = "1.4"
24
+ MCP_RESPONSE_SCHEMA_VERSION = "1.7"
22
25
 
23
26
 
24
27
  def build_route_skills_meta(
@@ -112,6 +115,15 @@ def build_route_skills_meta(
112
115
  "candidates_preview": candidates_preview,
113
116
  "context_items_count": len(context_items or []),
114
117
  }
118
+ rq_meta = result.get("route_quality")
119
+ if isinstance(rq_meta, dict):
120
+ meta["route_quality"] = rq_meta
121
+ fb_meta = result.get("feedback_effect")
122
+ if isinstance(fb_meta, dict):
123
+ meta["feedback_effect"] = fb_meta
124
+ ro_meta = result.get("routing_overlay")
125
+ if isinstance(ro_meta, dict):
126
+ meta["routing_overlay"] = ro_meta
115
127
  if fusion is not None and fusion.get("enabled"):
116
128
  meta["fusion"] = fusion
117
129
  if context_redaction is not None:
@@ -2,11 +2,11 @@
2
2
  MCP server for skillforge.
3
3
 
4
4
  Exposes skill routing as MCP tools so MCP-aware clients (Claude Desktop,
5
- Claude Code, Cursor, etc.) can use the orchestrator without running the
6
- HTTP server.
5
+ Claude Code, Cursor, etc.) can use the orchestrator locally.
7
6
 
8
7
  Tools exposed:
9
8
  route_skills / skillforge_bootstrap — routing (+ optional project materialize).
9
+ search_skills / explain_route / get_skill — retrieval, debugging, deterministic fetch.
10
10
  materialize_project — .cursor/rules, docs/SKILLFORGE-PRD.md, CLAUDE.md block.
11
11
  list_skills, skill_feedback, skill_referenced, disable_skill.
12
12
 
@@ -25,6 +25,9 @@ from pathlib import Path
25
25
 
26
26
  from app.db_paths import resolve_orchestrator_db
27
27
  from app.main import (
28
+ TOP_K_CANDIDATES,
29
+ MAX_ACTIVE_SKILLS,
30
+ SKILLFORGE_ROUTER_MODE,
28
31
  build_router_and_skills,
29
32
  format_context_items_markdown,
30
33
  init_db,
@@ -39,6 +42,14 @@ from app.main import (
39
42
  from app.materialize import materialize_project_files
40
43
  from app.mcp_contract import MCP_RESPONSE_SCHEMA_VERSION, build_route_skills_meta
41
44
  from app.redaction import redaction_enabled, redact_display_path
45
+ from app.route_policies import (
46
+ build_routing_overlay_payload,
47
+ load_route_policies_config,
48
+ merge_policy_includes,
49
+ merge_project_notes_into_route_query,
50
+ parse_routing_overlay,
51
+ )
52
+ from app.routing_signals import build_route_query_text
42
53
 
43
54
 
44
55
  def _env_truthy(name: str, default: str = "1") -> bool:
@@ -85,7 +96,7 @@ class MCPServer:
85
96
  self._db_cache: dict[str, sqlite3.Connection] = {}
86
97
 
87
98
  def _mcp_user_id(self, args: dict) -> str:
88
- """Per-tool user namespace for weights/sessions/events (aligned with HTTP bearer user id)."""
99
+ """Per-tool user namespace for weights/sessions/events."""
89
100
  raw = (
90
101
  args.get("user_id")
91
102
  or os.getenv("SKILLFORGE_MCP_USER_ID", "")
@@ -185,7 +196,7 @@ class MCPServer:
185
196
  return {
186
197
  "protocolVersion": "2024-11-05",
187
198
  "capabilities": caps,
188
- "serverInfo": {"name": "skillforge", "version": "0.7.0"},
199
+ "serverInfo": {"name": "skillforge", "version": "0.10.0"},
189
200
  }
190
201
 
191
202
  def handle_tools_list(self, params):
@@ -194,20 +205,25 @@ class MCPServer:
194
205
  {
195
206
  "name": "route_skills",
196
207
  "description": (
197
- "Route the user's prompt to the most relevant skills from the catalog "
198
- "and return SKILL.md context (full body or RAG chunks per CONTEXT_MODE). "
199
- "Returns up to 7 skills. "
200
- "Pass project_root for per-repo SQLite in .skillforge/ and learning. "
201
- "Optional include_project_rag merges top chunks from `skillforge index` into context. "
202
- "On success, _meta includes schema_version ("
203
- f"{MCP_RESPONSE_SCHEMA_VERSION}), sources[] (kind skill or file), "
204
- "budget (chars_skill_bodies, chars_project_chunks), fusion (MMR when combined index+RAG), "
205
- "candidates_preview, context_items_count."
208
+ "Two-step when SKILLFORGE_ROUTER_MODE=host (no in-process router LLM): (1) call with prompt "
209
+ "only returns a tight numbered shortlist + session_id; (2) call again with the same prompt "
210
+ "and picked_names (JSON array of exact catalog ids from the list) to load SKILL.md chunks. "
211
+ "With auto router modes, one call returns context. Optional conversation, project_root, "
212
+ "include_project_rag. picked_names may also be passed in embedding/full mode to skip "
213
+ "auto-pick and use the host-provided list."
206
214
  ),
207
215
  "inputSchema": {
208
216
  "type": "object",
209
217
  "properties": {
210
218
  "prompt": {"type": "string", "description": "The user's prompt or task description"},
219
+ "picked_names": {
220
+ "type": "array",
221
+ "items": {"type": "string"},
222
+ "description": (
223
+ "Host-chosen skill ids from the shortlist (same prompt as step 1). "
224
+ "Omit on first host-mode call; required for finalize after shortlist."
225
+ ),
226
+ },
211
227
  "project_root": {
212
228
  "type": "string",
213
229
  "description": "Repo/workspace root — stores orchestrator state in .skillforge/",
@@ -231,12 +247,80 @@ class MCPServer:
231
247
  },
232
248
  "user_id": {
233
249
  "type": "string",
234
- "description": "Logical user id for weights/sessions/events (same as HTTP user id string)",
250
+ "description": "Logical user id for weights/sessions/events",
235
251
  },
236
252
  },
237
253
  "required": ["prompt"],
238
254
  },
239
255
  },
256
+ {
257
+ "name": "search_skills",
258
+ "description": (
259
+ "Embedding-only retrieval: top skills for a query with similarity scores "
260
+ "and descriptions (no Haiku, no full route). Use to explore the catalog."
261
+ ),
262
+ "inputSchema": {
263
+ "type": "object",
264
+ "properties": {
265
+ "query": {"type": "string", "description": "Search query or task text"},
266
+ "limit": {
267
+ "type": "integer",
268
+ "description": f"Max skills to return (default {TOP_K_CANDIDATES})",
269
+ },
270
+ "project_root": {"type": "string"},
271
+ "user_id": {"type": "string"},
272
+ },
273
+ "required": ["query"],
274
+ },
275
+ },
276
+ {
277
+ "name": "explain_route",
278
+ "description": (
279
+ "Debug routing: embedding facets for the shortlist (same query text as route_skills when "
280
+ "`conversation` is passed — conversation-aware when SKILLFORGE_ROUTER_CONV_MAX_TURNS > 0), "
281
+ "optional Haiku rerank, Haiku/embedding-only pick with reasoning, and policy merge audit. "
282
+ "Does not write sessions or increment uses."
283
+ ),
284
+ "inputSchema": {
285
+ "type": "object",
286
+ "properties": {
287
+ "prompt": {"type": "string"},
288
+ "conversation": {"type": "array", "items": {"type": "object"}},
289
+ "limit": {
290
+ "type": "integer",
291
+ "description": "Max shortlist rows in facets (default TOP_K)",
292
+ },
293
+ "project_root": {"type": "string"},
294
+ "user_id": {"type": "string"},
295
+ },
296
+ "required": ["prompt"],
297
+ },
298
+ },
299
+ {
300
+ "name": "get_skill",
301
+ "description": (
302
+ "Load one skill by name: full SKILL.md body or a short summary. "
303
+ "Use for deterministic workflows when you already know the skill name."
304
+ ),
305
+ "inputSchema": {
306
+ "type": "object",
307
+ "properties": {
308
+ "skill_name": {"type": "string"},
309
+ "format": {
310
+ "type": "string",
311
+ "enum": ["full", "summary"],
312
+ "description": "summary = description + first ~8k chars of body",
313
+ "default": "full",
314
+ },
315
+ "max_chars": {
316
+ "type": "integer",
317
+ "description": "If > 0, truncate body to this many characters",
318
+ "default": 0,
319
+ },
320
+ },
321
+ "required": ["skill_name"],
322
+ },
323
+ },
240
324
  {
241
325
  "name": "list_skills",
242
326
  "description": (
@@ -303,6 +387,8 @@ class MCPServer:
303
387
  "name": "materialize_project",
304
388
  "description": (
305
389
  "Write project-local Skillforge files: .cursor/rules/skillforge.mdc, "
390
+ ".cursor/commands/skillforge.md (Cursor /skillforge), "
391
+ ".claude/commands/skillforge.md (Claude Code /skillforge), "
306
392
  "docs/SKILLFORGE-PRD.md, and a CLAUDE.md section. "
307
393
  "Pass project_root (workspace path) and skill_names from route_skills. "
308
394
  "Hosts must supply project_root; MCP does not infer cwd."
@@ -318,7 +404,11 @@ class MCPServer:
318
404
  },
319
405
  "merge": {
320
406
  "type": "boolean",
321
- "description": "If false and .cursor/rules/skillforge.mdc exists, skip overwriting that file",
407
+ "description": (
408
+ "If false and .cursor/rules/skillforge.mdc, "
409
+ ".cursor/commands/skillforge.md, or "
410
+ ".claude/commands/skillforge.md exists, skip overwriting those files"
411
+ ),
322
412
  "default": True,
323
413
  },
324
414
  },
@@ -358,6 +448,12 @@ class MCPServer:
358
448
 
359
449
  if name == "route_skills":
360
450
  return await self._tool_route_skills(args)
451
+ if name == "search_skills":
452
+ return self._tool_search_skills(args)
453
+ if name == "explain_route":
454
+ return await self._tool_explain_route(args)
455
+ if name == "get_skill":
456
+ return self._tool_get_skill(args)
361
457
  if name == "list_skills":
362
458
  return self._tool_list_skills(args)
363
459
  if name == "skill_feedback":
@@ -396,6 +492,16 @@ class MCPServer:
396
492
  ),
397
493
  }
398
494
 
495
+ picked_names_from_host_supplied = "picked_names" in args
496
+ if picked_names_from_host_supplied:
497
+ raw_pn = args.get("picked_names")
498
+ if isinstance(raw_pn, list):
499
+ picked_names_from_host = [str(x) for x in raw_pn if x is not None]
500
+ else:
501
+ picked_names_from_host = []
502
+ else:
503
+ picked_names_from_host = None
504
+
399
505
  con = self._get_con(args)
400
506
  result = await run_route_turn(
401
507
  con,
@@ -406,6 +512,8 @@ class MCPServer:
406
512
  session_id=session_id,
407
513
  project_root=pr,
408
514
  include_project_rag=self._include_project_rag_from_args(args),
515
+ picked_names_from_host=picked_names_from_host,
516
+ picked_names_from_host_supplied=picked_names_from_host_supplied,
409
517
  )
410
518
  picked_names = result["picked_names"]
411
519
  reasoning = result["reasoning"]
@@ -425,22 +533,29 @@ class MCPServer:
425
533
  "context_mode": self.router.context_mode,
426
534
  "context_items_count": len(context_items),
427
535
  "project_rag_items_count": (result.get("event") or {}).get("project_rag_items_count", 0),
536
+ "host_pick_shortlist": bool(result.get("host_pick_shortlist")),
428
537
  }
429
538
  (d / "last_route.json").write_text(json.dumps(snap, indent=2), encoding="utf-8")
430
539
  except OSError:
431
540
  pass
432
541
 
433
542
  db_disp = redact_display_path(db_path) if redaction_enabled() else str(db_path)
434
- blocks = [
435
- f"# Skillforge routed {len(picked_names)} skill(s); context=`{self.router.context_mode}`",
436
- f"_DB:_ `{db_disp}`",
437
- f"_Reasoning: {reasoning}_" if reasoning else "",
438
- "",
439
- ]
440
- if context_items:
441
- blocks.append(format_context_items_markdown(context_items))
442
- elif not picked_names:
443
- blocks.append("_No skills matched this prompt closely enough to load._")
543
+ if result.get("host_pick_shortlist"):
544
+ response_text = (result.get("host_pick_markdown") or "").strip() + (
545
+ f"\n\n---\n_session_id:_ `{result['session_id']}` · _orchestrator:_ `{db_disp}`"
546
+ )
547
+ blocks = [response_text]
548
+ else:
549
+ blocks = [
550
+ f"# Skillforge — routed {len(picked_names)} skill(s); context=`{self.router.context_mode}`",
551
+ f"_DB:_ `{db_disp}`",
552
+ f"_Reasoning: {reasoning}_" if reasoning else "",
553
+ "",
554
+ ]
555
+ if context_items:
556
+ blocks.append(format_context_items_markdown(context_items))
557
+ elif not picked_names:
558
+ blocks.append("_No skills matched this prompt closely enough to load._")
444
559
  response_text = "\n".join(b for b in blocks if b is not None)
445
560
  meta = build_route_skills_meta(
446
561
  result=result,
@@ -453,11 +568,208 @@ class MCPServer:
453
568
  fusion=(result.get("event") or {}).get("context_fusion"),
454
569
  context_redaction=(result.get("event") or {}).get("context_redaction"),
455
570
  )
571
+ if result.get("host_pick_shortlist"):
572
+ meta["host_pick_shortlist"] = True
573
+ meta["host_pick_candidates"] = result.get("host_pick_candidates") or []
456
574
  return {
457
575
  "content": [{"type": "text", "text": response_text}],
458
576
  "_meta": meta,
459
577
  }
460
578
 
579
+ def _tool_search_skills(self, args):
580
+ query = (args.get("query") or "").strip()
581
+ user_id = self._mcp_user_id(args)
582
+ pr = self._project_root_from_args(args)
583
+ db_path = resolve_orchestrator_db(pr)
584
+ if not query:
585
+ return {
586
+ "content": [{"type": "text", "text": "query is required."}],
587
+ "isError": True,
588
+ }
589
+ try:
590
+ limit = int(args.get("limit") or TOP_K_CANDIDATES)
591
+ except (TypeError, ValueError):
592
+ limit = TOP_K_CANDIDATES
593
+ limit = max(1, min(limit, 50))
594
+ con = self._get_con(args)
595
+ policies_cfg = load_route_policies_config(pr)
596
+ overlay_audit = []
597
+ exclude_skills, routing_boosts, project_notes = parse_routing_overlay(
598
+ policies_cfg,
599
+ by_name=self.router._by_name,
600
+ audit_out=overlay_audit,
601
+ )
602
+ q2 = merge_project_notes_into_route_query(query, project_notes, pr)
603
+ facets = self.router.shortlist_with_facets(
604
+ q2,
605
+ con,
606
+ k=limit,
607
+ user_id=user_id,
608
+ exclude_skills=exclude_skills,
609
+ routing_boosts=routing_boosts,
610
+ )
611
+ lines = ["# search_skills — embedding shortlist", ""]
612
+ for f in facets:
613
+ lines.append(
614
+ f"- **{f['name']}** (cos {f['cosine_similarity']}, score {f['routing_score']}): "
615
+ f"{(f.get('description_preview') or '')[:220]}"
616
+ )
617
+ text = "\n".join(lines)
618
+ return {
619
+ "content": [{"type": "text", "text": text}],
620
+ "_meta": {
621
+ "schema_version": MCP_RESPONSE_SCHEMA_VERSION,
622
+ "tool": "search_skills",
623
+ "orchestrator_db": redact_display_path(db_path) if redaction_enabled() else str(db_path),
624
+ "results": facets,
625
+ "count": len(facets),
626
+ },
627
+ }
628
+
629
+ async def _tool_explain_route(self, args):
630
+ prompt = (args.get("prompt") or "").strip()
631
+ conversation = args.get("conversation") or []
632
+ user_id = self._mcp_user_id(args)
633
+ pr = self._project_root_from_args(args)
634
+ db_path = resolve_orchestrator_db(pr)
635
+ if not prompt:
636
+ return {
637
+ "content": [{"type": "text", "text": "prompt is required."}],
638
+ "isError": True,
639
+ }
640
+ try:
641
+ limit = int(args.get("limit") or TOP_K_CANDIDATES)
642
+ except (TypeError, ValueError):
643
+ limit = TOP_K_CANDIDATES
644
+ limit = max(1, min(limit, 50))
645
+ con = self._get_con(args)
646
+ policies_cfg = load_route_policies_config(pr)
647
+ overlay_audit = []
648
+ exclude_skills, routing_boosts, project_notes = parse_routing_overlay(
649
+ policies_cfg,
650
+ by_name=self.router._by_name,
651
+ audit_out=overlay_audit,
652
+ )
653
+ route_query = merge_project_notes_into_route_query(
654
+ build_route_query_text(prompt, conversation),
655
+ project_notes,
656
+ pr,
657
+ )
658
+ facets = self.router.shortlist_with_facets(
659
+ route_query,
660
+ con,
661
+ k=limit,
662
+ user_id=user_id,
663
+ exclude_skills=exclude_skills,
664
+ routing_boosts=routing_boosts,
665
+ )
666
+ candidates = self.router.shortlist(
667
+ route_query,
668
+ con,
669
+ limit,
670
+ user_id,
671
+ exclude_skills=exclude_skills,
672
+ routing_boosts=routing_boosts,
673
+ )
674
+ candidates = await self.router.rerank_candidates_haiku(route_query, conversation, candidates)
675
+ picked, reasoning = await self.router.pick_final(
676
+ prompt, conversation, candidates, route_query=route_query
677
+ )
678
+ merged, policy_audit = merge_policy_includes(
679
+ prompt,
680
+ list(picked),
681
+ policies_cfg,
682
+ self.router._by_name,
683
+ con,
684
+ user_id,
685
+ max_active=MAX_ACTIVE_SKILLS,
686
+ )
687
+ router_mode = "full" if self.router.anthropic else "embedding-only"
688
+ notes_effective = bool(project_notes.strip() and (pr or "").strip())
689
+ routing_ov = build_routing_overlay_payload(
690
+ project_root=pr or "",
691
+ exclude_skills=exclude_skills,
692
+ routing_boosts=routing_boosts,
693
+ project_notes_applied=notes_effective,
694
+ project_notes_len=len(project_notes) if project_notes else 0,
695
+ audit=overlay_audit,
696
+ )
697
+ explain = {
698
+ "schema_version": MCP_RESPONSE_SCHEMA_VERSION,
699
+ "tool": "explain_route",
700
+ "orchestrator_db": redact_display_path(db_path) if redaction_enabled() else str(db_path),
701
+ "router_mode": router_mode,
702
+ "embedding_shortlist": facets,
703
+ "picked_before_policy": list(picked),
704
+ "picked_after_policy": merged,
705
+ "router_reasoning": reasoning,
706
+ "policy": {
707
+ "rules_loaded": len(policies_cfg.get("rules") or [])
708
+ if isinstance(policies_cfg.get("rules"), list)
709
+ else 0,
710
+ "audit": policy_audit,
711
+ },
712
+ }
713
+ if routing_ov is not None:
714
+ explain["routing_overlay"] = routing_ov
715
+ lines = [
716
+ "# explain_route — routing diagnostics (no DB writes)",
717
+ "",
718
+ f"**Router:** {router_mode}",
719
+ f"**Picked (router):** {', '.join(picked) if picked else '_(none)_'}",
720
+ f"**After policies:** {', '.join(merged) if merged else '_(none)_'}",
721
+ f"**Reasoning:** {reasoning}" if reasoning else "**Reasoning:** _(n/a)_",
722
+ "",
723
+ "## Shortlist (embedding)",
724
+ ]
725
+ for f in facets[:15]:
726
+ lines.append(
727
+ f"- `{f['name']}` cos={f['cosine_similarity']} weight={f['learned_weight']} "
728
+ f"score={f['routing_score']}"
729
+ )
730
+ if policy_audit:
731
+ lines.extend(["", "## Policy audit"])
732
+ for row in policy_audit[:30]:
733
+ lines.append(f"- {row}")
734
+ body = "\n".join(lines)
735
+ return {"content": [{"type": "text", "text": body}], "_meta": explain}
736
+
737
+ def _tool_get_skill(self, args):
738
+ name = (args.get("skill_name") or "").strip()
739
+ fmt = (args.get("format") or "full").strip().lower()
740
+ if fmt not in ("full", "summary"):
741
+ fmt = "full"
742
+ max_chars = args.get("max_chars")
743
+ try:
744
+ mc = int(max_chars) if max_chars is not None else 0
745
+ except (TypeError, ValueError):
746
+ mc = 0
747
+ if not name or name not in self.skills:
748
+ return {
749
+ "content": [{"type": "text", "text": f"Unknown skill: {name or '(empty)'}"}],
750
+ "isError": True,
751
+ }
752
+ s = self.skills[name]
753
+ if fmt == "summary":
754
+ body = f"{s.description}\n\n---\n\n{(s.body or '')[:8000]}"
755
+ else:
756
+ body = s.body or ""
757
+ if mc > 0:
758
+ body = body[:mc]
759
+ header = f"# get_skill: `{name}`\n**Source:** {s.source} · **format:** {fmt}\n\n"
760
+ text = header + body
761
+ return {
762
+ "content": [{"type": "text", "text": text}],
763
+ "_meta": {
764
+ "schema_version": MCP_RESPONSE_SCHEMA_VERSION,
765
+ "tool": "get_skill",
766
+ "skill_name": name,
767
+ "source": s.source,
768
+ "format": fmt,
769
+ "chars": len(body),
770
+ },
771
+ }
772
+
461
773
  def _tool_list_skills(self, args):
462
774
  user_id = self._mcp_user_id(args)
463
775
  con = self._get_con(args)
@@ -551,6 +863,13 @@ class MCPServer:
551
863
  session_id = args.get("session_id") or None
552
864
  user_id = self._mcp_user_id(args)
553
865
  merge = args.get("merge", True)
866
+ if SKILLFORGE_ROUTER_MODE == "host":
867
+ msg = (
868
+ "skillforge_bootstrap does not support SKILLFORGE_ROUTER_MODE=host (two-step routing). "
869
+ "Set SKILLFORGE_ROUTER_MODE=embedding for one-shot bootstrap, or call route_skills twice "
870
+ "(shortlist then picked_names) and materialize_project yourself."
871
+ )
872
+ return {"content": [{"type": "text", "text": msg}], "isError": True}
554
873
  if not prompt.strip():
555
874
  return {"content": [{"type": "text", "text": "No prompt provided."}], "isError": True}
556
875
  if not root: