@mohammadhprp/system-prompt 0.12.4 → 0.12.6

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 (43) hide show
  1. package/framework/commands/README.md +2 -6
  2. package/framework/commands/audit-your-codebase.md +47 -0
  3. package/framework/commands/explain-codebase.md +101 -0
  4. package/framework/commands/learn.md +1 -1
  5. package/framework/plugins/ponytail/README.md +1 -1
  6. package/framework/plugins/ponytail/capabilities.md +1 -1
  7. package/framework/references/standards/pull-requests.md +1 -1
  8. package/framework/skills/README.md +1 -0
  9. package/framework/skills/adhd/SKILL.md +141 -0
  10. package/framework/skills/adhd/examples.md +77 -0
  11. package/framework/skills/gh/SKILL.md +157 -0
  12. package/framework/skills/gh/examples.md +10 -0
  13. package/framework/skills/ponytail/SKILL.md +145 -0
  14. package/framework/skills/ponytail/references/ponytail-audit.md +18 -0
  15. package/framework/skills/ponytail/references/ponytail-debt.md +21 -0
  16. package/framework/skills/ponytail/references/ponytail-gain.md +25 -0
  17. package/framework/skills/ponytail/references/ponytail-help.md +18 -0
  18. package/framework/skills/ponytail/references/ponytail-mode.md +33 -0
  19. package/framework/skills/ponytail/references/ponytail-review.md +27 -0
  20. package/framework/skills/ponytail/references/ponytail-rules.md +31 -0
  21. package/framework/skills/ponytail/references/principle-boundary-discipline.md +7 -0
  22. package/framework/skills/ponytail/references/principle-encode-lessons-in-structure.md +13 -0
  23. package/framework/skills/ponytail/references/principle-fix-root-causes.md +17 -0
  24. package/framework/skills/ponytail/references/principle-make-operations-idempotent.md +12 -0
  25. package/framework/skills/ponytail/references/principle-model-the-domain.md +7 -0
  26. package/framework/skills/ponytail/references/principle-prove-it-works.md +27 -0
  27. package/framework/skills/ponytail/references/principle-sequence-verifiable-units.md +7 -0
  28. package/framework/skills/review/SKILL.md +106 -11
  29. package/framework/skills/review/examples.md +4 -3
  30. package/framework/skills/review/scripts/render_review.py +95 -0
  31. package/framework/skills/review/scripts/resolve_spec_context.py +723 -0
  32. package/framework/skills/review/scripts/validate_review_json.py +348 -0
  33. package/framework/skills/unslop/SKILL.md +34 -3
  34. package/framework/skills/unslop/examples.md +2 -0
  35. package/framework/skills/unslop/references/eval.md +44 -0
  36. package/package.json +1 -1
  37. package/src/catalog.js +6 -7
  38. package/framework/commands/changelog.md +0 -44
  39. package/framework/commands/commit.md +0 -28
  40. package/framework/commands/mr.md +0 -45
  41. package/framework/commands/pr.md +0 -39
  42. package/framework/commands/release.md +0 -34
  43. package/framework/commands/review.md +0 -24
