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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ Uninstall with `./scripts/uninstall.sh` (`--dry-run` to preview).
| `devloop spec "..."` | Have an agent interview you and write a spec |
| `devloop nightshift` | Survey configured repos, run selected specs headlessly, and write a morning digest |
| `devloop <spec.md>` | Run a spec |
| `devloop <spec-stack.md>` | Run a stack manifest, one PR per child, each based on the previous |
| `devloop --no-tmux <spec.md>` | Run a spec in the current foreground terminal |
| `devloop --create-pr <spec.md>` | Run a spec and maintain a draft PR, then mark it ready once the accepted checkpoint verifies (requires `gh`) |
| `devloop update` | Install the latest released Devloop |
Expand Down
120 changes: 117 additions & 3 deletions devloop
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,10 @@ ACCEPTED_HEAD=""
REMOTE_HEAD=""
CHECKS_STATE=""
STATUS_COMMENT_ID=""
STACK_SPECS=()
STACK_BRANCHES=()
STACK_PULL_REQUESTS=()
STACK_STATUSES=()
READINESS=""
READINESS_DETAIL=""
RUN_START_PASS=1
Expand Down Expand Up @@ -208,6 +212,7 @@ Common commands:
devloop nightshift
devloop nightshift --install-schedule 02:00
devloop .devloop/specs/change.md
devloop .devloop/specs/change-stack.md
devloop --tui .devloop/specs/change.md
devloop --plain .devloop/specs/change.md
devloop --report-format markdown .devloop/specs/change.md 3
Expand Down Expand Up @@ -3025,6 +3030,13 @@ run_command() {
if [ "$max" -lt 1 ]; then max=1; fi
if [ "$max" -gt 10 ]; then max=10; fi

local resolved_spec
resolved_spec="$(absolute_existing_file "$spec" 2>/dev/null || printf '%s\n' "$spec")"
if is_stack_manifest "$resolved_spec"; then
run_stack "$resolved_spec" "$max" "$report_format" "$strict" "$use_worktree" "$coder" "$reviewer" "$create_pr" "$timeout_minutes"
return $?
fi

if [ "$no_tmux" = false ] && devloop_tmux_interactive; then
local validated_spec repo
validated_spec="$(validate_spec_for_tmux "$spec" "$strict")" || return $?
Expand All @@ -3043,6 +3055,102 @@ run_command() {
return "$(final_exit_code "$code")"
}

parse_stack_children() {
local manifest="$1"
local dir line child
[ -f "$manifest" ] || return 1
dir="$(cd "$(dirname "$manifest")" >/dev/null 2>&1 && pwd -P)" || return 1
spec_section "$manifest" "Stack" |
sed -nE 's/.*\]\(([^)]+\.md)\).*/\1/p; s/^[[:space:]]*[-*0-9.]+[[:space:]]+([^[:space:]]+\.md)[[:space:]]*$/\1/p' |
while IFS= read -r line; do
child="$line"
case "$child" in
/*) ;;
*) child="$dir/${child#./}" ;;
esac
printf '%s\n' "$child"
done
}

is_stack_manifest() {
local spec="$1"
local children
[ -f "$spec" ] || return 1
children="$(parse_stack_children "$spec")" || return 1
[ -n "$children" ]
}

stack_child_missing() {
local children="$1"
local child
while IFS= read -r child; do
[ -n "$child" ] || continue
if [ ! -f "$child" ]; then printf '%s\n' "$child"; return 0; fi
done <<< "$children"
return 1
}

run_stack() {
local manifest="$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"
local timeout_minutes="$9"
local children child missing base index total code
children="$(parse_stack_children "$manifest")"
if [ -z "$children" ]; then
printf 'stack manifest lists no child specs: %s\n' "$manifest" >&2
return 2
fi
if missing="$(stack_child_missing "$children")"; then
printf 'stack child spec not found: %s\n' "$missing" >&2
return 2
fi
total="$(printf '%s\n' "$children" | grep -c .)"
STACK_BRANCHES=()
STACK_PULL_REQUESTS=()
STACK_STATUSES=()
STACK_SPECS=()
base=""
index=0
code=0
while IFS= read -r child; do
[ -n "$child" ] || continue
index=$((index + 1))
ui_print_notice "stack $index/$total: $(basename "$child")"
run_devloop "$child" "$max" "$report_format" "$strict" "$use_worktree" "$coder" "$reviewer" "$create_pr" "$timeout_minutes" "$base"
code=$?
STACK_SPECS+=("$child")
STACK_BRANCHES+=("$FINAL_BRANCH")
STACK_PULL_REQUESTS+=("$PULL_REQUEST")
STACK_STATUSES+=("$STATUS")
if [ "$STATUS" != "accepted" ]; then
printf 'stack stopped at %s (%s)\n' "$(basename "$child")" "$STATUS" >&2
print_stack_summary
return "$(final_exit_code "$code")"
fi
base="$FINAL_BRANCH"
done <<< "$children"
print_stack_summary
return "$(final_exit_code "$code")"
}

print_stack_summary() {
local index total
total="${#STACK_SPECS[@]}"
printf '\ndevloop stack\n\n'
index=0
while [ "$index" -lt "$total" ]; do
result_line "$((index + 1)). $(basename "${STACK_SPECS[$index]}")" \
"${STACK_STATUSES[$index]} | ${STACK_BRANCHES[$index]}${STACK_PULL_REQUESTS[$index]:+ | ${STACK_PULL_REQUESTS[$index]}}"
index=$((index + 1))
done
}

run_devloop() {
local spec_arg="$1"
local max="$2"
Expand All @@ -3053,6 +3161,7 @@ run_devloop() {
local reviewer="$7"
local create_pr="$8"
local timeout_minutes="${9:-$DEFAULT_TIMEOUT_MINUTES}"
local base_override="${10:-}"

MAX="$max"
CODER="$coder"
Expand Down Expand Up @@ -3108,7 +3217,11 @@ run_devloop() {
local source_branch
source_branch="$(git -C "$SOURCE_REPO" rev-parse --abbrev-ref HEAD)"
local base
base="$(base_branch "$SOURCE_REPO")"
if [ -n "$base_override" ]; then
base="$base_override"
else
base="$(base_branch "$SOURCE_REPO")"
fi

event_step "preflight" "running preflight checks"
if preflight_run "$SOURCE_REPO" "$coder" "$reviewer" "$create_pr"; then
Expand Down Expand Up @@ -3140,7 +3253,7 @@ run_devloop() {
local repo="$SOURCE_REPO"
if [ "$use_worktree" = true ]; then
event_step "worktree" "creating worktree"
repo="$(create_worktree "$SOURCE_REPO" "$WORK_TYPE" "$WORK_BREAKING" "$WORK_SLUG")" || {
repo="$(create_worktree "$SOURCE_REPO" "$WORK_TYPE" "$WORK_BREAKING" "$WORK_SLUG" "${base_override:-HEAD}")" || {
rm -f "$criteria_file" "$obligations_file"
return 2
}
Expand Down Expand Up @@ -4179,12 +4292,13 @@ create_worktree() {
local type="$2"
local breaking="$3"
local slug="$4"
local start_point="${5:-HEAD}"
local current branch worktree leaf
current=""
branch="$(next_branch "$repo" "$type" "$breaking" "$slug" "$current")"
leaf="$(branch_leaf "$branch")"
worktree="$(next_worktree_path "$repo" "$leaf")"
if ! git -C "$repo" worktree add -b "$branch" "$worktree" HEAD >/dev/null 2>&1; then
if ! git -C "$repo" worktree add -b "$branch" "$worktree" "$start_point" >/dev/null 2>&1; then
printf 'failed to create worktree\n' >&2
return 1
fi
Expand Down
89 changes: 89 additions & 0 deletions scripts/devloop_test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -1308,6 +1308,95 @@ ok "living status comment"

is_devloop_runtime_artifact_path ".devloop/status/slug-history.tsv" || fail "status history is not treated as a runtime artifact"

stack_dir="$work/stack-specs"
mkdir -p "$stack_dir"
cat > "$stack_dir/2026-08-17-retry-stack.md" <<'MARKDOWN'
# Retry delivery end to end

## Stack

1. [Persist the outbox](./2026-08-17-retry-01-outbox.md) - branch feat/retry-outbox, base main
2. [Retry from the outbox](./2026-08-17-retry-02-sender.md) - branch feat/retry-sender, base feat/retry-outbox

## Notes

- The sender depends on the outbox table.
MARKDOWN
printf '# Outbox\n' > "$stack_dir/2026-08-17-retry-01-outbox.md"
printf '# Sender\n' > "$stack_dir/2026-08-17-retry-02-sender.md"

stack_dir_real="$(cd "$stack_dir" && pwd -P)"
stack_children="$(parse_stack_children "$stack_dir/2026-08-17-retry-stack.md")"
equals "$(printf '%s\n' "$stack_children" | wc -l | tr -d ' ')" "2" "parse_stack_children count"
equals "$(printf '%s\n' "$stack_children" | head -n 1)" "$stack_dir_real/2026-08-17-retry-01-outbox.md" "parse_stack_children resolves the first child"
equals "$(printf '%s\n' "$stack_children" | tail -n 1)" "$stack_dir_real/2026-08-17-retry-02-sender.md" "parse_stack_children preserves order"
is_stack_manifest "$stack_dir/2026-08-17-retry-stack.md" || fail "is_stack_manifest missed a manifest"
if is_stack_manifest "$stack_dir/2026-08-17-retry-01-outbox.md"; then fail "is_stack_manifest matched a plain spec"; fi
if is_stack_manifest "$stack_dir/absent.md"; then fail "is_stack_manifest matched a missing file"; fi

cat > "$stack_dir/bare-stack.md" <<'MARKDOWN'
# Bare stack

## Stack

- 2026-08-17-retry-01-outbox.md
- 2026-08-17-retry-02-sender.md
MARKDOWN
equals "$(parse_stack_children "$stack_dir/bare-stack.md" | head -n 1)" "$stack_dir_real/2026-08-17-retry-01-outbox.md" "parse_stack_children accepts bare paths"

if stack_child_missing "$stack_children" >/dev/null; then fail "stack_child_missing flagged present children"; fi
equals "$(stack_child_missing "$stack_dir/gone.md")" "$stack_dir/gone.md" "stack_child_missing reports the first absent child"

cat > "$stack_dir/empty-stack.md" <<'MARKDOWN'
# Empty stack

## Stack

- None yet
MARKDOWN
if is_stack_manifest "$stack_dir/empty-stack.md"; then fail "is_stack_manifest matched a stack with no child specs"; fi
if run_stack "$stack_dir/empty-stack.md" 1 markdown false false codex claude false 5 >/dev/null 2>&1; then fail "run_stack accepted a manifest with no children"; fi

cat > "$stack_dir/missing-child-stack.md" <<'MARKDOWN'
# Missing child

## Stack

1. [Gone](./gone.md)
MARKDOWN
if run_stack "$stack_dir/missing-child-stack.md" 1 markdown false false codex claude false 5 >/dev/null 2>&1; then fail "run_stack accepted a missing child spec"; fi

STACK_SPECS=("$stack_dir/2026-08-17-retry-01-outbox.md" "$stack_dir/2026-08-17-retry-02-sender.md")
STACK_STATUSES=("accepted" "max-turns")
STACK_BRANCHES=("feat/retry-outbox" "feat/retry-sender")
STACK_PULL_REQUESTS=("https://pr/1" "")
stack_summary_text="$(print_stack_summary)"
contains "$stack_summary_text" "1. 2026-08-17-retry-01-outbox.md" "stack summary first entry"
contains "$stack_summary_text" "accepted | feat/retry-outbox | https://pr/1" "stack summary accepted child"
contains "$stack_summary_text" "max-turns | feat/retry-sender" "stack summary stopped child"
STACK_SPECS=()
STACK_STATUSES=()
STACK_BRANCHES=()
STACK_PULL_REQUESTS=()
ok "stack manifests"

stack_worktree_repo="$work/stack-worktree-repo"
git init -q "$stack_worktree_repo"
git -C "$stack_worktree_repo" config user.email devloop-test@example.com
git -C "$stack_worktree_repo" config user.name "devloop test"
printf 'base\n' > "$stack_worktree_repo/file.txt"
git -C "$stack_worktree_repo" add file.txt
git -C "$stack_worktree_repo" commit -q -m init
git -C "$stack_worktree_repo" branch feat/parent-layer
git -C "$stack_worktree_repo" switch -q feat/parent-layer
printf 'parent\n' >> "$stack_worktree_repo/file.txt"
git -C "$stack_worktree_repo" commit -q -am "parent layer"
git -C "$stack_worktree_repo" switch -q -
stack_child_worktree="$(create_worktree "$stack_worktree_repo" feat false child-layer feat/parent-layer)"
contains "$(cat "$stack_child_worktree/file.txt")" "parent" "create_worktree branches from the requested start point"
git -C "$stack_worktree_repo" worktree remove --force "$stack_child_worktree"
ok "stacked worktree start point"

: > "$work/empty-obligations.txt"
contains "$(review_prompt codex spec.md track.md main 2 out.md slug 5 "$work/empty-obligations.txt" false abc1234)" "Checkpoint: abc1234" "review prompt checkpoint"
contains "$(review_prompt codex spec.md track.md main 2 out.md slug 5 "$work/empty-obligations.txt" false abc1234)" "Do not commit, amend, or push" "review prompt checkpoint rule"
Expand Down
25 changes: 25 additions & 0 deletions skills/devloop-spec/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,31 @@ Write exactly one spec sized for one worktree and one PR. Push back before draft

When interactive, name the overflow, propose the smallest useful slice, and ask the user to confirm before writing. When non-interactive and the source is otherwise implementation-ready, spec the first foundational slice and list deferred work in Notes as follow-up specs.

When the work genuinely needs more than one pull request and the slices are already clear, write a stack instead of refusing. See Stack Manifests.

## Stack Manifests

A stack is one manifest plus one child spec per pull request. `devloop <manifest>` runs the children in order, branching each from the previous child's branch and targeting it as the PR base.

Name the manifest `YYYY-MM-DD-<slug>-stack.md` and the children `YYYY-MM-DD-<slug>-01-<child>.md`, continuing in dependency order. Each child is a complete, standard spec that must stand on its own: independently understandable, independently verifiable, and independently revertible.

The manifest needs an H1, a `## Stack` section listing the children in dependency order, and any cross-cutting notes:

````markdown
# <Problem the whole stack solves>

## Stack

1. [<Child problem>](./YYYY-MM-DD-<slug>-01-<child>.md) - branch <branch>, base <base branch>
2. [<Child problem>](./YYYY-MM-DD-<slug>-02-<child>.md) - branch <branch>, base <previous branch>

## Notes

- <why this order, and what each child depends on>
````

Slice by incremental problem resolution, not by architecture label. Do not create a stack when one coherent pull request can carry the change.

## Interview Gate

Before drafting, decide whether the source is implementation-ready. Start or continue an interview when any of these are true:
Expand Down
Loading