main
py 478 lines 17.3 KB
Raw
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())