From f6611e00c480058014b73708ab7bfdae6eefc5cc Mon Sep 17 00:00:00 2001
From: satyaborg <satya.borg@gmail.com>
Date: Fri, 29 May 2026 10:52:55 +1000
Subject: [PATCH 1/3] feat: add bash devloop runtime

---
 README.md  |   33 +-
 devloop    | 1727 ++++++++++++++++++++++++++++++++++++++++++++++++++++
 install.sh |   40 ++
 3 files changed, 1779 insertions(+), 21 deletions(-)
 create mode 100755 devloop
 create mode 100755 install.sh

diff --git a/README.md b/README.md
index 5f33562..d1debfa 100644
--- a/README.md
+++ b/README.md
@@ -1,36 +1,28 @@
 [![CI](https://github.com/satyaborg/devloop/actions/workflows/ci.yml/badge.svg)](https://github.com/satyaborg/devloop/actions/workflows/ci.yml)
-[![npm version](https://img.shields.io/npm/v/@satyaborg/devloop.svg)](https://www.npmjs.com/package/@satyaborg/devloop)
-[![license](https://img.shields.io/npm/l/@satyaborg/devloop.svg)](LICENSE)
-[![npm downloads](https://img.shields.io/npm/dm/@satyaborg/devloop.svg)](https://www.npmjs.com/package/@satyaborg/devloop)
+[![license](https://img.shields.io/github/license/satyaborg/devloop.svg)](LICENSE)
 
 # Devloop
 
 **Spec in. Reviewed code out.**
 
-`devloop` runs a local implementation and review loop for agent-written code.
+`devloop` is a single Bash executable that runs a local implementation and review loop for agent-written code.
 
 By default, Codex makes the change, Claude Code reviews it, and Codex retries until the work is accepted, stalled, unclear, or out of passes.
 
 ## Install
 
-Prereqs: Bun 1.2+, git, and the agent CLIs you want to use. The default pairing requires `codex` and `claude`.
+Prereqs: Bash, git, and the agent CLIs you want to use. The default pairing requires `codex` and `claude`.
 
 ```sh
-npm install -g @satyaborg/devloop
+git clone https://github.com/satyaborg/devloop.git
+cd devloop
+./install.sh
 ```
 
 Run without installing:
 
 ```sh
-bunx @satyaborg/devloop --help
-```
-
-Install from source:
-
-```sh
-git clone https://github.com/satyaborg/devloop.git
-cd devloop
-bun scripts/install.ts
+./devloop --help
 ```
 
 ## Quick Start
@@ -78,7 +70,7 @@ devloop [options] <spec.md> [max=5]
 | Option | Meaning |
 | --- | --- |
 | `--plain` | Force plain output, useful for CI |
-| `--tui` | Force the terminal UI |
+| `--tui` | Force simple terminal progress output |
 | `--coder <agent>` | Choose `codex` or `claude` for implementation |
 | `--reviewer <agent>` | Choose `codex` or `claude` for review |
 | `--report-format <format>` | Choose `html` or `markdown` |
@@ -102,13 +94,12 @@ devloop [options] <spec.md> [max=5]
 ## Development
 
 ```sh
-bun scripts/install.ts
-bun run typecheck
-bun test
-bun run package:smoke
+bash -n devloop install.sh
+./devloop --help
+DEVLOOP_BIN_DIR="$(mktemp -d)/bin" ./install.sh
 ```
 
-`bun test` enforces 100% line, function, and statement coverage for the TypeScript core.
+The supported runtime is the root [`devloop`](devloop) Bash script.
 
 ## License
 
diff --git a/devloop b/devloop
new file mode 100755
index 0000000..363e129
--- /dev/null
+++ b/devloop
@@ -0,0 +1,1727 @@
+#!/usr/bin/env bash
+set -uo pipefail
+
+CODEX_REASONING_ARGS=(-c 'model_reasoning_effort="xhigh"')
+CLAUDE_EFFORT_ARGS=(--effort max)
+
+SCRIPT_PATH="${BASH_SOURCE[0]}"
+while [ -L "$SCRIPT_PATH" ]; do
+  LINK_DIR="$(cd -P "$(dirname "$SCRIPT_PATH")" >/dev/null 2>&1 && pwd)"
+  LINK_TARGET="$(readlink "$SCRIPT_PATH")"
+  case "$LINK_TARGET" in
+    /*) SCRIPT_PATH="$LINK_TARGET" ;;
+    *) SCRIPT_PATH="$LINK_DIR/$LINK_TARGET" ;;
+  esac
+done
+ROOT_DIR="$(cd -P "$(dirname "$SCRIPT_PATH")" >/dev/null 2>&1 && pwd)"
+
+USE_TUI=false
+if [ -t 1 ]; then USE_TUI=true; fi
+
+RUN_CODE=0
+RUN_STDOUT=""
+RUN_STDERR=""
+RUN_OUTPUT=""
+
+STATUS=""
+PASSES=0
+MAX=5
+REPORT=""
+TRACK=""
+FINAL_BRANCH=""
+FINAL_COMMIT=""
+FINAL_COMMIT_MESSAGE=""
+SOURCE_REPO=""
+WORKTREE_REPO=""
+CODER=""
+REVIEWER=""
+CODER_SESSION_ID=""
+REVIEWER_SESSION_ID=""
+PULL_REQUEST=""
+PULL_REQUEST_ERROR=""
+
+COMMIT_PASSES=()
+COMMIT_HASHES=()
+COMMIT_MESSAGES=()
+COMMIT_PATHS=()
+
+main() {
+  if [ "${1:-}" = "spec" ]; then
+    shift
+    spec_command "$@"
+    return $?
+  fi
+
+  if [ "$#" -eq 0 ] || has_arg "-h" "$@" || has_arg "--help" "$@"; then
+    welcome
+    return 0
+  fi
+
+  run_command "$@"
+}
+
+welcome() {
+  cat <<'EOF'
+       __          __                
+  ____/ /__ _   __/ /___  ____  ____ 
+ / __  / _ \ | / / / __ \/ __ \/ __ \
+/ /_/ /  __/ |/ / / /_/ / /_/ / /_/ /
+\__,_/\___/|___/_/\____/\____/ .___/ 
+                            /_/
+
+Spec-driven code and review loop. Codex implements and Claude Code reviews by default.
+
+Usage:
+  devloop [options] <spec.md> [max=5]
+
+Common commands:
+  devloop spec "add retry behavior to the chat sender"
+  devloop .specs/change.md
+  devloop --tui .specs/change.md
+  devloop --plain .specs/change.md
+  devloop --report-format markdown .specs/change.md 3
+  devloop --coder claude --reviewer codex .specs/change.md
+  devloop --create-pr .specs/change.md
+
+Options:
+  --tui                         force simple terminal progress output
+  --plain                       force plain output
+  --coder codex|claude          choose the implementation agent
+  --reviewer codex|claude       choose the review agent
+  --report-format html|markdown choose report format
+  --no-strict                   weaken acceptance gates
+  --in-place                    run in the current worktree
+  --create-pr, --pr             push accepted branch and open a PR
+  -h, --help                    show this screen
+EOF
+}
+
+usage() {
+  printf '%s\n' "usage: devloop [--plain|--tui] [--in-place] [--no-strict] [--create-pr|--pr] [--coder codex|claude] [--reviewer codex|claude] [--report-format html|markdown] <spec.md> [max=5]"
+}
+
+spec_usage() {
+  cat <<'EOF'
+usage: devloop spec [--agent codex|claude|<cmd>] [--output spec.md] [--force] [context...]
+       devloop spec --print-skill
+       devloop spec --skill-path
+
+Without context, the bundled skill uses its interview path before writing a spec.
+EOF
+}
+
+has_arg() {
+  local needle="$1"
+  shift
+  local item
+  for item in "$@"; do
+    if [ "$item" = "$needle" ]; then return 0; fi
+  done
+  return 1
+}
+
+run_command() {
+  local report_format="html"
+  local strict=true
+  local use_worktree=true
+  local coder="codex"
+  local reviewer="claude"
+  local create_pr=false
+  local spec=""
+  local max_raw="5"
+  local max_set=false
+  local arg value
+
+  while [ "$#" -gt 0 ]; do
+    arg="$1"
+    shift
+    case "$arg" in
+      --report-format)
+        if [ "$#" -eq 0 ]; then usage >&2; return 2; fi
+        value="$1"
+        shift
+        case "$value" in
+          html) report_format="html" ;;
+          markdown|md) report_format="markdown" ;;
+          *) usage >&2; return 2 ;;
+        esac
+        ;;
+      --coder)
+        if [ "$#" -eq 0 ]; then printf '%s\n' "coder must be codex or claude" >&2; usage >&2; return 2; fi
+        value="$(normalize_agent "$1")"
+        shift
+        if [ -z "$value" ]; then printf '%s\n' "coder must be codex or claude" >&2; usage >&2; return 2; fi
+        coder="$value"
+        ;;
+      --reviewer)
+        if [ "$#" -eq 0 ]; then printf '%s\n' "reviewer must be codex or claude" >&2; usage >&2; return 2; fi
+        value="$(normalize_agent "$1")"
+        shift
+        if [ -z "$value" ]; then printf '%s\n' "reviewer must be codex or claude" >&2; usage >&2; return 2; fi
+        reviewer="$value"
+        ;;
+      --html) report_format="html" ;;
+      --markdown|--md) report_format="markdown" ;;
+      --no-strict) strict=false ;;
+      --strict) strict=true ;;
+      --in-place) use_worktree=false ;;
+      --create-pr|--pr) create_pr=true ;;
+      --plain) USE_TUI=false ;;
+      --tui) USE_TUI=true ;;
+      -h|--help) usage; return 0 ;;
+      --*)
+        printf 'unknown option: %s\n' "$arg" >&2
+        usage >&2
+        return 2
+        ;;
+      *)
+        if [ -z "$spec" ]; then
+          spec="$arg"
+        elif [ "$max_set" = false ]; then
+          max_raw="$arg"
+          max_set=true
+        else
+          usage >&2
+          return 2
+        fi
+        ;;
+    esac
+  done
+
+  if [ -z "$spec" ]; then usage >&2; return 2; fi
+  if ! printf '%s\n' "$max_raw" | grep -Eq '^[+-]?[0-9]+$'; then
+    printf '%s\n' "max must be an integer between 1 and 10" >&2
+    return 2
+  fi
+
+  local max="$max_raw"
+  if [ "$max" -lt 1 ]; then max=1; fi
+  if [ "$max" -gt 10 ]; then max=10; fi
+
+  run_devloop "$spec" "$max" "$report_format" "$strict" "$use_worktree" "$coder" "$reviewer" "$create_pr"
+  local code=$?
+  case "$STATUS" in
+    accepted) return 0 ;;
+    stalled|max-turns|unclear) return 1 ;;
+    *) return "$code" ;;
+  esac
+}
+
+run_devloop() {
+  local spec_arg="$1"
+  local max="$2"
+  local report_format="$3"
+  local strict="$4"
+  local use_worktree="$5"
+  local coder="$6"
+  local reviewer="$7"
+  local create_pr="$8"
+
+  MAX="$max"
+  CODER="$coder"
+  REVIEWER="$reviewer"
+  STATUS="max-turns"
+  PASSES=0
+  FINAL_COMMIT=""
+  FINAL_COMMIT_MESSAGE=""
+  PULL_REQUEST=""
+  PULL_REQUEST_ERROR=""
+  COMMIT_PASSES=()
+  COMMIT_HASHES=()
+  COMMIT_MESSAGES=()
+  COMMIT_PATHS=()
+
+  local spec
+  spec="$(absolute_existing_file "$spec_arg")" || { usage >&2; return 2; }
+  local spec_text
+  spec_text="$(cat "$spec")"
+
+  local criteria_file
+  criteria_file="$(mktemp "${TMPDIR:-/tmp}/devloop-criteria.XXXXXX")"
+  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
+    return 2
+  fi
+  event_gate "acceptance criteria" "$criteria_count" "$criteria_count found"
+
+  SOURCE_REPO="$(git -C "$PWD" rev-parse --show-toplevel 2>/dev/null)" || {
+    printf '%s\n' "not inside a git repository" >&2
+    return 2
+  }
+  local source_branch
+  source_branch="$(git -C "$SOURCE_REPO" rev-parse --abbrev-ref HEAD)"
+  local base
+  base="$(base_branch "$SOURCE_REPO")"
+
+  event_step "naming" "derive branch name with $(agent_label "$coder")"
+  local naming_tmp naming_log naming_error
+  naming_tmp=""
+  naming_log=""
+  naming_error=""
+  if ! resolve_work_item "$coder" "$SOURCE_REPO" "$spec" "$spec_text"; then
+    naming_error="$WORK_ITEM_ERROR"
+    event_done "naming" false "$naming_error"
+    if [ -n "${WORK_ITEM_LOG:-}" ]; then
+      printf 'naming failed: %s\nnaming log: %s\n' "$naming_error" "$WORK_ITEM_LOG" >&2
+    else
+      printf 'naming failed: %s\n' "$naming_error" >&2
+    fi
+    return 2
+  fi
+  local slug="$WORK_SLUG"
+  event_done "naming" true "$(branch_base "$WORK_TYPE" "$WORK_BREAKING" "$WORK_SLUG")"
+
+  local repo="$SOURCE_REPO"
+  if [ "$use_worktree" = true ]; then
+    event_step "worktree" "create worktree"
+    repo="$(create_worktree "$SOURCE_REPO" "$WORK_TYPE" "$WORK_BREAKING" "$WORK_SLUG")" || return 2
+    event_done "worktree" true "$repo"
+  fi
+  WORKTREE_REPO="$repo"
+
+  mkdir -p "$repo/.codex/specs" "$repo/.codex/tracks" "$repo/.codex/reviews" "$repo/.codex/reports" "$repo/.codex/logs" "$repo/.codex/sessions"
+  if [ -n "${WORK_ITEM_LOG:-}" ] && [ -f "$WORK_ITEM_LOG" ]; then
+    cp "$WORK_ITEM_LOG" "$repo/.codex/logs/$slug-naming.log"
+    rm -rf "$(dirname "$WORK_ITEM_LOG")"
+  fi
+
+  local run_spec="$spec"
+  if [ "$use_worktree" = true ]; then
+    run_spec="$repo/.codex/specs/$slug.md"
+    printf '%s' "$spec_text" > "$run_spec"
+  fi
+
+  local initial_dirty
+  initial_dirty="$(mktemp "${TMPDIR:-/tmp}/devloop-dirty.XXXXXX")"
+  status_paths "$repo" > "$initial_dirty"
+
+  local run_branch
+  run_branch="$(git -C "$repo" branch --show-current)"
+  FINAL_BRANCH="$run_branch"
+  TRACK=".codex/tracks/$slug.md"
+  if [ "$report_format" = "html" ]; then
+    REPORT=".codex/reports/$slug.html"
+  else
+    REPORT=".codex/reports/$slug.md"
+  fi
+  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"
+
+  local pass prior_hash
+  prior_hash=""
+  for pass in $(seq 1 "$max"); do
+    PASSES="$pass"
+    local coder_log=".codex/logs/$slug-r$pass-coder.log"
+    local coder_id="coder-$pass"
+    event_step "$coder_id" "pass $pass/$max $(agent_label "$coder") implementation"
+    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"
+      event_done "$coder_id" false "failed"
+      break
+    fi
+
+    local commit_id="commit-$pass"
+    event_step "$commit_id" "pass $pass/$max commit"
+    if commit_pass "$repo" "$WORK_TYPE" "$WORK_BREAKING" "$WORK_SLUG" "$pass" "$initial_dirty"; then
+      if [ -n "$PASS_BRANCH" ]; then FINAL_BRANCH="$PASS_BRANCH"; fi
+      if [ -n "$PASS_COMMIT" ]; then
+        COMMIT_PASSES+=("$pass")
+        COMMIT_HASHES+=("$PASS_COMMIT")
+        COMMIT_MESSAGES+=("$PASS_MESSAGE")
+        COMMIT_PATHS+=("$PASS_PATHS")
+        FINAL_COMMIT="$PASS_COMMIT"
+        FINAL_COMMIT_MESSAGE="$PASS_MESSAGE"
+      fi
+      if [ -n "$PASS_COMMIT" ]; then
+        event_done "$commit_id" true "1 commit"
+      else
+        event_done "$commit_id" true "no changes"
+      fi
+    else
+      STATUS="commit-error"
+      event_done "$commit_id" false "$COMMIT_ERROR"
+      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"
+    event_step "$reviewer_id" "pass $pass/$max $(agent_label "$reviewer") review"
+    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"
+      event_done "$reviewer_id" false "failed"
+      break
+    fi
+
+    if [ ! -f "$repo/$review" ]; then
+      STATUS="review-missing"
+      break
+    fi
+
+    local verdict
+    verdict="$(parse_verdict "$repo/$review")"
+    if [ "$verdict" = "ACCEPT" ]; then
+      event_gate "pass $pass verdict" 1 "$verdict"
+      if [ "$strict" = true ] && ! has_passing_matrix "$repo/$review" "$criteria_count"; then
+        STATUS="unclear"
+      else
+        STATUS="accepted"
+      fi
+      break
+    fi
+    event_gate "pass $pass verdict" 0 "${verdict:-MISSING}"
+    if [ "$verdict" = "UNCLEAR" ]; then
+      STATUS="unclear"
+      break
+    elif [ "$verdict" = "REJECT" ]; then
+      local hash
+      hash="$(findings_hash "$repo/$review")"
+      if [ -n "$prior_hash" ] && [ "$hash" = "$prior_hash" ]; then
+        STATUS="stalled"
+        break
+      fi
+      prior_hash="$hash"
+    else
+      STATUS="no-verdict"
+      break
+    fi
+  done
+
+  if [ "$PASSES" -gt "$max" ]; then PASSES="$max"; fi
+
+  if [ "$create_pr" = true ] && [ "$STATUS" = "accepted" ]; then
+    event_step "pull-request" "push branch and create PR"
+    if create_pull_request "$repo" "$FINAL_BRANCH" "$base"; then
+      event_done "pull-request" true "${PULL_REQUEST:-origin/$FINAL_BRANCH}"
+    else
+      STATUS="pr-error"
+      event_done "pull-request" false "$PULL_REQUEST_ERROR"
+    fi
+  fi
+
+  CODER_SESSION_ID="$(read_first_line "$repo/$coder_session")"
+  REVIEWER_SESSION_ID="$(read_first_line "$repo/$reviewer_session")"
+  synthesize_report "$repo" "$slug" "$reviewer" "$run_spec" "$spec" "$spec_text" "$SOURCE_REPO" "$repo" "$TRACK" "$REPORT" "$STATUS" "$PASSES" "$max" "$base" "$source_branch" "$FINAL_BRANCH" "$FINAL_COMMIT" "$FINAL_COMMIT_MESSAGE" "$PULL_REQUEST" "$PULL_REQUEST_ERROR" "$coder" "$repo/$reviewer_session" "$CODER_SESSION_ID" "$REVIEWER_SESSION_ID" "$report_format"
+
+  print_result
+  rm -f "$criteria_file" "$initial_dirty"
+  return 0
+}
+
+event_step() {
+  local id="$1"
+  local title="$2"
+  if [ "$USE_TUI" = true ]; then
+    printf '\033[1m[devloop]\033[0m %s\n' "$title" >&2
+  else
+    printf '[devloop] %s\n' "$title" >&2
+  fi
+}
+
+event_done() {
+  local id="$1"
+  local ok="$2"
+  local detail="$3"
+  printf '[devloop] %s\n' "$detail" >&2
+}
+
+event_gate() {
+  local name="$1"
+  local ok_value="$2"
+  local detail="$3"
+  printf '[devloop] %s: %s\n' "$name" "$detail" >&2
+}
+
+event_log() {
+  local id="$1"
+  local line="$2"
+  printf '[devloop] %s\n' "$line" >&2
+}
+
+normalize_agent() {
+  printf '%s' "${1:-}" | tr '[:upper:]' '[:lower:]' | awk '$0=="codex" || $0=="claude"{print}'
+}
+
+agent_label() {
+  case "$1" in
+    codex) printf '%s' "Codex" ;;
+    claude) printf '%s' "Claude Code" ;;
+  esac
+}
+
+absolute_existing_file() {
+  local file="$1"
+  local dir base
+  if [ ! -f "$file" ]; then return 1; fi
+  dir="$(dirname "$file")"
+  base="$(basename "$file")"
+  (cd "$dir" >/dev/null 2>&1 && printf '%s/%s\n' "$(pwd -P)" "$base")
+}
+
+absolute_path() {
+  local file="$1"
+  local dir base
+  dir="$(dirname "$file")"
+  base="$(basename "$file")"
+  mkdir -p "$dir"
+  (cd "$dir" >/dev/null 2>&1 && printf '%s/%s\n' "$(pwd -P)" "$base")
+}
+
+line_count() {
+  local file="$1"
+  awk 'END{print NR + 0}' "$file"
+}
+
+parse_criteria() {
+  local file="$1"
+  awk '
+    BEGIN { inside = 0 }
+    /^[[:space:]]*##[[:space:]]+[Aa]cceptance[[:space:]]+[Cc]riteria[[:space:]]*$/ { inside = 1; next }
+    inside && /^[[:space:]]*##[[:space:]]+/ { exit }
+    inside {
+      line = $0
+      gsub(/^[[:space:]]+|[[:space:]]+$/, "", line)
+      sub(/^([-*]|[0-9]+[.)])[[:space:]]+/, "", line)
+      if (line != "") print line
+    }
+  ' "$file"
+}
+
+criteria_block() {
+  local criteria_file="$1"
+  local count=0
+  local line
+  while IFS= read -r line; do
+    count=$((count + 1))
+    printf 'AC%s: %s\n' "$count" "$line"
+  done < "$criteria_file"
+  if [ "$count" -eq 0 ]; then printf '%s\n' "No parsed acceptance criteria."; fi
+}
+
+base_branch() {
+  local repo="$1"
+  local out
+  if out="$(git -C "$repo" symbolic-ref --short refs/remotes/origin/HEAD 2>/dev/null)"; then
+    printf '%s\n' "${out#origin/}"
+    return
+  fi
+  if git -C "$repo" show-ref --verify -q refs/heads/main; then
+    printf '%s\n' "main"
+    return
+  fi
+  if git -C "$repo" show-ref --verify -q refs/heads/master; then
+    printf '%s\n' "master"
+    return
+  fi
+  printf '%s\n' "main"
+}
+
+status_paths() {
+  local repo="$1"
+  local item code file next
+  git -C "$repo" status --porcelain=v1 -z --untracked-files=all |
+    while IFS= read -r -d '' item; do
+      code="${item:0:2}"
+      file="${item:3}"
+      if [ -n "$file" ]; then printf '%s\n' "$file"; fi
+      case "$code" in
+        *R*|*C*)
+          if IFS= read -r -d '' next; then
+            if [ -n "$next" ]; then printf '%s\n' "$next"; fi
+          fi
+          ;;
+      esac
+    done
+}
+
+frontmatter_value() {
+  local key="$1"
+  local text="$2"
+  printf '%s\n' "$text" | awk -v key="$key" '
+    BEGIN { inside = 0 }
+    NR == 1 && $0 == "---" { inside = 1; next }
+    inside && $0 == "---" { exit }
+    inside {
+      split($0, parts, ":")
+      name = tolower(parts[1])
+      if (name == key) {
+        sub(/^[^:]*:[[:space:]]*/, "", $0)
+        gsub(/^[[:space:]]+|[[:space:]]+$/, "", $0)
+        gsub(/^["'"'"']|["'"'"']$/, "", $0)
+        print $0
+        exit
+      }
+    }
+  ' | awk '
+    $0 == "" || $0 == "null" || $0 == "undefined" { next }
+    /^\<.*\>$/ { next }
+    /\|/ { next }
+    { print }
+  '
+}
+
+slugify() {
+  printf '%s' "$1" |
+    tr '[:upper:]' '[:lower:]' |
+    sed -E "s/'//g; s/[^a-z0-9]+/-/g; s/^-+//; s/-+$//"
+}
+
+parse_bool() {
+  local value
+  value="$(printf '%s' "$1" | tr '[:upper:]' '[:lower:]')"
+  case "$value" in
+    true|yes|1) printf '%s' "true" ;;
+    false|no|0) printf '%s' "false" ;;
+    *) return 1 ;;
+  esac
+}
+
+WORK_TYPE=""
+WORK_SLUG=""
+WORK_BREAKING=""
+WORK_ITEM_ERROR=""
+WORK_ITEM_LOG=""
+
+resolve_work_item() {
+  local agent="$1"
+  local repo="$2"
+  local spec="$3"
+  local spec_text="$4"
+  WORK_TYPE=""
+  WORK_SLUG=""
+  WORK_BREAKING=""
+  WORK_ITEM_ERROR=""
+  WORK_ITEM_LOG=""
+
+  local fm_type fm_slug fm_breaking_raw fm_breaking
+  fm_type="$(frontmatter_value "type" "$spec_text")"
+  fm_slug="$(frontmatter_value "slug" "$spec_text")"
+  fm_breaking_raw="$(frontmatter_value "breaking" "$spec_text")"
+  fm_breaking=""
+
+  if [ -n "$fm_type" ]; then
+    fm_type="$(printf '%s' "$fm_type" | tr '[:upper:]' '[:lower:]')"
+    case "$fm_type" in
+      *!)
+        fm_type="${fm_type%!}"
+        fm_breaking="true"
+        ;;
+    esac
+    case "$fm_type" in
+      feat|fix|chore) ;;
+      *) WORK_ITEM_ERROR="frontmatter type must be feat, fix, or chore"; return 1 ;;
+    esac
+  fi
+
+  if [ -n "$fm_slug" ]; then fm_slug="$(slugify "$fm_slug")"; fi
+
+  if [ -n "$fm_breaking_raw" ]; then
+    local parsed_breaking
+    if ! parsed_breaking="$(parse_bool "$fm_breaking_raw")"; then
+      WORK_ITEM_ERROR="frontmatter breaking must be true or false"
+      return 1
+    fi
+    if [ "$fm_breaking" = "true" ] && [ "$parsed_breaking" = "false" ]; then
+      WORK_ITEM_ERROR="frontmatter breaking conflicts with type !"
+      return 1
+    fi
+    fm_breaking="$parsed_breaking"
+  fi
+
+  if [ -n "$fm_type" ] && [ -n "$fm_slug" ] && [ -n "$fm_breaking" ]; then
+    validate_work_item "$fm_type" "$fm_slug" "$fm_breaking" || return 1
+    WORK_TYPE="$fm_type"
+    WORK_SLUG="$fm_slug"
+    WORK_BREAKING="$fm_breaking"
+    WORK_ITEM_LOG=""
+    return 0
+  fi
+
+  local tmpdir log prompt
+  tmpdir="$(mktemp -d "${TMPDIR:-/tmp}/devloop-naming.XXXXXX")"
+  log="$tmpdir/naming.log"
+  WORK_ITEM_LOG="$log"
+  prompt="$(naming_prompt "$spec" "$spec_text")"
+  if ! run_agent_once "$agent" "$repo" "$log" "$prompt" "naming"; then
+    WORK_ITEM_ERROR="${RUN_OUTPUT:-$agent failed}"
+    return 1
+  fi
+  if ! parse_work_item "$RUN_OUTPUT"; then
+    return 1
+  fi
+
+  local final_type="$WORK_TYPE"
+  local final_slug="$WORK_SLUG"
+  local final_breaking="$WORK_BREAKING"
+  if [ -n "$fm_type" ]; then final_type="$fm_type"; fi
+  if [ -n "$fm_slug" ]; then final_slug="$fm_slug"; fi
+  if [ -n "$fm_breaking" ]; then final_breaking="$fm_breaking"; fi
+  validate_work_item "$final_type" "$final_slug" "$final_breaking" || return 1
+  WORK_TYPE="$final_type"
+  WORK_SLUG="$final_slug"
+  WORK_BREAKING="$final_breaking"
+}
+
+validate_work_item() {
+  local type="$1"
+  local slug="$2"
+  local breaking="$3"
+  case "$type" in
+    feat|fix|chore) ;;
+    *) WORK_ITEM_ERROR="naming output type must be feat, fix, or chore"; return 1 ;;
+  esac
+  slug="$(slugify "$slug")"
+  if [ -z "$slug" ]; then WORK_ITEM_ERROR="naming output slug is required"; return 1; fi
+  local words
+  words="$(printf '%s\n' "$slug" | awk -F- '{print NF}')"
+  if [ "$words" -gt 6 ]; then WORK_ITEM_ERROR="naming output slug must be 1-6 words"; return 1; fi
+  case "${slug%%-*}" in
+    feat|fix|chore) WORK_ITEM_ERROR="naming output slug must not include a type prefix"; return 1 ;;
+  esac
+  case "$breaking" in
+    true|false) ;;
+    *) WORK_ITEM_ERROR="naming output breaking must be boolean"; return 1 ;;
+  esac
+}
+
+parse_work_item() {
+  local output="$1"
+  local candidates reversed candidate type slug breaking
+  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
+    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')"
+    if [ -n "$type" ] && [ -n "$slug" ] && [ -n "$breaking" ]; then
+      if validate_work_item "$type" "$slug" "$breaking"; then
+        WORK_TYPE="$type"
+        WORK_SLUG="$(slugify "$slug")"
+        WORK_BREAKING="$breaking"
+        rm -f "$candidates" "$reversed"
+        return 0
+      fi
+    fi
+  done < "$reversed"
+  rm -f "$candidates" "$reversed"
+  if [ -z "$WORK_ITEM_ERROR" ]; then WORK_ITEM_ERROR="naming output must include JSON"; fi
+  return 1
+}
+
+naming_prompt() {
+  local spec="$1"
+  local spec_text="$2"
+  cat <<EOF
+Work item naming task.
+
+Read the spec and, when useful, inspect the repository to choose the semantic work item identity.
+
+Return exactly one JSON object and no markdown:
+{"type":"feat","slug":"short-kebab-case-name","breaking":false}
+
+Rules:
+- type must be one of: feat, fix, chore.
+- Use feat for new capability or materially expanded behavior.
+- Use fix for correcting broken, incorrect, or regressed behavior.
+- Use chore for maintenance, docs, tests, dependency work, refactors, and internal cleanup.
+- Use breaking true only when the work intentionally breaks an external API, data contract, command behavior, or migration expectation.
+- slug must be 1-6 short kebab-case words that name the actual work, not the process.
+- Exclude dates, issue numbers, repo names, agent names, and type words from slug.
+- Prefer concrete nouns from the problem domain over generic words like change, update, cleanup, or implementation.
+
+Spec path: $spec
+
+Spec:
+$spec_text
+EOF
+}
+
+branch_base() {
+  local type="$1"
+  local breaking="$2"
+  local slug="$3"
+  if [ "$breaking" = true ]; then
+    printf '%s!/%s\n' "$type" "$slug"
+  else
+    printf '%s/%s\n' "$type" "$slug"
+  fi
+}
+
+branch_leaf() {
+  local branch="$1"
+  printf '%s\n' "${branch##*/}"
+}
+
+regex_escape() {
+  printf '%s' "$1" | sed -E 's/[][().*^$+?{}|\\/]/\\&/g'
+}
+
+branch_exists() {
+  local repo="$1"
+  local branch="$2"
+  git -C "$repo" show-ref --verify --quiet "refs/heads/$branch"
+}
+
+next_branch() {
+  local repo="$1"
+  local type="$2"
+  local breaking="$3"
+  local slug="$4"
+  local current="$5"
+  local base branch suffix escaped
+  base="$(branch_base "$type" "$breaking" "$slug")"
+  escaped="$(regex_escape "$base")"
+  if [ "$current" = "$base" ] || printf '%s\n' "$current" | grep -Eq "^$escaped-[0-9]+$"; then
+    printf '%s\n' "$current"
+    return
+  fi
+  suffix=1
+  branch="$base"
+  while branch_exists "$repo" "$branch"; do
+    suffix=$((suffix + 1))
+    branch="$base-$suffix"
+  done
+  printf '%s\n' "$branch"
+}
+
+next_worktree_path() {
+  local repo="$1"
+  local slug="$2"
+  local parent base candidate suffix
+  parent="$(dirname "$repo")"
+  base="$parent/$(basename "$repo")-$(slugify "$slug")"
+  suffix=1
+  candidate="$base"
+  while [ -e "$candidate" ]; do
+    suffix=$((suffix + 1))
+    candidate="$base-$suffix"
+  done
+  printf '%s\n' "$candidate"
+}
+
+create_worktree() {
+  local repo="$1"
+  local type="$2"
+  local breaking="$3"
+  local slug="$4"
+  local current branch worktree leaf
+  current=""
+  branch="$(next_branch "$repo" "$type" "$breaking" "$slug" "$current")"
+  leaf="$(branch_leaf "$branch")"
+  worktree="$(next_worktree_path "$repo" "$leaf")"
+  if ! git -C "$repo" worktree add -b "$branch" "$worktree" HEAD >/dev/null 2>&1; then
+    printf 'failed to create worktree\n' >&2
+    return 1
+  fi
+  absolute_existing_dir "$worktree"
+}
+
+absolute_existing_dir() {
+  local dir="$1"
+  (cd "$dir" >/dev/null 2>&1 && pwd -P)
+}
+
+init_track() {
+  local file="$1"
+  local spec="$2"
+  local source_spec="$3"
+  local cwd="$4"
+  local source_repo="$5"
+  local worktree="$6"
+  local base="$7"
+  local branch="$8"
+  local worktree_branch="$9"
+  local max="${10}"
+  local report_format="${11}"
+  local strict="${12}"
+  local coder="${13}"
+  local reviewer="${14}"
+  local type="${15}"
+  local breaking="${16}"
+  local create_pr="${17}"
+  if [ -f "$file" ]; then return; fi
+  cat > "$file" <<EOF
+# Track: $(basename "$file" .md)
+
+- spec: $spec
+- source-spec: $source_spec
+- cwd: $cwd
+- source-repo: $source_repo
+- worktree: $worktree
+- base: $base
+- branch: $branch
+- worktree-branch: $worktree_branch
+- coder: $coder
+- reviewer: $reviewer
+- type: $type
+- breaking: $breaking
+- max: $max
+- report-format: $report_format
+- strict: $strict
+- create-pr: $create_pr
+- started: $(date -u +"%Y-%m-%dT%H:%M:%SZ")
+
+EOF
+}
+
+read_first_line() {
+  local file="$1"
+  if [ -f "$file" ]; then
+    sed -n '1p' "$file"
+  fi
+}
+
+run_agent_once() {
+  local agent="$1"
+  local repo="$2"
+  local log="$3"
+  local prompt="$4"
+  local id="$5"
+  case "$agent" in
+    codex)
+      run_with_prompt "$repo" "$log" "$id" "$prompt" codex exec "${CODEX_REASONING_ARGS[@]}" -s read-only -C "$repo" -
+      ;;
+    claude)
+      run_with_prompt "$repo" "$log" "$id" "$prompt" claude -p "${CLAUDE_EFFORT_ARGS[@]}" --dangerously-skip-permissions --add-dir "$repo"
+      ;;
+  esac
+  [ "$RUN_CODE" -eq 0 ]
+}
+
+run_agent() {
+  local agent="$1"
+  local repo="$2"
+  local session_file="$3"
+  local log="$4"
+  local prompt="$5"
+  local id="$6"
+  case "$agent" in
+    codex) run_codex "$repo" "$session_file" "$log" "$prompt" "$id" ;;
+    claude) run_claude "$repo" "$session_file" "$log" "$prompt" "$id" ;;
+  esac
+}
+
+run_codex() {
+  local repo="$1"
+  local session_file="$2"
+  local log="$3"
+  local prompt="$4"
+  local id="$5"
+  local session
+  session="$(read_first_line "$session_file")"
+  if [ -n "$session" ]; then
+    run_with_prompt "$repo" "$log" "$id" "$prompt" codex exec resume "${CODEX_REASONING_ARGS[@]}" --dangerously-bypass-approvals-and-sandbox "$session" -
+  else
+    run_with_prompt "$repo" "$log" "$id" "$prompt" codex exec "${CODEX_REASONING_ARGS[@]}" --dangerously-bypass-approvals-and-sandbox -C "$repo" -
+  fi
+  if [ "$RUN_CODE" -ne 0 ]; then return 1; fi
+  if [ -z "$session" ]; then
+    local next
+    next="$(extract_session_id "$RUN_OUTPUT")"
+    if [ -z "$next" ]; then return 1; fi
+    mkdir -p "$(dirname "$session_file")"
+    printf '%s\n' "$next" > "$session_file"
+  fi
+}
+
+run_claude() {
+  local repo="$1"
+  local session_file="$2"
+  local log="$3"
+  local prompt="$4"
+  local id="$5"
+  local session next
+  session="$(read_first_line "$session_file")"
+  if [ -n "$session" ]; then
+    run_with_prompt "$repo" "$log" "$id" "$prompt" claude -p --resume "$session" "${CLAUDE_EFFORT_ARGS[@]}" --dangerously-skip-permissions --add-dir "$repo"
+  else
+    next="$(new_uuid)"
+    run_with_prompt "$repo" "$log" "$id" "$prompt" claude -p --session-id "$next" "${CLAUDE_EFFORT_ARGS[@]}" --dangerously-skip-permissions --add-dir "$repo"
+  fi
+  if [ "$RUN_CODE" -ne 0 ]; then return 1; fi
+  if [ -z "$session" ]; then
+    mkdir -p "$(dirname "$session_file")"
+    printf '%s\n' "$next" > "$session_file"
+  fi
+}
+
+run_with_prompt() {
+  local cwd="$1"
+  local log="$2"
+  local id="$3"
+  local prompt="$4"
+  shift 4
+  local prompt_file stdout_file stderr_file output_file line
+  prompt_file="$(mktemp "${TMPDIR:-/tmp}/devloop-prompt.XXXXXX")"
+  stdout_file="$(mktemp "${TMPDIR:-/tmp}/devloop-stdout.XXXXXX")"
+  stderr_file="$(mktemp "${TMPDIR:-/tmp}/devloop-stderr.XXXXXX")"
+  output_file="$(mktemp "${TMPDIR:-/tmp}/devloop-output.XXXXXX")"
+  printf '%s' "$prompt" > "$prompt_file"
+  (cd "$cwd" >/dev/null 2>&1 && "$@" < "$prompt_file" > "$stdout_file" 2> "$stderr_file")
+  RUN_CODE=$?
+  RUN_STDOUT="$(cat "$stdout_file")"
+  RUN_STDERR="$(cat "$stderr_file")"
+  cat "$stdout_file" "$stderr_file" > "$output_file"
+  RUN_OUTPUT="$(cat "$output_file")"
+  if [ -n "$log" ]; then
+    mkdir -p "$(dirname "$log")"
+    cp "$output_file" "$log"
+  fi
+  if [ -n "$id" ]; then
+    while IFS= read -r line; do
+      if [ -n "$line" ]; then event_log "$id" "$line"; fi
+    done < "$output_file"
+  fi
+  rm -f "$prompt_file" "$stdout_file" "$stderr_file" "$output_file"
+}
+
+new_uuid() {
+  if command -v uuidgen >/dev/null 2>&1; then
+    uuidgen | tr '[:upper:]' '[:lower:]'
+  else
+    printf '00000000-0000-4000-8000-%012d\n' "$$"
+  fi
+}
+
+extract_session_id() {
+  printf '%s\n' "$1" |
+    grep -Eio '[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}' |
+    head -n 1 |
+    tr '[:upper:]' '[:lower:]'
+}
+
+coder_prompt() {
+  local spec="$1"
+  local track="$2"
+  local pass="$3"
+  local strict="$4"
+  local previous="$5"
+  local criteria_file="$6"
+  local criteria
+  criteria="$(criteria_block "$criteria_file")"
+  local strict_block=""
+  if [ "$strict" = true ]; then
+    strict_block="
+Strict lifecycle:
+1. Add or update regression tests before implementation.
+2. Run the narrow test first and record the failing result, unless impossible; if impossible, say why.
+3. Implement the smallest change.
+4. Run targeted tests, full tests, lint/typecheck, and coverage. Coverage must be 100% when the project exposes coverage tooling.
+"
+  fi
+  if [ "$pass" -eq 1 ]; then
+    cat <<EOF
+You are implementing against an approved spec.
+
+Spec: $spec
+Track: $track
+Pass: $pass
+Acceptance criteria:
+$criteria$strict_block
+Tasks:
+1. Read the spec.
+2. Implement the smallest working change satisfying the acceptance criteria.
+3. Append "## Pass $pass - implement" to $track with changed files, design tradeoffs, verification, and residual risk.
+
+Constraints:
+- Do not commit.
+- Do not edit the spec.
+- Do not revert unrelated dirty files.
+EOF
+  else
+    cat <<EOF
+Fix only the findings in the review. Do not refactor unrelated code.
+
+Spec: $spec
+Track: $track
+Review: $previous
+Pass: $pass
+Acceptance criteria:
+$criteria$strict_block
+Tasks:
+1. Read the review file.
+2. Fix each finding or explain why it is wrong in the track.
+3. Re-run relevant tests.
+4. Append "## Pass $pass - fix" to $track with per-finding outcomes.
+EOF
+  fi
+}
+
+review_prompt() {
+  local coder="$1"
+  local spec="$2"
+  local track="$3"
+  local base="$4"
+  local pass="$5"
+  local output="$6"
+  local slug="$7"
+  local max="$8"
+  local criteria_file="$9"
+  local strict="${10}"
+  local criteria priors strict_rule
+  criteria="$(criteria_block "$criteria_file")"
+  priors="$(list_reviews "$slug" "$pass" "$max")"
+  strict_rule=""
+  if [ "$strict" = true ]; then
+    strict_rule="
+- ACCEPT also requires regression-test evidence, red/green evidence when behavior changed, passing full tests, and 100% coverage when coverage tooling exists."
+  fi
+  cat <<EOF
+You are reviewing a $(agent_label "$coder") implementation. Be a senior reviewer, not a linter.
+
+Spec: $spec
+Track: $track
+Base: $base
+Pass: $pass
+Prior reviews:
+$priors
+Acceptance criteria:
+$criteria
+Output path: $output
+
+Steps:
+1. Read the spec and track.
+2. Run: git diff $base...HEAD
+3. Read prior reviews so you do not repeat resolved findings.
+4. Write the review to $output using this exact format:
+
+# Review $pass
+
+Verdict: <ACCEPT | REJECT | UNCLEAR>
+
+## Acceptance matrix
+
+| Criterion | Status | Implementation evidence | Test evidence |
+| --- | --- | --- | --- |
+| AC1 | <PASS, FAIL, or UNCLEAR> | <code or behavior evidence> | <test, check, or explicit gap> |
+
+## Review flags
+
+- Silent decision: <present or absent> - <evidence, or None>
+- Scope drift: <present or absent> - <evidence, or None>
+- Missing test: <present or absent> - <evidence, or None>
+
+## Findings
+
+1. [severity] <file:line> - <symptom>. Root cause: <why>. Principle: <principle>.
+
+## Missing tests
+
+- <gap, or None>
+
+## Fix instructions
+
+1. <standalone instruction>
+
+## Notes
+
+- <scope, disputes, ambiguity questions, lessons, or None>
+
+Rules:
+- The verdict line must appear verbatim.
+- ACCEPT requires every acceptance criterion PASS with concrete implementation evidence and concrete test evidence.$strict_rule
+- 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 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.
+- For ACCEPT: Findings and Fix instructions bodies are "None".
+- Findings must explain WHY, not just WHAT.
+EOF
+}
+
+list_reviews() {
+  local slug="$1"
+  local upto="$2"
+  local max="$3"
+  local limit i
+  limit="$upto"
+  if [ "$limit" -gt "$max" ]; then limit="$max"; fi
+  i=1
+  while [ "$i" -le "$limit" ]; do
+    printf -- '- .codex/reviews/%s-r%s.md\n' "$slug" "$i"
+    i=$((i + 1))
+  done
+}
+
+PASS_BRANCH=""
+PASS_COMMIT=""
+PASS_MESSAGE=""
+PASS_PATHS=""
+COMMIT_ERROR=""
+
+commit_pass() {
+  local repo="$1"
+  local type="$2"
+  local breaking="$3"
+  local slug="$4"
+  local pass="$5"
+  local initial_dirty="$6"
+  PASS_BRANCH=""
+  PASS_COMMIT=""
+  PASS_MESSAGE=""
+  PASS_PATHS=""
+  COMMIT_ERROR=""
+
+  local changed
+  changed="$(mktemp "${TMPDIR:-/tmp}/devloop-changed.XXXXXX")"
+  committable_paths "$repo" "$initial_dirty" > "$changed"
+  if [ ! -s "$changed" ]; then
+    rm -f "$changed"
+    return 0
+  fi
+
+  local current branch message
+  current="$(git -C "$repo" branch --show-current)"
+  branch="$(next_branch "$repo" "$type" "$breaking" "$slug" "$current")"
+  if [ "$branch" != "$current" ]; then
+    if ! run_git_capture "$repo" switch -c "$branch"; then return 1; fi
+  fi
+  PASS_BRANCH="$branch"
+  message="$(pass_commit_message "$type" "$breaking" "$slug" "$pass")"
+
+  local paths=()
+  local file
+  while IFS= read -r file; do
+    paths+=("$file")
+  done < "$changed"
+
+  if ! git -C "$repo" add -- "${paths[@]}" >/dev/null 2>&1; then
+    COMMIT_ERROR="git add failed"
+    rm -f "$changed"
+    return 1
+  fi
+  if ! run_git_capture "$repo" commit --only -m "$message" -- "${paths[@]}"; then
+    rm -f "$changed"
+    return 1
+  fi
+
+  PASS_COMMIT="$(git -C "$repo" rev-parse --short HEAD)"
+  PASS_MESSAGE="$message"
+  PASS_PATHS="$(paste_join "$changed" ", ")"
+  rm -f "$changed"
+}
+
+committable_paths() {
+  local repo="$1"
+  local initial_dirty="$2"
+  local file
+  status_paths "$repo" |
+    while IFS= read -r file; do
+      if grep -Fxq -- "$file" "$initial_dirty"; then continue; fi
+      case "$file" in
+        .codex/*) continue ;;
+      esac
+      printf '%s\n' "$file"
+    done |
+    LC_ALL=C sort
+}
+
+run_git_capture() {
+  local repo="$1"
+  shift
+  local out
+  out="$(git -C "$repo" "$@" 2>&1)"
+  local code=$?
+  if [ "$code" -ne 0 ]; then
+    COMMIT_ERROR="$(printf '%s\n' "$out" | sed '/^[[:space:]]*$/d' | tail -n 1)"
+    if [ -z "$COMMIT_ERROR" ]; then COMMIT_ERROR="git $* failed"; fi
+    return 1
+  fi
+}
+
+pass_commit_message() {
+  local type="$1"
+  local breaking="$2"
+  local slug="$3"
+  local pass="$4"
+  if [ "$pass" -eq 1 ]; then
+    if [ "$breaking" = true ]; then
+      printf '%s!: %s\n' "$type" "$slug"
+    else
+      printf '%s: %s\n' "$type" "$slug"
+    fi
+  elif [ "$type" = "chore" ]; then
+    printf '%s\n' "chore: $slug"
+  else
+    printf '%s\n' "fix: $slug"
+  fi
+}
+
+paste_join() {
+  local file="$1"
+  local separator="$2"
+  awk -v sep="$separator" 'NR == 1 { out = $0; next } { out = out sep $0 } END { print out }' "$file"
+}
+
+parse_verdict() {
+  local file="$1"
+  sed -nE 's/^Verdict:[[:space:]]+(ACCEPT|REJECT|UNCLEAR).*/\1/p' "$file" | head -n 1
+}
+
+has_passing_matrix() {
+  local file="$1"
+  local count="$2"
+  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
+      return 1
+    fi
+    i=$((i + 1))
+  done
+}
+
+findings_hash() {
+  local file="$1"
+  awk '
+    /^## Findings[[:space:]]*$/ { inside = 1; next }
+    inside && /^##[[:space:]]+/ { exit }
+    inside { print }
+  ' "$file" |
+    tr -d '0-9' |
+    tr '\r\n\t' ' ' |
+    sed -E 's/[ ]+/ /g; s/\./\
+/g; s/^[[:space:]]+|[[:space:]]+$//g' |
+    awk 'NF' |
+    LC_ALL=C sort |
+    hash_sha256
+}
+
+hash_sha256() {
+  if command -v shasum >/dev/null 2>&1; then
+    shasum -a 256 | awk '{print $1}'
+  else
+    sha256sum | awk '{print $1}'
+  fi
+}
+
+create_pull_request() {
+  local repo="$1"
+  local branch="$2"
+  local base="$3"
+  local remote="origin"
+  local out
+  out="$(git -C "$repo" push -u "$remote" "$branch" 2>&1)"
+  if [ "$?" -ne 0 ]; 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
+    PULL_REQUEST_ERROR="pushed $remote/$branch, but PR creation failed: $(printf '%s\n' "$out" | sed '/^[[:space:]]*$/d' | tail -n 1)"
+    return 1
+  fi
+  PULL_REQUEST="$(printf '%s\n' "$out" | awk '/^https?:\/\// { print; exit }')"
+  if [ -z "$PULL_REQUEST" ]; then PULL_REQUEST="$(printf '%s\n' "$out" | tail -n 1)"; fi
+}
+
+synthesize_report() {
+  local repo="$1"
+  local slug="$2"
+  local reviewer="$3"
+  local run_spec="$4"
+  local source_spec="$5"
+  local spec_text="$6"
+  local source_repo="$7"
+  local worktree="$8"
+  local track="$9"
+  local report="${10}"
+  local status="${11}"
+  local pass="${12}"
+  local max="${13}"
+  local base="${14}"
+  local initial_branch="${15}"
+  local branch="${16}"
+  local commit="${17}"
+  local commit_message="${18}"
+  local pull_request="${19}"
+  local pull_request_error="${20}"
+  local coder="${21}"
+  local reviewer_session_file="${22}"
+  local coder_session_id="${23}"
+  local reviewer_session_id="${24}"
+  local format="${25}"
+  local title subtitle reviews metadata body prompt
+  title="$(report_title "$spec_text" "$slug")"
+  subtitle="$(report_subtitle "$spec_text" "$title")"
+  reviews="$(list_reviews "$slug" "$pass" "$max")"
+  metadata="$(report_metadata "$status" "$pass" "$max" "$repo" "$run_spec" "$source_spec" "$source_repo" "$worktree" "$base" "$initial_branch" "$branch" "$commit" "$commit_message" "$pull_request" "$pull_request_error" "$coder" "$reviewer" "$coder_session_id" "$reviewer_session_id" "$track" "$reviews")"
+  if [ "$format" = "html" ]; then
+    body="Write the report to $report as valid standalone HTML. Use a readable document layout with embedded CSS, set the HTML <title> to the report title, render the report title and subtitle before Metadata, render a topical three-line haiku immediately after the subtitle, use a compact metadata table, and add substantive sections after it. Include these visible section headings: Metadata, The shape of the problem, What was built, What the review caught (and why it mattered), What to remember next time, Residual risk, Pointers. Do not optimize away substance: explain the decisions, tradeoffs, evidence, and transferable lessons clearly enough that the reader learns from the run."
+  else
+    body="Write the report to $report in markdown. Start with the report title as the H1, put the subtitle directly below it, put a topical three-line haiku immediately after the subtitle, then include these headings: Metadata, The shape of the problem, What was built, What the review caught (and why it mattered), What to remember next time, Residual risk, Pointers. Do not optimize away substance: explain the decisions, tradeoffs, evidence, and transferable lessons clearly enough that the reader learns from the run."
+  fi
+  prompt="$(cat <<EOF
+You are writing a learning-oriented post-mortem for a developer who just ran a devloop.
+
+Report framing to render visibly near the top, before Metadata:
+Title: $title
+Subtitle: $subtitle
+Haiku: Compose a three-line haiku, 5/7/5 syllables if possible, about this specific work.
+Haiku topic: $title - $subtitle
+
+Use that exact title and subtitle. The subtitle must be specific to this work, not a generic or hard-coded tagline. The haiku must be topical, concrete, and rendered immediately after the subtitle before Metadata.
+
+Metadata to render exactly and visibly:
+$metadata
+
+Inputs:
+- spec: $run_spec
+- track: $track
+Review files:
+$reviews
+- final status: $status
+- passes used: $pass / $max
+- base: $base, starting branch: $initial_branch, final branch: $branch, local commit: ${commit:-none}
+- pull request: ${pull_request:-none}
+
+$body
+
+Style:
+- Human readable, not ornamental.
+- Preserve useful substance over brevity.
+- Teach the why: symptom, root cause, principle, decision, tradeoff, and evidence.
+- No emoji.
+EOF
+)"
+  run_agent "$reviewer" "$repo" "$reviewer_session_file" "$repo/.codex/logs/$slug-report.log" "$prompt" "report" >/dev/null 2>&1 || true
+}
+
+report_metadata() {
+  local status="$1"
+  local pass="$2"
+  local max="$3"
+  local repo="$4"
+  local spec="$5"
+  local source_spec="$6"
+  local source_repo="$7"
+  local worktree="$8"
+  local base="$9"
+  local initial_branch="${10}"
+  local branch="${11}"
+  local commit="${12}"
+  local commit_message="${13}"
+  local pull_request="${14}"
+  local pull_request_error="${15}"
+  local coder="${16}"
+  local reviewer="${17}"
+  local coder_session_id="${18}"
+  local reviewer_session_id="${19}"
+  local track="${20}"
+  local reviews="${21}"
+  cat <<EOF
+Result: $status
+Passes: $pass / $max
+Repository: $repo
+Spec: $spec
+Source spec: $source_spec
+Source repository: $source_repo
+Worktree: $worktree
+Base branch: $base
+Starting branch: $initial_branch
+Final branch: $branch
+Local commit: ${commit:-none}
+Commit message: ${commit_message:-none}
+Commits:
+$(commit_lines)
+Pull request: ${pull_request:-none}
+Pull request error: ${pull_request_error:-none}
+Coder: $(agent_label "$coder")
+Reviewer: $(agent_label "$reviewer")
+Coder session: ${coder_session_id:-unknown}
+Reviewer session: ${reviewer_session_id:-unknown}
+Track: $track
+Reviews:
+$reviews
+EOF
+}
+
+commit_lines() {
+  local i
+  if [ "${#COMMIT_HASHES[@]}" -eq 0 ]; then
+    printf '%s\n' "- none"
+    return
+  fi
+  i=0
+  while [ "$i" -lt "${#COMMIT_HASHES[@]}" ]; do
+    printf -- '- pass %s %s %s (%s)\n' "${COMMIT_PASSES[$i]}" "${COMMIT_HASHES[$i]}" "${COMMIT_MESSAGES[$i]}" "${COMMIT_PATHS[$i]}"
+    i=$((i + 1))
+  done
+}
+
+report_title() {
+  local spec_text="$1"
+  local slug="$2"
+  local title
+  title="$(printf '%s\n' "$spec_text" | sed -nE 's/^#[[:space:]]+(.+)/\1/p' | while IFS= read -r line; do clean_report_text "$line"; break; done)"
+  if [ -n "$title" ]; then
+    printf '%s\n' "$title"
+  else
+    title_from_slug "$slug"
+  fi
+}
+
+report_subtitle() {
+  local spec_text="$1"
+  local title="$2"
+  local text
+  for heading in "Outcome" "Problem" "Acceptance criteria"; do
+    text="$(section_lead "$spec_text" "$heading")"
+    if [ -n "$text" ]; then printf '%s\n' "$text"; return; fi
+  done
+  printf 'Outcome, review findings, and residual risk for %s.\n' "$title"
+}
+
+section_lead() {
+  local spec_text="$1"
+  local heading="$2"
+  printf '%s\n' "$spec_text" | awk -v heading="$heading" '
+    BEGIN { inside = 0 }
+    {
+      line = $0
+      lower = tolower(line)
+      target = "## " tolower(heading)
+      if (lower == target) { inside = 1; next }
+      if (inside && line ~ /^##[[:space:]]+/) exit
+      if (inside) print line
+    }
+  ' |
+    sed -E 's/^[[:space:]]*([-*]|[0-9]+[.)])[[:space:]]+//' |
+    while IFS= read -r line; do
+      clean_report_text "$line"
+      if [ -n "$(clean_report_text "$line")" ]; then break; fi
+    done
+}
+
+clean_report_text() {
+  local text
+  text="$(printf '%s' "$1" | sed -E 's/^[[:space:]]+|[[:space:]]+$//g; s/[[:space:]]+/ /g')"
+  if [ -z "$text" ] || [ "$text" = "..." ]; then return; fi
+  if printf '%s\n' "$text" | grep -Eq '^<.*>$'; then return; fi
+  printf '%s\n' "$text"
+}
+
+title_from_slug() {
+  printf '%s\n' "$1" | awk -F- '{
+    for (i = 1; i <= NF; i++) {
+      if ($i == "") continue
+      word = toupper(substr($i, 1, 1)) substr($i, 2)
+      out = out (out ? " " : "") word
+    }
+    print out ? out : "Devloop Report"
+  }'
+}
+
+print_result() {
+  printf '\n'
+  result_line "result" "$STATUS"
+  result_line "passes" "$PASSES / $MAX"
+  result_line "coder" "$CODER"
+  result_line "reviewer" "$REVIEWER"
+  result_line "branch" "$FINAL_BRANCH"
+  result_line "commit" "${FINAL_COMMIT:-none}"
+  if [ -n "$PULL_REQUEST" ]; then result_line "pr" "$PULL_REQUEST"; fi
+  if [ "$WORKTREE_REPO" != "$SOURCE_REPO" ]; then result_line "worktree" "$WORKTREE_REPO"; fi
+  result_line "report" "$(display_path "$REPORT")"
+  result_line "track" "$(display_path "$TRACK")"
+}
+
+result_line() {
+  local label="$1:"
+  local value="$2"
+  printf '%-10s%s\n' "$label" "$value"
+}
+
+display_path() {
+  local file="$1"
+  if [ "$WORKTREE_REPO" != "$SOURCE_REPO" ]; then
+    printf '%s/%s\n' "$WORKTREE_REPO" "$file"
+  else
+    printf '%s\n' "$file"
+  fi
+}
+
+spec_command() {
+  local agent="codex"
+  local output=""
+  local force=false
+  local action=""
+  local context_items=()
+  local arg value
+  while [ "$#" -gt 0 ]; do
+    arg="$1"
+    shift
+    case "$arg" in
+      --agent)
+        if [ "$#" -eq 0 ]; then printf '%s\n' "--agent requires a value" >&2; spec_usage >&2; return 2; fi
+        agent="$1"
+        shift
+        ;;
+      --output|-o)
+        if [ "$#" -eq 0 ]; then printf '%s\n' "--output requires a value" >&2; spec_usage >&2; return 2; fi
+        output="$1"
+        shift
+        ;;
+      --force) force=true ;;
+      --print-skill) action="print-skill" ;;
+      --skill-path) action="skill-path" ;;
+      -h|--help) spec_usage; return 0 ;;
+      --*)
+        printf 'unknown option: %s\n' "$arg" >&2
+        spec_usage >&2
+        return 2
+        ;;
+      *) context_items+=("$arg") ;;
+    esac
+  done
+
+  local skill="$ROOT_DIR/skills/spec/SKILL.md"
+  case "$action" in
+    print-skill) cat "$skill"; return ;;
+    skill-path) printf '%s\n' "$skill"; return ;;
+  esac
+
+  local context today prompt cmd_args=()
+  context="$(resolve_spec_context "${context_items[@]}")"
+  today="$(date +%F)"
+  prompt="$(spec_prompt "$context" "$output" "$(cat "$skill")" "$today")"
+  if [ "$agent" = "codex" ]; then
+    run_with_prompt "$PWD" "" "" "$prompt" codex exec "${CODEX_REASONING_ARGS[@]}" -s read-only -C "$PWD" -
+  elif [ "$agent" = "claude" ]; then
+    run_with_prompt "$PWD" "" "" "$prompt" claude -p "${CLAUDE_EFFORT_ARGS[@]}" --add-dir "$PWD"
+  else
+    run_with_prompt "$PWD" "" "" "$prompt" "$agent"
+  fi
+  if [ "$RUN_CODE" -ne 0 ]; then
+    if [ -n "$RUN_STDERR" ]; then printf '%s\n' "$RUN_STDERR" >&2; else printf '%s\n' "${RUN_STDOUT:-spec agent failed}" >&2; fi
+    return 2
+  fi
+
+  local markdown file
+  if ! markdown="$(extract_generated_spec "$RUN_STDOUT$RUN_STDERR")"; then
+    printf '%s\n' "agent output must include spec frontmatter" >&2
+    return 2
+  fi
+  file="$(generated_spec_path "$markdown" "$output" "$today" "$force")"
+  if [ -f "$file" ] && [ "$force" = false ]; then
+    printf 'spec already exists: %s\n' "$file" >&2
+    return 2
+  fi
+  mkdir -p "$(dirname "$file")"
+  printf '%s\n' "$markdown" > "$file"
+  printf 'spec:  %s\n' "$file"
+  printf 'agent: %s\n' "$agent"
+}
+
+resolve_spec_context() {
+  if [ "$#" -eq 0 ]; then
+    printf '%s\n' "No source material was provided. Use the cold-start interview path in the bundled skill to discover intent before writing the spec."
+    return
+  fi
+  local first=true
+  local item file
+  for item in "$@"; do
+    if [ "$first" = false ]; then printf '\n---\n\n'; fi
+    first=false
+    if [ -f "$item" ]; then
+      file="$(absolute_existing_file "$item")"
+      printf 'Source file: %s\n\n' "$file"
+      cat "$file"
+    else
+      printf 'Context:\n%s\n' "$item"
+    fi
+  done
+}
+
+spec_prompt() {
+  local context="$1"
+  local output="$2"
+  local skill="$3"
+  local today="$4"
+  local output_line
+  if [ -n "$output" ]; then
+    output_line="Output path: $output"
+  else
+    output_line="Output path: choose a .specs/YYYY-MM-DD-<slug>.md path if you write a file; otherwise return markdown on stdout."
+  fi
+  cat <<EOF
+Use this bundled devloop skill to produce one implementation spec.
+
+Current date: $today
+$output_line
+
+If the source context is missing or too thin, follow the skill's interview path before drafting. Return only the final markdown spec. Do not wrap it in a code fence.
+
+Bundled skill:
+$skill
+
+Context:
+$context
+EOF
+}
+
+extract_generated_spec() {
+  local output="$1"
+  local tmp stripped next
+  tmp="$(mktemp "${TMPDIR:-/tmp}/devloop-spec.XXXXXX")"
+  stripped="$(mktemp "${TMPDIR:-/tmp}/devloop-spec-stripped.XXXXXX")"
+  printf '%s\n' "$output" > "$tmp"
+  cp "$tmp" "$stripped"
+  if head -n 1 "$stripped" | grep -Eq '^[[:space:]]*```(markdown|md)?[[:space:]]*$'; then
+    next="$(mktemp "${TMPDIR:-/tmp}/devloop-spec-next.XXXXXX")"
+    sed '1d' "$stripped" > "$next"
+    mv "$next" "$stripped"
+  fi
+  if tail -n 1 "$stripped" | grep -Eq '^[[:space:]]*```[[:space:]]*$'; then
+    next="$(mktemp "${TMPDIR:-/tmp}/devloop-spec-next.XXXXXX")"
+    sed '$d' "$stripped" > "$next"
+    mv "$next" "$stripped"
+  fi
+  if ! grep -q '^---[[:space:]]*$' "$stripped"; then
+    rm -f "$tmp" "$stripped"
+    return 1
+  fi
+  awk 'found || /^---[[:space:]]*$/ { found = 1; print }' "$stripped" | sed -E '${/^[[:space:]]*$/d;}'
+  rm -f "$tmp" "$stripped"
+}
+
+generated_spec_path() {
+  local markdown="$1"
+  local output="$2"
+  local today="$3"
+  local force="$4"
+  local title slug file base ext index
+  if [ -n "$output" ]; then
+    absolute_path "$output"
+    return
+  fi
+  title="$(printf '%s\n' "$markdown" | sed -nE 's/^#[[:space:]]+(.+)/\1/p' | head -n 1)"
+  slug="$(slugify "${title:-spec}")"
+  if [ -z "$slug" ]; then slug="spec"; fi
+  file="$PWD/.specs/$today-$slug.md"
+  if [ "$force" = true ]; then
+    printf '%s\n' "$file"
+    return
+  fi
+  base="${file%.md}"
+  ext=".md"
+  index=2
+  while [ -e "$file" ]; do
+    file="$base-$index$ext"
+    index=$((index + 1))
+  done
+  printf '%s\n' "$file"
+}
+
+main "$@"
diff --git a/install.sh b/install.sh
new file mode 100755
index 0000000..fbe3d26
--- /dev/null
+++ b/install.sh
@@ -0,0 +1,40 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+SCRIPT_PATH="${BASH_SOURCE[0]}"
+while [ -L "$SCRIPT_PATH" ]; do
+  SCRIPT_DIR="$(cd -P "$(dirname "$SCRIPT_PATH")" >/dev/null 2>&1 && pwd)"
+  SCRIPT_TARGET="$(readlink "$SCRIPT_PATH")"
+  case "$SCRIPT_TARGET" in
+    /*) SCRIPT_PATH="$SCRIPT_TARGET" ;;
+    *) SCRIPT_PATH="$SCRIPT_DIR/$SCRIPT_TARGET" ;;
+  esac
+done
+
+ROOT="$(cd -P "$(dirname "$SCRIPT_PATH")" >/dev/null 2>&1 && pwd)"
+BIN_DIR="${DEVLOOP_BIN_DIR:-$HOME/.local/bin}"
+TARGET="$BIN_DIR/devloop"
+SOURCE="$ROOT/devloop"
+
+if [ ! -f "$SOURCE" ]; then
+  echo "missing devloop executable: $SOURCE" >&2
+  exit 1
+fi
+
+mkdir -p "$BIN_DIR"
+chmod +x "$SOURCE"
+ln -sfn "$SOURCE" "$TARGET"
+
+echo "installed devloop -> $SOURCE"
+
+case ":${PATH:-}:" in
+  *":$BIN_DIR:"*) ;;
+  *)
+    echo
+    echo "$BIN_DIR is not on PATH. Add this to your shell profile:"
+    echo "export PATH=\"$BIN_DIR:\$PATH\""
+    ;;
+esac
+
+echo
+echo "try: devloop --help"

From 530cbbe931fc919e58d8300e62201ff691715eaf Mon Sep 17 00:00:00 2001
From: satyaborg <satya.borg@gmail.com>
Date: Fri, 29 May 2026 11:14:48 +1000
Subject: [PATCH 2/3] chore: remove legacy typescript project

---
 .github/ISSUE_TEMPLATE/bug_report.yml |    4 +-
 .github/PULL_REQUEST_TEMPLATE.md      |    4 +-
 .github/workflows/ci.yml              |   16 +-
 .github/workflows/publish.yml         |   55 -
 .github/workflows/release.yml         |    2 +-
 AGENTS.md                             |   15 +-
 CONTRIBUTING.md                       |   36 +-
 SECURITY.md                           |    4 +-
 bun.lock                              |   61 -
 bunfig.toml                           |    5 -
 package.json                          |   57 -
 release-please-config.json            |    4 +-
 scripts/install.ts                    |   42 -
 scripts/package-smoke.ts              |   88 --
 src/agent-options.ts                  |    2 -
 src/cli.ts                            |  127 ---
 src/devloop.ts                        | 1499 -------------------------
 src/spec.ts                           |  253 -----
 src/tui-view.ts                       |   44 -
 src/tui.ts                            |   51 -
 tests/devloop.test.ts                 |  770 -------------
 tests/devloop_test.sh                 |   60 +-
 tests/package.test.ts                 |  177 ---
 tests/spec.test.ts                    |  199 ----
 tests/tui-view.test.ts                |   91 --
 tsconfig.json                         |   14 -
 26 files changed, 79 insertions(+), 3601 deletions(-)
 delete mode 100644 .github/workflows/publish.yml
 delete mode 100644 bun.lock
 delete mode 100644 bunfig.toml
 delete mode 100644 package.json
 delete mode 100644 scripts/install.ts
 delete mode 100644 scripts/package-smoke.ts
 delete mode 100644 src/agent-options.ts
 delete mode 100755 src/cli.ts
 delete mode 100644 src/devloop.ts
 delete mode 100644 src/spec.ts
 delete mode 100644 src/tui-view.ts
 delete mode 100644 src/tui.ts
 delete mode 100644 tests/devloop.test.ts
 delete mode 100644 tests/package.test.ts
 delete mode 100644 tests/spec.test.ts
 delete mode 100644 tests/tui-view.test.ts
 delete mode 100644 tsconfig.json

diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml
index fdde8dd..836a4e6 100644
--- a/.github/ISSUE_TEMPLATE/bug_report.yml
+++ b/.github/ISSUE_TEMPLATE/bug_report.yml
@@ -23,12 +23,12 @@ body:
     id: version
     attributes:
       label: devloop version
-      placeholder: "@satyaborg/devloop 0.1.0"
+      placeholder: "Git commit or release tag"
   - type: textarea
     id: environment
     attributes:
       label: Environment
-      description: Bun version, OS, shell, and configured coder/reviewer agents.
+      description: OS, Bash version, git version, and configured coder/reviewer agents.
   - type: textarea
     id: logs
     attributes:
diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md
index 2303259..2897073 100644
--- a/.github/PULL_REQUEST_TEMPLATE.md
+++ b/.github/PULL_REQUEST_TEMPLATE.md
@@ -4,9 +4,7 @@
 
 ## Verification
 
-- [ ] `bun run typecheck`
-- [ ] `bun test`
-- [ ] `bun run package:smoke` when packaging, install docs, or release automation changes
+- [ ] `bash tests/devloop_test.sh`
 
 ## Notes
 
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 034874f..5a65711 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -11,20 +11,6 @@ permissions:
 jobs:
   test:
     runs-on: ubuntu-latest
-    env:
-      NPM_CONFIG_CACHE: ${{ runner.temp }}/devloop-npm-cache
     steps:
       - uses: actions/checkout@v6
-      - uses: oven-sh/setup-bun@v2
-        with:
-          bun-version: latest
-      - uses: actions/setup-node@v6
-        with:
-          node-version: 24
-          registry-url: https://registry.npmjs.org
-          package-manager-cache: false
-      - run: bun install --frozen-lockfile
-      - run: bun run typecheck
-      - run: bun test
-      - run: npm --cache "$NPM_CONFIG_CACHE" pack --dry-run --json
-      - run: bun run package:smoke
+      - run: bash tests/devloop_test.sh
diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml
deleted file mode 100644
index d485257..0000000
--- a/.github/workflows/publish.yml
+++ /dev/null
@@ -1,55 +0,0 @@
-name: Publish
-
-on:
-  workflow_run:
-    workflows: ["Release Please"]
-    types: [completed]
-
-permissions:
-  contents: read
-  id-token: write
-
-jobs:
-  npm:
-    if: github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.head_branch == 'main'
-    runs-on: ubuntu-latest
-    env:
-      NPM_CONFIG_CACHE: ${{ runner.temp }}/devloop-npm-cache
-    steps:
-      - uses: actions/checkout@v6
-        with:
-          ref: main
-      - uses: oven-sh/setup-bun@v2
-        with:
-          bun-version: latest
-      - uses: actions/setup-node@v6
-        with:
-          node-version: 24
-          registry-url: https://registry.npmjs.org
-          package-manager-cache: false
-      - run: bun install --frozen-lockfile
-      - id: publishable
-        env:
-          GH_TOKEN: ${{ github.token }}
-        run: |
-          VERSION="$(node -p "require('./package.json').version")"
-          echo "version=$VERSION" >> "$GITHUB_OUTPUT"
-          if ! gh release view "v$VERSION" >/dev/null 2>&1; then
-            echo "publish=false" >> "$GITHUB_OUTPUT"
-            exit 0
-          fi
-          if npm --cache "$NPM_CONFIG_CACHE" view "@satyaborg/devloop@$VERSION" version >/dev/null 2>&1; then
-            echo "publish=false" >> "$GITHUB_OUTPUT"
-            exit 0
-          fi
-          echo "publish=true" >> "$GITHUB_OUTPUT"
-      - if: steps.publishable.outputs.publish == 'true'
-        run: bun run typecheck
-      - if: steps.publishable.outputs.publish == 'true'
-        run: bun test
-      - if: steps.publishable.outputs.publish == 'true'
-        run: npm --cache "$NPM_CONFIG_CACHE" pack --dry-run --json
-      - if: steps.publishable.outputs.publish == 'true'
-        run: bun run package:smoke
-      - if: steps.publishable.outputs.publish == 'true'
-        run: npm publish
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index c1d4c9d..5896864 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -18,4 +18,4 @@ jobs:
           token: ${{ secrets.RELEASE_PLEASE_TOKEN || github.token }}
           config-file: release-please-config.json
           manifest-file: .release-please-manifest.json
-          # release-please-config.json sets release-type: node and CHANGELOG.md.
+          # release-please-config.json sets release metadata and CHANGELOG.md.
diff --git a/AGENTS.md b/AGENTS.md
index 24d2f32..a1485df 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -2,26 +2,25 @@
 
 ## Project Structure & Module Organization
 
-This is a Bun and TypeScript CLI project. Core source lives in `src/`: `cli.ts` handles command-line entry, `devloop.ts` contains the loop and parsing logic, and `tui*.ts` owns the OpenTUI interface. Tests live in `tests/` and mirror the main modules, for example `tests/devloop.test.ts` and `tests/tui-view.test.ts`. `templates/spec.md` is the starter spec copied by users, while `scripts/install.ts` installs dependencies and links the local `devloop` binary. Generated runtime output belongs under `.codex/` in target repositories and should not be committed here.
+This is a Bash CLI project. The active runtime is the root `devloop` executable. `install.sh` links it into a local bin directory, `tests/devloop_test.sh` covers the shell runtime, `skills/spec/SKILL.md` is the spec-generation prompt asset, and `templates/spec.md` is the starter spec. Generated runtime output belongs under `.codex/` in target repositories and should not be committed here.
 
 ## Build, Test, and Development Commands
 
-- `bun scripts/install.ts`: install dependencies and link `devloop` into `~/.local/bin` or `DEVLOOP_BIN_DIR`.
-- `bun test`: run the Bun test suite with coverage enabled.
-- `bun run typecheck`: run `tsc --noEmit` against `src/**/*.ts` and `tests/**/*.ts`.
-- `devloop --plain .specs/change.md`: example local CLI invocation from a target git worktree.
+- `bash tests/devloop_test.sh`: run the shell test suite.
+- `./install.sh`: link `devloop` into `~/.local/bin` or `DEVLOOP_BIN_DIR`.
+- `./devloop --plain .specs/change.md`: example local CLI invocation from a target git worktree.
 
 ## Coding Style & Naming Conventions
 
-Use strict TypeScript modules with explicit `.ts` import extensions, matching the existing `moduleResolution: "Bundler"` setup. Keep code formatted with two-space indentation, double quotes, semicolons, and small named functions. Prefer clear camelCase names for variables and functions, PascalCase for exported types, and kebab-case for branch/spec slugs such as `devloop/change`.
+Use readable Bash with small named functions, quoted expansions, explicit status handling, and portable macOS/Linux shell utilities where practical. Prefer kebab-case for branch/spec slugs such as `devloop/change`.
 
 ## Testing Guidelines
 
-Tests use `bun:test`. Name test files `*.test.ts` and keep behavior grouped with `describe` and `test`. `bunfig.toml` requires 100% line, function, and statement coverage for non-test TypeScript files, so update tests with every behavior change. For CLI behavior, prefer fixture-style tests that assert generated files, git state, status codes, and user-visible output.
+Tests use `tests/devloop_test.sh`. Keep behavior fixture-style where possible: assert generated files, git state, status codes, and user-visible output.
 
 ## Commit & Pull Request Guidelines
 
-Git history follows Conventional Commits, for example `fix: surface devloop commit failures` and `chore: cover tui view rendering`. Use concise subjects in imperative style and keep unrelated changes in separate commits. Pull requests should include a short problem summary, the implementation approach, test evidence (`bun test`, `bun run typecheck`), and screenshots or terminal captures when changing TUI behavior.
+Git history follows Conventional Commits, for example `fix: surface devloop commit failures` and `chore: cover shell runtime`. Use concise subjects in imperative style and keep unrelated changes in separate commits. Pull requests should include a short problem summary, the implementation approach, and test evidence (`bash tests/devloop_test.sh`).
 
 ## Agent-Specific Instructions
 
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 43d7090..bb8296f 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -1,17 +1,14 @@
 # Contributing
 
-devloop is a Bun and TypeScript CLI. Keep changes narrow, typed, and covered by tests.
+devloop is a Bash CLI. Keep changes narrow, readable, and covered by shell tests.
 
 ## Local Setup
 
 ```sh
