Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 12 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,16 @@ devloop spec
devloop spec --agent claude --output .specs/chat-retry.md notes.md
```

By default, generated specs are written under `.specs/`. To change that for a repo, open `devloop`, choose `Settings`, and set the default spec directory. The setting is stored in `.devloop/config` and can be repo-relative or absolute:

```ini
spec_dir=/Users/satya/Projects/specs
```

The Settings menu expands `~/...` before saving. Absolute paths are machine-local, so avoid committing them as shared project config.

The interactive spec picker searches the configured directory, `.specs/`, and `.devloop/specs/`.

Strict mode is on by default. It requires acceptance criteria and only accepts
reviews that pass both the spec gate and engineering quality gate:

Expand Down Expand Up @@ -99,10 +109,11 @@ devloop [options] <spec.md> [max=5]

When stdout is a terminal, running `devloop` without arguments opens a menu:

- `Run a spec`: pick a `.specs/*.md` file, review run settings, then start.
- `Run a spec`: pick a spec from the configured spec directory, `.specs/`, or `.devloop/specs/`.
- `Create a spec`: choose the spec agent and provide source context.
- `Continue a run`: pick a prior `.codex/tracks/*.md` and continue in that worktree.
- `Open reports`: pick a prior report from any registered worktree.
- `Settings`: edit repo-local settings such as the default spec directory.
- `Doctor`: verify required commands, optional UI tools, and installed skills.

`gum` powers prompts, confirmations, status output, paging, and setup screens. `fzf` powers searchable pickers for specs, tracks, and reports.
Expand Down
184 changes: 175 additions & 9 deletions devloop
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,139 @@ has_arg() {
return 1
}

trim_string() {
printf '%s\n' "$1" | sed -E 's/^[[:space:]]+//; s/[[:space:]]+$//'
}

devloop_config_file() {
printf '%s\n' ".devloop/config"
}

devloop_config_value() {
local key="$1"
local file line value
file="$(devloop_config_file)"
[ -f "$file" ] || return 1
while IFS= read -r line; do
value="$(printf '%s\n' "$line" | sed -nE "s/^[[:space:]]*$key[[:space:]]*=[[:space:]]*(.*)$/\1/p")"
[ -n "$value" ] || continue
value="${value%%#*}"
value="$(trim_string "$value")"
case "$value" in
\"*\") value="${value#\"}"; value="${value%\"}" ;;
esac
printf '%s\n' "$value"
done < "$file" | tail -n 1
}

normalize_spec_dir() {
local dir="$1"
dir="$(trim_string "$dir")"
case "$dir" in
./*)
while [ "${dir#./}" != "$dir" ]; do dir="${dir#./}"; done
;;
esac
while [ "$dir" != "/" ] && [ "$dir" != "." ] && [ "${dir%/}" != "$dir" ]; do dir="${dir%/}"; done
case "$dir" in
""|"."|".."|"/"|\~*|../*|*/../*|*/..) return 1 ;;
esac
printf '%s\n' "$dir"
}

expand_spec_dir_input() {
local dir="$1"
dir="$(trim_string "$dir")"
case "$dir" in
"~")
if [ -z "${HOME:-}" ]; then return 1; fi
printf '%s\n' "$HOME"
;;
"~/"*)
if [ -z "${HOME:-}" ]; then return 1; fi
printf '%s/%s\n' "${HOME%/}" "${dir#\~/}"
;;
*)
printf '%s\n' "$dir"
;;
esac
}

devloop_spec_dir() {
local value normalized
value="$(devloop_config_value spec_dir || true)"
if normalized="$(normalize_spec_dir "$value")"; then
printf '%s\n' "$normalized"
else
printf '%s\n' ".specs"
fi
}

spec_search_dirs() {
local configured dir seen
configured="$(devloop_spec_dir)"
seen=""
for dir in "$configured" ".specs" ".devloop/specs"; do
[ -n "$dir" ] || continue
case "$seen" in
*"|$dir|"*) continue ;;
esac
seen="$seen|$dir|"
printf '%s\n' "$dir"
done
}

spec_search_label() {
local label="" dir
while IFS= read -r dir; do
if [ -z "$label" ]; then label="$dir"; else label="$label, $dir"; fi
done <<EOF
$(spec_search_dirs)
EOF
printf '%s\n' "$label"
}

list_spec_files() {
local dir
spec_search_dirs |
while IFS= read -r dir; do
if [ -d "$dir" ]; then
find "$dir" -type f -name '*.md'
fi
done | LC_ALL=C sort
}

spec_dir_status() {
local dir="$1"
if [ -d "$dir" ]; then
printf '%s\n' "exists"
else
printf '%s\n' "missing"
fi
}

write_config_spec_dir() {
local dir="$1"
local expanded normalized file tmp
expanded="$(expand_spec_dir_input "$dir")" || {
printf '%s\n' "spec directory could not be expanded" >&2
return 2
}
normalized="$(normalize_spec_dir "$expanded")" || {
printf '%s\n' "spec directory must be absolute, or relative without '..'" >&2
return 2
}
file="$(devloop_config_file)"
mkdir -p "$(dirname "$file")" "$normalized" || return 1
tmp="$(mktemp "${TMPDIR:-/tmp}/devloop-config.XXXXXX")"
if [ -f "$file" ]; then
sed -E '/^[[:space:]]*spec_dir[[:space:]]*=/d' "$file" > "$tmp"
fi
printf 'spec_dir=%s\n' "$normalized" >> "$tmp"
mv "$tmp" "$file"
printf '%s\n' "$normalized"
}

