diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 719976ba..e5693189 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.1" + "version": "1.1.2" } ] } diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 60044937..1c894a0c 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.1", + "version": "1.1.2", "author": { "name": "humania-org" }, diff --git a/.github/workflows/plan-file-test.yml b/.github/workflows/plan-file-test.yml new file mode 100644 index 00000000..690abb95 --- /dev/null +++ b/.github/workflows/plan-file-test.yml @@ -0,0 +1,51 @@ +name: Plan File Validation Tests + +on: + push: + paths: + - 'scripts/setup-rlcr-loop.sh' + - 'hooks/loop-plan-file-validator.sh' + - 'hooks/loop-codex-stop-hook.sh' + - 'hooks/loop-write-validator.sh' + - 'hooks/loop-edit-validator.sh' + - 'hooks/loop-bash-validator.sh' + - 'hooks/lib/loop-common.sh' + - 'tests/test-plan-file-*.sh' + - 'tests/test-state-exit-naming.sh' + pull_request: + paths: + - 'scripts/setup-rlcr-loop.sh' + - 'hooks/loop-plan-file-validator.sh' + - 'hooks/loop-codex-stop-hook.sh' + - 'hooks/loop-write-validator.sh' + - 'hooks/loop-edit-validator.sh' + - 'hooks/loop-bash-validator.sh' + - 'hooks/lib/loop-common.sh' + - 'tests/test-plan-file-*.sh' + - 'tests/test-state-exit-naming.sh' + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install dependencies + run: | + sudo apt-get update + sudo apt-get install -y jq + + - name: Run plan file validation tests + run: | + chmod +x tests/test-plan-file-validation.sh + ./tests/test-plan-file-validation.sh + + - name: Run plan file hook tests + run: | + chmod +x tests/test-plan-file-hooks.sh + ./tests/test-plan-file-hooks.sh + + - name: Run state exit naming tests + run: | + chmod +x tests/test-state-exit-naming.sh + ./tests/test-state-exit-naming.sh diff --git a/.gitignore b/.gitignore index bf86ccdf..284dfecf 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ -wip +# Scratchpad +temp # Humanize loop state directory (used by start-rlcr-loop) .humanize-loop.local/ diff --git a/README.md b/README.md index 1c89cac9..33b70844 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Humanize -**Current Version: 1.1.1** +**Current Version: 1.1.2** > Derived from the [GAAC (GitHub-as-a-Context)](https://github.com/SihaoLiu/gaac) project. @@ -113,19 +113,20 @@ This provides a real-time dashboard showing: **The loop is fully interruptible** - you can exit Claude Code at any time and resume later: -- **Loop state**: Controlled solely by the presence of `.humanize-loop.local/*/state.md` +- **Loop state**: Controlled solely by the presence of `state.md` in the current loop directory (newest timestamp in `.humanize-loop.local/`) - **Resume**: Simply restart Claude Code in the same directory - the loop continues automatically -- **Cancel**: Remove the state file to stop the loop permanently +- **Cancel**: Rename `state.md` to `cancel-state.md` to stop the loop permanently ```bash -# Cancel the active loop +# Cancel the active loop (recommended) /humanize:cancel-rlcr-loop -# Or manually remove state file -rm .humanize-loop.local/*/state.md +# Or manually rename state file (find newest loop directory first) +LOOP_DIR=$(ls -1d .humanize-loop.local/*/ | sort -r | head -1) +mv "${LOOP_DIR}state.md" "${LOOP_DIR}cancel-state.md" ``` -The loop directory with all summaries and review results is preserved for reference. +The loop directory with all summaries, review results, and state information is preserved for reference. ## Goal Tracker System @@ -243,13 +244,21 @@ humanize/ When loop is active, creates: `.humanize-loop.local//` **Files Created**: -- `state.md` - Current round, config (YAML frontmatter) +- `state.md` - Current round, config (YAML frontmatter). Presence indicates active loop. +- `plan.md` - Backup copy of the plan file (for integrity verification) - `goal-tracker.md` - Immutable (goals/AC) + Mutable (active tasks, deferred, etc.) - `round-N-prompt.md` - Instructions FROM Codex TO Claude - `round-N-summary.md` - Work summary written BY Claude - `round-N-review-prompt.md` - Prompt sent to Codex - `round-N-review-result.md` - Codex's review output +**Exit State Files** (state.md renamed on loop end): +- `complete-state.md` - Loop completed successfully (Codex confirmed all goals met) +- `cancel-state.md` - User cancelled via `/humanize:cancel-rlcr-loop` +- `maxiter-state.md` - Reached maximum iteration limit +- `stop-state.md` - Codex triggered circuit breaker +- `unexpected-state.md` - Schema/integrity issues or abnormal termination + **Cache Directory** (not in project): - `$HOME/.cache/humanize///` - `round-N-codex-run.cmd` - Command invoked diff --git a/commands/cancel-rlcr-loop.md b/commands/cancel-rlcr-loop.md index a8595f10..20328652 100644 --- a/commands/cancel-rlcr-loop.md +++ b/commands/cancel-rlcr-loop.md @@ -1,6 +1,6 @@ --- description: "Cancel active RLCR loop" -allowed-tools: ["Bash(ls .humanize-loop.local/*/state.md:*)", "Bash(rm .humanize-loop.local/*/state.md)", "Bash(cat .humanize-loop.local/*/state.md)", "Read"] +allowed-tools: ["Bash(ls -1d .humanize-loop.local/*/)", "Bash(mv .humanize-loop.local/*/state.md .humanize-loop.local/*/cancel-state.md)", "Bash(cat .humanize-loop.local/*/state.md)", "Read"] hide-from-slash-command-tool: "true" --- @@ -8,17 +8,28 @@ hide-from-slash-command-tool: "true" To cancel the active loop: -1. Check if any loop is active by looking for state files: +1. Find the current loop directory (newest timestamp): ```bash -ls .humanize-loop.local/*/state.md 2>/dev/null || echo "NO_LOOP" +LOOP_DIR=$(ls -1d .humanize-loop.local/*/ 2>/dev/null | sort -r | head -1) +echo "Loop dir: ${LOOP_DIR:-NONE}" ``` -2. **If NO_LOOP**: Say "No active RLCR loop found." +2. **If NONE**: Say "No active RLCR loop found." -3. **If state file(s) found**: - - Read the state file to get the current round number - - Remove the state file(s) using: `rm .humanize-loop.local/*/state.md` - - Report: "Cancelled RLCR loop (was at round N of M)" +3. Check if the current loop is active (state.md exists): -The loop directory with summaries and review results will be preserved for reference. +```bash +ls "${LOOP_DIR}state.md" 2>/dev/null || echo "NO_ACTIVE_LOOP" +``` + +4. **If NO_ACTIVE_LOOP**: Say "No active RLCR loop found. (Loop directory exists but no state.md)" + +5. **If state.md found**: + - Read the state file to get the current round number and max iterations + - Rename state.md to cancel-state.md: `mv "${LOOP_DIR}state.md" "${LOOP_DIR}cancel-state.md"` + - Report: "Cancelled RLCR loop (was at round N of M). State preserved as cancel-state.md" + +**Key principle**: The current loop directory is always the one with the newest timestamp. A loop is active only if `state.md` exists in that directory. + +The loop directory with summaries, review results, and state information will be preserved for reference. diff --git a/commands/start-rlcr-loop.md b/commands/start-rlcr-loop.md index 8155cbb7..9c4ca473 100644 --- a/commands/start-rlcr-loop.md +++ b/commands/start-rlcr-loop.md @@ -9,7 +9,7 @@ hide-from-slash-command-tool: "true" Execute the setup script to initialize the loop: -```! +```bash "${CLAUDE_PLUGIN_ROOT}/scripts/setup-rlcr-loop.sh" $ARGUMENTS ``` diff --git a/hooks/hooks.json b/hooks/hooks.json index d843a23f..07554fcb 100644 --- a/hooks/hooks.json +++ b/hooks/hooks.json @@ -1,6 +1,16 @@ { "description": "Humanize Plugin Hooks - Validation hooks and Stop hooks for /start-rlcr-loop", "hooks": { + "UserPromptSubmit": [ + { + "hooks": [ + { + "type": "command", + "command": "${CLAUDE_PLUGIN_ROOT}/hooks/loop-plan-file-validator.sh" + } + ] + } + ], "PreToolUse": [ { "matcher": "Write", diff --git a/hooks/lib/loop-common.sh b/hooks/lib/loop-common.sh index 89838d49..e40ba462 100755 --- a/hooks/lib/loop-common.sh +++ b/hooks/lib/loop-common.sh @@ -161,12 +161,17 @@ command_modifies_file() { local patterns=( ">[[:space:]]*[^[:space:]]*${file_pattern}" + ">>[[:space:]]*[^[:space:]]*${file_pattern}" "tee[[:space:]]+(-a[[:space:]]+)?[^[:space:]]*${file_pattern}" "sed[[:space:]]+-i[^|]*${file_pattern}" "awk[[:space:]]+-i[[:space:]]+inplace[^|]*${file_pattern}" "perl[[:space:]]+-[^[:space:]]*i[^|]*${file_pattern}" "(mv|cp)[[:space:]]+[^[:space:]]+[[:space:]]+[^[:space:]]*${file_pattern}" + "rm[[:space:]]+(-[rfv]+[[:space:]]+)?[^[:space:]]*${file_pattern}" "dd[[:space:]].*of=[^[:space:]]*${file_pattern}" + "truncate[[:space:]]+[^|]*${file_pattern}" + "printf[[:space:]].*>[[:space:]]*[^[:space:]]*${file_pattern}" + "exec[[:space:]]+[0-9]*>[[:space:]]*[^[:space:]]*${file_pattern}" ) for pattern in "${patterns[@]}"; do @@ -190,3 +195,38 @@ After Round 0, only Codex can modify the Goal Tracker. Include a Goal Tracker Up "CURRENT_ROUND=$current_round" \ "SUMMARY_FILE=$summary_file" } + +# End the loop by renaming state.md to indicate exit reason +# Usage: end_loop "$loop_dir" "$state_file" "complete|cancel|maxiter|stop|unexpected" +# Arguments: +# $1 - loop_dir: Path to the loop directory +# $2 - state_file: Path to the state.md file +# $3 - reason: One of complete, cancel, maxiter, stop, unexpected +# Returns: 0 on success, 1 on failure +end_loop() { + local loop_dir="$1" + local state_file="$2" + local reason="$3" # complete, cancel, maxiter, stop, unexpected + + # Validate reason + case "$reason" in + complete|cancel|maxiter|stop|unexpected) + ;; + *) + echo "Error: Invalid end_loop reason: $reason" >&2 + return 1 + ;; + esac + + local target_name="${reason}-state.md" + + if [[ -f "$state_file" ]]; then + mv "$state_file" "$loop_dir/$target_name" + echo "Loop ended: $reason" >&2 + echo "State preserved as: $loop_dir/$target_name" >&2 + return 0 + else + echo "Warning: State file not found, cannot end loop" >&2 + return 1 + fi +} diff --git a/hooks/loop-bash-validator.sh b/hooks/loop-bash-validator.sh index c9d40d5c..ad8f4e4f 100755 --- a/hooks/loop-bash-validator.sh +++ b/hooks/loop-bash-validator.sh @@ -74,6 +74,19 @@ if command_modifies_file "$COMMAND_LOWER" "state\.md"; then exit 2 fi +# ======================================== +# Block Plan Backup Modifications (All Rounds) +# ======================================== +# Plan backup is read-only - protects plan integrity during loop +# Use command_modifies_file helper for consistent pattern matching + +if command_modifies_file "$COMMAND_LOWER" "\.humanize-loop\.local(/[^/]+)?/plan\.md"; then + FALLBACK="Writing to plan.md backup is not allowed during RLCR loop." + REASON=$(load_and_render_safe "$TEMPLATE_DIR" "block/plan-backup-protected.md" "$FALLBACK") + echo "$REASON" >&2 + exit 2 +fi + # ======================================== # Block Goal Tracker Modifications (All Rounds) # ======================================== diff --git a/hooks/loop-codex-stop-hook.sh b/hooks/loop-codex-stop-hook.sh index ad5126ea..35ae56a8 100755 --- a/hooks/loop-codex-stop-hook.sh +++ b/hooks/loop-codex-stop-hook.sh @@ -58,6 +58,163 @@ fi STATE_FILE="$LOOP_DIR/state.md" +# ======================================== +# Parse State File (all frontmatter fields) +# ======================================== + +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}" +CODEX_TIMEOUT="${STATE_CODEX_TIMEOUT:-${CODEX_TIMEOUT:-$DEFAULT_CODEX_TIMEOUT}}" + +# 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" + exit 0 +fi + +if [[ ! "$MAX_ITERATIONS" =~ ^[0-9]+$ ]]; then + MAX_ITERATIONS=42 +fi + +# ======================================== +# Quick-check 0: Schema Validation (v1.1.2+ fields) +# ======================================== +# If schema is outdated, terminate loop as unexpected + +if [[ -z "$PLAN_TRACKED" || -z "$START_BRANCH" ]]; then + REASON="RLCR loop state file is missing required fields (plan_tracked or start_branch). + +This indicates the loop was started with an older version of humanize. + +**Options:** +1. Cancel the loop: \`/humanize:cancel-rlcr-loop\` +2. Update humanize plugin to version 1.1.2+ +3. Restart the RLCR loop with the updated plugin" + jq -n --arg reason "$REASON" --arg msg "Loop: Blocked - state schema outdated" \ + '{"decision": "block", "reason": $reason, "systemMessage": $msg}' + exit 0 +fi + +# ======================================== +# Quick-check 0.5: Branch Consistency +# ======================================== + +CURRENT_BRANCH=$(git -C "$PROJECT_ROOT" rev-parse --abbrev-ref HEAD 2>/dev/null || echo "") + +if [[ -n "$START_BRANCH" && "$CURRENT_BRANCH" != "$START_BRANCH" ]]; then + REASON="Git branch changed during RLCR loop. + +Started on: $START_BRANCH +Current: $CURRENT_BRANCH + +Branch switching is not allowed. Switch back to $START_BRANCH or cancel the loop." + jq -n --arg reason "$REASON" --arg msg "Loop: Blocked - branch changed" \ + '{"decision": "block", "reason": $reason, "systemMessage": $msg}' + exit 0 +fi + +# ======================================== +# Quick-check 0.6: Plan File Integrity +# ======================================== + +BACKUP_PLAN="$LOOP_DIR/plan.md" +FULL_PLAN_PATH="$PROJECT_ROOT/$PLAN_FILE" + +# Check backup exists +if [[ ! -f "$BACKUP_PLAN" ]]; then + REASON="Plan file backup not found in loop directory. + +Please copy the plan file to the loop directory: + cp \"$FULL_PLAN_PATH\" \"$BACKUP_PLAN\" + +This backup is required for plan integrity verification." + jq -n --arg reason "$REASON" --arg msg "Loop: Blocked - plan backup missing" \ + '{"decision": "block", "reason": $reason, "systemMessage": $msg}' + exit 0 +fi + +# Check original plan file still matches backup +if [[ ! -f "$FULL_PLAN_PATH" ]]; then + REASON="Project plan file has been deleted. + +Original: $PLAN_FILE +Backup available at: $BACKUP_PLAN + +You can restore from backup if needed. Plan file modifications are not allowed during RLCR loop." + jq -n --arg reason "$REASON" --arg msg "Loop: Blocked - plan file deleted" \ + '{"decision": "block", "reason": $reason, "systemMessage": $msg}' + exit 0 +fi + +# Check plan file integrity +# For tracked files: check both git status (uncommitted) AND content diff (committed changes) +# 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 "") + if [[ -n "$PLAN_GIT_STATUS" ]]; then + REASON="Plan file has uncommitted modifications. + +File: $PLAN_FILE +Status: $PLAN_GIT_STATUS + +This RLCR loop was started with --track-plan-file. Plan file modifications are not allowed during the loop." + jq -n --arg reason "$REASON" --arg msg "Loop: Blocked - plan file modified (uncommitted)" \ + '{"decision": "block", "reason": $reason, "systemMessage": $msg}' + exit 0 + fi +fi + +# Always verify content matches backup (catches committed changes for tracked files) +if ! diff -q "$FULL_PLAN_PATH" "$BACKUP_PLAN" &>/dev/null; then + FALLBACK="# Plan File Modified + +The plan file \`$PLAN_FILE\` has been modified since the RLCR loop started. + +**Modifying plan files is forbidden during an active RLCR loop.** + +If you need to change the plan: +1. Cancel the current loop: \`/humanize:cancel-rlcr-loop\` +2. Update the plan file +3. Start a new loop: \`/humanize:start-rlcr-loop $PLAN_FILE\` + +Backup available at: \`$BACKUP_PLAN\`" + REASON=$(load_and_render_safe "$TEMPLATE_DIR" "block/plan-file-modified.md" "$FALLBACK" \ + "PLAN_FILE=$PLAN_FILE" \ + "BACKUP_PATH=$BACKUP_PLAN") + jq -n --arg reason "$REASON" --arg msg "Loop: Blocked - plan file modified" \ + '{"decision": "block", "reason": $reason, "systemMessage": $msg}' + exit 0 +fi + # ======================================== # Quick Check: Are All Todos Completed? # ======================================== @@ -79,6 +236,7 @@ if [[ -f "$TODO_CHECKER" ]]; then FALLBACK="# Incomplete Todos Complete these tasks before exiting: + {{INCOMPLETE_LIST}}" REASON=$(load_and_render_safe "$TEMPLATE_DIR" "block/incomplete-todos.md" "$FALLBACK" \ "INCOMPLETE_LIST=$INCOMPLETE_LIST") @@ -157,6 +315,7 @@ EOF FALLBACK="# Large Files Detected Files exceeding {{MAX_LINES}} lines: + {{LARGE_FILES}} Split these into smaller modules before continuing." @@ -242,8 +401,6 @@ Please commit all changes before exiting. # ======================================== # Check Unpushed Commits (only when push_every_round is true) # ======================================== - # Read push_every_round from state file - PUSH_EVERY_ROUND=$(grep -E "^push_every_round:" "$STATE_FILE" 2>/dev/null | sed 's/push_every_round: *//' || echo "false") if [[ "$PUSH_EVERY_ROUND" == "true" ]]; then # Check if local branch is ahead of remote (unpushed commits) @@ -274,44 +431,6 @@ Please push before exiting." fi fi -# ======================================== -# Parse State File -# ======================================== - -if [[ ! -f "$STATE_FILE" ]]; then - exit 0 -fi - -# Extract frontmatter values -FRONTMATTER=$(sed -n '/^---$/,/^---$/{ /^---$/d; p; }' "$STATE_FILE" 2>/dev/null || echo "") - -CURRENT_ROUND=$(echo "$FRONTMATTER" | grep '^current_round:' | sed 's/current_round: *//' | tr -d ' ') -MAX_ITERATIONS=$(echo "$FRONTMATTER" | grep '^max_iterations:' | sed 's/max_iterations: *//' | tr -d ' ') -CODEX_MODEL=$(echo "$FRONTMATTER" | grep '^codex_model:' | sed 's/codex_model: *//' | tr -d ' ') -CODEX_EFFORT=$(echo "$FRONTMATTER" | grep '^codex_effort:' | sed 's/codex_effort: *//' | tr -d ' ') -STATE_CODEX_TIMEOUT=$(echo "$FRONTMATTER" | grep '^codex_timeout:' | sed 's/codex_timeout: *//' | tr -d ' ') -PLAN_FILE=$(echo "$FRONTMATTER" | grep '^plan_file:' | sed 's/plan_file: *//') - -# Defaults -CURRENT_ROUND="${CURRENT_ROUND:-0}" -MAX_ITERATIONS="${MAX_ITERATIONS:-10}" -CODEX_MODEL="${CODEX_MODEL:-$DEFAULT_CODEX_MODEL}" -CODEX_EFFORT="${CODEX_EFFORT:-$DEFAULT_CODEX_EFFORT}" -# Timeout priority: state file > env var > default -CODEX_TIMEOUT="${STATE_CODEX_TIMEOUT:-${CODEX_TIMEOUT:-$DEFAULT_CODEX_TIMEOUT}}" - -# Validate numeric fields -if [[ ! "$CURRENT_ROUND" =~ ^[0-9]+$ ]]; then - echo "Warning: State file corrupted (current_round), stopping loop" >&2 - rm -f "$STATE_FILE" - exit 0 -fi - -# max_iterations must be a number -if [[ ! "$MAX_ITERATIONS" =~ ^[0-9]+$ ]]; then - MAX_ITERATIONS=42 -fi - # ======================================== # Check Summary File Exists # ======================================== @@ -409,7 +528,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 - rm -f "$STATE_FILE" + end_loop "$LOOP_DIR" "$STATE_FILE" "maxiter" exit 0 fi @@ -722,7 +841,7 @@ if [[ "$LAST_LINE_TRIMMED" == "COMPLETE" ]]; then else echo "Codex review passed. Loop complete!" >&2 fi - rm -f "$STATE_FILE" + end_loop "$LOOP_DIR" "$STATE_FILE" "complete" exit 0 fi @@ -752,7 +871,7 @@ if [[ "$LAST_LINE_TRIMMED" == "STOP" ]]; then echo " $REVIEW_RESULT_FILE" >&2 fi echo "========================================" >&2 - rm -f "$STATE_FILE" + end_loop "$LOOP_DIR" "$STATE_FILE" "stop" exit 0 fi diff --git a/hooks/loop-edit-validator.sh b/hooks/loop-edit-validator.sh index cec145a8..dabf3d8f 100755 --- a/hooks/loop-edit-validator.sh +++ b/hooks/loop-edit-validator.sh @@ -74,6 +74,20 @@ if is_state_file_path "$FILE_PATH_LOWER"; then exit 2 fi +# ======================================== +# Block Plan Backup Edits +# ======================================== + +FILENAME=$(basename "$FILE_PATH") +if [[ "$FILENAME" == "plan.md" ]]; then + if [[ "$FILE_PATH" == *"/.humanize-loop.local/"* ]]; then + FALLBACK="Editing plan.md backup is not allowed during RLCR loop." + REASON=$(load_and_render_safe "$TEMPLATE_DIR" "block/plan-backup-protected.md" "$FALLBACK") + echo "$REASON" >&2 + exit 2 + fi +fi + # ======================================== # Block Goal Tracker After Round 0 # ======================================== diff --git a/hooks/loop-plan-file-validator.sh b/hooks/loop-plan-file-validator.sh new file mode 100755 index 00000000..962a552d --- /dev/null +++ b/hooks/loop-plan-file-validator.sh @@ -0,0 +1,138 @@ +#!/bin/bash +# +# UserPromptSubmit hook for plan file validation during RLCR loop +# +# Validates: +# - State schema version (plan_tracked, start_branch fields required) +# - Branch consistency (no switching during loop) +# - Plan file tracking status consistency +# + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd)" +PROJECT_ROOT="${CLAUDE_PROJECT_DIR:-$(pwd)}" + +# Source shared loop functions and template loader +source "$SCRIPT_DIR/lib/loop-common.sh" + +# Read hook input (required for UserPromptSubmit hooks) +INPUT=$(cat) + +# Find active loop using shared function +LOOP_BASE_DIR="$PROJECT_ROOT/.humanize-loop.local" +LOOP_DIR=$(find_active_loop "$LOOP_BASE_DIR") + +# If no active loop, allow exit +if [[ -z "$LOOP_DIR" ]]; then + exit 0 +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 "") + +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) + +# ======================================== +# Schema Validation (v1.1.2+ required fields) +# ======================================== + +# Helper function to output schema validation error +schema_validation_error() { + local field_name="$1" + local fallback="RLCR loop state file is missing required field: \`${field_name}\`\n\nThis indicates the loop was started with an older version of humanize.\n\n**Options:**\n1. Cancel the loop: \`/humanize:cancel-rlcr-loop\`\n2. Update humanize plugin to version 1.1.2+\n3. Restart the RLCR loop with the updated plugin" + + local reason + reason=$(load_and_render_safe "$TEMPLATE_DIR" "block/schema-outdated.md" "$fallback" "FIELD_NAME=$field_name") + + # Escape newlines for JSON + local escaped_reason + escaped_reason=$(echo "$reason" | jq -Rs '.') + + cat << EOF +{ + "decision": "block", + "reason": $escaped_reason +} +EOF +} + +# Check required fields +REQUIRED_FIELDS=("plan_tracked:$PLAN_TRACKED" "start_branch:$START_BRANCH") +for field_entry in "${REQUIRED_FIELDS[@]}"; do + field_name="${field_entry%%:*}" + field_value="${field_entry#*:}" + + if [[ -z "$field_value" ]]; then + schema_validation_error "$field_name" + exit 0 + fi +done + +# ======================================== +# Branch Consistency Check +# ======================================== + +CURRENT_BRANCH=$(git -C "$PROJECT_ROOT" rev-parse --abbrev-ref HEAD 2>/dev/null || echo "") +if [[ -n "$START_BRANCH" && "$CURRENT_BRANCH" != "$START_BRANCH" ]]; then + cat << EOF +{ + "decision": "block", + "reason": "Git branch has changed during RLCR loop.\\n\\nStarted on: $START_BRANCH\\nCurrent: $CURRENT_BRANCH\\n\\nBranch switching is not allowed during an active RLCR loop. Please switch back to the original branch or cancel the loop with /humanize:cancel-rlcr-loop" +} +EOF + exit 0 +fi + +# ======================================== +# Plan File Tracking Status Check +# ======================================== + +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 "") + + if [[ "$PLAN_IS_TRACKED" != "true" ]]; then + cat << EOF +{ + "decision": "block", + "reason": "Plan file is no longer tracked in git.\\n\\nFile: $PLAN_FILE\\n\\nThis RLCR loop was started with --track-plan-file, but the plan file has been removed from git tracking." +} +EOF + exit 0 + fi + + if [[ -n "$PLAN_GIT_STATUS" ]]; then + cat << EOF +{ + "decision": "block", + "reason": "Plan file has uncommitted modifications.\\n\\nFile: $PLAN_FILE\\nStatus: $PLAN_GIT_STATUS\\n\\nThis RLCR loop was started with --track-plan-file. Plan file modifications are not allowed during the loop." +} +EOF + exit 0 + 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") + + if [[ "$PLAN_IS_TRACKED" == "true" ]]; then + cat << EOF +{ + "decision": "block", + "reason": "Plan file is now tracked in git but loop was started without --track-plan-file.\\n\\nFile: $PLAN_FILE\\n\\nThe plan file must remain gitignored during this RLCR loop." +} +EOF + exit 0 + fi +fi + +exit 0 diff --git a/hooks/loop-write-validator.sh b/hooks/loop-write-validator.sh index 5aa5705b..5a35244e 100755 --- a/hooks/loop-write-validator.sh +++ b/hooks/loop-write-validator.sh @@ -56,10 +56,12 @@ if [[ "$IS_SUMMARY_FILE" == "false" ]] && [[ "$IN_HUMANIZE_LOOP_DIR" == "false" exit 0 fi -# For state.md and goal-tracker.md in .humanize-loop.local, we need further validation +# For state.md, goal-tracker.md, and plan.md in .humanize-loop.local, we need further validation # For other files in .humanize-loop.local that aren't summaries, allow them +FILENAME=$(basename "$FILE_PATH") +IS_PLAN_BACKUP=$([[ "$FILENAME" == "plan.md" ]] && echo "true" || echo "false") if [[ "$IN_HUMANIZE_LOOP_DIR" == "true" ]] && [[ "$IS_SUMMARY_FILE" == "false" ]]; then - if ! is_state_file_path "$FILE_PATH_LOWER" && ! is_goal_tracker_path "$FILE_PATH_LOWER"; then + if ! is_state_file_path "$FILE_PATH_LOWER" && ! is_goal_tracker_path "$FILE_PATH_LOWER" && [[ "$IS_PLAN_BACKUP" != "true" ]]; then exit 0 fi fi @@ -87,6 +89,19 @@ if is_state_file_path "$FILE_PATH_LOWER"; then exit 2 fi +# ======================================== +# Block Plan Backup Writes +# ======================================== + +if [[ "$IS_PLAN_BACKUP" == "true" ]]; then + if [[ "$FILE_PATH" == *"/.humanize-loop.local/"* ]]; then + FALLBACK="Writing to plan.md backup is not allowed during RLCR loop." + REASON=$(load_and_render_safe "$TEMPLATE_DIR" "block/plan-backup-protected.md" "$FALLBACK") + echo "$REASON" >&2 + exit 2 + fi +fi + # ======================================== # Block Goal Tracker After Round 0 # ======================================== diff --git a/prompt-template/block/plan-backup-protected.md b/prompt-template/block/plan-backup-protected.md new file mode 100644 index 00000000..349b2051 --- /dev/null +++ b/prompt-template/block/plan-backup-protected.md @@ -0,0 +1,7 @@ +# Plan Backup Protected + +The `plan.md` file in the loop directory is a backup of the original plan file and cannot be modified. + +This backup ensures plan integrity throughout the RLCR loop. + +If you need to reference the plan, read it instead of modifying it. diff --git a/prompt-template/block/plan-file-modified.md b/prompt-template/block/plan-file-modified.md new file mode 100644 index 00000000..6a7fb863 --- /dev/null +++ b/prompt-template/block/plan-file-modified.md @@ -0,0 +1,12 @@ +# Plan File Modified + +The plan file `{{PLAN_FILE}}` has been modified since the RLCR loop started. + +**Modifying plan files is forbidden during an active RLCR loop.** + +If you need to change the plan: +1. Cancel the current loop: `/humanize:cancel-rlcr-loop` +2. Update the plan file +3. Start a new loop: `/humanize:start-rlcr-loop {{PLAN_FILE}}` + +Backup available at: `{{BACKUP_PATH}}` diff --git a/prompt-template/block/schema-outdated.md b/prompt-template/block/schema-outdated.md new file mode 100644 index 00000000..c6b4ccd1 --- /dev/null +++ b/prompt-template/block/schema-outdated.md @@ -0,0 +1,12 @@ +# State Schema Outdated + +RLCR loop state file is missing required field: `{{FIELD_NAME}}` + +This indicates the loop was started with an older version of humanize. + +**Options:** +1. Cancel the loop: `/humanize:cancel-rlcr-loop` +2. Update humanize plugin to version 1.1.2+ +3. Restart the RLCR loop with the updated plugin + +The loop will be terminated as 'unexpected' to preserve state information. diff --git a/scripts/setup-rlcr-loop.sh b/scripts/setup-rlcr-loop.sh index 7c3099df..bcabcea9 100755 --- a/scripts/setup-rlcr-loop.sh +++ b/scripts/setup-rlcr-loop.sh @@ -25,6 +25,8 @@ DEFAULT_MAX_ITERATIONS=42 # ======================================== PLAN_FILE="" +PLAN_FILE_EXPLICIT="" +TRACK_PLAN_FILE="false" MAX_ITERATIONS="$DEFAULT_MAX_ITERATIONS" CODEX_MODEL="$DEFAULT_CODEX_MODEL" CODEX_EFFORT="$DEFAULT_CODEX_EFFORT" @@ -40,9 +42,11 @@ USAGE: ARGUMENTS: Path to a markdown file containing the implementation plan - (must exist and have at least 5 lines) + (must exist, have at least 5 lines, no spaces in path) OPTIONS: + --plan-file Explicit plan file path (alternative to positional arg) + --track-plan-file Indicate plan file should be tracked in git (must be clean) --max Maximum iterations before auto-stop (default: 42) --codex-model Codex model and reasoning effort (default: gpt-5.2-codex:high) @@ -138,6 +142,18 @@ while [[ $# -gt 0 ]]; do PUSH_EVERY_ROUND="true" shift ;; + --plan-file) + if [[ -z "${2:-}" ]]; then + echo "Error: --plan-file requires a file path" >&2 + exit 1 + fi + PLAN_FILE_EXPLICIT="$2" + shift 2 + ;; + --track-plan-file) + TRACK_PLAN_FILE="true" + shift + ;; -*) echo "Unknown option: $1" >&2 echo "Use --help for usage information" >&2 @@ -160,6 +176,17 @@ done # Validate Prerequisites # ======================================== +PROJECT_ROOT="${CLAUDE_PROJECT_DIR:-$(pwd)}" + +# Merge explicit and positional plan file +if [[ -n "$PLAN_FILE_EXPLICIT" && -n "$PLAN_FILE" ]]; then + echo "Error: Cannot specify both --plan-file and positional plan file" >&2 + exit 1 +fi +if [[ -n "$PLAN_FILE_EXPLICIT" ]]; then + PLAN_FILE="$PLAN_FILE_EXPLICIT" +fi + # Check plan file is provided if [[ -z "$PLAN_FILE" ]]; then echo "Error: No plan file provided" >&2 @@ -170,20 +197,140 @@ if [[ -z "$PLAN_FILE" ]]; then exit 1 fi -# Make path absolute if relative -if [[ ! "$PLAN_FILE" = /* ]]; then - PROJECT_ROOT="${CLAUDE_PROJECT_DIR:-$(pwd)}" - PLAN_FILE="$PROJECT_ROOT/$PLAN_FILE" +# ======================================== +# Git Repository Validation +# ======================================== + +# Check git repo +if ! git rev-parse --git-dir &>/dev/null; then + echo "Error: Project must be a git repository" >&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 + exit 1 +fi + +# ======================================== +# Plan File Path Validation +# ======================================== + +# Reject absolute paths +if [[ "$PLAN_FILE" = /* ]]; then + echo "Error: Plan file must be a relative path, got: $PLAN_FILE" >&2 + exit 1 +fi + +# Reject paths with spaces (not supported for YAML serialization consistency) +if [[ "$PLAN_FILE" =~ [[:space:]] ]]; then + echo "Error: Plan file path cannot contain spaces" >&2 + echo " Got: $PLAN_FILE" >&2 + echo " Rename the file or directory to remove spaces" >&2 + exit 1 +fi + +# Reject paths with shell metacharacters (prevents injection when used in shell commands) +# Use glob pattern matching (== *[...]*) instead of regex (=~) for portability +if [[ "$PLAN_FILE" == *[\;\&\|\$\`\<\>\(\)\{\}\[\]\!\#\~\*\?\\]* ]]; then + echo "Error: Plan file path contains shell metacharacters" >&2 + echo " Got: $PLAN_FILE" >&2 + echo " Rename the file to use only alphanumeric, dash, underscore, dot, and slash" >&2 + exit 1 +fi + +# Build full path +FULL_PLAN_PATH="$PROJECT_ROOT/$PLAN_FILE" + +# Reject symlinks +if [[ -L "$FULL_PLAN_PATH" ]]; then + echo "Error: Plan file cannot be a symbolic link" >&2 + exit 1 fi -# Check plan file exists -if [[ ! -f "$PLAN_FILE" ]]; then +# Check parent directory exists (provides clearer error for typos in path) +PLAN_DIR="$(dirname "$FULL_PLAN_PATH")" +if [[ ! -d "$PLAN_DIR" ]]; then + echo "Error: Plan file directory not found: $(dirname "$PLAN_FILE")" >&2 + exit 1 +fi + +# Check file exists +if [[ ! -f "$FULL_PLAN_PATH" ]]; then echo "Error: Plan file not found: $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 +RESOLVED_PLAN_DIR=$(cd "$PLAN_DIR" 2>/dev/null && pwd) || { + echo "Error: Cannot resolve plan file directory: $(dirname "$PLAN_FILE")" >&2 + echo " This may indicate permission issues or broken symlinks in the path" >&2 + exit 1 +} +REAL_PLAN_PATH="$RESOLVED_PLAN_DIR/$(basename "$FULL_PLAN_PATH")" +if [[ ! "$REAL_PLAN_PATH" = "$PROJECT_ROOT"/* ]]; then + echo "Error: Plan file must be within project directory" >&2 + exit 1 +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 + # Get list of submodule paths + SUBMODULES=$(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 + exit 1 + fi + done + fi +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") + +if [[ "$TRACK_PLAN_FILE" == "true" ]]; then + # Must be tracked and clean + if [[ "$PLAN_IS_TRACKED" != "true" ]]; then + echo "Error: --track-plan-file requires plan file to be tracked in git" >&2 + echo " File: $PLAN_FILE" >&2 + echo " Run: git add $PLAN_FILE && git commit" >&2 + exit 1 + fi + if [[ -n "$PLAN_GIT_STATUS" ]]; then + echo "Error: --track-plan-file requires plan file to be clean (no modifications)" >&2 + echo " File: $PLAN_FILE" >&2 + echo " Status: $PLAN_GIT_STATUS" >&2 + echo " Commit or stash your changes first" >&2 + exit 1 + fi +else + # Must be gitignored (not tracked) + if [[ "$PLAN_IS_TRACKED" == "true" ]]; then + echo "Error: Plan file must be gitignored when not using --track-plan-file" >&2 + echo " File: $PLAN_FILE" >&2 + echo " Either:" >&2 + echo " 1. Add to .gitignore and remove from git: git rm --cached $PLAN_FILE" >&2 + echo " 2. Use --track-plan-file if you want to track the plan file" >&2 + exit 1 + fi +fi + +# ======================================== +# Plan File Content Validation +# ======================================== + # Check plan file has at least 5 lines -LINE_COUNT=$(wc -l < "$PLAN_FILE" | tr -d ' ') +LINE_COUNT=$(wc -l < "$FULL_PLAN_PATH" | tr -d ' ') if [[ "$LINE_COUNT" -lt 5 ]]; then echo "Error: Plan is too simple (only $LINE_COUNT lines, need at least 5)" >&2 echo "" >&2 @@ -200,11 +347,26 @@ if ! command -v codex &>/dev/null; then exit 1 fi +# ======================================== +# Record Branch +# ======================================== + +START_BRANCH=$(git -C "$PROJECT_ROOT" rev-parse --abbrev-ref HEAD) + +# Validate branch name for YAML safety (prevents injection in state.md) +# Reject branches with YAML-unsafe characters: colon, hash, quotes, newlines +if [[ "$START_BRANCH" == *[:\#\"\'\`]* ]] || [[ "$START_BRANCH" =~ $'\n' ]]; then + echo "Error: Branch name contains YAML-unsafe characters" >&2 + echo " Branch: $START_BRANCH" >&2 + echo " Characters not allowed: : # \" ' \` newline" >&2 + echo " Please checkout a branch with a simpler name" >&2 + exit 1 +fi + # ======================================== # Setup State Directory # ======================================== -PROJECT_ROOT="${CLAUDE_PROJECT_DIR:-$(pwd)}" LOOP_BASE_DIR="$PROJECT_ROOT/.humanize-loop.local" # Create timestamp for this loop session @@ -213,6 +375,9 @@ LOOP_DIR="$LOOP_BASE_DIR/$TIMESTAMP" mkdir -p "$LOOP_DIR" +# Copy plan file to loop directory as backup +cp "$FULL_PLAN_PATH" "$LOOP_DIR/plan.md" + # Docs path default DOCS_PATH="docs" @@ -229,6 +394,8 @@ codex_effort: $CODEX_EFFORT codex_timeout: $CODEX_TIMEOUT push_every_round: $PUSH_EVERY_ROUND plan_file: $PLAN_FILE +plan_tracked: $TRACK_PLAN_FILE +start_branch: $START_BRANCH started_at: $(date -u +%Y-%m-%dT%H:%M:%SZ) --- EOF @@ -238,7 +405,7 @@ EOF # ======================================== GOAL_TRACKER_FILE="$LOOP_DIR/goal-tracker.md" -PLAN_CONTENT=$(cat "$PLAN_FILE") +PLAN_CONTENT=$(cat "$FULL_PLAN_PATH") cat > "$GOAL_TRACKER_FILE" << 'GOAL_TRACKER_EOF' # Goal Tracker @@ -262,10 +429,10 @@ GOAL_TRACKER_EOF # Extract goal from plan file (look for ## Goal, ## Objective, or first paragraph) # This is a heuristic - Claude will refine it in round 0 -GOAL_LINE=$(grep -i -m1 '^\s*##\s*\(goal\|objective\|purpose\)' "$PLAN_FILE" 2>/dev/null || echo "") +GOAL_LINE=$(grep -i -m1 '^\s*##\s*\(goal\|objective\|purpose\)' "$FULL_PLAN_PATH" 2>/dev/null || echo "") if [[ -n "$GOAL_LINE" ]]; then # Get the content after the heading - GOAL_SECTION=$(sed -n '/^\s*##\s*[Gg]oal\|^\s*##\s*[Oo]bjective\|^\s*##\s*[Pp]urpose/,/^\s*##/p' "$PLAN_FILE" | head -20 | tail -n +2 | head -10) + GOAL_SECTION=$(sed -n '/^\s*##\s*[Gg]oal\|^\s*##\s*[Oo]bjective\|^\s*##\s*[Pp]urpose/,/^\s*##/p' "$FULL_PLAN_PATH" | head -20 | tail -n +2 | head -10) echo "$GOAL_SECTION" >> "$GOAL_TRACKER_FILE" else # Use first non-empty, non-heading paragraph as goal description @@ -283,7 +450,7 @@ cat >> "$GOAL_TRACKER_FILE" << 'GOAL_TRACKER_EOF' GOAL_TRACKER_EOF # Extract acceptance criteria from plan file (look for ## Acceptance, ## Criteria, ## Requirements) -AC_SECTION=$(sed -n '/^\s*##\s*[Aa]cceptance\|^\s*##\s*[Cc]riteria\|^\s*##\s*[Rr]equirements/,/^\s*##/p' "$PLAN_FILE" 2>/dev/null | head -30 | tail -n +2 | head -25) +AC_SECTION=$(sed -n '/^\s*##\s*[Aa]cceptance\|^\s*##\s*[Cc]riteria\|^\s*##\s*[Rr]equirements/,/^\s*##/p' "$FULL_PLAN_PATH" 2>/dev/null | head -30 | tail -n +2 | head -25) if [[ -n "$AC_SECTION" ]]; then echo "$AC_SECTION" >> "$GOAL_TRACKER_FILE" else @@ -355,7 +522,7 @@ Before starting implementation, you MUST initialize the Goal Tracker: For all tasks that need to be completed, please create Todos to track each item in order of importance. You are strictly prohibited from only addressing the most important issues - you MUST create Todos for ALL discovered issues and attempt to resolve each one. -$(cat "$PLAN_FILE") +$(cat "$LOOP_DIR/plan.md") --- @@ -402,6 +569,8 @@ cat << EOF === start-rlcr-loop activated === Plan File: $PLAN_FILE ($LINE_COUNT lines) +Plan Tracked: $TRACK_PLAN_FILE +Start Branch: $START_BRANCH Max Iterations: $MAX_ITERATIONS Codex Model: $CODEX_MODEL Codex Effort: $CODEX_EFFORT diff --git a/tests/test-plan-file-hooks.sh b/tests/test-plan-file-hooks.sh new file mode 100755 index 00000000..04d36301 --- /dev/null +++ b/tests/test-plan-file-hooks.sh @@ -0,0 +1,742 @@ +#!/bin/bash +# +# Tests for plan file hooks during RLCR loop +# +# Tests: +# - UserPromptSubmit hook (loop-plan-file-validator.sh) +# - Write validator blocking plan.md +# - Edit validator blocking plan.md +# - Bash validator blocking plan.md modifications +# + +set -uo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" + +# Test helpers +GREEN='\033[0;32m' +RED='\033[0;31m' +YELLOW='\033[0;33m' +NC='\033[0m' +TESTS_PASSED=0 +TESTS_FAILED=0 +TESTS_SKIPPED=0 + +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)); } +skip() { echo -e "${YELLOW}SKIP${NC}: $1 - $2"; TESTS_SKIPPED=$((TESTS_SKIPPED + 1)); } + +# Setup test environment +TEST_DIR=$(mktemp -d) +trap "rm -rf $TEST_DIR" EXIT + +# Default branch name (set after first git init) +DEFAULT_BRANCH="" + +setup_test_loop() { + cd "$TEST_DIR" + + # Only init git if not already initialized + if [[ ! -d ".git" ]]; then + git init -q + git config user.email "test@test.com" + git config user.name "Test" + echo "initial" > init.txt + git add init.txt + git commit -q -m "Initial commit" + # Capture default branch name (main or master depending on git version) + DEFAULT_BRANCH=$(git rev-parse --abbrev-ref HEAD) + fi + + # Get current branch name (handles both 'main' and 'master' defaults) + CURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD) + + # Create loop directory structure + LOOP_DIR="$TEST_DIR/.humanize-loop.local/2024-01-01_12-00-00" + mkdir -p "$LOOP_DIR" + + # Create plan file (gitignored) + mkdir -p plans + cat > plans/test-plan.md << 'EOF' +# Test Plan +## Goal +Test the RLCR loop +## Requirements +- Requirement 1 +EOF + echo "plans/" >> .gitignore + git add .gitignore + git commit -q -m "Add gitignore" + + # Create plan backup + cp plans/test-plan.md "$LOOP_DIR/plan.md" + + # Create state file with v1.1.2+ fields (plan_file is quoted in YAML) + # Use actual branch name to handle both 'main' and 'master' defaults + cat > "$LOOP_DIR/state.md" << EOF +--- +current_round: 0 +max_iterations: 42 +plan_file: "plans/test-plan.md" +plan_tracked: false +start_branch: $CURRENT_BRANCH +--- +EOF +} + +echo "=== Test: UserPromptSubmit Hook ===" +echo "" + +# Test 1: Hook passes with valid state +setup_test_loop +export CLAUDE_PROJECT_DIR="$TEST_DIR" + +echo "Test 1: Hook passes with valid state" +set +e +RESULT=$(echo '{}' | "$PROJECT_ROOT/hooks/loop-plan-file-validator.sh" 2>&1) +EXIT_CODE=$? +set -e +if [[ $EXIT_CODE -eq 0 ]] && [[ -z "$RESULT" ]]; then + pass "Hook passes with valid state" +else + fail "Hook with valid state" "exit 0, no output" "exit $EXIT_CODE, output: $RESULT" +fi + +# Test 1.5: Hook correctly parses YAML-quoted plan_file +echo "Test 1.5: Hook correctly parses YAML-quoted plan_file" +# The hook should strip quotes and find the plan file correctly +set +e +RESULT=$(echo '{}' | "$PROJECT_ROOT/hooks/loop-plan-file-validator.sh" 2>&1) +EXIT_CODE=$? +set -e +# If the plan_file wasn't parsed correctly, it would fail to find the file +# and might block. Success means empty output and exit 0. +if [[ $EXIT_CODE -eq 0 ]] && [[ -z "$RESULT" ]]; then + pass "Hook correctly parses YAML-quoted plan_file" +else + fail "Hook parsing YAML-quoted plan_file" "exit 0, no output" "exit $EXIT_CODE, output: $RESULT" +fi + +# Test 2: Hook blocks when plan_tracked field is missing +echo "Test 2: Hook blocks when plan_tracked field is missing" +cat > "$LOOP_DIR/state.md" << EOF +--- +current_round: 0 +max_iterations: 42 +plan_file: "plans/test-plan.md" +start_branch: $DEFAULT_BRANCH +--- +EOF +set +e +RESULT=$(echo '{}' | "$PROJECT_ROOT/hooks/loop-plan-file-validator.sh" 2>&1) +EXIT_CODE=$? +set -e +if [[ $EXIT_CODE -eq 0 ]] && echo "$RESULT" | grep -q "plan_tracked"; then + pass "Hook blocks on missing plan_tracked" +else + fail "Hook blocking missing plan_tracked" "block with plan_tracked error" "$RESULT" +fi + +# Test 3: Hook blocks when start_branch field is missing +echo "Test 3: Hook blocks when start_branch field is missing" +cat > "$LOOP_DIR/state.md" << 'EOF' +--- +current_round: 0 +max_iterations: 42 +plan_file: "plans/test-plan.md" +plan_tracked: false +--- +EOF +set +e +RESULT=$(echo '{}' | "$PROJECT_ROOT/hooks/loop-plan-file-validator.sh" 2>&1) +EXIT_CODE=$? +set -e +if [[ $EXIT_CODE -eq 0 ]] && echo "$RESULT" | grep -q "start_branch"; then + pass "Hook blocks on missing start_branch" +else + fail "Hook blocking missing start_branch" "block with start_branch error" "$RESULT" +fi + +# Restore valid state for remaining tests +setup_test_loop + +# Test 4: Hook blocks when branch changes +echo "Test 4: Hook blocks when branch changes" +git checkout -q -b feature-branch +cat > "$LOOP_DIR/state.md" << EOF +--- +current_round: 0 +max_iterations: 42 +plan_file: "plans/test-plan.md" +plan_tracked: false +start_branch: $DEFAULT_BRANCH +--- +EOF +set +e +RESULT=$(echo '{}' | "$PROJECT_ROOT/hooks/loop-plan-file-validator.sh" 2>&1) +EXIT_CODE=$? +set -e +if [[ $EXIT_CODE -eq 0 ]] && echo "$RESULT" | grep -q "branch"; then + pass "Hook blocks on branch change" +else + fail "Hook blocking branch change" "block with branch error" "$RESULT" +fi +git checkout -q "$DEFAULT_BRANCH" + +echo "" +echo "=== Test: Write Validator ===" +echo "" + +# Restore state +setup_test_loop + +# Test 5: Write validator blocks plan.md in loop directory +echo "Test 5: Block writes to plan.md backup" +HOOK_INPUT='{"tool_name": "Write", "tool_input": {"file_path": "'$LOOP_DIR'/plan.md"}}' +set +e +RESULT=$(echo "$HOOK_INPUT" | "$PROJECT_ROOT/hooks/loop-write-validator.sh" 2>&1) +EXIT_CODE=$? +set -e +if [[ $EXIT_CODE -eq 2 ]] && echo "$RESULT" | grep -qi "plan"; then + pass "Write validator blocks plan.md backup" +else + fail "Write validator blocking plan.md" "exit 2 with plan error" "exit $EXIT_CODE, output: $RESULT" +fi + +echo "" +echo "=== Test: Edit Validator ===" +echo "" + +# Test 6: Edit validator blocks plan.md in loop directory +echo "Test 6: Block edits to plan.md backup" +HOOK_INPUT='{"tool_name": "Edit", "tool_input": {"file_path": "'$LOOP_DIR'/plan.md"}}' +set +e +RESULT=$(echo "$HOOK_INPUT" | "$PROJECT_ROOT/hooks/loop-edit-validator.sh" 2>&1) +EXIT_CODE=$? +set -e +if [[ $EXIT_CODE -eq 2 ]] && echo "$RESULT" | grep -qi "plan"; then + pass "Edit validator blocks plan.md backup" +else + fail "Edit validator blocking plan.md" "exit 2 with plan error" "exit $EXIT_CODE, output: $RESULT" +fi + +echo "" +echo "=== Test: Bash Validator ===" +echo "" + +# Test 7: Bash validator blocks modifications to plan.md +echo "Test 7: Block bash modifications to plan.md backup" +HOOK_INPUT='{"tool_name": "Bash", "tool_input": {"command": "echo test > '$LOOP_DIR'/plan.md"}}' +set +e +RESULT=$(echo "$HOOK_INPUT" | "$PROJECT_ROOT/hooks/loop-bash-validator.sh" 2>&1) +EXIT_CODE=$? +set -e +if [[ $EXIT_CODE -eq 2 ]] && echo "$RESULT" | grep -qi "plan"; then + pass "Bash validator blocks plan.md modification" +else + fail "Bash validator blocking plan.md" "exit 2 with plan error" "exit $EXIT_CODE, output: $RESULT" +fi + +# Test 8: Bash validator blocks rm on plan.md +echo "Test 8: Block bash rm on plan.md backup" +HOOK_INPUT='{"tool_name": "Bash", "tool_input": {"command": "rm '$LOOP_DIR'/plan.md"}}' +set +e +RESULT=$(echo "$HOOK_INPUT" | "$PROJECT_ROOT/hooks/loop-bash-validator.sh" 2>&1) +EXIT_CODE=$? +set -e +if [[ $EXIT_CODE -eq 2 ]] && echo "$RESULT" | grep -qi "plan"; then + pass "Bash validator blocks rm on plan.md" +else + fail "Bash validator blocking rm" "exit 2 with plan error" "exit $EXIT_CODE, output: $RESULT" +fi + +# Test 8a: Bash validator blocks direct .humanize-loop.local/plan.md (no intermediate dir) +# This tests Fix #1 for the regex bypass vulnerability +echo "Test 8a: Block bash modifications to direct .humanize-loop.local/plan.md" +HOOK_INPUT='{"tool_name": "Bash", "tool_input": {"command": "echo evil > .humanize-loop.local/plan.md"}}' +set +e +RESULT=$(echo "$HOOK_INPUT" | "$PROJECT_ROOT/hooks/loop-bash-validator.sh" 2>&1) +EXIT_CODE=$? +set -e +if [[ $EXIT_CODE -eq 2 ]] && echo "$RESULT" | grep -qi "plan"; then + pass "Bash validator blocks direct .humanize-loop.local/plan.md" +else + fail "Bash validator direct plan.md" "exit 2 with plan error" "exit $EXIT_CODE, output: $RESULT" +fi + +echo "" +echo "=== Test: Command Injection Bypass Prevention ===" +echo "" + +# Test 8.1: Block command substitution bypass attempt +echo "Test 8.1: Block command substitution bypass" +HOOK_INPUT='{"tool_name": "Bash", "tool_input": {"command": "echo test > .humanize-loop.local/$(date +%Y)/plan.md"}}' +set +e +RESULT=$(echo "$HOOK_INPUT" | "$PROJECT_ROOT/hooks/loop-bash-validator.sh" 2>&1) +EXIT_CODE=$? +set -e +if [[ $EXIT_CODE -eq 2 ]] && echo "$RESULT" | grep -qi "plan"; then + pass "Bash validator blocks command substitution bypass" +else + fail "Command substitution bypass" "exit 2 with plan error" "exit $EXIT_CODE, output: $RESULT" +fi + +# Test 8.2: Block glob expansion bypass attempt +echo "Test 8.2: Block glob expansion bypass" +HOOK_INPUT='{"tool_name": "Bash", "tool_input": {"command": "echo test > .humanize-loop.local/*/plan.md"}}' +set +e +RESULT=$(echo "$HOOK_INPUT" | "$PROJECT_ROOT/hooks/loop-bash-validator.sh" 2>&1) +EXIT_CODE=$? +set -e +if [[ $EXIT_CODE -eq 2 ]] && echo "$RESULT" | grep -qi "plan"; then + pass "Bash validator blocks glob expansion bypass" +else + fail "Glob expansion bypass" "exit 2 with plan error" "exit $EXIT_CODE, output: $RESULT" +fi + +# Test 8.3: Block brace expansion bypass attempt +echo "Test 8.3: Block brace expansion bypass" +HOOK_INPUT='{"tool_name": "Bash", "tool_input": {"command": "tee .humanize-loop.local/{a,b,c}/plan.md"}}' +set +e +RESULT=$(echo "$HOOK_INPUT" | "$PROJECT_ROOT/hooks/loop-bash-validator.sh" 2>&1) +EXIT_CODE=$? +set -e +if [[ $EXIT_CODE -eq 2 ]] && echo "$RESULT" | grep -qi "plan"; then + pass "Bash validator blocks brace expansion bypass" +else + fail "Brace expansion bypass" "exit 2 with plan error" "exit $EXIT_CODE, output: $RESULT" +fi + +# Test 8.4: Block piped command bypass attempt +echo "Test 8.4: Block piped command bypass" +HOOK_INPUT='{"tool_name": "Bash", "tool_input": {"command": "cat input.txt | tee .humanize-loop.local/2024-01-01_12-00-00/plan.md"}}' +set +e +RESULT=$(echo "$HOOK_INPUT" | "$PROJECT_ROOT/hooks/loop-bash-validator.sh" 2>&1) +EXIT_CODE=$? +set -e +if [[ $EXIT_CODE -eq 2 ]] && echo "$RESULT" | grep -qi "plan"; then + pass "Bash validator blocks piped command bypass" +else + fail "Piped command bypass" "exit 2 with plan error" "exit $EXIT_CODE, output: $RESULT" +fi + +# Test 8.5: Block backtick command substitution bypass +echo "Test 8.5: Block backtick command substitution bypass" +HOOK_INPUT='{"tool_name": "Bash", "tool_input": {"command": "echo test > .humanize-loop.local/`echo test`/plan.md"}}' +set +e +RESULT=$(echo "$HOOK_INPUT" | "$PROJECT_ROOT/hooks/loop-bash-validator.sh" 2>&1) +EXIT_CODE=$? +set -e +if [[ $EXIT_CODE -eq 2 ]] && echo "$RESULT" | grep -qi "plan"; then + pass "Bash validator blocks backtick substitution bypass" +else + fail "Backtick substitution bypass" "exit 2 with plan error" "exit $EXIT_CODE, output: $RESULT" +fi + +echo "" +echo "=== Test: YAML Quote Parsing ===" +echo "" + +# Test 8.6: Hook correctly parses quoted start_branch (strips quotes) +echo "Test 8.6: Hook correctly strips quotes from start_branch" +setup_test_loop +# Create state with quoted branch name +cat > "$LOOP_DIR/state.md" << EOF +--- +current_round: 0 +max_iterations: 42 +plan_file: "plans/test-plan.md" +plan_tracked: false +start_branch: "$DEFAULT_BRANCH" +--- +EOF +set +e +RESULT=$(echo '{}' | "$PROJECT_ROOT/hooks/loop-plan-file-validator.sh" 2>&1) +EXIT_CODE=$? +set -e +# Should pass (no output, exit 0) - quotes should be stripped and branch should match current +if [[ $EXIT_CODE -eq 0 ]] && [[ -z "$RESULT" ]]; then + pass "Hook correctly strips quotes from start_branch" +else + fail "Quote stripping from start_branch" "exit 0, no output" "exit $EXIT_CODE, output: $RESULT" +fi + +# Test 8.7: Hook detects branch mismatch with quoted value +echo "Test 8.7: Hook detects branch mismatch with quoted start_branch" +setup_test_loop +cat > "$LOOP_DIR/state.md" << 'EOF' +--- +current_round: 0 +max_iterations: 42 +plan_file: "plans/test-plan.md" +plan_tracked: false +start_branch: "different-branch" +--- +EOF +set +e +RESULT=$(echo '{}' | "$PROJECT_ROOT/hooks/loop-plan-file-validator.sh" 2>&1) +EXIT_CODE=$? +set -e +# Should block due to branch mismatch (current is main, state says different-branch) +if [[ $EXIT_CODE -eq 0 ]] && echo "$RESULT" | grep -q "branch"; then + pass "Hook detects branch mismatch with quoted start_branch" +else + fail "Branch mismatch detection with quotes" "block with branch error" "exit $EXIT_CODE, output: $RESULT" +fi + +# Test 8.8: Stop hook correctly parses both quoted fields +echo "Test 8.8: Stop hook parses quoted plan_file and start_branch" +setup_test_loop +cat > "$LOOP_DIR/state.md" << EOF +--- +current_round: 0 +max_iterations: 42 +plan_file: "plans/test-plan.md" +plan_tracked: false +start_branch: "$DEFAULT_BRANCH" +--- +EOF +# Create summary to get past that check +cat > "$LOOP_DIR/round-0-summary.md" << 'SUMEOF' +# Summary +Work done. +SUMEOF +# Create goal tracker +cat > "$LOOP_DIR/goal-tracker.md" << 'GTEOF' +# Goal Tracker +## IMMUTABLE SECTION +### Ultimate Goal +Test goal +### Acceptance Criteria +- Criterion 1 +## MUTABLE SECTION +### Plan Version: 1 (Updated: Round 0) +#### Active Tasks +| Task | Target AC | Status | Notes | +|------|-----------|--------|-------| +| Task 1 | AC1 | done | - | +GTEOF +set +e +RESULT=$(echo '{}' | "$PROJECT_ROOT/hooks/loop-codex-stop-hook.sh" 2>&1) +EXIT_CODE=$? +set -e +# Should NOT fail on YAML parsing - if it fails, should be for other reasons (codex missing, etc) +if ! echo "$RESULT" | grep -qi "yaml\|parse error\|invalid.*field"; then + pass "Stop hook parses quoted plan_file and start_branch" +else + fail "Stop hook YAML parsing" "no YAML parse errors" "output: $RESULT" +fi + +# Test 8.9: Hook handles plan_file path with hyphens correctly +echo "Test 8.9: Hook handles plan_file with hyphens in path" +setup_test_loop +mkdir -p "$TEST_DIR/my-plans" +cat > "$TEST_DIR/my-plans/test-plan.md" << 'EOF' +# Test Plan +## Goal +Test the RLCR loop +## Requirements +- Requirement 1 +EOF +cp "$TEST_DIR/my-plans/test-plan.md" "$LOOP_DIR/plan.md" +cat > "$LOOP_DIR/state.md" << EOF +--- +current_round: 0 +max_iterations: 42 +plan_file: "my-plans/test-plan.md" +plan_tracked: false +start_branch: "$DEFAULT_BRANCH" +--- +EOF +set +e +RESULT=$(echo '{}' | "$PROJECT_ROOT/hooks/loop-plan-file-validator.sh" 2>&1) +EXIT_CODE=$? +set -e +if [[ $EXIT_CODE -eq 0 ]] && [[ -z "$RESULT" ]]; then + pass "Hook handles plan_file with hyphens in path" +else + fail "Plan file path with hyphens" "exit 0, no output" "exit $EXIT_CODE, output: $RESULT" +fi + +# Restore for remaining tests +setup_test_loop + +echo "" +echo "=== Test: Stop Hook Plan File Integrity ===" +echo "" + +# Test 9: Stop hook blocks when plan file has been modified +echo "Test 9: Stop hook blocks when plan file is modified" +setup_test_loop +# Modify the project plan file (different from backup) +echo "# Modified content" >> "$TEST_DIR/plans/test-plan.md" +# Create a summary file so the hook doesn't fail on that check first +cat > "$LOOP_DIR/round-0-summary.md" << 'EOF' +# Summary +Work done. +EOF +# Create goal tracker so the hook doesn't fail on that check +cat > "$LOOP_DIR/goal-tracker.md" << 'EOF' +# Goal Tracker +## IMMUTABLE SECTION +### Ultimate Goal +Test goal +### Acceptance Criteria +- Criterion 1 +## MUTABLE SECTION +### Plan Version: 1 (Updated: Round 0) +#### Plan Evolution Log +| Round | Change | Reason | Impact on AC | +|-------|--------|--------|--------------| +| 0 | Initial plan | - | - | +#### Active Tasks +| Task | Target AC | Status | Notes | +|------|-----------|--------|-------| +| Task 1 | AC1 | in_progress | - | +EOF +set +e +RESULT=$(echo '{}' | "$PROJECT_ROOT/hooks/loop-codex-stop-hook.sh" 2>&1) +EXIT_CODE=$? +set -e +# The hook should output JSON with "block" decision and mention plan file modified +if echo "$RESULT" | grep -q '"decision"' && echo "$RESULT" | grep -qi "plan.*modified"; then + pass "Stop hook blocks when plan file is modified" +else + fail "Stop hook plan modification detection" "block with plan modified error" "exit $EXIT_CODE, output: $RESULT" +fi + +# Test 10: Stop hook blocks when plan file is deleted +echo "Test 10: Stop hook blocks when plan file is deleted" +setup_test_loop +# Delete the project plan file +rm -f "$TEST_DIR/plans/test-plan.md" +# Create necessary files +cat > "$LOOP_DIR/round-0-summary.md" << 'EOF' +# Summary +Work done. +EOF +cat > "$LOOP_DIR/goal-tracker.md" << 'EOF' +# Goal Tracker +## IMMUTABLE SECTION +### Ultimate Goal +Test goal +### Acceptance Criteria +- Criterion 1 +## MUTABLE SECTION +### Plan Version: 1 (Updated: Round 0) +#### Active Tasks +| Task | Target AC | Status | Notes | +|------|-----------|--------|-------| +| Task 1 | AC1 | done | - | +EOF +set +e +RESULT=$(echo '{}' | "$PROJECT_ROOT/hooks/loop-codex-stop-hook.sh" 2>&1) +EXIT_CODE=$? +set -e +if echo "$RESULT" | grep -q '"decision"' && echo "$RESULT" | grep -qi "plan.*deleted"; then + pass "Stop hook blocks when plan file is deleted" +else + fail "Stop hook plan deletion detection" "block with plan deleted error" "exit $EXIT_CODE, output: $RESULT" +fi + +# Test 11: Stop hook blocks when plan backup is missing +echo "Test 11: Stop hook blocks when plan backup is missing" +setup_test_loop +# Remove the backup +rm -f "$LOOP_DIR/plan.md" +cat > "$LOOP_DIR/round-0-summary.md" << 'EOF' +# Summary +Work done. +EOF +set +e +RESULT=$(echo '{}' | "$PROJECT_ROOT/hooks/loop-codex-stop-hook.sh" 2>&1) +EXIT_CODE=$? +set -e +if echo "$RESULT" | grep -q '"decision"' && echo "$RESULT" | grep -qi "backup.*not found\|plan.*backup"; then + pass "Stop hook blocks when plan backup is missing" +else + fail "Stop hook plan backup detection" "block with backup missing error" "exit $EXIT_CODE, output: $RESULT" +fi + +# Test 12: Stop hook detects tracked file modifications (Fix #3 - Race condition) +echo "Test 12: Stop hook detects tracked plan file modifications" +cd "$TEST_DIR" +rm -rf tracked-stop-test 2>/dev/null || true +mkdir -p tracked-stop-test +cd tracked-stop-test +git init -q +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" +# Get the default branch name for this new repo +TEST12_BRANCH=$(git rev-parse --abbrev-ref HEAD) +# Create tracked plan file +cat > tracked-plan.md << 'EOF' +# Tracked Plan +## Goal +Test tracked file +## Requirements +- Requirement 1 +EOF +git add tracked-plan.md +git commit -q -m "Add plan" +# Create loop directory +TRACKED_LOOP_DIR="$PWD/.humanize-loop.local/2024-01-01_12-00-00" +mkdir -p "$TRACKED_LOOP_DIR" +cp tracked-plan.md "$TRACKED_LOOP_DIR/plan.md" +cat > "$TRACKED_LOOP_DIR/state.md" << EOF +--- +current_round: 0 +max_iterations: 42 +plan_file: tracked-plan.md +plan_tracked: true +start_branch: $TEST12_BRANCH +--- +EOF +cat > "$TRACKED_LOOP_DIR/round-0-summary.md" << 'EOF' +# Summary +Work done. +EOF +cat > "$TRACKED_LOOP_DIR/goal-tracker.md" << 'EOF' +# Goal Tracker +## IMMUTABLE SECTION +### Ultimate Goal +Test goal +### Acceptance Criteria +- Criterion 1 +## MUTABLE SECTION +### Plan Version: 1 (Updated: Round 0) +#### Active Tasks +| Task | Target AC | Status | Notes | +|------|-----------|--------|-------| +| Task 1 | AC1 | done | - | +EOF +# Now modify the tracked plan file (simulate race condition) +echo "# Modified" >> tracked-plan.md +export CLAUDE_PROJECT_DIR="$PWD" +set +e +RESULT=$(echo '{}' | "$PROJECT_ROOT/hooks/loop-codex-stop-hook.sh" 2>&1) +EXIT_CODE=$? +set -e +# Should detect modification via git status +if echo "$RESULT" | grep -q '"decision"' && echo "$RESULT" | grep -qi "plan.*modif\|uncommitted"; then + pass "Stop hook detects tracked plan file modifications" +else + fail "Stop hook tracked file detection" "block with modification error" "exit $EXIT_CODE, output: $RESULT" +fi + +# Test 13: Stop hook returns JSON block for outdated schema (Fix #5) +echo "Test 13: Stop hook returns JSON block for outdated schema" +cd "$TEST_DIR" +setup_test_loop +export CLAUDE_PROJECT_DIR="$TEST_DIR" +# Create state without plan_tracked (old schema) +cat > "$LOOP_DIR/state.md" << 'EOF' +--- +current_round: 0 +max_iterations: 42 +plan_file: plans/test-plan.md +--- +EOF +set +e +RESULT=$(echo '{}' | "$PROJECT_ROOT/hooks/loop-codex-stop-hook.sh" 2>&1) +EXIT_CODE=$? +set -e +# Should return JSON with block decision, not silently exit +if echo "$RESULT" | grep -q '"decision".*"block"' && echo "$RESULT" | grep -qi "schema\|missing.*field\|plan_tracked"; then + pass "Stop hook returns JSON block for outdated schema" +else + fail "Stop hook schema blocking" "JSON block response" "exit $EXIT_CODE, output: $RESULT" +fi + +# Test 14: Stop hook blocks tracked file with committed changes (content differs from backup) +# This tests the security fix: even if git status is clean, content must match backup +echo "Test 14: Stop hook blocks tracked file with committed changes" +cd "$TEST_DIR" +rm -rf tracked-commit-test 2>/dev/null || true +mkdir -p tracked-commit-test +cd tracked-commit-test +git init -q +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" +# Get the default branch name for this new repo +TEST14_BRANCH=$(git rev-parse --abbrev-ref HEAD) +# Create tracked plan file +cat > tracked-plan.md << 'EOF' +# Tracked Plan +## Goal +Test tracked file +## Requirements +- Requirement 1 +EOF +git add tracked-plan.md +git commit -q -m "Add plan" +# Create loop directory and backup +TRACKED_LOOP_DIR="$PWD/.humanize-loop.local/2024-01-01_12-00-00" +mkdir -p "$TRACKED_LOOP_DIR" +cp tracked-plan.md "$TRACKED_LOOP_DIR/plan.md" +cat > "$TRACKED_LOOP_DIR/state.md" << EOF +--- +current_round: 0 +max_iterations: 42 +plan_file: tracked-plan.md +plan_tracked: true +start_branch: $TEST14_BRANCH +--- +EOF +cat > "$TRACKED_LOOP_DIR/round-0-summary.md" << 'EOF' +# Summary +Work done. +EOF +cat > "$TRACKED_LOOP_DIR/goal-tracker.md" << 'EOF' +# Goal Tracker +## IMMUTABLE SECTION +### Ultimate Goal +Test goal +### Acceptance Criteria +- Criterion 1 +## MUTABLE SECTION +### Plan Version: 1 (Updated: Round 0) +#### Active Tasks +| Task | Target AC | Status | Notes | +|------|-----------|--------|-------| +| Task 1 | AC1 | done | - | +EOF +# Modify and COMMIT the plan file (git status will be clean) +echo "# Modified and committed" >> tracked-plan.md +git add tracked-plan.md +git commit -q -m "Modify plan" +# Verify git status is clean for the plan file +GIT_STATUS_CHECK=$(git status --porcelain tracked-plan.md) +if [[ -n "$GIT_STATUS_CHECK" ]]; then + fail "Test 14 setup" "clean git status" "git status: $GIT_STATUS_CHECK" +else + export CLAUDE_PROJECT_DIR="$PWD" + set +e + RESULT=$(echo '{}' | "$PROJECT_ROOT/hooks/loop-codex-stop-hook.sh" 2>&1) + EXIT_CODE=$? + set -e + # Should detect modification via content diff (not git status) + if echo "$RESULT" | grep -q '"decision"' && echo "$RESULT" | grep -qi "plan.*modif"; then + pass "Stop hook blocks tracked file with committed changes" + else + fail "Stop hook committed file detection" "block with modification error" "exit $EXIT_CODE, output: $RESULT" + fi +fi + +echo "" +echo "=========================================" +echo "Test Results" +echo "=========================================" +echo -e "Passed: ${GREEN}$TESTS_PASSED${NC}" +echo -e "Failed: ${RED}$TESTS_FAILED${NC}" +echo -e "Skipped: ${YELLOW}$TESTS_SKIPPED${NC}" +echo "" + +exit $TESTS_FAILED diff --git a/tests/test-plan-file-validation.sh b/tests/test-plan-file-validation.sh new file mode 100755 index 00000000..e24fe2e9 --- /dev/null +++ b/tests/test-plan-file-validation.sh @@ -0,0 +1,552 @@ +#!/bin/bash +# +# Tests for plan file validation in setup-rlcr-loop.sh +# +# Tests: +# - Absolute path rejection +# - Relative path within project +# - Symlink rejection +# - Submodule rejection +# - Git repo validation +# - Plan file tracking status validation +# + +set -uo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" + +# Test helpers +GREEN='\033[0;32m' +RED='\033[0;31m' +YELLOW='\033[0;33m' +NC='\033[0m' +TESTS_PASSED=0 +TESTS_FAILED=0 +TESTS_SKIPPED=0 + +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)); } +skip() { echo -e "${YELLOW}SKIP${NC}: $1 - $2"; TESTS_SKIPPED=$((TESTS_SKIPPED + 1)); } + +# Setup test environment +TEST_DIR=$(mktemp -d) +trap "rm -rf $TEST_DIR" EXIT + +setup_test_repo() { + cd "$TEST_DIR" + + # Only init git if not already initialized + if [[ ! -d ".git" ]]; then + git init -q + git config user.email "test@test.com" + git config user.name "Test" + echo "initial" > init.txt + git add init.txt + git commit -q -m "Initial commit" + + # Create test plan files + mkdir -p plans + cat > plans/test-plan.md << 'EOF' +# Test Plan + +## Goal +Test the RLCR loop functionality + +## Requirements +- Requirement 1 +- Requirement 2 +- Requirement 3 +EOF + + # Add plans/ to gitignore (default behavior) + echo "plans/" >> .gitignore + git add .gitignore + git commit -q -m "Add gitignore" + fi +} + +# Mock codex command if not available +mock_codex() { + if ! command -v codex &>/dev/null; then + mkdir -p "$TEST_DIR/bin" + cat > "$TEST_DIR/bin/codex" << 'EOF' +#!/bin/bash +echo "mock codex" +EOF + chmod +x "$TEST_DIR/bin/codex" + export PATH="$TEST_DIR/bin:$PATH" + fi +} + +echo "=== Test: Plan File Path Validation ===" +echo "" + +# Test 1: Absolute path should fail +setup_test_repo +mock_codex + +echo "Test 1: Reject absolute path" +set +e +RESULT=$("$PROJECT_ROOT/scripts/setup-rlcr-loop.sh" "/absolute/path/plan.md" 2>&1) +EXIT_CODE=$? +set -e +if [[ $EXIT_CODE -ne 0 ]] && echo "$RESULT" | grep -q "relative path"; then + pass "Absolute path rejected" +else + fail "Absolute path rejection" "exit 1 with relative path error" "$RESULT" +fi + +# Test 2: Non-existent file should fail +echo "Test 2: Reject non-existent file" +set +e +RESULT=$("$PROJECT_ROOT/scripts/setup-rlcr-loop.sh" "nonexistent.md" 2>&1) +EXIT_CODE=$? +set -e +if [[ $EXIT_CODE -ne 0 ]] && echo "$RESULT" | grep -q "not found"; then + pass "Non-existent file rejected" +else + fail "Non-existent file rejection" "exit 1 with not found error" "$RESULT" +fi + +# Test 2.5: Non-existent directory should fail with clear error +echo "Test 2.5: Reject non-existent parent directory" +set +e +RESULT=$("$PROJECT_ROOT/scripts/setup-rlcr-loop.sh" "nonexistent-dir/plan.md" 2>&1) +EXIT_CODE=$? +set -e +if [[ $EXIT_CODE -ne 0 ]] && echo "$RESULT" | grep -q "directory not found"; then + pass "Non-existent parent directory rejected with clear error" +else + fail "Non-existent parent directory rejection" "exit 1 with directory not found error" "$RESULT" +fi + +# Test 2.6: Path with spaces should fail +echo "Test 2.6: Reject path with spaces" +mkdir -p "$TEST_DIR/path with spaces" +cat > "$TEST_DIR/path with spaces/plan.md" << 'EOF' +# Plan +## Goal +Test spaces +## Requirements +- Requirement 1 +- Requirement 2 +EOF +set +e +RESULT=$("$PROJECT_ROOT/scripts/setup-rlcr-loop.sh" "path with spaces/plan.md" 2>&1) +EXIT_CODE=$? +set -e +if [[ $EXIT_CODE -ne 0 ]] && echo "$RESULT" | grep -q "cannot contain spaces"; then + pass "Path with spaces rejected" +else + fail "Path with spaces rejection" "exit 1 with spaces error" "$RESULT" +fi + +# Test 2.7: Filename with spaces should fail +echo "Test 2.7: Reject filename with spaces" +cat > "$TEST_DIR/plan with spaces.md" << 'EOF' +# Plan +## Goal +Test spaces +## Requirements +- Requirement 1 +- Requirement 2 +EOF +set +e +RESULT=$("$PROJECT_ROOT/scripts/setup-rlcr-loop.sh" "plan with spaces.md" 2>&1) +EXIT_CODE=$? +set -e +if [[ $EXIT_CODE -ne 0 ]] && echo "$RESULT" | grep -q "cannot contain spaces"; then + pass "Filename with spaces rejected" +else + fail "Filename with spaces rejection" "exit 1 with spaces error" "$RESULT" +fi + +# Test 2.8: Path with shell metacharacters should fail +echo "Test 2.8: Reject path with shell metacharacters" +cat > "$TEST_DIR/plans/test-plan.md" << 'EOF' +# Plan +## Goal +Test metacharacters +## Requirements +- Requirement 1 +- Requirement 2 +EOF +# Test various shell metacharacters +for meta_char in ';' '&' '|' '$' '`' '<' '>' '(' ')' '{' '}' '[' ']' '!' '#' '~' '*' '?'; do + RESULT=$("$PROJECT_ROOT/scripts/setup-rlcr-loop.sh" "plans/test${meta_char}plan.md" 2>&1) || true + if ! echo "$RESULT" | grep -q "shell metacharacters"; then + fail "Shell metacharacter rejection ($meta_char)" "error mentioning metacharacters" "$RESULT" + break + fi +done +pass "Path with shell metacharacters rejected" + +# Test 3: Symlink should fail +echo "Test 3: Reject symbolic link" +ln -sf plans/test-plan.md "$TEST_DIR/link-plan.md" +set +e +RESULT=$("$PROJECT_ROOT/scripts/setup-rlcr-loop.sh" "link-plan.md" 2>&1) +EXIT_CODE=$? +set -e +if [[ $EXIT_CODE -ne 0 ]] && echo "$RESULT" | grep -q "symbolic link"; then + pass "Symlink rejected" +else + fail "Symlink rejection" "exit 1 with symbolic link error" "$RESULT" +fi + +# Test 3.5: Path resolution error handling (Fix #4) +echo "Test 3.5: Handle path resolution errors gracefully" +# Create a directory structure where cd might fail +mkdir -p "$TEST_DIR/permission-test" +cd "$TEST_DIR/permission-test" +git init -q +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" +# Create a plan directory that we'll make inaccessible +mkdir -p plans +cat > plans/plan.md << 'EOF' +# Plan +## Goal +Test path resolution +## Requirements +- Requirement 1 +- Requirement 2 +EOF +echo "plans/" >> .gitignore +git add .gitignore +git commit -q -m "Gitignore" +# Make the plans directory unreadable (if we have permission to do so) +if chmod 000 plans 2>/dev/null; then + set +e + RESULT=$("$PROJECT_ROOT/scripts/setup-rlcr-loop.sh" "plans/plan.md" 2>&1) + EXIT_CODE=$? + set -e + # Restore permissions for cleanup + chmod 755 plans + # Should fail with clear error about directory access + if [[ $EXIT_CODE -ne 0 ]] && echo "$RESULT" | grep -qE "resolve|not found|directory"; then + pass "Path resolution error handled gracefully" + else + fail "Path resolution error" "clear error message" "exit $EXIT_CODE, output: $RESULT" + fi +else + skip "Path resolution error" "cannot change permissions in test environment" +fi +cd "$TEST_DIR" + +# Test 4: Plan outside project (../ escape) should fail +echo "Test 4: Reject path escaping project directory" +mkdir -p "$TEST_DIR/outside" +cat > "$TEST_DIR/outside/escape-plan.md" << 'EOF' +# Escape Plan +## Goal +Test escape +## Requirements +- Requirement 1 +- Requirement 2 +EOF +mkdir -p "$TEST_DIR/project" +cd "$TEST_DIR/project" +git init -q +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" +set +e +RESULT=$("$PROJECT_ROOT/scripts/setup-rlcr-loop.sh" "../outside/escape-plan.md" 2>&1) +EXIT_CODE=$? +set -e +if [[ $EXIT_CODE -ne 0 ]] && echo "$RESULT" | grep -qE "(within project|not found)"; then + pass "Path escape rejected" +else + fail "Path escape rejection" "exit 1 with project directory error" "$RESULT" +fi + +# Test 5: Non-git repo should fail +echo "Test 5: Reject non-git repository" +# Create a completely separate directory that is NOT inside any git repo +NOGIT_DIR=$(mktemp -d) +cd "$NOGIT_DIR" +cat > plan.md << 'EOF' +# Plan +## Goal +Test non-git +## Requirements +- Requirement 1 +- Requirement 2 +EOF +set +e +RESULT=$("$PROJECT_ROOT/scripts/setup-rlcr-loop.sh" "plan.md" 2>&1) +EXIT_CODE=$? +set -e +rm -rf "$NOGIT_DIR" +cd "$TEST_DIR" +if [[ $EXIT_CODE -ne 0 ]] && echo "$RESULT" | grep -q "git repository"; then + pass "Non-git repo rejected" +else + fail "Non-git repo rejection" "exit 1 with git repository error" "$RESULT" +fi + +# Test 6: Git repo without commits should fail +echo "Test 6: Reject git repo without commits" +# Create a completely separate directory that is NOT inside any git repo +NOCOMMIT_DIR=$(mktemp -d) +cd "$NOCOMMIT_DIR" +git init -q +cat > plan.md << 'EOF' +# Plan +## Goal +Test no commits +## Requirements +- Requirement 1 +- Requirement 2 +EOF +set +e +RESULT=$("$PROJECT_ROOT/scripts/setup-rlcr-loop.sh" "plan.md" 2>&1) +EXIT_CODE=$? +set -e +rm -rf "$NOCOMMIT_DIR" +cd "$TEST_DIR" +if [[ $EXIT_CODE -ne 0 ]] && echo "$RESULT" | grep -q "at least one commit"; then + pass "Git repo without commits rejected" +else + fail "Git repo without commits rejection" "exit 1 with commit error" "$RESULT" +fi + +echo "" +echo "=== Test: Plan File Tracking Validation ===" +echo "" + +# Test 7: Tracked file without --track-plan-file should fail +echo "Test 7: Reject tracked file without --track-plan-file" +cd "$TEST_DIR" +rm -rf tracked-test 2>/dev/null || true +mkdir -p tracked-test +cd tracked-test +git init -q +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" +cat > tracked-plan.md << 'EOF' +# Tracked Plan +## Goal +Test tracking +## Requirements +- Requirement 1 +- Requirement 2 +EOF +git add tracked-plan.md +git commit -q -m "Add plan" +set +e +RESULT=$("$PROJECT_ROOT/scripts/setup-rlcr-loop.sh" "tracked-plan.md" 2>&1) +EXIT_CODE=$? +set -e +if [[ $EXIT_CODE -ne 0 ]] && echo "$RESULT" | grep -q "gitignored"; then + pass "Tracked file without --track-plan-file rejected" +else + fail "Tracked file rejection" "exit 1 with gitignored error" "$RESULT" +fi + +# Test 8: Untracked file with --track-plan-file should fail +echo "Test 8: Reject untracked file with --track-plan-file" +cd "$TEST_DIR" +rm -rf untracked-test 2>/dev/null || true +mkdir -p untracked-test +cd untracked-test +git init -q +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" +mkdir -p plans +cat > plans/untracked-plan.md << 'EOF' +# Untracked Plan +## Goal +Test untracked +## Requirements +- Requirement 1 +- Requirement 2 +EOF +echo "plans/" >> .gitignore +git add .gitignore +git commit -q -m "Gitignore" +set +e +RESULT=$("$PROJECT_ROOT/scripts/setup-rlcr-loop.sh" --track-plan-file "plans/untracked-plan.md" 2>&1) +EXIT_CODE=$? +set -e +if [[ $EXIT_CODE -ne 0 ]] && echo "$RESULT" | grep -q "tracked in git"; then + pass "Untracked file with --track-plan-file rejected" +else + fail "Untracked file with --track-plan-file rejection" "exit 1 with tracked error" "$RESULT" +fi + +# Test 9: Modified tracked file with --track-plan-file should fail +echo "Test 9: Reject modified tracked file with --track-plan-file" +cd "$TEST_DIR" +rm -rf modified-test 2>/dev/null || true +mkdir -p modified-test +cd modified-test +git init -q +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" +cat > modified-plan.md << 'EOF' +# Modified Plan +## Goal +Test modified +## Requirements +- Requirement 1 +- Requirement 2 +EOF +git add modified-plan.md +git commit -q -m "Add plan" +echo "# Extra line" >> modified-plan.md +set +e +RESULT=$("$PROJECT_ROOT/scripts/setup-rlcr-loop.sh" --track-plan-file "modified-plan.md" 2>&1) +EXIT_CODE=$? +set -e +if [[ $EXIT_CODE -ne 0 ]] && echo "$RESULT" | grep -q "clean"; then + pass "Modified tracked file with --track-plan-file rejected" +else + fail "Modified tracked file rejection" "exit 1 with clean error" "$RESULT" +fi + +echo "" +echo "=== Test: Branch Name Validation ===" +echo "" + +# Test 9.5: Reject branch names with YAML-unsafe characters (Fix #2) +# Note: Git itself may reject some of these characters, which is fine +# We test that either git rejects it OR our script rejects it +echo "Test 9.5: Reject branch with colon (YAML-unsafe)" +cd "$TEST_DIR" +rm -rf branch-test 2>/dev/null || true +mkdir -p branch-test +cd branch-test +git init -q +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" +# Get the default branch name for this repo (main or master) +BRANCH_TEST_DEFAULT=$(git rev-parse --abbrev-ref HEAD) +mkdir -p plans +cat > plans/plan.md << 'EOF' +# Plan +## Goal +Test branch validation +## Requirements +- Requirement 1 +- Requirement 2 +EOF +echo "plans/" >> .gitignore +git add .gitignore +git commit -q -m "Gitignore" +# Try to create branch with colon (YAML-unsafe) - git may reject this +if git checkout -q -b "feature:test" 2>/dev/null; then + set +e + RESULT=$("$PROJECT_ROOT/scripts/setup-rlcr-loop.sh" "plans/plan.md" 2>&1) + EXIT_CODE=$? + set -e + if [[ $EXIT_CODE -ne 0 ]] && echo "$RESULT" | grep -q "YAML-unsafe"; then + pass "Branch with colon rejected" + else + fail "Branch with colon rejection" "exit 1 with YAML-unsafe error" "$RESULT" + fi + git checkout -q "$BRANCH_TEST_DEFAULT" 2>/dev/null || true +else + # Git itself rejected the branch name, which is also fine + pass "Branch with colon rejected (by git)" +fi + +# Test 9.6: Reject branch names with hash (YAML comment) +echo "Test 9.6: Reject branch with hash (YAML comment)" +git checkout -q "$BRANCH_TEST_DEFAULT" 2>/dev/null || true +# Try to create a branch with hash - some git versions may not allow this +if git checkout -q -b "test#comment" 2>/dev/null; then + set +e + RESULT=$("$PROJECT_ROOT/scripts/setup-rlcr-loop.sh" "plans/plan.md" 2>&1) + EXIT_CODE=$? + set -e + if [[ $EXIT_CODE -ne 0 ]] && echo "$RESULT" | grep -q "YAML-unsafe"; then + pass "Branch with hash rejected" + else + fail "Branch with hash rejection" "exit 1 with YAML-unsafe error" "$RESULT" + fi + git checkout -q "$BRANCH_TEST_DEFAULT" 2>/dev/null || true +else + pass "Branch with hash rejected (by git)" +fi + +# Test 9.7: Reject branch names with quotes +echo "Test 9.7: Reject branch with quotes (YAML-unsafe)" +git checkout -q "$BRANCH_TEST_DEFAULT" 2>/dev/null || true +if git checkout -q -b 'test"quote' 2>/dev/null; then + set +e + RESULT=$("$PROJECT_ROOT/scripts/setup-rlcr-loop.sh" "plans/plan.md" 2>&1) + EXIT_CODE=$? + set -e + if [[ $EXIT_CODE -ne 0 ]] && echo "$RESULT" | grep -q "YAML-unsafe"; then + pass "Branch with quotes rejected" + else + fail "Branch with quotes rejection" "exit 1 with YAML-unsafe error" "$RESULT" + fi + git checkout -q "$BRANCH_TEST_DEFAULT" 2>/dev/null || true +else + pass "Branch with quotes rejected (by git)" +fi + +echo "" +echo "=== Test: CLI Options ===" +echo "" + +# Test 10: --plan-file option works +echo "Test 10: --plan-file option" +cd "$TEST_DIR" +setup_test_repo +mock_codex +set +e +# This should fail validation (not actually run), but pass CLI parsing +RESULT=$("$PROJECT_ROOT/scripts/setup-rlcr-loop.sh" --plan-file "plans/test-plan.md" 2>&1) +EXIT_CODE=$? +set -e +# Should get past CLI parsing - either run or fail on some validation +if ! echo "$RESULT" | grep -q "requires a file path"; then + pass "--plan-file option accepted" +else + fail "--plan-file option" "option accepted" "$RESULT" +fi + +# Test 11: Both --plan-file and positional should fail +echo "Test 11: Reject both --plan-file and positional" +set +e +RESULT=$("$PROJECT_ROOT/scripts/setup-rlcr-loop.sh" --plan-file "plans/a.md" "plans/b.md" 2>&1) +EXIT_CODE=$? +set -e +if [[ $EXIT_CODE -ne 0 ]] && echo "$RESULT" | grep -q "Cannot specify both"; then + pass "Both --plan-file and positional rejected" +else + fail "Both options rejection" "exit 1 with both error" "$RESULT" +fi + +echo "" +echo "=========================================" +echo "Test Results" +echo "=========================================" +echo -e "Passed: ${GREEN}$TESTS_PASSED${NC}" +echo -e "Failed: ${RED}$TESTS_FAILED${NC}" +echo -e "Skipped: ${YELLOW}$TESTS_SKIPPED${NC}" +echo "" + +exit $TESTS_FAILED diff --git a/tests/test-state-exit-naming.sh b/tests/test-state-exit-naming.sh new file mode 100755 index 00000000..fe9c39cf --- /dev/null +++ b/tests/test-state-exit-naming.sh @@ -0,0 +1,250 @@ +#!/bin/bash +# +# Tests for state.md rename on exit +# +# Tests: +# - complete-state.md on COMPLETE +# - stop-state.md on STOP +# - maxiter-state.md on max iterations +# - cancel-state.md on cancel +# - unexpected-state.md on schema error +# + +set -uo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" + +# Test helpers +GREEN='\033[0;32m' +RED='\033[0;31m' +YELLOW='\033[0;33m' +NC='\033[0m' +TESTS_PASSED=0 +TESTS_FAILED=0 +TESTS_SKIPPED=0 + +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)); } +skip() { echo -e "${YELLOW}SKIP${NC}: $1 - $2"; TESTS_SKIPPED=$((TESTS_SKIPPED + 1)); } + +# Setup test environment +TEST_DIR=$(mktemp -d) +trap "rm -rf $TEST_DIR" EXIT + +echo "=== Test: State Exit Naming Conventions ===" +echo "" + +# Test 1: Only state.md indicates active loop +echo "Test 1: Only state.md indicates active loop" +cd "$TEST_DIR" +git init -q +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" + +LOOP_DIR="$TEST_DIR/.humanize-loop.local/2024-01-01_12-00-00" +mkdir -p "$LOOP_DIR" + +# Create completed state (should not be detected as active) +cat > "$LOOP_DIR/complete-state.md" << 'EOF' +--- +current_round: 5 +max_iterations: 42 +plan_file: plan.md +plan_tracked: false +start_branch: main +--- +EOF + +export CLAUDE_PROJECT_DIR="$TEST_DIR" + +# Source the loop-common.sh to get find_active_loop +source "$PROJECT_ROOT/hooks/lib/loop-common.sh" + +ACTIVE_LOOP=$(find_active_loop "$TEST_DIR/.humanize-loop.local") +if [[ -z "$ACTIVE_LOOP" ]]; then + pass "complete-state.md not detected as active loop" +else + fail "complete-state.md detection" "no active loop" "$ACTIVE_LOOP" +fi + +# Test 2: state.md IS detected as active loop +echo "Test 2: state.md is detected as active loop" +cat > "$LOOP_DIR/state.md" << 'EOF' +--- +current_round: 0 +max_iterations: 42 +plan_file: plan.md +plan_tracked: false +start_branch: main +--- +EOF + +ACTIVE_LOOP=$(find_active_loop "$TEST_DIR/.humanize-loop.local") +if [[ -n "$ACTIVE_LOOP" ]]; then + pass "state.md detected as active loop" +else + fail "state.md detection" "active loop found" "no active loop" +fi + +# Test 3: cancel-state.md not detected as active +echo "Test 3: cancel-state.md not detected as active loop" +rm -f "$LOOP_DIR/state.md" +cat > "$LOOP_DIR/cancel-state.md" << 'EOF' +--- +current_round: 3 +max_iterations: 42 +--- +EOF + +ACTIVE_LOOP=$(find_active_loop "$TEST_DIR/.humanize-loop.local") +if [[ -z "$ACTIVE_LOOP" ]]; then + pass "cancel-state.md not detected as active loop" +else + fail "cancel-state.md detection" "no active loop" "$ACTIVE_LOOP" +fi + +# Test 4: unexpected-state.md not detected as active +echo "Test 4: unexpected-state.md not detected as active loop" +cat > "$LOOP_DIR/unexpected-state.md" << 'EOF' +--- +current_round: 2 +--- +EOF + +ACTIVE_LOOP=$(find_active_loop "$TEST_DIR/.humanize-loop.local") +if [[ -z "$ACTIVE_LOOP" ]]; then + pass "unexpected-state.md not detected as active loop" +else + fail "unexpected-state.md detection" "no active loop" "$ACTIVE_LOOP" +fi + +# Test 5: maxiter-state.md not detected as active +echo "Test 5: maxiter-state.md not detected as active loop" +cat > "$LOOP_DIR/maxiter-state.md" << 'EOF' +--- +current_round: 42 +max_iterations: 42 +--- +EOF + +ACTIVE_LOOP=$(find_active_loop "$TEST_DIR/.humanize-loop.local") +if [[ -z "$ACTIVE_LOOP" ]]; then + pass "maxiter-state.md not detected as active loop" +else + fail "maxiter-state.md detection" "no active loop" "$ACTIVE_LOOP" +fi + +# Test 6: stop-state.md not detected as active +echo "Test 6: stop-state.md not detected as active loop" +cat > "$LOOP_DIR/stop-state.md" << 'EOF' +--- +current_round: 9 +max_iterations: 42 +--- +EOF + +ACTIVE_LOOP=$(find_active_loop "$TEST_DIR/.humanize-loop.local") +if [[ -z "$ACTIVE_LOOP" ]]; then + pass "stop-state.md not detected as active loop" +else + fail "stop-state.md detection" "no active loop" "$ACTIVE_LOOP" +fi + +# Test 7: Newer directory with state.md takes precedence +echo "Test 7: Newer directory with state.md takes precedence" +NEWER_LOOP_DIR="$TEST_DIR/.humanize-loop.local/2024-01-02_12-00-00" +mkdir -p "$NEWER_LOOP_DIR" +cat > "$NEWER_LOOP_DIR/state.md" << 'EOF' +--- +current_round: 0 +max_iterations: 10 +plan_file: new-plan.md +plan_tracked: false +start_branch: main +--- +EOF + +ACTIVE_LOOP=$(find_active_loop "$TEST_DIR/.humanize-loop.local") +if [[ "$ACTIVE_LOOP" == "$NEWER_LOOP_DIR" ]]; then + pass "Newer directory with state.md takes precedence" +else + fail "Newer directory precedence" "$NEWER_LOOP_DIR" "$ACTIVE_LOOP" +fi + +echo "" +echo "=== Test: end_loop() Function ===" +echo "" + +# Test 8: end_loop rejects invalid reason +echo "Test 8: end_loop rejects invalid reason" +END_LOOP_TEST_DIR="$TEST_DIR/.humanize-loop.local/2024-01-03_12-00-00" +mkdir -p "$END_LOOP_TEST_DIR" +cat > "$END_LOOP_TEST_DIR/state.md" << 'EOF' +--- +current_round: 0 +--- +EOF + +set +e +RESULT=$(end_loop "$END_LOOP_TEST_DIR" "$END_LOOP_TEST_DIR/state.md" "invalid_reason" 2>&1) +EXIT_CODE=$? +set -e +if [[ $EXIT_CODE -ne 0 ]] && echo "$RESULT" | grep -q "Invalid end_loop reason"; then + pass "end_loop rejects invalid reason" +else + fail "end_loop invalid reason" "exit 1 with invalid reason error" "exit $EXIT_CODE: $RESULT" +fi + +# Test 9: end_loop creates correct file for each valid reason +echo "Test 9: end_loop creates correct files for valid reasons" +REASONS_PASS=true +for reason in complete cancel maxiter stop unexpected; do + mkdir -p "$END_LOOP_TEST_DIR" + cat > "$END_LOOP_TEST_DIR/state.md" << 'EOF' +--- +current_round: 0 +--- +EOF + set +e + end_loop "$END_LOOP_TEST_DIR" "$END_LOOP_TEST_DIR/state.md" "$reason" >/dev/null 2>&1 + EXIT_CODE=$? + set -e + EXPECTED_FILE="$END_LOOP_TEST_DIR/${reason}-state.md" + if [[ $EXIT_CODE -ne 0 ]] || [[ ! -f "$EXPECTED_FILE" ]]; then + fail "end_loop $reason" "$EXPECTED_FILE exists" "exit $EXIT_CODE, file exists: $(test -f "$EXPECTED_FILE" && echo yes || echo no)" + REASONS_PASS=false + break + fi + rm -f "$EXPECTED_FILE" +done +if [[ "$REASONS_PASS" == "true" ]]; then + pass "end_loop creates correct files for all valid reasons" +fi + +# Test 10: end_loop handles missing state file +echo "Test 10: end_loop handles missing state file gracefully" +rm -f "$END_LOOP_TEST_DIR/state.md" +set +e +RESULT=$(end_loop "$END_LOOP_TEST_DIR" "$END_LOOP_TEST_DIR/state.md" "complete" 2>&1) +EXIT_CODE=$? +set -e +if [[ $EXIT_CODE -ne 0 ]] && echo "$RESULT" | grep -q "State file not found"; then + pass "end_loop handles missing state file" +else + fail "end_loop missing state file" "exit 1 with not found warning" "exit $EXIT_CODE: $RESULT" +fi + +echo "" +echo "=========================================" +echo "Test Results" +echo "=========================================" +echo -e "Passed: ${GREEN}$TESTS_PASSED${NC}" +echo -e "Failed: ${RED}$TESTS_FAILED${NC}" +echo -e "Skipped: ${YELLOW}$TESTS_SKIPPED${NC}" +echo "" + +exit $TESTS_FAILED