-bun scripts/install.ts
-bun run typecheck
-bun test
-bun run package:smoke
+bash tests/devloop_test.sh
 ```
 
-`bun test` is configured with 100% line, function, and statement coverage for non-test TypeScript files.
+The active executable is `./devloop`; install locally with `./install.sh`.
 
 ## Pull Requests
 
@@ -23,31 +20,6 @@ bun run package:smoke
 
 ## Release Automation
 
-Release Please manages version bumps from Conventional Commits, opens release PRs, updates `CHANGELOG.md`, and creates GitHub releases when release PRs merge. The repository uses `release-please-config.json` with the `node` release type for `@satyaborg/devloop`.
+Release Please manages version bumps from Conventional Commits, opens release PRs, updates `CHANGELOG.md`, and creates GitHub releases when release PRs merge.
 
 The release workflow can use the default GitHub token, but GitHub-token-created pull requests and releases can have workflow trigger limitations. Maintainers who need CI to run on Release Please PRs should create a fine-grained `RELEASE_PLEASE_TOKEN` with repository contents and pull request permissions, then store it as a GitHub Actions secret. The workflow falls back to the default token when that secret is absent.
-
-## npm Publishing
-
-The package is published as `@satyaborg/devloop`; the installed binary remains `devloop`.
-
-Publishing is handled by `.github/workflows/publish.yml`. It runs after the Release Please workflow completes, verifies that a matching GitHub release exists for the package version, skips versions that are already on npm, reruns typecheck/tests/package checks, and then runs `npm publish`.
-
-The publish workflow uses npm trusted publishing through GitHub Actions OIDC. It intentionally does not use a long-lived `NPM_TOKEN` or `NODE_AUTH_TOKEN`.
-
-One-time npm setup before the first automated publish:
-
-1. Ensure the `@satyaborg` npm scope is owned by the maintainer account.
-2. `@satyaborg/devloop` must be created or first-published by a maintainer as a public package if npm requires an existing package before trusted publishing can be configured.
-3. In npm package settings, add a trusted publisher for GitHub Actions.
-4. Configure owner `satyaborg`, repository `devloop`, workflow filename `publish.yml`, and allowed action `npm publish`.
-5. Leave the environment blank unless the GitHub workflow is later changed to use a protected environment.
-
-For a manual first publish, verify locally first:
-
-```sh
-bun run typecheck
-bun test
-npm --cache /private/tmp/devloop-npm-cache pack --dry-run --json
-bun run package:smoke
-```
diff --git a/SECURITY.md b/SECURITY.md
index 8a611d9..fccdb37 100644
--- a/SECURITY.md
+++ b/SECURITY.md
@@ -2,7 +2,7 @@
 
 ## Security Model
 
-devloop runs local agent CLIs with broad permissions because the configured coder and reviewer need access to inspect and modify a checkout. By default, devloop creates isolated sibling git worktrees before invoking those agents, but the agents still run on your machine with your local credentials, environment variables, PATH, and filesystem permissions.
+devloop is a Bash harness that runs local agent CLIs with broad permissions because the configured coder and reviewer need access to inspect and modify a checkout. By default, devloop creates isolated sibling git worktrees before invoking those agents, but the agents still run on your machine with your local credentials, environment variables, PATH, and filesystem permissions.
 
 devloop writes `.codex/` artifacts for specs, tracks, reviews, reports, logs, and session ids. Treat those files as local development artifacts that may contain prompts, review text, command output, and repository paths.
 
@@ -18,4 +18,4 @@ If that is not available, open a minimal issue that does not include exploit det
 
 ## Supported Versions
 
-Security fixes target the latest released npm package and the `main` branch.
+Security fixes target the root `devloop` executable on the `main` branch and the latest GitHub release.
diff --git a/bun.lock b/bun.lock
deleted file mode 100644
index 0facbb4..0000000
--- a/bun.lock
+++ /dev/null
@@ -1,61 +0,0 @@
-{
-  "lockfileVersion": 1,
-  "configVersion": 1,
-  "workspaces": {
-    "": {
-      "name": "@satyaborg/devloop",
-      "dependencies": {
-        "@opentui/core": "^0.2.15",
-      },
-      "devDependencies": {
-        "@types/bun": "^1.3.1",
-        "typescript": "^5.9.3",
-      },
-    },
-  },
-  "packages": {
-    "@opentui/core": ["@opentui/core@0.2.15", "", { "dependencies": { "bun-ffi-structs": "0.2.2", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.2.15", "@opentui/core-darwin-x64": "0.2.15", "@opentui/core-linux-arm64": "0.2.15", "@opentui/core-linux-x64": "0.2.15", "@opentui/core-win32-arm64": "0.2.15", "@opentui/core-win32-x64": "0.2.15" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-YGHttdZWScMcSvtYgZkLR6VhUO1OoUiQzwYjZgIusf5eCkPLD8PapH+PTMVqAiX16CHO6JxfMlkHv5qDiHAccQ=="],
-
-    "@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.2.15", "", { "os": "darwin", "cpu": "arm64" }, "sha512-s25f9GmZd6wxNM5ExRmwwnLT+NLCKxnTWuO9aObOlqsXfLMGHQZrb6YwgAn/PSTua98KmH7GJCVWdPgZ/P+0RQ=="],
-
-    "@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.2.15", "", { "os": "darwin", "cpu": "x64" }, "sha512-GyaipN+nOcEr8rcTO2mqKTGmOBk0C300I69fLtubD3BadHcMI1DVNlQrcf/J1mkQEuMYbmBTi/1hT1ybWGr2Mw=="],
-
-    "@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.2.15", "", { "os": "linux", "cpu": "arm64" }, "sha512-h+uyufselGT4afKMP8Lg4yUl5Kp+DJBlhu3XpWXhphE5Pnq5+f0uGBr4P+34CNcWxMsDnvagSQLFRCS4rGrOWA=="],
-
-    "@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.2.15", "", { "os": "linux", "cpu": "x64" }, "sha512-jx+NImPq4wSp3Apfe7tlixiEJNnRyECTRJRWhGF6ZJz4PwFfgK2UHZKYR0DZHbV8nYawoDNQPJDXEWcoZShnMg=="],
-
-    "@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.2.15", "", { "os": "win32", "cpu": "arm64" }, "sha512-2SQQLvf3sgmToxrNika9AdcccKrjPJEn5jW6sSv0oEixNBzUzW41vSZZG4LM/V3lL8eg0LoYDnRZeKLB4gwSqQ=="],
-
-    "@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.2.15", "", { "os": "win32", "cpu": "x64" }, "sha512-SVMVgnC7LVEm+yVZKdmmhRBj/xAT94PanT+UCcHxaCWK+OLmv/AX+ohHq2m0odup6iXcEqj+7mAltO9fgJLFIg=="],
-
-    "@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="],
-
-    "@types/node": ["@types/node@25.9.1", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg=="],
-
-    "ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="],
-
-    "bun-ffi-structs": ["bun-ffi-structs@0.2.2", "", { "peerDependencies": { "typescript": "^5" } }, "sha512-N/ZWtyN0piZlrXQT7TO0V+q952orYqkfhXRXM1Hcbb+R3QSiBH4vLnib187Mrs1H7pWIYECAmPeapGYDOMCl+w=="],
-
-    "bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="],
-
-    "diff": ["diff@9.0.0", "", {}, "sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw=="],
-
-    "emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="],
-
-    "get-east-asian-width": ["get-east-asian-width@1.6.0", "", {}, "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA=="],
-
-    "marked": ["marked@17.0.1", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-boeBdiS0ghpWcSwoNm/jJBwdpFaMnZWRzjA6SkUMYb40SVaN1x7mmfGKp0jvexGcx+7y2La5zRZsYFZI6Qpypg=="],
-
-    "string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="],
-
-    "strip-ansi": ["strip-ansi@7.1.2", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA=="],
-
-    "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
-
-    "undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="],
-
-    "web-tree-sitter": ["web-tree-sitter@0.25.10", "", { "peerDependencies": { "@types/emscripten": "^1.40.0" }, "optionalPeers": ["@types/emscripten"] }, "sha512-Y09sF44/13XvgVKgO2cNDw5rGk6s26MgoZPXLESvMXeefBf7i6/73eFurre0IsTW6E14Y0ArIzhUMmjoc7xyzA=="],
-
-    "yoga-layout": ["yoga-layout@3.2.1", "", {}, "sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ=="],
-  }
-}
diff --git a/bunfig.toml b/bunfig.toml
deleted file mode 100644
index 72f3051..0000000
--- a/bunfig.toml
+++ /dev/null
@@ -1,5 +0,0 @@
-[test]
-coverage = true
-coverageThreshold = { lines = 1.0, functions = 1.0, statements = 1.0 }
-coverageReporter = ["text", "lcov"]
-coverageSkipTestFiles = true
diff --git a/package.json b/package.json
deleted file mode 100644
index c1b1b96..0000000
--- a/package.json
+++ /dev/null
@@ -1,57 +0,0 @@
-{
-  "name": "@satyaborg/devloop",
-  "version": "0.1.0",
-  "description": "Spec-driven code and review loop with Codex and Claude Code.",
-  "license": "MIT",
-  "author": {
-    "name": "@satyaborg",
-    "url": "https://satyaborg.com"
-  },
-  "repository": {
-    "type": "git",
-    "url": "git+https://github.com/satyaborg/devloop.git"
-  },
-  "bugs": {
-    "url": "https://github.com/satyaborg/devloop/issues"
-  },
-  "homepage": "https://github.com/satyaborg/devloop#readme",
-  "keywords": [
-    "agent",
-    "automation",
-    "claude",
-    "cli",
-    "codex",
-    "devloop"
-  ],
-  "type": "module",
-  "bin": {
-    "devloop": "./src/cli.ts"
-  },
-  "files": [
-    "src",
-    "skills/spec/SKILL.md",
-    "templates/spec.md",
-    "README.md",
-    "LICENSE"
-  ],
-  "publishConfig": {
-    "access": "public"
-  },
-  "engines": {
-    "bun": ">=1.2.0"
-  },
-  "packageManager": "bun@1.3.11",
-  "scripts": {
-    "install:local": "bun scripts/install.ts",
-    "package:smoke": "bun scripts/package-smoke.ts",
-    "test": "bun test",
-    "typecheck": "tsc --noEmit"
-  },
-  "dependencies": {
-    "@opentui/core": "^0.2.15"
-  },
-  "devDependencies": {
-    "@types/bun": "^1.3.1",
-    "typescript": "^5.9.3"
-  }
-}
diff --git a/release-please-config.json b/release-please-config.json
index 94d3c09..b58e9db 100644
--- a/release-please-config.json
+++ b/release-please-config.json
@@ -2,8 +2,8 @@
   "$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json",
   "packages": {
     ".": {
-      "release-type": "node",
-      "package-name": "@satyaborg/devloop",
+      "release-type": "simple",
+      "package-name": "devloop",
       "changelog-path": "CHANGELOG.md",
       "include-component-in-tag": false
     }
diff --git a/scripts/install.ts b/scripts/install.ts
deleted file mode 100644
index 34ab3ea..0000000
--- a/scripts/install.ts
+++ /dev/null
@@ -1,42 +0,0 @@
-#!/usr/bin/env bun
-import { chmod, mkdir, readlink, rm, symlink } from "node:fs/promises";
-import { homedir } from "node:os";
-import path from "node:path";
-import { fileURLToPath } from "node:url";
-
-const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
-const cli = path.join(root, "src", "cli.ts");
-const binDir = process.env.DEVLOOP_BIN_DIR ?? path.join(homedir(), ".local", "bin");
-const link = path.join(binDir, "devloop");
-
-await run(["bun", "install"], root);
-await mkdir(binDir, { recursive: true });
-await chmod(cli, 0o755);
-
-const existing = await readlink(link).catch(() => "");
-if (existing && path.resolve(binDir, existing) === cli) {
-  console.log(`devloop already points to ${cli}`);
-} else {
-  await rm(link, { force: true });
-  await symlink(cli, link);
-  console.log(`installed devloop -> ${cli}`);
-}
-
-if (!pathInEnv(binDir)) {
-  console.log("");
-  console.log(`${binDir} is not on PATH. Add this to ~/.zshrc:`);
-  console.log(`export PATH="${binDir}:$PATH"`);
-}
-
-console.log("");
-console.log("try: devloop --help");
-
-async function run(cmd: string[], cwd: string) {
-  const proc = Bun.spawn(cmd, { cwd, stdout: "inherit", stderr: "inherit" });
-  const code = await proc.exited;
-  if (code !== 0) process.exit(code);
-}
-
-function pathInEnv(dir: string) {
-  return (process.env.PATH ?? "").split(path.delimiter).some((entry) => path.resolve(entry) === dir);
-}
diff --git a/scripts/package-smoke.ts b/scripts/package-smoke.ts
deleted file mode 100644
index 189030c..0000000
--- a/scripts/package-smoke.ts
+++ /dev/null
@@ -1,88 +0,0 @@
-#!/usr/bin/env bun
-import { mkdir, mkdtemp, rm } from "node:fs/promises";
-import { tmpdir } from "node:os";
-import path from "node:path";
-import { fileURLToPath } from "node:url";
-
-type PackFile = { path: string };
-type PackResult = {
-  filename: string;
-  files: PackFile[];
-  name: string;
-  version: string;
-};
-
-const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
-const work = await mkdtemp(path.join(tmpdir(), "devloop-package-smoke."));
-const cache = path.join(work, "npm-cache");
-const packDir = path.join(work, "pack");
-const prefix = path.join(work, "prefix");
-
-try {
-  await mkdir(packDir, { recursive: true });
-  const output = await run(["npm", "--cache", cache, "pack", "--json", "--pack-destination", packDir], root);
-  const [pack] = JSON.parse(output) as PackResult[];
-  if (!pack) throw new Error("npm pack did not return package metadata");
-
-  const paths = pack.files.map((file) => file.path).sort();
-  for (const required of [
-    "package.json",
-    "README.md",
-    "LICENSE",
-    "src/cli.ts",
-    "src/devloop.ts",
-    "src/spec.ts",
-    "src/tui.ts",
-    "src/tui-view.ts",
-    "skills/spec/SKILL.md",
-    "templates/spec.md",
-  ]) {
-    if (!paths.includes(required)) throw new Error(`packed package is missing ${required}`);
-  }
-
-  for (const excluded of [
-    "AGENTS.md",
-    "bunfig.toml",
-    "tsconfig.json",
-    "scripts/install.ts",
-  ]) {
-    if (paths.includes(excluded)) throw new Error(`packed package includes ${excluded}`);
-  }
-
-  for (const excludedPrefix of ["tests/", "coverage/", ".codex/", ".specs/", ".github/"]) {
-    const match = paths.find((item) => item.startsWith(excludedPrefix));
-    if (match) throw new Error(`packed package includes ${match}`);
-  }
-
-  const tarball = path.join(packDir, pack.filename);
-  await run(["npm", "--cache", cache, "install", "--global", tarball, "--prefix", prefix, "--no-audit", "--fund=false"], root);
-  const help = await run([installedBin(prefix), "--help"], root);
-  if (!help.includes("Common commands:")) throw new Error("installed devloop --help did not print CLI help");
-
-  console.log(`package smoke passed: ${pack.name}@${pack.version}`);
-} finally {
-  await rm(work, { recursive: true, force: true });
-}
-
-async function run(cmd: string[], cwd: string) {
-  const proc = Bun.spawn(cmd, {
-    cwd,
-    env: Bun.env,
-    stdout: "pipe",
-    stderr: "pipe",
-  });
-  const [stdout, stderr, code] = await Promise.all([
-    new Response(proc.stdout).text(),
-    new Response(proc.stderr).text(),
-    proc.exited,
-  ]);
-  if (code !== 0)
-    throw new Error(`${cmd.join(" ")} failed with ${code}\n${stdout}${stderr}`.trim());
-  return stdout;
-}
-
-function installedBin(prefixDir: string) {
-  return process.platform === "win32"
-    ? path.join(prefixDir, "devloop.cmd")
-    : path.join(prefixDir, "bin", "devloop");
-}
diff --git a/src/agent-options.ts b/src/agent-options.ts
deleted file mode 100644
index fca1935..0000000
--- a/src/agent-options.ts
+++ /dev/null
@@ -1,2 +0,0 @@
-export const CODEX_REASONING_ARGS = ["-c", 'model_reasoning_effort="xhigh"'] as const;
-export const CLAUDE_EFFORT_ARGS = ["--effort", "max"] as const;
diff --git a/src/cli.ts b/src/cli.ts
deleted file mode 100755
index 129fc39..0000000
--- a/src/cli.ts
+++ /dev/null
@@ -1,127 +0,0 @@
-#!/usr/bin/env bun
-import {
-  isIsolatedWorktree,
-  parseArgs,
-  resultPath,
-  runDevloop,
-  welcome,
-  type Event,
-  type Sink,
-} from "./devloop.ts";
-import {
-  bundledSpecSkillPath,
-  generateSpec,
-  parseSpecArgs,
-  readBundledSpecSkill,
-} from "./spec.ts";
-import { createTuiSink } from "./tui.ts";
-
-const argv = process.argv.slice(2);
-if (argv[0] === "spec") await runSpecCommand(argv.slice(1));
-
-if (argv.length === 0 || argv.includes("-h") || argv.includes("--help")) {
-  console.log(welcome());
-  process.exit(0);
-}
-
-const parsed = parseArgs(argv);
-
-if (typeof parsed === "string") {
-  console.error(parsed);
-  process.exit(argv.includes("-h") || argv.includes("--help") ? 0 : 2);
-}
-
-const useTui = argv.includes("--tui") || (!argv.includes("--plain") && Boolean(process.stdout.isTTY));
-const sink = useTui ? await createTuiSink() : plainSink();
-
-try {
-  const result = await runDevloop(parsed, sink);
-  await sink.close?.();
-  if (useTui) printResult(result);
-  process.exit(result.status === "accepted" ? 0 : result.status === "stalled" || result.status === "max-turns" || result.status === "unclear" ? 1 : 2);
-} catch (error) {
-  await sink.close?.();
-  console.error(error instanceof Error ? error.message : String(error));
-  process.exit(2);
-}
-
-function plainSink(): Sink {
-  return {
-    event(event: Event) {
-      if (event.type === "step") console.error(`[devloop] ${event.title}`);
-      else if (event.type === "done") console.error(`[devloop] ${event.detail}`);
-      else if (event.type === "gate") console.error(`[devloop] ${event.name}: ${event.detail}`);
-      else if (event.type === "result") printResult(event.result);
-    },
-  };
-}
-
-function printResult(result: {
-  status: string;
-  passes: number;
-  max: number;
-  report: string;
-  track: string;
-  branch?: string;
-  commit?: string;
-  pullRequest?: string;
-  worktree?: string;
-  sourceRepo?: string;
-  coder?: string;
-  reviewer?: string;
-}) {
-  console.log("");
-  console.log(resultLine("result", result.status));
-  console.log(resultLine("passes", `${result.passes} / ${result.max}`));
-  if (result.coder) console.log(resultLine("coder", result.coder));
-  if (result.reviewer) console.log(resultLine("reviewer", result.reviewer));
-  if (result.branch) console.log(resultLine("branch", result.branch));
-  if (result.commit !== undefined)
-    console.log(resultLine("commit", result.commit || "none"));
-  if (result.pullRequest) console.log(resultLine("pr", result.pullRequest));
-  if (hasWorktreeInfo(result) && isIsolatedWorktree(result))
-    console.log(resultLine("worktree", result.worktree));
-  console.log(resultLine("report", displayPath(result, result.report)));
-  console.log(resultLine("track", displayPath(result, result.track)));
-}
-
-function hasWorktreeInfo(result: {
-  worktree?: string;
-  sourceRepo?: string;
-}): result is { worktree: string; sourceRepo: string } {
-  return Boolean(result.worktree && result.sourceRepo);
-}
-
-function displayPath(
-  result: { worktree?: string; sourceRepo?: string },
-  file: string,
-) {
-  return hasWorktreeInfo(result) ? resultPath(result, file) : file;
-}
-
-function resultLine(label: string, value: string) {
-  return `${`${label}:`.padEnd(10)}${value}`;
-}
-
-async function runSpecCommand(argv: string[]) {
-  const parsed = parseSpecArgs(argv);
-  if (typeof parsed === "string") {
-    const help = argv.includes("-h") || argv.includes("--help");
-    console[help ? "log" : "error"](parsed);
-    process.exit(help ? 0 : 2);
-  }
-
-  try {
-    if (parsed.type === "print-skill") console.log(await readBundledSpecSkill());
-    else if (parsed.type === "skill-path") console.log(bundledSpecSkillPath());
-    else {
-      const result = await generateSpec(parsed.options);
-      console.log(`spec:  ${result.file}`);
-      console.log(`agent: ${result.agent}`);
-    }
-    process.exit(0);
-  } catch (error) {
-    console.error(error instanceof Error ? error.message : String(error));
-    process.exit(2);
-  }
-}
diff --git a/src/devloop.ts b/src/devloop.ts
deleted file mode 100644
index 034dd60..0000000
--- a/src/devloop.ts
+++ /dev/null
@@ -1,1499 +0,0 @@
-import { createHash, randomUUID } from "node:crypto";
-import {
-  copyFile,
-  mkdir,
-  mkdtemp,
-  readFile,
-  realpath,
-  rm,
-  stat,
-  writeFile,
-} from "node:fs/promises";
-import { tmpdir } from "node:os";
-import path from "node:path";
-import { CLAUDE_EFFORT_ARGS, CODEX_REASONING_ARGS } from "./agent-options.ts";
-
-export type ReportFormat = "html" | "markdown";
-export type Agent = "codex" | "claude";
-export type Verdict = "ACCEPT" | "REJECT" | "UNCLEAR";
-export type Status =
-  | "accepted"
-  | "stalled"
-  | "max-turns"
-  | "unclear"
-  | "no-verdict"
-  | "coder-error"
-  | "reviewer-error"
-  | "review-missing"
-  | "commit-error"
-  | "pr-error";
-
-type WorkType = "feat" | "fix" | "chore";
-type WorkItem = {
-  slug: string;
-  type: WorkType;
-  breaking: boolean;
-};
-
-export type CommitRecord = {
-  pass: number;
-  commit: string;
-  message: string;
-  paths: string[];
-};
-
-export type Options = {
-  spec: string;
-  max: number;
-  reportFormat: ReportFormat;
-  strict: boolean;
-  worktree: boolean;
-  coder: Agent;
-  reviewer: Agent;
-  cwd: string;
-  createPr?: boolean;
-};
-
-export type Result = {
-  status: Status;
-  passes: number;
-  max: number;
-  report: string;
-  track: string;
-  branch: string;
-  commit: string;
-  commitMessage: string;
-  commits: CommitRecord[];
-  worktree: string;
-  sourceRepo: string;
-  coder: Agent;
-  reviewer: Agent;
-  coderSessionId: string;
-  reviewerSessionId: string;
-  pullRequest?: string;
-  pullRequestError?: string;
-};
-
-export type Event =
-  | { type: "gate"; name: string; ok: boolean; detail: string }
-  | { type: "step"; id: string; title: string }
-  | { type: "log"; id: string; line: string }
-  | { type: "done"; id: string; ok: boolean; detail: string }
-  | { type: "result"; result: Result };
-
-export type Sink = {
-  event(event: Event): void | Promise<void>;
-  close?(): void | Promise<void>;
-};
-
-type RunResult = {
-  code: number;
-  output: string;
-  stdout: string;
-  stderr: string;
-};
-type Runner = (
-  cmd: string,
-  args: string[],
-  input?: string,
-  log?: string,
-  id?: string,
-) => Promise<RunResult>;
-
-export const LOGO = [
-  "       __          __                ",
-  "  ____/ /__ _   __/ /___  ____  ____ ",
-  " / __  / _ \\ | / / / __ \\/ __ \\/ __ \\",
-  "/ /_/ /  __/ |/ / / /_/ / /_/ / /_/ /",
-  "\\__,_/\\___/|___/_/\\____/\\____/ .___/ ",
-  "                            /_/",
-].join("\n");
-
-export function welcome() {
-  return `${LOGO}
-
-Spec-driven code and review loop. Codex implements and Claude Code reviews by default.
-
-Usage:
-  devloop [options] <spec.md> [max=5]
-
-Common commands:
-  devloop spec "add retry behavior to the chat sender"
-  devloop .specs/change.md
-  devloop --tui .specs/change.md
-  devloop --plain .specs/change.md
-  devloop --report-format markdown .specs/change.md 3
-  devloop --coder claude --reviewer codex .specs/change.md
-  devloop --create-pr .specs/change.md
-  bun scripts/install.ts
-
-Options:
-  --tui                         force the collapsed TUI
-  --plain                       force plain output
-  --coder codex|claude          choose the implementation agent
-  --reviewer codex|claude       choose the review agent
-  --report-format html|markdown choose report format
-  --no-strict                   weaken acceptance gates
-  --in-place                    run in the current worktree
-  --create-pr, --pr             push accepted branch and open a PR
-  -h, --help                    show this screen`;
-}
-
-export function parseArgs(
-  argv: string[],
-  cwd = process.cwd(),
-): Options | string {
-  let reportFormat: ReportFormat = "html";
-  let strict = true;
-  let worktree = true;
-  let coder: Agent = "codex";
-  let reviewer: Agent = "claude";
-  let createPr = false;
-  let spec = "";
-  let maxRaw = "5";
-  let maxSet = false;
-
-  for (let i = 0; i < argv.length; i++) {
-    const arg = argv[i]!;
-    if (arg === "--report-format") {
-      const value = argv[++i];
-      if (value !== "html" && value !== "markdown" && value !== "md")
-        return usage();
-      reportFormat = value === "md" ? "markdown" : value;
-    } else if (arg === "--coder") {
-      const value = parseAgent(argv[++i]);
-      if (!value) return `coder must be codex or claude\n${usage()}`;
-      coder = value;
-    } else if (arg === "--reviewer") {
-      const value = parseAgent(argv[++i]);
-      if (!value) return `reviewer must be codex or claude\n${usage()}`;
-      reviewer = value;
-    } else if (arg === "--html") reportFormat = "html";
-    else if (arg === "--markdown" || arg === "--md") reportFormat = "markdown";
-    else if (arg === "--no-strict") strict = false;
-    else if (arg === "--strict") strict = true;
-    else if (arg === "--in-place") worktree = false;
-    else if (arg === "--create-pr" || arg === "--pr") createPr = true;
-    else if (arg === "--plain" || arg === "--tui") continue;
-    else if (arg === "-h" || arg === "--help") return usage();
-    else if (arg.startsWith("--")) return `unknown option: ${arg}\n${usage()}`;
-    else if (!spec) spec = arg;
-    else if (!maxSet) {
-      maxRaw = arg;
-      maxSet = true;
-    } else return usage();
-  }
-
-  if (!spec) return usage();
-  if (!/^[+-]?\d+$/.test(maxRaw))
-    return "max must be an integer between 1 and 10";
-  return {
-    spec,
-    max: clamp(Number.parseInt(maxRaw, 10), 1, 10),
-    reportFormat,
-    strict,
-    worktree,
-    coder,
-    reviewer,
-    cwd,
-    createPr,
-  };
-}
-
-export function usage() {
-  return "usage: devloop [--plain|--tui] [--in-place] [--no-strict] [--create-pr|--pr] [--coder codex|claude] [--reviewer codex|claude] [--report-format html|markdown] <spec.md> [max=5]";
-}
-
-export function parseCriteria(markdown: string): string[] {
-  const lines = markdown.split(/\r?\n/);
-  const start = lines.findIndex((line) =>
-    /^##\s+acceptance criteria\s*$/i.test(line.trim()),
-  );
-  if (start < 0) return [];
-  const body = lines.slice(start + 1);
-  const end = body.findIndex((line) => /^##\s+/.test(line));
-  return body
-    .slice(0, end < 0 ? body.length : end)
-    .map((line) => line.trim().replace(/^([-*]|\d+[.)])\s+/, ""))
-    .filter(Boolean);
-}
-
-export function parseVerdict(review: string): Verdict | "" {
-  const match = review.match(/^Verdict:\s+(ACCEPT|REJECT|UNCLEAR)/m);
-  return match ? (match[1] as Verdict) : "";
-}
-
-export function hasPassingMatrix(review: string, count: number) {
-  if (!/^## Acceptance matrix\s*$/m.test(review)) return false;
-  return Array.from(
-    { length: count },
-    (_, i) => new RegExp(`^\\|\\s*AC${i + 1}\\s*\\|\\s*PASS\\s*\\|`, "mi"),
-  ).every((r) => r.test(review));
-}
-
-export function reportFraming(specText: string, slug: string) {
-  const title = reportTitle(specText) ?? titleFromSlug(slug);
-  return {
-    title,
-    subtitle:
-      sectionLead(specText, "Outcome") ??
-      sectionLead(specText, "Problem") ??
-      sectionLead(specText, "Acceptance criteria") ??
-      `Outcome, review findings, and residual risk for ${title}.`,
-  };
-}
-
-export function findingsHash(review: string) {
-  const body =
-    review.match(/^## Findings\s*\n([\s\S]*?)(?:\n##\s+|$)/m)?.[1] ?? "";
-  const normalized = body
-    .replace(/\d+/g, "")
-    .replace(/[ \t\r\n]+/g, " ")
-    .split(".")
-    .map((line) => line.trim())
-    .filter(Boolean)
-    .sort()
-    .join("\n");
-  return createHash("sha256").update(normalized).digest("hex");
-}
-
-export function isIsolatedWorktree(
-  result: Pick<Result, "sourceRepo" | "worktree">,
-) {
-  return result.worktree !== result.sourceRepo;
-}
-
-export function resultPath(
-  result: Pick<Result, "sourceRepo" | "worktree">,
-  file: string,
-) {
-  return isIsolatedWorktree(result) ? path.join(result.worktree, file) : file;
-}
-
-export async function runDevloop(
-  options: Options,
-  sink: Sink = { event: () => {} },
-): Promise<Result> {
-  const spec = await absoluteFile(options.spec, options.cwd);
-  const specText = await readFile(spec, "utf8");
-  const criteria = parseCriteria(specText);
-  if (options.strict && criteria.length === 0)
-    throw new Error("strict mode requires ## Acceptance criteria");
-  await sink.event({
-    type: "gate",
-    name: "acceptance criteria",
-    ok: criteria.length > 0,
-    detail: `${criteria.length} found`,
-  });
-
-  const sourceRepo = (
-    await command("git", ["-C", options.cwd, "rev-parse", "--show-toplevel"])
-  ).trim();
-  const sourceBranch = (
-    await command("git", [
-      "-C",
-      sourceRepo,
-      "rev-parse",
-      "--abbrev-ref",
-      "HEAD",
-    ])
-  ).trim();
-  const base = await baseBranch(sourceRepo);
-  const namingId = "naming";
-  await sink.event({
-    type: "step",
-    id: namingId,
-    title: `derive branch name with ${agentLabel(options.coder)}`,
-  });
-  let namingLog = "";
-  let namingError = "";
-  const work = await (async () => {
-    const fields = workItemFields(specText);
-    namingLog = completeWorkItem(fields)
-      ? ""
-      : path.join(
-          await mkdtemp(path.join(tmpdir(), "devloop-naming.")),
-          "naming.log",
-        );
-    return resolveWorkItem({
-      agent: options.coder,
-      runner: makeRunner(sourceRepo, sink),
-      repo: sourceRepo,
-      spec,
-      specText,
-      fields,
-      log: namingLog,
-    });
-  })().catch((error) => {
-    namingError = error instanceof Error ? error.message : String(error);
-    return undefined;
-  });
-  await sink.event({
-    type: "done",
-    id: namingId,
-    ok: Boolean(work),
-    detail: work ? `${branchBase(work)}` : namingError,
-  });
-  if (!work)
-    throw new Error(
-      `naming failed: ${namingError}${namingLog ? `\nnaming log: ${namingLog}` : ""}`,
-    );
-  const slug = work.slug;
-
-  let repo = sourceRepo;
-  if (options.worktree) {
-    const worktreeId = "worktree";
-    await sink.event({
-      type: "step",
-      id: worktreeId,
-      title: "create worktree",
-    });
-    repo = await createWorktree(sourceRepo, work);
-    await sink.event({
-      type: "done",
-      id: worktreeId,
-      ok: true,
-      detail: repo,
-    });
-  }
-
-  const dirs = [
-    ".codex/specs",
-    ".codex/tracks",
-    ".codex/reviews",
-    ".codex/reports",
-    ".codex/logs",
-    ".codex/sessions",
-  ];
-  await Promise.all(
-    dirs.map((dir) => mkdir(path.join(repo, dir), { recursive: true })),
-  );
-  if (namingLog) {
-    await copyFile(namingLog, path.join(repo, ".codex/logs", `${slug}-naming.log`));
-    await rm(path.dirname(namingLog), { recursive: true, force: true });
-  }
-
-  const runSpec = options.worktree
-    ? await snapshotSpec(repo, slug, specText)
-    : spec;
-  const initialDirty = await statusPaths(repo);
-  const runBranch = (
-    await command("git", ["-C", repo, "branch", "--show-current"])
-  ).trim();
-  const track = `.codex/tracks/${slug}.md`;
-  const report = `.codex/reports/${slug}.${options.reportFormat === "html" ? "html" : "md"}`;
-  const coderSession = `.codex/sessions/${slug}-coder-${options.coder}.id`;
-  const reviewerSession = `.codex/sessions/${slug}-reviewer-${options.reviewer}.id`;
-  const runner = makeRunner(repo, sink);
-  await initTrack(path.join(repo, track), {
-    spec: runSpec,
-    sourceSpec: spec,
-    cwd: options.cwd,
-    sourceRepo,
-    worktree: repo,
-    base,
-    branch: sourceBranch,
-    worktreeBranch: runBranch,
-    max: options.max,
-    reportFormat: options.reportFormat,
-    strict: options.strict,
-    coder: options.coder,
-    reviewer: options.reviewer,
-    type: work.type,
-    breaking: work.breaking,
-    createPr: Boolean(options.createPr),
-  });
-
-  let status: Status = "max-turns";
-  let prior = "";
-  let pass = 0;
-  let commit = "";
-  let commitMessage = "";
-  let finalBranch = runBranch;
-  const commits: CommitRecord[] = [];
-  let pullRequest = "";
-  let pullRequestError = "";
-
-  for (pass = 1; pass <= options.max; pass++) {
-    const coderLog = `.codex/logs/${slug}-r${pass}-coder.log`;
-    const coderId = `coder-${pass}`;
-    await sink.event({
-      type: "step",
-      id: coderId,
-      title: `pass ${pass}/${options.max} ${agentLabel(options.coder)} implementation`,
-    });
-    const coded = await runAgent(
-      options.coder,
-      runner,
-      repo,
-      path.join(repo, coderSession),
-      path.join(repo, coderLog),
-      coderPrompt({
-        spec: runSpec,
-        track,
-        pass,
-        strict: options.strict,
-        previous: `.codex/reviews/${slug}-r${pass - 1}.md`,
-        criteria,
-      }),
-      coderId,
-    );
-    await sink.event({
-      type: "done",
-      id: coderId,
-      ok: coded,
-      detail: coded ? "completed" : "failed",
-    });
-    if (!coded) {
-      status = "coder-error";
-      break;
-    }
-
-    const commitId = `commit-${pass}`;
-    await sink.event({
-      type: "step",
-      id: commitId,
-      title: `pass ${pass}/${options.max} commit`,
-    });
-    let commitError = "";
-    const committed = await commitPass({
-      repo,
-      work,
-      pass,
-      initialDirty,
-    }).catch((error) => {
-      commitError = error instanceof Error ? error.message : String(error);
-      return undefined;
-    });
-    if (!committed) {
-      status = "commit-error";
-      await sink.event({
-        type: "done",
-        id: commitId,
-        ok: false,
-        detail: commitError || "failed",
-      });
-      break;
-    }
-    if (committed.branch) finalBranch = committed.branch;
-    const passCommits = committed.commits;
-    commits.push(...passCommits);
-    const latest = passCommits.at(-1);
-    if (latest) {
-      commit = latest.commit;
-      commitMessage = latest.message;
-    }
-    await sink.event({
-      type: "done",
-      id: commitId,
-      ok: true,
-      detail: passCommits.length
-        ? `${passCommits.length} commit${passCommits.length === 1 ? "" : "s"}`
-        : "no changes",
-    });
-
-    const review = `.codex/reviews/${slug}-r${pass}.md`;
-    const reviewerLog = `.codex/logs/${slug}-r${pass}-reviewer.log`;
-    const reviewerId = `reviewer-${pass}`;
-    await sink.event({
-      type: "step",
-      id: reviewerId,
-      title: `pass ${pass}/${options.max} ${agentLabel(options.reviewer)} review`,
-    });
-    const ok = await runAgent(
-      options.reviewer,
-      runner,
-      repo,
-      path.join(repo, reviewerSession),
-      path.join(repo, reviewerLog),
-      reviewPrompt({
-        coder: options.coder,
-        spec: runSpec,
-        track,
-        base,
-        pass,
-        output: review,
-        priors: listReviews(slug, pass, options.max),
-        criteria,
-        strict: options.strict,
-      }),
-      reviewerId,
-    );
-    await sink.event({
-      type: "done",
-      id: reviewerId,
-      ok,
-      detail: ok ? "completed" : "failed",
-    });
-    if (!ok) {
-      status = "reviewer-error";
-      break;
-    }
-
-    let reviewText = "";
-    try {
-      reviewText = await readFile(path.join(repo, review), "utf8");
-    } catch {
-      status = "review-missing";
-      break;
-    }
-    const verdict = parseVerdict(reviewText);
-    await sink.event({
-      type: "gate",
-      name: `pass ${pass} verdict`,
-      ok: verdict === "ACCEPT",
-      detail: verdict || "MISSING",
-    });
-    if (verdict === "ACCEPT") {
-      status =
-        options.strict && !hasPassingMatrix(reviewText, criteria.length)
-          ? "unclear"
-          : "accepted";
-      break;
-    }
-    if (verdict === "UNCLEAR") {
-      status = "unclear";
-      break;
-    }
-    if (verdict === "REJECT") {
-      const hash = findingsHash(reviewText);
-      if (prior && hash === prior) {
-        status = "stalled";
-        break;
-      }
-      prior = hash;
-    } else {
-      status = "no-verdict";
-      break;
-    }
-  }
-
-  if (pass > options.max) pass = options.max;
-  if (options.createPr && status === "accepted") {
-    const prId = "pull-request";
-    await sink.event({
-      type: "step",
-      id: prId,
-      title: "push branch and create PR",
-    });
-    const published = await createPullRequest(repo, finalBranch, base).catch(
-      (error) => {
-        pullRequestError = error instanceof Error ? error.message : String(error);
-        return undefined;
-      },
-    );
-    if (published) {
-      pullRequest = published.url;
-      await sink.event({
-        type: "done",
-        id: prId,
-        ok: true,
-        detail: pullRequest || `${published.remote}/${published.branch}`,
-      });
-    } else {
-      status = "pr-error";
-      await sink.event({
-        type: "done",
-        id: prId,
-        ok: false,
-        detail: pullRequestError || "failed",
-      });
-    }
-  }
-
-  const coderSessionId = await readLine(path.join(repo, coderSession));
-  const reviewerSessionId = await readLine(path.join(repo, reviewerSession));
-  await synthesizeReport(runner, repo, {
-    slug,
-    reviewer: options.reviewer,
-    spec: runSpec,
-    specText,
-    sourceSpec: spec,
-    sourceRepo,
-    worktree: repo,
-    track,
-    report,
-    status,
-    pass,
-    max: options.max,
-    base,
-    initialBranch: sourceBranch,
-    branch: finalBranch,
-    commit,
-    commitMessage,
-    commits,
-    pullRequest,
-    pullRequestError,
-    coder: options.coder,
-    reviewerSessionFile: path.join(repo, reviewerSession),
-    coderSessionId,
-    reviewerSessionId,
-    format: options.reportFormat,
-    reviews: listReviews(slug, pass, options.max),
-  });
-  const result = {
-    status,
-    passes: pass,
-    max: options.max,
-    report,
-    track,
-    branch: finalBranch,
-    commit,
-    commitMessage,
-    commits,
-    pullRequest,
-    pullRequestError,
-    worktree: repo,
-    sourceRepo,
-    coder: options.coder,
-    reviewer: options.reviewer,
-    coderSessionId,
-    reviewerSessionId,
-  };
-  await sink.event({ type: "result", result });
-  return result;
-}
-
-async function absoluteFile(file: string, cwd: string) {
-  const full = path.resolve(cwd, file);
-  if (!(await stat(full).catch(() => false))) throw new Error(usage());
-  return realpath(full);
-}
-
-async function command(cmd: string, args: string[], cwd?: string) {
-  const proc = Bun.spawn([cmd, ...args], {
-    cwd,
-    stdout: "pipe",
-    stderr: "pipe",
-    env: Bun.env,
-  });
-  const [out, err, code] = await Promise.all([
-    new Response(proc.stdout).text(),
-    new Response(proc.stderr).text(),
-    proc.exited,
-  ]);
-  if (code !== 0)
-    throw new Error(
-      err.trim() ||
-        out.trim() ||
-        `${cmd} ${args.join(" ")} failed with exit ${code}`,
-    );
-  return out;
-}
-
-async function createPullRequest(repo: string, branch: string, base: string) {
-  const remote = "origin";
-  await command("git", ["-C", repo, "push", "-u", remote, branch]);
-  const output = await command(
-    "gh",
-    ["pr", "create", "--fill", "--base", base, "--head", branch],
-    repo,
-  ).catch((error) => {
-    const message = error instanceof Error ? error.message : String(error);
-    throw new Error(`pushed ${remote}/${branch}, but PR creation failed: ${message}`);
-  });
-  return {
-    url: output.split(/\s+/).find((part) => /^https?:\/\//.test(part)) ?? output.trim(),
-    remote,
-    branch,
-  };
-}
-
-async function createWorktree(repo: string, work: WorkItem) {
-  const branch = await nextBranch(repo, work, "");
-  const worktree = await nextWorktreePath(repo, branchLeaf(branch));
-  await command("git", [
-    "-C",
-    repo,
-    "worktree",
-    "add",
-    "-b",
-    branch,
-    worktree,
-    "HEAD",
-  ]);
-  return realpath(worktree);
-}
-
-async function nextWorktreePath(repo: string, slug: string) {
-  const base = path.join(
-    path.dirname(repo),
-    `${path.basename(repo)}-${slugify(slug)}`,
-  );
-  let suffix = 1;
-  let candidate = base;
-  while (await stat(candidate).catch(() => false)) {
-    suffix++;
-    candidate = `${base}-${suffix}`;
-  }
-  return candidate;
-}
-
-async function snapshotSpec(repo: string, slug: string, specText: string) {
-  const file = path.join(repo, ".codex/specs", `${slug}.md`);
-  await writeFile(file, specText);
-  return file;
-}
-
-async function baseBranch(repo: string) {
-  for (const args of [
-    ["-C", repo, "symbolic-ref", "--short", "refs/remotes/origin/HEAD"],
-    ["-C", repo, "show-ref", "--verify", "-q", "refs/heads/main"],
-    ["-C", repo, "show-ref", "--verify", "-q", "refs/heads/master"],
-  ]) {
-    const proc = Bun.spawn(["git", ...args], {
-      stdout: "pipe",
-      stderr: "pipe",
-    });
-    if ((await proc.exited) === 0) {
-      if (args[2] === "symbolic-ref")
-        return (await new Response(proc.stdout).text())
-          .trim()
-          .replace(/^origin\//, "");
-      return args.at(-1)!.split("/").pop()!;
-    }
-  }
-  return "main";
-}
-
-async function statusPaths(repo: string) {
-  const out = await command("git", [
-    "-C",
-    repo,
-    "status",
-    "--porcelain=v1",
-    "-z",
-    "--untracked-files=all",
-  ]);
-  const parts = out.split("\0").filter(Boolean);
-  const paths = new Set<string>();
-  for (let i = 0; i < parts.length; i++) {
-    const item = parts[i]!;
-    const code = item.slice(0, 2);
-    const file = item.slice(3);
-    if (file) paths.add(file);
-    if (code.includes("R") || code.includes("C")) {
-      const next = parts[++i];
-      if (next) paths.add(next);
-    }
-  }
-  return paths;
-}
-
-async function switchToWorkBranch(repo: string, work: WorkItem) {
-  const current = (
-    await command("git", ["-C", repo, "branch", "--show-current"])
-  ).trim();
-  const branch = await nextBranch(repo, work, current);
-  if (branch !== current)
-    await command("git", ["-C", repo, "switch", "-c", branch]);
-  return branch;
-}
-
-async function commitPass(input: {
-  repo: string;
-  work: WorkItem;
-  pass: number;
-  initialDirty: Set<string>;
-}): Promise<
-  { branch: string; commits: CommitRecord[] } | { branch: ""; commits: [] }
-> {
-  const changed = await committablePaths(input.repo, input.initialDirty);
-  if (changed.length === 0) return { branch: "", commits: [] };
-  const branch = await switchToWorkBranch(input.repo, input.work);
-  const message = passCommitMessage(input.work, input.pass);
-  await command("git", ["-C", input.repo, "add", "--", ...changed]);
-  await command("git", [
-    "-C",
-    input.repo,
-    "commit",
-    "--only",
-    "-m",
-    message,
-    "--",
-    ...changed,
-  ]);
-  return {
-    branch,
-    commits: [
-      {
-        pass: input.pass,
-        commit: (
-          await command("git", ["-C", input.repo, "rev-parse", "--short", "HEAD"])
-        ).trim(),
-        message,
-        paths: changed,
-      },
-    ],
-  };
-}
-
-async function committablePaths(repo: string, initialDirty: Set<string>) {
-  return [...(await statusPaths(repo))]
-    .filter((file) => !initialDirty.has(file) && !file.startsWith(".codex/"))
-    .sort();
-}
-
-function passCommitMessage(work: WorkItem, pass: number) {
-  if (pass === 1) return `${work.type}${work.breaking ? "!" : ""}: ${work.slug}`;
-  const type = work.type === "chore" ? "chore" : "fix";
-  return `${type}: ${work.slug}`;
-}
-
-async function nextBranch(repo: string, work: WorkItem, current: string) {
-  const base = branchBase(work);
-  if (
-    current === base ||
-    new RegExp(`^${escapeRegex(base)}-\\d+$`).test(current)
-  )
-    return current;
-  let suffix = 1;
-  let branch = base;
-  while (await branchExists(repo, branch)) {
-    suffix++;
-    branch = `${base}-${suffix}`;
-  }
-  return branch;
-}
-
-function branchBase(work: WorkItem) {
-  return `${work.type}${work.breaking ? "!" : ""}/${work.slug}`;
-}
-
-async function branchExists(repo: string, branch: string) {
-  const proc = Bun.spawn([
-    "git",
-    "-C",
-    repo,
-    "show-ref",
-    "--verify",
-    "--quiet",
-    `refs/heads/${branch}`,
-  ]);
-  return (await proc.exited) === 0;
-}
-
-function makeRunner(cwd: string, sink: Sink): Runner {
-  return async (cmd, args, input = "", log, id) => {
-    let proc: Bun.Subprocess<"pipe", "pipe", "pipe">;
-    try {
-      proc = Bun.spawn([cmd, ...args], {
-        cwd,
-        stdin: "pipe",
-        stdout: "pipe",
-        stderr: "pipe",
-        env: Bun.env,
-      });
-    } catch (error) {
-      const output = error instanceof Error ? error.message : String(error);
-      if (log) await writeFile(log, output);
-      return { code: 127, output, stdout: "", stderr: output };
-    }
-    proc.stdin.write(input);
-    proc.stdin.end();
-    let output = "";
-    let stdout = "";
-    let stderr = "";
-    const pump = async (
-      stream: ReadableStream<Uint8Array>,
-      append: (text: string) => void,
-    ) => {
-      const reader = stream.getReader();
-      const decoder = new TextDecoder();
-      let pending = "";
-      for (;;) {
-        const { done, value } = await reader.read();
-        if (done) break;
-        const text = decoder.decode(value);
-        output += text;
-        append(text);
-        pending += text;
-        const lines = pending.split(/\r?\n/);
-        pending = lines.pop() ?? "";
-        if (id)
-          for (const line of lines.filter(Boolean))
-            await sink.event({ type: "log", id, line });
-      }
-      if (id && pending) await sink.event({ type: "log", id, line: pending });
-    };
-    const [, , code] = await Promise.all([
-      pump(proc.stdout, (text) => {
-        stdout += text;
-      }),
-      pump(proc.stderr, (text) => {
-        stderr += text;
-      }),
-      proc.exited,
-    ]);
-    if (log) await writeFile(log, output);
-    return { code, output, stdout, stderr };
-  };
-}
-
-async function initTrack(
-  file: string,
-  data: {
-    spec: string;
-    sourceSpec: string;
-    cwd: string;
-    sourceRepo: string;
-    worktree: string;
-    base: string;
-    branch: string;
-    worktreeBranch: string;
-    max: number;
-    reportFormat: ReportFormat;
-    strict: boolean;
-    coder: Agent;
-    reviewer: Agent;
-    type: WorkType;
-    breaking: boolean;
-    createPr: boolean;
-  },
-) {
-  if (await stat(file).catch(() => false)) return;
-  await writeFile(
-    file,
-    `# Track: ${path.basename(file, ".md")}\n\n- spec: ${data.spec}\n- source-spec: ${data.sourceSpec}\n- cwd: ${data.cwd}\n- source-repo: ${data.sourceRepo}\n- worktree: ${data.worktree}\n- base: ${data.base}\n- branch: ${data.branch}\n- worktree-branch: ${data.worktreeBranch}\n- coder: ${data.coder}\n- reviewer: ${data.reviewer}\n- type: ${data.type}\n- breaking: ${data.breaking}\n- max: ${data.max}\n- report-format: ${data.reportFormat}\n- strict: ${data.strict}\n- create-pr: ${data.createPr}\n- started: ${new Date().toISOString()}\n\n`,
-  );
-}
-
-async function readLine(file: string) {
-  return (
-    (await readFile(file, "utf8").catch(() => "")).split(/\r?\n/, 1)[0] ?? ""
-  );
-}
-
-async function writeLine(file: string, value: string) {
-  await writeFile(file, `${value}\n`);
-}
-
-async function runAgent(
-  agent: Agent,
-  runner: Runner,
-  repo: string,
-  sessionFile: string,
-  log: string,
-  prompt: string,
-  id: string,
-) {
-  return agent === "codex"
-    ? runCodex(runner, repo, sessionFile, log, prompt, id)
-    : runClaude(runner, repo, sessionFile, log, prompt, id);
-}
-
-async function runAgentOnce(
-  agent: Agent,
-  runner: Runner,
-  repo: string,
-  log: string,
-  prompt: string,
-  id: string,
-) {
-  return agent === "codex"
-    ? runner(
-        "codex",
-        ["exec", ...CODEX_REASONING_ARGS, "-s", "read-only", "-C", repo, "-"],
-        prompt,
-        log,
-        id,
-      )
-    : runner(
-        "claude",
-        [
-          "-p",
-          ...CLAUDE_EFFORT_ARGS,
-          "--dangerously-skip-permissions",
-          "--add-dir",
-          repo,
-        ],
-        prompt,
-        log,
-        id,
-      );
-}
-
-async function runCodex(
-  runner: Runner,
-  repo: string,
-  sessionFile: string,
-  log: string,
-  prompt: string,
-  id: string,
-) {
-  const session = await readLine(sessionFile);
-  const args = session
-    ? [
-        "exec",
-        "resume",
-        ...CODEX_REASONING_ARGS,
-        "--dangerously-bypass-approvals-and-sandbox",
-        session,
-        "-",
-      ]
-    : [
-        "exec",
-        ...CODEX_REASONING_ARGS,
-        "--dangerously-bypass-approvals-and-sandbox",
-        "-C",
-        repo,
-        "-",
-      ];
-  const result = await runner("codex", args, prompt, log, id);
-  if (result.code !== 0) return false;
-  if (!session) {
-    const next = extractSessionId(result.output);
-    if (!next) return false;
-    await writeLine(sessionFile, next);
-  }
-  return true;
-}
-
-async function runClaude(
-  runner: Runner,
-  repo: string,
-  sessionFile: string,
-  log: string,
-  prompt: string,
-  id: string,
-) {
-  const session = await readLine(sessionFile);
-  const next = session || randomUUID();
-  const args = session
-    ? [
-        "-p",
-        "--resume",
-        session,
-        ...CLAUDE_EFFORT_ARGS,
-        "--dangerously-skip-permissions",
-        "--add-dir",
-        repo,
-      ]
-    : [
-        "-p",
-        "--session-id",
-        next,
-        ...CLAUDE_EFFORT_ARGS,
-        "--dangerously-skip-permissions",
-        "--add-dir",
-        repo,
-      ];
-  const result = await runner(
-    "claude",
-    args,
-    prompt,
-    log,
-    id,
-  );
-  if (result.code !== 0) return false;
-  if (!session) await writeLine(sessionFile, next);
-  return true;
-}
-
-function extractSessionId(output: string) {
-  return output
-    .split(/\r?\n/)
-    .filter((line) =>
-      /(session.?id|thread_id|codex exec resume|codex resume|To continue this session)/i.test(
-        line,
-      ),
-    )
-    .join("\n")
-    .match(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i)?.[0]
-    .toLowerCase();
-}
-
-function listReviews(slug: string, upto: number, max: number) {
-  return Array.from(
-    { length: Math.min(upto, max) },
-    (_, i) => `- .codex/reviews/${slug}-r${i + 1}.md`,
-  ).join("\n");
-}
-
-function criteriaBlock(criteria: string[]) {
-  return (
-    criteria.map((criterion, i) => `AC${i + 1}: ${criterion}`).join("\n") ||
-    "No parsed acceptance criteria."
-  );
-}
-
-function coderPrompt(input: {
-  spec: string;
-  track: string;
-  pass: number;
-  strict: boolean;
-  previous: string;
-  criteria: string[];
-}) {
-  const strict = input.strict
-    ? "\nStrict lifecycle:\n1. Add or update regression tests before implementation.\n2. Run the narrow test first and record the failing result, unless impossible; if impossible, say why.\n3. Implement the smallest change.\n4. Run targeted tests, full tests, lint/typecheck, and coverage. Coverage must be 100% when the project exposes coverage tooling.\n"
-    : "";
-  return input.pass === 1
-    ? `You are implementing against an approved spec.\n\nSpec: ${input.spec}\nTrack: ${input.track}\nPass: ${input.pass}\nAcceptance criteria:\n${criteriaBlock(input.criteria)}${strict}\nTasks:\n1. Read the spec.\n2. Implement the smallest working change satisfying the acceptance criteria.\n3. Append "## Pass ${input.pass} - implement" to ${input.track} with changed files, design tradeoffs, verification, and residual risk.\n\nConstraints:\n- Do not commit.\n- Do not edit the spec.\n- Do not revert unrelated dirty files.\n`
-    : `Fix only the findings in the review. Do not refactor unrelated code.\n\nSpec: ${input.spec}\nTrack: ${input.track}\nReview: ${input.previous}\nPass: ${input.pass}\nAcceptance criteria:\n${criteriaBlock(input.criteria)}${strict}\nTasks:\n1. Read the review file.\n2. Fix each finding or explain why it is wrong in the track.\n3. Re-run relevant tests.\n4. Append "## Pass ${input.pass} - fix" to ${input.track} with per-finding outcomes.\n`;
-}
-
-function reviewPrompt(input: {
-  coder: Agent;
-  spec: string;
-  track: string;
-  base: string;
-  pass: number;
-  output: string;
-  priors: string;
-  criteria: string[];
-  strict: boolean;
-}) {
-  return `You are reviewing a ${agentLabel(input.coder)} implementation. Be a senior reviewer, not a linter.\n\nSpec: ${input.spec}\nTrack: ${input.track}\nBase: ${input.base}\nPass: ${input.pass}\nPrior reviews:\n${input.priors}\nAcceptance criteria:\n${criteriaBlock(input.criteria)}\nOutput path: ${input.output}\n\nSteps:\n1. Read the spec and track.\n2. Run: git diff ${input.base}...HEAD\n3. Read prior reviews so you do not repeat resolved findings.\n4. Write the review to ${input.output} using this exact format:\n\n# Review ${input.pass}\n\nVerdict: <ACCEPT | REJECT | UNCLEAR>\n\n## Acceptance matrix\n\n| Criterion | Status | Implementation evidence | Test evidence |\n| --- | --- | --- | --- |\n| AC1 | <PASS, FAIL, or UNCLEAR> | <code or behavior evidence> | <test, check, or explicit gap> |\n\n## Review flags\n\n- Silent decision: <present or absent> - <evidence, or None>\n- Scope drift: <present or absent> - <evidence, or None>\n- Missing test: <present or absent> - <evidence, or None>\n\n## Findings\n\n1. [severity] <file:line> - <symptom>. Root cause: <why>. Principle: <principle>.\n\n## Missing tests\n\n- <gap, or None>\n\n## Fix instructions\n\n1. <standalone instruction>\n\n## Notes\n\n- <scope, disputes, ambiguity questions, lessons, or None>\n\nRules:\n- The verdict line must appear verbatim.\n- ACCEPT requires every acceptance criterion PASS with concrete implementation evidence and concrete test evidence.${input.strict ? "\n- ACCEPT also requires regression-test evidence, red/green evidence when behavior changed, passing full tests, and 100% coverage when coverage tooling exists." : ""}\n- 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.\n- 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.\n- Flag a missing test when behavior changed without targeted test evidence, even if the full suite passed.\n- Use UNCLEAR only when spec ambiguity prevents a defensible ACCEPT or REJECT, and put the exact question in Notes.\n- For ACCEPT: Findings and Fix instructions bodies are "None".\n- Findings must explain WHY, not just WHAT.\n`;
-}
-
-function commitLines(commits: CommitRecord[]) {
-  return commits.length
-    ? commits
-        .map(
-          (item) =>
-            `- pass ${item.pass} ${item.commit} ${item.message} (${item.paths.join(", ")})`,
-        )
-        .join("\n")
-    : "- none";
-}
-
-async function synthesizeReport(
-  runner: Runner,
-  repo: string,
-  input: {
-    slug: string;
-    reviewer: Agent;
-    spec: string;
-    specText: string;
-    sourceSpec: string;
-    sourceRepo: string;
-    worktree: string;
-    track: string;
-    report: string;
-    status: Status;
-    pass: number;
-    max: number;
-    base: string;
-    initialBranch: string;
-    branch: string;
-    commit: string;
-    commitMessage: string;
-    commits: CommitRecord[];
-    pullRequest: string;
-    pullRequestError: string;
-    coder: Agent;
-    reviewerSessionFile: string;
-    coderSessionId: string;
-    reviewerSessionId: string;
-    format: ReportFormat;
-    reviews: string;
-  },
-) {
-  const framing = reportFraming(input.specText, input.slug);
-  const metadata = `Result: ${input.status}
-Passes: ${input.pass} / ${input.max}
-Repository: ${repo}
-Spec: ${input.spec}
-Source spec: ${input.sourceSpec}
-Source repository: ${input.sourceRepo}
-Worktree: ${input.worktree}
-Base branch: ${input.base}
-Starting branch: ${input.initialBranch}
-Final branch: ${input.branch}
-Local commit: ${input.commit || "none"}
-Commit message: ${input.commitMessage || "none"}
-Commits:
-${commitLines(input.commits)}
-Pull request: ${input.pullRequest || "none"}
-Pull request error: ${input.pullRequestError || "none"}
-Coder: ${agentLabel(input.coder)}
-Reviewer: ${agentLabel(input.reviewer)}
-Coder session: ${input.coderSessionId || "unknown"}
-Reviewer session: ${input.reviewerSessionId || "unknown"}
-Track: ${input.track}
-Reviews:
-${input.reviews}`;
-  const body =
-    input.format === "html"
-      ? `Write the report to ${input.report} as valid standalone HTML. Use a readable document layout with embedded CSS, set the HTML <title> to the report title, render the report title and subtitle before Metadata, render a topical three-line haiku immediately after the subtitle, use a compact metadata table, and add substantive sections after it. Include these visible section headings: Metadata, The shape of the problem, What was built, What the review caught (and why it mattered), What to remember next time, Residual risk, Pointers. Do not optimize away substance: explain the decisions, tradeoffs, evidence, and transferable lessons clearly enough that the reader learns from the run.`
-      : `Write the report to ${input.report} in markdown. Start with the report title as the H1, put the subtitle directly below it, put a topical three-line haiku immediately after the subtitle, then include these headings: Metadata, The shape of the problem, What was built, What the review caught (and why it mattered), What to remember next time, Residual risk, Pointers. Do not optimize away substance: explain the decisions, tradeoffs, evidence, and transferable lessons clearly enough that the reader learns from the run.`;
-  await runAgent(
-    input.reviewer,
-    runner,
-    repo,
-    input.reviewerSessionFile,
-    path.join(repo, `.codex/logs/${input.slug}-report.log`),
-    `You are writing a learning-oriented post-mortem for a developer who just ran a devloop.\n\nReport framing to render visibly near the top, before Metadata:\nTitle: ${framing.title}\nSubtitle: ${framing.subtitle}\nHaiku: Compose a three-line haiku, 5/7/5 syllables if possible, about this specific work.\nHaiku topic: ${framing.title} - ${framing.subtitle}\n\nUse that exact title and subtitle. The subtitle must be specific to this work, not a generic or hard-coded tagline. The haiku must be topical, concrete, and rendered immediately after the subtitle before Metadata.\n\nMetadata to render exactly and visibly:\n${metadata}\n\nInputs:\n- spec: ${input.spec}\n- track: ${input.track}\nReview files:\n${input.reviews}\n- final status: ${input.status}\n- passes used: ${input.pass} / ${input.max}\n- base: ${input.base}, starting branch: ${input.initialBranch}, final branch: ${input.branch}, local commit: ${input.commit || "none"}\n- pull request: ${input.pullRequest || "none"}\n\n${body}\n\nStyle:\n- Human readable, not ornamental.\n- Preserve useful substance over brevity.\n- Teach the why: symptom, root cause, principle, decision, tradeoff, and evidence.\n- No emoji.\n`,
-    "report",
-  );
-}
-
-function reportTitle(specText: string) {
-  for (const line of specText.split(/\r?\n/)) {
-    const match = line.match(/^#\s+(.+)$/);
-    const title = cleanReportText(match?.[1] ?? "");
-    if (title) return title;
-  }
-  return undefined;
-}
-
-function sectionLead(
-  specText: string,
-  heading: "Outcome" | "Problem" | "Acceptance criteria",
-) {
-  const lines = specText.split(/\r?\n/);
-  const headingPattern = new RegExp(`^##\\s+${heading}\\s*$`, "i");
-  const start = lines.findIndex((line) => headingPattern.test(line.trim()));
-  if (start < 0) return undefined;
-  for (const line of lines.slice(start + 1)) {
-    if (/^##\s+/.test(line)) return undefined;
-    const text = cleanReportText(line.replace(/^([-*]|\d+[.)])\s+/, ""));
-    if (text) return text;
-  }
-  return undefined;
-}
-
-function cleanReportText(value: string) {
-  const text = value.trim().replace(/\s+/g, " ");
-  if (!text || text === "..." || /^<.*>$/.test(text)) return undefined;
-  return text;
-}
-
-function titleFromSlug(slug: string) {
-  return (
-    slug
-      .split("-")
-      .filter(Boolean)
-      .map((part) => part[0]!.toUpperCase() + part.slice(1))
-      .join(" ") || "Devloop Report"
-  );
-}
-
-function clamp(value: number, min: number, max: number) {
-  return Math.max(min, Math.min(max, value));
-}
-
-function parseAgent(value: string | undefined): Agent | undefined {
-  const normalized = (value ?? "").trim().toLowerCase();
-  if (normalized === "codex") return "codex";
-  if (normalized === "claude") return "claude";
-  return undefined;
-}
-
-function agentLabel(agent: Agent) {
-  return agent === "codex" ? "Codex" : "Claude Code";
-}
-
-async function resolveWorkItem(input: {
-  agent: Agent;
-  runner: Runner;
-  repo: string;
-  spec: string;
-  specText: string;
-  fields: WorkItemFields;
-  log: string;
-}) {
-  const explicit = completeWorkItem(input.fields);
-  if (explicit) return explicit;
-  const derived = await deriveWorkItem(input);
-  return mergeWorkItem(derived, input.fields);
-}
-
-async function deriveWorkItem(input: {
-  agent: Agent;
-  runner: Runner;
-  repo: string;
-  spec: string;
-  specText: string;
-  log: string;
-}) {
-  const result = await runAgentOnce(
-    input.agent,
-    input.runner,
-    input.repo,
-    input.log,
-    namingPrompt(input.spec, input.specText),
-    "naming",
-  );
-  if (result.code !== 0)
-    throw new Error(result.output || `${input.agent} failed`);
-  return parseWorkItem(result.stdout || result.output);
-}
-
-function namingPrompt(spec: string, specText: string) {
-  return `Work item naming task.
-
-Read the spec and, when useful, inspect the repository to choose the semantic work item identity.
-
-Return exactly one JSON object and no markdown:
-{"type":"feat","slug":"short-kebab-case-name","breaking":false}
-
-Rules:
-- type must be one of: feat, fix, chore.
-- Use feat for new capability or materially expanded behavior.
-- Use fix for correcting broken, incorrect, or regressed behavior.
-- Use chore for maintenance, docs, tests, dependency work, refactors, and internal cleanup.
-- Use breaking true only when the work intentionally breaks an external API, data contract, command behavior, or migration expectation.
-- slug must be 1-6 short kebab-case words that name the actual work, not the process.
-- Exclude dates, issue numbers, repo names, agent names, and type words from slug.
-- Prefer concrete nouns from the problem domain over generic words like change, update, cleanup, or implementation.
-
-Spec path: ${spec}
-
-Spec:
-${specText}`;
-}
-
-function parseWorkItem(output: string): WorkItem {
-  const errors: string[] = [];
-  for (const candidate of jsonObjectCandidates(output).reverse()) {
-    try {
-      return workItemFromJson(JSON.parse(candidate));
-    } catch (error) {
-      errors.push(error instanceof Error ? error.message : String(error));
-    }
-  }
-  throw new Error(errors.at(0) ?? "naming output must include JSON");
-}
-
-function workItemFromJson(parsed: unknown): WorkItem {
-  if (!isRecord(parsed)) throw new Error("naming output must be an object");
-  return validateWorkItem(parsed);
-}
-
-type WorkItemFields = {
-  type?: WorkType;
-  slug?: string;
-  breaking?: boolean;
-};
-
-function mergeWorkItem(work: WorkItem, fields: WorkItemFields) {
-  return validateWorkItem({
-    type: fields.type ?? work.type,
-    slug: fields.slug ?? work.slug,
-    breaking: fields.breaking ?? work.breaking,
-  });
-}
-
-function completeWorkItem(fields: WorkItemFields) {
-  if (fields.type && fields.slug && fields.breaking !== undefined)
-    return validateWorkItem(fields);
-  return undefined;
-}
-
-function validateWorkItem(input: Record<string, unknown>): WorkItem {
-  const type = input.type;
-  if (typeof type !== "string" || !isWorkType(type))
-    throw new Error("naming output type must be feat, fix, or chore");
-  const slug = typeof input.slug === "string" ? slugify(input.slug) : "";
-  if (!slug) throw new Error("naming output slug is required");
-  if (slug.split("-").length > 6)
-    throw new Error("naming output slug must be 1-6 words");
-  if (["feat", "fix", "chore"].includes(slug.split("-", 1)[0] ?? ""))
-    throw new Error("naming output slug must not include a type prefix");
-  if (typeof input.breaking !== "boolean")
-    throw new Error("naming output breaking must be boolean");
-  return { type, slug, breaking: input.breaking };
-}
-
-function jsonObjectCandidates(output: string) {
-  const candidates: string[] = [];
-  let start = -1;
-  let depth = 0;
-  let string = false;
-  let escape = false;
-  for (let i = 0; i < output.length; i++) {
-    const char = output[i]!;
-    if (string) {
-      if (escape) escape = false;
-      else if (char === "\\") escape = true;
-      else if (char === "\"") string = false;
-    } else if (char === "\"") string = true;
-    else if (char === "{") {
-      if (depth === 0) start = i;
-      depth++;
-    } else if (char === "}" && depth > 0) {
-      depth--;
-      if (depth === 0 && start >= 0) candidates.push(output.slice(start, i + 1));
-    }
-  }
-  return candidates;
-}
-
-function workItemFields(specText: string): WorkItemFields {
-  const metadata = parseFrontmatter(specText);
-  const fields: WorkItemFields = {};
-  const type = frontmatterValue(metadata, "type");
-  if (type) {
-    const base = type.toLowerCase().replace(/!$/, "");
-    if (!isWorkType(base))
-      throw new Error("frontmatter type must be feat, fix, or chore");
-    fields.type = base;
-    if (type.endsWith("!")) fields.breaking = true;
-  }
-  const slug = frontmatterValue(metadata, "slug");
-  if (slug) fields.slug = slugify(slug);
-  const breaking = frontmatterValue(metadata, "breaking");
-  if (breaking) {
-    const parsed = parseBoolean(breaking);
-    if (fields.breaking === true && !parsed)
-      throw new Error("frontmatter breaking conflicts with type !");
-    fields.breaking = parsed;
-  }
-  return fields;
-}
-
-function parseFrontmatter(text: string) {
-  const metadata = new Map<string, string>();
-  const match = text.match(/^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/);
-  if (!match) return metadata;
-  for (const line of match[1]!.split(/\r?\n/)) {
-    const pair = line.trim().match(/^([A-Za-z][A-Za-z0-9_-]*):\s*(.*)$/);
-    if (pair) metadata.set(pair[1]!.toLowerCase(), pair[2]!.trim());
-  }
-  return metadata;
-}
-
-function frontmatterValue(metadata: Map<string, string>, key: string) {
-  const value = (metadata.get(key) ?? "").replace(/^["']|["']$/g, "").trim();
-  if (
-    !value ||
-    value === "null" ||
-    value === "undefined" ||
-    value.includes("|") ||
-    /^<.*>$/.test(value)
-  )
-    return undefined;
-  return value;
-}
-
-function parseBoolean(value: string) {
-  if (/^(true|yes|1)$/i.test(value)) return true;
-  if (/^(false|no|0)$/i.test(value)) return false;
-  throw new Error("frontmatter breaking must be true or false");
-}
-
-function isRecord(value: unknown): value is Record<string, unknown> {
-  return Boolean(value && typeof value === "object" && !Array.isArray(value));
-}
-
-function isWorkType(value: string): value is WorkType {
-  return value === "feat" || value === "fix" || value === "chore";
-}
-
-function branchLeaf(branch: string) {
-  return branch.split("/").at(-1) ?? branch;
-}
-
-function slugify(value: string) {
-  return value
-    .toLowerCase()
-    .replace(/['’]/g, "")
-    .replace(/[^a-z0-9]+/g, "-")
-    .replace(/^-+|-+$/g, "");
-}
-
-function escapeRegex(value: string) {
-  return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
-}
diff --git a/src/spec.ts b/src/spec.ts
deleted file mode 100644
index 7f3d343..0000000
--- a/src/spec.ts
+++ /dev/null
@@ -1,253 +0,0 @@
-import { mkdir, readFile, stat, writeFile } from "node:fs/promises";
-import path from "node:path";
-import { fileURLToPath } from "node:url";
-import { CLAUDE_EFFORT_ARGS, CODEX_REASONING_ARGS } from "./agent-options.ts";
-
-export type GenerateSpecOptions = {
-  agent: string;
-  context: string[];
-  cwd: string;
-  force: boolean;
-  output?: string;
-  today?: string;
-};
-
-export type SpecCommand =
-  | { type: "generate"; options: GenerateSpecOptions }
-  | { type: "print-skill" }
-  | { type: "skill-path" };
-
-export type AgentResult = {
-  code: number;
-  stdout: string;
-  stderr: string;
-  output: string;
-};
-
-export type AgentRunner = (
-  cmd: string,
-  args: string[],
-  input: string,
-  cwd: string,
-) => Promise<AgentResult>;
-
-export type GeneratedSpec = {
-  agent: string;
-  command: string[];
-  file: string;
-};
-
-export function parseSpecArgs(
-  argv: string[],
-  cwd = process.cwd(),
-): SpecCommand | string {
-  let agent = "codex";
-  let force = false;
-  let output = "";
-  let action: "print-skill" | "skill-path" | "" = "";
-  const context: string[] = [];
-
-  for (let i = 0; i < argv.length; i++) {
-    const arg = argv[i]!;
-    if (arg === "--agent") {
-      const value = argv[++i];
-      if (!value) return `--agent requires a value\n${specUsage()}`;
-      agent = value;
-    } else if (arg === "--output" || arg === "-o") {
-      const value = argv[++i];
-      if (!value) return `--output requires a value\n${specUsage()}`;
-      output = value;
-    } else if (arg === "--force") force = true;
-    else if (arg === "--print-skill") action = "print-skill";
-    else if (arg === "--skill-path") action = "skill-path";
-    else if (arg === "-h" || arg === "--help") return specUsage();
-    else if (arg.startsWith("--")) return `unknown option: ${arg}\n${specUsage()}`;
-    else context.push(arg);
-  }
-
-  if (action) return { type: action };
-  return {
-    type: "generate",
-    options: {
-      agent,
-      context,
-      cwd,
-      force,
-      output: output || undefined,
-    },
-  };
-}
-
-export function specUsage() {
-  return [
-    "usage: devloop spec [--agent codex|claude|<cmd>] [--output spec.md] [--force] [context...]",
-    "       devloop spec --print-skill",
-    "       devloop spec --skill-path",
-    "",
-    "Without context, the bundled skill uses its interview path before writing a spec.",
-  ].join("\n");
-}
-
-export function bundledSpecSkillPath() {
-  return path.resolve(
-    path.dirname(fileURLToPath(import.meta.url)),
-    "..",
-    "skills",
-    "spec",
-    "SKILL.md",
-  );
-}
-
-export async function readBundledSpecSkill() {
-  return readFile(bundledSpecSkillPath(), "utf8");
-}
-
-export async function generateSpec(
-  options: GenerateSpecOptions,
-  runner: AgentRunner = runAgent,
-): Promise<GeneratedSpec> {
-  const skill = await readBundledSpecSkill();
-  const context = await resolveContext(options.context, options.cwd);
-  const today = options.today ?? new Date().toISOString().slice(0, 10);
-  const invocation = agentInvocation(options.agent, options.cwd);
-  const result = await runner(
-    invocation.cmd,
-    invocation.args,
-    specPrompt({
-      context,
-      output: options.output,
-      skill,
-      today,
-    }),
-    options.cwd,
-  );
-  if (result.code !== 0)
-    throw new Error(result.stderr.trim() || result.stdout.trim() || "spec agent failed");
-  const markdown = extractGeneratedSpec(result.stdout || result.output);
-  const file = await generatedSpecPath({
-    cwd: options.cwd,
-    force: options.force,
-    markdown,
-    output: options.output,
-    today,
-  });
-  await mkdir(path.dirname(file), { recursive: true });
-  if ((await stat(file).catch(() => false)) && !options.force)
-    throw new Error(`spec already exists: ${file}`);
-  await writeFile(file, markdown.endsWith("\n") ? markdown : `${markdown}\n`);
-  return { agent: options.agent, command: [invocation.cmd, ...invocation.args], file };
-}
-
-export function agentInvocation(agent: string, cwd: string) {
-  if (agent === "codex")
-    return {
-      cmd: "codex",
-      args: ["exec", ...CODEX_REASONING_ARGS, "-s", "read-only", "-C", cwd, "-"],
-    };
-  if (agent === "claude")
-    return {
-      cmd: "claude",
-      args: ["-p", ...CLAUDE_EFFORT_ARGS, "--add-dir", cwd],
-    };
-  return { cmd: agent, args: [] };
-}
-
-export function specPrompt(input: {
-  context: string;
-  output?: string;
-  skill: string;
-  today: string;
-}) {
-  return `Use this bundled devloop skill to produce one implementation spec.
-
-Current date: ${input.today}
-${input.output ? `Output path: ${input.output}` : "Output path: choose a .specs/YYYY-MM-DD-<slug>.md path if you write a file; otherwise return markdown on stdout."}
-
-If the source context is missing or too thin, follow the skill's interview path before drafting. Return only the final markdown spec. Do not wrap it in a code fence.
-
-Bundled skill:
-${input.skill}
-
-Context:
-${input.context}`;
-}
-
-export function extractGeneratedSpec(output: string) {
-  const trimmed = output.trim();
-  const fenced = trimmed.match(/^```(?:markdown|md)?\s*\n([\s\S]*?)\n```$/i);
-  const candidate = (fenced?.[1] ?? trimmed).trim();
-  const start = candidate.indexOf("---");
-  if (start < 0) throw new Error("agent output must include spec frontmatter");
-  return candidate.slice(start).trim();
-}
-
-async function resolveContext(items: string[], cwd: string) {
-  if (items.length === 0)
-    return "No source material was provided. Use the cold-start interview path in the bundled skill to discover intent before writing the spec.";
-  const resolved = await Promise.all(items.map((item) => contextBlock(item, cwd)));
-  return resolved.join("\n\n---\n\n");
-}
-
-async function contextBlock(item: string, cwd: string) {
-  const file = path.resolve(cwd, item);
-  const info = await stat(file).catch(() => undefined);
-  if (info?.isFile()) return `Source file: ${file}\n\n${await readFile(file, "utf8")}`;
-  return `Context:\n${item}`;
-}
-
-async function generatedSpecPath(input: {
-  cwd: string;
-  force: boolean;
-  markdown: string;
-  output?: string;
-  today: string;
-}) {
-  if (input.output) return path.resolve(input.cwd, input.output);
-  const title = input.markdown.match(/^#\s+(.+)$/m)?.[1] ?? "spec";
-  const slug = slugify(title) || "spec";
-  const file = path.join(input.cwd, ".specs", `${input.today}-${slug}.md`);
-  return input.force ? file : nextAvailablePath(file);
-}
-
-async function nextAvailablePath(file: string) {
-  const extension = path.extname(file);
-  const base = file.slice(0, -extension.length);
-  let index = 2;
-  let candidate = file;
-  while (await stat(candidate).catch(() => false)) {
-    candidate = `${base}-${index}${extension}`;
-    index++;
-  }
-  return candidate;
-}
-
-function slugify(value: string) {
-  return value
-    .toLowerCase()
-    .replace(/'/g, "")
-    .replace(/[^a-z0-9]+/g, "-")
-    .replace(/^-+|-+$/g, "");
-}
-
-async function runAgent(
-  cmd: string,
-  args: string[],
-  input: string,
-  cwd: string,
-): Promise<AgentResult> {
-  const proc = Bun.spawn([cmd, ...args], {
-    cwd,
-    env: Bun.env,
-    stdin: "pipe",
-    stdout: "pipe",
-    stderr: "pipe",
-  });
-  proc.stdin.write(input);
-  proc.stdin.end();
-  const [stdout, stderr, code] = await Promise.all([
-    new Response(proc.stdout).text(),
-    new Response(proc.stderr).text(),
-    proc.exited,
-  ]);
-  return { code, stdout, stderr, output: `${stdout}${stderr}` };
-}
diff --git a/src/tui-view.ts b/src/tui-view.ts
deleted file mode 100644
index 0e6a025..0000000
--- a/src/tui-view.ts
+++ /dev/null
@@ -1,44 +0,0 @@
-import {
-  isIsolatedWorktree,
-  LOGO,
-  resultPath,
-  type Result,
-} from "./devloop.ts";
-
-export type Row = { id: string; title: string; status: "run" | "ok" | "fail"; detail: string; lines: string[]; open: boolean };
-const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
-
-export function view(rows: Row[], selected: number, result?: Result, spinnerFrame = 0) {
-  const body = rows.flatMap((item, i) => {
-    const mark = i === selected ? ">" : " ";
-    const fold = item.lines.length ? (item.open ? "[-]" : "[+]") : "   ";
-    const head = `${mark} ${icon(item.status, spinnerFrame)} ${fold} ${item.title} - ${item.detail}`;
-    return item.open ? [head, ...item.lines.slice(-80).map((line) => `      ${line}`)] : [head];
-  });
-  const tail = result
-    ? [
-        "",
-        resultLine("result", result.status),
-        resultLine("passes", `${result.passes} / ${result.max}`),
-        resultLine("coder", result.coder),
-        resultLine("reviewer", result.reviewer),
-        resultLine("branch", result.branch),
-        resultLine("commit", result.commit || "none"),
-        ...(result.pullRequest ? [resultLine("pr", result.pullRequest)] : []),
-        ...(isIsolatedWorktree(result) ? [resultLine("worktree", result.worktree)] : []),
-        resultLine("report", resultPath(result, result.report)),
-        resultLine("track", resultPath(result, result.track)),
-      ]
-    : ["", "enter toggles logs, ↑/↓ moves"];
-  return [LOGO, "", ...body, ...tail].join("\n");
-}
-
-function resultLine(label: string, value: string) {
-  return `${`${label}:`.padEnd(10)}${value}`;
-}
-
-function icon(status: Row["status"], spinnerFrame: number) {
-  if (status === "ok") return "ok";
-  if (status === "fail") return "!!";
-  return SPINNER_FRAMES[Math.abs(spinnerFrame) % SPINNER_FRAMES.length]!;
-}
diff --git a/src/tui.ts b/src/tui.ts
deleted file mode 100644
index 9e6cef1..0000000
--- a/src/tui.ts
+++ /dev/null
@@ -1,51 +0,0 @@
-import { type Event, type Result, type Sink } from "./devloop.ts";
-import { view, type Row } from "./tui-view.ts";
-
-export async function createTuiSink(): Promise<Sink> {
-  const { TextRenderable, createCliRenderer } = await import("@opentui/core");
-  const renderer = await createCliRenderer({ exitOnCtrlC: true, consoleMode: "disabled", screenMode: "alternate-screen" });
-  const text = new TextRenderable(renderer, { id: "devloop", width: "100%", height: "100%", content: "" });
-  const rows: Row[] = [];
-  let selected = 0;
-  let spinnerFrame = 0;
-  let result: Result | undefined;
-  const spinner = setInterval(() => {
-    if (!rows.some((item) => item.status === "run")) return;
-    spinnerFrame++;
-    render();
-  }, 120);
-
-  renderer.root.add(text);
-  renderer.keyInput.on("keypress", (key) => {
-    if (key.name === "up") selected = Math.max(0, selected - 1);
-    else if (key.name === "down") selected = Math.min(rows.length - 1, selected + 1);
-    else if (rows.length && (key.name === "return" || key.name === "space")) rows[selected]!.open = !rows[selected]!.open;
-    render();
-  });
-
-  function render() {
-    text.content = view(rows, selected, result, spinnerFrame);
-    renderer.requestRender();
-  }
-
-  render();
-  return {
-    event(event: Event) {
-      if (event.type === "step") rows.push({ id: event.id, title: event.title, status: "run", detail: "running", lines: [], open: false });
-      else if (event.type === "log") row(rows, event.id).lines.push(event.line);
-      else if (event.type === "done") Object.assign(row(rows, event.id), { status: event.ok ? "ok" : "fail", detail: event.detail });
-      else if (event.type === "gate") rows.push({ id: event.name, title: event.name, status: event.ok ? "ok" : "fail", detail: event.detail, lines: [], open: false });
-      else result = event.result;
-      selected = Math.min(selected, Math.max(0, rows.length - 1));
-      render();
-    },
-    close() {
-      clearInterval(spinner);
-      renderer.destroy();
-    },
-  };
-}
-
-function row(rows: Row[], id: string) {
-  return rows.find((item) => item.id === id) ?? rows[rows.push({ id, title: id, status: "run", detail: "running", lines: [], open: false }) - 1]!;
-}
diff --git a/tests/devloop.test.ts b/tests/devloop.test.ts
deleted file mode 100644
index 661a728..0000000
--- a/tests/devloop.test.ts
+++ /dev/null
@@ -1,770 +0,0 @@
-import { afterAll, beforeEach, describe, expect, test } from "bun:test";
-import { mkdir, mkdtemp, readFile, realpath, rm, stat, writeFile } from "node:fs/promises";
-import { tmpdir } from "node:os";
-import path from "node:path";
-import { hasPassingMatrix, parseArgs, parseCriteria, parseVerdict, reportFraming, runDevloop, welcome, type Event, type Options } from "../src/devloop.ts";
-
-const root = await mkdtemp(path.join(tmpdir(), "devloop-test."));
-let oldPath = process.env.PATH ?? "";
-const defaultAgents = { coder: "codex", reviewer: "claude" } as const;
-
-afterAll(async () => rm(root, { recursive: true, force: true }));
-beforeEach(() => {
-  oldPath = process.env.PATH ?? "";
-  delete process.env.DEVLOOP_TEST_VERDICTS;
-  delete process.env.DEVLOOP_TEST_STATE;
-  delete process.env.DEVLOOP_TEST_NO_MATRIX;
-  delete process.env.DEVLOOP_TEST_NO_REVIEW;
-  delete process.env.DEVLOOP_TEST_NO_VERDICT;
-  delete process.env.DEVLOOP_TEST_FAIL_CODEX;
-  delete process.env.DEVLOOP_TEST_FAIL_NAMING;
-  delete process.env.DEVLOOP_TEST_FAIL_CLAUDE;
-  delete process.env.DEVLOOP_TEST_NOISY_NAMING;
-  delete process.env.DEVLOOP_TEST_WORK_ITEM;
-  delete process.env.DEVLOOP_TEST_MULTI_COMMIT;
-  delete process.env.DEVLOOP_TEST_NO_CODE_CHANGE;
-  delete process.env.DEVLOOP_TEST_FAIL_GH;
-});
-
-describe("parsing", () => {
-  test("parses options tightly", () => {
-    expect(parseArgs(["--no-strict", "--report-format", "md", "spec.md", "08"], "/x")).toEqual({
-      spec: "spec.md",
-      max: 8,
-      reportFormat: "markdown",
-      strict: false,
-      worktree: true,
-      coder: "codex",
-      reviewer: "claude",
-      cwd: "/x",
-      createPr: false,
-    } satisfies Options);
-    expect(parseArgs(["--in-place", "spec.md"], "/x")).toMatchObject({ worktree: false });
-    expect(parseArgs(["--create-pr", "spec.md"], "/x")).toMatchObject({ createPr: true });
-    expect(parseArgs(["--pr", "spec.md"], "/x")).toMatchObject({ createPr: true });
-    expect(parseArgs(["--coder", "claude", "--reviewer", "codex", "spec.md"], "/x")).toMatchObject({ coder: "claude", reviewer: "codex" });
-    expect(parseArgs(["--coder", "gpt", "spec.md"], "/x")).toContain("coder must be codex or claude");
-    expect(parseArgs(["--reviewer", "gpt", "spec.md"], "/x")).toContain("reviewer must be codex or claude");
-    expect(parseArgs(["spec.md", "0"], "/x")).toMatchObject({ max: 1 });
-    expect(parseArgs(["spec.md", "99"], "/x")).toMatchObject({ max: 10 });
-    expect(parseArgs(["--wat"], "/x")).toContain("unknown option");
-    expect(parseArgs([], "/x")).toContain("usage:");
-    expect(parseArgs(["spec.md", "nope"], "/x")).toBe("max must be an integer between 1 and 10");
-  });
-
-  test("extracts acceptance criteria", () => {
-    expect(parseCriteria("# Spec\n\n## Acceptance criteria\n1. One\n- Two\n\n## Notes\nNope")).toEqual(["One", "Two"]);
-    expect(parseCriteria("# Spec")).toEqual([]);
-    expect(parseVerdict("Verdict: ACCEPT\n")).toBe("ACCEPT");
-    expect(parseVerdict("No verdict here\n")).toBe("");
-    const review = "## Acceptance matrix\n\n| Criterion | Status | Implementation evidence | Test evidence |\n| --- | --- | --- | --- |\n| AC1 | PASS | code path | bun test |\n| AC2 | PASS | behavior | typecheck |\n";
-    expect(hasPassingMatrix(review, 2)).toBe(true);
-    expect(hasPassingMatrix(review.replace("| AC2 | PASS |", "| AC2 | FAIL |"), 2)).toBe(false);
-  });
-
-  test("derives report framing from the spec", () => {
-    expect(
-      reportFraming(
-        "# Add chat retries\n\n## Problem\nUsers lose messages when the transport flakes.\n\n## Outcome\nFailed sends retry without duplicating messages.\n",
-        "chat-retries",
-      ),
-    ).toEqual({
-      title: "Add chat retries",
-      subtitle: "Failed sends retry without duplicating messages.",
-    });
-    expect(reportFraming("# Config fallback\n\n## Problem\n- Missing config crashes startup.\n", "config-fallback")).toEqual({
-      title: "Config fallback",
-      subtitle: "Missing config crashes startup.",
-    });
-    expect(reportFraming("# <Concise title>\n\n## Acceptance criteria\n1. First useful criterion.\n", "fallback-slug")).toEqual({
-      title: "Fallback Slug",
-      subtitle: "First useful criterion.",
-    });
-    expect(reportFraming("# <Concise title>\n\n## Outcome\n<The observable end state>\n", "fallback-slug")).toEqual({
-      title: "Fallback Slug",
-      subtitle: "Outcome, review findings, and residual risk for Fallback Slug.",
-    });
-  });
-
-  test("renders a useful default screen", () => {
-    expect(welcome()).toContain("____/ /__");
-    expect(welcome()).toContain("Common commands:");
-    expect(welcome()).toContain("devloop .specs/change.md");
-    expect(welcome()).toContain("--create-pr");
-    expect(welcome()).toContain("bun scripts/install.ts");
-  });
-});
-
-describe("loop", () => {
-  test("accepts and writes core artifacts", async () => {
-    const { repo, state } = await fixture("accept");
-    process.env.DEVLOOP_TEST_VERDICTS = "ACCEPT";
-    const { result, events } = await run(repo);
-    const worktree = result.worktree;
-
-    expect(result.status).toBe("accepted");
-    expect(result.passes).toBe(1);
-    expect(result.branch).toBe("feat/change");
-    expect(result.commit).toMatch(/^[0-9a-f]+$/);
-    expect(result.commitMessage).toBe("feat: change");
-    expect(result.commits).toEqual([{ pass: 1, commit: result.commit, message: "feat: change", paths: ["feature.txt"] }]);
-    expect(result.sourceRepo).toBe(repo);
-    expect(worktree).not.toBe(repo);
-    await exists(path.join(worktree, ".codex/specs/change.md"));
-    await exists(path.join(worktree, ".codex/tracks/change.md"));
-    await exists(path.join(worktree, ".codex/reviews/change-r1.md"));
-    await exists(path.join(worktree, ".codex/reports/change.html"));
-    await exists(path.join(worktree, ".codex/logs/change-naming.log"));
-    expect(result.coder).toBe("codex");
-    expect(result.reviewer).toBe("claude");
-    expect(await readFile(path.join(worktree, ".codex/sessions/change-coder-codex.id"), "utf8")).toContain("00000000-0000-4000-8000-000000000001");
-    expect(await readFile(path.join(worktree, ".codex/tracks/change.md"), "utf8")).toContain("- strict: true");
-    expect(await readFile(path.join(worktree, ".codex/tracks/change.md"), "utf8")).toContain("- create-pr: false");
-    expect(await readFile(path.join(worktree, ".codex/tracks/change.md"), "utf8")).toContain("- coder: codex");
-    expect(await readFile(path.join(worktree, ".codex/tracks/change.md"), "utf8")).toContain("- reviewer: claude");
-    expect(await readFile(path.join(worktree, ".codex/tracks/change.md"), "utf8")).toContain(`- source-repo: ${repo}`);
-    expect(await readFile(path.join(worktree, ".codex/reviews/change-r1.md"), "utf8")).toContain("| AC1 | PASS | mock evidence | mock test |");
-    const codexArgs = await readFile(path.join(state, "codex-args.log"), "utf8");
-    expect(codexArgs.split(/\r?\n/, 1)[0]).toBe(`exec -c model_reasoning_effort="xhigh" -s read-only -C ${repo} -`);
-    expect(codexArgs).toContain(`exec -c model_reasoning_effort="xhigh" --dangerously-bypass-approvals-and-sandbox -C ${worktree} -`);
-    const claudeArgs = await readFile(path.join(state, "claude-args.log"), "utf8");
-    expect(claudeArgs).toContain(`--effort max --dangerously-skip-permissions --add-dir ${worktree}`);
-    expect((await Bun.$`git -C ${repo} branch --show-current`.text()).trim()).toBe("main");
-    expect((await Bun.$`git -C ${worktree} branch --show-current`.text()).trim()).toBe("feat/change");
-    expect((await Bun.$`git -C ${worktree} log -1 --format=%s`.text()).trim()).toBe("feat: change");
-    expect(await Bun.$`git -C ${worktree} show --name-only --format= HEAD`.text()).toContain("feature.txt");
-    expect(await Bun.$`git -C ${worktree} show --name-only --format= HEAD`.text()).not.toContain(".codex/");
-    const reportPrompt = await readFile(path.join(state, "claude-prompts.log"), "utf8");
-    expect(reportPrompt).toContain("Coder: Codex");
-    expect(reportPrompt).toContain("Reviewer: Claude Code");
-    expect(reportPrompt).toContain("Coder session: 00000000-0000-4000-8000-000000000001");
-    expect(reportPrompt).toContain("Final branch: feat/change");
-    expect(reportPrompt).toContain(`Worktree: ${worktree}`);
-    expect(reportPrompt).toContain(`Local commit: ${result.commit}`);
-    expect(reportPrompt).toContain("Commit message: feat: change");
-    expect(reportPrompt).toContain(`- pass 1 ${result.commit} feat: change (feature.txt)`);
-    expect(reportPrompt).toContain("Pull request: none");
-    expect(reportPrompt).toContain("Pull request error: none");
-    expect(reportPrompt).toContain("Title: Fixture spec");
-    expect(reportPrompt).toContain("Subtitle: The loop runs deterministically under test.");
-    expect(reportPrompt).toContain("Haiku: Compose a three-line haiku");
-    expect(reportPrompt).toContain("Haiku topic: Fixture spec - The loop runs deterministically under test.");
-    expect(reportPrompt).toContain("rendered immediately after the subtitle before Metadata");
-    expect(reportPrompt).toContain("The subtitle must be specific to this work");
-    expect(reportPrompt).toContain("| Criterion | Status | Implementation evidence | Test evidence |");
-    expect(reportPrompt).toContain("## Review flags");
-    expect(reportPrompt).toContain("Silent decision:");
-    expect(reportPrompt).toContain("Scope drift:");
-    expect(reportPrompt).toContain("Missing test:");
-    expect(reportPrompt).toContain("Flag scope drift when the diff changes behavior");
-    expect(reportPrompt).toContain("Use UNCLEAR only when spec ambiguity prevents a defensible ACCEPT or REJECT");
-    expect(events).toContainEqual({ type: "done", id: "naming", ok: true, detail: "feat/change" });
-    expect(events).toContainEqual({ type: "done", id: "worktree", ok: true, detail: worktree });
-    expect(events).toContainEqual({ type: "done", id: "commit-1", ok: true, detail: "1 commit" });
-    expect(events.some((event) => event.type === "gate" && event.name === "acceptance criteria" && event.ok)).toBe(true);
-    expect(events).toContainEqual({ type: "log", id: "coder-1", line: "codex-tail" });
-  });
-
-  test("rejects then accepts with resumed sessions", async () => {
-    const { repo, state } = await fixture("reject-accept");
-    process.env.DEVLOOP_TEST_VERDICTS = "REJECT,ACCEPT";
-    const { result } = await run(repo, { max: 3 });
-
-    expect(result.status).toBe("accepted");
-    expect(result.passes).toBe(2);
-    expect(result.commits.map((item) => item.message)).toEqual(["feat: change", "fix: change"]);
-    expect(await readFile(path.join(result.worktree, ".codex/reviews/change-r1.md"), "utf8")).toContain("Verdict: REJECT");
-    expect(await readFile(path.join(result.worktree, ".codex/reviews/change-r2.md"), "utf8")).toContain("Verdict: ACCEPT");
-    expect(await readFile(path.join(state, "codex-args.log"), "utf8")).toContain("exec resume -c model_reasoning_effort=\"xhigh\" --dangerously-bypass-approvals-and-sandbox 00000000-0000-4000-8000-000000000001 -");
-  });
-
-  test("supports swapping coder and reviewer agents", async () => {
-    const { repo, state } = await fixture("swapped-agents");
-    process.env.DEVLOOP_TEST_VERDICTS = "ACCEPT";
-    const { result, events } = await run(repo, { coder: "claude", reviewer: "codex" });
-
-    expect(result.status).toBe("accepted");
-    expect(result.coder).toBe("claude");
-    expect(result.reviewer).toBe("codex");
-    expect(await readFile(path.join(result.worktree, ".codex/tracks/change.md"), "utf8")).toContain("- coder: claude");
-    expect(await readFile(path.join(result.worktree, ".codex/tracks/change.md"), "utf8")).toContain("- reviewer: codex");
-    const claudePrompts = await readFile(path.join(state, "claude-prompts.log"), "utf8");
-    const codexPrompts = await readFile(path.join(state, "codex-prompts.log"), "utf8");
-    expect(claudePrompts).toContain("Work item naming task.");
-    expect(claudePrompts).toContain("You are implementing against an approved spec.");
-    expect(codexPrompts).toContain("You are reviewing a Claude Code implementation.");
-    expect(codexPrompts).toContain("Coder: Claude Code");
-    expect(codexPrompts).toContain("Reviewer: Codex");
-    expect(await readFile(path.join(state, "codex-args.log"), "utf8")).toContain("exec resume -c model_reasoning_effort=\"xhigh\" --dangerously-bypass-approvals-and-sandbox 00000000-0000-4000-8000-000000000001 -");
-    expect(events).toContainEqual({ type: "log", id: "coder-1", line: "claude-tail" });
-  });
-
-  test("stalls on repeated reject findings", async () => {
-    const { repo } = await fixture("stall");
-    process.env.DEVLOOP_TEST_VERDICTS = "REJECT,REJECT";
-    const { result } = await run(repo, { max: 5 });
-
-    expect(result.status).toBe("stalled");
-    expect(result.passes).toBe(2);
-  });
-
-  test("supports markdown reports", async () => {
-    const { repo, state } = await fixture("markdown");
-    process.env.DEVLOOP_TEST_VERDICTS = "ACCEPT";
-    const { result } = await run(repo, { reportFormat: "markdown" });
-
-    expect(result.report).toBe(".codex/reports/change.md");
-    await exists(path.join(result.worktree, ".codex/reports/change.md"));
-    expect(await exists(path.join(result.worktree, ".codex/reports/change.html"), false)).toBe(false);
-    expect(await readFile(path.join(state, "claude-prompts.log"), "utf8")).toContain("in markdown");
-  });
-
-  test("isolates default runs from files dirty before the run", async () => {
-    const { repo } = await fixture("dirty-before");
-    await writeFile(path.join(repo, "dirty.txt"), "do not commit\n");
-    await writeFile(path.join(repo, "old.txt"), "old\n");
-    await Bun.$`git -C ${repo} add old.txt`.quiet();
-    await Bun.$`git -C ${repo} commit -q -m old`.quiet();
-    await Bun.$`git -C ${repo} mv old.txt renamed.txt`.quiet();
-    process.env.DEVLOOP_TEST_VERDICTS = "ACCEPT";
-    const { result } = await run(repo);
-
-    expect(result.status).toBe("accepted");
-    expect(await Bun.$`git -C ${result.worktree} show --name-only --format= HEAD`.text()).toContain("feature.txt");
-    expect(await Bun.$`git -C ${result.worktree} show --name-only --format= HEAD`.text()).not.toContain("dirty.txt");
-    expect(await Bun.$`git -C ${result.worktree} show --name-only --format= HEAD`.text()).not.toContain("renamed.txt");
-    expect(await exists(path.join(result.worktree, "dirty.txt"), false)).toBe(false);
-    expect(await Bun.$`git -C ${repo} status --short -- dirty.txt`.text()).toContain("?? dirty.txt");
-    expect(await Bun.$`git -C ${repo} status --short -- renamed.txt`.text()).toContain("renamed.txt");
-  });
-
-  test("supports opting out and running in the current worktree", async () => {
-    const { repo, state } = await fixture("in-place");
-    await writeFile(path.join(repo, "dirty.txt"), "do not commit\n");
-    await writeFile(path.join(repo, "old.txt"), "old\n");
-    await Bun.$`git -C ${repo} add old.txt`.quiet();
-    await Bun.$`git -C ${repo} commit -q -m old`.quiet();
-    await Bun.$`git -C ${repo} mv old.txt renamed.txt`.quiet();
-    process.env.DEVLOOP_TEST_VERDICTS = "ACCEPT";
-    const { result, events } = await run(repo, { worktree: false });
-
-    expect(result.status).toBe("accepted");
-    expect(result.worktree).toBe(repo);
-    expect(result.sourceRepo).toBe(repo);
-    expect(await readFile(path.join(state, "codex-args.log"), "utf8")).toContain(`exec -c model_reasoning_effort="xhigh" --dangerously-bypass-approvals-and-sandbox -C ${repo} -`);
-    expect((await Bun.$`git -C ${repo} branch --show-current`.text()).trim()).toBe("feat/change");
-    expect(await Bun.$`git -C ${repo} show --name-only --format= HEAD`.text()).toContain("feature.txt");
-    expect(await Bun.$`git -C ${repo} show --name-only --format= HEAD`.text()).not.toContain("dirty.txt");
-    expect(await Bun.$`git -C ${repo} show --name-only --format= HEAD`.text()).not.toContain("renamed.txt");
-    expect(await Bun.$`git -C ${repo} status --short -- dirty.txt`.text()).toContain("?? dirty.txt");
-    expect(await Bun.$`git -C ${repo} status --short -- renamed.txt`.text()).toContain("renamed.txt");
-    expect(events.some((event) => event.type === "step" && event.id === "worktree")).toBe(false);
-  });
-
-  test("does not create an in-place branch when a coder pass has no eligible changes", async () => {
-    const { repo } = await fixture("in-place-no-changes");
-    process.env.DEVLOOP_TEST_NO_CODE_CHANGE = "1";
-    process.env.DEVLOOP_TEST_VERDICTS = "ACCEPT";
-    const { result, events } = await run(repo, { worktree: false });
-
-    expect(result.status).toBe("accepted");
-    expect(result.branch).toBe("main");
-    expect(result.commit).toBe("");
-    expect(result.commits).toEqual([]);
-    expect((await Bun.$`git -C ${repo} branch --show-current`.text()).trim()).toBe("main");
-    expect(events).toContainEqual({ type: "done", id: "commit-1", ok: true, detail: "no changes" });
-  });
-
-  test("reports commit errors", async () => {
-    const { repo } = await fixture("commit-error");
-    await writeFile(path.join(repo, ".git/hooks/pre-commit"), "#!/usr/bin/env bash\necho 'pre-commit blocked commit' >&2\nexit 1\n", { mode: 0o755 });
-    process.env.DEVLOOP_TEST_VERDICTS = "ACCEPT";
-    const { result, events } = await run(repo);
-
-    expect(result.status).toBe("commit-error");
-    expect(events).toContainEqual({ type: "done", id: "commit-1", ok: false, detail: "pre-commit blocked commit" });
-  });
-
-  test("creates one bundled commit per coder pass", async () => {
-    const { repo } = await fixture("multi-file-pass");
-    process.env.DEVLOOP_TEST_VERDICTS = "ACCEPT";
-    process.env.DEVLOOP_TEST_MULTI_COMMIT = "1";
-    const { result, events } = await run(repo);
-
-    expect(result.status).toBe("accepted");
-    expect(result.commits).toEqual([{ pass: 1, commit: result.commit, message: "feat: change", paths: ["api.txt", "ui.txt"] }]);
-    expect(result.commitMessage).toBe("feat: change");
-    expect((await Bun.$`git -C ${result.worktree} log --format=%s --reverse main..HEAD`.text()).trim()).toBe("feat: change");
-    expect(events).toContainEqual({ type: "done", id: "commit-1", ok: true, detail: "1 commit" });
-  });
-
-  test("optionally pushes and opens a pull request", async () => {
-    const { repo, state } = await fixture("create-pr");
-    const remote = path.join(path.dirname(repo), "remote.git");
-    await Bun.$`git init -q --bare ${remote}`.quiet();
-    await Bun.$`git -C ${repo} remote add origin ${remote}`.quiet();
-    process.env.DEVLOOP_TEST_VERDICTS = "ACCEPT";
-    const { result, events } = await run(repo, { createPr: true });
-
-    expect(result.status).toBe("accepted");
-    expect(result.pullRequest).toBe("https://github.com/example/repo/pull/42");
-    expect(result.pullRequestError).toBe("");
-    expect(await Bun.$`git -C ${repo} ls-remote --heads origin feat/change`.text()).toContain("refs/heads/feat/change");
-    expect(await readFile(path.join(state, "gh-args.log"), "utf8")).toBe("pr create --fill --base main --head feat/change\n");
-    expect(await readFile(path.join(state, "gh-pwd.log"), "utf8")).toBe(`${result.worktree}\n`);
-    expect(await readFile(path.join(result.worktree, ".codex/tracks/change.md"), "utf8")).toContain("- create-pr: true");
-    const reportPrompt = await readFile(path.join(state, "claude-prompts.log"), "utf8");
-    expect(reportPrompt).toContain("Pull request: https://github.com/example/repo/pull/42");
-    expect(reportPrompt).toContain("- pull request: https://github.com/example/repo/pull/42");
-    expect(events).toContainEqual({ type: "done", id: "pull-request", ok: true, detail: "https://github.com/example/repo/pull/42" });
-  });
-
-  test("reports pull request publishing failures", async () => {
-    const { repo, state } = await fixture("create-pr-fail");
-    process.env.DEVLOOP_TEST_VERDICTS = "ACCEPT";
-    const { result, events } = await run(repo, { createPr: true });
-
-    expect(result.status).toBe("pr-error");
-    expect(result.pullRequest).toBe("");
-    expect(result.pullRequestError).toContain("origin");
-    const error = result.pullRequestError ?? "";
-    expect(events).toContainEqual({ type: "done", id: "pull-request", ok: false, detail: error });
-    const reportPrompt = await readFile(path.join(state, "claude-prompts.log"), "utf8");
-    expect(reportPrompt).toContain("Result: pr-error");
-    expect(reportPrompt).toContain("Pull request: none");
-    expect(reportPrompt).toContain("Pull request error:");
-  });
-
-  test("reports a pushed branch when pull request creation fails after push", async () => {
-    const { repo, state } = await fixture("create-pr-after-push-fail");
-    const remote = path.join(path.dirname(repo), "remote.git");
-    await Bun.$`git init -q --bare ${remote}`.quiet();
-    await Bun.$`git -C ${repo} remote add origin ${remote}`.quiet();
-    process.env.DEVLOOP_TEST_FAIL_GH = "1";
-    process.env.DEVLOOP_TEST_VERDICTS = "ACCEPT";
-    const { result } = await run(repo, { createPr: true });
-
-    expect(result.status).toBe("pr-error");
-    expect(result.pullRequestError).toContain("pushed origin/feat/change");
-    expect(result.pullRequestError).toContain("gh blocked");
-    expect(await Bun.$`git -C ${repo} ls-remote --heads origin feat/change`.text()).toContain("refs/heads/feat/change");
-    const reportPrompt = await readFile(path.join(state, "claude-prompts.log"), "utf8");
-    expect(reportPrompt).toContain("pushed origin/feat/change");
-  });
-
-  test("uses a suffixed branch when the default branch exists", async () => {
-    const { repo } = await fixture("branch-exists");
-    await Bun.$`git -C ${repo} branch feat/change`.quiet();
-    process.env.DEVLOOP_TEST_VERDICTS = "ACCEPT";
-    const { result } = await run(repo);
-
-    expect(result.status).toBe("accepted");
-    expect(result.branch).toBe("feat/change-2");
-    expect(path.basename(result.worktree)).toBe("repo-change-2");
-  });
-
-  test("uses a suffixed worktree path when the default path exists", async () => {
-    const { repo } = await fixture("worktree-path-exists");
-    await mkdir(path.join(path.dirname(repo), "repo-change"), { recursive: true });
-    process.env.DEVLOOP_TEST_VERDICTS = "ACCEPT";
-    const { result } = await run(repo);
-
-    expect(result.status).toBe("accepted");
-    expect(path.basename(result.worktree)).toBe("repo-change-2");
-  });
-
-  test("uses codex-derived artifact names and preserves invocation repo ownership", async () => {
-    const work = await fixture("space-work", undefined, "change with spaces.md");
-    const specOnly = await fixture("space-spec", undefined, "external spec.md");
-    process.env.PATH = `${work.bin}:${oldPath}`;
-    process.env.DEVLOOP_TEST_STATE = work.state;
-    process.env.DEVLOOP_TEST_VERDICTS = "REJECT,ACCEPT";
-
-    process.env.DEVLOOP_TEST_WORK_ITEM = '{"type":"feat","slug":"change-with-spaces","breaking":false}';
-    const spaced = await runDevloop({ spec: work.specPath, max: 2, reportFormat: "html", strict: true, worktree: true, cwd: work.repo, ...defaultAgents });
-    expect(spaced.status).toBe("accepted");
-    await exists(path.join(spaced.worktree, ".codex/reviews/change-with-spaces-r2.md"));
-
-    process.env.DEVLOOP_TEST_VERDICTS = "ACCEPT";
-    process.env.DEVLOOP_TEST_WORK_ITEM = '{"type":"feat","slug":"external-spec","breaking":false}';
-    const external = await runDevloop({ spec: specOnly.specPath, max: 1, reportFormat: "html", strict: true, worktree: true, cwd: work.repo, ...defaultAgents });
-    expect(external.status).toBe("accepted");
-    await exists(path.join(external.worktree, ".codex/tracks/external-spec.md"));
-    expect(await exists(path.join(work.repo, ".codex"), false)).toBe(false);
-    expect(await exists(path.join(specOnly.repo, ".codex"), false)).toBe(false);
-  });
-
-  test("uses codex-derived breaking work names", async () => {
-    const spec = [
-      "---",
-      "type: fix",
-      "breaking: true",
-      "---",
-      "",
-      "# Minimal AI SDK chat orchestration",
-      "",
-      "## Acceptance criteria",
-      "1. The loop runs deterministically under test.",
-      "",
-    ].join("\n");
-    const { repo, specPath } = await fixture("dated-spec", spec, "2026-05-26-minimal-ai-sdk-chat-orchestration.md");
-    process.env.DEVLOOP_TEST_VERDICTS = "ACCEPT";
-    process.env.DEVLOOP_TEST_WORK_ITEM = '{"type":"feat","slug":"minimal-ai-sdk-chat-orchestration","breaking":false}';
-    const result = await runDevloop({ spec: specPath, max: 1, reportFormat: "html", strict: true, worktree: true, cwd: repo, ...defaultAgents });
-
-    expect(result.status).toBe("accepted");
-    expect(result.branch).toBe("fix!/minimal-ai-sdk-chat-orchestration");
-    expect(result.commitMessage).toBe("fix!: minimal-ai-sdk-chat-orchestration");
-    expect(path.basename(result.worktree)).toBe("repo-minimal-ai-sdk-chat-orchestration");
-    await exists(path.join(result.worktree, ".codex/specs/minimal-ai-sdk-chat-orchestration.md"));
-    await exists(path.join(result.worktree, ".codex/tracks/minimal-ai-sdk-chat-orchestration.md"));
-    expect(await readFile(path.join(result.worktree, ".codex/tracks/minimal-ai-sdk-chat-orchestration.md"), "utf8")).toContain("- breaking: true");
-  });
-
-  test("uses complete frontmatter names without a codex naming call", async () => {
-    const spec = [
-      "---",
-      "type: chore",
-      "slug: readme-refresh",
-      "breaking: false",
-      "---",
-      "",
-      "# Refresh README",
-      "",
-      "## Acceptance criteria",
-      "1. The loop runs deterministically under test.",
-      "",
-    ].join("\n");
-    const { repo, state, specPath } = await fixture("frontmatter-name", spec, "ignored-filename.md");
-    process.env.DEVLOOP_TEST_FAIL_NAMING = "1";
-    process.env.DEVLOOP_TEST_VERDICTS = "ACCEPT";
-    const result = await runDevloop({ spec: specPath, max: 1, reportFormat: "html", strict: true, worktree: true, cwd: repo, ...defaultAgents });
-
-    expect(result.status).toBe("accepted");
-    expect(result.branch).toBe("chore/readme-refresh");
-    expect(await readFile(path.join(state, "codex-prompts.log"), "utf8")).not.toContain("Work item naming task.");
-  });
-
-  test("parses noisy codex naming output", async () => {
-    const { repo } = await fixture("noisy-name");
-    process.env.DEVLOOP_TEST_WORK_ITEM = '{"type":"fix","slug":"noisy-json","breaking":false}';
-    process.env.DEVLOOP_TEST_NOISY_NAMING = "1";
-    process.env.DEVLOOP_TEST_VERDICTS = "ACCEPT";
-    const result = await runDevloop({ spec: path.join(repo, ".specs/change.md"), max: 1, reportFormat: "html", strict: true, worktree: true, cwd: repo, ...defaultAgents });
-
-    expect(result.status).toBe("accepted");
-    expect(result.branch).toBe("fix/noisy-json");
-    expect(await readFile(path.join(result.worktree, ".codex/logs/noisy-json-naming.log"), "utf8")).toContain("{not json}");
-  });
-
-  test("uses codex-derived fix and chore work names", async () => {
-    const fix = await fixture("fix-prefix", undefined, "fix-null-check.md");
-    process.env.DEVLOOP_TEST_VERDICTS = "ACCEPT";
-    process.env.DEVLOOP_TEST_WORK_ITEM = '{"type":"fix","slug":"null-check","breaking":false}';
-    expect((await runDevloop({ spec: fix.specPath, max: 1, reportFormat: "html", strict: true, worktree: true, cwd: fix.repo, ...defaultAgents })).branch).toBe("fix/null-check");
-
-    const chore = await fixture("chore-prefix", undefined, "docs-readme-refresh.md");
-    process.env.DEVLOOP_TEST_VERDICTS = "ACCEPT";
-    process.env.DEVLOOP_TEST_WORK_ITEM = '{"type":"chore","slug":"readme-refresh","breaking":false}';
-    expect((await runDevloop({ spec: chore.specPath, max: 1, reportFormat: "html", strict: true, worktree: true, cwd: chore.repo, ...defaultAgents })).branch).toBe("chore/readme-refresh");
-  });
-
-  test("rejects invalid codex-derived work names", async () => {
-    const { repo } = await fixture("bad-name");
-    process.env.DEVLOOP_TEST_WORK_ITEM = '{"type":"docs","slug":"feat-bad-name","breaking":false}';
-
-    await expect(run(repo)).rejects.toThrow("naming log:");
-  });
-
-  test("rejects invalid explicit breaking metadata", async () => {
-    const spec = [
-      "---",
-      "breaking: maybe",
-      "---",
-      "",
-      "# Bad metadata",
-      "",
-      "## Acceptance criteria",
-      "1. The loop runs deterministically under test.",
-      "",
-    ].join("\n");
-    const { repo, specPath } = await fixture("bad-breaking", spec);
-
-    await expect(runDevloop({ spec: specPath, max: 1, reportFormat: "html", strict: true, worktree: true, cwd: repo, ...defaultAgents })).rejects.toThrow("frontmatter breaking must be true or false");
-  });
-
-  test("requires acceptance criteria in strict mode", async () => {
-    const { repo } = await fixture("no-criteria", "# Spec\n");
-    await expect(run(repo)).rejects.toThrow("strict mode requires ## Acceptance criteria");
-    await expect(runDevloop({ spec: path.join(repo, ".specs/missing.md"), max: 1, reportFormat: "html", strict: true, worktree: true, cwd: repo, ...defaultAgents })).rejects.toThrow("usage:");
-  });
-
-  test("allows missing criteria only when strict is off", async () => {
-    const { repo } = await fixture("loose", "# Spec\n");
-    process.env.DEVLOOP_TEST_VERDICTS = "ACCEPT";
-    const { result, events } = await run(repo, { strict: false });
-
-    expect(result.status).toBe("accepted");
-    expect(events).toContainEqual({ type: "gate", name: "acceptance criteria", ok: false, detail: "0 found" });
-  });
-
-  test("turns strict accepts without matrix into unclear", async () => {
-    const { repo } = await fixture("no-matrix");
-    process.env.DEVLOOP_TEST_VERDICTS = "ACCEPT";
-    process.env.DEVLOOP_TEST_NO_MATRIX = "1";
-    const { result } = await run(repo);
-
-    expect(result.status).toBe("unclear");
-    expect(result.passes).toBe(1);
-  });
-
-  test("handles agent and review failures", async () => {
-    const codex = await fixture("codex-fail");
-    process.env.DEVLOOP_TEST_FAIL_CODEX = "1";
-    expect((await run(codex.repo)).result.status).toBe("coder-error");
-    delete process.env.DEVLOOP_TEST_FAIL_CODEX;
-
-    const claude = await fixture("claude-fail");
-    process.env.DEVLOOP_TEST_FAIL_CLAUDE = "1";
-    expect((await run(claude.repo)).result.status).toBe("reviewer-error");
-    delete process.env.DEVLOOP_TEST_FAIL_CLAUDE;
-
-    const missing = await fixture("missing-review");
-    process.env.DEVLOOP_TEST_NO_REVIEW = "1";
-    expect((await run(missing.repo)).result.status).toBe("review-missing");
-    delete process.env.DEVLOOP_TEST_NO_REVIEW;
-
-    const noVerdict = await fixture("no-verdict");
-    process.env.DEVLOOP_TEST_NO_VERDICT = "1";
-    expect((await run(noVerdict.repo)).result.status).toBe("no-verdict");
-    delete process.env.DEVLOOP_TEST_NO_VERDICT;
-  });
-
-  test("handles unclear verdicts and missing executables", async () => {
-    const unclear = await fixture("unclear");
-    process.env.DEVLOOP_TEST_VERDICTS = "UNCLEAR";
-    expect((await run(unclear.repo)).result.status).toBe("unclear");
-
-    const missingClaude = await fixture("missing-claude-bin");
-    await rm(path.join(missingClaude.repo, "../bin/claude"), { force: true });
-    process.env.PATH = `${path.join(missingClaude.repo, "../bin")}:/usr/bin:/bin`;
-    process.env.DEVLOOP_TEST_VERDICTS = "ACCEPT";
-    expect((await run(missingClaude.repo)).result.status).toBe("reviewer-error");
-  });
-
-  test("falls back to main when no base branch exists", async () => {
-    const { repo } = await fixture("no-base");
-    await Bun.$`git -C ${repo} branch -m topic`.quiet();
-    process.env.DEVLOOP_TEST_VERDICTS = "ACCEPT";
-    const result = await runDevloop({ spec: path.join(repo, ".specs/change.md"), max: 1, reportFormat: "html", strict: true, worktree: true, cwd: repo, ...defaultAgents });
-    expect(result.status).toBe("accepted");
-    expect(await readFile(path.join(result.worktree, ".codex/tracks/change.md"), "utf8")).toContain("- base: main");
-  });
-
-  test("uses origin head as the base branch when available", async () => {
-    const { repo } = await fixture("origin-head");
-    await Bun.$`git -C ${repo} update-ref refs/remotes/origin/trunk HEAD`.quiet();
-    await Bun.$`git -C ${repo} symbolic-ref refs/remotes/origin/HEAD refs/remotes/origin/trunk`.quiet();
-    process.env.DEVLOOP_TEST_VERDICTS = "ACCEPT";
-    const result = await runDevloop({ spec: path.join(repo, ".specs/change.md"), max: 1, reportFormat: "html", strict: true, worktree: true, cwd: repo, ...defaultAgents });
-
-    expect(result.status).toBe("accepted");
-    expect(await readFile(path.join(result.worktree, ".codex/tracks/change.md"), "utf8")).toContain("- base: trunk");
-  });
-});
-
-async function fixture(name: string, spec = "# Fixture spec\n\n## Acceptance criteria\n1. The loop runs deterministically under test.\n", specName = "change.md") {
-  const dir = path.join(root, name);
-  const repo = path.join(dir, "repo");
-  const bin = path.join(dir, "bin");
-  const state = path.join(dir, "state");
-  await Bun.$`mkdir -p ${repo}/.specs ${bin} ${state}`.quiet();
-  await Bun.$`git init -q ${repo}`.quiet();
-  await Bun.$`git -C ${repo} symbolic-ref HEAD refs/heads/main`.quiet();
-  await writeFile(path.join(repo, "README.md"), "# Fixture\n");
-  const specPath = path.join(repo, ".specs", specName);
-  await writeFile(specPath, spec);
-  await Bun.$`git -C ${repo} config user.email devloop-test@example.com`.quiet();
-  await Bun.$`git -C ${repo} config user.name "devloop test"`.quiet();
-  await Bun.$`git -C ${repo} add README.md`.quiet();
-  await Bun.$`git -C ${repo} commit -q -m init`.quiet();
-  await installMocks(bin);
-  process.env.PATH = `${bin}:${oldPath}`;
-  process.env.DEVLOOP_TEST_STATE = state;
-  return { repo: await real(repo), state, bin, specPath };
-}
-
-async function installMocks(bin: string) {
-  await writeFile(
-    path.join(bin, "codex"),
-    `#!/usr/bin/env bash
-set -euo pipefail
-prompt=$(cat)
-mkdir -p "$DEVLOOP_TEST_STATE"
-printf '%s\\n' "$*" >> "$DEVLOOP_TEST_STATE/codex-args.log"
-printf '%s\\n---\\n' "$prompt" >> "$DEVLOOP_TEST_STATE/codex-prompts.log"
-if [[ "$prompt" == Work\\ item\\ naming\\ task.* ]]; then
-  [[ -z "\${DEVLOOP_TEST_FAIL_NAMING:-}" ]] || exit 42
-  [[ -z "\${DEVLOOP_TEST_NOISY_NAMING:-}" ]] || printf 'trace {"ignore":true}\\n'
-  if [[ -n "\${DEVLOOP_TEST_WORK_ITEM:-}" ]]; then
-    printf '%s\\n' "$DEVLOOP_TEST_WORK_ITEM"
-  else
-    printf '%s\\n' '{"type":"feat","slug":"change","breaking":false}'
-  fi
-  [[ -z "\${DEVLOOP_TEST_NOISY_NAMING:-}" ]] || printf 'tail {not json}\\n'
-  exit 0
-fi
-[[ -z "\${DEVLOOP_TEST_FAIL_CODEX:-}" ]] || exit 42
-if [[ "$prompt" == *"Output path:"* ]]; then
-  [[ -z "\${DEVLOOP_TEST_NO_REVIEW:-}" ]] || exit 0
-  review_file=$(printf '%s\\n' "$prompt" | awk -F': ' '/^Output path: /{print $2; exit}')
-  count=$(( $(cat "$DEVLOOP_TEST_STATE/codex-review-count" 2>/dev/null || echo 0) + 1 ))
-  printf '%s\\n' "$count" > "$DEVLOOP_TEST_STATE/codex-review-count"
-  IFS=',' read -r -a verdicts <<< "\${DEVLOOP_TEST_VERDICTS:-ACCEPT}"
-  verdict="\${verdicts[$(( count <= \${#verdicts[@]} ? count - 1 : \${#verdicts[@]} - 1 ))]}"
-  mkdir -p "$(dirname "$review_file")"
-  {
-    printf '# Review %s\\n\\n' "$count"
-    [[ -n "\${DEVLOOP_TEST_NO_VERDICT:-}" ]] || printf 'Verdict: %s\\n\\n' "$verdict"
-    if [[ -z "\${DEVLOOP_TEST_NO_MATRIX:-}" ]]; then
-      printf '## Acceptance matrix\\n\\n'
-      printf '| Criterion | Status | Implementation evidence | Test evidence |\\n'
-      printf '| --- | --- | --- | --- |\\n'
-      printf '| AC1 | PASS | mock evidence | mock test |\\n\\n'
-    fi
-    printf '## Review flags\\n\\n- Silent decision: absent - None\\n- Scope drift: absent - None\\n- Missing test: absent - None\\n\\n'
-    printf '## Findings\\n\\n'
-    if [[ "$verdict" == "ACCEPT" ]]; then printf 'None\\n\\n'; else printf '1. [must-fix] devloop.ts:1 - repeated fixture finding. Root cause: mock review. Principle: deterministic retry behavior.\\n\\n'; fi
-    printf '## Missing tests\\n\\n- None\\n\\n## Fix instructions\\n\\n'
-    if [[ "$verdict" == "ACCEPT" ]]; then printf 'None\\n\\n'; else printf '1. Fix the repeated fixture finding.\\n\\n'; fi
-    printf '## Notes\\n\\n- None\\n'
-  } > "$review_file"
-  printf 'To continue this session, run codex exec resume 00000000-0000-4000-8000-000000000001\\n'
-  exit 0
-fi
-report_file=$(printf '%s\\n' "$prompt" | sed -n 's/^Write the report to \\([^ ]*\\).*/\\1/p' | head -n 1)
-if [[ -n "$report_file" ]]; then
-  mkdir -p "$(dirname "$report_file")"
-  printf '# mock devloop report\\n' > "$report_file"
-  printf 'To continue this session, run codex exec resume 00000000-0000-4000-8000-000000000001\\n'
-  exit 0
-fi
-count=$(( $(cat "$DEVLOOP_TEST_STATE/codex-count" 2>/dev/null || echo 0) + 1 ))
-printf '%s\\n' "$count" > "$DEVLOOP_TEST_STATE/codex-count"
-track=$(printf '%s\\n' "$prompt" | awk -F': ' '/^Track: /{print $2; exit}')
-[[ -z "$track" ]] || printf '\\n## Pass %s - mock codex\\n- verification: fixture\\n' "$count" >> "$track"
-if [[ -z "\${DEVLOOP_TEST_NO_CODE_CHANGE:-}" ]]; then
-  if [[ -n "\${DEVLOOP_TEST_MULTI_COMMIT:-}" ]]; then
-    printf 'api pass %s\\n' "$count" >> api.txt
-    printf 'ui pass %s\\n' "$count" >> ui.txt
-  else
-    printf 'feature pass %s\\n' "$count" >> feature.txt
-  fi
-fi
-printf 'codex pass %s\\n' "$count"
-printf 'To continue this session, run codex exec resume 00000000-0000-4000-8000-000000000001\\n'
-printf 'codex-tail' >&2
-`,
-    { mode: 0o755 },
-  );
-  await writeFile(
-    path.join(bin, "claude"),
-    `#!/usr/bin/env bash
-set -euo pipefail
-[[ -z "\${DEVLOOP_TEST_FAIL_CLAUDE:-}" ]] || exit 43
-prompt=$(cat)
-mkdir -p "$DEVLOOP_TEST_STATE"
-printf '%s\\n' "$*" >> "$DEVLOOP_TEST_STATE/claude-args.log"
-printf '%s\\n---\\n' "$prompt" >> "$DEVLOOP_TEST_STATE/claude-prompts.log"
-if [[ "$prompt" == Work\\ item\\ naming\\ task.* ]]; then
-  [[ -z "\${DEVLOOP_TEST_FAIL_NAMING:-}" ]] || exit 42
-  [[ -z "\${DEVLOOP_TEST_NOISY_NAMING:-}" ]] || printf 'trace {"ignore":true}\\n'
-  if [[ -n "\${DEVLOOP_TEST_WORK_ITEM:-}" ]]; then
-    printf '%s\\n' "$DEVLOOP_TEST_WORK_ITEM"
-  else
-    printf '%s\\n' '{"type":"feat","slug":"change","breaking":false}'
-  fi
-  [[ -z "\${DEVLOOP_TEST_NOISY_NAMING:-}" ]] || printf 'tail {not json}\\n'
-  exit 0
-fi
-if [[ "$prompt" == *"Output path:"* ]]; then
-  [[ -z "\${DEVLOOP_TEST_NO_REVIEW:-}" ]] || exit 0
-  review_file=$(printf '%s\\n' "$prompt" | awk -F': ' '/^Output path: /{print $2; exit}')
-  count=$(( $(cat "$DEVLOOP_TEST_STATE/claude-review-count" 2>/dev/null || echo 0) + 1 ))
-  printf '%s\\n' "$count" > "$DEVLOOP_TEST_STATE/claude-review-count"
-  IFS=',' read -r -a verdicts <<< "\${DEVLOOP_TEST_VERDICTS:-ACCEPT}"
-  verdict="\${verdicts[$(( count <= \${#verdicts[@]} ? count - 1 : \${#verdicts[@]} - 1 ))]}"
-  mkdir -p "$(dirname "$review_file")"
-  {
-    printf '# Review %s\\n\\n' "$count"
-    [[ -n "\${DEVLOOP_TEST_NO_VERDICT:-}" ]] || printf 'Verdict: %s\\n\\n' "$verdict"
-    if [[ -z "\${DEVLOOP_TEST_NO_MATRIX:-}" ]]; then
-      printf '## Acceptance matrix\\n\\n'
-      printf '| Criterion | Status | Implementation evidence | Test evidence |\\n'
-      printf '| --- | --- | --- | --- |\\n'
-      printf '| AC1 | PASS | mock evidence | mock test |\\n\\n'
-    fi
-    printf '## Review flags\\n\\n- Silent decision: absent - None\\n- Scope drift: absent - None\\n- Missing test: absent - None\\n\\n'
-    printf '## Findings\\n\\n'
-    if [[ "$verdict" == "ACCEPT" ]]; then printf 'None\\n\\n'; else printf '1. [must-fix] devloop.ts:1 - repeated fixture finding. Root cause: mock review. Principle: deterministic retry behavior.\\n\\n'; fi
-    printf '## Missing tests\\n\\n- None\\n\\n## Fix instructions\\n\\n'
-    if [[ "$verdict" == "ACCEPT" ]]; then printf 'None\\n\\n'; else printf '1. Fix the repeated fixture finding.\\n\\n'; fi
-    printf '## Notes\\n\\n- None\\n'
-  } > "$review_file"
-else
-  report_file=$(printf '%s\\n' "$prompt" | sed -n 's/^Write the report to \\([^ ]*\\).*/\\1/p' | head -n 1)
-  if [[ -n "$report_file" ]]; then
-    mkdir -p "$(dirname "$report_file")"
-    printf '# mock devloop report\\n' > "$report_file"
-  else
-    count=$(( $(cat "$DEVLOOP_TEST_STATE/claude-count" 2>/dev/null || echo 0) + 1 ))
-    printf '%s\\n' "$count" > "$DEVLOOP_TEST_STATE/claude-count"
-    track=$(printf '%s\\n' "$prompt" | awk -F': ' '/^Track: /{print $2; exit}')
-    [[ -z "$track" ]] || printf '\\n## Pass %s - mock claude\\n- verification: fixture\\n' "$count" >> "$track"
-    if [[ -z "\${DEVLOOP_TEST_NO_CODE_CHANGE:-}" ]]; then
-      if [[ -n "\${DEVLOOP_TEST_MULTI_COMMIT:-}" ]]; then
-        printf 'api pass %s\\n' "$count" >> api.txt
-        printf 'ui pass %s\\n' "$count" >> ui.txt
-      else
-        printf 'feature pass %s\\n' "$count" >> feature.txt
-      fi
-    fi
-    printf 'claude pass %s\\n' "$count"
-    printf 'claude-tail' >&2
-  fi
-fi
-`,
-    { mode: 0o755 },
-  );
-  await writeFile(
-    path.join(bin, "gh"),
-    `#!/usr/bin/env bash
-set -euo pipefail
-mkdir -p "$DEVLOOP_TEST_STATE"
-printf '%s\\n' "$*" >> "$DEVLOOP_TEST_STATE/gh-args.log"
-printf '%s\\n' "$PWD" >> "$DEVLOOP_TEST_STATE/gh-pwd.log"
-[[ -z "\${DEVLOOP_TEST_FAIL_GH:-}" ]] || { echo 'gh blocked' >&2; exit 42; }
-printf 'https://github.com/example/repo/pull/42\\n'
-`,
-    { mode: 0o755 },
-  );
-}
-
-async function run(repo: string, overrides: Partial<Options> = {}) {
-  const events: Event[] = [];
-  const result = await runDevloop(
-    { spec: path.join(repo, ".specs/change.md"), max: 1, reportFormat: "html", strict: true, worktree: true, cwd: repo, ...defaultAgents, ...overrides },
-    { event: (event) => void events.push(event) },
-  );
-  return { result, events };
-}
-
-async function exists(file: string, expected = true) {
-  const ok = Boolean(await stat(file).catch(() => false));
-  if (expected) expect(ok).toBe(true);
-  return ok;
-}
-
-async function real(file: string) {
-  return realpath(file);
-}
diff --git a/tests/devloop_test.sh b/tests/devloop_test.sh
index 58ac515..7d8a0bc 100755
--- a/tests/devloop_test.sh
+++ b/tests/devloop_test.sh
@@ -2,4 +2,62 @@
 set -euo pipefail
 
 ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
-exec bun test "$ROOT/tests/devloop.test.ts"
+
+fail() {
+  echo "not ok - $*" >&2
+  exit 1
+}
+
+ok() {
+  echo "ok - $*"
+}
+
+contains() {
+  local haystack="$1"
+  local needle="$2"
+  local label="$3"
+  [[ "$haystack" == *"$needle"* ]] || fail "$label missing: $needle"
+}
+
+bash -n "$ROOT/devloop" "$ROOT/install.sh"
+ok "bash syntax"
+
+help="$("$ROOT/devloop" --help)"
+contains "$help" "Common commands:" "help"
+contains "$help" "--create-pr" "help"
+ok "help output"
+
+skill_path="$("$ROOT/devloop" spec --skill-path)"
+[[ "$skill_path" == "$ROOT/skills/spec/SKILL.md" ]] || fail "unexpected skill path: $skill_path"
+ok "spec skill path"
+
+work=$(mktemp -d "${TMPDIR:-/tmp}/devloop-test.XXXXXX")
+trap 'rm -rf "$work"' EXIT
+
+bin_dir="$work/bin"
+DEVLOOP_BIN_DIR="$bin_dir" "$ROOT/install.sh" >/tmp/devloop-install-test.out
+[[ -x "$ROOT/devloop" ]] || fail "devloop is not executable"
+[[ -L "$bin_dir/devloop" ]] || fail "installer did not create symlink"
+"$bin_dir/devloop" --help >/tmp/devloop-help-test.out
+contains "$(cat /tmp/devloop-help-test.out)" "Spec-driven code and review loop." "installed help"
+ok "installer"
+
+agent="$work/spec-agent"
+cat > "$agent" <<'AGENT'
+#!/usr/bin/env bash
+set -euo pipefail
+cat >/tmp/devloop-spec-agent-prompt.txt
+printf '%s\n' '---' 'status: draft' 'type: feat' 'created: 2026-05-29' 'pr: null' '---' '' '# Shell migration spec'
+AGENT
+chmod +x "$agent"
+
+repo="$work/repo"
+mkdir -p "$repo"
+(
+  cd "$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"
+contains "$(cat /tmp/devloop-spec-agent-prompt.txt)" "Keep devloop as Bash." "spec prompt"
+ok "spec generation"
diff --git a/tests/package.test.ts b/tests/package.test.ts
deleted file mode 100644
index 83cb59f..0000000
--- a/tests/package.test.ts
+++ /dev/null
@@ -1,177 +0,0 @@
-import { afterAll, describe, expect, test } from "bun:test";
-import { mkdtemp, readFile, rm, stat } from "node:fs/promises";
-import { tmpdir } from "node:os";
-import path from "node:path";
-
-const root = path.resolve(import.meta.dir, "..");
-const npmCache = await mkdtemp(path.join(tmpdir(), "devloop-npm-cache."));
-
-afterAll(async () => rm(npmCache, { recursive: true, force: true }));
-
-describe("npm package readiness", () => {
-  test("declares public package metadata for the scoped npm package", async () => {
-    const pkg = await packageJson();
-
-    expect(pkg.name).toBe("@satyaborg/devloop");
-    expect(pkg.bin).toEqual({ devloop: "./src/cli.ts" });
-    expect(pkg.description).toBe("Spec-driven code and review loop with Codex and Claude Code.");
-    expect(pkg.license).toBe("MIT");
-    expect(pkg.author).toEqual({ name: "@satyaborg", url: "https://satyaborg.com" });
-    expect(pkg.repository).toEqual({ type: "git", url: "git+https://github.com/satyaborg/devloop.git" });
-    expect(pkg.bugs).toEqual({ url: "https://github.com/satyaborg/devloop/issues" });
-    expect(pkg.homepage).toBe("https://github.com/satyaborg/devloop#readme");
-    expect(pkg.keywords).toEqual(expect.arrayContaining(["agent", "codex", "claude", "cli", "devloop"]));
-    expect(pkg.packageManager).toMatch(/^bun@\d+\.\d+\.\d+$/);
-    expect(pkg.engines).toEqual({ bun: ">=1.2.0" });
-    expect(pkg.publishConfig).toEqual({ access: "public" });
-  });
-
-  test("allows only runtime package files into the packed tarball", async () => {
-    const pkg = await packageJson();
-
-    expect(pkg.files).toEqual([
-      "src",
-      "skills/spec/SKILL.md",
-      "templates/spec.md",
-      "README.md",
-      "LICENSE",
-    ]);
-
-    const paths = await dryRunPackFiles();
-    for (const required of [
-      "package.json",
-      "README.md",
-      "LICENSE",
-      "src/cli.ts",
-      "src/devloop.ts",
-      "src/spec.ts",
-      "src/tui.ts",
-      "src/tui-view.ts",
-      "skills/spec/SKILL.md",
-      "templates/spec.md",
-    ]) {
-      expect(paths).toContain(required);
-    }
-
-    for (const forbidden of [
-      "AGENTS.md",
-      "bunfig.toml",
-      "tsconfig.json",
-      "scripts/install.ts",
-    ]) {
-      expect(paths).not.toContain(forbidden);
-    }
-
-    for (const prefix of ["tests/", "coverage/", ".codex/", ".specs/", ".github/"]) {
-      expect(paths.some((item) => item.startsWith(prefix))).toBe(false);
-    }
-  });
-});
-
-describe("release readiness documentation", () => {
-  test("documents public install paths, badges, prerequisites, and security model", async () => {
-    const readme = await readFile(path.join(root, "README.md"), "utf8");
-
-    expect(readme).toContain("[![CI](https://github.com/satyaborg/devloop/actions/workflows/ci.yml/badge.svg)]");
-    expect(readme).toContain("[![npm version](https://img.shields.io/npm/v/@satyaborg/devloop.svg)]");
-    expect(readme).toContain("[![license](https://img.shields.io/npm/l/@satyaborg/devloop.svg)]");
-    expect(readme).toContain("[![npm downloads](https://img.shields.io/npm/dm/@satyaborg/devloop.svg)]");
-    expect(readme).toContain("npm install -g @satyaborg/devloop");
-    expect(readme).toContain("bunx @satyaborg/devloop");
-    expect(readme).toContain("`devloop` runs a local implementation and review loop");
-    expect(readme).toContain("Prereqs:");
-    expect(readme).toContain("Bun");
-    expect(readme).toContain("bun scripts/install.ts");
-    expect(readme).toContain("runs local agent CLIs against your checkout");
-    expect(readme).toContain("Uses isolated sibling git worktrees by default");
-    expect(readme).toContain("Writes tracks, reviews, reports, logs, session ids, and spec snapshots under `.codex/`");
-    expect(readme).toContain("adds no telemetry");
-  });
-
-  test("documents release automation and first publish setup", async () => {
-    const contributing = await readFile(path.join(root, "CONTRIBUTING.md"), "utf8");
-    const security = await readFile(path.join(root, "SECURITY.md"), "utf8");
-
-    expect(contributing).toContain("Conventional Commits");
-    expect(contributing).toContain("Release Please");
-    expect(contributing).toContain("CHANGELOG.md");
-    expect(contributing).toContain("GitHub releases");
-    expect(contributing).toContain("trusted publishing");
-    expect(contributing).toContain("@satyaborg/devloop");
-    expect(contributing).toContain("must be created or first-published by a maintainer");
-    expect(contributing).toContain("publish.yml");
-    expect(security).toContain("runs local agent CLIs with broad permissions");
-    expect(security).toContain("does not add telemetry");
-  });
-});
-
-describe("open source project files", () => {
-  test("includes contributor, issue, pull request, CI, release, and publish files", async () => {
-    for (const file of [
-      "CONTRIBUTING.md",
-      "SECURITY.md",
-      "CODE_OF_CONDUCT.md",
-      ".github/PULL_REQUEST_TEMPLATE.md",
-      ".github/ISSUE_TEMPLATE/bug_report.yml",
-      ".github/ISSUE_TEMPLATE/feature_request.yml",
-      ".github/workflows/ci.yml",
-      ".github/workflows/release.yml",
-      ".github/workflows/publish.yml",
-      "release-please-config.json",
-      ".release-please-manifest.json",
-      "CHANGELOG.md",
-    ]) {
-      expect(await exists(path.join(root, file))).toBe(true);
-    }
-  });
-
-  test("runs CI, release, and publish workflows with the expected gates", async () => {
-    const ci = await readFile(path.join(root, ".github/workflows/ci.yml"), "utf8");
-    const release = await readFile(path.join(root, ".github/workflows/release.yml"), "utf8");
-    const publish = await readFile(path.join(root, ".github/workflows/publish.yml"), "utf8");
-
-    expect(ci).toContain("pull_request:");
-    expect(ci).toContain("push:");
-    expect(ci).toContain("branches: [main]");
-    expect(ci).toContain("oven-sh/setup-bun");
-    expect(ci).toContain("bun install --frozen-lockfile");
-    expect(ci).toContain("bun run typecheck");
-    expect(ci).toContain("bun test");
-    expect(ci).toContain("npm --cache");
-    expect(ci).toContain("pack --dry-run --json");
-    expect(ci).toContain("bun run package:smoke");
-
-    expect(release).toContain("googleapis/release-please-action");
-    expect(release).toContain("release-type");
-    expect(release).toContain("node");
-    expect(release).toContain("CHANGELOG.md");
-
-    expect(publish).toContain("workflow_run:");
-    expect(publish).toContain("Release Please");
-    expect(publish).toContain("id-token: write");
-    expect(publish).toContain("bun run typecheck");
-    expect(publish).toContain("bun test");
-    expect(publish).toContain("bun run package:smoke");
-    expect(publish).toContain("npm publish");
-    expect(publish).not.toContain("NPM_TOKEN");
-    expect(publish).not.toContain("NODE_AUTH_TOKEN");
-  });
-});
-
-async function packageJson() {
-  return JSON.parse(await readFile(path.join(root, "package.json"), "utf8")) as Record<string, unknown>;
-}
-
-async function dryRunPackFiles() {
-  const output =
-    await Bun.$`npm --cache ${npmCache} pack --dry-run --json`
-      .cwd(root)
-      .quiet()
-      .text();
-  const [pack] = JSON.parse(output) as Array<{ files: Array<{ path: string }> }>;
-  return pack.files.map((file) => file.path).sort();
-}
-
-async function exists(file: string) {
-  return Boolean(await stat(file).catch(() => false));
-}
diff --git a/tests/spec.test.ts b/tests/spec.test.ts
deleted file mode 100644
index f6c55ef..0000000
--- a/tests/spec.test.ts
+++ /dev/null
@@ -1,199 +0,0 @@
-import { afterAll, describe, expect, test } from "bun:test";
-import { chmod, mkdir, mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises";
-import { tmpdir } from "node:os";
-import path from "node:path";
-import {
-  agentInvocation,
-  bundledSpecSkillPath,
-  extractGeneratedSpec,
-  generateSpec,
-  parseSpecArgs,
-  readBundledSpecSkill,
-  specPrompt,
-  specUsage,
-  type AgentRunner,
-  type GenerateSpecOptions,
-} from "../src/spec.ts";
-
-const root = await mkdtemp(path.join(tmpdir(), "devloop-spec-test."));
-
-afterAll(async () => rm(root, { recursive: true, force: true }));
-
-describe("spec command parsing", () => {
-  test("parses generation options and utility actions", () => {
-    expect(parseSpecArgs(["--agent", "claude", "--output", ".specs/x.md", "--force", "notes"], "/repo")).toEqual({
-      type: "generate",
-      options: {
-        agent: "claude",
-        context: ["notes"],
-        cwd: "/repo",
-        force: true,
-        output: ".specs/x.md",
-      },
-    });
-    expect(parseSpecArgs(["--print-skill"], "/repo")).toEqual({ type: "print-skill" });
-    expect(parseSpecArgs(["--skill-path"], "/repo")).toEqual({ type: "skill-path" });
-    expect(parseSpecArgs(["--agent"], "/repo")).toContain("--agent requires a value");
-    expect(parseSpecArgs(["-o"], "/repo")).toContain("--output requires a value");
-    expect(parseSpecArgs(["--wat"], "/repo")).toContain("unknown option");
-    expect(parseSpecArgs(["--help"], "/repo")).toBe(specUsage());
-    expect(parseSpecArgs([], "/repo")).toEqual({
-      type: "generate",
-      options: {
-        agent: "codex",
-        context: [],
-        cwd: "/repo",
-        force: false,
-        output: undefined,
-      },
-    });
-  });
-
-  test("exposes the bundled skill", async () => {
-    expect(bundledSpecSkillPath()).toEndWith(path.join("skills", "spec", "SKILL.md"));
-    expect(await readBundledSpecSkill()).toContain("name: spec");
-    expect(await readBundledSpecSkill()).toContain("Cold Start Interview");
-  });
-});
-
-describe("spec prompt helpers", () => {
-  test("builds agent-specific invocations", () => {
-    expect(agentInvocation("codex", "/repo")).toEqual({ cmd: "codex", args: ["exec", "-c", 'model_reasoning_effort="xhigh"', "-s", "read-only", "-C", "/repo", "-"] });
-    expect(agentInvocation("claude", "/repo")).toEqual({ cmd: "claude", args: ["-p", "--effort", "max", "--add-dir", "/repo"] });
-    expect(agentInvocation("my-agent", "/repo")).toEqual({ cmd: "my-agent", args: [] });
-  });
-
-  test("builds prompts and extracts markdown specs", () => {
-    expect(specPrompt({ context: "notes", output: ".specs/x.md", skill: "skill body", today: "2026-05-28" })).toContain("Output path: .specs/x.md");
-    expect(specPrompt({ context: "notes", skill: "skill body", today: "2026-05-28" })).toContain("choose a .specs/YYYY-MM-DD-<slug>.md path");
-    expect(specPrompt({ context: "No source material was provided.", skill: "skill body", today: "2026-05-28" })).toContain("interview path");
-    expect(extractGeneratedSpec("```markdown\n---\nstatus: draft\n---\n\n# Chat retries\n```")).toBe("---\nstatus: draft\n---\n\n# Chat retries");
-    expect(extractGeneratedSpec("preface\n---\nstatus: draft\n---\n\n# Chat retries")).toBe("---\nstatus: draft\n---\n\n# Chat retries");
-    expect(() => extractGeneratedSpec("no spec here")).toThrow("agent output must include spec frontmatter");
-  });
-});
-
-describe("spec generation", () => {
-  test("generates with codex, file context, and requested output", async () => {
-    const cwd = await fixture("codex-output");
-    await writeFile(path.join(cwd, "notes.md"), "Retry failed chat sends.\n");
-    const calls: Array<{ cmd: string; args: string[]; input: string; cwd: string }> = [];
-    const runner: AgentRunner = async (cmd, args, input, runCwd) => {
-      calls.push({ cmd, args, input, cwd: runCwd });
-      return { code: 0, stdout: specMarkdown("Chat retries"), stderr: "", output: specMarkdown("Chat retries") };
-    };
-
-    const result = await generateSpec(baseOptions(cwd, { context: ["notes.md", "Keep the existing CLI shape."], output: ".specs/chat-retry.md" }), runner);
-
-    expect(result).toEqual({
-      agent: "codex",
-      command: ["codex", "exec", "-c", 'model_reasoning_effort="xhigh"', "-s", "read-only", "-C", cwd, "-"],
-      file: path.join(cwd, ".specs/chat-retry.md"),
-    });
-    expect(calls[0]).toMatchObject({ cmd: "codex", args: ["exec", "-c", 'model_reasoning_effort="xhigh"', "-s", "read-only", "-C", cwd, "-"], cwd });
-    expect(calls[0]!.input).toContain("Source file:");
-    expect(calls[0]!.input).toContain("Retry failed chat sends.");
-    expect(calls[0]!.input).toContain("Context:\nKeep the existing CLI shape.");
-    expect(calls[0]!.input).toContain("Current date: 2026-05-28");
-    expect(await readFile(result.file, "utf8")).toBe(`${specMarkdown("Chat retries")}\n`);
-  });
-
-  test("generates from the bundled cold-start interview path without context", async () => {
-    const cwd = await fixture("cold-start");
-    const calls: Array<{ input: string }> = [];
-    const runner: AgentRunner = async (_cmd, _args, input) => {
-      calls.push({ input });
-      return { code: 0, stdout: specMarkdown("Cold start spec"), stderr: "", output: specMarkdown("Cold start spec") };
-    };
-
-    const result = await generateSpec(baseOptions(cwd, { context: [] }), runner);
-
-    expect(calls[0]!.input).toContain("No source material was provided");
-    expect(calls[0]!.input).toContain("Cold Start Interview");
-    expect(result.file).toBe(path.join(cwd, ".specs", "2026-05-28-cold-start-spec.md"));
-  });
-
-  test("generates dated paths and suffixes existing files", async () => {
-    const cwd = await fixture("dated-output");
-    await mkdir(path.join(cwd, ".specs"), { recursive: true });
-    await writeFile(path.join(cwd, ".specs", "2026-05-28-chat-retries.md"), "exists\n");
-    const runner: AgentRunner = async () => ({ code: 0, stdout: specMarkdown("Chat retries"), stderr: "", output: specMarkdown("Chat retries") });
-
-    const result = await generateSpec(baseOptions(cwd, { agent: "claude", context: ["Add retries."] }), runner);
-
-    expect(result.command).toEqual(["claude", "-p", "--effort", "max", "--add-dir", cwd]);
-    expect(result.file).toBe(path.join(cwd, ".specs", "2026-05-28-chat-retries-2.md"));
-    expect(await readFile(result.file, "utf8")).toContain("# Chat retries");
-  });
-
-  test("refuses explicit overwrites and reports agent failures", async () => {
-    const cwd = await fixture("errors");
-    const file = path.join(cwd, ".specs", "exists.md");
-    await mkdir(path.dirname(file), { recursive: true });
-    await writeFile(file, "exists\n");
-    const ok: AgentRunner = async () => ({ code: 0, stdout: specMarkdown("Overwrite spec"), stderr: "", output: specMarkdown("Overwrite spec") });
-    const fail: AgentRunner = async () => ({ code: 1, stdout: "", stderr: "agent failed\n", output: "agent failed\n" });
-
-    await expect(generateSpec(baseOptions(cwd, { output: ".specs/exists.md" }), ok)).rejects.toThrow("spec already exists");
-    await expect(generateSpec(baseOptions(cwd), fail)).rejects.toThrow("agent failed");
-    const forced = await generateSpec(baseOptions(cwd, { force: true, output: ".specs/exists.md" }), ok);
-    expect(await readFile(forced.file, "utf8")).toContain("# Overwrite spec");
-  });
-
-  test("runs a custom agent command through stdin", async () => {
-    const cwd = await fixture("custom-agent");
-    const agent = path.join(cwd, "agent.sh");
-    await writeFile(
-      agent,
-      [
-        "#!/usr/bin/env bash",
-        "set -euo pipefail",
-        "cat > prompt.log",
-        "printf '%s\\n' '---' 'status: draft' 'type: feat' 'created: 2026-05-28' 'pr: null' '---' '' '# Custom agent spec'",
-        "",
-      ].join("\n"),
-    );
-    await chmod(agent, 0o755);
-
-    const result = await generateSpec(baseOptions(cwd, { agent, context: ["Use a custom agent."] }));
-
-    expect(result.command).toEqual([agent]);
-    expect(await exists(path.join(cwd, "prompt.log"))).toBe(true);
-    expect(result.file).toBe(path.join(cwd, ".specs", "2026-05-28-custom-agent-spec.md"));
-    expect(await readFile(result.file, "utf8")).toContain("# Custom agent spec");
-  });
-});
-
-async function fixture(name: string) {
-  const dir = path.join(root, name);
-  await mkdir(dir, { recursive: true });
-  return dir;
-}
-
-function baseOptions(cwd: string, overrides: Partial<GenerateSpecOptions> = {}): GenerateSpecOptions {
-  return {
-    agent: "codex",
-    context: ["Add a spec generator."],
-    cwd,
-    force: false,
-    today: "2026-05-28",
-    ...overrides,
-  };
-}
-
-function specMarkdown(title: string) {
-  return [
-    "---",
-    "status: draft",
-    "type: feat",
-    "created: 2026-05-28",
-    "pr: null",
-    "---",
-    "",
-    `# ${title}`,
-  ].join("\n");
-}
-
-async function exists(file: string) {
-  return Boolean(await stat(file).catch(() => false));
-}
diff --git a/tests/tui-view.test.ts b/tests/tui-view.test.ts
deleted file mode 100644
index 85c7876..0000000
--- a/tests/tui-view.test.ts
+++ /dev/null
@@ -1,91 +0,0 @@
-import { describe, expect, test } from "bun:test";
-import { view, type Row } from "../src/tui-view.ts";
-
-const baseRow = {
-  id: "step",
-  title: "run tests",
-  status: "run",
-  detail: "running",
-  lines: [],
-  open: false,
-} satisfies Row;
-
-describe("tui view", () => {
-  test("renders empty state with logo and help", () => {
-    const output = view([], 0);
-
-    expect(output).toContain("____/ /__");
-    expect(output).toContain("enter toggles logs, ↑/↓ moves");
-  });
-
-  test("renders closed and open rows", () => {
-    const closed = view([{ ...baseRow, lines: ["hidden"] }], 0);
-    const open = view([{ ...baseRow, status: "ok", detail: "completed", lines: Array.from({ length: 82 }, (_, i) => `line-${i}`), open: true }], 0);
-
-    expect(closed).toContain("> ⠋ [+] run tests - running");
-    expect(closed).not.toContain("hidden");
-    expect(open).toContain("> ok [-] run tests - completed");
-    expect(open).not.toContain("line-0");
-    expect(open).toContain("line-81");
-  });
-
-  test("animates running rows with a spinner frame", () => {
-    const output = view([{ ...baseRow }], 0, undefined, 3);
-
-    expect(output).toContain("> ⠸     run tests - running");
-  });
-
-  test("renders failed rows and result details", () => {
-    const output = view([{ ...baseRow, status: "fail", detail: "failed" }], 0, {
-      status: "commit-error",
-      passes: 1,
-      max: 5,
-      report: ".codex/reports/change.html",
-      track: ".codex/tracks/change.md",
-      branch: "feat/change",
-      commit: "",
-      commitMessage: "",
-      commits: [],
-      pullRequest: "https://github.com/example/repo/pull/42",
-      worktree: "/tmp/repo-change",
-      sourceRepo: "/tmp/repo",
-      coder: "codex",
-      reviewer: "claude",
-      coderSessionId: "codex-session",
-      reviewerSessionId: "claude-session",
-    });
-
-    expect(output).toContain("> !!     run tests - failed");
-    expect(output).toContain("result:   commit-error");
-    expect(output).toContain("reviewer: claude");
-    expect(output).toContain("commit:   none");
-    expect(output).toContain("pr:       https://github.com/example/repo/pull/42");
-    expect(output).toContain("worktree: /tmp/repo-change");
-    expect(output).toContain("report:   /tmp/repo-change/.codex/reports/change.html");
-    expect(output).toContain("track:    /tmp/repo-change/.codex/tracks/change.md");
-  });
-
-  test("suppresses worktree details for in-place results", () => {
-    const output = view([], 0, {
-      status: "accepted",
-      passes: 1,
-      max: 5,
-      report: ".codex/reports/change.html",
-      track: ".codex/tracks/change.md",
-      branch: "feat/change",
-      commit: "abc123",
-      commitMessage: "feat: change",
-      commits: [{ pass: 1, commit: "abc123", message: "feat: change", paths: ["feature.txt"] }],
-      worktree: "/tmp/repo",
-      sourceRepo: "/tmp/repo",
-      coder: "codex",
-      reviewer: "claude",
-      coderSessionId: "codex-session",
-      reviewerSessionId: "claude-session",
-    });
-
-    expect(output).not.toContain("worktree:");
-    expect(output).toContain("report:   .codex/reports/change.html");
-    expect(output).toContain("track:    .codex/tracks/change.md");
-  });
-});
diff --git a/tsconfig.json b/tsconfig.json
deleted file mode 100644
index b1f1259..0000000
--- a/tsconfig.json
+++ /dev/null
@@ -1,14 +0,0 @@
-{
-  "compilerOptions": {
-    "allowImportingTsExtensions": true,
-    "isolatedModules": true,
-    "module": "Preserve",
-    "moduleResolution": "Bundler",
-    "noEmit": true,
-    "skipLibCheck": true,
-    "strict": true,
-    "target": "ES2022",
-    "types": ["bun-types"]
-  },
-  "include": ["src/**/*.ts", "tests/**/*.ts"]
-}

From 7fb28cc7f7da3788d7ef3a233242465ad4a8e2c9 Mon Sep 17 00:00:00 2001
From: satyaborg <satya.borg@gmail.com>
Date: Fri, 29 May 2026 11:54:03 +1000
Subject: [PATCH 3/3] fix: harden bash runtime helpers

---
 devloop               |  27 +++++++---
 tests/devloop_test.sh | 112 ++++++++++++++++++++++++++++++++++++++++++
 2 files changed, 133 insertions(+), 6 deletions(-)

diff --git a/devloop b/devloop
index 363e129..5f55271 100755
--- a/devloop
+++ b/devloop
@@ -243,12 +243,14 @@ run_devloop() {
   criteria_count="$(line_count "$criteria_file")"
   if [ "$strict" = true ] && [ "$criteria_count" -eq 0 ]; then
     printf '%s\n' "strict mode requires ## Acceptance criteria" >&2
+    rm -f "$criteria_file"
     return 2
   fi
   event_gate "acceptance criteria" "$criteria_count" "$criteria_count found"
 
   SOURCE_REPO="$(git -C "$PWD" rev-parse --show-toplevel 2>/dev/null)" || {
     printf '%s\n' "not inside a git repository" >&2
+    rm -f "$criteria_file"
     return 2
   }
   local source_branch
@@ -269,6 +271,7 @@ run_devloop() {
     else
       printf 'naming failed: %s\n' "$naming_error" >&2
     fi
+    rm -f "$criteria_file"
     return 2
   fi
   local slug="$WORK_SLUG"
@@ -277,7 +280,10 @@ run_devloop() {
   local repo="$SOURCE_REPO"
   if [ "$use_worktree" = true ]; then
     event_step "worktree" "create worktree"
-    repo="$(create_worktree "$SOURCE_REPO" "$WORK_TYPE" "$WORK_BREAKING" "$WORK_SLUG")" || return 2
+    repo="$(create_worktree "$SOURCE_REPO" "$WORK_TYPE" "$WORK_BREAKING" "$WORK_SLUG")" || {
+      rm -f "$criteria_file"
+      return 2
+    }
     event_done "worktree" true "$repo"
   fi
   WORKTREE_REPO="$repo"
@@ -990,12 +996,13 @@ new_uuid() {
   if command -v uuidgen >/dev/null 2>&1; then
     uuidgen | tr '[:upper:]' '[:lower:]'
   else
-    printf '00000000-0000-4000-8000-%012d\n' "$$"
+    printf '00000000-0000-4000-8000-%04x%04x%04x\n' "$(( RANDOM & 0xffff ))" "$(( RANDOM & 0xffff ))" "$(( RANDOM & 0xffff ))"
   fi
 }
 
 extract_session_id() {
   printf '%s\n' "$1" |
+    grep -Ei '(session.?id|thread_id|codex exec resume|codex resume|To continue this session)' |
     grep -Eio '[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}' |
     head -n 1 |
     tr '[:upper:]' '[:lower:]'
@@ -1291,9 +1298,15 @@ findings_hash() {
   ' "$file" |
     tr -d '0-9' |
     tr '\r\n\t' ' ' |
-    sed -E 's/[ ]+/ /g; s/\./\
-/g; s/^[[:space:]]+|[[:space:]]+$//g' |
-    awk 'NF' |
+    awk '{
+      gsub(/[[:space:]]+/, " ")
+      count = split($0, parts, ".")
+      for (i = 1; i <= count; i++) {
+        line = parts[i]
+        gsub(/^[[:space:]]+|[[:space:]]+$/, "", line)
+        if (line != "") print line
+      }
+    }' |
     LC_ALL=C sort |
     hash_sha256
 }
@@ -1724,4 +1737,6 @@ generated_spec_path() {
   printf '%s\n' "$file"
 }
 
-main "$@"
+if [ "${DEVLOOP_LIB:-}" != "1" ]; then
+  main "$@"
+fi
diff --git a/tests/devloop_test.sh b/tests/devloop_test.sh
index 7d8a0bc..d3cf449 100755
--- a/tests/devloop_test.sh
+++ b/tests/devloop_test.sh
@@ -19,9 +19,20 @@ contains() {
   [[ "$haystack" == *"$needle"* ]] || fail "$label missing: $needle"
 }
 
+equals() {
+  local actual="$1"
+  local expected="$2"
+  local label="$3"
+  [[ "$actual" == "$expected" ]] || fail "$label expected [$expected], got [$actual]"
+}
+
 bash -n "$ROOT/devloop" "$ROOT/install.sh"
 ok "bash syntax"
 
+DEVLOOP_LIB=1
+source "$ROOT/devloop"
+unset DEVLOOP_LIB
+
 help="$("$ROOT/devloop" --help)"
 contains "$help" "Common commands:" "help"
 contains "$help" "--create-pr" "help"
@@ -34,6 +45,107 @@ ok "spec skill path"
 work=$(mktemp -d "${TMPDIR:-/tmp}/devloop-test.XXXXXX")
 trap 'rm -rf "$work"' EXIT
 
+criteria_file="$work/criteria.md"
+cat > "$criteria_file" <<'MARKDOWN'
+# Spec
+
+## Acceptance criteria
+1. First thing
+- Second thing
+
+## Notes
+Ignore me
+MARKDOWN
+equals "$(parse_criteria "$criteria_file")" $'First thing\nSecond thing' "parse_criteria"
+
+review_file="$work/review.md"
+cat > "$review_file" <<'MARKDOWN'
+# Review
+
+Verdict: ACCEPT
+
+## Acceptance matrix
+
+| Criterion | Status | Implementation evidence | Test evidence |
+| --- | --- | --- | --- |
+| AC1 | PASS | code path | test |
+| AC2 | PASS | behavior | test |
+MARKDOWN
+equals "$(parse_verdict "$review_file")" "ACCEPT" "parse_verdict"
+has_passing_matrix "$review_file" 2 || fail "has_passing_matrix rejected passing matrix"
+sed 's/| AC2 | PASS |/| AC2 | FAIL |/' "$review_file" > "$work/review-fail.md"
+if has_passing_matrix "$work/review-fail.md" 2; then fail "has_passing_matrix accepted failing matrix"; fi
+
+findings_a="$work/findings-a.md"
+findings_b="$work/findings-b.md"
+cat > "$findings_a" <<'MARKDOWN'
+## Findings
+
+1. Fix item 123.
+2. Another item 456.
+
+## Notes
+none
+MARKDOWN
+cat > "$findings_b" <<'MARKDOWN'
+## Findings
+
+2. Another item 999.
+1. Fix item 000.
+
+## Notes
+none
+MARKDOWN
+equals "$(findings_hash "$findings_a")" "$(findings_hash "$findings_b")" "findings_hash normalizes order and numbers"
+
+equals "$(slugify "Feat: Chat Retry's")" "feat-chat-retrys" "slugify"
+equals "$(parse_bool yes)" "true" "parse_bool true"
+equals "$(parse_bool 0)" "false" "parse_bool false"
+if parse_bool maybe >/dev/null 2>&1; then fail "parse_bool accepted invalid value"; fi
+
+frontmatter_text=$'---\ntype: fix!\nslug: "Chat Retry"\nbreaking: true\nempty: null\n---\n# Title'
+equals "$(frontmatter_value type "$frontmatter_text")" "fix!" "frontmatter type"
+equals "$(frontmatter_value slug "$frontmatter_text")" "Chat Retry" "frontmatter slug"
+equals "$(frontmatter_value empty "$frontmatter_text")" "" "frontmatter ignores null"
+
+parse_work_item 'noise {"type":"feat","slug":"chat-retry","breaking":false}' || fail "parse_work_item failed"
+equals "$WORK_TYPE" "feat" "work item type"
+equals "$WORK_SLUG" "chat-retry" "work item slug"
+equals "$WORK_BREAKING" "false" "work item breaking"
+if parse_work_item '{"type":"feat","slug":"feat-chat-retry","breaking":false}' >/dev/null 2>&1; then fail "parse_work_item accepted type-prefixed slug"; fi
+
+equals "$(branch_base fix true null-check)" "fix!/null-check" "branch_base breaking"
+equals "$(pass_commit_message feat false chat-retry 1)" "feat: chat-retry" "first pass commit"
+equals "$(pass_commit_message feat false chat-retry 2)" "fix: chat-retry" "later pass commit"
+equals "$(pass_commit_message chore false docs 2)" "chore: docs" "later chore commit"
+
+branch_repo="$work/branch-repo"
+git init -q "$branch_repo"
+git -C "$branch_repo" config user.email devloop-test@example.com
+git -C "$branch_repo" config user.name "devloop test"
+printf x > "$branch_repo/file.txt"
+git -C "$branch_repo" add file.txt
+git -C "$branch_repo" commit -q -m init
+git -C "$branch_repo" branch feat/chat-retry
+equals "$(next_branch "$branch_repo" feat false chat-retry "")" "feat/chat-retry-2" "next_branch suffix"
+
+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"
+
+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"
+
+old_path="$PATH"
+no_uuid_path="$work/no-uuid"
+mkdir -p "$no_uuid_path"
+PATH="$no_uuid_path"
+uuid_one="$(new_uuid)"
+uuid_two="$(new_uuid)"
+PATH="$old_path"
+[[ "$uuid_one" != "$uuid_two" ]] || fail "new_uuid fallback returned duplicate values"
+contains "$uuid_one" "00000000-0000-4000-8000-" "new_uuid fallback format"
+ok "pure helpers"
+
 bin_dir="$work/bin"
 DEVLOOP_BIN_DIR="$bin_dir" "$ROOT/install.sh" >/tmp/devloop-install-test.out
 [[ -x "$ROOT/devloop" ]] || fail "devloop is not executable"
