Compare commits

...

5 Commits

Author SHA1 Message Date
math0r 55bd617a6e
Merge 6325793e86 into 560a2e3a6f 2026-06-11 21:29:54 -05:00
Brian 560a2e3a6f
feat: installer detects Python version and warns when 3.11+ (tomllib) is missing (#2466)
* feat: installer detects Python version and warns when 3.11+ (tomllib) is missing

Several BMAD features need Python at runtime: memlog (3.8+) and the TOML
config resolution scripts (3.11+ for stdlib tomllib). Users install into
varied environments (Linux, Windows, WSL, Docker) where Python may be
missing or too old, and previously only found out via runtime errors.

The installer now probes PATH at startup (py -3 / python3 / python) and
classifies the result: 3.11+ passes silently with a success line; 3.8-3.10
or missing/too-old Python gets a warning naming exactly which features
degrade, plus per-platform install hints. The warning requires an explicit
ack — continue (fix later, no reinstall needed) or quit and re-run after
installing Python. Warn-don't-block: most of BMAD works without Python, so
the install is never refused. In --yes mode the warning logs and continues
without prompting.

* fix: align Python check with runtime truth (python3) and harden edge cases

Review fixes for the installer Python check:

- Probe python3 first on all platforms: every runtime call site invokes a
  literal python3, so only that command vouches for BMAD features. Python
  found via py/python now gets an explicit mismatch warning instead of a
  false "all BMAD features supported".
- Treat closed/piped stdin as non-interactive (in addition to --yes) so
  scripted installs no longer silently exit 0 via clack's cancel path.
- Retry probes with shell:true on win32 EINVAL (CVE-2024-27980 hardening
  rejects .bat/.cmd shims like pyenv-win's without a shell).
- Add Suite 46 branch tests for checkPythonEnvironment with stubbed
  detection, prompts, and process.exit.
2026-06-11 21:27:52 -05:00
raph 6325793e86 feat(planning-poker): add complete SKILL.md workflow 2026-06-08 14:44:18 +02:00
raph 4d930586a9 feat(planning-poker): register PP in module-help.csv 2026-06-08 14:43:52 +02:00
raph df74a12d0e feat(planning-poker): add customize.toml with config defaults 2026-06-08 14:43:07 +02:00
6 changed files with 757 additions and 0 deletions

View File

@ -0,0 +1,377 @@
---
name: bmad-planning-poker
description: 'Collaborative story point estimation with AI agents. Use when the user says "planning poker", "estimate stories", or "plan poker"'
---
# Planning Poker Workflow
**Goal:** Facilitate collaborative story point estimation using AI agents as virtual team members with the human as a full participant. Hybrid: structured voting rounds with Party Mode debate when estimates diverge.
**Your Role:** You are a Planning Poker facilitator. Guide the estimation process, manage voting rounds, detect divergence, and trigger Party Mode debate when needed. The human is a voting participant, not just an observer.
## Conventions
- Bare paths resolve from the skill root.
- `{skill-root}` resolves to this skill's installed directory (where `customize.toml` lives).
- `{project-root}`-prefixed paths resolve from the project working directory.
- `{skill-name}` resolves to the skill directory's basename.
## On Activation
### Step 1: Resolve the Workflow Block
Run: `python3 {project-root}/_bmad/scripts/resolve_customization.py --skill {skill-root} --key workflow`
**If the script fails**, resolve the `workflow` block yourself by reading these three files in base → team → user order and applying the same structural merge rules as the resolver:
1. `{skill-root}/customize.toml` — defaults
2. `{project-root}/_bmad/custom/{skill-name}.toml` — team overrides
3. `{project-root}/_bmad/custom/{skill-name}.user.toml` — personal overrides
Any missing file is skipped.
### Step 2: Execute Prepend Steps
Execute each entry in `{workflow.activation_steps_prepend}` in order before proceeding.
### Step 3: Load Persistent Facts
Treat every entry in `{workflow.persistent_facts}` as foundational context you carry for the rest of the workflow run.
### Step 4: Load Config
Load config from `{project-root}/_bmad/bmm/config.yaml` and resolve:
- `project_name`, `user_name`
- `communication_language`, `document_output_language`
- `implementation_artifacts`, `planning_artifacts`
- `date` as system-generated current datetime
- `project_context` = `**/project-context.md` (load if exists)
- YOU MUST ALWAYS SPEAK OUTPUT in your Agent communication style with the config `{communication_language}`
### Step 5: Greet the User
Greet `{user_name}`, speaking in `{communication_language}`.
### Step 6: Execute Append Steps
Execute each entry in `{workflow.activation_steps_append}` in order.
Activation is complete.
## Paths
- `sprint_status_file` = `{implementation_artifacts}/sprint-status.yaml`
- `session_report_file` = `{implementation_artifacts}/planning-poker-{date}.md`
- `estimation_scale` = `{workflow.estimation_scale}` (default: fibonacci)
- `divergence_threshold` = `{workflow.divergence_threshold}` (default: 2.0)
- `max_rounds` = `{workflow.max_rounds}` (default: 3)
- `participating_agents` = `{workflow.participating_agents}`
## Input Files
| Input | Path | Load Strategy |
|-------|------|---------------|
| Sprint status | `{sprint_status_file}` | FULL_LOAD |
## Estimation Scales
### Fibonacci (default)
1, 2, 3, 5, 8, 13, 21 — use for relative effort estimation.
### T-shirt
XS, S, M, L, XL, XXL — use when effort is too uncertain for numbers.
### Linear
1, 2, 3, 4, 5, 6, 7, 8, 9, 10 — use when the team prefers absolute scale.
## Execution
<workflow>
<step n="1" goal="Discover unestimated stories">
<action>Load {project_context} for project-wide patterns and conventions (if exists)</action>
<action>Communicate in {communication_language} with {user_name}</action>
<action>Read the FULL file: {sprint_status_file}</action>
<check if="file not found">
<output>No sprint-status.yaml found. Run sprint-planning first to generate it.</output>
<action>Exit workflow</action>
</check>
<action>Parse the YAML. Look for the `development_status` section.</action>
<action>If a `story_points` section exists, skip any story that already has a story_points entry.</action>
<action>Collect all unestimated stories (keys matching `{epic_num}-{story_num}-{title}` pattern with no story_points entry).</action>
<action>Sort stories by epic number, then story number.</action>
<check if="no unestimated stories found">
<output>All stories already estimated. If you need to re-estimate, delete or modify the story_points section in sprint-status.yaml first.</output>
<action>Exit workflow</action>
</check>
<output>
**Stories to Estimate:** {{unestimated_count}}
{{#each unestimated_stories}}
- **{{key}}** — {{title}}
{{/each}}
**Scale:** {{estimation_scale}}
**Divergence threshold:** {{divergence_threshold}}x
**Participating agents:** {{participating_agents}}
**Human participant:** {{user_name}} (you)
</output>
<check if="unestimated_count > 15">
<output>⚠️ Large backlog detected. Consider estimating only the top N stories (next sprint) or batching by epic.</output>
<ask>Estimate all {{unestimated_count}} stories, limit to top-N by priority, or batch by epic? [all / top-N / epic / quit]</ask>
</check>
</step>
<step n="2" goal="Estimate each story">
<action>For each unestimated story in order:</action>
<substep n="2a" goal="Present the story">
<action>Display the story details:</action>
<output>
---
## Story {{story_key}}: {{story_title}}
{{story_description}}
**Acceptance Criteria:**
{{#each acceptance_criteria}}
- {{this}}
{{/each}}
---
</output>
</substep>
<substep n="2b" goal="Silent voting round">
<output>
🎴 **Silent Round** — Everyone estimates privately.
Valid values: {{scale_values}}
**{{user_name}}**, what is your estimate for this story?
</output>
<ask>Your estimate (or "?" if unsure / "skip" to skip this story):</ask>
<check if="user_input == 'skip'">
<action>Mark story as skipped, continue to next story</action>
</check>
<check if="user_input == '?'">
<action>Treat as "needs discussion" — estimate as "?" and proceed to reveal</action>
</check>
<action>Store human estimate as `{{human_estimate}}`</action>
<action>For each agent in {{participating_agents}}, load the agent's persona from `{project-root}/_bmad/core/config.yaml` agents section and ask them to estimate in character. Each agent gives ONLY their estimate and a 1-sentence reason. Agents do NOT see each other's estimates yet.</action>
<action>Collect agent estimates into `{{agent_estimates}}` map: `{agent_name: {estimate: value, reason: "..."}}`</action>
</substep>
<substep n="2c" goal="Reveal all estimates">
<output>
## Reveal — Story {{story_key}}
| Participant | Estimate |
|-------------|----------|
| **{{user_name}} (You)** | {{human_estimate}} |
{{#each agent_estimates}}
| {{name}} ({{title}}) | {{estimate}} |
{{/each}}
**Reasoning:**
{{#each agent_estimates}}
- **{{name}}:** {{reason}}
{{/each}}
</output>
</substep>
<substep n="2d" goal="Check for divergence">
<action>Find the max and min numeric estimates from {{all_estimates}} (exclude "?" values).</action>
<action>Calculate ratio: max_estimate / min_estimate</action>
<check if="any estimate is '?'">
<action>Divergence is automatic — trigger debate to clarify.</action>
</check>
<check if="ratio > {{divergence_threshold}}">
<output>
⚠️ **Divergence detected!**
Max estimate ({{max_estimate}}) is {{ratio}}x the min estimate ({{min_estimate}}).
Threshold: {{divergence_threshold}}x
**The outliers should explain their reasoning.**
</output>
<action>Proceed to Party Debate (step 2e)</action>
</check>
<check if="ratio <= {{divergence_threshold}} AND no '?' estimates">
<action>Skip debate — estimates are within acceptable range. Proceed to consensus (step 2f).</action>
</check>
</substep>
<substep n="2e" goal="Party Mode debate">
<action>If this is round > {{max_rounds}}, skip debate and go to PM tiebreak (step 2f).</action>
<output>
💬 **Entering debate mode.** The agents will discuss their estimates. You can challenge, agree, or push back on any point. This is your team talking — jump in anytime.
*Loading Party Mode...*
</output>
<action>Invoke the Party Mode skill (`bmad-party-mode`) context. Present the situation:</action>
<action>
- The story being estimated: {{story_key}}: {{story_title}} — {{story_description}}
- The current round's estimates: show all estimates
- The divergence: max ({{max_estimate}}) vs min ({{min_estimate}})
- Instruction: "These agents need to debate their estimates for this story. {{user_name}} is a participant. The high and low estimators should defend their positions. Goal: converge or clarify the disagreement."
- Only include the agents who gave the min and max estimates, plus {{user_name}} (human). Other agents can stay silent unless they have a relevant point.
</action>
<action>Run the Party Mode exchange (3-5 turns max for this debate). After the debate, summarize the key arguments made.</action>
<action>After debate, return to silent voting round (step 2b) for a re-vote. Increment round counter.</action>
<check if="round > {{max_rounds}}">
<output>Maximum rounds reached ({{max_rounds}}). The PM will cast the final estimate.</output>
<action>PM (John) reviews all debate arguments and casts the final estimate. Display it with reasoning.</action>
<action>Set `{{consensus_estimate}}` = PM's estimate.</action>
<action>Proceed to save (step 3).</action>
</check>
</substep>
<substep n="2f" goal="Reach consensus">
<check if="estimates converge naturally (ratio <= threshold AND no '?')">
<output>
✅ **Consensus reached!**
Estimates cluster around: {{mode_estimate}}
| Participant | Final Estimate |
|-------------|---------------|
| **Consensus** | {{consensus_estimate}} |
</output>
<action>Set `{{consensus_estimate}}` = the mode (most common) of all estimates, or the median if no mode.</action>
</check>
<action>Ask for final confirmation:</action>
<ask>The consensus estimate for **{{story_key}}** is **{{consensus_estimate}}**.
Accept this estimate? [y / n (provide your own) / skip]</ask>
<check if="user says 'n' with alternative">
<action>Use user's alternative as `{{consensus_estimate}}`. The human has final authority.</action>
</check>
<check if="user says 'skip'">
<action>Mark story as skipped. Continue to next story.</action>
</check>
<action>Store `{{consensus_estimate}}` with justification from the debate/discussion.</action>
</substep>
<step n="3" goal="Save estimation results">
<action>Update `{sprint_status_file}`:</action>
<action>Add a `story_points` section alongside `development_status` (do NOT modify `development_status`):</action>
```yaml
story_points:
1-1-user-authentication: 5
1-2-account-management: 8
```
<action>Each entry: story key → consensus estimate (number or t-shirt size string).</action>
<action>Do NOT overwrite existing story_points entries — only add new ones for stories estimated in this session.</action>
<output>
**sprint-status.yaml updated** — {{newly_estimated_count}} stories estimated.
</output>
</step>
<step n="4" goal="Generate session report">
<action>Create `{{session_report_file}}` with full trace:</action>
```markdown
# Planning Poker Session — {{date}}
**Project:** {{project_name}}
**Scale:** {{estimation_scale}}
**Threshold:** {{divergence_threshold}}x
**Max rounds:** {{max_rounds}}
**Agents:** {{participating_agents}}
**Human:** {{user_name}}
---
## Summary
| Story | Final Estimate | Rounds | Debate? |
|-------|---------------|--------|---------|
{{#each estimated_stories}}
| {{key}} | {{estimate}} | {{rounds}} | {{had_debate}} |
{{/each}}
---
## Detailed Results
{{#each estimated_stories}}
### {{key}}: {{title}}
**Description:** {{description}}
**Round 1:**
| Participant | Estimate | Reasoning |
|-------------|----------|-----------|
| {{user_name}} (You) | {{human_est}} | — |
{{#each agent_estimates}}
| {{name}} ({{title}}) | {{estimate}} | {{reason}} |
{{/each}}
{{#if had_debate}}
**Debate:** {{debate_summary}}
**Round {{final_round}} (re-vote):**
| Participant | Estimate |
|-------------|----------|
| Consensus | {{final_estimate}} |
{{/if}}
**Final Estimate:** {{final_estimate}}
**Justification:** {{justification}}
---
{{/each}}
```
<action>Write report to `{{session_report_file}}`.</action>
<output>
📄 **Session report saved:** {{session_report_file}}
</output>
</step>
<step n="5" goal="Display completion summary">
<output>
## Planning Poker Complete 🎴
- **Stories estimated:** {{estimated_count}}
- **Stories skipped:** {{skipped_count}}
- **Debates triggered:** {{debate_count}}
- **Average rounds per story:** {{avg_rounds}}
**Updated:** `{{sprint_status_file}}` (story_points section)
**Report:** `{{session_report_file}}`
**Next:** Run sprint-planning to incorporate estimates into your sprint plan.
</output>
<action>Run: `python3 {project-root}/_bmad/scripts/resolve_customization.py --skill {skill-root} --key workflow.on_complete` — if the resolved value is non-empty, follow it as the final terminal instruction before exiting.</action>
</step>
</workflow>

View File

@ -0,0 +1,32 @@
# DO NOT EDIT -- overwritten on every update.
#
# Workflow customization surface for bmad-planning-poker. Mirrors the
# agent customization shape under the [workflow] namespace.
[workflow]
# Steps to run before the standard activation (config load, greet).
activation_steps_prepend = []
# Steps to run after greet but before the workflow begins.
activation_steps_append = []
# Persistent facts the workflow keeps in mind for the whole run.
persistent_facts = [
"file:{project-root}/**/project-context.md",
]
# Estimation scale: fibonacci | tshirt | linear
estimation_scale = "fibonacci"
# Divergence threshold: max/min > this triggers debate
divergence_threshold = 2.0
# Maximum re-vote rounds before PM tiebreaks
max_rounds = 3
# Agents participating in estimation (agent codes)
participating_agents = ["bmad-agent-pm", "bmad-agent-dev", "bmad-agent-architect"]
# Post-completion hook (empty by default)
on_complete = ""

View File

@ -30,3 +30,4 @@ BMad Method,bmad-checkpoint-preview,Checkpoint,CK,Guided walkthrough of a change
BMad Method,bmad-qa-generate-e2e-tests,QA Automation Test,QA,Generate automated API and E2E tests for implemented code. NOT for code review or story validation — use CR for that.,,,4-implementation,bmad-dev-story,,false,implementation_artifacts,test suite
BMad Method,bmad-retrospective,Retrospective,ER,Optional at epic end: Review completed work lessons learned and next epic or if major issues consider CC.,,,4-implementation,bmad-code-review,,false,implementation_artifacts,retrospective
BMad Method,bmad-investigate,Investigate,IN,Forensic case investigation calibrated to the input. Evidence-graded analysis with hypothesis tracking. Produces a structured case file.,,4-implementation,,,false,implementation_artifacts,investigation report
BMad Method,bmad-planning-poker,Plan Poker,PP,Collaborative story point estimation with AI agents. Silent voting rounds and Party Mode debate when estimates diverge.,,,4-implementation,bmad-create-epics-and-stories,bmad-sprint-planning,false,implementation_artifacts,session report and story points

Can't render this file because it has a wrong number of fields in line 32.

View File

@ -3318,6 +3318,144 @@ async function runTests() {
console.log('');
// ============================================================
// Test Suite 46: Python environment check (version parsing + classification)
// ============================================================
console.log(`${colors.yellow}Test Suite 46: python-check version parsing and classification${colors.reset}\n`);
try {
const { parsePythonVersion, classifyPython, detectPython } = require('../tools/installer/core/python-check');
// Version parsing
const v312 = parsePythonVersion('Python 3.12.1');
assert(v312 && v312.major === 3 && v312.minor === 12 && v312.patch === 1, 'parses "Python 3.12.1"');
const v311 = parsePythonVersion('Python 3.11.0\n');
assert(v311 && v311.raw === '3.11.0', 'parses with trailing newline');
const v2 = parsePythonVersion('\nPython 2.7.18');
assert(v2 && v2.major === 2, 'parses Python 2 output (stderr-style)');
const noPatch = parsePythonVersion('Python 3.13');
assert(noPatch && noPatch.patch === 0, 'missing patch defaults to 0');
assert(parsePythonVersion('') === null, 'empty output returns null');
assert(parsePythonVersion('command not found: python3') === null, 'non-version output returns null');
assert(parsePythonVersion(null) === null, 'null output returns null');
// Classification against feature requirements
assert(classifyPython({ major: 3, minor: 11 }) === 'full', '3.11 is full support (tomllib floor)');
assert(classifyPython({ major: 3, minor: 13 }) === 'full', '3.13 is full support');
assert(classifyPython({ major: 4, minor: 0 }) === 'full', 'hypothetical 4.0 is full support');
assert(classifyPython({ major: 3, minor: 10 }) === 'partial', '3.10 is partial (memlog yes, tomllib no)');
assert(classifyPython({ major: 3, minor: 8 }) === 'partial', '3.8 is partial (memlog floor)');
assert(classifyPython({ major: 3, minor: 7 }) === 'unsupported', '3.7 is unsupported');
assert(classifyPython({ major: 2, minor: 7 }) === 'unsupported', '2.7 is unsupported');
assert(classifyPython(null) === 'none', 'no python is none');
// Detection smoke test — must not throw, and if it finds a Python the
// result must be well-formed. (CI machines may or may not have Python.)
const detected = detectPython();
assert(
detected === null ||
(typeof detected.command === 'string' &&
typeof detected.version.raw === 'string' &&
typeof detected.isRuntimeCommand === 'boolean'),
'detectPython returns null or a well-formed result',
);
// checkPythonEnvironment branch coverage — stub detection, prompts, and
// process.exit so the assertions are deterministic regardless of the
// machine's Python. python-check resolves detectPython via module.exports
// and prompts via the shared module object, so swapping properties works.
const pythonCheck = require('../tools/installer/core/python-check');
const promptsModule = require('../tools/installer/prompts');
const real = {
detectPython: pythonCheck.detectPython,
log: promptsModule.log,
note: promptsModule.note,
select: promptsModule.select,
cancel: promptsModule.cancel,
exit: process.exit,
};
const stub = (detectResult, selectAnswer) => {
const seen = { success: [], warn: [], info: [], note: [], select: [], cancel: [], exit: [] };
pythonCheck.detectPython = () => detectResult;
promptsModule.log = {
success: async (m) => void seen.success.push(m),
warn: async (m) => void seen.warn.push(m),
info: async (m) => void seen.info.push(m),
error: async () => {},
};
promptsModule.note = async (m, t) => void seen.note.push(t || m);
promptsModule.select = async (opts) => {
seen.select.push(opts.message);
return selectAnswer;
};
promptsModule.cancel = async (m) => void seen.cancel.push(m);
process.exit = (code) => {
seen.exit.push(code);
throw new Error('__stub_exit__');
};
return seen;
};
try {
const v = (major, minor, patch) => ({ major, minor, patch, raw: `${major}.${minor}.${patch}` });
// Branch: full support via the runtime command — success, no prompt.
let seen = stub({ command: 'python3', version: v(3, 12, 1), isRuntimeCommand: true }, 'continue');
let result = await pythonCheck.checkPythonEnvironment();
assert(result.status === 'full' && seen.success.length === 1, 'full support via python3 logs success');
assert(seen.select.length === 0 && seen.warn.length === 0, 'full support via python3 skips warning and ack prompt');
// Branch: modern Python found, but not as `python3` — runtime mismatch.
seen = stub({ command: 'py -3', version: v(3, 12, 0), isRuntimeCommand: false }, 'continue');
result = await pythonCheck.checkPythonEnvironment();
assert(seen.success.length === 0, 'python3-mismatch never reports full support');
assert(
seen.warn.length === 1 && seen.warn[0].includes('python3') && seen.warn[0].includes('py -3'),
'python3-mismatch warns that scripts invoke python3',
);
assert(seen.select.length === 1 && result.status === 'full', 'python3-mismatch still requires the ack prompt');
// Branch: partial support (3.83.10) — warn + ack, continue returns.
seen = stub({ command: 'python3', version: v(3, 9, 5), isRuntimeCommand: true }, 'continue');
result = await pythonCheck.checkPythonEnvironment();
assert(
result.status === 'partial' && seen.warn.length === 1 && seen.warn[0].includes('3.11+'),
'partial support warns about tomllib floor',
);
assert(seen.select.length === 1 && seen.exit.length === 0, 'partial support prompts and continue proceeds');
// Branch: no Python, non-interactive — warn + info, never prompts.
seen = stub(null, 'continue');
result = await pythonCheck.checkPythonEnvironment({ nonInteractive: true });
assert(result.status === 'none' && seen.warn[0].includes('No Python found'), 'non-interactive with no Python warns');
assert(seen.select.length === 0 && seen.info.length === 1, 'non-interactive skips the ack prompt and logs continuation');
// Branch: no Python, interactive, user quits — cancel message + exit 0.
seen = stub(null, 'quit');
let threw = false;
try {
await pythonCheck.checkPythonEnvironment();
} catch (error) {
threw = error.message === '__stub_exit__';
}
assert(threw && seen.exit.length === 1 && seen.exit[0] === 0, 'quit choice exits 0 (user-cancel convention)');
assert(seen.cancel.length === 1, 'quit choice shows the cancel guidance');
} finally {
pythonCheck.detectPython = real.detectPython;
promptsModule.log = real.log;
promptsModule.note = real.note;
promptsModule.select = real.select;
promptsModule.cancel = real.cancel;
process.exit = real.exit;
}
} catch (error) {
console.log(`${colors.red}Test Suite 46 setup failed: ${error.message}${colors.reset}`);
console.log(error.stack);
failed++;
}
console.log('');
// ============================================================
// Summary
// ============================================================

View File

@ -0,0 +1,199 @@
const { spawnSync } = require('node:child_process');
const prompts = require('../prompts');
// Python 3.11 added stdlib `tomllib` (PEP 680), which the shared scripts in
// src/scripts/ (resolve_config.py, resolve_customization.py) require to read
// BMAD's TOML config files. memlog.py is more lenient and runs on 3.8+.
const PYTHON_FULL_SUPPORT = { major: 3, minor: 11 };
const PYTHON_PARTIAL_SUPPORT = { major: 3, minor: 8 };
// Every runtime call site (skill steps, on_complete hooks) invokes a literal
// `python3`, so only that command's version vouches for BMAD features. The
// fallback probes exist to tell the user "Python is installed, but not under
// the name BMAD uses" instead of a misleading "No Python found".
const RUNTIME_COMMAND = 'python3';
const PROBE_CANDIDATES =
process.platform === 'win32'
? [
{ command: 'python3', args: ['--version'] },
{ command: 'py', args: ['-3', '--version'] },
{ command: 'python', args: ['--version'] },
]
: [
{ command: 'python3', args: ['--version'] },
{ command: 'python', args: ['--version'] },
];
/**
* Parse a `python --version` output line into version parts.
* Python 3 prints to stdout; Python 2 printed to stderr callers pass both.
* @param {string} output - Combined stdout/stderr from `python --version`
* @returns {{major: number, minor: number, patch: number, raw: string}|null}
*/
function parsePythonVersion(output) {
if (!output) return null;
const match = output.match(/Python\s+(\d+)\.(\d+)(?:\.(\d+))?/);
if (!match) return null;
return {
major: Number(match[1]),
minor: Number(match[2]),
patch: Number(match[3] || 0),
raw: `${match[1]}.${match[2]}.${match[3] || 0}`,
};
}
/**
* Classify a detected Python version against BMAD's feature requirements.
* @param {{major: number, minor: number}|null} version
* @returns {'full'|'partial'|'unsupported'|'none'}
*/
function classifyPython(version) {
if (!version) return 'none';
const { major, minor } = version;
if (major > PYTHON_FULL_SUPPORT.major || (major === PYTHON_FULL_SUPPORT.major && minor >= PYTHON_FULL_SUPPORT.minor)) {
return 'full';
}
if (major === PYTHON_PARTIAL_SUPPORT.major && minor >= PYTHON_PARTIAL_SUPPORT.minor) {
return 'partial';
}
return 'unsupported';
}
/**
* Run one probe candidate and return its parsed version, or null.
* @param {{command: string, args: string[]}} candidate
* @returns {{major: number, minor: number, patch: number, raw: string}|null}
*/
function probeVersion(candidate) {
const run = (extra = {}) =>
spawnSync(candidate.command, candidate.args, {
encoding: 'utf8',
timeout: 5000,
windowsHide: true,
...extra,
});
let result = run();
// Node >=18.20/20.12 refuses to spawn .bat/.cmd without a shell
// (CVE-2024-27980 hardening) and reports EINVAL — pyenv-win ships its
// python shims as .bat. Args here are static literals, so a shell retry
// is injection-safe.
if (result.error && result.error.code === 'EINVAL' && process.platform === 'win32') {
result = run({ shell: true });
}
if (result.error) return null;
return parsePythonVersion(`${result.stdout || ''}\n${result.stderr || ''}`);
}
/**
* Probe the local environment for a Python interpreter.
* Tries each candidate command and returns the first that reports a version.
* `isRuntimeCommand` is true only when the match is `python3` the command
* BMAD scripts actually invoke.
* @returns {{command: string, version: {major: number, minor: number, patch: number, raw: string}, isRuntimeCommand: boolean}|null}
*/
function detectPython() {
for (const candidate of PROBE_CANDIDATES) {
try {
const version = probeVersion(candidate);
if (version) {
const display = candidate.args.length > 1 ? `${candidate.command} ${candidate.args.slice(0, -1).join(' ')}` : candidate.command;
return { command: display, version, isRuntimeCommand: candidate.command === RUNTIME_COMMAND };
}
} catch {
// Candidate not runnable — try the next one.
}
}
return null;
}
function upgradeHints() {
return [
'How to get Python 3.11+ (as `python3`):',
' macOS: brew install python3',
' Windows: winget install Python.Python.3.12 (then ensure `python3` resolves, e.g. enable the python3 alias)',
' Linux/WSL: sudo apt install python3 (Ubuntu 24.04+ ships 3.12; older distros: use pyenv or deadsnakes)',
' Docker: add python3 to your image (e.g. apk add python3 / apt-get install -y python3)',
].join('\n');
}
/**
* Check the local Python environment and warn about degraded BMAD features.
*
* Warn-don't-block: most of BMAD works without Python, so the install always
* may proceed but the user must explicitly acknowledge the warning so it
* can't scroll past unseen. In non-interactive runs (--yes, or stdin is not
* a TTY) the warning is logged and the install continues without a prompt.
*
* @param {Object} [options]
* @param {boolean} [options.nonInteractive=false] - Skip the ack prompt (--yes, or no TTY)
* @returns {Promise<{status: string, detected: Object|null}>}
*/
async function checkPythonEnvironment({ nonInteractive = false } = {}) {
// Called via module.exports so tests can stub detection.
const detected = module.exports.detectPython();
const status = classifyPython(detected ? detected.version : null);
if (status === 'full' && detected.isRuntimeCommand) {
await prompts.log.success(`Python ${detected.version.raw} detected (${detected.command}) — all BMAD features supported.`);
return { status, detected };
}
if (detected && !detected.isRuntimeCommand) {
await prompts.log.warn(
`Python ${detected.version.raw} found via \`${detected.command}\`, but BMAD scripts invoke \`python3\`, which is not on PATH.\n` +
`Python-powered features (memlog session memory, TOML config resolution) won't run until \`python3\` resolves —\n` +
`add a python3 alias/shim, or reinstall Python with the python3 launcher enabled.`,
);
} else if (status === 'partial') {
await prompts.log.warn(
`Python ${detected.version.raw} detected (${detected.command}) — BMAD's TOML config tools need Python 3.11+ (stdlib tomllib).\n` +
`Works: memlog session memory. Won't work: config/customization resolution scripts.`,
);
} else {
const found =
status === 'unsupported' ? `Python ${detected.version.raw} detected (${detected.command}) — too old.` : 'No Python found on PATH.';
await prompts.log.warn(
`${found} BMAD installs fine without it, but Python-powered features\n` +
`(memlog session memory, TOML config resolution) won't run until Python 3.11+ is available.`,
);
}
await prompts.note(upgradeHints(), 'Python 3.11+ recommended');
if (nonInteractive) {
await prompts.log.info('Continuing anyway (non-interactive run). You can fix Python later — no reinstall needed.');
return { status, detected };
}
const choice = await prompts.select({
message: "BMAD's Python-powered features won't work yet. How do you want to proceed?",
choices: [
{
name: 'Continue install',
value: 'continue',
hint: 'BMAD works without Python — you can fix Python later, no reinstall needed',
},
{
name: 'Quit and fix Python first',
value: 'quit',
hint: 'make Python 3.11+ available as python3, then re-run the installer',
},
],
default: 'continue',
});
if (choice === 'quit') {
await prompts.cancel('Make Python 3.11+ available as `python3` (see hints above), then re-run the installer.');
process.exit(0);
}
return { status, detected };
}
module.exports = {
checkPythonEnvironment,
detectPython,
parsePythonVersion,
classifyPython,
PYTHON_FULL_SUPPORT,
PYTHON_PARTIAL_SUPPORT,
};

View File

@ -161,6 +161,16 @@ class UI {
const messageLoader = new MessageLoader();
await messageLoader.displayStartMessage();
// Probe the local Python before any other prompts: several BMAD features
// (memlog session memory, TOML config resolution) need Python 3.11+ at
// runtime. Warn-don't-block, but require an explicit ack so the warning
// can't scroll past unseen. The installer runs in the destination
// environment, so probing PATH here tests the right machine.
// Skip the ack when stdin isn't a TTY (CI/Docker/piped): clack's select
// on closed stdin resolves to cancel, which would silently exit 0.
const { checkPythonEnvironment } = require('./core/python-check');
await checkPythonEnvironment({ nonInteractive: !!options.yes || !process.stdin.isTTY });
// Parse channel flags (--channel/--all-*/--next=/--pin) once. Warnings
// are surfaced immediately so the user sees them before any git ops run.
const channelOptions = parseChannelOptions(options);