Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
50 changes: 50 additions & 0 deletions .github/workflows/initiative-planner.yml
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,11 @@ on:
required: false
default: false
type: boolean
plan_artifact_run_id:
description: 'Apply a REVIEWED plan from a prior dry-run: the run ID whose initiative-plan-dry-run artifact (plan.json) to download and apply WITHOUT re-planning. Empty => the default plan-then-apply flow (Bob plans).'
required: false
default: ''
type: string
model:
description: 'Anthropic model id for the planner'
required: false
Expand Down Expand Up @@ -95,6 +100,7 @@ jobs:
timeout-minutes: 30
permissions:
contents: read # checkout, read AGENTS.md/scripts/docs
actions: read # gh run download — fetch a prior run's reviewed plan.json artifact
issues: write # create epic + story sub-issues, link DAG, set blocked_by
discussions: write # post the plan summary back to the idea
id-token: write # claude-code-action OIDC
Expand All @@ -105,6 +111,9 @@ jobs:
DISCUSSION_NUMBER: ${{ github.event.inputs.discussion }}
DRY_RUN: ${{ inputs.dry_run == true && '1' || '0' }}
FORCE_REPLAN: ${{ inputs.force_replan == true && '1' || '0' }}
# When set, the apply run loads this prior run's reviewed plan.json instead
# of re-planning (the plan/apply split, #604). Empty => Bob plans as before.
PLAN_ARTIFACT_RUN_ID: ${{ github.event.inputs.plan_artifact_run_id }}
TOOLING_DIR: ${{ github.workspace }}/scripts/initiative-planner
steps:
- name: Checkout agent repo
Expand All @@ -121,13 +130,40 @@ jobs:
- name: Install Python jsonschema
run: pip install --quiet 'jsonschema>=4'

# ── reviewed-plan handoff (#604) ────────────────────────────────────────
# When a maintainer supplies a prior dry-run's run ID, load THAT run's
# authoritative plan.json (the one they reviewed) into $PLAN_PATH. The
# dry-run uploads it as the `initiative-plan-dry-run` artifact below. This
# is what lets the apply run skip the LLM re-plan (the Bob step is gated
# off when PLAN_ARTIFACT_RUN_ID is set).
- name: Download reviewed plan artifact
if: ${{ env.PLAN_ARTIFACT_RUN_ID != '' }}
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
dest="${{ runner.temp }}/reviewed-plan"
mkdir -p "$dest"
gh run download "$PLAN_ARTIFACT_RUN_ID" \
--repo "$REPO" \
--name initiative-plan-dry-run \
--dir "$dest"
Comment thread
don-petry marked this conversation as resolved.
Resolved
Comment thread
don-petry marked this conversation as resolved.
Resolved
if [ ! -s "$dest/plan.json" ]; then
echo "::error::run $PLAN_ARTIFACT_RUN_ID has no plan.json in its initiative-plan-dry-run artifact" >&2
exit 1
fi
cp "$dest/plan.json" "$PLAN_PATH"
echo "loaded reviewed plan from run $PLAN_ARTIFACT_RUN_ID -> $PLAN_PATH"
Comment thread
don-petry marked this conversation as resolved.
Resolved

- name: Gather planning context
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: bash scripts/initiative-planner/gather-context.sh

- name: Plan the initiative (BMAD Scrum Master)
id: bob
# Skipped on the reviewed-plan handoff (#604): when a prior plan.json is
# supplied, we apply it verbatim and never re-plan.
if: ${{ env.PLAN_ARTIFACT_RUN_ID == '' }}
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
uses: anthropics/claude-code-action@d5726de019ec4498aa667642bc3a80fca83aa102 # v1.0.148
Expand Down Expand Up @@ -284,6 +320,20 @@ jobs:
Finish with a 3–5 line summary: the epic, the story count, the
dependency shape, and any open questions.

