Compare commits
5 Commits
36ab53112a
...
55bd617a6e
| Author | SHA1 | Date |
|---|---|---|
|
|
55bd617a6e | |
|
|
560a2e3a6f | |
|
|
6325793e86 | |
|
|
4d930586a9 | |
|
|
df74a12d0e |
|
|
@ -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>
|
||||
|
|
@ -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 = ""
|
||||
|
|
@ -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.
|
|
|
@ -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.8–3.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
|
||||
// ============================================================
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Reference in New Issue