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, 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
18
+
19
+DEFAULT_PROMPT_TEMPLATE = ROOT / "prompts" / "reskill.md"
20
+DEFAULT_ANALYZED_DIR = ROOT / "data" / "analyzed"
21
+DEFAULT_SNAPSHOTS_DIR = ROOT / "data" / "snapshots"
22
+DEFAULT_WISDOM_FILE = ROOT / ".squad" / "identity" / "wisdom.md"
23
+DEFAULT_SKILLS_DIR = ROOT / ".squad" / "skills"
24
+DEFAULT_REPORT_DIR = ROOT / ".squad" / "reskill"
25
+DEFAULT_MODELS_ENDPOINT = "https://models.github.ai/inference/chat/completions"
26
+DEFAULT_MODELS_MODEL = "openai/gpt-4.1"
27
+DEFAULT_MODELS_TIMEOUT = 30
28
+
29
+
30
+def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
31
+ parser = argparse.ArgumentParser(description="Run the SquadScope reskill retrospective.")
32
+ parser.add_argument("--current-datetime", required=True, help="ISO-8601 timestamp for the reskill run.")
33
+ parser.add_argument(
34
+ "--prompt-template",
35
+ type=Path,
36
+ default=DEFAULT_PROMPT_TEMPLATE,
37
+ help="Prompt template path (defaults to prompts/reskill.md).",
38
+ )
39
+ parser.add_argument(
40
+ "--analyzed-dir",
41
+ type=Path,
42
+ default=DEFAULT_ANALYZED_DIR,
43
+ help="Directory containing analyzed weekly summaries.",
44
+ )
45
+ parser.add_argument(
46
+ "--snapshots-dir",
47
+ type=Path,
48
+ default=DEFAULT_SNAPSHOTS_DIR,
49
+ help="Directory containing weekly snapshot JSON files.",
50
+ )
51
+ parser.add_argument(
52
+ "--wisdom-file",
53
+ type=Path,
54
+ default=DEFAULT_WISDOM_FILE,
55
+ help="Path to the learned wisdom markdown file.",
56
+ )
57
+ parser.add_argument(
58
+ "--skills-dir",
59
+ type=Path,
60
+ default=DEFAULT_SKILLS_DIR,
61
+ help="Directory containing learned skill markdown files.",
62
+ )
63
+ parser.add_argument(
64
+ "--output",
65
+ type=Path,
66
+ help="Path to write the reskill report. Defaults to .squad/reskill/YYYY-WNN.md.",
67
+ )
68
+ parser.add_argument("--limit", type=int, default=5, help="Maximum number of analyzed summaries to include.")
69
+ parser.add_argument(
70
+ "--print-prompt",
71
+ action="store_true",
72
+ help="Render the prompt to stdout without calling GitHub Models.",
73
+ )
74
+ return parser.parse_args(argv)
75
+
76
+
77
+def parse_datetime(value: str) -> datetime:
78
+ candidate = value.strip()
79
+ if candidate.endswith("Z"):
80
+ candidate = f"{candidate[:-1]}+00:00"
81
+ parsed = datetime.fromisoformat(candidate)
82
+ return parsed if parsed.tzinfo else parsed.replace(tzinfo=UTC)
83
+
84
+
85
+def week_slug(value: datetime) -> str:
86
+ year, week, _ = value.isocalendar()
87
+ return f"{year}-W{week:02d}"
88
+
89
+
90
+def default_output_path(current_datetime: str) -> Path:
91
+ return DEFAULT_REPORT_DIR / f"{week_slug(parse_datetime(current_datetime))}.md"
92
+
93
+
94
+def render_wisdom(wisdom_file: Path) -> str:
95
+ if not wisdom_file.exists():
96
+ return "_No learned wisdom has been recorded yet._"
97
+ content = wisdom_file.read_text(encoding="utf-8").strip()
98
+ return content or "_No learned wisdom has been recorded yet._"
99
+
100
+
101
+def render_skills(skills_dir: Path) -> str:
102
+ if not skills_dir.exists():
103
+ return "_No learned skills have been extracted yet._"
104
+
105
+ skill_files = sorted(path for path in skills_dir.rglob("*.md") if path.is_file())
106
+ if not skill_files:
107
+ return "_No learned skills have been extracted yet._"
108
+
109
+ blocks = []
110
+ for path in skill_files:
111
+ relative_path = path.relative_to(ROOT) if path.is_relative_to(ROOT) else path
112
+ content = path.read_text(encoding="utf-8").strip()
113
+ if content:
114
+ blocks.append(f"--- Skill Source: {relative_path} ---\n{content}")
115
+ return "\n\n".join(blocks) if blocks else "_No learned skills have been extracted yet._"
116
+
117
+
118
+def find_recent_summaries(analyzed_dir: Path, limit: int) -> list[Path]:
119
+ summaries = sorted(analyzed_dir.glob("*-summary.md")) if analyzed_dir.exists() else []
120
+ if limit <= 0:
121
+ return summaries
122
+ return summaries[-limit:]
123
+
124
+
125
+def render_recent_analyses(analyzed_dir: Path, limit: int) -> str:
126
+ summaries = find_recent_summaries(analyzed_dir, limit)
127
+ if not summaries:
128
+ return "_No analyzed summaries are available yet._"
129
+
130
+ blocks = []
131
+ for path in summaries:
132
+ relative_path = path.relative_to(ROOT) if path.is_relative_to(ROOT) else path
133
+ blocks.append(f"--- Analysis Source: {relative_path} ---\n{path.read_text(encoding='utf-8').strip()}")
134
+ return "\n\n".join(blocks)
135
+
136
+
137
+def snapshot_candidates(week: str, snapshots_dir: Path) -> list[Path]:
138
+ if not snapshots_dir.exists():
139
+ return []
140
+ patterns = [f"{week}.json", f"{week}-*.json"]
141
+ matches: list[Path] = []
142
+ for pattern in patterns:
143
+ matches.extend(sorted(path for path in snapshots_dir.glob(pattern) if path.is_file()))
144
+ deduped = []
145
+ seen: set[Path] = set()
146
+ for path in matches:
147
+ if path not in seen:
148
+ deduped.append(path)
149
+ seen.add(path)
150
+ return deduped
151
+
152
+
153
+def render_snapshot_context(analyzed_dir: Path, snapshots_dir: Path, limit: int) -> str:
154
+ summaries = find_recent_summaries(analyzed_dir, limit)
155
+ if not summaries:
156
+ return "_No analyzed summaries are available, so no snapshot hindsight can be matched yet._"
157
+
158
+ blocks = []
159
+ for summary_path in summaries:
160
+ week = summary_path.name.removesuffix("-summary.md")
161
+ matches = snapshot_candidates(week, snapshots_dir)
162
+ if not matches:
163
+ blocks.append(f"--- Snapshot Context: {week} ---\nNo snapshot data available for hindsight validation.")
164
+ continue
165
+ rendered_matches = []
166
+ for snapshot_path in matches:
167
+ relative_path = snapshot_path.relative_to(ROOT) if snapshot_path.is_relative_to(ROOT) else snapshot_path
168
+ rendered_matches.append(f"File: {relative_path}\n{snapshot_path.read_text(encoding='utf-8').strip()}")
169
+ blocks.append(f"--- Snapshot Context: {week} ---\n" + "\n\n".join(rendered_matches))
170
+ return "\n\n".join(blocks)
171
+
172
+
173
+def render_prompt(
174
+ *,
175
+ prompt_template_path: Path,
176
+ current_datetime: str,
177
+ output_path: Path,
178
+ analyzed_dir: Path,
179
+ snapshots_dir: Path,
180
+ wisdom_file: Path,
181
+ skills_dir: Path,
182
+ limit: int,
183
+) -> str:
184
+ prompt = prompt_template_path.read_text(encoding="utf-8")
185
+ replacements = {
186
+ "{{CURRENT_DATETIME}}": current_datetime,
187
+ "{{OUTPUT_PATH}}": str(output_path),
188
+ "{{WISDOM}}": render_wisdom(wisdom_file),
189
+ "{{SKILLS}}": render_skills(skills_dir),
190
+ "{{QUALITY_TREND}}": track_quality.build_quality_report(analyzed_dir).strip(),
191
+ "{{RECENT_ANALYSES}}": render_recent_analyses(analyzed_dir, limit),
192
+ "{{SNAPSHOT_CONTEXT}}": render_snapshot_context(analyzed_dir, snapshots_dir, limit),
193
+ }
194
+ for needle, value in replacements.items():
195
+ prompt = prompt.replace(needle, value)
196
+ return prompt
197
+
198
+
199
+def extract_markdown(response_payload: dict[str, Any]) -> str:
200
+ choices = response_payload.get("choices") or []
201
+ if not choices:
202
+ raise ValueError("GitHub Models response did not include any choices.")
203
+
204
+ message = choices[0].get("message") or {}
205
+ content = message.get("content")
206
+
207
+ if isinstance(content, str):
208
+ return content.strip() + "\n"
209
+
210
+ if isinstance(content, list):
211
+ parts: list[str] = []
212
+ for item in content:
213
+ if isinstance(item, dict):
214
+ text = item.get("text") or item.get("output_text")
215
+ if text:
216
+ parts.append(text)
217
+ if parts:
218
+ return "\n".join(parts).strip() + "\n"
219
+
220
+ text = choices[0].get("text")
221
+ if isinstance(text, str) and text.strip():
222
+ return text.strip() + "\n"
223
+
224
+ raise ValueError("GitHub Models response did not contain markdown output.")
225
+
226
+
227
+def call_github_models(prompt: str) -> str:
228
+ token = os.environ.get("GITHUB_TOKEN")
229
+ if not token:
230
+ raise RuntimeError("GITHUB_TOKEN is required for GitHub Models fallback.")
231
+
232
+ endpoint = os.environ.get("GITHUB_MODELS_ENDPOINT", DEFAULT_MODELS_ENDPOINT)
233
+ model = os.environ.get("GITHUB_MODELS_MODEL", DEFAULT_MODELS_MODEL)
234
+ timeout = int(os.environ.get("GITHUB_MODELS_TIMEOUT", str(DEFAULT_MODELS_TIMEOUT)))
235
+ payload = {
236
+ "model": model,
237
+ "messages": [{"role": "user", "content": prompt}],
238
+ "temperature": 0.2,
239
+ }
240
+ body = json.dumps(payload).encode("utf-8")
241
+ req = request.Request(
242
+ endpoint,
243
+ data=body,
244
+ headers={
245
+ "Authorization": f"Bearer {token}",
246
+ "Content-Type": "application/json",
247
+ "Accept": "application/json",
248
+ },
249
+ method="POST",
250
+ )
251
+
252
+ try:
253
+ with request.urlopen(req, timeout=timeout) as response:
254
+ response_payload = json.load(response)
255
+ except error.HTTPError as exc: # pragma: no cover - exercised via message formatting
256
+ detail = exc.read().decode("utf-8", errors="replace")
257
+ raise RuntimeError(f"GitHub Models API request failed ({exc.code}): {detail}") from exc
258
+ except error.URLError as exc: # pragma: no cover - network failures are environment-specific
259
+ raise RuntimeError(f"GitHub Models API request failed: {exc.reason}") from exc
260
+
261
+ return extract_markdown(response_payload)
262
+
263
+
264
+def main(argv: list[str] | None = None) -> int:
265
+ args = parse_args(argv)
266
+ output_path = args.output or default_output_path(args.current_datetime)
267
+ prompt = render_prompt(
268
+ prompt_template_path=args.prompt_template,
269
+ current_datetime=args.current_datetime,
270
+ output_path=output_path,
271
+ analyzed_dir=args.analyzed_dir,
272
+ snapshots_dir=args.snapshots_dir,
273
+ wisdom_file=args.wisdom_file,
274
+ skills_dir=args.skills_dir,
275
+ limit=args.limit,
276
+ )
277
+
278
+ if args.print_prompt:
279
+ print(prompt)
280
+ return 0
281
+
282
+ markdown = call_github_models(prompt)
283
+ output_path.parent.mkdir(parents=True, exist_ok=True)
284
+ output_path.write_text(markdown, encoding="utf-8")
285
+ return 0
286
+
287
+
288
+if __name__ == "__main__":
289
+ raise SystemExit(main())