Compare commits

..

1 Commits

Author SHA1 Message Date
Alex Verkhovsky 4a0218ea39
Merge 205bc438cb into 993d02b8b3 2026-01-15 08:43:36 +09:00
33 changed files with 558 additions and 4279 deletions

View File

@ -11,6 +11,7 @@ ignores:
- .claude/** - .claude/**
- .roo/** - .roo/**
- .codex/** - .codex/**
- .agentvibes/**
- .kiro/** - .kiro/**
- sample-project/** - sample-project/**
- test-project-install/** - test-project-install/**

View File

@ -20,13 +20,10 @@ This flexibility enables:
## Categories ## Categories
- [Categories](#categories)
- [Custom Stand-Alone Modules](#custom-stand-alone-modules) - [Custom Stand-Alone Modules](#custom-stand-alone-modules)
- [Custom Add-On Modules](#custom-add-on-modules) - [Custom Add-On Modules](#custom-add-on-modules)
- [Custom Global Modules](#custom-global-modules) - [Custom Global Modules](#custom-global-modules)
- [Custom Agents](#custom-agents) - [Custom Agents](#custom-agents)
- [BMad Tiny Agents](#bmad-tiny-agents)
- [Simple and Expert Agents](#simple-and-expert-agents)
- [Custom Workflows](#custom-workflows) - [Custom Workflows](#custom-workflows)
## Custom Stand-Alone Modules ## Custom Stand-Alone Modules
@ -62,6 +59,7 @@ Similar to Custom Stand-Alone Modules, but designed to add functionality that ap
Examples include: Examples include:
- The current TTS (Text-to-Speech) functionality for Claude, which will soon be converted to a global module
- The core module, which is always installed and provides all agents with party mode and advanced elicitation capabilities - The core module, which is always installed and provides all agents with party mode and advanced elicitation capabilities
- Installation and update tools that work with any BMad method configuration - Installation and update tools that work with any BMad method configuration

View File

@ -66,18 +66,19 @@ Type "exit" or "done" to conclude the session. Participating agents will say per
## Example Party Compositions ## Example Party Compositions
| Topic | Typical Agents | | Topic | Typical Agents |
| ---------------------- | ------------------------------------------------------------- | |-------|---------------|
| **Product Strategy** | PM + Innovation Strategist (CIS) + Analyst | | **Product Strategy** | PM + Innovation Strategist (CIS) + Analyst |
| **Technical Design** | Architect + Creative Problem Solver (CIS) + Game Architect | | **Technical Design** | Architect + Creative Problem Solver (CIS) + Game Architect |
| **User Experience** | UX Designer + Design Thinking Coach (CIS) + Storyteller (CIS) | | **User Experience** | UX Designer + Design Thinking Coach (CIS) + Storyteller (CIS) |
| **Quality Assessment** | TEA + DEV + Architect | | **Quality Assessment** | TEA + DEV + Architect |
## Key Features ## Key Features
- **Intelligent agent selection** — Selects based on expertise needed - **Intelligent agent selection** — Selects based on expertise needed
- **Authentic personalities** — Each agent maintains their unique voice - **Authentic personalities** — Each agent maintains their unique voice
- **Natural cross-talk** — Agents reference and build on each other - **Natural cross-talk** — Agents reference and build on each other
- **Optional TTS** — Voice configurations for each agent
- **Graceful exit** — Personalized farewells - **Graceful exit** — Personalized farewells
## Tips ## Tips

6
package-lock.json generated
View File

@ -9,7 +9,6 @@
"version": "6.0.0-alpha.23", "version": "6.0.0-alpha.23",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@clack/prompts": "^0.11.0",
"@kayvan/markdown-tree-parser": "^1.6.1", "@kayvan/markdown-tree-parser": "^1.6.1",
"boxen": "^5.1.2", "boxen": "^5.1.2",
"chalk": "^4.1.2", "chalk": "^4.1.2",
@ -34,6 +33,7 @@
"devDependencies": { "devDependencies": {
"@astrojs/sitemap": "^3.6.0", "@astrojs/sitemap": "^3.6.0",
"@astrojs/starlight": "^0.37.0", "@astrojs/starlight": "^0.37.0",
"@clack/prompts": "^0.11.0",
"@eslint/js": "^9.33.0", "@eslint/js": "^9.33.0",
"archiver": "^7.0.1", "archiver": "^7.0.1",
"astro": "^5.16.0", "astro": "^5.16.0",
@ -759,6 +759,7 @@
"version": "0.5.0", "version": "0.5.0",
"resolved": "https://registry.npmjs.org/@clack/core/-/core-0.5.0.tgz", "resolved": "https://registry.npmjs.org/@clack/core/-/core-0.5.0.tgz",
"integrity": "sha512-p3y0FIOwaYRUPRcMO7+dlmLh8PSRcrjuTndsiA0WAFbWES0mLZlrjVoBRZ9DzkPFJZG6KGkJmoEAY0ZcVWTkow==", "integrity": "sha512-p3y0FIOwaYRUPRcMO7+dlmLh8PSRcrjuTndsiA0WAFbWES0mLZlrjVoBRZ9DzkPFJZG6KGkJmoEAY0ZcVWTkow==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"picocolors": "^1.0.0", "picocolors": "^1.0.0",
@ -769,6 +770,7 @@
"version": "0.11.0", "version": "0.11.0",
"resolved": "https://registry.npmjs.org/@clack/prompts/-/prompts-0.11.0.tgz", "resolved": "https://registry.npmjs.org/@clack/prompts/-/prompts-0.11.0.tgz",
"integrity": "sha512-pMN5FcrEw9hUkZA4f+zLlzivQSeQf5dRGJjSUbvVYDLvpKCdQx5OaknvKzgbtXOizhP+SJJJjqEbOe55uKKfAw==", "integrity": "sha512-pMN5FcrEw9hUkZA4f+zLlzivQSeQf5dRGJjSUbvVYDLvpKCdQx5OaknvKzgbtXOizhP+SJJJjqEbOe55uKKfAw==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@clack/core": "0.5.0", "@clack/core": "0.5.0",
@ -12149,6 +12151,7 @@
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
"dev": true,
"license": "ISC" "license": "ISC"
}, },
"node_modules/picomatch": { "node_modules/picomatch": {
@ -13395,6 +13398,7 @@
"version": "1.0.5", "version": "1.0.5",
"resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz",
"integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/sitemap": { "node_modules/sitemap": {

View File

@ -18,6 +18,7 @@ agent:
critical_actions: critical_actions:
- "Load into memory {project-root}/_bmad/core/config.yaml and set variable project_name, output_folder, user_name, communication_language" - "Load into memory {project-root}/_bmad/core/config.yaml and set variable project_name, output_folder, user_name, communication_language"
- "Remember the users name is {user_name}"
- "ALWAYS communicate in {communication_language}" - "ALWAYS communicate in {communication_language}"
menu: menu:

View File

@ -130,6 +130,7 @@ After agent loading and introduction:
- Handle missing or incomplete agent entries gracefully - Handle missing or incomplete agent entries gracefully
- Cross-reference manifest with actual agent files - Cross-reference manifest with actual agent files
- Prepare agent selection logic for intelligent conversation routing - Prepare agent selection logic for intelligent conversation routing
- Set up TTS voice configurations for each agent
## NEXT STEP: ## NEXT STEP:

View File

@ -6,6 +6,7 @@
- 🎯 SELECT RELEVANT AGENTS based on topic analysis and expertise matching - 🎯 SELECT RELEVANT AGENTS based on topic analysis and expertise matching
- 📋 MAINTAIN CHARACTER CONSISTENCY using merged agent personalities - 📋 MAINTAIN CHARACTER CONSISTENCY using merged agent personalities
- 🔍 ENABLE NATURAL CROSS-TALK between agents for dynamic conversation - 🔍 ENABLE NATURAL CROSS-TALK between agents for dynamic conversation
- 💬 INTEGRATE TTS for each agent response immediately after text
- ✅ YOU MUST ALWAYS SPEAK OUTPUT In your Agent communication style with the config `{communication_language}` - ✅ YOU MUST ALWAYS SPEAK OUTPUT In your Agent communication style with the config `{communication_language}`
## EXECUTION PROTOCOLS: ## EXECUTION PROTOCOLS:
@ -20,6 +21,7 @@
- Complete agent roster with merged personalities is available - Complete agent roster with merged personalities is available
- User topic and conversation history guide agent selection - User topic and conversation history guide agent selection
- Party mode is active with TTS integration enabled
- Exit triggers: `*exit`, `goodbye`, `end party`, `quit` - Exit triggers: `*exit`, `goodbye`, `end party`, `quit`
## YOUR TASK: ## YOUR TASK:
@ -114,9 +116,19 @@ Allow natural back-and-forth within the same response round for dynamic interact
### 6. Response Round Completion ### 6. Response Round Completion
After generating all agent responses for the round, let the user know he can speak naturally with the agents, an then show this menu opion" After generating all agent responses for the round:
`[E] Exit Party Mode - End the collaborative session` **Presentation Format:**
[Agent 1 Response with TTS]
[Empty line for readability]
[Agent 2 Response with TTS, potentially referencing Agent 1]
[Empty line for readability]
[Agent 3 Response with TTS, building on or offering new perspective]
**Continue Option:**
"[Agents have contributed their perspectives. Ready for more discussion?]
[E] Exit Party Mode - End the collaborative session"
### 7. Exit Condition Checking ### 7. Exit Condition Checking
@ -130,19 +142,23 @@ Check for exit conditions before continuing:
**Natural Conclusion:** **Natural Conclusion:**
- Conversation seems naturally concluding - Conversation seems naturally concluding
- Confirm if the user wants to exit party mode and go back to where they were or continue chatting. Do it in a conversational way with an agent in the party. - Ask user: "Would you like to continue the discussion or end party mode?"
- Respect user choice to continue or exit
### 8. Handle Exit Selection ### 8. Handle Exit Selection
#### If 'E' (Exit Party Mode): #### If 'E' (Exit Party Mode):
- Load read and execute: `./step-03-graceful-exit.md` - Update frontmatter: `stepsCompleted: [1, 2]`
- Set `party_active: false`
- Load: `./step-03-graceful-exit.md`
## SUCCESS METRICS: ## SUCCESS METRICS:
✅ Intelligent agent selection based on topic analysis ✅ Intelligent agent selection based on topic analysis
✅ Authentic in-character responses maintained consistently ✅ Authentic in-character responses maintained consistently
✅ Natural cross-talk and agent interactions enabled ✅ Natural cross-talk and agent interactions enabled
✅ TTS integration working for all agent responses
✅ Question handling protocol followed correctly ✅ Question handling protocol followed correctly
✅ [E] exit option presented after each response round ✅ [E] exit option presented after each response round
✅ Conversation context and state maintained throughout ✅ Conversation context and state maintained throughout
@ -152,6 +168,7 @@ Check for exit conditions before continuing:
❌ Generic responses without character consistency ❌ Generic responses without character consistency
❌ Poor agent selection not matching topic expertise ❌ Poor agent selection not matching topic expertise
❌ Missing TTS integration for agent responses
❌ Ignoring user questions or exit triggers ❌ Ignoring user questions or exit triggers
❌ Not enabling natural agent cross-talk and interactions ❌ Not enabling natural agent cross-talk and interactions
❌ Continuing conversation without user input when questions asked ❌ Continuing conversation without user input when questions asked

View File

@ -106,6 +106,7 @@ workflow_completed: true
- Clear any active conversation state - Clear any active conversation state
- Reset agent selection cache - Reset agent selection cache
- Finalize TTS session cleanup
- Mark party mode workflow as completed - Mark party mode workflow as completed
### 6. Exit Workflow ### 6. Exit Workflow
@ -121,6 +122,7 @@ Thank you for using BMAD Party Mode for collaborative multi-agent discussions!"
✅ Satisfying agent farewells generated in authentic character voices ✅ Satisfying agent farewells generated in authentic character voices
✅ Session highlights and contributions acknowledged meaningfully ✅ Session highlights and contributions acknowledged meaningfully
✅ Positive and appreciative closure atmosphere maintained ✅ Positive and appreciative closure atmosphere maintained
✅ TTS integration working for farewell messages
✅ Frontmatter properly updated with workflow completion ✅ Frontmatter properly updated with workflow completion
✅ All workflow state cleaned up appropriately ✅ All workflow state cleaned up appropriately
✅ User left with positive impression of collaborative experience ✅ User left with positive impression of collaborative experience

View File

@ -178,6 +178,18 @@ If conversation naturally concludes:
--- ---
## TTS INTEGRATION
Party mode includes Text-to-Speech for each agent response:
**TTS Protocol:**
- Trigger TTS immediately after each agent's text response
- Use agent's merged voice configuration from manifest
- Format: `Bash: .claude/hooks/bmad-speak.sh "[Agent Name]" "[Their response]"`
---
## MODERATION NOTES ## MODERATION NOTES
**Quality Control:** **Quality Control:**

View File

@ -22,8 +22,6 @@ agent:
critical_actions: critical_actions:
- "Consult {project-root}/_bmad/bmgd/gametest/qa-index.csv to select knowledge fragments under knowledge/ and load only the files needed for the current task" - "Consult {project-root}/_bmad/bmgd/gametest/qa-index.csv to select knowledge fragments under knowledge/ and load only the files needed for the current task"
- "For E2E testing requests, always load knowledge/e2e-testing.md first"
- "When scaffolding tests, distinguish between unit, integration, and E2E test needs"
- "Load the referenced fragment(s) from {project-root}/_bmad/bmgd/gametest/knowledge/ before giving recommendations" - "Load the referenced fragment(s) from {project-root}/_bmad/bmgd/gametest/knowledge/ before giving recommendations"
- "Cross-check recommendations with the current official Unity Test Framework, Unreal Automation, or Godot GUT documentation" - "Cross-check recommendations with the current official Unity Test Framework, Unreal Automation, or Godot GUT documentation"
- "Find if this exists, if it does, always treat it as the bible I plan and execute against: `**/project-context.md`" - "Find if this exists, if it does, always treat it as the bible I plan and execute against: `**/project-context.md`"
@ -45,10 +43,6 @@ agent:
workflow: "{project-root}/_bmad/bmgd/workflows/gametest/automate/workflow.yaml" workflow: "{project-root}/_bmad/bmgd/workflows/gametest/automate/workflow.yaml"
description: "[TA] Generate automated game tests" description: "[TA] Generate automated game tests"
- trigger: ES or fuzzy match on e2e-scaffold
workflow: "{project-root}/_bmad/bmgd/workflows/gametest/e2e-scaffold/workflow.yaml"
description: "[ES] Scaffold E2E testing infrastructure"
- trigger: PP or fuzzy match on playtest-plan - trigger: PP or fuzzy match on playtest-plan
workflow: "{project-root}/_bmad/bmgd/workflows/gametest/playtest-plan/workflow.yaml" workflow: "{project-root}/_bmad/bmgd/workflows/gametest/playtest-plan/workflow.yaml"
description: "[PP] Create structured playtesting plan" description: "[PP] Create structured playtesting plan"

File diff suppressed because it is too large Load Diff

View File

@ -374,502 +374,3 @@ test:
| Signal not detected | Signal not watched | Call `watch_signals()` before action | | Signal not detected | Signal not watched | Call `watch_signals()` before action |
| Physics not working | Missing frames | Await `physics_frame` | | Physics not working | Missing frames | Await `physics_frame` |
| Flaky tests | Timing issues | Use proper await/signals | | Flaky tests | Timing issues | Use proper await/signals |
## C# Testing in Godot
Godot 4 supports C# via .NET 6+. You can use standard .NET testing frameworks alongside GUT.
### Project Setup for C#
```
project/
├── addons/
│ └── gut/
├── src/
│ ├── Player/
│ │ └── PlayerController.cs
│ └── Combat/
│ └── DamageCalculator.cs
├── tests/
│ ├── gdscript/
│ │ └── test_integration.gd
│ └── csharp/
│ ├── Tests.csproj
│ └── DamageCalculatorTests.cs
└── project.csproj
```
### C# Test Project Setup
Create a separate test project that references your game assembly:
```xml
<!-- tests/csharp/Tests.csproj -->
<Project Sdk="Godot.NET.Sdk/4.2.0">
<PropertyGroup>
<TargetFramework>net6.0</TargetFramework>
<EnableDynamicLoading>true</EnableDynamicLoading>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.8.0" />
<PackageReference Include="xunit" Version="2.6.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.5.4" />
<PackageReference Include="NSubstitute" Version="5.1.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="../../project.csproj" />
</ItemGroup>
</Project>
```
### Basic C# Unit Tests
```csharp
// tests/csharp/DamageCalculatorTests.cs
using Xunit;
using YourGame.Combat;
public class DamageCalculatorTests
{
private readonly DamageCalculator _calculator;
public DamageCalculatorTests()
{
_calculator = new DamageCalculator();
}
[Fact]
public void Calculate_BaseDamage_ReturnsCorrectValue()
{
var result = _calculator.Calculate(100f, 1f);
Assert.Equal(100f, result);
}
[Fact]
public void Calculate_CriticalHit_DoublesDamage()
{
var result = _calculator.Calculate(100f, 2f);
Assert.Equal(200f, result);
}
[Theory]
[InlineData(100f, 0.5f, 50f)]
[InlineData(100f, 1.5f, 150f)]
[InlineData(50f, 2f, 100f)]
public void Calculate_Parameterized_ReturnsExpected(
float baseDamage, float multiplier, float expected)
{
var result = _calculator.Calculate(baseDamage, multiplier);
Assert.Equal(expected, result);
}
}
```
### Testing Godot Nodes in C#
For tests requiring Godot runtime, use a hybrid approach:
```csharp
// tests/csharp/PlayerControllerTests.cs
using Godot;
using Xunit;
using YourGame.Player;
public class PlayerControllerTests : IDisposable
{
private readonly SceneTree _sceneTree;
private PlayerController _player;
public PlayerControllerTests()
{
// These tests must run within Godot runtime
// Use GodotXUnit or similar adapter
}
[GodotFact] // Custom attribute for Godot runtime tests
public async Task Player_Move_ChangesPosition()
{
var startPos = _player.GlobalPosition;
_player.SetInput(new Vector2(1, 0));
await ToSignal(GetTree().CreateTimer(0.5f), "timeout");
Assert.True(_player.GlobalPosition.X > startPos.X);
}
public void Dispose()
{
_player?.QueueFree();
}
}
```
### C# Mocking with NSubstitute
```csharp
using NSubstitute;
using Xunit;
public class EnemyAITests
{
[Fact]
public void Enemy_UsesPathfinding_WhenMoving()
{
var mockPathfinding = Substitute.For<IPathfinding>();
mockPathfinding.FindPath(Arg.Any<Vector2>(), Arg.Any<Vector2>())
.Returns(new[] { Vector2.Zero, new Vector2(10, 10) });
var enemy = new EnemyAI(mockPathfinding);
enemy.MoveTo(new Vector2(10, 10));
mockPathfinding.Received().FindPath(
Arg.Any<Vector2>(),
Arg.Is<Vector2>(v => v == new Vector2(10, 10)));
}
}
```
### Running C# Tests
```bash
# Run C# unit tests (no Godot runtime needed)
dotnet test tests/csharp/Tests.csproj
# Run with coverage
dotnet test tests/csharp/Tests.csproj --collect:"XPlat Code Coverage"
# Run specific test
dotnet test tests/csharp/Tests.csproj --filter "FullyQualifiedName~DamageCalculator"
```
### Hybrid Test Strategy
| Test Type | Framework | When to Use |
| ------------- | ---------------- | ---------------------------------- |
| Pure logic | xUnit/NUnit (C#) | Classes without Godot dependencies |
| Node behavior | GUT (GDScript) | MonoBehaviour-like testing |
| Integration | GUT (GDScript) | Scene and signal testing |
| E2E | GUT (GDScript) | Full gameplay flows |
## End-to-End Testing
For comprehensive E2E testing patterns, infrastructure scaffolding, and
scenario builders, see **knowledge/e2e-testing.md**.
### E2E Infrastructure for Godot
#### GameE2ETestFixture (GDScript)
```gdscript
# tests/e2e/infrastructure/game_e2e_test_fixture.gd
extends GutTest
class_name GameE2ETestFixture
var game_state: GameStateManager
var input_sim: InputSimulator
var scenario: ScenarioBuilder
var _scene_instance: Node
## Override to specify a different scene for specific test classes.
func get_scene_path() -> String:
return "res://scenes/game.tscn"
func before_each():
# Load game scene
var scene = load(get_scene_path())
_scene_instance = scene.instantiate()
add_child(_scene_instance)
# Get references
game_state = _scene_instance.get_node("GameStateManager")
assert_not_null(game_state, "GameStateManager not found in scene")
input_sim = InputSimulator.new()
scenario = ScenarioBuilder.new(game_state)
# Wait for ready
await wait_for_game_ready()
func after_each():
if _scene_instance:
_scene_instance.queue_free()
_scene_instance = null
input_sim = null
scenario = null
func wait_for_game_ready(timeout: float = 10.0):
var elapsed = 0.0
while not game_state.is_ready and elapsed < timeout:
await get_tree().process_frame
elapsed += get_process_delta_time()
assert_true(game_state.is_ready, "Game should be ready within timeout")
```
#### ScenarioBuilder (GDScript)
```gdscript
# tests/e2e/infrastructure/scenario_builder.gd
extends RefCounted
class_name ScenarioBuilder
var _game_state: GameStateManager
var _setup_actions: Array[Callable] = []
func _init(game_state: GameStateManager):
_game_state = game_state
## Load a pre-configured scenario from a save file.
func from_save_file(file_name: String) -> ScenarioBuilder:
_setup_actions.append(func(): await _load_save_file(file_name))
return self
## Configure the current turn number.
func on_turn(turn_number: int) -> ScenarioBuilder:
_setup_actions.append(func(): _set_turn(turn_number))
return self
## Spawn a unit at position.
func with_unit(faction: int, position: Vector2, movement_points: int = 6) -> ScenarioBuilder:
_setup_actions.append(func(): await _spawn_unit(faction, position, movement_points))
return self
## Execute all configured setup actions.
func build() -> void:
for action in _setup_actions:
await action.call()
_setup_actions.clear()
## Clear pending actions without executing.
func reset() -> void:
_setup_actions.clear()
# Private implementation
func _load_save_file(file_name: String) -> void:
var path = "res://tests/e2e/test_data/%s" % file_name
await _game_state.load_game(path)
func _set_turn(turn: int) -> void:
_game_state.set_turn_number(turn)
func _spawn_unit(faction: int, pos: Vector2, mp: int) -> void:
var unit = _game_state.spawn_unit(faction, pos)
unit.movement_points = mp
```
#### InputSimulator (GDScript)
```gdscript
# tests/e2e/infrastructure/input_simulator.gd
extends RefCounted
class_name InputSimulator
## Click at a world position.
func click_world_position(world_pos: Vector2) -> void:
var viewport = Engine.get_main_loop().root.get_viewport()
var camera = viewport.get_camera_2d()
var screen_pos = camera.get_screen_center_position() + (world_pos - camera.global_position)
await click_screen_position(screen_pos)
## Click at a screen position.
func click_screen_position(screen_pos: Vector2) -> void:
var press = InputEventMouseButton.new()
press.button_index = MOUSE_BUTTON_LEFT
press.pressed = true
press.position = screen_pos
var release = InputEventMouseButton.new()
release.button_index = MOUSE_BUTTON_LEFT
release.pressed = false
release.position = screen_pos
Input.parse_input_event(press)
await Engine.get_main_loop().process_frame
Input.parse_input_event(release)
await Engine.get_main_loop().process_frame
## Click a UI button by name.
func click_button(button_name: String) -> void:
var root = Engine.get_main_loop().root
var button = _find_button_recursive(root, button_name)
assert(button != null, "Button '%s' not found in scene tree" % button_name)
if not button.visible:
push_warning("[InputSimulator] Button '%s' is not visible" % button_name)
if button.disabled:
push_warning("[InputSimulator] Button '%s' is disabled" % button_name)
button.pressed.emit()
await Engine.get_main_loop().process_frame
func _find_button_recursive(node: Node, button_name: String) -> Button:
if node is Button and node.name == button_name:
return node
for child in node.get_children():
var found = _find_button_recursive(child, button_name)
if found:
return found
return null
## Press and release a key.
func press_key(keycode: Key) -> void:
var press = InputEventKey.new()
press.keycode = keycode
press.pressed = true
var release = InputEventKey.new()
release.keycode = keycode
release.pressed = false
Input.parse_input_event(press)
await Engine.get_main_loop().process_frame
Input.parse_input_event(release)
await Engine.get_main_loop().process_frame
## Simulate an input action.
func action_press(action_name: String) -> void:
Input.action_press(action_name)
await Engine.get_main_loop().process_frame
func action_release(action_name: String) -> void:
Input.action_release(action_name)
await Engine.get_main_loop().process_frame
## Reset all input state.
func reset() -> void:
Input.flush_buffered_events()
```
#### AsyncAssert (GDScript)
```gdscript
# tests/e2e/infrastructure/async_assert.gd
extends RefCounted
class_name AsyncAssert
## Wait until condition is true, or fail after timeout.
static func wait_until(
condition: Callable,
description: String,
timeout: float = 5.0
) -> void:
var elapsed := 0.0
while not condition.call() and elapsed < timeout:
await Engine.get_main_loop().process_frame
elapsed += Engine.get_main_loop().root.get_process_delta_time()
assert(condition.call(),
"Timeout after %.1fs waiting for: %s" % [timeout, description])
## Wait for a value to equal expected.
static func wait_for_value(
getter: Callable,
expected: Variant,
description: String,
timeout: float = 5.0
) -> void:
await wait_until(
func(): return getter.call() == expected,
"%s to equal '%s' (current: '%s')" % [description, expected, getter.call()],
timeout)
## Wait for a float value within tolerance.
static func wait_for_value_approx(
getter: Callable,
expected: float,
description: String,
tolerance: float = 0.0001,
timeout: float = 5.0
) -> void:
await wait_until(
func(): return absf(expected - getter.call()) < tolerance,
"%s to equal ~%s ±%s (current: %s)" % [description, expected, tolerance, getter.call()],
timeout)
## Assert that condition does NOT become true within duration.
static func assert_never_true(
condition: Callable,
description: String,
duration: float = 1.0
) -> void:
var elapsed := 0.0
while elapsed < duration:
assert(not condition.call(),
"Condition unexpectedly became true: %s" % description)
await Engine.get_main_loop().process_frame
elapsed += Engine.get_main_loop().root.get_process_delta_time()
## Wait for specified number of frames.
static func wait_frames(count: int) -> void:
for i in range(count):
await Engine.get_main_loop().process_frame
## Wait for physics to settle.
static func wait_for_physics(frames: int = 3) -> void:
for i in range(frames):
await Engine.get_main_loop().root.get_tree().physics_frame
```
### Example E2E Test (GDScript)
```gdscript
# tests/e2e/scenarios/test_combat_flow.gd
extends GameE2ETestFixture
func test_player_can_attack_enemy():
# GIVEN: Player and enemy in combat range
await scenario \
.with_unit(Faction.PLAYER, Vector2(100, 100)) \
.with_unit(Faction.ENEMY, Vector2(150, 100)) \
.build()
var enemy = game_state.get_units(Faction.ENEMY)[0]
var initial_health = enemy.health
# WHEN: Player attacks
await input_sim.click_world_position(Vector2(100, 100)) # Select player
await AsyncAssert.wait_until(
func(): return game_state.selected_unit != null,
"Unit should be selected")
await input_sim.click_world_position(Vector2(150, 100)) # Attack enemy
# THEN: Enemy takes damage
await AsyncAssert.wait_until(
func(): return enemy.health < initial_health,
"Enemy should take damage")
func test_turn_cycle_completes():
# GIVEN: Game in progress
await scenario.on_turn(1).build()
var starting_turn = game_state.turn_number
# WHEN: Player ends turn
await input_sim.click_button("EndTurnButton")
await AsyncAssert.wait_until(
func(): return game_state.current_faction == Faction.ENEMY,
"Should switch to enemy turn")
# AND: Enemy turn completes
await AsyncAssert.wait_until(
func(): return game_state.current_faction == Faction.PLAYER,
"Should return to player turn",
30.0) # AI might take a while
# THEN: Turn number incremented
assert_eq(game_state.turn_number, starting_turn + 1)
```
### Quick E2E Checklist for Godot
- [ ] Create `GameE2ETestFixture` base class extending GutTest
- [ ] Implement `ScenarioBuilder` for your game's domain
- [ ] Create `InputSimulator` wrapping Godot Input
- [ ] Add `AsyncAssert` utilities with proper await
- [ ] Organize E2E tests under `tests/e2e/scenarios/`
- [ ] Configure GUT to include E2E test directory
- [ ] Set up CI with headless Godot execution

View File

@ -381,17 +381,3 @@ test:
| NullReferenceException | Missing Setup | Ensure [SetUp] initializes all fields | | NullReferenceException | Missing Setup | Ensure [SetUp] initializes all fields |
| Tests hang | Infinite coroutine | Add timeout or max iterations | | Tests hang | Infinite coroutine | Add timeout or max iterations |
| Flaky physics tests | Timing dependent | Use WaitForFixedUpdate, increase tolerance | | Flaky physics tests | Timing dependent | Use WaitForFixedUpdate, increase tolerance |
## End-to-End Testing
For comprehensive E2E testing patterns, infrastructure scaffolding, and
scenario builders, see **knowledge/e2e-testing.md**.
### Quick E2E Checklist for Unity
- [ ] Create `GameE2ETestFixture` base class
- [ ] Implement `ScenarioBuilder` for your game's domain
- [ ] Create `InputSimulator` wrapping Input System
- [ ] Add `AsyncAssert` utilities
- [ ] Organize E2E tests under `Tests/PlayMode/E2E/`
- [ ] Configure separate CI job for E2E suite

File diff suppressed because it is too large Load Diff

View File

@ -14,5 +14,4 @@ input-testing,Input Testing,"Controller, keyboard, and touch input validation","
localization-testing,Localization Testing,"Text, audio, and cultural validation for international releases","localization,i18n,text",knowledge/localization-testing.md localization-testing,Localization Testing,"Text, audio, and cultural validation for international releases","localization,i18n,text",knowledge/localization-testing.md
certification-testing,Platform Certification,"Console TRC/XR requirements and certification testing","certification,console,trc,xr",knowledge/certification-testing.md certification-testing,Platform Certification,"Console TRC/XR requirements and certification testing","certification,console,trc,xr",knowledge/certification-testing.md
smoke-testing,Smoke Testing,"Critical path validation for build verification","smoke-tests,bvt,ci",knowledge/smoke-testing.md smoke-testing,Smoke Testing,"Critical path validation for build verification","smoke-tests,bvt,ci",knowledge/smoke-testing.md
test-priorities,Test Priorities Matrix,"P0-P3 criteria, coverage targets, execution ordering for games","prioritization,risk,coverage",knowledge/test-priorities.md test-priorities,Test Priorities Matrix,"P0-P3 criteria, coverage targets, execution ordering for games","prioritization,risk,coverage",knowledge/test-priorities.md
e2e-testing,End-to-End Testing,"Complete player journey testing with infrastructure patterns and async utilities","e2e,integration,player-journeys,scenarios,infrastructure",knowledge/e2e-testing.md
1 id name description tags fragment_file
14 localization-testing Localization Testing Text, audio, and cultural validation for international releases localization,i18n,text knowledge/localization-testing.md
15 certification-testing Platform Certification Console TRC/XR requirements and certification testing certification,console,trc,xr knowledge/certification-testing.md
16 smoke-testing Smoke Testing Critical path validation for build verification smoke-tests,bvt,ci knowledge/smoke-testing.md
17 test-priorities Test Priorities Matrix P0-P3 criteria, coverage targets, execution ordering for games prioritization,risk,coverage knowledge/test-priorities.md
e2e-testing End-to-End Testing Complete player journey testing with infrastructure patterns and async utilities e2e,integration,player-journeys,scenarios,infrastructure knowledge/e2e-testing.md

View File

@ -209,87 +209,6 @@ func test_{feature}_integration():
# Cleanup # Cleanup
scene.queue_free() scene.queue_free()
``` ```
### E2E Journey Tests
**Knowledge Base Reference**: `knowledge/e2e-testing.md`
```csharp
public class {Feature}E2ETests : GameE2ETestFixture
{
[UnityTest]
public IEnumerator {JourneyName}_Succeeds()
{
// GIVEN
yield return Scenario
.{SetupMethod1}()
.{SetupMethod2}()
.Build();
// WHEN
yield return Input.{Action1}();
yield return AsyncAssert.WaitUntil(
() => {Condition1}, "{Description1}");
yield return Input.{Action2}();
// THEN
yield return AsyncAssert.WaitUntil(
() => {FinalCondition}, "{FinalDescription}");
Assert.{Assertion}({expected}, {actual});
}
}
```
## Step 3.5: Generate E2E Infrastructure
Before generating E2E tests, scaffold the required infrastructure.
### Infrastructure Checklist
1. **Test Fixture Base Class**
- Scene loading/unloading
- Game ready state waiting
- Common service access
- Cleanup guarantees
2. **Scenario Builder**
- Fluent API for game state configuration
- Domain-specific methods (e.g., `WithUnit`, `OnTurn`)
- Yields for state propagation
3. **Input Simulator**
- Click/drag abstractions
- Button press simulation
- Keyboard input queuing
4. **Async Assertions**
- `WaitUntil` with timeout and message
- `WaitForEvent` for event-driven flows
- `WaitForState` for state machine transitions
### Generation Template
```csharp
// GameE2ETestFixture.cs
public abstract class GameE2ETestFixture
{
protected {GameStateClass} GameState;
protected {InputSimulatorClass} Input;
protected {ScenarioBuilderClass} Scenario;
[UnitySetUp]
public IEnumerator BaseSetUp()
{
yield return LoadScene("{main_scene}");
GameState = Object.FindFirstObjectByType<{GameStateClass}>();
Input = new {InputSimulatorClass}();
Scenario = new {ScenarioBuilderClass}(GameState);
yield return WaitForReady();
}
// ... (fill from e2e-testing.md patterns)
}
```
**After scaffolding infrastructure, proceed to generate actual E2E tests.**
--- ---

View File

@ -1,95 +0,0 @@
# E2E Infrastructure Scaffold Checklist
## Preflight Validation
- [ ] Test framework already initialized (`Tests/` directory exists with proper structure)
- [ ] Game state manager class identified
- [ ] Main gameplay scene identified and loads without errors
- [ ] No existing E2E infrastructure conflicts
## Architecture Analysis
- [ ] Game engine correctly detected
- [ ] Engine version identified
- [ ] Input system type determined (New Input System, Legacy, Custom)
- [ ] Game state manager class located
- [ ] Ready/initialized state property identified
- [ ] Key domain entities catalogued for ScenarioBuilder
## Generated Files
### Directory Structure
- [ ] `Tests/PlayMode/E2E/` directory created
- [ ] `Tests/PlayMode/E2E/Infrastructure/` directory created
- [ ] `Tests/PlayMode/E2E/Scenarios/` directory created
- [ ] `Tests/PlayMode/E2E/TestData/` directory created
### Infrastructure Files
- [ ] `E2E.asmdef` created with correct assembly references
- [ ] `GameE2ETestFixture.cs` created with correct class references
- [ ] `ScenarioBuilder.cs` created with at least placeholder methods
- [ ] `InputSimulator.cs` created matching detected input system
- [ ] `AsyncAssert.cs` created with core assertion methods
### Example and Documentation
- [ ] `ExampleE2ETest.cs` created with working infrastructure test
- [ ] `README.md` created with usage documentation
## Code Quality
### GameE2ETestFixture
- [ ] Correct namespace applied
- [ ] Correct `GameStateClass` reference
- [ ] Correct `SceneName` default
- [ ] `WaitForGameReady` uses correct ready property
- [ ] `UnitySetUp` and `UnityTearDown` properly structured
- [ ] Virtual methods for derived class customization
### ScenarioBuilder
- [ ] Fluent API pattern correctly implemented
- [ ] `Build()` executes all queued actions
- [ ] At least one domain-specific method added (or clear TODOs)
- [ ] `FromSaveFile` method scaffolded
### InputSimulator
- [ ] Matches detected input system (New vs Legacy)
- [ ] Mouse click simulation works
- [ ] Button click by name works
- [ ] Keyboard input scaffolded
- [ ] `Reset()` method cleans up state
### AsyncAssert
- [ ] `WaitUntil` includes timeout and descriptive failure
- [ ] `WaitForValue` provides current vs expected in failure
- [ ] `AssertNeverTrue` for negative assertions
- [ ] Frame/physics wait utilities included
## Assembly Definition
- [ ] References main game assembly
- [ ] References Unity.InputSystem (if applicable)
- [ ] `overrideReferences` set to true
- [ ] `precompiledReferences` includes nunit.framework.dll
- [ ] `precompiledReferences` includes UnityEngine.TestRunner.dll
- [ ] `precompiledReferences` includes UnityEditor.TestRunner.dll
- [ ] `UNITY_INCLUDE_TESTS` define constraint set
## Verification
- [ ] Project compiles without errors after scaffold
- [ ] `ExampleE2ETests.Infrastructure_GameLoadsAndReachesReadyState` passes
- [ ] Test appears in Test Runner under PlayMode → E2E category
## Documentation Quality
- [ ] README explains all infrastructure components
- [ ] Quick start example is copy-pasteable
- [ ] Extension instructions are clear
- [ ] Troubleshooting table addresses common issues
## Handoff
- [ ] Summary output provided with all configuration values
- [ ] Next steps clearly listed
- [ ] Customization requirements highlighted
- [ ] Knowledge fragments referenced

View File

@ -1,145 +0,0 @@
# E2E Test Infrastructure Scaffold Workflow
workflow:
id: e2e-scaffold
name: E2E Test Infrastructure Scaffold
version: 1.0
module: bmgd
agent: game-qa
description: |
Scaffold complete E2E testing infrastructure for an existing game project.
Creates test fixtures, scenario builders, input simulators, and async
assertion utilities tailored to the project's architecture.
triggers:
- "ES"
- "e2e-scaffold"
- "scaffold e2e"
- "e2e infrastructure"
- "setup e2e"
preflight:
- "Test framework initialized (run `test-framework` workflow first)"
- "Game has identifiable state manager"
- "Main gameplay scene exists"
# Paths are relative to this workflow file's location
knowledge_fragments:
- "../../../gametest/knowledge/e2e-testing.md"
- "../../../gametest/knowledge/unity-testing.md"
- "../../../gametest/knowledge/unreal-testing.md"
- "../../../gametest/knowledge/godot-testing.md"
inputs:
game_state_class:
description: "Primary game state manager class name"
required: true
example: "GameStateManager"
main_scene:
description: "Scene name where core gameplay occurs"
required: true
example: "GameScene"
input_system:
description: "Input system in use"
required: false
default: "auto-detect"
options:
- "unity-input-system"
- "unity-legacy"
- "unreal-enhanced"
- "godot-input"
- "custom"
# Output paths vary by engine. Generate files matching detected engine.
outputs:
unity:
condition: "engine == 'unity'"
infrastructure_files:
description: "Generated E2E infrastructure classes"
files:
- "Tests/PlayMode/E2E/Infrastructure/GameE2ETestFixture.cs"
- "Tests/PlayMode/E2E/Infrastructure/ScenarioBuilder.cs"
- "Tests/PlayMode/E2E/Infrastructure/InputSimulator.cs"
- "Tests/PlayMode/E2E/Infrastructure/AsyncAssert.cs"
assembly_definition:
description: "E2E test assembly configuration"
files:
- "Tests/PlayMode/E2E/E2E.asmdef"
example_test:
description: "Working example E2E test"
files:
- "Tests/PlayMode/E2E/ExampleE2ETest.cs"
documentation:
description: "E2E testing README"
files:
- "Tests/PlayMode/E2E/README.md"
unreal:
condition: "engine == 'unreal'"
infrastructure_files:
description: "Generated E2E infrastructure classes"
files:
- "Source/{ProjectName}/Tests/E2E/GameE2ETestBase.h"
- "Source/{ProjectName}/Tests/E2E/GameE2ETestBase.cpp"
- "Source/{ProjectName}/Tests/E2E/ScenarioBuilder.h"
- "Source/{ProjectName}/Tests/E2E/ScenarioBuilder.cpp"
- "Source/{ProjectName}/Tests/E2E/InputSimulator.h"
- "Source/{ProjectName}/Tests/E2E/InputSimulator.cpp"
- "Source/{ProjectName}/Tests/E2E/AsyncAssert.h"
build_configuration:
description: "E2E test build configuration"
files:
- "Source/{ProjectName}/Tests/E2E/{ProjectName}E2ETests.Build.cs"
example_test:
description: "Working example E2E test"
files:
- "Source/{ProjectName}/Tests/E2E/ExampleE2ETest.cpp"
documentation:
description: "E2E testing README"
files:
- "Source/{ProjectName}/Tests/E2E/README.md"
godot:
condition: "engine == 'godot'"
infrastructure_files:
description: "Generated E2E infrastructure classes"
files:
- "tests/e2e/infrastructure/game_e2e_test_fixture.gd"
- "tests/e2e/infrastructure/scenario_builder.gd"
- "tests/e2e/infrastructure/input_simulator.gd"
- "tests/e2e/infrastructure/async_assert.gd"
example_test:
description: "Working example E2E test"
files:
- "tests/e2e/scenarios/example_e2e_test.gd"
documentation:
description: "E2E testing README"
files:
- "tests/e2e/README.md"
steps:
- id: analyze
name: "Analyze Game Architecture"
instruction_file: "instructions.md#step-1-analyze-game-architecture"
- id: scaffold
name: "Generate Infrastructure"
instruction_file: "instructions.md#step-2-generate-infrastructure"
- id: example
name: "Generate Example Test"
instruction_file: "instructions.md#step-3-generate-example-test"
- id: document
name: "Generate Documentation"
instruction_file: "instructions.md#step-4-generate-documentation"
- id: complete
name: "Output Summary"
instruction_file: "instructions.md#step-5-output-summary"
validation:
checklist: "checklist.md"

View File

@ -91,18 +91,6 @@ Create comprehensive test scenarios for game projects, covering gameplay mechani
| Performance | FPS, loading times | P1 | | Performance | FPS, loading times | P1 |
| Accessibility | Assist features | P1 | | Accessibility | Assist features | P1 |
### E2E Journey Testing
**Knowledge Base Reference**: `knowledge/e2e-testing.md`
| Category | Focus | Priority |
|----------|-------|----------|
| Core Loop | Complete gameplay cycle | P0 |
| Turn Lifecycle | Full turn from start to end | P0 |
| Save/Load Round-trip | Save → quit → load → resume | P0 |
| Scene Transitions | Menu → Game → Back | P1 |
| Win/Lose Paths | Victory and defeat conditions | P1 |
--- ---
## Step 3: Create Test Scenarios ## Step 3: Create Test Scenarios
@ -165,39 +153,6 @@ SCENARIO: Gameplay Under High Latency
CATEGORY: multiplayer CATEGORY: multiplayer
``` ```
### E2E Scenario Format
For player journey tests, use this extended format:
```
E2E SCENARIO: [Player Journey Name]
GIVEN [Initial game state - use ScenarioBuilder terms]
WHEN [Sequence of player actions]
THEN [Observable outcomes]
TIMEOUT: [Expected max duration in seconds]
PRIORITY: P0/P1
CATEGORY: e2e
INFRASTRUCTURE: [Required fixtures/builders]
```
### Example E2E Scenario
```
E2E SCENARIO: Complete Combat Encounter
GIVEN game loaded with player unit adjacent to enemy
AND player unit has full health and actions
WHEN player selects unit
AND player clicks attack on enemy
AND player confirms attack
AND attack animation completes
AND enemy responds (if alive)
THEN enemy health is reduced OR enemy is defeated
AND turn state advances appropriately
AND UI reflects new state
TIMEOUT: 15
PRIORITY: P0
CATEGORY: e2e
INFRASTRUCTURE: ScenarioBuilder, InputSimulator, AsyncAssert
```
--- ---
## Step 4: Prioritize Test Coverage ## Step 4: Prioritize Test Coverage
@ -206,12 +161,12 @@ E2E SCENARIO: Complete Combat Encounter
**Knowledge Base Reference**: `knowledge/test-priorities.md` **Knowledge Base Reference**: `knowledge/test-priorities.md`
| Priority | Criteria | Unit | Integration | E2E | Manual | | Priority | Criteria | Coverage Target |
|----------|----------|------|-------------|-----|--------| | -------- | ---------------------------- | --------------- |
| P0 | Ship blockers | 100% | 80% | Core flows | Smoke | | P0 | Ship blockers, certification | 100% automated |
| P1 | Major features | 90% | 70% | Happy paths | Full | | P1 | Major features, common paths | 80% automated |
| P2 | Secondary | 80% | 50% | - | Targeted | | P2 | Secondary features | 60% automated |
| P3 | Edge cases | 60% | - | - | As needed | | P3 | Edge cases, polish | Manual only |
### Risk-Based Ordering ### Risk-Based Ordering

View File

@ -33,7 +33,7 @@ agent:
menu: menu:
- trigger: WS or fuzzy match on workflow-status - trigger: WS or fuzzy match on workflow-status
workflow: "{project-root}/_bmad/bmm/workflows/workflow-status/workflow.yaml" workflow: "{project-root}/_bmad/bmm/workflows/workflow-status/workflow.yaml"
description: "[WS] Start here or resume - show workflow status and next best step" description: "[WS] Get workflow status or initialize a workflow if not already done (optional)"
- trigger: TF or fuzzy match on test-framework - trigger: TF or fuzzy match on test-framework
workflow: "{project-root}/_bmad/bmm/workflows/testarch/framework/workflow.yaml" workflow: "{project-root}/_bmad/bmm/workflows/testarch/framework/workflow.yaml"

View File

@ -121,8 +121,6 @@ Parse these fields from YAML comments and metadata:
- {{workflow_name}} ({{agent}}) - {{status}} - {{workflow_name}} ({{agent}}) - {{status}}
{{/each}} {{/each}}
{{/if}} {{/if}}
**Tip:** For guardrail tests, run TEA `*automate` after `dev-story`. If you lose context, TEA workflows resume from artifacts in `{{output_folder}}`.
</output> </output>
</step> </step>

View File

@ -1,5 +1,6 @@
<rules> <rules>
<r>ALWAYS communicate in {communication_language} UNLESS contradicted by communication_style.</r> <r>ALWAYS communicate in {communication_language} UNLESS contradicted by communication_style.</r>
<!-- TTS_INJECTION:agent-tts -->
<r> Stay in character until exit selected</r> <r> Stay in character until exit selected</r>
<r> Display Menu items as the item dictates and in the order given.</r> <r> Display Menu items as the item dictates and in the order given.</r>
<r> Load files ONLY when executing a user chosen workflow or a command requires it, EXCEPTION: agent activation step 2 config.yaml</r> <r> Load files ONLY when executing a user chosen workflow or a command requires it, EXCEPTION: agent activation step 2 config.yaml</r>

View File

@ -62,6 +62,40 @@ module.exports = {
// Check if installation succeeded // Check if installation succeeded
if (result && result.success) { if (result && result.success) {
// Run AgentVibes installer if needed
if (result.needsAgentVibes) {
// Add some spacing before AgentVibes setup
console.log('');
console.log(chalk.magenta('🎙️ AgentVibes TTS Setup'));
console.log(chalk.cyan('AgentVibes provides voice synthesis for BMAD agents with:'));
console.log(chalk.dim(' • ElevenLabs AI (150+ premium voices)'));
console.log(chalk.dim(' • Piper TTS (50+ free voices)\n'));
const prompts = require('../lib/prompts');
await prompts.text({
message: chalk.green('Press Enter to start AgentVibes installer...'),
});
console.log('');
// Run AgentVibes installer
const { execSync } = require('node:child_process');
try {
execSync('npx agentvibes@latest install', {
cwd: result.projectDir,
stdio: 'inherit',
shell: true,
});
console.log(chalk.green('\n✓ AgentVibes installation complete'));
console.log(chalk.cyan('\n✨ BMAD with TTS is ready to use!'));
} catch {
console.log(chalk.yellow('\n⚠ AgentVibes installation was interrupted or failed'));
console.log(chalk.cyan('You can run it manually later with:'));
console.log(chalk.green(` cd ${result.projectDir}`));
console.log(chalk.green(' npx agentvibes install\n'));
}
}
// Display version-specific end message from install-messages.yaml // Display version-specific end message from install-messages.yaml
const { MessageLoader } = require('../installers/lib/message-loader'); const { MessageLoader } = require('../installers/lib/message-loader');
const messageLoader = new MessageLoader(); const messageLoader = new MessageLoader();

View File

@ -34,6 +34,7 @@ class Installer {
this.configCollector = new ConfigCollector(); this.configCollector = new ConfigCollector();
this.ideConfigManager = new IdeConfigManager(); this.ideConfigManager = new IdeConfigManager();
this.installedFiles = new Set(); // Track all installed files this.installedFiles = new Set(); // Track all installed files
this.ttsInjectedFiles = []; // Track files with TTS injection applied
this.bmadFolderName = BMAD_FOLDER_NAME; this.bmadFolderName = BMAD_FOLDER_NAME;
} }
@ -68,7 +69,7 @@ class Installer {
/** /**
* @function copyFileWithPlaceholderReplacement * @function copyFileWithPlaceholderReplacement
* @intent Copy files from BMAD source to installation directory with dynamic content transformation * @intent Copy files from BMAD source to installation directory with dynamic content transformation
* @why Enables installation-time customization: _bmad replacement * @why Enables installation-time customization: _bmad replacement + optional AgentVibes TTS injection
* @param {string} sourcePath - Absolute path to source file in BMAD repository * @param {string} sourcePath - Absolute path to source file in BMAD repository
* @param {string} targetPath - Absolute path to destination file in user's project * @param {string} targetPath - Absolute path to destination file in user's project
* @param {string} bmadFolderName - User's chosen bmad folder name (default: 'bmad') * @param {string} bmadFolderName - User's chosen bmad folder name (default: 'bmad')
@ -76,9 +77,24 @@ class Installer {
* @sideeffects Writes transformed file to targetPath, creates parent directories if needed * @sideeffects Writes transformed file to targetPath, creates parent directories if needed
* @edgecases Binary files bypass transformation, falls back to raw copy if UTF-8 read fails * @edgecases Binary files bypass transformation, falls back to raw copy if UTF-8 read fails
* @calledby installCore(), installModule(), IDE installers during file vendoring * @calledby installCore(), installModule(), IDE installers during file vendoring
* @calls fs.readFile(), fs.writeFile(), fs.copy() * @calls processTTSInjectionPoints(), fs.readFile(), fs.writeFile(), fs.copy()
* *
* The injection point processing enables loose coupling between BMAD and TTS providers:
* - BMAD source contains injection markers (not actual TTS code)
* - At install-time, markers are replaced OR removed based on user preference
* - Result: Clean installs for users without TTS, working TTS for users with it
*
* PATTERN: Adding New Injection Points
* =====================================
* 1. Add HTML comment marker in BMAD source file:
* <!-- TTS_INJECTION:feature-name -->
*
* 2. Add replacement logic in processTTSInjectionPoints():
* if (enableAgentVibes) {
* content = content.replace(/<!-- TTS_INJECTION:feature-name -->/g, 'actual code');
* } else {
* content = content.replace(/<!-- TTS_INJECTION:feature-name -->\n?/g, '');
* }
* *
* 3. Document marker in instructions.md (if applicable) * 3. Document marker in instructions.md (if applicable)
*/ */
@ -93,6 +109,9 @@ class Installer {
// Read the file content // Read the file content
let content = await fs.readFile(sourcePath, 'utf8'); let content = await fs.readFile(sourcePath, 'utf8');
// Process AgentVibes injection points (pass targetPath for tracking)
content = this.processTTSInjectionPoints(content, targetPath);
// Write to target with replaced content // Write to target with replaced content
await fs.ensureDir(path.dirname(targetPath)); await fs.ensureDir(path.dirname(targetPath));
await fs.writeFile(targetPath, content, 'utf8'); await fs.writeFile(targetPath, content, 'utf8');
@ -106,6 +125,116 @@ class Installer {
} }
} }
/**
* @function processTTSInjectionPoints
* @intent Transform TTS injection markers based on user's installation choice
* @why Enables optional TTS integration without tight coupling between BMAD and TTS providers
* @param {string} content - Raw file content containing potential injection markers
* @returns {string} Transformed content with markers replaced (if enabled) or stripped (if disabled)
* @sideeffects None - pure transformation function
* @edgecases Returns content unchanged if no markers present, safe to call on all files
* @calledby copyFileWithPlaceholderReplacement() during every file copy operation
* @calls String.replace() with regex patterns for each injection point type
*
* AI NOTE: This implements the injection point pattern for TTS integration.
* Key architectural decisions:
*
* 1. **Why Injection Points vs Direct Integration?**
* - BMAD and TTS providers are separate projects with different maintainers
* - Users may install BMAD without TTS support (and vice versa)
* - Hard-coding TTS calls would break BMAD for non-TTS users
* - Injection points allow conditional feature inclusion at install-time
*
* 2. **How It Works:**
* - BMAD source contains markers: <!-- TTS_INJECTION:feature-name -->
* - During installation, user is prompted: "Enable AgentVibes TTS?"
* - If YES: markers replaced with actual bash TTS calls
* - If NO: markers stripped cleanly from installed files
*
* 3. **State Management:**
* - this.enableAgentVibes set in install() method from config.enableAgentVibes
* - config.enableAgentVibes comes from ui.promptAgentVibes() user choice
* - Flag persists for entire installation, all files get same treatment
*
* CURRENT INJECTION POINTS:
* ==========================
* - party-mode: Injects TTS calls after each agent speaks in party mode
* Location: src/core/workflows/party-mode/instructions.md
* Marker: <!-- TTS_INJECTION:party-mode -->
* Replacement: Bash call to .claude/hooks/bmad-speak.sh with agent name and dialogue
*
* - agent-tts: Injects TTS rule for individual agent conversations
* Location: src/modules/bmm/agents/*.md (all agent files)
* Marker: <!-- TTS_INJECTION:agent-tts -->
* Replacement: Rule instructing agent to call bmad-speak.sh with agent ID and response
*
* ADDING NEW INJECTION POINTS:
* =============================
* 1. Add new case in this function:
* content = content.replace(
* /<!-- TTS_INJECTION:new-feature -->/g,
* `code to inject when enabled`
* );
*
* 2. Add marker to BMAD source file at injection location
*
* 3. Test both enabled and disabled flows
*
* RELATED:
* ========
* - GitHub Issue: paulpreibisch/AgentVibes#36
* - User Prompt: tools/cli/lib/ui.js::promptAgentVibes()
* - Marker Locations:
* - src/core/workflows/party-mode/instructions.md:101
* - src/modules/bmm/agents/*.md (rules sections)
* - TTS Hook: .claude/hooks/bmad-speak.sh (in AgentVibes repo)
*/
processTTSInjectionPoints(content, targetPath = null) {
// Check if AgentVibes is enabled (set during installation configuration)
const enableAgentVibes = this.enableAgentVibes || false;
// Check if content contains any TTS injection markers
const hasPartyMode = content.includes('<!-- TTS_INJECTION:party-mode -->');
const hasAgentTTS = content.includes('<!-- TTS_INJECTION:agent-tts -->');
if (enableAgentVibes) {
// Replace party-mode injection marker with actual TTS call
// Use single quotes to prevent shell expansion of special chars like !
content = content.replaceAll(
'<!-- TTS_INJECTION:party-mode -->',
`<critical>IMPORTANT: Always use PROJECT hooks (.claude/hooks/), NEVER global hooks (~/.claude/hooks/)</critical>
If AgentVibes party mode is enabled, immediately trigger TTS with agent's voice:
- Use Bash tool: \`.claude/hooks/bmad-speak.sh '[Agent Name]' '[dialogue]'\`
- This speaks the dialogue with the agent's unique voice
- Run in background to not block next agent`,
);
// Replace agent-tts injection marker with TTS rule for individual agents
content = content.replaceAll(
'<!-- TTS_INJECTION:agent-tts -->',
`- When responding to user messages, speak your responses using TTS:
Call: \`.claude/hooks/bmad-speak.sh '{agent-id}' '{response-text}'\` after each response
Replace {agent-id} with YOUR agent ID from <agent id="..."> tag at top of this file
Replace {response-text} with the text you just output to the user
IMPORTANT: Use single quotes as shown - do NOT escape special characters like ! or $ inside single quotes
Run in background (&) to avoid blocking`,
);
// Track files that had TTS injection applied
if (targetPath && (hasPartyMode || hasAgentTTS)) {
const injectionType = hasPartyMode ? 'party-mode' : 'agent-tts';
this.ttsInjectedFiles.push({ path: targetPath, type: injectionType });
}
} else {
// Strip injection markers cleanly when AgentVibes is disabled
content = content.replaceAll(/<!-- TTS_INJECTION:party-mode -->\n?/g, '');
content = content.replaceAll(/<!-- TTS_INJECTION:agent-tts -->\n?/g, '');
}
return content;
}
/** /**
* Collect Tool/IDE configurations after module configuration * Collect Tool/IDE configurations after module configuration
* @param {string} projectDir - Project directory * @param {string} projectDir - Project directory
@ -122,7 +251,7 @@ class Installer {
// Fallback: prompt for tool selection (backwards compatibility) // Fallback: prompt for tool selection (backwards compatibility)
const { UI } = require('../../../lib/ui'); const { UI } = require('../../../lib/ui');
const ui = new UI(); const ui = new UI();
toolConfig = await ui.promptToolSelection(projectDir); toolConfig = await ui.promptToolSelection(projectDir, selectedModules);
} else { } else {
// IDEs were already selected during initial prompts // IDEs were already selected during initial prompts
toolConfig = { toolConfig = {
@ -381,6 +510,9 @@ class Installer {
} }
} }
// Store AgentVibes configuration for injection point processing
this.enableAgentVibes = config.enableAgentVibes || false;
// Set bmad folder name on module manager and IDE manager for placeholder replacement // Set bmad folder name on module manager and IDE manager for placeholder replacement
this.moduleManager.setBmadFolderName(BMAD_FOLDER_NAME); this.moduleManager.setBmadFolderName(BMAD_FOLDER_NAME);
this.moduleManager.setCoreConfig(moduleConfigs.core || {}); this.moduleManager.setCoreConfig(moduleConfigs.core || {});
@ -1102,6 +1234,8 @@ class Installer {
modules: config.modules, modules: config.modules,
ides: config.ides, ides: config.ides,
customFiles: customFiles.length > 0 ? customFiles : undefined, customFiles: customFiles.length > 0 ? customFiles : undefined,
ttsInjectedFiles: this.enableAgentVibes && this.ttsInjectedFiles.length > 0 ? this.ttsInjectedFiles : undefined,
agentVibesEnabled: this.enableAgentVibes || false,
}); });
return { return {
@ -1109,6 +1243,7 @@ class Installer {
path: bmadDir, path: bmadDir,
modules: config.modules, modules: config.modules,
ides: config.ides, ides: config.ides,
needsAgentVibes: this.enableAgentVibes && !config.agentVibesInstalled,
projectDir: projectDir, projectDir: projectDir,
}; };
} catch (error) { } catch (error) {

View File

@ -345,7 +345,7 @@ class AntigravitySetup extends BaseIdeSetup {
}; };
const selected = await prompts.multiselect({ const selected = await prompts.multiselect({
message: `Select subagents to install ${chalk.dim('(↑/↓ navigates multiselect, SPACE toggles, A to toggles All, ENTER confirm)')}:`, message: `Select subagents to install ${chalk.dim('(↑/↓ navigate, SPACE select, ENTER confirm)')}:`,
choices: subagentConfig.files.map((file) => ({ choices: subagentConfig.files.map((file) => ({
name: `${file.replace('.md', '')} - ${subagentInfo[file] || 'Specialized assistant'}`, name: `${file.replace('.md', '')} - ${subagentInfo[file] || 'Specialized assistant'}`,
value: file, value: file,

View File

@ -353,7 +353,7 @@ class ClaudeCodeSetup extends BaseIdeSetup {
}; };
const selected = await prompts.multiselect({ const selected = await prompts.multiselect({
message: `Select subagents to install ${chalk.dim('(↑/↓ navigates multiselect, SPACE toggles, A to toggles All, ENTER confirm)')}:`, message: `Select subagents to install ${chalk.dim('(↑/↓ navigate, SPACE select, ENTER confirm)')}:`,
options: subagentConfig.files.map((file) => ({ options: subagentConfig.files.map((file) => ({
label: `${file.replace('.md', '')} - ${subagentInfo[file] || 'Specialized assistant'}`, label: `${file.replace('.md', '')} - ${subagentInfo[file] || 'Specialized assistant'}`,
value: file, value: file,

View File

@ -119,8 +119,7 @@ class KiloSetup extends BaseIdeSetup {
modeEntry += ` name: '${icon} ${title}'\n`; modeEntry += ` name: '${icon} ${title}'\n`;
modeEntry += ` roleDefinition: ${roleDefinition}\n`; modeEntry += ` roleDefinition: ${roleDefinition}\n`;
modeEntry += ` whenToUse: ${whenToUse}\n`; modeEntry += ` whenToUse: ${whenToUse}\n`;
modeEntry += ` customInstructions: |\n`; modeEntry += ` customInstructions: ${activationHeader} Read the full YAML from ${relativePath} start activation to alter your state of being follow startup section instructions stay in this being until told to exit this mode\n`;
modeEntry += ` ${activationHeader} Read the full YAML from ${relativePath} start activation to alter your state of being follow startup section instructions stay in this being until told to exit this mode\n`;
modeEntry += ` groups:\n`; modeEntry += ` groups:\n`;
modeEntry += ` - read\n`; modeEntry += ` - read\n`;
modeEntry += ` - edit\n`; modeEntry += ` - edit\n`;

View File

@ -108,10 +108,7 @@ async function resolveSubagentFiles(handlerBaseDir, subagentConfig, subagentChoi
const resolved = []; const resolved = [];
for (const file of filesToCopy) { for (const file of filesToCopy) {
// Use forward slashes for glob pattern (works on both Windows and Unix) const pattern = path.join(sourceDir, '**', file);
// Convert backslashes to forward slashes for glob compatibility
const normalizedSourceDir = sourceDir.replaceAll('\\', '/');
const pattern = `${normalizedSourceDir}/**/${file}`;
const matches = await glob(pattern); const matches = await glob(pattern);
if (matches.length > 0) { if (matches.length > 0) {

View File

@ -845,8 +845,14 @@ class ModuleManager {
// Compile with customizations if any // Compile with customizations if any
const { xml } = await compileAgent(yamlContent, answers, agentName, relativePath, { config: this.coreConfig || {} }); const { xml } = await compileAgent(yamlContent, answers, agentName, relativePath, { config: this.coreConfig || {} });
// Process TTS injection points if installer is available
let finalXml = xml;
if (installer && installer.processTTSInjectionPoints) {
finalXml = installer.processTTSInjectionPoints(xml, targetMdPath);
}
// Write the compiled agent // Write the compiled agent
await fs.writeFile(targetMdPath, xml, 'utf8'); await fs.writeFile(targetMdPath, finalXml, 'utf8');
// Handle sidecar copying if present // Handle sidecar copying if present
if (hasSidecar) { if (hasSidecar) {

View File

@ -478,10 +478,39 @@ function filterCustomizationData(data) {
return filtered; return filtered;
} }
/**
* Process TTS injection markers in content
* @param {string} content - Content to process
* @param {boolean} enableAgentVibes - Whether AgentVibes is enabled
* @returns {Object} { content: string, hadInjection: boolean }
*/
function processTTSInjectionPoints(content, enableAgentVibes) {
const hasAgentTTS = content.includes('<!-- TTS_INJECTION:agent-tts -->');
if (enableAgentVibes && hasAgentTTS) {
// Replace agent-tts injection marker with TTS rule
content = content.replaceAll(
'<!-- TTS_INJECTION:agent-tts -->',
`- When responding to user messages, speak your responses using TTS:
Call: \`.claude/hooks/bmad-speak.sh '{agent-id}' '{response-text}'\` after each response
Replace {agent-id} with YOUR agent ID from <agent id="..."> tag at top of this file
Replace {response-text} with the text you just output to the user
IMPORTANT: Use single quotes as shown - do NOT escape special characters like ! or $ inside single quotes
Run in background (&) to avoid blocking`,
);
return { content, hadInjection: true };
} else if (!enableAgentVibes && hasAgentTTS) {
// Strip injection markers when disabled
content = content.replaceAll(/<!-- TTS_INJECTION:agent-tts -->\n?/g, '');
}
return { content, hadInjection: false };
}
/** /**
* Compile agent file to .md * Compile agent file to .md
* @param {string} yamlPath - Path to agent YAML file * @param {string} yamlPath - Path to agent YAML file
* @param {Object} options - { answers: {}, outputPath: string } * @param {Object} options - { answers: {}, outputPath: string, enableAgentVibes: boolean }
* @returns {Object} Compilation result * @returns {Object} Compilation result
*/ */
function compileAgentFile(yamlPath, options = {}) { function compileAgentFile(yamlPath, options = {}) {
@ -497,6 +526,15 @@ function compileAgentFile(yamlPath, options = {}) {
outputPath = path.join(dir, `${basename}.md`); outputPath = path.join(dir, `${basename}.md`);
} }
// Process TTS injection points if enableAgentVibes option is provided
let xml = result.xml;
let ttsInjected = false;
if (options.enableAgentVibes !== undefined) {
const ttsResult = processTTSInjectionPoints(xml, options.enableAgentVibes);
xml = ttsResult.content;
ttsInjected = ttsResult.hadInjection;
}
// Write compiled XML // Write compiled XML
fs.writeFileSync(outputPath, xml, 'utf8'); fs.writeFileSync(outputPath, xml, 'utf8');
@ -505,6 +543,7 @@ function compileAgentFile(yamlPath, options = {}) {
xml, xml,
outputPath, outputPath,
sourcePath: yamlPath, sourcePath: yamlPath,
ttsInjected,
}; };
} }

