diff --git a/.github/workflows/initiative-planner.yml b/.github/workflows/initiative-planner.yml index 1ce56d140..14a91fde3 100644 --- a/.github/workflows/initiative-planner.yml +++ b/.github/workflows/initiative-planner.yml @@ -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 @@ -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 @@ -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 @@ -121,6 +130,30 @@ 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" + 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" + - name: Gather planning context env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -128,6 +161,9 @@ jobs: - 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 @@ -284,6 +320,25 @@ 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. Require explicit dry_run=false to prevent + # unexpected mutations — dry-run mode would create no epic or stories despite + # the intent to apply. + - name: Guard — reviewed-plan apply requires dry_run=false + if: ${{ env.PLAN_ARTIFACT_RUN_ID != '' && env.DRY_RUN == '1' }} + run: | + echo "::error::plan_artifact_run_id is set, but dry_run=true. Re-dispatch with dry_run=false to materialize the reviewed plan." + exit 1 + + - name: Apply the reviewed plan (no re-plan) + if: ${{ env.PLAN_ARTIFACT_RUN_ID != '' }} + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: bash scripts/initiative-planner/apply-reviewed-plan.sh + - name: Upload dry-run plan log if: ${{ env.DRY_RUN == '1' }} uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 diff --git a/docs/initiatives/idea-to-initiative-pipeline.md b/docs/initiatives/idea-to-initiative-pipeline.md index 54f143e06..f7dd33497 100644 --- a/docs/initiatives/idea-to-initiative-pipeline.md +++ b/docs/initiatives/idea-to-initiative-pipeline.md @@ -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 and review 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 @@ -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 and review it, then re-dispatch with + `plan_artifact_run_id` set to the dry-run's run ID: the workflow re-downloads + that artifact, **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; the artifact + is re-fetched from GitHub Actions and not taken from a local 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 diff --git a/scripts/initiative-planner/README.md b/scripts/initiative-planner/README.md index 04823aaf3..719175b28 100644 --- a/scripts/initiative-planner/README.md +++ b/scripts/initiative-planner/README.md @@ -19,9 +19,36 @@ 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 reviewed 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 and reviews it. + *This is the human review gate for the plan 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 diff --git a/scripts/initiative-planner/apply-reviewed-plan.sh b/scripts/initiative-planner/apply-reviewed-plan.sh new file mode 100755 index 000000000..cbf755272 --- /dev/null +++ b/scripts/initiative-planner/apply-reviewed-plan.sh @@ -0,0 +1,50 @@ +#!/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; } + +# Guard: reviewed plans must have an explicit source_discussion. A reviewed +# artifact is applied out-of-band, potentially in a different session context; +# it must definitively reference which discussion it came from, not rely on the +# current dispatch context. Also reject artifacts that target a different +# discussion than the one the maintainer declared. +plan_src="$(jq -r '.source_discussion // empty' "$PLAN_PATH")" +if [ -z "$plan_src" ]; then + echo "::error::plan.json must have a source_discussion field for reviewed-plan applies — the artifact must be self-contained about its origin." >&2 + exit 1 +fi +if [ -n "${DISCUSSION_NUMBER:-}" ]; then + if [ "$plan_src" != "$DISCUSSION_NUMBER" ]; then + echo "::error::plan.json source_discussion (${plan_src}) does not match this run's DISCUSSION_NUMBER (${DISCUSSION_NUMBER}) — supply the correct dry-run run ID for discussion #${DISCUSSION_NUMBER}." >&2 + exit 1 + fi +fi + +echo "==> applying reviewed plan (no re-plan): $PLAN_PATH" + +# Re-validate before applying. +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" diff --git a/tests/test_initiative_planner.bats b/tests/test_initiative_planner.bats index 9711af605..43869605a 100644 --- a/tests/test_initiative_planner.bats +++ b/tests/test_initiative_planner.bats @@ -299,3 +299,85 @@ 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 ] +} + +@test "apply-reviewed-plan rejects a plan whose source_discussion mismatches DISCUSSION_NUMBER" { + # plan.json carries source_discussion:413; dispatcher says 999 — must abort before creating anything. + DRY_RUN=1 DRY_RUN_LOG="$LOG" REPO="petry-projects/.github-private" \ + DISCUSSION_NUMBER=999 DISCUSSION_NODE_ID="D_test" PLAN_PATH="$PLAN" \ + run bash "$PLANNER_DIR/apply-reviewed-plan.sh" + [ "$status" -ne 0 ] + [[ "$output" == *"source_discussion"* ]] + [[ "$output" == *"413"* ]] + [[ "$output" == *"999"* ]] + [ ! -s "$LOG" ] || [ "$(grep -c '"op":"create_issue"' "$LOG")" -eq 0 ] +} + +@test "apply-reviewed-plan passes when source_discussion matches DISCUSSION_NUMBER" { + 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 ] + [ "$(grep -c '"op":"create_issue"' "$LOG")" -eq 4 ] +} + +@test "apply-reviewed-plan rejects plans without source_discussion field" { + # Reviewed plans must have an explicit source_discussion — they are applied + # out-of-band and must be self-contained about their origin. + jq 'del(.source_discussion)' "$PLAN" >"$TMP/no_src.json" + DRY_RUN=1 DRY_RUN_LOG="$LOG" REPO="petry-projects/.github-private" \ + DISCUSSION_NUMBER=999 DISCUSSION_NODE_ID="D_test" PLAN_PATH="$TMP/no_src.json" \ + run bash "$PLANNER_DIR/apply-reviewed-plan.sh" + [ "$status" -ne 0 ] + [ ! -s "$LOG" ] || [ "$(grep -c '"op":"create_issue"' "$LOG")" -eq 0 ] +}