diff --git a/apps/mcp-server/src/agent/agent-stack.service.spec.ts b/apps/mcp-server/src/agent/agent-stack.service.spec.ts index 1f0c3f2b..f908fa7f 100644 --- a/apps/mcp-server/src/agent/agent-stack.service.spec.ts +++ b/apps/mcp-server/src/agent/agent-stack.service.spec.ts @@ -23,7 +23,7 @@ describe('AgentStackService', () => { description: 'API development stack', category: 'development', primary_agent: 'backend-developer', - specialists: ['security-specialist', 'test-engineer'], + specialist_agents: ['security-specialist', 'test-engineer'], tags: ['api', 'backend'], }; @@ -32,7 +32,7 @@ describe('AgentStackService', () => { description: 'Frontend review stack', category: 'review', primary_agent: 'frontend-developer', - specialists: ['accessibility-specialist', 'performance-specialist'], + specialist_agents: ['accessibility-specialist', 'performance-specialist'], tags: ['frontend', 'ui'], }; @@ -79,7 +79,7 @@ describe('AgentStackService', () => { const customStack = { ...sampleStack, description: 'Custom API stack', - specialists: ['security-specialist'], + specialist_agents: ['security-specialist'], }; mockReaddir.mockImplementation(async (dirPath: string) => { @@ -188,13 +188,13 @@ describe('AgentStackService', () => { expect(stack.name).toBe('api-development'); expect(stack.primary_agent).toBe('backend-developer'); - expect(stack.specialists).toEqual(['security-specialist', 'test-engineer']); + expect(stack.specialist_agents).toEqual(['security-specialist', 'test-engineer']); }); it('should prefer custom stack over default', async () => { const customStack = { ...sampleStack, - specialists: ['security-specialist', 'test-engineer', 'code-quality-specialist'], + specialist_agents: ['security-specialist', 'test-engineer', 'code-quality-specialist'], }; mockReadFile.mockImplementation(async (filePath: string) => { @@ -207,7 +207,7 @@ describe('AgentStackService', () => { const stack = await service.resolveStack('api-development'); - expect(stack.specialists).toHaveLength(3); + expect(stack.specialist_agents).toHaveLength(3); }); it('should throw when stack not found', async () => { diff --git a/apps/mcp-server/src/agent/agent-stack.service.ts b/apps/mcp-server/src/agent/agent-stack.service.ts index b40f5566..b7f919de 100644 --- a/apps/mcp-server/src/agent/agent-stack.service.ts +++ b/apps/mcp-server/src/agent/agent-stack.service.ts @@ -104,7 +104,7 @@ export class AgentStackService { typeof obj.description === 'string' && typeof obj.category === 'string' && typeof obj.primary_agent === 'string' && - Array.isArray(obj.specialists) + Array.isArray(obj.specialist_agents) ); } @@ -114,8 +114,8 @@ export class AgentStackService { description: stack.description, category: stack.category, primary_agent: stack.primary_agent, - specialist_count: stack.specialists.length, - tags: stack.tags ?? [], + specialist_count: stack.specialist_agents.length, + tags: stack.tags, }; } } diff --git a/apps/mcp-server/src/agent/agent.types.ts b/apps/mcp-server/src/agent/agent.types.ts index ad90c94f..ffbc7887 100644 --- a/apps/mcp-server/src/agent/agent.types.ts +++ b/apps/mcp-server/src/agent/agent.types.ts @@ -184,18 +184,6 @@ export interface InlineAgentDefinition { [key: string]: unknown; } -/** - * Agent stack definition — a preset combination of primary agent + specialists - */ -export interface AgentStack { - name: string; - description: string; - category: string; - primary_agent: string; - specialists: string[]; - tags?: string[]; -} - /** * Summary of an agent stack for listing */ diff --git a/apps/mcp-server/src/mcp/handlers/agent.handler.spec.ts b/apps/mcp-server/src/mcp/handlers/agent.handler.spec.ts index c57e0ba6..f1f04cc7 100644 --- a/apps/mcp-server/src/mcp/handlers/agent.handler.spec.ts +++ b/apps/mcp-server/src/mcp/handlers/agent.handler.spec.ts @@ -977,7 +977,7 @@ describe('AgentHandler', () => { description: 'API development stack', category: 'development', primary_agent: 'backend-developer', - specialists: ['security-specialist', 'test-engineer'], + specialist_agents: ['security-specialist', 'test-engineer'], tags: ['api', 'backend'], }; diff --git a/apps/mcp-server/src/mcp/handlers/agent.handler.ts b/apps/mcp-server/src/mcp/handlers/agent.handler.ts index 75bd134c..2d80a167 100644 --- a/apps/mcp-server/src/mcp/handlers/agent.handler.ts +++ b/apps/mcp-server/src/mcp/handlers/agent.handler.ts @@ -255,8 +255,8 @@ export class AgentHandler extends AbstractHandler { try { const stack = await this.agentStackService.resolveStack(agentStack); primaryAgent = primaryAgent ?? stack.primary_agent; - specialists = specialists?.length ? specialists : stack.specialists; - includeParallel = includeParallel || stack.specialists.length > 0; + specialists = specialists?.length ? specialists : stack.specialist_agents; + includeParallel = includeParallel || stack.specialist_agents.length > 0; } catch (error) { return createErrorResponse( `Failed to resolve agent stack: ${error instanceof Error ? error.message : 'Unknown error'}`, diff --git a/packages/claude-code-plugin/hooks/lib/mode_engine.py b/packages/claude-code-plugin/hooks/lib/mode_engine.py new file mode 100644 index 00000000..b619cc30 --- /dev/null +++ b/packages/claude-code-plugin/hooks/lib/mode_engine.py @@ -0,0 +1,177 @@ +""" +Self-contained Mode Engine for CodingBuddy plugin. + +Provides PLAN/ACT/EVAL/AUTO mode instructions without requiring MCP server. +Reads .ai-rules/ files directly and outputs complete mode instructions. +""" + +import os +from typing import Optional + + +# Default agents per mode +DEFAULT_AGENTS = { + "PLAN": {"name": "technical-planner", "title": "Technical Planner"}, + "ACT": {"name": "software-engineer", "title": "Software Engineer"}, + "EVAL": {"name": "code-reviewer", "title": "Code Reviewer"}, + "AUTO": {"name": "auto-mode-agent", "title": "Auto Mode Agent"}, +} + +# Mode instruction templates (compact, within ~2000 char hook limit) +MODE_TEMPLATES = { + "PLAN": """# Mode: PLAN +## Agent: {agent_name} + +You are in PLAN mode. Design the implementation approach. + +Rules: +- Define test cases first (TDD perspective) +- Review architecture before implementation +- Output full plan in every response +- Do NOT auto-proceed to ACT — wait for user +- Consider alternatives for non-trivial decisions + +Checklist: +- [ ] Problem decomposed into sub-problems +- [ ] File paths identified +- [ ] TDD strategy defined +- [ ] Alternatives considered""", + "ACT": """# Mode: ACT +## Agent: {agent_name} + +You are in ACT mode. Execute the plan. + +Rules: +- Red -> Green -> Refactor cycle +- Implement minimally first +- Run tests after each change +- Proceed autonomously until blocked +- Only stop for errors or blockers""", + "EVAL": """# Mode: EVAL +## Agent: {agent_name} + +You are in EVAL mode. Review and improve. + +Rules: +- Check code quality (SOLID, DRY, complexity) +- Verify test coverage +- Security scan (OWASP top 10) +- Performance review +- Propose concrete improvements""", + "AUTO": """# Mode: AUTO +Autonomous PLAN -> ACT -> EVAL cycle. +Continue until: Critical=0, High=0. +Report progress at each cycle iteration.""", +} + + +def _resolve_rules_dir(cwd: Optional[str] = None) -> Optional[str]: + """ + Resolve path to .ai-rules/ directory. + + Resolution order: + 1. CODINGBUDDY_RULES_DIR env var + 2. Project local: {cwd}/.ai-rules/ + 3. Plugin bundled: {plugin_root}/../rules/.ai-rules/ (dev mode) + 4. Installed package: find via codingbuddy-rules npm + + Returns: + Path to .ai-rules/ directory, or None if not found. + """ + # 1. Environment variable + env_dir = os.environ.get("CODINGBUDDY_RULES_DIR") + if env_dir and os.path.isdir(env_dir): + return env_dir + + # 2. Project local + working_dir = cwd or os.getcwd() + local_dir = os.path.join(working_dir, ".ai-rules") + if os.path.isdir(local_dir): + return local_dir + + # 3. Plugin bundled (dev mode) + plugin_root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + bundled_dir = os.path.join(plugin_root, "..", "rules", ".ai-rules") + bundled_dir = os.path.normpath(bundled_dir) + if os.path.isdir(bundled_dir): + return bundled_dir + + return None + + +class ModeEngine: + """Self-contained mode engine that works without MCP server.""" + + def __init__(self, rules_dir: Optional[str] = None, cwd: Optional[str] = None): + """ + Initialize with path to .ai-rules/ directory. + + Args: + rules_dir: Explicit path to .ai-rules/ directory. + If None, auto-resolved via _resolve_rules_dir. + cwd: Working directory for resolution. Defaults to os.getcwd(). + """ + self.rules_dir = rules_dir or _resolve_rules_dir(cwd) + + def load_mode_rules(self, mode: str) -> Optional[str]: + """ + Read core.md and extract mode-specific section. + + Args: + mode: Mode name (PLAN, ACT, EVAL, AUTO) + + Returns: + Extracted mode rules text, or None if not found. + """ + if not self.rules_dir: + return None + + core_path = os.path.join(self.rules_dir, "rules", "core.md") + if not os.path.isfile(core_path): + return None + + try: + with open(core_path, "r", encoding="utf-8") as f: + return f.read() + except OSError: + return None + + def get_default_agent(self, mode: str) -> dict: + """ + Return default agent for the given mode. + + Args: + mode: Mode name (PLAN, ACT, EVAL, AUTO) + + Returns: + Dict with 'name' and 'title' keys. + """ + mode_upper = mode.upper() + return DEFAULT_AGENTS.get(mode_upper, DEFAULT_AGENTS["ACT"]) + + def build_instructions(self, mode: str) -> str: + """ + Build complete mode instructions for hook output. + + Output is kept within ~2000 char limit for UserPromptSubmit hooks. + Falls back to minimal template if .ai-rules/ is not found. + + Args: + mode: Mode name (PLAN, ACT, EVAL, AUTO) + + Returns: + Complete mode instructions string. + """ + mode_upper = mode.upper() + agent = self.get_default_agent(mode_upper) + template = MODE_TEMPLATES.get(mode_upper, MODE_TEMPLATES["ACT"]) + + instructions = template.format(agent_name=agent["name"]) + + # Add MCP enhancement hint + mcp_hint = ( + "\n\nIf mcp__codingbuddy__parse_mode is available, " + "call it for enhanced features (checklists, specialist agents, context tracking)." + ) + + return instructions + mcp_hint diff --git a/packages/claude-code-plugin/hooks/lib/test_mode_engine.py b/packages/claude-code-plugin/hooks/lib/test_mode_engine.py new file mode 100644 index 00000000..2d282ede --- /dev/null +++ b/packages/claude-code-plugin/hooks/lib/test_mode_engine.py @@ -0,0 +1,166 @@ +"""Tests for ModeEngine — self-contained mode instruction generator.""" + +import os +import tempfile +import unittest + +from mode_engine import ModeEngine, _resolve_rules_dir, DEFAULT_AGENTS, MODE_TEMPLATES + + +class TestResolveRulesDir(unittest.TestCase): + """Test rules directory resolution order.""" + + def test_env_var_takes_priority(self): + with tempfile.TemporaryDirectory() as tmpdir: + os.environ["CODINGBUDDY_RULES_DIR"] = tmpdir + try: + result = _resolve_rules_dir(cwd="/nonexistent") + self.assertEqual(result, tmpdir) + finally: + del os.environ["CODINGBUDDY_RULES_DIR"] + + def test_env_var_ignored_if_not_exists(self): + os.environ["CODINGBUDDY_RULES_DIR"] = "/nonexistent/path" + try: + result = _resolve_rules_dir(cwd="/also/nonexistent") + # Should not return the env var path since it doesn't exist + self.assertNotEqual(result, "/nonexistent/path") + finally: + del os.environ["CODINGBUDDY_RULES_DIR"] + + def test_project_local_ai_rules(self): + with tempfile.TemporaryDirectory() as tmpdir: + ai_rules = os.path.join(tmpdir, ".ai-rules") + os.makedirs(ai_rules) + result = _resolve_rules_dir(cwd=tmpdir) + self.assertEqual(result, ai_rules) + + def test_returns_none_when_nothing_found(self): + result = _resolve_rules_dir(cwd="/nonexistent/path/nowhere") + # May return None or find bundled dir depending on repo layout + # The key is it doesn't crash + self.assertIsInstance(result, (str, type(None))) + + +class TestModeEngineGetDefaultAgent(unittest.TestCase): + """Test default agent assignment per mode.""" + + def test_plan_agent(self): + engine = ModeEngine(rules_dir=None) + agent = engine.get_default_agent("PLAN") + self.assertEqual(agent["name"], "technical-planner") + + def test_act_agent(self): + engine = ModeEngine(rules_dir=None) + agent = engine.get_default_agent("ACT") + self.assertEqual(agent["name"], "software-engineer") + + def test_eval_agent(self): + engine = ModeEngine(rules_dir=None) + agent = engine.get_default_agent("EVAL") + self.assertEqual(agent["name"], "code-reviewer") + + def test_auto_agent(self): + engine = ModeEngine(rules_dir=None) + agent = engine.get_default_agent("AUTO") + self.assertEqual(agent["name"], "auto-mode-agent") + + def test_case_insensitive(self): + engine = ModeEngine(rules_dir=None) + agent = engine.get_default_agent("plan") + self.assertEqual(agent["name"], "technical-planner") + + def test_unknown_mode_falls_back_to_act(self): + engine = ModeEngine(rules_dir=None) + agent = engine.get_default_agent("UNKNOWN") + self.assertEqual(agent["name"], "software-engineer") + + +class TestModeEngineBuildInstructions(unittest.TestCase): + """Test instruction generation for each mode.""" + + def setUp(self): + self.engine = ModeEngine(rules_dir=None) + + def test_plan_instructions_contain_mode_header(self): + result = self.engine.build_instructions("PLAN") + self.assertIn("# Mode: PLAN", result) + self.assertIn("## Agent: technical-planner", result) + + def test_act_instructions_contain_tdd(self): + result = self.engine.build_instructions("ACT") + self.assertIn("# Mode: ACT", result) + self.assertIn("Red -> Green -> Refactor", result) + + def test_eval_instructions_contain_quality(self): + result = self.engine.build_instructions("EVAL") + self.assertIn("# Mode: EVAL", result) + self.assertIn("SOLID, DRY", result) + + def test_auto_instructions_contain_cycle(self): + result = self.engine.build_instructions("AUTO") + self.assertIn("# Mode: AUTO", result) + self.assertIn("PLAN -> ACT -> EVAL", result) + + def test_mcp_hint_always_present(self): + for mode in ["PLAN", "ACT", "EVAL", "AUTO"]: + result = self.engine.build_instructions(mode) + self.assertIn("mcp__codingbuddy__parse_mode", result) + + def test_output_within_char_limit(self): + for mode in ["PLAN", "ACT", "EVAL", "AUTO"]: + result = self.engine.build_instructions(mode) + self.assertLessEqual(len(result), 2000, f"{mode} exceeds 2000 char limit") + + def test_case_insensitive_mode(self): + result = self.engine.build_instructions("plan") + self.assertIn("# Mode: PLAN", result) + + +class TestModeEngineLoadRules(unittest.TestCase): + """Test loading rules from filesystem.""" + + def test_load_with_nonexistent_rules_dir_returns_none(self): + engine = ModeEngine(rules_dir="/nonexistent/path/nowhere") + result = engine.load_mode_rules("PLAN") + self.assertIsNone(result) + + def test_load_with_valid_core_md(self): + with tempfile.TemporaryDirectory() as tmpdir: + rules_dir = os.path.join(tmpdir, "rules") + os.makedirs(rules_dir) + core_path = os.path.join(rules_dir, "core.md") + with open(core_path, "w") as f: + f.write("# Core Rules\n## PLAN\nPlan rules here") + + engine = ModeEngine(rules_dir=tmpdir) + result = engine.load_mode_rules("PLAN") + self.assertIsNotNone(result) + self.assertIn("Core Rules", result) + + def test_load_missing_core_md_returns_none(self): + with tempfile.TemporaryDirectory() as tmpdir: + engine = ModeEngine(rules_dir=tmpdir) + result = engine.load_mode_rules("PLAN") + self.assertIsNone(result) + + +class TestModeEngineGracefulFallback(unittest.TestCase): + """Test graceful degradation when .ai-rules/ is missing.""" + + def test_build_instructions_without_rules_dir(self): + engine = ModeEngine(rules_dir=None) + result = engine.build_instructions("PLAN") + # Should still produce valid instructions from templates + self.assertIn("# Mode: PLAN", result) + self.assertIn("technical-planner", result) + + def test_all_modes_work_without_rules_dir(self): + engine = ModeEngine(rules_dir=None) + for mode in ["PLAN", "ACT", "EVAL", "AUTO"]: + result = engine.build_instructions(mode) + self.assertTrue(len(result) > 0, f"{mode} produced empty output") + + +if __name__ == "__main__": + unittest.main() diff --git a/packages/claude-code-plugin/hooks/test_user_prompt_submit.py b/packages/claude-code-plugin/hooks/test_user_prompt_submit.py index 6ae93208..b0a6fd89 100644 --- a/packages/claude-code-plugin/hooks/test_user_prompt_submit.py +++ b/packages/claude-code-plugin/hooks/test_user_prompt_submit.py @@ -116,7 +116,7 @@ class TestMainFunction: """Integration tests for the main hook function.""" def test_outputs_context_when_plan_detected(self): - """Test that context is output when PLAN keyword is detected.""" + """Test that self-contained mode instructions are output when PLAN keyword is detected.""" hook_path = Path(__file__).parent / "user-prompt-submit.py" input_data = json.dumps({"prompt": "PLAN: test feature"}) @@ -128,9 +128,9 @@ def test_outputs_context_when_plan_detected(self): ) assert result.returncode == 0 - assert "" in result.stdout - assert "MODE_KEYWORD_DETECTED: PLAN" in result.stdout - assert "MANDATORY_ACTION" in result.stdout + assert "# Mode: PLAN" in result.stdout + assert "technical-planner" in result.stdout + assert "mcp__codingbuddy__parse_mode" in result.stdout def test_no_output_when_no_keyword(self): """Test that no output when no keyword is detected.""" diff --git a/packages/claude-code-plugin/hooks/user-prompt-submit.py b/packages/claude-code-plugin/hooks/user-prompt-submit.py index 6a6cc81d..59f7e4a6 100644 --- a/packages/claude-code-plugin/hooks/user-prompt-submit.py +++ b/packages/claude-code-plugin/hooks/user-prompt-submit.py @@ -1,9 +1,12 @@ #!/usr/bin/env python3 """ -CodingBuddy Mode Detection Hook +CodingBuddy Mode Detection Hook (Self-Contained) Detects PLAN/ACT/EVAL/AUTO keywords at the start of user prompts -and injects context to trigger parse_mode MCP call. +and outputs complete mode instructions without requiring MCP server. + +Falls back to built-in templates if .ai-rules/ is not found. +Optionally suggests parse_mode MCP call for enhanced features. Supported languages: - English: PLAN, ACT, EVAL, AUTO @@ -27,15 +30,6 @@ "AUTO": r"^(AUTO|자동|自動|自动|AUTOMÁTICO)\s*[:\s]", } -# Context template for mode detection output -CONTEXT_TEMPLATE = """ -MODE_KEYWORD_DETECTED: {mode} -MANDATORY_ACTION: You MUST call mcp__codingbuddy__parse_mode with the user's prompt IMMEDIATELY. -DO NOT respond to the user before calling parse_mode. -DO NOT skip this step or rationalize why it's not needed. -This is a BLOCKING requirement from the CodingBuddy hook. -The parse_mode tool will provide mode-specific instructions, checklists, and agent recommendations. -""" def detect_mode(prompt: str) -> Optional[str]: @@ -66,15 +60,24 @@ def main(): detected_mode = detect_mode(prompt) if detected_mode: - # Output mandatory context for Claude - print(CONTEXT_TEMPLATE.format(mode=detected_mode)) + # Self-contained mode instructions via ModeEngine + _hooks_dir = os.path.dirname(os.path.abspath(__file__)) + _lib_dir = os.path.join(_hooks_dir, "lib") + if _lib_dir not in sys.path: + sys.path.insert(0, _lib_dir) + + try: + from mode_engine import ModeEngine + engine = ModeEngine() + instructions = engine.build_instructions(detected_mode) + print(instructions) + except Exception: + # Fallback: minimal instruction if ModeEngine fails + print(f"# Mode: {detected_mode}") + print("If mcp__codingbuddy__parse_mode is available, call it for enhanced features.") # Update HUD state with detected mode (#1090) try: - _hooks_dir = os.path.dirname(os.path.abspath(__file__)) - _lib_dir = os.path.join(_hooks_dir, "lib") - if _lib_dir not in sys.path: - sys.path.insert(0, _lib_dir) from hud_state import update_hud_state state_file = os.environ.get("CODINGBUDDY_HUD_STATE_FILE") if state_file: diff --git a/tests/e2e/plugin-hooks/test_user_prompt_submit_e2e.py b/tests/e2e/plugin-hooks/test_user_prompt_submit_e2e.py index a0cd5b76..137ed678 100644 --- a/tests/e2e/plugin-hooks/test_user_prompt_submit_e2e.py +++ b/tests/e2e/plugin-hooks/test_user_prompt_submit_e2e.py @@ -57,7 +57,7 @@ def test_detects_english_keyword(self, mock_env, keyword, mode): env=mock_env, ) assert result.succeeded - assert f"MODE_KEYWORD_DETECTED: {mode}" in result.stdout + assert f"# Mode: {mode}" in result.stdout def test_no_detection_for_normal_prompt(self, mock_env): result = run_hook( @@ -66,7 +66,7 @@ def test_no_detection_for_normal_prompt(self, mock_env): env=mock_env, ) assert result.succeeded - assert "MODE_KEYWORD_DETECTED" not in result.stdout + assert "# Mode:" not in result.stdout class TestKoreanModeKeywords: @@ -85,7 +85,7 @@ def test_detects_korean_keyword(self, mock_env, keyword, mode): env=mock_env, ) assert result.succeeded - assert f"MODE_KEYWORD_DETECTED: {mode}" in result.stdout + assert f"# Mode: {mode}" in result.stdout class TestJapaneseModeKeywords: @@ -104,7 +104,7 @@ def test_detects_japanese_keyword(self, mock_env, keyword, mode): env=mock_env, ) assert result.succeeded - assert f"MODE_KEYWORD_DETECTED: {mode}" in result.stdout + assert f"# Mode: {mode}" in result.stdout class TestChineseModeKeywords: @@ -123,7 +123,7 @@ def test_detects_chinese_keyword(self, mock_env, keyword, mode): env=mock_env, ) assert result.succeeded - assert f"MODE_KEYWORD_DETECTED: {mode}" in result.stdout + assert f"# Mode: {mode}" in result.stdout class TestSpanishModeKeywords: @@ -142,29 +142,28 @@ def test_detects_spanish_keyword(self, mock_env, keyword, mode): env=mock_env, ) assert result.succeeded - assert f"MODE_KEYWORD_DETECTED: {mode}" in result.stdout + assert f"# Mode: {mode}" in result.stdout class TestContextInjection: - """Verify correct context format is injected.""" + """Verify self-contained mode instructions are injected.""" - def test_context_contains_mandatory_action(self, mock_env): + def test_context_contains_parse_mode_hint(self, mock_env): result = run_hook( "user-prompt-submit.py", input_data={"prompt": "PLAN design a new feature"}, env=mock_env, ) - assert "MANDATORY_ACTION" in result.stdout + assert "# Mode: PLAN" in result.stdout assert "parse_mode" in result.stdout - def test_context_wrapped_in_tags(self, mock_env): + def test_context_contains_agent_name(self, mock_env): result = run_hook( "user-prompt-submit.py", input_data={"prompt": "AUTO implement feature"}, env=mock_env, ) - assert "" in result.stdout - assert "" in result.stdout + assert "# Mode: AUTO" in result.stdout def test_case_insensitive_detection(self, mock_env): """Keywords should be detected case-insensitively.""" @@ -174,4 +173,4 @@ def test_case_insensitive_detection(self, mock_env): env=mock_env, ) assert result.succeeded - assert "MODE_KEYWORD_DETECTED: PLAN" in result.stdout + assert "# Mode: PLAN" in result.stdout