diff --git a/README.md b/README.md index f9645ac..17328d5 100644 --- a/README.md +++ b/README.md @@ -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: @@ -99,10 +109,11 @@ devloop [options] [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. diff --git a/devloop b/devloop index 10afbcd..94279e9 100755 --- a/devloop +++ b/devloop @@ -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 <&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 } @@ -463,6 +596,7 @@ interactive_menu() { "Create a spec" \ "Continue a run" \ "Open reports" \ + "Settings" \ "Doctor" \ "Quit")" || return 130 case "$choice" in @@ -470,6 +604,7 @@ interactive_menu() { "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 @@ -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 $? @@ -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" @@ -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" @@ -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" - @@ -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-.md path if you write a file; otherwise return markdown on stdout." + output_line="Output path: choose a $spec_dir/YYYY-MM-DD-.md path if you write a file; otherwise return markdown on stdout." fi cat <.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-.md`. Do not wrap the spec in a code fence unless the caller explicitly asks for a fenced snippet. diff --git a/tests/devloop_test.sh b/tests/devloop_test.sh index 4a04b21..220ba81 100755 --- a/tests/devloop_test.sh +++ b/tests/devloop_test.sh @@ -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" @@ -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"