# ── reviewed-plan handoff (#604) ────────────────────────────────────────
# The mirror of Bob's in-prompt materialization for the supplied-plan path:
# validate the reviewed plan.json, then run apply-plan.sh on it. No LLM
# ran. FORCE_REPLAN and the blocking-open-questions / idempotency gates are
# honored by apply-plan.sh. DRY_RUN is forced off here — this step exists
# solely to materialize a plan a maintainer already approved; running it in
# dry-run mode would create no epic or stories despite the intent.
- name: Apply the reviewed plan (no re-plan)
if: ${{ env.PLAN_ARTIFACT_RUN_ID != '' }}
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
DRY_RUN: '0'
run: bash scripts/initiative-planner/apply-reviewed-plan.sh
Comment thread
don-petry marked this conversation as resolved.
Resolved

- name: Upload dry-run plan log
if: ${{ env.DRY_RUN == '1' }}
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
Expand Down
18 changes: 18 additions & 0 deletions docs/initiatives/idea-to-initiative-pipeline.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,14 @@ idea-triage.yml (weekly, automated) ──> "🧭 Idea Promotion
▼
initiative-planner.yml (on approval / dispatch, automated)
BMAD Scrum Master "Bob" sprint-planning + create-story
│ DRY-RUN: emits an authoritative plan.json artifact
│ (the `initiative-plan-dry-run` upload) — creates NOTHING
│
★ HUMAN (optional plan-review gate) — download plan.json, review / hand-edit it
│
▼
initiative-planner.yml (apply: re-dispatch with `plan_artifact_run_id`)
loads the reviewed plan.json — NO re-plan — validates + applies it
│ creates an EPIC + sub-issue DAG (blocked_by),
│ labelled `initiative` — INERT (no `initiative:auto`)
│ posts the plan back to the Discussion
Expand Down Expand Up @@ -92,6 +100,16 @@ unit-tested.
`initiative` only. **It never applies `initiative:auto`** — the epic is created
inert and a human activates it after review. `hands_off` stories also get
`dev-lead:hands-off` + `initiative:hold` so the driver never auto-releases them.
- **Plan → review → apply split (#604).** A dry-run uploads the authoritative
`plan.json` (the `initiative-plan-dry-run` artifact) and creates nothing. A
maintainer can download, review, and optionally hand-edit it, then re-dispatch
with `plan_artifact_run_id` set to the dry-run's run ID: the workflow loads that
reviewed `plan.json`, **skips Bob entirely (no re-plan)**, and validates + applies
it via `apply-reviewed-plan.sh` — so *what a maintainer reviewed is what
materializes*. With `plan_artifact_run_id` empty, the default plan-then-apply
flow is unchanged. The handoff is artifact-download-by-run-ID, not a committed
file. This plan-review gate is **distinct** from the `initiative:auto` gate: the
former binds the plan *contents*, the latter activates auto-*implementation*.
- Tooling: `scripts/initiative-planner/` (persona: `prompts/bmad/scrum-master.md`).

## Labels the pipeline relies on
Expand Down
28 changes: 28 additions & 0 deletions scripts/initiative-planner/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,37 @@ create-story template, which `plan.schema.json` mirrors field-for-field.
| `validate-plan.py` | Schema + semantic checks: unique ids, acyclic DAG, an entry point, no dangling edges. |
| `lib/mutations.sh` | DRY_RUN-aware GitHub helpers (create issue, link sub-issue, add `blocked_by`, comment on Discussion, find/close issues for the idempotency guard). |
| `apply-plan.sh` | Materialize a validated plan. Creates issues labelled `initiative` only — **never** `initiative:auto`. Idempotent: no-ops if the discussion is already planned, or supersedes the old epic when `FORCE_REPLAN=1`. |
| `apply-reviewed-plan.sh` | The plan/apply-split handoff (#604): apply a maintainer-**reviewed** plan.json WITHOUT re-planning — re-validates the (possibly hand-edited) artifact, then runs `apply-plan.sh`. The BMAD Scrum Master never runs on this path. |

Tests: `tests/test_initiative_planner.bats`, `tests/test_initiative_planner_redispatch.bats` (run via `lint.yml`).

## Plan → review → apply split (#604)

By default `initiative-planner.yml` plans **and** applies in a single run (Bob
emits `plan.json`, then `apply-plan.sh` materializes it). But because the preview
should bind the result — *what a maintainer reviewed is what materializes* — the
workflow also supports a two-step split:

1. **Plan (dry-run).** Dispatch with `dry_run: true` (the default). Bob plans and
the run uploads the authoritative `plan.json` as the `initiative-plan-dry-run`
artifact. **No issues are created.**
2. **Review.** A maintainer downloads `plan.json` from that run, reviews it, and
(optionally) hand-edits it. *This is the human review gate for the plan
Comment thread
don-petry marked this conversation as resolved.
Resolved
contents* — distinct from the later `initiative:auto` gate that activates
auto-implementation.
3. **Apply (no re-plan).** Re-dispatch with `plan_artifact_run_id` set to the
dry-run's run ID. The workflow downloads that run's reviewed `plan.json`,
**skips the LLM planning step entirely**, then re-validates and applies it via
`apply-reviewed-plan.sh`. The reviewed artifact is what materializes.

The handoff is **artifact download by run ID** (`gh run download`), not a
committed plan file — it keeps reviewed plans out of git and ties each apply to a
specific, auditable dry-run. Artifact retention is the default 90 days, so apply
within that window. With `plan_artifact_run_id` empty, behavior is unchanged
(plan-then-apply in one run). Even a human-reviewed plan still materializes
**inert** (no `initiative:auto`); `apply-plan.sh` enforces that regardless of
path.

## Local dry run

```bash
Expand Down
33 changes: 33 additions & 0 deletions scripts/initiative-planner/apply-reviewed-plan.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
#!/usr/bin/env bash
# apply-reviewed-plan.sh — apply a maintainer-REVIEWED plan.json WITHOUT re-planning.
#
# The plan/apply split (#604): a dry-run uploads an authoritative plan.json
# artifact; a maintainer reviews it; then the apply run is pointed at THAT
# artifact instead of asking the LLM to plan again. This script is the
# apply-side handoff — it never invokes the BMAD Scrum Master. What the
# maintainer reviewed is what materializes.
#
# It does exactly two things, in order:
# 1. Re-validate the supplied plan with validate-plan.py
# — we never apply an unvalidated artifact.
# 2. Hand it to apply-plan.sh, which owns all materialization (incl. the
# blocking-open-questions gate, the idempotency/supersede guard, and the
# `initiative:auto` invariant). DRY_RUN is honored throughout.
#
# Env (PLAN_PATH consumed here; the rest are passed straight through to
# apply-plan.sh — see its header): PLAN_PATH (required), REPO, DISCUSSION_NUMBER,
# DISCUSSION_NODE_ID, FORCE_REPLAN, DRY_RUN / DRY_RUN_LOG.
set -euo pipefail

SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"

PLAN_PATH="${PLAN_PATH:?PLAN_PATH required (path to the reviewed plan.json)}"
[ -f "$PLAN_PATH" ] && [ -s "$PLAN_PATH" ] || { echo "::error::reviewed plan '$PLAN_PATH' missing, empty, or not a regular file" >&2; exit 1; }

echo "==> applying reviewed plan (no re-plan): $PLAN_PATH"

# Re-validate before applying — a reviewed artifact may have been hand-edited.
python3 "$SCRIPT_DIR/validate-plan.py" "$PLAN_PATH"

# Materialize. apply-plan.sh inherits PLAN_PATH and the rest from the environment.
exec bash "$SCRIPT_DIR/apply-plan.sh"
51 changes: 51 additions & 0 deletions tests/test_initiative_planner.bats
Original file line number Diff line number Diff line change
Expand Up @@ -299,3 +299,54 @@ teardown() { rm -rf "$TMP"; }
[ "$(grep -c '"op":"create_issue"' "$LOG")" -eq 4 ]
[ "$(grep -c '"op":"close_issue"' "$LOG")" -eq 0 ]
}

# ── reviewed-plan apply path (#604) ───────────────────────────────────────────
# apply-reviewed-plan.sh is the no-re-plan handoff: a maintainer-reviewed plan.json
# is validated then handed straight to apply-plan.sh — no LLM planning step runs.

@test "apply-reviewed-plan validates then applies the supplied plan (DRY_RUN)" {
DRY_RUN=1 DRY_RUN_LOG="$LOG" REPO="petry-projects/.github-private" \
DISCUSSION_NUMBER=413 DISCUSSION_NODE_ID="D_test" PLAN_PATH="$PLAN" \
run bash "$PLANNER_DIR/apply-reviewed-plan.sh"
[ "$status" -eq 0 ]
[[ "$output" == *"plan OK"* ]]
[ "$(grep -c '"op":"create_issue"' "$LOG")" -eq 4 ]
[ "$(grep -c '"op":"add_sub_issue"' "$LOG")" -eq 3 ]
[ "$(grep -c '"op":"add_blocked_by"' "$LOG")" -eq 3 ]
}

@test "apply-reviewed-plan rejects an invalid supplied plan before applying anything" {
jq '.stories[1].blocked_by = [3] | .stories[2].blocked_by = [2]' "$PLAN" >"$TMP/cycle.json"
DRY_RUN=1 DRY_RUN_LOG="$LOG" REPO="petry-projects/.github-private" \
DISCUSSION_NUMBER=413 DISCUSSION_NODE_ID="D_test" PLAN_PATH="$TMP/cycle.json" \
run bash "$PLANNER_DIR/apply-reviewed-plan.sh"
[ "$status" -ne 0 ]
[[ "$output" == *"cycle"* ]]
[ ! -s "$LOG" ] || [ "$(grep -c '"op":"create_issue"' "$LOG")" -eq 0 ]
}

@test "apply-reviewed-plan errors on a missing supplied plan file" {
DRY_RUN=1 DRY_RUN_LOG="$LOG" REPO="petry-projects/.github-private" \
DISCUSSION_NUMBER=413 DISCUSSION_NODE_ID="D_test" PLAN_PATH="$TMP/does-not-exist.json" \
run bash "$PLANNER_DIR/apply-reviewed-plan.sh"
[ "$status" -ne 0 ]
[ ! -s "$LOG" ] || [ "$(grep -c '"op":"create_issue"' "$LOG")" -eq 0 ]
}

@test "apply-reviewed-plan errors when PLAN_PATH is a directory, not a regular file" {
DRY_RUN=1 DRY_RUN_LOG="$LOG" REPO="petry-projects/.github-private" \
DISCUSSION_NUMBER=413 DISCUSSION_NODE_ID="D_test" PLAN_PATH="$TMP" \
run bash "$PLANNER_DIR/apply-reviewed-plan.sh"
[ "$status" -ne 0 ]
[[ "$output" == *"not a regular file"* ]]
[ ! -s "$LOG" ] || [ "$(grep -c '"op":"create_issue"' "$LOG")" -eq 0 ]
}

@test "apply-reviewed-plan honors the blocking open-questions gate from apply-plan" {
jq '.open_questions = [{"question":"Confirm scope X before planning","blocking":true}]' "$PLAN" >"$TMP/blocking.json"
DRY_RUN=1 DRY_RUN_LOG="$LOG" REPO="petry-projects/.github-private" \
DISCUSSION_NUMBER=413 DISCUSSION_NODE_ID="D_test" PLAN_PATH="$TMP/blocking.json" \
run bash "$PLANNER_DIR/apply-reviewed-plan.sh"
[ "$status" -eq 0 ]
[ "$(grep -c '"op":"create_issue"' "$LOG")" -eq 0 ]
}
Loading