diff --git a/README.md b/README.md index d9e3a58..5b73e2a 100644 --- a/README.md +++ b/README.md @@ -65,6 +65,13 @@ Open a PR after an accepted run: devloop --create-pr .specs/change.md ``` +See tracked runs and cleanup candidates: + +```sh +devloop status +devloop clean +``` + ## Specs A good spec is short, concrete, and verifiable. Start from [`skills/devloop-spec/references/spec-template.md`](skills/devloop-spec/references/spec-template.md), or generate one: @@ -74,7 +81,7 @@ devloop spec devloop spec --agent claude --output .specs/chat-retry.md notes.md ``` -By default, generated specs are written under `.specs/`, and the interactive spec picker searches `.specs/` and `.devloop/specs/`. If your specs live somewhere else, open `devloop`, choose `Settings`, and add one extra spec path. Generated specs will be written there too. +By default, generated specs are written under `.specs/`, and the interactive spec picker searches `.specs/`. If your specs live somewhere else, open `devloop`, choose `Settings`, and add one extra spec path. Generated specs will be written there too. The custom path is saved in `.devloop/config` and can be repo-relative or absolute: @@ -82,7 +89,17 @@ The custom path is saved in `.devloop/config` and can be repo-relative or absolu spec_dir=/Users/satya/Projects/specs ``` -The Settings menu expands `~/...` before saving. `.devloop/` is ignored by git because absolute paths are machine-local. +The Settings menu expands `~/...` before saving. It also lets you change the per-run timeout. `.devloop/` is ignored by git because absolute paths are machine-local. + +Runs time out after 30 minutes by default. Change that in `Settings`, in `.devloop/config`, or per command: + +```ini +timeout_minutes=45 +``` + +```sh +devloop --timeout-minutes 45 .specs/change.md +``` Strict mode is on by default. It requires acceptance criteria and only accepts reviews that pass both the spec gate and engineering quality gate: @@ -105,6 +122,7 @@ devloop [options] [max=5] | `--coder ` | Choose Codex or Claude Code for implementation (`codex`/`claude`) | | `--reviewer ` | Choose Codex or Claude Code for review (`codex`/`claude`) | | `--report-format ` | Choose `html` or `markdown` | +| `--timeout-minutes ` | Cap one run, default `30` | | `--in-place` | Run in the current worktree | | `--create-pr`, `--pr` | Push the accepted branch and open a GitHub PR | | `--no-strict` | Weaken strict review gates | @@ -116,11 +134,11 @@ devloop [options] [max=5] When stdout is a terminal, running `devloop` without arguments opens a menu: -- `Run a spec`: pick a spec from the configured spec path, `.specs/`, or `.devloop/specs/`. +- `Run a spec`: pick a spec from the configured spec path or `.specs/`. - `Create a spec`: choose the spec agent and provide source context. - `Continue a run`: pick a prior `.codex/tracks/*.md` and continue in that worktree. - `Open reports`: pick a prior report from any registered worktree. -- `Settings`: view spec search paths, and add or remove one custom spec path. +- `Settings`: view spec search paths, add or remove one custom spec path, and set the run timeout. - `Doctor`: verify required commands, optional UI tools, and installed skills. Nested menu screens keep `Back` as the final option so you can return to the previous menu without exiting Devloop. @@ -130,8 +148,12 @@ Nested menu screens keep `Back` as the final option so you can return to the pre ## What Devloop Does - Runs up to 5 passes by default, clamped between 1 and 10. +- Stops a run after the configured timeout, default 30 minutes. - Uses isolated sibling git worktrees by default. +- Runs a small preflight before agents start: git identity, agent CLIs, installed skills, and `gh` when PR creation is enabled. +- Lints specs for a title, valid frontmatter type when frontmatter exists, and strict acceptance criteria. - Commits eligible changes after each coder pass. +- Runs `.devloop/verify` after each coder pass when it exists and is executable. In strict mode, a failing hook blocks acceptance. - Writes tracks, reviews, reports, logs, session ids, and spec snapshots under `.codex/`. - Leaves generated worktrees and branches in place for inspection. - Drops you into the generated worktree shell after interactive runs, unless `--no-shell` is set. @@ -141,10 +163,20 @@ Nested menu screens keep `Back` as the final option so you can return to the pre `devloop` runs local agent CLIs against your checkout, so those agents inherit your local credentials, PATH, and machine access. `devloop` itself adds no telemetry and does not send data anywhere; network behavior depends on the agents and commands you configure. +If present, `.devloop/verify` is executed from the run worktree with the pass number and slug as arguments. Keep that script local and auditable. + +## Operations + +`devloop status` summarizes tracked runs across registered git worktrees. It shows the slug, latest verdict-derived status, pass count, branch, worktree, report path, and a suggested next command. + +`devloop clean` defaults to a dry run. `devloop clean --force` removes eligible generated worktrees, but skips accepted runs and user-dirty worktrees unless forced. `.codex/` runtime artifacts do not count as user dirt. + ## Development ```sh bash -n devloop install.sh release.sh skill_helpers.sh +shellcheck devloop install.sh skill_helpers.sh tests/devloop_test.sh +bash tests/devloop_test.sh ./devloop --help ./devloop --version tmp="$(mktemp -d)" diff --git a/devloop b/devloop index 796945c..3c81367 100755 --- a/devloop +++ b/devloop @@ -5,6 +5,7 @@ CODEX_MODEL_ARGS=(-m gpt-5.5) CODEX_REASONING_ARGS=(-c 'model_reasoning_effort="xhigh"') CLAUDE_MODEL_ARGS=(--model claude-opus-4-8) CLAUDE_EFFORT_ARGS=(--effort max) +DEFAULT_TIMEOUT_MINUTES=30 SCRIPT_PATH="${BASH_SOURCE[0]}" while [ -L "$SCRIPT_PATH" ]; do @@ -41,6 +42,10 @@ RUN_CODE=0 RUN_STDOUT="" RUN_STDERR="" RUN_OUTPUT="" +RUN_TIMEOUT_MINUTES="$DEFAULT_TIMEOUT_MINUTES" +RUN_TIMEOUT_SECONDS=$((DEFAULT_TIMEOUT_MINUTES * 60)) +RUN_DEADLINE=0 +RUN_TIMED_OUT=false STATUS="" PASSES=0 @@ -59,6 +64,10 @@ REVIEWER_SESSION_ID="" PULL_REQUEST="" PULL_REQUEST_ERROR="" RUN_START_PASS=1 +VERIFY_LOG="" +VERIFY_DETAIL="" +PREFLIGHT_ERROR="" +SPEC_LINT_ERROR="" COMMIT_PASSES=() COMMIT_HASHES=() @@ -92,6 +101,18 @@ main() { return $? fi + if [ "${1:-}" = "status" ]; then + shift + status_command "$@" + return $? + fi + + if [ "${1:-}" = "clean" ]; then + shift + clean_command "$@" + return $? + fi + if [ "${1:-}" = "continue" ]; then shift continue_command "$@" @@ -149,6 +170,8 @@ Usage: Common commands: devloop doctor devloop reports + devloop status + devloop clean devloop continue devloop menu devloop spec "add retry behavior to the chat sender" @@ -165,6 +188,7 @@ Options: --coder codex|claude choose Codex or Claude Code for implementation --reviewer codex|claude choose Codex or Claude Code for review --report-format html|markdown choose report format + --timeout-minutes N cap one run, default 30 --no-strict weaken strict review gates --in-place run in the current worktree --create-pr, --pr push accepted branch and open a PR @@ -184,6 +208,8 @@ welcome_tui() { gum style --foreground "$UI_ACCENT_COLOR" --bold "Common commands" printf ' %-42s %s\n' "devloop doctor" "check required and optional tools" printf ' %-42s %s\n' "devloop reports" "open previous run reports" + printf ' %-42s %s\n' "devloop status" "summarize tracked runs" + printf ' %-42s %s\n' "devloop clean" "show safe cleanup candidates" printf ' %-42s %s\n' "devloop continue" "resume a tracked run" printf ' %-42s %s\n' "devloop menu" "open the guided UI" printf ' %-42s %s\n' 'devloop spec "add retry behavior"' "generate a spec" @@ -196,6 +222,7 @@ welcome_tui() { printf ' %-30s %s\n' "--coder codex|claude" "choose implementation agent" printf ' %-30s %s\n' "--reviewer codex|claude" "choose review agent" printf ' %-30s %s\n' "--report-format html|markdown" "choose report format" + printf ' %-30s %s\n' "--timeout-minutes N" "cap one run, default 30" printf ' %-30s %s\n' "--no-strict" "weaken strict review gates" printf ' %-30s %s\n' "--in-place" "run in the current worktree" printf ' %-30s %s\n' "--create-pr, --pr" "push accepted branch and open a PR" @@ -206,7 +233,7 @@ welcome_tui() { } usage() { - printf '%s\n' "usage: devloop [--version] [--plain|--tui] [--in-place] [--no-strict] [--create-pr|--pr] [--no-shell|--stay|--shell|--enter-worktree] [--coder codex|claude] [--reviewer codex|claude] [--report-format html|markdown] [max=5]" + printf '%s\n' "usage: devloop [--version] [--plain|--tui] [--in-place] [--no-strict] [--create-pr|--pr] [--no-shell|--stay|--shell|--enter-worktree] [--coder codex|claude] [--reviewer codex|claude] [--report-format html|markdown] [--timeout-minutes N] [max=5]" printf '%s\n' "agents: Codex or Claude Code" } @@ -280,7 +307,12 @@ config_file_value() { local line value [ -f "$file" ] || return 1 while IFS= read -r line; do - value="$(printf '%s\n' "$line" | sed -nE "s/^[[:space:]]*$key[[:space:]]*=[[:space:]]*(.*)$/\1/p")" + value="$(printf '%s\n' "$line" | awk -v key="$key" ' + $0 ~ "^[[:space:]]*" key "[[:space:]]*=" { + sub("^[[:space:]]*" key "[[:space:]]*=[[:space:]]*", "", $0) + print + } + ')" [ -n "$value" ] || continue value="${value%%#*}" value="$(trim_string "$value")" @@ -312,14 +344,14 @@ write_config_value() { local value="$3" local file tmp case "$key" in - spec_dir) ;; + spec_dir|timeout_minutes) ;; *) printf 'unknown config key: %s\n' "$key" >&2; return 2 ;; esac file="$(devloop_config_file_for_scope "$scope")" || return $? mkdir -p "$(dirname "$file")" || return 1 tmp="$(mktemp "${TMPDIR:-/tmp}/devloop-config.XXXXXX")" if [ -f "$file" ]; then - sed -E "/^[[:space:]]*$key[[:space:]]*=/d" "$file" > "$tmp" + awk -v key="$key" '$0 !~ "^[[:space:]]*" key "[[:space:]]*="' "$file" > "$tmp" fi printf '%s=%s\n' "$key" "$value" >> "$tmp" mv "$tmp" "$file" @@ -330,13 +362,13 @@ remove_config_value() { local key="$2" local file tmp case "$key" in - spec_dir) ;; + spec_dir|timeout_minutes) ;; *) printf 'unknown config key: %s\n' "$key" >&2; return 2 ;; esac file="$(devloop_config_file_for_scope "$scope")" || return $? [ -f "$file" ] || return 0 tmp="$(mktemp "${TMPDIR:-/tmp}/devloop-config.XXXXXX")" - sed -E "/^[[:space:]]*$key[[:space:]]*=/d" "$file" > "$tmp" + awk -v key="$key" '$0 !~ "^[[:space:]]*" key "[[:space:]]*="' "$file" > "$tmp" mv "$tmp" "$file" } @@ -359,11 +391,11 @@ expand_spec_dir_input() { local dir="$1" dir="$(trim_string "$dir")" case "$dir" in - "~") + \~) if [ -z "${HOME:-}" ]; then return 1; fi printf '%s\n' "$HOME" ;; - "~/"*) + \~/*) if [ -z "${HOME:-}" ]; then return 1; fi printf '%s/%s\n' "${HOME%/}" "${dir#\~/}" ;; @@ -385,7 +417,7 @@ devloop_spec_dir() { is_default_spec_dir() { case "$1" in - ".specs"|".devloop/specs") return 0 ;; + ".specs") return 0 ;; *) return 1 ;; esac } @@ -421,7 +453,7 @@ spec_search_dirs() { local configured dir seen configured="$(devloop_spec_dir)" seen="" - for dir in "$configured" ".specs" ".devloop/specs"; do + for dir in "$configured" ".specs"; do [ -n "$dir" ] || continue case "$seen" in *"|$dir|"*) continue ;; @@ -490,6 +522,74 @@ remove_config_spec_dir() { remove_config_value "$scope" spec_dir } +normalize_timeout_minutes() { + local value="$1" + value="$(trim_string "$value")" + if ! printf '%s\n' "$value" | grep -Eq '^[0-9]+$'; then return 1; fi + if [ "$value" -lt 1 ] || [ "$value" -gt 1440 ]; then return 1; fi + printf '%s\n' "$value" +} + +devloop_timeout_minutes() { + local value normalized + value="$(devloop_config_value timeout_minutes || true)" + if normalized="$(normalize_timeout_minutes "$value")"; then + printf '%s\n' "$normalized" + else + printf '%s\n' "$DEFAULT_TIMEOUT_MINUTES" + fi +} + +configured_timeout_minutes() { + local value normalized + value="$(devloop_config_value timeout_minutes || true)" + [ -n "$value" ] || return 1 + normalized="$(normalize_timeout_minutes "$value")" || return 1 + [ "$normalized" = "$DEFAULT_TIMEOUT_MINUTES" ] && return 1 + printf '%s\n' "$normalized" +} + +configured_timeout_minutes_scope() { + local file value normalized + file="$(devloop_local_config_file)" + if value="$(config_file_value timeout_minutes "$file")" && [ -n "$value" ]; then + normalized="$(normalize_timeout_minutes "$value")" || return 1 + [ "$normalized" = "$DEFAULT_TIMEOUT_MINUTES" ] && return 1 + printf '%s\n' "local" + return 0 + fi + if file="$(devloop_global_config_file 2>/dev/null)" && value="$(config_file_value timeout_minutes "$file")" && [ -n "$value" ]; then + normalized="$(normalize_timeout_minutes "$value")" || return 1 + [ "$normalized" = "$DEFAULT_TIMEOUT_MINUTES" ] && return 1 + printf '%s\n' "global" + return 0 + fi + return 1 +} + +write_config_timeout_minutes() { + local scope="local" + local value normalized + if [ "$#" -eq 2 ]; then + scope="$1" + value="$2" + else + value="$1" + fi + normalized="$(normalize_timeout_minutes "$value")" || { + printf '%s\n' "timeout must be an integer between 1 and 1440 minutes" >&2 + return 2 + } + write_config_value "$scope" timeout_minutes "$normalized" || return $? + printf '%s\n' "$normalized" +} + +remove_config_timeout_minutes() { + local scope="local" + if [ "$#" -eq 1 ]; then scope="$1"; fi + remove_config_value "$scope" timeout_minutes +} + ui_has_gum() { [ "$USE_TUI" = true ] && command -v gum >/dev/null 2>&1 } @@ -751,14 +851,31 @@ ui_spinner_wait() { local pid="$1" local done_file="$2" local title="${3:-working}" - local frames='|/-\' + local stderr_file="${4:-}" + local frames="|/-\\" local index=0 local elapsed frame minutes seconds if [ "$USE_TUI" != true ] || [ ! -t 2 ]; then - while [ ! -s "$done_file" ] && kill -0 "$pid" 2>/dev/null; do sleep 1; done + while [ ! -s "$done_file" ] && kill -0 "$pid" 2>/dev/null; do + if run_deadline_reached; then + RUN_TIMED_OUT=true + if [ -n "$stderr_file" ]; then timeout_message >> "$stderr_file"; fi + printf '%s\n' "124" > "$done_file" + terminate_pid_tree "$pid" + return + fi + sleep 1 + done return fi while [ ! -s "$done_file" ] && kill -0 "$pid" 2>/dev/null; do + if run_deadline_reached; then + RUN_TIMED_OUT=true + if [ -n "$stderr_file" ]; then timeout_message >> "$stderr_file"; fi + printf '%s\n' "124" > "$done_file" + terminate_pid_tree "$pid" + return + fi frame="${frames:$((index % 4)):1}" elapsed="$SECONDS" if [ "${UI_STEP_STARTED:-0}" -gt 0 ]; then elapsed=$((SECONDS - UI_STEP_STARTED)); fi @@ -778,8 +895,8 @@ event_store_start() { i=0 while [ "$i" -lt "${#EVENT_IDS[@]}" ]; do if [ "${EVENT_IDS[$i]}" = "$id" ]; then - EVENT_TITLES[$i]="$title" - EVENT_STARTS[$i]="$SECONDS" + EVENT_TITLES[i]="$title" + EVENT_STARTS[i]="$SECONDS" return fi i=$((i + 1)) @@ -807,7 +924,7 @@ event_elapsed() { local i=0 while [ "$i" -lt "${#EVENT_IDS[@]}" ]; do if [ "${EVENT_IDS[$i]}" = "$id" ]; then - printf '%s\n' "$((SECONDS - EVENT_STARTS[$i]))" + printf '%s\n' "$((SECONDS - EVENT_STARTS[i]))" return fi i=$((i + 1)) @@ -829,6 +946,7 @@ run_header() { local coder="$6" local reviewer="$7" local create_pr="$8" + local timeout_minutes="${9:-$DEFAULT_TIMEOUT_MINUTES}" if [ "$USE_TUI" != true ]; then return; fi ui_header "devloop" "$spec" ui_print_key_values \ @@ -836,6 +954,7 @@ run_header() { "coder" "$(agent_label "$coder")" \ "reviewer" "$(agent_label "$reviewer")" \ "passes" "$max" \ + "timeout" "$timeout_minutes minutes" \ "strict" "$strict" \ "report" "$report_format" \ "pr" "$create_pr" @@ -916,22 +1035,27 @@ interactive_create_spec() { } interactive_settings() { - local choice custom_spec_dir scope saved value + local choice custom_spec_dir custom_timeout scope saved value timeout_display local choices=() while true; do custom_spec_dir="$(configured_spec_dir || true)" - ui_header "Settings" "Spec paths" + custom_timeout="$(configured_timeout_minutes || true)" + timeout_display="$(devloop_timeout_minutes) minutes" + ui_header "Settings" "Spec paths and run timeout" if [ -n "$custom_spec_dir" ]; then ui_print_key_values \ "custom" "$custom_spec_dir" \ "default" ".specs" \ - "" ".devloop/specs" - choices=("Remove spec path" "Back") + "timeout" "$timeout_display" + choices=("Remove spec path" "Set timeout" "Back") else ui_print_key_values \ "default" ".specs" \ - "" ".devloop/specs" - choices=("Add spec path" "Back") + "timeout" "$timeout_display" + choices=("Add spec path" "Set timeout" "Back") + fi + if [ -n "$custom_timeout" ]; then + choices=("${choices[@]:0:${#choices[@]}-1}" "Remove timeout" "Back") fi choice="$(ui_choose "Spec paths" "${choices[@]}")" || return 130 case "$choice" in @@ -950,6 +1074,21 @@ interactive_settings() { printf '%s\n' "spec path removed" >&2 fi ;; + "Set timeout") + value="$(ui_input "Timeout minutes, 1-1440" "$(devloop_timeout_minutes)")" || return 130 + if saved="$(write_config_timeout_minutes local "$value")"; then + printf 'timeout saved: %s minutes\n' "$saved" >&2 + else + printf '%s\n' "timeout not saved" >&2 + fi + ;; + "Remove timeout") + scope="$(configured_timeout_minutes_scope || true)" + if [ -n "$scope" ]; then + remove_config_timeout_minutes "$scope" + printf '%s\n' "timeout removed" >&2 + fi + ;; "Back") ui_go_back; return 0 ;; esac done @@ -964,7 +1103,9 @@ interactive_run_setup() { local reviewer="claude" local create_pr=false local max="5" + local timeout_minutes local choice value code + timeout_minutes="$(devloop_timeout_minutes)" while true; do ui_header "Run setup" "$spec" @@ -973,6 +1114,7 @@ interactive_run_setup() { "coder" "$(agent_label "$coder")" \ "reviewer" "$(agent_label "$reviewer")" \ "passes" "$max" \ + "timeout" "$timeout_minutes minutes" \ "strict" "$strict" \ "report" "$report_format" \ "pr" "$create_pr" @@ -981,6 +1123,7 @@ interactive_run_setup() { "Change coder" \ "Change reviewer" \ "Change pass limit" \ + "Change timeout" \ "Toggle strict mode" \ "Toggle worktree mode" \ "Toggle PR creation" \ @@ -991,11 +1134,11 @@ interactive_run_setup() { if [ "$create_pr" = true ] && ! ui_confirm "Push the accepted branch and open a PR after acceptance?"; then continue fi - run_header "$spec" "$max" "$report_format" "$strict" "$use_worktree" "$coder" "$reviewer" "$create_pr" - run_devloop "$spec" "$max" "$report_format" "$strict" "$use_worktree" "$coder" "$reviewer" "$create_pr" + run_header "$spec" "$max" "$report_format" "$strict" "$use_worktree" "$coder" "$reviewer" "$create_pr" "$timeout_minutes" + run_devloop "$spec" "$max" "$report_format" "$strict" "$use_worktree" "$coder" "$reviewer" "$create_pr" "$timeout_minutes" code=$? maybe_enter_worktree - return "$code" + return "$(final_exit_code "$code")" ;; "Change coder") value="$(ui_choose "Implementation agent" "Codex" "Claude Code" "Back")" || return 130 @@ -1015,6 +1158,15 @@ interactive_run_setup() { printf '%s\n' "pass limit must be an integer between 1 and 10" >&2 fi ;; + "Change timeout") + value="$(ui_input "Timeout minutes, 1-1440" "$timeout_minutes")" || return 130 + if timeout_minutes="$(normalize_timeout_minutes "$value")"; then + : + else + printf '%s\n' "timeout must be an integer between 1 and 1440 minutes" >&2 + timeout_minutes="$(devloop_timeout_minutes)" + fi + ;; "Toggle strict mode") if [ "$strict" = true ]; then strict=false; else strict=true; fi ;; @@ -1070,6 +1222,100 @@ reports_command() { view_file "$selected" } +status_command() { + if [ "$#" -gt 0 ]; then + printf '%s\n' "usage: devloop status" >&2 + return 2 + fi + local list track count + list="$(mktemp "${TMPDIR:-/tmp}/devloop-status.XXXXXX")" + list_artifact_files ".codex/tracks" > "$list" + if [ ! -s "$list" ]; then + rm -f "$list" + printf '%s\n' "no devloop tracks found" >&2 + return 1 + fi + printf '%-24s %-12s %-7s %-24s %s\n' "slug" "status" "passes" "branch" "worktree" + count=0 + while IFS= read -r track; do + print_track_status "$track" + count=$((count + 1)) + done < "$list" + rm -f "$list" + [ "$count" -gt 0 ] +} + +clean_command() { + local dry_run=true + local force=false + local arg + while [ "$#" -gt 0 ]; do + arg="$1" + shift + case "$arg" in + --dry-run) dry_run=true ;; + --force) force=true; dry_run=false ;; + -h|--help) + printf '%s\n' "usage: devloop clean [--dry-run|--force]" + return 0 + ;; + *) + printf 'unknown option: %s\n' "$arg" >&2 + printf '%s\n' "usage: devloop clean [--dry-run|--force]" >&2 + return 2 + ;; + esac + done + + if [ "$dry_run" = false ] && [ "$force" != true ] && [ "$USE_TUI" = true ] && [ -t 0 ]; then + if ! ui_confirm "Remove eligible Devloop worktrees?"; then + dry_run=true + fi + fi + + local list track seen worktree slug source_repo status removed + list="$(mktemp "${TMPDIR:-/tmp}/devloop-clean.XXXXXX")" + list_artifact_files ".codex/tracks" > "$list" + if [ ! -s "$list" ]; then + rm -f "$list" + printf '%s\n' "no devloop tracks found" >&2 + return 1 + fi + + seen="" + removed=0 + while IFS= read -r track; do + worktree="$(track_value "worktree" "$track")" + slug="$(basename "$track" .md)" + [ -n "$worktree" ] || continue + case "$seen" in + *"|$worktree|"*) continue ;; + esac + seen="$seen|$worktree|" + source_repo="$(track_value "source-repo" "$track")" + status="$(clean_candidate_status "$track" "$worktree" "$source_repo")" + if [ "$status" = "ready" ] || [ "$force" = true ]; then + if [ "$dry_run" = true ]; then + printf 'would remove: %s (%s)\n' "$worktree" "$slug" + else + if remove_devloop_worktree "$source_repo" "$worktree" "$force"; then + printf 'removed: %s (%s)\n' "$worktree" "$slug" + removed=$((removed + 1)) + else + printf 'failed: %s (%s)\n' "$worktree" "$slug" >&2 + fi + fi + else + printf 'skip: %s (%s: %s)\n' "$worktree" "$slug" "$status" + fi + done < "$list" + rm -f "$list" + if [ "$dry_run" = true ]; then + printf '%s\n' "run devloop clean --force to remove eligible worktrees" + fi + if [ "$dry_run" = false ] && [ "$removed" -eq 0 ]; then return 1; fi +} + continue_command() { if [ "$#" -gt 1 ]; then printf '%s\n' "usage: devloop continue [track.md]" >&2 @@ -1153,9 +1399,144 @@ track_value() { sed -nE "s/^- ${key}: //p" "$file" | head -n 1 } +latest_review_file() { + local track="$1" + local dir slug latest + dir="$(cd "$(dirname "$track")/.." >/dev/null 2>&1 && pwd -P)/reviews" + slug="$(basename "$track" .md)" + [ -d "$dir" ] || return 1 + latest="$(find "$dir" -type f -name "$slug-r*.md" | + sed -nE 's/.*-r([0-9]+)\.md$/\1/p' | + LC_ALL=C sort -n | + tail -n 1)" + [ -n "$latest" ] || return 1 + printf '%s/%s-r%s.md\n' "$dir" "$slug" "$latest" +} + +track_run_status() { + local track="$1" + local review verdict verify_status + if ! review="$(latest_review_file "$track")"; then + verify_status="$(track_verify_status "$track")" + if [ -n "$verify_status" ]; then + printf '%s\n' "$verify_status" + else + printf '%s\n' "no-review" + fi + return + fi + verdict="$(parse_verdict "$review")" + case "$verdict" in + ACCEPT) printf '%s\n' "accepted" ;; + REJECT) printf '%s\n' "rejected" ;; + UNCLEAR) printf '%s\n' "unclear" ;; + *) printf '%s\n' "no-verdict" ;; + esac +} + +track_verify_status() { + local track="$1" + awk ' + /^## Pass [0-9]+ - verify[[:space:]]*$/ { inside = 1; latest = ""; next } + inside && /^##[[:space:]]+/ { inside = 0 } + inside && /^- status:[[:space:]]+/ { + latest = $0 + sub(/^- status:[[:space:]]+/, "", latest) + } + END { + if (latest == "FAIL") print "verify-error" + else if (latest == "PASS") print "verified" + } + ' "$track" +} + +track_report_path() { + local track="$1" + local slug dir report + slug="$(basename "$track" .md)" + dir="$(cd "$(dirname "$track")/.." >/dev/null 2>&1 && pwd -P)/reports" + for report in "$dir/$slug.html" "$dir/$slug.md"; do + if [ -f "$report" ]; then + printf '%s\n' "$report" + return + fi + done +} + +print_track_status() { + local track="$1" + local slug status passes branch worktree report next + slug="$(basename "$track" .md)" + status="$(track_run_status "$track")" + passes="$(( $(next_pass_from_track "$track") - 1 ))" + branch="$(track_value "worktree-branch" "$track")" + worktree="$(track_value "worktree" "$track")" + report="$(track_report_path "$track")" + printf '%-24s %-12s %-7s %-24s %s\n' "$slug" "$status" "$passes" "${branch:-unknown}" "${worktree:-unknown}" + if [ -n "$report" ]; then printf ' report: %s\n' "$report"; fi + if [ "$status" = "accepted" ]; then + printf ' next: inspect report\n' + else + next="$(absolute_existing_file "$track" 2>/dev/null || printf '%s' "$track")" + printf ' next: devloop continue %s\n' "$next" + fi +} + +clean_candidate_status() { + local track="$1" + local worktree="$2" + local source_repo="$3" + local status + if [ -z "$worktree" ] || [ ! -d "$worktree" ]; then + printf '%s\n' "missing" + return + fi + if [ -n "$source_repo" ] && [ "$worktree" = "$source_repo" ]; then + printf '%s\n' "source-worktree" + return + fi + status="$(track_run_status "$track")" + if [ "$status" = "accepted" ]; then + printf '%s\n' "accepted" + return + fi + if has_user_dirty_paths "$worktree"; then + printf '%s\n' "dirty" + return + fi + printf '%s\n' "ready" +} + +has_user_dirty_paths() { + local repo="$1" + local file + while IFS= read -r file; do + [ -n "$file" ] || continue + case "$file" in + .codex/*) continue ;; + esac + return 0 + done </dev/null 2>&1; then return 1; fi + if [ "$force" = true ]; then + git -C "$source_repo" worktree remove --force "$worktree" >/dev/null 2>&1 + else + git -C "$source_repo" worktree remove "$worktree" >/dev/null 2>&1 + fi +} + run_from_track() { local track="$1" - local worktree spec max report_format strict coder reviewer create_pr old_pwd code next_pass + local worktree spec max report_format strict coder reviewer create_pr timeout_minutes old_pwd code next_pass track="$(absolute_existing_file "$track")" || { printf 'track not found: %s\n' "$track" >&2 return 2 @@ -1168,6 +1549,7 @@ run_from_track() { coder="$(track_value "coder" "$track")" reviewer="$(track_value "reviewer" "$track")" create_pr="$(track_value "create-pr" "$track")" + timeout_minutes="$(track_value "timeout-minutes" "$track")" if [ -z "$worktree" ]; then worktree="$(cd "$(dirname "$track")/../.." >/dev/null 2>&1 && pwd -P)"; fi if [ -z "$spec" ]; then spec="$(track_value "source-spec" "$track")"; fi @@ -1177,6 +1559,7 @@ run_from_track() { if [ -z "$coder" ]; then coder="codex"; fi if [ -z "$reviewer" ]; then reviewer="claude"; fi if [ -z "$create_pr" ]; then create_pr=false; fi + if ! timeout_minutes="$(normalize_timeout_minutes "$timeout_minutes")"; then timeout_minutes="$(devloop_timeout_minutes)"; fi next_pass="$(next_pass_from_track "$track")" if [ "$next_pass" -gt "$max" ]; then max="$next_pass"; fi @@ -1192,13 +1575,13 @@ run_from_track() { old_pwd="$PWD" cd "$worktree" || return 2 RUN_START_PASS="$next_pass" - run_header "$spec" "$max" "$report_format" "$strict" false "$coder" "$reviewer" "$create_pr" - run_devloop "$spec" "$max" "$report_format" "$strict" false "$coder" "$reviewer" "$create_pr" + run_header "$spec" "$max" "$report_format" "$strict" false "$coder" "$reviewer" "$create_pr" "$timeout_minutes" + run_devloop "$spec" "$max" "$report_format" "$strict" false "$coder" "$reviewer" "$create_pr" "$timeout_minutes" code=$? RUN_START_PASS=1 maybe_enter_worktree cd "$old_pwd" || return "$code" - return "$code" + return "$(final_exit_code "$code")" } next_pass_from_track() { @@ -1247,10 +1630,12 @@ run_command() { local coder="codex" local reviewer="claude" local create_pr=false + local timeout_minutes local spec="" local max_raw="5" local max_set=false local arg value + timeout_minutes="$(devloop_timeout_minutes)" while [ "$#" -gt 0 ]; do arg="$1" @@ -1280,6 +1665,14 @@ run_command() { if [ -z "$value" ]; then printf '%s\n' "reviewer must be Codex or Claude Code" >&2; usage >&2; return 2; fi reviewer="$value" ;; + --timeout-minutes) + if [ "$#" -eq 0 ]; then printf '%s\n' "--timeout-minutes requires a value" >&2; usage >&2; return 2; fi + if ! timeout_minutes="$(normalize_timeout_minutes "$1")"; then + printf '%s\n' "timeout must be an integer between 1 and 1440 minutes" >&2 + return 2 + fi + shift + ;; --html) report_format="html" ;; --markdown|--md) report_format="markdown" ;; --no-strict) strict=false ;; @@ -1320,15 +1713,11 @@ run_command() { if [ "$max" -lt 1 ]; then max=1; fi if [ "$max" -gt 10 ]; then max=10; fi - run_header "$spec" "$max" "$report_format" "$strict" "$use_worktree" "$coder" "$reviewer" "$create_pr" - run_devloop "$spec" "$max" "$report_format" "$strict" "$use_worktree" "$coder" "$reviewer" "$create_pr" + run_header "$spec" "$max" "$report_format" "$strict" "$use_worktree" "$coder" "$reviewer" "$create_pr" "$timeout_minutes" + run_devloop "$spec" "$max" "$report_format" "$strict" "$use_worktree" "$coder" "$reviewer" "$create_pr" "$timeout_minutes" local code=$? maybe_enter_worktree - case "$STATUS" in - accepted) return 0 ;; - stalled|max-turns|unclear) return 1 ;; - *) return "$code" ;; - esac + return "$(final_exit_code "$code")" } run_devloop() { @@ -1340,10 +1729,15 @@ run_devloop() { local coder="$6" local reviewer="$7" local create_pr="$8" + local timeout_minutes="${9:-$DEFAULT_TIMEOUT_MINUTES}" MAX="$max" CODER="$coder" REVIEWER="$reviewer" + RUN_TIMEOUT_MINUTES="$timeout_minutes" + RUN_TIMEOUT_SECONDS=$((timeout_minutes * 60)) + RUN_DEADLINE=$(($(current_epoch) + RUN_TIMEOUT_SECONDS)) + RUN_TIMED_OUT=false STATUS="max-turns" PASSES=0 FINAL_COMMIT="" @@ -1369,8 +1763,9 @@ run_devloop() { parse_criteria "$spec" > "$criteria_file" local criteria_count criteria_count="$(line_count "$criteria_file")" - if [ "$strict" = true ] && [ "$criteria_count" -eq 0 ]; then - printf '%s\n' "strict mode requires ## Acceptance criteria" >&2 + if ! lint_spec_file "$spec" "$spec_text" "$criteria_count" "$strict"; then + STATUS="spec-error" + printf '%s\n' "$SPEC_LINT_ERROR" >&2 rm -f "$criteria_file" return 2 fi @@ -1386,10 +1781,19 @@ run_devloop() { local base base="$(base_branch "$SOURCE_REPO")" + event_step "preflight" "preflight checks" + if preflight_run "$SOURCE_REPO" "$coder" "$reviewer" "$create_pr"; then + event_done "preflight" true "ready" + else + STATUS="preflight-error" + event_done "preflight" false "$PREFLIGHT_ERROR" + printf 'preflight failed: %s\n' "$PREFLIGHT_ERROR" >&2 + rm -f "$criteria_file" + return 2 + fi + event_step "naming" "derive branch name with $(agent_label "$coder")" - local naming_tmp naming_log naming_error - naming_tmp="" - naming_log="" + local naming_error naming_error="" if ! resolve_work_item "$coder" "$SOURCE_REPO" "$spec" "$spec_text"; then naming_error="$WORK_ITEM_ERROR" @@ -1444,7 +1848,7 @@ run_devloop() { local coder_session=".codex/sessions/$slug-coder-$coder.id" local reviewer_session=".codex/sessions/$slug-reviewer-$reviewer.id" - init_track "$repo/$TRACK" "$run_spec" "$spec" "$PWD" "$SOURCE_REPO" "$repo" "$base" "$source_branch" "$run_branch" "$max" "$report_format" "$strict" "$coder" "$reviewer" "$WORK_TYPE" "$WORK_BREAKING" "$create_pr" + init_track "$repo/$TRACK" "$run_spec" "$spec" "$PWD" "$SOURCE_REPO" "$repo" "$base" "$source_branch" "$run_branch" "$max" "$report_format" "$strict" "$coder" "$reviewer" "$WORK_TYPE" "$WORK_BREAKING" "$create_pr" "$timeout_minutes" local pass prior_hash prior_hash="" @@ -1452,6 +1856,10 @@ run_devloop() { prior_hash="$(findings_hash "$repo/.codex/reviews/$slug-r$((start_pass - 1)).md")" fi for pass in $(seq "$start_pass" "$max"); do + if run_deadline_reached; then + STATUS="timeout" + break + fi PASSES="$pass" local coder_log=".codex/logs/$slug-r$pass-coder.log" local coder_id="coder-$pass" @@ -1459,7 +1867,7 @@ run_devloop() { if run_agent "$coder" "$repo" "$repo/$coder_session" "$repo/$coder_log" "$(coder_prompt "$run_spec" "$TRACK" "$pass" "$strict" ".codex/reviews/$slug-r$((pass - 1)).md" "$criteria_file")" "$coder_id"; then event_done "$coder_id" true "completed" else - STATUS="coder-error" + if [ "$RUN_TIMED_OUT" = true ]; then STATUS="timeout"; else STATUS="coder-error"; fi event_done "$coder_id" false "failed" break fi @@ -1487,6 +1895,29 @@ run_devloop() { break fi + local verify_id="verify-$pass" + if verify_hook_configured "$repo" "$SOURCE_REPO"; then + event_step "$verify_id" "pass $pass/$max verification" + if run_verify_hook "$repo" "$SOURCE_REPO" "$slug" "$pass"; then + event_done "$verify_id" true "$VERIFY_DETAIL" + else + event_done "$verify_id" false "$VERIFY_DETAIL" + if [ "$RUN_TIMED_OUT" = true ]; then + STATUS="timeout" + break + fi + if [ "$strict" = true ]; then + STATUS="verify-error" + break + fi + fi + fi + + if run_deadline_reached; then + STATUS="timeout" + break + fi + local review=".codex/reviews/$slug-r$pass.md" local reviewer_log=".codex/logs/$slug-r$pass-reviewer.log" local reviewer_id="reviewer-$pass" @@ -1494,7 +1925,7 @@ run_devloop() { if run_agent "$reviewer" "$repo" "$repo/$reviewer_session" "$repo/$reviewer_log" "$(review_prompt "$coder" "$run_spec" "$TRACK" "$base" "$pass" "$review" "$slug" "$max" "$criteria_file" "$strict")" "$reviewer_id"; then event_done "$reviewer_id" true "completed" else - STATUS="reviewer-error" + if [ "$RUN_TIMED_OUT" = true ]; then STATUS="timeout"; else STATUS="reviewer-error"; fi event_done "$reviewer_id" false "failed" break fi @@ -1595,6 +2026,193 @@ event_log() { fi } +current_epoch() { + date +%s +} + +run_deadline_reached() { + [ "${RUN_DEADLINE:-0}" -gt 0 ] || return 1 + [ "$(current_epoch)" -ge "$RUN_DEADLINE" ] +} + +timeout_message() { + printf 'devloop run timed out after %s minutes\n' "$RUN_TIMEOUT_MINUTES" +} + +final_exit_code() { + local fallback="${1:-0}" + case "$STATUS" in + accepted) printf '%s\n' "0" ;; + "") printf '%s\n' "$fallback" ;; + *) + if [ "$fallback" -ne 0 ]; then + printf '%s\n' "$fallback" + else + printf '%s\n' "1" + fi + ;; + esac +} + +lint_spec_file() { + local spec="$1" + local spec_text="$2" + local criteria_count="$3" + local strict="$4" + local title type + SPEC_LINT_ERROR="" + title="$(printf '%s\n' "$spec_text" | sed -nE 's/^#[[:space:]]+(.+)/\1/p' | head -n 1)" + if [ -z "$(clean_report_text "$title")" ]; then + SPEC_LINT_ERROR="spec requires an H1 title: $spec" + return 1 + fi + if printf '%s\n' "$spec_text" | sed -n '1p' | grep -Eq '^---[[:space:]]*$'; then + type="$(frontmatter_value type "$spec_text")" + case "$type" in + feat|fix|chore|feat!|fix!|chore!) ;; + *) + SPEC_LINT_ERROR="spec frontmatter type must be feat, fix, chore, or their ! forms" + return 1 + ;; + esac + fi + if [ "$strict" = true ] && [ "$criteria_count" -eq 0 ]; then + SPEC_LINT_ERROR="strict mode requires ## Acceptance criteria" + return 1 + fi +} + +preflight_run() { + local repo="$1" + local coder="$2" + local reviewer="$3" + local create_pr="$4" + PREFLIGHT_ERROR="" + if ! command -v git >/dev/null 2>&1; then + PREFLIGHT_ERROR="missing command: git" + return 1 + fi + if ! git -C "$repo" var GIT_AUTHOR_IDENT >/dev/null 2>&1; then + PREFLIGHT_ERROR="git author identity is not configured" + return 1 + fi + if ! preflight_agent "$coder"; then return 1; fi + if ! preflight_agent "$reviewer"; then return 1; fi + if ! preflight_agent_skills "$coder"; then return 1; fi + if ! preflight_agent_skills "$reviewer"; then return 1; fi + if [ "$create_pr" = true ] && ! command -v gh >/dev/null 2>&1; then + PREFLIGHT_ERROR="missing command: gh" + return 1 + fi +} + +preflight_agent() { + local agent="$1" + local command_name + command_name="$(agent_command "$agent")" + if [ -z "$command_name" ]; then + PREFLIGHT_ERROR="unknown agent: $agent" + return 1 + fi + if ! command -v "$command_name" >/dev/null 2>&1; then + PREFLIGHT_ERROR="missing command: $command_name" + return 1 + fi +} + +agent_command() { + case "$1" in + codex) printf '%s\n' "codex" ;; + claude) printf '%s\n' "claude" ;; + esac +} + +agent_skill_dir() { + [ -n "${HOME:-}" ] || return 1 + case "$1" in + codex) printf '%s\n' "${HOME%/}/.agents/skills" ;; + claude) printf '%s\n' "${HOME%/}/.claude/skills" ;; + esac +} + +preflight_agent_skills() { + local agent="$1" + local dir + local skill + local source dest bundled installed + dir="$(agent_skill_dir "$agent")" + [ -n "$dir" ] || return 0 + for skill in devloop-spec devloop-review; do + source="$ROOT_DIR/skills/$skill" + dest="$dir/$skill" + if [ ! -f "$dest/SKILL.md" ]; then + PREFLIGHT_ERROR="missing installed skill: $dest/SKILL.md" + return 1 + fi + bundled="$(devloop_skill_tree_checksum "$source")" || { + PREFLIGHT_ERROR="failed to checksum bundled skill: $source" + return 1 + } + installed="$(devloop_skill_tree_checksum "$dest")" || { + PREFLIGHT_ERROR="failed to checksum installed skill: $dest" + return 1 + } + if [ "$bundled" != "$installed" ]; then + PREFLIGHT_ERROR="stale installed skill: $dest" + return 1 + fi + done +} + +verify_hook_path() { + local repo="$1" + local source_repo="$2" + if [ -n "$source_repo" ] && [ -x "$source_repo/.devloop/verify" ]; then + printf '%s\n' "$source_repo/.devloop/verify" + return + fi + if [ -x "$repo/.devloop/verify" ]; then + printf '%s\n' "$repo/.devloop/verify" + fi +} + +verify_hook_configured() { + [ -n "$(verify_hook_path "$1" "$2")" ] +} + +run_verify_hook() { + local repo="$1" + local source_repo="$2" + local slug="$3" + local pass="$4" + local hook + hook="$(verify_hook_path "$repo" "$source_repo")" + VERIFY_LOG=".codex/logs/$slug-r$pass-verify.log" + VERIFY_DETAIL="skipped" + [ -n "$hook" ] || return 0 + run_with_prompt "$repo" "$repo/$VERIFY_LOG" "verify-$pass" "" "$hook" "$pass" "$slug" + if [ "$RUN_CODE" -eq 0 ]; then + VERIFY_DETAIL="$VERIFY_LOG" + append_verify_track "$repo/$TRACK" "$pass" "PASS" "$VERIFY_LOG" + return 0 + fi + VERIFY_DETAIL="$VERIFY_LOG failed" + append_verify_track "$repo/$TRACK" "$pass" "FAIL" "$VERIFY_LOG" + return 1 +} + +append_verify_track() { + local track="$1" + local pass="$2" + local status="$3" + local log="$4" + { + printf '\n## Pass %s - verify\n' "$pass" + printf -- '- status: %s\n' "$status" + printf -- '- log: %s\n' "$log" + } >> "$track" +} + normalize_agent() { printf '%s' "${1:-}" | tr '[:upper:]' '[:lower:]' | sed -E 's/[[:space:]_-]+//g' | awk '$0=="codex"{print "codex"} $0=="claude" || $0=="claudecode"{print "claude"}' } @@ -1848,12 +2466,15 @@ validate_work_item() { parse_work_item() { local output="$1" - local candidates reversed candidate type slug breaking + local candidates candidate type slug breaking index + local items=() candidates="$(mktemp "${TMPDIR:-/tmp}/devloop-json.XXXXXX")" - reversed="$(mktemp "${TMPDIR:-/tmp}/devloop-json-rev.XXXXXX")" printf '%s\n' "$output" | grep -Eo '\{[^{}]*\}' > "$candidates" || true - awk '{ lines[NR] = $0 } END { for (i = NR; i >= 1; i--) print lines[i] }' "$candidates" > "$reversed" while IFS= read -r candidate; do + items+=("$candidate") + done < "$candidates" + for ((index = ${#items[@]} - 1; index >= 0; index--)); do + candidate="${items[index]}" type="$(printf '%s\n' "$candidate" | sed -nE 's/.*"type"[[:space:]]*:[[:space:]]*"([^"]+)".*/\1/p')" slug="$(printf '%s\n' "$candidate" | sed -nE 's/.*"slug"[[:space:]]*:[[:space:]]*"([^"]+)".*/\1/p')" breaking="$(printf '%s\n' "$candidate" | sed -nE 's/.*"breaking"[[:space:]]*:[[:space:]]*(true|false).*/\1/p')" @@ -1862,12 +2483,12 @@ parse_work_item() { WORK_TYPE="$type" WORK_SLUG="$(slugify "$slug")" WORK_BREAKING="$breaking" - rm -f "$candidates" "$reversed" + rm -f "$candidates" return 0 fi fi - done < "$reversed" - rm -f "$candidates" "$reversed" + done + rm -f "$candidates" if [ -z "$WORK_ITEM_ERROR" ]; then WORK_ITEM_ERROR="naming output must include JSON"; fi return 1 } @@ -2003,9 +2624,12 @@ init_track() { local type="${15}" local breaking="${16}" local create_pr="${17}" + local timeout_minutes="${18}" + local track_name if [ -f "$file" ]; then return; fi + track_name="$(basename "$file" .md)" cat > "$file" < "$prompt_file" rm -f "$code_file" - if [ "$USE_TUI" = true ] && [ -t 2 ]; then - ( - cd "$cwd" >/dev/null 2>&1 && "$@" < "$prompt_file" > "$stdout_file" 2> "$stderr_file" - printf '%s\n' "$?" > "$code_file" - ) & - pid=$! - ui_spinner_wait "$pid" "$code_file" "${UI_STEP_TITLE:-working}" - wait "$pid" >/dev/null 2>&1 || true - if [ -f "$code_file" ]; then - RUN_CODE="$(cat "$code_file")" - else - RUN_CODE=1 - fi + ( + cd "$cwd" >/dev/null 2>&1 && "$@" < "$prompt_file" > "$stdout_file" 2> "$stderr_file" + printf '%s\n' "$?" > "$code_file" + ) & + pid=$! + ui_spinner_wait "$pid" "$code_file" "${UI_STEP_TITLE:-working}" "$stderr_file" + wait "$pid" >/dev/null 2>&1 || true + if [ -f "$code_file" ]; then + RUN_CODE="$(cat "$code_file")" else - (cd "$cwd" >/dev/null 2>&1 && "$@" < "$prompt_file" > "$stdout_file" 2> "$stderr_file") - RUN_CODE=$? + RUN_CODE=1 fi case "$RUN_CODE" in ''|*[!0-9]*) RUN_CODE=1 ;; @@ -2159,6 +2779,21 @@ run_with_prompt() { rm -f "$prompt_file" "$stdout_file" "$stderr_file" "$output_file" "$code_file" } +terminate_pid_tree() { + local pid="$1" + # This catches the shell and direct children portably on macOS/Linux. + # Grandchildren may survive if the agent spawns its own process tree. + if command -v pkill >/dev/null 2>&1; then + pkill -TERM -P "$pid" >/dev/null 2>&1 || true + fi + kill -TERM "$pid" >/dev/null 2>&1 || true + sleep 1 + if command -v pkill >/dev/null 2>&1; then + pkill -KILL -P "$pid" >/dev/null 2>&1 || true + fi + kill -KILL "$pid" >/dev/null 2>&1 || true +} + new_uuid() { if command -v uuidgen >/dev/null 2>&1; then uuidgen | tr '[:upper:]' '[:lower:]' @@ -2321,7 +2956,7 @@ Rules: - The verdict line must appear verbatim. - ACCEPT requires every acceptance criterion PASS with concrete implementation evidence and concrete test evidence.$strict_rule - ACCEPT requires a complete Engineering quality matrix. Reject if any engineering quality row is FAIL. -- Flag a silent decision when the diff makes a tradeoff, default choice, compatibility choice, migration choice, or risk acceptance that is not recorded in the spec or track. +- Flag a silent decision when the diff makes a tradeoff, default choice, migration choice, or risk acceptance that is not recorded in the spec or track. - Flag scope drift when the diff changes behavior, public API, dependencies, files, or architecture outside the acceptance criteria, or includes a broad refactor not needed for the spec. - Flag a missing test when behavior changed without targeted test evidence, even if the full suite passed. - Use UNCLEAR only when spec ambiguity prevents a defensible ACCEPT or REJECT, and put the exact question in Notes. @@ -2465,7 +3100,7 @@ has_passing_matrix() { local i=1 grep -Eq '^##[[:space:]]+Acceptance matrix[[:space:]]*$' "$file" || return 1 while [ "$i" -le "$count" ]; do - if ! grep -Eiq "^\|[[:space:]]*AC$i[[:space:]]*\|[[:space:]]*PASS[[:space:]]*\|" "$file"; then + if ! grep -Eiq "^\|[[:space:]]*AC${i}[[:space:]]*\|[[:space:]]*PASS[[:space:]]*\|" "$file"; then return 1 fi i=$((i + 1)) @@ -2484,7 +3119,7 @@ has_passing_quality_matrix() { "Simplicity" \ "Security" \ "Operational safety"; do - if ! grep -Eiq "^\|[[:space:]]*$area[[:space:]]*\|[[:space:]]*(PASS|N/A)[[:space:]]*\|" "$file"; then + if ! grep -Eiq "^\|[[:space:]]*${area}[[:space:]]*\|[[:space:]]*(PASS|N/A)[[:space:]]*\|" "$file"; then return 1 fi done @@ -2526,13 +3161,11 @@ create_pull_request() { local base="$3" local remote="origin" local out - out="$(git -C "$repo" push -u "$remote" "$branch" 2>&1)" - if [ "$?" -ne 0 ]; then + if ! out="$(git -C "$repo" push -u "$remote" "$branch" 2>&1)"; then PULL_REQUEST_ERROR="$(printf '%s\n' "$out" | sed '/^[[:space:]]*$/d' | tail -n 1)" return 1 fi - out="$(cd "$repo" >/dev/null 2>&1 && gh pr create --fill --base "$base" --head "$branch" 2>&1)" - if [ "$?" -ne 0 ]; then + if ! out="$(cd "$repo" >/dev/null 2>&1 && gh pr create --fill --base "$base" --head "$branch" 2>&1)"; then PULL_REQUEST_ERROR="pushed $remote/$branch, but PR creation failed: $(printf '%s\n' "$out" | sed '/^[[:space:]]*$/d' | tail -n 1)" return 1 fi @@ -2807,7 +3440,7 @@ spec_command() { skill-path) printf '%s\n' "$skill"; return ;; esac - local context today prompt spec_dir cmd_args=() + local context today prompt spec_dir context="$(resolve_spec_context "${context_items[@]}")" today="$(date +%F)" spec_dir="$(devloop_spec_dir)" diff --git a/tests/devloop_test.sh b/tests/devloop_test.sh index 92d64b8..796dcae 100755 --- a/tests/devloop_test.sh +++ b/tests/devloop_test.sh @@ -1,7 +1,7 @@ #!/usr/bin/env bash set -euo pipefail -ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) +REPO_ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) fail() { echo "not ok - $*" >&2 @@ -26,37 +26,40 @@ equals() { [[ "$actual" == "$expected" ]] || fail "$label expected [$expected], got [$actual]" } -bash -n "$ROOT/devloop" "$ROOT/install.sh" "$ROOT/skill_helpers.sh" "$ROOT/release.sh" +bash -n "$REPO_ROOT/devloop" "$REPO_ROOT/install.sh" "$REPO_ROOT/skill_helpers.sh" "$REPO_ROOT/release.sh" ok "bash syntax" DEVLOOP_LIB=1 -source "$ROOT/devloop" +source "$REPO_ROOT/devloop" unset DEVLOOP_LIB equals "${CODEX_MODEL_ARGS[*]}" "-m gpt-5.5" "codex model args" equals "${CLAUDE_MODEL_ARGS[*]}" "--model claude-opus-4-8" "claude model args" -version="$(sed -n '1p' "$ROOT/VERSION")" -equals "$("$ROOT/devloop" --version)" "devloop $version" "version output" -equals "$("$ROOT/devloop" -V)" "devloop $version" "short version output" -equals "$("$ROOT/devloop" --plain --version)" "devloop $version" "version after global flag" +version="$(sed -n '1p' "$REPO_ROOT/VERSION")" +equals "$("$REPO_ROOT/devloop" --version)" "devloop $version" "version output" +equals "$("$REPO_ROOT/devloop" -V)" "devloop $version" "short version output" +equals "$("$REPO_ROOT/devloop" --plain --version)" "devloop $version" "version after global flag" -help="$("$ROOT/devloop" --help)" +help="$("$REPO_ROOT/devloop" --help)" contains "$help" "Common commands:" "help" contains "$help" "devloop doctor" "help" contains "$help" "devloop reports" "help" +contains "$help" "devloop status" "help" +contains "$help" "devloop clean" "help" contains "$help" "--create-pr" "help" contains "$help" "--no-shell" "help" contains "$help" "--enter-worktree" "help" contains "$help" "--version" "help" contains "$help" "v$version" "help" +contains "$help" "--timeout-minutes" "help" ok "help output" -skill_path="$("$ROOT/devloop" spec --skill-path)" -[[ "$skill_path" == "$ROOT/skills/devloop-spec/SKILL.md" ]] || fail "unexpected skill path: $skill_path" -contains "$("$ROOT/devloop" spec --print-skill)" "name: devloop-spec" "spec skill" +skill_path="$("$REPO_ROOT/devloop" spec --skill-path)" +[[ "$skill_path" == "$REPO_ROOT/skills/devloop-spec/SKILL.md" ]] || fail "unexpected skill path: $skill_path" +contains "$("$REPO_ROOT/devloop" spec --print-skill)" "name: devloop-spec" "spec skill" ok "spec skill path" -for skill in "$ROOT"/skills/*/SKILL.md; do +for skill in "$REPO_ROOT"/skills/*/SKILL.md; do name="$(sed -n 's/^name: *//p' "$skill" | head -n 1)" description="$(sed -n 's/^description: *//p' "$skill" | head -n 1)" dirname="$(basename "$(dirname "$skill")")" @@ -123,7 +126,7 @@ if has_passing_quality_matrix "$work/review-quality-fail.md"; then fail "has_pas review_prompt_text="$(review_prompt codex "$criteria_file" ".codex/tracks/test.md" main 1 ".codex/reviews/test-r1.md" test 5 "$criteria_file" true)" contains "$review_prompt_text" "Skill: use the installed devloop-review skill." "review prompt" -contains "$review_prompt_text" "Bundled skill path, for fallback only: $ROOT/skills/devloop-review/SKILL.md" "review prompt" +contains "$review_prompt_text" "Bundled skill path, for fallback only: $REPO_ROOT/skills/devloop-review/SKILL.md" "review prompt" contains "$review_prompt_text" "Engineering quality matrix" "review prompt" findings_a="$work/findings-a.md" @@ -196,6 +199,20 @@ equals "$(track_value max "$branch_repo/.codex/tracks/chat-retry.md")" "3" "trac touch "$branch_repo/.codex/reviews/chat-retry-r1.md" "$branch_repo/.codex/reviews/chat-retry-r3.md" equals "$(next_pass_from_track "$branch_repo/.codex/tracks/chat-retry.md")" "4" "track next pass" +clean_status_repo="$work/clean-status-repo" +git init -q "$clean_status_repo" +git -C "$clean_status_repo" config user.email devloop-test@example.com +git -C "$clean_status_repo" config user.name "devloop test" +printf '%s\n' ".codex/" > "$clean_status_repo/.gitignore" +printf '%s\n' "# Clean status" > "$clean_status_repo/README.md" +git -C "$clean_status_repo" add .gitignore README.md +git -C "$clean_status_repo" commit -q -m init +mkdir -p "$clean_status_repo/.codex/tracks" +clean_status_track="$clean_status_repo/.codex/tracks/clean-status.md" +printf '%s\n' "# Track" > "$clean_status_track" +if has_user_dirty_paths "$clean_status_repo"; then fail "clean worktree reported dirty"; fi +equals "$(clean_candidate_status "$clean_status_track" "$clean_status_repo" "$work/source-repo")" "ready" "clean candidate ready" + spec_output=$'preface\n---\nstatus: draft\n---\n\n# Generated' equals "$(extract_generated_spec "$spec_output")" $'---\nstatus: draft\n---\n\n# Generated' "extract_generated_spec" @@ -203,20 +220,20 @@ config_repo="$work/config-repo" config_home="$work/config-home" mkdir -p "$config_repo/.specs" "$config_repo/.devloop/specs" "$config_home" config_repo_real="$(cd "$config_repo" && pwd)" -printf '%s\n' "# Legacy" > "$config_repo/.specs/legacy.md" +printf '%s\n' "# Default" > "$config_repo/.specs/default.md" printf '%s\n' "# Devloop" > "$config_repo/.devloop/specs/devloop.md" config_specs="$(cd "$config_repo" && HOME="$config_home" list_spec_files)" -contains "$config_specs" ".specs/legacy.md" "default spec search" -contains "$config_specs" ".devloop/specs/devloop.md" "default spec search" -equals "$(cd "$config_repo" && HOME="$config_home" spec_search_label)" ".specs, .devloop/specs" "spec search label" +contains "$config_specs" ".specs/default.md" "default spec search" +if printf '%s\n' "$config_specs" | grep -Fq ".devloop/specs/devloop.md"; then fail "default spec search included .devloop/specs"; fi +equals "$(cd "$config_repo" && HOME="$config_home" spec_search_label)" ".specs" "spec search label" equals "$(cd "$config_repo" && HOME="$config_home" write_config_spec_dir "custom-specs")" "custom-specs" "write config spec dir" equals "$(cd "$config_repo" && HOME="$config_home" devloop_spec_dir)" "custom-specs" "configured spec dir" equals "$(cd "$config_repo" && HOME="$config_home" configured_spec_dir)" "custom-specs" "custom spec dir" equals "$(cd "$config_repo" && HOME="$config_home" configured_spec_dir_scope)" "local" "custom spec dir scope" [[ -d "$config_repo/custom-specs" ]] || fail "configured spec dir was not created" configured_specs="$(cd "$config_repo" && HOME="$config_home" list_spec_files)" -contains "$configured_specs" ".specs/legacy.md" "configured spec search includes legacy dir" -contains "$configured_specs" ".devloop/specs/devloop.md" "configured spec search includes devloop dir" +contains "$configured_specs" ".specs/default.md" "configured spec search includes default dir" +if printf '%s\n' "$configured_specs" | grep -Fq ".devloop/specs/devloop.md"; then fail "configured spec search included .devloop/specs"; fi equals "$(cd "$config_repo" && HOME="$config_home" generated_spec_path "$spec_output" "" "2026-05-29" false)" "$config_repo_real/custom-specs/2026-05-29-generated.md" "configured generated spec path" if (cd "$config_repo" && HOME="$config_home" write_config_spec_dir "../bad") >/dev/null 2>&1; then fail "write_config_spec_dir accepted path traversal"; fi @@ -228,14 +245,14 @@ equals "$(cd "$config_repo" && HOME="$config_home" configured_spec_dir)" "$absol printf '%s\n' "# Shared" > "$absolute_specs/shared.md" absolute_configured_specs="$(cd "$config_repo" && HOME="$config_home" list_spec_files)" contains "$absolute_configured_specs" "$absolute_specs/shared.md" "configured spec search includes absolute dir" -contains "$absolute_configured_specs" ".specs/legacy.md" "absolute spec search includes legacy dir" +contains "$absolute_configured_specs" ".specs/default.md" "absolute spec search includes default dir" equals "$(cd "$config_repo" && HOME="$config_home" generated_spec_path "$spec_output" "" "2026-05-29" false)" "$absolute_specs/2026-05-29-generated.md" "absolute generated spec path" equals "$(spec_dir_status "$absolute_specs")" "exists" "spec dir status exists" equals "$(spec_dir_status "$work/missing-specs")" "missing" "spec dir status missing" (cd "$config_repo" && HOME="$config_home" remove_config_spec_dir local) if (cd "$config_repo" && HOME="$config_home" configured_spec_dir) >/dev/null 2>&1; then fail "custom spec dir was not removed"; fi equals "$(cd "$config_repo" && HOME="$config_home" devloop_spec_dir)" ".specs" "removed custom spec dir falls back" -equals "$(cd "$config_repo" && HOME="$config_home" spec_search_label)" ".specs, .devloop/specs" "removed custom spec search label" +equals "$(cd "$config_repo" && HOME="$config_home" spec_search_label)" ".specs" "removed custom spec search label" global_repo="$work/global-repo" global_home="$work/global-home" @@ -256,8 +273,9 @@ equals "$(cd "$global_repo" && HOME="$global_home" devloop_config_value coder)" tilde_repo="$work/tilde-repo" tilde_home="$work/home" +tilde_input="~"/shared-specs mkdir -p "$tilde_repo" "$tilde_home" -equals "$(cd "$tilde_repo" && HOME="$tilde_home" write_config_spec_dir "~/shared-specs")" "$tilde_home/shared-specs" "tilde input expands when saved" +equals "$(cd "$tilde_repo" && HOME="$tilde_home" write_config_spec_dir "$tilde_input")" "$tilde_home/shared-specs" "tilde input expands when saved" equals "$(cat "$tilde_repo/.devloop/config")" "spec_dir=$tilde_home/shared-specs" "tilde input saved as absolute path" raw_tilde_repo="$work/raw-tilde-repo" @@ -265,6 +283,36 @@ mkdir -p "$raw_tilde_repo/.devloop" printf '%s\n' "spec_dir=~/raw-specs" > "$raw_tilde_repo/.devloop/config" equals "$(cd "$raw_tilde_repo" && devloop_spec_dir)" ".specs" "raw tilde config falls back" +equals "$(normalize_timeout_minutes 1)" "1" "timeout lower bound" +equals "$(normalize_timeout_minutes 30)" "30" "timeout normalize" +equals "$(normalize_timeout_minutes 1440)" "1440" "timeout upper bound" +if normalize_timeout_minutes 0 >/dev/null 2>&1; then fail "timeout accepted zero"; fi +if normalize_timeout_minutes 1441 >/dev/null 2>&1; then fail "timeout accepted above upper bound"; fi +if normalize_timeout_minutes nope >/dev/null 2>&1; then fail "timeout accepted non-numeric"; fi +equals "$(cd "$config_repo" && HOME="$config_home" devloop_timeout_minutes)" "30" "default timeout" +equals "$(cd "$config_repo" && HOME="$config_home" write_config_timeout_minutes 45)" "45" "write timeout" +equals "$(cd "$config_repo" && HOME="$config_home" devloop_timeout_minutes)" "45" "configured timeout" +(cd "$config_repo" && HOME="$config_home" remove_config_timeout_minutes local) +equals "$(cd "$config_repo" && HOME="$config_home" devloop_timeout_minutes)" "30" "removed timeout falls back" + +lint_spec_text=$'---\ntype: feat\n---\n# Title\n\n## Acceptance criteria\n1. Thing' +lint_spec_file "$criteria_file" "$lint_spec_text" 1 true || fail "lint_spec_file rejected valid spec" +bad_lint_spec=$'---\ntype: invalid\n---\n# Title\n\n## Acceptance criteria\n1. Thing' +if lint_spec_file "$criteria_file" "$bad_lint_spec" 1 true >/dev/null 2>&1; then fail "lint_spec_file accepted invalid type"; fi +if lint_spec_file "$criteria_file" "# Title" 0 true >/dev/null 2>&1; then fail "lint_spec_file accepted missing strict criteria"; fi +if lint_spec_file "$criteria_file" "## Missing H1" 0 false >/dev/null 2>&1; then fail "lint_spec_file accepted missing H1"; fi + +STATUS="accepted" +equals "$(final_exit_code 0)" "0" "final exit accepted" +STATUS="timeout" +equals "$(final_exit_code 0)" "1" "final exit timeout" +STATUS="stalled" +equals "$(final_exit_code 0)" "1" "final exit stalled" +STATUS="preflight-error" +equals "$(final_exit_code 2)" "2" "final exit preserves early error" +STATUS="" +equals "$(final_exit_code 2)" "2" "final exit blank preserves fallback" + session_output=$'unrelated 11111111-1111-4111-8111-111111111111\nTo continue this session, run codex exec resume 22222222-2222-4222-8222-222222222222' equals "$(extract_session_id "$session_output")" "22222222-2222-4222-8222-222222222222" "extract_session_id uses session marker" @@ -298,7 +346,7 @@ ok "pure helpers" ( DEVLOOP_RELEASE_LIB=1 - source "$ROOT/release.sh" + source "$REPO_ROOT/release.sh" release_version_valid "0.1.0" || fail "release version rejected valid patch" release_version_valid "1.2.3-alpha.1+build.7" || fail "release version rejected valid prerelease" if release_version_valid "01.2.3"; then fail "release version accepted leading zero"; fi @@ -316,8 +364,8 @@ ok "release helpers" bin_dir="$work/bin" install_home="$work/install-home" -DEVLOOP_BIN_DIR="$bin_dir" HOME="$install_home" "$ROOT/install.sh" >/tmp/devloop-install-test.out -[[ -x "$ROOT/devloop" ]] || fail "devloop is not executable" +DEVLOOP_BIN_DIR="$bin_dir" HOME="$install_home" "$REPO_ROOT/install.sh" >/tmp/devloop-install-test.out +[[ -x "$REPO_ROOT/devloop" ]] || fail "devloop is not executable" [[ -L "$bin_dir/devloop" ]] || fail "installer did not create symlink" [[ -f "$install_home/.agents/skills/devloop-spec/SKILL.md" ]] || fail "installer did not install Codex spec skill" [[ -f "$install_home/.agents/skills/devloop-spec/references/spec-template.md" ]] || fail "installer did not install Codex spec template reference" @@ -331,11 +379,11 @@ contains "$(cat /tmp/devloop-help-test.out)" "Spec-driven code and review loop." ok "installer" printf '%s\n' "user edit" >> "$install_home/.agents/skills/devloop-review/SKILL.md" -DEVLOOP_BIN_DIR="$bin_dir" HOME="$install_home" "$ROOT/install.sh" >/tmp/devloop-install-skip.out 2>&1 +DEVLOOP_BIN_DIR="$bin_dir" HOME="$install_home" "$REPO_ROOT/install.sh" >/tmp/devloop-install-skip.out 2>&1 contains "$(cat /tmp/devloop-install-skip.out)" "skipping modified skill" "installer modified skill guard" contains "$(cat /tmp/devloop-install-skip.out)" "try: devloop doctor" "installer guidance after skill skip" contains "$(cat "$install_home/.agents/skills/devloop-review/SKILL.md")" "user edit" "installer modified skill preserved" -DEVLOOP_FORCE=1 DEVLOOP_BIN_DIR="$bin_dir" HOME="$install_home" "$ROOT/install.sh" >/tmp/devloop-install-force.out +DEVLOOP_FORCE=1 DEVLOOP_BIN_DIR="$bin_dir" HOME="$install_home" "$REPO_ROOT/install.sh" >/tmp/devloop-install-force.out if grep -q "user edit" "$install_home/.agents/skills/devloop-review/SKILL.md"; then fail "installer force did not restore skill"; fi ok "installer skill updates" @@ -367,10 +415,263 @@ repo_specs="$work/repo-specs" printf 'spec_dir=%s\n' "$repo_specs" > "$repo/.devloop/config" ( cd "$repo" - "$ROOT/devloop" spec --agent "$agent" "Keep devloop as Bash." >/tmp/devloop-spec-test.out + "$REPO_ROOT/devloop" spec --agent "$agent" "Keep devloop as Bash." >/tmp/devloop-spec-test.out ) contains "$(cat /tmp/devloop-spec-test.out)" "spec:" "spec command" [[ -f "$repo_specs/$(date +%F)-shell-migration-spec.md" ]] || fail "spec command did not write dated spec under absolute configured dir" contains "$(cat /tmp/devloop-spec-agent-prompt.txt)" "Keep devloop as Bash." "spec prompt" contains "$(cat /tmp/devloop-spec-agent-prompt.txt)" "Output path: choose a $repo_specs/" "spec prompt configured output" ok "spec generation" + +cat > "$fake_bin/codex" <<'AGENT' +#!/usr/bin/env bash +set -euo pipefail +prompt="$(cat)" +if printf '%s\n' "$prompt" | grep -q "Work item naming task"; then + printf '%s\n' '{"type":"feat","slug":"fake-loop","breaking":false}' + exit 0 +fi +pass="$(printf '%s\n' "$prompt" | sed -nE 's/^Pass: ([0-9]+).*/\1/p' | head -n 1)" +track="$(printf '%s\n' "$prompt" | sed -nE 's/^Track: (.+)$/\1/p' | head -n 1)" +mode="${DEVLOOP_FAKE_MODE:-accept}" +case "$mode" in + no-changes) ;; + *) printf 'pass %s\n' "${pass:-1}" >> result.txt ;; +esac +if [ -n "$track" ]; then + { + printf '\n## fake coder pass %s\n' "${pass:-1}" + printf -- '- mode: %s\n' "$mode" + } >> "$track" +fi +printf '%s\n' "To continue this session, run codex exec resume 11111111-1111-4111-8111-111111111111" +AGENT + +cat > "$fake_bin/claude" <<'AGENT' +#!/usr/bin/env bash +set -euo pipefail +prompt="$(cat)" +if printf '%s\n' "$prompt" | grep -q "learning-oriented post-mortem"; then + report="$(printf '%s\n' "$prompt" | sed -nE 's/.*Write the report to ([^ ]+) .*/\1/p' | head -n 1)" + if [ -z "$report" ]; then report=".codex/reports/fake.md"; fi + mkdir -p "$(dirname "$report")" + printf '%s\n' "# Fake report" "Result: ${DEVLOOP_FAKE_MODE:-accept}" > "$report" + exit 0 +fi +output="$(printf '%s\n' "$prompt" | sed -nE 's/^Output path: (.+)$/\1/p' | head -n 1)" +pass="$(printf '%s\n' "$prompt" | sed -nE 's/^Pass: ([0-9]+).*/\1/p' | head -n 1)" +mode="${DEVLOOP_FAKE_MODE:-accept}" +if [ "$mode" = "missing-review" ]; then exit 0; fi +verdict="ACCEPT" +ac_status="PASS" +maintainability="PASS" +findings="None" +fixes="None" +case "$mode" in + reject-then-accept) + if [ "${pass:-1}" = "1" ]; then + verdict="REJECT" + findings="1. [should-fix] result.txt:1 - first pass incomplete. Root cause: fixture. Principle: retry." + fixes="1. Complete the fixture." + fi + ;; + bad-ac) ac_status="FAIL" ;; + bad-quality) maintainability="FAIL" ;; + unclear) verdict="UNCLEAR" ;; +esac +mkdir -p "$(dirname "$output")" +cat > "$output" < "$repo_path/README.md" + git -C "$repo_path" add README.md + git -C "$repo_path" commit -q -m init + cat > "$repo_path/.specs/$slug.md" < "$loop_repo/.devloop/verify" <<'VERIFY' +#!/usr/bin/env bash +set -euo pipefail +printf 'verify pass %s %s\n' "${1:-}" "${2:-}" +VERIFY +chmod +x "$loop_repo/.devloop/verify" +if ! accept_output="$(run_loop "$loop_repo" "e2e-accept" accept 1 2>&1)"; then + printf '%s\n' "$accept_output" >&2 + fail "accept loop failed" +fi +contains "$accept_output" "accepted" "accept loop" +accept_worktree="$(printf '%s\n' "$accept_output" | sed -nE 's/^worktree:[[:space:]]+//p')" +[[ -f "$accept_worktree/result.txt" ]] || fail "accept loop did not write result" +contains "$(cat "$accept_worktree/.codex/logs/e2e-accept-r1-verify.log")" "verify pass" "verify hook" +contains "$(cd "$loop_repo" && HOME="$install_home" PATH="$fake_bin:$bin_dir:$PATH" "$REPO_ROOT/devloop" status)" "e2e-accept" "status command" +contains "$(cd "$loop_repo" && HOME="$install_home" PATH="$fake_bin:$bin_dir:$PATH" "$REPO_ROOT/devloop" clean --dry-run)" "skip:" "clean skips accepted" +ok "e2e accept and verify" + +loop_repo="$work/loop-retry" +make_loop_repo "$loop_repo" "e2e-retry" "E2E Retry" +if ! retry_output="$(run_loop "$loop_repo" "e2e-retry" reject-then-accept 2 2>&1)"; then + printf '%s\n' "$retry_output" >&2 + fail "retry loop failed" +fi +contains "$retry_output" "accepted" "retry loop" +contains "$retry_output" "2 / 2" "retry loop passes" +ok "e2e reject then accept" + +loop_repo="$work/loop-bad-ac" +make_loop_repo "$loop_repo" "e2e-bad-ac" "E2E Bad AC" +if bad_ac_output="$(run_loop "$loop_repo" "e2e-bad-ac" bad-ac 1 2>&1)"; then + printf '%s\n' "$bad_ac_output" >&2 + fail "bad acceptance loop unexpectedly passed" +fi +contains "$bad_ac_output" "unclear" "bad acceptance loop" +ok "e2e bad acceptance matrix" + +loop_repo="$work/loop-bad-quality" +make_loop_repo "$loop_repo" "e2e-bad-quality" "E2E Bad Quality" +if bad_quality_output="$(run_loop "$loop_repo" "e2e-bad-quality" bad-quality 1 2>&1)"; then + printf '%s\n' "$bad_quality_output" >&2 + fail "bad quality loop unexpectedly passed" +fi +contains "$bad_quality_output" "unclear" "bad quality loop" +ok "e2e bad quality matrix" + +loop_repo="$work/loop-missing-review" +make_loop_repo "$loop_repo" "e2e-missing-review" "E2E Missing Review" +if missing_review_output="$(run_loop "$loop_repo" "e2e-missing-review" missing-review 1 2>&1)"; then + printf '%s\n' "$missing_review_output" >&2 + fail "missing review loop unexpectedly passed" +fi +contains "$missing_review_output" "review-missing" "missing review loop" +ok "e2e missing review" + +loop_repo="$work/loop-no-changes" +make_loop_repo "$loop_repo" "e2e-no-changes" "E2E No Changes" +if ! no_changes_output="$(run_loop "$loop_repo" "e2e-no-changes" no-changes 1 2>&1)"; then + printf '%s\n' "$no_changes_output" >&2 + fail "no changes loop failed" +fi +contains "$no_changes_output" "accepted" "no changes loop" +contains "$no_changes_output" "commit: none" "no changes loop" +ok "e2e no changes" + +loop_repo="$work/loop-dirty" +make_loop_repo "$loop_repo" "e2e-dirty" "E2E Dirty" +printf '%s\n' "user dirty" > "$loop_repo/dirty.txt" +if ! dirty_output="$(run_loop "$loop_repo" "e2e-dirty" accept 1 "--in-place" 2>&1)"; then + printf '%s\n' "$dirty_output" >&2 + fail "dirty in-place loop failed" +fi +contains "$dirty_output" "accepted" "dirty loop" +contains "$(git -C "$loop_repo" status --porcelain=v1 -- dirty.txt)" "dirty.txt" "dirty file remains dirty" +if git -C "$loop_repo" show --name-only --format= HEAD | grep -Fxq "dirty.txt"; then fail "dirty file was committed"; fi +ok "e2e dirty file preserved" + +loop_repo="$work/loop-verify-fail" +make_loop_repo "$loop_repo" "e2e-verify-fail" "E2E Verify Fail" +mkdir -p "$loop_repo/.devloop" +cat > "$loop_repo/.devloop/verify" <<'VERIFY' +#!/usr/bin/env bash +set -euo pipefail +printf '%s\n' "verify failed" +exit 1 +VERIFY +chmod +x "$loop_repo/.devloop/verify" +if verify_fail_output="$(run_loop "$loop_repo" "e2e-verify-fail" accept 1 2>&1)"; then + printf '%s\n' "$verify_fail_output" >&2 + fail "verify failure loop unexpectedly passed" +fi +contains "$verify_fail_output" "verify-error" "verify failure loop" +contains "$(cd "$loop_repo" && HOME="$install_home" PATH="$fake_bin:$bin_dir:$PATH" "$REPO_ROOT/devloop" status)" "verify-error" "verify failure status" +clean_output="$(cd "$loop_repo" && HOME="$install_home" PATH="$fake_bin:$bin_dir:$PATH" "$REPO_ROOT/devloop" clean --dry-run)" +contains "$clean_output" "would remove:" "clean dry run" +(cd "$loop_repo" && HOME="$install_home" PATH="$fake_bin:$bin_dir:$PATH" "$REPO_ROOT/devloop" clean --force >/tmp/devloop-clean-force.out) +contains "$(cat /tmp/devloop-clean-force.out)" "removed:" "clean force" +ok "e2e verify failure and clean"