169 lines
5.4 KiB
Bash
Executable File
169 lines
5.4 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# Client-neutral Bash entry for the structured router.
|
|
# routing.json is the only route table; this file must not duplicate route rules.
|
|
set -euo pipefail
|
|
|
|
HINT=""
|
|
OUT_DIR=""
|
|
PROJECT_ROOT=""
|
|
|
|
while [[ $# -gt 0 ]]; do
|
|
case "$1" in
|
|
-Hint|-hint|--hint) HINT="${2:-}"; shift 2 ;;
|
|
-OutDir|-out-dir|--out-dir) OUT_DIR="${2:-}"; shift 2 ;;
|
|
-ProjectRoot|-project-root|--project-root) PROJECT_ROOT="${2:-}"; shift 2 ;;
|
|
-h|--help)
|
|
echo 'Usage: master-route.sh --hint TEXT [--out-dir DIR] [--project-root DIR]'
|
|
exit 0
|
|
;;
|
|
*)
|
|
if [[ -z "$HINT" && "$1" != -* ]]; then HINT="$1"; shift
|
|
else echo "Unknown arg: $1" >&2; exit 2; fi
|
|
;;
|
|
esac
|
|
done
|
|
|
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
SKILLS_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
|
PACKAGE_ROOT="$(cd "$SKILLS_ROOT/.." && pwd)"
|
|
CONFIG_PATH="$SKILLS_ROOT/config/routing.json"
|
|
|
|
PYTHON=""
|
|
for candidate in python3 python; do
|
|
if command -v "$candidate" >/dev/null 2>&1; then
|
|
PYTHON="$candidate"
|
|
break
|
|
fi
|
|
done
|
|
if [[ -z "$PYTHON" ]]; then
|
|
echo 'ERROR: Python 3 is required by the Bash structured router.' >&2
|
|
exit 2
|
|
fi
|
|
if [[ ! -f "$CONFIG_PATH" ]]; then
|
|
echo "ERROR: routing config missing: $CONFIG_PATH" >&2
|
|
exit 2
|
|
fi
|
|
|
|
# Match the PowerShell entrypoint: by default, route artifacts belong to the
|
|
# caller's current project rather than the installed reverse-skill package.
|
|
if [[ -z "$PROJECT_ROOT" ]]; then PROJECT_ROOT="$(pwd -P)"; fi
|
|
if [[ -z "$OUT_DIR" ]]; then
|
|
OUT_DIR="$PROJECT_ROOT/work/master-route-$(date +%Y%m%d-%H%M%S)"
|
|
fi
|
|
|
|
"$PYTHON" - "$CONFIG_PATH" "$SKILLS_ROOT" "$PROJECT_ROOT" "$OUT_DIR" "$HINT" <<'PY'
|
|
import datetime
|
|
import json
|
|
import pathlib
|
|
import re
|
|
import sys
|
|
|
|
config_path, skills_root_raw, project_root_raw, out_dir_raw, hint = sys.argv[1:]
|
|
skills_root = pathlib.Path(skills_root_raw)
|
|
project_root = pathlib.Path(project_root_raw).resolve()
|
|
out_dir = pathlib.Path(out_dir_raw)
|
|
|
|
try:
|
|
config = json.loads(pathlib.Path(config_path).read_text(encoding="utf-8-sig"))
|
|
except (OSError, json.JSONDecodeError) as exc:
|
|
print(f"ERROR: routing config is not valid JSON: {exc}", file=sys.stderr)
|
|
raise SystemExit(2)
|
|
|
|
routes = config.get("routes", {})
|
|
priority = config.get("priority", [])
|
|
fallback = config.get("meta", {}).get("fallbackId", "R0")
|
|
route_ids = set(routes)
|
|
if set(priority) != route_ids:
|
|
missing = sorted(route_ids - set(priority))
|
|
extra = sorted(set(priority) - route_ids)
|
|
print(f"WARN: routes/priority mismatch; missing={missing}, extra={extra}", file=sys.stderr)
|
|
|
|
text = hint.lower()
|
|
scores = {}
|
|
selected_order = []
|
|
|
|
def matches(pattern):
|
|
return bool(re.search(pattern, text, flags=re.IGNORECASE))
|
|
|
|
try:
|
|
for route_id, route in routes.items():
|
|
for rule in route.get("keywords", []):
|
|
must = rule.get("must")
|
|
hit = bool(must and matches(must))
|
|
must_all = rule.get("mustAll") or []
|
|
if isinstance(must_all, str):
|
|
must_all = [must_all]
|
|
if hit and any(not matches(pattern) for pattern in must_all):
|
|
hit = False
|
|
exclude = rule.get("exclude")
|
|
if hit and exclude and matches(exclude):
|
|
hit = False
|
|
if hit:
|
|
scores[route_id] = scores.get(route_id, 0) + 1
|
|
if route_id not in selected_order:
|
|
selected_order.append(route_id)
|
|
except re.error as exc:
|
|
print(f"ERROR: invalid regex in routing.json: {exc}", file=sys.stderr)
|
|
raise SystemExit(2)
|
|
|
|
primary = None
|
|
max_score = -1
|
|
for route_id in priority:
|
|
score = scores.get(route_id)
|
|
if score is not None and score > max_score:
|
|
primary = route_id
|
|
max_score = score
|
|
|
|
notes = []
|
|
if primary is None:
|
|
primary = fallback
|
|
confidence = "low"
|
|
notes.append("No strong keyword hit; open routing.md full matrix" if text else "Empty hint; provide task text")
|
|
else:
|
|
confidence = "high" if len(selected_order) == 1 else "medium"
|
|
|
|
if primary not in routes:
|
|
print(f"ERROR: fallback/primary route is missing: {primary}", file=sys.stderr)
|
|
raise SystemExit(2)
|
|
|
|
route = routes[primary]
|
|
primary_path = route["skill"]
|
|
skill_path = skills_root / pathlib.PurePosixPath(primary_path)
|
|
if not skill_path.is_file():
|
|
print(f"ERROR: PRIMARY skill missing: {skill_path}", file=sys.stderr)
|
|
raise SystemExit(2)
|
|
|
|
out_dir.mkdir(parents=True, exist_ok=True)
|
|
secondary = [f"skills/{routes[item]['skill']}" for item in selected_order if item != primary]
|
|
lines = [
|
|
"# reverse-skill Master route (PRIMARY)",
|
|
f"- created: {datetime.datetime.now(datetime.timezone.utc).astimezone().isoformat()}",
|
|
"- package: reverse-skill",
|
|
f"- hint: {hint}",
|
|
f"- primary: {primary}",
|
|
f"- primary_label: {route['label']}",
|
|
f"- primary_skill: skills/{primary_path}",
|
|
f"- confidence: {confidence}",
|
|
f"- project_root: {project_root}",
|
|
f"- secondary: {', '.join(secondary) if secondary else '(none)'}",
|
|
"",
|
|
"## MUST open next",
|
|
"",
|
|
"1. skills/MASTER-ROUTING.md",
|
|
f"2. skills/{primary_path}",
|
|
"",
|
|
"## Notes",
|
|
]
|
|
lines.extend(f"- {note}" for note in notes)
|
|
if not notes:
|
|
lines.append("- (none)")
|
|
(out_dir / "route-scope.md").write_text("\n".join(lines) + "\n", encoding="utf-8")
|
|
|
|
print(f"PRIMARY -> skills/{primary_path}")
|
|
print(f"Label: {route['label']} | confidence: {confidence}")
|
|
for note in notes:
|
|
print(f"NOTE: {note}")
|
|
print(f"Wrote {out_dir / 'route-scope.md'}")
|
|
print("ACTION: Open PRIMARY SKILL.md now and execute ACTION REQUIRED.")
|
|
PY
|