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/.claude/CLAUDE.md b/.claude/CLAUDE.md index f1b8703b..8dbd6e5b 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -3,6 +3,8 @@ This is a Claude Code plugin that provides iterative development with Codex revi # Humanize Project Rules - Everything about this project, including but not limited to implementations, comments, tests and documentations should be in English. No Emoji or CJK char is allowed. -- **MANDATORY**: Every commit MUST include a version bump in `.claude-plugin/plugin.json` and `README.md` (the "Current Version" line). This applies to ALL commits without exception - bug fixes, features, documentation changes, etc. Increment the patch version (e.g., 1.0.0 -> 1.0.1) for each commit. +- **Version Update Rules**: + - **On feature branches** (not `main` or `master`): No need to bump version on every commit. However, ensure the branch has an incremental version update compared to the default main branch before merging. + - **On default branch** (`main` or `master`): Every commit MUST include a version bump in `.claude-plugin/plugin.json` and `README.md` (the "Current Version" line). Increment the patch version (e.g., 1.0.0 -> 1.0.1, 1.0.9 -> 1.1.0, 3.4.7 -> 4.0.0) for each commit. - Every `git push` or `git commit` MUST confirm with user first, MUST NOT commit or push to remote without check with user. - Version number must be in format of `X.Y.Z` where X/Y/Z is numeric number. Version MUST NOT include anything other than `X.Y.Z`. For example, a good version is `9.732.42`; Bad version examples (MUST NOT USE): `3.22.7-alpha` (extra "-alpha" string), `9.77.2 (2026-01-07)` (useless date/timestamp). diff --git a/.github/workflows/template-test.yml b/.github/workflows/template-test.yml index 3746eb6a..ec74911d 100644 --- a/.github/workflows/template-test.yml +++ b/.github/workflows/template-test.yml @@ -6,6 +6,7 @@ on: paths: - 'prompt-template/**' - 'hooks/**' + - 'scripts/**' - 'tests/**' - '.github/workflows/template-test.yml' pull_request: @@ -13,6 +14,7 @@ on: paths: - 'prompt-template/**' - 'hooks/**' + - 'scripts/**' - 'tests/**' - '.github/workflows/template-test.yml' @@ -47,6 +49,14 @@ jobs: chmod +x tests/test-template-references.sh ./tests/test-template-references.sh + - name: Run plan file handling tests + run: | + echo "========================================" + echo "Running plan file handling tests" + echo "========================================" + chmod +x tests/test-plan-file-handling.sh + ./tests/test-plan-file-handling.sh + - name: Verify all hook scripts still work run: | echo "========================================" diff --git a/README.md b/README.md index 1c89cac9..35e6db90 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. @@ -115,18 +115,26 @@ This provides a real-time dashboard showing: - **Loop state**: Controlled solely by the presence of `.humanize-loop.local/*/state.md` - **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 the state file with a prefix to stop the loop ```bash # Cancel the active loop /humanize:cancel-rlcr-loop -# Or manually remove state file -rm .humanize-loop.local/*/state.md +# Or manually rename state file +for f in .humanize-loop.local/*/state.md; do mv "$f" "${f%state.md}cancelled-state.md"; done ``` The loop directory with all summaries and review results is preserved for reference. +**State file prefixes indicate termination reason:** +- `completed-state.md` - Normal completion (Codex said COMPLETE) +- `stopped-state.md` - Stagnation/circuit breaker (Codex said STOP, or max iterations) +- `cancelled-state.md` - User manually cancelled +- `unexpected-state.md` - Error conditions (corruption, legacy state, branch change) + +Any prefixed state file can be renamed back to `state.md` to manually restart the loop. + ## Goal Tracker System Humanize uses a **Goal Tracker** to prevent goal drift across iterations: @@ -196,12 +204,43 @@ OPTIONS: --codex-timeout Timeout for each Codex review in seconds (default: 5400) --push-every-round Require git push after each round (default: commits stay local) + --commit-plan-file Include the plan file in commits (default: plan file stays uncommitted) -h, --help Show help message ``` +### Plan File Handling + +The plan file behavior depends on the `--commit-plan-file` flag and whether the plan file is inside or outside the git repository: + +#### Case 1: Inside repo with `--commit-plan-file` +- **Setup**: Plan file must be tracked (committed) AND clean (no uncommitted changes) +- **UserPromptSubmit hook**: If plan file content differs from backup, **blocks prompt** before processing +- **Stop hook**: If plan file has uncommitted changes (dirty), allows stop with error message +- Plan file changes must be committed like any other file + +#### Case 2: Outside repo with `--commit-plan-file` +- **Setup**: Early failure - this configuration is not allowed +- **Stop hook**: If detected (e.g., from manual state file edit), allows stop with conflict error + +#### Case 3: Inside repo without `--commit-plan-file` (default) +- **Setup**: Plan file can be tracked or untracked, dirty or clean +- **UserPromptSubmit hook**: If plan file content differs from backup (or is missing), **blocks prompt** before processing with recovery options +- Accidental commits of the plan file are blocked with an error +- A backup of the original plan file is saved to `.humanize-loop.local//plan-backup.md` + +#### Case 4: Outside repo without `--commit-plan-file` +- **Setup**: No restrictions +- **UserPromptSubmit hook**: If plan file content differs from backup, **blocks prompt** before processing with recovery options + +**Key principle**: Plan file content changes are detected **before prompt processing** via the UserPromptSubmit hook. This blocks work from starting with a stale plan, giving the user a chance to restart the loop or restore the original plan. + ## Prerequisites -Required tools: +**Required environment:** +- Git repository with at least one commit +- Plan file path must not contain spaces or special characters (`[ ] * ? { } | ( ) ^ $ \`) + +**Required tools:** - `codex` - OpenAI Codex CLI (for review) Check if Codex is available: @@ -209,6 +248,9 @@ Check if Codex is available: codex --version ``` +**Note on upgrading from pre-1.1.2:** +If you have an active RLCR loop started with a version before 1.1.2, the loop will be automatically terminated on the next stop attempt. Your work is preserved in `.humanize-loop.local/` - simply start a new loop with the updated plugin. + ## Directory Structure ``` @@ -243,7 +285,8 @@ 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 with `start_commit`, `commit_plan_file`, etc.) +- `plan-backup.md` - Backup copy of the original plan file at loop start - `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 diff --git a/commands/cancel-rlcr-loop.md b/commands/cancel-rlcr-loop.md index a8595f10..1995a6d9 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(for f in .humanize-loop.local/*/state.md*)", "Bash(cat .humanize-loop.local/*/state.md)", "Read"] hide-from-slash-command-tool: "true" --- @@ -18,7 +18,11 @@ 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` + - Rename the state file(s) to cancelled-state.md: + ```bash + for f in .humanize-loop.local/*/state.md; do mv "$f" "${f%state.md}cancelled-state.md"; done + ``` - Report: "Cancelled RLCR loop (was at round N of M)" The loop directory with summaries and review results will be preserved for reference. +The cancelled-state.md file can be renamed back to state.md to manually restart the loop if needed. diff --git a/hooks/hooks.json b/hooks/hooks.json index d843a23f..97390fa5 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-validator.sh" + } + ] + } + ], "PreToolUse": [ { "matcher": "Write", diff --git a/hooks/lib/loop-common.sh b/hooks/lib/loop-common.sh index 89838d49..2bebb7c3 100755 --- a/hooks/lib/loop-common.sh +++ b/hooks/lib/loop-common.sh @@ -20,6 +20,46 @@ if ! validate_template_dir "$TEMPLATE_DIR" 2>/dev/null; then echo "Warning: Template directory validation failed. Using inline fallbacks." >&2 fi +# Stop an active loop by renaming state.md to -state.md +# This preserves the state file for manual inspection/restart if needed +# The prefix indicates why the loop was stopped: +# - completed: Normal completion (Codex said COMPLETE) +# - stopped: Stagnation/circuit breaker (Codex said STOP, or max iterations) +# - unexpected: Error conditions (corruption, legacy state, branch change) +# - cancelled: User manually cancelled +# Usage: stop_loop "$STATE_FILE" "completed|stopped|unexpected|cancelled" +stop_loop() { + local state_file="$1" + local prefix="${2:-stopped}" # Default to "stopped" for backward compatibility + if [[ -f "$state_file" ]]; then + local new_file="${state_file%state.md}${prefix}-state.md" + mv "$state_file" "$new_file" 2>/dev/null || rm -f "$state_file" + fi +} + +# Check if HEAD is a descendant of start_commit +# Returns 0 if HEAD is descendant (valid), 1 if not (user checked out older branch) +# Usage: check_start_commit_ancestry "$START_COMMIT" +check_start_commit_ancestry() { + local start_commit="$1" + + # If no start_commit or not in a git repo, skip check + if [[ -z "$start_commit" ]]; then + return 0 + fi + + if ! command -v git &>/dev/null || ! git rev-parse --git-dir &>/dev/null 2>&1; then + return 0 + fi + + # Check if start_commit is an ancestor of HEAD + if git merge-base --is-ancestor "$start_commit" HEAD 2>/dev/null; then + return 0 + else + return 1 + fi +} + # Find the most recent active loop directory # Only checks the newest directory - older directories are ignored even if they have state.md # This prevents "zombie" loops from being revived after abnormal exits @@ -62,6 +102,135 @@ to_lower() { echo "$1" | tr '[:upper:]' '[:lower:]' } +# Normalize a path: remove double slashes, resolve . and .. components +# Usage: _normalize_path "/home//user/../project/./src" +# Output: "/home/project/src" +# Note: This is a helper for get_relative_path, defined at top level for zsh compatibility +_normalize_path() { + # Enable shwordsplit for zsh (localoptions makes it local to this function) + [[ -n "$ZSH_VERSION" ]] && setopt localoptions shwordsplit + + local path="$1" + # Remove trailing slash + path="${path%/}" + # Remove double slashes + while [[ "$path" == *//* ]]; do + path="${path//\/\//\/}" + done + # Resolve . and .. components by iterating over path parts + local result="" part old_ifs="$IFS" + IFS='/' + set -f # Disable glob expansion + for part in ${path#/}; do + if [[ -z "$part" ]] || [[ "$part" == "." ]]; then + continue + elif [[ "$part" == ".." ]]; then + # Remove last component from result + result="${result%/*}" + else + result="$result/$part" + fi + done + set +f # Re-enable glob expansion + IFS="$old_ifs" + if [[ -z "$result" ]]; then + echo "/" + else + echo "$result" + fi +} + +# Compute relative path from base to target (portable across Linux/macOS/BSD) +# Usage: get_relative_path "/base/dir" "/base/dir/sub/file.txt" +# Output: "sub/file.txt" +get_relative_path() { + # Enable 0-indexed arrays in zsh for bash compatibility + [[ -n "$ZSH_VERSION" ]] && setopt localoptions ksharrays + + local base="$1" + local target="$2" + + # Normalize paths (resolve symlinks, remove trailing slashes) + local base_real target_real + base_real=$(cd "$base" 2>/dev/null && pwd -P) || base_real="$base" + target_real=$(realpath "$target" 2>/dev/null) || target_real="$target" + + # Try GNU realpath --relative-to (Linux) + if realpath --relative-to="$base_real" "$target_real" 2>/dev/null; then + return 0 + fi + + # Try grealpath (macOS with coreutils) + if command -v grealpath &>/dev/null; then + if grealpath --relative-to="$base_real" "$target_real" 2>/dev/null; then + return 0 + fi + fi + + # Fallback: compute relative path in bash/zsh (works for inside/outside base) + if [[ "$base_real" == /* ]] && [[ "$target_real" == /* ]]; then + local base_clean target_clean + + # Normalize paths using top-level helper (for zsh compatibility) + base_clean=$(_normalize_path "$base_real") + target_clean=$(_normalize_path "$target_real") + [[ -z "$base_clean" ]] && base_clean="/" + [[ -z "$target_clean" ]] && target_clean="/" + + if [[ "$base_clean" == "$target_clean" ]]; then + echo "." + return 0 + fi + + local base_trim target_trim + base_trim="${base_clean#/}" + target_trim="${target_clean#/}" + + # Split paths into arrays (bash/zsh compatible using awk) + # This avoids read -a which is bash-specific + local base_parts target_parts rel_parts + base_parts=$(echo "$base_trim" | awk -F'/' '{ for(i=1;i<=NF;i++) if($i!="") print $i }') + target_parts=$(echo "$target_trim" | awk -F'/' '{ for(i=1;i<=NF;i++) if($i!="") print $i }') + + # Convert to arrays using while read (portable) + local -a base_arr target_arr rel_arr + while IFS= read -r part; do + [[ -n "$part" ]] && base_arr+=("$part") + done <<< "$base_parts" + while IFS= read -r part; do + [[ -n "$part" ]] && target_arr+=("$part") + done <<< "$target_parts" + + # Find common prefix length + local i=0 + while [[ $i -lt ${#base_arr[@]} ]] && [[ $i -lt ${#target_arr[@]} ]] && \ + [[ "${base_arr[$i]}" == "${target_arr[$i]}" ]]; do + ((i++)) + done + + # Add ".." for each remaining base component + local j + for ((j=i; j<${#base_arr[@]}; j++)); do + rel_arr+=("..") + done + # Add remaining target components + for ((j=i; j<${#target_arr[@]}; j++)); do + rel_arr+=("${target_arr[$j]}") + done + + if (( ${#rel_arr[@]} == 0 )); then + echo "." + else + local IFS='/' + echo "${rel_arr[*]}" + fi + return 0 + fi + + # Cannot compute relative path, return basename as last resort + basename "$target" +} + # Check if a path (lowercase) matches a round file pattern # Usage: is_round_file "$lowercase_path" "summary|prompt|todos" is_round_file_type() { diff --git a/hooks/loop-bash-validator.sh b/hooks/loop-bash-validator.sh index c9d40d5c..071021ef 100755 --- a/hooks/loop-bash-validator.sh +++ b/hooks/loop-bash-validator.sh @@ -45,6 +45,38 @@ fi CURRENT_ROUND=$(get_current_round "$ACTIVE_LOOP_DIR/state.md") STATE_FILE="$ACTIVE_LOOP_DIR/state.md" +# ======================================== +# Check Git Ancestry (HEAD must be descendant of start_commit) +# ======================================== +# If user checked out an older branch, block the command and stop the loop. + +START_COMMIT=$(grep -E "^start_commit:" "$STATE_FILE" 2>/dev/null | sed 's/start_commit: *//' || echo "") + +if ! check_start_commit_ancestry "$START_COMMIT"; then + CURRENT_HEAD=$(git rev-parse --short HEAD 2>/dev/null || echo "unknown") + START_COMMIT_SHORT=$(git rev-parse --short "$START_COMMIT" 2>/dev/null || echo "$START_COMMIT") + + # Stop the loop (unexpected: branch changed) + stop_loop "$STATE_FILE" "unexpected" + + FALLBACK="# RLCR Loop Terminated - Branch Changed + +The current HEAD (\`$CURRENT_HEAD\`) is not a descendant of the loop's start commit (\`$START_COMMIT_SHORT\`). + +This typically happens when you checked out an older branch or reset to a previous commit. + +The loop has been stopped because the commit history no longer matches. + +**To continue working:** +1. If you want to continue the RLCR loop, checkout the original branch and start a new loop +2. If you want to work without the loop, you can proceed normally" + + load_and_render_safe "$TEMPLATE_DIR" "block/branch-changed.md" "$FALLBACK" \ + "CURRENT_HEAD=$CURRENT_HEAD" \ + "START_COMMIT=$START_COMMIT_SHORT" >&2 + exit 2 +fi + # ======================================== # Block Git Push When push_every_round is false # ======================================== @@ -64,6 +96,57 @@ Use --push-every-round flag when starting the loop if you need to push each roun fi fi +# ======================================== +# Block Git Commit When Plan File is Staged +# ======================================== +# When commit_plan_file is false, prevent committing if plan file is staged. +# This is a pre-commit check that catches the issue before it happens, +# complementing the post-commit check in the stop hook. + +COMMIT_PLAN_FILE=$(grep -E "^commit_plan_file:" "$STATE_FILE" 2>/dev/null | sed 's/commit_plan_file: *//' || echo "false") +PLAN_FILE_FROM_STATE=$(grep -E "^plan_file:" "$STATE_FILE" 2>/dev/null | sed 's/plan_file: *//' || echo "") + +if [[ "$COMMIT_PLAN_FILE" != "true" ]] && [[ -n "$PLAN_FILE_FROM_STATE" ]]; then + # Check if command is a git commit command + if [[ "$COMMAND_LOWER" =~ ^[[:space:]]*git[[:space:]]+commit ]]; then + # Get relative path of plan file from git toplevel (git diff outputs repo-relative paths) + GIT_TOPLEVEL=$(git rev-parse --show-toplevel 2>/dev/null || echo "") + if [[ -n "$GIT_TOPLEVEL" ]]; then + PLAN_FILE_REL_GIT=$(get_relative_path "$GIT_TOPLEVEL" "$PLAN_FILE_FROM_STATE") + else + PLAN_FILE_REL_GIT=$(get_relative_path "$PROJECT_ROOT" "$PLAN_FILE_FROM_STATE") + fi + + # Check if plan file is staged (would be included in commit) + STAGED_FILES=$(git diff --cached --name-only 2>/dev/null || true) + + # Use grep -Fx for literal fixed-string exact-line matching (no regex) + if echo "$STAGED_FILES" | grep -qFx "$PLAN_FILE_REL_GIT"; then + FALLBACK="# Git Commit Blocked: Plan File is Staged + +The plan file is staged and would be included in this commit, but \`--commit-plan-file\` was not set when starting the loop. + +**Plan file**: \`{{PLAN_FILE}}\` + +**To fix**: Unstage the plan file before committing: +\`\`\`bash +git reset HEAD {{PLAN_FILE}} +\`\`\` + +Then retry your commit command. + +**Alternative**: If you want to track plan file changes, restart the loop with \`--commit-plan-file\`: +\`\`\` +/humanize:cancel-rlcr-loop +/humanize:start-rlcr-loop {{PLAN_FILE}} --commit-plan-file +\`\`\`" + load_and_render_safe "$TEMPLATE_DIR" "block/plan-file-staged.md" "$FALLBACK" \ + "PLAN_FILE=$PLAN_FILE_REL_GIT" >&2 + exit 2 + fi + fi +fi + # ======================================== # Block State File Modifications (All Rounds) # ======================================== diff --git a/hooks/loop-codex-stop-hook.sh b/hooks/loop-codex-stop-hook.sh index ad5126ea..172ce10d 100755 --- a/hooks/loop-codex-stop-hook.sh +++ b/hooks/loop-codex-stop-hook.sh @@ -182,6 +182,43 @@ fi # Before running expensive Codex review, check if all changes have been # committed and pushed. This ensures work is properly saved. +# Read plan file settings from state (defaults for older state files without these fields) +COMMIT_PLAN_FILE=$(grep -E "^commit_plan_file:" "$STATE_FILE" 2>/dev/null | sed 's/commit_plan_file: *//' || echo "false") +PLAN_FILE_FROM_STATE=$(grep -E "^plan_file:" "$STATE_FILE" 2>/dev/null | sed 's/plan_file: *//' || echo "") +START_COMMIT=$(grep -E "^start_commit:" "$STATE_FILE" 2>/dev/null | sed 's/start_commit: *//' || echo "") + +# ======================================== +# Check for Pre-1.1.2 State File +# ======================================== +# Old state files lack start_commit. These loops are incompatible with 1.1.2+. +# Allow exit, terminate loop, and inform user to start a new loop. + +if [[ -z "$START_COMMIT" ]] && grep -q "^plan_file:" "$STATE_FILE" 2>/dev/null; then + # Terminate the loop by renaming state file (unexpected: legacy state file) + stop_loop "$STATE_FILE" "unexpected" + + echo "" >&2 + echo "========================================" >&2 + echo "RLCR Loop Terminated - Upgrade Required" >&2 + echo "========================================" >&2 + echo "This loop was created with Humanize pre-1.1.2." >&2 + echo "Humanize 1.1.2+ loops are not compatible with older versions." >&2 + echo "" >&2 + echo "Your work has been preserved. Please start a new loop:" >&2 + echo " /humanize:start-rlcr-loop " >&2 + echo "========================================" >&2 + + # Allow Claude to exit + exit 0 +fi + +# ======================================== +# Note: Pre-Prompt Validation in UserPromptSubmit Hook +# ======================================== +# The following validations are handled by loop-plan-validator.sh (UserPromptSubmit hook) +# which runs BEFORE the prompt is processed: +# - Plan file validation (tracked/clean status, content changes, configuration conflicts) + # Check if git is available and we're in a git repo if command -v git &>/dev/null && git rev-parse --git-dir &>/dev/null 2>&1; then GIT_ISSUES="" @@ -189,11 +226,29 @@ if command -v git &>/dev/null && git rev-parse --git-dir &>/dev/null 2>&1; then # Check for uncommitted changes (staged or unstaged) GIT_STATUS=$(git status --porcelain 2>/dev/null) - if [[ -n "$GIT_STATUS" ]]; then + + # Filter out plan file from git status when commit_plan_file is false + # git status --porcelain outputs paths relative to git root, so use git toplevel + FILTERED_GIT_STATUS="$GIT_STATUS" + if [[ "$COMMIT_PLAN_FILE" != "true" ]] && [[ -n "$PLAN_FILE_FROM_STATE" ]]; then + GIT_TOPLEVEL=$(git rev-parse --show-toplevel 2>/dev/null || echo "") + if [[ -n "$GIT_TOPLEVEL" ]]; then + PLAN_FILE_REL_GIT=$(get_relative_path "$GIT_TOPLEVEL" "$PLAN_FILE_FROM_STATE") + else + PLAN_FILE_REL_GIT=$(get_relative_path "$PROJECT_ROOT" "$PLAN_FILE_FROM_STATE") + fi + # Escape only basic grep special chars: . * [ ] ^ $ \ + # Note: + ? { } | ( ) are literal in basic grep but become special when escaped! + # Escape backslash first (separately for POSIX portability), then other special chars + PLAN_FILE_ESCAPED=$(printf '%s\n' "$PLAN_FILE_REL_GIT" | sed -e 's/\\/\\\\/g' -e 's/[][.*^$]/\\&/g') + FILTERED_GIT_STATUS=$(echo "$GIT_STATUS" | grep -v " ${PLAN_FILE_ESCAPED}\$" || true) + fi + + if [[ -n "$FILTERED_GIT_STATUS" ]]; then GIT_ISSUES="uncommitted changes" # Check for special cases in untracked files - UNTRACKED=$(echo "$GIT_STATUS" | grep '^??' || true) + UNTRACKED=$(echo "$FILTERED_GIT_STATUS" | grep '^??' || true) # Check if .humanize-loop.local is untracked if echo "$UNTRACKED" | grep -q '\.humanize-loop\.local'; then @@ -215,7 +270,6 @@ if command -v git &>/dev/null && git rev-parse --git-dir &>/dev/null 2>&1; then fi fi - # Block if there are uncommitted changes if [[ -n "$GIT_ISSUES" ]]; then # Git has uncommitted changes - block and remind Claude to commit FALLBACK="# Git Not Clean @@ -239,6 +293,54 @@ Please commit all changes before exiting. exit 0 fi + # ======================================== + # Post-Commit Check: Plan File Accidentally Committed? + # ======================================== + # When commit_plan_file is false, verify plan file wasn't accidentally committed + + if [[ "$COMMIT_PLAN_FILE" != "true" ]] && [[ -n "$PLAN_FILE_FROM_STATE" ]]; then + # Check commits since START_COMMIT for accidental plan file commits + # Note: Pre-1.1.2 loops (empty START_COMMIT) are handled earlier and exit before reaching here + # Use git-toplevel-relative path for git log (consistent with git status filtering) + GIT_TOPLEVEL_POST=$(git rev-parse --show-toplevel 2>/dev/null || echo "") + if [[ -n "$GIT_TOPLEVEL_POST" ]]; then + PLAN_FILE_REL_POST=$(get_relative_path "$GIT_TOPLEVEL_POST" "$PLAN_FILE_FROM_STATE") + else + PLAN_FILE_REL_POST=$(get_relative_path "$PROJECT_ROOT" "$PLAN_FILE_FROM_STATE") + fi + PLAN_FILE_COMMITS=$(git log --oneline --follow "${START_COMMIT}..HEAD" -- "$PLAN_FILE_REL_POST" 2>/dev/null || true) + + if [[ -n "$PLAN_FILE_COMMITS" ]]; then + FALLBACK="# Plan File Accidentally Committed + +The plan file was committed but --commit-plan-file was not set. + +Plan file: {{PLAN_FILE}} + +Commits containing the plan file: +{{PLAN_FILE_COMMITS}} + +**Required Actions**: +1. Reset the commit(s) that include the plan file: \`git reset --soft HEAD~N\` +2. Unstage the plan file: \`git reset HEAD {{PLAN_FILE}}\` +3. Re-commit without the plan file +4. Or restart the loop with --commit-plan-file if you want to track the plan file" + REASON=$(load_and_render_safe "$TEMPLATE_DIR" "block/plan-file-committed.md" "$FALLBACK" \ + "PLAN_FILE=$PLAN_FILE_REL_POST" \ + "PLAN_FILE_COMMITS=$PLAN_FILE_COMMITS") + + jq -n \ + --arg reason "$REASON" \ + --arg msg "Loop: Blocked - plan file was accidentally committed" \ + '{ + "decision": "block", + "reason": $reason, + "systemMessage": $msg + }' + exit 0 + fi + fi + # ======================================== # Check Unpushed Commits (only when push_every_round is true) # ======================================== @@ -303,7 +405,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 - rm -f "$STATE_FILE" + stop_loop "$STATE_FILE" "unexpected" exit 0 fi @@ -409,7 +511,7 @@ NEXT_ROUND=$((CURRENT_ROUND + 1)) if [[ $NEXT_ROUND -gt $MAX_ITERATIONS ]]; then echo "RLCR loop did not complete, but reached max iterations ($MAX_ITERATIONS). Exiting." >&2 - rm -f "$STATE_FILE" + stop_loop "$STATE_FILE" "stopped" exit 0 fi @@ -722,7 +824,7 @@ if [[ "$LAST_LINE_TRIMMED" == "COMPLETE" ]]; then else echo "Codex review passed. Loop complete!" >&2 fi - rm -f "$STATE_FILE" + stop_loop "$STATE_FILE" "completed" exit 0 fi @@ -752,7 +854,7 @@ if [[ "$LAST_LINE_TRIMMED" == "STOP" ]]; then echo " $REVIEW_RESULT_FILE" >&2 fi echo "========================================" >&2 - rm -f "$STATE_FILE" + stop_loop "$STATE_FILE" "stopped" exit 0 fi diff --git a/hooks/loop-plan-validator.sh b/hooks/loop-plan-validator.sh new file mode 100755 index 00000000..b467668a --- /dev/null +++ b/hooks/loop-plan-validator.sh @@ -0,0 +1,317 @@ +#!/bin/bash +# +# UserPromptSubmit Hook: Validate plan file before processing prompt +# +# When in an active RLCR loop, validates the plan file based on four cases: +# +# Case 1: --commit-plan-file + Inside repo +# - Plan file must be tracked by git +# - Plan file must be clean (no uncommitted changes) +# - Plan file content must match backup +# +# Case 2: No --commit-plan-file + Inside repo +# - Plan file can have any git status (dirty, untracked, etc.) +# - Plan file content must match backup +# +# Case 3: --commit-plan-file + Outside repo +# - Configuration conflict - block immediately +# +# Case 4: No --commit-plan-file + Outside repo +# - Plan file content must match backup +# +# This runs BEFORE Claude processes the prompt, preventing work on a stale plan. +# + +set -euo pipefail + +# Load shared functions +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd)" +source "$SCRIPT_DIR/lib/loop-common.sh" + +# ======================================== +# Find Active Loop +# ======================================== + +PROJECT_ROOT="${CLAUDE_PROJECT_DIR:-$(pwd)}" +LOOP_BASE_DIR="$PROJECT_ROOT/.humanize-loop.local" +ACTIVE_LOOP_DIR=$(find_active_loop "$LOOP_BASE_DIR") + +# No active loop - allow prompt +if [[ -z "$ACTIVE_LOOP_DIR" ]]; then + exit 0 +fi + +# ======================================== +# Read Plan File Settings from State +# ======================================== + +STATE_FILE="$ACTIVE_LOOP_DIR/state.md" +if [[ ! -f "$STATE_FILE" ]]; then + exit 0 +fi + +# ======================================== +# Check for Pre-1.1.2 State File (Backward Compatibility) +# ======================================== +# Old state files lack start_commit, which is required for post-commit validation. +# Block prompt and advise user to start a new loop. + +START_COMMIT=$(grep -E "^start_commit:" "$STATE_FILE" 2>/dev/null | sed 's/start_commit: *//' || echo "") + +if [[ -z "$START_COMMIT" ]] && grep -q "^plan_file:" "$STATE_FILE" 2>/dev/null; then + # Rename state file to terminate the loop (unexpected: legacy state file) + stop_loop "$STATE_FILE" "unexpected" + + FALLBACK="# RLCR Loop Terminated - Upgrade Required + +This loop was started with an older version of Humanize (pre-1.1.2). +The state file is missing required fields for proper operation. + +Your work has been preserved. Please start a new loop with the updated plugin. + +\`/humanize:start-rlcr-loop \`" + + REASON=$(load_and_render_safe "$TEMPLATE_DIR" "block/pre-112-state-file.md" "$FALLBACK") + + echo "$REASON" >&2 + exit 2 +fi + +# Read plan file settings first (needed for ancestry check decision) +PLAN_FILE=$(grep -E "^plan_file:" "$STATE_FILE" 2>/dev/null | sed 's/plan_file: *//' || echo "") +COMMIT_PLAN_FILE=$(grep -E "^commit_plan_file:" "$STATE_FILE" 2>/dev/null | sed 's/commit_plan_file: *//' || echo "false") + +# No plan file configured - allow prompt +if [[ -z "$PLAN_FILE" ]]; then + exit 0 +fi + +# ======================================== +# Determine Plan File Location +# ======================================== +# Use git toplevel (not PROJECT_ROOT) to handle monorepo subdirectories correctly + +PLAN_FILE_REL=$(get_relative_path "$PROJECT_ROOT" "$PLAN_FILE") + +# Compute path relative to git toplevel for inside/outside decision +GIT_TOPLEVEL=$(git rev-parse --show-toplevel 2>/dev/null || echo "") +PLAN_FILE_INSIDE_REPO="false" +if [[ -n "$GIT_TOPLEVEL" ]]; then + PLAN_FILE_REL_GIT=$(get_relative_path "$GIT_TOPLEVEL" "$PLAN_FILE") + if [[ "$PLAN_FILE_REL_GIT" != ../* ]]; then + PLAN_FILE_INSIDE_REPO="true" + fi +fi + +# ======================================== +# Check Git Ancestry - Only block if plan file has changes +# ======================================== +# This check only applies when: +# - --commit-plan-file is NOT set (Case 2 or 4) +# - Plan file is inside the repo +# - Plan file is tracked by git +# If ancestry fails but there are no changes to the plan file, allow the prompt. + +if ! check_start_commit_ancestry "$START_COMMIT"; then + # Ancestry failed - user may have checked out a different branch + SHOULD_BLOCK="false" + + # Only check for plan file changes if: + # 1. --commit-plan-file is NOT set + # 2. Plan file is inside the repo + # 3. Plan file is tracked + if [[ "$COMMIT_PLAN_FILE" != "true" ]] && \ + [[ "$PLAN_FILE_INSIDE_REPO" == "true" ]] && \ + [[ -n "$PLAN_FILE_REL_GIT" ]] && \ + git ls-files --error-unmatch "$PLAN_FILE_REL_GIT" &>/dev/null 2>&1; then + + # Check if there are changes to the plan file between start_commit and HEAD + # Check both directions explicitly for clarity + FORWARD_CHANGES=$(git log --oneline "${START_COMMIT}..HEAD" -- "$PLAN_FILE_REL_GIT" 2>/dev/null || true) + BACKWARD_CHANGES=$(git log --oneline "HEAD..${START_COMMIT}" -- "$PLAN_FILE_REL_GIT" 2>/dev/null || true) + PLAN_FILE_CHANGES="${FORWARD_CHANGES}${BACKWARD_CHANGES}" + + if [[ -n "$PLAN_FILE_CHANGES" ]]; then + SHOULD_BLOCK="true" + fi + fi + + if [[ "$SHOULD_BLOCK" == "true" ]]; then + CURRENT_HEAD=$(git rev-parse --short HEAD 2>/dev/null || echo "unknown") + START_COMMIT_SHORT=$(git rev-parse --short "$START_COMMIT" 2>/dev/null || echo "$START_COMMIT") + + # Stop the loop (unexpected: plan file changed on different branch) + stop_loop "$STATE_FILE" "unexpected" + + FALLBACK="# RLCR Loop Terminated - Plan File Changed on Different Branch + +The current HEAD (\`$CURRENT_HEAD\`) diverged from the loop's start commit (\`$START_COMMIT_SHORT\`), +and the plan file has changes between these commits. + +This typically happens when you checked out a different branch that has modified the plan file. + +**To continue working:** +1. If you want to continue the RLCR loop, checkout the original branch and start a new loop +2. If you want to work without the loop, you can proceed normally + +\`/humanize:start-rlcr-loop \`" + + REASON=$(load_and_render_safe "$TEMPLATE_DIR" "block/branch-changed.md" "$FALLBACK" \ + "CURRENT_HEAD=$CURRENT_HEAD" \ + "START_COMMIT=$START_COMMIT_SHORT") + + echo "$REASON" >&2 + exit 2 + fi + # If SHOULD_BLOCK is false, allow the prompt to continue +fi + +# ======================================== +# Case 3: --commit-plan-file + Outside repo = Configuration Conflict +# ======================================== + +if [[ "$COMMIT_PLAN_FILE" == "true" ]] && [[ "$PLAN_FILE_INSIDE_REPO" == "false" ]]; then + # Use git-relative path for display if available + DISPLAY_REL="${PLAN_FILE_REL_GIT:-$PLAN_FILE_REL}" + + FALLBACK="# Configuration Conflict: Plan File Outside Repository + +**Error**: --commit-plan-file is set but the plan file is outside the git repository. + +**Plan file**: \`$PLAN_FILE\` +**Relative to git root**: \`$DISPLAY_REL\` + +This is a configuration error. The loop cannot continue. + +**To fix**: Cancel this loop and start a new one with either: +1. Move the plan file inside the repository and use --commit-plan-file +2. Use the loop without --commit-plan-file for external plan files + +\`/humanize:cancel-rlcr-loop\`" + + REASON=$(load_and_render_safe "$TEMPLATE_DIR" "block/plan-file-outside-repo-conflict.md" "$FALLBACK" \ + "PLAN_FILE=$PLAN_FILE" \ + "PLAN_FILE_REL=$DISPLAY_REL") + + echo "$REASON" >&2 + exit 2 +fi + +# ======================================== +# Case 1: --commit-plan-file + Inside repo = Must be tracked AND clean +# ======================================== + +if [[ "$COMMIT_PLAN_FILE" == "true" ]] && [[ "$PLAN_FILE_INSIDE_REPO" == "true" ]]; then + # Check if plan file is tracked (use git-relative path for monorepo support) + if ! git ls-files --error-unmatch "$PLAN_FILE_REL_GIT" &>/dev/null 2>&1; then + FALLBACK="# Error: Plan File Not Tracked + +The plan file is not tracked by git, but --commit-plan-file requires it to be tracked. + +**Plan file**: \`$PLAN_FILE\` + +**Options:** +1. **Track the plan file**: \`git add '$PLAN_FILE' && git commit -m 'Add plan file'\` +2. **Cancel and restart** without --commit-plan-file if you want the plan to remain untracked + +\`/humanize:cancel-rlcr-loop\`" + + REASON=$(load_and_render_safe "$TEMPLATE_DIR" "block/plan-file-not-tracked.md" "$FALLBACK" \ + "PLAN_FILE=$PLAN_FILE") + + echo "$REASON" >&2 + exit 2 + fi + + # Check if plan file is clean (no uncommitted changes) + # Use git-relative path for monorepo support + PLAN_FILE_STATUS=$(git status --porcelain "$PLAN_FILE_REL_GIT" 2>/dev/null || true) + if [[ -n "$PLAN_FILE_STATUS" ]]; then + FALLBACK="# Error: Plan File Has Uncommitted Changes + +The plan file has uncommitted changes, but --commit-plan-file requires it to be clean. + +**Plan file**: \`$PLAN_FILE\` +**Status**: \`$PLAN_FILE_STATUS\` + +**Options:** +1. **Commit the plan file changes**: \`git add '$PLAN_FILE' && git commit -m 'Update plan'\` +2. **Discard the changes**: \`git checkout -- '$PLAN_FILE'\` +3. **Cancel and restart** without --commit-plan-file if you want the plan to remain uncommitted + +\`/humanize:cancel-rlcr-loop\`" + + REASON=$(load_and_render_safe "$TEMPLATE_DIR" "block/plan-file-uncommitted.md" "$FALLBACK" \ + "PLAN_FILE=$PLAN_FILE" \ + "PLAN_FILE_STATUS=$PLAN_FILE_STATUS") + + echo "$REASON" >&2 + exit 2 + fi +fi + +# ======================================== +# All Cases: Check Plan File Content vs Backup +# ======================================== +# For Cases 1, 2, and 4, verify plan file content matches backup + +PLAN_BACKUP_FILE="$ACTIVE_LOOP_DIR/plan-backup.md" + +# No backup - can't validate content, allow prompt +if [[ ! -f "$PLAN_BACKUP_FILE" ]]; then + exit 0 +fi + +PLAN_MODIFIED="false" +PLAN_MISSING="false" + +if [[ ! -f "$PLAN_FILE" ]]; then + # Plan file was deleted/moved + PLAN_MODIFIED="true" + PLAN_MISSING="true" +elif ! diff -q "$PLAN_FILE" "$PLAN_BACKUP_FILE" &>/dev/null; then + # Plan file content differs from backup + PLAN_MODIFIED="true" +fi + +# Plan file unchanged - allow prompt +if [[ "$PLAN_MODIFIED" != "true" ]]; then + exit 0 +fi + +# ======================================== +# Block Prompt - Plan File Content Changed +# ======================================== + +STATUS_TEXT="modified" +if [[ "$PLAN_MISSING" == "true" ]]; then + STATUS_TEXT="missing/deleted" +fi + +FALLBACK="# RLCR Loop Blocked: Plan File Changed + +The plan file has been $STATUS_TEXT since the loop started. + +**Plan file**: \`$PLAN_FILE\` +**Backup**: \`$PLAN_BACKUP_FILE\` + +The RLCR loop cannot continue because the plan has changed. + +**Options:** +1. **Restart the loop** with the new plan: + \`/humanize:start-rlcr-loop $PLAN_FILE\` + +2. **Restore the original plan** from backup: + \`cp '$PLAN_BACKUP_FILE' '$PLAN_FILE'\` + Then submit your prompt again. + +3. **Cancel the loop** and work without RLCR: + \`/humanize:cancel-rlcr-loop\`" + +REASON=$(load_and_render_safe "$TEMPLATE_DIR" "block/plan-file-changed-prompt-block.md" "$FALLBACK" \ + "PLAN_FILE=$PLAN_FILE" \ + "PLAN_BACKUP_FILE=$PLAN_BACKUP_FILE" \ + "STATUS_TEXT=$STATUS_TEXT") + +echo "$REASON" >&2 +exit 2 diff --git a/prompt-template/block/branch-changed.md b/prompt-template/block/branch-changed.md new file mode 100644 index 00000000..cb6e6604 --- /dev/null +++ b/prompt-template/block/branch-changed.md @@ -0,0 +1,22 @@ +# RLCR Loop Terminated - Branch Changed + +The current HEAD (`{{CURRENT_HEAD}}`) is not a descendant of the loop's start commit (`{{START_COMMIT}}`). + +**What happened:** +- You have checked out an older branch or reset to a previous commit +- The loop state is no longer valid for this commit history +- The state file has been renamed to `unexpected-state.md` to stop the loop + +**Why this matters:** +The RLCR loop tracks progress against a specific commit history. When you switch to a branch that doesn't include the loop's starting point, the loop's state (round number, summaries, etc.) no longer makes sense. + +**To continue working:** +1. If you want to continue the RLCR loop: + - Checkout the original branch that contains the loop's commits + - Rename `unexpected-state.md` back to `state.md` to resume +2. If you want to start fresh: + - Start a new RLCR loop: `/humanize:start-rlcr-loop ` +3. If you want to work without the loop: + - Simply proceed with your work normally + +Your previous round summaries and review results are still available in the loop directory for reference. diff --git a/prompt-template/block/plan-file-changed-commit-mode.md b/prompt-template/block/plan-file-changed-commit-mode.md new file mode 100644 index 00000000..03d547a6 --- /dev/null +++ b/prompt-template/block/plan-file-changed-commit-mode.md @@ -0,0 +1,16 @@ +# Error: Plan File Changed (--commit-plan-file mode) + +The plan file has changed since the loop started, but --commit-plan-file requires it to be tracked and clean. + +**Plan file**: `{{PLAN_FILE}}` +**Issues**: +{{ISSUE_DETAILS}} + +**Backup**: `{{PLAN_BACKUP_FILE}}` + +The loop cannot continue because the plan file state has changed unexpectedly. + +**Options:** +1. **Commit the plan file changes** and restart the loop +2. **Revert to the original plan**: `cp '{{PLAN_BACKUP_FILE}}' '{{PLAN_FILE}}'` then commit +3. **Start a new loop** without --commit-plan-file if you want the plan to remain uncommitted diff --git a/prompt-template/block/plan-file-changed-prompt-block.md b/prompt-template/block/plan-file-changed-prompt-block.md new file mode 100644 index 00000000..66d3c93c --- /dev/null +++ b/prompt-template/block/plan-file-changed-prompt-block.md @@ -0,0 +1,19 @@ +# RLCR Loop Blocked: Plan File Changed + +The plan file has been {{STATUS_TEXT}} since the loop started. + +**Plan file**: `{{PLAN_FILE}}` +**Backup**: `{{PLAN_BACKUP_FILE}}` + +The RLCR loop cannot continue because the plan has changed. + +**Options:** +1. **Restart the loop** with the new plan: + `/humanize:start-rlcr-loop {{PLAN_FILE}}` + +2. **Restore the original plan** from backup: + `cp '{{PLAN_BACKUP_FILE}}' '{{PLAN_FILE}}'` + Then submit your prompt again. + +3. **Cancel the loop** and work without RLCR: + `/humanize:cancel-rlcr-loop` diff --git a/prompt-template/block/plan-file-committed.md b/prompt-template/block/plan-file-committed.md new file mode 100644 index 00000000..39881b16 --- /dev/null +++ b/prompt-template/block/plan-file-committed.md @@ -0,0 +1,33 @@ +# Plan File Accidentally Committed + +The plan file was committed but `--commit-plan-file` was not set when starting the RLCR loop. + +**Plan file**: `{{PLAN_FILE}}` + +**Commits containing the plan file**: +{{PLAN_FILE_COMMITS}} + +## Why This Is Blocked + +When `--commit-plan-file` is not set, the plan file is treated as a working document that should not be tracked in version control. This allows you to modify the plan file during the loop without polluting the commit history. + +## Required Actions + +Choose one of the following: + +### Option 1: Remove the plan file from commits (Recommended) + +1. Identify how many commits contain the plan file +2. Reset those commits: `git reset --soft HEAD~N` (where N is the number of commits to reset) +3. Unstage the plan file: `git reset HEAD {{PLAN_FILE}}` +4. Re-commit your changes without the plan file + +### Option 2: Use --commit-plan-file + +If you actually want the plan file to be tracked in version control: + +1. Cancel this RLCR loop: `/humanize:cancel-rlcr-loop` +2. Restart with the --commit-plan-file flag: + `/humanize:start-rlcr-loop --commit-plan-file` + +**Note**: The plan file backup is always saved in `.humanize-loop.local//plan-backup.md` regardless of this setting. diff --git a/prompt-template/block/plan-file-modified-warning.md b/prompt-template/block/plan-file-modified-warning.md new file mode 100644 index 00000000..f03ce40f --- /dev/null +++ b/prompt-template/block/plan-file-modified-warning.md @@ -0,0 +1,20 @@ +# Warning: Plan File Modified + +The plan file has been modified since the loop started. + +**Plan file**: `{{PLAN_FILE}}` ({{TRACKING_STATUS}}) +**Backup**: `{{PLAN_BACKUP_FILE}}` + +The current plan differs from the backup taken when the loop started. Your work may no longer align with the updated plan. + +**Options:** +1. **Restart the loop** with the new plan: + `/humanize:start-rlcr-loop {{PLAN_FILE}}` + +2. **Continue with the new plan** by overwriting the backup: + `cp '{{PLAN_FILE}}' '{{PLAN_BACKUP_FILE}}'` + Then start a new loop with `/humanize:start-rlcr-loop {{PLAN_FILE}}` + +3. **Revert to the original plan**: + `cp '{{PLAN_BACKUP_FILE}}' '{{PLAN_FILE}}'` + Then start a new loop with `/humanize:start-rlcr-loop {{PLAN_FILE}}` diff --git a/prompt-template/block/plan-file-not-tracked.md b/prompt-template/block/plan-file-not-tracked.md new file mode 100644 index 00000000..1338b959 --- /dev/null +++ b/prompt-template/block/plan-file-not-tracked.md @@ -0,0 +1,11 @@ +# Error: Plan File Not Tracked + +The plan file is not tracked by git, but --commit-plan-file requires it to be tracked. + +**Plan file**: `{{PLAN_FILE}}` + +**Options:** +1. **Track the plan file**: `git add '{{PLAN_FILE}}' && git commit -m 'Add plan file'` +2. **Cancel and restart** without --commit-plan-file if you want the plan to remain untracked + +`/humanize:cancel-rlcr-loop` diff --git a/prompt-template/block/plan-file-outside-repo-conflict.md b/prompt-template/block/plan-file-outside-repo-conflict.md new file mode 100644 index 00000000..4371b040 --- /dev/null +++ b/prompt-template/block/plan-file-outside-repo-conflict.md @@ -0,0 +1,13 @@ +# Configuration Conflict: Plan File Outside Repository + +**Error**: --commit-plan-file is set but the plan file is outside the git repository. + +**Plan file**: `{{PLAN_FILE}}` +**Relative path**: `{{PLAN_FILE_REL}}` + +This is a configuration error that should have been caught at setup. +The loop cannot continue with this configuration. + +**To fix**: Start a new loop with either: +1. Move the plan file inside the repository and use --commit-plan-file +2. Use the loop without --commit-plan-file for external plan files diff --git a/prompt-template/block/plan-file-staged.md b/prompt-template/block/plan-file-staged.md new file mode 100644 index 00000000..6e4cbf68 --- /dev/null +++ b/prompt-template/block/plan-file-staged.md @@ -0,0 +1,29 @@ +# Git Commit Blocked: Plan File is Staged + +The plan file is staged and would be included in this commit, but `--commit-plan-file` was not set when starting the RLCR loop. + +**Plan file**: `{{PLAN_FILE}}` + +## Why This Is Blocked + +When `--commit-plan-file` is not set, the plan file should not be committed to version control. This prevents polluting the commit history with working document changes. + +## How to Fix + +### Option 1: Unstage the plan file (Recommended) + +Run this command to unstage the plan file, then retry your commit: + +```bash +git reset HEAD {{PLAN_FILE}} +``` + +### Option 2: Use --commit-plan-file + +If you want the plan file to be tracked in version control: + +1. Cancel this RLCR loop: `/humanize:cancel-rlcr-loop` +2. Restart with the --commit-plan-file flag: + `/humanize:start-rlcr-loop {{PLAN_FILE}} --commit-plan-file` + +**Note**: The plan file backup is always saved in `.humanize-loop.local//plan-backup.md` regardless of this setting. diff --git a/prompt-template/block/plan-file-uncommitted.md b/prompt-template/block/plan-file-uncommitted.md new file mode 100644 index 00000000..477df87b --- /dev/null +++ b/prompt-template/block/plan-file-uncommitted.md @@ -0,0 +1,11 @@ +# Error: Plan File Has Uncommitted Changes + +The plan file has uncommitted changes, but --commit-plan-file requires it to be clean. + +**Plan file**: `{{PLAN_FILE}}` +**Status**: `{{PLAN_FILE_STATUS}}` + +**Options:** +1. **Commit the plan file changes**: `git add '{{PLAN_FILE}}' && git commit -m 'Update plan'` +2. **Discard the changes**: `git checkout -- '{{PLAN_FILE}}'` +3. **Start a new loop** without --commit-plan-file if you want the plan to remain uncommitted diff --git a/prompt-template/block/pre-112-state-file.md b/prompt-template/block/pre-112-state-file.md new file mode 100644 index 00000000..742855f9 --- /dev/null +++ b/prompt-template/block/pre-112-state-file.md @@ -0,0 +1,17 @@ +# RLCR Loop Terminated - Upgrade Required + +This loop was started with an older version of Humanize (pre-1.1.2) that did not track the starting commit. The new plan file protection features cannot work reliably without this information. + +**What happened:** +- Your state file has been renamed to `unexpected-state.md` to stop the loop +- Your work and summaries in `.humanize-loop.local/` are preserved + +**To continue your work:** +1. Update Humanize to version 1.1.2 or later +2. Start a new RLCR loop with your plan file: + `/humanize:start-rlcr-loop ` + +**Why this change:** +Version 1.1.2 introduced the `--commit-plan-file` option which requires tracking the starting commit to detect accidental plan file commits. Old state files cannot support this feature safely. + +Your previous round summaries and review results are still available in the loop directory for reference. diff --git a/scripts/setup-rlcr-loop.sh b/scripts/setup-rlcr-loop.sh index 7c3099df..ac414478 100755 --- a/scripts/setup-rlcr-loop.sh +++ b/scripts/setup-rlcr-loop.sh @@ -10,6 +10,10 @@ set -euo pipefail +# Source shared functions (for get_relative_path) +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd)" +source "$SCRIPT_DIR/../hooks/lib/loop-common.sh" + # ======================================== # Default Configuration # ======================================== @@ -30,6 +34,7 @@ CODEX_MODEL="$DEFAULT_CODEX_MODEL" CODEX_EFFORT="$DEFAULT_CODEX_EFFORT" CODEX_TIMEOUT="$DEFAULT_CODEX_TIMEOUT" PUSH_EVERY_ROUND="false" +COMMIT_PLAN_FILE="false" show_help() { cat << 'HELP_EOF' @@ -49,6 +54,10 @@ OPTIONS: --codex-timeout Timeout for each Codex review in seconds (default: 5400) --push-every-round Require git push after each round (default: commits stay local) + --commit-plan-file Include the plan file in commits (default: plan file stays uncommitted) + When set, the plan file must not be git-ignored and will be committed. + When not set, the plan file is allowed to remain dirty (uncommitted), + and any accidental commits of the plan file will be blocked. -h, --help Show this help message DESCRIPTION: @@ -138,6 +147,10 @@ while [[ $# -gt 0 ]]; do PUSH_EVERY_ROUND="true" shift ;; + --commit-plan-file) + COMMIT_PLAN_FILE="true" + shift + ;; -*) echo "Unknown option: $1" >&2 echo "Use --help for usage information" >&2 @@ -170,9 +183,10 @@ if [[ -z "$PLAN_FILE" ]]; then exit 1 fi +PROJECT_ROOT="${CLAUDE_PROJECT_DIR:-$(pwd)}" + # Make path absolute if relative if [[ ! "$PLAN_FILE" = /* ]]; then - PROJECT_ROOT="${CLAUDE_PROJECT_DIR:-$(pwd)}" PLAN_FILE="$PROJECT_ROOT/$PLAN_FILE" fi @@ -182,6 +196,32 @@ if [[ ! -f "$PLAN_FILE" ]]; then exit 1 fi +# Require git repository with at least one commit +if ! git rev-parse --git-dir &>/dev/null 2>&1; then + echo "Error: RLCR loop requires a git repository" >&2 + echo "Please initialize a git repository first: git init" >&2 + exit 1 +fi + +if ! git rev-parse HEAD &>/dev/null 2>&1; then + echo "Error: RLCR loop requires at least one commit in the repository" >&2 + echo "Please create an initial commit first: git commit -m 'Initial commit'" >&2 + exit 1 +fi + +# Get relative path for validation (uses portable get_relative_path from loop-common.sh) +PLAN_FILE_REL=$(get_relative_path "$PROJECT_ROOT" "$PLAN_FILE") + +# Reject paths with spaces or regex metacharacters (required for git status filtering) +if [[ "$PLAN_FILE_REL" =~ [[:space:]\[\]\*\?\{\}\|\(\)\^\$\\] ]]; then + echo "Error: Plan file path contains unsupported characters" >&2 + echo "Plan file: $PLAN_FILE_REL" >&2 + echo "Plan file paths must not contain spaces or special characters:" >&2 + echo " spaces, [ ] * ? { } | ( ) ^ \$ \\" >&2 + echo "Please rename or move the plan file to a simpler path." >&2 + exit 1 +fi + # Check plan file has at least 5 lines LINE_COUNT=$(wc -l < "$PLAN_FILE" | tr -d ' ') if [[ "$LINE_COUNT" -lt 5 ]]; then @@ -200,11 +240,72 @@ if ! command -v codex &>/dev/null; then exit 1 fi +# Compute path relative to git toplevel for "inside git repo" check +# This handles monorepo setups where PROJECT_ROOT may be a subdirectory +GIT_TOPLEVEL=$(git rev-parse --show-toplevel 2>/dev/null || echo "") +PLAN_FILE_REL_GIT="" +if [[ -n "$GIT_TOPLEVEL" ]]; then + PLAN_FILE_REL_GIT=$(get_relative_path "$GIT_TOPLEVEL" "$PLAN_FILE") +fi + +# Validate --commit-plan-file requirements +if [[ "$COMMIT_PLAN_FILE" == "true" ]]; then + if [[ -z "$PLAN_FILE_REL_GIT" ]] || [[ "$PLAN_FILE_REL_GIT" == ../* ]]; then + echo "Error: --commit-plan-file is set but the plan file is outside the git repository" >&2 + echo "Plan file: $PLAN_FILE" >&2 + if [[ -n "$PLAN_FILE_REL_GIT" ]]; then + echo "Relative to git root: $PLAN_FILE_REL_GIT" >&2 + fi + echo "Either move the plan file inside the git repository or omit --commit-plan-file" >&2 + exit 1 + fi + + if git check-ignore -q "$PLAN_FILE" 2>/dev/null; then + echo "Error: --commit-plan-file is set but the plan file is git-ignored" >&2 + echo "Plan file: $PLAN_FILE" >&2 + echo "Either remove from .gitignore or omit --commit-plan-file" >&2 + exit 1 + fi +fi + +# ======================================== +# Check Plan File Tracking Status +# ======================================== +# Determine if plan file is tracked in git (inside repo and committed) + +PLAN_FILE_TRACKED="false" +if [[ -n "$PLAN_FILE_REL_GIT" ]] && [[ "$PLAN_FILE_REL_GIT" != ../* ]]; then + if ! git check-ignore -q "$PLAN_FILE" 2>/dev/null; then + if git ls-files --error-unmatch "$PLAN_FILE_REL_GIT" &>/dev/null 2>&1; then + PLAN_FILE_TRACKED="true" + fi + fi +fi + +# Enforce --commit-plan-file requirements: must be tracked and clean +if [[ "$COMMIT_PLAN_FILE" == "true" ]]; then + if [[ "$PLAN_FILE_TRACKED" != "true" ]]; then + echo "Error: --commit-plan-file is set but the plan file is not tracked by git" >&2 + echo "Plan file: $PLAN_FILE_REL" >&2 + echo "Add and commit: git add '$PLAN_FILE_REL' && git commit -m 'Add plan file'" >&2 + echo "Or omit --commit-plan-file to allow uncommitted plan files" >&2 + exit 1 + fi + + PLAN_FILE_STATUS=$(git status --porcelain "$PLAN_FILE_REL_GIT" 2>/dev/null || true) + if [[ -n "$PLAN_FILE_STATUS" ]]; then + echo "Error: --commit-plan-file is set but the plan file has uncommitted changes" >&2 + echo "Plan file: $PLAN_FILE_REL (status: $PLAN_FILE_STATUS)" >&2 + echo "Commit changes: git add '$PLAN_FILE_REL' && git commit -m 'Update plan'" >&2 + echo "Or omit --commit-plan-file to allow uncommitted plan files" >&2 + exit 1 + fi +fi + # ======================================== # Setup State Directory # ======================================== -PROJECT_ROOT="${CLAUDE_PROJECT_DIR:-$(pwd)}" LOOP_BASE_DIR="$PROJECT_ROOT/.humanize-loop.local" # Create timestamp for this loop session @@ -213,7 +314,9 @@ LOOP_DIR="$LOOP_BASE_DIR/$TIMESTAMP" mkdir -p "$LOOP_DIR" -# Docs path default +# Backup plan file and record starting commit for post-commit validation +cp "$PLAN_FILE" "$LOOP_DIR/plan-backup.md" +START_COMMIT=$(git rev-parse HEAD 2>/dev/null || echo "") DOCS_PATH="docs" # ======================================== @@ -228,7 +331,10 @@ codex_model: $CODEX_MODEL codex_effort: $CODEX_EFFORT codex_timeout: $CODEX_TIMEOUT push_every_round: $PUSH_EVERY_ROUND +commit_plan_file: $COMMIT_PLAN_FILE plan_file: $PLAN_FILE +plan_file_tracked: $PLAN_FILE_TRACKED +start_commit: $START_COMMIT started_at: $(date -u +%Y-%m-%dT%H:%M:%SZ) --- EOF @@ -402,6 +508,9 @@ cat << EOF === start-rlcr-loop activated === Plan File: $PLAN_FILE ($LINE_COUNT lines) +Plan File Backup: $LOOP_DIR/plan-backup.md +Commit Plan File: $COMMIT_PLAN_FILE +Start Commit: ${START_COMMIT:-"(not in git repo)"} Max Iterations: $MAX_ITERATIONS Codex Model: $CODEX_MODEL Codex Effort: $CODEX_EFFORT diff --git a/tests/test-plan-file-handling.sh b/tests/test-plan-file-handling.sh new file mode 100755 index 00000000..bd773a8c --- /dev/null +++ b/tests/test-plan-file-handling.sh @@ -0,0 +1,1036 @@ +#!/bin/bash +# +# Test plan file handling functionality +# +# Tests for: +# - --commit-plan-file option parsing +# - Plan file backup creation +# - State file fields (commit_plan_file, start_commit) +# - Git-ignored validation +# - Git status filtering for plan file +# + +set -uo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' # No Color + +PASSED=0 +FAILED=0 +WARNINGS=0 + +pass() { + echo -e " ${GREEN}PASS${NC}: $1" + ((PASSED++)) +} + +fail() { + echo -e " ${RED}FAIL${NC}: $1" + ((FAILED++)) +} + +warn() { + echo -e " ${YELLOW}WARN${NC}: $1" + ((WARNINGS++)) +} + +section() { + echo "" + echo -e "${BLUE}========================================${NC}" + echo -e "${BLUE}$1${NC}" + echo -e "${BLUE}========================================${NC}" +} + +# Create temporary test directory +TEST_DIR=$(mktemp -d) +trap "rm -rf $TEST_DIR" EXIT + +section "Section 1: Setup Script Option Parsing" + +# Test 1.1: --commit-plan-file option is recognized in help +echo "Testing --commit-plan-file in help output..." +HELP_OUTPUT=$("$PROJECT_ROOT/scripts/setup-rlcr-loop.sh" --help 2>&1 || true) +if echo "$HELP_OUTPUT" | grep -q -- "--commit-plan-file"; then + pass "--commit-plan-file option documented in help" +else + fail "--commit-plan-file option not found in help output" +fi + +# Test 1.2: Help shows plan file stays uncommitted by default +if echo "$HELP_OUTPUT" | grep -q "plan file stays uncommitted"; then + pass "Help explains default behavior (plan file stays uncommitted)" +else + fail "Help doesn't explain default behavior" +fi + +section "Section 2: State File Schema" + +# Create a mock plan file +MOCK_PLAN="$TEST_DIR/test-plan.md" +cat > "$MOCK_PLAN" << 'EOF' +# Test Plan + +## Goal +Test the plan file handling feature. + +## Tasks +1. Task one +2. Task two +3. Task three +EOF + +# Test 2.1: Check setup script creates correct state file fields +echo "Testing state file schema..." + +# We can't run the full setup without codex, but we can verify the script +# contains the correct state file template +SETUP_SCRIPT="$PROJECT_ROOT/scripts/setup-rlcr-loop.sh" + +if grep -q "commit_plan_file:" "$SETUP_SCRIPT"; then + pass "State file includes commit_plan_file field" +else + fail "State file missing commit_plan_file field" +fi + +if grep -q "start_commit:" "$SETUP_SCRIPT"; then + pass "State file includes start_commit field" +else + fail "State file missing start_commit field" +fi + +if grep -q "plan_file_tracked:" "$SETUP_SCRIPT"; then + pass "State file includes plan_file_tracked field" +else + fail "State file missing plan_file_tracked field" +fi + +# Test 2.2: Check plan backup is created +if grep -q "plan-backup.md" "$SETUP_SCRIPT"; then + pass "Setup script creates plan-backup.md" +else + fail "Setup script doesn't create plan-backup.md" +fi + +# Test 2.3: Check PROJECT_ROOT is initialized unconditionally +# PLAN_FILE_REL now uses get_relative_path (portable function from loop-common.sh) +if grep -n 'PROJECT_ROOT=' "$SETUP_SCRIPT" | head -1 | cut -d: -f1 > /tmp/proj_root_line && \ + grep -n 'PLAN_FILE_REL=.*get_relative_path' "$SETUP_SCRIPT" | head -1 | cut -d: -f1 > /tmp/plan_rel_line; then + PROJ_LINE=$(cat /tmp/proj_root_line) + REL_LINE=$(cat /tmp/plan_rel_line) + if [[ "$PROJ_LINE" -lt "$REL_LINE" ]]; then + pass "PROJECT_ROOT initialized before PLAN_FILE_REL calculation" + else + fail "PROJECT_ROOT not initialized before PLAN_FILE_REL" + fi +else + fail "PROJECT_ROOT not initialized before PLAN_FILE_REL" +fi + +# Test 2.4: Check tracked plan file must be clean when --commit-plan-file is set +if grep -q "plan file has uncommitted changes" "$SETUP_SCRIPT"; then + pass "Setup script validates tracked plan file must be clean when --commit-plan-file is set" +else + fail "Setup script missing tracked plan file clean check" +fi + +if grep -q "git ls-files.*error-unmatch" "$SETUP_SCRIPT"; then + pass "Setup script uses git ls-files to check if plan file is tracked" +else + fail "Setup script missing git ls-files check for tracked status" +fi + +section "Section 3: Git-Ignored Validation" + +# Test 3.1: Check setup script validates git-ignored plan file +echo "Testing git-ignored validation logic..." + +if grep -q "git check-ignore" "$SETUP_SCRIPT"; then + pass "Setup script checks if plan file is git-ignored" +else + fail "Setup script doesn't check git-ignored status" +fi + +if grep -q "plan file is git-ignored" "$SETUP_SCRIPT"; then + pass "Setup script has error message for git-ignored plan file" +else + fail "Setup script missing git-ignored error message" +fi + +# Test 3.2: Check setup script validates simple path (no spaces/regex chars) +echo "Testing simple path validation..." + +if grep -q "unsupported characters" "$SETUP_SCRIPT"; then + pass "Setup script validates plan file path for special characters" +else + fail "Setup script missing special character validation" +fi + +if grep -q '\[:space:\]' "$SETUP_SCRIPT"; then + pass "Setup script checks for spaces in path" +else + fail "Setup script doesn't check for spaces in path" +fi + +# Test 3.3: Check setup script validates relative path (uses portable get_relative_path) +if grep -q 'PLAN_FILE_REL=.*get_relative_path' "$SETUP_SCRIPT"; then + pass "Setup script uses portable relative path for validation" +else + fail "Setup script doesn't use portable relative path for validation" +fi + +# Test 3.4: Check setup script requires git repository +echo "Testing git repository requirement..." + +if grep -q "RLCR loop requires a git repository" "$SETUP_SCRIPT"; then + pass "Setup script requires git repository" +else + fail "Setup script doesn't require git repository" +fi + +# Test 3.5: Check setup script requires at least one commit +if grep -q "RLCR loop requires at least one commit" "$SETUP_SCRIPT"; then + pass "Setup script requires at least one commit" +else + fail "Setup script doesn't require at least one commit" +fi + +# Test 3.6: Check setup script validates plan file inside git repo when --commit-plan-file +echo "Testing plan file location validation..." + +if grep -q "plan file is outside the git repository" "$SETUP_SCRIPT"; then + pass "Setup script validates plan file must be inside git repo for --commit-plan-file" +else + fail "Setup script missing outside git repo validation for --commit-plan-file" +fi + +if grep -q 'PLAN_FILE_REL_GIT.* == \.\./\*' "$SETUP_SCRIPT"; then + pass "Setup script checks for ../ prefix in git-relative path" +else + fail "Setup script doesn't check for ../ prefix" +fi + +section "Section 4: Stop Hook Git Status Filtering" + +STOP_HOOK="$PROJECT_ROOT/hooks/loop-codex-stop-hook.sh" + +# Test 4.1: Check stop hook reads commit_plan_file from state +echo "Testing stop hook state reading..." + +if grep -q "COMMIT_PLAN_FILE=.*grep.*commit_plan_file" "$STOP_HOOK"; then + pass "Stop hook reads commit_plan_file from state" +else + fail "Stop hook doesn't read commit_plan_file from state" +fi + +# Test 4.2: Check stop hook reads start_commit from state +if grep -q "START_COMMIT=.*grep.*start_commit" "$STOP_HOOK"; then + pass "Stop hook reads start_commit from state" +else + fail "Stop hook doesn't read start_commit from state" +fi + +# Test 4.3: Check stop hook filters plan file from git status +if grep -q "FILTERED_GIT_STATUS" "$STOP_HOOK"; then + pass "Stop hook has filtered git status logic" +else + fail "Stop hook missing filtered git status logic" +fi + +# Test 4.4: Check stop hook has post-commit check +if grep -q "Plan File Accidentally Committed" "$STOP_HOOK"; then + pass "Stop hook has post-commit check for plan file" +else + fail "Stop hook missing post-commit check" +fi + +# Test 4.5: Check stop hook uses git log to detect plan file commits +if grep -q "git log.*START_COMMIT.*HEAD" "$STOP_HOOK"; then + pass "Stop hook uses git log for post-commit validation" +else + fail "Stop hook doesn't use git log for validation" +fi + +# Test 4.6: Check state reading has fallback defaults (P1 fix) +if grep -q 'START_COMMIT=.*|| echo ""' "$STOP_HOOK"; then + pass "Stop hook has fallback for missing start_commit (backward compat)" +else + fail "Stop hook missing fallback for start_commit" +fi + +# Test 4.7: Check exact path matching uses end-of-line anchor (P2 fix) +if grep -q 'grep -v.*\$' "$STOP_HOOK"; then + pass "Stop hook uses end-of-line anchor for exact path matching" +else + fail "Stop hook missing end-of-line anchor in path filtering" +fi + +# Test 4.8: Check stop hook handles pre-1.1.2 loops (terminates and allows exit) +if grep -q 'Check for Pre-1.1.2 State File' "$STOP_HOOK" && \ + grep -q 'stop_loop.*STATE_FILE' "$STOP_HOOK"; then + pass "Stop hook detects and terminates pre-1.1.2 loops" +else + fail "Stop hook missing pre-1.1.2 loop handling" +fi + +# Note: Pre-1.1.2 state file check has moved to UserPromptSubmit hook (Section 9) + +# Test 4.9: Check stop hook documents pre-prompt validation in UserPromptSubmit +if grep -q "Pre-Prompt Validation in UserPromptSubmit" "$STOP_HOOK"; then + pass "Stop hook documents pre-prompt validation in UserPromptSubmit" +else + fail "Stop hook missing documentation about pre-prompt validation" +fi + +# Note: Plan file validation tests (Cases 1-4) and pre-1.1.2 check have been moved to Section 9 +# (UserPromptSubmit Hook Tests) as all pre-prompt validations are now handled by +# loop-plan-validator.sh which runs BEFORE the prompt is processed. + +section "Section 4b: Bash Validator Pre-Commit Check" + +BASH_VALIDATOR="$PROJECT_ROOT/hooks/loop-bash-validator.sh" + +# Test 4b.1: Check bash validator has pre-commit check for staged plan file +echo "Testing bash validator pre-commit check..." + +if grep -q 'Block Git Commit When Plan File is Staged' "$BASH_VALIDATOR"; then + pass "Bash validator has pre-commit check section" +else + fail "Bash validator missing pre-commit check section" +fi + +# Test 4b.2: Check bash validator detects git commit command +if grep -q 'git.*commit' "$BASH_VALIDATOR"; then + pass "Bash validator detects git commit command" +else + fail "Bash validator doesn't detect git commit command" +fi + +# Test 4b.3: Check bash validator uses git diff --cached to check staged files +if grep -q 'git diff --cached --name-only' "$BASH_VALIDATOR"; then + pass "Bash validator uses git diff --cached to check staged files" +else + fail "Bash validator doesn't check staged files" +fi + +# Test 4b.4: Check bash validator reads commit_plan_file from state +if grep -q 'COMMIT_PLAN_FILE=.*grep.*commit_plan_file' "$BASH_VALIDATOR"; then + pass "Bash validator reads commit_plan_file from state" +else + fail "Bash validator doesn't read commit_plan_file from state" +fi + +# Test 4b.5: Check bash validator uses plan-file-staged template +if grep -q 'plan-file-staged.md' "$BASH_VALIDATOR"; then + pass "Bash validator uses plan-file-staged template" +else + fail "Bash validator doesn't use plan-file-staged template" +fi + +section "Section 5: Template File Existence" + +# Test 5.1: Check plan-file-committed template exists +TEMPLATE_FILE="$PROJECT_ROOT/prompt-template/block/plan-file-committed.md" +if [[ -f "$TEMPLATE_FILE" ]]; then + pass "plan-file-committed.md template exists" +else + fail "plan-file-committed.md template missing" +fi + +# Test 5.1a2: Check plan-file-staged template exists (for Bash validator pre-commit check) +TEMPLATE_FILE_STAGED="$PROJECT_ROOT/prompt-template/block/plan-file-staged.md" +if [[ -f "$TEMPLATE_FILE_STAGED" ]]; then + pass "plan-file-staged.md template exists" +else + fail "plan-file-staged.md template missing" +fi + +# Test 5.1b: Check pre-112-state-file template exists +TEMPLATE_FILE_OLD="$PROJECT_ROOT/prompt-template/block/pre-112-state-file.md" +if [[ -f "$TEMPLATE_FILE_OLD" ]]; then + pass "pre-112-state-file.md template exists" +else + fail "pre-112-state-file.md template missing" +fi + +# Test 5.1c: Check plan-file-changed-prompt-block template exists (for UserPromptSubmit hook) +TEMPLATE_FILE_CHANGED_PROMPT="$PROJECT_ROOT/prompt-template/block/plan-file-changed-prompt-block.md" +if [[ -f "$TEMPLATE_FILE_CHANGED_PROMPT" ]]; then + pass "plan-file-changed-prompt-block.md template exists" +else + fail "plan-file-changed-prompt-block.md template missing" +fi + +# Test 5.1c2: Check plan-file-uncommitted template exists (for UserPromptSubmit Case 1) +TEMPLATE_FILE_UNCOMMITTED="$PROJECT_ROOT/prompt-template/block/plan-file-uncommitted.md" +if [[ -f "$TEMPLATE_FILE_UNCOMMITTED" ]]; then + pass "plan-file-uncommitted.md template exists" +else + fail "plan-file-uncommitted.md template missing" +fi + +# Test 5.1c3: Check plan-file-not-tracked template exists (for UserPromptSubmit Case 1) +TEMPLATE_FILE_NOT_TRACKED="$PROJECT_ROOT/prompt-template/block/plan-file-not-tracked.md" +if [[ -f "$TEMPLATE_FILE_NOT_TRACKED" ]]; then + pass "plan-file-not-tracked.md template exists" +else + fail "plan-file-not-tracked.md template missing" +fi + +# Test 5.1d: Check plan-file-outside-repo-conflict template exists +TEMPLATE_FILE_OUTSIDE="$PROJECT_ROOT/prompt-template/block/plan-file-outside-repo-conflict.md" +if [[ -f "$TEMPLATE_FILE_OUTSIDE" ]]; then + pass "plan-file-outside-repo-conflict.md template exists" +else + fail "plan-file-outside-repo-conflict.md template missing" +fi + +# Test 5.1e: Check plan-file-changed-commit-mode template exists +TEMPLATE_FILE_CHANGED="$PROJECT_ROOT/prompt-template/block/plan-file-changed-commit-mode.md" +if [[ -f "$TEMPLATE_FILE_CHANGED" ]]; then + pass "plan-file-changed-commit-mode.md template exists" +else + fail "plan-file-changed-commit-mode.md template missing" +fi + +# Test 5.2: Check template has required placeholders +if [[ -f "$TEMPLATE_FILE" ]]; then + if grep -q "{{PLAN_FILE}}" "$TEMPLATE_FILE"; then + pass "Template has PLAN_FILE placeholder" + else + fail "Template missing PLAN_FILE placeholder" + fi + + if grep -q "{{PLAN_FILE_COMMITS}}" "$TEMPLATE_FILE"; then + pass "Template has PLAN_FILE_COMMITS placeholder" + else + fail "Template missing PLAN_FILE_COMMITS placeholder" + fi +fi + +section "Section 6: Git Status Filtering Unit Test" + +# Test the actual filtering logic in isolation +echo "Testing git status filtering logic with exact path matching..." + +# Simulate git status output with similar filenames (P2 test case) +MOCK_GIT_STATUS=" M src/main.js + M docs/plan.md + M docs/plan.md.bak +?? docs/plan.md~ +?? .humanize-loop.local/ +?? new-file.txt" + +PLAN_FILE_REL="docs/plan.md" +# Use the same escaping and anchoring as the actual code +PLAN_FILE_ESCAPED=$(echo "$PLAN_FILE_REL" | sed 's/[.[\*^$()+?{|]/\\&/g') + +# Filter using exact path matching (anchored to end of line) +FILTERED=$(echo "$MOCK_GIT_STATUS" | grep -v " ${PLAN_FILE_ESCAPED}\$" || true) + +if echo "$FILTERED" | grep -q "src/main.js"; then + pass "Filtering preserves non-plan files (src/main.js)" +else + fail "Filtering incorrectly removed non-plan files" +fi + +if echo "$FILTERED" | grep -q " docs/plan.md$"; then + fail "Filtering didn't remove exact plan file" +else + pass "Filtering correctly removes exact plan file" +fi + +# P2 fix: Verify similar filenames are NOT filtered +if echo "$FILTERED" | grep -q "docs/plan.md.bak"; then + pass "Filtering preserves similar filename (docs/plan.md.bak)" +else + fail "Filtering incorrectly removed similar filename .bak" +fi + +if echo "$FILTERED" | grep -q "docs/plan.md~"; then + pass "Filtering preserves similar filename (docs/plan.md~)" +else + fail "Filtering incorrectly removed similar filename ~" +fi + +if echo "$FILTERED" | grep -q ".humanize-loop.local"; then + pass "Filtering preserves .humanize-loop.local" +else + fail "Filtering incorrectly removed .humanize-loop.local" +fi + +section "Section 7: Post-Commit Check Unit Test" + +# Test the git log command pattern +echo "Testing post-commit check logic..." + +# Create a mock git repo for testing +MOCK_REPO="$TEST_DIR/mock-repo" +mkdir -p "$MOCK_REPO" +cd "$MOCK_REPO" +git init -q +git config user.email "test@test.com" +git config user.name "Test User" +git config commit.gpgsign false + +# Create initial commit +echo "initial" > file.txt +git add file.txt +git commit -q -m "Initial commit" +START_COMMIT=$(git rev-parse HEAD) + +# Create a plan file +mkdir -p docs +echo "# Plan" > docs/plan.md + +# Commit the plan file +git add docs/plan.md +git commit -q -m "Add plan file" + +# Check if git log detects the plan file commit +PLAN_FILE_COMMITS=$(git log --oneline --follow "${START_COMMIT}..HEAD" -- "docs/plan.md" 2>/dev/null || true) + +if [[ -n "$PLAN_FILE_COMMITS" ]]; then + pass "Git log correctly detects plan file in commits" +else + fail "Git log failed to detect plan file in commits" +fi + +# Test with a file that wasn't committed +OTHER_COMMITS=$(git log --oneline --follow "${START_COMMIT}..HEAD" -- "non-existent.md" 2>/dev/null || true) + +if [[ -z "$OTHER_COMMITS" ]]; then + pass "Git log correctly returns empty for non-committed files" +else + fail "Git log incorrectly returned results for non-committed file" +fi + +# Test: Pre-1.1.2 loops are terminated by stop hook (no backward compat) +echo "Testing pre-1.1.2 loop handling (terminate and allow exit)..." + +# When START_COMMIT is empty, stop hook terminates loop and allows Claude to exit +# This is tested by checking the stop hook code structure +if grep -q 'stop_loop.*STATE_FILE' "$STOP_HOOK" && grep -q 'Upgrade Required' "$STOP_HOOK"; then + pass "Stop hook terminates pre-1.1.2 loops with upgrade message" +else + fail "Stop hook missing pre-1.1.2 termination logic" +fi + +# P3 fix: Test fresh repo scenario +echo "Testing fresh repo scenario (P3 fix)..." + +FRESH_REPO="$TEST_DIR/fresh-repo" +mkdir -p "$FRESH_REPO" +cd "$FRESH_REPO" +git init -q +git config user.email "test@test.com" +git config user.name "Test User" +git config commit.gpgsign false + +# Check that git rev-parse HEAD fails in empty repo +if git rev-parse HEAD &>/dev/null; then + fail "Expected git rev-parse HEAD to fail in empty repo" +else + pass "git rev-parse HEAD correctly fails in empty repo" +fi + +# Now add a commit with plan file +mkdir -p docs +echo "# Plan" > docs/plan.md +git add docs/plan.md +git commit -q -m "First commit with plan file" + +# Now git rev-parse HEAD should work +if git rev-parse HEAD &>/dev/null; then + pass "git rev-parse HEAD works after first commit" + # And we should detect the plan file + FIRST_COMMIT_PLANS=$(git log --oneline --follow -- "docs/plan.md" 2>/dev/null || true) + if [[ -n "$FIRST_COMMIT_PLANS" ]]; then + pass "Git log detects plan file in first commit (fresh repo)" + else + fail "Git log failed to detect plan file in first commit" + fi +else + fail "git rev-parse HEAD should work after first commit" +fi + +cd "$SCRIPT_DIR" + +section "Section 8: Behavioral Tests for Four Plan File Cases" + +# These tests verify the actual behavior of setup and stop hook for the four cases + +echo "Setting up test repository for behavioral tests..." +BEHAVIOR_TEST_REPO="$TEST_DIR/behavior-test-repo" +mkdir -p "$BEHAVIOR_TEST_REPO" +cd "$BEHAVIOR_TEST_REPO" +git init -q +git config user.email "test@test.com" +git config user.name "Test User" +git config commit.gpgsign false + +# Create initial commit +echo "initial" > file.txt +git add file.txt +git commit -q -m "Initial commit" + +# Test 8.1: Case 2.3 - Setup accepts tracked dirty plan file when --commit-plan-file is NOT set +echo "Testing Case 2.3: Setup accepts tracked dirty plan file without --commit-plan-file..." + +# Create and commit a plan file +mkdir -p docs +cat > docs/plan.md << 'EOF' +# Test Plan + +## Goal +Test tracked dirty plan file acceptance. + +## Tasks +1. Task one +2. Task two +3. Task three +EOF +git add docs/plan.md +git commit -q -m "Add plan file" + +# Make the plan file dirty (modify without committing) +echo "# Modified content" >> docs/plan.md + +# Check that setup script only enforces clean status when COMMIT_PLAN_FILE is true +# We verify the script has the check inside the COMMIT_PLAN_FILE==true conditional block +SETUP_CONTENT=$(cat "$PROJECT_ROOT/scripts/setup-rlcr-loop.sh") +# The clean check (git status --porcelain) should appear inside the COMMIT_PLAN_FILE==true block +# and not outside of it +if echo "$SETUP_CONTENT" | grep -A30 'COMMIT_PLAN_FILE.*==.*true' | grep -q 'git status --porcelain'; then + pass "Case 2.3: Setup only requires clean plan file when --commit-plan-file is set" +else + fail "Case 2.3: Setup might incorrectly reject tracked dirty plan files" +fi + +# Reset the plan file +git checkout -- docs/plan.md + +# Test 8.2: Case 2.1 - Setup requires tracked AND clean plan file when --commit-plan-file IS set +echo "Testing Case 2.1: Setup requires tracked and clean plan file with --commit-plan-file..." + +# Verify setup script checks for tracked status when --commit-plan-file is set +if grep -q 'plan file is not tracked by git' "$PROJECT_ROOT/scripts/setup-rlcr-loop.sh"; then + pass "Case 2.1: Setup checks if plan file is tracked when --commit-plan-file is set" +else + fail "Case 2.1: Setup doesn't check for tracked status with --commit-plan-file" +fi + +# Verify setup script checks for clean status when --commit-plan-file is set +# Use a larger context window since the check is not immediately after the condition +if grep -A30 'COMMIT_PLAN_FILE.*==.*true' "$PROJECT_ROOT/scripts/setup-rlcr-loop.sh" | grep -q 'git status --porcelain'; then + pass "Case 2.1: Setup checks if plan file is clean when --commit-plan-file is set" +else + fail "Case 2.1: Setup doesn't check for clean status with --commit-plan-file" +fi + +# Test 8.3: Case 2.2 - Setup rejects plan file outside repo with --commit-plan-file +echo "Testing Case 2.2: Setup rejects outside repo plan file with --commit-plan-file..." + +# Create a plan file outside the repo +OUTSIDE_PLAN="$TEST_DIR/outside-plan.md" +cat > "$OUTSIDE_PLAN" << 'EOF' +# Outside Plan + +## Goal +Test outside repo rejection. + +## Tasks +1. Task one +2. Task two +3. Task three +EOF + +# Verify setup script would reject this combination +if grep -q 'plan file is outside the git repository' "$PROJECT_ROOT/scripts/setup-rlcr-loop.sh"; then + pass "Case 2.2: Setup rejects outside repo plan file with --commit-plan-file" +else + fail "Case 2.2: Setup doesn't reject outside repo plan file with --commit-plan-file" +fi + +# Test 8.4: Stop hook follows Claude Code hooks spec for allowing exit +echo "Testing stop hook decision: follows hooks spec for allowing exit..." + +# Per Claude Code hooks spec: omit "decision" field to allow stop (NOT "decision": "allow") +# Verify stop hook does NOT use invalid "decision": "allow" +INVALID_ALLOW_COUNT=$(grep -c '"decision": "allow"' "$PROJECT_ROOT/hooks/loop-codex-stop-hook.sh" 2>/dev/null || true) +INVALID_ALLOW_COUNT=${INVALID_ALLOW_COUNT:-0} +if [[ "$INVALID_ALLOW_COUNT" -eq 0 ]]; then + pass "Stop hook follows Claude Code hooks spec (no invalid 'allow' decisions)" +else + fail "Stop hook has invalid 'decision: allow' usage: found $INVALID_ALLOW_COUNT instances" +fi + +# Test 8.5: Stop hook documents that pre-prompt validation is in UserPromptSubmit +echo "Testing stop hook documentation about pre-prompt validation..." + +if grep -q "Pre-Prompt Validation in UserPromptSubmit" "$PROJECT_ROOT/hooks/loop-codex-stop-hook.sh"; then + pass "Stop hook documents that pre-prompt validation is in UserPromptSubmit" +else + fail "Stop hook missing documentation about pre-prompt validation in UserPromptSubmit" +fi + +# Note: Plan file validation (Cases 1-4) and pre-1.1.2 check are now handled by +# UserPromptSubmit hook. Tests for these are in Section 9. + +cd "$SCRIPT_DIR" + +section "Section 9: UserPromptSubmit Hook Tests" + +# The UserPromptSubmit hook (loop-plan-validator.sh) now handles plan file content validation +# It runs BEFORE Claude processes the prompt, blocking if the plan has changed + +PLAN_VALIDATOR="$PROJECT_ROOT/hooks/loop-plan-validator.sh" + +# Test 9.1: Check plan validator hook exists +echo "Testing UserPromptSubmit hook for plan file validation..." + +if [[ -f "$PLAN_VALIDATOR" ]]; then + pass "UserPromptSubmit hook (loop-plan-validator.sh) exists" +else + fail "UserPromptSubmit hook (loop-plan-validator.sh) missing" +fi + +# Test 9.2: Check hook is registered in hooks.json +HOOKS_JSON="$PROJECT_ROOT/hooks/hooks.json" +if grep -q "UserPromptSubmit" "$HOOKS_JSON"; then + pass "UserPromptSubmit hook is registered in hooks.json" +else + fail "UserPromptSubmit hook not registered in hooks.json" +fi + +if grep -q "loop-plan-validator.sh" "$HOOKS_JSON"; then + pass "loop-plan-validator.sh is referenced in hooks.json" +else + fail "loop-plan-validator.sh not referenced in hooks.json" +fi + +# Test 9.3: Check pre-1.1.2 state file detection (backward compatibility) +echo "Testing pre-1.1.2 state file detection..." +if grep -q "Pre-1.1.2 State File" "$PLAN_VALIDATOR"; then + pass "Plan validator checks for pre-1.1.2 state files" +else + fail "Plan validator missing pre-1.1.2 state file check" +fi + +if grep -q 'load_and_render_safe.*pre-112-state-file.md' "$PLAN_VALIDATOR"; then + pass "Plan validator uses template for pre-1.1.2 message" +else + fail "Plan validator doesn't use template for pre-1.1.2 message" +fi + +# Pre-1.1.2 loops now use stop_loop with "unexpected" prefix +if grep -q 'stop_loop.*STATE_FILE.*"unexpected"' "$PLAN_VALIDATOR"; then + pass "Plan validator uses stop_loop with 'unexpected' prefix for pre-1.1.2 loops" +else + fail "Plan validator doesn't use stop_loop properly for pre-1.1.2 loops" +fi + +# Test 9.4: Check plan validator determines plan file location +if grep -q 'PLAN_FILE_INSIDE_REPO=' "$PLAN_VALIDATOR"; then + pass "Plan validator determines if plan file is inside/outside repo" +else + fail "Plan validator doesn't determine plan file location" +fi + +# Test 9.5: Case 3 - --commit-plan-file + Outside repo = Configuration Conflict +echo "Testing Case 3: --commit-plan-file + Outside repo..." +if grep -q 'COMMIT_PLAN_FILE.*==.*true.*&&.*PLAN_FILE_INSIDE_REPO.*==.*false' "$PLAN_VALIDATOR"; then + pass "Plan validator handles Case 3 (--commit-plan-file + outside repo)" +else + fail "Plan validator doesn't handle Case 3" +fi + +if grep -q 'plan-file-outside-repo-conflict.md' "$PLAN_VALIDATOR"; then + pass "Plan validator uses template for Case 3 conflict" +else + fail "Plan validator doesn't use template for Case 3" +fi + +# Test 9.6: Case 1 - --commit-plan-file + Inside repo = Must be tracked AND clean +echo "Testing Case 1: --commit-plan-file + Inside repo..." +if grep -q 'COMMIT_PLAN_FILE.*==.*true.*&&.*PLAN_FILE_INSIDE_REPO.*==.*true' "$PLAN_VALIDATOR"; then + pass "Plan validator handles Case 1 (--commit-plan-file + inside repo)" +else + fail "Plan validator doesn't handle Case 1" +fi + +# Test 9.6b: Check plan validator verifies plan file is tracked +if grep -q 'git ls-files --error-unmatch' "$PLAN_VALIDATOR"; then + pass "Plan validator checks if plan file is tracked (Case 1)" +else + fail "Plan validator doesn't check tracked status" +fi + +# Test 9.6c: Check plan validator verifies plan file is clean +if grep -q 'git status --porcelain' "$PLAN_VALIDATOR"; then + pass "Plan validator checks if plan file is clean (Case 1)" +else + fail "Plan validator doesn't check clean status" +fi + +# Test 9.6d: Check plan validator uses templates for Case 1 errors +if grep -q 'plan-file-not-tracked.md' "$PLAN_VALIDATOR"; then + pass "Plan validator uses template for not-tracked error" +else + fail "Plan validator doesn't use template for not-tracked error" +fi + +if grep -q 'plan-file-uncommitted.md' "$PLAN_VALIDATOR"; then + pass "Plan validator uses template for uncommitted error" +else + fail "Plan validator doesn't use template for uncommitted error" +fi + +# Test 9.7: All Cases - Check plan file content vs backup +echo "Testing content check (all cases)..." +if grep -q 'diff -q.*PLAN_FILE.*PLAN_BACKUP_FILE' "$PLAN_VALIDATOR"; then + pass "Plan validator uses diff to compare plan file with backup" +else + fail "Plan validator doesn't use diff for plan file comparison" +fi + +# Test 9.8: Check plan validator handles missing plan file +if grep -q 'PLAN_MISSING' "$PLAN_VALIDATOR"; then + pass "Plan validator handles missing/deleted plan file" +else + fail "Plan validator doesn't handle missing plan file" +fi + +# Test 9.9: Check plan validator uses exit code 2 to block (per Claude Code spec) +if grep -q 'exit 2' "$PLAN_VALIDATOR"; then + pass "Plan validator uses exit code 2 to block prompt" +else + fail "Plan validator doesn't use exit code 2 for blocking" +fi + +# Test 9.10: Check plan validator uses stderr for block message +if grep -q 'echo.*>&2' "$PLAN_VALIDATOR" && grep -q 'exit 2' "$PLAN_VALIDATOR"; then + pass "Plan validator outputs block message to stderr" +else + fail "Plan validator doesn't output block message to stderr" +fi + +# Test 9.11: Check plan validator uses template for content changed error +if grep -q 'load_and_render_safe.*plan-file-changed-prompt-block.md' "$PLAN_VALIDATOR"; then + pass "Plan validator uses template for content changed error" +else + fail "Plan validator doesn't use template for content changed error" +fi + +# Test 9.12: Plan validator uses stop_loop with prefix for state file handling +if grep -q 'stop_loop.*STATE_FILE.*"unexpected"' "$PLAN_VALIDATOR"; then + pass "Plan validator uses stop_loop with 'unexpected' prefix" +else + fail "Plan validator doesn't use stop_loop with proper prefix" +fi + +section "Section 10: Git Ancestry Check and Loop Termination" + +LOOP_COMMON="$PROJECT_ROOT/hooks/lib/loop-common.sh" + +# Test 10.1: Check stop_loop function exists in loop-common.sh +echo "Testing stop_loop function..." + +if grep -q 'stop_loop()' "$LOOP_COMMON"; then + pass "stop_loop function exists in loop-common.sh" +else + fail "stop_loop function missing from loop-common.sh" +fi + +# Test 10.2: Check stop_loop uses prefix parameter for renaming +if grep -q '\${prefix}-state.md' "$LOOP_COMMON"; then + pass "stop_loop uses prefix parameter for state file renaming" +else + fail "stop_loop doesn't use prefix parameter" +fi + +# Test 10.3: Check check_start_commit_ancestry function exists +echo "Testing check_start_commit_ancestry function..." + +if grep -q 'check_start_commit_ancestry()' "$LOOP_COMMON"; then + pass "check_start_commit_ancestry function exists in loop-common.sh" +else + fail "check_start_commit_ancestry function missing" +fi + +# Test 10.4: Check ancestry function uses git merge-base --is-ancestor +if grep -q 'git merge-base --is-ancestor' "$LOOP_COMMON"; then + pass "Ancestry check uses git merge-base --is-ancestor" +else + fail "Ancestry check doesn't use git merge-base --is-ancestor" +fi + +# Test 10.5: Check plan validator has ancestry check +echo "Testing plan validator ancestry check..." + +if grep -q 'Check Git Ancestry' "$PLAN_VALIDATOR"; then + pass "Plan validator has git ancestry check section" +else + fail "Plan validator missing git ancestry check" +fi + +if grep -q 'check_start_commit_ancestry' "$PLAN_VALIDATOR"; then + pass "Plan validator calls check_start_commit_ancestry" +else + fail "Plan validator doesn't call check_start_commit_ancestry" +fi + +# Test 10.6: Check bash validator has ancestry check +echo "Testing bash validator ancestry check..." + +if grep -q 'Check Git Ancestry' "$BASH_VALIDATOR"; then + pass "Bash validator has git ancestry check section" +else + fail "Bash validator missing git ancestry check" +fi + +if grep -q 'check_start_commit_ancestry' "$BASH_VALIDATOR"; then + pass "Bash validator calls check_start_commit_ancestry" +else + fail "Bash validator doesn't call check_start_commit_ancestry" +fi + +# Test 10.7: Check stop hook uses stop_loop instead of rm +echo "Testing stop hook uses stop_loop..." + +# Check that there are no rm -f $STATE_FILE occurrences +if grep -q 'rm -f.*\$STATE_FILE' "$STOP_HOOK" 2>/dev/null; then + fail "Stop hook still uses rm -f for state file" +else + pass "Stop hook doesn't use rm -f for state file" +fi + +if grep -q 'stop_loop.*STATE_FILE' "$STOP_HOOK"; then + pass "Stop hook uses stop_loop function" +else + fail "Stop hook doesn't use stop_loop function" +fi + +# Test 10.8: Check cancel-rlcr-loop uses mv with cancelled prefix +echo "Testing cancel-rlcr-loop uses cancelled prefix..." + +CANCEL_SKILL="$PROJECT_ROOT/commands/cancel-rlcr-loop.md" +if grep -q 'cancelled-state.md' "$CANCEL_SKILL"; then + pass "Cancel skill renames to cancelled-state.md" +else + fail "Cancel skill doesn't rename to cancelled-state.md" +fi + +if grep -q 'rm.*state.md' "$CANCEL_SKILL"; then + fail "Cancel skill still uses rm for state.md" +else + pass "Cancel skill doesn't use rm for state.md" +fi + +# Test 10.9: Check branch-changed template exists +echo "Testing branch-changed template..." + +BRANCH_CHANGED_TEMPLATE="$PROJECT_ROOT/prompt-template/block/branch-changed.md" +if [[ -f "$BRANCH_CHANGED_TEMPLATE" ]]; then + pass "branch-changed.md template exists" +else + warn "branch-changed.md template missing (fallback will be used)" +fi + +# Test 10.9b: Check stop_loop is called with correct prefixes +echo "Testing stop_loop prefix usage..." + +# Check stop hook uses "completed" prefix for COMPLETE +if grep -q 'stop_loop.*"completed"' "$STOP_HOOK"; then + pass "Stop hook uses 'completed' prefix for COMPLETE" +else + fail "Stop hook missing 'completed' prefix for COMPLETE" +fi + +# Check stop hook uses "stopped" prefix for STOP/max iterations +STOPPED_COUNT=$(grep -c 'stop_loop.*"stopped"' "$STOP_HOOK" 2>/dev/null || echo "0") +if [[ "$STOPPED_COUNT" -ge 2 ]]; then + pass "Stop hook uses 'stopped' prefix for STOP and max iterations" +else + fail "Stop hook missing 'stopped' prefix usage (found $STOPPED_COUNT, expected 2)" +fi + +# Check stop hook uses "unexpected" prefix for errors +UNEXPECTED_COUNT=$(grep -c 'stop_loop.*"unexpected"' "$STOP_HOOK" 2>/dev/null || echo "0") +if [[ "$UNEXPECTED_COUNT" -ge 2 ]]; then + pass "Stop hook uses 'unexpected' prefix for error cases" +else + fail "Stop hook missing 'unexpected' prefix usage (found $UNEXPECTED_COUNT, expected 2)" +fi + +# Check plan validator uses "unexpected" prefix +if grep -q 'stop_loop.*"unexpected"' "$PLAN_VALIDATOR"; then + pass "Plan validator uses 'unexpected' prefix" +else + fail "Plan validator missing 'unexpected' prefix" +fi + +# Check bash validator uses "unexpected" prefix +if grep -q 'stop_loop.*"unexpected"' "$BASH_VALIDATOR"; then + pass "Bash validator uses 'unexpected' prefix" +else + fail "Bash validator missing 'unexpected' prefix" +fi + +# Test 10.10: Unit test for ancestry check +echo "Testing ancestry check logic..." + +cd "$BEHAVIOR_TEST_REPO" + +# Get current HEAD as start_commit +START_COMMIT=$(git rev-parse HEAD) + +# Create a new commit +echo "new content" > new-file.txt +git add new-file.txt +git commit -q -m "New commit" + +# Test: HEAD should be descendant of START_COMMIT +if git merge-base --is-ancestor "$START_COMMIT" HEAD; then + pass "Ancestry check correctly identifies descendant commit" +else + fail "Ancestry check failed for descendant commit" +fi + +# Test: START_COMMIT should NOT be descendant of itself if we go back +# Create a branch from START_COMMIT +git checkout -q -b old-branch "$START_COMMIT" + +# Now HEAD is START_COMMIT, which IS a descendant of itself (equal) +if git merge-base --is-ancestor "$START_COMMIT" HEAD; then + pass "Ancestry check allows HEAD equal to start_commit" +else + fail "Ancestry check should allow HEAD equal to start_commit" +fi + +# Go back to main +git checkout -q - + +cd "$SCRIPT_DIR" + +section "Test Summary" + +echo "" +echo -e "Passed: ${GREEN}$PASSED${NC}" +echo -e "Failed: ${RED}$FAILED${NC}" +echo -e "Warnings: ${YELLOW}$WARNINGS${NC}" +echo "" + +if [[ $FAILED -eq 0 ]]; then + echo -e "${GREEN}All plan file handling tests passed!${NC}" + exit 0 +else + echo -e "${RED}Some tests failed!${NC}" + exit 1 +fi diff --git a/tests/test-template-references.sh b/tests/test-template-references.sh index a302ad19..01692b67 100755 --- a/tests/test-template-references.sh +++ b/tests/test-template-references.sh @@ -56,6 +56,7 @@ section "Section 1: Scanning Shell Scripts for Template References" # Find all shell scripts that might use templates SCRIPTS_TO_CHECK=( "$PROJECT_ROOT/hooks/loop-codex-stop-hook.sh" + "$PROJECT_ROOT/hooks/loop-plan-validator.sh" "$PROJECT_ROOT/hooks/loop-read-validator.sh" "$PROJECT_ROOT/hooks/loop-write-validator.sh" "$PROJECT_ROOT/hooks/loop-edit-validator.sh"