| 1 | #!/usr/bin/env python3 |
| 2 | from __future__ import annotations |
| 3 | |
| 4 | import argparse |
| 5 | import json |
| 6 | import os |
| 7 | import sys |
| 8 | from datetime import UTC, datetime |
| 9 | from pathlib import Path |
| 10 | from typing import Any |
| 11 | from urllib import error, parse, request |
| 12 | |
| 13 | ROOT = Path(__file__).resolve().parent.parent |
| 14 | if str(ROOT) not in sys.path: |
| 15 | sys.path.insert(0, str(ROOT)) |
| 16 | |
| 17 | from scripts import track_quality # noqa: E402 |
| 18 | from scripts.analyze_fallback import ( # noqa: E402 |
| 19 | DEFAULT_CONTINUITY_FILE, |
| 20 | resolve_analysis_context_paths, |
| 21 | ) |
| 22 | from scripts.assemble_historical_context import ( # noqa: E402 |
| 23 | DEFAULT_CONTENT_ROOT, |
| 24 | compress_to_budget, |
| 25 | extract_month_notes, |
| 26 | extract_yearly_narrative, |
| 27 | resolve_latest_monthly_path, |
| 28 | resolve_latest_yearly_path, |
| 29 | ) |
| 30 | from scripts.learned_context import render_continuity # noqa: E402 |
| 31 | from scripts.load_scorecard import render_scorecard_section # noqa: E402 |
| 32 | |
| 33 | DEFAULT_PROMPT_TEMPLATE = ROOT / "prompts" / "reskill.md" |
| 34 | DEFAULT_ANALYZED_DIR = ROOT / "data" / "analyzed" |
| 35 | DEFAULT_SNAPSHOTS_DIR = ROOT / "data" / "snapshots" |
| 36 | DEFAULT_WISDOM_FILE = ROOT / ".squad" / "identity" / "wisdom.md" |
| 37 | DEFAULT_SKILLS_DIR = ROOT / ".squad" / "skills" |
| 38 | DEFAULT_REPORT_DIR = ROOT / ".squad" / "reskill" |
| 39 | DEFAULT_MODELS_ENDPOINT = "https://models.github.ai/inference/chat/completions" |
| 40 | DEFAULT_MODELS_MODEL = "openai/gpt-4o" |
| 41 | DEFAULT_MODELS_TIMEOUT = 30 |
| 42 | ALLOWED_MODELS_HOSTS: frozenset[str] = frozenset({"models.github.ai"}) |
| 43 | ARCHIVE_MONTHLY_MAX_WORDS = 200 |
| 44 | ARCHIVE_YEARLY_MAX_WORDS = 500 |
| 45 | |
| 46 | |
| 47 | def validate_https_url( |
| 48 | url: str, *, label: str, allowed_hosts: frozenset[str] | None = None |
| 49 | ) -> None: |
| 50 | parsed = parse.urlparse(url) |
| 51 | if parsed.scheme.lower() != "https": |
| 52 | raise ValueError(f"{label} must use HTTPS: {url}") |
| 53 | if parsed.username or parsed.password: |
| 54 | raise ValueError(f"{label} must not include credentials: {url}") |
| 55 | if not parsed.hostname: |
| 56 | raise ValueError(f"{label} must include a hostname: {url}") |
| 57 | try: |
| 58 | port = parsed.port |
| 59 | except ValueError as exc: |
| 60 | raise ValueError(f"{label} has an invalid port: {url}") from exc |
| 61 | if port not in (None, 443): |
| 62 | raise ValueError(f"{label} must not use unexpected ports: {url}") |
| 63 | if allowed_hosts is not None and parsed.hostname.lower() not in allowed_hosts: |
| 64 | raise ValueError(f"{label} host must be one of {sorted(allowed_hosts)}: {url}") |
| 65 | |
| 66 | |
| 67 | def parse_args(argv: list[str] | None = None) -> argparse.Namespace: |
| 68 | parser = argparse.ArgumentParser(description="Run the SquadScope reskill retrospective.") |
| 69 | parser.add_argument( |
| 70 | "--current-datetime", required=True, help="ISO-8601 timestamp for the reskill run." |
| 71 | ) |
| 72 | parser.add_argument( |
| 73 | "--prompt-template", |
| 74 | type=Path, |
| 75 | default=DEFAULT_PROMPT_TEMPLATE, |
| 76 | help="Prompt template path (defaults to prompts/reskill.md).", |
| 77 | ) |
| 78 | parser.add_argument( |
| 79 | "--analyzed-dir", |
| 80 | type=Path, |
| 81 | default=DEFAULT_ANALYZED_DIR, |
| 82 | help="Directory containing analyzed weekly summaries.", |
| 83 | ) |
| 84 | parser.add_argument( |
| 85 | "--snapshots-dir", |
| 86 | type=Path, |
| 87 | default=DEFAULT_SNAPSHOTS_DIR, |
| 88 | help="Directory containing weekly snapshot JSON files.", |
| 89 | ) |
| 90 | parser.add_argument( |
| 91 | "--wisdom-file", |
| 92 | type=Path, |
| 93 | default=DEFAULT_WISDOM_FILE, |
| 94 | help="Path to the learned wisdom markdown file.", |
| 95 | ) |
| 96 | parser.add_argument( |
| 97 | "--skills-dir", |
| 98 | type=Path, |
| 99 | default=DEFAULT_SKILLS_DIR, |
| 100 | help="Directory containing learned skill markdown files.", |
| 101 | ) |
| 102 | parser.add_argument( |
| 103 | "--continuity-file", |
| 104 | type=Path, |
| 105 | default=DEFAULT_CONTINUITY_FILE, |
| 106 | help="Path to the learned continuity capsule markdown file.", |
| 107 | ) |
| 108 | parser.add_argument( |
| 109 | "--content-root", |
| 110 | type=Path, |
| 111 | default=DEFAULT_CONTENT_ROOT, |
| 112 | help="Path to the content root used for monthly/yearly continuity inputs.", |
| 113 | ) |
| 114 | parser.add_argument( |
| 115 | "--output", |
| 116 | type=Path, |
| 117 | help="Path to write the reskill report. Defaults to .squad/reskill/YYYY-WNN.md.", |
| 118 | ) |
| 119 | parser.add_argument( |
| 120 | "--limit", type=int, default=5, help="Maximum number of analyzed summaries to include." |
| 121 | ) |
| 122 | parser.add_argument( |
| 123 | "--scorecard", |
| 124 | action="store_true", |
| 125 | help="Include prediction scorecard data in the reskill prompt.", |
| 126 | ) |
| 127 | parser.add_argument( |
| 128 | "--scorecard-count", |
| 129 | type=int, |
| 130 | default=4, |
| 131 | help="Number of recent scorecards to include (default: 4).", |
| 132 | ) |
| 133 | parser.add_argument("--topic", default=None, help="Topic ID for scorecard resolution.") |
| 134 | parser.add_argument( |
| 135 | "--print-prompt", |
| 136 | action="store_true", |
| 137 | help="Render the prompt to stdout without calling GitHub Models.", |
| 138 | ) |
| 139 | parser.add_argument( |
| 140 | "--prompt-output", |
| 141 | type=Path, |
| 142 | help="Optional path to write the rendered prompt while running the script.", |
| 143 | ) |
| 144 | return parser.parse_args(argv) |
| 145 | |
| 146 | |
| 147 | def parse_datetime(value: str) -> datetime: |
| 148 | candidate = value.strip() |
| 149 | if candidate.endswith("Z"): |
| 150 | candidate = f"{candidate[:-1]}+00:00" |
| 151 | parsed = datetime.fromisoformat(candidate) |
| 152 | return parsed if parsed.tzinfo else parsed.replace(tzinfo=UTC) |
| 153 | |
| 154 | |
| 155 | def week_slug(value: datetime) -> str: |
| 156 | year, week, _ = value.isocalendar() |
| 157 | return f"{year}-W{week:02d}" |
| 158 | |
| 159 | |
| 160 | def default_output_path(current_datetime: str) -> Path: |
| 161 | return DEFAULT_REPORT_DIR / f"{week_slug(parse_datetime(current_datetime))}.md" |
| 162 | |
| 163 | |
| 164 | def render_wisdom(wisdom_file: Path) -> str: |
| 165 | from scripts.sanitize_repo_content import _escape_untrusted_boundaries |
| 166 | |
| 167 | if not wisdom_file.exists(): |
| 168 | return "_No learned wisdom has been recorded yet._" |
| 169 | content = wisdom_file.read_text(encoding="utf-8").strip() |
| 170 | if not content: |
| 171 | return "_No learned wisdom has been recorded yet._" |
| 172 | return _escape_untrusted_boundaries(content) |
| 173 | |
| 174 | |
| 175 | def render_skills(skills_dir: Path) -> str: |
| 176 | from scripts.sanitize_repo_content import _escape_untrusted_boundaries |
| 177 | |
| 178 | if not skills_dir.exists(): |
| 179 | return "_No learned skills have been extracted yet._" |
| 180 | |
| 181 | skill_files = sorted(path for path in skills_dir.rglob("*.md") if path.is_file()) |
| 182 | if not skill_files: |
| 183 | return "_No learned skills have been extracted yet._" |
| 184 | |
| 185 | blocks = [] |
| 186 | for path in skill_files: |
| 187 | relative_path = path.relative_to(ROOT) if path.is_relative_to(ROOT) else path |
| 188 | safe_path = _escape_untrusted_boundaries(str(relative_path)) |
| 189 | content = path.read_text(encoding="utf-8").strip() |
| 190 | if content: |
| 191 | blocks.append( |
| 192 | f"--- Skill Source: {safe_path} ---\n{_escape_untrusted_boundaries(content)}" |
| 193 | ) |
| 194 | return "\n\n".join(blocks) if blocks else "_No learned skills have been extracted yet._" |
| 195 | |
| 196 | |
| 197 | def find_recent_summaries(analyzed_dir: Path, limit: int) -> list[Path]: |
| 198 | summaries = sorted(analyzed_dir.glob("*-summary.md")) if analyzed_dir.exists() else [] |
| 199 | if limit <= 0: |
| 200 | return summaries |
| 201 | return summaries[-limit:] |
| 202 | |
| 203 | |
| 204 | def render_recent_analyses(analyzed_dir: Path, limit: int) -> str: |
| 205 | from scripts.sanitize_repo_content import _escape_untrusted_boundaries |
| 206 | |
| 207 | summaries = find_recent_summaries(analyzed_dir, limit) |
| 208 | if not summaries: |
| 209 | return "_No analyzed summaries are available yet._" |
| 210 | |
| 211 | blocks = [] |
| 212 | for path in summaries: |
| 213 | relative_path = path.relative_to(ROOT) if path.is_relative_to(ROOT) else path |
| 214 | safe_path = _escape_untrusted_boundaries(str(relative_path)) |
| 215 | content = _escape_untrusted_boundaries(path.read_text(encoding="utf-8").strip()) |
| 216 | blocks.append(f"--- Analysis Source: {safe_path} ---\n{content}") |
| 217 | return "\n\n".join(blocks) |
| 218 | |
| 219 | |
| 220 | def snapshot_candidates(week: str, snapshots_dir: Path) -> list[Path]: |
| 221 | if not snapshots_dir.exists(): |
| 222 | return [] |
| 223 | patterns = [f"{week}.json", f"{week}-*.json"] |
| 224 | matches: list[Path] = [] |
| 225 | for pattern in patterns: |
| 226 | matches.extend(sorted(path for path in snapshots_dir.glob(pattern) if path.is_file())) |
| 227 | deduped = [] |
| 228 | seen: set[Path] = set() |
| 229 | for path in matches: |
| 230 | if path not in seen: |
| 231 | deduped.append(path) |
| 232 | seen.add(path) |
| 233 | return deduped |
| 234 | |
| 235 | |
| 236 | def render_snapshot_context(analyzed_dir: Path, snapshots_dir: Path, limit: int) -> str: |
| 237 | from scripts.sanitize_repo_content import _escape_untrusted_boundaries |
| 238 | |
| 239 | summaries = find_recent_summaries(analyzed_dir, limit) |
| 240 | if not summaries: |
| 241 | return "_No analyzed summaries are available, so no snapshot hindsight can be matched yet._" |
| 242 | |
| 243 | blocks = [] |
| 244 | for summary_path in summaries: |
| 245 | week = summary_path.name.removesuffix("-summary.md") |
| 246 | safe_week = _escape_untrusted_boundaries(week) |
| 247 | matches = snapshot_candidates(week, snapshots_dir) |
| 248 | if not matches: |
| 249 | blocks.append( |
| 250 | f"--- Snapshot Context: {safe_week} ---\nNo snapshot data available for hindsight validation." |
| 251 | ) |
| 252 | continue |
| 253 | rendered_matches = [] |
| 254 | for snapshot_path in matches: |
| 255 | relative_path = ( |
| 256 | snapshot_path.relative_to(ROOT) |
| 257 | if snapshot_path.is_relative_to(ROOT) |
| 258 | else snapshot_path |
| 259 | ) |
| 260 | safe_path = _escape_untrusted_boundaries(str(relative_path)) |
| 261 | content = _escape_untrusted_boundaries( |
| 262 | snapshot_path.read_text(encoding="utf-8").strip() |
| 263 | ) |
| 264 | rendered_matches.append(f"File: {safe_path}\n{content}") |
| 265 | blocks.append(f"--- Snapshot Context: {safe_week} ---\n" + "\n\n".join(rendered_matches)) |
| 266 | return "\n\n".join(blocks) |
| 267 | |
| 268 | |
| 269 | def render_archive_context(current_datetime: str, content_root: Path) -> str: |
| 270 | from scripts.sanitize_repo_content import _escape_untrusted_boundaries |
| 271 | |
| 272 | blocks: list[str] = [] |
| 273 | monthly_path = resolve_latest_monthly_path(content_root, current_datetime) |
| 274 | if monthly_path and monthly_path.exists(): |
| 275 | relative_path = ( |
| 276 | monthly_path.relative_to(ROOT) if monthly_path.is_relative_to(ROOT) else monthly_path |
| 277 | ) |
| 278 | monthly_raw = monthly_path.read_text(encoding="utf-8").strip() |
| 279 | monthly_content = compress_to_budget( |
| 280 | extract_month_notes(monthly_raw) or monthly_raw, |
| 281 | ARCHIVE_MONTHLY_MAX_WORDS, |
| 282 | ) |
| 283 | blocks.append( |
| 284 | f"--- Monthly Rollup: {_escape_untrusted_boundaries(str(relative_path))} ---\n" |
| 285 | f"{_escape_untrusted_boundaries(monthly_content)}" |
| 286 | ) |
| 287 | else: |
| 288 | blocks.append("_No monthly rollup was available yet._") |
| 289 | |
| 290 | yearly_path = resolve_latest_yearly_path(content_root, current_datetime) |
| 291 | if yearly_path and yearly_path.exists(): |
| 292 | relative_path = ( |
| 293 | yearly_path.relative_to(ROOT) if yearly_path.is_relative_to(ROOT) else yearly_path |
| 294 | ) |
| 295 | yearly_raw = yearly_path.read_text(encoding="utf-8").strip() |
| 296 | yearly_content = compress_to_budget( |
| 297 | extract_yearly_narrative(yearly_raw) or yearly_raw, |
| 298 | ARCHIVE_YEARLY_MAX_WORDS, |
| 299 | ) |
| 300 | blocks.append( |
| 301 | f"--- Yearly Narrative: {_escape_untrusted_boundaries(str(relative_path))} ---\n" |
| 302 | f"{_escape_untrusted_boundaries(yearly_content)}" |
| 303 | ) |
| 304 | else: |
| 305 | blocks.append("_No yearly narrative was available yet._") |
| 306 | |
| 307 | return "\n\n".join(blocks) |
| 308 | |
| 309 | |
| 310 | def render_prompt( |
| 311 | *, |
| 312 | prompt_template_path: Path, |
| 313 | current_datetime: str, |
| 314 | output_path: Path, |
| 315 | analyzed_dir: Path, |
| 316 | snapshots_dir: Path, |
| 317 | wisdom_file: Path, |
| 318 | skills_dir: Path, |
| 319 | limit: int = 5, |
| 320 | continuity_file: Path = DEFAULT_CONTINUITY_FILE, |
| 321 | content_root: Path = DEFAULT_CONTENT_ROOT, |
| 322 | scorecard_section: str = "", |
| 323 | ) -> str: |
| 324 | if ( |
| 325 | wisdom_file == DEFAULT_WISDOM_FILE |
| 326 | and skills_dir == DEFAULT_SKILLS_DIR |
| 327 | and continuity_file == DEFAULT_CONTINUITY_FILE |
| 328 | ): |
| 329 | wisdom_file, skills_dir, continuity_file = resolve_analysis_context_paths() |
| 330 | |
| 331 | prompt = prompt_template_path.read_text(encoding="utf-8") |
| 332 | replacements = { |
| 333 | "{{CURRENT_DATETIME}}": current_datetime, |
| 334 | "{{OUTPUT_PATH}}": str(output_path), |
| 335 | "{{WISDOM}}": render_wisdom(wisdom_file), |
| 336 | "{{SKILLS}}": render_skills(skills_dir), |
| 337 | "{{CONTINUITY}}": render_continuity(continuity_file), |
| 338 | "{{ARCHIVE_CONTEXT}}": render_archive_context(current_datetime, content_root), |
| 339 | "{{QUALITY_TREND}}": track_quality.build_quality_report(analyzed_dir).strip(), |
| 340 | "{{RECENT_ANALYSES}}": render_recent_analyses(analyzed_dir, limit), |
| 341 | "{{SNAPSHOT_CONTEXT}}": render_snapshot_context(analyzed_dir, snapshots_dir, limit), |
| 342 | "{{SCORECARD}}": scorecard_section, |
| 343 | } |
| 344 | for needle, value in replacements.items(): |
| 345 | prompt = prompt.replace(needle, value) |
| 346 | return prompt |
| 347 | |
| 348 | |
| 349 | def extract_markdown(response_payload: dict[str, Any]) -> str: |
| 350 | choices = response_payload.get("choices") or [] |
| 351 | if not choices: |
| 352 | raise ValueError("GitHub Models response did not include any choices.") |
| 353 | |
| 354 | message = choices[0].get("message") or {} |
| 355 | content = message.get("content") |
| 356 | |
| 357 | if isinstance(content, str): |
| 358 | return content.strip() + "\n" |
| 359 | |
| 360 | if isinstance(content, list): |
| 361 | parts: list[str] = [] |
| 362 | for item in content: |
| 363 | if isinstance(item, dict): |
| 364 | text = item.get("text") or item.get("output_text") |
| 365 | if text: |
| 366 | parts.append(text) |
| 367 | if parts: |
| 368 | return "\n".join(parts).strip() + "\n" |
| 369 | |
| 370 | text = choices[0].get("text") |
| 371 | if isinstance(text, str) and text.strip(): |
| 372 | return text.strip() + "\n" |
| 373 | |
| 374 | raise ValueError("GitHub Models response did not contain markdown output.") |
| 375 | |
| 376 | |
| 377 | def call_github_models(prompt: str) -> str: |
| 378 | token = os.environ.get("GITHUB_TOKEN") |
| 379 | if not token: |
| 380 | raise RuntimeError("GITHUB_TOKEN is required for GitHub Models fallback.") |
| 381 | |
| 382 | # Inject canary token for output leak detection |
| 383 | from scripts.analyze_fallback import validate_output_safety |
| 384 | from scripts.canary_token import generate_canary, inject_canary |
| 385 | |
| 386 | canary = generate_canary() |
| 387 | prompt = inject_canary(prompt, canary) |
| 388 | |
| 389 | endpoint = os.environ.get("GITHUB_MODELS_ENDPOINT", DEFAULT_MODELS_ENDPOINT) |
| 390 | validate_https_url(endpoint, label="GitHub Models endpoint", allowed_hosts=ALLOWED_MODELS_HOSTS) |
| 391 | model = os.environ.get("GITHUB_MODELS_MODEL", DEFAULT_MODELS_MODEL) |
| 392 | timeout = int(os.environ.get("GITHUB_MODELS_TIMEOUT", str(DEFAULT_MODELS_TIMEOUT))) |
| 393 | payload = { |
| 394 | "model": model, |
| 395 | "messages": [{"role": "user", "content": prompt}], |
| 396 | "temperature": 0.2, |
| 397 | } |
| 398 | body = json.dumps(payload).encode("utf-8") |
| 399 | req = request.Request( |
| 400 | endpoint, |
| 401 | data=body, |
| 402 | headers={ |
| 403 | "Authorization": f"Bearer {token}", |
| 404 | "Content-Type": "application/json", |
| 405 | "Accept": "application/json", |
| 406 | }, |
| 407 | method="POST", |
| 408 | ) |
| 409 | |
| 410 | try: |
| 411 | with request.urlopen(req, timeout=timeout) as response: # nosec B310 |
| 412 | response_payload = json.load(response) |
| 413 | except error.HTTPError as exc: # pragma: no cover - exercised via message formatting |
| 414 | detail = exc.read().decode("utf-8", errors="replace") |
| 415 | raise RuntimeError(f"GitHub Models API request failed ({exc.code}): {detail}") from exc |
| 416 | except error.URLError as exc: # pragma: no cover - network failures are environment-specific |
| 417 | raise RuntimeError(f"GitHub Models API request failed: {exc.reason}") from exc |
| 418 | |
| 419 | markdown = extract_markdown(response_payload) |
| 420 | # Validate output for canary leak and injection artifacts |
| 421 | violations = validate_output_safety(markdown, canary) |
| 422 | if violations: |
| 423 | msg = f"Output safety violations detected: {'; '.join(violations)}" |
| 424 | canary_leaked = any("Canary token leaked" in v for v in violations) |
| 425 | if canary_leaked: |
| 426 | raise RuntimeError(f"BLOCKED: {msg}") |
| 427 | print(f"::warning::{msg}", file=sys.stderr) |
| 428 | return markdown |
| 429 | |
| 430 | |
| 431 | def main(argv: list[str] | None = None) -> int: |
| 432 | args = parse_args(argv) |
| 433 | output_path = args.output or default_output_path(args.current_datetime) |
| 434 | |
| 435 | scorecard_section = "" |
| 436 | if args.scorecard: |
| 437 | scorecard_section = render_scorecard_section(args.topic, args.scorecard_count) |
| 438 | |
| 439 | prompt = render_prompt( |
| 440 | prompt_template_path=args.prompt_template, |
| 441 | current_datetime=args.current_datetime, |
| 442 | output_path=output_path, |
| 443 | analyzed_dir=args.analyzed_dir, |
| 444 | snapshots_dir=args.snapshots_dir, |
| 445 | wisdom_file=args.wisdom_file, |
| 446 | skills_dir=args.skills_dir, |
| 447 | continuity_file=args.continuity_file, |
| 448 | content_root=args.content_root, |
| 449 | limit=args.limit, |
| 450 | scorecard_section=scorecard_section, |
| 451 | ) |
| 452 | |
| 453 | if args.print_prompt: |
| 454 | print(prompt) |
| 455 | return 0 |
| 456 | |
| 457 | if args.prompt_output: |
| 458 | args.prompt_output.parent.mkdir(parents=True, exist_ok=True) |
| 459 | args.prompt_output.write_text(prompt, encoding="utf-8") |
| 460 | |
| 461 | try: |
| 462 | markdown = call_github_models(prompt) |
| 463 | except RuntimeError as exc: |
| 464 | print(f"⚠️ Reskill: GitHub Models call failed — {exc}", file=sys.stderr) |
| 465 | print("Writing placeholder reskill report and continuing.", file=sys.stderr) |
| 466 | output_path.parent.mkdir(parents=True, exist_ok=True) |
| 467 | output_path.write_text( |
| 468 | f"# Reskill skipped\n\nGitHub Models unavailable: {exc}\n", |
| 469 | encoding="utf-8", |
| 470 | ) |
| 471 | return 0 |
| 472 | output_path.parent.mkdir(parents=True, exist_ok=True) |
| 473 | output_path.write_text(markdown, encoding="utf-8") |
| 474 | return 0 |
| 475 | |
| 476 | |
| 477 | if __name__ == "__main__": |
| 478 | raise SystemExit(main()) |