From d9b05c392dae5594c365c3ffb61efa70e660df4b Mon Sep 17 00:00:00 2001 From: donpetry-bot <281750570+donpetry-bot@users.noreply.github.com> Date: Sun, 14 Jun 2026 11:53:59 +0000 Subject: [PATCH 1/4] =?UTF-8?q?feat:=20implement=20issue=20#682=20?= =?UTF-8?q?=E2=80=94=20initiative-planner:=20Bob=20materializes=20a=20plan?= =?UTF-8?q?=20even=20when=20he=20has=20unresolved=20open=20questions=20?= =?UTF-8?q?=E2=80=94=20should=20gate,=20not=20guess?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 11 ++++++ prompts/bmad/scrum-master.md | 6 ++++ scripts/initiative-planner/README.md | 9 +++++ scripts/initiative-planner/apply-plan.sh | 36 ++++++++++++++++++- scripts/initiative-planner/plan.schema.json | 21 ++++++++++-- tests/test_initiative_planner.bats | 38 +++++++++++++++++++++ 6 files changed, 118 insertions(+), 3 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 19d1df9f5..0c8bcc2f8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -66,6 +66,17 @@ This is the `.github-private` org infrastructure repo for `petry-projects`. It c - Scripts must be POSIX-compatible shell (`#!/usr/bin/env bash` with `set -euo pipefail`). - No hardcoded tokens or secrets — use `$GITHUB_TOKEN` from the environment. +### Initiative Planner — blocking open-questions gate + +`scripts/initiative-planner/apply-plan.sh` will **not** materialize an epic + sub-issue +DAG while the plan still has plan-blocking open questions (#682). If `open_questions` +contains any item shaped `{"question": "...", "blocking": true}`, the script creates +**zero** issues, posts the questions back to the source discussion with "not yet planned" +framing, and exits cleanly (DRY_RUN unchanged). Plain-string `open_questions` remain +advisory and do not gate. This is a hard, script-level gate (like the `initiative:auto` +invariant) and must not be worked around with direct `gh` calls. See +`scripts/initiative-planner/README.md`. + ### Oversized PRs (> 300 changed files) GitHub's unified-diff endpoint (`gh pr diff`) is hard-capped at 300 changed files and returns diff --git a/prompts/bmad/scrum-master.md b/prompts/bmad/scrum-master.md index a8efc90f5..c88d930be 100644 --- a/prompts/bmad/scrum-master.md +++ b/prompts/bmad/scrum-master.md @@ -90,5 +90,11 @@ mirrors the BMAD create-story template field-for-field: - Never apply `initiative:auto`; the epic is created inert (apply-plan enforces). - Don't invent acceptance criteria or Dev Notes you can't tie to the idea or the repo. Unresolved items go in `open_questions`, not guesses. +- If an unresolved item must be answered before the idea is even plannable, emit + it as a **blocking** open question — an object `{"question": "...", "blocking": + true}` — not a guessed plan. `apply-plan.sh` will then create **nothing** and + post the questions back to the discussion (a hard gate, #682). Use plain-string + `open_questions` only for advisory "nice to confirm" items that should not halt + planning. - Don't duplicate an open epic from `open_epics`; if already covered, say so in a single story + `open_questions` referencing that epic. diff --git a/scripts/initiative-planner/README.md b/scripts/initiative-planner/README.md index 2027c6d90..91cdfa4fe 100644 --- a/scripts/initiative-planner/README.md +++ b/scripts/initiative-planner/README.md @@ -43,3 +43,12 @@ cat /tmp/plan.jsonl | jq . - `hands_off` stories also get `dev-lead:hands-off` + `initiative:hold`. - A dependency cycle, a missing entry point, or a dangling `blocked_by` fails validation before any issue is created. +- **Blocking open-questions gate (#682).** If the plan's `open_questions` + contains any item flagged `blocking:true`, `apply-plan.sh` creates **zero** + issues. Instead it posts the questions back to the source discussion with + "not yet planned — answer these to proceed" framing and exits cleanly + (DRY_RUN honored). This stops Bob from shipping an inert epic + sub-issue DAG + that a human would have to close and re-plan once scope is settled (incidents + #650→#659, #666→#667). Re-running after the questions are answered then + materializes the real plan. Plain-string `open_questions` stay advisory and do + **not** gate — only objects shaped `{"question": "...", "blocking": true}` do. diff --git a/scripts/initiative-planner/apply-plan.sh b/scripts/initiative-planner/apply-plan.sh index fbefcbad5..cfd17bd30 100755 --- a/scripts/initiative-planner/apply-plan.sh +++ b/scripts/initiative-planner/apply-plan.sh @@ -34,6 +34,40 @@ DISCUSSION_NODE_ID="${DISCUSSION_NODE_ID:-}" [ -s "$PLAN_PATH" ] || { echo "::error::plan file '$PLAN_PATH' missing or empty" >&2; exit 1; } +# ── blocking open-questions gate ────────────────────────────────────────────── +# Bob routes anything he can't resolve to `open_questions`. A question shaped as +# an object with `blocking:true` means the idea is not yet plannable — building +# an epic + story DAG now would just create throwaway issues a human must close +# and re-plan (see #682; incidents #650→#659, #666→#667). So if ANY blocking +# question is present we create NOTHING: post the questions back to the source +# discussion with "not yet planned" framing and exit cleanly (DRY_RUN honored +# via comment_on_discussion). Re-running once the questions are answered then +# materializes the real plan. Plain-string questions stay advisory only. +blocking_count="$(jq '[(.open_questions // [])[] | select(type=="object" and .blocking==true)] | length' "$PLAN_PATH")" +if [ "$blocking_count" -gt 0 ]; then + src_g="$(jq -r '.source_discussion // empty' "$PLAN_PATH")" + [ -n "$src_g" ] || src_g="$DISCUSSION_NUMBER" + if [ -n "$DISCUSSION_NODE_ID" ]; then + blocking_list="$(jq -r '[(.open_questions // [])[] | select(type=="object" and .blocking==true) | .question] | map("- " + .) | join("\n")' "$PLAN_PATH")" + other_list="$(jq -r '(.open_questions // []) | map(select((type=="string") or (type=="object" and (.blocking // false) != true))) | map(if type=="string" then . else .question end) | if length>0 then "\n\n_Also worth confirming (non-blocking):_\n" + (map("- " + .) | join("\n")) else "" end' "$PLAN_PATH")" + gate_comment="$(printf '\n**⏸️ Not yet planned — the BMAD Scrum Master (Bob) has blocking open questions.**\n\nThis idea cannot be turned into an epic until these are answered:\n\n%s%s\n\n---\n**No epic or stories were created.** Answer the questions above, then re-approve / re-dispatch the planner and Bob will materialize the full epic + sub-issue DAG.' \ + "$blocking_list" "$other_list")" + comment_on_discussion "$DISCUSSION_NODE_ID" "$gate_comment" + echo "posted 'not yet planned' open-questions back to discussion #${DISCUSSION_NUMBER:-?}" + fi + echo "::warning::initiative-planner: idea #${src_g:-?} has ${blocking_count} blocking open question(s) — no epic created. Answer them and re-dispatch." + if [ -n "${GITHUB_STEP_SUMMARY:-}" ]; then + { + echo "## Initiative not yet planned — needs answers$(if [ "${DRY_RUN:-0}" = "1" ]; then echo " (DRY_RUN)"; fi)" + echo "- Source discussion: #${src_g:-?}" + echo "- Blocking open questions: ${blocking_count}" + echo "- **No epic or stories were created.** Answer the questions and re-dispatch the planner." + } >>"$GITHUB_STEP_SUMMARY" + fi + echo "gated: ${blocking_count} blocking open question(s); created nothing. dry_run=${DRY_RUN:-0}" + exit 0 +fi + # ── epic ────────────────────────────────────────────────────────────────────── epic_title="$(jq -r '.epic.title' "$PLAN_PATH")" epic_body="$(jq -r '.epic.body' "$PLAN_PATH")" @@ -119,7 +153,7 @@ if [ -n "$DISCUSSION_NODE_ID" ]; then sz="$(jq -r --argjson i "$lid" '.stories[] | select(.id==$i) | .size' "$PLAN_PATH")" rows="${rows}- #${NUM_BY_LOCAL[$lid]} (${sz}) — ${t}"$'\n' done - oq="$(jq -r '(.open_questions // []) | if length>0 then "\n**Open questions for review:**\n" + ([.[] | "- " + .] | join("\n")) else "" end' "$PLAN_PATH")" + oq="$(jq -r '(.open_questions // []) | map(if type=="string" then . else .question end) | if length>0 then "\n**Open questions for review:**\n" + (map("- " + .) | join("\n")) else "" end' "$PLAN_PATH")" comment="$(printf '\n**📋 Initiative planned by the BMAD Scrum Master (Bob).**\n\nEpic **#%s** — %s\n\n%s stories created (inert — labelled `initiative`, NOT `initiative:auto`):\n\n%s\n%s\n---\nReview the epic and its sub-issue DAG, adjust as needed, then add **`initiative:auto`** to epic #%s to hand it to `initiative-driver` for auto-implementation.' \ "$epic_number" "$epic_title" "$story_count" "$rows" "$oq" "$epic_number")" comment_on_discussion "$DISCUSSION_NODE_ID" "$comment" diff --git a/scripts/initiative-planner/plan.schema.json b/scripts/initiative-planner/plan.schema.json index 641a834df..a07fd4f1e 100644 --- a/scripts/initiative-planner/plan.schema.json +++ b/scripts/initiative-planner/plan.schema.json @@ -115,8 +115,25 @@ }, "open_questions": { "type": "array", - "items": { "type": "string" }, - "description": "Things the planner could not resolve from context; surfaced for the human reviewer." + "description": "Things the planner could not resolve from context; surfaced for the human reviewer. A plain string is advisory (non-blocking). An object flagged `blocking:true` is plan-blocking: apply-plan.sh creates NO issues and posts the questions back to the source discussion instead (see #682).", + "items": { + "oneOf": [ + { "type": "string" }, + { + "type": "object", + "additionalProperties": false, + "required": ["question"], + "properties": { + "question": { "type": "string", "minLength": 5 }, + "blocking": { + "type": "boolean", + "default": false, + "description": "True => this question must be answered before the idea is plannable; halts materialization." + } + } + } + ] + } } } } diff --git a/tests/test_initiative_planner.bats b/tests/test_initiative_planner.bats index d360d5e7a..85a878f8f 100644 --- a/tests/test_initiative_planner.bats +++ b/tests/test_initiative_planner.bats @@ -45,6 +45,13 @@ teardown() { rm -rf "$TMP"; } [[ "$output" == *"plan OK"* ]] } +@test "validate-plan accepts open_questions carrying a blocking flag" { + jq '.open_questions = [{"question":"Confirm scope X before planning","blocking":true}]' "$PLAN" >"$TMP/oqobj.json" + run python3 "$PLANNER_DIR/validate-plan.py" "$TMP/oqobj.json" + [ "$status" -eq 0 ] + [[ "$output" == *"plan OK"* ]] +} + @test "validate-plan rejects a self-blocking story" { jq '.stories[0].blocked_by = [1]' "$PLAN" >"$TMP/bad.json" run python3 "$PLANNER_DIR/validate-plan.py" "$TMP/bad.json" @@ -115,6 +122,37 @@ teardown() { rm -rf "$TMP"; } grep '"op":"add_blocked_by"' "$LOG" | grep -q '"blocked_by":195' } +@test "apply-plan gates on a blocking open question: creates zero issues and exits 0" { + 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-plan.sh" + [ "$status" -eq 0 ] + [ "$(grep -c '"op":"create_issue"' "$LOG")" -eq 0 ] + [ "$(grep -c '"op":"add_sub_issue"' "$LOG")" -eq 0 ] + [ "$(grep -c '"op":"add_blocked_by"' "$LOG")" -eq 0 ] +} + +@test "apply-plan blocking gate posts the open questions back to the discussion" { + 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" \ + bash "$PLANNER_DIR/apply-plan.sh" + grep -q '"op":"comment_on_discussion"' "$LOG" + body="$(grep '"op":"comment_on_discussion"' "$LOG" | jq -r '.body')" + [[ "$body" == *"Confirm scope X before planning"* ]] + [[ "$body" == *"Not yet planned"* ]] + [[ "$body" == *"No epic or stories were created"* ]] +} + +@test "apply-plan does NOT gate on a non-blocking open question" { + jq '.open_questions = [{"question":"Nice to confirm pricing later","blocking":false}]' "$PLAN" >"$TMP/nonblock.json" + DRY_RUN=1 DRY_RUN_LOG="$LOG" REPO="petry-projects/.github-private" \ + DISCUSSION_NUMBER=413 DISCUSSION_NODE_ID="D_test" PLAN_PATH="$TMP/nonblock.json" \ + bash "$PLANNER_DIR/apply-plan.sh" + [ "$(grep -c '"op":"create_issue"' "$LOG")" -eq 4 ] +} + @test "story bodies are rendered in the BMAD create-story template" { DRY_RUN=1 DRY_RUN_LOG="$LOG" REPO="petry-projects/.github-private" \ DISCUSSION_NUMBER=413 DISCUSSION_NODE_ID="D_test" PLAN_PATH="$PLAN" \ From 47757800fbae26638ffb6d5dc25290afc793489c Mon Sep 17 00:00:00 2001 From: donpetry-bot <281750570+donpetry-bot@users.noreply.github.com> Date: Sun, 14 Jun 2026 11:59:04 +0000 Subject: [PATCH 2/4] chore: apply manual instructions [skip ci-relay] --- .gitleaks.toml | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/.gitleaks.toml b/.gitleaks.toml index 1541c2e62..c9385310e 100644 --- a/.gitleaks.toml +++ b/.gitleaks.toml @@ -121,3 +121,15 @@ regexes = [ '''api-request''', '''[0-9a-f]{64}''', ] + +# Historical commit 58934f64 added an EXPIRED_JWT test fixture to test_gitleaks_config.bats +# at line 60 — an expired JWT used as a test vector to validate gitleaks suppression behaviour. +# The variable name EXPIRED_JWT and its context in the test file confirm it is not a real +# credential. The token was removed in a subsequent commit; the finding is a history artifact. +[[allowlists]] +description = "Suppress EXPIRED_JWT test fixture in historical test_gitleaks_config.bats" +targetRules = ["jwt"] +condition = "AND" +commits = ["58934f6496c015e1a58ee9814ba120fc22db0d61"] +paths = ['''^tests/test_gitleaks_config\.bats$'''] +regexes = ['''EXPIRED_JWT'''] From e14945a2fae834cd51582de58f4f2178ef6374ca Mon Sep 17 00:00:00 2001 From: donpetry-bot <281750570+donpetry-bot@users.noreply.github.com> Date: Sun, 14 Jun 2026 12:02:35 +0000 Subject: [PATCH 3/4] chore: apply manual instructions [skip ci-relay] --- scripts/initiative-planner/apply-plan.sh | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/scripts/initiative-planner/apply-plan.sh b/scripts/initiative-planner/apply-plan.sh index cfd17bd30..ce4492844 100755 --- a/scripts/initiative-planner/apply-plan.sh +++ b/scripts/initiative-planner/apply-plan.sh @@ -49,9 +49,9 @@ if [ "$blocking_count" -gt 0 ]; then [ -n "$src_g" ] || src_g="$DISCUSSION_NUMBER" if [ -n "$DISCUSSION_NODE_ID" ]; then blocking_list="$(jq -r '[(.open_questions // [])[] | select(type=="object" and .blocking==true) | .question] | map("- " + .) | join("\n")' "$PLAN_PATH")" - other_list="$(jq -r '(.open_questions // []) | map(select((type=="string") or (type=="object" and (.blocking // false) != true))) | map(if type=="string" then . else .question end) | if length>0 then "\n\n_Also worth confirming (non-blocking):_\n" + (map("- " + .) | join("\n")) else "" end' "$PLAN_PATH")" - gate_comment="$(printf '\n**⏸️ Not yet planned — the BMAD Scrum Master (Bob) has blocking open questions.**\n\nThis idea cannot be turned into an epic until these are answered:\n\n%s%s\n\n---\n**No epic or stories were created.** Answer the questions above, then re-approve / re-dispatch the planner and Bob will materialize the full epic + sub-issue DAG.' \ - "$blocking_list" "$other_list")" + other_list="$(jq -r '(.open_questions // []) | map(select((type=="string") or (type=="object" and .blocking != true))) | map(if type=="string" then . else .question end) | if length>0 then "\n\n_Also worth confirming (non-blocking):_\n" + (map("- " + .) | join("\n")) else "" end' "$PLAN_PATH")" + printf -v gate_comment '\n**⏸️ Not yet planned — the BMAD Scrum Master (Bob) has blocking open questions.**\n\nThis idea cannot be turned into an epic until these are answered:\n\n%s%s\n\n---\n**No epic or stories were created.** Answer the questions above, then re-approve / re-dispatch the planner and Bob will materialize the full epic + sub-issue DAG.' \ + "$blocking_list" "$other_list" comment_on_discussion "$DISCUSSION_NODE_ID" "$gate_comment" echo "posted 'not yet planned' open-questions back to discussion #${DISCUSSION_NUMBER:-?}" fi From c7974cccee60d6a1311635cb27eb292c3507cbfb Mon Sep 17 00:00:00 2001 From: donpetry-bot <281750570+donpetry-bot@users.noreply.github.com> Date: Sun, 14 Jun 2026 12:05:58 +0000 Subject: [PATCH 4/4] fix(reviews): address review comments [skip ci-relay] --- .gitleaks.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitleaks.toml b/.gitleaks.toml index c9385310e..dc15e86f6 100644 --- a/.gitleaks.toml +++ b/.gitleaks.toml @@ -132,4 +132,5 @@ targetRules = ["jwt"] condition = "AND" commits = ["58934f6496c015e1a58ee9814ba120fc22db0d61"] paths = ['''^tests/test_gitleaks_config\.bats$'''] +regexTarget = "line" regexes = ['''EXPIRED_JWT''']