From a645bdcf595cb083d122ec4012da604fd4ad3c93 Mon Sep 17 00:00:00 2001 From: satyaborg Date: Mon, 17 Aug 2026 17:00:05 +1000 Subject: [PATCH] feat: run stack manifests as dependency-ordered pull requests Work that needed more than one pull request had no path through devloop: the spec skill could only refuse it and ask for a smaller slice. A spec with a Stack section is now a manifest. Its children run in order, each worktree branching from the previous child's branch and each PR targeting it as the base, so reviewers get a readable chain instead of one mega-diff. The stack stops at the first child that does not reach accepted. --- README.md | 1 + devloop | 120 ++++++++++++++++++++++++++++++++++- scripts/devloop_test.sh | 89 ++++++++++++++++++++++++++ skills/devloop-spec/SKILL.md | 25 ++++++++ 4 files changed, 232 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 1204bf7..9df080c 100644 --- a/README.md +++ b/README.md @@ -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 ` | Run a spec | +| `devloop ` | Run a stack manifest, one PR per child, each based on the previous | | `devloop --no-tmux ` | Run a spec in the current foreground terminal | | `devloop --create-pr ` | 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 | diff --git a/devloop b/devloop index ff0e150..3ac706f 100755 --- a/devloop +++ b/devloop @@ -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 @@ -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 @@ -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 $? @@ -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" @@ -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" @@ -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 @@ -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 } @@ -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 diff --git a/scripts/devloop_test.sh b/scripts/devloop_test.sh index 29047b9..ebeee2a 100755 --- a/scripts/devloop_test.sh +++ b/scripts/devloop_test.sh @@ -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" diff --git a/skills/devloop-spec/SKILL.md b/skills/devloop-spec/SKILL.md index 83ea3b2..f7edfbe 100644 --- a/skills/devloop-spec/SKILL.md +++ b/skills/devloop-spec/SKILL.md @@ -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 ` 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--stack.md` and the children `YYYY-MM-DD--01-.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 +# + +## Stack + +1. [](./YYYY-MM-DD--01-.md) - branch , base +2. [](./YYYY-MM-DD--02-.md) - branch , base + +## Notes + +- +```` + +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: