@heytherevibin/skillforge 0.10.1 → 0.11.7

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 (58) hide show
  1. package/CHANGELOG.md +49 -0
  2. package/CONTRIBUTING.md +5 -3
  3. package/README.md +37 -345
  4. package/RELEASING.md +7 -6
  5. package/STRATEGY.md +2 -2
  6. package/bin/cli.js +297 -52
  7. package/ci/test-user-env-profile.cjs +65 -0
  8. package/docs/README.md +14 -0
  9. package/docs/architecture-and-data.md +90 -0
  10. package/docs/cli-reference.md +57 -0
  11. package/docs/environment-and-configuration.md +76 -0
  12. package/docs/getting-started.md +88 -0
  13. package/docs/mcp-integration.md +75 -0
  14. package/docs/troubleshooting.md +50 -0
  15. package/lib/templates/claude-code-skillforge-global.md +3 -3
  16. package/lib/templates/cursor-skillforge-global.md +6 -2
  17. package/lib/user-env-profile.js +141 -0
  18. package/package.json +3 -2
  19. package/python/app/agent_cli.py +334 -0
  20. package/python/app/explain_route.py +170 -0
  21. package/python/app/health_cli.py +13 -0
  22. package/python/app/main.py +131 -48
  23. package/python/app/materialize.py +150 -68
  24. package/python/app/mcp_contract.py +2 -1
  25. package/python/app/mcp_operator.py +252 -0
  26. package/python/app/mcp_server.py +290 -118
  27. package/python/app/npm_pkg_version.py +38 -0
  28. package/python/app/pick_diversify.py +51 -0
  29. package/python/app/replay_cli.py +145 -0
  30. package/python/app/route_cli.py +251 -87
  31. package/python/app/route_cli_pick.py +35 -0
  32. package/python/app/route_policies.py +18 -3
  33. package/python/app/route_quality.py +70 -1
  34. package/python/app/router_llm.py +85 -0
  35. package/python/app/router_mode.py +21 -0
  36. package/python/app/routing_signals.py +7 -1
  37. package/python/app/skill_manifest.py +67 -0
  38. package/python/app/skills_author_cli.py +117 -0
  39. package/python/app/tips_cli.py +37 -0
  40. package/python/app/tools_cli.py +276 -0
  41. package/python/fixtures/route_eval/smoke.json +5 -0
  42. package/python/requirements.txt +1 -0
  43. package/python/tests/test_capabilities_bundle.py +33 -0
  44. package/python/tests/test_materialize_hosts.py +108 -0
  45. package/python/tests/test_mcp_contract.py +1 -1
  46. package/python/tests/test_mcp_initialize_clientinfo.py +26 -0
  47. package/python/tests/test_mcp_operator.py +84 -0
  48. package/python/tests/test_npm_pkg_version.py +21 -0
  49. package/python/tests/test_pick_diversify.py +47 -0
  50. package/python/tests/test_replay_cli.py +31 -0
  51. package/python/tests/test_route_cli_pick.py +25 -0
  52. package/python/tests/test_route_policies.py +29 -0
  53. package/python/tests/test_route_quality.py +72 -0
  54. package/python/tests/test_router_llm.py +63 -0
  55. package/python/tests/test_router_mode_env.py +21 -0
  56. package/python/tests/test_routing_signals.py +20 -0
  57. package/python/tests/test_skill_manifest.py +48 -0
  58. package/python/tests/test_tools_cli.py +69 -0