@@ -0,0 +1,723 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import base64
5
+ import json
6
+ import os
7
+ import re
8
+ import urllib.error
9
+ import urllib.parse
10
+ import urllib.request
11
+ from datetime import datetime, timezone
12
+ from pathlib import Path
13
+ from typing import Any
14
+
15
+
16
+ API_ROOT = "https://api.github.com"
17
+ GRAPHQL_ROOT = f"{API_ROOT}/graphql"
18
+ NO_SPEC_CONTEXT_MESSAGE = "No approved or repository spec context was found for this PR."
19
+ REPO_ROOT = Path(
20
+ (os.environ.get("OZ_REPO_ROOT") or "").strip()
21
+ or Path(__file__).resolve().parents[4]
22
+ )
23
+
24
+ _CLOSING_ISSUES_QUERY = (
25
+ "query($owner: String!, $name: String!, $number: Int!, $after: String) {"
26
+ " repository(owner: $owner, name: $name) {"
27
+ " pullRequest(number: $number) {"
28
+ " closingIssuesReferences(first: 100, after: $after) {"
29
+ " pageInfo { hasNextPage endCursor }"
30
+ " nodes {"
31
+ " number"
32
+ " repository { owner { login } name }"
33
+ " }"
34
+ " }"
35
+ " }"
36
+ " }"
37
+ " }"
38
+ )
39
+
40
+ _MANUAL_LINKED_ISSUES_QUERY = (
41
+ "query($owner: String!, $name: String!, $number: Int!, $after: String) {"
42
+ " repository(owner: $owner, name: $name) {"
43
+ " pullRequest(number: $number) {"
44
+ " timelineItems(first: 100, after: $after, itemTypes: [CONNECTED_EVENT, DISCONNECTED_EVENT]) {"
45
+ " pageInfo { hasNextPage endCursor }"
46
+ " nodes {"
47
+ " __typename"
48
+ " ... on ConnectedEvent {"
49
+ " subject {"
50
+ " __typename"
51
+ " ... on Issue {"
52
+ " number"
53
+ " repository { owner { login } name }"
54
+ " }"
55
+ " }"
56
+ " }"
57
+ " ... on DisconnectedEvent {"
58
+ " subject {"
59
+ " __typename"
60
+ " ... on Issue {"
61
+ " number"
62
+ " repository { owner { login } name }"
63
+ " }"
64
+ " }"
65
+ " }"
66
+ " }"
67
+ " }"
68
+ " }"
69
+ " }"
70
+ " }"
71
+ )
72
+
73
+
74
+ def _resolve_token() -> str:
75
+ token = (os.environ.get("GH_TOKEN") or os.environ.get("GITHUB_TOKEN") or "").strip()
76
+ if not token:
77
+ raise SystemExit(
78
+ "GH_TOKEN or GITHUB_TOKEN must be set to resolve PR spec context."
79
+ )
80
+ return token
81
+
82
+
83
+ def _parse_args() -> argparse.Namespace:
84
+ parser = argparse.ArgumentParser(
85
+ description="Resolve approved or repository spec context for a pull request."
86
+ )
87
+ parser.add_argument(
88
+ "--repo",
89
+ required=True,
90
+ help="Repository slug in OWNER/REPO format.",
91
+ )
92
+ parser.add_argument(
93
+ "--pr",
94
+ type=int,
95
+ required=True,
96
+ help="Pull request number to resolve spec context for.",
97
+ )
98
+ return parser.parse_args()
99
+
100
+
101
+ def _gh_request(
102
+ path_or_url: str,
103
+ *,
104
+ token: str,
105
+ accept: str = "application/vnd.github+json",
106
+ params: dict[str, str] | None = None,
107
+ method: str = "GET",
108
+ payload: bytes | None = None,
109
+ allow_http_error: bool = False,
110
+ ) -> tuple[int, bytes, dict[str, str]]:
111
+ url = path_or_url if path_or_url.startswith("https://") else f"{API_ROOT}{path_or_url}"
112
+ if params:
113
+ url = f"{url}?{urllib.parse.urlencode(params)}"
114
+ request = urllib.request.Request(url, data=payload, method=method) # noqa: S310
115
+ request.add_header("Authorization", f"Bearer {token}")
116
+ request.add_header("Accept", accept)
117
+ request.add_header("X-GitHub-Api-Version", "2022-11-28")
118
+ request.add_header("User-Agent", "oz-resolve-review-spec-context")
119
+ if payload is not None:
120
+ request.add_header("Content-Type", "application/json")
121
+ try:
122
+ with urllib.request.urlopen(request) as response: # noqa: S310
123
+ return response.status, response.read(), dict(response.headers)
124
+ except urllib.error.HTTPError as exc:
125
+ body = exc.read() if exc.fp is not None else b""
126
+ if allow_http_error:
127
+ return exc.code, body, dict(exc.headers or {})
128
+ detail = body.decode("utf-8", errors="replace")[:500]
129
+ raise SystemExit(
130
+ f"GitHub API request failed ({exc.code}) for {path_or_url}: {detail}"
131
+ ) from exc
132
+
133
+
134
+ def _gh_json(
135
+ path: str,
136
+ *,
137
+ token: str,
138
+ params: dict[str, str] | None = None,
139
+ allow_http_error: bool = False,
140
+ ) -> tuple[int, Any]:
141
+ status, body, _headers = _gh_request(
142
+ path,
143
+ token=token,
144
+ params=params,
145
+ allow_http_error=allow_http_error,
146
+ )
147
+ return status, json.loads(body.decode("utf-8"))
148
+
149
+
150
+ def _parse_next_link(link_header: str) -> str | None:
151
+ if not link_header:
152
+ return None
153
+ for piece in link_header.split(","):
154
+ segment = piece.strip()
155
+ if not segment.startswith("<"):
156
+ continue
157
+ end = segment.find(">")
158
+ if end == -1:
159
+ continue
160
+ url = segment[1:end]
161
+ rel_part = segment[end + 1 :]
162
+ if 'rel="next"' not in rel_part:
163
+ continue
164
+ parsed = urllib.parse.urlparse(url)
165
+ return parsed.path + (f"?{parsed.query}" if parsed.query else "")
166
+ return None
167
+
168
+
169
+ def _gh_paginated_json(
170
+ path: str,
171
+ *,
172
+ token: str,
173
+ params: dict[str, str] | None = None,
174
+ per_page: int = 100,
175
+ ) -> list[Any]:
176
+ merged_params = dict(params or {})
177
+ merged_params.setdefault("per_page", str(per_page))
178
+ next_path: str | None = (
179
+ f"{path}?{urllib.parse.urlencode(merged_params)}" if merged_params else path
180
+ )
181
+ items: list[Any] = []
182
+ while next_path:
183
+ status, body, headers = _gh_request(next_path, token=token)
184
+ if status != 200:
185
+ raise SystemExit(f"GitHub API returned status {status} for {next_path}")
186
+ page = json.loads(body.decode("utf-8"))
187
+ if not isinstance(page, list):
188
+ raise SystemExit(
189
+ f"Expected JSON array from {next_path}, got {type(page).__name__}."
190
+ )
191
+ items.extend(page)
192
+ next_path = _parse_next_link(headers.get("Link") or headers.get("link") or "")
193
+ return items
194
+
195
+
196
+ def _gh_graphql_json(query: str, variables: dict[str, Any], *, token: str) -> dict[str, Any]:
197
+ payload = json.dumps({"query": query, "variables": variables}).encode("utf-8")
198
+ _status, body, _headers = _gh_request(
199
+ GRAPHQL_ROOT,
200
+ token=token,
201
+ accept="application/json",
202
+ method="POST",
203
+ payload=payload,
204
+ )
205
+ data = json.loads(body.decode("utf-8"))
206
+ errors = data.get("errors") or []
207
+ if errors:
208
+ raise SystemExit(f"GitHub GraphQL request failed: {errors}")
209
+ return data
210
+
211
+
212
+ def parse_datetime(value: str) -> datetime:
213
+ return datetime.fromisoformat(value.replace("Z", "+00:00")).astimezone(timezone.utc)
214
+
215
+
216
+ def read_local_spec_files(workspace: Path, issue_number: int) -> list[tuple[str, str]]:
217
+ spec_dir_name = f"GH{issue_number}"
218
+ spec_dir = workspace / "specs" / spec_dir_name
219
+ results: list[tuple[str, str]] = []
220
+ for name in ("product.md", "tech.md"):
221
+ path = spec_dir / name
222
+ if path.exists():
223
+ results.append(
224
+ (f"specs/{spec_dir_name}/{name}", path.read_text(encoding="utf-8").strip())
225
+ )
226
+ return results
227
+
228
+
229
+ def _fetch_pull(owner: str, repo: str, pr_number: int, *, token: str) -> dict[str, Any]:
230
+ _status, payload = _gh_json(
231
+ f"/repos/{owner}/{repo}/pulls/{pr_number}",
232
+ token=token,
233
+ )
234
+ if not isinstance(payload, dict):
235
+ raise SystemExit(
236
+ f"Expected object payload for pull request #{pr_number}, got {type(payload).__name__}."
237
+ )
238
+ return payload
239
+
240
+
241
+ def _fetch_pull_files(
242
+ owner: str,
243
+ repo: str,
244
+ pr_number: int,
245
+ *,
246
+ token: str,
247
+ ) -> list[dict[str, Any]]:
248
+ files = _gh_paginated_json(
249
+ f"/repos/{owner}/{repo}/pulls/{pr_number}/files",
250
+ token=token,
251
+ )
252
+ return [item for item in files if isinstance(item, dict)]
253
+
254
+
255
+ def _fetch_issue(
256
+ owner: str,
257
+ repo: str,
258
+ issue_number: int,
259
+ *,
260
+ token: str,
261
+ ) -> dict[str, Any] | None:
262
+ status, payload = _gh_json(
263
+ f"/repos/{owner}/{repo}/issues/{issue_number}",
264
+ token=token,
265
+ allow_http_error=True,
266
+ )
267
+ if status == 404:
268
+ return None
269
+ if not isinstance(payload, dict):
270
+ raise SystemExit(
271
+ f"Expected object payload for issue #{issue_number}, got {type(payload).__name__}."
272
+ )
273
+ return payload
274
+
275
+
276
+ def _fetch_file_contents(
277
+ owner: str,
278
+ repo: str,
279
+ path: str,
280
+ *,
281
+ ref: str,
282
+ token: str,
283
+ ) -> str | None:
284
+ encoded_path = urllib.parse.quote(path, safe="/")
285
+ status, payload = _gh_json(
286
+ f"/repos/{owner}/{repo}/contents/{encoded_path}",
287
+ token=token,
288
+ params={"ref": ref},
289
+ allow_http_error=True,
290
+ )
291
+ if status == 404:
292
+ return None
293
+ if not isinstance(payload, dict):
294
+ return None
295
+ content = str(payload.get("content") or "").strip()
296
+ encoding = str(payload.get("encoding") or "").strip().lower()
297
+ if not content or encoding != "base64":
298
+ return None
299
+ return base64.b64decode(content.encode("utf-8")).decode("utf-8").strip()
300
+
301
+
302
+ def _normalize_github_linked_issue(node: Any, *, source: str) -> dict[str, Any] | None:
303
+ if not isinstance(node, dict):
304
+ return None
305
+ number = node.get("number")
306
+ if not isinstance(number, int):
307
+ return None
308
+ repository = node.get("repository") or {}
309
+ owner = ((repository.get("owner") or {}).get("login") or "").strip()
310
+ repo = str(repository.get("name") or "").strip()
311
+ if not owner or not repo:
312
+ return None
313
+ return {
314
+ "owner": owner,
315
+ "repo": repo,
316
+ "number": number,
317
+ "source": source,
318
+ }
319
+
320
+
321
+ def _graphql_pull_request_data(
322
+ owner: str,
323
+ repo: str,
324
+ pr_number: int,
325
+ query: str,
326
+ *,
327
+ token: str,
328
+ after: str | None,
329
+ ) -> dict[str, Any]:
330
+ data = _gh_graphql_json(
331
+ query,
332
+ {
333
+ "owner": owner,
334
+ "name": repo,
335
+ "number": int(pr_number),
336
+ "after": after,
337
+ },
338
+ token=token,
339
+ )
340
+ return (((data.get("data") or {}).get("repository") or {}).get("pullRequest") or {})
341
+
342
+
343
+ def _fetch_closing_issue_references(
344
+ owner: str,
345
+ repo: str,
346
+ pr_number: int,
347
+ *,
348
+ token: str,
349
+ ) -> list[dict[str, Any]]:
350
+ linked: dict[tuple[str, str, int, str], dict[str, Any]] = {}
351
+ cursor: str | None = None
352
+ while True:
353
+ pr_data = _graphql_pull_request_data(
354
+ owner,
355
+ repo,
356
+ pr_number,
357
+ _CLOSING_ISSUES_QUERY,
358
+ token=token,
359
+ after=cursor,
360
+ )
361
+ closing_refs = pr_data.get("closingIssuesReferences") or {}
362
+ for node in closing_refs.get("nodes") or []:
363
+ issue_ref = _normalize_github_linked_issue(
364
+ node,
365
+ source="closingIssuesReferences",
366
+ )
367
+ if issue_ref is None:
368
+ continue
369
+ key = (
370
+ issue_ref["owner"].lower(),
371
+ issue_ref["repo"].lower(),
372
+ int(issue_ref["number"]),
373
+ str(issue_ref["source"]),
374
+ )
375
+ linked[key] = issue_ref
376
+ page_info = closing_refs.get("pageInfo") or {}
377
+ if not page_info.get("hasNextPage"):
378
+ break
379
+ cursor = page_info.get("endCursor")
380
+ if not cursor:
381
+ break
382
+ return sorted(
383
+ linked.values(),
384
+ key=lambda item: (
385
+ str(item["owner"]).lower(),
386
+ str(item["repo"]).lower(),
387
+ int(item["number"]),
388
+ str(item["source"]),
389
+ ),
390
+ )
391
+
392
+
393
+ def _fetch_manual_linked_issue_references(
394
+ owner: str,
395
+ repo: str,
396
+ pr_number: int,
397
+ *,
398
+ token: str,
399
+ ) -> list[dict[str, Any]]:
400
+ connected: dict[tuple[str, str, int], dict[str, Any]] = {}
401
+ cursor: str | None = None
402
+ while True:
403
+ pr_data = _graphql_pull_request_data(
404
+ owner,
405
+ repo,
406
+ pr_number,
407
+ _MANUAL_LINKED_ISSUES_QUERY,
408
+ token=token,
409
+ after=cursor,
410
+ )
411
+ timeline_items = pr_data.get("timelineItems") or {}
412
+ for node in timeline_items.get("nodes") or []:
413
+ if not isinstance(node, dict):
414
+ continue
415
+ issue_ref = _normalize_github_linked_issue(
416
+ node.get("subject"),
417
+ source="manualLink",
418
+ )
419
+ if issue_ref is None:
420
+ continue
421
+ key = (
422
+ issue_ref["owner"].lower(),
423
+ issue_ref["repo"].lower(),
424
+ int(issue_ref["number"]),
425
+ )
426
+ typename = str(node.get("__typename") or "")
427
+ if typename == "ConnectedEvent":
428
+ connected[key] = issue_ref
429
+ elif typename == "DisconnectedEvent":
430
+ connected.pop(key, None)
431
+ page_info = timeline_items.get("pageInfo") or {}
432
+ if not page_info.get("hasNextPage"):
433
+ break
434
+ cursor = page_info.get("endCursor")
435
+ if not cursor:
436
+ break
437
+ return sorted(
438
+ connected.values(),
439
+ key=lambda item: (
440
+ str(item["owner"]).lower(),
441
+ str(item["repo"]).lower(),
442
+ int(item["number"]),
443
+ str(item["source"]),
444
+ ),
445
+ )
446
+
447
+
448
+ def _dedupe_ints(values: list[int]) -> list[int]:
449
+ return list(dict.fromkeys(int(value) for value in values))
450
+
451
+
452
+ def _same_repo_issue_numbers(
453
+ owner: str,
454
+ repo: str,
455
+ issue_refs: list[dict[str, Any]],
456
+ ) -> list[int]:
457
+ normalized_owner = owner.lower()
458
+ normalized_repo = repo.lower()
459
+ return _dedupe_ints(
460
+ [
461
+ int(issue_ref["number"])
462
+ for issue_ref in issue_refs
463
+ if str(issue_ref.get("owner") or "").lower() == normalized_owner
464
+ and str(issue_ref.get("repo") or "").lower() == normalized_repo
465
+ ]
466
+ )
467
+
468
+
469
+ def _deterministic_issue_candidates(pr: dict[str, Any], changed_files: list[str]) -> list[int]:
470
+ head_ref = str(((pr.get("head") or {}).get("ref")) or "")
471
+ branch_issue_matches = [
472
+ int(match.group(1))
473
+ for match in re.finditer(
474
+ r"(?:^|/)(?:spec|implement)-issue-(\d+)(?:$|[/-])",
475
+ head_ref,
476
+ )
477
+ ]
478
+ spec_file_issue_numbers = [
479
+ int(match.group(1))
480
+ for filename in changed_files
481
+ for match in [re.match(r"^specs/GH(\d+)/(?:product|tech)\.md$", filename)]
482
+ if match
483
+ ]
484
+ return _dedupe_ints(branch_issue_matches + spec_file_issue_numbers)
485
+
486
+
487
+ def _resolve_deterministic_issue_numbers(
488
+ owner: str,
489
+ repo: str,
490
+ pr: dict[str, Any],
491
+ changed_files: list[str],
492
+ *,
493
+ token: str,
494
+ ) -> list[int]:
495
+ resolved: list[int] = []
496
+ for candidate in _deterministic_issue_candidates(pr, changed_files):
497
+ issue = _fetch_issue(owner, repo, candidate, token=token)
498
+ if issue is None:
499
+ continue
500
+ if not issue.get("pull_request"):
501
+ resolved.append(candidate)
502
+ return resolved
503
+
504
+
505
+ def resolve_issue_number_for_pr(
506
+ owner: str,
507
+ repo: str,
508
+ pr_number: int,
509
+ pr: dict[str, Any],
510
+ changed_files: list[str],
511
+ *,
512
+ token: str,
513
+ ) -> int | None:
514
+ deterministic_issue_numbers = _resolve_deterministic_issue_numbers(
515
+ owner,
516
+ repo,
517
+ pr,
518
+ changed_files,
519
+ token=token,
520
+ )
521
+ github_linked_issues = _fetch_closing_issue_references(
522
+ owner,
523
+ repo,
524
+ pr_number,
525
+ token=token,
526
+ )
527
+ github_linked_issues.extend(
528
+ _fetch_manual_linked_issue_references(
529
+ owner,
530
+ repo,
531
+ pr_number,
532
+ token=token,
533
+ )
534
+ )
535
+ same_repo_linked_numbers = _same_repo_issue_numbers(owner, repo, github_linked_issues)
536
+
537
+ primary_issue_number: int | None = None
538
+ if len(deterministic_issue_numbers) == 1:
539
+ primary_issue_number = deterministic_issue_numbers[0]
540
+ elif len(deterministic_issue_numbers) == 0 and len(same_repo_linked_numbers) == 1:
541
+ primary_issue_number = same_repo_linked_numbers[0]
542
+ return primary_issue_number
543
+
544
+
545
+ def find_matching_spec_prs(
546
+ owner: str,
547
+ repo: str,
548
+ issue_number: int,
549
+ *,
550
+ token: str,
551
+ ) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
552
+ expected_spec_branch = f"oz-agent/spec-issue-{issue_number}"
553
+ matching = _gh_paginated_json(
554
+ f"/repos/{owner}/{repo}/pulls",
555
+ token=token,
556
+ params={"state": "all", "head": f"{owner}:{expected_spec_branch}"},
557
+ )
558
+ approved: list[dict[str, Any]] = []
559
+ unapproved: list[dict[str, Any]] = []
560
+ for pr in matching:
561
+ if not isinstance(pr, dict):
562
+ continue
563
+ pr_number = int(pr.get("number") or 0)
564
+ if pr_number <= 0:
565
+ continue
566
+ issue_payload = _fetch_issue(owner, repo, pr_number, token=token) or {}
567
+ labels = [
568
+ str((label or {}).get("name") or "")
569
+ for label in issue_payload.get("labels", []) or []
570
+ if isinstance(label, dict)
571
+ ]
572
+ files = _fetch_pull_files(owner, repo, pr_number, token=token)
573
+ spec_files = [
574
+ str(file.get("filename") or "")
575
+ for file in files
576
+ if str(file.get("filename") or "").startswith("specs/")
577
+ ]
578
+ head = pr.get("head") or {}
579
+ head_repo = head.get("repo") or {}
580
+ entry = {
581
+ "number": pr_number,
582
+ "url": str(pr.get("html_url") or ""),
583
+ "updated_at": str(pr.get("updated_at") or ""),
584
+ "head_ref_name": str(head.get("ref") or ""),
585
+ "head_repo_full_name": str(head_repo.get("full_name") or ""),
586
+ "spec_files": spec_files,
587
+ }
588
+ if "plan-approved" in labels:
589
+ approved.append(entry)
590
+ else:
591
+ unapproved.append(entry)
592
+ approved.sort(key=lambda item: parse_datetime(item["updated_at"]), reverse=True)
593
+ unapproved.sort(key=lambda item: parse_datetime(item["updated_at"]), reverse=True)
594
+ return approved, unapproved
595
+
596
+
597
+ def resolve_spec_context_for_issue(
598
+ owner: str,
599
+ repo: str,
600
+ issue_number: int,
601
+ *,
602
+ workspace: Path,
603
+ token: str,
604
+ ) -> dict[str, Any]:
605
+ approved, unapproved = find_matching_spec_prs(owner, repo, issue_number, token=token)
606
+ selected = approved[0] if approved else None
607
+ local_specs = read_local_spec_files(workspace, issue_number)
608
+ if selected and selected["head_repo_full_name"] != f"{owner}/{repo}":
609
+ raise RuntimeError(
610
+ f"Linked approved spec PR #{selected['number']} uses branch "
611
+ f"{selected['head_repo_full_name']}:{selected['head_ref_name']}, which this workflow cannot push to."
612
+ )
613
+
614
+ spec_context_source = "approved-pr" if selected else "directory" if local_specs else ""
615
+ spec_entries: list[dict[str, str]] = []
616
+ if selected:
617
+ for path in selected["spec_files"]:
618
+ content = _fetch_file_contents(
619
+ owner,
620
+ repo,
621
+ path,
622
+ ref=selected["head_ref_name"],
623
+ token=token,
624
+ )
625
+ if content:
626
+ spec_entries.append({"path": path, "content": content})
627
+ elif local_specs:
628
+ for path, content in local_specs:
629
+ spec_entries.append({"path": path, "content": content})
630
+
631
+ return {
632
+ "selected_spec_pr": selected,
633
+ "approved_spec_prs": approved,
634
+ "unapproved_spec_prs": unapproved,
635
+ "spec_context_source": spec_context_source,
636
+ "spec_entries": spec_entries,
637
+ }
638
+
639
+
640
+ def resolve_spec_context_for_pr(
641
+ owner: str,
642
+ repo: str,
643
+ pr_number: int,
644
+ *,
645
+ workspace: Path,
646
+ token: str,
647
+ ) -> dict[str, Any]:
648
+ pr = _fetch_pull(owner, repo, pr_number, token=token)
649
+ files = _fetch_pull_files(owner, repo, pr_number, token=token)
650
+ changed_files = [str(file.get("filename") or "") for file in files]
651
+ issue_number = resolve_issue_number_for_pr(
652
+ owner,
653
+ repo,
654
+ pr_number,
655
+ pr,
656
+ changed_files,
657
+ token=token,
658
+ )
659
+ if not issue_number:
660
+ return {
661
+ "issue_number": None,
662
+ "spec_context_source": "",
663
+ "selected_spec_pr": None,
664
+ "spec_entries": [],
665
+ "changed_files": changed_files,
666
+ }
667
+ spec_context = resolve_spec_context_for_issue(
668
+ owner,
669
+ repo,
670
+ issue_number,
671
+ workspace=workspace,
672
+ token=token,
673
+ )
674
+ spec_context["issue_number"] = issue_number
675
+ spec_context["changed_files"] = changed_files
676
+ return spec_context
677
+
678
+
679
+ def _format_spec_context(spec_context: dict[str, object]) -> str:
680
+ sections: list[str] = []
681
+ selected_spec_pr = spec_context.get("selected_spec_pr")
682
+ source = str(spec_context.get("spec_context_source") or "")
683
+ if (
684
+ source == "approved-pr"
685
+ and isinstance(selected_spec_pr, dict)
686
+ and selected_spec_pr.get("number")
687
+ and selected_spec_pr.get("url")
688
+ ):
689
+ sections.append(
690
+ f"Linked approved spec PR: [#{selected_spec_pr['number']}]({selected_spec_pr['url']})"
691
+ )
692
+ elif source == "directory":
693
+ sections.append("Repository spec context was found in `specs/`.")
694
+ for entry in spec_context.get("spec_entries", []):
695
+ if not isinstance(entry, dict):
696
+ continue
697
+ path = str(entry.get("path") or "").strip()
698
+ content = str(entry.get("content") or "").strip()
699
+ if not path or not content:
700
+ continue
701
+ sections.append(f"## {path}\n\n{content}")
702
+ return "\n\n".join(sections).strip() or NO_SPEC_CONTEXT_MESSAGE
703
+
704
+
705
+ def main() -> None:
706
+ args = _parse_args()
707
+ if "/" not in args.repo:
708
+ raise SystemExit(
709
+ f"Invalid repository slug: {args.repo!r}. Expected OWNER/REPO."
710
+ )
711
+ owner, repo = args.repo.split("/", 1)
712
+ spec_context = resolve_spec_context_for_pr(
713
+ owner,
714
+ repo,
715
+ args.pr,
716
+ workspace=REPO_ROOT,
717
+ token=_resolve_token(),
718
+ )
719
+ print(_format_spec_context(spec_context))
720
+
721
+
722
+ if __name__ == "__main__":
723
+ main()