Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 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
13 changes: 13 additions & 0 deletions .gitleaks.toml
Original file line number Diff line number Diff line change
Expand Up @@ -121,3 +121,16 @@ 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$''']
regexTarget = "line"
regexes = ['''EXPIRED_JWT''']
11 changes: 11 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions prompts/bmad/scrum-master.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
9 changes: 9 additions & 0 deletions scripts/initiative-planner/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
36 changes: 35 additions & 1 deletion scripts/initiative-planner/apply-plan.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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 != 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 '<!-- initiative-planner -->\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")"
Expand Down Expand Up @@ -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 '<!-- initiative-planner -->\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"
Expand Down
21 changes: 19 additions & 2 deletions scripts/initiative-planner/plan.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -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."
}
}
}
]
}
}
}
}
38 changes: 38 additions & 0 deletions tests/test_initiative_planner.bats
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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" \
Expand Down
Loading