@apolloyh/apollo-agent 0.1.3 → 0.1.4

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.
@@ -1 +1 @@
1
- {"version":3,"file":"skills.d.ts","sourceRoot":"","sources":["../../src/skills/skills.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,aAAa,CAAC;AAK/C,qBAAa,YAAY;IAIX,OAAO,CAAC,QAAQ,CAAC,MAAM;IAHnC,OAAO,CAAC,YAAY,CAA2C;IAC/D,OAAO,CAAC,QAAQ,CAAC,YAAY,CAA6B;gBAE7B,MAAM,EAAE,WAAW;IAEhD,OAAO,CAAC,SAAS;IAQX,MAAM,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,KAAK,CAAC;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IAoBtF,QAAQ,IAAI,OAAO,CAAC,KAAK,CAAC;QAAE,WAAW,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IAY/E,IAAI,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC;IAaxC,YAAY,CAAC,UAAU,EAAE,MAAM,EAAE,aAAa,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAA;KAAE,CAAC;IAuCvG,OAAO,CAAC,OAAO;YAKD,WAAW;CAuB1B"}
1
+ {"version":3,"file":"skills.d.ts","sourceRoot":"","sources":["../../src/skills/skills.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,aAAa,CAAC;AAK/C,qBAAa,YAAY;IAIX,OAAO,CAAC,QAAQ,CAAC,MAAM;IAHnC,OAAO,CAAC,YAAY,CAA2C;IAC/D,OAAO,CAAC,QAAQ,CAAC,YAAY,CAA6B;gBAE7B,MAAM,EAAE,WAAW;IAEhD,OAAO,CAAC,SAAS;IAMX,MAAM,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,KAAK,CAAC;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IAoBtF,QAAQ,IAAI,OAAO,CAAC,KAAK,CAAC;QAAE,WAAW,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IAY/E,IAAI,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC;IAaxC,YAAY,CAAC,UAAU,EAAE,MAAM,EAAE,aAAa,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAA;KAAE,CAAC;IAuCvG,OAAO,CAAC,OAAO;YAKD,WAAW;CAuB1B"}
@@ -1,6 +1,5 @@
1
1
  import fs from "node:fs/promises";
2
2
  import path from "node:path";
3
- import { fileURLToPath } from "node:url";
4
3
  import { safeRealResolve, truncate } from "../utils.js";
5
4
  export class SkillManager {
6
5
  config;
@@ -10,11 +9,7 @@ export class SkillManager {
10
9
  this.config = config;
11
10
  }
12
11
  skillDirs() {
13
- const bundled = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../src/skills");
14
- return Array.from(new Set([
15
- ...this.config.skills.directories.map((dir) => path.resolve(this.config.workspaceRoot, dir)),
16
- bundled,
17
- ]));
12
+ return Array.from(new Set(this.config.skills.directories.map((dir) => path.resolve(this.config.workspaceRoot, dir))));
18
13
  }
19
14
  async search(query) {
20
15
  const results = [];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@apolloyh/apollo-agent",
3
- "version": "0.1.3",
3
+ "version": "0.1.4",
4
4
  "type": "module",
5
5
  "description": "Apollo — your personal agent. Answer first, act when needed. Claude Code–style Task subagents.",
6
6
  "bin": {
@@ -20,7 +20,6 @@
20
20
  },
21
21
  "files": [
22
22
  "dist",
23
- "src/skills/tech-research-skill",
24
23
  "README.md",
25
24
  "docs/sdk.md",
26
25
  ".apollo/config.example.json",
@@ -1,200 +0,0 @@
1
- ---
2
- name: tech-research-skill
3
- description: "技术方向、行业领域、产品赛道、GitHub 仓库和开源项目的调查、研究、竞品分析、趋势分析和报告生成。Use when the user asks to investigate, research, evaluate, compare, analyze, score, summarize, or report on a technology direction, market/domain niche, AI infra, agent, RAG, LLM, devtool, data, security, web3, GitHub repository, GitHub URL, or open-source project. Handles GitHub repo search, repo health, issues/commits, implementation patterns, competitor landscape, market trend research, evidence sources, Markdown report output, and downloadable report links."
4
- ---
5
-
6
- # Tech Research Skill
7
-
8
- Use this skill for both:
9
-
10
- - **Direction research**: investigate a technology direction, industry/domain, product category, or open-source theme.
11
- - **Repository research**: investigate one or more GitHub repositories or open-source projects.
12
-
13
- Default to this skill for prompts like "调查数字人方向", "分析 RAG agent 赛道", "研究某 GitHub 项目", "竞品分析", "趋势分析", "开源项目尽调", or "生成研究报告".
14
-
15
- ## Tool Location
16
-
17
- The shell tools live in the same directory as this `SKILL.md`.
18
-
19
- Set the tool directory from the loaded skill path:
20
-
21
- ```sh
22
- TECH_RESEARCH_SKILL_DIR="$(dirname "$SKILL_PATH")"
23
- ```
24
-
25
- If the current shell is already running from this skill directory, use:
26
-
27
- ```sh
28
- TECH_RESEARCH_SKILL_DIR="."
29
- ```
30
-
31
- Do not use absolute paths. Do not run exploratory `find`, `ls`, or `echo $CLAUDE_SKILL_DIR` just to locate these tools. If path context is uncertain, call `skill_read` for `tech-research-skill` and derive the directory from the returned path.
32
-
33
- Tool role labels:
34
-
35
- - `repo_search`: `"$TECH_RESEARCH_SKILL_DIR/repo-fetch" --search "<query>" --per-page 10`
36
- - `repo_fetch`: `"$TECH_RESEARCH_SKILL_DIR/repo-fetch" owner/repo --include issues,commits`
37
- - `repo_analyze`: `"$TECH_RESEARCH_SKILL_DIR/repo-analyze"` reads `repo-fetch` JSON from stdin and emits deterministic local health signals. It does not call an LLM, does not use network, and does not require model credentials.
38
- - `repo_report_generate`: `"$TECH_RESEARCH_SKILL_DIR/report-generate" --template investment --format markdown --output <report>.md` renders `repo-analyze` JSON into Markdown or HTML. It does not call an LLM.
39
-
40
- These role labels are not separate executable filenames. The executable scripts are `repo-fetch`, `repo-analyze`, and `report-generate`.
41
-
42
- The outer agent is responsible for the actual judgment: repository health interpretation, implementation-pattern analysis, competitor analysis, market/trend synthesis, opportunity mapping, and final recommendation. Treat `repo-analyze` output as structured evidence, not as the final answer.
43
-
44
- ## GitHub API Token
45
-
46
- `repo-fetch` uses the GitHub REST API. Unauthenticated requests are rate-limited quickly, so prefer a local token.
47
-
48
- Supported environment variables:
49
-
50
- ```sh
51
- GITHUB_TOKEN=<github personal access token>
52
- GH_TOKEN=<github personal access token>
53
- ```
54
-
55
- `repo-fetch` also loads a local `.env` file from this skill directory. That `.env` file is ignored by git and may contain:
56
-
57
- ```sh
58
- GITHUB_TOKEN=<github personal access token>
59
- ```
60
-
61
- Never hardcode a GitHub token into `repo-fetch`, `SKILL.md`, reports, examples, command output, or committed files. If a token appears in chat/logs/source, tell the user to revoke and rotate it.
62
-
63
- ## Workflow A: Technology Direction Research
64
-
65
- Use this workflow when the user provides a field, technology direction, product category, or market/domain.
66
-
67
- ### Turn Budget Rules
68
-
69
- Finish with a report instead of continuing to search. For a normal direction investigation:
70
-
71
- - Run at most 4 GitHub search queries.
72
- - Fetch details for at most 4 representative repositories.
73
- - Fetch/search at most 5 web sources.
74
- - If GitHub or web search quality is poor twice, stop searching and write the report with limitations.
75
- - Do not run `date`, `ls docs`, or other housekeeping commands before writing the report.
76
- - Prefer a concise evidence-backed report over exhaustive searching.
77
- - Once there is enough evidence for a directional judgment, immediately write the Markdown report to the repository-root `docs/` directory. If the current working directory is `agent`, use `../docs/<filename>.md`; if the current working directory is this skill directory, use `../../../../docs/<filename>.md`. Do not write `docs/<filename>.md` from the `agent` directory, because that creates `agent/docs` and the frontend will not treat it as the canonical report location.
78
-
79
- 1. Frame the topic:
80
- - Normalize the topic name.
81
- - Identify adjacent keywords and English/Chinese search terms.
82
- - Decide whether the user needs build advice, investment analysis, competitor analysis, learning research, or a general trend report.
83
-
84
- 2. Search GitHub first:
85
-
86
- ```sh
87
- "$TECH_RESEARCH_SKILL_DIR/repo-fetch" --search "<topic keywords> stars:>100" --per-page 10
88
- ```
89
-
90
- Select 5-10 representative repositories:
91
-
92
- - Most-starred or widely used projects.
93
- - Recently active challengers.
94
- - Different implementation approaches.
95
- - Commercially relevant SDKs, frameworks, or infrastructure.
96
- - Niche projects that reveal emerging use cases.
97
-
98
- Do not rely only on stars. Prefer repos with recent commits, active issues/PRs, releases, real users, and clear positioning.
99
-
100
- 3. Analyze representative repositories:
101
-
102
- ```sh
103
- "$TECH_RESEARCH_SKILL_DIR/repo-fetch" owner/repo --include issues,commits \
104
- | "$TECH_RESEARCH_SKILL_DIR/repo-analyze"
105
- ```
106
-
107
- `repo-analyze` only computes local deterministic signals from the fetched GitHub data. The agent must read the JSON and perform the higher-level health analysis itself.
108
-
109
- 4. Search the web for trend evidence:
110
- - Use current authoritative sources: official docs, standards bodies, foundation pages, vendor announcements, market reports, funding/news coverage, developer surveys, ecosystem reports, benchmarks, regulation/compliance updates.
111
- - Collect source URLs and dates.
112
- - Distinguish observed evidence from inference.
113
-
114
- 5. Produce a report with this structure:
115
- - **Executive Judgment**: high/medium/low potential and why.
116
- - **GitHub Landscape**: repo table, implementation approaches, health, risks.
117
- - **Competitor Analysis**: direct open-source, commercial products, infrastructure platforms, substitutes.
118
- - **Trend and Market Signals**: drivers, tailwinds, headwinds, 6-18 month outlook.
119
- - **Opportunity Map**: underserved users, product wedges, technical differentiation, distribution/community angles.
120
- - **Recommendation**: build/watch/avoid, MVP scope, validation experiments, what to monitor.
121
- - **Evidence Sources**: final section with every repo, GitHub query, issue/commit dataset, and web page used.
122
-
123
- ## Workflow B: GitHub Repository Research
124
-
125
- Use this workflow when the user gives a GitHub URL, `owner/repo`, or asks for open-source project health.
126
-
127
- Convert GitHub URLs to `owner/repo`.
128
-
129
- Fetch and analyze:
130
-
131
- ```sh
132
- "$TECH_RESEARCH_SKILL_DIR/repo-fetch" owner/repo --include issues,commits \
133
- | "$TECH_RESEARCH_SKILL_DIR/repo-analyze" \
134
- | "$TECH_RESEARCH_SKILL_DIR/report-generate" --template weekly --format markdown
135
- ```
136
-
137
- For investment or opportunity analysis:
138
-
139
- ```sh
140
- "$TECH_RESEARCH_SKILL_DIR/repo-fetch" owner/repo --include issues,commits \
141
- | "$TECH_RESEARCH_SKILL_DIR/repo-analyze" \
142
- | "$TECH_RESEARCH_SKILL_DIR/report-generate" --template investment --format markdown
143
- ```
144
-
145
- The generated repository report is a starting template. For user-facing research, the agent should expand it with its own analysis, web evidence, competitor context, and a final evidence-source section.
146
-
147
- ## Report Output
148
-
149
- Always write final user-facing reports as Markdown under the repository root `docs/` directory, not under `agent/docs`.
150
-
151
- Canonical report locations by current working directory:
152
-
153
- ```sh
154
- # If cwd is the agent root:
155
- DOCS_DIR="../docs"
156
-
157
- # If cwd is this skill directory:
158
- DOCS_DIR="../../../../docs"
159
- ```
160
-
161
- Before writing, choose the path from the actual cwd. Never use `docs/<filename>.md` when cwd is `agent`.
162
-
163
- Use a lowercase slug plus timestamp when useful:
164
-
165
- ```text
166
- ../docs/digital-human-research-20260611-153000.md
167
- ../docs/repo-health-owner-repo-20260611-153000.md
168
- ```
169
-
170
- Do not put an absolute path in the report instructions.
171
-
172
- Use the dedicated file-writing tool when available. Do not use shell redirection or here-docs for writing the report if a `write_file` tool exists.
173
-
174
- Use a simple stable filename when no timestamp is available, such as `../docs/digital-human-research.md` from the agent root. It is better to overwrite a topic report than to spend a turn checking the clock or listing directories.
175
-
176
-
177
- Every final report must end with:
178
-
179
- ```md
180
- ## Evidence Sources
181
- ```
182
-
183
- Include:
184
-
185
- - GitHub repositories used as evidence.
186
- - GitHub search queries that shaped repo selection.
187
- - Issue/commit datasets used.
188
- - Web pages, articles, docs, market reports, announcements, and search results used for trend or competitor claims.
189
- - Source names, URLs, and publication/access dates when available.
190
-
191
- Do not put download links inside the Markdown report file, and do not manually add download links in the web frontend final answer. The frontend chat route appends report links after the assistant message once it detects the generated file. If a manual link is ever needed outside the frontend, the `file` query parameter must be only the basename in `docs`, not a path.
192
-
193
- ## Quality Bar
194
-
195
- - Use real repositories and real external sources; do not invent competitors or metrics.
196
- - Cite GitHub repos and web sources with links.
197
- - Prefer evidence-backed judgment over generic commentary.
198
- - Separate facts from inference.
199
- - Call out uncertainty, missing credentials, GitHub API limits, failed fetches, and weak evidence.
200
- - If the user asks in Chinese, answer in Chinese unless they request otherwise.
@@ -1,4 +0,0 @@
1
- interface:
2
- display_name: "Tech Research"
3
- short_description: "Research tech directions, repos, competitors, and trends"
4
- default_prompt: "Use $tech-research-skill to research a technology direction or GitHub repository and produce an evidence-backed report."
@@ -1,435 +0,0 @@
1
- #!/usr/bin/env python3
2
- import argparse
3
- import datetime as dt
4
- import json
5
- import math
6
- import sys
7
-
8
- VERSION = "1.1.0"
9
- MODEL = "local-heuristic-v1"
10
-
11
-
12
- def now_iso():
13
- return dt.datetime.now(dt.timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
14
-
15
-
16
- def fail(code, message, details=None, exit_code=1):
17
- payload = {
18
- "ok": False,
19
- "error": {
20
- "code": code,
21
- "message": message,
22
- "details": details or {},
23
- },
24
- }
25
- print(json.dumps(payload, ensure_ascii=False, sort_keys=True), file=sys.stderr)
26
- raise SystemExit(exit_code)
27
-
28
-
29
- def read_stdin_json(max_bytes):
30
- raw = sys.stdin.buffer.read(max_bytes + 1)
31
- if not raw:
32
- fail("invalid_input", "stdin is empty")
33
- if len(raw) > max_bytes:
34
- fail("invalid_input", "stdin exceeds --max-input-bytes", {"max_input_bytes": max_bytes})
35
- try:
36
- return json.loads(raw.decode("utf-8"))
37
- except UnicodeDecodeError as exc:
38
- fail("invalid_input", "stdin must be UTF-8", {"error": str(exc)})
39
- except json.JSONDecodeError as exc:
40
- fail("invalid_json", "stdin must be valid JSON", {"error": str(exc)})
41
-
42
-
43
- def validate_fetch_payload(payload):
44
- if not isinstance(payload, dict):
45
- fail("invalid_input", "input JSON must be an object")
46
- if payload.get("ok") is not True or payload.get("source") != "repo-fetch":
47
- fail("invalid_input", "input must be successful repo-fetch output")
48
- mode = payload.get("mode", "repository")
49
- if mode == "repository" and not isinstance(payload.get("repository"), dict):
50
- fail("invalid_input", "repository input missing repository object")
51
- if mode == "search" and not isinstance(payload.get("search"), dict):
52
- fail("invalid_input", "search input missing search object")
53
- if mode not in {"repository", "search"}:
54
- fail("invalid_input", "unsupported repo-fetch mode", {"mode": mode})
55
-
56
-
57
- def parse_time(value):
58
- if not value or not isinstance(value, str):
59
- return None
60
- normalized = value.replace("Z", "+00:00")
61
- try:
62
- parsed = dt.datetime.fromisoformat(normalized)
63
- except ValueError:
64
- return None
65
- if parsed.tzinfo is None:
66
- parsed = parsed.replace(tzinfo=dt.timezone.utc)
67
- return parsed.astimezone(dt.timezone.utc)
68
-
69
-
70
- def days_since(value):
71
- parsed = parse_time(value)
72
- if parsed is None:
73
- return None
74
- return max(0, (dt.datetime.now(dt.timezone.utc) - parsed).days)
75
-
76
-
77
- def clamp(value, minimum=0, maximum=100):
78
- return max(minimum, min(maximum, int(round(value))))
79
-
80
-
81
- def count_recent(items, key, max_days):
82
- count = 0
83
- for item in items or []:
84
- if isinstance(item, dict):
85
- age = days_since(item.get(key))
86
- if age is not None and age <= max_days:
87
- count += 1
88
- return count
89
-
90
-
91
- def stars_points(stars):
92
- stars = stars or 0
93
- if stars <= 0:
94
- return 0
95
- return min(22, math.log10(stars + 1) * 6)
96
-
97
-
98
- def forks_points(forks):
99
- forks = forks or 0
100
- if forks <= 0:
101
- return 0
102
- return min(10, math.log10(forks + 1) * 4)
103
-
104
-
105
- def recency_status(age_days):
106
- if age_days is None:
107
- return "watch", "no pushed_at timestamp was captured"
108
- if age_days <= 30:
109
- return "good", f"last push was {age_days} days ago"
110
- if age_days <= 180:
111
- return "watch", f"last push was {age_days} days ago"
112
- return "poor", f"last push was {age_days} days ago"
113
-
114
-
115
- def score_repository(repo, data):
116
- stars = repo.get("stargazers_count") or 0
117
- forks = repo.get("forks_count") or 0
118
- open_issues = repo.get("open_issues_count") or 0
119
- push_age = days_since(repo.get("pushed_at"))
120
- commits = data.get("commits") if isinstance(data, dict) else []
121
- issues = data.get("issues") if isinstance(data, dict) else []
122
-
123
- score = 38
124
- score += stars_points(stars)
125
- score += forks_points(forks)
126
-
127
- if push_age is None:
128
- score -= 2
129
- elif push_age <= 30:
130
- score += 18
131
- elif push_age <= 90:
132
- score += 12
133
- elif push_age <= 180:
134
- score += 6
135
- elif push_age <= 365:
136
- score -= 4
137
- else:
138
- score -= 14
139
-
140
- if commits:
141
- recent_commits = count_recent(commits, "date", 90)
142
- score += min(10, len(commits) / 3)
143
- if recent_commits:
144
- score += min(8, recent_commits * 2)
145
- else:
146
- score -= 4
147
-
148
- if issues:
149
- recently_updated_issues = count_recent(issues, "updated_at", 90)
150
- score += min(6, recently_updated_issues)
151
-
152
- if repo.get("archived"):
153
- score -= 35
154
- if repo.get("disabled"):
155
- score -= 25
156
- if not repo.get("license"):
157
- score -= 5
158
- if stars >= 1000 and forks >= 100:
159
- score += 5
160
- if open_issues > max(200, stars * 0.12):
161
- score -= 5
162
-
163
- return clamp(score)
164
-
165
-
166
- def investment_fit(score, risks):
167
- high_risks = sum(1 for risk in risks if risk.get("severity") == "high")
168
- if score >= 80 and high_risks == 0:
169
- return "strong"
170
- if score >= 65:
171
- return "promising"
172
- if score >= 45:
173
- return "watchlist"
174
- return "avoid"
175
-
176
-
177
- def analyze_repository(fetch_payload):
178
- repo = fetch_payload.get("repository") or {}
179
- data = fetch_payload.get("data") or {}
180
- issues = data.get("issues") or []
181
- commits = data.get("commits") or []
182
- name = repo.get("full_name") or repo.get("html_url") or "unknown repository"
183
- stars = repo.get("stargazers_count") or 0
184
- forks = repo.get("forks_count") or 0
185
- open_issues = repo.get("open_issues_count") or 0
186
- push_age = days_since(repo.get("pushed_at"))
187
- activity_status, activity_detail = recency_status(push_age)
188
- recent_commits = count_recent(commits, "date", 90)
189
- updated_issues = count_recent(issues, "updated_at", 90)
190
-
191
- signals = [
192
- {
193
- "name": "activity",
194
- "status": activity_status,
195
- "detail": activity_detail
196
- + (f"; {recent_commits} captured commits in the last 90 days" if commits else "; commit dataset was not captured"),
197
- },
198
- {
199
- "name": "community",
200
- "status": "good" if stars >= 1000 else "watch" if stars >= 100 else "poor",
201
- "detail": f"{stars} stars, {forks} forks, {open_issues} open issues",
202
- },
203
- {
204
- "name": "maintenance",
205
- "status": "poor" if repo.get("archived") or repo.get("disabled") else "good" if updated_issues or recent_commits else "watch",
206
- "detail": f"archived={bool(repo.get('archived'))}, disabled={bool(repo.get('disabled'))}, license={repo.get('license') or 'not captured'}",
207
- },
208
- {
209
- "name": "risk",
210
- "status": "poor" if repo.get("archived") or repo.get("disabled") else "watch" if not repo.get("license") else "good",
211
- "detail": "primary risk flags are based on archive/disabled/license metadata and captured issue activity",
212
- },
213
- {
214
- "name": "momentum",
215
- "status": "good" if (push_age is not None and push_age <= 30 and stars >= 100) else "watch" if push_age is not None and push_age <= 180 else "poor",
216
- "detail": f"recent issue updates captured: {updated_issues}; recent commits captured: {recent_commits}",
217
- },
218
- ]
219
-
220
- risks = []
221
- if repo.get("archived"):
222
- risks.append({"severity": "high", "title": "Repository is archived", "detail": "GitHub metadata marks the repository as archived."})
223
- if repo.get("disabled"):
224
- risks.append({"severity": "high", "title": "Repository is disabled", "detail": "GitHub metadata marks the repository as disabled."})
225
- if push_age is None:
226
- risks.append({"severity": "medium", "title": "Missing activity timestamp", "detail": "No pushed_at value was captured."})
227
- elif push_age > 365:
228
- risks.append({"severity": "high", "title": "Long inactivity window", "detail": f"Last push was {push_age} days ago."})
229
- elif push_age > 180:
230
- risks.append({"severity": "medium", "title": "Activity is slowing", "detail": f"Last push was {push_age} days ago."})
231
- if not repo.get("license"):
232
- risks.append({"severity": "medium", "title": "License not captured", "detail": "The repository metadata did not include an SPDX license."})
233
- if not commits:
234
- risks.append({"severity": "low", "title": "No commit sample", "detail": "Run repo-fetch with --include commits for stronger activity evidence."})
235
- if not issues:
236
- risks.append({"severity": "low", "title": "No issue sample", "detail": "Run repo-fetch with --include issues for stronger maintenance evidence."})
237
- if not risks:
238
- risks.append({"severity": "low", "title": "No major metadata risk", "detail": "No high-risk flags were found in the captured GitHub metadata."})
239
-
240
- score = score_repository(repo, data)
241
- fit = investment_fit(score, risks)
242
- summary = (
243
- f"{name} has {stars} stars and {forks} forks. "
244
- f"Captured metadata indicates {activity_detail}; optional datasets include "
245
- f"{', '.join(sorted(data.keys())) if data else 'repository metadata only'}. "
246
- "This local score is a deterministic signal summary, not a final strategic judgment."
247
- )
248
-
249
- recommendations = [
250
- {
251
- "priority": "high" if score < 60 else "medium",
252
- "action": "Review maintainers, releases, and recent pull requests before relying on the project.",
253
- "reason": "The local analyzer only sees repository metadata plus optional issue/commit samples.",
254
- },
255
- {
256
- "priority": "medium",
257
- "action": "Compare implementation approach against adjacent repositories in the same topic.",
258
- "reason": "Relative positioning is more useful than a standalone repository score.",
259
- },
260
- ]
261
- if not commits or not issues:
262
- recommendations.append(
263
- {
264
- "priority": "medium",
265
- "action": "Fetch issues and commits for a stronger health read.",
266
- "reason": "Missing optional datasets reduce confidence in maintenance and momentum signals.",
267
- }
268
- )
269
-
270
- return {
271
- "health_score": score,
272
- "summary": summary,
273
- "signals": signals,
274
- "risks": risks,
275
- "recommendations": recommendations,
276
- "investment_notes": {
277
- "fit": fit,
278
- "rationale": f"Local repository health score is {score}/100. The agent should combine this with market, competitor, and product evidence before making a final call.",
279
- "diligence_questions": [
280
- "Are maintainers still shipping releases or accepting pull requests?",
281
- "Do users report production adoption, benchmarks, or deployment constraints?",
282
- "What commercial or open-source alternatives solve the same job better?",
283
- ],
284
- },
285
- }
286
-
287
-
288
- def analyze_search(fetch_payload):
289
- search = fetch_payload.get("search") or {}
290
- items = [item for item in search.get("items") or [] if isinstance(item, dict)]
291
- query = search.get("query") or "unknown query"
292
- total_count = search.get("total_count") or 0
293
- top_items = items[:5]
294
- top_stars = max([(item.get("stargazers_count") or 0) for item in items] or [0])
295
- recent_count = sum(1 for item in items if (days_since(item.get("pushed_at")) or 999999) <= 180)
296
-
297
- score = 35
298
- score += min(20, math.log10(total_count + 1) * 5 if total_count else 0)
299
- score += min(25, math.log10(top_stars + 1) * 6 if top_stars else 0)
300
- score += min(20, recent_count * 3)
301
- if search.get("incomplete_results"):
302
- score -= 5
303
- score = clamp(score)
304
-
305
- leaders = ", ".join(
306
- f"{item.get('full_name')} ({item.get('stargazers_count') or 0} stars)"
307
- for item in top_items
308
- if item.get("full_name")
309
- )
310
- if not leaders:
311
- leaders = "no repositories captured"
312
-
313
- risks = [
314
- {
315
- "severity": "medium",
316
- "title": "Search result only",
317
- "detail": "Search payloads do not include issue or commit samples; fetch representative repositories before final judgment.",
318
- }
319
- ]
320
- if search.get("incomplete_results"):
321
- risks.append({"severity": "medium", "title": "Incomplete GitHub search", "detail": "GitHub marked the search result as incomplete."})
322
- if not items:
323
- risks.append({"severity": "high", "title": "No repositories captured", "detail": "The search query did not return usable repository items."})
324
-
325
- return {
326
- "health_score": score,
327
- "summary": f"GitHub search `{query}` returned {total_count} total matches. Leading captured repositories: {leaders}. This is a local landscape signal, not a final market analysis.",
328
- "signals": [
329
- {
330
- "name": "community",
331
- "status": "good" if top_stars >= 1000 else "watch" if top_stars >= 100 else "poor",
332
- "detail": f"top captured repository has {top_stars} stars",
333
- },
334
- {
335
- "name": "activity",
336
- "status": "good" if recent_count >= 5 else "watch" if recent_count else "poor",
337
- "detail": f"{recent_count} captured repositories were pushed within the last 180 days",
338
- },
339
- {
340
- "name": "momentum",
341
- "status": "good" if score >= 70 else "watch" if score >= 45 else "poor",
342
- "detail": f"search-level local score is {score}/100 across {len(items)} captured repositories",
343
- },
344
- ],
345
- "risks": risks,
346
- "recommendations": [
347
- {
348
- "priority": "high",
349
- "action": "Fetch 3-5 representative repositories with issues and commits.",
350
- "reason": "Repository details are required for evidence-backed health and implementation analysis.",
351
- },
352
- {
353
- "priority": "medium",
354
- "action": "Use multiple adjacent keyword searches before choosing competitors.",
355
- "reason": "One GitHub query can miss projects that use different positioning language.",
356
- },
357
- ],
358
- "investment_notes": {
359
- "fit": investment_fit(score, risks),
360
- "rationale": f"The search landscape score is {score}/100 based on captured repository count, top stars, and recency. The agent should synthesize final potential and competitor analysis.",
361
- "diligence_questions": [
362
- "Which captured repositories represent production-ready infrastructure versus demos?",
363
- "Which projects are substitutes, complements, or direct competitors?",
364
- "Are commercial vendors creating adoption that open-source projects can leverage?",
365
- ],
366
- },
367
- }
368
-
369
-
370
- def build_evidence_sources(fetch_payload):
371
- repositories = []
372
- repo = fetch_payload.get("repository") or {}
373
- if repo.get("full_name") or repo.get("html_url"):
374
- repositories.append(
375
- {
376
- "name": repo.get("full_name") or repo.get("html_url"),
377
- "url": repo.get("html_url"),
378
- }
379
- )
380
-
381
- search = fetch_payload.get("search") or {}
382
- for item in search.get("items") or []:
383
- if isinstance(item, dict) and (item.get("full_name") or item.get("html_url")):
384
- repositories.append(
385
- {
386
- "name": item.get("full_name") or item.get("html_url"),
387
- "url": item.get("html_url"),
388
- }
389
- )
390
-
391
- return {
392
- "github_search_query": search.get("query"),
393
- "repositories": repositories,
394
- "web_pages": [],
395
- }
396
-
397
-
398
- def main():
399
- parser = argparse.ArgumentParser(description="Summarize repo-fetch JSON with local deterministic heuristics. No LLM or API credentials are used.")
400
- parser.add_argument("--max-input-bytes", type=int, default=200000, help="Maximum stdin size. Default: 200000.")
401
- args = parser.parse_args()
402
-
403
- if args.max_input_bytes < 1:
404
- fail("invalid_arguments", "--max-input-bytes must be at least 1", {"max_input_bytes": args.max_input_bytes})
405
-
406
- fetch_payload = read_stdin_json(args.max_input_bytes)
407
- validate_fetch_payload(fetch_payload)
408
-
409
- mode = fetch_payload.get("mode", "repository")
410
- analysis = analyze_search(fetch_payload) if mode == "search" else analyze_repository(fetch_payload)
411
-
412
- repo = fetch_payload.get("repository", {})
413
- search = fetch_payload.get("search", {})
414
- subject = repo.get("full_name") or f"github search: {search.get('query')}"
415
- payload = {
416
- "ok": True,
417
- "source": "repo-analyze",
418
- "version": VERSION,
419
- "analyzed_at": now_iso(),
420
- "model": MODEL,
421
- "provider": "local-deterministic",
422
- "input": {
423
- "repository_full_name": subject,
424
- "mode": mode,
425
- "fetched_at": fetch_payload.get("fetched_at"),
426
- "included": sorted((fetch_payload.get("data") or {}).keys()),
427
- },
428
- "evidence_sources": build_evidence_sources(fetch_payload),
429
- "analysis": analysis,
430
- }
431
- print(json.dumps(payload, ensure_ascii=False, sort_keys=True))
432
-
433
-
434
- if __name__ == "__main__":
435
- main()
@@ -1,310 +0,0 @@
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()
@@ -1,254 +0,0 @@
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()