503 lines
15 KiB
Bash
Executable File
503 lines
15 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
|
|
# hermes-upstream-watch.sh
|
|
# Safe upstream watcher for hermes-agent.
|
|
# Produces a markdown report for manual review before any pull.
|
|
|
|
set -u
|
|
|
|
# -----------------------------
|
|
# Configuration (hardcoded)
|
|
# -----------------------------
|
|
REPO_DIR="/home/iadnah/.hermes/hermes-agent/"
|
|
VAULT_DIR="/home/iadnah/lilaBuild/vaults/obsidian-private/hermes/"
|
|
PAIN_FILE="${VAULT_DIR}/hermes-pain-points.md"
|
|
REPORT_DIR="${VAULT_DIR}/reports/"
|
|
|
|
# -----------------------------
|
|
# Argument parsing
|
|
# -----------------------------
|
|
QUIET=0
|
|
if [ "${1:-}" = "--quiet" ]; then
|
|
QUIET=1
|
|
elif [ "${1:-}" != "" ]; then
|
|
printf 'Usage: %s [--quiet]\n' "$0" >&2
|
|
exit 2
|
|
fi
|
|
|
|
# -----------------------------
|
|
# Utility helpers
|
|
# -----------------------------
|
|
trim() {
|
|
local s="$1"
|
|
s="${s#"${s%%[![:space:]]*}"}"
|
|
s="${s%"${s##*[![:space:]]}"}"
|
|
printf '%s' "$s"
|
|
}
|
|
|
|
add_unique_array() {
|
|
local -n arr_ref="$1"
|
|
local value="$2"
|
|
local existing
|
|
for existing in "${arr_ref[@]:-}"; do
|
|
if [ "$existing" = "$value" ]; then
|
|
return
|
|
fi
|
|
done
|
|
arr_ref+=("$value")
|
|
}
|
|
|
|
join_by() {
|
|
local delimiter="$1"
|
|
shift || true
|
|
local out=""
|
|
local item
|
|
for item in "$@"; do
|
|
if [ -z "$out" ]; then
|
|
out="$item"
|
|
else
|
|
out="${out}${delimiter}${item}"
|
|
fi
|
|
done
|
|
printf '%s' "$out"
|
|
}
|
|
|
|
is_number() {
|
|
case "$1" in
|
|
''|*[!0-9]*) return 1 ;;
|
|
*) return 0 ;;
|
|
esac
|
|
}
|
|
|
|
# -----------------------------
|
|
# Validate core commands
|
|
# -----------------------------
|
|
for required_cmd in curl jq git date grep cat mkdir; do
|
|
if ! command -v "$required_cmd" >/dev/null 2>&1; then
|
|
printf 'Hard failure: required command not found: %s\n' "$required_cmd" >&2
|
|
exit 1
|
|
fi
|
|
done
|
|
|
|
# -----------------------------
|
|
# Ensure directories/files exist
|
|
# -----------------------------
|
|
if ! mkdir -p "$REPORT_DIR"; then
|
|
printf 'Hard failure: could not create reports directory: %s\n' "$REPORT_DIR" >&2
|
|
exit 1
|
|
fi
|
|
|
|
if [ ! -f "$PAIN_FILE" ]; then
|
|
if ! cat >"$PAIN_FILE" <<'EOF'
|
|
# Hermes Pain Points & Interests
|
|
|
|
## Areas of Interest (keywords/phrases that should always be flagged)
|
|
- gateway
|
|
- skills config
|
|
- skill config
|
|
- telegram
|
|
- provider error
|
|
- provider errors
|
|
- memory
|
|
- honcho
|
|
- mcp
|
|
- profile
|
|
- cron
|
|
- scheduler
|
|
- auth
|
|
- authentication
|
|
|
|
## Known Pain Points & Workarounds (format: date | topic | description)
|
|
- 2026-05-10 | Telegram gateway | Local patch for message ordering race condition. Upstream commit touching gateway/ should be reviewed carefully.
|
|
- 2026-04-29 | MCP connection | Timeout on thor.uplinklounge.com for emailInbox-iadnah. Check any networking or MCP changes.
|
|
EOF
|
|
then
|
|
printf 'Hard failure: could not write pain-points file: %s\n' "$PAIN_FILE" >&2
|
|
exit 1
|
|
fi
|
|
fi
|
|
|
|
# -----------------------------
|
|
# Parse pain-points file
|
|
# -----------------------------
|
|
interest_keywords=()
|
|
pain_dates=()
|
|
pain_topics=()
|
|
pain_descriptions=()
|
|
|
|
section=""
|
|
while IFS= read -r line || [ -n "$line" ]; do
|
|
case "$line" in
|
|
"## Areas of Interest"*)
|
|
section="interest"
|
|
continue
|
|
;;
|
|
"## Known Pain Points"*)
|
|
section="pain"
|
|
continue
|
|
;;
|
|
"## "*)
|
|
section=""
|
|
;;
|
|
esac
|
|
|
|
if [ "$section" = "interest" ]; then
|
|
case "$line" in
|
|
"- "*)
|
|
kw="$(trim "${line#- }")"
|
|
if [ -n "$kw" ]; then
|
|
add_unique_array interest_keywords "$kw"
|
|
fi
|
|
;;
|
|
esac
|
|
elif [ "$section" = "pain" ]; then
|
|
case "$line" in
|
|
"- "*)
|
|
entry="${line#- }"
|
|
IFS='|' read -r p_date p_topic p_desc <<<"$entry"
|
|
p_date="$(trim "$p_date")"
|
|
p_topic="$(trim "$p_topic")"
|
|
p_desc="$(trim "$p_desc")"
|
|
if [ -n "$p_date" ] && [ -n "$p_topic" ]; then
|
|
pain_dates+=("$p_date")
|
|
pain_topics+=("$p_topic")
|
|
pain_descriptions+=("$p_desc")
|
|
fi
|
|
;;
|
|
esac
|
|
fi
|
|
done <"$PAIN_FILE"
|
|
|
|
# -----------------------------
|
|
# Gather local git state
|
|
# -----------------------------
|
|
git_available=1
|
|
git_warnings=()
|
|
branch_name="unknown"
|
|
local_dirty="unknown"
|
|
behind_count="unknown"
|
|
ahead_count="unknown"
|
|
local_head_sha="unknown"
|
|
local_head_date="unknown"
|
|
upstream_latest_sha="unknown"
|
|
upstream_latest_date="unknown"
|
|
git_status_full="git status unavailable"
|
|
git_log_last20="git log unavailable"
|
|
|
|
if [ ! -d "${REPO_DIR}/.git" ]; then
|
|
git_available=0
|
|
add_unique_array git_warnings "Local repository missing or invalid at ${REPO_DIR}"
|
|
else
|
|
if ! git -C "$REPO_DIR" fetch origin --quiet >/dev/null 2>&1; then
|
|
add_unique_array git_warnings "git fetch origin failed (network/auth issue possible)."
|
|
fi
|
|
|
|
branch_name="$(git -C "$REPO_DIR" rev-parse --abbrev-ref HEAD 2>/dev/null || printf 'unknown')"
|
|
status_porcelain="$(git -C "$REPO_DIR" status --porcelain 2>/dev/null || printf '')"
|
|
if [ -z "$status_porcelain" ]; then
|
|
local_dirty="clean"
|
|
else
|
|
local_dirty="dirty"
|
|
fi
|
|
|
|
behind_count="$(git -C "$REPO_DIR" rev-list --count HEAD..origin/main 2>/dev/null || printf 'unknown')"
|
|
ahead_count="$(git -C "$REPO_DIR" rev-list --count origin/main..HEAD 2>/dev/null || printf 'unknown')"
|
|
local_head_sha="$(git -C "$REPO_DIR" rev-parse --short HEAD 2>/dev/null || printf 'unknown')"
|
|
local_head_date="$(git -C "$REPO_DIR" show -s --format=%cs HEAD 2>/dev/null || printf 'unknown')"
|
|
upstream_latest_sha="$(git -C "$REPO_DIR" rev-parse --short origin/main 2>/dev/null || printf 'unknown')"
|
|
upstream_latest_date="$(git -C "$REPO_DIR" show -s --format=%cs origin/main 2>/dev/null || printf 'unknown')"
|
|
git_status_full="$(git -C "$REPO_DIR" status 2>&1 || printf 'git status command failed')"
|
|
git_log_last20="$(git -C "$REPO_DIR" log --oneline --decorate -20 2>&1 || printf 'git log command failed')"
|
|
|
|
if [ "$branch_name" != "main" ]; then
|
|
add_unique_array git_warnings "Local repository is on branch '${branch_name}' (expected main)."
|
|
fi
|
|
if [ "$local_dirty" = "dirty" ]; then
|
|
add_unique_array git_warnings "Local repository has uncommitted changes."
|
|
fi
|
|
fi
|
|
|
|
# -----------------------------
|
|
# Fetch upstream commits (last 24h)
|
|
# -----------------------------
|
|
since_utc="$(date -u -d '24 hours ago' +%Y-%m-%dT%H:%M:%SZ)"
|
|
api_url="https://api.github.com/repos/NousResearch/hermes-agent/commits?since=${since_utc}&per_page=100"
|
|
api_json=""
|
|
api_available=1
|
|
api_note=""
|
|
total_commits=0
|
|
|
|
if ! api_json="$(curl --max-time 15 -s "$api_url" 2>/dev/null)"; then
|
|
api_available=0
|
|
api_note="API unavailable"
|
|
api_json='{"error":"API unavailable"}'
|
|
fi
|
|
|
|
if [ "$api_available" -eq 1 ]; then
|
|
if ! printf '%s' "$api_json" | jq -e 'type == "array"' >/dev/null 2>&1; then
|
|
api_available=0
|
|
api_note="API unavailable"
|
|
else
|
|
total_commits="$(printf '%s' "$api_json" | jq -r 'length' 2>/dev/null || printf '0')"
|
|
fi
|
|
fi
|
|
|
|
if [ "$api_available" -eq 0 ]; then
|
|
total_commits=0
|
|
fi
|
|
|
|
# -----------------------------
|
|
# Relevance and pain-point matching
|
|
# -----------------------------
|
|
relevant_entries=()
|
|
other_entries=()
|
|
crosscheck_entries=()
|
|
relevant_count=0
|
|
|
|
if [ "$api_available" -eq 1 ] && [ "$total_commits" -gt 0 ]; then
|
|
commit_json_lines="$(printf '%s' "$api_json" | jq -c '.[]')"
|
|
|
|
while IFS= read -r commit_json; do
|
|
[ -z "$commit_json" ] && continue
|
|
|
|
sha_full="$(printf '%s' "$commit_json" | jq -r '.sha // ""')"
|
|
author_name="$(printf '%s' "$commit_json" | jq -r '.commit.author.name // "unknown"')"
|
|
author_date="$(printf '%s' "$commit_json" | jq -r '.commit.author.date // "unknown"')"
|
|
commit_message="$(printf '%s' "$commit_json" | jq -r '.commit.message // ""')"
|
|
[ -z "$sha_full" ] && continue
|
|
|
|
sha_short="${sha_full:0:7}"
|
|
commit_title="${commit_message%%$'\n'*}"
|
|
if [ "$commit_message" = "$commit_title" ]; then
|
|
commit_body=""
|
|
else
|
|
commit_body="${commit_message#*$'\n'}"
|
|
fi
|
|
commit_body_one_line="${commit_body//$'\n'/ }"
|
|
commit_body_preview="${commit_body_one_line:0:80}"
|
|
|
|
match_tags=()
|
|
path_tags=()
|
|
overlap_notes=()
|
|
|
|
combined_text="${commit_title} ${commit_body_one_line}"
|
|
combined_text_lower="${combined_text,,}"
|
|
|
|
for keyword in "${interest_keywords[@]:-}"; do
|
|
keyword_lower="${keyword,,}"
|
|
if [ -n "$keyword_lower" ] && [[ "$combined_text_lower" == *"$keyword_lower"* ]]; then
|
|
add_unique_array match_tags "$keyword"
|
|
fi
|
|
done
|
|
|
|
changed_files=""
|
|
if [ "$git_available" -eq 1 ] && git -C "$REPO_DIR" cat-file -e "${sha_full}^{commit}" >/dev/null 2>&1; then
|
|
changed_files="$(git -C "$REPO_DIR" show --name-only --pretty="" "$sha_full" 2>/dev/null || printf '')"
|
|
fi
|
|
|
|
if [ -n "$changed_files" ]; then
|
|
while IFS= read -r changed_path || [ -n "$changed_path" ]; do
|
|
[ -z "$changed_path" ] && continue
|
|
changed_path_lower="${changed_path,,}"
|
|
case "$changed_path_lower" in
|
|
*"gateway/"*) add_unique_array path_tags "gateway" ;;
|
|
esac
|
|
case "$changed_path_lower" in
|
|
*"skills/"*) add_unique_array path_tags "skills" ;;
|
|
esac
|
|
case "$changed_path_lower" in
|
|
*"cron"*) add_unique_array path_tags "cron" ;;
|
|
esac
|
|
case "$changed_path_lower" in
|
|
*"memory"*) add_unique_array path_tags "memory" ;;
|
|
esac
|
|
case "$changed_path_lower" in
|
|
*"honcho"*) add_unique_array path_tags "honcho" ;;
|
|
esac
|
|
case "$changed_path_lower" in
|
|
*"mcp"*) add_unique_array path_tags "mcp" ;;
|
|
esac
|
|
case "$changed_path_lower" in
|
|
*"telegram"*) add_unique_array path_tags "telegram" ;;
|
|
esac
|
|
done <<<"$changed_files"
|
|
fi
|
|
|
|
context_text_lower="${combined_text_lower} ${changed_files,,}"
|
|
idx=0
|
|
while [ "$idx" -lt "${#pain_topics[@]}" ]; do
|
|
topic="${pain_topics[$idx]}"
|
|
topic_lower="${topic,,}"
|
|
if [ -n "$topic_lower" ] && [[ "$context_text_lower" == *"$topic_lower"* ]]; then
|
|
note="${pain_dates[$idx]} | ${pain_topics[$idx]}"
|
|
add_unique_array overlap_notes "$note"
|
|
fi
|
|
idx=$((idx + 1))
|
|
done
|
|
|
|
is_relevant=0
|
|
if [ "${#match_tags[@]}" -gt 0 ] || [ "${#path_tags[@]}" -gt 0 ] || [ "${#overlap_notes[@]}" -gt 0 ]; then
|
|
is_relevant=1
|
|
fi
|
|
|
|
if [ "$is_relevant" -eq 1 ]; then
|
|
relevant_count=$((relevant_count + 1))
|
|
|
|
combined_matches=("${match_tags[@]:-}" "${path_tags[@]:-}")
|
|
unique_matches=()
|
|
for m in "${combined_matches[@]:-}"; do
|
|
[ -z "$m" ] && continue
|
|
add_unique_array unique_matches "$m"
|
|
done
|
|
matches_text="$(join_by ', ' "${unique_matches[@]:-}")"
|
|
if [ -z "$matches_text" ]; then
|
|
matches_text="pain-point overlap"
|
|
fi
|
|
|
|
entry="- **[${matches_text}]** \`${sha_short}\` - ${commit_title}"
|
|
if [ -n "$commit_body_preview" ]; then
|
|
entry="${entry}\n Body: ${commit_body_preview}"
|
|
fi
|
|
if [ "${#overlap_notes[@]}" -gt 0 ]; then
|
|
for ov in "${overlap_notes[@]}"; do
|
|
ov_date="$(trim "${ov%%|*}")"
|
|
ov_topic="$(trim "${ov#*|}")"
|
|
entry="${entry}\n → ⚠️ Overlaps with known pain point from ${ov_date} (${ov_topic})."
|
|
add_unique_array crosscheck_entries "- ⚠️ ${ov_date} | ${ov_topic} | Commit \`${sha_short}\` (${commit_title})"
|
|
done
|
|
fi
|
|
add_unique_array relevant_entries "$entry"
|
|
else
|
|
add_unique_array other_entries "- \`${sha_short}\` - ${commit_title} (${author_name}, ${author_date})"
|
|
fi
|
|
done <<<"$commit_json_lines"
|
|
fi
|
|
|
|
# -----------------------------
|
|
# Quiet mode gate
|
|
# -----------------------------
|
|
create_report=1
|
|
behind_for_gate=0
|
|
if is_number "$behind_count"; then
|
|
behind_for_gate="$behind_count"
|
|
fi
|
|
|
|
if [ "$QUIET" -eq 1 ]; then
|
|
create_report=0
|
|
if [ "$relevant_count" -gt 0 ] || [ "$behind_for_gate" -ge 3 ]; then
|
|
create_report=1
|
|
fi
|
|
fi
|
|
|
|
if [ "$create_report" -eq 0 ]; then
|
|
exit 0
|
|
fi
|
|
|
|
# -----------------------------
|
|
# Build recommendation text
|
|
# -----------------------------
|
|
recommendation="Review upstream commits before pulling."
|
|
if [ "$api_available" -eq 0 ]; then
|
|
recommendation="API unavailable; defer pull decisions until connectivity returns and rerun this watcher."
|
|
elif is_number "$behind_count" && [ "$behind_count" -gt 0 ] && [ "$relevant_count" -gt 0 ]; then
|
|
recommendation="Review the ${behind_count} commits before pulling. Relevant changes were detected in sensitive areas and should be tested first."
|
|
elif is_number "$behind_count" && [ "$behind_count" -gt 0 ]; then
|
|
recommendation="Review the ${behind_count} commits before pulling. No high-signal pain-point overlaps were found, but a normal verification pass is advised."
|
|
elif [ "$relevant_count" -gt 0 ]; then
|
|
recommendation="Recent commits match tracked interests/pain points. Review these changes before pulling even if branch divergence is low."
|
|
fi
|
|
|
|
# -----------------------------
|
|
# Generate markdown report
|
|
# -----------------------------
|
|
run_date="$(date -u +%Y-%m-%d)"
|
|
generated_at="$(date -u '+%Y-%m-%d %H:%M UTC')"
|
|
report_path="${REPORT_DIR}/hermes-upstream-${run_date}.md"
|
|
|
|
local_changes_line="$local_dirty"
|
|
if [ "$local_dirty" = "dirty" ]; then
|
|
local_changes_line="dirty (warn: uncommitted changes present)"
|
|
elif [ "$local_dirty" = "clean" ]; then
|
|
local_changes_line="clean"
|
|
fi
|
|
|
|
{
|
|
printf '# Hermes-Agent Upstream Watch - %s\n\n' "$run_date"
|
|
printf '**Generated**: %s\n' "$generated_at"
|
|
printf '**Local HEAD**: %s (%s)\n' "$local_head_sha" "$local_head_date"
|
|
printf '**Upstream latest**: %s (%s)\n' "$upstream_latest_sha" "$upstream_latest_date"
|
|
printf '**Commits behind**: %s\n' "$behind_count"
|
|
printf '**Commits ahead**: %s\n' "$ahead_count"
|
|
printf '**Local changes**: %s\n\n' "$local_changes_line"
|
|
|
|
if [ "${#git_warnings[@]}" -gt 0 ]; then
|
|
for warn in "${git_warnings[@]}"; do
|
|
printf '> ⚠️ %s\n' "$warn"
|
|
done
|
|
printf '\n'
|
|
fi
|
|
|
|
printf '## Recent Upstream Activity (last 24h)\n'
|
|
if [ "$api_available" -eq 1 ]; then
|
|
printf 'Total commits: %s\n\n' "$total_commits"
|
|
else
|
|
printf 'Total commits: 0\n\n'
|
|
printf '> API unavailable\n\n'
|
|
fi
|
|
|
|
printf '## Relevant / Interesting Changes\n'
|
|
if [ "${#relevant_entries[@]}" -eq 0 ]; then
|
|
printf -- '- No relevant commits matched current interests/pain points.\n\n'
|
|
else
|
|
for entry in "${relevant_entries[@]}"; do
|
|
printf '%b\n\n' "$entry"
|
|
done
|
|
fi
|
|
|
|
if [ "${#other_entries[@]}" -gt 0 ]; then
|
|
printf '### Other commits\n'
|
|
for other in "${other_entries[@]}"; do
|
|
printf '%s\n' "$other"
|
|
done
|
|
printf '\n'
|
|
fi
|
|
|
|
printf '## Pain Point Cross-Check\n'
|
|
if [ "${#crosscheck_entries[@]}" -eq 0 ]; then
|
|
printf -- '- No direct overlaps detected with known pain points.\n\n'
|
|
else
|
|
for cross in "${crosscheck_entries[@]}"; do
|
|
printf '%s\n' "$cross"
|
|
done
|
|
printf '\n'
|
|
fi
|
|
|
|
printf '## Recommendation\n'
|
|
printf '%s\n\n' "$recommendation"
|
|
printf '**Full raw commit data** and git status saved below for reference.\n\n'
|
|
|
|
printf '<details>\n'
|
|
printf '<summary>Raw Data</summary>\n\n'
|
|
printf '### GitHub API JSON\n'
|
|
printf '```json\n'
|
|
printf '%s\n' "$api_json"
|
|
printf '```\n\n'
|
|
|
|
printf '### Git Log (last 20 commits)\n'
|
|
printf '```text\n'
|
|
printf '%s\n' "$git_log_last20"
|
|
printf '```\n\n'
|
|
|
|
printf '### Git Status\n'
|
|
printf '```text\n'
|
|
printf '%s\n' "$git_status_full"
|
|
printf '```\n\n'
|
|
printf '</details>\n'
|
|
} >"$report_path"
|
|
|
|
if [ "$QUIET" -eq 0 ] || [ "$relevant_count" -gt 0 ] || [ "$behind_for_gate" -ge 3 ]; then
|
|
printf 'Report written to %s\n' "$report_path"
|
|
fi
|
|
|
|
exit 0
|