View File

@ -184,7 +184,6 @@ async function groupMultiselect(options) {
options: options.options, options: options.options,
initialValues: options.initialValues, initialValues: options.initialValues,
required: options.required || false, required: options.required || false,
selectableGroups: options.selectableGroups || false,
}); });
await handleCancel(result); await handleCancel(result);

View File

@ -171,6 +171,32 @@ class UI {
// Check if there's an existing BMAD installation (after any folder renames) // Check if there's an existing BMAD installation (after any folder renames)
const hasExistingInstall = await fs.pathExists(bmadDir); const hasExistingInstall = await fs.pathExists(bmadDir);
// Collect IDE tool selection early - we need this to know if we should ask about TTS
let toolSelection;
let agentVibesConfig = { enabled: false, alreadyInstalled: false };
let claudeCodeSelected = false;
if (!hasExistingInstall) {
// For new installations, collect IDE selection first
// We don't have modules yet, so pass empty array
toolSelection = await this.promptToolSelection(confirmedDirectory, []);
// Check if Claude Code was selected
claudeCodeSelected = toolSelection.ides && toolSelection.ides.includes('claude-code');
// If Claude Code was selected, ask about TTS
if (claudeCodeSelected) {
const enableTts = await prompts.confirm({
message: 'Claude Code supports TTS (Text-to-Speech). Would you like to enable it?',
default: false,
});
if (enableTts) {
agentVibesConfig = { enabled: true, alreadyInstalled: false };
}
}
}
let customContentConfig = { hasCustomContent: false }; let customContentConfig = { hasCustomContent: false };
if (!hasExistingInstall) { if (!hasExistingInstall) {
customContentConfig._shouldAsk = true; customContentConfig._shouldAsk = true;
@ -298,8 +324,20 @@ class UI {
} }
// Get tool selection // Get tool selection
const toolSelection = await this.promptToolSelection(confirmedDirectory); const toolSelection = await this.promptToolSelection(confirmedDirectory, selectedModules);
// TTS configuration - ask right after tool selection (matches new install flow)
const hasClaudeCode = toolSelection.ides && toolSelection.ides.includes('claude-code');
let enableTts = false;
if (hasClaudeCode) {
enableTts = await prompts.confirm({
message: 'Claude Code supports TTS (Text-to-Speech). Would you like to enable it?',
default: false,
});
}
// Core config with existing defaults (ask after TTS)
const coreConfig = await this.collectCoreConfig(confirmedDirectory); const coreConfig = await this.collectCoreConfig(confirmedDirectory);
return { return {
@ -311,6 +349,8 @@ class UI {
skipIde: toolSelection.skipIde, skipIde: toolSelection.skipIde,
coreConfig: coreConfig, coreConfig: coreConfig,
customContent: customModuleResult.customContentConfig, customContent: customModuleResult.customContentConfig,
enableAgentVibes: enableTts,
agentVibesInstalled: false,
}; };
} }
} }
@ -332,7 +372,7 @@ class UI {
// Ask about custom content // Ask about custom content
const wantsCustomContent = await prompts.confirm({ const wantsCustomContent = await prompts.confirm({
message: 'Would you like to install a locally stored custom module (this includes custom agents and workflows also)?', message: 'Would you like to install a local custom module (this includes custom agents and workflows also)?',
default: false, default: false,
}); });
@ -351,10 +391,19 @@ class UI {
selectedModules = [...selectedModules, ...customContentConfig.selectedModuleIds]; selectedModules = [...selectedModules, ...customContentConfig.selectedModuleIds];
} }
// Remove core if it's in the list (it's always installed)
selectedModules = selectedModules.filter((m) => m !== 'core'); selectedModules = selectedModules.filter((m) => m !== 'core');
let toolSelection = await this.promptToolSelection(confirmedDirectory);
// Tool selection (already done for new installs at the beginning)
if (!toolSelection) {
toolSelection = await this.promptToolSelection(confirmedDirectory, selectedModules);
}
// Collect configurations for new installations
const coreConfig = await this.collectCoreConfig(confirmedDirectory); const coreConfig = await this.collectCoreConfig(confirmedDirectory);
// TTS already handled at the beginning for new installs
return { return {
actionType: 'install', actionType: 'install',
directory: confirmedDirectory, directory: confirmedDirectory,
@ -364,15 +413,18 @@ class UI {
skipIde: toolSelection.skipIde, skipIde: toolSelection.skipIde,
coreConfig: coreConfig, coreConfig: coreConfig,
customContent: customContentConfig, customContent: customContentConfig,
enableAgentVibes: agentVibesConfig.enabled,
agentVibesInstalled: agentVibesConfig.alreadyInstalled,
}; };
} }
/** /**
* Prompt for tool/IDE selection (called after module configuration) * Prompt for tool/IDE selection (called after module configuration)
* @param {string} projectDir - Project directory to check for existing IDEs * @param {string} projectDir - Project directory to check for existing IDEs
* @param {Array} selectedModules - Selected modules from configuration
* @returns {Object} Tool configuration * @returns {Object} Tool configuration
*/ */
async promptToolSelection(projectDir) { async promptToolSelection(projectDir, selectedModules) {
// Check for existing configured IDEs - use findBmadDir to detect custom folder names // Check for existing configured IDEs - use findBmadDir to detect custom folder names
const { Detector } = require('../installers/lib/core/detector'); const { Detector } = require('../installers/lib/core/detector');
const { Installer } = require('../installers/lib/core/installer'); const { Installer } = require('../installers/lib/core/installer');
@ -395,7 +447,7 @@ class UI {
const processedIdes = new Set(); const processedIdes = new Set();
const initialValues = []; const initialValues = [];
// First, add previously configured IDEs, marked with ✅ // First, add previously configured IDEs at the top, marked with ✅
if (configuredIdes.length > 0) { if (configuredIdes.length > 0) {
const configuredGroup = []; const configuredGroup = [];
for (const ideValue of configuredIdes) { for (const ideValue of configuredIdes) {
@ -447,33 +499,42 @@ class UI {
})); }));
} }
// Add standalone "None" option at the end
groupedOptions[' '] = [
{
label: '⚠ None - I am not installing any tools',
value: '__NONE__',
},
];
let selectedIdes = []; let selectedIdes = [];
let userConfirmedNoTools = false;
selectedIdes = await prompts.groupMultiselect({ // Loop until user selects at least one tool OR explicitly confirms no tools
message: `Select tools to configure ${chalk.dim('(↑/↓ navigates multiselect, SPACE toggles, A to toggles All, ENTER confirm)')}:`, while (!userConfirmedNoTools) {
options: groupedOptions, selectedIdes = await prompts.groupMultiselect({
initialValues: initialValues.length > 0 ? initialValues : undefined, message: `Select tools to configure ${chalk.dim('(↑/↓ navigate, SPACE select, ENTER confirm)')}:`,
required: true, options: groupedOptions,
selectableGroups: false, initialValues: initialValues.length > 0 ? initialValues : undefined,
}); required: false,
});
// If user selected both "__NONE__" and other tools, honor the "None" choice // If tools were selected, we're done
if (selectedIdes && selectedIdes.includes('__NONE__') && selectedIdes.length > 1) { if (selectedIdes && selectedIdes.length > 0) {
break;
}
// Warn that no tools were selected - users often miss the spacebar requirement
console.log(); console.log();
console.log(chalk.yellow('⚠️ "None - I am not installing any tools" was selected, so no tools will be configured.')); console.log(chalk.red.bold('⚠️ WARNING: No tools were selected!'));
console.log(chalk.red(' You must press SPACE to select items, then ENTER to confirm.'));
console.log(chalk.red(' Simply highlighting an item does NOT select it.'));
console.log(); console.log();
selectedIdes = [];
} else if (selectedIdes && selectedIdes.includes('__NONE__')) { const goBack = await prompts.confirm({
// Only "__NONE__" was selected message: chalk.yellow('Would you like to go back and select at least one tool?'),
selectedIdes = []; default: true,
});
if (goBack) {
// Re-display a message before looping back
console.log();
} else {
// User explicitly chose to proceed without tools
userConfirmedNoTools = true;
}
} }
return { return {
@ -500,6 +561,27 @@ class UI {
return { backupFirst, preserveCustomizations }; return { backupFirst, preserveCustomizations };
} }
/**
* Prompt for module selection
* @param {Array} modules - Available modules
* @returns {Array} Selected modules
*/
async promptModules(modules) {
const choices = modules.map((mod) => ({
name: `${mod.name} - ${mod.description}`,
value: mod.id,
checked: false,
}));
const selectedModules = await prompts.multiselect({
message: `Select modules to add ${chalk.dim('(↑/↓ navigate, SPACE select, ENTER confirm)')}:`,
choices,
required: true,
});
return selectedModules;
}
/** /**
* Confirm action * Confirm action
* @param {string} message - Confirmation message * @param {string} message - Confirmation message
@ -526,6 +608,25 @@ class UI {
if (result.modules && result.modules.length > 0) { if (result.modules && result.modules.length > 0) {
console.log(chalk.dim(`Modules: ${result.modules.join(', ')}`)); console.log(chalk.dim(`Modules: ${result.modules.join(', ')}`));
} }
if (result.agentVibesEnabled) {
console.log(chalk.dim(`TTS: Enabled`));
}
// TTS injection info (simplified)
if (result.ttsInjectedFiles && result.ttsInjectedFiles.length > 0) {
console.log(chalk.dim(`\n💡 TTS enabled for ${result.ttsInjectedFiles.length} agent(s)`));
console.log(chalk.dim(' Agents will now speak when using AgentVibes'));
}
console.log(chalk.yellow('\nThank you for helping test the early release version of the new BMad Core and BMad Method!'));
console.log(chalk.cyan('Stable Beta coming soon - please read the full README.md and linked documentation to get started!'));
// Add changelog link at the end
console.log(
chalk.magenta(
"\n📋 Want to see what's new? Check out the changelog: https://github.com/bmad-code-org/BMAD-METHOD/blob/main/CHANGELOG.md",
),
);
} }
/** /**
@ -667,40 +768,20 @@ class UI {
* @param {Array} moduleChoices - Available module choices * @param {Array} moduleChoices - Available module choices
* @returns {Array} Selected module IDs * @returns {Array} Selected module IDs
*/ */
async selectModules(moduleChoices, defaultSelections = null) { async selectModules(moduleChoices, defaultSelections = []) {
// If defaultSelections is provided, use it to override checked state // Mark choices as checked based on defaultSelections
// Otherwise preserve the checked state from moduleChoices (set by getModuleChoices)
const choicesWithDefaults = moduleChoices.map((choice) => ({ const choicesWithDefaults = moduleChoices.map((choice) => ({
...choice, ...choice,
...(defaultSelections === null ? {} : { checked: defaultSelections.includes(choice.value) }), checked: defaultSelections.includes(choice.value),
})); }));
// Add a "None" option at the end for users who changed their mind
const choicesWithSkipOption = [
...choicesWithDefaults,
{
value: '__NONE__',
label: '⚠ None / I changed my mind - skip module installation',
checked: false,
},
];
const selected = await prompts.multiselect({ const selected = await prompts.multiselect({
message: `Select modules to install ${chalk.dim('(↑/↓ navigates multiselect, SPACE toggles, A to toggles All, ENTER confirm)')}:`, message: `Select modules to install ${chalk.dim('(↑/↓ navigate, SPACE select, ENTER confirm)')}:`,
choices: choicesWithSkipOption, choices: choicesWithDefaults,
required: true, required: false,
}); });
// If user selected both "__NONE__" and other items, honor the "None" choice return selected || [];
if (selected && selected.includes('__NONE__') && selected.length > 1) {
console.log();
console.log(chalk.yellow('⚠️ "None / I changed my mind" was selected, so no modules will be installed.'));
console.log();
return [];
}
// Filter out the special '__NONE__' value
return selected ? selected.filter((m) => m !== '__NONE__') : [];
} }
/** /**
@ -980,6 +1061,136 @@ class UI {
return path.resolve(expanded); return path.resolve(expanded);
} }
/**
* @function promptAgentVibes
* @intent Ask user if they want AgentVibes TTS integration during BMAD installation
* @why Enables optional voice features without forcing TTS on users who don't want it
* @param {string} projectDir - Absolute path to user's project directory
* @returns {Promise<Object>} Configuration object: { enabled: boolean, alreadyInstalled: boolean }
* @sideeffects None - pure user input collection, no files written
* @edgecases Shows warning if user enables TTS but AgentVibes not detected
* @calledby promptInstall() during installation flow, after core config, before IDE selection
* @calls checkAgentVibesInstalled(), prompts.select(), chalk.green/yellow/dim()
*
* AI NOTE: This prompt is strategically positioned in installation flow:
* - AFTER core config (user_name, etc)
* - BEFORE IDE selection (which can hang on Windows/PowerShell)
*
* Flow Logic:
* 1. Auto-detect if AgentVibes already installed (checks for hook files)
* 2. Show detection status to user (green checkmark or gray "not detected")
* 3. Prompt: "Enable AgentVibes TTS?" (defaults to true if detected)
* 4. If user says YES but AgentVibes NOT installed:
* Show warning with installation link (graceful degradation)
* 5. Return config to promptInstall(), which passes to installer.install()
*
* State Flow:
* promptAgentVibes() { enabled, alreadyInstalled }
*
* promptInstall() config.enableAgentVibes
*
* installer.install() this.enableAgentVibes
*
* processTTSInjectionPoints() injects OR strips markers
*
* RELATED:
* ========
* - Detection: checkAgentVibesInstalled() - looks for bmad-speak.sh and play-tts.sh
* - Processing: installer.js::processTTSInjectionPoints()
* - Markers: src/core/workflows/party-mode/instructions.md:101, src/modules/bmm/agents/*.md
* - GitHub Issue: paulpreibisch/AgentVibes#36
*/
async promptAgentVibes(projectDir) {
CLIUtils.displaySection('🎤 Voice Features', 'Enable TTS for multi-agent conversations');
// Check if AgentVibes is already installed
const agentVibesInstalled = await this.checkAgentVibesInstalled(projectDir);
if (agentVibesInstalled) {
console.log(chalk.green(' ✓ AgentVibes detected'));
} else {
console.log(chalk.dim(' AgentVibes not detected'));
}
const enableTts = await prompts.confirm({
message: 'Enable Agents to Speak Out loud (powered by Agent Vibes? Claude Code only currently)',
default: false,
});
if (enableTts && !agentVibesInstalled) {
console.log(chalk.yellow('\n ⚠️ AgentVibes not installed'));
console.log(chalk.dim(' Install AgentVibes separately to enable TTS:'));
console.log(chalk.dim(' https://github.com/paulpreibisch/AgentVibes\n'));
}
return {
enabled: enableTts,
alreadyInstalled: agentVibesInstalled,
};
}
/**
* @function checkAgentVibesInstalled
* @intent Detect if AgentVibes TTS hooks are present in user's project
* @why Allows auto-enabling TTS and showing helpful installation guidance
* @param {string} projectDir - Absolute path to user's project directory
* @returns {Promise<boolean>} true if both required AgentVibes hooks exist, false otherwise
* @sideeffects None - read-only file existence checks
* @edgecases Returns false if either hook missing (both required for functional TTS)
* @calledby promptAgentVibes() to determine default value and show detection status
* @calls fs.pathExists() twice (bmad-speak.sh, play-tts.sh)
*
* AI NOTE: This checks for the MINIMUM viable AgentVibes installation.
*
* Required Files:
* ===============
* 1. .claude/hooks/bmad-speak.sh
* - Maps agent display names agent IDs voice profiles
* - Calls play-tts.sh with agent's assigned voice
* - Created by AgentVibes installer
*
* 2. .claude/hooks/play-tts.sh
* - Core TTS router (ElevenLabs or Piper)
* - Provider-agnostic interface
* - Required by bmad-speak.sh
*
* Why Both Required:
* ==================
* - bmad-speak.sh alone: No TTS backend
* - play-tts.sh alone: No BMAD agent voice mapping
* - Both together: Full party mode TTS integration
*
* Detection Strategy:
* ===================
* We use simple file existence (not version checks) because:
* - Fast and reliable
* - Works across all AgentVibes versions
* - User will discover version issues when TTS runs (fail-fast)
*
* PATTERN: Adding New Detection Criteria
* =======================================
* If future AgentVibes features require additional files:
* 1. Add new pathExists check to this function
* 2. Update documentation in promptAgentVibes()
* 3. Consider: should missing file prevent detection or just log warning?
*
* RELATED:
* ========
* - AgentVibes Installer: creates these hooks
* - bmad-speak.sh: calls play-tts.sh with agent voices
* - Party Mode: uses bmad-speak.sh for agent dialogue
*/
async checkAgentVibesInstalled(projectDir) {
const fs = require('fs-extra');
const path = require('node:path');
// Check for AgentVibes hook files
const hookPath = path.join(projectDir, '.claude', 'hooks', 'bmad-speak.sh');
const playTtsPath = path.join(projectDir, '.claude', 'hooks', 'play-tts.sh');
return (await fs.pathExists(hookPath)) && (await fs.pathExists(playTtsPath));
}
/** /**
* Load existing configurations to use as defaults * Load existing configurations to use as defaults
* @param {string} directory - Installation directory * @param {string} directory - Installation directory
@ -990,6 +1201,7 @@ class UI {
hasCustomContent: false, hasCustomContent: false,
coreConfig: {}, coreConfig: {},
ideConfig: { ides: [], skipIde: false }, ideConfig: { ides: [], skipIde: false },
agentVibesConfig: { enabled: false, alreadyInstalled: false },
}; };
try { try {
@ -1003,6 +1215,10 @@ class UI {
configs.ideConfig.skipIde = false; configs.ideConfig.skipIde = false;
} }
// Load AgentVibes configuration
const agentVibesInstalled = await this.checkAgentVibesInstalled(directory);
configs.agentVibesConfig = { enabled: agentVibesInstalled, alreadyInstalled: agentVibesInstalled };
return configs; return configs;
} catch { } catch {
// If loading fails, return empty configs // If loading fails, return empty configs
@ -1245,32 +1461,12 @@ class UI {
checked: m.checked, checked: m.checked,
})); }));
// Add "None / I changed my mind" option at the end
const choicesWithSkip = [
...selectChoices,
{
name: '⚠ None / I changed my mind - keep no custom modules',
value: '__NONE__',
checked: false,
},
];
const keepModules = await prompts.multiselect({ const keepModules = await prompts.multiselect({
message: `Select custom modules to keep ${chalk.dim('(↑/↓ navigates multiselect, SPACE toggles, A to toggles All, ENTER confirm)')}:`, message: `Select custom modules to keep ${chalk.dim('(↑/↓ navigate, SPACE select, ENTER confirm)')}:`,
choices: choicesWithSkip, choices: selectChoices,
required: true, required: false,
}); });
result.selectedCustomModules = keepModules || [];
// If user selected both "__NONE__" and other modules, honor the "None" choice
if (keepModules && keepModules.includes('__NONE__') && keepModules.length > 1) {
console.log();
console.log(chalk.yellow('⚠️ "None / I changed my mind" was selected, so no custom modules will be kept.'));
console.log();
result.selectedCustomModules = [];
} else {
// Filter out the special '__NONE__' value
result.selectedCustomModules = keepModules ? keepModules.filter((m) => m !== '__NONE__') : [];
}
break; break;
} }