From f6611e00c480058014b73708ab7bfdae6eefc5cc Mon Sep 17 00:00:00 2001 From: satyaborg 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] [max=5] | Option | Meaning | | --- | --- | | `--plain` | Force plain output, useful for CI | -| `--tui` | Force the terminal UI | +| `--tui` | Force simple terminal progress output | | `--coder ` | Choose `codex` or `claude` for implementation | | `--reviewer ` | Choose `codex` or `claude` for review | | `--report-format ` | Choose `html` or `markdown` | @@ -102,13 +94,12 @@ devloop [options] [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] [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] [max=5]" +} + +spec_usage() { + cat <<'EOF' +usage: devloop spec [--agent codex|claude|] [--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 </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" < "$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 < + +## Acceptance matrix + +| Criterion | Status | Implementation evidence | Test evidence | +| --- | --- | --- | --- | +| AC1 | | | | + +## Review flags + +- Silent decision: - +- Scope drift: - +- Missing test: - + +## Findings + +1. [severity] - . Root cause: . Principle: . + +## Missing tests + +- + +## Fix instructions + +1. + +## Notes + +- + +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 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"