#!/usr/bin/env bash set -euo pipefail ROOT="${XH1_RESEARCH_ROOT:-research}" CONFIG="${XH1_RESEARCH_CONFIG:-xh1-research.conf}" STATE="${ROOT}/.xh1" API_BASE_URL="${API_BASE_URL:-https://openrouter.ai/api/v1}" API_KEY_ENV="${API_KEY_ENV:-OPENROUTER_API_KEY}" RESEARCH_MODEL="${RESEARCH_MODEL:-}" REVIEW_MODEL="${REVIEW_MODEL:-$RESEARCH_MODEL}" REVISION_MODEL="${REVISION_MODEL:-$RESEARCH_MODEL}" MAX_RESEARCH_ROUNDS="${MAX_RESEARCH_ROUNDS:-3}" MAX_API_RETRIES="${MAX_API_RETRIES:-3}" DELAY_SECONDS="${DELAY_SECONDS:-5}" MAX_ITERATIONS="${MAX_ITERATIONS:-0}" REVIEW_ENABLED="${REVIEW_ENABLED:-true}" AUTO_COMMIT="${AUTO_COMMIT:-false}" RUN_ID="" mkdir -p "$STATE/logs" "$STATE/responses" "$STATE/runs" die() { echo "ERROR: $*" >&2 exit 1 } log() { echo "[$(date -u '+%Y-%m-%dT%H:%M:%SZ')] $*" | tee -a "$STATE/logs/harness.log" } require() { command -v "$1" >/dev/null 2>&1 || die "Required command not found: $1" } require_dependencies() { require bash require curl require jq require find require grep require sed require awk } timestamp() { date -u '+%Y%m%dT%H%M%SZ' } load_config() { if [[ -f "$CONFIG" ]]; then # shellcheck disable=SC1090 source "$CONFIG" fi } get_api_key() { local key="${!API_KEY_ENV:-}" [[ -n "$key" ]] || die "Missing API key. Set ${API_KEY_ENV}." printf '%s' "$key" } start_run() { RUN_ID="$(timestamp)" mkdir -p "$STATE/runs/$RUN_ID" : > "$STATE/runs/$RUN_ID/completed" : > "$STATE/runs/$RUN_ID/failed" : > "$STATE/runs/$RUN_ID/attempts" log "Started run: $RUN_ID" } run_has_completed() { grep -Fxq "$1" "$STATE/runs/$RUN_ID/completed" 2>/dev/null } run_has_failed() { grep -Fxq "$1" "$STATE/runs/$RUN_ID/failed" 2>/dev/null } mark_completed() { printf '%s\n' "$1" >> "$STATE/runs/$RUN_ID/completed" } mark_failed() { printf '%s\n' "$1" >> "$STATE/runs/$RUN_ID/failed" } record_attempt() { printf '%s\t%s\t%s\t%s\t%s\n' \ "$(date -u '+%Y-%m-%dT%H:%M:%SZ')" \ "$1" "$2" "$3" "$4" \ >> "$STATE/runs/$RUN_ID/attempts" } is_soon() { grep -qE '^SOON[[:space:]]*$' "$1" } find_next_task() { while IFS= read -r -d '' file; do [[ "$file" == "$STATE"/* ]] && continue if is_soon "$file" && ! run_has_completed "$file" && ! run_has_failed "$file" then printf '%s\n' "$file" return 0 fi done < <( find "$ROOT" \ -type f \ -name '*.md' \ -not -path "$STATE/*" \ -print0 ) return 1 } build_context() { local file="$1" local relative="${file#"$ROOT"/}" local area="${relative%%/*}" cat < "$json" then if jq -er \ '.choices[0].message.content' \ "$json" > "$output" then log "API response received: $(wc -c < "$output" | tr -d ' ') bytes" return 0 fi fi log "API request failed." sleep $((attempt * attempt)) ((attempt++)) done return 1 } replace_soon() { local target="$1" local replacement="$2" is_soon "$target" || die "Safety check failed: $target is no longer SOON." cp "$replacement" "$target" } git_commit() { [[ "$AUTO_COMMIT" == "true" ]] || return 0 git rev-parse --is-inside-work-tree >/dev/null 2>&1 || return 0 git add "$1" git commit \ -m "research: $(basename "$1" .md | tr '-' ' ')" || true } research_topic() { local file="$1" log "==================================================" log "Researching: $file" log "==================================================" local round=1 local current="$STATE/runs/$RUN_ID/${file//\//_}.current.md" local review="$STATE/runs/$RUN_ID/${file//\//_}.review.md" while (( round <= MAX_RESEARCH_ROUNDS )); do log "Research round $round/$MAX_RESEARCH_ROUNDS" local candidate="$STATE/runs/$RUN_ID/${file//\//_}.round${round}.md" if (( round == 1 )); then log "Running researcher." if ! api_call \ "$(research_system_prompt)" \ "$(research_user_prompt "$file")" \ "$candidate" \ "$RESEARCH_MODEL" then record_attempt "$file" "$round" "research" "api-failure" mark_failed "$file" return 1 fi record_attempt "$file" "$round" "research" "completed" else log "Running revision agent." if ! api_call \ "$(revision_system_prompt)" \ "$(revision_user_prompt "$current" "$review")" \ "$candidate" \ "$REVISION_MODEL" then record_attempt "$file" "$round" "revision" "api-failure" mark_failed "$file" return 1 fi record_attempt "$file" "$round" "revision" "completed" fi cp "$candidate" "$current" if [[ "$REVIEW_ENABLED" != "true" ]]; then replace_soon "$file" "$candidate" mark_completed "$file" git_commit "$file" return 0 fi log "Running reviewer." if ! api_call \ "$(review_system_prompt)" \ "$(review_user_prompt "$candidate")" \ "$review" \ "$REVIEW_MODEL" then record_attempt "$file" "$round" "review" "api-failure" mark_failed "$file" return 1 fi echo echo "==========================================" echo "RESEARCH REVIEW" echo "==========================================" cat "$review" echo "==========================================" echo local verdict verdict="$(grep -m1 '^VERDICT:' "$review" || true)" record_attempt "$file" "$round" "review" "$verdict" if [[ "$verdict" == "VERDICT: PASS" ]]; then log "Research PASSED review." replace_soon "$file" "$candidate" mark_completed "$file" git_commit "$file" log "Completed: $file" return 0 fi log "Research rejected by reviewer." if (( round >= MAX_RESEARCH_ROUNDS )); then log "Maximum research rounds reached." log "Leaving original document unchanged." log "Marking topic failed for this run." mark_failed "$file" return 2 fi log "Preparing revision round $((round + 1))." ((round++)) sleep "$DELAY_SECONDS" done mark_failed "$file" return 2 } run_single() { start_run local file="${1:-}" if [[ -z "$file" ]]; then file="$(find_next_task || true)" fi [[ -n "$file" ]] || die "No research topics remain." research_topic "$file" } run_all() { start_run local iteration=0 while true; do if (( MAX_ITERATIONS > 0 && iteration >= MAX_ITERATIONS )); then log "Maximum run iterations reached." break fi local file file="$(find_next_task || true)" if [[ -z "$file" ]]; then log "No remaining research topics." break fi ((iteration += 1)) echo echo "##################################################" echo "XH-1 AUTONOMOUS RESEARCH" echo "Iteration: $iteration" echo "Topic: $file" echo "##################################################" echo research_topic "$file" || true sleep "$DELAY_SECONDS" done status } status() { local total local soon total="$( find "$ROOT" \ -type f \ -name '*.md' \ -not -path "$STATE/*" | wc -l | tr -d ' ' )" soon="$( grep -RIl \ '^SOON[[:space:]]*$' \ "$ROOT" \ --include='*.md' \ --exclude-dir='.xh1' \ 2>/dev/null | wc -l | tr -d ' ' )" local completed=$((total - soon)) echo echo "╔══════════════════════════════════════════╗" echo "║ XH-1 RESEARCH STATUS ║" echo "╚══════════════════════════════════════════╝" echo printf " Total documents : %s\n" "$total" printf " Completed : %s\n" "$completed" printf " Remaining SOON : %s\n" "$soon" if (( total > 0 )); then awk \ -v done="$completed" \ -v total="$total" \ 'BEGIN { printf " Progress : %.1f%%\n", (done / total) * 100 }' fi echo echo "Next topic:" find_next_task || echo " None" echo } reset_failure() { local file="${1:-}" [[ -n "$file" ]] || die "Usage: $0 reset-failure " [[ -f "$file" ]] || die "File does not exist: $file" if [[ -n "$RUN_ID" && -f "$STATE/runs/$RUN_ID/failed" ]]; then sed -i "\|^${file}$|d" \ "$STATE/runs/$RUN_ID/failed" fi log "Failure reset: $file" } review_command() { local file="${1:-}" [[ -n "$file" ]] || die "Usage: $0 review " [[ -f "$file" ]] || die "File does not exist: $file" local output="$STATE/manual-review-$(timestamp).md" api_call \ "$(review_system_prompt)" \ "$(review_user_prompt "$file")" \ "$output" \ "$REVIEW_MODEL" cat "$output" } help() { cat <<'HELP' XH-1 RESEARCH HARNESS COMMANDS init Create configuration. status Show research status. next Show next SOON document. run Research the next topic. run Research a specific topic. run-all Process all SOON documents. Each topic: RESEARCH | v REVIEW | +---+---+ | | PASS FAIL | | v v WRITE REVISE | v REVIEW Maximum rounds are controlled by: MAX_RESEARCH_ROUNDS review Manually review a document. reset-failure Clear a failed topic from the current run. help Show this help. SAFETY A document is only replaced after: VERDICT: PASS If all rounds fail: The original SOON file remains untouched. The failed topic is skipped for the remainder of that run. CONFIGURATION API_BASE_URL API_KEY_ENV RESEARCH_MODEL REVIEW_MODEL REVISION_MODEL MAX_RESEARCH_ROUNDS MAX_API_RETRIES DELAY_SECONDS MAX_ITERATIONS REVIEW_ENABLED AUTO_COMMIT EXAMPLE export OPENROUTER_API_KEY="..." ./xh1-research status ./xh1-research run ./xh1-research run-all HELP } load_config require_dependencies case "${1:-status}" in init) echo "Configuration is created manually in $CONFIG." exit 0 ;; status) status ;; next) find_next_task || true ;; run) run_single "${2:-}" ;; run-all|loop|resume) run_all ;; review) review_command "${2:-}" ;; reset-failure) reset_failure "${2:-}" ;; help|-h|--help) help ;; *) die "Unknown command: $1. Use ./xh1-research help" ;; esac