From 16c6b6b0c79a1eb9c0e2ab6b6d91b7ad672ff24a Mon Sep 17 00:00:00 2001 From: Sihao Liu Date: Thu, 15 Jan 2026 13:02:20 -0800 Subject: [PATCH 01/12] [command] fix bash command exec format --- commands/start-rlcr-loop.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 ``` From dcf7cbff5a006147e98ab76ff71cab7c8c863d6c Mon Sep 17 00:00:00 2001 From: Sihao Liu Date: Thu, 15 Jan 2026 14:07:03 -0800 Subject: [PATCH 02/12] impl(cli) init implementation effort to add CLI to track plan file --- .claude-plugin/marketplace.json | 2 +- .claude-plugin/plugin.json | 2 +- .github/workflows/plan-file-test.yml | 51 +++ .gitignore | 3 +- README.md | 2 +- commands/cancel-rlcr-loop.md | 8 +- hooks/hooks.json | 10 + hooks/loop-bash-validator.sh | 11 + hooks/loop-codex-stop-hook.sh | 101 ++++- hooks/loop-edit-validator.sh | 14 + hooks/loop-plan-file-validator.sh | 130 +++++++ hooks/loop-write-validator.sh | 19 +- .../block/plan-backup-protected.md | 7 + prompt-template/block/plan-file-modified.md | 12 + prompt-template/block/schema-outdated.md | 12 + scripts/setup-rlcr-loop.sh | 152 +++++++- tests/test-plan-file-hooks.sh | 239 ++++++++++++ tests/test-plan-file-validation.sh | 350 ++++++++++++++++++ tests/test-state-exit-naming.sh | 187 ++++++++++ 19 files changed, 1285 insertions(+), 27 deletions(-) create mode 100644 .github/workflows/plan-file-test.yml create mode 100755 hooks/loop-plan-file-validator.sh create mode 100644 prompt-template/block/plan-backup-protected.md create mode 100644 prompt-template/block/plan-file-modified.md create mode 100644 prompt-template/block/schema-outdated.md create mode 100755 tests/test-plan-file-hooks.sh create mode 100755 tests/test-plan-file-validation.sh create mode 100755 tests/test-state-exit-naming.sh 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..d2390695 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. diff --git a/commands/cancel-rlcr-loop.md b/commands/cancel-rlcr-loop.md index a8595f10..4d00b555 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 .humanize-loop.local/*/state.md:*)", "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" --- @@ -18,7 +18,7 @@ ls .humanize-loop.local/*/state.md 2>/dev/null || echo "NO_LOOP" 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)" + - Rename the state file to cancel-state.md using: `mv .humanize-loop.local/*/state.md .humanize-loop.local/*/cancel-state.md` + - Report: "Cancelled RLCR loop (was at round N of M). State preserved as cancel-state.md" -The loop directory with summaries and review results will be preserved for reference. +The loop directory with summaries, review results, and state information will be preserved for reference. 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/loop-bash-validator.sh b/hooks/loop-bash-validator.sh index c9d40d5c..9eb8fdca 100755 --- a/hooks/loop-bash-validator.sh +++ b/hooks/loop-bash-validator.sh @@ -74,6 +74,17 @@ 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 + +if echo "$COMMAND" | grep -qE "(>|>>|tee|mv|cp|rm).*\.humanize-loop\.local/[^/]+/plan\.md"; then + REASON="Modifying plan.md backup via bash is not allowed during RLCR loop." + 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..cf97b6f7 100755 --- a/hooks/loop-codex-stop-hook.sh +++ b/hooks/loop-codex-stop-hook.sh @@ -58,6 +58,95 @@ fi STATE_FILE="$LOOP_DIR/state.md" +# ======================================== +# Parse State File (Early - needed for integrity checks) +# ======================================== + +if [[ ! -f "$STATE_FILE" ]]; then + exit 0 +fi + +EARLY_FRONTMATTER=$(sed -n '/^---$/,/^---$/{ /^---$/d; p; }' "$STATE_FILE" 2>/dev/null || echo "") +PLAN_TRACKED=$(echo "$EARLY_FRONTMATTER" | grep '^plan_tracked:' | sed 's/plan_tracked: *//' | tr -d ' ') +START_BRANCH=$(echo "$EARLY_FRONTMATTER" | grep '^start_branch:' | sed 's/start_branch: *//' | tr -d ' ') +PLAN_FILE_PATH=$(echo "$EARLY_FRONTMATTER" | grep '^plan_file:' | sed 's/plan_file: *//') + +# ======================================== +# Quick-check 0: Schema Validation (v1.1.2+ fields) +# ======================================== +# If schema is outdated, allow exit with unexpected status + +if [[ -z "$PLAN_TRACKED" || -z "$START_BRANCH" ]]; then + mv "$STATE_FILE" "$LOOP_DIR/unexpected-state.md" + echo "Loop terminated: state schema outdated (missing plan_tracked or start_branch)" >&2 + echo "State preserved as: $LOOP_DIR/unexpected-state.md" >&2 + echo "Please update humanize plugin to v1.1.2+ and restart the loop." >&2 + exit 0 # Allow exit +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_PATH" + +# 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_PATH +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 + +if ! diff -q "$FULL_PLAN_PATH" "$BACKUP_PLAN" &>/dev/null; then + REASON="Plan file has been modified during RLCR loop. + +Modifying plan files is forbidden during an active RLCR loop. +If you need to change the plan, please restart the RLCR loop with the updated plan file. + +Original backup: $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? # ======================================== @@ -303,7 +392,8 @@ 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" + mv "$STATE_FILE" "$LOOP_DIR/unexpected-state.md" + echo "State preserved as: $LOOP_DIR/unexpected-state.md" >&2 exit 0 fi @@ -409,7 +499,8 @@ 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" + mv "$STATE_FILE" "$LOOP_DIR/maxiter-state.md" + echo "State preserved as: $LOOP_DIR/maxiter-state.md" >&2 exit 0 fi @@ -722,7 +813,8 @@ if [[ "$LAST_LINE_TRIMMED" == "COMPLETE" ]]; then else echo "Codex review passed. Loop complete!" >&2 fi - rm -f "$STATE_FILE" + mv "$STATE_FILE" "$LOOP_DIR/complete-state.md" + echo "State preserved as: $LOOP_DIR/complete-state.md" >&2 exit 0 fi @@ -752,7 +844,8 @@ if [[ "$LAST_LINE_TRIMMED" == "STOP" ]]; then echo " $REVIEW_RESULT_FILE" >&2 fi echo "========================================" >&2 - rm -f "$STATE_FILE" + mv "$STATE_FILE" "$LOOP_DIR/stop-state.md" + echo "State preserved as: $LOOP_DIR/stop-state.md" >&2 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..df1ddb42 --- /dev/null +++ b/hooks/loop-plan-file-validator.sh @@ -0,0 +1,130 @@ +#!/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)}" + +# Read hook input (required for UserPromptSubmit hooks) +INPUT=$(cat) + +# Find active loop +LOOP_BASE_DIR="$PROJECT_ROOT/.humanize-loop.local" +if [[ ! -d "$LOOP_BASE_DIR" ]]; then + exit 0 # No loop directory +fi + +# Get the newest directory (by timestamp name, descending) +LATEST_LOOP=$(ls -1d "$LOOP_BASE_DIR"/*/ 2>/dev/null | sort -r | head -1) +if [[ -z "$LATEST_LOOP" ]]; then + exit 0 +fi + +LATEST_LOOP="${LATEST_LOOP%/}" +STATE_FILE="$LATEST_LOOP/state.md" +if [[ ! -f "$STATE_FILE" ]]; then + exit 0 # No active loop +fi + +# Parse state file +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: *//' || true) +START_BRANCH=$(echo "$FRONTMATTER" | grep '^start_branch:' | sed 's/start_branch: *//' | tr -d ' ' || true) + +# ======================================== +# Schema Validation (v1.1.2+ required fields) +# ======================================== + +# Check for plan_tracked field +if [[ -z "$PLAN_TRACKED" ]]; then + cat << 'EOF' +{ + "decision": "block", + "reason": "RLCR loop state file is missing `plan_tracked` field.\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" +} +EOF + exit 0 +fi + +# Check for start_branch field +if [[ -z "$START_BRANCH" ]]; then + cat << 'EOF' +{ + "decision": "block", + "reason": "RLCR loop state file is missing `start_branch` field.\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" +} +EOF + exit 0 +fi + +# ======================================== +# 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..7f0ac4ac 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" @@ -43,6 +45,8 @@ ARGUMENTS: (must exist and have at least 5 lines) 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,107 @@ 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 -# Check plan file exists -if [[ ! -f "$PLAN_FILE" ]]; then +# 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 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) +REAL_PLAN_PATH=$(cd "$(dirname "$FULL_PLAN_PATH")" && pwd)/$(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 +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 + +# ======================================== +# Plan File Tracking Status Validation +# ======================================== + +PLAN_GIT_STATUS=$(git -C "$PROJECT_ROOT" status --porcelain "$PLAN_FILE" 2>/dev/null || echo "") +PLAN_IS_IGNORED=$(git -C "$PROJECT_ROOT" check-ignore -q "$PLAN_FILE" 2>/dev/null && echo "true" || echo "false") +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 +314,16 @@ if ! command -v codex &>/dev/null; then exit 1 fi +# ======================================== +# Record Branch +# ======================================== + +START_BRANCH=$(git -C "$PROJECT_ROOT" rev-parse --abbrev-ref HEAD) + # ======================================== # 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 +332,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 +351,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 +362,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 +386,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 +407,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 +479,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 +526,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..2b278cd5 --- /dev/null +++ b/tests/test-plan-file-hooks.sh @@ -0,0 +1,239 @@ +#!/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 + +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" + fi + + # 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 + cat > "$LOOP_DIR/state.md" << EOF +--- +current_round: 0 +max_iterations: 42 +plan_file: plans/test-plan.md +plan_tracked: false +start_branch: main +--- +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 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: main +--- +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: main +--- +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 main + +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 + +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..f69f8404 --- /dev/null +++ b/tests/test-plan-file-validation.sh @@ -0,0 +1,350 @@ +#!/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 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 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: 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..81aea99e --- /dev/null +++ b/tests/test-state-exit-naming.sh @@ -0,0 +1,187 @@ +#!/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 "=========================================" +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 From c83c416b2e8fba9c93e50c0363083620621949cb Mon Sep 17 00:00:00 2001 From: Sihao Liu Date: Thu, 15 Jan 2026 14:27:00 -0800 Subject: [PATCH 03/12] fix(hooks): address critical issues from code review - Add end_loop() function to loop-common.sh for standardized state file renaming on exit (complete, cancel, maxiter, stop, unexpected) - Refactor loop-codex-stop-hook.sh to use end_loop() instead of inline mv - Fix bash validator to use command_modifies_file() helper with rm pattern for more robust plan.md protection - Fix DRY violation in loop-plan-file-validator.sh schema validation - Use schema-outdated.md template instead of hardcoded JSON - Add parent directory existence check in setup-rlcr-loop.sh for clearer error messages when path contains typos - Add negative tests for end_loop() function and path validation --- .claude-plugin/plugin.json | 2 +- README.md | 2 +- hooks/lib/loop-common.sh | 37 ++++++++++++++++++ hooks/loop-bash-validator.sh | 5 ++- hooks/loop-codex-stop-hook.sh | 15 +++---- hooks/loop-plan-file-validator.sh | 45 +++++++++++++-------- scripts/setup-rlcr-loop.sh | 9 ++++- tests/test-plan-file-validation.sh | 12 ++++++ tests/test-state-exit-naming.sh | 63 ++++++++++++++++++++++++++++++ 9 files changed, 159 insertions(+), 31 deletions(-) diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 1c894a0c..ae353f56 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.2", + "version": "1.1.3", "author": { "name": "humania-org" }, diff --git a/README.md b/README.md index d2390695..121ac78e 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Humanize -**Current Version: 1.1.2** +**Current Version: 1.1.3** > Derived from the [GAAC (GitHub-as-a-Context)](https://github.com/SihaoLiu/gaac) project. diff --git a/hooks/lib/loop-common.sh b/hooks/lib/loop-common.sh index 89838d49..ef7d760b 100755 --- a/hooks/lib/loop-common.sh +++ b/hooks/lib/loop-common.sh @@ -161,11 +161,13 @@ 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}" ) @@ -190,3 +192,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 9eb8fdca..40437bf9 100755 --- a/hooks/loop-bash-validator.sh +++ b/hooks/loop-bash-validator.sh @@ -79,8 +79,9 @@ fi # ======================================== # Plan backup is read-only - protects plan integrity during loop -if echo "$COMMAND" | grep -qE "(>|>>|tee|mv|cp|rm).*\.humanize-loop\.local/[^/]+/plan\.md"; then - REASON="Modifying plan.md backup via bash is not allowed during RLCR loop." +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 diff --git a/hooks/loop-codex-stop-hook.sh b/hooks/loop-codex-stop-hook.sh index cf97b6f7..c7c70902 100755 --- a/hooks/loop-codex-stop-hook.sh +++ b/hooks/loop-codex-stop-hook.sh @@ -77,9 +77,8 @@ PLAN_FILE_PATH=$(echo "$EARLY_FRONTMATTER" | grep '^plan_file:' | sed 's/plan_fi # If schema is outdated, allow exit with unexpected status if [[ -z "$PLAN_TRACKED" || -z "$START_BRANCH" ]]; then - mv "$STATE_FILE" "$LOOP_DIR/unexpected-state.md" + end_loop "$LOOP_DIR" "$STATE_FILE" "unexpected" echo "Loop terminated: state schema outdated (missing plan_tracked or start_branch)" >&2 - echo "State preserved as: $LOOP_DIR/unexpected-state.md" >&2 echo "Please update humanize plugin to v1.1.2+ and restart the loop." >&2 exit 0 # Allow exit fi @@ -392,8 +391,7 @@ 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 - mv "$STATE_FILE" "$LOOP_DIR/unexpected-state.md" - echo "State preserved as: $LOOP_DIR/unexpected-state.md" >&2 + end_loop "$LOOP_DIR" "$STATE_FILE" "unexpected" exit 0 fi @@ -499,8 +497,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 - mv "$STATE_FILE" "$LOOP_DIR/maxiter-state.md" - echo "State preserved as: $LOOP_DIR/maxiter-state.md" >&2 + end_loop "$LOOP_DIR" "$STATE_FILE" "maxiter" exit 0 fi @@ -813,8 +810,7 @@ if [[ "$LAST_LINE_TRIMMED" == "COMPLETE" ]]; then else echo "Codex review passed. Loop complete!" >&2 fi - mv "$STATE_FILE" "$LOOP_DIR/complete-state.md" - echo "State preserved as: $LOOP_DIR/complete-state.md" >&2 + end_loop "$LOOP_DIR" "$STATE_FILE" "complete" exit 0 fi @@ -844,8 +840,7 @@ if [[ "$LAST_LINE_TRIMMED" == "STOP" ]]; then echo " $REVIEW_RESULT_FILE" >&2 fi echo "========================================" >&2 - mv "$STATE_FILE" "$LOOP_DIR/stop-state.md" - echo "State preserved as: $LOOP_DIR/stop-state.md" >&2 + end_loop "$LOOP_DIR" "$STATE_FILE" "stop" exit 0 fi diff --git a/hooks/loop-plan-file-validator.sh b/hooks/loop-plan-file-validator.sh index df1ddb42..1cbc6146 100755 --- a/hooks/loop-plan-file-validator.sh +++ b/hooks/loop-plan-file-validator.sh @@ -13,6 +13,9 @@ 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) @@ -45,27 +48,37 @@ START_BRANCH=$(echo "$FRONTMATTER" | grep '^start_branch:' | sed 's/start_branch # Schema Validation (v1.1.2+ required fields) # ======================================== -# Check for plan_tracked field -if [[ -z "$PLAN_TRACKED" ]]; then - cat << 'EOF' -{ - "decision": "block", - "reason": "RLCR loop state file is missing `plan_tracked` field.\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" -} -EOF - exit 0 -fi +# 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 '.') -# Check for start_branch field -if [[ -z "$START_BRANCH" ]]; then - cat << 'EOF' + cat << EOF { "decision": "block", - "reason": "RLCR loop state file is missing `start_branch` field.\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" + "reason": $escaped_reason } EOF - exit 0 -fi +} + +# 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 diff --git a/scripts/setup-rlcr-loop.sh b/scripts/setup-rlcr-loop.sh index 7f0ac4ac..96900e17 100755 --- a/scripts/setup-rlcr-loop.sh +++ b/scripts/setup-rlcr-loop.sh @@ -232,6 +232,13 @@ if [[ -L "$FULL_PLAN_PATH" ]]; then exit 1 fi +# 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 @@ -239,7 +246,7 @@ if [[ ! -f "$FULL_PLAN_PATH" ]]; then fi # Check file is within project (no ../ escaping) -REAL_PLAN_PATH=$(cd "$(dirname "$FULL_PLAN_PATH")" && pwd)/$(basename "$FULL_PLAN_PATH") +REAL_PLAN_PATH=$(cd "$PLAN_DIR" && pwd)/$(basename "$FULL_PLAN_PATH") if [[ ! "$REAL_PLAN_PATH" = "$PROJECT_ROOT"/* ]]; then echo "Error: Plan file must be within project directory" >&2 exit 1 diff --git a/tests/test-plan-file-validation.sh b/tests/test-plan-file-validation.sh index f69f8404..c3d4d861 100755 --- a/tests/test-plan-file-validation.sh +++ b/tests/test-plan-file-validation.sh @@ -109,6 +109,18 @@ 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 3: Symlink should fail echo "Test 3: Reject symbolic link" ln -sf plans/test-plan.md "$TEST_DIR/link-plan.md" diff --git a/tests/test-state-exit-naming.sh b/tests/test-state-exit-naming.sh index 81aea99e..fe9c39cf 100755 --- a/tests/test-state-exit-naming.sh +++ b/tests/test-state-exit-naming.sh @@ -175,6 +175,69 @@ 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" From f1ca6660ae8ae3e9a70930c8edd13d9f893b1eba Mon Sep 17 00:00:00 2001 From: Sihao Liu Date: Thu, 15 Jan 2026 14:31:14 -0800 Subject: [PATCH 04/12] test(hooks): add stop hook plan file integrity tests Add tests for plan file modification detection in stop hook: - Test 9: Block when plan file is modified during loop - Test 10: Block when plan file is deleted during loop - Test 11: Block when plan backup is missing Also revert version to 1.1.2 (single bump on feature branch) --- .claude-plugin/plugin.json | 2 +- README.md | 2 +- tests/test-plan-file-hooks.sh | 97 +++++++++++++++++++++++++++++++++++ 3 files changed, 99 insertions(+), 2 deletions(-) diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index ae353f56..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.3", + "version": "1.1.2", "author": { "name": "humania-org" }, diff --git a/README.md b/README.md index 121ac78e..d2390695 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Humanize -**Current Version: 1.1.3** +**Current Version: 1.1.2** > Derived from the [GAAC (GitHub-as-a-Context)](https://github.com/SihaoLiu/gaac) project. diff --git a/tests/test-plan-file-hooks.sh b/tests/test-plan-file-hooks.sh index 2b278cd5..29b82566 100755 --- a/tests/test-plan-file-hooks.sh +++ b/tests/test-plan-file-hooks.sh @@ -227,6 +227,103 @@ else fail "Bash validator blocking rm" "exit 2 with plan error" "exit $EXIT_CODE, output: $RESULT" fi +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 + echo "" echo "=========================================" echo "Test Results" From 9e2152017ef1a204c2a989180b83905a11aed27e Mon Sep 17 00:00:00 2001 From: Sihao Liu Date: Thu, 15 Jan 2026 15:10:51 -0800 Subject: [PATCH 05/12] refactor(loop): unify loop directory and state management concepts - Use find_active_loop() in loop-plan-file-validator.sh instead of duplicating logic - Update cancel-rlcr-loop.md to find newest directory first, then check state.md - Document rename-on-exit behavior (state.md -> -state.md) in README - Add Exit State Files documentation section --- README.md | 23 ++++++++++++++++------- commands/cancel-rlcr-loop.md | 25 ++++++++++++++++++------- hooks/loop-plan-file-validator.sh | 17 +++++------------ 3 files changed, 39 insertions(+), 26 deletions(-) diff --git a/README.md b/README.md index d2390695..33b70844 100644 --- a/README.md +++ b/README.md @@ -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 4d00b555..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(mv .humanize-loop.local/*/state.md .humanize-loop.local/*/cancel-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 - - Rename the state file to cancel-state.md using: `mv .humanize-loop.local/*/state.md .humanize-loop.local/*/cancel-state.md` +3. Check if the current loop is active (state.md exists): + +```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/hooks/loop-plan-file-validator.sh b/hooks/loop-plan-file-validator.sh index 1cbc6146..94dd03f3 100755 --- a/hooks/loop-plan-file-validator.sh +++ b/hooks/loop-plan-file-validator.sh @@ -19,23 +19,16 @@ source "$SCRIPT_DIR/lib/loop-common.sh" # Read hook input (required for UserPromptSubmit hooks) INPUT=$(cat) -# Find active loop +# Find active loop using shared function LOOP_BASE_DIR="$PROJECT_ROOT/.humanize-loop.local" -if [[ ! -d "$LOOP_BASE_DIR" ]]; then - exit 0 # No loop directory -fi +LOOP_DIR=$(find_active_loop "$LOOP_BASE_DIR") -# Get the newest directory (by timestamp name, descending) -LATEST_LOOP=$(ls -1d "$LOOP_BASE_DIR"/*/ 2>/dev/null | sort -r | head -1) -if [[ -z "$LATEST_LOOP" ]]; then +# If no active loop, allow exit +if [[ -z "$LOOP_DIR" ]]; then exit 0 fi -LATEST_LOOP="${LATEST_LOOP%/}" -STATE_FILE="$LATEST_LOOP/state.md" -if [[ ! -f "$STATE_FILE" ]]; then - exit 0 # No active loop -fi +STATE_FILE="$LOOP_DIR/state.md" # Parse state file FRONTMATTER=$(sed -n '/^---$/,/^---$/{ /^---$/d; p; }' "$STATE_FILE" 2>/dev/null || echo "") From 5261ecc3b8db1c5a585756bc8186a80f5fec035c Mon Sep 17 00:00:00 2001 From: Sihao Liu Date: Thu, 15 Jan 2026 15:32:04 -0800 Subject: [PATCH 06/12] refactor(hooks): consolidate state parsing and fix pipefail issues - Remove unused PLAN_IS_IGNORED variable from setup-rlcr-loop.sh - Consolidate redundant state file parsing in stop hook into single section - Update plan-file-modified check to use load_and_render_safe with template - Add || true to grep pipelines for pipefail compatibility with missing fields --- hooks/loop-codex-stop-hook.sh | 102 +++++++++++++++++----------------- scripts/setup-rlcr-loop.sh | 1 - 2 files changed, 51 insertions(+), 52 deletions(-) diff --git a/hooks/loop-codex-stop-hook.sh b/hooks/loop-codex-stop-hook.sh index c7c70902..52097c14 100755 --- a/hooks/loop-codex-stop-hook.sh +++ b/hooks/loop-codex-stop-hook.sh @@ -59,17 +59,48 @@ fi STATE_FILE="$LOOP_DIR/state.md" # ======================================== -# Parse State File (Early - needed for integrity checks) +# Parse State File (all frontmatter fields) # ======================================== if [[ ! -f "$STATE_FILE" ]]; then exit 0 fi -EARLY_FRONTMATTER=$(sed -n '/^---$/,/^---$/{ /^---$/d; p; }' "$STATE_FILE" 2>/dev/null || echo "") -PLAN_TRACKED=$(echo "$EARLY_FRONTMATTER" | grep '^plan_tracked:' | sed 's/plan_tracked: *//' | tr -d ' ') -START_BRANCH=$(echo "$EARLY_FRONTMATTER" | grep '^start_branch:' | sed 's/start_branch: *//' | tr -d ' ') -PLAN_FILE_PATH=$(echo "$EARLY_FRONTMATTER" | grep '^plan_file:' | sed 's/plan_file: *//') +FRONTMATTER=$(sed -n '/^---$/,/^---$/{ /^---$/d; p; }' "$STATE_FILE" 2>/dev/null || echo "") + +# Fields for integrity checks (may be empty for old 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: *//' | tr -d ' ' || true) +PLAN_FILE=$(echo "$FRONTMATTER" | grep '^plan_file:' | sed 's/plan_file: *//' || 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) @@ -106,7 +137,7 @@ fi # ======================================== BACKUP_PLAN="$LOOP_DIR/plan.md" -FULL_PLAN_PATH="$PROJECT_ROOT/$PLAN_FILE_PATH" +FULL_PLAN_PATH="$PROJECT_ROOT/$PLAN_FILE" # Check backup exists if [[ ! -f "$BACKUP_PLAN" ]]; then @@ -125,7 +156,7 @@ fi if [[ ! -f "$FULL_PLAN_PATH" ]]; then REASON="Project plan file has been deleted. -Original: $PLAN_FILE_PATH +Original: $PLAN_FILE Backup available at: $BACKUP_PLAN You can restore from backup if needed. Plan file modifications are not allowed during RLCR loop." @@ -135,12 +166,21 @@ You can restore from backup if needed. Plan file modifications are not allowed d fi if ! diff -q "$FULL_PLAN_PATH" "$BACKUP_PLAN" &>/dev/null; then - REASON="Plan file has been modified during RLCR loop. + 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.** -Modifying plan files is forbidden during an active RLCR loop. -If you need to change the plan, please restart the RLCR loop with the updated plan file. +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\` -Original backup: $BACKUP_PLAN" +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 @@ -330,8 +370,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) @@ -362,44 +400,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 - end_loop "$LOOP_DIR" "$STATE_FILE" "unexpected" - exit 0 -fi - -# max_iterations must be a number -if [[ ! "$MAX_ITERATIONS" =~ ^[0-9]+$ ]]; then - MAX_ITERATIONS=42 -fi - # ======================================== # Check Summary File Exists # ======================================== diff --git a/scripts/setup-rlcr-loop.sh b/scripts/setup-rlcr-loop.sh index 96900e17..42d59f35 100755 --- a/scripts/setup-rlcr-loop.sh +++ b/scripts/setup-rlcr-loop.sh @@ -269,7 +269,6 @@ fi # ======================================== PLAN_GIT_STATUS=$(git -C "$PROJECT_ROOT" status --porcelain "$PLAN_FILE" 2>/dev/null || echo "") -PLAN_IS_IGNORED=$(git -C "$PROJECT_ROOT" check-ignore -q "$PLAN_FILE" 2>/dev/null && echo "true" || echo "false") 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 From 76982ea75ef70da34fb83377498108029d1652de Mon Sep 17 00:00:00 2001 From: Sihao Liu Date: Thu, 15 Jan 2026 15:48:05 -0800 Subject: [PATCH 07/12] fix(plan-file): fix YAML injection and add space validation - Quote plan_file value in state.md YAML to prevent injection with special characters like colons and hashes - Add validation to reject plan file paths containing spaces at setup - Make space trimming consistent across all hooks (use tr -d ' ') - Update hook parsing to strip YAML quotes from plan_file value - Add tests for space validation and YAML-quoted plan_file parsing - Document no-spaces restriction in help text --- hooks/loop-codex-stop-hook.sh | 2 +- hooks/loop-plan-file-validator.sh | 2 +- scripts/setup-rlcr-loop.sh | 12 +++++++-- tests/test-plan-file-hooks.sh | 25 ++++++++++++++---- tests/test-plan-file-validation.sh | 41 ++++++++++++++++++++++++++++++ 5 files changed, 73 insertions(+), 9 deletions(-) diff --git a/hooks/loop-codex-stop-hook.sh b/hooks/loop-codex-stop-hook.sh index 52097c14..3a4341d7 100755 --- a/hooks/loop-codex-stop-hook.sh +++ b/hooks/loop-codex-stop-hook.sh @@ -71,7 +71,7 @@ FRONTMATTER=$(sed -n '/^---$/,/^---$/{ /^---$/d; p; }' "$STATE_FILE" 2>/dev/null # Fields for integrity checks (may be empty for old 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: *//' | tr -d ' ' || true) -PLAN_FILE=$(echo "$FRONTMATTER" | grep '^plan_file:' | sed 's/plan_file: *//' || true) +PLAN_FILE=$(echo "$FRONTMATTER" | grep '^plan_file:' | sed 's/plan_file: *//; s/^"//; s/"$//' | tr -d ' ' || true) # Fields for loop iteration control CURRENT_ROUND=$(echo "$FRONTMATTER" | grep '^current_round:' | sed 's/current_round: *//' | tr -d ' ' || true) diff --git a/hooks/loop-plan-file-validator.sh b/hooks/loop-plan-file-validator.sh index 94dd03f3..9b4a43b5 100755 --- a/hooks/loop-plan-file-validator.sh +++ b/hooks/loop-plan-file-validator.sh @@ -34,7 +34,7 @@ STATE_FILE="$LOOP_DIR/state.md" 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: *//' || true) +PLAN_FILE=$(echo "$FRONTMATTER" | grep '^plan_file:' | sed 's/plan_file: *//; s/^"//; s/"$//' | tr -d ' ' || true) START_BRANCH=$(echo "$FRONTMATTER" | grep '^start_branch:' | sed 's/start_branch: *//' | tr -d ' ' || true) # ======================================== diff --git a/scripts/setup-rlcr-loop.sh b/scripts/setup-rlcr-loop.sh index 42d59f35..263b046a 100755 --- a/scripts/setup-rlcr-loop.sh +++ b/scripts/setup-rlcr-loop.sh @@ -42,7 +42,7 @@ 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) @@ -223,6 +223,14 @@ if [[ "$PLAN_FILE" = /* ]]; then 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 + # Build full path FULL_PLAN_PATH="$PROJECT_ROOT/$PLAN_FILE" @@ -356,7 +364,7 @@ codex_model: $CODEX_MODEL codex_effort: $CODEX_EFFORT codex_timeout: $CODEX_TIMEOUT push_every_round: $PUSH_EVERY_ROUND -plan_file: $PLAN_FILE +plan_file: "$PLAN_FILE" plan_tracked: $TRACK_PLAN_FILE start_branch: $START_BRANCH started_at: $(date -u +%Y-%m-%dT%H:%M:%SZ) diff --git a/tests/test-plan-file-hooks.sh b/tests/test-plan-file-hooks.sh index 29b82566..b1138f95 100755 --- a/tests/test-plan-file-hooks.sh +++ b/tests/test-plan-file-hooks.sh @@ -64,12 +64,12 @@ EOF # Create plan backup cp plans/test-plan.md "$LOOP_DIR/plan.md" - # Create state file with v1.1.2+ fields + # Create state file with v1.1.2+ fields (plan_file is quoted in YAML) cat > "$LOOP_DIR/state.md" << EOF --- current_round: 0 max_iterations: 42 -plan_file: plans/test-plan.md +plan_file: "plans/test-plan.md" plan_tracked: false start_branch: main --- @@ -94,13 +94,28 @@ 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 +plan_file: "plans/test-plan.md" start_branch: main --- EOF @@ -120,7 +135,7 @@ cat > "$LOOP_DIR/state.md" << 'EOF' --- current_round: 0 max_iterations: 42 -plan_file: plans/test-plan.md +plan_file: "plans/test-plan.md" plan_tracked: false --- EOF @@ -144,7 +159,7 @@ cat > "$LOOP_DIR/state.md" << 'EOF' --- current_round: 0 max_iterations: 42 -plan_file: plans/test-plan.md +plan_file: "plans/test-plan.md" plan_tracked: false start_branch: main --- diff --git a/tests/test-plan-file-validation.sh b/tests/test-plan-file-validation.sh index c3d4d861..8793a940 100755 --- a/tests/test-plan-file-validation.sh +++ b/tests/test-plan-file-validation.sh @@ -121,6 +121,47 @@ 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 3: Symlink should fail echo "Test 3: Reject symbolic link" ln -sf plans/test-plan.md "$TEST_DIR/link-plan.md" From e93a1b0b9b2f211b65e88f67e28d8052ad79a161 Mon Sep 17 00:00:00 2001 From: Sihao Liu Date: Thu, 15 Jan 2026 15:56:55 -0800 Subject: [PATCH 08/12] fix(security): harden injection prevention and add comprehensive tests - Fix command injection bypass in bash validator by using broader pattern matching that catches shell expansion/substitution attempts - Quote start_branch in state.md to prevent YAML injection (completing the fix from 76982ea which only quoted plan_file) - Fix inconsistent quote handling in state parsers by removing tr -d ' ' from PLAN_FILE and adding quote removal to START_BRANCH parsing Tests added: - Command injection bypass prevention (5 tests): command substitution, glob expansion, brace expansion, piped commands, backtick substitution - YAML quote parsing (4 tests): quote stripping, branch mismatch detection, stop hook parsing, hyphenated paths --- hooks/loop-bash-validator.sh | 3 +- hooks/loop-codex-stop-hook.sh | 4 +- hooks/loop-plan-file-validator.sh | 4 +- scripts/setup-rlcr-loop.sh | 2 +- tests/test-plan-file-hooks.sh | 197 ++++++++++++++++++++++++++++++ 5 files changed, 204 insertions(+), 6 deletions(-) diff --git a/hooks/loop-bash-validator.sh b/hooks/loop-bash-validator.sh index 40437bf9..a6fbf931 100755 --- a/hooks/loop-bash-validator.sh +++ b/hooks/loop-bash-validator.sh @@ -78,8 +78,9 @@ fi # Block Plan Backup Modifications (All Rounds) # ======================================== # Plan backup is read-only - protects plan integrity during loop +# Use broad pattern matching to catch bypasses via shell expansion/substitution -if command_modifies_file "$COMMAND_LOWER" "\.humanize-loop\.local/[^/]+/plan\.md"; then +if echo "$COMMAND_LOWER" | grep -qE '\.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 diff --git a/hooks/loop-codex-stop-hook.sh b/hooks/loop-codex-stop-hook.sh index 3a4341d7..1e0ec427 100755 --- a/hooks/loop-codex-stop-hook.sh +++ b/hooks/loop-codex-stop-hook.sh @@ -70,8 +70,8 @@ FRONTMATTER=$(sed -n '/^---$/,/^---$/{ /^---$/d; p; }' "$STATE_FILE" 2>/dev/null # Fields for integrity checks (may be empty for old 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: *//' | tr -d ' ' || true) -PLAN_FILE=$(echo "$FRONTMATTER" | grep '^plan_file:' | sed 's/plan_file: *//; s/^"//; s/"$//' | 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) diff --git a/hooks/loop-plan-file-validator.sh b/hooks/loop-plan-file-validator.sh index 9b4a43b5..b4fcc1a9 100755 --- a/hooks/loop-plan-file-validator.sh +++ b/hooks/loop-plan-file-validator.sh @@ -34,8 +34,8 @@ STATE_FILE="$LOOP_DIR/state.md" 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/"$//' | tr -d ' ' || true) -START_BRANCH=$(echo "$FRONTMATTER" | grep '^start_branch:' | sed 's/start_branch: *//' | 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) diff --git a/scripts/setup-rlcr-loop.sh b/scripts/setup-rlcr-loop.sh index 263b046a..1152ddc2 100755 --- a/scripts/setup-rlcr-loop.sh +++ b/scripts/setup-rlcr-loop.sh @@ -366,7 +366,7 @@ codex_timeout: $CODEX_TIMEOUT push_every_round: $PUSH_EVERY_ROUND plan_file: "$PLAN_FILE" plan_tracked: $TRACK_PLAN_FILE -start_branch: $START_BRANCH +start_branch: "$START_BRANCH" started_at: $(date -u +%Y-%m-%dT%H:%M:%SZ) --- EOF diff --git a/tests/test-plan-file-hooks.sh b/tests/test-plan-file-hooks.sh index b1138f95..bbe7781b 100755 --- a/tests/test-plan-file-hooks.sh +++ b/tests/test-plan-file-hooks.sh @@ -242,6 +242,203 @@ else fail "Bash validator blocking rm" "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: "main" +--- +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 "main" should match current branch +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: "main" +--- +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: "main" +--- +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 "" From 0d87b6fc0adcd6bc8298093e94dc19f3e9aa48a3 Mon Sep 17 00:00:00 2001 From: Sihao Liu Date: Thu, 15 Jan 2026 16:11:03 -0800 Subject: [PATCH 09/12] fix(plan-file): address code review issues for plan file CLI - Fix race condition: skip diff check for tracked plan files (rely on git status in UserPromptSubmit hook instead) - Add shell metacharacter validation to prevent injection in paths - Use command_modifies_file helper for plan.md bash validation - Add missing bash modification patterns (truncate, printf, exec) - Standardize YAML: remove unnecessary quotes from state file values - Standardize template fallback formatting with consistent blank lines - Optimize git submodule check by checking .gitmodules first - Add test for shell metacharacter rejection --- hooks/lib/loop-common.sh | 3 +++ hooks/loop-bash-validator.sh | 4 ++-- hooks/loop-codex-stop-hook.sh | 29 ++++++++++++++++--------- hooks/loop-plan-file-validator.sh | 2 ++ scripts/setup-rlcr-loop.sh | 34 ++++++++++++++++++++---------- tests/test-plan-file-validation.sh | 20 ++++++++++++++++++ 6 files changed, 69 insertions(+), 23 deletions(-) diff --git a/hooks/lib/loop-common.sh b/hooks/lib/loop-common.sh index ef7d760b..e40ba462 100755 --- a/hooks/lib/loop-common.sh +++ b/hooks/lib/loop-common.sh @@ -169,6 +169,9 @@ command_modifies_file() { "(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 diff --git a/hooks/loop-bash-validator.sh b/hooks/loop-bash-validator.sh index a6fbf931..d73855d6 100755 --- a/hooks/loop-bash-validator.sh +++ b/hooks/loop-bash-validator.sh @@ -78,9 +78,9 @@ fi # Block Plan Backup Modifications (All Rounds) # ======================================== # Plan backup is read-only - protects plan integrity during loop -# Use broad pattern matching to catch bypasses via shell expansion/substitution +# Use command_modifies_file helper for consistent pattern matching -if echo "$COMMAND_LOWER" | grep -qE '\.humanize-loop\.local.*plan\.md'; then +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 diff --git a/hooks/loop-codex-stop-hook.sh b/hooks/loop-codex-stop-hook.sh index 1e0ec427..caee07c8 100755 --- a/hooks/loop-codex-stop-hook.sh +++ b/hooks/loop-codex-stop-hook.sh @@ -69,6 +69,8 @@ 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) @@ -105,13 +107,13 @@ fi # ======================================== # Quick-check 0: Schema Validation (v1.1.2+ fields) # ======================================== -# If schema is outdated, allow exit with unexpected status +# If schema is outdated, terminate loop as unexpected if [[ -z "$PLAN_TRACKED" || -z "$START_BRANCH" ]]; then end_loop "$LOOP_DIR" "$STATE_FILE" "unexpected" echo "Loop terminated: state schema outdated (missing plan_tracked or start_branch)" >&2 echo "Please update humanize plugin to v1.1.2+ and restart the loop." >&2 - exit 0 # Allow exit + exit 0 fi # ======================================== @@ -165,8 +167,12 @@ You can restore from backup if needed. Plan file modifications are not allowed d exit 0 fi -if ! diff -q "$FULL_PLAN_PATH" "$BACKUP_PLAN" &>/dev/null; then - FALLBACK="# Plan File Modified +# For tracked plan files, rely on git status check in UserPromptSubmit hook +# Skip diff check because git operations (checkout, rebase) may legitimately change content +# For gitignored files, enforce exact match as defense-in-depth +if [[ "$PLAN_TRACKED" != "true" ]]; then + 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. @@ -178,12 +184,13 @@ If you need to change the plan: 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 + 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 fi # ======================================== @@ -207,6 +214,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") @@ -285,6 +293,7 @@ EOF FALLBACK="# Large Files Detected Files exceeding {{MAX_LINES}} lines: + {{LARGE_FILES}} Split these into smaller modules before continuing." diff --git a/hooks/loop-plan-file-validator.sh b/hooks/loop-plan-file-validator.sh index b4fcc1a9..962a552d 100755 --- a/hooks/loop-plan-file-validator.sh +++ b/hooks/loop-plan-file-validator.sh @@ -31,6 +31,8 @@ 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) diff --git a/scripts/setup-rlcr-loop.sh b/scripts/setup-rlcr-loop.sh index 1152ddc2..879a0c6c 100755 --- a/scripts/setup-rlcr-loop.sh +++ b/scripts/setup-rlcr-loop.sh @@ -231,6 +231,15 @@ if [[ "$PLAN_FILE" =~ [[:space:]] ]]; then 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" @@ -261,15 +270,18 @@ if [[ ! "$REAL_PLAN_PATH" = "$PROJECT_ROOT"/* ]]; then fi # Check not in submodule -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 +# 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 # ======================================== @@ -364,9 +376,9 @@ codex_model: $CODEX_MODEL codex_effort: $CODEX_EFFORT codex_timeout: $CODEX_TIMEOUT push_every_round: $PUSH_EVERY_ROUND -plan_file: "$PLAN_FILE" +plan_file: $PLAN_FILE plan_tracked: $TRACK_PLAN_FILE -start_branch: "$START_BRANCH" +start_branch: $START_BRANCH started_at: $(date -u +%Y-%m-%dT%H:%M:%SZ) --- EOF diff --git a/tests/test-plan-file-validation.sh b/tests/test-plan-file-validation.sh index 8793a940..f4479d78 100755 --- a/tests/test-plan-file-validation.sh +++ b/tests/test-plan-file-validation.sh @@ -162,6 +162,26 @@ 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" From b49dad6c796d498621e8c8d7b6b3dfa630ebb0c8 Mon Sep 17 00:00:00 2001 From: Sihao Liu Date: Thu, 15 Jan 2026 16:21:05 -0800 Subject: [PATCH 10/12] fix(security): address code review issues for plan file CLI Fix 5 issues identified in code review: 1. Bash validator regex bypass (Critical) - Pattern now matches .humanize-loop.local/plan.md directly - Changed [^/]+/plan\.md to (/[^/]+)?/plan\.md 2. YAML injection via branch names (Critical) - Added validation for YAML-unsafe characters in branch names - Rejects branches containing : # " ' ` or newlines 3. Race condition in stop hook (Critical) - Added git status check for tracked files in stop hook - Catches modifications between UserPromptSubmit and stop hook 4. Path resolution edge case (Important) - Added explicit error handling for cd failures - Provides clear error message for permission/symlink issues 5. Schema version handling (Important) - Changed from silent exit to JSON block response - Users now get clear instructions to cancel or update Added comprehensive tests for all fixes: - Test 8a: Direct .humanize-loop.local/plan.md bypass - Test 3.5: Path resolution error handling - Tests 9.5-9.7: YAML-unsafe branch name rejection - Test 12: Tracked file race condition detection - Test 13: Schema validation JSON block response --- hooks/loop-bash-validator.sh | 2 +- hooks/loop-codex-stop-hook.sh | 36 ++++++-- scripts/setup-rlcr-loop.sh | 19 ++++- tests/test-plan-file-hooks.sh | 105 ++++++++++++++++++++++++ tests/test-plan-file-validation.sh | 127 +++++++++++++++++++++++++++++ 5 files changed, 280 insertions(+), 9 deletions(-) diff --git a/hooks/loop-bash-validator.sh b/hooks/loop-bash-validator.sh index d73855d6..ad8f4e4f 100755 --- a/hooks/loop-bash-validator.sh +++ b/hooks/loop-bash-validator.sh @@ -80,7 +80,7 @@ fi # 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 +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 diff --git a/hooks/loop-codex-stop-hook.sh b/hooks/loop-codex-stop-hook.sh index caee07c8..5fa4d5b0 100755 --- a/hooks/loop-codex-stop-hook.sh +++ b/hooks/loop-codex-stop-hook.sh @@ -110,9 +110,16 @@ fi # If schema is outdated, terminate loop as unexpected if [[ -z "$PLAN_TRACKED" || -z "$START_BRANCH" ]]; then - end_loop "$LOOP_DIR" "$STATE_FILE" "unexpected" - echo "Loop terminated: state schema outdated (missing plan_tracked or start_branch)" >&2 - echo "Please update humanize plugin to v1.1.2+ and restart the loop." >&2 + 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 @@ -167,10 +174,25 @@ You can restore from backup if needed. Plan file modifications are not allowed d exit 0 fi -# For tracked plan files, rely on git status check in UserPromptSubmit hook -# Skip diff check because git operations (checkout, rebase) may legitimately change content -# For gitignored files, enforce exact match as defense-in-depth -if [[ "$PLAN_TRACKED" != "true" ]]; then +# Check plan file integrity based on tracking mode +# For tracked files: verify git status is clean (catches race condition between hooks) +# For gitignored files: verify content matches backup exactly +if [[ "$PLAN_TRACKED" == "true" ]]; then + # Tracked file: check git status (final safety check for race condition) + 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" \ + '{"decision": "block", "reason": $reason, "systemMessage": $msg}' + exit 0 + fi +else + # Gitignored file: verify content matches backup exactly if ! diff -q "$FULL_PLAN_PATH" "$BACKUP_PLAN" &>/dev/null; then FALLBACK="# Plan File Modified diff --git a/scripts/setup-rlcr-loop.sh b/scripts/setup-rlcr-loop.sh index 879a0c6c..bcabcea9 100755 --- a/scripts/setup-rlcr-loop.sh +++ b/scripts/setup-rlcr-loop.sh @@ -263,7 +263,14 @@ if [[ ! -f "$FULL_PLAN_PATH" ]]; then fi # Check file is within project (no ../ escaping) -REAL_PLAN_PATH=$(cd "$PLAN_DIR" && pwd)/$(basename "$FULL_PLAN_PATH") +# 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 @@ -346,6 +353,16 @@ fi 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 # ======================================== diff --git a/tests/test-plan-file-hooks.sh b/tests/test-plan-file-hooks.sh index bbe7781b..41dfc06f 100755 --- a/tests/test-plan-file-hooks.sh +++ b/tests/test-plan-file-hooks.sh @@ -242,6 +242,20 @@ 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 "" @@ -536,6 +550,97 @@ 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" +# 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: main +--- +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 + echo "" echo "=========================================" echo "Test Results" diff --git a/tests/test-plan-file-validation.sh b/tests/test-plan-file-validation.sh index f4479d78..da4ecc40 100755 --- a/tests/test-plan-file-validation.sh +++ b/tests/test-plan-file-validation.sh @@ -195,6 +195,49 @@ 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" @@ -378,6 +421,90 @@ 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" +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 main 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 main 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 main 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 main 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 main 2>/dev/null || true +else + pass "Branch with quotes rejected (by git)" +fi + echo "" echo "=== Test: CLI Options ===" echo "" From c3165b614a946c412a511428166406d761103441 Mon Sep 17 00:00:00 2001 From: Sihao Liu Date: Thu, 15 Jan 2026 16:29:26 -0800 Subject: [PATCH 11/12] fix(tests): use dynamic branch name instead of hardcoded main Tests now work on both systems with 'main' and 'master' as default branch. - setup_test_loop() captures DEFAULT_BRANCH after git init - All state.md files use the actual branch name - Branch validation tests use BRANCH_TEST_DEFAULT variable --- tests/test-plan-file-hooks.sh | 41 +++++++++++++++++++----------- tests/test-plan-file-validation.sh | 12 +++++---- 2 files changed, 33 insertions(+), 20 deletions(-) diff --git a/tests/test-plan-file-hooks.sh b/tests/test-plan-file-hooks.sh index 41dfc06f..8aa0a8e2 100755 --- a/tests/test-plan-file-hooks.sh +++ b/tests/test-plan-file-hooks.sh @@ -31,6 +31,9 @@ skip() { echo -e "${YELLOW}SKIP${NC}: $1 - $2"; TESTS_SKIPPED=$((TESTS_SKIPPED + 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" @@ -42,8 +45,13 @@ setup_test_loop() { 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" @@ -65,13 +73,14 @@ EOF 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: main +start_branch: $CURRENT_BRANCH --- EOF } @@ -111,12 +120,12 @@ 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' +cat > "$LOOP_DIR/state.md" << EOF --- current_round: 0 max_iterations: 42 plan_file: "plans/test-plan.md" -start_branch: main +start_branch: $DEFAULT_BRANCH --- EOF set +e @@ -155,13 +164,13 @@ 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' +cat > "$LOOP_DIR/state.md" << EOF --- current_round: 0 max_iterations: 42 plan_file: "plans/test-plan.md" plan_tracked: false -start_branch: main +start_branch: $DEFAULT_BRANCH --- EOF set +e @@ -173,7 +182,7 @@ if [[ $EXIT_CODE -eq 0 ]] && echo "$RESULT" | grep -q "branch"; then else fail "Hook blocking branch change" "block with branch error" "$RESULT" fi -git checkout -q main +git checkout -q "$DEFAULT_BRANCH" echo "" echo "=== Test: Write Validator ===" @@ -333,20 +342,20 @@ echo "" 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' +cat > "$LOOP_DIR/state.md" << EOF --- current_round: 0 max_iterations: 42 plan_file: "plans/test-plan.md" plan_tracked: false -start_branch: "main" +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 "main" should match current branch +# 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 @@ -379,13 +388,13 @@ 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' +cat > "$LOOP_DIR/state.md" << EOF --- current_round: 0 max_iterations: 42 plan_file: "plans/test-plan.md" plan_tracked: false -start_branch: "main" +start_branch: "$DEFAULT_BRANCH" --- EOF # Create summary to get past that check @@ -431,13 +440,13 @@ Test the RLCR loop - Requirement 1 EOF cp "$TEST_DIR/my-plans/test-plan.md" "$LOOP_DIR/plan.md" -cat > "$LOOP_DIR/state.md" << 'EOF' +cat > "$LOOP_DIR/state.md" << EOF --- current_round: 0 max_iterations: 42 plan_file: "my-plans/test-plan.md" plan_tracked: false -start_branch: "main" +start_branch: "$DEFAULT_BRANCH" --- EOF set +e @@ -562,6 +571,8 @@ 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 @@ -576,13 +587,13 @@ git commit -q -m "Add plan" 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' +cat > "$TRACKED_LOOP_DIR/state.md" << EOF --- current_round: 0 max_iterations: 42 plan_file: tracked-plan.md plan_tracked: true -start_branch: main +start_branch: $TEST12_BRANCH --- EOF cat > "$TRACKED_LOOP_DIR/round-0-summary.md" << 'EOF' diff --git a/tests/test-plan-file-validation.sh b/tests/test-plan-file-validation.sh index da4ecc40..e24fe2e9 100755 --- a/tests/test-plan-file-validation.sh +++ b/tests/test-plan-file-validation.sh @@ -439,6 +439,8 @@ 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 @@ -462,7 +464,7 @@ if git checkout -q -b "feature:test" 2>/dev/null; then else fail "Branch with colon rejection" "exit 1 with YAML-unsafe error" "$RESULT" fi - git checkout -q main 2>/dev/null || true + 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)" @@ -470,7 +472,7 @@ fi # Test 9.6: Reject branch names with hash (YAML comment) echo "Test 9.6: Reject branch with hash (YAML comment)" -git checkout -q main 2>/dev/null || true +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 @@ -482,14 +484,14 @@ if git checkout -q -b "test#comment" 2>/dev/null; then else fail "Branch with hash rejection" "exit 1 with YAML-unsafe error" "$RESULT" fi - git checkout -q main 2>/dev/null || true + 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 main 2>/dev/null || true +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) @@ -500,7 +502,7 @@ if git checkout -q -b 'test"quote' 2>/dev/null; then else fail "Branch with quotes rejection" "exit 1 with YAML-unsafe error" "$RESULT" fi - git checkout -q main 2>/dev/null || true + git checkout -q "$BRANCH_TEST_DEFAULT" 2>/dev/null || true else pass "Branch with quotes rejected (by git)" fi From 90e7c52ec9f0b8e5c0f4ce42fb0262b63faf16bc Mon Sep 17 00:00:00 2001 From: Sihao Liu Date: Thu, 15 Jan 2026 16:34:27 -0800 Subject: [PATCH 12/12] fix(security): verify plan content matches backup even for tracked files When plan_tracked is true, previously only git status was checked. This allowed bypassing integrity verification by committing plan changes (git status would be clean but content differs from backup). Now the stop hook always verifies content matches backup using diff, regardless of tracking mode. This catches both uncommitted and committed changes to the plan file. Added Test 14 to verify committed changes to tracked files are blocked. --- hooks/loop-codex-stop-hook.sh | 32 +++++++------- tests/test-plan-file-hooks.sh | 78 +++++++++++++++++++++++++++++++++++ 2 files changed, 94 insertions(+), 16 deletions(-) diff --git a/hooks/loop-codex-stop-hook.sh b/hooks/loop-codex-stop-hook.sh index 5fa4d5b0..35ae56a8 100755 --- a/hooks/loop-codex-stop-hook.sh +++ b/hooks/loop-codex-stop-hook.sh @@ -174,11 +174,11 @@ You can restore from backup if needed. Plan file modifications are not allowed d exit 0 fi -# Check plan file integrity based on tracking mode -# For tracked files: verify git status is clean (catches race condition between hooks) -# For gitignored files: verify content matches backup exactly +# 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: check git status (final safety check for race condition) + # 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. @@ -187,14 +187,15 @@ 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" \ + jq -n --arg reason "$REASON" --arg msg "Loop: Blocked - plan file modified (uncommitted)" \ '{"decision": "block", "reason": $reason, "systemMessage": $msg}' exit 0 fi -else - # Gitignored file: verify content matches backup exactly - if ! diff -q "$FULL_PLAN_PATH" "$BACKUP_PLAN" &>/dev/null; then - FALLBACK="# Plan File Modified +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. @@ -206,13 +207,12 @@ If you need to change the plan: 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 + 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 # ======================================== diff --git a/tests/test-plan-file-hooks.sh b/tests/test-plan-file-hooks.sh index 8aa0a8e2..04d36301 100755 --- a/tests/test-plan-file-hooks.sh +++ b/tests/test-plan-file-hooks.sh @@ -652,6 +652,84 @@ 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"