@apolloyh/apollo-agent 0.1.0

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 (103) hide show
  1. package/.apollo/config.example.json +37 -0
  2. package/.env.example +17 -0
  3. package/README.md +62 -0
  4. package/dist/brand.d.ts +17 -0
  5. package/dist/brand.d.ts.map +1 -0
  6. package/dist/brand.js +23 -0
  7. package/dist/cli-args.d.ts +13 -0
  8. package/dist/cli-args.d.ts.map +1 -0
  9. package/dist/cli-args.js +50 -0
  10. package/dist/config.d.ts +13 -0
  11. package/dist/config.d.ts.map +1 -0
  12. package/dist/config.js +190 -0
  13. package/dist/constants/spinner-verbs.d.ts +7 -0
  14. package/dist/constants/spinner-verbs.d.ts.map +1 -0
  15. package/dist/constants/spinner-verbs.js +21 -0
  16. package/dist/coordinator/workflow.d.ts +20 -0
  17. package/dist/coordinator/workflow.d.ts.map +1 -0
  18. package/dist/coordinator/workflow.js +120 -0
  19. package/dist/goal/goal-mode.d.ts +26 -0
  20. package/dist/goal/goal-mode.d.ts.map +1 -0
  21. package/dist/goal/goal-mode.js +141 -0
  22. package/dist/hooks/runner.d.ts +43 -0
  23. package/dist/hooks/runner.d.ts.map +1 -0
  24. package/dist/hooks/runner.js +94 -0
  25. package/dist/index.d.ts +3 -0
  26. package/dist/index.d.ts.map +1 -0
  27. package/dist/index.js +1716 -0
  28. package/dist/llm/anthropic.d.ts +46 -0
  29. package/dist/llm/anthropic.d.ts.map +1 -0
  30. package/dist/llm/anthropic.js +278 -0
  31. package/dist/mcp/manager.d.ts +14 -0
  32. package/dist/mcp/manager.d.ts.map +1 -0
  33. package/dist/mcp/manager.js +93 -0
  34. package/dist/memory/memdir.d.ts +19 -0
  35. package/dist/memory/memdir.d.ts.map +1 -0
  36. package/dist/memory/memdir.js +96 -0
  37. package/dist/plan/plan-mode.d.ts +14 -0
  38. package/dist/plan/plan-mode.d.ts.map +1 -0
  39. package/dist/plan/plan-mode.js +90 -0
  40. package/dist/project-instructions.d.ts +8 -0
  41. package/dist/project-instructions.d.ts.map +1 -0
  42. package/dist/project-instructions.js +33 -0
  43. package/dist/runtime/compaction.d.ts +5 -0
  44. package/dist/runtime/compaction.d.ts.map +1 -0
  45. package/dist/runtime/compaction.js +37 -0
  46. package/dist/runtime/context.d.ts +25 -0
  47. package/dist/runtime/context.d.ts.map +1 -0
  48. package/dist/runtime/context.js +75 -0
  49. package/dist/runtime/permissions.d.ts +11 -0
  50. package/dist/runtime/permissions.d.ts.map +1 -0
  51. package/dist/runtime/permissions.js +119 -0
  52. package/dist/runtime/prompt-builder.d.ts +27 -0
  53. package/dist/runtime/prompt-builder.d.ts.map +1 -0
  54. package/dist/runtime/prompt-builder.js +112 -0
  55. package/dist/runtime/query-engine.d.ts +136 -0
  56. package/dist/runtime/query-engine.d.ts.map +1 -0
  57. package/dist/runtime/query-engine.js +1182 -0
  58. package/dist/runtime/tool-policy.d.ts +13 -0
  59. package/dist/runtime/tool-policy.d.ts.map +1 -0
  60. package/dist/runtime/tool-policy.js +106 -0
  61. package/dist/sdk.d.ts +33 -0
  62. package/dist/sdk.d.ts.map +1 -0
  63. package/dist/sdk.js +41 -0
  64. package/dist/session/resume-ui.d.ts +17 -0
  65. package/dist/session/resume-ui.d.ts.map +1 -0
  66. package/dist/session/resume-ui.js +64 -0
  67. package/dist/session/store.d.ts +66 -0
  68. package/dist/session/store.d.ts.map +1 -0
  69. package/dist/session/store.js +120 -0
  70. package/dist/skillify/skillify.d.ts +12 -0
  71. package/dist/skillify/skillify.d.ts.map +1 -0
  72. package/dist/skillify/skillify.js +52 -0
  73. package/dist/skills/skills.d.ts +26 -0
  74. package/dist/skills/skills.d.ts.map +1 -0
  75. package/dist/skills/skills.js +195 -0
  76. package/dist/status-ui.d.ts +37 -0
  77. package/dist/status-ui.d.ts.map +1 -0
  78. package/dist/status-ui.js +130 -0
  79. package/dist/thought-fold.d.ts +48 -0
  80. package/dist/thought-fold.d.ts.map +1 -0
  81. package/dist/thought-fold.js +156 -0
  82. package/dist/tools/builtin.d.ts +27 -0
  83. package/dist/tools/builtin.d.ts.map +1 -0
  84. package/dist/tools/builtin.js +898 -0
  85. package/dist/tools/registry.d.ts +17 -0
  86. package/dist/tools/registry.d.ts.map +1 -0
  87. package/dist/tools/registry.js +86 -0
  88. package/dist/trace.d.ts +68 -0
  89. package/dist/trace.d.ts.map +1 -0
  90. package/dist/trace.js +600 -0
  91. package/dist/types.d.ts +202 -0
  92. package/dist/types.d.ts.map +1 -0
  93. package/dist/types.js +1 -0
  94. package/dist/utils.d.ts +8 -0
  95. package/dist/utils.d.ts.map +1 -0
  96. package/dist/utils.js +78 -0
  97. package/docs/sdk.md +259 -0
  98. package/package.json +53 -0
  99. package/src/skills/tech-research-skill/SKILL.md +200 -0
  100. package/src/skills/tech-research-skill/agents/openai.yaml +4 -0
  101. package/src/skills/tech-research-skill/repo-analyze +435 -0
  102. package/src/skills/tech-research-skill/repo-fetch +310 -0
  103. package/src/skills/tech-research-skill/report-generate +254 -0
