Files
cronScripts/hermes-repo-watcher/hermes-upstream-watch.sh
T
2026-05-15 21:03:58 -04:00

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