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] <spec.md> [max=5]
 | `--coder <agent>` | Choose Codex or Claude Code for implementation (`codex`/`claude`) |
 | `--reviewer <agent>` | Choose Codex or Claude Code for review (`codex`/`claude`) |
 | `--report-format <format>` | Choose `html` or `markdown` |
+| `--timeout-minutes <n>` | 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] <spec.md> [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] <spec.md> [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] <spec.md> [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 <<EOF
+$(status_paths "$repo")
+EOF
+  return 1
+}
+
+remove_devloop_worktree() {
+  local source_repo="$1"
+  local worktree="$2"
+  local force="$3"
+  if [ -z "$source_repo" ] || ! git -C "$source_repo" rev-parse --show-toplevel >/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" <<EOF
-# Track: $(basename "$file" .md)
+# Track: $track_name
 
 - spec: $spec
 - source-spec: $source_spec
@@ -2023,6 +2647,7 @@ init_track() {
 - report-format: $report_format
 - strict: $strict
 - create-pr: $create_pr
+- timeout-minutes: $timeout_minutes
 - started: $(date -u +"%Y-%m-%dT%H:%M:%SZ")
 
 EOF
@@ -2123,22 +2748,17 @@ run_with_prompt() {
   code_file="$(mktemp "${TMPDIR:-/tmp}/devloop-code.XXXXXX")"
   printf '%s' "$prompt" > "$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" <<MARKDOWN
+# Review ${pass:-1}
+
+Verdict: $verdict
+
+## Acceptance matrix
+
+| Criterion | Status | Implementation evidence | Test evidence |
+| --- | --- | --- | --- |
+| AC1 | $ac_status | result.txt | fake verification |
+
+## Engineering quality matrix
+
+| Area | Status | Evidence |
+| --- | --- | --- |
+| Correctness | PASS | fixture |
+| Test quality | PASS | fixture |
+| Maintainability | $maintainability | fixture |
+| Architecture boundaries | PASS | fixture |
+| Simplicity | PASS | fixture |
+| Security | N/A | fixture |
+| Operational safety | PASS | fixture |
+
+## Review flags
+
+- Silent decision: absent - None
+- Scope drift: absent - None
+- Missing test: absent - None
+
+## Findings
+
+$findings
+
+## Missing tests
+
+- None
+
+## Fix instructions
+
+$fixes
+
+## Notes
+
+- None
+MARKDOWN
+AGENT
+chmod +x "$fake_bin/codex" "$fake_bin/claude"
+
+make_loop_repo() {
+  local repo_path="$1"
+  local slug="$2"
+  local title="$3"
+  mkdir -p "$repo_path/.specs"
+  git init -q "$repo_path"
+  git -C "$repo_path" config user.email devloop-test@example.com
+  git -C "$repo_path" config user.name "devloop test"
+  printf '%s\n' "# Fixture" > "$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" <<MARKDOWN
+---
+status: draft
+type: feat
+slug: $slug
+breaking: false
+pr: null
+---
+
+# $title
+
+## Acceptance criteria
+1. Write the result file.
+MARKDOWN
+}
+
+run_loop() {
+  local repo_path="$1"
+  local slug="$2"
+  local mode="$3"
+  local max="${4:-1}"
+  local extra="${5:-}"
+  if [ -n "$extra" ]; then
+    (
+      cd "$repo_path"
+      HOME="$install_home" PATH="$fake_bin:$bin_dir:$PATH" DEVLOOP_FAKE_MODE="$mode" "$REPO_ROOT/devloop" --plain --no-shell "$extra" ".specs/$slug.md" "$max"
+    )
+  else
+    (
+      cd "$repo_path"
+      HOME="$install_home" PATH="$fake_bin:$bin_dir:$PATH" DEVLOOP_FAKE_MODE="$mode" "$REPO_ROOT/devloop" --plain --no-shell ".specs/$slug.md" "$max"
+    )
+  fi
+}
+
+loop_repo="$work/loop-accept"
+make_loop_repo "$loop_repo" "e2e-accept" "E2E Accept"
+mkdir -p "$loop_repo/.devloop"
+cat > "$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"