@@ -0,0 +1,310 @@
1
+ #!/usr/bin/env python3
2
+ import argparse
3
+ import datetime as dt
4
+ import json
5
+ import os
6
+ import sys
7
+ import urllib.error
8
+ import urllib.parse
9
+ import urllib.request
10
+
11
+ VERSION = "1.0.0"
12
+
13
+
14
+ def load_env_file():
15
+ env_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), ".env")
16
+ if not os.path.exists(env_path):
17
+ return
18
+ try:
19
+ with open(env_path, "r", encoding="utf-8") as handle:
20
+ for raw_line in handle:
21
+ line = raw_line.strip()
22
+ if not line or line.startswith("#") or "=" not in line:
23
+ continue
24
+ key, value = line.split("=", 1)
25
+ key = key.strip()
26
+ value = value.strip().strip('"').strip("'")
27
+ if key and key not in os.environ:
28
+ os.environ[key] = value
29
+ except OSError as exc:
30
+ fail("env_error", "could not read .env file", {"error": str(exc)})
31
+
32
+
33
+ def now_iso():
34
+ return dt.datetime.now(dt.timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
35
+
36
+
37
+ def fail(code, message, details=None, exit_code=1):
38
+ payload = {
39
+ "ok": False,
40
+ "error": {
41
+ "code": code,
42
+ "message": message,
43
+ "details": details or {},
44
+ },
45
+ }
46
+ print(json.dumps(payload, ensure_ascii=False, sort_keys=True), file=sys.stderr)
47
+ raise SystemExit(exit_code)
48
+
49
+
50
+ def parse_owner_repo(value):
51
+ cleaned = value.strip()
52
+ if "github.com/" in cleaned:
53
+ parsed = urllib.parse.urlparse(cleaned)
54
+ cleaned = parsed.path.strip("/")
55
+ parts = cleaned.strip("/").split("/")
56
+ if len(parts) != 2 or not all(parts):
57
+ fail("invalid_arguments", "repo must be in owner/repo form", {"value": value})
58
+ return parts[0], parts[1]
59
+
60
+
61
+ def parse_include(values, include_issues, include_commits):
62
+ selected = set()
63
+ for value in values or []:
64
+ for item in value.split(","):
65
+ item = item.strip().lower()
66
+ if not item:
67
+ continue
68
+ if item not in {"issues", "commits"}:
69
+ fail("invalid_arguments", "include must contain only issues or commits", {"value": item})
70
+ selected.add(item)
71
+ if include_issues:
72
+ selected.add("issues")
73
+ if include_commits:
74
+ selected.add("commits")
75
+ return sorted(selected)
76
+
77
+
78
+ def request_json(url):
79
+ headers = {
80
+ "Accept": "application/vnd.github+json",
81
+ "User-Agent": "tech-research-skill/repo-fetch",
82
+ "X-GitHub-Api-Version": "2022-11-28",
83
+ }
84
+ token = os.environ.get("GITHUB_TOKEN") or os.environ.get("GH_TOKEN")
85
+ if token:
86
+ headers["Authorization"] = f"Bearer {token}"
87
+
88
+ request = urllib.request.Request(url, headers=headers)
89
+ try:
90
+ with urllib.request.urlopen(request, timeout=20) as response:
91
+ charset = response.headers.get_content_charset() or "utf-8"
92
+ return json.loads(response.read().decode(charset))
93
+ except urllib.error.HTTPError as exc:
94
+ body = exc.read().decode("utf-8", errors="replace")
95
+ message = body
96
+ try:
97
+ parsed = json.loads(body)
98
+ message = parsed.get("message", body)
99
+ except json.JSONDecodeError:
100
+ pass
101
+ details = {"url": url, "status": exc.code, "message": message}
102
+ if exc.code in {403, 429}:
103
+ details["hint"] = "GitHub API may be rate-limited. Set GITHUB_TOKEN or GH_TOKEN, or put GITHUB_TOKEN in this skill directory's ignored .env file."
104
+ fail("github_http_error", "GitHub API request failed", details)
105
+ except urllib.error.URLError as exc:
106
+ fail("network_error", "GitHub API request failed", {"url": url, "reason": str(exc.reason)})
107
+ except json.JSONDecodeError as exc:
108
+ fail("invalid_response", "GitHub API returned invalid JSON", {"url": url, "error": str(exc)})
109
+
110
+
111
+ def build_url(api_url, path, params=None):
112
+ base = api_url.rstrip("/")
113
+ query = urllib.parse.urlencode(params or {})
114
+ return f"{base}{path}" + (f"?{query}" if query else "")
115
+
116
+
117
+ def compact_repo(repo):
118
+ return {
119
+ "id": repo.get("id"),
120
+ "name": repo.get("name"),
121
+ "full_name": repo.get("full_name"),
122
+ "description": repo.get("description"),
123
+ "private": repo.get("private"),
124
+ "html_url": repo.get("html_url"),
125
+ "default_branch": repo.get("default_branch"),
126
+ "language": repo.get("language"),
127
+ "topics": repo.get("topics", []),
128
+ "stargazers_count": repo.get("stargazers_count"),
129
+ "watchers_count": repo.get("watchers_count"),
130
+ "forks_count": repo.get("forks_count"),
131
+ "open_issues_count": repo.get("open_issues_count"),
132
+ "license": repo.get("license", {}).get("spdx_id") if repo.get("license") else None,
133
+ "archived": repo.get("archived"),
134
+ "disabled": repo.get("disabled"),
135
+ "created_at": repo.get("created_at"),
136
+ "updated_at": repo.get("updated_at"),
137
+ "pushed_at": repo.get("pushed_at"),
138
+ "owner": {
139
+ "login": (repo.get("owner") or {}).get("login"),
140
+ "type": (repo.get("owner") or {}).get("type"),
141
+ },
142
+ }
143
+
144
+
145
+ def compact_search_repo(repo):
146
+ compacted = compact_repo(repo)
147
+ compacted.update(
148
+ {
149
+ "score": repo.get("score"),
150
+ "size": repo.get("size"),
151
+ "homepage": repo.get("homepage"),
152
+ "has_issues": repo.get("has_issues"),
153
+ "has_projects": repo.get("has_projects"),
154
+ "has_wiki": repo.get("has_wiki"),
155
+ }
156
+ )
157
+ return compacted
158
+
159
+
160
+ def compact_issue(issue):
161
+ return {
162
+ "id": issue.get("id"),
163
+ "number": issue.get("number"),
164
+ "title": issue.get("title"),
165
+ "state": issue.get("state"),
166
+ "html_url": issue.get("html_url"),
167
+ "labels": [label.get("name") for label in issue.get("labels", []) if label.get("name")],
168
+ "comments": issue.get("comments"),
169
+ "created_at": issue.get("created_at"),
170
+ "updated_at": issue.get("updated_at"),
171
+ "closed_at": issue.get("closed_at"),
172
+ "user": {
173
+ "login": (issue.get("user") or {}).get("login"),
174
+ "type": (issue.get("user") or {}).get("type"),
175
+ },
176
+ }
177
+
178
+
179
+ def compact_commit(commit):
180
+ commit_data = commit.get("commit") or {}
181
+ author_data = commit_data.get("author") or {}
182
+ return {
183
+ "sha": commit.get("sha"),
184
+ "html_url": commit.get("html_url"),
185
+ "message": (commit_data.get("message") or "").splitlines()[0],
186
+ "date": author_data.get("date"),
187
+ "author": {
188
+ "name": author_data.get("name"),
189
+ "login": (commit.get("author") or {}).get("login"),
190
+ },
191
+ }
192
+
193
+
194
+ def fetch_paged(api_url, path, per_page, max_pages, extra_params=None):
195
+ items = []
196
+ for page in range(1, max_pages + 1):
197
+ params = {"per_page": per_page, "page": page}
198
+ params.update(extra_params or {})
199
+ batch = request_json(build_url(api_url, path, params))
200
+ if not isinstance(batch, list):
201
+ fail("invalid_response", "GitHub API returned a non-list page", {"path": path, "page": page})
202
+ items.extend(batch)
203
+ if len(batch) < per_page:
204
+ break
205
+ return items
206
+
207
+
208
+ def main():
209
+ load_env_file()
210
+
211
+ parser = argparse.ArgumentParser(description="Fetch GitHub repository or repository-search data as JSON.")
212
+ parser.add_argument("repo", nargs="?", help="Repository in owner/repo form or a GitHub repository URL.")
213
+ parser.add_argument("--search", help="GitHub repository search query.")
214
+ parser.add_argument("--include", action="append", help="Comma-separated optional datasets: issues,commits.")
215
+ parser.add_argument("--issues", action="store_true", help="Include open issues. Pull requests are filtered out.")
216
+ parser.add_argument("--commits", action="store_true", help="Include recent commits.")
217
+ parser.add_argument("--per-page", type=int, default=30, help="GitHub page size, 1-100. Default: 30.")
218
+ parser.add_argument("--max-pages", type=int, default=1, help="Maximum pages per optional dataset. Default: 1.")
219
+ args = parser.parse_args()
220
+
221
+ if args.per_page < 1 or args.per_page > 100:
222
+ fail("invalid_arguments", "--per-page must be between 1 and 100", {"per_page": args.per_page})
223
+ if args.max_pages < 1:
224
+ fail("invalid_arguments", "--max-pages must be at least 1", {"max_pages": args.max_pages})
225
+ if bool(args.repo) == bool(args.search):
226
+ fail("invalid_arguments", "provide exactly one of repo or --search")
227
+
228
+ include = parse_include(args.include, args.issues, args.commits)
229
+ api_url = os.environ.get("GITHUB_API_URL", "https://api.github.com")
230
+
231
+ if args.search:
232
+ if include:
233
+ fail("invalid_arguments", "--include, --issues, and --commits require a concrete repository")
234
+ search_json = request_json(
235
+ build_url(
236
+ api_url,
237
+ "/search/repositories",
238
+ {
239
+ "q": args.search,
240
+ "sort": "stars",
241
+ "order": "desc",
242
+ "per_page": args.per_page,
243
+ "page": 1,
244
+ },
245
+ )
246
+ )
247
+ items = search_json.get("items")
248
+ if not isinstance(items, list):
249
+ fail("invalid_response", "GitHub search returned invalid results")
250
+ payload = {
251
+ "ok": True,
252
+ "source": "repo-fetch",
253
+ "version": VERSION,
254
+ "fetched_at": now_iso(),
255
+ "mode": "search",
256
+ "input": {
257
+ "query": args.search,
258
+ "per_page": args.per_page,
259
+ "max_pages": 1,
260
+ },
261
+ "search": {
262
+ "query": args.search,
263
+ "total_count": search_json.get("total_count"),
264
+ "incomplete_results": search_json.get("incomplete_results"),
265
+ "items": [compact_search_repo(item) for item in items],
266
+ },
267
+ "data": {},
268
+ }
269
+ print(json.dumps(payload, ensure_ascii=False, sort_keys=True))
270
+ return
271
+
272
+ owner, repo = parse_owner_repo(args.repo)
273
+ repo_json = request_json(build_url(api_url, f"/repos/{owner}/{repo}"))
274
+ data = {}
275
+
276
+ if "issues" in include:
277
+ raw_issues = fetch_paged(
278
+ api_url,
279
+ f"/repos/{owner}/{repo}/issues",
280
+ args.per_page,
281
+ args.max_pages,
282
+ {"state": "all", "sort": "updated", "direction": "desc"},
283
+ )
284
+ data["issues"] = [compact_issue(item) for item in raw_issues if "pull_request" not in item]
285
+
286
+ if "commits" in include:
287
+ raw_commits = fetch_paged(api_url, f"/repos/{owner}/{repo}/commits", args.per_page, args.max_pages)
288
+ data["commits"] = [compact_commit(item) for item in raw_commits]
289
+
290
+ payload = {
291
+ "ok": True,
292
+ "source": "repo-fetch",
293
+ "version": VERSION,
294
+ "fetched_at": now_iso(),
295
+ "mode": "repository",
296
+ "input": {
297
+ "owner": owner,
298
+ "repo": repo,
299
+ "include": include,
300
+ "per_page": args.per_page,
301
+ "max_pages": args.max_pages,
302
+ },
303
+ "repository": compact_repo(repo_json),
304
+ "data": data,
305
+ }
306
+ print(json.dumps(payload, ensure_ascii=False, sort_keys=True))
307
+
308
+
309
+ if __name__ == "__main__":
310
+ main()
@@ -0,0 +1,254 @@
1
+ #!/usr/bin/env python3
2
+ import argparse
3
+ import datetime as dt
4
+ import html
5
+ import json
6
+ import sys
7
+
8
+ VERSION = "1.0.0"
9
+
10
+
11
+ def now_iso():
12
+ return dt.datetime.now(dt.timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
13
+
14
+
15
+ def fail(code, message, details=None, exit_code=1):
16
+ payload = {
17
+ "ok": False,
18
+ "error": {
19
+ "code": code,
20
+ "message": message,
21
+ "details": details or {},
22
+ },
23
+ }
24
+ print(json.dumps(payload, ensure_ascii=False, sort_keys=True), file=sys.stderr)
25
+ raise SystemExit(exit_code)
26
+
27
+
28
+ def read_stdin_json():
29
+ raw = sys.stdin.read()
30
+ if not raw.strip():
31
+ fail("invalid_input", "stdin is empty")
32
+ try:
33
+ return json.loads(raw)
34
+ except json.JSONDecodeError as exc:
35
+ fail("invalid_json", "stdin must be valid JSON", {"error": str(exc)})
36
+
37
+
38
+ def validate_analysis_payload(payload):
39
+ if not isinstance(payload, dict):
40
+ fail("invalid_input", "input JSON must be an object")
41
+ if payload.get("ok") is not True or payload.get("source") != "repo-analyze":
42
+ fail("invalid_input", "input must be successful repo-analyze output")
43
+ analysis = payload.get("analysis")
44
+ if not isinstance(analysis, dict):
45
+ fail("invalid_input", "input missing analysis object")
46
+ return analysis
47
+
48
+
49
+ def bullet_lines(items, formatter):
50
+ if not items:
51
+ return ["- None reported."]
52
+ return [f"- {formatter(item)}" for item in items]
53
+
54
+
55
+ def evidence_source_lines(payload):
56
+ sources = payload.get("evidence_sources") or {}
57
+ lines = ["", "## Evidence Sources", ""]
58
+ has_sources = False
59
+
60
+ query = sources.get("github_search_query")
61
+ if query:
62
+ lines.append(f"- GitHub search query: `{query}`")
63
+ has_sources = True
64
+
65
+ for repo in sources.get("repositories") or []:
66
+ if not isinstance(repo, dict):
67
+ continue
68
+ name = repo.get("name") or "GitHub repository"
69
+ url = repo.get("url")
70
+ lines.append(f"- GitHub repository: {name}" + (f" - {url}" if url else ""))
71
+ has_sources = True
72
+
73
+ for page in sources.get("web_pages") or []:
74
+ if not isinstance(page, dict):
75
+ continue
76
+ title = page.get("title") or page.get("url") or "Web page"
77
+ url = page.get("url")
78
+ lines.append(f"- Web page: {title}" + (f" - {url}" if url else ""))
79
+ has_sources = True
80
+
81
+ if not has_sources:
82
+ repo = payload.get("input", {}).get("repository_full_name")
83
+ if repo and not repo.startswith("github search:"):
84
+ lines.append(f"- GitHub repository: {repo} - https://github.com/{repo}")
85
+ else:
86
+ lines.append("- None captured in the input payload.")
87
+
88
+ return lines
89
+
90
+
91
+ def render_daily_markdown(payload, analysis):
92
+ repo = payload.get("input", {}).get("repository_full_name") or "unknown repository"
93
+ lines = [
94
+ f"# Daily Repository Health Report: {repo}",
95
+ "",
96
+ f"- Generated: {now_iso()}",
97
+ f"- Health score: {analysis.get('health_score')}/100",
98
+ f"- Model: {payload.get('model')}",
99
+ "",
100
+ "## Summary",
101
+ "",
102
+ str(analysis.get("summary", "")),
103
+ "",
104
+ "## Signals",
105
+ "",
106
+ ]
107
+ lines.extend(bullet_lines(analysis.get("signals", []), lambda x: f"**{x.get('name', 'signal')}** ({x.get('status', 'unknown')}): {x.get('detail', '')}"))
108
+ lines.extend(["", "## Actions", ""])
109
+ lines.extend(bullet_lines(analysis.get("recommendations", []), lambda x: f"[{x.get('priority', 'unknown')}] {x.get('action', '')} - {x.get('reason', '')}"))
110
+ lines.extend(evidence_source_lines(payload))
111
+ return "\n".join(lines).rstrip() + "\n"
112
+
113
+
114
+ def render_weekly_markdown(payload, analysis):
115
+ repo = payload.get("input", {}).get("repository_full_name") or "unknown repository"
116
+ lines = [
117
+ f"# Weekly Repository Health Review: {repo}",
118
+ "",
119
+ f"- Generated: {now_iso()}",
120
+ f"- Health score: {analysis.get('health_score')}/100",
121
+ f"- Included data: {', '.join(payload.get('input', {}).get('included', [])) or 'repository metadata only'}",
122
+ "",
123
+ "## Executive Summary",
124
+ "",
125
+ str(analysis.get("summary", "")),
126
+ "",
127
+ "## Health Signals",
128
+ "",
129
+ ]
130
+ lines.extend(bullet_lines(analysis.get("signals", []), lambda x: f"**{x.get('name', 'signal')}**: {x.get('status', 'unknown')} - {x.get('detail', '')}"))
131
+ lines.extend(["", "## Risks", ""])
132
+ lines.extend(bullet_lines(analysis.get("risks", []), lambda x: f"[{x.get('severity', 'unknown')}] {x.get('title', '')} - {x.get('detail', '')}"))
133
+ lines.extend(["", "## Recommended Next Steps", ""])
134
+ lines.extend(bullet_lines(analysis.get("recommendations", []), lambda x: f"[{x.get('priority', 'unknown')}] {x.get('action', '')} - {x.get('reason', '')}"))
135
+ lines.extend(evidence_source_lines(payload))
136
+ return "\n".join(lines).rstrip() + "\n"
137
+
138
+
139
+ def render_investment_markdown(payload, analysis):
140
+ repo = payload.get("input", {}).get("repository_full_name") or "unknown repository"
141
+ notes = analysis.get("investment_notes") or {}
142
+ lines = [
143
+ f"# Investment Repository Diligence: {repo}",
144
+ "",
145
+ f"- Generated: {now_iso()}",
146
+ f"- Health score: {analysis.get('health_score')}/100",
147
+ f"- Investment fit: {notes.get('fit', 'unknown')}",
148
+ "",
149
+ "## Thesis",
150
+ "",
151
+ str(notes.get("rationale") or analysis.get("summary", "")),
152
+ "",
153
+ "## Key Signals",
154
+ "",
155
+ ]
156
+ lines.extend(bullet_lines(analysis.get("signals", []), lambda x: f"**{x.get('name', 'signal')}** ({x.get('status', 'unknown')}): {x.get('detail', '')}"))
157
+ lines.extend(["", "## Diligence Risks", ""])
158
+ lines.extend(bullet_lines(analysis.get("risks", []), lambda x: f"[{x.get('severity', 'unknown')}] {x.get('title', '')} - {x.get('detail', '')}"))
159
+ lines.extend(["", "## Diligence Questions", ""])
160
+ lines.extend(bullet_lines(notes.get("diligence_questions", []), lambda x: str(x)))
161
+ lines.extend(evidence_source_lines(payload))
162
+ return "\n".join(lines).rstrip() + "\n"
163
+
164
+
165
+ def markdown_to_html(markdown):
166
+ lines = []
167
+ in_list = False
168
+ for raw_line in markdown.splitlines():
169
+ line = raw_line.rstrip()
170
+ if line.startswith("# "):
171
+ if in_list:
172
+ lines.append("</ul>")
173
+ in_list = False
174
+ lines.append(f"<h1>{html.escape(line[2:])}</h1>")
175
+ elif line.startswith("## "):
176
+ if in_list:
177
+ lines.append("</ul>")
178
+ in_list = False
179
+ lines.append(f"<h2>{html.escape(line[3:])}</h2>")
180
+ elif line.startswith("- "):
181
+ if not in_list:
182
+ lines.append("<ul>")
183
+ in_list = True
184
+ lines.append(f"<li>{html.escape(line[2:])}</li>")
185
+ elif not line:
186
+ if in_list:
187
+ lines.append("</ul>")
188
+ in_list = False
189
+ else:
190
+ if in_list:
191
+ lines.append("</ul>")
192
+ in_list = False
193
+ lines.append(f"<p>{html.escape(line)}</p>")
194
+ if in_list:
195
+ lines.append("</ul>")
196
+ body = "\n".join(lines)
197
+ return f"""<!doctype html>
198
+ <html lang="en">
199
+ <head>
200
+ <meta charset="utf-8">
201
+ <meta name="viewport" content="width=device-width, initial-scale=1">
202
+ <title>Repository Health Report</title>
203
+ <style>
204
+ body {{ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; line-height: 1.55; margin: 40px auto; max-width: 920px; padding: 0 20px; color: #172026; }}
205
+ h1, h2 {{ line-height: 1.2; }}
206
+ h1 {{ margin-bottom: 24px; }}
207
+ h2 {{ border-top: 1px solid #d8dee4; margin-top: 32px; padding-top: 24px; }}
208
+ li {{ margin: 8px 0; }}
209
+ </style>
210
+ </head>
211
+ <body>
212
+ {body}
213
+ </body>
214
+ </html>
215
+ """
216
+
217
+
218
+ def render_report(template, output_format, payload, analysis):
219
+ renderers = {
220
+ "daily": render_daily_markdown,
221
+ "weekly": render_weekly_markdown,
222
+ "investment": render_investment_markdown,
223
+ }
224
+ markdown = renderers[template](payload, analysis)
225
+ if output_format == "markdown":
226
+ return markdown
227
+ if output_format == "html":
228
+ return markdown_to_html(markdown)
229
+ fail("invalid_arguments", "unsupported output format", {"format": output_format})
230
+
231
+
232
+ def main():
233
+ parser = argparse.ArgumentParser(description="Generate markdown or HTML reports from repo-analyze JSON.")
234
+ parser.add_argument("--template", choices=["daily", "weekly", "investment"], default="daily", help="Report template. Default: daily.")
235
+ parser.add_argument("--format", choices=["markdown", "html"], default="markdown", help="Output format. Default: markdown.")
236
+ parser.add_argument("--output", help="Write report to this path instead of stdout.")
237
+ args = parser.parse_args()
238
+
239
+ payload = read_stdin_json()
240
+ analysis = validate_analysis_payload(payload)
241
+ report = render_report(args.template, args.format, payload, analysis)
242
+
243
+ if args.output:
244
+ try:
245
+ with open(args.output, "w", encoding="utf-8") as handle:
246
+ handle.write(report)
247
+ except OSError as exc:
248
+ fail("write_error", "could not write output file", {"path": args.output, "error": str(exc)})
249
+ else:
250
+ print(report, end="")
251
+
252
+
253
+ if __name__ == "__main__":
254
+ main()