diff --git a/.agents/scripts/supervisor-helper.sh b/.agents/scripts/supervisor-helper.sh index b4de83e11b..a2d83066e6 100755 --- a/.agents/scripts/supervisor-helper.sh +++ b/.agents/scripts/supervisor-helper.sh @@ -213,6 +213,7 @@ source "${SUPERVISOR_MODULE_DIR}/memory-integration.sh" source "${SUPERVISOR_MODULE_DIR}/todo-sync.sh" source "${SUPERVISOR_MODULE_DIR}/ai-context.sh" source "${SUPERVISOR_MODULE_DIR}/ai-reason.sh" +source "${SUPERVISOR_MODULE_DIR}/ai-actions.sh" # Valid states for the state machine # shellcheck disable=SC2034 # Used by supervisor/state.sh @@ -691,16 +692,55 @@ main() { contest) cmd_contest "$@" ;; ai-context) build_ai_context "${REPO_PATH:-$(git rev-parse --show-toplevel 2>/dev/null || pwd)}" "${1:-full}" ;; ai-reason) run_ai_reasoning "${REPO_PATH:-$(git rev-parse --show-toplevel 2>/dev/null || pwd)}" "${1:-full}" ;; + ai-actions) + # Execute an action plan: supervisor-helper.sh ai-actions [--mode execute|dry-run|validate-only] --plan '' + local _aa_mode="execute" _aa_plan="" _aa_repo="" + _aa_repo="${REPO_PATH:-$(git rev-parse --show-toplevel 2>/dev/null || pwd)}" + while [[ $# -gt 0 ]]; do + case "$1" in + --mode) + _aa_mode="$2" + shift 2 + ;; + --plan) + _aa_plan="$2" + shift 2 + ;; + --dry-run) + _aa_mode="dry-run" + shift + ;; + --repo) + _aa_repo="$2" + shift 2 + ;; + *) shift ;; + esac + done + if [[ -z "$_aa_plan" ]]; then + log_error "ai-actions requires --plan ''" + return 1 + fi + execute_action_plan "$_aa_plan" "$_aa_repo" "$_aa_mode" + ;; + ai-pipeline) + # Run full reasoning + action execution: supervisor-helper.sh ai-pipeline [full|dry-run] + run_ai_actions_pipeline "${REPO_PATH:-$(git rev-parse --show-toplevel 2>/dev/null || pwd)}" "${1:-full}" + ;; ai-status) local last_run_ts last_run_ts=$(db "$SUPERVISOR_DB" "SELECT MAX(timestamp) FROM state_log WHERE task_id = 'ai-supervisor' AND to_state = 'complete';" 2>/dev/null || echo "never") local run_count run_count=$(db "$SUPERVISOR_DB" "SELECT COUNT(*) FROM state_log WHERE task_id = 'ai-supervisor' AND to_state = 'complete';" 2>/dev/null || echo 0) + local action_count + action_count=$(db "$SUPERVISOR_DB" "SELECT COUNT(*) FROM state_log WHERE task_id = 'ai-supervisor' AND from_state = 'actions';" 2>/dev/null || echo 0) echo "AI Supervisor Status" echo " Last run: ${last_run_ts:-never}" - echo " Total runs: $run_count" + echo " Total reasoning runs: $run_count" + echo " Total action executions: $action_count" echo " Enabled: ${SUPERVISOR_AI_ENABLED:-true}" echo " Interval: ${SUPERVISOR_AI_INTERVAL:-15} pulses (~$((${SUPERVISOR_AI_INTERVAL:-15} * 2))min)" + echo " Max actions/cycle: ${AI_MAX_ACTIONS_PER_CYCLE:-10}" echo " Log dir: ${AI_REASON_LOG_DIR:-$HOME/.aidevops/logs/ai-supervisor}" ;; help | --help | -h) show_usage ;; diff --git a/.agents/scripts/supervisor/ai-actions.sh b/.agents/scripts/supervisor/ai-actions.sh new file mode 100644 index 0000000000..46db1b6f15 --- /dev/null +++ b/.agents/scripts/supervisor/ai-actions.sh @@ -0,0 +1,1038 @@ +#!/usr/bin/env bash +# ai-actions.sh - AI Supervisor action executor (t1085.3) +# +# Executes validated actions from the AI reasoning engine's action plan. +# Each action type is validated before execution to prevent unintended changes. +# +# Used by: pulse.sh Phase 14 (AI Action Execution) — wired in t1085.5 +# Depends on: ai-reason.sh (run_ai_reasoning), todo-sync.sh, issue-sync.sh +# Sourced by: supervisor-helper.sh (set -euo pipefail inherited) + +# Globals expected from supervisor-helper.sh: +# SUPERVISOR_DB, SUPERVISOR_LOG, SCRIPT_DIR, REPO_PATH +# db(), log_info(), log_warn(), log_error(), sql_escape() +# commit_and_push_todo() (from todo-sync.sh) +# find_task_issue_number() (from issue-sync.sh) +# detect_repo_slug() (from supervisor-helper.sh) + +# Action execution log directory (shares with ai-reason) +AI_ACTIONS_LOG_DIR="${AI_ACTIONS_LOG_DIR:-$HOME/.aidevops/logs/ai-supervisor}" + +# Valid action types — any action not in this list is rejected +readonly AI_VALID_ACTION_TYPES="comment_on_issue create_task create_subtasks flag_for_review adjust_priority close_verified request_info" + +# Maximum actions per execution cycle (safety limit) +AI_MAX_ACTIONS_PER_CYCLE="${AI_MAX_ACTIONS_PER_CYCLE:-10}" + +# Dry-run mode — validate but don't execute (set via --dry-run flag or env) +AI_ACTIONS_DRY_RUN="${AI_ACTIONS_DRY_RUN:-false}" + +####################################### +# Execute a validated action plan from the AI reasoning engine +# Arguments: +# $1 - JSON action plan (array of action objects) +# $2 - repo path +# $3 - (optional) mode: "execute" (default), "dry-run", "validate-only" +# Outputs: +# JSON execution report to stdout +# Returns: +# 0 on success (even if some actions failed), 1 on invalid input +####################################### +execute_action_plan() { + local action_plan="$1" + local repo_path="${2:-$REPO_PATH}" + local mode="${3:-execute}" + + # Ensure log directory exists + mkdir -p "$AI_ACTIONS_LOG_DIR" + + local timestamp + timestamp=$(date -u '+%Y%m%d-%H%M%S') + local action_log="$AI_ACTIONS_LOG_DIR/actions-${timestamp}.md" + + # Validate input is a JSON array + local action_count + action_count=$(printf '%s' "$action_plan" | jq 'length' 2>/dev/null || echo -1) + + if [[ "$action_count" -eq -1 ]]; then + log_error "AI Actions: invalid JSON input" + echo '{"error":"invalid_json","executed":0,"failed":0}' + return 1 + fi + + if [[ "$action_count" -eq 0 ]]; then + log_info "AI Actions: empty action plan — nothing to execute" + echo '{"executed":0,"failed":0,"skipped":0,"actions":[]}' + return 0 + fi + + # Safety limit + if [[ "$action_count" -gt "$AI_MAX_ACTIONS_PER_CYCLE" ]]; then + log_warn "AI Actions: plan has $action_count actions, capping at $AI_MAX_ACTIONS_PER_CYCLE" + action_plan=$(printf '%s' "$action_plan" | jq ".[0:$AI_MAX_ACTIONS_PER_CYCLE]") + action_count="$AI_MAX_ACTIONS_PER_CYCLE" + fi + + log_info "AI Actions: processing $action_count actions ($mode mode)" + + # Start log + { + echo "# AI Supervisor Action Execution Log" + echo "" + echo "Timestamp: $timestamp" + echo "Mode: $mode" + echo "Actions: $action_count" + echo "Repo: $repo_path" + echo "" + } >"$action_log" + + # Resolve repo slug for GitHub operations + local repo_slug="" + repo_slug=$(detect_repo_slug "$repo_path" 2>/dev/null || echo "") + + # Process each action + local executed=0 + local failed=0 + local skipped=0 + local results="[]" + local i + + for ((i = 0; i < action_count; i++)); do + local action + action=$(printf '%s' "$action_plan" | jq ".[$i]") + + local action_type + action_type=$(printf '%s' "$action" | jq -r '.type // "unknown"') + + local reasoning + reasoning=$(printf '%s' "$action" | jq -r '.reasoning // "no reasoning provided"') + + # Step 1: Validate action type + if ! validate_action_type "$action_type"; then + log_warn "AI Actions: skipping invalid action type '$action_type'" + skipped=$((skipped + 1)) + results=$(printf '%s' "$results" | jq ". + [{\"index\":$i,\"type\":\"$action_type\",\"status\":\"skipped\",\"reason\":\"invalid_action_type\"}]") + { + echo "## Action $((i + 1)): $action_type — SKIPPED (invalid type)" + echo "" + } >>"$action_log" + continue + fi + + # Step 2: Validate action-specific fields + local validation_error + validation_error=$(validate_action_fields "$action" "$action_type") + if [[ -n "$validation_error" ]]; then + log_warn "AI Actions: skipping $action_type — $validation_error" + skipped=$((skipped + 1)) + local escaped_reason + escaped_reason=$(printf '%s' "$validation_error" | jq -Rs '.') + results=$(printf '%s' "$results" | jq ". + [{\"index\":$i,\"type\":\"$action_type\",\"status\":\"skipped\",\"reason\":$escaped_reason}]") + { + echo "## Action $((i + 1)): $action_type — SKIPPED ($validation_error)" + echo "" + } >>"$action_log" + continue + fi + + # Step 3: Execute (or simulate in dry-run/validate-only mode) + if [[ "$mode" == "validate-only" ]]; then + log_info "AI Actions: [$((i + 1))/$action_count] $action_type — validated" + skipped=$((skipped + 1)) + results=$(printf '%s' "$results" | jq ". + [{\"index\":$i,\"type\":\"$action_type\",\"status\":\"validated\"}]") + { + echo "## Action $((i + 1)): $action_type — VALIDATED" + echo "Reasoning: $reasoning" + echo "" + } >>"$action_log" + continue + fi + + if [[ "$mode" == "dry-run" || "$AI_ACTIONS_DRY_RUN" == "true" ]]; then + log_info "AI Actions: [$((i + 1))/$action_count] $action_type — dry-run" + executed=$((executed + 1)) + results=$(printf '%s' "$results" | jq ". + [{\"index\":$i,\"type\":\"$action_type\",\"status\":\"dry_run\"}]") + { + echo "## Action $((i + 1)): $action_type — DRY RUN" + echo "Reasoning: $reasoning" + echo "" + echo '```json' + printf '%s' "$action" | jq '.' + echo '```' + echo "" + } >>"$action_log" + continue + fi + + # Execute the action + local exec_result + exec_result=$(execute_single_action "$action" "$action_type" "$repo_path" "$repo_slug" 2>>"$SUPERVISOR_LOG") + local exec_rc=$? + + if [[ $exec_rc -eq 0 ]]; then + executed=$((executed + 1)) + log_info "AI Actions: [$((i + 1))/$action_count] $action_type — success" + results=$(printf '%s' "$results" | jq ". + [{\"index\":$i,\"type\":\"$action_type\",\"status\":\"executed\",\"result\":$exec_result}]") + else + failed=$((failed + 1)) + log_warn "AI Actions: [$((i + 1))/$action_count] $action_type — failed" + local escaped_result + escaped_result=$(printf '%s' "$exec_result" | jq -Rs '.') + results=$(printf '%s' "$results" | jq ". + [{\"index\":$i,\"type\":\"$action_type\",\"status\":\"failed\",\"error\":$escaped_result}]") + fi + + { + echo "## Action $((i + 1)): $action_type — $([ $exec_rc -eq 0 ] && echo "SUCCESS" || echo "FAILED")" + echo "Reasoning: $reasoning" + echo "Result: $exec_result" + echo "" + } >>"$action_log" + done + + # Summary + local summary + summary=$(jq -n \ + --argjson executed "$executed" \ + --argjson failed "$failed" \ + --argjson skipped "$skipped" \ + --argjson actions "$results" \ + '{executed: $executed, failed: $failed, skipped: $skipped, actions: $actions}') + + { + echo "## Summary" + echo "" + echo "- Executed: $executed" + echo "- Failed: $failed" + echo "- Skipped: $skipped" + echo "" + } >>"$action_log" + + log_info "AI Actions: complete (executed=$executed failed=$failed skipped=$skipped log=$action_log)" + + # Store execution event in DB + db "$SUPERVISOR_DB" " + INSERT INTO state_log (task_id, from_state, to_state, reason) + VALUES ('ai-supervisor', 'actions', 'complete', + '$(sql_escape "AI actions: $executed executed, $failed failed, $skipped skipped")'); + " 2>/dev/null || true + + printf '%s' "$summary" + return 0 +} + +####################################### +# Validate that an action type is in the allowed list +# Arguments: +# $1 - action type string +# Returns: +# 0 if valid, 1 if invalid +####################################### +validate_action_type() { + local action_type="$1" + local valid_type + + for valid_type in $AI_VALID_ACTION_TYPES; do + if [[ "$action_type" == "$valid_type" ]]; then + return 0 + fi + done + + return 1 +} + +####################################### +# Validate action-specific required fields +# Arguments: +# $1 - JSON action object +# $2 - action type +# Returns: +# Empty string if valid, error message if invalid +####################################### +validate_action_fields() { + local action="$1" + local action_type="$2" + + case "$action_type" in + comment_on_issue) + local issue_number body + issue_number=$(printf '%s' "$action" | jq -r '.issue_number // empty') + body=$(printf '%s' "$action" | jq -r '.body // empty') + if [[ -z "$issue_number" ]]; then + echo "missing required field: issue_number" + return 0 + fi + if [[ -z "$body" ]]; then + echo "missing required field: body" + return 0 + fi + # Validate issue_number is a positive integer + if ! [[ "$issue_number" =~ ^[0-9]+$ ]] || [[ "$issue_number" -eq 0 ]]; then + echo "issue_number must be a positive integer, got: $issue_number" + return 0 + fi + ;; + create_task) + local title + title=$(printf '%s' "$action" | jq -r '.title // empty') + if [[ -z "$title" ]]; then + echo "missing required field: title" + return 0 + fi + ;; + create_subtasks) + local parent_task_id subtasks + parent_task_id=$(printf '%s' "$action" | jq -r '.parent_task_id // empty') + subtasks=$(printf '%s' "$action" | jq -r '.subtasks // empty') + if [[ -z "$parent_task_id" ]]; then + echo "missing required field: parent_task_id" + return 0 + fi + if [[ -z "$subtasks" || "$subtasks" == "null" ]]; then + echo "missing required field: subtasks (array)" + return 0 + fi + local subtask_count + subtask_count=$(printf '%s' "$action" | jq '.subtasks | length' 2>/dev/null || echo 0) + if [[ "$subtask_count" -eq 0 ]]; then + echo "subtasks array is empty" + return 0 + fi + ;; + flag_for_review) + local issue_number reason + issue_number=$(printf '%s' "$action" | jq -r '.issue_number // empty') + reason=$(printf '%s' "$action" | jq -r '.reason // empty') + if [[ -z "$issue_number" ]]; then + echo "missing required field: issue_number" + return 0 + fi + if [[ -z "$reason" ]]; then + echo "missing required field: reason" + return 0 + fi + if ! [[ "$issue_number" =~ ^[0-9]+$ ]] || [[ "$issue_number" -eq 0 ]]; then + echo "issue_number must be a positive integer, got: $issue_number" + return 0 + fi + ;; + adjust_priority) + local task_id new_priority + task_id=$(printf '%s' "$action" | jq -r '.task_id // empty') + new_priority=$(printf '%s' "$action" | jq -r '.new_priority // empty') + if [[ -z "$task_id" ]]; then + echo "missing required field: task_id" + return 0 + fi + if [[ -z "$new_priority" ]]; then + echo "missing required field: new_priority" + return 0 + fi + ;; + close_verified) + local issue_number pr_number + issue_number=$(printf '%s' "$action" | jq -r '.issue_number // empty') + pr_number=$(printf '%s' "$action" | jq -r '.pr_number // empty') + if [[ -z "$issue_number" ]]; then + echo "missing required field: issue_number" + return 0 + fi + if [[ -z "$pr_number" ]]; then + echo "missing required field: pr_number (must prove merged PR exists)" + return 0 + fi + if ! [[ "$issue_number" =~ ^[0-9]+$ ]] || [[ "$issue_number" -eq 0 ]]; then + echo "issue_number must be a positive integer, got: $issue_number" + return 0 + fi + if ! [[ "$pr_number" =~ ^[0-9]+$ ]] || [[ "$pr_number" -eq 0 ]]; then + echo "pr_number must be a positive integer, got: $pr_number" + return 0 + fi + ;; + request_info) + local issue_number questions + issue_number=$(printf '%s' "$action" | jq -r '.issue_number // empty') + questions=$(printf '%s' "$action" | jq -r '.questions // empty') + if [[ -z "$issue_number" ]]; then + echo "missing required field: issue_number" + return 0 + fi + if [[ -z "$questions" || "$questions" == "null" ]]; then + echo "missing required field: questions (array)" + return 0 + fi + if ! [[ "$issue_number" =~ ^[0-9]+$ ]] || [[ "$issue_number" -eq 0 ]]; then + echo "issue_number must be a positive integer, got: $issue_number" + return 0 + fi + ;; + *) + echo "unhandled action type: $action_type" + return 0 + ;; + esac + + # Valid — return empty string + echo "" + return 0 +} + +####################################### +# Execute a single validated action +# Arguments: +# $1 - JSON action object +# $2 - action type +# $3 - repo path +# $4 - repo slug (owner/repo) +# Outputs: +# JSON result to stdout +# Returns: +# 0 on success, 1 on failure +####################################### +execute_single_action() { + local action="$1" + local action_type="$2" + local repo_path="$3" + local repo_slug="$4" + + case "$action_type" in + comment_on_issue) _exec_comment_on_issue "$action" "$repo_slug" ;; + create_task) _exec_create_task "$action" "$repo_path" ;; + create_subtasks) _exec_create_subtasks "$action" "$repo_path" ;; + flag_for_review) _exec_flag_for_review "$action" "$repo_slug" ;; + adjust_priority) _exec_adjust_priority "$action" "$repo_path" ;; + close_verified) _exec_close_verified "$action" "$repo_slug" ;; + request_info) _exec_request_info "$action" "$repo_slug" ;; + *) + echo '{"error":"unhandled_action_type"}' + return 1 + ;; + esac +} + +####################################### +# Action: comment_on_issue +# Posts a comment on a GitHub issue +####################################### +_exec_comment_on_issue() { + local action="$1" + local repo_slug="$2" + + local issue_number body + issue_number=$(printf '%s' "$action" | jq -r '.issue_number') + body=$(printf '%s' "$action" | jq -r '.body') + + if [[ -z "$repo_slug" ]]; then + echo '{"error":"no_repo_slug"}' + return 1 + fi + + if ! command -v gh &>/dev/null; then + echo '{"error":"gh_cli_not_available"}' + return 1 + fi + + # Verify issue exists before commenting + if ! gh issue view "$issue_number" --repo "$repo_slug" --json number &>/dev/null; then + echo "{\"error\":\"issue_not_found\",\"issue_number\":$issue_number}" + return 1 + fi + + # Add AI supervisor attribution footer + local full_body + full_body="${body} + +--- +*Posted by AI Supervisor (automated reasoning cycle)*" + + if gh issue comment "$issue_number" --repo "$repo_slug" --body "$full_body" &>/dev/null; then + echo "{\"commented\":true,\"issue_number\":$issue_number}" + return 0 + else + echo "{\"error\":\"comment_failed\",\"issue_number\":$issue_number}" + return 1 + fi +} + +####################################### +# Action: create_task +# Adds a new task to TODO.md via claim-task-id.sh +####################################### +_exec_create_task() { + local action="$1" + local repo_path="$2" + + local title description tags estimate model + title=$(printf '%s' "$action" | jq -r '.title') + description=$(printf '%s' "$action" | jq -r '.description // ""') + tags=$(printf '%s' "$action" | jq -r '(.tags // []) | join(" ")') + estimate=$(printf '%s' "$action" | jq -r '.estimate // "~1h"') + model=$(printf '%s' "$action" | jq -r '.model // "sonnet"') + + local todo_file="$repo_path/TODO.md" + if [[ ! -f "$todo_file" ]]; then + echo '{"error":"todo_file_not_found"}' + return 1 + fi + + # Allocate task ID via claim-task-id.sh + local claim_script="${SCRIPT_DIR}/claim-task-id.sh" + local task_id="" + + if [[ -x "$claim_script" ]]; then + local claim_output + claim_output=$("$claim_script" --title "$title" --repo-path "$repo_path" 2>/dev/null || echo "") + task_id=$(printf '%s' "$claim_output" | grep -oE 'task_id=t[0-9]+' | head -1 | sed 's/task_id=//') + fi + + if [[ -z "$task_id" ]]; then + # Fallback: use timestamp-based ID (will be reconciled later) + task_id="t$(date +%s | tail -c 5)" + log_warn "AI Actions: claim-task-id.sh unavailable, using fallback ID $task_id" + fi + + # Build the task line + local task_line="- [ ] $task_id $title" + if [[ -n "$tags" ]]; then + task_line="$task_line $tags" + fi + task_line="$task_line $estimate model:$model" + if [[ -n "$description" ]]; then + task_line="$task_line — $description" + fi + + # Append to TODO.md (before the first blank line after the last task) + # Find the "Backlog" or last task section and append there + printf '\n%s\n' "$task_line" >>"$todo_file" + + # Commit and push + if declare -f commit_and_push_todo &>/dev/null; then + commit_and_push_todo "$repo_path" "chore: AI supervisor created task $task_id" 2>/dev/null || true + fi + + echo "{\"created\":true,\"task_id\":\"$task_id\",\"title\":$(printf '%s' "$title" | jq -Rs '.')}" + return 0 +} + +####################################### +# Action: create_subtasks +# Breaks down an existing task into subtasks in TODO.md +####################################### +_exec_create_subtasks() { + local action="$1" + local repo_path="$2" + + local parent_task_id + parent_task_id=$(printf '%s' "$action" | jq -r '.parent_task_id') + + local todo_file="$repo_path/TODO.md" + if [[ ! -f "$todo_file" ]]; then + echo '{"error":"todo_file_not_found"}' + return 1 + fi + + # Verify parent task exists in TODO.md + if ! grep -q "^\s*- \[.\] $parent_task_id " "$todo_file" 2>/dev/null; then + echo "{\"error\":\"parent_task_not_found\",\"parent_task_id\":\"$parent_task_id\"}" + return 1 + fi + + # Count existing subtasks to determine next index + local existing_subtask_count + existing_subtask_count=$(grep -c "^\s*- \[.\] ${parent_task_id}\." "$todo_file" 2>/dev/null || echo 0) + + local subtask_count + subtask_count=$(printf '%s' "$action" | jq '.subtasks | length') + + local created_ids="" + local next_index=$((existing_subtask_count + 1)) + + # Find the line number of the parent task to insert subtasks after it + local parent_line_num + parent_line_num=$(grep -n "^\s*- \[.\] $parent_task_id " "$todo_file" | head -1 | cut -d: -f1) + + if [[ -z "$parent_line_num" ]]; then + echo "{\"error\":\"parent_task_line_not_found\"}" + return 1 + fi + + # Build subtask lines + local subtask_lines="" + local j + for ((j = 0; j < subtask_count; j++)); do + local subtask + subtask=$(printf '%s' "$action" | jq ".subtasks[$j]") + + local sub_title sub_tags sub_estimate sub_model + sub_title=$(printf '%s' "$subtask" | jq -r '.title // "Untitled subtask"') + sub_tags=$(printf '%s' "$subtask" | jq -r '(.tags // []) | join(" ")') + sub_estimate=$(printf '%s' "$subtask" | jq -r '.estimate // "~30m"') + sub_model=$(printf '%s' "$subtask" | jq -r '.model // "sonnet"') + + local sub_id="${parent_task_id}.${next_index}" + local sub_line=" - [ ] $sub_id $sub_title" + if [[ -n "$sub_tags" ]]; then + sub_line="$sub_line $sub_tags" + fi + sub_line="$sub_line $sub_estimate model:$sub_model" + + subtask_lines="${subtask_lines}${sub_line}\n" + created_ids="${created_ids}${sub_id}," + next_index=$((next_index + 1)) + done + + # Find the insertion point: after the parent task and any existing subtasks + local insert_after=$parent_line_num + # Skip existing subtasks (indented lines starting with the parent ID pattern) + local total_lines + total_lines=$(wc -l <"$todo_file" | tr -d ' ') + local check_line=$((parent_line_num + 1)) + while [[ $check_line -le $total_lines ]]; do + local line_content + line_content=$(sed -n "${check_line}p" "$todo_file") + if [[ "$line_content" =~ ^[[:space:]]+- ]]; then + insert_after=$check_line + check_line=$((check_line + 1)) + else + break + fi + done + + # Insert subtask lines after the insertion point + local temp_file + temp_file=$(mktemp) + { + head -n "$insert_after" "$todo_file" + printf '%b' "$subtask_lines" + tail -n "+$((insert_after + 1))" "$todo_file" + } >"$temp_file" + mv "$temp_file" "$todo_file" + + # Commit and push + if declare -f commit_and_push_todo &>/dev/null; then + commit_and_push_todo "$repo_path" "chore: AI supervisor created subtasks for $parent_task_id" 2>/dev/null || true + fi + + # Remove trailing comma from created_ids + created_ids="${created_ids%,}" + + echo "{\"created\":true,\"parent_task_id\":\"$parent_task_id\",\"subtask_ids\":\"$created_ids\",\"count\":$subtask_count}" + return 0 +} + +####################################### +# Action: flag_for_review +# Labels an issue for human review and posts a comment explaining why +####################################### +_exec_flag_for_review() { + local action="$1" + local repo_slug="$2" + + local issue_number reason + issue_number=$(printf '%s' "$action" | jq -r '.issue_number') + reason=$(printf '%s' "$action" | jq -r '.reason') + + if [[ -z "$repo_slug" ]]; then + echo '{"error":"no_repo_slug"}' + return 1 + fi + + if ! command -v gh &>/dev/null; then + echo '{"error":"gh_cli_not_available"}' + return 1 + fi + + # Verify issue exists + if ! gh issue view "$issue_number" --repo "$repo_slug" --json number &>/dev/null; then + echo "{\"error\":\"issue_not_found\",\"issue_number\":$issue_number}" + return 1 + fi + + # Add "needs-review" label (create if it doesn't exist) + gh label create "needs-review" --repo "$repo_slug" --description "Flagged for human review by AI supervisor" --color "D93F0B" 2>/dev/null || true + gh issue edit "$issue_number" --repo "$repo_slug" --add-label "needs-review" 2>/dev/null || true + + # Post comment explaining why + local comment_body + comment_body="## Flagged for Human Review + +**Reason:** $reason + +This issue has been flagged by the AI supervisor for human review. Please assess and take appropriate action. + +--- +*Flagged by AI Supervisor (automated reasoning cycle)*" + + gh issue comment "$issue_number" --repo "$repo_slug" --body "$comment_body" &>/dev/null || true + + echo "{\"flagged\":true,\"issue_number\":$issue_number}" + return 0 +} + +####################################### +# Action: adjust_priority +# Logs a priority adjustment recommendation +# NOTE: Does not reorder TODO.md (too risky for automated changes). +# Instead, posts the recommendation as a comment on the task's GitHub issue. +####################################### +_exec_adjust_priority() { + local action="$1" + local repo_path="$2" + + local task_id new_priority reasoning + task_id=$(printf '%s' "$action" | jq -r '.task_id') + new_priority=$(printf '%s' "$action" | jq -r '.new_priority') + reasoning=$(printf '%s' "$action" | jq -r '.reasoning // "No reasoning provided"') + + # Find the task's GitHub issue number + local issue_number="" + if declare -f find_task_issue_number &>/dev/null; then + issue_number=$(find_task_issue_number "$task_id" "$repo_path" 2>/dev/null || echo "") + fi + + local repo_slug="" + repo_slug=$(detect_repo_slug "$repo_path" 2>/dev/null || echo "") + + if [[ -n "$issue_number" && -n "$repo_slug" ]] && command -v gh &>/dev/null; then + local comment_body + comment_body="## Priority Adjustment Recommendation + +**Task:** $task_id +**Recommended priority:** $new_priority +**Reasoning:** $reasoning + +This is a recommendation from the AI supervisor. A human should review and decide whether to act on it. + +--- +*Recommended by AI Supervisor (automated reasoning cycle)*" + + gh issue comment "$issue_number" --repo "$repo_slug" --body "$comment_body" &>/dev/null || true + fi + + # Log to DB for tracking + db "$SUPERVISOR_DB" " + INSERT INTO state_log (task_id, from_state, to_state, reason) + VALUES ('$(sql_escape "$task_id")', 'priority', '$(sql_escape "$new_priority")', + '$(sql_escape "AI priority recommendation: $reasoning")'); + " 2>/dev/null || true + + echo "{\"recommended\":true,\"task_id\":\"$task_id\",\"new_priority\":\"$new_priority\"}" + return 0 +} + +####################################### +# Action: close_verified +# Closes a GitHub issue ONLY if a merged PR is verified +# This is the most safety-critical action — requires proof of merged PR +####################################### +_exec_close_verified() { + local action="$1" + local repo_slug="$2" + + local issue_number pr_number + issue_number=$(printf '%s' "$action" | jq -r '.issue_number') + pr_number=$(printf '%s' "$action" | jq -r '.pr_number') + + if [[ -z "$repo_slug" ]]; then + echo '{"error":"no_repo_slug"}' + return 1 + fi + + if ! command -v gh &>/dev/null; then + echo '{"error":"gh_cli_not_available"}' + return 1 + fi + + # CRITICAL: Verify the PR is actually merged + local pr_state + pr_state=$(gh pr view "$pr_number" --repo "$repo_slug" --json state --jq '.state' 2>/dev/null || echo "") + + if [[ "$pr_state" != "MERGED" ]]; then + echo "{\"error\":\"pr_not_merged\",\"pr_number\":$pr_number,\"pr_state\":\"$pr_state\"}" + return 1 + fi + + # Verify the PR has actual file changes (not empty) + local changed_files + changed_files=$(gh pr view "$pr_number" --repo "$repo_slug" --json changedFiles --jq '.changedFiles' 2>/dev/null || echo 0) + + if [[ "$changed_files" -eq 0 ]]; then + echo "{\"error\":\"pr_has_no_changes\",\"pr_number\":$pr_number}" + return 1 + fi + + # Verify the issue exists and is open + local issue_state + issue_state=$(gh issue view "$issue_number" --repo "$repo_slug" --json state --jq '.state' 2>/dev/null || echo "") + + if [[ "$issue_state" != "OPEN" ]]; then + echo "{\"error\":\"issue_not_open\",\"issue_number\":$issue_number,\"issue_state\":\"$issue_state\"}" + return 1 + fi + + # Close with a comment explaining the verification + local close_comment + close_comment="## Verified Complete + +This issue has been verified as complete: +- **PR:** #$pr_number (merged, $changed_files files changed) +- **Verification:** Automated check confirmed PR is merged with real deliverables + +--- +*Closed by AI Supervisor (automated verification)*" + + gh issue comment "$issue_number" --repo "$repo_slug" --body "$close_comment" &>/dev/null || true + gh issue close "$issue_number" --repo "$repo_slug" --reason completed &>/dev/null || { + echo "{\"error\":\"close_failed\",\"issue_number\":$issue_number}" + return 1 + } + + echo "{\"closed\":true,\"issue_number\":$issue_number,\"pr_number\":$pr_number,\"changed_files\":$changed_files}" + return 0 +} + +####################################### +# Action: request_info +# Posts a structured information request on a GitHub issue +####################################### +_exec_request_info() { + local action="$1" + local repo_slug="$2" + + local issue_number + issue_number=$(printf '%s' "$action" | jq -r '.issue_number') + + if [[ -z "$repo_slug" ]]; then + echo '{"error":"no_repo_slug"}' + return 1 + fi + + if ! command -v gh &>/dev/null; then + echo '{"error":"gh_cli_not_available"}' + return 1 + fi + + # Verify issue exists + if ! gh issue view "$issue_number" --repo "$repo_slug" --json number &>/dev/null; then + echo "{\"error\":\"issue_not_found\",\"issue_number\":$issue_number}" + return 1 + fi + + # Build questions list + local questions_md="" + local q_count + q_count=$(printf '%s' "$action" | jq '.questions | length') + local q + for ((q = 0; q < q_count; q++)); do + local question + question=$(printf '%s' "$action" | jq -r ".questions[$q]") + questions_md="${questions_md}$((q + 1)). ${question}\n" + done + + # Add "needs-info" label + gh label create "needs-info" --repo "$repo_slug" --description "Additional information requested" --color "0075CA" 2>/dev/null || true + gh issue edit "$issue_number" --repo "$repo_slug" --add-label "needs-info" 2>/dev/null || true + + local comment_body + comment_body="## Information Requested + +To make progress on this issue, we need some additional information: + +$(printf '%b' "$questions_md") +Please provide the requested details so we can proceed. + +--- +*Requested by AI Supervisor (automated reasoning cycle)*" + + if gh issue comment "$issue_number" --repo "$repo_slug" --body "$comment_body" &>/dev/null; then + echo "{\"requested\":true,\"issue_number\":$issue_number,\"questions\":$q_count}" + return 0 + else + echo "{\"error\":\"comment_failed\",\"issue_number\":$issue_number}" + return 1 + fi +} + +####################################### +# Run the full AI reasoning + action execution pipeline +# Convenience function that chains ai-reason.sh → ai-actions.sh +# Arguments: +# $1 - repo path +# $2 - (optional) mode: "full" (default), "dry-run" +# Returns: +# 0 on success, 1 on failure +####################################### +run_ai_actions_pipeline() { + local repo_path="${1:-$REPO_PATH}" + local mode="${2:-full}" + + # Step 1: Run reasoning to get action plan + local action_plan + action_plan=$(run_ai_reasoning "$repo_path" "$mode" 2>/dev/null) + local reason_rc=$? + + if [[ $reason_rc -ne 0 ]]; then + log_warn "AI Actions Pipeline: reasoning failed (rc=$reason_rc)" + echo '{"error":"reasoning_failed","actions":[]}' + return 1 + fi + + # Check if the result is an error object rather than an action array + local is_error + is_error=$(printf '%s' "$action_plan" | jq 'has("error")' 2>/dev/null || echo "false") + if [[ "$is_error" == "true" ]]; then + local error_msg + error_msg=$(printf '%s' "$action_plan" | jq -r '.error // "unknown"') + log_warn "AI Actions Pipeline: reasoning returned error: $error_msg" + echo "$action_plan" + return 1 + fi + + # Verify we got an array + local plan_type + plan_type=$(printf '%s' "$action_plan" | jq 'type' 2>/dev/null || echo "") + if [[ "$plan_type" != '"array"' ]]; then + log_warn "AI Actions Pipeline: expected array, got $plan_type" + echo '{"error":"invalid_plan_type","actions":[]}' + return 1 + fi + + local plan_count + plan_count=$(printf '%s' "$action_plan" | jq 'length' 2>/dev/null || echo 0) + + if [[ "$plan_count" -eq 0 ]]; then + log_info "AI Actions Pipeline: no actions proposed" + echo '{"executed":0,"failed":0,"skipped":0,"actions":[]}' + return 0 + fi + + # Step 2: Execute the action plan + local exec_mode="execute" + if [[ "$mode" == "dry-run" ]]; then + exec_mode="dry-run" + fi + + execute_action_plan "$action_plan" "$repo_path" "$exec_mode" + return $? +} + +####################################### +# CLI entry point for standalone testing +# Usage: ai-actions.sh [--mode execute|dry-run|validate-only] [--repo /path] [--plan ] +# ai-actions.sh pipeline [--mode full|dry-run] [--repo /path] +####################################### +if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then + set -euo pipefail + SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + + # Source dependencies + # shellcheck source=_common.sh + source "$SCRIPT_DIR/_common.sh" + # shellcheck source=ai-context.sh + source "$SCRIPT_DIR/ai-context.sh" + # shellcheck source=ai-reason.sh + source "$SCRIPT_DIR/ai-reason.sh" + + # Colour codes + BLUE="${BLUE:-\033[0;34m}" + GREEN="${GREEN:-\033[0;32m}" + YELLOW="${YELLOW:-\033[1;33m}" + RED="${RED:-\033[0;31m}" + NC="${NC:-\033[0m}" + + # Default paths + SUPERVISOR_DB="${SUPERVISOR_DB:-$HOME/.aidevops/.agent-workspace/supervisor/supervisor.db}" + SUPERVISOR_LOG="${SUPERVISOR_LOG:-$HOME/.aidevops/.agent-workspace/supervisor/cron.log}" + REPO_PATH="${REPO_PATH:-$(git rev-parse --show-toplevel 2>/dev/null || pwd)}" + + # Stub functions if not available from sourced modules + if ! declare -f detect_repo_slug &>/dev/null; then + detect_repo_slug() { + local repo_path="${1:-.}" + git -C "$repo_path" remote get-url origin 2>/dev/null | + sed -E 's#.*[:/]([^/]+/[^/]+?)(\.git)?$#\1#' || echo "" + return 0 + } + fi + + if ! declare -f commit_and_push_todo &>/dev/null; then + commit_and_push_todo() { + log_warn "commit_and_push_todo stub — skipping commit" + return 0 + } + fi + + if ! declare -f find_task_issue_number &>/dev/null; then + find_task_issue_number() { + local task_id="${1:-}" + local project_root="${2:-.}" + local todo_file="$project_root/TODO.md" + if [[ -f "$todo_file" ]]; then + grep -oE "ref:GH#[0-9]+" "$todo_file" | + head -1 | sed 's/ref:GH#//' || echo "" + fi + return 0 + } + fi + + # Parse args + mode="execute" + repo_path="$REPO_PATH" + plan="" + subcommand="" + + while [[ $# -gt 0 ]]; do + case "$1" in + pipeline) + subcommand="pipeline" + shift + ;; + --mode) + mode="$2" + shift 2 + ;; + --repo) + repo_path="$2" + shift 2 + ;; + --plan) + plan="$2" + shift 2 + ;; + --dry-run) + mode="dry-run" + shift + ;; + --help | -h) + echo "Usage: ai-actions.sh [--mode execute|dry-run|validate-only] [--repo /path] [--plan ]" + echo " ai-actions.sh pipeline [--mode full|dry-run] [--repo /path]" + echo "" + echo "Execute AI supervisor action plans." + echo "" + echo "Options:" + echo " --mode execute|dry-run|validate-only Execution mode (default: execute)" + echo " --repo /path Repository path (default: git root)" + echo " --plan JSON action plan (required unless pipeline)" + echo " --dry-run Shorthand for --mode dry-run" + echo " --help Show this help" + echo "" + echo "Subcommands:" + echo " pipeline Run full reasoning + execution pipeline" + exit 0 + ;; + *) + echo "Unknown option: $1" >&2 + exit 1 + ;; + esac + done + + if [[ "$subcommand" == "pipeline" ]]; then + run_ai_actions_pipeline "$repo_path" "$mode" + elif [[ -n "$plan" ]]; then + execute_action_plan "$plan" "$repo_path" "$mode" + else + echo "Error: --plan is required (or use 'pipeline' subcommand)" >&2 + exit 1 + fi +fi diff --git a/tests/test-ai-actions.sh b/tests/test-ai-actions.sh new file mode 100644 index 0000000000..9385f31e43 --- /dev/null +++ b/tests/test-ai-actions.sh @@ -0,0 +1,685 @@ +#!/usr/bin/env bash +# shellcheck disable=SC2034,SC1090 +# SC2034: Variables set for sourced scripts (BLUE, SUPERVISOR_DB, etc.) +# SC1090: Non-constant source paths (test harness pattern) +# +# test-ai-actions.sh - Unit tests for AI supervisor action executor (t1085.3) +# +# Tests validation logic, field checking, and action type handling +# without requiring GitHub API access or a real supervisor DB. +# +# Usage: bash tests/test-ai-actions.sh +# Exit codes: 0 = all pass, 1 = failures + +set -euo pipefail + +REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +ACTIONS_SCRIPT="$REPO_DIR/.agents/scripts/supervisor/ai-actions.sh" + +PASS=0 +FAIL=0 +TOTAL=0 + +pass() { + PASS=$((PASS + 1)) + TOTAL=$((TOTAL + 1)) + echo " PASS: $1" +} + +fail() { + FAIL=$((FAIL + 1)) + TOTAL=$((TOTAL + 1)) + echo " FAIL: $1" +} + +echo "=== AI Actions Executor Tests (t1085.3) ===" +echo "" + +# ─── Test 1: Syntax check ─────────────────────────────────────────── +echo "Test 1: Syntax check" +if bash -n "$ACTIONS_SCRIPT" 2>/dev/null; then + pass "ai-actions.sh passes bash -n" +else + fail "ai-actions.sh has syntax errors" + bash -n "$ACTIONS_SCRIPT" 2>&1 | head -5 +fi + +# ─── Test 2: Source without errors ────────────────────────────────── +echo "Test 2: Source without errors" + +# Create a minimal environment for sourcing +_test_source() { + ( + # Prevent standalone CLI block from running + BASH_SOURCE_OVERRIDE="sourced" + + # Provide required globals + BLUE='\033[0;34m' + GREEN='\033[0;32m' + YELLOW='\033[1;33m' + RED='\033[0;31m' + NC='\033[0m' + SUPERVISOR_DB="/tmp/test-ai-actions-$$.db" + SUPERVISOR_LOG="/dev/null" + SCRIPT_DIR="$REPO_DIR/.agents/scripts" + REPO_PATH="$REPO_DIR" + AI_REASON_LOG_DIR="/tmp/test-ai-actions-logs-$$" + AI_ACTIONS_LOG_DIR="/tmp/test-ai-actions-logs-$$" + + # Stub required functions + db() { sqlite3 -cmd ".timeout 5000" "$@" 2>/dev/null || true; } + log_info() { :; } + log_success() { :; } + log_warn() { :; } + log_error() { :; } + log_verbose() { :; } + sql_escape() { printf '%s' "$1" | sed "s/'/''/g"; } + detect_repo_slug() { echo "test/repo"; } + commit_and_push_todo() { :; } + find_task_issue_number() { echo ""; } + build_ai_context() { echo "# test context"; } + run_ai_reasoning() { echo '[]'; } + + export -f db log_info log_success log_warn log_error log_verbose sql_escape + export -f detect_repo_slug commit_and_push_todo find_task_issue_number + export -f build_ai_context run_ai_reasoning + + # Source the module (not as main script) + source "$ACTIONS_SCRIPT" + + # Verify key functions exist + declare -f validate_action_type &>/dev/null || exit 1 + declare -f validate_action_fields &>/dev/null || exit 1 + declare -f execute_action_plan &>/dev/null || exit 1 + declare -f execute_single_action &>/dev/null || exit 1 + declare -f run_ai_actions_pipeline &>/dev/null || exit 1 + + # Clean up + rm -rf "/tmp/test-ai-actions-logs-$$" + rm -f "$SUPERVISOR_DB" + ) +} + +if _test_source 2>/dev/null; then + pass "ai-actions.sh sources without errors and exports key functions" +else + fail "ai-actions.sh failed to source or missing key functions" +fi + +# ─── Test 3: validate_action_type ─────────────────────────────────── +echo "Test 3: Action type validation" + +_test_action_types() { + ( + BLUE='' GREEN='' YELLOW='' RED='' NC='' + SUPERVISOR_DB="/tmp/test-$$.db" + SUPERVISOR_LOG="/dev/null" + SCRIPT_DIR="$REPO_DIR/.agents/scripts" + REPO_PATH="$REPO_DIR" + AI_ACTIONS_LOG_DIR="/tmp/test-ai-actions-logs-$$" + db() { sqlite3 -cmd ".timeout 5000" "$@" 2>/dev/null || true; } + log_info() { :; } + log_success() { :; } + log_warn() { :; } + log_error() { :; } + log_verbose() { :; } + sql_escape() { printf '%s' "$1" | sed "s/'/''/g"; } + detect_repo_slug() { echo "test/repo"; } + commit_and_push_todo() { :; } + find_task_issue_number() { echo ""; } + build_ai_context() { echo "# test"; } + run_ai_reasoning() { echo '[]'; } + + source "$ACTIONS_SCRIPT" + + local failures=0 + + # Valid types should pass + for t in comment_on_issue create_task create_subtasks flag_for_review adjust_priority close_verified request_info; do + if ! validate_action_type "$t"; then + echo "FAIL: valid type '$t' rejected" + failures=$((failures + 1)) + fi + done + + # Invalid types should fail + for t in delete_repo force_push unknown "" "drop_table"; do + if validate_action_type "$t" 2>/dev/null; then + echo "FAIL: invalid type '$t' accepted" + failures=$((failures + 1)) + fi + done + + rm -rf "/tmp/test-ai-actions-logs-$$" "/tmp/test-$$.db" + exit "$failures" + ) +} + +if _test_action_types 2>/dev/null; then + pass "all 7 valid types accepted, invalid types rejected" +else + fail "action type validation has errors" +fi + +# ─── Test 4: validate_action_fields ───────────────────────────────── +echo "Test 4: Field validation" + +_test_field_validation() { + ( + BLUE='' GREEN='' YELLOW='' RED='' NC='' + SUPERVISOR_DB="/tmp/test-$$.db" + SUPERVISOR_LOG="/dev/null" + SCRIPT_DIR="$REPO_DIR/.agents/scripts" + REPO_PATH="$REPO_DIR" + AI_ACTIONS_LOG_DIR="/tmp/test-ai-actions-logs-$$" + db() { sqlite3 -cmd ".timeout 5000" "$@" 2>/dev/null || true; } + log_info() { :; } + log_success() { :; } + log_warn() { :; } + log_error() { :; } + log_verbose() { :; } + sql_escape() { printf '%s' "$1" | sed "s/'/''/g"; } + detect_repo_slug() { echo "test/repo"; } + commit_and_push_todo() { :; } + find_task_issue_number() { echo ""; } + build_ai_context() { echo "# test"; } + run_ai_reasoning() { echo '[]'; } + + source "$ACTIONS_SCRIPT" + + local failures=0 + + # comment_on_issue: valid + local result + result=$(validate_action_fields '{"type":"comment_on_issue","issue_number":123,"body":"test comment"}' "comment_on_issue") + if [[ -n "$result" ]]; then + echo "FAIL: valid comment_on_issue rejected: $result" + failures=$((failures + 1)) + fi + + # comment_on_issue: missing body + result=$(validate_action_fields '{"type":"comment_on_issue","issue_number":123}' "comment_on_issue") + if [[ -z "$result" ]]; then + echo "FAIL: comment_on_issue without body accepted" + failures=$((failures + 1)) + fi + + # comment_on_issue: missing issue_number + result=$(validate_action_fields '{"type":"comment_on_issue","body":"test"}' "comment_on_issue") + if [[ -z "$result" ]]; then + echo "FAIL: comment_on_issue without issue_number accepted" + failures=$((failures + 1)) + fi + + # comment_on_issue: non-numeric issue_number + result=$(validate_action_fields '{"type":"comment_on_issue","issue_number":"abc","body":"test"}' "comment_on_issue") + if [[ -z "$result" ]]; then + echo "FAIL: comment_on_issue with non-numeric issue_number accepted" + failures=$((failures + 1)) + fi + + # comment_on_issue: zero issue_number + result=$(validate_action_fields '{"type":"comment_on_issue","issue_number":0,"body":"test"}' "comment_on_issue") + if [[ -z "$result" ]]; then + echo "FAIL: comment_on_issue with zero issue_number accepted" + failures=$((failures + 1)) + fi + + # create_task: valid + result=$(validate_action_fields '{"type":"create_task","title":"Test task"}' "create_task") + if [[ -n "$result" ]]; then + echo "FAIL: valid create_task rejected: $result" + failures=$((failures + 1)) + fi + + # create_task: missing title + result=$(validate_action_fields '{"type":"create_task"}' "create_task") + if [[ -z "$result" ]]; then + echo "FAIL: create_task without title accepted" + failures=$((failures + 1)) + fi + + # create_subtasks: valid + result=$(validate_action_fields '{"type":"create_subtasks","parent_task_id":"t100","subtasks":[{"title":"sub1"}]}' "create_subtasks") + if [[ -n "$result" ]]; then + echo "FAIL: valid create_subtasks rejected: $result" + failures=$((failures + 1)) + fi + + # create_subtasks: empty subtasks array + result=$(validate_action_fields '{"type":"create_subtasks","parent_task_id":"t100","subtasks":[]}' "create_subtasks") + if [[ -z "$result" ]]; then + echo "FAIL: create_subtasks with empty array accepted" + failures=$((failures + 1)) + fi + + # create_subtasks: missing parent_task_id + result=$(validate_action_fields '{"type":"create_subtasks","subtasks":[{"title":"sub1"}]}' "create_subtasks") + if [[ -z "$result" ]]; then + echo "FAIL: create_subtasks without parent_task_id accepted" + failures=$((failures + 1)) + fi + + # flag_for_review: valid + result=$(validate_action_fields '{"type":"flag_for_review","issue_number":42,"reason":"needs human judgment"}' "flag_for_review") + if [[ -n "$result" ]]; then + echo "FAIL: valid flag_for_review rejected: $result" + failures=$((failures + 1)) + fi + + # flag_for_review: missing reason + result=$(validate_action_fields '{"type":"flag_for_review","issue_number":42}' "flag_for_review") + if [[ -z "$result" ]]; then + echo "FAIL: flag_for_review without reason accepted" + failures=$((failures + 1)) + fi + + # adjust_priority: valid + result=$(validate_action_fields '{"type":"adjust_priority","task_id":"t100","new_priority":"high"}' "adjust_priority") + if [[ -n "$result" ]]; then + echo "FAIL: valid adjust_priority rejected: $result" + failures=$((failures + 1)) + fi + + # adjust_priority: missing task_id + result=$(validate_action_fields '{"type":"adjust_priority","new_priority":"high"}' "adjust_priority") + if [[ -z "$result" ]]; then + echo "FAIL: adjust_priority without task_id accepted" + failures=$((failures + 1)) + fi + + # close_verified: valid + result=$(validate_action_fields '{"type":"close_verified","issue_number":10,"pr_number":20}' "close_verified") + if [[ -n "$result" ]]; then + echo "FAIL: valid close_verified rejected: $result" + failures=$((failures + 1)) + fi + + # close_verified: missing pr_number (CRITICAL safety check) + result=$(validate_action_fields '{"type":"close_verified","issue_number":10}' "close_verified") + if [[ -z "$result" ]]; then + echo "FAIL: close_verified without pr_number accepted (SAFETY VIOLATION)" + failures=$((failures + 1)) + fi + + # close_verified: zero pr_number + result=$(validate_action_fields '{"type":"close_verified","issue_number":10,"pr_number":0}' "close_verified") + if [[ -z "$result" ]]; then + echo "FAIL: close_verified with zero pr_number accepted" + failures=$((failures + 1)) + fi + + # request_info: valid + result=$(validate_action_fields '{"type":"request_info","issue_number":5,"questions":["What version?"]}' "request_info") + if [[ -n "$result" ]]; then + echo "FAIL: valid request_info rejected: $result" + failures=$((failures + 1)) + fi + + # request_info: missing questions + result=$(validate_action_fields '{"type":"request_info","issue_number":5}' "request_info") + if [[ -z "$result" ]]; then + echo "FAIL: request_info without questions accepted" + failures=$((failures + 1)) + fi + + rm -rf "/tmp/test-ai-actions-logs-$$" "/tmp/test-$$.db" + exit "$failures" + ) +} + +if _test_field_validation 2>/dev/null; then + pass "all field validation checks passed (20 cases)" +else + fail "field validation has errors" +fi + +# ─── Test 5: execute_action_plan with empty plan ──────────────────── +echo "Test 5: Empty action plan" + +_test_empty_plan() { + ( + BLUE='' GREEN='' YELLOW='' RED='' NC='' + SUPERVISOR_DB="/tmp/test-$$.db" + SUPERVISOR_LOG="/dev/null" + SCRIPT_DIR="$REPO_DIR/.agents/scripts" + REPO_PATH="$REPO_DIR" + AI_ACTIONS_LOG_DIR="/tmp/test-ai-actions-logs-$$" + mkdir -p "$AI_ACTIONS_LOG_DIR" + db() { sqlite3 -cmd ".timeout 5000" "$@" 2>/dev/null || true; } + log_info() { :; } + log_success() { :; } + log_warn() { :; } + log_error() { :; } + log_verbose() { :; } + sql_escape() { printf '%s' "$1" | sed "s/'/''/g"; } + detect_repo_slug() { echo "test/repo"; } + commit_and_push_todo() { :; } + find_task_issue_number() { echo ""; } + build_ai_context() { echo "# test"; } + run_ai_reasoning() { echo '[]'; } + + source "$ACTIONS_SCRIPT" + + local result + result=$(execute_action_plan '[]' "$REPO_DIR" "execute") + local executed + executed=$(printf '%s' "$result" | jq -r '.executed') + if [[ "$executed" != "0" ]]; then + echo "FAIL: empty plan should have 0 executed, got $executed" + exit 1 + fi + + rm -rf "/tmp/test-ai-actions-logs-$$" "/tmp/test-$$.db" + exit 0 + ) +} + +if _test_empty_plan 2>/dev/null; then + pass "empty action plan returns 0 executed" +else + fail "empty action plan handling broken" +fi + +# ─── Test 6: execute_action_plan with invalid JSON ────────────────── +echo "Test 6: Invalid JSON input" + +_test_invalid_json() { + ( + set +e # Disable errexit — we expect failures here + BLUE='' GREEN='' YELLOW='' RED='' NC='' + SUPERVISOR_DB="/tmp/test-$$.db" + SUPERVISOR_LOG="/dev/null" + SCRIPT_DIR="$REPO_DIR/.agents/scripts" + REPO_PATH="$REPO_DIR" + AI_ACTIONS_LOG_DIR="/tmp/test-ai-actions-logs-$$" + mkdir -p "$AI_ACTIONS_LOG_DIR" + db() { sqlite3 -cmd ".timeout 5000" "$@" 2>/dev/null || true; } + log_info() { :; } + log_success() { :; } + log_warn() { :; } + log_error() { :; } + log_verbose() { :; } + sql_escape() { printf '%s' "$1" | sed "s/'/''/g"; } + detect_repo_slug() { echo "test/repo"; } + commit_and_push_todo() { :; } + find_task_issue_number() { echo ""; } + build_ai_context() { echo "# test"; } + run_ai_reasoning() { echo '[]'; } + + source "$ACTIONS_SCRIPT" + + local result + result=$(execute_action_plan 'not json at all' "$REPO_DIR" "execute" 2>/dev/null) + local rc=$? + # Should return non-zero for invalid JSON + if [[ $rc -eq 0 ]]; then + echo "FAIL: invalid JSON should return non-zero exit code" + exit 1 + fi + # Output should contain error + local has_error + has_error=$(printf '%s' "$result" | jq -r 'has("error")' 2>/dev/null || echo "false") + if [[ "$has_error" != "true" ]]; then + echo "FAIL: invalid JSON should return error JSON, got: $result" + exit 1 + fi + + rm -rf "/tmp/test-ai-actions-logs-$$" "/tmp/test-$$.db" + exit 0 + ) +} + +if _test_invalid_json 2>/dev/null; then + pass "invalid JSON input returns error" +else + fail "invalid JSON handling broken" +fi + +# ─── Test 7: validate-only mode ──────────────────────────────────── +echo "Test 7: Validate-only mode" + +_test_validate_only() { + ( + BLUE='' GREEN='' YELLOW='' RED='' NC='' + SUPERVISOR_DB="/tmp/test-$$.db" + SUPERVISOR_LOG="/dev/null" + SCRIPT_DIR="$REPO_DIR/.agents/scripts" + REPO_PATH="$REPO_DIR" + AI_ACTIONS_LOG_DIR="/tmp/test-ai-actions-logs-$$" + mkdir -p "$AI_ACTIONS_LOG_DIR" + db() { sqlite3 -cmd ".timeout 5000" "$@" 2>/dev/null || true; } + log_info() { :; } + log_success() { :; } + log_warn() { :; } + log_error() { :; } + log_verbose() { :; } + sql_escape() { printf '%s' "$1" | sed "s/'/''/g"; } + detect_repo_slug() { echo "test/repo"; } + commit_and_push_todo() { :; } + find_task_issue_number() { echo ""; } + build_ai_context() { echo "# test"; } + run_ai_reasoning() { echo '[]'; } + + source "$ACTIONS_SCRIPT" + + local plan='[{"type":"comment_on_issue","issue_number":1,"body":"test","reasoning":"test"}]' + local result + result=$(execute_action_plan "$plan" "$REPO_DIR" "validate-only") + local skipped + skipped=$(printf '%s' "$result" | jq -r '.skipped') + if [[ "$skipped" != "1" ]]; then + echo "FAIL: validate-only should skip execution, got skipped=$skipped" + exit 1 + fi + local status + status=$(printf '%s' "$result" | jq -r '.actions[0].status') + if [[ "$status" != "validated" ]]; then + echo "FAIL: validate-only should set status=validated, got $status" + exit 1 + fi + + rm -rf "/tmp/test-ai-actions-logs-$$" "/tmp/test-$$.db" + exit 0 + ) +} + +if _test_validate_only 2>/dev/null; then + pass "validate-only mode validates without executing" +else + fail "validate-only mode broken" +fi + +# ─── Test 8: dry-run mode ────────────────────────────────────────── +echo "Test 8: Dry-run mode" + +_test_dry_run() { + ( + BLUE='' GREEN='' YELLOW='' RED='' NC='' + SUPERVISOR_DB="/tmp/test-$$.db" + SUPERVISOR_LOG="/dev/null" + SCRIPT_DIR="$REPO_DIR/.agents/scripts" + REPO_PATH="$REPO_DIR" + AI_ACTIONS_LOG_DIR="/tmp/test-ai-actions-logs-$$" + mkdir -p "$AI_ACTIONS_LOG_DIR" + db() { sqlite3 -cmd ".timeout 5000" "$@" 2>/dev/null || true; } + log_info() { :; } + log_success() { :; } + log_warn() { :; } + log_error() { :; } + log_verbose() { :; } + sql_escape() { printf '%s' "$1" | sed "s/'/''/g"; } + detect_repo_slug() { echo "test/repo"; } + commit_and_push_todo() { :; } + find_task_issue_number() { echo ""; } + build_ai_context() { echo "# test"; } + run_ai_reasoning() { echo '[]'; } + + source "$ACTIONS_SCRIPT" + + local plan='[{"type":"create_task","title":"Test task","reasoning":"test"},{"type":"flag_for_review","issue_number":5,"reason":"test","reasoning":"test"}]' + local result + result=$(execute_action_plan "$plan" "$REPO_DIR" "dry-run") + local executed + executed=$(printf '%s' "$result" | jq -r '.executed') + if [[ "$executed" != "2" ]]; then + echo "FAIL: dry-run should count as executed, got $executed" + exit 1 + fi + local status + status=$(printf '%s' "$result" | jq -r '.actions[0].status') + if [[ "$status" != "dry_run" ]]; then + echo "FAIL: dry-run should set status=dry_run, got $status" + exit 1 + fi + + rm -rf "/tmp/test-ai-actions-logs-$$" "/tmp/test-$$.db" + exit 0 + ) +} + +if _test_dry_run 2>/dev/null; then + pass "dry-run mode simulates without executing" +else + fail "dry-run mode broken" +fi + +# ─── Test 9: Safety limit enforcement ────────────────────────────── +echo "Test 9: Safety limit (max actions per cycle)" + +_test_safety_limit() { + ( + BLUE='' GREEN='' YELLOW='' RED='' NC='' + SUPERVISOR_DB="/tmp/test-$$.db" + SUPERVISOR_LOG="/dev/null" + SCRIPT_DIR="$REPO_DIR/.agents/scripts" + REPO_PATH="$REPO_DIR" + AI_ACTIONS_LOG_DIR="/tmp/test-ai-actions-logs-$$" + AI_MAX_ACTIONS_PER_CYCLE=2 + mkdir -p "$AI_ACTIONS_LOG_DIR" + db() { sqlite3 -cmd ".timeout 5000" "$@" 2>/dev/null || true; } + log_info() { :; } + log_success() { :; } + log_warn() { :; } + log_error() { :; } + log_verbose() { :; } + sql_escape() { printf '%s' "$1" | sed "s/'/''/g"; } + detect_repo_slug() { echo "test/repo"; } + commit_and_push_todo() { :; } + find_task_issue_number() { echo ""; } + build_ai_context() { echo "# test"; } + run_ai_reasoning() { echo '[]'; } + + source "$ACTIONS_SCRIPT" + + # Create a plan with 5 actions but limit is 2 + local plan='[ + {"type":"create_task","title":"Task 1","reasoning":"test"}, + {"type":"create_task","title":"Task 2","reasoning":"test"}, + {"type":"create_task","title":"Task 3","reasoning":"test"}, + {"type":"create_task","title":"Task 4","reasoning":"test"}, + {"type":"create_task","title":"Task 5","reasoning":"test"} + ]' + local result + result=$(execute_action_plan "$plan" "$REPO_DIR" "validate-only") + local action_count + action_count=$(printf '%s' "$result" | jq '.actions | length') + if [[ "$action_count" != "2" ]]; then + echo "FAIL: safety limit should cap at 2, got $action_count actions" + exit 1 + fi + + rm -rf "/tmp/test-ai-actions-logs-$$" "/tmp/test-$$.db" + exit 0 + ) +} + +if _test_safety_limit 2>/dev/null; then + pass "safety limit caps actions at configured maximum" +else + fail "safety limit enforcement broken" +fi + +# ─── Test 10: Invalid action type skipped ─────────────────────────── +echo "Test 10: Invalid action types are skipped" + +_test_invalid_type_skipped() { + ( + BLUE='' GREEN='' YELLOW='' RED='' NC='' + SUPERVISOR_DB="/tmp/test-$$.db" + SUPERVISOR_LOG="/dev/null" + SCRIPT_DIR="$REPO_DIR/.agents/scripts" + REPO_PATH="$REPO_DIR" + AI_ACTIONS_LOG_DIR="/tmp/test-ai-actions-logs-$$" + mkdir -p "$AI_ACTIONS_LOG_DIR" + db() { sqlite3 -cmd ".timeout 5000" "$@" 2>/dev/null || true; } + log_info() { :; } + log_success() { :; } + log_warn() { :; } + log_error() { :; } + log_verbose() { :; } + sql_escape() { printf '%s' "$1" | sed "s/'/''/g"; } + detect_repo_slug() { echo "test/repo"; } + commit_and_push_todo() { :; } + find_task_issue_number() { echo ""; } + build_ai_context() { echo "# test"; } + run_ai_reasoning() { echo '[]'; } + + source "$ACTIONS_SCRIPT" + + local plan='[{"type":"delete_everything","reasoning":"evil"},{"type":"create_task","title":"Good task","reasoning":"valid"}]' + local result + result=$(execute_action_plan "$plan" "$REPO_DIR" "validate-only") + local skipped + skipped=$(printf '%s' "$result" | jq -r '.skipped') + # Both should be skipped in validate-only: 1 for invalid type, 1 for validated + local first_status + first_status=$(printf '%s' "$result" | jq -r '.actions[0].status') + if [[ "$first_status" != "skipped" ]]; then + echo "FAIL: invalid type should be skipped, got $first_status" + exit 1 + fi + local first_reason + first_reason=$(printf '%s' "$result" | jq -r '.actions[0].reason') + if [[ "$first_reason" != "invalid_action_type" ]]; then + echo "FAIL: skip reason should be invalid_action_type, got $first_reason" + exit 1 + fi + + rm -rf "/tmp/test-ai-actions-logs-$$" "/tmp/test-$$.db" + exit 0 + ) +} + +if _test_invalid_type_skipped 2>/dev/null; then + pass "invalid action types are skipped with correct reason" +else + fail "invalid action type handling broken" +fi + +# ─── Test 11: CLI help flag ──────────────────────────────────────── +echo "Test 11: CLI --help flag" +_help_output=$(bash "$ACTIONS_SCRIPT" --help 2>/dev/null || true) +if printf '%s' "$_help_output" | grep -q "Usage:"; then + pass "CLI --help shows usage" +else + fail "CLI --help does not show usage (output: ${_help_output:0:80})" +fi + +# ─── Test 12: Supervisor-helper.sh sources all modules ────────────── +echo "Test 12: supervisor-helper.sh sources ai-actions.sh" +if bash -u "$REPO_DIR/.agents/scripts/supervisor-helper.sh" help >/dev/null 2>&1; then + pass "supervisor-helper.sh help runs with ai-actions.sh sourced" +else + fail "supervisor-helper.sh help failed after ai-actions.sh addition" + bash -u "$REPO_DIR/.agents/scripts/supervisor-helper.sh" help 2>&1 | head -5 +fi + +# ─── Summary ──────────────────────────────────────────────────────── +echo "" +echo "=== Results: $PASS/$TOTAL passed, $FAIL failed ===" + +if [[ "$FAIL" -gt 0 ]]; then + exit 1 +fi +exit 0