@@ -0,0 +1,145 @@
1
+ """Replay SQLite events chronologically for a session or window (CLI debugging).
2
+
3
+ Not a deterministic 're-invoke routing' sandbox — timelines are reconstructed from persisted events."""
4
+
5
+ from __future__ import annotations
6
+
7
+ import argparse
8
+ import json
9
+ import sqlite3
10
+
11
+ from app.db_paths import resolve_orchestrator_db
12
+
13
+
14
+ def _replay_rows(
15
+ con: sqlite3.Connection,
16
+ *,
17
+ session_id: str | None,
18
+ user_id: str,
19
+ newest_first_snapshot: bool,
20
+ limit: int,
21
+ ) -> list[tuple[float, str | None, str | None, str | None]]:
22
+ uid = (user_id or "").strip()
23
+ lim = max(1, min(int(limit), 5000))
24
+
25
+ cur: sqlite3.Cursor
26
+ if session_id and session_id.strip():
27
+ sid = session_id.strip()
28
+ cur = con.execute(
29
+ """
30
+ SELECT ts, session_id, event_type, payload
31
+ FROM events
32
+ WHERE user_id = ? AND session_id = ?
33
+ ORDER BY ts ASC
34
+ LIMIT ?
35
+ """,
36
+ (uid, sid, lim),
37
+ )
38
+ else:
39
+ order = "DESC" if newest_first_snapshot else "ASC"
40
+ cur = con.execute(
41
+ f"""
42
+ SELECT ts, session_id, event_type, payload
43
+ FROM events
44
+ WHERE user_id = ?
45
+ ORDER BY ts {order}
46
+ LIMIT ?
47
+ """,
48
+ (uid, lim),
49
+ )
50
+ rows = [(float(ts), sid, et, payload) for ts, sid, et, payload in cur.fetchall()]
51
+ return rows
52
+
53
+
54
+ def _format_human(ts: float, sid: str | None, et: str | None, payload_raw: str | None) -> str:
55
+ payload: dict[str, object] | str = {}
56
+ if payload_raw:
57
+ try:
58
+ payload = json.loads(payload_raw)
59
+ except json.JSONDecodeError:
60
+ payload = payload_raw
61
+ sid_short = ((sid or "-")[:12]) if sid else "-"
62
+ bullet = f"- ts={ts:.3f} session={sid_short} event={et or '?'} "
63
+ if et == "route" and isinstance(payload, dict):
64
+ picked = payload.get("picked_names") or payload.get("picked") or ""
65
+ bullet += f" picked={picked!r}"
66
+ if payload.get("host_pick_shortlist"):
67
+ bullet += " [shortlist]"
68
+ elif isinstance(payload, dict):
69
+ js = json.dumps(payload, ensure_ascii=False)
70
+ bullet += js[:260]
71
+ if len(js) > 260:
72
+ bullet += "…"
73
+ else:
74
+ bullet += str(payload)[:260]
75
+ return bullet
76
+
77
+
78
+ def main() -> None:
79
+ ap = argparse.ArgumentParser(description="Replay orchestrator SQLite events as a chronological timeline.")
80
+ ap.add_argument(
81
+ "--project-root",
82
+ default="",
83
+ help="Workspace root (uses <root>/.skillforge/orchestrator.db)",
84
+ )
85
+ ap.add_argument("--session-id", default="", help="If set, only events for this session_id (chrono ASC)")
86
+ ap.add_argument(
87
+ "--user",
88
+ default="",
89
+ metavar="USER_ID",
90
+ help="Logical MCP user namespace (matches SKILLFORGE_MCP_USER_ID)",
91
+ )
92
+ ap.add_argument(
93
+ "--limit",
94
+ type=int,
95
+ default=200,
96
+ help="Max rows (default 200, max 5000). Per-session replay uses chronological ASC.",
97
+ )
98
+ ap.add_argument(
99
+ "--newest-first",
100
+ action="store_true",
101
+ help="Without --session-id, take the newest slice (DESC) instead of oldest ASC snapshot",
102
+ )
103
+ ap.add_argument(
104
+ "--json",
105
+ action="store_true",
106
+ help="Emit JSON array [{ts,session_id,event_type,payload}, …]",
107
+ )
108
+ args = ap.parse_args()
109
+
110
+ db_path = resolve_orchestrator_db((args.project_root or "").strip() or None)
111
+ if not db_path.exists():
112
+ print(f"No database yet: {db_path}")
113
+ raise SystemExit(1)
114
+
115
+ con = sqlite3.connect(str(db_path))
116
+ sess = (args.session_id or "").strip() or None
117
+ rows_raw = _replay_rows(
118
+ con,
119
+ session_id=sess,
120
+ user_id=args.user,
121
+ newest_first_snapshot=bool(args.newest_first),
122
+ limit=args.limit,
123
+ )
124
+ con.close()
125
+
126
+ if args.json:
127
+ out: list[dict[str, object]] = []
128
+ for ts, sid, et, pay in rows_raw:
129
+ try:
130
+ parsed: object = json.loads(pay) if pay else {}
131
+ except json.JSONDecodeError:
132
+ parsed = pay or ""
133
+ out.append({"ts": ts, "session_id": sid, "event_type": et, "payload": parsed})
134
+ print(json.dumps(out, indent=2))
135
+ return
136
+
137
+ print(f"# skillforge replay db={db_path}")
138
+ sess_display = sess if sess else "(all sessions)"
139
+ print(f"# user_id={args.user!r} session_filter={sess_display!r} rows={len(rows_raw)}")
140
+ for ts, sid, et, pay in rows_raw:
141
+ print(_format_human(ts, sid, et, pay))
142
+
143
+
144
+ if __name__ == "__main__":
145
+ main()
@@ -1,15 +1,19 @@
1
- """Terminal routing — same pipeline as MCP ``route_skills`` (for scripting and parity checks)."""
1
+ """Terminal routing — same pipeline as MCP ``route_skills`` (scripting + interactive host-pick)."""
2
2
  from __future__ import annotations
