@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.
- package/CHANGELOG.md +29 -0
- package/CONTRIBUTING.md +30 -19
- package/README.md +248 -198
- package/RELEASING.md +19 -7
- package/SECURITY.md +61 -13
- package/STRATEGY.md +40 -14
- package/bin/cli.js +112 -5
- package/ci/bundle-gate.json +4 -0
- package/lib/host-setup.js +312 -0
- package/lib/templates/claude-code-skillforge-global.md +19 -0
- package/lib/templates/cursor-skillforge-global.md +16 -0
- package/package.json +3 -2
- package/python/app/eval_cli.py +133 -0
- package/python/app/feedback_meta.py +96 -0
- package/python/app/health_cli.py +160 -0
- package/python/app/main.py +502 -26
- package/python/app/materialize.py +72 -4
- package/python/app/mcp_contract.py +13 -1
- package/python/app/mcp_server.py +344 -25
- package/python/app/route_cli.py +32 -13
- package/python/app/route_eval_harness.py +98 -0
- package/python/app/route_policies.py +243 -0
- package/python/app/route_quality.py +99 -0
- package/python/app/routing_signals.py +155 -0
- package/python/app/weights_cli.py +152 -0
- package/python/fixtures/route_eval/smoke.json +18 -0
- package/python/requirements.txt +1 -0
- package/python/tests/test_feedback_weights.py +77 -0
- package/python/tests/test_materialize.py +51 -0
- package/python/tests/test_mcp_contract.py +117 -0
- package/python/tests/test_route_eval_harness.py +45 -0
- package/python/tests/test_route_policies.py +115 -0
- package/python/tests/test_route_quality.py +120 -0
- package/python/tests/test_routing_overlay.py +55 -0
- package/python/tests/test_routing_signals.py +112 -0
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
"""Write project-local Skillforge bootstrap files (.cursor/
|
|
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.
|
|
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
|
|
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
|
-
-
|
|
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.
|
|
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:
|
package/python/app/mcp_server.py
CHANGED
|
@@ -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
|
|
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
|
|
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.
|
|
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
|
-
"
|
|
198
|
-
"
|
|
199
|
-
"
|
|
200
|
-
"
|
|
201
|
-
"
|
|
202
|
-
"
|
|
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
|
|
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":
|
|
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
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
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:
|