diff --git a/.gitignore b/.gitignore index 8ef0d573..2308545e 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,8 @@ temp # Humanize state directories (runtime-generated, project-local) .humanize/ +.claude-flow/ +.swarm/ # Python cache __pycache__/ diff --git a/config/codex-hooks.json b/config/codex-hooks.json new file mode 100644 index 00000000..7a04402a --- /dev/null +++ b/config/codex-hooks.json @@ -0,0 +1,23 @@ +{ + "description": "Humanize Codex Hooks - Native Stop hooks for RLCR and PR loops", + "hooks": { + "Stop": [ + { + "hooks": [ + { + "type": "command", + "command": "{{HUMANIZE_RUNTIME_ROOT}}/hooks/loop-codex-stop-hook.sh", + "timeout": 7200, + "statusMessage": "humanize RLCR stop hook" + }, + { + "type": "command", + "command": "{{HUMANIZE_RUNTIME_ROOT}}/hooks/pr-loop-stop-hook.sh", + "timeout": 7200, + "statusMessage": "humanize PR stop hook" + } + ] + } + ] + } +} diff --git a/docs/bitlesson.md b/docs/bitlesson.md index bb2c3bac..01bb32e5 100644 --- a/docs/bitlesson.md +++ b/docs/bitlesson.md @@ -18,6 +18,10 @@ Provider routing is automatic: If the configured provider binary is missing, the selector falls back to the default Codex model so the loop can still proceed. +On Codex-only installs, Humanize writes `provider_mode: "codex-only"` into the user config. +When that mode is present, the selector forces BitLesson selection onto the Codex/OpenAI path +before provider resolution, even if an older default such as `haiku` would otherwise route to Claude. + ## Workflow Each project keeps its BitLesson knowledge base at `.humanize/bitlesson.md`. diff --git a/docs/install-for-codex.md b/docs/install-for-codex.md index a0c5dac2..8698d001 100644 --- a/docs/install-for-codex.md +++ b/docs/install-for-codex.md @@ -1,6 +1,6 @@ # Install Humanize Skills for Codex -This guide explains how to install the Humanize skills for Codex skill runtime (`$CODEX_HOME/skills`). +This guide explains how to install Humanize for Codex CLI, including the skill runtime (`$CODEX_HOME/skills`) and the native Codex `Stop` hook (`$CODEX_HOME/hooks.json`). ## Quick Install (Recommended) @@ -25,8 +25,14 @@ Or use the unified installer directly: This will: - Sync `humanize`, `humanize-gen-plan`, `humanize-refine-plan`, and `humanize-rlcr` into `${CODEX_HOME:-~/.codex}/skills` - Copy runtime dependencies into `${CODEX_HOME:-~/.codex}/skills/humanize` +- Install/update native Humanize Stop hooks in `${CODEX_HOME:-~/.codex}/hooks.json` +- Enable the experimental `codex_hooks` feature in `${CODEX_HOME:-~/.codex}/config.toml` when `codex` is available +- Seed `~/.config/humanize/config.json` with a Codex/OpenAI `bitlesson_model` when that key is not already set +- Mark the install as `provider_mode: "codex-only"` when using `--target codex` - Use RLCR defaults: `codex exec` with `gpt-5.4:high`, `codex review` with `gpt-5.4:high` +Requires Codex CLI `0.114.0` or newer for native hooks. Older Codex builds are not supported by the Codex install path. + ## Verify ```bash @@ -58,6 +64,21 @@ Installed files/directories: - `${CODEX_HOME:-~/.codex}/skills/humanize/templates/` - `${CODEX_HOME:-~/.codex}/skills/humanize/config/` - `${CODEX_HOME:-~/.codex}/skills/humanize/agents/` +- `${CODEX_HOME:-~/.codex}/hooks.json` +- `${XDG_CONFIG_HOME:-~/.config}/humanize/config.json` (created or updated only when Humanize config keys are unset) + +Verify native hooks: + +```bash +codex features list | rg codex_hooks +sed -n '1,220p' "${CODEX_HOME:-$HOME/.codex}/hooks.json" +``` + +Expected: +- `codex_hooks` is `true` +- `hooks.json` contains `loop-codex-stop-hook.sh` and `pr-loop-stop-hook.sh` +- `${XDG_CONFIG_HOME:-~/.config}/humanize/config.json` contains `bitlesson_model` set to a Codex/OpenAI model such as `gpt-5.4` +- for `--target codex`, `${XDG_CONFIG_HOME:-~/.config}/humanize/config.json` also contains `provider_mode: "codex-only"` ## Optional: Install for Both Codex and Kimi @@ -73,6 +94,9 @@ Installed files/directories: # Custom Codex skills dir ./scripts/install-skills-codex.sh --codex-skills-dir /custom/codex/skills + +# Reinstall only the native hooks/config +./scripts/install-codex-hooks.sh ``` ## Troubleshooting @@ -82,3 +106,10 @@ If scripts are not found from installed skills: ```bash ls -la "${CODEX_HOME:-$HOME/.codex}/skills/humanize/scripts" ``` + +If native exit gating does not trigger: + +```bash +codex features enable codex_hooks +sed -n '1,220p' "${CODEX_HOME:-$HOME/.codex}/hooks.json" +``` diff --git a/docs/usage.md b/docs/usage.md index e12d45b9..b5625bec 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -277,6 +277,7 @@ Current built-in keys: | `codex_model` | `gpt-5.4` | Shared default model for Codex-backed review and analysis | | `codex_effort` | `high` | Shared default reasoning effort (`xhigh`, `high`, `medium`, `low`) | | `bitlesson_model` | `haiku` | Model used by the BitLesson selector agent | +| `provider_mode` | unset | Optional runtime mode hint such as `codex-only` | | `agent_teams` | `false` | Project-level default for agent teams workflow | | `alternative_plan_language` | `""` | Optional translated plan variant language; supported values include `Chinese`, `Korean`, `Japanese`, `Spanish`, `French`, `German`, `Portuguese`, `Russian`, `Arabic`, or ISO codes like `zh` | | `gen_plan_mode` | `discussion` | Default plan-generation mode | @@ -300,6 +301,10 @@ To override, add to `.humanize/config.json`: } ``` +On Codex installs, Humanize also seeds `${XDG_CONFIG_HOME:-~/.config}/humanize/config.json` +with a Codex/OpenAI `bitlesson_model` and `provider_mode: "codex-only"` when those keys +are unset, so BitLesson selection stays on the Codex/OpenAI path without probing Claude. + Codex model is resolved with this precedence: 1. CLI `--codex-model` flag (highest priority) 2. Feature-specific defaults (e.g., PR loop defaults to `medium` effort) diff --git a/hooks/loop-codex-stop-hook.sh b/hooks/loop-codex-stop-hook.sh index 4d397a19..ae703d30 100755 --- a/hooks/loop-codex-stop-hook.sh +++ b/hooks/loop-codex-stop-hook.sh @@ -980,6 +980,9 @@ mkdir -p "$CACHE_DIR" # portable-timeout.sh already sourced above +# Disable native hooks for nested Codex reviewer calls to prevent Stop-hook recursion. +CODEX_DISABLE_HOOKS_ARGS=(--disable codex_hooks) + # Build command arguments for summary review (codex exec) CODEX_EXEC_ARGS=("-m" "$CODEX_EXEC_MODEL") if [[ -n "$CODEX_EXEC_EFFORT" ]]; then @@ -1056,14 +1059,14 @@ Provider: codex echo "# Review base ($review_base_type): $review_base" echo "# Timeout: $CODEX_TIMEOUT seconds" echo "" - echo "codex review --base $review_base ${CODEX_REVIEW_ARGS[*]}" + echo "codex ${CODEX_DISABLE_HOOKS_ARGS[*]} review --base $review_base ${CODEX_REVIEW_ARGS[*]}" } > "$CODEX_REVIEW_CMD_FILE" echo "Code review command saved to: $CODEX_REVIEW_CMD_FILE" >&2 echo "Running codex review with timeout ${CODEX_TIMEOUT}s in $PROJECT_ROOT (base: $review_base)..." >&2 CODEX_REVIEW_EXIT_CODE=0 - (cd "$PROJECT_ROOT" && run_with_timeout "$CODEX_TIMEOUT" codex review --base "$review_base" "${CODEX_REVIEW_ARGS[@]}") \ + (cd "$PROJECT_ROOT" && run_with_timeout "$CODEX_TIMEOUT" codex "${CODEX_DISABLE_HOOKS_ARGS[@]}" review --base "$review_base" "${CODEX_REVIEW_ARGS[@]}") \ > "$CODEX_REVIEW_LOG_FILE" 2>&1 || CODEX_REVIEW_EXIT_CODE=$? echo "Code review exit code: $CODEX_REVIEW_EXIT_CODE" >&2 @@ -1387,7 +1390,7 @@ CODEX_PROMPT_CONTENT=$(cat "$REVIEW_PROMPT_FILE") echo "# Working directory: $PROJECT_ROOT" echo "# Timeout: $CODEX_TIMEOUT seconds" echo "" - echo "codex exec ${CODEX_EXEC_ARGS[*]} \"\"" + echo "codex ${CODEX_DISABLE_HOOKS_ARGS[*]} exec ${CODEX_EXEC_ARGS[*]} \"\"" echo "" echo "# Prompt content:" echo "$CODEX_PROMPT_CONTENT" @@ -1397,7 +1400,7 @@ echo "Codex command saved to: $CODEX_CMD_FILE" >&2 echo "Running summary review with timeout ${CODEX_TIMEOUT}s..." >&2 CODEX_EXIT_CODE=0 -printf '%s' "$CODEX_PROMPT_CONTENT" | run_with_timeout "$CODEX_TIMEOUT" codex exec "${CODEX_EXEC_ARGS[@]}" - \ +printf '%s' "$CODEX_PROMPT_CONTENT" | run_with_timeout "$CODEX_TIMEOUT" codex "${CODEX_DISABLE_HOOKS_ARGS[@]}" exec "${CODEX_EXEC_ARGS[@]}" - \ > "$CODEX_STDOUT_FILE" 2> "$CODEX_STDERR_FILE" || CODEX_EXIT_CODE=$? echo "Codex exit code: $CODEX_EXIT_CODE" >&2 diff --git a/hooks/pr-loop-stop-hook.sh b/hooks/pr-loop-stop-hook.sh index f02710e2..8dedd8c0 100755 --- a/hooks/pr-loop-stop-hook.sh +++ b/hooks/pr-loop-stop-hook.sh @@ -1334,12 +1334,15 @@ if [[ "${HUMANIZE_CODEX_BYPASS_SANDBOX:-}" == "true" ]] || [[ "${HUMANIZE_CODEX_ CODEX_AUTO_FLAG="--dangerously-bypass-approvals-and-sandbox" fi +# Disable native hooks for nested Codex reviewer calls to prevent Stop-hook recursion. +CODEX_DISABLE_HOOKS_ARGS=(--disable codex_hooks) + CODEX_ARGS+=("$CODEX_AUTO_FLAG" "-C" "$PROJECT_ROOT") CODEX_PROMPT_CONTENT=$(cat "$CODEX_PROMPT_FILE") CODEX_EXIT_CODE=0 -printf '%s' "$CODEX_PROMPT_CONTENT" | run_with_timeout "$PR_CODEX_TIMEOUT" codex exec "${CODEX_ARGS[@]}" - \ +printf '%s' "$CODEX_PROMPT_CONTENT" | run_with_timeout "$PR_CODEX_TIMEOUT" codex "${CODEX_DISABLE_HOOKS_ARGS[@]}" exec "${CODEX_ARGS[@]}" - \ > "$CHECK_FILE" 2>/dev/null || CODEX_EXIT_CODE=$? if [[ $CODEX_EXIT_CODE -ne 0 ]]; then diff --git a/scripts/bitlesson-select.sh b/scripts/bitlesson-select.sh index 9399b06c..4d2b668d 100755 --- a/scripts/bitlesson-select.sh +++ b/scripts/bitlesson-select.sh @@ -15,6 +15,10 @@ PROJECT_ROOT="${CLAUDE_PROJECT_DIR:-$(git rev-parse --show-toplevel 2>/dev/null MERGED_CONFIG="$(load_merged_config "$PLUGIN_ROOT" "$PROJECT_ROOT")" BITLESSON_MODEL="$(get_config_value "$MERGED_CONFIG" "bitlesson_model")" BITLESSON_MODEL="${BITLESSON_MODEL:-haiku}" +CODEX_FALLBACK_MODEL="$(get_config_value "$MERGED_CONFIG" "codex_model")" +CODEX_FALLBACK_MODEL="${CODEX_FALLBACK_MODEL:-$DEFAULT_CODEX_MODEL}" +PROVIDER_MODE="$(get_config_value "$MERGED_CONFIG" "provider_mode")" +PROVIDER_MODE="${PROVIDER_MODE:-auto}" # Source portable timeout wrapper source "$SCRIPT_DIR/portable-timeout.sh" @@ -82,12 +86,34 @@ if [[ -z "$BITLESSON_FILE" ]]; then exit 1 fi +if [[ ! -f "$BITLESSON_FILE" ]]; then + echo "Error: BitLesson file not found: $BITLESSON_FILE" >&2 + exit 1 +fi + +BITLESSON_CONTENT="$(cat "$BITLESSON_FILE")" +if [[ -z "$(printf '%s' "$BITLESSON_CONTENT" | tr -d ' \t\n\r')" ]]; then + echo "Error: BitLesson file is empty (whitespace only): $BITLESSON_FILE" >&2 + exit 1 +fi + +if ! printf '%s\n' "$BITLESSON_CONTENT" | grep -Eq '^[[:space:]]*##[[:space:]]+Lesson:'; then + printf 'LESSON_IDS: NONE\n' + printf 'RATIONALE: The BitLesson file has no recorded lessons yet.\n' + exit 0 +fi + # ======================================== # Determine Provider from BITLESSON_MODEL # ======================================== BITLESSON_PROVIDER="$(detect_provider "$BITLESSON_MODEL")" +if [[ "$PROVIDER_MODE" == "codex-only" ]] && [[ "$BITLESSON_PROVIDER" == "claude" ]]; then + BITLESSON_MODEL="$CODEX_FALLBACK_MODEL" + BITLESSON_PROVIDER="codex" +fi + # ======================================== # Conditional Dependency Check (with fallback) # ======================================== @@ -99,17 +125,6 @@ if ! check_provider_dependency "$BITLESSON_PROVIDER" 2>/dev/null; then check_provider_dependency "$BITLESSON_PROVIDER" fi -if [[ ! -f "$BITLESSON_FILE" ]]; then - echo "Error: BitLesson file not found: $BITLESSON_FILE" >&2 - exit 1 -fi - -BITLESSON_CONTENT="$(cat "$BITLESSON_FILE")" -if [[ -z "$(printf '%s' "$BITLESSON_CONTENT" | tr -d ' \t\n\r')" ]]; then - echo "Error: BitLesson file is empty (whitespace only): $BITLESSON_FILE" >&2 - exit 1 -fi - # ======================================== # Detect Project Root (for -C) # ======================================== @@ -148,6 +163,7 @@ $BITLESSON_CONTENT 1. Match only lessons that are directly relevant to the sub-task scope and failure mode. 2. Prefer precision over recall: do not include weakly related lessons. 3. If nothing is relevant, return \`NONE\`. +4. Use only the information in this prompt. Do not use tools, shell commands, browser access, MCP servers, or repository inspection. ## Output Format (Stable) @@ -164,21 +180,35 @@ EOF SELECTOR_TIMEOUT=120 -CODEX_EXIT_CODE=0 -if [[ "$BITLESSON_PROVIDER" == "codex" ]]; then - CODEX_EXEC_ARGS=("-m" "$BITLESSON_MODEL" "-c" "model_reasoning_effort=high") +run_selector() { + local provider="$1" + local model="$2" + + if [[ "$provider" == "codex" ]]; then + local codex_exec_args=( + "--disable" "codex_hooks" + "--skip-git-repo-check" + "--ephemeral" + "-s" "read-only" + "-m" "$model" + "-c" "model_reasoning_effort=low" + "-C" "$CODEX_PROJECT_ROOT" + ) + printf '%s' "$PROMPT" | run_with_timeout "$SELECTOR_TIMEOUT" codex exec "${codex_exec_args[@]}" - + return $? + fi - # Determine automation flag based on environment variable (same as ask-codex.sh) - CODEX_AUTO_FLAG="--full-auto" - if [[ "${HUMANIZE_CODEX_BYPASS_SANDBOX:-}" == "true" ]] || [[ "${HUMANIZE_CODEX_BYPASS_SANDBOX:-}" == "1" ]]; then - CODEX_AUTO_FLAG="--dangerously-bypass-approvals-and-sandbox" + if [[ "$provider" == "claude" ]]; then + printf '%s' "$PROMPT" | run_with_timeout "$SELECTOR_TIMEOUT" claude --print --model "$model" - + return $? fi - CODEX_EXEC_ARGS+=("$CODEX_AUTO_FLAG" "-C" "$CODEX_PROJECT_ROOT") - RAW_OUTPUT="$(printf '%s' "$PROMPT" | run_with_timeout "$SELECTOR_TIMEOUT" codex exec "${CODEX_EXEC_ARGS[@]}" -)" || CODEX_EXIT_CODE=$? -elif [[ "$BITLESSON_PROVIDER" == "claude" ]]; then - RAW_OUTPUT="$(printf '%s' "$PROMPT" | run_with_timeout "$SELECTOR_TIMEOUT" claude --print --model "$BITLESSON_MODEL" -)" || CODEX_EXIT_CODE=$? -fi + echo "Error: Unsupported BitLesson provider '$provider'" >&2 + return 1 +} + +CODEX_EXIT_CODE=0 +RAW_OUTPUT="$(run_selector "$BITLESSON_PROVIDER" "$BITLESSON_MODEL" 2>&1)" || CODEX_EXIT_CODE=$? if [[ $CODEX_EXIT_CODE -eq 124 ]]; then echo "Error: BitLesson selector timed out after ${SELECTOR_TIMEOUT} seconds" >&2 @@ -187,6 +217,7 @@ fi if [[ $CODEX_EXIT_CODE -ne 0 ]]; then echo "Error: BitLesson selector failed (exit code $CODEX_EXIT_CODE)" >&2 + printf '%s\n' "$RAW_OUTPUT" >&2 exit "$CODEX_EXIT_CODE" fi diff --git a/scripts/install-codex-hooks.sh b/scripts/install-codex-hooks.sh new file mode 100755 index 00000000..362b822f --- /dev/null +++ b/scripts/install-codex-hooks.sh @@ -0,0 +1,197 @@ +#!/bin/bash +# +# Install/update Humanize native Codex hooks in CODEX_HOME/hooks.json. +# + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +CODEX_CONFIG_DIR="${CODEX_HOME:-${HOME}/.codex}" +RUNTIME_ROOT="$CODEX_CONFIG_DIR/skills/humanize" +DRY_RUN="false" +ENABLE_FEATURE="true" +HOOKS_TEMPLATE="$REPO_ROOT/config/codex-hooks.json" + +usage() { + cat <<'EOF' +Install/update Humanize native Codex hooks. + +Usage: + scripts/install-codex-hooks.sh [options] + +Options: + --codex-config-dir PATH Codex config dir (default: ${CODEX_HOME:-~/.codex}) + --runtime-root PATH Installed Humanize runtime root (default: /skills/humanize) + --skip-enable-feature Do not run `codex features enable codex_hooks` + --dry-run Print actions without writing + -h, --help Show help +EOF +} + +log() { + printf '[install-codex-hooks] %s\n' "$*" +} + +die() { + printf '[install-codex-hooks] Error: %s\n' "$*" >&2 + exit 1 +} + +while [[ $# -gt 0 ]]; do + case "$1" in + --codex-config-dir) + [[ -n "${2:-}" ]] || die "--codex-config-dir requires a value" + CODEX_CONFIG_DIR="$2" + shift 2 + ;; + --runtime-root) + [[ -n "${2:-}" ]] || die "--runtime-root requires a value" + RUNTIME_ROOT="$2" + shift 2 + ;; + --skip-enable-feature) + ENABLE_FEATURE="false" + shift + ;; + --dry-run) + DRY_RUN="true" + shift + ;; + -h|--help) + usage + exit 0 + ;; + *) + die "unknown option: $1" + ;; + esac +done + +[[ -f "$HOOKS_TEMPLATE" ]] || die "hook template not found: $HOOKS_TEMPLATE" + +HOOKS_FILE="$CODEX_CONFIG_DIR/hooks.json" + +require_codex_hooks_support() { + if ! command -v codex >/dev/null 2>&1; then + die "Codex CLI with native hooks support is required. Install Codex 0.114.0+ first." + fi + + if ! codex features list 2>/dev/null | grep -qE '^codex_hooks[[:space:]]'; then + die "Installed Codex CLI does not expose the codex_hooks feature. Humanize Codex install requires Codex 0.114.0+." + fi +} + +merge_hooks_json() { + local hooks_file="$1" + local template_file="$2" + local runtime_root="$3" + + if ! command -v python3 >/dev/null 2>&1; then + die "python3 is required to merge Codex hooks" + fi + + python3 - "$hooks_file" "$template_file" "$runtime_root" <<'PY' +import json +import pathlib +import re +import sys + +hooks_file = pathlib.Path(sys.argv[1]) +template_file = pathlib.Path(sys.argv[2]) +runtime_root = sys.argv[3] + +template_text = template_file.read_text(encoding="utf-8") +template_text = template_text.replace("{{HUMANIZE_RUNTIME_ROOT}}", runtime_root) +template = json.loads(template_text) + +existing = {} +if hooks_file.exists(): + with hooks_file.open("r", encoding="utf-8") as fh: + existing = json.load(fh) + +if not isinstance(existing, dict): + raise SystemExit(f"existing hooks config must be a JSON object: {hooks_file}") + +hooks = existing.setdefault("hooks", {}) +if not isinstance(hooks, dict): + raise SystemExit(f"existing hooks config has invalid 'hooks' object: {hooks_file}") + +stop_groups = hooks.get("Stop", []) +if stop_groups is None: + stop_groups = [] +if not isinstance(stop_groups, list): + raise SystemExit(f"existing hooks config has invalid Stop array: {hooks_file}") + +managed_pattern = re.compile(r"(^|/)humanize/hooks/(loop-codex-stop-hook\.sh|pr-loop-stop-hook\.sh)$") + +filtered_groups = [] +for group in stop_groups: + if not isinstance(group, dict): + filtered_groups.append(group) + continue + group_hooks = group.get("hooks") + if not isinstance(group_hooks, list): + filtered_groups.append(group) + continue + kept_hooks = [] + for hook in group_hooks: + if not isinstance(hook, dict): + kept_hooks.append(hook) + continue + command = hook.get("command") + if isinstance(command, str) and managed_pattern.search(command): + continue + kept_hooks.append(hook) + if kept_hooks: + new_group = dict(group) + new_group["hooks"] = kept_hooks + filtered_groups.append(new_group) + +managed_stop_groups = template.get("hooks", {}).get("Stop", []) +filtered_groups.extend(managed_stop_groups) +hooks["Stop"] = filtered_groups + +if not existing.get("description"): + existing["description"] = template.get("description", "Humanize Codex Hooks") + +hooks_file.parent.mkdir(parents=True, exist_ok=True) +hooks_file.write_text(json.dumps(existing, indent=2) + "\n", encoding="utf-8") +PY +} + +enable_feature() { + local config_dir="$1" + + [[ "$ENABLE_FEATURE" == "true" ]] || return 0 + + if CODEX_HOME="$config_dir" codex features enable codex_hooks >/dev/null 2>&1; then + log "enabled codex_hooks feature in $config_dir/config.toml" + else + die "failed to enable codex_hooks feature automatically in $config_dir/config.toml" + fi +} + +log "codex config dir: $CODEX_CONFIG_DIR" +log "runtime root: $RUNTIME_ROOT" +log "hooks file: $HOOKS_FILE" + +require_codex_hooks_support + +if [[ "$DRY_RUN" == "true" ]]; then + log "DRY-RUN merge $HOOKS_TEMPLATE -> $HOOKS_FILE" + if [[ "$ENABLE_FEATURE" == "true" ]]; then + log "DRY-RUN enable codex_hooks feature in $CODEX_CONFIG_DIR/config.toml" + fi + exit 0 +fi + +merge_hooks_json "$HOOKS_FILE" "$HOOKS_TEMPLATE" "$RUNTIME_ROOT" +enable_feature "$CODEX_CONFIG_DIR" + +cat </skills//SKILL.md + # /scripts + if [[ -d "$candidate_root/skills" ]] && [[ -d "$candidate_root/scripts" ]]; then + SKILLS_SOURCE_ROOT="$candidate_root/skills" + RUNTIME_SOURCE_ROOT="$candidate_root" + return 0 + fi + + # Installed runtime layout: + # /humanize/scripts/install-skill.sh + # /humanize-gen-plan/SKILL.md + # /humanize-rlcr/SKILL.md + if [[ -d "$runtime_root/scripts" ]] && [[ -d "$runtime_root/hooks" ]] && [[ -d "$runtime_root/prompt-template" ]]; then + skills_root="$(cd "$runtime_root/.." && pwd)" + if [[ -f "$skills_root/humanize/SKILL.md" ]] && [[ -f "$skills_root/humanize-gen-plan/SKILL.md" ]] && [[ -f "$skills_root/humanize-refine-plan/SKILL.md" ]] && [[ -f "$skills_root/humanize-rlcr/SKILL.md" ]]; then + SKILLS_SOURCE_ROOT="$skills_root" + RUNTIME_SOURCE_ROOT="$runtime_root" + return 0 + fi + fi + + die "could not resolve Humanize source layout from: $candidate_root" +} + sync_dir() { local src="$1" local dst="$2" @@ -107,7 +147,7 @@ sync_dir() { sync_one_skill() { local skill="$1" local target_dir="$2" - local src="$REPO_ROOT/skills/$skill" + local src="$SKILLS_SOURCE_ROOT/$skill" local dst="$target_dir/$skill" sync_dir "$src" "$dst" } @@ -120,7 +160,7 @@ install_runtime_bundle() { log "syncing runtime bundle into: $runtime_root" for component in scripts hooks prompt-template templates config agents; do - sync_dir "$REPO_ROOT/$component" "$runtime_root/$component" + sync_dir "$RUNTIME_SOURCE_ROOT/$component" "$runtime_root/$component" done } @@ -192,6 +232,7 @@ strip_claude_specific_frontmatter() { sync_target() { local label="$1" local target_dir="$2" + local selected_skills=("${SKILL_NAMES[@]}") log "target: $label" log "skills dir: $target_dir" @@ -200,7 +241,7 @@ sync_target() { mkdir -p "$target_dir" fi - for skill in "${SKILL_NAMES[@]}"; do + for skill in "${selected_skills[@]}"; do log "syncing [$label] skill: $skill" sync_one_skill "$skill" "$target_dir" done @@ -209,6 +250,140 @@ sync_target() { strip_claude_specific_frontmatter "$target_dir" } +install_codex_native_hooks() { + local target_dir="$1" + local runtime_root="$target_dir/humanize" + local hooks_installer="$REPO_ROOT/scripts/install-codex-hooks.sh" + local args=( + --codex-config-dir "$CODEX_CONFIG_DIR" + --runtime-root "$runtime_root" + ) + + [[ -x "$hooks_installer" ]] || die "missing Codex hooks installer: $hooks_installer" + [[ "$DRY_RUN" == "true" ]] && args+=(--dry-run) + + log "installing native Codex hooks into: $CODEX_CONFIG_DIR" + "$hooks_installer" "${args[@]}" +} + +install_codex_user_config() { + local runtime_root="$1" + local install_target="$2" + local user_config_dir="${HUMANIZE_USER_CONFIG_DIR}" + local user_config_file="$user_config_dir/config.json" + local default_config_file="$runtime_root/config/default_config.json" + + [[ -f "$default_config_file" ]] || die "missing default config: $default_config_file" + + if ! command -v python3 >/dev/null 2>&1; then + die "python3 is required to update Humanize user config for Codex installs" + fi + + if [[ "$DRY_RUN" == "true" ]]; then + log "DRY-RUN seed Codex-friendly BitLesson config in $user_config_file" + return + fi + + mkdir -p "$user_config_dir" + + python3 - "$default_config_file" "$user_config_file" "$install_target" <<'PY' +import json +import pathlib +import sys + +default_config = pathlib.Path(sys.argv[1]) +user_config = pathlib.Path(sys.argv[2]) +install_target = sys.argv[3] + +defaults = json.loads(default_config.read_text(encoding="utf-8")) +default_codex_model = defaults.get("codex_model") or "gpt-5.4" + +if user_config.exists(): + try: + data = json.loads(user_config.read_text(encoding="utf-8")) + except json.JSONDecodeError as exc: + print(f"malformed existing user config: {user_config}: {exc}", file=sys.stderr) + sys.exit(2) + if not isinstance(data, dict): + print(f"existing user config is not a JSON object: {user_config}", file=sys.stderr) + sys.exit(2) +else: + data = {} + +if not data.get("bitlesson_model"): + data["bitlesson_model"] = data.get("codex_model") or default_codex_model + +if install_target == "codex" and not data.get("provider_mode"): + data["provider_mode"] = "codex-only" + +user_config.write_text(json.dumps(data, indent=2, sort_keys=True) + "\n", encoding="utf-8") +PY + case "$?" in + 0) + log "ensured BitLesson uses a Codex/OpenAI model in $user_config_file" + ;; + 2) + die "failed to update $user_config_file because it is malformed; fix it manually and rerun install" + ;; + *) + die "failed to update Humanize user config at $user_config_file" + ;; + esac +} + +install_bitlesson_selector_shim() { + local primary_runtime_root="$1" + local secondary_runtime_root="${2:-}" + local shim_path="$COMMAND_BIN_DIR/bitlesson-selector" + + if [[ "$DRY_RUN" == "true" ]]; then + log "DRY-RUN install bitlesson-selector shim into $shim_path" + return + fi + + mkdir -p "$COMMAND_BIN_DIR" + + cat > "$shim_path" <> "$shim_path" <> "$shim_path" <<'EOF' +) + +for candidate in "${candidate_paths[@]}"; do + if [[ -x "$candidate" ]]; then + exec "$candidate" "$@" + fi +done + +echo "Error: Humanize bitlesson selector runtime not found. Re-run install-skill.sh." >&2 +exit 1 +EOF + + chmod +x "$shim_path" + log "installed bitlesson-selector shim into: $shim_path" +} + +install_kimi_target() { + sync_target "kimi" "$KIMI_SKILLS_DIR" +} + +install_codex_target() { + sync_target "codex" "$CODEX_SKILLS_DIR" + install_codex_user_config "$CODEX_SKILLS_DIR/humanize" "$TARGET" + install_codex_native_hooks "$CODEX_SKILLS_DIR" +} + while [[ $# -gt 0 ]]; do case "$1" in --target) @@ -239,6 +414,16 @@ while [[ $# -gt 0 ]]; do CODEX_SKILLS_DIR="$2" shift 2 ;; + --codex-config-dir) + [[ -n "${2:-}" ]] || die "--codex-config-dir requires a value" + CODEX_CONFIG_DIR="$2" + shift 2 + ;; + --command-bin-dir) + [[ -n "${2:-}" ]] || die "--command-bin-dir requires a value" + COMMAND_BIN_DIR="$2" + shift 2 + ;; --dry-run) DRY_RUN="true" shift @@ -253,6 +438,7 @@ while [[ $# -gt 0 ]]; do esac done +resolve_source_layout "$REPO_ROOT" validate_repo if [[ -n "$LEGACY_SKILLS_DIR" ]]; then @@ -273,18 +459,23 @@ if [[ "$TARGET" == "kimi" || "$TARGET" == "both" ]]; then fi if [[ "$TARGET" == "codex" || "$TARGET" == "both" ]]; then log "codex skills dir: $CODEX_SKILLS_DIR" + log "codex config dir: $CODEX_CONFIG_DIR" fi +log "command bin dir: $COMMAND_BIN_DIR" case "$TARGET" in kimi) - sync_target "kimi" "$KIMI_SKILLS_DIR" + install_kimi_target + install_bitlesson_selector_shim "$KIMI_SKILLS_DIR/humanize" ;; codex) - sync_target "codex" "$CODEX_SKILLS_DIR" + install_codex_target + install_bitlesson_selector_shim "$CODEX_SKILLS_DIR/humanize" "$KIMI_SKILLS_DIR/humanize" ;; both) - sync_target "kimi" "$KIMI_SKILLS_DIR" - sync_target "codex" "$CODEX_SKILLS_DIR" + install_kimi_target + install_codex_target + install_bitlesson_selector_shim "$CODEX_SKILLS_DIR/humanize" "$KIMI_SKILLS_DIR/humanize" ;; esac @@ -304,6 +495,7 @@ fi if [[ "$TARGET" == "codex" || "$TARGET" == "both" ]]; then cat </humanize +Codex installs also update native hook/config state in: + $CODEX_CONFIG_DIR + No shell profile changes were made. +If $COMMAND_BIN_DIR is on PATH, the bitlesson-selector shim is now available there. EOF diff --git a/scripts/rlcr-stop-gate.sh b/scripts/rlcr-stop-gate.sh index 306f875c..c707941c 100755 --- a/scripts/rlcr-stop-gate.sh +++ b/scripts/rlcr-stop-gate.sh @@ -24,6 +24,8 @@ HOOK_SCRIPT="$HUMANIZE_ROOT/hooks/loop-codex-stop-hook.sh" SESSION_ID="${CLAUDE_SESSION_ID:-}" TRANSCRIPT_PATH="${CLAUDE_TRANSCRIPT_PATH:-}" PRINT_JSON="false" +HOOK_MODEL="${CODEX_MODEL:-humanize-skill-gate}" +HOOK_PERMISSION_MODE="${CODEX_PERMISSION_MODE:-default}" usage() { cat <<'EOF' @@ -88,10 +90,15 @@ HOOK_INPUT=$(jq -n \ --arg session_id "$SESSION_ID" \ --arg transcript_path "$TRANSCRIPT_PATH" \ --arg cwd "$PROJECT_ROOT" \ + --arg model "$HOOK_MODEL" \ + --arg permission_mode "$HOOK_PERMISSION_MODE" \ '{ hook_event_name: "Stop", stop_hook_active: false, cwd: $cwd, + model: $model, + permission_mode: $permission_mode, + last_assistant_message: null, session_id: ($session_id | select(length > 0)), transcript_path: ($transcript_path | select(length > 0)) }') diff --git a/skills/humanize-rlcr/SKILL.md b/skills/humanize-rlcr/SKILL.md index e65a05b6..d9873b47 100644 --- a/skills/humanize-rlcr/SKILL.md +++ b/skills/humanize-rlcr/SKILL.md @@ -1,21 +1,15 @@ --- name: humanize-rlcr -description: Start RLCR (Ralph-Loop with Codex Review) with hook-equivalent enforcement from skill mode by reusing the existing stop-hook logic. +description: Start RLCR (Ralph-Loop with Codex Review) on Codex using the native Stop hook. type: flow user-invocable: false disable-model-invocation: true --- -# Humanize RLCR Loop (Hook-Equivalent) +# Humanize RLCR Loop -Use this flow to run RLCR in environments without native hooks. -Do not re-implement review logic manually. Always call the RLCR stop gate wrapper: - -```bash -"{{HUMANIZE_RUNTIME_ROOT}}/scripts/rlcr-stop-gate.sh" -``` - -The wrapper executes `hooks/loop-codex-stop-hook.sh`, so skill-mode behavior stays aligned with hook-mode behavior. +Use this flow as the Codex entrypoint for RLCR. +Codex installs of Humanize require native hooks support and install the Humanize `Stop` hooks automatically. ## Runtime Root @@ -49,24 +43,13 @@ For each round: 4. Write required summary file: - Normal phase: `.humanize/rlcr//round--summary.md` - Finalize phase: `.humanize/rlcr//finalize-summary.md` -5. Run gate command: - -```bash -GATE_CMD=("{{HUMANIZE_RUNTIME_ROOT}}/scripts/rlcr-stop-gate.sh") -[[ -n "${CLAUDE_SESSION_ID:-}" ]] && GATE_CMD+=(--session-id "$CLAUDE_SESSION_ID") -[[ -n "${CLAUDE_TRANSCRIPT_PATH:-}" ]] && GATE_CMD+=(--transcript-path "$CLAUDE_TRANSCRIPT_PATH") -"${GATE_CMD[@]}" -GATE_EXIT=$? -``` - -6. Handle gate result: - - `0`: loop is allowed to exit (done). - - `10`: blocked by RLCR logic. Follow returned instructions exactly, continue next round. - - `20`: infrastructure error (wrapper/hook/runtime). Report error, do not fake completion. +5. Stop or exit normally. +6. Let the native Humanize `Stop` hook run automatically. +7. If the hook blocks exit, follow the returned instructions exactly and continue the next round. ## What This Enforces -By routing through the stop-hook logic, this skill enforces: +The native Stop-hook path enforces: - state/schema validation (`current_round`, `max_iterations`, `review_started`, `base_branch`, etc.) - branch consistency checks @@ -86,8 +69,8 @@ By routing through the stop-hook logic, this skill enforces: ## Critical Rules 1. Never manually edit `state.md` or `finalize-state.md`. -2. Never skip a blocked gate result by declaring completion manually. -3. Never run ad-hoc `codex exec` / `codex review` in place of the gate for phase transitions. +2. Never skip a blocked hook result by declaring completion manually. +3. Never run ad-hoc `codex exec` / `codex review` in place of the hook-managed phase transitions. 4. Always use files generated by the loop (`round-*-prompt.md`, `round-*-review-result.md`) as source of truth. ## Options @@ -121,9 +104,6 @@ Review phase `codex review` runs with `gpt-5.4:high`. # Review-only mode /flow:humanize-rlcr --skip-impl - -# Load skill without auto-execution -/skill:humanize-rlcr ``` ## Cancel diff --git a/skills/humanize/SKILL.md b/skills/humanize/SKILL.md index 1b916306..b9a6ccd5 100644 --- a/skills/humanize/SKILL.md +++ b/skills/humanize/SKILL.md @@ -45,7 +45,7 @@ The RLCR (Ralph-Loop with Codex Review) loop has two phases: - Issues marked with `[P0-9]` severity markers - If issues found → AI fixes them and continues - If no issues → loop completes with Finalize Phase -- In skill mode, always run `{{HUMANIZE_RUNTIME_ROOT}}/scripts/rlcr-stop-gate.sh` to enforce hook-equivalent transitions and blocking +- On Codex CLI `0.114.0+` with `codex_hooks` enabled, Humanize installs a native `Stop` hook so exit gating runs automatically ### 2. PR Loop - Automated PR Review Handling @@ -80,10 +80,7 @@ Transforms a rough draft document into a structured implementation plan with: "{{HUMANIZE_RUNTIME_ROOT}}/scripts/setup-rlcr-loop.sh" --skip-impl ``` -```bash -# For each round, run the RLCR gate (required) -"{{HUMANIZE_RUNTIME_ROOT}}/scripts/rlcr-stop-gate.sh" -``` +After each round, write the required summary and stop/exit normally. Humanize's native Codex `Stop` hook handles review gating automatically. **Common Options:** - `--max N` - Maximum iterations before auto-stop (default: 42) @@ -207,7 +204,7 @@ The RLCR loop uses a Goal Tracker to prevent goal drift: 2. **Maintain Goal Tracker**: Keep goal-tracker.md up-to-date with progress 3. **Be thorough**: Include details about implementation, files changed, tests added 4. **No cheating**: Don't try to exit by editing state files or running cancel commands -5. **Run stop gate each round**: Use `scripts/rlcr-stop-gate.sh` instead of manual phase control +5. **Use the native Stop hook on Codex**: After writing the required summary, stop/exit normally so Codex runs the Humanize Stop hook 6. **Trust the process**: External review helps improve implementation quality ## Prerequisites diff --git a/tests/run-all-tests.sh b/tests/run-all-tests.sh index cd3fb58a..8c1a4d67 100755 --- a/tests/run-all-tests.sh +++ b/tests/run-all-tests.sh @@ -84,6 +84,7 @@ TEST_SUITES=( "test-task-tag-routing.sh" "test-config-merge.sh" "test-config-error-handling.sh" + "test-codex-hook-install.sh" "test-unified-codex-config.sh" "test-pr-loop-1-scripts.sh" "test-pr-loop-2-hooks.sh" diff --git a/tests/test-agent-teams.sh b/tests/test-agent-teams.sh index 27285561..9f5b3663 100755 --- a/tests/test-agent-teams.sh +++ b/tests/test-agent-teams.sh @@ -532,11 +532,18 @@ setup_mock_codex_impl_feedback() { mkdir -p "$TEST_DIR/bin" cat > "$TEST_DIR/bin/codex" << MOCK_EOF #!/bin/bash -if [[ "\$1" == "exec" ]]; then +subcommand="" +for arg in "\$@"; do + if [[ "\$arg" == "exec" || "\$arg" == "review" ]]; then + subcommand="\$arg" + break + fi +done +if [[ "\$subcommand" == "exec" ]]; then cat << 'REVIEW' $feedback REVIEW -elif [[ "\$1" == "review" ]]; then +elif [[ "\$subcommand" == "review" ]]; then echo "No issues found." fi MOCK_EOF @@ -550,9 +557,16 @@ setup_mock_codex_review_issues() { mkdir -p "$TEST_DIR/bin" cat > "$TEST_DIR/bin/codex" << MOCK_EOF #!/bin/bash -if [[ "\$1" == "exec" ]]; then +subcommand="" +for arg in "\$@"; do + if [[ "\$arg" == "exec" || "\$arg" == "review" ]]; then + subcommand="\$arg" + break + fi +done +if [[ "\$subcommand" == "exec" ]]; then echo "Should not be called in review phase" -elif [[ "\$1" == "review" ]]; then +elif [[ "\$subcommand" == "review" ]]; then cat << 'REVIEWOUT' $review_output REVIEWOUT diff --git a/tests/test-bitlesson-select-routing.sh b/tests/test-bitlesson-select-routing.sh index d3c205c3..dee42a5f 100755 --- a/tests/test-bitlesson-select-routing.sh +++ b/tests/test-bitlesson-select-routing.sh @@ -26,6 +26,44 @@ create_mock_bitlesson() { EOF } +create_real_bitlesson() { + local dir="$1" + mkdir -p "$dir" + cat > "$dir/bitlesson.md" <<'EOF' +# BitLesson Knowledge Base +## Entries + +## Lesson: Avoid tracker drift +Lesson ID: BL-20260315-tracker-drift +Scope: goal-tracker.md +Problem Description: Tracker diverges from actual task status. +Root Cause: Status rows are not updated after verification. +Solution: Update tracker rows immediately after each verification step. +Constraints: Keep tracker edits minimal. +Validation Evidence: Verified in test fixture. +Source Rounds: 0 +EOF +} + +create_real_humanize_bitlesson() { + local dir="$1" + mkdir -p "$dir/.humanize" + cat > "$dir/.humanize/bitlesson.md" <<'EOF' +# BitLesson Knowledge Base +## Entries + +## Lesson: Avoid tracker drift +Lesson ID: BL-20260315-tracker-drift +Scope: goal-tracker.md +Problem Description: Tracker diverges from actual task status. +Root Cause: Status rows are not updated after verification. +Solution: Update tracker rows immediately after each verification step. +Constraints: Keep tracker edits minimal. +Validation Evidence: Verified in test fixture. +Source Rounds: 0 +EOF +} + # Helper: create a mock codex binary that outputs valid bitlesson-selector format create_mock_codex() { local bin_dir="$1" @@ -102,7 +140,7 @@ echo "--- Test 1: gpt-* model routes to codex ---" echo "" setup_test_dir -create_mock_bitlesson "$TEST_DIR" +create_real_humanize_bitlesson "$TEST_DIR" BIN_DIR="$TEST_DIR/bin" create_mock_codex "$BIN_DIR" mkdir -p "$TEST_DIR/.humanize" @@ -131,7 +169,7 @@ echo "--- Test 1b: gpt-* codex path passes stdin prompt via trailing '-' ---" echo "" setup_test_dir -create_mock_bitlesson "$TEST_DIR" +create_real_humanize_bitlesson "$TEST_DIR" BIN_DIR="$TEST_DIR/bin" STDIN_FILE="$TEST_DIR/codex-stdin.txt" create_recording_mock_codex "$BIN_DIR" "$STDIN_FILE" @@ -166,7 +204,7 @@ echo "--- Test 2: haiku model routes to claude ---" echo "" setup_test_dir -create_mock_bitlesson "$TEST_DIR" +create_real_humanize_bitlesson "$TEST_DIR" BIN_DIR="$TEST_DIR/bin" create_mock_claude "$BIN_DIR" mkdir -p "$TEST_DIR/.humanize" @@ -195,7 +233,7 @@ echo "--- Test 3: sonnet model routes to claude ---" echo "" setup_test_dir -create_mock_bitlesson "$TEST_DIR" +create_real_humanize_bitlesson "$TEST_DIR" BIN_DIR="$TEST_DIR/bin" create_mock_claude "$BIN_DIR" mkdir -p "$TEST_DIR/.humanize" @@ -224,7 +262,7 @@ echo "--- Test 4: OPUS (uppercase) model routes to claude ---" echo "" setup_test_dir -create_mock_bitlesson "$TEST_DIR" +create_real_humanize_bitlesson "$TEST_DIR" BIN_DIR="$TEST_DIR/bin" create_mock_claude "$BIN_DIR" mkdir -p "$TEST_DIR/.humanize" @@ -253,7 +291,7 @@ echo "--- Test 5: Unknown model exits non-zero with error ---" echo "" setup_test_dir -create_mock_bitlesson "$TEST_DIR" +create_real_humanize_bitlesson "$TEST_DIR" mkdir -p "$TEST_DIR/.humanize" printf '{"bitlesson_model": "unknown-xyz-model"}' > "$TEST_DIR/.humanize/config.json" @@ -279,7 +317,7 @@ echo "--- Test 6: gpt-* model with missing codex binary exits non-zero ---" echo "" setup_test_dir -create_mock_bitlesson "$TEST_DIR" +create_real_humanize_bitlesson "$TEST_DIR" mkdir -p "$TEST_DIR/.humanize" printf '{"bitlesson_model": "gpt-4o"}' > "$TEST_DIR/.humanize/config.json" # Use a bin dir that contains a stub claude but NOT codex. @@ -315,7 +353,7 @@ echo "--- Test 7: haiku model falls back to codex when claude binary is missing echo "" setup_test_dir -create_mock_bitlesson "$TEST_DIR" +create_real_humanize_bitlesson "$TEST_DIR" mkdir -p "$TEST_DIR/.humanize" printf '{"bitlesson_model": "haiku"}' > "$TEST_DIR/.humanize/config.json" # Use a bin dir that contains a stub codex but NOT claude. @@ -348,4 +386,102 @@ fi # Summary # ======================================== +echo "" +echo "--- Test 8: codex-only provider mode forces codex routing ---" +echo "" + +setup_test_dir +create_real_bitlesson "$TEST_DIR" +mkdir -p "$TEST_DIR/.humanize" +printf '{"bitlesson_model": "haiku", "codex_model": "gpt-5.4", "provider_mode": "codex-only"}' > "$TEST_DIR/.humanize/config.json" +FALLBACK_BIN="$TEST_DIR/fallback-bin" +create_mock_codex "$FALLBACK_BIN" + +exit_code=0 +stdout_out="" +stdout_out=$(CLAUDE_PROJECT_DIR="$TEST_DIR" XDG_CONFIG_HOME="$TEST_DIR/no-user" \ + PATH="$FALLBACK_BIN:$PATH" \ + bash "$BITLESSON_SELECT" \ + --task "Initialize tracker" \ + --paths "plans/plan.md" \ + --bitlesson-file "$TEST_DIR/bitlesson.md" 2>/dev/null) || exit_code=$? + +if [[ $exit_code -eq 0 ]] && echo "$stdout_out" | grep -q "mock codex"; then + pass "codex-only provider mode forces codex routing" +else + fail "codex-only provider mode forces codex routing" "exit=0 + mock codex rationale" "exit=$exit_code, stdout=$stdout_out" +fi + +echo "" +echo "--- Test 9: Placeholder BitLesson file short-circuits to NONE ---" +echo "" + +setup_test_dir +create_mock_bitlesson "$TEST_DIR" +mkdir -p "$TEST_DIR/.humanize" +printf '{"bitlesson_model": "gpt-5.4"}' > "$TEST_DIR/.humanize/config.json" + +exit_code=0 +stdout_out="" +stdout_out=$(CLAUDE_PROJECT_DIR="$TEST_DIR" XDG_CONFIG_HOME="$TEST_DIR/no-user" \ + PATH="$SAFE_BASE_PATH" \ + bash "$BITLESSON_SELECT" \ + --task "Any task" \ + --paths "README.md" \ + --bitlesson-file "$TEST_DIR/.humanize/bitlesson.md" 2>/dev/null) || exit_code=$? + +if [[ $exit_code -eq 0 ]] && echo "$stdout_out" | grep -q "LESSON_IDS: NONE" && echo "$stdout_out" | grep -q "no recorded lessons"; then + pass "Placeholder BitLesson file returns NONE without invoking a model" +else + fail "Placeholder BitLesson file returns NONE without invoking a model" "exit=0 + NONE rationale" "exit=$exit_code, stdout=$stdout_out" +fi + +echo "" +echo "--- Test 10: Codex selector disables hooks and avoids full-auto ---" +echo "" + +setup_test_dir +create_real_bitlesson "$TEST_DIR" +mkdir -p "$TEST_DIR/.humanize" +printf '{"bitlesson_model": "gpt-5.4"}' > "$TEST_DIR/.humanize/config.json" +CAPTURE_BIN="$TEST_DIR/capture-bin" +mkdir -p "$CAPTURE_BIN" +cat > "$CAPTURE_BIN/codex" <<'EOF' +#!/bin/bash +printf '%s\n' "$@" > "${TEST_CAPTURE_ARGS:?}" +cat > /dev/null +cat <<'OUT' +LESSON_IDS: BL-20260315-tracker-drift +RATIONALE: The tracker lesson directly matches the task. +OUT +EOF +chmod +x "$CAPTURE_BIN/codex" + +CAPTURE_ARGS="$TEST_DIR/codex-args.txt" +exit_code=0 +stdout_out="" +stdout_out=$(TEST_CAPTURE_ARGS="$CAPTURE_ARGS" CLAUDE_PROJECT_DIR="$TEST_DIR" XDG_CONFIG_HOME="$TEST_DIR/no-user" \ + PATH="$CAPTURE_BIN:$SAFE_BASE_PATH" \ + bash "$BITLESSON_SELECT" \ + --task "Update the goal tracker after verification" \ + --paths "goal-tracker.md" \ + --bitlesson-file "$TEST_DIR/bitlesson.md" 2>/dev/null) || exit_code=$? + +captured_args="$(cat "$CAPTURE_ARGS")" + +if [[ $exit_code -eq 0 ]] \ + && echo "$stdout_out" | grep -q "BL-20260315-tracker-drift" \ + && echo "$captured_args" | grep -q -- '--disable' \ + && echo "$captured_args" | grep -q -- 'codex_hooks' \ + && echo "$captured_args" | grep -q -- '--skip-git-repo-check' \ + && echo "$captured_args" | grep -q -- '--ephemeral' \ + && echo "$captured_args" | grep -q -- 'read-only' \ + && ! echo "$captured_args" | grep -q -- '--full-auto'; then + pass "Codex selector runs as a direct helper without hooks or full-auto" +else + fail "Codex selector runs as a direct helper without hooks or full-auto" \ + "exit=0 + direct-helper args" \ + "exit=$exit_code, stdout=$stdout_out, args=$captured_args" +fi + print_test_summary "Bitlesson Select Routing Test Summary" diff --git a/tests/test-codex-hook-install.sh b/tests/test-codex-hook-install.sh new file mode 100755 index 00000000..55fc71d3 --- /dev/null +++ b/tests/test-codex-hook-install.sh @@ -0,0 +1,340 @@ +#!/bin/bash +# +# Tests for Codex-native hook installation and merge behavior. +# + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd)" +PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +source "$SCRIPT_DIR/test-helpers.sh" + +INSTALL_SCRIPT="$PROJECT_ROOT/scripts/install-skill.sh" + +echo "==========================================" +echo "Codex Hook Install Tests" +echo "==========================================" +echo "" + +if [[ ! -x "$INSTALL_SCRIPT" ]]; then + echo "FATAL: install-skill.sh not found at $INSTALL_SCRIPT" >&2 + exit 1 +fi + +if ! command -v python3 >/dev/null 2>&1; then + echo "FATAL: python3 is required for this test" >&2 + exit 1 +fi + +setup_test_dir + +FAKE_BIN="$TEST_DIR/bin" +CODEX_HOME_DIR="$TEST_DIR/codex-home" +HOOKS_FILE="$CODEX_HOME_DIR/hooks.json" +FEATURE_LOG="$TEST_DIR/codex-features.log" +XDG_CONFIG_HOME_DIR="$TEST_DIR/xdg-config" +HUMANIZE_USER_CONFIG="$XDG_CONFIG_HOME_DIR/humanize/config.json" +COMMAND_BIN_DIR="$TEST_DIR/command-bin" +mkdir -p "$FAKE_BIN" "$CODEX_HOME_DIR" "$COMMAND_BIN_DIR" + +cat > "$FAKE_BIN/codex" <<'EOF' +#!/bin/bash +set -euo pipefail + +if [[ "${1:-}" == "features" && "${2:-}" == "list" ]]; then + cat <<'LIST' +codex_hooks under development false +LIST + exit 0 +fi + +if [[ "${1:-}" == "features" && "${2:-}" == "enable" && "${3:-}" == "codex_hooks" ]]; then + printf 'CODEX_HOME=%s\n' "${CODEX_HOME:-}" >> "${TEST_CODEX_FEATURE_LOG:?}" + mkdir -p "${CODEX_HOME:?}" + : > "${CODEX_HOME}/.codex-hooks-enabled" + exit 0 +fi + +if [[ "${1:-}" == "exec" ]]; then + cat <<'OUT' +LESSON_IDS: NONE +RATIONALE: No matching lessons found (fake codex exec). +OUT + exit 0 +fi + +echo "unexpected fake codex invocation: $*" >&2 +exit 1 +EOF +chmod +x "$FAKE_BIN/codex" + +cat > "$HOOKS_FILE" <<'EOF' +{ + "description": "Existing hooks", + "hooks": { + "SessionStart": [ + { + "hooks": [ + { + "type": "command", + "command": "/custom/session-start.sh", + "timeout": 15 + } + ] + } + ], + "Stop": [ + { + "hooks": [ + { + "type": "command", + "command": "/tmp/old/skills/humanize/hooks/loop-codex-stop-hook.sh", + "timeout": 30 + }, + { + "type": "command", + "command": "/tmp/old/skills/humanize/hooks/pr-loop-stop-hook.sh", + "timeout": 30 + } + ] + }, + { + "hooks": [ + { + "type": "command", + "command": "/custom/keep-me.sh", + "timeout": 5 + } + ] + } + ] + } +} +EOF + +PATH="$FAKE_BIN:$PATH" TEST_CODEX_FEATURE_LOG="$FEATURE_LOG" XDG_CONFIG_HOME="$XDG_CONFIG_HOME_DIR" \ + "$INSTALL_SCRIPT" \ + --target codex \ + --codex-config-dir "$CODEX_HOME_DIR" \ + --codex-skills-dir "$CODEX_HOME_DIR/skills" \ + --command-bin-dir "$COMMAND_BIN_DIR" \ + > "$TEST_DIR/install.log" 2>&1 + +if [[ -f "$CODEX_HOME_DIR/skills/humanize/SKILL.md" ]]; then + pass "Codex install syncs Humanize skill bundle" +else + fail "Codex install syncs Humanize skill bundle" "skills/humanize/SKILL.md exists" "missing" +fi + +if [[ -f "$CODEX_HOME_DIR/skills/humanize-rlcr/SKILL.md" ]]; then + pass "Codex install keeps humanize-rlcr entrypoint skill" +else + fail "Codex install keeps humanize-rlcr entrypoint skill" "skills/humanize-rlcr/SKILL.md exists" "missing" +fi + +if [[ -f "$HOOKS_FILE" ]]; then + pass "Codex install writes hooks.json" +else + fail "Codex install writes hooks.json" "$HOOKS_FILE exists" "missing" +fi + +if [[ -f "$CODEX_HOME_DIR/.codex-hooks-enabled" ]]; then + pass "Codex install enables codex_hooks feature" +else + fail "Codex install enables codex_hooks feature" ".codex-hooks-enabled marker exists" "missing" +fi + +if [[ -f "$HUMANIZE_USER_CONFIG" ]]; then + pass "Codex install writes Humanize user config" +else + fail "Codex install writes Humanize user config" "$HUMANIZE_USER_CONFIG exists" "missing" +fi + +if [[ -x "$COMMAND_BIN_DIR/bitlesson-selector" ]]; then + pass "Codex install writes a PATH-ready bitlesson-selector shim" +else + fail "Codex install writes a PATH-ready bitlesson-selector shim" "$COMMAND_BIN_DIR/bitlesson-selector exists" "missing" +fi + +if [[ "$(jq -r '.bitlesson_model // empty' "$HUMANIZE_USER_CONFIG")" == "gpt-5.4" ]]; then + pass "Codex install seeds bitlesson_model with a Codex/OpenAI model" +else + fail "Codex install seeds bitlesson_model with a Codex/OpenAI model" \ + "gpt-5.4" "$(jq -c '.' "$HUMANIZE_USER_CONFIG" 2>/dev/null || echo MISSING)" +fi + +if [[ "$(jq -r '.provider_mode // empty' "$HUMANIZE_USER_CONFIG")" == "codex-only" ]]; then + pass "Codex install marks Humanize user config as codex-only" +else + fail "Codex install marks Humanize user config as codex-only" \ + "codex-only" "$(jq -c '.' "$HUMANIZE_USER_CONFIG" 2>/dev/null || echo MISSING)" +fi + +runtime_root="$CODEX_HOME_DIR/skills/humanize" +PY_OUTPUT="$( + python3 - "$HOOKS_FILE" "$runtime_root" <<'PY' +import json +import pathlib +import sys + +hooks_file = pathlib.Path(sys.argv[1]) +runtime_root = sys.argv[2] +data = json.loads(hooks_file.read_text(encoding="utf-8")) + +commands = [] +for group in data["hooks"]["Stop"]: + for hook in group.get("hooks", []): + command = hook.get("command") + if isinstance(command, str): + commands.append(command) + +expected = { + f"{runtime_root}/hooks/loop-codex-stop-hook.sh", + f"{runtime_root}/hooks/pr-loop-stop-hook.sh", +} + +print("FOUND=" + ("1" if expected.issubset(set(commands)) else "0")) +print("KEEP=" + ("1" if "/custom/keep-me.sh" in commands else "0")) +print("OLD=" + ("1" if any("/tmp/old/skills/humanize/hooks/" in cmd for cmd in commands) else "0")) +print("SESSION=" + ("1" if data["hooks"]["SessionStart"][0]["hooks"][0]["command"] == "/custom/session-start.sh" else "0")) +print("COUNT=" + str(sum(1 for cmd in commands if "/humanize/hooks/" in cmd))) +PY +)" + +if grep -q '^FOUND=1$' <<<"$PY_OUTPUT"; then + pass "Codex install adds managed Humanize Stop hook commands" +else + fail "Codex install adds managed Humanize Stop hook commands" "FOUND=1" "$PY_OUTPUT" +fi + +if grep -q '^KEEP=1$' <<<"$PY_OUTPUT"; then + pass "Codex install preserves unrelated Stop hooks" +else + fail "Codex install preserves unrelated Stop hooks" "KEEP=1" "$PY_OUTPUT" +fi + +if grep -q '^OLD=0$' <<<"$PY_OUTPUT"; then + pass "Codex install removes stale Humanize hook commands" +else + fail "Codex install removes stale Humanize hook commands" "OLD=0" "$PY_OUTPUT" +fi + +if grep -q '^SESSION=1$' <<<"$PY_OUTPUT"; then + pass "Codex install preserves SessionStart hooks" +else + fail "Codex install preserves SessionStart hooks" "SESSION=1" "$PY_OUTPUT" +fi + +if grep -q '^COUNT=2$' <<<"$PY_OUTPUT"; then + pass "Codex install writes exactly two managed Humanize Stop hooks" +else + fail "Codex install writes exactly two managed Humanize Stop hooks" "COUNT=2" "$PY_OUTPUT" +fi + +mkdir -p "$TEST_DIR/project" +cat > "$TEST_DIR/project/bitlesson.md" <<'EOF' +# BitLesson Knowledge Base +## Entries + +EOF + +shim_output="$( + CLAUDE_PROJECT_DIR="$TEST_DIR/project" \ + XDG_CONFIG_HOME="$XDG_CONFIG_HOME_DIR" \ + PATH="$COMMAND_BIN_DIR:$FAKE_BIN:$PATH" \ + "$COMMAND_BIN_DIR/bitlesson-selector" \ + --task "Verify the shim dispatches into the installed runtime" \ + --paths "README.md" \ + --bitlesson-file "$TEST_DIR/project/bitlesson.md" +)" + +if grep -q '^LESSON_IDS: NONE$' <<<"$shim_output"; then + pass "bitlesson-selector shim dispatches into installed runtime" +else + fail "bitlesson-selector shim dispatches into installed runtime" "LESSON_IDS: NONE" "$shim_output" +fi + +PATH="$FAKE_BIN:$PATH" TEST_CODEX_FEATURE_LOG="$FEATURE_LOG" XDG_CONFIG_HOME="$XDG_CONFIG_HOME_DIR" \ + "$INSTALL_SCRIPT" \ + --target codex \ + --codex-config-dir "$CODEX_HOME_DIR" \ + --codex-skills-dir "$CODEX_HOME_DIR/skills" \ + > "$TEST_DIR/install-2.log" 2>&1 + +PY_OUTPUT_2="$( + python3 - "$HOOKS_FILE" <<'PY' +import json +import pathlib +import sys + +hooks_file = pathlib.Path(sys.argv[1]) +data = json.loads(hooks_file.read_text(encoding="utf-8")) + +commands = [] +for group in data["hooks"]["Stop"]: + for hook in group.get("hooks", []): + command = hook.get("command") + if isinstance(command, str): + commands.append(command) + +print(sum(1 for cmd in commands if "/humanize/hooks/" in cmd)) +PY +)" + +if [[ "$PY_OUTPUT_2" == "2" ]]; then + pass "Codex install is idempotent for managed hook commands" +else + fail "Codex install is idempotent for managed hook commands" "2" "$PY_OUTPUT_2" +fi + +if [[ "$(wc -l < "$FEATURE_LOG" | tr -d ' ')" == "2" ]]; then + pass "Codex feature enable runs on each Codex install/update" +else + fail "Codex feature enable runs on each Codex install/update" "2 log entries" "$(cat "$FEATURE_LOG")" +fi + +UNSUPPORTED_BIN="$TEST_DIR/bin-unsupported" +UNSUPPORTED_HOME="$TEST_DIR/codex-home-unsupported" +mkdir -p "$UNSUPPORTED_BIN" "$UNSUPPORTED_HOME" + +cat > "$UNSUPPORTED_BIN/codex" <<'EOF' +#!/bin/bash +set -euo pipefail + +if [[ "${1:-}" == "features" && "${2:-}" == "list" ]]; then + cat <<'LIST' +apply_patch_freeform under development false +LIST + exit 0 +fi + +echo "unexpected fake codex invocation: $*" >&2 +exit 1 +EOF +chmod +x "$UNSUPPORTED_BIN/codex" + +set +e +PATH="$UNSUPPORTED_BIN:$PATH" \ + "$INSTALL_SCRIPT" \ + --target codex \ + --codex-config-dir "$UNSUPPORTED_HOME" \ + --codex-skills-dir "$UNSUPPORTED_HOME/skills" \ + > "$TEST_DIR/install-unsupported.log" 2>&1 +UNSUPPORTED_EXIT=$? +set -e + +if [[ "$UNSUPPORTED_EXIT" -ne 0 ]]; then + pass "Codex install rejects builds without native hooks support" +else + fail "Codex install rejects builds without native hooks support" "non-zero exit" "exit 0" +fi + +if grep -q "codex_hooks feature" "$TEST_DIR/install-unsupported.log"; then + pass "Unsupported Codex failure explains missing codex_hooks feature" +else + fail "Unsupported Codex failure explains missing codex_hooks feature" \ + "error mentioning codex_hooks feature" \ + "$(cat "$TEST_DIR/install-unsupported.log")" +fi + +print_test_summary "Codex Hook Install Tests" diff --git a/tests/test-disable-nested-codex-hooks.sh b/tests/test-disable-nested-codex-hooks.sh new file mode 100644 index 00000000..ae0f8bba --- /dev/null +++ b/tests/test-disable-nested-codex-hooks.sh @@ -0,0 +1,213 @@ +#!/bin/bash +# +# Ensure Humanize's nested Codex reviewer calls disable native hooks to avoid recursion. +# + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd)" +PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" + +GREEN='\033[0;32m' +RED='\033[0;31m' +NC='\033[0m' +TESTS_PASSED=0 +TESTS_FAILED=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)) +} + +echo "==========================================" +echo "Disable Nested Codex Hooks Tests" +echo "==========================================" +echo "" + +TEST_DIR="$(mktemp -d)" +trap 'rm -rf "$TEST_DIR"' EXIT + +export XDG_CACHE_HOME="$TEST_DIR/.cache" +mkdir -p "$XDG_CACHE_HOME" + +STOP_HOOK="$PROJECT_ROOT/hooks/loop-codex-stop-hook.sh" +PR_STOP_HOOK="$PROJECT_ROOT/hooks/pr-loop-stop-hook.sh" + +setup_repo() { + local repo_dir="$1" + + mkdir -p "$repo_dir" + cd "$repo_dir" + git init -q + git config user.email "test@test.com" + git config user.name "Test User" + git config commit.gpgsign false + + cat > .gitignore <<'EOF' +.humanize/ +plans/ +.cache/ +EOF + mkdir -p plans + cat > plans/test-plan.md <<'EOF' +# Test Plan +EOF + echo "init" > init.txt + git add .gitignore init.txt + git -c commit.gpgsign=false commit -q -m "initial" +} + +setup_mock_codex() { + local bin_dir="$1" + local args_file="$2" + + mkdir -p "$bin_dir" + cat > "$bin_dir/codex" < "$args_file" + +subcommand="" +for arg in "\$@"; do + if [[ "\$arg" == "exec" || "\$arg" == "review" ]]; then + subcommand="\$arg" + break + fi +done + +if [[ "\$subcommand" == "exec" ]]; then + echo "Review: keep iterating." + exit 0 +fi + +if [[ "\$subcommand" == "review" ]]; then + echo "No issues found." + exit 0 +fi + +echo "unexpected codex args: \$*" >&2 +exit 1 +EOF + chmod +x "$bin_dir/codex" +} + +setup_loop_dir() { + local repo_dir="$1" + local review_started="$2" + local loop_dir="$repo_dir/.humanize/rlcr/2026-03-14_12-00-00" + local current_branch + local base_commit + + current_branch="$(git -C "$repo_dir" rev-parse --abbrev-ref HEAD)" + base_commit="$(git -C "$repo_dir" rev-parse HEAD)" + + mkdir -p "$loop_dir" + cat > "$loop_dir/state.md" < "$loop_dir/goal-tracker.md" <<'EOF' +# Goal Tracker +## IMMUTABLE SECTION +### Ultimate Goal +Test nested codex disable +### Acceptance Criteria +- AC-1: Hook can run + +## MUTABLE SECTION +### Active Tasks +- Verify hook argv +EOF + + cat > "$loop_dir/round-1-summary.md" <<'EOF' +# Round Summary +Implemented initial changes. +EOF + + if [[ "$review_started" == "true" ]]; then + echo "build_finish_round=1" > "$loop_dir/.review-phase-started" + fi +} + +run_loop_hook() { + local repo_dir="$1" + local args_file="$2" + local review_started="$3" + local bin_dir="$TEST_DIR/bin-${review_started}" + + setup_mock_codex "$bin_dir" "$args_file" + setup_loop_dir "$repo_dir" "$review_started" + + set +e + OUTPUT=$(echo '{}' | PATH="$bin_dir:$PATH" CLAUDE_PROJECT_DIR="$repo_dir" bash "$STOP_HOOK" 2>&1) + EXIT_CODE=$? + set -e + + if [[ $EXIT_CODE -ne 0 ]]; then + fail "loop hook completes in $review_started mode" "exit 0" "exit=$EXIT_CODE output=$OUTPUT" + return + fi +} + +REPO_IMPL="$TEST_DIR/repo-impl" +setup_repo "$REPO_IMPL" +run_loop_hook "$REPO_IMPL" "$TEST_DIR/impl.args" "false" + +if grep -q -- '--disable codex_hooks exec' "$TEST_DIR/impl.args"; then + pass "implementation-phase stop hook disables codex_hooks for codex exec" +else + fail "implementation-phase stop hook disables codex_hooks for codex exec" \ + "--disable codex_hooks exec" "$(cat "$TEST_DIR/impl.args" 2>/dev/null || echo missing)" +fi + +REPO_REVIEW="$TEST_DIR/repo-review" +setup_repo "$REPO_REVIEW" +run_loop_hook "$REPO_REVIEW" "$TEST_DIR/review.args" "true" + +if grep -q -- '--disable codex_hooks review' "$TEST_DIR/review.args"; then + pass "review-phase stop hook disables codex_hooks for codex review" +else + fail "review-phase stop hook disables codex_hooks for codex review" \ + "--disable codex_hooks review" "$(cat "$TEST_DIR/review.args" 2>/dev/null || echo missing)" +fi + +if grep -q 'codex "\${CODEX_DISABLE_HOOKS_ARGS\[@\]}" exec' "$PR_STOP_HOOK"; then + pass "PR stop hook disables codex_hooks for nested codex exec" +else + fail "PR stop hook disables codex_hooks for nested codex exec" \ + 'codex "${CODEX_DISABLE_HOOKS_ARGS[@]}" exec' "not found" +fi + +echo "" +echo "========================================" +echo "Disable Nested Codex Hooks Tests" +echo "========================================" +echo "Passed: $TESTS_PASSED" +echo "Failed: $TESTS_FAILED" + +if [[ $TESTS_FAILED -ne 0 ]]; then + exit 1 +fi diff --git a/tests/test-finalize-phase.sh b/tests/test-finalize-phase.sh index 96890a41..4eaef4b6 100755 --- a/tests/test-finalize-phase.sh +++ b/tests/test-finalize-phase.sh @@ -57,11 +57,18 @@ setup_mock_codex() { cat > "$TEST_DIR/bin/codex" << EOF #!/bin/bash # Mock codex - outputs the provided content -if [[ "\$1" == "exec" ]]; then +subcommand="" +for arg in "\$@"; do + if [[ "\$arg" == "exec" || "\$arg" == "review" ]]; then + subcommand="\$arg" + break + fi +done +if [[ "\$subcommand" == "exec" ]]; then cat << 'REVIEW' $output REVIEW -elif [[ "\$1" == "review" ]]; then +elif [[ "\$subcommand" == "review" ]]; then # Handle codex review command cat << 'REVIEWOUT' $review_output @@ -82,11 +89,18 @@ setup_mock_codex_with_tracking() { #!/bin/bash # Track that codex was called echo "CODEX_WAS_CALLED" > "$TEST_DIR/codex_called.marker" -if [[ "\$1" == "exec" ]]; then +subcommand="" +for arg in "\$@"; do + if [[ "\$arg" == "exec" || "\$arg" == "review" ]]; then + subcommand="\$arg" + break + fi +done +if [[ "\$subcommand" == "exec" ]]; then cat << 'REVIEW' $output REVIEW -elif [[ "\$1" == "review" ]]; then +elif [[ "\$subcommand" == "review" ]]; then cat << 'REVIEWOUT' $review_output REVIEWOUT @@ -106,11 +120,18 @@ setup_mock_codex_review_failure() { cat > "$TEST_DIR/bin/codex" << EOF #!/bin/bash # Mock codex - fails on review command -if [[ "\$1" == "exec" ]]; then +subcommand="" +for arg in "\$@"; do + if [[ "\$arg" == "exec" || "\$arg" == "review" ]]; then + subcommand="\$arg" + break + fi +done +if [[ "\$subcommand" == "exec" ]]; then cat << 'REVIEW' $exec_output REVIEW -elif [[ "\$1" == "review" ]]; then +elif [[ "\$subcommand" == "review" ]]; then # Simulate failure with non-zero exit echo "Error: Codex review failed" >&2 exit $review_exit_code @@ -128,11 +149,18 @@ setup_mock_codex_review_empty_stdout() { cat > "$TEST_DIR/bin/codex" << EOF #!/bin/bash # Mock codex - produces empty stdout on review -if [[ "\$1" == "exec" ]]; then +subcommand="" +for arg in "\$@"; do + if [[ "\$arg" == "exec" || "\$arg" == "review" ]]; then + subcommand="\$arg" + break + fi +done +if [[ "\$subcommand" == "exec" ]]; then cat << 'REVIEW' $exec_output REVIEW -elif [[ "\$1" == "review" ]]; then +elif [[ "\$subcommand" == "review" ]]; then # Exit successfully but produce no output exit 0 fi diff --git a/tests/test-task-tag-routing.sh b/tests/test-task-tag-routing.sh index ae9365f7..24871e00 100755 --- a/tests/test-task-tag-routing.sh +++ b/tests/test-task-tag-routing.sh @@ -28,14 +28,21 @@ create_mock_codex() { mkdir -p "$bin_dir" cat > "$bin_dir/codex" << MOCK_EOF #!/bin/bash -if [[ "\$1" == "exec" ]]; then +subcommand="" +for arg in "\$@"; do + if [[ "\$arg" == "exec" || "\$arg" == "review" ]]; then + subcommand="\$arg" + break + fi +done +if [[ "\$subcommand" == "exec" ]]; then cat << 'OUT' $exec_output OUT -elif [[ "\$1" == "review" ]]; then +elif [[ "\$subcommand" == "review" ]]; then echo "No issues found." else - echo "mock-codex: unsupported command \$1" >&2 + echo "mock-codex: unsupported command \$*" >&2 exit 1 fi MOCK_EOF