#!/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 '
\n' printf 'Raw Data\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 '
\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