ui_has_gum() {
[ "$USE_TUI" = true ] && command -v gum >/dev/null 2>&1
}
Expand Down Expand Up @@ -463,13 +596,15 @@ interactive_menu() {
"Create a spec" \
"Continue a run" \
"Open reports" \
"Settings" \
"Doctor" \
"Quit")" || return 130
case "$choice" in
"Run a spec") interactive_run_spec; return $? ;;
"Create a spec") interactive_create_spec; return $? ;;
"Continue a run") continue_command; return $? ;;
"Open reports") reports_command; return $? ;;
"Settings") interactive_settings || return $? ;;
"Doctor") devloop_doctor "$ROOT_DIR"; return $? ;;
"Quit") return 0 ;;
esac
Expand All @@ -479,7 +614,7 @@ interactive_menu() {
interactive_run_spec() {
local spec
if ! spec="$(select_spec_file)"; then
printf '%s\n' "no specs found under .specs" >&2
printf 'no specs found under %s\n' "$(spec_search_label)" >&2
if ui_confirm "Create a spec now?"; then
interactive_create_spec
return $?
Expand All @@ -500,6 +635,33 @@ interactive_create_spec() {
fi
}

interactive_settings() {
local choice value saved spec_dir
while true; do
spec_dir="$(devloop_spec_dir)"
ui_header "Settings" "Repo-local devloop config"
ui_print_key_values \
"config" "$(devloop_config_file)" \
"default spec dir" "$spec_dir" \
"spec dir status" "$(spec_dir_status "$spec_dir")" \
"searched spec dirs" "$(spec_search_label)"
choice="$(ui_choose "Edit settings" \
"Change default spec directory" \
"Back")" || return 130
case "$choice" in
"Change default spec directory")
value="$(ui_input "Default spec directory" "$spec_dir")" || return 130
if saved="$(write_config_spec_dir "$value")"; then
printf 'settings saved: spec_dir=%s\n' "$saved" >&2
else
printf '%s\n' "settings not saved" >&2
fi
;;
"Back") return 0 ;;
esac
done
}