3
3
 
4
4
  import argparse
5
5
  import asyncio
6
6
  import json
7
+ import os
7
8
  import sys
8
9
  import time
9
10
  from pathlib import Path
10
11
 
11
12
  from app.db_paths import resolve_orchestrator_db
13
+ from app.explain_route import compute_explain_route
12
14
  from app.main import (
15
+ TOP_K_CANDIDATES,
16
+ SKILLFORGE_ROUTER_MODE,
13
17
  build_router_and_skills,
14
18
  format_context_items_markdown,
15
19
  init_db,
@@ -17,137 +21,296 @@ from app.main import (
17
21
  )
18
22
  from app.mcp_contract import MCP_RESPONSE_SCHEMA_VERSION, build_route_skills_meta
19
23
  from app.redaction import redaction_enabled, redact_display_path
24
+ from app.route_cli_pick import parse_interactive_skill_pick
25
+
26
+
27
+ def _truthy_route_env(name: str, default: str = "0") -> bool:
28
+ return os.getenv(name, default).strip().lower() not in ("0", "false", "no", "")
29
+
30
+
31
+ def _interactive_enabled(args: argparse.Namespace, *, stdin_tty: bool) -> bool:
32
+ if args.no_interactive:
33
+ return False
34
+ if args.interactive:
35
+ return stdin_tty
36
+ return _truthy_route_env("SKILLFORGE_ROUTE_INTERACTIVE") and stdin_tty
20
37
 
21
38
 
22
39
  def _parse_args(argv: list[str] | None) -> argparse.Namespace:
23
- p = argparse.ArgumentParser(description="Route a prompt through Skillforge (stdio: skill bodies + metadata).")
40
+ p = argparse.ArgumentParser(description="Route a prompt through Skillforge (parity with MCP route_skills).")
41
+ p.add_argument("prompt_parts", nargs="*", help="Prompt (positional words, joined). Or use --prompt.")
42
+ p.add_argument("--prompt", "-p", default="", help="Alternative prompt string.")
43
+ p.add_argument("--project-root", default="", help="Workspace root → project .skillforge/orchestrator.db.")
44
+ p.add_argument("--session-id", default="", help="Stable session id.")
45
+ p.add_argument("--user-id", default="", help="Logical MCP user scope (weights/events).")
24
46
  p.add_argument(
25
- "prompt_parts",
26
- nargs="*",
27
- help="Prompt text (whitespace-joined). Prefer this or use --prompt.",
28
- )
29
- p.add_argument("--prompt", "-p", default="", help="Prompt string (alternative to positional words).")
30
- p.add_argument(
31
- "--project-root",
47
+ "--picked-names",
32
48
  default="",
33
- help="Repo rootuses .skillforge/orchestrator.db; else SKILLFORGE_PROJECT_ROOT or global DB.",
49
+ help="Comma-separated catalog ids host finalize (parity with MCP picked_names).",
34
50
  )
35
- p.add_argument("--session-id", default="", help="Stable session id (reuse across turns for reroute stats).")
36
- p.add_argument("--user-id", default="", help="Logical user id for weights/sessions/events.")
51
+ p.add_argument("--json-meta", action="store_true", help="Print route_meta JSON on stderr after stdout (legacy).")
52
+ p.add_argument("--json", action="store_true", help="One JSON envelope on stdout (scripting-friendly).")
37
53
  p.add_argument(
38
- "--picked-names",
39
- default="",
40
- help="Comma-separated catalog skill ids (host pick). Skips auto router/Haiku; same as MCP picked_names.",
54
+ "--explain",
55
+ action="store_true",
56
+ help="Diagnostics: embedding shortlist + router hypothesis (explain_route pipeline). ",
41
57
  )
42
- p.add_argument("--json-meta", action="store_true", help="Print routing metadata as JSON on stderr after output.")
43
58
  p.add_argument(
44
- "--include-project-rag",
59
+ "--explain-only",
45
60
  action="store_true",
46
- help="Append chunks from `skillforge index` (same DB as --project-root). Requires --project-root.",
61
+ help="Only diagnostics no finalize (ignores picked-names). ",
47
62
  )
63
+ p.add_argument("--explain-limit", type=int, default=0, metavar="N", help="explain shortlist width (≤50). ")
64
+ p.add_argument("-i", "--interactive", action="store_true", help="TTY prompt after host-mode shortlist. ")
65
+ p.add_argument("--no-interactive", action="store_true", help="Disable env-driven auto prompts. ")
66
+ p.add_argument("--quiet", action="store_true", help="Less stderr chatter during bootstrap. ")
67
+ p.add_argument("--include-project-rag", action="store_true", help="Needs --project-root. ")
48
68
  return p.parse_args(argv)
49
69
 
50
70
 
71
+ def _markdown_route(result: dict, *, sid: str, db_disp: str, router) -> str:
72
+ picked_names = result["picked_names"]
73
+ reasoning = result["reasoning"]
74
+ context_items = result.get("context_items") or []
75
+ if result.get("host_pick_shortlist"):
76
+ return (
77
+ (result.get("host_pick_markdown") or "").strip() + f"\n\n---\n_session_id:_ `{sid}` · _DB:_ `{db_disp}`"
78
+ ).strip()
79
+ blocks = [
80
+ f"# Skillforge — routed {len(picked_names)} skill(s); context=`{router.context_mode}`",
81
+ f"_DB:_ `{db_disp}`",
82
+ f"_Reasoning: {reasoning}_" if reasoning else "",
83
+ "",
84
+ ]
85
+ if context_items:
86
+ blocks.append(format_context_items_markdown(context_items))
87
+ elif not picked_names:
88
+ blocks.append("_No skills matched this prompt closely enough to load._")
89
+ return "\n".join(b for b in blocks if b is not None)
90
+
91
+
92
+ def _meta(result: dict, *, md_out: str, user_id: str, db_path, skills_map) -> dict:
93
+ m = build_route_skills_meta(
94
+ result=result,
95
+ picked_names=list(result["picked_names"]),
96
+ user_id=user_id,
97
+ db_path=db_path,
98
+ skills_map=skills_map,
99
+ response_text=md_out,
100
+ context_items=result.get("context_items"),
101
+ fusion=(result.get("event") or {}).get("context_fusion"),
102
+ context_redaction=(result.get("event") or {}).get("context_redaction"),
103
+ )
104
+ if result.get("host_pick_shortlist"):
105
+ m["host_pick_shortlist"] = True
106
+ m["host_pick_candidates"] = result.get("host_pick_candidates") or []
107
+ return m
108
+
109
+
110
+ def _explain_lim(v: int) -> int:
111
+ lim = TOP_K_CANDIDATES if v <= 0 else v
112
+ return max(1, min(int(lim), 50))
113
+
114
+
115
+ def _write_last_route(rr: dict, *, pr: str | None, user_id: str, router, picks_via_interactive: list[str]) -> None:
116
+ if not pr:
117
+ return
118
+ try:
119
+ root = Path(pr).expanduser().resolve()
120
+ (root / ".skillforge").mkdir(parents=True, exist_ok=True)
121
+ snap = {
122
+ "ts": time.time(),
123
+ "session_id": rr["session_id"],
124
+ "picked": rr["picked_names"],
125
+ "reasoning": rr["reasoning"],
126
+ "route_ms": round(rr["route_ms"], 1),
127
+ "user_id": user_id,
128
+ "source": "cli_route",
129
+ "schema_version": MCP_RESPONSE_SCHEMA_VERSION,
130
+ "context_mode": router.context_mode,
131
+ "context_items_count": len(rr.get("context_items") or []),
132
+ "project_rag_items_count": (rr.get("event") or {}).get("project_rag_items_count", 0),
133
+ "host_pick_shortlist": bool(rr.get("host_pick_shortlist")),
134
+ "picked_via_interactive": picks_via_interactive,
135
+ }
136
+ (root / ".skillforge" / "last_route.json").write_text(json.dumps(snap, indent=2), encoding="utf-8")
137
+ except OSError:
138
+ pass
139
+
140
+
51
141
  async def _run(args: argparse.Namespace) -> int:
52
142
  text = " ".join(args.prompt_parts).strip() or args.prompt.strip()
53
143
  if not text:
54
- print("skillforge route: provide a prompt (positional words or --prompt).", file=sys.stderr)
144
+ print("skillforge route: provide a prompt.", file=sys.stderr)
55
145
  return 2
56
146
 
147
+ stdin_tty = sys.stdin.isatty()
57
148
  pr = (args.project_root or "").strip() or None
149
+ explain_only = bool(args.explain_only)
150
+ picked_ini = "" if explain_only else (args.picked_names or "").strip()
151
+ picks_ini = [x.strip() for x in picked_ini.split(",") if x.strip()] if picked_ini else []
152
+ cli_picked = picked_ini != ""
153
+
58
154
  if args.include_project_rag and not pr:
59
155
  print("skillforge route: --include-project-rag requires --project-root.", file=sys.stderr)
60
156
  return 2
157
+
158
+ tty_interactive = (
159
+ not cli_picked
160
+ and SKILLFORGE_ROUTER_MODE == "host"
161
+ and stdin_tty
162
+ and _interactive_enabled(args, stdin_tty=stdin_tty)
163
+ )
164
+
61
165
  db_path = resolve_orchestrator_db(pr)
62
166
  con = init_db(db_path)
63
167
  db_disp = redact_display_path(db_path) if redaction_enabled() else str(db_path)
64
-
65
- router, skills = await asyncio.to_thread(build_router_and_skills, log=True, log_prefix="[skillforge-route]")
66
- session_id = args.session_id.strip() or None
67
168
  user_id = args.user_id.strip()
169
+ session_id_arg = args.session_id.strip() or None
68
170
 
69
- picked_raw = (args.picked_names or "").strip()
70
- picked_supplied = bool(picked_raw)
71
- picked_list = [x.strip() for x in picked_raw.split(",") if x.strip()] if picked_raw else []
171
+ explain_md = None
172
+ explain_meta = None
173
+ want_diag = bool(args.explain or explain_only)
72
174
 
73
175
  try:
176
+ router, skills_map = await asyncio.to_thread(
177
+ build_router_and_skills, log=(not args.quiet), log_prefix="[skillforge-route]"
178
+ )
179
+
180
+ if want_diag:
181
+ explain_md, explain_meta = await compute_explain_route(
182
+ router,
183
+ con,
184
+ prompt=text,
185
+ conversation=[],
186
+ limit=_explain_lim(int(args.explain_limit)),
187
+ user_id=user_id,
188
+ project_root=pr,
189
+ db_path=db_path,
190
+ )
191
+ if explain_only:
192
+ if args.json:
193
+ print(
194
+ json.dumps({
195
+ "tool": "skillforge-route",
196
+ "schema_version": MCP_RESPONSE_SCHEMA_VERSION,
197
+ "phase": "explain_only",
198
+ "explain": explain_meta,
199
+ "explain_markdown": explain_md or "",
200
+ "orchestrator_db": db_disp,
201
+ "prompt": text,
202
+ }, indent=2),
203
+ flush=True,
204
+ )
205
+ else:
206
+ print(explain_md or "", flush=True)
207
+ return 0
208
+
209
+ picks_interactive_track: list[str] = []
210
+
74
211
  result = await run_route_turn(
75
212
  con,
76
213
  router,
77
214
  text,
78
215
  conversation=[],
79
216
  user_id=user_id,
80
- session_id=session_id,
217
+ session_id=session_id_arg,
81
218
  project_root=pr,
82
219
  include_project_rag=bool(args.include_project_rag),
83
- picked_names_from_host=picked_list if picked_supplied else None,
84
- picked_names_from_host_supplied=picked_supplied,
220
+ picked_names_from_host=picks_ini if cli_picked else None,
221
+ picked_names_from_host_supplied=cli_picked,
85
222
  )
86
- finally:
87
- con.close()
88
223
 
89
- picked_names = result["picked_names"]
90
- reasoning = result["reasoning"]
91
- sid = result["session_id"]
92
- context_items = result.get("context_items") or []
224
+ if tty_interactive and result.get("host_pick_shortlist"):
225
+ sid = result["session_id"]
226
+ rows = result.get("host_pick_candidates") or []
227
+ md0 = _markdown_route(result, sid=sid, db_disp=db_disp, router=router)
228
+ meta0 = _meta(result, md_out=md0, user_id=user_id, db_path=db_path, skills_map=skills_map)
229
+ hint = "(ranks 1,3… or ids; empty skips finalize)"
230
+ if args.json:
231
+ print(
232
+ json.dumps({
233
+ "tool": "skillforge-route",
234
+ "schema_version": MCP_RESPONSE_SCHEMA_VERSION,
235
+ "phase": "host_shortlist_prompt",
236
+ "prompt": text,
237
+ "session_id": sid,
238
+ "route_meta": meta0,
239
+ "route_markdown": md0,
240
+ "orchestrator_db": db_disp,
241
+ **({"explain": explain_meta} if explain_meta else {}),
242
+ }, indent=2),
243
+ flush=True,
244
+ )
245
+ else:
246
+ print(md0, flush=True)
247
+ if explain_md:
248
+ print(f"\n---\n{explain_md}\n", file=sys.stderr)
93
249
 
94
- if pr:
95
- try:
96
- d = Path(pr).expanduser().resolve() / ".skillforge"
97
- d.mkdir(parents=True, exist_ok=True)
98
- snap = {
99
- "ts": time.time(),
100
- "session_id": sid,
101
- "picked": picked_names,
102
- "reasoning": reasoning,
103
- "route_ms": round(result["route_ms"], 1),
104
- "user_id": user_id,
105
- "source": "cli_route",
250
+ ip = ""
251
+ try:
252
+ ip = input("" if args.quiet else f"skillforge route {hint}: ")
253
+ except EOFError:
254
+ ip = ""
255
+ chosen = parse_interactive_skill_pick(ip, rows)
256
+
257
+ if not chosen:
258
+ if args.json_meta:
259
+ print(json.dumps(meta0, indent=2), file=sys.stderr)
260
+ return 0
261
+
262
+ picks_interactive_track = chosen
263
+ result = await run_route_turn(
264
+ con,
265
+ router,
266
+ text,
267
+ conversation=[],
268
+ user_id=user_id,
269
+ session_id=sid,
270
+ project_root=pr,
271
+ include_project_rag=bool(args.include_project_rag),
272
+ picked_names_from_host=chosen,
273
+ picked_names_from_host_supplied=True,
274
+ )
275
+
276
+ sid = result["session_id"]
277
+ md_final = _markdown_route(result, sid=sid, db_disp=db_disp, router=router)
278
+ mf = _meta(result, md_out=md_final, user_id=user_id, db_path=db_path, skills_map=skills_map)
279
+ phase = (
280
+ "host_shortlist_static"
281
+ if result.get("host_pick_shortlist")
282
+ else "context"
283
+ )
284
+ _write_last_route(result, pr=pr, user_id=user_id, router=router, picks_via_interactive=picks_interactive_track)
285
+
286
+ if args.json:
287
+ env = {
288
+ "tool": "skillforge-route",
106
289
  "schema_version": MCP_RESPONSE_SCHEMA_VERSION,
107
- "context_mode": router.context_mode,
108
- "context_items_count": len(context_items),
109
- "project_rag_items_count": (result.get("event") or {}).get("project_rag_items_count", 0),
110
- "host_pick_shortlist": bool(result.get("host_pick_shortlist")),
290
+ "phase": phase,
291
+ "prompt": text,
292
+ "session_id": sid,
293
+ "route_meta": mf,
294
+ "route_markdown": md_final,
295
+ "orchestrator_db": db_disp,
111
296
  }
112
- (d / "last_route.json").write_text(json.dumps(snap, indent=2), encoding="utf-8")
113
- except OSError:
114
- pass
297
+ if picks_interactive_track:
298
+ env["picked_via_interactive"] = picks_interactive_track
299
+ if explain_meta:
300
+ env["explain"] = explain_meta
301
+ print(json.dumps(env, indent=2), flush=True)
302
+ else:
303
+ print(md_final, flush=True)
304
+ if args.explain and explain_md:
305
+ print(f"\n---\n{explain_md}\n", file=sys.stderr)
115
306
 
116
- if result.get("host_pick_shortlist"):
117
- response_text = ((result.get("host_pick_markdown") or "").strip() + f"\n\n---\n_session_id:_ `{sid}` · _DB:_ `{db_disp}`")
118
- print(response_text.strip())
119
- else:
120
- blocks = [
121
- f"# Skillforge — routed {len(picked_names)} skill(s); context=`{router.context_mode}`",
122
- f"_DB:_ `{db_disp}`",
123
- f"_Reasoning: {reasoning}_" if reasoning else "",
124
- "",
125
- ]
126
- if context_items:
127
- blocks.append(format_context_items_markdown(context_items))
128
- elif not picked_names:
129
- blocks.append("_No skills matched this prompt closely enough to load._")
130
- response_text = "\n".join(b for b in blocks if b is not None)
131
- print(response_text)
132
-
133
- if args.json_meta:
134
- meta = build_route_skills_meta(
135
- result=result,
136
- picked_names=picked_names,
137
- user_id=user_id,
138
- db_path=db_path,
139
- skills_map=skills,
140
- response_text=response_text,
141
- context_items=context_items,
142
- fusion=(result.get("event") or {}).get("context_fusion"),
143
- context_redaction=(result.get("event") or {}).get("context_redaction"),
144
- )
145
- if result.get("host_pick_shortlist"):
146
- meta["host_pick_shortlist"] = True
147
- meta["host_pick_candidates"] = result.get("host_pick_candidates") or []
148
- print(json.dumps(meta, indent=2), file=sys.stderr)
307
+ if args.json_meta:
308
+ print(json.dumps(mf, indent=2), file=sys.stderr)
309
+
310
+ return 0
149
311
 
150
- return 0
312
+ finally:
313
+ con.close()
151
314
 
152
315
 
153
316
  def main(argv: list[str] | None = None) -> None:
@@ -157,3 +320,4 @@ def main(argv: list[str] | None = None) -> None:
157
320
 
158
321
  if __name__ == "__main__":
159
322
  main()
323
+
@@ -0,0 +1,35 @@
1
+ """Parse interactive host-pick tokens (numbers ↔ skill ids)."""
2
+ from __future__ import annotations
3
+
4
+ from typing import Any
5
+
6
+
7
+ def parse_interactive_skill_pick(line: str, host_pick_rows: list[dict[str, Any]]) -> list[str]:
8
+ """Map user input (`1`, `2,3`, `foo-skill`) onto catalog ids using ``rank`` from rows."""
9
+ raw = (line or "").strip()
10
+ if not raw or raw.lower() in ("q", "quit", "exit"):
11
+ return []
12
+ seen: dict[str, None] = {}
13
+ by_rank: dict[int, str] = {}
14
+ for r in host_pick_rows:
15
+ rk = r.get("rank")
16
+ nid = str(r.get("name") or r.get("id") or "").strip()
17
+ if rk is None or not nid:
18
+ continue
19
+ try:
20
+ ir = int(rk)
21
+ except (TypeError, ValueError):
22
+ continue
23
+ by_rank[ir] = nid
24
+
25
+ for part in raw.replace(";", ",").split(","):
26
+ chunk = part.strip().strip("`").strip()
27
+ if not chunk:
28
+ continue
29
+ if chunk.isdigit():
30
+ name = by_rank.get(int(chunk))
31
+ if name:
32
+ seen.setdefault(name, None)
33
+ else:
34
+ seen.setdefault(chunk, None)
35
+ return list(seen.keys())
@@ -39,6 +39,7 @@ from __future__ import annotations
39
39
  import json
40
40
  import os
41
41
  import re
42
+ import sys
42
43
  import sqlite3
43
44
  from pathlib import Path
44
45
  from typing import Any
@@ -51,7 +52,12 @@ def load_route_policies_config(project_root: str | None) -> dict[str, Any]:
51
52
  try:
52
53
  data = json.loads(raw_env)
53
54
  return data if isinstance(data, dict) else {"rules": []}
54
- except json.JSONDecodeError:
55
+ except json.JSONDecodeError as exc:
56
+ print(
57
+ "[skillforge] SKILLFORGE_ROUTE_POLICIES is set but invalid JSON — policies ignored.",
58
+ str(exc),
59
+ file=sys.stderr,
60
+ )
55
61
  return {"rules": []}
56
62
 
57
63
  paths: list[Path] = []
@@ -66,9 +72,18 @@ def load_route_policies_config(project_root: str | None) -> dict[str, Any]:
66
72
  for p in paths:
67
73
  if p.is_file():
68
74
  try:
69
- data = json.loads(p.read_text(encoding="utf-8"))
75
+ raw = p.read_text(encoding="utf-8")
76
+ except OSError:
77
+ continue
78
+ try:
79
+ data = json.loads(raw)
70
80
  return data if isinstance(data, dict) else {"rules": []}
71
- except (OSError, json.JSONDecodeError):
81
+ except json.JSONDecodeError as exc:
82
+ print(
83
+ f"[skillforge] invalid JSON in route policies file — skipping {p}:",
84
+ str(exc),
85
+ file=sys.stderr,
86
+ )
72
87
  continue
73
88
  return {"rules": []}
74
89