gh-issue-to-demand-signal について
gh-issue-to-demand-signalは、競合の公開 GitHub リポジトリのオープン issue を分析し、需要ギャップの順位付きレポートとGTM向けメッセージ案を作成するスキルです。ノイズを除外して6つの需要カテゴリに分類し、issue のタイトルをそのまま保ちながらエンゲージメントを基に評価します。
主な機能
- 01オープン GitHub issue を最大200件取得
- 02プルリクエストと低シグナルのノイズを除外
- 03issue を6つの需要カテゴリに分類
- 04エンゲージメントシグナルで需要を評価
- 05ギャップレポートとメッセージ案を作成
活用シーン
- 01競合リポジトリの需要ギャップを評価
- 02issue タイトルから繰り返される不満を発見
- 03根拠に基づく訴求とアウトリーチ文を作成
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ varnan-tech/ opendirectory --skill gh-issue-to-demand-signalインストール後、Claude Code で/gh-issue-to-demand-signalを入力して読み込まれたか確認します。
同梱ファイル
SKILL.md と一緒にインストールされる参考資料やスクリプト。
.env.exampleREADME.mdevals/evals.jsonreferences/demand-categories.mdgtm-translation.md詳細
GitHub Issue の需要シグナル
競合他社の公開 GitHub リポジトリを指定します。オープン中の Issue を取得し、ノイズをローカルで除去します。需要を 6 つのカテゴリに分類し、実際のエンゲージメントに基づいてスコアを付けます。需要ギャップのランキングレポートと GTM メッセージング概要を出力します。
重要なルール: 出力内の Issue タイトルはすべて、GitHub API のレスポンスから一字一句そのまま引用してください。各クラスターのテーマ名は、そのクラスター内の実際の Issue タイトルから導き出してください。ノイズ除去後に Issue が 10 件未満の場合は処理を停止し、信頼できる分類を行うにはリポジトリの規模が小さすぎることをユーザーに伝えてください。Issue の内容を創作することは一切禁止です。
よくある間違い
| エージェントがやりがちなこと | それが間違っている理由 |
|---|---|
| フィルタリングせず、未加工の Issue 200 件をすべて AI に送る | ボットの Issue、PR、エンゲージメントがゼロのノイズによってクラスター数が膨らみ、コンテキストが無駄になります。まずローカルでフィルタリングしてください。 |
| コメント数を主要な需要シグナルとして使う | コメントにはメンテナーの返信、話題から外れた議論、スパムが含まれます。reactions["+1"] が最も信頼できる購入者シグナルです。 |
| クラスターを要約する際に Issue タイトルを言い換える | 言い換えると、購入者が使った正確な表現が失われます。それこそがこの分析の目的です。Issue タイトルを一字一句そのまま使ってください。 |
| フィルタリング後に Issue が 10 件未満でもステップ 4 以降を続ける | Issue が 10 件未満の場合、リポジトリの規模が小さすぎるか、指定された URL が間違っています。データがまばらな状態で分類しても、意味のないカテゴリができるだけです。 |
| 分析にプルリクエストを含める | GitHub Issues エンドポイントは PR も返します。Issue オブジェクトに pull_request キーがないことを確認して除外してください。 |
| 3 つの条件をすべて確認せずに、Issue を無視された需要として分類する | 3 つすべてに該当する必要があります。reactions >= 10、経過日数 >= 180 日、planned/in-progress/roadmap ラベルがないことです。どれか 1 つでも満たさなければ、その Issue は対象外です。 |
ステップ 1:セットアップの確認
echo "GITHUB_TOKEN: ${GITHUB_TOKEN:-not set, unauthenticated rate limit applies (60 req/hr)}"
GITHUB_TOKEN が設定されていない場合: 続行します。ユーザーに次のように伝えてください。「GITHUB_TOKEN が設定されていません。未認証時のレート制限は 1 時間あたり 60 リクエストです。制限に達する前に 2 回取得できます。繰り返し使用する場合は、github.com/settings/tokens でトークンを追加してください(公開リポジトリにはスコープは不要です)。」
ステップ 2:入力の取得
必要なもの:
- GitHub リポジトリの URL(例:https://github.com/owner/repo)または owner/repo 形式のスラッグ(例:facebook/react)
入力から owner と repo を解析します。
python3 << 'PYEOF'
import re, sys, os
raw = "REPO_INPUT_HERE"
# Normalize to owner/repo
if raw.startswith("http"):
m = re.search(r"github\.com/([^/]+)/([^/?\s]+)", raw)
if not m:
print("ERROR: Could not parse GitHub URL. Expected format: https://github.com/owner/repo")
sys.exit(1)
owner, repo = m.group(1), m.group(2).rstrip("/")
elif "/" in raw:
parts = raw.strip().split("/")
owner, repo = parts[0], parts[1]
else:
print("ERROR: Input must be a GitHub URL or owner/repo slug (e.g. vercel/next.js)")
sys.exit(1)
print(f"Owner: {owner}")
print(f"Repo: {repo}")
with open("/tmp/ghd-target.txt", "w") as f:
f.write(f"{owner}/{repo}")
PYEOF
解析に失敗した場合: 停止します。次のように尋ねてください。「GitHub リポジトリを URL(https://github.com/owner/repo)または owner/repo 形式のスラッグ(例:vercel/next.js)で指定してください。」
ステップ 3:GitHub REST API から Issue を取得
Issue を最大 200 件(100 件ずつ 2 ページ)取得します。最初の取得後にレート制限を確認してください。
python3 << 'PYEOF'
import json, urllib.request, os, sys
from datetime import datetime, timezone
target = open("/tmp/ghd-target.txt").read().strip()
owner_repo = target
token = os.environ.get("GITHUB_TOKEN", "")
headers = {"Accept": "application/vnd.github+json", "User-Agent": "gh-issue-demand-signal/1.0"}
if token:
headers["Authorization"] = f"Bearer {token}"
all_issues = []
rate_limit_hit = False
for page in [1, 2]:
url = f"https://api.github.com/repos/{owner_repo}/issues?state=open&per_page=100&page={page}"
req = urllib.request.Request(url, headers=headers)
try:
with urllib.request.urlopen(req, timeout=30) as resp:
# Check rate limit after first page
if page == 1:
remaining = int(resp.headers.get("X-RateLimit-Remaining", 999))
reset_ts = resp.headers.get("X-RateLimit-Reset", "")
if remaining == 0:
reset_str = datetime.fromtimestamp(int(reset_ts), tz=timezone.utc).strftime("%H:%M UTC") if reset_ts else "unknown"
print(f"ERROR: GitHub rate limit exhausted. Resets at {reset_str}.")
print("Add GITHUB_TOKEN to your .env file to get 5000 req/hr. See github.com/settings/tokens (no scopes needed).")
sys.exit(1)
print(f"Rate limit remaining: {remaining}")
# Check for 404/403
status = resp.status
if status == 404:
print(f"ERROR: Repo '{owner_repo}' not found. Check the URL or slug.")
sys.exit(1)
page_data = json.loads(resp.read())
if not page_data:
print(f"Page {page}: empty, stopping.")
break
all_issues.extend(page_data)
print(f"Page {page}: {len(page_data)} issues fetched")
except urllib.error.HTTPError as e:
if e.code == 404:
print(f"ERROR: Repo '{owner_repo}' not found (404). Check the URL or slug.")
elif e.code == 403:
print(f"ERROR: Access denied (403). Repo may be private or rate limit hit.")
else:
print(f"ERROR: GitHub API returned HTTP {e.code}")
sys.exit(1)
except Exception as e:
print(f"ERROR: Failed to fetch page {page}: {e}")
sys.exit(1)
print(f"Total raw issues fetched: {len(all_issues)}")
json.dump(all_issues, open("/tmp/ghd-raw-issues.json", "w"), indent=2)
PYEOF
GitHub が 404 を返した場合: 停止します。ユーザーに次のように伝えてください。「リポジトリが見つかりません。URL またはスラッグを確認して、もう一度お試しください。非公開リポジトリは、認証と明示的な repo スコープがないとアクセスできません。」
GitHub がレート制限ヘッダー付きで 403 を返した場合: 停止します。リセット時刻を表示し、GITHUB_TOKEN を追加するようユーザーに伝えてください。
ステップ 4:ローカルで前処理 -- フィルタリング、スコア付け、無視された需要の検出
API 呼び出しは行いません。純粋な Python 処理です。AI に渡す前に実行してください。
python3 << 'PYEOF'
import json, re
from datetime import datetime, timezone
raw = json.load(open("/tmp/ghd-raw-issues.json"))
target = open("/tmp/ghd-target.txt").read().strip()
now = datetime.now(tz=timezone.utc)
noise_patterns = re.compile(
r"^(chore|deps|bump|renovate|dependabot|release|ci|build|revert)[\s:\[]",
re.IGNORECASE
)
pr_title_patterns = re.compile(
r"^(feat|fix|refactor|docs|test|style|perf|chore)(\(.+\))?:",
re.IGNORECASE
)
filtered = []
noise_count = 0
noise_reasons = {}
for issue in raw:
# Skip pull requests (GitHub Issues endpoint returns PRs too)
if "pull_request" in issue:
noise_count += 1
noise_reasons["pull_request"] = noise_reasons.get("pull_request", 0) + 1
continue
title = issue.get("title", "")
reactions = issue.get("reactions", {}).get("+1", 0)
comments = issue.get("comments", 0)
user_type = (issue.get("user") or {}).get("type", "User")
# Skip bot-authored issues
if user_type == "Bot":
noise_count += 1
noise_reasons["bot_author"] = noise_reasons.get("bot_author", 0) + 1
continue
# Skip bot-pattern titles
if noise_patterns.match(title):
noise_count += 1
noise_reasons["bot_title"] = noise_reasons.get("bot_title", 0) + 1
continue
# Skip PR-as-issue titles
if pr_title_patterns.match(title):
noise_count += 1
noise_reasons["pr_as_issue"] = noise_reasons.get("pr_as_issue", 0) + 1
continue
# Skip zero-signal issues
if reactions == 0 and comments == 0:
noise_count += 1
noise_reasons["zero_signal"] = noise_reasons.get("zero_signal", 0) + 1
continue
# Compute demand score
demand_score = (reactions * 2) + (comments * 0.5)
# Detect ignored demand
created_at = issue.get("created_at", "")
if created_at:
created = datetime.fromisoformat(created_at.replace("Z", "+00:00"))
age_days = (now - created).days
else:
age_days = 0
labels = [l.get("name", "").lower() for l in issue.get("labels", [])]
has_planned_label = any(
kw in label for label in labels
for kw in ["in-progress", "planned", "roadmap", "wip", "in progress"]
)
ignored_demand = (
reactions >= 10 and
age_days >= 180 and
not has_planned_label
)
filtered.append({
"number": issue["number"],
"title": title,
"url": issue.get("html_url", f"https://github.com/{target}/issues/{issue['number']}"),
"reactions_plus1": reactions,
"comments": comments,
"demand_score": demand_score,
"age_days": age_days,
"labels": labels,
"ignored_demand": ignored_demand,
"body_snippet": (issue.get("body") or "")[:300]
})
# Sort by demand score descending
filtered.sort(key=lambda x: x["demand_score"], reverse=True)
print(f"Raw issues: {len(raw)}")
print(f"Noise filtered: {noise_count} ({', '.join(f'{k}: {v}' for k, v in noise_reasons.items())})")
print(f"Issues for analysis: {len(filtered)}")
if len(filtered) < 10:
print(f"ERROR: Only {len(filtered)} issues remain after filtering. This repo has too few engaged issues for reliable clustering.")
print("Try a larger repo or a repo with more community engagement.")
import sys; sys.exit(1)
# Ignored demand summary
ignored = [i for i in filtered if i["ignored_demand"]]
print(f"Ignored demand issues: {len(ignored)}")
json.dump(filtered, open("/tmp/ghd-filtered-issues.json", "w"), indent=2)
print("Pre-processing complete.")
PYEOF
フィルタリング後に Issue が 10 件未満の場合: 停止します。見つかった Issue の数、フィルタリングされた数、信頼できる需要分類を行うにはリポジトリの規模が小さすぎる理由を、ユーザーに正確に伝えてください。
ステップ 5:Issue の分類
フィルタリング済みの Issue を分析用に表示します。
python3 << 'PYEOF'
import json
filtered = json.load(open("/tmp/ghd-filtered-issues.json"))
target = open("/tmp/ghd-target.txt").read().strip()
issue_list = filtered[:150]
print(f"Repo: {target}")
print(f"Issues to cluster: {len(issue_list)}")
print()
for i in issue_list:
labels_str = f" [{', '.join(i['labels'][:3])}]" if i['labels'] else ""
print(f"#{i['number']} [score:{round(i['demand_score'],1)} reactions:{i['reactions_plus1']}] {i['title']}{labels_str}")
PYEOF
上に表示した各 Issue を、次の 6 つのカテゴリのいずれかに分類します。
feature_gap、bug_pattern、ux_complaint、performance、integration_missing、docs_missing
ルール:
- 各 Issue を必ず 1 つのカテゴリに分類する
- タイトルにあるユーザーの正確な表現を使って、1 文の課題ステートメントを抽出する。言い換えは禁止
- クラスターのテーマを 5~8 個特定する。すべての Issue に見られる主な不満のパターンを表す、3~6 語の短いフレーズにする
- em ダッシュは禁止。マーケティング表現は禁止
分析結果を /tmp/ghd-clusters.json に次の厳密な構造で書き込みます。
{
"classified_issues": [
{"number": 123, "category": "feature_gap", "pain_statement": "Users need X which does not exist yet"}
],
"cluster_themes": [
{"theme_name": "Missing export options", "category": "feature_gap", "issue_numbers": [123, 456, 789]}
],
"category_counts": {"feature_gap": 5, "bug_pattern": 3, "ux_complaint": 4, "performance": 2, "integration_missing": 6, "docs_missing": 1}
}
ファイルを書き込んだ後、次のコマンドで確認します。
python3 -c "
import json
d = json.load(open('/tmp/ghd-clusters.json'))
print(f'Classified: {len(d[\"classified_issues\"])} issues, {len(d[\"cluster_themes\"])} themes')
print('Categories:', d['category_counts'])
"
ステップ 6:メッセージングの概要
クラスタごとの需要スコアを合計し、上位 3 件を表示します。
python3 << 'PYEOF'
import json
filtered = json.load(open("/tmp/ghd-filtered-issues.json"))
clusters = json.load(open("/tmp/ghd-clusters.json"))
target = open("/tmp/ghd-target.txt").read().strip()
demand_by_issue = {i["number"]: i["demand_score"] for i in filtered}
issue_titles = {i["number"]: i["title"] for i in filtered}
issue_reactions = {i["number"]: i["reactions_plus1"] for i in filtered}
enriched_themes = []
for theme in clusters.get("cluster_themes", []):
issue_nums = theme.get("issue_numbers", [])
total_demand = sum(demand_by_issue.get(n, 0) for n in issue_nums)
top_issues = sorted(issue_nums, key=lambda n: demand_by_issue.get(n, 0), reverse=True)[:3]
enriched_themes.append({
"theme_name": theme["theme_name"],
"category": theme["category"],
"issue_count": len(issue_nums),
"total_demand_score": round(total_demand, 1),
"top_issues": [
{"number": n, "title": issue_titles.get(n, ""), "reactions": issue_reactions.get(n, 0)}
for n in top_issues
]
})
enriched_themes.sort(key=lambda x: x["total_demand_score"], reverse=True)
json.dump(enriched_themes, open("/tmp/ghd-enriched-themes.json", "w"), indent=2)
print(f"Top 3 clusters for messaging brief (repo: {target}):")
for t in enriched_themes[:3]:
print(f"\n {t['theme_name']} ({t['category']}) -- total demand: {t['total_demand_score']}")
for ti in t["top_issues"]:
print(f" #{ti['number']}: \"{ti['title']}\" ({ti['reactions']} reactions)")
PYEOF
上記で表示した上位 3 クラスタをもとに、GTM メッセージングの概要を作成します。
ルール:
- ポジショニングの切り口には、それが由来する具体的なクラスタを記載すること
- アウトリーチのきっかけには、実際の課題タイトルを引用符で囲んでそのまま引用すること
- 見出しには、数字または具体的な名称のある課題を含めること。一般的な表現は不可
- エムダッシュは使用しないこと。禁止語句:powerful、robust、seamless、innovative、game-changing、streamline、leverage、transform
次の正確な構造で、概要を /tmp/ghd-brief.json に書き込みます。
{
"positioning_angles": [
{
"angle_name": "3-5 word label",
"cluster_source": "theme_name from cluster",
"positioning_statement": "2-3 sentences on what your product does that this competitor does not",
"evidence": "verbatim issue title that best illustrates this gap"
}
],
"outreach_hooks": [
{
"hook_type": "pain quote hook",
"hook_text": "2-3 sentences quoting a verbatim issue title in quotes",
"best_for": "audience this hook works for"
}
],
"cluster_headlines": [
{
"theme_name": "from the cluster",
"headline": "specific headline with a number or named pain",
"sub_copy": "1 sentence expanding the headline"
}
]
}
ファイルを書き込んだ後、次の内容で確認します。
python3 -c "
import json
d = json.load(open('/tmp/ghd-brief.json'))
print('Positioning angles:', len(d.get('positioning_angles', [])))
print('Outreach hooks:', len(d.get('outreach_hooks', [])))
print('Cluster headlines:', len(d.get('cluster_headlines', [])))
"
ステップ 7:セルフ QA
提示する前に実行します。根拠を検証し、違反を削除し、出力の整合性を確認します。
python3 << 'PYEOF'
import json
filtered = json.load(open("/tmp/ghd-filtered-issues.json"))
clusters = json.load(open("/tmp/ghd-clusters.json"))
themes = json.load(open("/tmp/ghd-enriched-themes.json"))
brief = json.load(open("/tmp/ghd-brief.json"))
target = open("/tmp/ghd-target.txt").read().strip()
failures = []
real_titles = {i["number"]: i["title"] for i in filtered}
# Verify: classified_issues only reference real issue numbers
real_numbers = set(real_titles.keys())
hallucinated = [
c["number"] for c in clusters.get("classified_issues", [])
if c["number"] not in real_numbers
]
if hallucinated:
failures.append(f"Removed {len(hallucinated)} hallucinated issue numbers from classified_issues: {hallucinated[:5]}")
clusters["classified_issues"] = [
c for c in clusters.get("classified_issues", [])
if c["number"] in real_numbers
]
# Verify: top-10 list is sorted by demand_score descending
top10 = filtered[:10]
for i, issue in enumerate(top10):
if i > 0 and issue["demand_score"] > top10[i-1]["demand_score"]:
failures.append("Top-10 list was not sorted by demand_score -- re-sorted.")
filtered.sort(key=lambda x: x["demand_score"], reverse=True)
top10 = filtered[:10]
break
# Verify: ignored demand issues meet all 3 criteria
ignored = [i for i in filtered if i["ignored_demand"]]
for issue in ignored:
if issue["reactions_plus1"] < 10 or issue["age_days"] < 180:
issue["ignored_demand"] = False
failures.append(f"Removed issue #{issue['number']} from ignored demand -- did not meet all 3 criteria")
# Check messaging brief counts
if len(brief.get("positioning_angles", [])) != 3:
failures.append(f"Expected 3 positioning angles, got {len(brief.get('positioning_angles', []))}")
if len(brief.get("outreach_hooks", [])) != 3:
failures.append(f"Expected 3 outreach hooks, got {len(brief.get('outreach_hooks', []))}")
if len(brief.get("cluster_headlines", [])) != 3:
failures.append(f"Expected 3 cluster headlines, got {len(brief.get('cluster_headlines', []))}")
# Check for em dashes in brief
brief_str = json.dumps(brief)
if "\u2014" in brief_str:
brief_str = brief_str.replace("\u2014", " - ")
brief = json.loads(brief_str)
failures.append("Fixed: em dash characters removed from messaging brief")
# Check for forbidden words
forbidden = ["powerful", "robust", "seamless", "innovative", "game-changing", "streamline", "leverage", "transform"]
full_text = (json.dumps(clusters) + json.dumps(brief)).lower()
for word in forbidden:
if word in full_text:
failures.append(f"Warning: forbidden word '{word}' found in output -- review before presenting")
# Build final output bundle
output = {
"repo": target,
"issues_analyzed": len(filtered),
"clusters": clusters,
"enriched_themes": themes,
"filtered_issues": filtered,
"messaging_brief": brief,
"data_quality_flags": failures
}
json.dump(output, open("/tmp/ghd-output.json", "w"), indent=2)
print(f"QA complete. Issues addressed: {len(failures)}")
for f in failures:
print(f" - {f}")
if not failures:
print("All QA checks passed.")
PYEOF
ステップ 8:出力を保存して提示
python3 << 'PYEOF'
import json
from datetime import datetime, timezone
output = json.load(open("/tmp/ghd-output.json"))
target = output["repo"]
repo_slug = target.replace("/", "-")
date_str = datetime.now(tz=timezone.utc).strftime("%Y-%m-%d")
filtered = output["filtered_issues"]
themes = output["enriched_themes"]
clusters = output["clusters"]
brief = output["messaging_brief"]
flags = output["data_quality_flags"]
ignored = [i for i in filtered if i.get("ignored_demand")]
top10 = filtered[:10]
# Build category summary from cluster data
category_counts = clusters.get("category_counts", {})
lines = [
f"## Demand Gap Report: {target}",
f"Issues analyzed: {output['issues_analyzed']} | Date: {date_str}",
"",
"---",
"",
"### Demand Gap Leaderboard",
"",
"| Rank | Theme | Category | Issues | Total Demand Score | Top Issue Reactions |",
"|---|---|---|---|---|---|",
]
for i, theme in enumerate(themes[:8], 1):
top_reactions = theme["top_issues"][0]["reactions"] if theme["top_issues"] else 0
lines.append(
f"| {i} | {theme['theme_name']} | {theme['category']} | "
f"{theme['issue_count']} | {theme['total_demand_score']} | {top_reactions} |"
)
lines += ["", "---", ""]
if ignored:
lines += [
"### Ignored Demand (High Reactions, No Maintainer Response)",
"",
"These issues have 10+ reactions, are 6+ months old, and have no planned/in-progress label.",
"This is your opportunity window.",
"",
]
for issue in ignored[:10]:
lines.append(
f"- [{issue['title']}]({issue['url']}) -- "
f"{issue['reactions_plus1']} reactions, {issue['age_days']} days old"
)
lines += ["", "---", ""]
lines += [
"### Top 10 Highest-Demand Issues",
"",
"| Rank | Issue | Reactions | Comments | Demand Score | Link |",
"|---|---|---|---|---|---|",
]
for i, issue in enumerate(top10, 1):
short_title = issue["title"][:70] + ("..." if len(issue["title"]) > 70 else "")
lines.append(
f"| {i} | {short_title} | {issue['reactions_plus1']} | "
f"{issue['comments']} | {round(issue['demand_score'], 1)} | "
f"[#{issue['number']}]({issue['url']}) |"
)
lines += ["", "---", "", "### Cluster Deep Dives", ""]
for theme in themes[:3]:
lines.append(f"#### {theme['theme_name']}")
lines.append(f"Category: {theme['category']} | Issues: {theme['issue_count']} | Total demand score: {theme['total_demand_score']}")
lines.append("")
lines.append("Top issues in this cluster:")
for ti in theme["top_issues"]:
lines.append(f"- \"{ti['title']}\" -- {ti['reactions']} reactions")
lines.append("")
lines += ["---", "", "### Messaging Brief", ""]
for angle in brief.get("positioning_angles", []):
lines.append(f"**{angle.get('angle_name', 'Angle')}**")
lines.append(angle.get("positioning_statement", ""))
lines.append(f"Evidence: \"{angle.get('evidence', '')}\"")
lines.append("")
lines += ["---", "", "### GTM Angles", ""]
for hook in brief.get("outreach_hooks", []):
lines.append(f"**{hook.get('hook_type', 'Hook')}**")
lines.append(hook.get("hook_text", ""))
lines.append(f"Best for: {hook.get('best_for', '')}")
lines.append("")
lines += ["---", ""]
if flags:
lines.append(f"Data quality notes: {'; '.join(flags)}")
else:
lines.append("Data quality notes: None")
output_path = f"docs/demand-signals/{repo_slug}-{date_str}.md"
import os
os.makedirs("docs/demand-signals", exist_ok=True)
open(output_path, "w").write("\n".join(lines))
print(f"Saved to: {output_path}")
# Print to console
print("\n" + "\n".join(lines))
PYEOF
一時ファイルをクリーンアップします。
rm -f /tmp/ghd-target.txt /tmp/ghd-raw-issues.json /tmp/ghd-filtered-issues.json \
/tmp/ghd-cluster-request.json /tmp/ghd-clusters.json /tmp/ghd-enriched-themes.json \
/tmp/ghd-brief-request.json /tmp/ghd-brief.json /tmp/ghd-output.json