interactive_run_setup() {
local spec="$1"
local report_format="html"
Expand Down Expand Up @@ -579,9 +741,7 @@ interactive_run_setup() {
select_spec_file() {
local list selected
list="$(mktemp "${TMPDIR:-/tmp}/devloop-specs.XXXXXX")"
if [ -d ".specs" ]; then
find ".specs" -type f -name '*.md' | LC_ALL=C sort > "$list"
fi
list_spec_files > "$list"
selected="$(ui_pick_from_file "$list" "Select spec")"
local code=$?
rm -f "$list"
Expand Down Expand Up @@ -2345,10 +2505,11 @@ spec_command() {
skill-path) printf '%s\n' "$skill"; return ;;
esac

local context today prompt cmd_args=()
local context today prompt spec_dir cmd_args=()
context="$(resolve_spec_context "${context_items[@]}")"
today="$(date +%F)"
prompt="$(spec_prompt "$context" "$output" "$skill" "$today")"
spec_dir="$(devloop_spec_dir)"
prompt="$(spec_prompt "$context" "$output" "$skill" "$today" "$spec_dir")"
if [ "$USE_TUI" = true ]; then event_step "spec" "generate spec with $agent"; fi
if [ "$agent" = "codex" ]; then
run_with_prompt "$PWD" "" "" "$prompt" codex exec "${CODEX_MODEL_ARGS[@]}" "${CODEX_REASONING_ARGS[@]}" -s read-only -C "$PWD" -
Expand Down Expand Up @@ -2407,11 +2568,12 @@ spec_prompt() {
local output="$2"
local skill="$3"
local today="$4"
local spec_dir="$5"
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."
output_line="Output path: choose a $spec_dir/YYYY-MM-DD-<slug>.md path if you write a file; otherwise return markdown on stdout."
fi
cat <<EOF
Use the installed devloop-spec skill to produce one implementation spec.
Expand Down Expand Up @@ -2458,15 +2620,19 @@ generated_spec_path() {
local output="$2"
local today="$3"
local force="$4"
local title slug file base ext index
local title slug file base ext index spec_dir
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"
spec_dir="$(devloop_spec_dir)"
case "$spec_dir" in
/*) file="$spec_dir/$today-$slug.md" ;;
*) file="$PWD/$spec_dir/$today-$slug.md" ;;
esac
if [ "$force" = true ]; then
printf '%s\n' "$file"
return
Expand Down
2 changes: 1 addition & 1 deletion skills/devloop-spec/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -124,4 +124,4 @@ Keep every standard section present, remove leftover placeholders, and list the

## Output

When a caller provides an output path, write the spec there. Otherwise, write only the markdown spec to stdout or save it under `.specs/YYYY-MM-DD-<slug>.md` in the target repository. Do not wrap the spec in a code fence unless the caller explicitly asks for a fenced snippet.
When a caller provides an output path, write the spec there. Otherwise, write only the markdown spec to stdout or save it under the caller's requested default spec directory, usually `.specs/YYYY-MM-DD-<slug>.md`. Do not wrap the spec in a code fence unless the caller explicitly asks for a fenced snippet.
48 changes: 46 additions & 2 deletions tests/devloop_test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,47 @@ equals "$(next_pass_from_track "$branch_repo/.codex/tracks/chat-retry.md")" "4"
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"

config_repo="$work/config-repo"
mkdir -p "$config_repo/.specs" "$config_repo/.devloop/specs"
config_repo_real="$(cd "$config_repo" && pwd)"
printf '%s\n' "# Legacy" > "$config_repo/.specs/legacy.md"
printf '%s\n' "# Devloop" > "$config_repo/.devloop/specs/devloop.md"
config_specs="$(cd "$config_repo" && list_spec_files)"
contains "$config_specs" ".specs/legacy.md" "default spec search"
contains "$config_specs" ".devloop/specs/devloop.md" "default spec search"
equals "$(cd "$config_repo" && spec_search_label)" ".specs, .devloop/specs" "spec search label"
equals "$(cd "$config_repo" && write_config_spec_dir "custom-specs")" "custom-specs" "write config spec dir"
equals "$(cd "$config_repo" && devloop_spec_dir)" "custom-specs" "configured spec dir"
[[ -d "$config_repo/custom-specs" ]] || fail "configured spec dir was not created"
configured_specs="$(cd "$config_repo" && list_spec_files)"
contains "$configured_specs" ".specs/legacy.md" "configured spec search includes legacy dir"
contains "$configured_specs" ".devloop/specs/devloop.md" "configured spec search includes devloop dir"
equals "$(cd "$config_repo" && generated_spec_path "$spec_output" "" "2026-05-29" false)" "$config_repo_real/custom-specs/2026-05-29-generated.md" "configured generated spec path"
if (cd "$config_repo" && write_config_spec_dir "../bad") >/dev/null 2>&1; then fail "write_config_spec_dir accepted path traversal"; fi

absolute_specs="$work/shared-specs"
equals "$(cd "$config_repo" && write_config_spec_dir "$absolute_specs")" "$absolute_specs" "write absolute config spec dir"
equals "$(cd "$config_repo" && devloop_spec_dir)" "$absolute_specs" "absolute configured spec dir"
[[ -d "$absolute_specs" ]] || fail "absolute configured spec dir was not created"
printf '%s\n' "# Shared" > "$absolute_specs/shared.md"
absolute_configured_specs="$(cd "$config_repo" && list_spec_files)"
contains "$absolute_configured_specs" "$absolute_specs/shared.md" "configured spec search includes absolute dir"
contains "$absolute_configured_specs" ".specs/legacy.md" "absolute spec search includes legacy dir"
equals "$(cd "$config_repo" && generated_spec_path "$spec_output" "" "2026-05-29" false)" "$absolute_specs/2026-05-29-generated.md" "absolute generated spec path"
equals "$(spec_dir_status "$absolute_specs")" "exists" "spec dir status exists"
equals "$(spec_dir_status "$work/missing-specs")" "missing" "spec dir status missing"

tilde_repo="$work/tilde-repo"
tilde_home="$work/home"
mkdir -p "$tilde_repo" "$tilde_home"
equals "$(cd "$tilde_repo" && HOME="$tilde_home" write_config_spec_dir "~/shared-specs")" "$tilde_home/shared-specs" "tilde input expands when saved"
equals "$(cat "$tilde_repo/.devloop/config")" "spec_dir=$tilde_home/shared-specs" "tilde input saved as absolute path"

raw_tilde_repo="$work/raw-tilde-repo"
mkdir -p "$raw_tilde_repo/.devloop"
printf '%s\n' "spec_dir=~/raw-specs" > "$raw_tilde_repo/.devloop/config"
equals "$(cd "$raw_tilde_repo" && devloop_spec_dir)" ".specs" "raw tilde config falls back"

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"

Expand Down Expand Up @@ -258,12 +299,15 @@ AGENT
chmod +x "$agent"

repo="$work/repo"
mkdir -p "$repo"
mkdir -p "$repo/.devloop"
repo_specs="$work/repo-specs"
printf 'spec_dir=%s\n' "$repo_specs" > "$repo/.devloop/config"
(
cd "$repo"
"$ROOT/devloop" spec --agent "$agent" "Keep devloop as Bash." >/tmp/devloop-spec-test.out
)
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"
[[ -f "$repo_specs/$(date +%F)-shell-migration-spec.md" ]] || fail "spec command did not write dated spec under absolute configured dir"
contains "$(cat /tmp/devloop-spec-agent-prompt.txt)" "Keep devloop as Bash." "spec prompt"
contains "$(cat /tmp/devloop-spec-agent-prompt.txt)" "Output path: choose a $repo_specs/" "spec prompt configured output"
ok "spec generation"