diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 36a2c6ae..9b44bd08 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -8,7 +8,7 @@ "name": "humanize", "source": "./", "description": "Humanize - An iterative development plugin that uses Codex to review Claude's work. Creates a feedback loop where Claude implements plans and Codex independently reviews progress, ensuring quality through continuous refinement.", - "version": "1.1.4" + "version": "1.1.5" } ] } diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 3838abeb..1b9b2294 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "humanize", "description": "Humanize - An iterative development plugin that uses Codex to review Claude's work. Creates a feedback loop where Claude implements plans and Codex independently reviews progress, ensuring quality through continuous refinement.", - "version": "1.1.4", + "version": "1.1.5", "author": { "name": "humania-org" }, diff --git a/README.md b/README.md index 1d5b557b..a536cefc 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Humanize -**Current Version: 1.1.4** +**Current Version: 1.1.5** > Derived from the [GAAC (GitHub-as-a-Context)](https://github.com/SihaoLiu/gaac) project. diff --git a/hooks/check-todos-from-transcript.py b/hooks/check-todos-from-transcript.py index eb27da93..ab359fcd 100755 --- a/hooks/check-todos-from-transcript.py +++ b/hooks/check-todos-from-transcript.py @@ -3,8 +3,11 @@ Helper script to check for incomplete todos from Claude Code transcript. Reads the transcript JSONL file and finds the most recent TodoWrite tool call. -Returns exit code 0 if all todos are completed (or no todos exist). -Returns exit code 1 if there are incomplete todos, with details on stderr. + +Exit codes: + 0 - All todos are completed (or no todos exist) + 1 - There are incomplete todos (details on stdout) + 2 - Parse error reading hook input JSON Usage: echo '{"transcript_path": "/path/to/transcript.jsonl"}' | python3 check-todos-from-transcript.py @@ -88,9 +91,10 @@ def main(): # Read hook input from stdin try: hook_input = json.load(sys.stdin) - except json.JSONDecodeError: - # No valid input, assume no todos - sys.exit(0) + except json.JSONDecodeError as e: + # Parse error - exit with code 2 + print(f"PARSE_ERROR: {e}", file=sys.stderr) + sys.exit(2) transcript_path = hook_input.get("transcript_path", "") if not transcript_path: diff --git a/hooks/lib/loop-common.sh b/hooks/lib/loop-common.sh index eba9607f..2427d6bf 100755 --- a/hooks/lib/loop-common.sh +++ b/hooks/lib/loop-common.sh @@ -8,6 +8,41 @@ # - loop-bash-validator.sh # +# ======================================== +# Constants +# ======================================== + +# State file field names +readonly FIELD_PLAN_TRACKED="plan_tracked" +readonly FIELD_START_BRANCH="start_branch" +readonly FIELD_PLAN_FILE="plan_file" +readonly FIELD_CURRENT_ROUND="current_round" +readonly FIELD_MAX_ITERATIONS="max_iterations" +readonly FIELD_PUSH_EVERY_ROUND="push_every_round" +readonly FIELD_CODEX_MODEL="codex_model" +readonly FIELD_CODEX_EFFORT="codex_effort" +readonly FIELD_CODEX_TIMEOUT="codex_timeout" + +# Codex review markers +readonly MARKER_COMPLETE="COMPLETE" +readonly MARKER_STOP="STOP" + +# Exit reasons (used with end_loop function) +# complete - Codex confirmed all goals achieved (normal success) +# cancel - User cancelled with /cancel-rlcr-loop +# maxiter - Reached maximum iterations limit +# stop - Codex triggered circuit breaker (stagnation detected) +# unexpected - System error or invalid state (e.g., corrupted state file) +readonly EXIT_COMPLETE="complete" +readonly EXIT_CANCEL="cancel" +readonly EXIT_MAXITER="maxiter" +readonly EXIT_STOP="stop" +readonly EXIT_UNEXPECTED="unexpected" + +# ======================================== +# Library Setup +# ======================================== + # Source template loader LOOP_COMMON_DIR="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd)" source "$LOOP_COMMON_DIR/template-loader.sh" @@ -45,6 +80,7 @@ find_active_loop() { # Extract current round number from state.md # Outputs the round number to stdout, defaults to 0 +# Note: For full state parsing, use parse_state_file() instead get_current_round() { local state_file="$1" @@ -52,11 +88,54 @@ get_current_round() { frontmatter=$(sed -n '/^---$/,/^---$/{ /^---$/d; p; }' "$state_file" 2>/dev/null || echo "") local current_round - current_round=$(echo "$frontmatter" | grep '^current_round:' | sed 's/current_round: *//' | tr -d ' ') + current_round=$(echo "$frontmatter" | grep "^${FIELD_CURRENT_ROUND}:" | sed "s/${FIELD_CURRENT_ROUND}: *//" | tr -d ' ') echo "${current_round:-0}" } +# Parse state file frontmatter and set variables +# Usage: parse_state_file "$STATE_FILE" +# Sets the following variables (caller must declare them): +# STATE_FRONTMATTER - raw frontmatter content +# STATE_PLAN_TRACKED - "true" or "false" +# STATE_START_BRANCH - branch name +# STATE_PLAN_FILE - plan file path +# STATE_CURRENT_ROUND - current round number +# STATE_MAX_ITERATIONS - max iterations +# STATE_PUSH_EVERY_ROUND - "true" or "false" +# STATE_CODEX_MODEL - codex model name +# STATE_CODEX_EFFORT - codex effort level +# STATE_CODEX_TIMEOUT - codex timeout in seconds +# Returns: 0 on success, 1 if file not found +parse_state_file() { + local state_file="$1" + + if [[ ! -f "$state_file" ]]; then + return 1 + fi + + STATE_FRONTMATTER=$(sed -n '/^---$/,/^---$/{ /^---$/d; p; }' "$state_file" 2>/dev/null || echo "") + + # Parse fields with consistent quote handling + # Legacy quote-stripping kept for backward compatibility with older state files + STATE_PLAN_TRACKED=$(echo "$STATE_FRONTMATTER" | grep "^${FIELD_PLAN_TRACKED}:" | sed "s/${FIELD_PLAN_TRACKED}: *//" | tr -d ' ' || true) + STATE_START_BRANCH=$(echo "$STATE_FRONTMATTER" | grep "^${FIELD_START_BRANCH}:" | sed "s/${FIELD_START_BRANCH}: *//; s/^\"//; s/\"\$//" || true) + STATE_PLAN_FILE=$(echo "$STATE_FRONTMATTER" | grep "^${FIELD_PLAN_FILE}:" | sed "s/${FIELD_PLAN_FILE}: *//; s/^\"//; s/\"\$//" || true) + STATE_CURRENT_ROUND=$(echo "$STATE_FRONTMATTER" | grep "^${FIELD_CURRENT_ROUND}:" | sed "s/${FIELD_CURRENT_ROUND}: *//" | tr -d ' ' || true) + STATE_MAX_ITERATIONS=$(echo "$STATE_FRONTMATTER" | grep "^${FIELD_MAX_ITERATIONS}:" | sed "s/${FIELD_MAX_ITERATIONS}: *//" | tr -d ' ' || true) + STATE_PUSH_EVERY_ROUND=$(echo "$STATE_FRONTMATTER" | grep "^${FIELD_PUSH_EVERY_ROUND}:" | sed "s/${FIELD_PUSH_EVERY_ROUND}: *//" | tr -d ' ' || true) + STATE_CODEX_MODEL=$(echo "$STATE_FRONTMATTER" | grep "^${FIELD_CODEX_MODEL}:" | sed "s/${FIELD_CODEX_MODEL}: *//" | tr -d ' ' || true) + STATE_CODEX_EFFORT=$(echo "$STATE_FRONTMATTER" | grep "^${FIELD_CODEX_EFFORT}:" | sed "s/${FIELD_CODEX_EFFORT}: *//" | tr -d ' ' || true) + STATE_CODEX_TIMEOUT=$(echo "$STATE_FRONTMATTER" | grep "^${FIELD_CODEX_TIMEOUT}:" | sed "s/${FIELD_CODEX_TIMEOUT}: *//" | tr -d ' ' || true) + + # Apply defaults + STATE_CURRENT_ROUND="${STATE_CURRENT_ROUND:-0}" + STATE_MAX_ITERATIONS="${STATE_MAX_ITERATIONS:-10}" + STATE_PUSH_EVERY_ROUND="${STATE_PUSH_EVERY_ROUND:-false}" + + return 0 +} + # Convert a string to lowercase to_lower() { echo "$1" | tr '[:upper:]' '[:lower:]' @@ -83,6 +162,29 @@ extract_round_number() { echo "$filename_lower" | sed -n 's/.*round-\([0-9][0-9]*\)-\(summary\|prompt\|todos\)\.md$/\1/p' } +# Check if a file is in the allowlist for the active loop +# Usage: is_allowlisted_file "$file_path" "$active_loop_dir" +# Returns: 0 if allowlisted, 1 otherwise +is_allowlisted_file() { + local file_path="$1" + local active_loop_dir="$2" + + local allowlist=( + "round-1-todos.md" + "round-2-todos.md" + "round-0-summary.md" + "round-1-summary.md" + ) + + for allowed in "${allowlist[@]}"; do + if [[ "$file_path" == "$active_loop_dir/$allowed" ]]; then + return 0 + fi + done + + return 1 +} + # Standard message for blocking todos file access # Usage: todos_blocked_message "Read|Write|Bash" todos_blocked_message() { diff --git a/hooks/lib/template-loader.sh b/hooks/lib/template-loader.sh index 7df141b0..7f46853d 100644 --- a/hooks/lib/template-loader.sh +++ b/hooks/lib/template-loader.sh @@ -3,7 +3,22 @@ # Template loading functions for RLCR loop hooks # # This library provides functions to load and render prompt templates. +# +# Template Variable Syntax +# ======================== # Templates use {{VARIABLE_NAME}} syntax for placeholders. +# - Variable names: uppercase letters, numbers, underscores only +# - Example: {{PLAN_FILE}}, {{CURRENT_ROUND}}, {{GOAL_TRACKER_FILE}} +# - Single-pass substitution: {{VAR}} in a value will NOT be expanded +# - Missing variables: placeholder is kept as-is (e.g., {{UNDEFINED}}) +# +# Available functions: +# - get_template_dir: Get path to template directory +# - load_template: Load a template file by name +# - render_template: Replace {{VAR}} placeholders with values +# - load_and_render: Load and render in one call +# - load_and_render_safe: Same as above but with fallback for missing templates +# - validate_template_dir: Check if template directory is valid # # Get the template directory path diff --git a/hooks/loop-bash-validator.sh b/hooks/loop-bash-validator.sh index 8b198668..f6c0163b 100755 --- a/hooks/loop-bash-validator.sh +++ b/hooks/loop-bash-validator.sh @@ -42,15 +42,19 @@ if [[ -z "$ACTIVE_LOOP_DIR" ]]; then exit 0 fi -CURRENT_ROUND=$(get_current_round "$ACTIVE_LOOP_DIR/state.md") STATE_FILE="$ACTIVE_LOOP_DIR/state.md" +# Parse state file using shared function to get current round +parse_state_file "$STATE_FILE" +CURRENT_ROUND="$STATE_CURRENT_ROUND" + # ======================================== # Block Git Push When push_every_round is false # ======================================== # Default behavior: commits stay local, no need to push to remote -PUSH_EVERY_ROUND=$(grep -E "^push_every_round:" "$STATE_FILE" 2>/dev/null | sed 's/push_every_round: *//' || echo "false") +# Note: parse_state_file was called above, STATE_* vars are available +PUSH_EVERY_ROUND="$STATE_PUSH_EVERY_ROUND" if [[ "$PUSH_EVERY_ROUND" != "true" ]]; then # Check if command is a git push command @@ -130,8 +134,13 @@ fi # ======================================== if command_modifies_file "$COMMAND_LOWER" "round-[0-9]+-todos\.md"; then - todos_blocked_message "Bash" >&2 - exit 2 + # Require full path to active loop dir to prevent same-basename bypass from different roots + ACTIVE_LOOP_DIR_LOWER=$(to_lower "$ACTIVE_LOOP_DIR") + ACTIVE_LOOP_DIR_ESCAPED=$(echo "$ACTIVE_LOOP_DIR_LOWER" | sed 's/[\\.*^$[(){}+?|]/\\&/g') + if ! echo "$COMMAND_LOWER" | grep -qE "${ACTIVE_LOOP_DIR_ESCAPED}/round-[12]-todos\.md"; then + todos_blocked_message "Bash" >&2 + exit 2 + fi fi exit 0 diff --git a/hooks/loop-codex-stop-hook.sh b/hooks/loop-codex-stop-hook.sh index 18df65e6..3d324f55 100755 --- a/hooks/loop-codex-stop-hook.sh +++ b/hooks/loop-codex-stop-hook.sh @@ -33,7 +33,7 @@ HOOK_INPUT=$(cat) # from a previous blocked stop. We WANT to run Codex review each iteration. # Loop termination is controlled by: # - No active loop directory (no state.md) -> exit early below -# - Codex outputs "COMPLETE" -> allow exit +# - Codex outputs MARKER_COMPLETE -> allow exit # - current_round >= max_iterations -> allow exit # ======================================== @@ -47,6 +47,13 @@ LOOP_BASE_DIR="$PROJECT_ROOT/.humanize/rlcr" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd)" source "$SCRIPT_DIR/lib/loop-common.sh" +# Source portable timeout wrapper for git operations +PLUGIN_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +source "$PLUGIN_ROOT/scripts/portable-timeout.sh" + +# Default timeout for git operations (30 seconds) +GIT_TIMEOUT=30 + # Template directory is set by loop-common.sh via template-loader.sh LOOP_DIR=$(find_active_loop "$LOOP_BASE_DIR") @@ -59,44 +66,44 @@ fi STATE_FILE="$LOOP_DIR/state.md" # ======================================== -# Parse State File (all frontmatter fields) +# Parse State File (using shared function) # ======================================== if [[ ! -f "$STATE_FILE" ]]; then exit 0 fi -FRONTMATTER=$(sed -n '/^---$/,/^---$/{ /^---$/d; p; }' "$STATE_FILE" 2>/dev/null || echo "") - -# Fields for integrity checks (may be empty for old state files) -# Note: Values are unquoted since v1.1.2+ validates paths don't contain special chars -# Legacy quote-stripping kept for backward compatibility with older state files -PLAN_TRACKED=$(echo "$FRONTMATTER" | grep '^plan_tracked:' | sed 's/plan_tracked: *//' | tr -d ' ' || true) -START_BRANCH=$(echo "$FRONTMATTER" | grep '^start_branch:' | sed 's/start_branch: *//; s/^"//; s/"$//' || true) -PLAN_FILE=$(echo "$FRONTMATTER" | grep '^plan_file:' | sed 's/plan_file: *//; s/^"//; s/"$//' || true) - -# Fields for loop iteration control -CURRENT_ROUND=$(echo "$FRONTMATTER" | grep '^current_round:' | sed 's/current_round: *//' | tr -d ' ' || true) -MAX_ITERATIONS=$(echo "$FRONTMATTER" | grep '^max_iterations:' | sed 's/max_iterations: *//' | tr -d ' ' || true) -PUSH_EVERY_ROUND=$(echo "$FRONTMATTER" | grep '^push_every_round:' | sed 's/push_every_round: *//' | tr -d ' ' || true) - -# Fields for Codex configuration -CODEX_MODEL=$(echo "$FRONTMATTER" | grep '^codex_model:' | sed 's/codex_model: *//' | tr -d ' ' || true) -CODEX_EFFORT=$(echo "$FRONTMATTER" | grep '^codex_effort:' | sed 's/codex_effort: *//' | tr -d ' ' || true) -STATE_CODEX_TIMEOUT=$(echo "$FRONTMATTER" | grep '^codex_timeout:' | sed 's/codex_timeout: *//' | tr -d ' ' || true) - -# Apply defaults -CURRENT_ROUND="${CURRENT_ROUND:-0}" -MAX_ITERATIONS="${MAX_ITERATIONS:-10}" -PUSH_EVERY_ROUND="${PUSH_EVERY_ROUND:-false}" -CODEX_MODEL="${CODEX_MODEL:-$DEFAULT_CODEX_MODEL}" -CODEX_EFFORT="${CODEX_EFFORT:-$DEFAULT_CODEX_EFFORT}" +# Use shared parsing function from loop-common.sh +parse_state_file "$STATE_FILE" + +# Map STATE_* variables to local names for backward compatibility +PLAN_TRACKED="$STATE_PLAN_TRACKED" +START_BRANCH="$STATE_START_BRANCH" +PLAN_FILE="$STATE_PLAN_FILE" +CURRENT_ROUND="$STATE_CURRENT_ROUND" +MAX_ITERATIONS="$STATE_MAX_ITERATIONS" +PUSH_EVERY_ROUND="$STATE_PUSH_EVERY_ROUND" +CODEX_MODEL="${STATE_CODEX_MODEL:-$DEFAULT_CODEX_MODEL}" +CODEX_EFFORT="${STATE_CODEX_EFFORT:-$DEFAULT_CODEX_EFFORT}" CODEX_TIMEOUT="${STATE_CODEX_TIMEOUT:-${CODEX_TIMEOUT:-$DEFAULT_CODEX_TIMEOUT}}" +# Re-validate Codex Model and Effort for YAML safety (in case state.md was manually edited) +# Use same validation patterns as setup-rlcr-loop.sh +if [[ ! "$CODEX_MODEL" =~ ^[a-zA-Z0-9._-]+$ ]]; then + echo "Error: Invalid codex_model in state file: $CODEX_MODEL" >&2 + end_loop "$LOOP_DIR" "$STATE_FILE" "$EXIT_UNEXPECTED" + exit 0 +fi +if [[ ! "$CODEX_EFFORT" =~ ^[a-zA-Z0-9_-]+$ ]]; then + echo "Error: Invalid codex_effort in state file: $CODEX_EFFORT" >&2 + end_loop "$LOOP_DIR" "$STATE_FILE" "$EXIT_UNEXPECTED" + exit 0 +fi + # Validate numeric fields early if [[ ! "$CURRENT_ROUND" =~ ^[0-9]+$ ]]; then echo "Warning: State file corrupted (current_round), stopping loop" >&2 - end_loop "$LOOP_DIR" "$STATE_FILE" "unexpected" + end_loop "$LOOP_DIR" "$STATE_FILE" "$EXIT_UNEXPECTED" exit 0 fi @@ -127,7 +134,22 @@ fi # Quick-check 0.5: Branch Consistency # ======================================== -CURRENT_BRANCH=$(git -C "$PROJECT_ROOT" rev-parse --abbrev-ref HEAD 2>/dev/null || echo "") +# Use || GIT_EXIT_CODE=$? to prevent set -e from aborting on non-zero exit +CURRENT_BRANCH=$(run_with_timeout "$GIT_TIMEOUT" git -C "$PROJECT_ROOT" rev-parse --abbrev-ref HEAD 2>/dev/null) || GIT_EXIT_CODE=$? +GIT_EXIT_CODE=${GIT_EXIT_CODE:-0} +if [[ $GIT_EXIT_CODE -ne 0 || -z "$CURRENT_BRANCH" ]]; then + REASON="Git operation failed or timed out. + +Cannot verify branch consistency. This may indicate: +- Git is not responding +- Repository is in an invalid state +- Network issues (if remote operations are involved) + +Please check git status manually and try again." + jq -n --arg reason "$REASON" --arg msg "Loop: Blocked - git operation failed" \ + '{"decision": "block", "reason": $reason, "systemMessage": $msg}' + exit 0 +fi if [[ -n "$START_BRANCH" && "$CURRENT_BRANCH" != "$START_BRANCH" ]]; then REASON="Git branch changed during RLCR loop. @@ -179,7 +201,7 @@ fi # For gitignored files: check content diff only if [[ "$PLAN_TRACKED" == "true" ]]; then # Tracked file: first check git status for uncommitted changes - PLAN_GIT_STATUS=$(git -C "$PROJECT_ROOT" status --porcelain "$PLAN_FILE" 2>/dev/null || echo "") + PLAN_GIT_STATUS=$(run_with_timeout "$GIT_TIMEOUT" git -C "$PROJECT_ROOT" status --porcelain "$PLAN_FILE" 2>/dev/null || echo "") if [[ -n "$PLAN_GIT_STATUS" ]]; then REASON="Plan file has uncommitted modifications. @@ -228,6 +250,25 @@ if [[ -f "$TODO_CHECKER" ]]; then TODO_RESULT=$(echo "$HOOK_INPUT" | python3 "$TODO_CHECKER" 2>&1) || TODO_EXIT=$? TODO_EXIT=${TODO_EXIT:-0} + if [[ "$TODO_EXIT" -eq 2 ]]; then + # Parse error - block and surface the error + REASON="Todo checker encountered a parse error. + +Error: $TODO_RESULT + +This may indicate an issue with the hook input or transcript format. +Please try again or cancel the loop if this persists." + jq -n \ + --arg reason "$REASON" \ + --arg msg "Loop: Blocked - todo checker parse error" \ + '{ + "decision": "block", + "reason": $reason, + "systemMessage": $msg + }' + exit 0 + fi + if [[ "$TODO_EXIT" -eq 1 ]]; then # Incomplete todos found - block immediately without Codex review # Extract the incomplete todo list from the result @@ -253,6 +294,37 @@ Complete these tasks before exiting: fi fi +# ======================================== +# Cache Git Status Output +# ======================================== +# Cache git status output to avoid calling it multiple times. +# Used by both large file check and git clean check below. +# IMPORTANT: Fail-closed on git failures to prevent bypassing checks. + +GIT_STATUS_CACHED="" +GIT_IS_REPO=false + +if command -v git &>/dev/null && run_with_timeout "$GIT_TIMEOUT" git rev-parse --git-dir &>/dev/null 2>&1; then + GIT_IS_REPO=true + # Capture exit code to detect timeout/failure - do NOT use || echo "" which would fail-open + GIT_STATUS_EXIT=0 + GIT_STATUS_CACHED=$(run_with_timeout "$GIT_TIMEOUT" git status --porcelain 2>/dev/null) || GIT_STATUS_EXIT=$? + + if [[ $GIT_STATUS_EXIT -ne 0 ]]; then + # Git status failed or timed out - fail-closed by blocking exit + FALLBACK="# Git Status Failed + +Git status operation failed or timed out (exit code {{GIT_STATUS_EXIT}}). + +Cannot verify repository state. Please check git status manually and try again." + REASON=$(load_and_render_safe "$TEMPLATE_DIR" "block/git-status-failed.md" "$FALLBACK" \ + "GIT_STATUS_EXIT=$GIT_STATUS_EXIT") + jq -n --arg reason "$REASON" --arg msg "Loop: Blocked - git status failed (exit $GIT_STATUS_EXIT)" \ + '{"decision": "block", "reason": $reason, "systemMessage": $msg}' + exit 0 + fi +fi + # ======================================== # Quick Check: Large File Detection # ======================================== @@ -261,7 +333,7 @@ fi MAX_LINES=2000 -if command -v git &>/dev/null && git rev-parse --git-dir &>/dev/null 2>&1; then +if [[ "$GIT_IS_REPO" == "true" ]]; then LARGE_FILES="" while IFS= read -r line; do @@ -303,13 +375,14 @@ if command -v git &>/dev/null && git rev-parse --git-dir &>/dev/null 2>&1; then # Count lines and trim whitespace (portable across shells) line_count=$(wc -l < "$filename" 2>/dev/null | tr -d ' ') || continue + # Validate line_count is numeric before comparison + [[ "$line_count" =~ ^[0-9]+$ ]] || continue + if [ "$line_count" -gt "$MAX_LINES" ]; then LARGE_FILES="${LARGE_FILES} - \`${filename}\`: ${line_count} lines (${file_type} file)" fi - done </dev/null) -EOF + done <<< "$GIT_STATUS_CACHED" if [ -n "$LARGE_FILES" ]; then FALLBACK="# Large Files Detected @@ -341,18 +414,17 @@ fi # Before running expensive Codex review, check if all changes have been # committed and pushed. This ensures work is properly saved. -# Check if git is available and we're in a git repo -if command -v git &>/dev/null && git rev-parse --git-dir &>/dev/null 2>&1; then +# Use cached git status from above +if [[ "$GIT_IS_REPO" == "true" ]]; then GIT_ISSUES="" SPECIAL_NOTES="" - # Check for uncommitted changes (staged or unstaged) - GIT_STATUS=$(git status --porcelain 2>/dev/null) - if [[ -n "$GIT_STATUS" ]]; then + # Check for uncommitted changes (staged or unstaged) using cached status + if [[ -n "$GIT_STATUS_CACHED" ]]; then GIT_ISSUES="uncommitted changes" # Check for special cases in untracked files - UNTRACKED=$(echo "$GIT_STATUS" | grep '^??' || true) + UNTRACKED=$(echo "$GIT_STATUS_CACHED" | grep '^??' || true) # Check if .humanize* directories are untracked (includes .humanize/ and any legacy .humanize-* dirs) if echo "$UNTRACKED" | grep -q '\.humanize'; then @@ -404,10 +476,10 @@ Please commit all changes before exiting. if [[ "$PUSH_EVERY_ROUND" == "true" ]]; then # Check if local branch is ahead of remote (unpushed commits) - GIT_AHEAD=$(git status -sb 2>/dev/null | grep -o 'ahead [0-9]*' || true) + GIT_AHEAD=$(run_with_timeout "$GIT_TIMEOUT" git status -sb 2>/dev/null | grep -o 'ahead [0-9]*' || true) if [[ -n "$GIT_AHEAD" ]]; then AHEAD_COUNT=$(echo "$GIT_AHEAD" | grep -o '[0-9]*') - CURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo "unknown") + CURRENT_BRANCH=$(run_with_timeout "$GIT_TIMEOUT" git rev-parse --abbrev-ref HEAD 2>/dev/null || echo "unknown") FALLBACK="# Unpushed Commits @@ -466,21 +538,34 @@ GOAL_TRACKER_FILE="$LOOP_DIR/goal-tracker.md" if [[ "$CURRENT_ROUND" -eq 0 ]] && [[ -f "$GOAL_TRACKER_FILE" ]]; then # Check if goal-tracker.md still contains placeholder text - GOAL_TRACKER_CONTENT=$(cat "$GOAL_TRACKER_FILE") + # Extract each section and check for generic placeholder pattern within that section + # This avoids coupling to specific placeholder wording and prevents false positives + # from stray mentions of placeholder text elsewhere in the file HAS_GOAL_PLACEHOLDER=false HAS_AC_PLACEHOLDER=false HAS_TASKS_PLACEHOLDER=false - if echo "$GOAL_TRACKER_CONTENT" | grep -q '\[To be extracted from plan'; then + # Extract Ultimate Goal section (### Ultimate Goal to next heading) + # Use awk to extract lines between start and end patterns, excluding end pattern + GOAL_SECTION=$(awk '/^### Ultimate Goal/{found=1; next} /^##/{found=0} found' "$GOAL_TRACKER_FILE" 2>/dev/null) + # Check for generic placeholder pattern "[To be " within this section + if echo "$GOAL_SECTION" | grep -qE '\[To be [a-z]'; then HAS_GOAL_PLACEHOLDER=true fi - if echo "$GOAL_TRACKER_CONTENT" | grep -q '\[To be defined by Claude'; then + # Extract Acceptance Criteria section (### Acceptance Criteria to next heading) + AC_SECTION=$(awk '/^### Acceptance Criteria/{found=1; next} /^##/{found=0} found' "$GOAL_TRACKER_FILE" 2>/dev/null) + # Check for generic placeholder pattern "[To be " within this section + if echo "$AC_SECTION" | grep -qE '\[To be [a-z]'; then HAS_AC_PLACEHOLDER=true fi - if echo "$GOAL_TRACKER_CONTENT" | grep -q '\[To be populated by Claude'; then + # Extract Active Tasks section (#### Active Tasks to next heading or EOF) + # Active Tasks is a level-4 heading, so match any ## or higher + TASKS_SECTION=$(awk '/^#### Active Tasks/{found=1; next} /^##/{found=0} found' "$GOAL_TRACKER_FILE" 2>/dev/null) + # Check for generic placeholder pattern "[To be " within this section + if echo "$TASKS_SECTION" | grep -qE '\[To be [a-z]'; then HAS_TASKS_PLACEHOLDER=true fi @@ -528,7 +613,7 @@ NEXT_ROUND=$((CURRENT_ROUND + 1)) if [[ $NEXT_ROUND -gt $MAX_ITERATIONS ]]; then echo "RLCR loop did not complete, but reached max iterations ($MAX_ITERATIONS). Exiting." >&2 - end_loop "$LOOP_DIR" "$STATE_FILE" "maxiter" + end_loop "$LOOP_DIR" "$STATE_FILE" "$EXIT_MAXITER" exit 0 fi @@ -835,18 +920,18 @@ LAST_LINE=$(echo "$REVIEW_CONTENT" | grep -v '^[[:space:]]*$' | tail -1) LAST_LINE_TRIMMED=$(echo "$LAST_LINE" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//') # Handle COMPLETE - loop finished successfully -if [[ "$LAST_LINE_TRIMMED" == "COMPLETE" ]]; then +if [[ "$LAST_LINE_TRIMMED" == "$MARKER_COMPLETE" ]]; then if [[ "$FULL_ALIGNMENT_CHECK" == "true" ]]; then echo "Codex review passed. All goals achieved. Loop complete!" >&2 else echo "Codex review passed. Loop complete!" >&2 fi - end_loop "$LOOP_DIR" "$STATE_FILE" "complete" + end_loop "$LOOP_DIR" "$STATE_FILE" "$EXIT_COMPLETE" exit 0 fi # Handle STOP - circuit breaker triggered -if [[ "$LAST_LINE_TRIMMED" == "STOP" ]]; then +if [[ "$LAST_LINE_TRIMMED" == "$MARKER_STOP" ]]; then echo "" >&2 echo "========================================" >&2 if [[ "$FULL_ALIGNMENT_CHECK" == "true" ]]; then @@ -871,7 +956,7 @@ if [[ "$LAST_LINE_TRIMMED" == "STOP" ]]; then echo " $REVIEW_RESULT_FILE" >&2 fi echo "========================================" >&2 - end_loop "$LOOP_DIR" "$STATE_FILE" "stop" + end_loop "$LOOP_DIR" "$STATE_FILE" "$EXIT_STOP" exit 0 fi diff --git a/hooks/loop-edit-validator.sh b/hooks/loop-edit-validator.sh index f611d6fa..031e090e 100755 --- a/hooks/loop-edit-validator.sh +++ b/hooks/loop-edit-validator.sh @@ -34,8 +34,13 @@ FILE_PATH_LOWER=$(to_lower "$FILE_PATH") # ======================================== if is_round_file_type "$FILE_PATH_LOWER" "todos"; then - todos_blocked_message "Edit" >&2 - exit 2 + PROJECT_ROOT="${CLAUDE_PROJECT_DIR:-$(pwd)}" + LOOP_BASE_DIR="$PROJECT_ROOT/.humanize/rlcr" + LOOP_DIR=$(find_active_loop "$LOOP_BASE_DIR") + if [[ -z "$LOOP_DIR" ]] || ! is_allowlisted_file "$FILE_PATH" "$LOOP_DIR"; then + todos_blocked_message "Edit" >&2 + exit 2 + fi fi if is_round_file_type "$FILE_PATH_LOWER" "prompt"; then @@ -55,15 +60,17 @@ fi # Find Active Loop and Current Round # ======================================== -PROJECT_ROOT="${CLAUDE_PROJECT_DIR:-$(pwd)}" -LOOP_BASE_DIR="$PROJECT_ROOT/.humanize/rlcr" -ACTIVE_LOOP_DIR=$(find_active_loop "$LOOP_BASE_DIR") +PROJECT_ROOT="${PROJECT_ROOT:-${CLAUDE_PROJECT_DIR:-$(pwd)}}" +LOOP_BASE_DIR="${LOOP_BASE_DIR:-$PROJECT_ROOT/.humanize/rlcr}" +ACTIVE_LOOP_DIR="${LOOP_DIR:-$(find_active_loop "$LOOP_BASE_DIR")}" if [[ -z "$ACTIVE_LOOP_DIR" ]]; then exit 0 fi -CURRENT_ROUND=$(get_current_round "$ACTIVE_LOOP_DIR/state.md") +# Parse state file using shared function +parse_state_file "$ACTIVE_LOOP_DIR/state.md" +CURRENT_ROUND="$STATE_CURRENT_ROUND" # ======================================== # Block State File Edits @@ -112,7 +119,7 @@ if is_round_file_type "$FILE_PATH_LOWER" "summary"; then if [[ -n "$CLAUDE_FILENAME" ]]; then CLAUDE_ROUND=$(extract_round_number "$CLAUDE_FILENAME") - if [[ -n "$CLAUDE_ROUND" ]] && [[ "$CLAUDE_ROUND" != "$CURRENT_ROUND" ]]; then + if [[ -n "$CLAUDE_ROUND" ]] && [[ "$CLAUDE_ROUND" != "$CURRENT_ROUND" ]] && ! is_allowlisted_file "$FILE_PATH" "$ACTIVE_LOOP_DIR"; then CORRECT_PATH="$ACTIVE_LOOP_DIR/round-${CURRENT_ROUND}-summary.md" FALLBACK="# Wrong Round Number diff --git a/hooks/loop-plan-file-validator.sh b/hooks/loop-plan-file-validator.sh index e628b3f8..b197305d 100755 --- a/hooks/loop-plan-file-validator.sh +++ b/hooks/loop-plan-file-validator.sh @@ -16,6 +16,13 @@ PROJECT_ROOT="${CLAUDE_PROJECT_DIR:-$(pwd)}" # Source shared loop functions and template loader source "$SCRIPT_DIR/lib/loop-common.sh" +# Source portable timeout wrapper for git operations +PLUGIN_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +source "$PLUGIN_ROOT/scripts/portable-timeout.sh" + +# Default timeout for git operations (30 seconds) +GIT_TIMEOUT=30 + # Read hook input (required for UserPromptSubmit hooks) INPUT=$(cat) @@ -30,14 +37,13 @@ fi STATE_FILE="$LOOP_DIR/state.md" -# Parse state file -# Note: Values are unquoted since v1.1.2+ validates paths don't contain special chars -# Legacy quote-stripping kept for backward compatibility with older state files -FRONTMATTER=$(sed -n '/^---$/,/^---$/{ /^---$/d; p; }' "$STATE_FILE" 2>/dev/null || echo "") +# Parse state file using shared function +parse_state_file "$STATE_FILE" -PLAN_TRACKED=$(echo "$FRONTMATTER" | grep '^plan_tracked:' | sed 's/plan_tracked: *//' | tr -d ' ' || true) -PLAN_FILE=$(echo "$FRONTMATTER" | grep '^plan_file:' | sed 's/plan_file: *//; s/^"//; s/"$//' || true) -START_BRANCH=$(echo "$FRONTMATTER" | grep '^start_branch:' | sed 's/start_branch: *//; s/^"//; s/"$//' || true) +# Map STATE_* variables to local names for backward compatibility +PLAN_TRACKED="$STATE_PLAN_TRACKED" +PLAN_FILE="$STATE_PLAN_FILE" +START_BRANCH="$STATE_START_BRANCH" # ======================================== # Schema Validation (v1.1.2+ required fields) @@ -63,8 +69,8 @@ schema_validation_error() { EOF } -# Check required fields -REQUIRED_FIELDS=("plan_tracked:$PLAN_TRACKED" "start_branch:$START_BRANCH") +# Check required fields (using FIELD_* constants from loop-common.sh) +REQUIRED_FIELDS=("${FIELD_PLAN_TRACKED}:$PLAN_TRACKED" "${FIELD_START_BRANCH}:$START_BRANCH") for field_entry in "${REQUIRED_FIELDS[@]}"; do field_name="${field_entry%%:*}" field_value="${field_entry#*:}" @@ -79,7 +85,18 @@ done # Branch Consistency Check # ======================================== -CURRENT_BRANCH=$(git -C "$PROJECT_ROOT" rev-parse --abbrev-ref HEAD 2>/dev/null || echo "") +# Use || GIT_EXIT_CODE=$? to prevent set -e from aborting on non-zero exit +CURRENT_BRANCH=$(run_with_timeout "$GIT_TIMEOUT" git -C "$PROJECT_ROOT" rev-parse --abbrev-ref HEAD 2>/dev/null) || GIT_EXIT_CODE=$? +GIT_EXIT_CODE=${GIT_EXIT_CODE:-0} +if [[ $GIT_EXIT_CODE -ne 0 || -z "$CURRENT_BRANCH" ]]; then + cat << EOF +{ + "decision": "block", + "reason": "Git operation failed or timed out.\\n\\nCannot verify branch consistency. Please check git status and try again." +} +EOF + exit 0 +fi if [[ -n "$START_BRANCH" && "$CURRENT_BRANCH" != "$START_BRANCH" ]]; then cat << EOF { @@ -98,8 +115,54 @@ FULL_PLAN_PATH="$PROJECT_ROOT/$PLAN_FILE" if [[ "$PLAN_TRACKED" == "true" ]]; then # Must be tracked and clean - PLAN_IS_TRACKED=$(git -C "$PROJECT_ROOT" ls-files --error-unmatch "$PLAN_FILE" &>/dev/null && echo "true" || echo "false") - PLAN_GIT_STATUS=$(git -C "$PROJECT_ROOT" status --porcelain "$PLAN_FILE" 2>/dev/null || echo "") + # Use || LS_FILES_EXIT=$? to prevent set -e from aborting on non-zero exit + # ls-files --error-unmatch returns: 0 (tracked), 1 (not tracked), 124 (timeout), other (error) + run_with_timeout "$GIT_TIMEOUT" git -C "$PROJECT_ROOT" ls-files --error-unmatch "$PLAN_FILE" &>/dev/null || LS_FILES_EXIT=$? + LS_FILES_EXIT=${LS_FILES_EXIT:-0} + if [[ $LS_FILES_EXIT -eq 124 ]]; then + # Timeout - fail closed + cat << EOF +{ + "decision": "block", + "reason": "Git operation timed out while checking plan file tracking status.\\n\\nPlease check git status and try again." +} +EOF + exit 0 + elif [[ $LS_FILES_EXIT -ne 0 && $LS_FILES_EXIT -ne 1 ]]; then + # Unexpected git error - fail closed + cat << EOF +{ + "decision": "block", + "reason": "Git operation failed while checking plan file tracking status (exit code: $LS_FILES_EXIT).\\n\\nPlease check git status and try again." +} +EOF + exit 0 + fi + PLAN_IS_TRACKED=$([[ $LS_FILES_EXIT -eq 0 ]] && echo "true" || echo "false") + + # Use || STATUS_EXIT=$? to prevent set -e from aborting on non-zero exit + # git status --porcelain returns: 0 (success), 124 (timeout), other (error) + PLAN_GIT_STATUS=$(run_with_timeout "$GIT_TIMEOUT" git -C "$PROJECT_ROOT" status --porcelain "$PLAN_FILE" 2>/dev/null) || STATUS_EXIT=$? + STATUS_EXIT=${STATUS_EXIT:-0} + if [[ $STATUS_EXIT -eq 124 ]]; then + # Timeout - fail closed + cat << EOF +{ + "decision": "block", + "reason": "Git operation timed out while checking plan file status.\\n\\nPlease check git status and try again." +} +EOF + exit 0 + elif [[ $STATUS_EXIT -ne 0 ]]; then + # Unexpected git error - fail closed + cat << EOF +{ + "decision": "block", + "reason": "Git operation failed while checking plan file status (exit code: $STATUS_EXIT).\\n\\nPlease check git status and try again." +} +EOF + exit 0 + fi if [[ "$PLAN_IS_TRACKED" != "true" ]]; then cat << EOF @@ -122,7 +185,30 @@ EOF fi else # Must be gitignored (not tracked) - PLAN_IS_TRACKED=$(git -C "$PROJECT_ROOT" ls-files --error-unmatch "$PLAN_FILE" &>/dev/null && echo "true" || echo "false") + # Check if git command succeeds - fail closed on timeout/error + # ls-files --error-unmatch returns: 0 (tracked), 1 (not tracked), 124 (timeout), other (error) + run_with_timeout "$GIT_TIMEOUT" git -C "$PROJECT_ROOT" ls-files --error-unmatch "$PLAN_FILE" &>/dev/null || LS_FILES_EXIT=$? + LS_FILES_EXIT=${LS_FILES_EXIT:-0} + if [[ $LS_FILES_EXIT -eq 124 ]]; then + # Timeout - fail closed + cat << EOF +{ + "decision": "block", + "reason": "Git operation timed out while checking plan file tracking status.\\n\\nPlease check git status and try again." +} +EOF + exit 0 + elif [[ $LS_FILES_EXIT -ne 0 && $LS_FILES_EXIT -ne 1 ]]; then + # Unexpected git error - fail closed + cat << EOF +{ + "decision": "block", + "reason": "Git operation failed while checking plan file tracking status (exit code: $LS_FILES_EXIT).\\n\\nPlease check git status and try again." +} +EOF + exit 0 + fi + PLAN_IS_TRACKED=$([[ $LS_FILES_EXIT -eq 0 ]] && echo "true" || echo "false") if [[ "$PLAN_IS_TRACKED" == "true" ]]; then cat << EOF diff --git a/hooks/loop-read-validator.sh b/hooks/loop-read-validator.sh index 4ddbb885..da22ca31 100755 --- a/hooks/loop-read-validator.sh +++ b/hooks/loop-read-validator.sh @@ -34,8 +34,13 @@ FILE_PATH_LOWER=$(to_lower "$FILE_PATH") # ======================================== if is_round_file_type "$FILE_PATH_LOWER" "todos"; then - todos_blocked_message "Read" >&2 - exit 2 + PROJECT_ROOT="${CLAUDE_PROJECT_DIR:-$(pwd)}" + LOOP_BASE_DIR="$PROJECT_ROOT/.humanize/rlcr" + LOOP_DIR=$(find_active_loop "$LOOP_BASE_DIR") + if [[ -z "$LOOP_DIR" ]] || ! is_allowlisted_file "$FILE_PATH" "$LOOP_DIR"; then + todos_blocked_message "Read" >&2 + exit 2 + fi fi # ======================================== @@ -53,15 +58,17 @@ IN_HUMANIZE_LOOP_DIR=$(is_in_humanize_loop_dir "$FILE_PATH" && echo "true" || ec # Find Active Loop and Current Round # ======================================== -PROJECT_ROOT="${CLAUDE_PROJECT_DIR:-$(pwd)}" -LOOP_BASE_DIR="$PROJECT_ROOT/.humanize/rlcr" -ACTIVE_LOOP_DIR=$(find_active_loop "$LOOP_BASE_DIR") +PROJECT_ROOT="${PROJECT_ROOT:-${CLAUDE_PROJECT_DIR:-$(pwd)}}" +LOOP_BASE_DIR="${LOOP_BASE_DIR:-$PROJECT_ROOT/.humanize/rlcr}" +ACTIVE_LOOP_DIR="${LOOP_DIR:-$(find_active_loop "$LOOP_BASE_DIR")}" if [[ -z "$ACTIVE_LOOP_DIR" ]]; then exit 0 fi -CURRENT_ROUND=$(get_current_round "$ACTIVE_LOOP_DIR/state.md") +# Parse state file using shared function +parse_state_file "$ACTIVE_LOOP_DIR/state.md" +CURRENT_ROUND="$STATE_CURRENT_ROUND" # ======================================== # Extract Round Number and File Type @@ -100,7 +107,7 @@ fi # Validate Round Number # ======================================== -if [[ "$CLAUDE_ROUND" != "$CURRENT_ROUND" ]]; then +if [[ "$CLAUDE_ROUND" != "$CURRENT_ROUND" ]] && ! is_allowlisted_file "$FILE_PATH" "$ACTIVE_LOOP_DIR"; then FALLBACK="# Wrong Round File You tried to read round-{{CLAUDE_ROUND}}-{{FILE_TYPE}}.md but current round is **{{CURRENT_ROUND}}**. diff --git a/hooks/loop-write-validator.sh b/hooks/loop-write-validator.sh index 719f2464..7a802481 100755 --- a/hooks/loop-write-validator.sh +++ b/hooks/loop-write-validator.sh @@ -35,8 +35,13 @@ FILE_PATH_LOWER=$(to_lower "$FILE_PATH") # ======================================== if is_round_file_type "$FILE_PATH_LOWER" "todos"; then - todos_blocked_message "Write" >&2 - exit 2 + PROJECT_ROOT="${CLAUDE_PROJECT_DIR:-$(pwd)}" + LOOP_BASE_DIR="$PROJECT_ROOT/.humanize/rlcr" + LOOP_DIR=$(find_active_loop "$LOOP_BASE_DIR") + if [[ -z "$LOOP_DIR" ]] || ! is_allowlisted_file "$FILE_PATH" "$LOOP_DIR"; then + todos_blocked_message "Write" >&2 + exit 2 + fi fi if is_round_file_type "$FILE_PATH_LOWER" "prompt"; then @@ -70,15 +75,18 @@ fi # Find Active Loop and Current Round # ======================================== -PROJECT_ROOT="${CLAUDE_PROJECT_DIR:-$(pwd)}" -LOOP_BASE_DIR="$PROJECT_ROOT/.humanize/rlcr" -ACTIVE_LOOP_DIR=$(find_active_loop "$LOOP_BASE_DIR") +# Re-initialize if not set by earlier todos check +PROJECT_ROOT="${PROJECT_ROOT:-${CLAUDE_PROJECT_DIR:-$(pwd)}}" +LOOP_BASE_DIR="${LOOP_BASE_DIR:-$PROJECT_ROOT/.humanize/rlcr}" +ACTIVE_LOOP_DIR="${LOOP_DIR:-$(find_active_loop "$LOOP_BASE_DIR")}" if [[ -z "$ACTIVE_LOOP_DIR" ]]; then exit 0 fi -CURRENT_ROUND=$(get_current_round "$ACTIVE_LOOP_DIR/state.md") +# Parse state file using shared function +parse_state_file "$ACTIVE_LOOP_DIR/state.md" +CURRENT_ROUND="$STATE_CURRENT_ROUND" # ======================================== # Block State File Writes @@ -145,7 +153,7 @@ fi if [[ "$IS_SUMMARY_FILE" == "true" ]]; then CLAUDE_ROUND=$(extract_round_number "$CLAUDE_FILENAME") - if [[ -n "$CLAUDE_ROUND" ]] && [[ "$CLAUDE_ROUND" != "$CURRENT_ROUND" ]]; then + if [[ -n "$CLAUDE_ROUND" ]] && [[ "$CLAUDE_ROUND" != "$CURRENT_ROUND" ]] && ! is_allowlisted_file "$FILE_PATH" "$ACTIVE_LOOP_DIR"; then CORRECT_PATH="$ACTIVE_LOOP_DIR/round-${CURRENT_ROUND}-summary.md" FALLBACK="# Wrong Round Number diff --git a/prompt-template/block/git-status-failed.md b/prompt-template/block/git-status-failed.md new file mode 100644 index 00000000..a097a7a9 --- /dev/null +++ b/prompt-template/block/git-status-failed.md @@ -0,0 +1,10 @@ +# Git Status Failed + +Git status operation failed or timed out (exit code {{GIT_STATUS_EXIT}}). + +Cannot verify repository state. This may indicate: +- Git is not responding (possible lock contention) +- Repository is in an invalid state +- Large repository causing slow operations + +Please check git status manually and try again. diff --git a/scripts/setup-rlcr-loop.sh b/scripts/setup-rlcr-loop.sh index c1755a60..0299aac2 100755 --- a/scripts/setup-rlcr-loop.sh +++ b/scripts/setup-rlcr-loop.sh @@ -20,6 +20,13 @@ DEFAULT_CODEX_EFFORT="high" DEFAULT_CODEX_TIMEOUT=5400 DEFAULT_MAX_ITERATIONS=42 +# Default timeout for git operations (30 seconds) +GIT_TIMEOUT=30 + +# Source portable timeout wrapper +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd)" +source "$SCRIPT_DIR/portable-timeout.sh" + # ======================================== # Parse Arguments # ======================================== @@ -201,15 +208,15 @@ fi # Git Repository Validation # ======================================== -# Check git repo -if ! git rev-parse --git-dir &>/dev/null; then - echo "Error: Project must be a git repository" >&2 +# Check git repo (with timeout) +if ! run_with_timeout "$GIT_TIMEOUT" git rev-parse --git-dir &>/dev/null; then + echo "Error: Project must be a git repository (or git command timed out)" >&2 exit 1 fi -# Check at least one commit -if ! git rev-parse HEAD &>/dev/null 2>&1; then - echo "Error: Git repository must have at least one commit" >&2 +# Check at least one commit (with timeout) +if ! run_with_timeout "$GIT_TIMEOUT" git rev-parse HEAD &>/dev/null 2>&1; then + echo "Error: Git repository must have at least one commit (or git command timed out)" >&2 exit 1 fi @@ -262,6 +269,12 @@ if [[ ! -f "$FULL_PLAN_PATH" ]]; then exit 1 fi +# Check file is readable +if [[ ! -r "$FULL_PLAN_PATH" ]]; then + echo "Error: Plan file not readable: $PLAN_FILE" >&2 + exit 1 +fi + # Check file is within project (no ../ escaping) # Resolve the real path by cd'ing to the directory and getting pwd # This handles symlinks in parent directories and ../ path components @@ -279,9 +292,9 @@ fi # Check not in submodule # Quick check: only run expensive git submodule status if .gitmodules exists if [[ -f "$PROJECT_ROOT/.gitmodules" ]]; then - if git -C "$PROJECT_ROOT" submodule status 2>/dev/null | grep -q .; then + if run_with_timeout "$GIT_TIMEOUT" git -C "$PROJECT_ROOT" submodule status 2>/dev/null | grep -q .; then # Get list of submodule paths - SUBMODULES=$(git -C "$PROJECT_ROOT" submodule status | awk '{print $2}') + SUBMODULES=$(run_with_timeout "$GIT_TIMEOUT" git -C "$PROJECT_ROOT" submodule status | awk '{print $2}') for submod in $SUBMODULES; do if [[ "$PLAN_FILE" = "$submod"/* || "$PLAN_FILE" = "$submod" ]]; then echo "Error: Plan file cannot be inside a git submodule: $submod" >&2 @@ -295,8 +308,25 @@ fi # Plan File Tracking Status Validation # ======================================== -PLAN_GIT_STATUS=$(git -C "$PROJECT_ROOT" status --porcelain "$PLAN_FILE" 2>/dev/null || echo "") -PLAN_IS_TRACKED=$(git -C "$PROJECT_ROOT" ls-files --error-unmatch "$PLAN_FILE" &>/dev/null && echo "true" || echo "false") +# Check git status - fail closed on timeout +# Use || true to capture exit code without triggering set -e +PLAN_GIT_STATUS=$(run_with_timeout "$GIT_TIMEOUT" git -C "$PROJECT_ROOT" status --porcelain "$PLAN_FILE" 2>/dev/null) || STATUS_EXIT=$? +STATUS_EXIT=${STATUS_EXIT:-0} +if [[ $STATUS_EXIT -eq 124 ]]; then + echo "Error: Git operation timed out while checking plan file status" >&2 + exit 1 +fi + +# Check if tracked - fail closed on timeout +# ls-files --error-unmatch returns 1 for untracked files (expected behavior) +# We need to distinguish between: 0 (tracked), 1 (not tracked), 124 (timeout) +run_with_timeout "$GIT_TIMEOUT" git -C "$PROJECT_ROOT" ls-files --error-unmatch "$PLAN_FILE" &>/dev/null || LS_FILES_EXIT=$? +LS_FILES_EXIT=${LS_FILES_EXIT:-0} +if [[ $LS_FILES_EXIT -eq 124 ]]; then + echo "Error: Git operation timed out while checking plan file tracking status" >&2 + exit 1 +fi +PLAN_IS_TRACKED=$([[ $LS_FILES_EXIT -eq 0 ]] && echo "true" || echo "false") if [[ "$TRACK_PLAN_FILE" == "true" ]]; then # Must be tracked and clean @@ -339,6 +369,51 @@ if [[ "$LINE_COUNT" -lt 5 ]]; then exit 1 fi +# Check plan has actual content (not just whitespace/blank lines/comments) +# Exclude: blank lines, shell/YAML comments (# ...), and HTML comments () +# Note: Lines starting with # are treated as comments, not markdown headings +# A "content line" is any line that is not blank and not purely a comment +# For multi-line HTML comments, we count lines inside them as non-content +CONTENT_LINES=0 +IN_COMMENT=false +while IFS= read -r line || [[ -n "$line" ]]; do + # If inside multi-line comment, check for end marker + if [[ "$IN_COMMENT" == "true" ]]; then + if [[ "$line" =~ --\>[[:space:]]*$ ]]; then + IN_COMMENT=false + fi + continue + fi + # Skip blank lines + if [[ "$line" =~ ^[[:space:]]*$ ]]; then + continue + fi + # Skip single-line HTML comments (must check BEFORE multi-line start) + # Single-line: on same line + if [[ "$line" =~ ^[[:space:]]*\ on same line) + # Only trigger if the line contains + if [[ "$line" =~ ^[[:space:]]*\ + + + + + +EOF +set +e +RESULT=$("$PROJECT_ROOT/scripts/setup-rlcr-loop.sh" "plans/comment-plan.md" 2>&1) +EXIT_CODE=$? +set -e +if [[ $EXIT_CODE -ne 0 ]] && echo "$RESULT" | grep -q "insufficient content"; then + pass "Plan with only HTML comments rejected" +else + fail "HTML-comment-only plan rejection" "exit 1 with insufficient content error" "$RESULT" +fi + +# Test 9.9.2: Reject plan file with only shell/markdown comments (# lines) +echo "Test 9.9.2: Reject plan with only # comments" +cat > plans/hash-comment-plan.md << 'EOF' +# This is a comment line 1 +# This is a comment line 2 +# This is a comment line 3 +# This is a comment line 4 +# This is a comment line 5 +# This is a comment line 6 +EOF +set +e +RESULT=$("$PROJECT_ROOT/scripts/setup-rlcr-loop.sh" "plans/hash-comment-plan.md" 2>&1) +EXIT_CODE=$? +set -e +if [[ $EXIT_CODE -ne 0 ]] && echo "$RESULT" | grep -q "insufficient content"; then + pass "Plan with only # comments rejected" +else + fail "#-comment-only plan rejection" "exit 1 with insufficient content error" "$RESULT" +fi + +# Test 9.10: Accept plan with enough non-blank content +# Note: Lines starting with # are treated as comments, so we use plain text +echo "Test 9.10: Accept plan with sufficient non-blank content" +cat > plans/good-plan.md << 'EOF' +Good Plan + +Goal +This is a valid plan file with enough content. + +Requirements +- Requirement 1 +- Requirement 2 + +Implementation +Details here. +EOF +set +e +RESULT=$("$PROJECT_ROOT/scripts/setup-rlcr-loop.sh" "plans/good-plan.md" 2>&1) +EXIT_CODE=$? +set -e +# Should not fail due to content validation (may fail later for other reasons like codex) +if ! echo "$RESULT" | grep -q "insufficient content"; then + pass "Valid plan with sufficient content accepted" +else + fail "Valid plan acceptance" "no insufficient content error" "$RESULT" +fi + +# Test 9.10.1: Accept plan with single-line HTML comments and valid content +# Regression test: single-line HTML comments should NOT trigger multi-line comment mode +echo "Test 9.10.1: Accept plan with single-line HTML comments + valid content" +cat > plans/single-line-html-comment-plan.md << 'EOF' + +This plan has real content + +Goal +The goal is to test single-line comment handling. + +Requirements +- Requirement 1 +- Requirement 2 +- Requirement 3 +EOF +set +e +RESULT=$("$PROJECT_ROOT/scripts/setup-rlcr-loop.sh" "plans/single-line-html-comment-plan.md" 2>&1) +EXIT_CODE=$? +set -e +# Should not fail due to content validation - single-line comments should be skipped properly +if ! echo "$RESULT" | grep -q "insufficient content"; then + pass "Plan with single-line HTML comments + valid content accepted" +else + fail "Single-line HTML comment handling" "no insufficient content error" "$RESULT" +fi + echo "" echo "=== Test: CLI Options ===" echo "" diff --git a/tests/test-state-exit-naming.sh b/tests/test-state-exit-naming.sh index 0320efad..4ff3c47a 100755 --- a/tests/test-state-exit-naming.sh +++ b/tests/test-state-exit-naming.sh @@ -43,7 +43,7 @@ git config user.email "test@test.com" git config user.name "Test" echo "init" > init.txt git add init.txt -git commit -q -m "Initial" +git -c commit.gpgsign=false commit -q -m "Initial" LOOP_DIR="$TEST_DIR/.humanize/rlcr/2024-01-01_12-00-00" mkdir -p "$LOOP_DIR" diff --git a/tests/test-template-loader.sh b/tests/test-template-loader.sh index 85dcc414..74bbd784 100755 --- a/tests/test-template-loader.sh +++ b/tests/test-template-loader.sh @@ -542,6 +542,102 @@ else fail "Realistic injection scenario" "$EXPECTED" "$RESULT" fi +# ======================================== +# Test 37-41: Additional Edge Cases +# ======================================== +# These tests cover additional edge cases for template rendering. + +echo "" +echo "========================================" +echo "Additional Edge Case Tests" +echo "========================================" + +# Test 37: Empty variable substitution +echo "" +echo "Test 37: Empty variable substitution" +TEMPLATE="Hello {{NAME}}, status: {{STATUS}}" +RESULT=$(render_template "$TEMPLATE" "NAME=" "STATUS=active") +EXPECTED="Hello , status: active" +if [[ "$RESULT" == "$EXPECTED" ]]; then + pass "Empty variable substitution works" +else + fail "Empty variable substitution" "$EXPECTED" "$RESULT" +fi + +# Test 38: Unicode characters in template +echo "" +echo "Test 38: Unicode characters in template" +TEMPLATE="Greeting: {{GREETING}}" +RESULT=$(render_template "$TEMPLATE" "GREETING=Hello World") +EXPECTED="Greeting: Hello World" +if [[ "$RESULT" == "$EXPECTED" ]]; then + pass "Unicode in template renders correctly" +else + fail "Unicode in template" "$EXPECTED" "$RESULT" +fi + +# Test 38.1: Actual non-ASCII Unicode in template (e acute) +echo "" +echo "Test 38.1: Non-ASCII Unicode in template" +# Using French accented word "caf\xc3\xa9" (cafe with acute e) +TEMPLATE=$'Caf\xc3\xa9: {{ITEM}}' +RESULT=$(render_template "$TEMPLATE" "ITEM=espresso") +EXPECTED=$'Caf\xc3\xa9: espresso' +if [[ "$RESULT" == "$EXPECTED" ]]; then + pass "Non-ASCII Unicode in template renders correctly" +else + fail "Non-ASCII Unicode in template" "$EXPECTED" "$RESULT" +fi + +# Test 39: Unicode characters in value +echo "" +echo "Test 39: Unicode characters in value" +TEMPLATE="Message: {{MSG}}" +RESULT=$(render_template "$TEMPLATE" "MSG=Bonjour mon ami") +EXPECTED="Message: Bonjour mon ami" +if [[ "$RESULT" == "$EXPECTED" ]]; then + pass "Unicode in value renders correctly" +else + fail "Unicode in value" "$EXPECTED" "$RESULT" +fi + +# Test 39.1: Actual non-ASCII Unicode in value (accented characters) +echo "" +echo "Test 39.1: Non-ASCII Unicode in value" +TEMPLATE="Location: {{PLACE}}" +# Using "Caf\xc3\xa9" (cafe with acute e) and "\xc3\xa0" (a with grave) +RESULT=$(render_template "$TEMPLATE" $'PLACE=Caf\xc3\xa9 \xc3\xa0 Paris') +EXPECTED=$'Location: Caf\xc3\xa9 \xc3\xa0 Paris' +if [[ "$RESULT" == "$EXPECTED" ]]; then + pass "Non-ASCII Unicode in value renders correctly" +else + fail "Non-ASCII Unicode in value" "$EXPECTED" "$RESULT" +fi + +# Test 40: Variable name edge cases - underscore prefix +echo "" +echo "Test 40: Variable with underscore prefix" +TEMPLATE="Value: {{_PRIVATE}}" +RESULT=$(render_template "$TEMPLATE" "_PRIVATE=secret") +EXPECTED="Value: secret" +if [[ "$RESULT" == "$EXPECTED" ]]; then + pass "Underscore-prefixed variable works" +else + fail "Underscore-prefixed variable" "$EXPECTED" "$RESULT" +fi + +# Test 41: Variable name with numbers +echo "" +echo "Test 41: Variable name with numbers" +TEMPLATE="Round: {{ROUND_1}} and {{ROUND_2}}" +RESULT=$(render_template "$TEMPLATE" "ROUND_1=first" "ROUND_2=second") +EXPECTED="Round: first and second" +if [[ "$RESULT" == "$EXPECTED" ]]; then + pass "Variable names with numbers work" +else + fail "Variable names with numbers" "$EXPECTED" "$RESULT" +fi + # ======================================== # Summary # ======================================== diff --git a/tests/test-todo-checker.sh b/tests/test-todo-checker.sh new file mode 100755 index 00000000..a4286020 --- /dev/null +++ b/tests/test-todo-checker.sh @@ -0,0 +1,313 @@ +#!/bin/bash +# +# Test script for check-todos-from-transcript.py +# +# Tests the Python todo checker for proper error handling +# and correct interpretation of todo states. +# + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +TODO_CHECKER="$PROJECT_ROOT/hooks/check-todos-from-transcript.py" + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +NC='\033[0m' # No Color + +TESTS_PASSED=0 +TESTS_FAILED=0 + +# Test helper functions +pass() { + echo -e "${GREEN}PASS${NC}: $1" + TESTS_PASSED=$((TESTS_PASSED + 1)) +} + +fail() { + echo -e "${RED}FAIL${NC}: $1" + echo " Expected: $2" + echo " Got: $3" + TESTS_FAILED=$((TESTS_FAILED + 1)) +} + +# Setup test environment +TEST_DIR=$(mktemp -d) +trap "rm -rf $TEST_DIR" EXIT + +echo "========================================" +echo "Testing check-todos-from-transcript.py" +echo "========================================" +echo "" + +# ======================================== +# Test Group 1: Input Handling +# ======================================== +echo "Test Group 1: Input Handling" +echo "" + +# Test 1: Invalid JSON input should exit 2 (parse error) +echo "Test 1: Invalid JSON input" +set +e +RESULT=$(echo "not json at all" | python3 "$TODO_CHECKER" 2>&1) +EXIT_CODE=$? +set -e +if [[ $EXIT_CODE -eq 2 ]]; then + pass "Invalid JSON returns exit code 2" +else + fail "Invalid JSON handling" "exit 2" "exit $EXIT_CODE" +fi + +# Test 2: Empty input should exit 2 (parse error) +echo "Test 2: Empty input" +set +e +RESULT=$(echo "" | python3 "$TODO_CHECKER" 2>&1) +EXIT_CODE=$? +set -e +if [[ $EXIT_CODE -eq 2 ]]; then + pass "Empty input returns exit code 2" +else + fail "Empty input handling" "exit 2" "exit $EXIT_CODE" +fi + +# Test 3: Valid JSON without transcript_path should exit 0 +echo "Test 3: JSON without transcript_path" +set +e +RESULT=$(echo '{"other": "data"}' | python3 "$TODO_CHECKER" 2>&1) +EXIT_CODE=$? +set -e +if [[ $EXIT_CODE -eq 0 ]]; then + pass "JSON without transcript_path exits 0" +else + fail "Missing transcript_path" "exit 0" "exit $EXIT_CODE" +fi + +# Test 4: Non-existent transcript file should exit 0 +echo "Test 4: Non-existent transcript file" +set +e +RESULT=$(echo '{"transcript_path": "/nonexistent/path/transcript.jsonl"}' | python3 "$TODO_CHECKER" 2>&1) +EXIT_CODE=$? +set -e +if [[ $EXIT_CODE -eq 0 ]]; then + pass "Non-existent file exits 0" +else + fail "Non-existent file handling" "exit 0" "exit $EXIT_CODE" +fi + +# ======================================== +# Test Group 2: Todo Detection +# ======================================== +echo "" +echo "Test Group 2: Todo Detection" +echo "" + +# Test 5: Transcript with all completed todos +echo "Test 5: All todos completed" +cat > "$TEST_DIR/transcript-all-complete.jsonl" << 'EOF' +{"type": "assistant", "message": {"content": [{"type": "tool_use", "name": "TodoWrite", "input": {"todos": [{"content": "Task 1", "status": "completed"}, {"content": "Task 2", "status": "completed"}]}}]}} +EOF +set +e +RESULT=$(echo "{\"transcript_path\": \"$TEST_DIR/transcript-all-complete.jsonl\"}" | python3 "$TODO_CHECKER" 2>&1) +EXIT_CODE=$? +set -e +if [[ $EXIT_CODE -eq 0 ]]; then + pass "All todos completed exits 0" +else + fail "All todos completed" "exit 0" "exit $EXIT_CODE, output: $RESULT" +fi + +# Test 6: Transcript with incomplete todos +echo "Test 6: Incomplete todos" +cat > "$TEST_DIR/transcript-incomplete.jsonl" << 'EOF' +{"type": "assistant", "message": {"content": [{"type": "tool_use", "name": "TodoWrite", "input": {"todos": [{"content": "Task 1", "status": "completed"}, {"content": "Task 2", "status": "pending"}]}}]}} +EOF +set +e +RESULT=$(echo "{\"transcript_path\": \"$TEST_DIR/transcript-incomplete.jsonl\"}" | python3 "$TODO_CHECKER" 2>&1) +EXIT_CODE=$? +set -e +if [[ $EXIT_CODE -eq 1 ]]; then + pass "Incomplete todos exits 1" +else + fail "Incomplete todos" "exit 1" "exit $EXIT_CODE" +fi + +# Test 7: Output includes incomplete todo details +echo "Test 7: Output includes todo details" +if echo "$RESULT" | grep -q "Task 2"; then + pass "Output includes incomplete task name" +else + fail "Output includes task name" "Task 2 in output" "$RESULT" +fi + +# Test 8: In-progress status counts as incomplete +echo "Test 8: In-progress status" +cat > "$TEST_DIR/transcript-in-progress.jsonl" << 'EOF' +{"type": "assistant", "message": {"content": [{"type": "tool_use", "name": "TodoWrite", "input": {"todos": [{"content": "Task 1", "status": "in_progress"}]}}]}} +EOF +set +e +RESULT=$(echo "{\"transcript_path\": \"$TEST_DIR/transcript-in-progress.jsonl\"}" | python3 "$TODO_CHECKER" 2>&1) +EXIT_CODE=$? +set -e +if [[ $EXIT_CODE -eq 1 ]]; then + pass "In-progress status exits 1" +else + fail "In-progress status" "exit 1" "exit $EXIT_CODE" +fi + +# ======================================== +# Test Group 3: Transcript Format Variations +# ======================================== +echo "" +echo "Test Group 3: Transcript Format Variations" +echo "" + +# Test 9: Empty transcript file +echo "Test 9: Empty transcript file" +touch "$TEST_DIR/transcript-empty.jsonl" +set +e +RESULT=$(echo "{\"transcript_path\": \"$TEST_DIR/transcript-empty.jsonl\"}" | python3 "$TODO_CHECKER" 2>&1) +EXIT_CODE=$? +set -e +if [[ $EXIT_CODE -eq 0 ]]; then + pass "Empty transcript exits 0" +else + fail "Empty transcript" "exit 0" "exit $EXIT_CODE" +fi + +# Test 10: Transcript with invalid JSONL lines +echo "Test 10: Invalid JSONL lines ignored" +cat > "$TEST_DIR/transcript-invalid-lines.jsonl" << 'EOF' +not json +{"type": "assistant", "message": {"content": [{"type": "tool_use", "name": "TodoWrite", "input": {"todos": [{"content": "Task", "status": "completed"}]}}]}} +also not json +EOF +set +e +RESULT=$(echo "{\"transcript_path\": \"$TEST_DIR/transcript-invalid-lines.jsonl\"}" | python3 "$TODO_CHECKER" 2>&1) +EXIT_CODE=$? +set -e +if [[ $EXIT_CODE -eq 0 ]]; then + pass "Invalid JSONL lines ignored, valid todo found" +else + fail "Invalid JSONL handling" "exit 0 (valid todo found)" "exit $EXIT_CODE" +fi + +# Test 11: Multiple TodoWrite calls - uses latest +echo "Test 11: Multiple TodoWrite calls uses latest" +cat > "$TEST_DIR/transcript-multiple.jsonl" << 'EOF' +{"type": "assistant", "message": {"content": [{"type": "tool_use", "name": "TodoWrite", "input": {"todos": [{"content": "Old Task", "status": "pending"}]}}]}} +{"type": "assistant", "message": {"content": [{"type": "tool_use", "name": "TodoWrite", "input": {"todos": [{"content": "New Task", "status": "completed"}]}}]}} +EOF +set +e +RESULT=$(echo "{\"transcript_path\": \"$TEST_DIR/transcript-multiple.jsonl\"}" | python3 "$TODO_CHECKER" 2>&1) +EXIT_CODE=$? +set -e +if [[ $EXIT_CODE -eq 0 ]]; then + pass "Uses latest TodoWrite (all completed)" +else + fail "Multiple TodoWrite handling" "exit 0 (latest is completed)" "exit $EXIT_CODE, output: $RESULT" +fi + +# Test 12: Direct tool_use entry format +echo "Test 12: Direct tool_use entry format" +cat > "$TEST_DIR/transcript-direct.jsonl" << 'EOF' +{"type": "tool_use", "name": "TodoWrite", "input": {"todos": [{"content": "Task", "status": "completed"}]}} +EOF +set +e +RESULT=$(echo "{\"transcript_path\": \"$TEST_DIR/transcript-direct.jsonl\"}" | python3 "$TODO_CHECKER" 2>&1) +EXIT_CODE=$? +set -e +if [[ $EXIT_CODE -eq 0 ]]; then + pass "Direct tool_use format handled" +else + fail "Direct tool_use format" "exit 0" "exit $EXIT_CODE" +fi + +# Test 13: type: message format +echo "Test 13: Alternative message format" +cat > "$TEST_DIR/transcript-message.jsonl" << 'EOF' +{"type": "message", "content": [{"type": "tool_use", "name": "TodoWrite", "input": {"todos": [{"content": "Task", "status": "completed"}]}}]} +EOF +set +e +RESULT=$(echo "{\"transcript_path\": \"$TEST_DIR/transcript-message.jsonl\"}" | python3 "$TODO_CHECKER" 2>&1) +EXIT_CODE=$? +set -e +if [[ $EXIT_CODE -eq 0 ]]; then + pass "Alternative message format handled" +else + fail "Alternative message format" "exit 0" "exit $EXIT_CODE" +fi + +# ======================================== +# Test Group 4: Edge Cases +# ======================================== +echo "" +echo "Test Group 4: Edge Cases" +echo "" + +# Test 14: Todo with missing status field +echo "Test 14: Todo with missing status" +cat > "$TEST_DIR/transcript-no-status.jsonl" << 'EOF' +{"type": "assistant", "message": {"content": [{"type": "tool_use", "name": "TodoWrite", "input": {"todos": [{"content": "Task without status"}]}}]}} +EOF +set +e +RESULT=$(echo "{\"transcript_path\": \"$TEST_DIR/transcript-no-status.jsonl\"}" | python3 "$TODO_CHECKER" 2>&1) +EXIT_CODE=$? +set -e +# Missing status should be treated as incomplete (not "completed") +if [[ $EXIT_CODE -eq 1 ]]; then + pass "Missing status treated as incomplete" +else + fail "Missing status handling" "exit 1 (incomplete)" "exit $EXIT_CODE" +fi + +# Test 15: Todo with empty content +echo "Test 15: Todo with empty content" +cat > "$TEST_DIR/transcript-empty-content.jsonl" << 'EOF' +{"type": "assistant", "message": {"content": [{"type": "tool_use", "name": "TodoWrite", "input": {"todos": [{"content": "", "status": "pending"}]}}]}} +EOF +set +e +RESULT=$(echo "{\"transcript_path\": \"$TEST_DIR/transcript-empty-content.jsonl\"}" | python3 "$TODO_CHECKER" 2>&1) +EXIT_CODE=$? +set -e +if [[ $EXIT_CODE -eq 1 ]]; then + pass "Empty content todo handled (still incomplete)" +else + fail "Empty content handling" "exit 1" "exit $EXIT_CODE" +fi + +# Test 16: Unicode in todo content +echo "Test 16: Unicode in todo content" +cat > "$TEST_DIR/transcript-unicode.jsonl" << 'EOF' +{"type": "assistant", "message": {"content": [{"type": "tool_use", "name": "TodoWrite", "input": {"todos": [{"content": "Task with unicode", "status": "completed"}]}}]}} +EOF +set +e +RESULT=$(echo "{\"transcript_path\": \"$TEST_DIR/transcript-unicode.jsonl\"}" | python3 "$TODO_CHECKER" 2>&1) +EXIT_CODE=$? +set -e +if [[ $EXIT_CODE -eq 0 ]]; then + pass "Unicode content handled" +else + fail "Unicode content" "exit 0" "exit $EXIT_CODE" +fi + +# ======================================== +# Summary +# ======================================== +echo "" +echo "========================================" +echo "Test Summary" +echo "========================================" +echo -e "Passed: ${GREEN}$TESTS_PASSED${NC}" +echo -e "Failed: ${RED}$TESTS_FAILED${NC}" + +if [[ $TESTS_FAILED -eq 0 ]]; then + echo "" + echo -e "${GREEN}All tests passed!${NC}" + exit 0 +else + echo "" + echo -e "${RED}Some tests failed!${NC}" + exit 1 +fi