diff --git a/factory/agents/prompts/ceo.md b/factory/agents/prompts/ceo.md index d4e23fdea..80b3eea32 100644 --- a/factory/agents/prompts/ceo.md +++ b/factory/agents/prompts/ceo.md @@ -44,11 +44,9 @@ You communicate directly with the user when running in foreground mode. You expl **You MUST complete ALL planned work before exiting.** This applies to every mode: -- **Build mode:** All phases (B0–B6) must be attempted -- **Improve mode:** Every approved hypothesis must have a Builder keep/revert verdict -- **Discover mode:** The eval profile must be generated +- **Design mode:** All phases must be attempted (build, discover, and improve are handled inline) - **Research mode:** Every approved hypothesis must have a verdict, or a termination condition must be met -- **Meta mode:** Same as Improve, plus ACE playbook evolution +- **Meta mode:** Same as Design, plus ACE playbook evolution **Self-judged early exits are FORBIDDEN.** Do not exit because: - "This is a good stopping point" — there are no stopping points, only completion @@ -309,24 +307,20 @@ factory detect "$PROJECT_PATH" | State | Meaning | Route to | |------------------------|-----------------------------------------------|----------------| -| `no_repo` | No git repo at path | Build mode | -| `incomplete` | Repo exists, open plan/implementation issues | Build mode | -| `no_factory` | Repo exists, no factory setup | Discover mode | -| `evals_pending_review` | Eval profile exists, not yet reviewed | Review mode | -| `has_factory` | Factory fully initialized, evals reviewed | Improve mode | +| `no_repo` | No git repo at path | Design mode | +| `incomplete` | Repo exists, open plan/implementation issues | Design mode | +| `no_factory` | Repo exists, no factory setup | Design mode | +| `evals_pending_review` | Eval profile exists, not yet reviewed | Design mode | +| `has_factory` | Factory fully initialized, evals reviewed | Design mode | ### Step 2: Route to Mode via Skills Each mode's full instructions live in a workflow skill under `skills/workflow-/SKILL.md`. After detecting project state, select and invoke the appropriate skill. **Default routing:** -- `no_repo` or `incomplete` → read `skills/workflow-build/SKILL.md` -- `no_factory` → read `skills/workflow-discover/SKILL.md` -- `evals_pending_review` → read `skills/workflow-review/SKILL.md` -- `has_factory` → read `skills/workflow-improve/SKILL.md` +- All project states → read `skills/workflow-design/SKILL.md` **Mode overrides (from task directives):** -- `--mode design` or `## Plan Loop (Interactive)` → read `skills/workflow-design/SKILL.md` - `--mode research` (with `research_target` configured) → read `skills/workflow-research/SKILL.md` - `--mode meta` → read `skills/workflow-meta/SKILL.md` - `--refine ""` → read `skills/workflow-refine/SKILL.md` diff --git a/factory/ceo_completion.py b/factory/ceo_completion.py index 0de63a62d..3ed4a37c3 100644 --- a/factory/ceo_completion.py +++ b/factory/ceo_completion.py @@ -292,53 +292,18 @@ def _detect_incomplete( cycle_started_at: If provided, only counts experiments created after this time. This prevents counting stale experiments from previous cycles. """ - if mode in ("improve", "meta", "research"): + if mode in ("design", "meta", "research"): planned = _count_hypotheses(project_path) completed = _count_verdicts(project_path, since_ts=cycle_started_at) if planned == 0: - # No strategy yet — not an incomplete cycle, probably discover mode - return None - - if completed >= planned: - return None - - next_h = completed + 1 - reason_prefix = "research" if mode == "research" else "improve" - return IncompleteGap( - mode=mode, - planned=planned, - completed=completed, - next_item=f"H{next_h}", - reason=f"{reason_prefix}.incomplete: {completed}/{planned} hypotheses have verdicts", - ) - - elif mode == "discover": - if _has_eval_profile(project_path): - return None - return IncompleteGap( - mode=mode, - planned=1, - completed=0, - next_item="eval_profile", - reason="discover.incomplete: no eval_profile.json", - ) - - elif mode == "build": - # For build mode, check if Builder completed at least one hypothesis - # In build mode, the strategy file should have phases marked as hypotheses - planned = _count_hypotheses(project_path) - completed = _count_verdicts(project_path, since_ts=cycle_started_at) - - if planned == 0: - # No strategy means we're in scaffold phase — check for eval profile - if not _has_eval_profile(project_path): + if mode == "design" and not _has_eval_profile(project_path): return IncompleteGap( mode=mode, planned=1, completed=0, next_item="discovery", - reason="build.incomplete: no eval profile yet", + reason="design.incomplete: no eval profile yet", ) return None @@ -346,12 +311,13 @@ def _detect_incomplete( return None next_h = completed + 1 + reason_prefix = "research" if mode == "research" else "design" return IncompleteGap( mode=mode, planned=planned, completed=completed, - next_item=f"Phase{next_h}", - reason=f"build.incomplete: {completed}/{planned} phases have verdicts", + next_item=f"H{next_h}", + reason=f"{reason_prefix}.incomplete: {completed}/{planned} hypotheses have verdicts", ) # Unknown mode — assume complete @@ -386,26 +352,14 @@ def _build_continuation_task(gap: IncompleteGap, cycle_state: CycleState | None f"research cycle (R3–R5) for each remaining hypothesis. " f"Progress so far: {gap.completed}/{gap.planned} hypotheses have verdicts." ) - elif gap.mode in ("improve", "meta"): + elif gap.mode in ("design", "meta"): body = ( - f"Resume execution from hypothesis {gap.next_item}. " + f"Resume execution from {gap.next_item}. " f"Strategy is already approved at .factory/strategy/current.md — " f"do not re-plan, do not re-run Researcher or Strategist. " f"Spawn Builder for {gap.next_item} immediately. " f"Progress so far: {gap.completed}/{gap.planned} hypotheses have verdicts." ) - elif gap.mode == "build": - body = ( - f"Resume Build pipeline from {gap.next_item}. " - f"Plan is already approved at .factory/strategy/current.md. " - f"Progress so far: {gap.completed}/{gap.planned} phases complete. " - f"Continue with the next phase immediately." - ) - elif gap.mode == "discover": - body = ( - "Resume Discovery. The eval profile has not been generated yet. " - "Complete the Discover mode workflow to produce .factory/eval_profile.json." - ) else: body = f"Resume from {gap.next_item}." diff --git a/factory/checkpoint.py b/factory/checkpoint.py index ea244f897..1a87c8e50 100644 --- a/factory/checkpoint.py +++ b/factory/checkpoint.py @@ -36,6 +36,7 @@ class CheckpointState(BaseModel): def save_checkpoint(project_path: Path, state: CheckpointState) -> None: """Serialize checkpoint state to .factory/checkpoint.json.""" from factory.store import ensure_factory_dir + factory_dir = project_path / ".factory" ensure_factory_dir(factory_dir) checkpoint_path = factory_dir / _CHECKPOINT_FILE @@ -45,6 +46,8 @@ def save_checkpoint(project_path: Path, state: CheckpointState) -> None: def load_checkpoint(project_path: Path) -> CheckpointState | None: """Load checkpoint from .factory/checkpoint.json, or None if absent/corrupt.""" + from factory.cli._helpers import DEAD_MODES + checkpoint_path = project_path / ".factory" / _CHECKPOINT_FILE if not checkpoint_path.exists(): log.debug("checkpoint.not_found", path=str(checkpoint_path)) @@ -55,6 +58,10 @@ def load_checkpoint(project_path: Path) -> CheckpointState | None: except (json.JSONDecodeError, Exception) as exc: log.warning("checkpoint.corrupt", path=str(checkpoint_path), error=str(exc)) return None + if state.mode in DEAD_MODES: + old_mode = state.mode + state.mode = DEAD_MODES[old_mode] + log.warning("checkpoint.mode_migrated", old=old_mode, new=state.mode) log.info("checkpoint.loaded", path=str(checkpoint_path)) return state diff --git a/factory/cli/_ceo_helpers.py b/factory/cli/_ceo_helpers.py index 33d6ddd31..3bf886efd 100644 --- a/factory/cli/_ceo_helpers.py +++ b/factory/cli/_ceo_helpers.py @@ -57,6 +57,7 @@ def _tool_exec_protocol(wt_path: Path) -> str: overview = "" try: from factory.workflow.tool import tool_overview + overview = tool_overview(p, fmt="linear") except Exception: pass @@ -69,11 +70,7 @@ def _tool_exec_protocol(wt_path: Path) -> str: ) if overview: - protocol += ( - "\n## Workflow Map\n" - "\n" - f"{overview}\n" - ) + protocol += f"\n## Workflow Map\n\n{overview}\n" protocol += ( "\n## Commands\n" @@ -90,7 +87,7 @@ def _tool_exec_protocol(wt_path: Path) -> str: '1. Run "next" to see your current task — it tells you the node type, ' "role, and what to do\n" "2. Execute the task:\n" - " - Agent nodes: run factory agent --task \"...\" --project \n" + ' - Agent nodes: run factory agent --task "..." --project \n' " - Study nodes: run the study command shown\n" " - Function nodes: run the command shown\n" '3. Run "next" again — the tool auto-detects that the previous node completed\n' @@ -98,12 +95,12 @@ def _tool_exec_protocol(wt_path: Path) -> str: "4. Repeat until GATE or DONE\n" "5. For GATE nodes: the tool asks you to evaluate — read the artifacts, then\n" ' call "submit" with your verdict (PROCEED, RETRY, or HALT)\n' - "6. If RETRY: the tool rewinds — run \"next\" to get the retry task\n" + '6. If RETRY: the tool rewinds — run "next" to get the retry task\n' "7. If DONE: report completion\n" "\n" "## Important\n" "\n" - "- For most nodes, just run the command and call \"next\" — the tool handles tracking\n" + '- For most nodes, just run the command and call "next" — the tool handles tracking\n' '- Only call "submit" for gate verdicts (PROCEED/RETRY/HALT)\n' "- The tool auto-detects agent completion via .factory/reviews/ files\n" "- The tool auto-evaluates fn gates (precheck, guard) on your behalf\n" @@ -144,16 +141,32 @@ def _tool_exec_protocol(wt_path: Path) -> str: def _validate_ceo_flags( args: argparse.Namespace, -) -> tuple[str, bool, bool, bool, str | None, str | None, str | None, str | None, bool, str | None, bool] | int: +) -> ( + tuple[ + str, + bool, + bool, + bool, + str | None, + str | None, + str | None, + str | None, + bool, + str | None, + bool, + ] + | int +): """Validate and resolve top-level CLI flags. Returns parsed values or an error code.""" mode: str = getattr(args, "mode", "auto") if mode == "interactive": mode = "design" if mode.startswith("project:"): - mode = mode[len("project:"):] + mode = mode[len("project:") :] all_modes = get_all_ceo_modes() if mode not in all_modes and mode != "auto": from factory.workflow.registry import WorkflowRegistry + raw_path = getattr(args, "path", None) project_path = Path(raw_path).resolve() if raw_path else Path.cwd() entries = WorkflowRegistry.discover(project_path) @@ -209,6 +222,7 @@ def _validate_ceo_flags( raw_path = getattr(args, "path", None) if not raw_path: from factory.plugins import get_registry + plugin_registry = get_registry() has_pre_hooks = bool(plugin_registry.ceo_pre_hooks) if not has_pre_hooks: @@ -294,7 +308,19 @@ def _validate_ceo_flags( ) return 1 - return (mode, headless, bg, bg_agents, prompt_file, focus, dir_name, refine_request, auto_approve, from_plan, just_plan) + return ( + mode, + headless, + bg, + bg_agents, + prompt_file, + focus, + dir_name, + refine_request, + auto_approve, + from_plan, + just_plan, + ) # ── project resolution ──────────────────────────────────────── @@ -454,9 +480,23 @@ def _validate_late_flags( ) return 1 - if focus and mode not in ("improve", "research", "create", "evolve", "study", "frontend-design", "frontend-design-discover") and not design_existing and not just_plan: + if ( + focus + and mode + not in ( + "design", + "research", + "create", + "evolve", + "study", + "frontend-design", + "frontend-design-discover", + ) + and not design_existing + and not just_plan + ): print( - f"Error: --focus (targeted mode) only works in improve, research, create, evolve, study, frontend-design, " + f"Error: --focus (targeted mode) only works in design, research, create, evolve, study, frontend-design, " f"frontend-design-discover, or design (with --just-plan) mode, " f"got '{mode}'. The project must already be built before targeting specific items.", file=sys.stderr, @@ -602,7 +642,7 @@ def _execute_ceo( elif mode == "design": ceo_mode = "design" elif interactive: - ceo_mode = "build" + ceo_mode = "design" else: ceo_mode = mode @@ -746,12 +786,18 @@ def _execute_ceo( if engine == "tool": base_prompt = resolve_prompt( - "ceo", wt_path, use_profile=use_profile, workflow_mode=None, + "ceo", + wt_path, + use_profile=use_profile, + workflow_mode=None, ) prompt = base_prompt + _tool_exec_protocol(wt_path) else: prompt = resolve_prompt( - "ceo", wt_path, use_profile=use_profile, workflow_mode=ceo_mode, + "ceo", + wt_path, + use_profile=use_profile, + workflow_mode=ceo_mode, ) runner = get_runner(runner_name) extras: dict[str, object] = {} @@ -776,6 +822,7 @@ def _execute_ceo( if engine == "tool": try: from factory.workflow.tool import tool_finalize + finalize_result = tool_finalize(wt_path) log.info("tool_exec.finalized", result=finalize_result) except Exception: @@ -847,13 +894,18 @@ def _run_headless( executor = WorkflowExecutor(wf, wt_path, agent_pool=DEFAULT_AGENT_POOL) try: exec_result = asyncio.run(executor.execute()) - print(json.dumps({ - "workflow": ceo_mode, - "engine": "deterministic", - "success": exec_result.success, - "nodes_executed": exec_result.nodes_executed, - "duration_ms": round(exec_result.duration_ms, 1), - }, indent=2)) + print( + json.dumps( + { + "workflow": ceo_mode, + "engine": "deterministic", + "success": exec_result.success, + "nodes_executed": exec_result.nodes_executed, + "duration_ms": round(exec_result.duration_ms, 1), + }, + indent=2, + ) + ) code = 0 if exec_result.success else 1 if code != 0: return code @@ -863,7 +915,7 @@ def _run_headless( min_growth=min_growth, max_new=max_new, branch=branch, - already_improved=mode in ("improve", "meta") or discover_only, + already_improved=mode in ("design", "meta") or discover_only, model=model, no_github=no_github, use_profile=use_profile, @@ -916,7 +968,7 @@ def _run_headless( min_growth=min_growth, max_new=max_new, branch=branch, - already_improved=mode in ("improve", "meta") or discover_only, + already_improved=mode in ("design", "meta") or discover_only, model=model, no_github=no_github, use_profile=use_profile, @@ -929,6 +981,7 @@ def _run_headless( if engine == "tool": try: from factory.workflow.tool import tool_finalize + tool_finalize(wt_path) except Exception: pass diff --git a/factory/cli/_helpers.py b/factory/cli/_helpers.py index 4bc4da77f..7fb6607b4 100644 --- a/factory/cli/_helpers.py +++ b/factory/cli/_helpers.py @@ -17,17 +17,20 @@ _WIZARD_INPUT_PATH = Path("~/.factory/wizard_input.md") +DEAD_MODES: dict[str, str] = { + "build": "design", + "improve": "design", + "discover": "design", + "interactive": "design", + "parallel-improve": "design", +} + CEO_MODES = [ "auto", "auto-fresh", - "build", - "discover", "founder", - "improve", "meta", "design", - "interactive", - "parallel-improve", "research", "review", "deep-qa", @@ -46,12 +49,9 @@ RUN_MODES = [ "auto", "auto-fresh", - "build", - "discover", + "design", "founder", - "improve", "meta", - "parallel-improve", "research", "study", "swebench", @@ -69,15 +69,10 @@ def get_all_ceo_modes() -> list[str]: DEPRECATED_MODES: frozenset[str] = frozenset( { - "build", - "improve", "research", "meta", - "discover", "review", "refine", - "parallel-improve", - "interactive", } ) @@ -87,12 +82,9 @@ def warn_deprecated_mode(mode: str) -> None: if mode not in DEPRECATED_MODES: return replacement = "design" - extra = "" - if mode == "interactive": - extra = " ('interactive' is an alias for 'design')" log.warning("deprecated_cli_mode", mode=mode, replacement=replacement) print( - f"WARNING: --mode {mode} is deprecated{extra}. " + f"WARNING: --mode {mode} is deprecated. " f"Use --mode {replacement} instead. " f"This mode remains functional but will be removed in a future release.", file=sys.stderr, diff --git a/factory/cli/_mode_handlers.py b/factory/cli/_mode_handlers.py index 88fd92dfc..638ac69d9 100644 --- a/factory/cli/_mode_handlers.py +++ b/factory/cli/_mode_handlers.py @@ -1,4 +1,5 @@ """Mode-specific early-exit handlers for CEO commands (review, deep-qa).""" + from __future__ import annotations import argparse @@ -48,7 +49,9 @@ def _resolve_bg_agents(args: argparse.Namespace) -> bool: return bool(val and val.lower() in ("1", "true", "yes")) -def _auto_detect_mode(project_path: Path, has_prompt: bool = False, force_fresh: bool = False) -> str: +def _auto_detect_mode( + project_path: Path, has_prompt: bool = False, force_fresh: bool = False +) -> str: """Detect the right mode based on project state. Checks for an in-flight cycle first — if one exists, returns its mode @@ -58,33 +61,39 @@ def _auto_detect_mode(project_path: Path, has_prompt: bool = False, force_fresh: project_path: Path to the project. has_prompt: True if a build spec is available. force_fresh: If True, ignores in-flight cycle and detects from scratch. - - When a build spec is available (--prompt, idea file, or raw prompt), - no_factory routes to build (not discover). """ + import structlog + from factory.ceo_completion import read_cycle_state + from factory.cli._helpers import DEAD_MODES + from factory.cli._path_resolver import _has_research_target from factory.models import ProjectState from factory.state import detect_state - from factory.cli._path_resolver import _has_research_target + _log = structlog.get_logger() if not force_fresh: cycle_state = read_cycle_state(project_path) if cycle_state: + mode = cycle_state.mode + if mode in DEAD_MODES: + new_mode = DEAD_MODES[mode] + _log.warning("cycle_state.mode_migrated", old=mode, new=new_mode) + mode = new_mode print( - f" In-flight cycle: {cycle_state.cycle_id} → mode: {cycle_state.mode} " + f" In-flight cycle: {cycle_state.cycle_id} → mode: {mode} " f"(respawns: {cycle_state.respawns})", file=sys.stderr, ) - return cycle_state.mode + return mode state = detect_state(project_path) mode_map = { - ProjectState.NO_REPO: "build", - ProjectState.REPO_INCOMPLETE: "build", - ProjectState.NO_FACTORY: "build" if has_prompt else "discover", - ProjectState.EVALS_PENDING_REVIEW: "discover", - ProjectState.HAS_FACTORY: "improve", + ProjectState.NO_REPO: "design", + ProjectState.REPO_INCOMPLETE: "design", + ProjectState.NO_FACTORY: "design", + ProjectState.EVALS_PENDING_REVIEW: "design", + ProjectState.HAS_FACTORY: "design", } mode = mode_map[state] diff --git a/factory/cli/_task_builder.py b/factory/cli/_task_builder.py index 2e1a3b9ea..f57b7fdb8 100644 --- a/factory/cli/_task_builder.py +++ b/factory/cli/_task_builder.py @@ -17,12 +17,10 @@ def _slug(desc: str) -> str: def _mode_suffix(mode: str, discover_only: bool) -> str: _SIMPLE_MODE_SUFFIXES = { - "build": ( - "\n\nRun Build mode: the project is new or incomplete. Run the Plan Loop " - "(P0-P3) to produce an approved build plan, then follow the Build pipeline " - "(B3-B6): Build phases → E2E verification. " - "Do NOT skip to Improve mode — the project needs to be built first. " - "The full step-by-step playbook is in your system prompt above." + "design": ( + "\n\nRun Design mode: the universal entry point. Design mode handles build, " + "discover, and improve workflows inline via its conditional gates. " + "Follow the step-by-step playbook in your system prompt above." ), "meta": ( "\n\nRun Meta mode: full self-improvement. First, run the complete Improve loop " diff --git a/factory/cli/run.py b/factory/cli/run.py index 22617b23a..450158718 100644 --- a/factory/cli/run.py +++ b/factory/cli/run.py @@ -1,4 +1,5 @@ """Factory run command — single-shot and heartbeat loop execution.""" + from __future__ import annotations import argparse @@ -205,7 +206,7 @@ def _chain_modes( if state == ProjectState.HAS_FACTORY and already_improved: return 0 next_mode = _auto_detect_mode(project_path) - if next_mode == "improve": + if next_mode == "design": already_improved = True print( f"[factory] Chaining: state={state.value} → mode={next_mode} " @@ -332,10 +333,7 @@ def _shutdown_handler(signum: int, frame: object) -> None: signal.signal(signal.SIGINT, old_sigint) elapsed = time.monotonic() - start_time - print( - f"[factory] Shutting down gracefully after {cycle} cycles." - f" Total runtime: {elapsed:.0f}s" - ) + print(f"[factory] Shutting down gracefully after {cycle} cycles. Total runtime: {elapsed:.0f}s") return 0 @@ -432,9 +430,9 @@ def cmd_run(args: argparse.Namespace) -> int: file=sys.stderr, ) return 1 - if focus and mode not in ("improve", "research"): + if focus and mode not in ("design", "research"): print( - f"Error: --focus (targeted mode) only works in improve or research mode, got '{mode}'. " + f"Error: --focus (targeted mode) only works in design or research mode, got '{mode}'. " "The project must already be built before targeting specific items.", file=sys.stderr, ) @@ -457,7 +455,7 @@ def cmd_run(args: argparse.Namespace) -> int: print(f" Cleaned {len(pruned)} stale worktree(s)", file=sys.stderr) budget_kwargs = dict(min_growth=min_growth, max_new=max_new, branch=branch) - skip_improve = mode in ("improve", "meta") or discover_only + skip_improve = mode in ("design", "meta") or discover_only overwrite = getattr(args, "overwrite", None) diff --git a/factory/visualizer/state.py b/factory/visualizer/state.py index a75e37c73..a9cbc4dbd 100644 --- a/factory/visualizer/state.py +++ b/factory/visualizer/state.py @@ -26,7 +26,7 @@ # Mode-specific phase definitions: (display_name, builder_key, is_loop_phase) MODE_PHASES: dict[str, list[tuple[str, str, bool]]] = { - "improve": [ + "design": [ ("Observe", "research", False), ("Hypothesize", "strategize", False), ("Build", "build", True), @@ -43,17 +43,6 @@ ("Run", "eval", True), ("Archive", "archive", False), ], - "build": [ - ("Research", "research", False), - ("Plan", "strategize", False), - ("Build", "build", True), - ("Verify", "eval", False), - ("Archive", "archive", False), - ], - "discover": [ - ("Detect", "detect", False), - ("Discover", "discover", False), - ], "meta": [ ("Observe", "research", False), ("Hypothesize", "strategize", False), @@ -66,7 +55,7 @@ } MODE_AGENT_TO_PHASE: dict[str, dict[str, str]] = { - "improve": { + "design": { "researcher": "Observe", "strategist": "Hypothesize", "builder": "Build", @@ -87,19 +76,6 @@ "adversarial_tester": "Run", "archivist": "Archive", }, - "build": { - "researcher": "Research", - "strategist": "Plan", - "builder": "Build", - "qa": "Verify", - "health_checker": "Verify", - "code_reviewer": "Verify", - "adversarial_tester": "Verify", - "archivist": "Archive", - }, - "discover": { - "researcher": "Discover", - }, "meta": { "researcher": "Observe", "strategist": "Hypothesize", @@ -113,7 +89,7 @@ } MODE_EVENT_TO_PHASE: dict[str, dict[str, str]] = { - "improve": { + "design": { "study.started": "Observe", "study.completed": "Observe", "insights.started": "Observe", @@ -131,18 +107,6 @@ "guard.completed": "Run", "archive.completed": "Archive", }, - "build": { - "study.started": "Research", - "study.completed": "Research", - "eval.started": "Verify", - "eval.completed": "Verify", - "archive.completed": "Archive", - }, - "discover": { - "detect": "Detect", - "discover.started": "Discover", - "discover.completed": "Discover", - }, "meta": { "study.started": "Observe", "study.completed": "Observe", @@ -206,9 +170,9 @@ def infer_mode_from_artifacts(factory_dir: Path) -> str | None: return "research" except (json.JSONDecodeError, OSError): pass - return "improve" + return "design" if (factory_dir / "eval_profile.json").exists(): - return "discover" + return "design" return None @@ -271,11 +235,11 @@ def update_state(state: FactoryLiveState, event: dict[str, Any]) -> FactoryLiveS if event_type == "detect": detected = data.get("state", "") mode_map = { - "new": "Build", - "init": "Build", - "discovered": "Improve", - "running": "Improve", - "stale": "Improve", + "new": "Design", + "init": "Design", + "discovered": "Design", + "running": "Design", + "stale": "Design", } inferred = mode_map.get(detected) if inferred and not state.current_mode: diff --git a/factory/workflow/definitions.py b/factory/workflow/definitions.py index 88f295935..e81a4b2a0 100644 --- a/factory/workflow/definitions.py +++ b/factory/workflow/definitions.py @@ -33,9 +33,7 @@ ForkNode, GateNode, JoinNode, - SelectionNode, Study, - SubgraphForkNode, VerdictType, Workflow, ) @@ -61,7 +59,6 @@ "doc_update_workflow", "spec_generate_workflow", "spec_update_workflow", - "parallel_improve_workflow", "founder_workflow", "frontend_design_workflow", "frontend_design_discover_workflow", @@ -686,8 +683,16 @@ def design_workflow(just_plan: bool = False) -> Workflow: Edge(source="gate_has_factory", target="graph_update", condition=VerdictType.PROCEED), Edge(source="gate_has_factory", target="discover", condition=VerdictType.HALT), Edge(source="discover", target="gate_factory_md_exists"), - Edge(source="gate_factory_md_exists", target="factory_init", condition=VerdictType.PROCEED), - Edge(source="gate_factory_md_exists", target="create_factory_md", condition=VerdictType.HALT), + Edge( + source="gate_factory_md_exists", + target="factory_init", + condition=VerdictType.PROCEED, + ), + Edge( + source="gate_factory_md_exists", + target="create_factory_md", + condition=VerdictType.HALT, + ), Edge(source="create_factory_md", target="factory_init"), Edge(source="factory_init", target="graph_update"), Edge(source="concat_study", target="fork_research"), @@ -4112,11 +4117,8 @@ def _get_builtin_registry() -> dict[str, Any]: if _BUILTIN_REGISTRY is not None: return _BUILTIN_REGISTRY _BUILTIN_REGISTRY = { - "build": build_workflow, "design": design_workflow, - "discover": discover_workflow, "review": review_workflow, - "improve": improve_workflow, "research": research_workflow, "meta": meta_workflow, "refine": refine_workflow, @@ -4129,7 +4131,6 @@ def _get_builtin_registry() -> dict[str, Any]: "frontend-design": frontend_design_workflow, "frontend-design-discover": frontend_design_discover_workflow, "frontend-design-scan": frontend_design_scan_workflow, - "parallel-improve": parallel_improve_workflow, "plan": lambda: design_workflow(just_plan=True), "evolve": evolve_workflow, "deep-research": lambda: __import__( @@ -4177,271 +4178,6 @@ def _get_builtin_registry() -> dict[str, Any]: return _BUILTIN_REGISTRY -# ── W₁₂: Parallel Improve Mode ───────────────────────────────── - - -def parallel_improve_workflow() -> Workflow: - """W₁₂: Parallel Improve — study → research → strategy → fork N experiments → select best. - - Reuses the improve workflow's shared prefix (study → research → strategy), - then forks N hypotheses into isolated git worktrees, runs the per-experiment - subgraph concurrently (begin → builder → QA → eval), joins at a barrier, - selects the best result, and merges the winner. - """ - nodes: dict[str, Any] = {} - edges: list[Edge] = [] - - # ── Shared prefix (identical to improve) ── - - nodes["study"] = Study( - id="study", - command="factory study {project_path}", - writes={".factory/strategy/observations.md"}, - ) - - nodes["researcher"] = AgentNode( - id="researcher", - role=AgentRole.RESEARCHER, - prompt_template=( - "Deep research for the project. " - "Read observations at .factory/strategy/observations.md. " - "Analyze codebase structure, eval scores, and experiment history. " - "Search the web for best practices relevant to weak dimensions. " - "Check .factory/archive/ for prior knowledge. " - "Write findings to .factory/strategy/research-local.md." - ), - reads={".factory/strategy/observations.md"}, - writes={".factory/strategy/research-local.md"}, - ) - - nodes["gate_research"] = GateNode( - id="gate_research", - evaluator_type="agent", - evaluator_role=AgentRole.CEO, - gate_prompt=( - "Are observations grounded in data? Did web research surface useful patterns? " - "Any blind spots in the analysis?" - ), - reads={".factory/strategy/research-local.md"}, - ) - - nodes["strategist"] = AgentNode( - id="strategist", - role=AgentRole.STRATEGIST, - prompt_template=( - "Generate prioritized hypotheses for PARALLEL execution. " - "Read the backlog at .factory/strategy/backlog.md — clear as many items as possible. " - "Read Hypothesis Budget from observations for constraints. " - "Read CEO research review at .factory/reviews/ceo-verdict-researcher.md. " - "Generate MULTIPLE independent hypotheses that can run concurrently. " - "Each hypothesis must target different files/areas to avoid merge conflicts. " - "Tag backlog items with **Backlog item:** and new items with **New:**. " - "Write to .factory/strategy/current.md with each hypothesis under a " - "## Hypothesis N heading." - ), - reads={".factory/strategy/research-local.md", ".factory/strategy/observations.md"}, - writes={".factory/strategy/current.md"}, - ) - - nodes["gate_strategy"] = GateNode( - id="gate_strategy", - evaluator_type="agent", - evaluator_role=AgentRole.CEO, - gate_prompt=( - "HARD GATE for parallel experiments. Check: " - "Are hypotheses independent (target different files/areas)? " - "Would merge conflicts be unlikely? " - "Each specific enough to implement? Scoped to one PR each? " - "Expected eval impact realistic? Follows FEEC priority? " - "Write PLAN APPROVED with approved hypotheses." - ), - reads={".factory/strategy/current.md"}, - ) - - # ── Per-experiment subgraph (runs N times in parallel worktrees) ── - - nodes["exp_begin"] = FnNode( - id="exp_begin", - command='factory begin {project_path} --hypothesis "$HYPOTHESIS"', - writes={".factory/experiments/current_id"}, - ) - - nodes["exp_builder"] = AgentNode( - id="exp_builder", - role=AgentRole.BUILDER, - prompt_template=( - "Implement the current hypothesis from .factory/strategy/current.md. " - "Read CLAUDE.md and factory.md. Read the CEO strategy approval. " - "Implement exactly what the hypothesis describes. Run tests. " - "Commit changes." - ), - reads={".factory/strategy/current.md"}, - writes={".factory/reviews/builder-latest.md"}, - ) - - nodes["exp_gate_build"] = GateNode( - id="exp_gate_build", - evaluator_type="agent", - evaluator_role=AgentRole.CEO, - gate_prompt=( - "Read builder output and diff. Does work match the hypothesis? " - "No scope creep? Tests included? REDIRECT if off-scope." - ), - reads={".factory/reviews/builder-latest.md"}, - ) - - dq_nodes, dq_edges = _deep_qa_subgraph( - code_reviewer_extra=" This is a parallel experiment branch.", - adversarial_extra=" This is a parallel experiment branch.", - ) - # Namespace deep-QA nodes for the experiment subgraph - exp_dq_nodes: dict[str, Any] = {} - exp_dq_edges: list[Edge] = [] - dq_rename = {nid: f"exp_{nid}" for nid in dq_nodes} - for nid, node in dq_nodes.items(): - new_id = dq_rename[nid] - update: dict[str, Any] = {"id": new_id} - # Rename ForkNode targets and JoinNode sources - if isinstance(node, ForkNode): - update["targets"] = [dq_rename.get(t, t) for t in node.targets] - if isinstance(node, JoinNode): - update["sources"] = [dq_rename.get(s, s) for s in node.sources] - new_node = node.model_copy(update=update) - exp_dq_nodes[new_id] = new_node - for edge in dq_edges: - exp_dq_edges.append( - Edge( - source=dq_rename[edge.source], - target=dq_rename[edge.target], - condition=edge.condition, - ) - ) - nodes.update(exp_dq_nodes) - - nodes["exp_gate_qa"] = GateNode( - id="exp_gate_qa", - evaluator_type="agent", - evaluator_role=AgentRole.CEO, - gate_prompt=( - "Review QA results for this experiment branch. " - "PROCEED if all checks pass. " - "RELOOP to exp_builder (max 3 iterations) if issues found." - ), - reads={ - ".factory/reviews/health-check.md", - ".factory/reviews/code-review.md", - ".factory/reviews/adversarial-qa.md", - }, - ) - - nodes["exp_gate_precheck"] = GateNode( - id="exp_gate_precheck", - evaluator_type="fn", - evaluator_command="factory precheck {project_path} --score-before 0 --score-after 0", - reads={".factory/reviews/adversarial-qa.md"}, - ) - - nodes["exp_eval"] = FnNode( - id="exp_eval", - command="factory eval {project_path}", - reads={".factory/reviews/adversarial-qa.md"}, - writes={".factory/last_eval.json"}, - ) - - # ── SubgraphForkNode: fork N experiment branches ── - - nodes["fork_experiments"] = SubgraphForkNode( - id="fork_experiments", - subgraph_entry="exp_begin", - subgraph_exit="exp_eval", - parallelism=3, - reads={".factory/strategy/current.md"}, - writes={".factory/parallel_results.json"}, - ) - - # ── JoinNode: barrier after all branches ── - - nodes["join_experiments"] = JoinNode( - id="join_experiments", - sources=["fork_experiments"], - reads={".factory/parallel_results.json"}, - writes={".factory/parallel_joined.json"}, - ) - - # ── SelectionNode: pick the best ── - - nodes["select_best"] = SelectionNode( - id="select_best", - strategy="best_score", - reads={".factory/parallel_joined.json"}, - writes={".factory/selection_result.json"}, - ) - - # ── Post-selection ── - - nodes["archivist"] = AgentNode( - id="archivist", - role=AgentRole.ARCHIVIST, - prompt_template=( - "Archive parallel experiment tournament results. " - "Record which hypotheses were tested, their scores, " - "which one won and why, and learnings from losers." - ), - reads={".factory/selection_result.json"}, - writes={".factory/archive/experiment.md"}, - blocking=False, - ) - - # ── Edges ── - - # Shared prefix - edges = [ - Edge(source="study", target="researcher"), - Edge(source="researcher", target="gate_research"), - Edge(source="gate_research", target="strategist", condition=VerdictType.PROCEED), - Edge(source="gate_research", target="researcher", condition=VerdictType.RELOOP), - Edge(source="strategist", target="gate_strategy"), - Edge(source="gate_strategy", target="fork_experiments", condition=VerdictType.PROCEED), - Edge(source="gate_strategy", target="strategist", condition=VerdictType.RELOOP), - ] - - # Per-experiment subgraph edges - edges.extend( - [ - Edge(source="exp_begin", target="exp_builder"), - Edge(source="exp_builder", target="exp_gate_build"), - Edge(source="exp_gate_build", target="exp_fork_qa", condition=VerdictType.PROCEED), - Edge(source="exp_gate_build", target="exp_builder", condition=VerdictType.RELOOP), - *exp_dq_edges, - Edge(source="exp_join_qa", target="exp_gate_qa"), - Edge(source="exp_gate_qa", target="exp_gate_precheck", condition=VerdictType.PROCEED), - Edge(source="exp_gate_qa", target="exp_builder", condition=VerdictType.RELOOP), - Edge(source="exp_gate_precheck", target="exp_eval", condition=VerdictType.PROCEED), - Edge(source="exp_gate_precheck", target="exp_eval", condition=VerdictType.HALT), - ] - ) - - # Fork → Join → Select → Archive - edges.extend( - [ - Edge(source="fork_experiments", target="join_experiments"), - Edge(source="join_experiments", target="select_best"), - Edge(source="select_best", target="archivist"), - ] - ) - - def trigger(state: ProjectState, ctx: dict[str, Any]) -> bool: - return state == ProjectState.HAS_FACTORY and ctx.get("mode") == "parallel-improve" - - return Workflow( - name="parallel-improve", - nodes=nodes, - edges=edges, - start_node="study", - trigger=trigger, - ) - - # ── W₁₃: Founder Mode ────────────────────────────────────────── diff --git a/tests/test_ceo_completion.py b/tests/test_ceo_completion.py index cd02d0af5..76fea15eb 100644 --- a/tests/test_ceo_completion.py +++ b/tests/test_ceo_completion.py @@ -19,13 +19,13 @@ def test_write_and_read_cycle_state(self, tmp_path: Path) -> None: write_cycle_state, ) - state = create_cycle_state("build", "Build a CLI tool") + state = create_cycle_state("design", "Build a CLI tool") write_cycle_state(tmp_path, state) loaded = read_cycle_state(tmp_path) assert loaded is not None assert loaded.cycle_id == state.cycle_id - assert loaded.mode == "build" + assert loaded.mode == "design" assert loaded.initial_prompt == "Build a CLI tool" assert loaded.respawns == 0 @@ -44,7 +44,7 @@ def test_delete_cycle_state(self, tmp_path: Path) -> None: write_cycle_state, ) - state = create_cycle_state("improve") + state = create_cycle_state("design") write_cycle_state(tmp_path, state) assert read_cycle_state(tmp_path) is not None @@ -73,7 +73,7 @@ def test_stale_cycle_state_ignored(self, tmp_path: Path) -> None: state_data = { "cycle_id": "old123", "started_at": old_time.isoformat(), - "mode": "build", + "mode": "design", "initial_prompt": "", "respawns": 5, } @@ -98,7 +98,7 @@ def test_cycle_state_truncates_long_prompt(self, tmp_path: Path) -> None: from factory.ceo_completion import create_cycle_state, write_cycle_state, read_cycle_state long_prompt = "x" * 5000 - state = create_cycle_state("build", long_prompt) + state = create_cycle_state("design", long_prompt) write_cycle_state(tmp_path, state) loaded = read_cycle_state(tmp_path) @@ -122,15 +122,15 @@ def test_claude_always_allowed(self, tmp_path: Path) -> None: class TestDetectIncomplete: """Tests for _detect_incomplete().""" - def test_build_incomplete_no_eval_profile(self, tmp_path: Path) -> None: + def test_design_incomplete_no_eval_profile(self, tmp_path: Path) -> None: """Build mode without strategy needs eval profile.""" from factory.ceo_completion import _detect_incomplete (tmp_path / ".factory").mkdir() - gap = _detect_incomplete(tmp_path, "build") + gap = _detect_incomplete(tmp_path, "design") assert gap is not None - assert gap.mode == "build" + assert gap.mode == "design" assert gap.next_item == "discovery" assert "no eval profile" in gap.reason @@ -151,10 +151,10 @@ def test_improve_complete_when_all_verdicts(self, tmp_path: Path) -> None: exp_dir.mkdir(parents=True) (exp_dir / "verdict.json").write_text('{"verdict": "keep"}') - gap = _detect_incomplete(tmp_path, "improve") + gap = _detect_incomplete(tmp_path, "design") assert gap is None - def test_improve_incomplete_when_missing_verdicts(self, tmp_path: Path) -> None: + def test_design_incomplete_when_missing_verdicts(self, tmp_path: Path) -> None: """Improve mode is incomplete when verdict count < hypothesis count.""" from factory.ceo_completion import _detect_incomplete @@ -170,20 +170,32 @@ def test_improve_incomplete_when_missing_verdicts(self, tmp_path: Path) -> None: exp_dir.mkdir(parents=True) (exp_dir / "verdict.json").write_text('{"verdict": "keep"}') - gap = _detect_incomplete(tmp_path, "improve") + gap = _detect_incomplete(tmp_path, "design") assert gap is not None assert gap.planned == 3 assert gap.completed == 1 assert gap.next_item == "H2" - assert "improve.incomplete" in gap.reason + assert "design.incomplete" in gap.reason - def test_improve_no_strategy_returns_none(self, tmp_path: Path) -> None: - """No strategy file means nothing planned — not incomplete.""" + def test_design_no_strategy_no_eval_profile_returns_gap(self, tmp_path: Path) -> None: + """No strategy + no eval profile means discovery is needed.""" from factory.ceo_completion import _detect_incomplete (tmp_path / ".factory").mkdir() - gap = _detect_incomplete(tmp_path, "improve") + gap = _detect_incomplete(tmp_path, "design") + assert gap is not None + assert "no eval profile" in gap.reason + + def test_design_no_strategy_with_eval_profile_returns_none(self, tmp_path: Path) -> None: + """No strategy but eval profile exists means nothing planned — not incomplete.""" + from factory.ceo_completion import _detect_incomplete + + factory_dir = tmp_path / ".factory" + factory_dir.mkdir() + (factory_dir / "eval_profile.json").write_text('{"dimensions": []}') + + gap = _detect_incomplete(tmp_path, "design") assert gap is None def test_discover_complete_when_profile_exists(self, tmp_path: Path) -> None: @@ -194,19 +206,19 @@ def test_discover_complete_when_profile_exists(self, tmp_path: Path) -> None: factory_dir.mkdir() (factory_dir / "eval_profile.json").write_text('{"dimensions": []}') - gap = _detect_incomplete(tmp_path, "discover") + gap = _detect_incomplete(tmp_path, "design") assert gap is None - def test_discover_incomplete_when_no_profile(self, tmp_path: Path) -> None: - """Discover mode is incomplete without eval_profile.json.""" + def test_design_incomplete_when_no_eval_profile_discover(self, tmp_path: Path) -> None: + """Design mode is incomplete without eval_profile.json when no hypotheses exist.""" from factory.ceo_completion import _detect_incomplete (tmp_path / ".factory").mkdir() - gap = _detect_incomplete(tmp_path, "discover") + gap = _detect_incomplete(tmp_path, "design") assert gap is not None - assert gap.mode == "discover" - assert "no eval_profile.json" in gap.reason + assert gap.mode == "design" + assert "no eval profile" in gap.reason class TestCountVerdictsWithResultsTsv: @@ -390,7 +402,7 @@ def test_improve_filters_by_cycle_start(self, tmp_path: Path) -> None: # Current cycle started at 10:00 on Apr 29 — only 1 verdict should count cycle_start = datetime(2026, 4, 29, 10, 0, 0, tzinfo=timezone.utc) - gap = _detect_incomplete(tmp_path, "improve", cycle_started_at=cycle_start) + gap = _detect_incomplete(tmp_path, "design", cycle_started_at=cycle_start) # Should be incomplete: 2 hypotheses, only 1 current-cycle verdict assert gap is not None @@ -420,7 +432,7 @@ def test_improve_complete_with_cycle_filtering(self, tmp_path: Path) -> None: ) cycle_start = datetime(2026, 4, 29, 10, 0, 0, tzinfo=timezone.utc) - gap = _detect_incomplete(tmp_path, "improve", cycle_started_at=cycle_start) + gap = _detect_incomplete(tmp_path, "design", cycle_started_at=cycle_start) # Should be complete: 2 hypotheses, 2 current-cycle verdicts assert gap is None @@ -447,13 +459,13 @@ def test_build_filters_by_cycle_start(self, tmp_path: Path) -> None: ) cycle_start = datetime(2026, 4, 29, 10, 0, 0, tzinfo=timezone.utc) - gap = _detect_incomplete(tmp_path, "build", cycle_started_at=cycle_start) + gap = _detect_incomplete(tmp_path, "design", cycle_started_at=cycle_start) # Should be incomplete: 3 phases, only 1 current-cycle verdict assert gap is not None assert gap.planned == 3 assert gap.completed == 1 - assert gap.next_item == "Phase2" + assert gap.next_item == "H2" class TestDetectIncompleteResearchMode: @@ -517,15 +529,15 @@ def test_improve_continuation(self) -> None: from factory.ceo_completion import _build_continuation_task, IncompleteGap gap = IncompleteGap( - mode="improve", + mode="design", planned=5, completed=2, next_item="H3", - reason="improve.incomplete", + reason="design.incomplete", ) task = _build_continuation_task(gap) - assert "Resume execution from hypothesis H3" in task + assert "Resume execution from H3" in task assert "do not re-plan" in task assert "Spawn Builder for H3" in task assert "2/5" in task @@ -535,31 +547,31 @@ def test_discover_continuation(self) -> None: from factory.ceo_completion import _build_continuation_task, IncompleteGap gap = IncompleteGap( - mode="discover", + mode="design", planned=1, completed=0, next_item="eval_profile", - reason="discover.incomplete", + reason="design.incomplete", ) task = _build_continuation_task(gap) - assert "Resume Discovery" in task or "discover" in task.lower() + assert "design" in task.lower() - def test_build_continuation(self) -> None: - """Build mode continuation tells CEO to resume from next phase.""" + def test_design_continuation_with_phases(self) -> None: + """Design mode continuation tells CEO to resume from next hypothesis.""" from factory.ceo_completion import _build_continuation_task, IncompleteGap gap = IncompleteGap( - mode="build", + mode="design", planned=6, completed=3, - next_item="Phase4", - reason="build.incomplete", + next_item="H4", + reason="design.incomplete", ) task = _build_continuation_task(gap) - assert "Resume Build pipeline" in task - assert "Phase4" in task + assert "Resume execution from H4" in task + assert "3/6" in task def test_research_continuation(self) -> None: """Research mode continuation tells CEO to spawn Builder for next H.""" @@ -589,18 +601,18 @@ def test_continuation_includes_mode_directive(self) -> None: ) gap = IncompleteGap( - mode="build", + mode="design", planned=6, completed=3, next_item="Phase4", - reason="build.incomplete", + reason="design.incomplete", ) - cycle_state = create_cycle_state("build", "Build a CLI") + cycle_state = create_cycle_state("design", "Build a CLI") task = _build_continuation_task(gap, cycle_state) assert "## CRITICAL: Mode Override" in task assert "CONTINUATION" in task - assert "BUILD" in task + assert "DESIGN" in task assert "Do NOT re-detect mode" in task assert cycle_state.cycle_id in task @@ -635,7 +647,7 @@ async def test_complete_on_first_try_no_respawn( result, code = await run_ceo_with_completion_guard( tmp_path, "Initial task", - mode="improve", + mode="design", runner_name="claude", ) @@ -684,7 +696,7 @@ async def mock_invoke(role, task, path, **kwargs): result, code = await run_ceo_with_completion_guard( tmp_path, "Initial task", - mode="improve", + mode="design", runner_name="claude", ) @@ -716,7 +728,7 @@ async def test_respects_user_interrupt( result, code = await run_ceo_with_completion_guard( tmp_path, "Initial task", - mode="improve", + mode="design", runner_name="claude", ) @@ -745,7 +757,7 @@ async def mock_invoke(role, task, path, **kwargs): result, code = await run_ceo_with_completion_guard( tmp_path, "Initial task", - mode="improve", + mode="design", runner_name="claude", ) @@ -774,7 +786,7 @@ async def test_cap_hit_writes_incomplete_file( result, code = await run_ceo_with_completion_guard( tmp_path, "Initial task", - mode="improve", + mode="design", runner_name="claude", max_respawns=2, # Low cap for test ) @@ -809,7 +821,7 @@ async def test_disabled_via_env_var( result, code = await run_ceo_with_completion_guard( tmp_path, "Initial task", - mode="improve", + mode="design", runner_name="claude", ) @@ -837,7 +849,7 @@ async def test_creates_cycle_state_on_fresh_cycle( await run_ceo_with_completion_guard( tmp_path, "Build task", - mode="build", + mode="design", runner_name="claude", ) @@ -871,7 +883,7 @@ async def mock_invoke(role, task, path, **kwargs): await run_ceo_with_completion_guard( tmp_path, "Improve task", - mode="improve", + mode="design", runner_name="claude", ) @@ -919,13 +931,13 @@ async def mock_invoke(role, task, path, **kwargs): await run_ceo_with_completion_guard( tmp_path, "Build task", - mode="build", # Start in build mode + mode="design", # Start in design mode runner_name="claude", ) assert call_count == 2 # Both invocations should see the same mode - assert all(m == "build" for m in observed_modes) + assert all(m == "design" for m in observed_modes) async def test_respawn_increments_counter( self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch @@ -960,7 +972,7 @@ async def mock_invoke(role, task, path, **kwargs): await run_ceo_with_completion_guard( tmp_path, "Improve task", - mode="improve", + mode="design", runner_name="claude", ) @@ -1001,7 +1013,7 @@ async def mock_invoke(role, task, path, **kwargs): await run_ceo_with_completion_guard( tmp_path, "Task", - mode="improve", + mode="design", runner_name="claude", ) @@ -1012,7 +1024,7 @@ async def mock_invoke(role, task, path, **kwargs): assert len(respawn_events) == 1 assert "cycle_id" in respawn_events[0]["data"] assert "mode" in respawn_events[0]["data"] - assert respawn_events[0]["data"]["mode"] == "improve" + assert respawn_events[0]["data"]["mode"] == "design" class TestAutoDetectModeWithCycle: @@ -1027,12 +1039,12 @@ def test_returns_cycle_mode_when_inflight(self, tmp_path: Path) -> None: (tmp_path / ".git").mkdir() # Write in-flight cycle state for build mode - state = create_cycle_state("build", "Initial task") + state = create_cycle_state("design", "Initial task") write_cycle_state(tmp_path, state) # Even though project has no factory, should return build (from cycle) mode = _auto_detect_mode(tmp_path, has_prompt=False) - assert mode == "build" + assert mode == "design" def test_ignores_cycle_when_force_fresh(self, tmp_path: Path) -> None: """_auto_detect_mode ignores cycle.json when force_fresh=True.""" @@ -1043,12 +1055,12 @@ def test_ignores_cycle_when_force_fresh(self, tmp_path: Path) -> None: (tmp_path / ".git").mkdir() # Write in-flight cycle state for build mode - state = create_cycle_state("build", "Initial task") + state = create_cycle_state("design", "Initial task") write_cycle_state(tmp_path, state) # With force_fresh, should detect from state (no_factory → discover) mode = _auto_detect_mode(tmp_path, has_prompt=False, force_fresh=True) - assert mode == "discover" + assert mode == "design" def test_detects_normally_when_no_cycle(self, tmp_path: Path) -> None: """_auto_detect_mode detects from project state when no cycle.json.""" @@ -1059,7 +1071,7 @@ def test_detects_normally_when_no_cycle(self, tmp_path: Path) -> None: # No cycle state exists mode = _auto_detect_mode(tmp_path, has_prompt=False) - assert mode == "discover" # no_factory state + assert mode == "design" # no_factory state → design def test_detects_normally_when_cycle_stale(self, tmp_path: Path) -> None: """_auto_detect_mode ignores stale cycle.json.""" @@ -1076,7 +1088,7 @@ def test_detects_normally_when_cycle_stale(self, tmp_path: Path) -> None: state_data = { "cycle_id": "old123", "started_at": old_time.isoformat(), - "mode": "build", + "mode": "design", "initial_prompt": "", "respawns": 0, } @@ -1084,7 +1096,7 @@ def test_detects_normally_when_cycle_stale(self, tmp_path: Path) -> None: # Should ignore stale cycle and detect from state mode = _auto_detect_mode(tmp_path, has_prompt=False) - assert mode == "discover" # no_factory state + assert mode == "design" # no_factory state → design class TestCeoPromptResearchMode: @@ -1225,7 +1237,7 @@ async def test_background_bypasses_respawn_loop(self, tmp_path: Path) -> None: stdout, code = await run_ceo_with_completion_guard( tmp_path, "initial task", - mode="improve", + mode="design", background=True, ) @@ -1245,7 +1257,7 @@ def test_prints_hint_when_session_exists( """Resume hint is printed to stderr when session.json exists.""" from factory.ceo_completion import print_resume_hint, write_ceo_session_id - write_ceo_session_id(tmp_path, "abc-123", mode="improve") + write_ceo_session_id(tmp_path, "abc-123", mode="design") print_resume_hint(tmp_path) captured = capsys.readouterr() @@ -1262,7 +1274,7 @@ def test_no_hint_when_session_cleaned_up( write_ceo_session_id, ) - write_ceo_session_id(tmp_path, "abc-123", mode="improve") + write_ceo_session_id(tmp_path, "abc-123", mode="design") delete_cycle_state(tmp_path) print_resume_hint(tmp_path) @@ -1300,14 +1312,14 @@ async def test_hint_printed_on_respawn_cap_hit( (strategy_dir / "current.md").write_text("#### H1: A\n") (tmp_path / ".factory" / "experiments").mkdir() - write_ceo_session_id(tmp_path, "test-session-id", mode="improve") + write_ceo_session_id(tmp_path, "test-session-id", mode="design") mock_invoke = AsyncMock(return_value=("Incomplete", 0)) with patch("factory.agents.runner.invoke_agent", mock_invoke): await run_ceo_with_completion_guard( tmp_path, "Initial task", - mode="improve", + mode="design", runner_name="claude", max_respawns=0, ) @@ -1329,14 +1341,14 @@ async def test_no_hint_on_clean_completion( exp_dir.mkdir(parents=True) (exp_dir / "verdict.json").write_text('{"verdict": "keep"}') - write_ceo_session_id(tmp_path, "test-session-id", mode="improve") + write_ceo_session_id(tmp_path, "test-session-id", mode="design") mock_invoke = AsyncMock(return_value=("Done", 0)) with patch("factory.agents.runner.invoke_agent", mock_invoke): await run_ceo_with_completion_guard( tmp_path, "Initial task", - mode="improve", + mode="design", runner_name="claude", ) @@ -1354,14 +1366,14 @@ async def test_hint_printed_on_user_interrupt( (strategy_dir / "current.md").write_text("#### H1: A\n") (tmp_path / ".factory" / "experiments").mkdir() - write_ceo_session_id(tmp_path, "interrupt-session", mode="improve") + write_ceo_session_id(tmp_path, "interrupt-session", mode="design") mock_invoke = AsyncMock(return_value=("Interrupted", 130)) with patch("factory.agents.runner.invoke_agent", mock_invoke): await run_ceo_with_completion_guard( tmp_path, "Initial task", - mode="improve", + mode="design", runner_name="claude", ) diff --git a/tests/test_chain_modes_terminal.py b/tests/test_chain_modes_terminal.py index 83e353294..e85278152 100644 --- a/tests/test_chain_modes_terminal.py +++ b/tests/test_chain_modes_terminal.py @@ -22,7 +22,7 @@ def _terminal_workflow() -> Workflow: def _non_terminal_workflow() -> Workflow: return Workflow( - name="improve", + name="design", nodes={"start": FnNode(id="start", command="true")}, edges=[], start_node="start", @@ -35,9 +35,7 @@ def test_returns_zero_for_terminal_mode(self, tmp_path: Path) -> None: """_chain_modes exits immediately when completed_mode is terminal.""" from factory.cli.run import _chain_modes - with patch.object( - WorkflowRegistry, "get_workflow", return_value=_terminal_workflow() - ): + with patch.object(WorkflowRegistry, "get_workflow", return_value=_terminal_workflow()): result = _chain_modes(tmp_path, completed_mode="swebench") assert result == 0 @@ -45,9 +43,10 @@ def test_does_not_call_run_single_cycle_for_terminal(self, tmp_path: Path) -> No """Terminal mode prevents any further cycle execution.""" from factory.cli.run import _chain_modes - with patch.object( - WorkflowRegistry, "get_workflow", return_value=_terminal_workflow() - ), patch("factory.cli.run._run_single_cycle") as mock_run: + with ( + patch.object(WorkflowRegistry, "get_workflow", return_value=_terminal_workflow()), + patch("factory.cli.run._run_single_cycle") as mock_run, + ): _chain_modes(tmp_path, completed_mode="swebench") mock_run.assert_not_called() @@ -55,14 +54,16 @@ def test_non_terminal_mode_proceeds(self, tmp_path: Path) -> None: """Non-terminal completed_mode does not short-circuit.""" from factory.cli.run import _chain_modes - with patch.object( - WorkflowRegistry, "get_workflow", return_value=_non_terminal_workflow() - ), \ - patch("factory.state.detect_state", return_value=ProjectState.HAS_FACTORY), \ - patch("factory.cli.run._auto_detect_mode", return_value="improve"), \ - patch("factory.cli.run._run_single_cycle", return_value=0): + with ( + patch.object(WorkflowRegistry, "get_workflow", return_value=_non_terminal_workflow()), + patch("factory.state.detect_state", return_value=ProjectState.HAS_FACTORY), + patch("factory.cli.run._auto_detect_mode", return_value="design"), + patch("factory.cli.run._run_single_cycle", return_value=0), + ): result = _chain_modes( - tmp_path, completed_mode="improve", already_improved=True, + tmp_path, + completed_mode="design", + already_improved=True, ) assert result == 0 @@ -70,9 +71,11 @@ def test_no_completed_mode_proceeds(self, tmp_path: Path) -> None: """Without completed_mode, _chain_modes runs normally.""" from factory.cli.run import _chain_modes - with patch("factory.state.detect_state", return_value=ProjectState.HAS_FACTORY), \ - patch("factory.cli.run._auto_detect_mode", return_value="improve"), \ - patch("factory.cli.run._run_single_cycle", return_value=0): + with ( + patch("factory.state.detect_state", return_value=ProjectState.HAS_FACTORY), + patch("factory.cli.run._auto_detect_mode", return_value="design"), + patch("factory.cli.run._run_single_cycle", return_value=0), + ): result = _chain_modes(tmp_path, already_improved=True) assert result == 0 @@ -87,8 +90,6 @@ def test_project_local_terminal_workflow(self, tmp_path: Path) -> None: start_node="start", terminal=True, ) - with patch.object( - WorkflowRegistry, "get_workflow", return_value=local_terminal - ): + with patch.object(WorkflowRegistry, "get_workflow", return_value=local_terminal): result = _chain_modes(tmp_path, completed_mode="custom_bench") assert result == 0 diff --git a/tests/test_checkpoint.py b/tests/test_checkpoint.py index 8582be7ee..b098e21fa 100644 --- a/tests/test_checkpoint.py +++ b/tests/test_checkpoint.py @@ -28,7 +28,7 @@ def checkpoint_project(tmp_path: Path) -> Path: def sample_state() -> CheckpointState: """Return a sample CheckpointState for testing.""" return CheckpointState( - mode="improve", + mode="design", active_experiment_id=38, completed_agents=["researcher", "strategist"], pending_agents=["builder", "health_checker"], @@ -46,7 +46,7 @@ def test_checkpoint_state_strict() -> None: """CheckpointState rejects extra fields.""" with pytest.raises(Exception): CheckpointState( - mode="improve", + mode="design", active_experiment_id=None, completed_agents=[], pending_agents=[], @@ -60,7 +60,7 @@ def test_checkpoint_state_strict() -> None: def test_checkpoint_state_nullable_fields() -> None: """CheckpointState allows None for optional fields.""" state = CheckpointState( - mode="build", + mode="design", active_experiment_id=None, completed_agents=[], pending_agents=["researcher"], @@ -75,7 +75,7 @@ def test_checkpoint_state_nullable_fields() -> None: def test_checkpoint_state_completed_hypotheses() -> None: """CheckpointState includes completed_hypotheses field.""" state = CheckpointState( - mode="improve", + mode="design", active_experiment_id=3, completed_agents=["researcher", "strategist"], pending_agents=["builder"], @@ -90,7 +90,7 @@ def test_checkpoint_state_completed_hypotheses() -> None: def test_checkpoint_state_completed_hypotheses_default() -> None: """completed_hypotheses defaults to empty list for backwards compat.""" state = CheckpointState( - mode="improve", + mode="design", active_experiment_id=None, completed_agents=[], pending_agents=[], @@ -114,7 +114,7 @@ def test_save_and_load(checkpoint_project: Path, sample_state: CheckpointState) loaded = load_checkpoint(checkpoint_project) assert loaded is not None assert loaded == sample_state - assert loaded.mode == "improve" + assert loaded.mode == "design" assert loaded.active_experiment_id == 38 assert loaded.completed_agents == ["researcher", "strategist"] assert loaded.pending_agents == ["builder", "health_checker"] @@ -156,7 +156,7 @@ def test_save_creates_factory_dir(tmp_path: Path, sample_state: CheckpointState) def test_format_full(sample_state: CheckpointState) -> None: """format_checkpoint includes all fields.""" output = format_checkpoint(sample_state) - assert "improve" in output + assert "design" in output assert "38" in output assert "researcher" in output assert "builder" in output @@ -170,7 +170,7 @@ def test_format_full(sample_state: CheckpointState) -> None: def test_format_empty_scores() -> None: """format_checkpoint omits eval scores line when empty.""" state = CheckpointState( - mode="discover", + mode="design", active_experiment_id=None, completed_agents=[], pending_agents=[], @@ -180,7 +180,8 @@ def test_format_empty_scores() -> None: ) output = format_checkpoint(state) assert "Eval scores" not in output - assert "discover" in output + assert "design" in output + assert "Mode:" in output def test_format_completed_hypotheses(sample_state: CheckpointState) -> None: @@ -213,7 +214,7 @@ def test_cli_checkpoint_save_and_show( str(checkpoint_project), "--save", "--mode", - "improve", + "design", "--experiment", "38", "--completed", @@ -233,7 +234,7 @@ def test_cli_checkpoint_save_and_show( code = main(["checkpoint", str(checkpoint_project)]) assert code == 0 output = capsys.readouterr().out - assert "improve" in output + assert "design" in output assert "38" in output assert "researcher" in output assert "builder" in output @@ -307,7 +308,7 @@ def test_cli_checkpoint_save_with_completed_hypotheses( str(checkpoint_project), "--save", "--mode", - "improve", + "design", "--completed", "researcher,strategist", "--pending", @@ -351,7 +352,7 @@ def test_load_checkpoint_backwards_compat(checkpoint_project: Path) -> None: checkpoint_path = checkpoint_project / ".factory" / "checkpoint.json" old_data = { - "mode": "improve", + "mode": "design", "active_experiment_id": None, "completed_agents": ["researcher"], "pending_agents": ["strategist"], @@ -365,3 +366,27 @@ def test_load_checkpoint_backwards_compat(checkpoint_project: Path) -> None: assert loaded is not None assert loaded.completed_hypotheses == [] assert loaded.completed_agents == ["researcher"] + + +@pytest.mark.parametrize( + "dead_mode", ["build", "improve", "discover", "interactive", "parallel-improve"] +) +def test_load_checkpoint_migrates_dead_modes(checkpoint_project: Path, dead_mode: str) -> None: + """load_checkpoint migrates dead modes to 'design'.""" + import json + + checkpoint_path = checkpoint_project / ".factory" / "checkpoint.json" + old_data = { + "mode": dead_mode, + "active_experiment_id": None, + "completed_agents": [], + "pending_agents": [], + "last_eval_scores": {}, + "current_hypothesis": None, + "timestamp": "2026-04-26T00:00:00", + } + checkpoint_path.write_text(json.dumps(old_data)) + + loaded = load_checkpoint(checkpoint_project) + assert loaded is not None + assert loaded.mode == "design" diff --git a/tests/test_cli.py b/tests/test_cli.py index d333c836e..3bd001a20 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -174,8 +174,8 @@ def test_ceo_mode_design(self): def test_ceo_mode_interactive_backward_compat(self): parser = build_parser() - args = parser.parse_args(["ceo", "distributed eval runner", "--mode", "interactive"]) - assert args.mode == "interactive" + args = parser.parse_args(["ceo", "distributed eval runner", "--mode", "design"]) + assert args.mode == "design" assert args.path == "distributed eval runner" def test_ceo_mode_project_prefix(self): @@ -419,10 +419,10 @@ def test_design_new_idea_mode_is_ideation(self): task = cmd[dsp_idx + 1] assert "Mode: ideation" in task - def test_interactive_backward_compat_alias(self, tmp_path): - """--mode interactive is accepted as a backward-compatible alias for design.""" + def test_design_mode_emits_plan_loop(self, tmp_path): + """--mode design generates a task with the Plan Loop section.""" with _mock_foreground() as mock_run: - main(["ceo", str(tmp_path), "--mode", "interactive"]) + main(["ceo", str(tmp_path), "--mode", "design"]) cmd = mock_run.call_args[0][0] dsp_idx = cmd.index("--dangerously-skip-permissions") task = cmd[dsp_idx + 1] @@ -430,7 +430,7 @@ def test_interactive_backward_compat_alias(self, tmp_path): def test_auto_approve_rejected_without_design_mode(self, capsys): """--auto-approve without --mode design is rejected.""" - result = main(["ceo", "/some/path", "--mode", "improve", "--auto-approve"]) + result = main(["ceo", "/some/path", "--mode", "founder", "--auto-approve"]) assert result == 1 assert "--auto-approve only applies to --mode design" in capsys.readouterr().err @@ -514,7 +514,7 @@ def test_auto_approve_false_by_default(self): class TestRunAutoApprove: def test_run_auto_approve_rejected_without_design(self, capsys): """cmd_run rejects --auto-approve when mode is not design.""" - result = main(["run", "/some/path", "--mode", "improve", "--auto-approve"]) + result = main(["run", "/some/path", "--mode", "founder", "--auto-approve"]) assert result == 1 assert "--auto-approve only applies to --mode design" in capsys.readouterr().err @@ -525,6 +525,14 @@ def test_run_auto_approve_rejected_default_mode(self, capsys): assert "--auto-approve only applies to --mode design" in capsys.readouterr().err +class TestRunFocusIncompatibleMode: + def test_run_focus_rejected_with_incompatible_mode(self, capsys): + """cmd_run rejects --focus with a mode other than design or research.""" + result = main(["run", "/some/path", "--mode", "founder", "--focus", "auth"]) + assert result == 1 + assert "only works in design or research mode" in capsys.readouterr().err + + class TestAutoApproveEvent: def test_execute_ceo_emits_auto_approve_event(self, tmp_path): """_execute_ceo calls _emit_cli_event with 'auto_approve.enabled' when flag is set.""" @@ -1045,13 +1053,13 @@ def test_mode_default_is_auto(self): def test_mode_discover(self): parser = build_parser() - args = parser.parse_args(["run", "/some/path", "--mode", "discover"]) - assert args.mode == "discover" + args = parser.parse_args(["run", "/some/path", "--mode", "design"]) + assert args.mode == "design" def test_mode_improve_explicit(self): parser = build_parser() - args = parser.parse_args(["run", "/some/path", "--mode", "improve"]) - assert args.mode == "improve" + args = parser.parse_args(["run", "/some/path", "--mode", "design"]) + assert args.mode == "design" def test_mode_meta(self): parser = build_parser() @@ -1109,18 +1117,18 @@ def test_run_local_path_no_clone(self, tmp_path): assert result == 0 mock_agent.assert_called_once() - def test_run_discover_mode(self, tmp_path): - """cmd_run with --mode=discover passes discover task to CEO.""" + def test_run_design_mode(self, tmp_path): + """cmd_run with --mode=design passes design task to CEO.""" with ( patch("factory.agents.runner.invoke_agent", _mock_invoke_agent_ok()) as mock_agent, patch("factory.cli.run._chain_modes", return_value=0), ): - result = main(["run", str(tmp_path), "--mode", "discover"]) + result = main(["run", str(tmp_path), "--mode", "design"]) assert result == 0 call_args = mock_agent.call_args task = call_args[0][1] # second positional arg is the task - assert "Discover mode" in task + assert "Mode: design" in task def test_run_meta_mode(self, tmp_path): """cmd_run with --mode=meta passes meta task to CEO.""" @@ -2016,48 +2024,48 @@ def test_auto_detect_improve_without_research(self, tmp_project, sample_config): from factory.cli._mode_handlers import _auto_detect_mode mode = _auto_detect_mode(tmp_project, force_fresh=True) - assert mode == "improve" + assert mode == "design" class TestBuildCeoTaskDesign: """Unit tests for _build_ceo_task design_existing parameter.""" def test_existing_project_emits_plan_loop_section(self, tmp_path): - task = _build_ceo_task(tmp_path, "build", design_existing=True) + task = _build_ceo_task(tmp_path, "design", design_existing=True) assert "## Plan Loop (Interactive)" in task assert "existing_project: true" in task assert "existing project" in task def test_existing_project_with_focus(self, tmp_path): - task = _build_ceo_task(tmp_path, "build", design_existing=True, focus="auth layer") + task = _build_ceo_task(tmp_path, "design", design_existing=True, focus="auth layer") assert "## Plan Loop (Interactive)" in task assert "auth layer" in task assert "Focus topic" in task def test_existing_project_without_focus(self, tmp_path): - task = _build_ceo_task(tmp_path, "build", design_existing=True) + task = _build_ceo_task(tmp_path, "design", design_existing=True) assert "No specific topic was provided" in task def test_new_idea_emits_plan_loop_section(self, tmp_path): - task = _build_ceo_task(tmp_path, "build", design_idea="weather CLI") + task = _build_ceo_task(tmp_path, "design", design_idea="weather CLI") assert "## Plan Loop (Interactive)" in task assert "weather CLI" in task def test_existing_uses_same_header_as_new_idea(self, tmp_path): """Both new ideas and existing projects use the same Plan Loop header.""" - existing_task = _build_ceo_task(tmp_path, "build", design_existing=True) - new_task = _build_ceo_task(tmp_path, "build", design_idea="weather CLI") + existing_task = _build_ceo_task(tmp_path, "design", design_existing=True) + new_task = _build_ceo_task(tmp_path, "design", design_idea="weather CLI") assert "## Plan Loop (Interactive)" in existing_task assert "## Plan Loop (Interactive)" in new_task def test_existing_project_has_existing_flag(self, tmp_path): """Existing project task includes the existing_project flag for CEO conditionals.""" - task = _build_ceo_task(tmp_path, "build", design_existing=True) + task = _build_ceo_task(tmp_path, "design", design_existing=True) assert "existing_project: true" in task def test_existing_mode_shows_display_mode(self, tmp_path): """When display_mode is provided, task shows it instead of internal mode.""" - task = _build_ceo_task(tmp_path, "build", design_existing=True, display_mode="design") + task = _build_ceo_task(tmp_path, "design", design_existing=True, display_mode="design") assert "Mode: design" in task @@ -2089,7 +2097,7 @@ def test_create_mode_without_focus(self, tmp_path): def test_build_ceo_task_create_description(self, tmp_path): """_build_ceo_task emits the Create Mode section when create_description is provided.""" - task = _build_ceo_task(tmp_path, "build", create_description="a mode for validating PRs") + task = _build_ceo_task(tmp_path, "design", create_description="a mode for validating PRs") assert "## Create Mode (New Factory Mode)" in task assert "a mode for validating PRs" in task assert "Mode description from user" in task @@ -2097,7 +2105,7 @@ def test_build_ceo_task_create_description(self, tmp_path): def test_build_ceo_task_no_create_description(self, tmp_path): """_build_ceo_task omits the Create Mode section when create_description is None.""" - task = _build_ceo_task(tmp_path, "build", create_description=None) + task = _build_ceo_task(tmp_path, "design", create_description=None) assert "## Create Mode (New Factory Mode)" not in task @@ -2447,7 +2455,7 @@ def test_refine_default_is_none(self): def test_refine_exclusive_with_interactive(self, tmp_path, capsys): with _mock_foreground(): - result = main(["ceo", str(tmp_path), "--refine", "fix bug", "--mode", "interactive"]) + result = main(["ceo", str(tmp_path), "--refine", "fix bug", "--mode", "design"]) assert result == 1 assert "mutually exclusive" in capsys.readouterr().err @@ -2496,17 +2504,17 @@ class TestBuildCeoTaskRefine: """Tests for _build_ceo_task refinement mode section.""" def test_refine_request_emits_section(self, tmp_path): - task = _build_ceo_task(tmp_path, "build", refine_request="fix the login bug") + task = _build_ceo_task(tmp_path, "design", refine_request="fix the login bug") assert "## Refinement Mode" in task assert "fix the login bug" in task assert "Mode: Refine" in task def test_no_refine_request_omits_section(self, tmp_path): - task = _build_ceo_task(tmp_path, "build") + task = _build_ceo_task(tmp_path, "design") assert "## Refinement Mode" not in task def test_refine_request_none_omits_section(self, tmp_path): - task = _build_ceo_task(tmp_path, "build", refine_request=None) + task = _build_ceo_task(tmp_path, "design", refine_request=None) assert "## Refinement Mode" not in task @@ -2650,7 +2658,7 @@ class TestFromPlanFlag: def test_from_plan_requires_design_mode(self, capsys): """--from-plan without --mode design is rejected.""" - result = main(["ceo", "/some/path", "--mode", "improve", "--from-plan", "plan.md"]) + result = main(["ceo", "/some/path", "--mode", "founder", "--from-plan", "plan.md"]) assert result == 1 assert "--from-plan requires --mode design" in capsys.readouterr().err @@ -3046,7 +3054,7 @@ class TestJustPlanFlag: def test_just_plan_requires_design_mode(self, capsys): """--just-plan without --mode design is rejected.""" - result = main(["ceo", "/some/path", "--mode", "improve", "--just-plan"]) + result = main(["ceo", "/some/path", "--mode", "founder", "--just-plan"]) assert result == 1 assert "--just-plan requires --mode design" in capsys.readouterr().err diff --git a/tests/test_dashboard.py b/tests/test_dashboard.py index 1b11b2e27..991dd30e6 100644 --- a/tests/test_dashboard.py +++ b/tests/test_dashboard.py @@ -762,7 +762,7 @@ def test_mode_specific_phase_accepted(self, phase_projects_dir: Path): (factory / "reviews" / "researcher-latest.md").write_text("Output") from factory.events import emit_event - emit_event(proj, "cycle.started", data={"mode": "improve"}) + emit_event(proj, "cycle.started", data={"mode": "design"}) emit_event(proj, "agent.started", agent="researcher", data={"task": "study"}) emit_event(proj, "agent.completed", agent="researcher") emit_event(proj, "agent.started", agent="strategist", data={"task": "plan"}) diff --git a/tests/test_deprecation.py b/tests/test_deprecation.py index 1583a3822..a2e2ed453 100644 --- a/tests/test_deprecation.py +++ b/tests/test_deprecation.py @@ -2,41 +2,63 @@ from __future__ import annotations +import argparse from unittest.mock import patch import pytest -import structlog -from factory.cli._helpers import DEPRECATED_MODES, CEO_MODES, RUN_MODES, warn_deprecated_mode +from factory.cli._helpers import ( + CEO_MODES, + DEAD_MODES, + DEPRECATED_MODES, + RUN_MODES, + warn_deprecated_mode, +) EXPECTED_DEPRECATED = frozenset( { - "build", - "improve", "research", "meta", - "discover", "review", "refine", - "parallel-improve", - "interactive", } ) +EXPECTED_DEAD = { + "build": "design", + "improve": "design", + "discover": "design", + "interactive": "design", + "parallel-improve": "design", +} + def test_deprecated_modes_exact_set(): assert DEPRECATED_MODES == EXPECTED_DEPRECATED +def test_dead_modes_exact_map(): + assert DEAD_MODES == EXPECTED_DEAD + + def test_deprecated_modes_subset_of_known_modes(): - all_known = set(CEO_MODES) | set(RUN_MODES) | {"interactive", "refine", "review"} + all_known = set(CEO_MODES) | set(RUN_MODES) | {"refine", "review"} for mode in DEPRECATED_MODES: assert mode in all_known, f"{mode} is deprecated but not a known CLI mode" +def test_dead_modes_not_in_ceo_or_run(): + for mode in DEAD_MODES: + assert mode not in CEO_MODES, f"dead mode {mode} still in CEO_MODES" + assert mode not in RUN_MODES, f"dead mode {mode} still in RUN_MODES" + assert mode not in DEPRECATED_MODES, f"dead mode {mode} still in DEPRECATED_MODES" + + class TestWarnDeprecatedMode: def test_deprecated_mode_emits_structlog(self): + import structlog + cfg = structlog.get_config() old_processors = cfg.get("processors", []) try: @@ -48,29 +70,30 @@ def test_deprecated_mode_emits_structlog(self): orig_log = _helpers.log _helpers.log = log try: - warn_deprecated_mode("build") + warn_deprecated_mode("research") finally: _helpers.log = orig_log mock_warn.assert_called_once_with( - "deprecated_cli_mode", mode="build", replacement="design" + "deprecated_cli_mode", mode="research", replacement="design" ) finally: structlog.configure(processors=old_processors) def test_deprecated_mode_prints_stderr(self, capsys): with patch("factory.cli._helpers.log"): - warn_deprecated_mode("build") + warn_deprecated_mode("research") captured = capsys.readouterr() assert "WARNING" in captured.err - assert "--mode build is deprecated" in captured.err + assert "--mode research is deprecated" in captured.err assert "--mode design instead" in captured.err assert "remains functional" in captured.err - def test_interactive_has_alias_note(self, capsys): - with patch("factory.cli._helpers.log"): - warn_deprecated_mode("interactive") + def test_dead_mode_does_not_warn(self, capsys): + with patch("factory.cli._helpers.log") as mock_log: + warn_deprecated_mode("build") + mock_log.warning.assert_not_called() captured = capsys.readouterr() - assert "alias for 'design'" in captured.err + assert captured.err == "" def test_create_not_deprecated(self, capsys): with patch("factory.cli._helpers.log") as mock_log: @@ -120,3 +143,59 @@ def test_all_deprecated_modes_warn(self, mode, capsys): warn_deprecated_mode(mode) captured = capsys.readouterr() assert f"--mode {mode} is deprecated" in captured.err + + +class TestAutoDetectMigratesDeadModes: + """_auto_detect_mode migrates dead modes from stale cycle state.""" + + @pytest.mark.parametrize("dead_mode", sorted(EXPECTED_DEAD)) + def test_cycle_state_dead_mode_migrated(self, tmp_path, dead_mode): + from datetime import datetime, timezone + + from factory.cli._mode_handlers import _auto_detect_mode + from factory.models import CycleState + + state_dir = tmp_path / ".factory" / "state" + state_dir.mkdir(parents=True) + state = CycleState( + cycle_id="test-cycle", + started_at=datetime.now(tz=timezone.utc), + mode=dead_mode, + respawns=0, + ) + (state_dir / "cycle.json").write_text(state.model_dump_json()) + + mode = _auto_detect_mode(tmp_path) + assert mode == "design" + + +class TestValidateCeoFlagsModeAliases: + """_validate_ceo_flags migrates interactive alias and strips project: prefix.""" + + def _make_args(self, mode: str, path: str = "/tmp/proj"): + ns = argparse.Namespace() + ns.mode = mode + ns.path = path + ns.headless = False + ns.prompt = None + ns.from_plan = None + ns.just_plan = False + ns.focus = None + ns.refine = None + ns.auto_approve = False + ns.clean_pr = False + ns.plugin = False + ns.plugin_folder = None + return ns + + def test_interactive_alias_becomes_design(self): + from factory.cli._ceo_helpers import _validate_ceo_flags + + result = _validate_ceo_flags(self._make_args("interactive")) + assert result[0] == "design" + + def test_project_prefix_stripped(self): + from factory.cli._ceo_helpers import _validate_ceo_flags + + result = _validate_ceo_flags(self._make_args("project:design")) + assert result[0] == "design" diff --git a/tests/test_evolve_workflow.py b/tests/test_evolve_workflow.py index 1c2c8e2d3..bf9606977 100644 --- a/tests/test_evolve_workflow.py +++ b/tests/test_evolve_workflow.py @@ -252,7 +252,7 @@ def test_trigger_on_evolve_mode(self): def test_trigger_false_for_other_modes(self): """Trigger does not fire for non-evolve modes.""" wf = evolve_workflow() - assert wf.trigger(ProjectState.HAS_FACTORY, {"mode": "improve"}) is False + assert wf.trigger(ProjectState.HAS_FACTORY, {"mode": "design"}) is False assert wf.trigger(ProjectState.HAS_FACTORY, {}) is False def test_trigger_independent_of_state(self): diff --git a/tests/test_inner_outer_loop.py b/tests/test_inner_outer_loop.py index 53a77eb51..9020cb92c 100644 --- a/tests/test_inner_outer_loop.py +++ b/tests/test_inner_outer_loop.py @@ -306,7 +306,7 @@ def test_backward_compat_without_new_fields(self, tmp_path: Path) -> None: (project / ".factory").mkdir() old_data = { - "mode": "improve", + "mode": "design", "active_experiment_id": None, "completed_agents": [], "pending_agents": [], diff --git a/tests/test_issue.py b/tests/test_issue.py index 9e4cd8e11..bdaca6163 100644 --- a/tests/test_issue.py +++ b/tests/test_issue.py @@ -732,7 +732,7 @@ def test_cmd_ceo_multi_focus_assembles_correctly(self) -> None: ns = argparse.Namespace( path="/tmp/fake", profile=None, - mode="improve", + mode="design", headless=False, bg=False, bg_agents=False, @@ -789,7 +789,7 @@ def test_cmd_ceo_single_focus_assembles_correctly(self) -> None: ns = argparse.Namespace( path="/tmp/fake", profile=None, - mode="improve", + mode="design", headless=False, bg=False, bg_agents=False, @@ -840,7 +840,7 @@ def test_cmd_ceo_multi_focus_no_github_fails(self) -> None: ns = argparse.Namespace( path="/tmp/fake", profile=None, - mode="improve", + mode="design", headless=False, bg=False, bg_agents=False, @@ -880,7 +880,7 @@ def test_cmd_run_multi_focus_assembles_correctly(self) -> None: ns = argparse.Namespace( path="/tmp/fake", profile=None, - mode="improve", + mode="design", loop=False, focus="111 and 112", discover_only=False, @@ -938,7 +938,7 @@ def test_cmd_run_single_focus_assembles_correctly(self) -> None: ns = argparse.Namespace( path="/tmp/fake", profile=None, - mode="improve", + mode="design", loop=False, focus="42", discover_only=False, @@ -991,7 +991,7 @@ def test_cmd_run_multi_focus_no_github_fails(self) -> None: ns = argparse.Namespace( path="/tmp/fake", profile=None, - mode="improve", + mode="design", loop=False, focus="111 and 112", discover_only=False, @@ -1109,7 +1109,7 @@ def test_cmd_run_adds_focus_to_backlog(self) -> None: ns = argparse.Namespace( path="/tmp/fake", profile=None, - mode="improve", + mode="design", loop=False, focus="111 and 112", discover_only=False, @@ -1160,7 +1160,7 @@ def test_cmd_run_context_set_to_none_for_multi(self) -> None: ns = argparse.Namespace( path="/tmp/fake", profile=None, - mode="improve", + mode="design", loop=False, focus="111, 112", discover_only=False, diff --git a/tests/test_lazy_loading.py b/tests/test_lazy_loading.py index dd743e2e6..5fffff9f0 100644 --- a/tests/test_lazy_loading.py +++ b/tests/test_lazy_loading.py @@ -28,14 +28,37 @@ def test_registry_contains_all_workflows(self) -> None: registry = _get_builtin_registry() required = { - "build", "design", "improve", "research", "meta", - "discover", "review", "refine", "create", "founder", - "deep-qa", "swebench", "legacybench", "featurebench", - "programbench", "terminalbench", "tomswe", "salitrap", - "doc-generate", "doc-update", - "spec-generate", "spec-update", "parallel-improve", - "frontend-design", "frontend-design-discover", - "frontend-design-scan", "plan", "evolve", + "design", + "research", + "meta", + "review", + "refine", + "create", + "founder", + "deep-qa", + "swebench", + "legacybench", + "featurebench", + "programbench", + "terminalbench", + "tomswe", + "salitrap", + "doc-generate", + "doc-update", + "spec-generate", + "spec-update", + "frontend-design", + "frontend-design-discover", + "frontend-design-scan", + "plan", + "evolve", + "deep-research", + "study", + "research-standalone", + "swebenchifyhard", + "mini-swebench", + "devopsgym", + "outer-loop", } assert required.issubset(set(registry.keys())), ( f"Missing: {required - set(registry.keys())}" @@ -113,6 +136,7 @@ class TestTelemetryLazyImport: def _reset_telemetry(self): """Save and restore telemetry module state without reloading.""" import factory.telemetry + saved_has = factory.telemetry._HAS_LANGFUSE saved_client = factory.telemetry._client yield @@ -122,12 +146,14 @@ def _reset_telemetry(self): def test_langfuse_not_imported_at_module_level(self) -> None: """_HAS_LANGFUSE starts as None (lazy — not checked at import time).""" import factory.telemetry + factory.telemetry._HAS_LANGFUSE = None assert factory.telemetry._HAS_LANGFUSE is None def test_is_enabled_caches_import_result(self) -> None: """is_enabled() should cache the import check result.""" import factory.telemetry + factory.telemetry._HAS_LANGFUSE = None factory.telemetry._client = None @@ -141,6 +167,7 @@ def test_is_enabled_caches_import_result(self) -> None: def test_is_enabled_returns_false_without_host(self) -> None: """is_enabled() returns False when no LANGFUSE env vars are set.""" import factory.telemetry + factory.telemetry._client = None factory.telemetry._HAS_LANGFUSE = None @@ -177,9 +204,11 @@ async def test_timing_summary_emitted(self, tmp_path: Path) -> None: captured_events: list[dict] = [] with patch("factory.workflow.executor.log") as mock_log: + def capture_info(*args, **kwargs): if args and args[0] == "workflow.timing_summary": captured_events.append(kwargs) + mock_log.info = capture_info mock_log.debug = lambda *a, **kw: None mock_log.error = lambda *a, **kw: None diff --git a/tests/test_parallel_improve.py b/tests/test_parallel_improve.py deleted file mode 100644 index 2407bb90c..000000000 --- a/tests/test_parallel_improve.py +++ /dev/null @@ -1,1507 +0,0 @@ -"""Tests for the parallel experiment execution workflow.""" - -from __future__ import annotations - -import csv -import json -import os -from datetime import datetime, timezone -from pathlib import Path -from unittest.mock import AsyncMock, MagicMock, patch - -import pytest -from pydantic import ValidationError - -from factory.models import ExperimentRecord, FactoryConfig, ParallelConfig -from factory.store import _parse_parallel -from factory.workflow.definitions import parallel_improve_workflow, register_all -from factory.workflow.executor import ( - WorkflowExecutor, - _collect_subgraph_nodes, - _parse_hypotheses, -) -from factory.workflow.primitives import ( - Edge, - FnNode, - SelectionNode, - SubgraphForkNode, - Workflow, -) - - -# ── ParallelConfig model tests ────────────────────────────────── - - -class TestParallelConfig: - def test_defaults(self) -> None: - config = ParallelConfig() - assert config.parallel_hypotheses == 1 - assert config.selection_strategy == "best_score" - - def test_custom_values(self) -> None: - config = ParallelConfig(parallel_hypotheses=4, selection_strategy="best_score") - assert config.parallel_hypotheses == 4 - - def test_max_hypotheses(self) -> None: - config = ParallelConfig(parallel_hypotheses=8) - assert config.parallel_hypotheses == 8 - - def test_exceeds_max(self) -> None: - with pytest.raises(ValidationError): - ParallelConfig(parallel_hypotheses=9) - - def test_zero_invalid(self) -> None: - with pytest.raises(ValidationError): - ParallelConfig(parallel_hypotheses=0) - - def test_negative_invalid(self) -> None: - with pytest.raises(ValidationError): - ParallelConfig(parallel_hypotheses=-1) - - def test_extra_field_forbidden(self) -> None: - with pytest.raises(ValidationError): - ParallelConfig(unknown_field="x") # type: ignore[call-arg] - - -class TestFactoryConfigParallel: - def test_parallel_none_by_default(self) -> None: - config = FactoryConfig( - goal="test", scope=[], guards=[], eval_command="echo 1", - eval_threshold=0.5, constraints=[], - ) - assert config.parallel is None - - def test_parallel_config_accepted(self) -> None: - config = FactoryConfig( - goal="test", scope=[], guards=[], eval_command="echo 1", - eval_threshold=0.5, constraints=[], - parallel=ParallelConfig(parallel_hypotheses=3), - ) - assert config.parallel is not None - assert config.parallel.parallel_hypotheses == 3 - - -class TestSupersededVerdict: - def test_superseded_valid(self) -> None: - from datetime import datetime, timezone - record = ExperimentRecord( - id=1, timestamp=datetime.now(tz=timezone.utc), - hypothesis="test", change_summary="superseded", - issue_number=None, pr_number=None, - score_before=0.5, score_after=0.6, delta=0.1, - verdict="superseded", cost_usd=None, notes="", - ) - assert record.verdict == "superseded" - - -# ── Primitive node type tests ──────────────────────────────────── - - -class TestSubgraphForkNode: - def test_basic(self) -> None: - node = SubgraphForkNode( - id="fork", subgraph_entry="begin", subgraph_exit="eval", - ) - assert node.subgraph_entry == "begin" - assert node.subgraph_exit == "eval" - assert node.parallelism == 3 - assert node.worktree_isolated is True - - def test_custom_parallelism(self) -> None: - node = SubgraphForkNode( - id="fork", subgraph_entry="a", subgraph_exit="b", - parallelism=5, - ) - assert node.parallelism == 5 - - def test_extra_forbidden(self) -> None: - with pytest.raises(ValidationError): - SubgraphForkNode( - id="fork", subgraph_entry="a", subgraph_exit="b", - unknown=True, # type: ignore[call-arg] - ) - - -class TestSelectionNode: - def test_basic(self) -> None: - node = SelectionNode(id="select") - assert node.strategy == "best_score" - - def test_extra_forbidden(self) -> None: - with pytest.raises(ValidationError): - SelectionNode(id="select", unknown=True) # type: ignore[call-arg] - - -# ── Workflow definition tests ──────────────────────────────────── - - -class TestParallelImproveWorkflow: - def test_valid_graph(self) -> None: - wf = parallel_improve_workflow() - issues = wf.validate_graph() - assert issues == [], f"parallel-improve workflow has issues: {issues}" - - def test_name(self) -> None: - wf = parallel_improve_workflow() - assert wf.name == "parallel-improve" - - def test_start_node(self) -> None: - wf = parallel_improve_workflow() - assert wf.start_node == "study" - - def test_has_subgraph_fork(self) -> None: - wf = parallel_improve_workflow() - fork_nodes = [ - n for n in wf.nodes.values() - if isinstance(n, SubgraphForkNode) - ] - assert len(fork_nodes) == 1 - assert fork_nodes[0].id == "fork_experiments" - - def test_has_selection_node(self) -> None: - wf = parallel_improve_workflow() - sel_nodes = [ - n for n in wf.nodes.values() - if isinstance(n, SelectionNode) - ] - assert len(sel_nodes) == 1 - assert sel_nodes[0].id == "select_best" - - def test_registered(self) -> None: - workflows = register_all() - assert "parallel-improve" in workflows - - def test_trigger(self) -> None: - from factory.models import ProjectState - wf = parallel_improve_workflow() - assert wf.trigger is not None - assert wf.trigger(ProjectState.HAS_FACTORY, {"mode": "parallel-improve"}) - assert not wf.trigger(ProjectState.HAS_FACTORY, {"mode": "improve"}) - assert not wf.trigger(ProjectState.NO_REPO, {"mode": "parallel-improve"}) - - -# ── Helper function tests ──────────────────────────────────────── - - -class TestParseHypotheses: - def test_heading_format(self, tmp_path: Path) -> None: - f = tmp_path / "current.md" - f.write_text( - "## Hypothesis 1\nAdd caching\n\n" - "## Hypothesis 2\nRefactor auth\n" - ) - result = _parse_hypotheses(f) - assert len(result) == 2 - assert "caching" in result[0].lower() - assert "auth" in result[1].lower() - - def test_bullet_fallback(self, tmp_path: Path) -> None: - f = tmp_path / "current.md" - f.write_text("- **Add caching** to API\n- **Refactor auth** module\n") - result = _parse_hypotheses(f) - assert len(result) == 2 - - def test_empty_file(self, tmp_path: Path) -> None: - f = tmp_path / "current.md" - f.write_text("") - result = _parse_hypotheses(f) - assert result == [] - - -class TestCollectSubgraphNodes: - def test_linear_subgraph(self) -> None: - wf = Workflow( - name="test", - nodes={ - "pre": FnNode(id="pre", writes={"a"}), - "a": FnNode(id="a", writes={"b"}), - "b": FnNode(id="b", reads={"b"}, writes={"c"}), - "c": FnNode(id="c", reads={"c"}, writes={"d"}), - "post": FnNode(id="post", reads={"d"}), - }, - edges=[ - Edge(source="pre", target="a"), - Edge(source="a", target="b"), - Edge(source="b", target="c"), - Edge(source="c", target="post"), - ], - start_node="pre", - ) - result = _collect_subgraph_nodes(wf, "a", "c") - assert result == {"a", "b", "c"} - - def test_single_node(self) -> None: - wf = Workflow( - name="test", - nodes={ - "a": FnNode(id="a", writes={"x"}), - "b": FnNode(id="b", reads={"x"}), - }, - edges=[Edge(source="a", target="b")], - start_node="a", - ) - result = _collect_subgraph_nodes(wf, "a", "a") - assert result == {"a"} - - -# ── Executor dry-run tests ─────────────────────────────────────── - - -@pytest.fixture -def tmp_project(tmp_path: Path) -> Path: - factory_dir = tmp_path / ".factory" - factory_dir.mkdir() - (factory_dir / "strategy").mkdir() - (factory_dir / "reviews").mkdir() - (factory_dir / "experiments").mkdir() - (factory_dir / "archive").mkdir() - return tmp_path - - -class TestSubgraphForkDryRun: - async def test_dry_run_subgraph_fork(self, tmp_project: Path) -> None: - wf = Workflow( - name="test-parallel", - nodes={ - "pre": FnNode(id="pre", command="echo pre", writes={"pre.txt"}), - "fork": SubgraphForkNode( - id="fork", - subgraph_entry="step_a", - subgraph_exit="step_b", - parallelism=2, - reads={"pre.txt"}, - writes={"fork_result.json"}, - ), - "step_a": FnNode(id="step_a", writes={"a.txt"}), - "step_b": FnNode(id="step_b", reads={"a.txt"}, writes={"b.txt"}), - "post": FnNode(id="post", reads={"fork_result.json"}, writes={"done.txt"}), - }, - edges=[ - Edge(source="pre", target="fork"), - Edge(source="step_a", target="step_b"), - Edge(source="fork", target="post"), - ], - start_node="pre", - ) - - # Write strategy file so hypotheses can be parsed - strategy_dir = tmp_project / ".factory" / "strategy" - (strategy_dir / "current.md").write_text( - "## Hypothesis 1\nAdd caching\n\n## Hypothesis 2\nRefactor\n" - ) - - executor = WorkflowExecutor(wf, tmp_project, dry_run=True) - result = await executor.execute() - - assert result.success - assert "fork" in result.node_outputs - - async def test_dry_run_selection(self, tmp_project: Path) -> None: - wf = Workflow( - name="test-select", - nodes={ - "pre": FnNode(id="pre", command="echo pre", writes={"pre.txt"}), - "select": SelectionNode( - id="select", - reads={"pre.txt"}, - writes={"result.json"}, - ), - }, - edges=[Edge(source="pre", target="select")], - start_node="pre", - ) - - executor = WorkflowExecutor(wf, tmp_project, dry_run=True) - result = await executor.execute() - - assert result.success - assert "select" in result.node_outputs - selection = json.loads(result.node_outputs["select"]) - assert selection["strategy"] == "best_score" - assert selection["winner"] is None - - -# ── Checkpoint tests ───────────────────────────────────────────── - - -class TestCheckpointParallelFields: - def test_new_fields_default(self) -> None: - from factory.checkpoint import CheckpointState - state = CheckpointState( - mode="parallel-improve", - active_experiment_id=None, - completed_agents=[], - pending_agents=[], - last_eval_scores={}, - current_hypothesis=None, - timestamp="2026-01-01T00:00:00Z", - ) - assert state.active_experiment_ids == [] - assert state.parallel_branch_status == {} - - def test_with_parallel_fields(self) -> None: - from factory.checkpoint import CheckpointState, format_checkpoint - state = CheckpointState( - mode="parallel-improve", - active_experiment_id=None, - active_experiment_ids=[1, 2, 3], - completed_agents=[], - pending_agents=[], - last_eval_scores={}, - current_hypothesis=None, - parallel_branch_status={"1": "running", "2": "completed", "3": "failed"}, - timestamp="2026-01-01T00:00:00Z", - ) - assert state.active_experiment_ids == [1, 2, 3] - formatted = format_checkpoint(state) - assert "Parallel exps" in formatted - assert "Branch status" in formatted - - -# ── _parse_parallel tests (store.py coverage) ────────────────── - - -class TestParseParallel: - def test_empty_input_returns_none(self) -> None: - assert _parse_parallel("") is None - assert _parse_parallel([]) is None - - def test_list_with_hypotheses(self) -> None: - result = _parse_parallel(["parallel_hypotheses: 4"]) - assert result is not None - assert result.parallel_hypotheses == 4 - - def test_list_with_selection_strategy(self) -> None: - result = _parse_parallel(["selection_strategy: best_score"]) - assert result is not None - assert result.selection_strategy == "best_score" - - def test_list_with_both_keys(self) -> None: - result = _parse_parallel([ - "parallel_hypotheses: 3", - "selection_strategy: best_score", - ]) - assert result is not None - assert result.parallel_hypotheses == 3 - assert result.selection_strategy == "best_score" - - def test_string_input(self) -> None: - result = _parse_parallel("parallel_hypotheses: 2") - assert result is not None - assert result.parallel_hypotheses == 2 - - def test_invalid_hypotheses_value_skipped(self) -> None: - result = _parse_parallel(["parallel_hypotheses: abc"]) - assert result is None - - def test_unknown_keys_returns_none(self) -> None: - result = _parse_parallel(["unknown_key: value"]) - assert result is None - - def test_invalid_selection_strategy_skipped(self) -> None: - result = _parse_parallel(["selection_strategy: tournament"]) - assert result is None - - def test_out_of_range_returns_none(self) -> None: - result = _parse_parallel(["parallel_hypotheses: 0"]) - assert result is None - - def test_float_input(self) -> None: - result = _parse_parallel(3.0) - assert result is None - - def test_whitespace_handling(self) -> None: - result = _parse_parallel([" parallel_hypotheses : 5 "]) - assert result is not None - assert result.parallel_hypotheses == 5 - - -# ── ExperimentStore superseded roundtrip (store.py coverage) ──── - - -class TestSupersededFinalize: - async def test_finalize_superseded_writes_tsv(self, tmp_path: Path) -> None: - from factory.store import ExperimentStore - - project = tmp_path / "proj" - project.mkdir() - factory_dir = project / ".factory" - factory_dir.mkdir() - (factory_dir / "experiments").mkdir() - tsv_path = factory_dir / "results.tsv" - tsv_path.write_text( - "id\ttimestamp\thypothesis\tchange_summary\tissue_number\tpr_number\t" - "score_before\tscore_after\tdelta\tverdict\tcost_usd\tnotes\tresearch_citations\n" - ) - - store = ExperimentStore(project) - record = ExperimentRecord( - id=1, - timestamp=datetime.now(tz=timezone.utc), - hypothesis="test hypothesis", - change_summary="superseded by experiment 2", - issue_number=None, - pr_number=None, - score_before=0.5, - score_after=0.6, - delta=None, - verdict="superseded", - cost_usd=None, - notes="", - ) - await store.finalize(1, record) - - verdict_file = factory_dir / "experiments" / "001" / "verdict.json" - assert verdict_file.exists() - data = json.loads(verdict_file.read_text()) - assert data["verdict"] == "superseded" - assert data["delta"] == 0.1 - - with open(tsv_path, newline="") as f: - reader = csv.DictReader(f, dialect="excel-tab") - rows = list(reader) - assert len(rows) == 1 - assert rows[0]["verdict"] == "superseded" - - async def test_load_history_reads_superseded(self, tmp_path: Path) -> None: - from factory.store import ExperimentStore - - project = tmp_path / "proj" - project.mkdir() - factory_dir = project / ".factory" - factory_dir.mkdir() - (factory_dir / "experiments").mkdir() - tsv_path = factory_dir / "results.tsv" - tsv_path.write_text( - "id\ttimestamp\thypothesis\tchange_summary\tissue_number\tpr_number\t" - "score_before\tscore_after\tdelta\tverdict\tcost_usd\tnotes\tresearch_citations\n" - ) - - store = ExperimentStore(project) - record = ExperimentRecord( - id=1, - timestamp=datetime.now(tz=timezone.utc), - hypothesis="test", - change_summary="superseded", - issue_number=None, - pr_number=None, - score_before=None, - score_after=0.7, - delta=None, - verdict="superseded", - cost_usd=None, - notes="loser", - ) - await store.finalize(1, record) - - history = await store.load_history() - assert len(history) == 1 - assert history[0].verdict == "superseded" - assert history[0].notes == "loser" - - -# ── SubgraphForkNode validation error paths (validation.py) ──── - - -class TestSubgraphForkValidation: - def test_missing_entry_node(self) -> None: - wf = Workflow( - name="bad", - nodes={ - "start": FnNode(id="start", writes={"x"}), - "fork": SubgraphForkNode( - id="fork", subgraph_entry="missing", subgraph_exit="start", - reads={"x"}, - ), - }, - edges=[Edge(source="start", target="fork")], - start_node="start", - ) - issues = wf.validate_graph() - assert any("entry 'missing' not in nodes" in i for i in issues) - - def test_missing_exit_node(self) -> None: - wf = Workflow( - name="bad", - nodes={ - "start": FnNode(id="start", writes={"x"}), - "fork": SubgraphForkNode( - id="fork", subgraph_entry="start", subgraph_exit="missing", - reads={"x"}, - ), - }, - edges=[Edge(source="start", target="fork")], - start_node="start", - ) - issues = wf.validate_graph() - assert any("exit 'missing' not in nodes" in i for i in issues) - - def test_no_path_from_entry_to_exit(self) -> None: - wf = Workflow( - name="bad", - nodes={ - "start": FnNode(id="start", writes={"x"}), - "a": FnNode(id="a", writes={"y"}), - "b": FnNode(id="b", writes={"z"}), - "fork": SubgraphForkNode( - id="fork", subgraph_entry="a", subgraph_exit="b", - reads={"x"}, - ), - }, - edges=[ - Edge(source="start", target="fork"), - Edge(source="fork", target="a"), - ], - start_node="start", - ) - issues = wf.validate_graph() - assert any("no path from entry 'a' to exit 'b'" in i for i in issues) - - -# ── _execute_selection non-dry-run tests (executor.py coverage) ─ - - -class TestSelectionAllFailed: - async def test_all_branches_failed_halts(self, tmp_project: Path) -> None: - wf = Workflow( - name="test-select", - nodes={ - "pre": FnNode(id="pre", command="echo pre", writes={"pre.txt"}), - "select": SelectionNode(id="select", reads={"pre.txt"}, writes={"result.json"}), - }, - edges=[Edge(source="pre", target="select")], - start_node="pre", - ) - executor = WorkflowExecutor(wf, tmp_project, dry_run=False) - executor.result.node_outputs["fork"] = json.dumps([ - {"exp_id": 1, "success": False, "halted": True, "halt_reason": "err", - "worktree_path": "/tmp/fake", "branch": "factory/exp-1", "hypothesis": "h1"}, - {"exp_id": 2, "success": False, "halted": True, "halt_reason": "err", - "worktree_path": "/tmp/fake", "branch": "factory/exp-2", "hypothesis": "h2"}, - ]) - executor.completed_files = {"pre.txt"} - - await executor._execute_selection( - SelectionNode(id="select", reads={"pre.txt"}, writes={"result.json"}), - ) - - assert executor.result.halted is True - assert "all parallel experiment branches failed" in executor.result.halt_reason - - -class TestSelectionPicksBest: - async def test_selects_highest_score(self, tmp_project: Path) -> None: - wt1 = tmp_project / ".factory-worktrees" / "exp-1" - wt2 = tmp_project / ".factory-worktrees" / "exp-2" - wt1.mkdir(parents=True) - wt2.mkdir(parents=True) - (wt1 / ".factory").mkdir() - (wt2 / ".factory").mkdir() - (wt1 / ".factory" / "last_eval.json").write_text(json.dumps({"total": 0.7})) - (wt2 / ".factory" / "last_eval.json").write_text(json.dumps({"total": 0.9})) - - wf = Workflow( - name="test-select", - nodes={ - "pre": FnNode(id="pre", command="echo pre", writes={"pre.txt"}), - "select": SelectionNode(id="select", reads={"pre.txt"}, writes={"result.json"}), - }, - edges=[Edge(source="pre", target="select")], - start_node="pre", - ) - executor = WorkflowExecutor(wf, tmp_project, dry_run=False) - executor.result.node_outputs["fork"] = json.dumps([ - {"exp_id": 1, "success": True, "halted": False, "halt_reason": "", - "worktree_path": str(wt1), "branch": "factory/exp-1", "hypothesis": "h1"}, - {"exp_id": 2, "success": True, "halted": False, "halt_reason": "", - "worktree_path": str(wt2), "branch": "factory/exp-2", "hypothesis": "h2"}, - ]) - executor.completed_files = {"pre.txt"} - - mock_finalize = AsyncMock() - with patch("subprocess.run") as mock_sp, \ - patch("factory.store.ExperimentStore.finalize", mock_finalize): - mock_sp.return_value = MagicMock(returncode=0) - - await executor._execute_selection( - SelectionNode(id="select", reads={"pre.txt"}, writes={"result.json"}), - ) - - assert not executor.result.halted - selection = json.loads(executor.result.node_outputs["select"]) - assert selection["winner_exp_id"] == 2 - assert selection["winner_score"] == 0.9 - assert selection["total_branches"] == 2 - assert selection["successful_branches"] == 2 - - mock_finalize.assert_called_once() - finalized_record = mock_finalize.call_args[0][1] - assert finalized_record.verdict == "superseded" - - async def test_score_key_fallback(self, tmp_project: Path) -> None: - """Uses 'score' key when 'total' is absent.""" - wt1 = tmp_project / ".factory-worktrees" / "exp-1" - wt1.mkdir(parents=True) - (wt1 / ".factory").mkdir() - (wt1 / ".factory" / "last_eval.json").write_text(json.dumps({"score": 0.85})) - - wf = Workflow( - name="test-select", - nodes={ - "pre": FnNode(id="pre", command="echo pre", writes={"pre.txt"}), - "select": SelectionNode(id="select", reads={"pre.txt"}, writes={"result.json"}), - }, - edges=[Edge(source="pre", target="select")], - start_node="pre", - ) - executor = WorkflowExecutor(wf, tmp_project, dry_run=False) - executor.result.node_outputs["fork"] = json.dumps([ - {"exp_id": 1, "success": True, "halted": False, "halt_reason": "", - "worktree_path": str(wt1), "branch": "factory/exp-1", "hypothesis": "h1"}, - ]) - executor.completed_files = {"pre.txt"} - - with patch("subprocess.run") as mock_sp: - mock_sp.return_value = MagicMock(returncode=0) - await executor._execute_selection( - SelectionNode(id="select", reads={"pre.txt"}, writes={"result.json"}), - ) - - assert not executor.result.halted - selection = json.loads(executor.result.node_outputs["select"]) - assert selection["winner_score"] == 0.85 - - async def test_missing_eval_file_defaults_to_zero(self, tmp_project: Path) -> None: - wt1 = tmp_project / ".factory-worktrees" / "exp-1" - wt1.mkdir(parents=True) - - wf = Workflow( - name="test-select", - nodes={ - "pre": FnNode(id="pre", command="echo pre", writes={"pre.txt"}), - "select": SelectionNode(id="select", reads={"pre.txt"}, writes={"result.json"}), - }, - edges=[Edge(source="pre", target="select")], - start_node="pre", - ) - executor = WorkflowExecutor(wf, tmp_project, dry_run=False) - executor.result.node_outputs["fork"] = json.dumps([ - {"exp_id": 1, "success": True, "halted": False, "halt_reason": "", - "worktree_path": str(wt1), "branch": "factory/exp-1", "hypothesis": "h1"}, - ]) - executor.completed_files = {"pre.txt"} - - with patch("subprocess.run") as mock_sp: - mock_sp.return_value = MagicMock(returncode=0) - await executor._execute_selection( - SelectionNode(id="select", reads={"pre.txt"}, writes={"result.json"}), - ) - - selection = json.loads(executor.result.node_outputs["select"]) - assert selection["winner_score"] == 0.0 - - async def test_malformed_eval_json_defaults_to_zero(self, tmp_project: Path) -> None: - wt1 = tmp_project / ".factory-worktrees" / "exp-1" - wt1.mkdir(parents=True) - (wt1 / ".factory").mkdir() - (wt1 / ".factory" / "last_eval.json").write_text("not json") - - wf = Workflow( - name="test-select", - nodes={ - "pre": FnNode(id="pre", command="echo pre", writes={"pre.txt"}), - "select": SelectionNode(id="select", reads={"pre.txt"}, writes={"result.json"}), - }, - edges=[Edge(source="pre", target="select")], - start_node="pre", - ) - executor = WorkflowExecutor(wf, tmp_project, dry_run=False) - executor.result.node_outputs["fork"] = json.dumps([ - {"exp_id": 1, "success": True, "halted": False, "halt_reason": "", - "worktree_path": str(wt1), "branch": "factory/exp-1", "hypothesis": "h1"}, - ]) - executor.completed_files = {"pre.txt"} - - with patch("subprocess.run") as mock_sp: - mock_sp.return_value = MagicMock(returncode=0) - await executor._execute_selection( - SelectionNode(id="select", reads={"pre.txt"}, writes={"result.json"}), - ) - - selection = json.loads(executor.result.node_outputs["select"]) - assert selection["winner_score"] == 0.0 - - -class TestSelectionMergeFailure: - async def test_merge_failure_halts(self, tmp_project: Path) -> None: - import subprocess as sp - - wt1 = tmp_project / ".factory-worktrees" / "exp-1" - wt1.mkdir(parents=True) - - wf = Workflow( - name="test-select", - nodes={ - "pre": FnNode(id="pre", command="echo pre", writes={"pre.txt"}), - "select": SelectionNode(id="select", reads={"pre.txt"}, writes={"result.json"}), - }, - edges=[Edge(source="pre", target="select")], - start_node="pre", - ) - executor = WorkflowExecutor(wf, tmp_project, dry_run=False) - executor.result.node_outputs["fork"] = json.dumps([ - {"exp_id": 1, "success": True, "halted": False, "halt_reason": "", - "worktree_path": str(wt1), "branch": "factory/exp-1", "hypothesis": "h1"}, - ]) - executor.completed_files = {"pre.txt"} - - with patch("subprocess.run") as mock_sp: - mock_sp.side_effect = sp.CalledProcessError(1, "git merge") - await executor._execute_selection( - SelectionNode(id="select", reads={"pre.txt"}, writes={"result.json"}), - ) - - assert executor.result.halted is True - assert "failed to merge winner branch" in executor.result.halt_reason - - -class TestSelectionCleanup: - async def test_finalize_failure_is_logged_not_fatal(self, tmp_project: Path) -> None: - wt1 = tmp_project / ".factory-worktrees" / "exp-1" - wt2 = tmp_project / ".factory-worktrees" / "exp-2" - wt1.mkdir(parents=True) - wt2.mkdir(parents=True) - - wf = Workflow( - name="test-select", - nodes={ - "pre": FnNode(id="pre", command="echo pre", writes={"pre.txt"}), - "select": SelectionNode(id="select", reads={"pre.txt"}, writes={"result.json"}), - }, - edges=[Edge(source="pre", target="select")], - start_node="pre", - ) - executor = WorkflowExecutor(wf, tmp_project, dry_run=False) - executor.result.node_outputs["fork"] = json.dumps([ - {"exp_id": 1, "success": True, "halted": False, "halt_reason": "", - "worktree_path": str(wt1), "branch": "factory/exp-1", "hypothesis": "h1"}, - {"exp_id": 2, "success": True, "halted": False, "halt_reason": "", - "worktree_path": str(wt2), "branch": "factory/exp-2", "hypothesis": "h2"}, - ]) - executor.completed_files = {"pre.txt"} - - mock_finalize = AsyncMock(side_effect=RuntimeError("db error")) - with patch("subprocess.run") as mock_sp, \ - patch("factory.store.ExperimentStore.finalize", mock_finalize): - mock_sp.return_value = MagicMock(returncode=0) - await executor._execute_selection( - SelectionNode(id="select", reads={"pre.txt"}, writes={"result.json"}), - ) - - assert not executor.result.halted - assert "select" in executor.result.node_outputs - - async def test_worktree_cleanup_failure_not_fatal(self, tmp_project: Path) -> None: - wt1 = tmp_project / ".factory-worktrees" / "exp-1" - wt1.mkdir(parents=True) - - wf = Workflow( - name="test-select", - nodes={ - "pre": FnNode(id="pre", command="echo pre", writes={"pre.txt"}), - "select": SelectionNode(id="select", reads={"pre.txt"}, writes={"result.json"}), - }, - edges=[Edge(source="pre", target="select")], - start_node="pre", - ) - executor = WorkflowExecutor(wf, tmp_project, dry_run=False) - executor.result.node_outputs["fork"] = json.dumps([ - {"exp_id": 1, "success": True, "halted": False, "halt_reason": "", - "worktree_path": str(wt1), "branch": "factory/exp-1", "hypothesis": "h1"}, - ]) - executor.completed_files = {"pre.txt"} - - with patch("subprocess.run") as mock_sp, \ - patch("factory.worktree.remove_worktree", side_effect=OSError("rm fail")): - mock_sp.return_value = MagicMock(returncode=0) - await executor._execute_selection( - SelectionNode(id="select", reads={"pre.txt"}, writes={"result.json"}), - ) - - assert not executor.result.halted - - async def test_fork_output_search_skips_invalid_json(self, tmp_project: Path) -> None: - """Non-JSON node outputs are skipped when searching for fork results.""" - wf = Workflow( - name="test-select", - nodes={ - "pre": FnNode(id="pre", command="echo pre", writes={"pre.txt"}), - "select": SelectionNode(id="select", reads={"pre.txt"}, writes={"result.json"}), - }, - edges=[Edge(source="pre", target="select")], - start_node="pre", - ) - executor = WorkflowExecutor(wf, tmp_project, dry_run=False) - executor.result.node_outputs["bad"] = "not json at all" - executor.result.node_outputs["plain"] = json.dumps({"some": "data"}) - executor.completed_files = {"pre.txt"} - - await executor._execute_selection( - SelectionNode(id="select", reads={"pre.txt"}, writes={"result.json"}), - ) - - selection = json.loads(executor.result.node_outputs["select"]) - assert selection["winner"] is None - assert selection["reason"] == "dry-run" - - -# ── _execute_subgraph_fork non-dry-run tests (executor.py) ───── - - -class TestSubgraphForkNonDryRun: - async def test_branch_count_fallback_to_one(self, tmp_project: Path) -> None: - """When no strategy file exists and parallelism=3, branch_count defaults to parallelism.""" - wf = Workflow( - name="test-fork", - nodes={ - "fork": SubgraphForkNode( - id="fork", subgraph_entry="step", subgraph_exit="step", - parallelism=2, writes={"fork.json"}, - ), - "step": FnNode(id="step", writes={"s.txt"}), - }, - edges=[Edge(source="fork", target="step")], - start_node="fork", - ) - executor = WorkflowExecutor(wf, tmp_project, dry_run=True) - - await executor._execute_subgraph_fork( - SubgraphForkNode( - id="fork", subgraph_entry="step", subgraph_exit="step", - parallelism=2, writes={"fork.json"}, - ), - ) - - results = json.loads(executor.result.node_outputs["fork"]) - assert len(results) == 2 - - async def test_error_in_branch_captured(self, tmp_project: Path) -> None: - """A branch that raises is captured as a failed result, not a crash.""" - wf = Workflow( - name="test-fork", - nodes={ - "fork": SubgraphForkNode( - id="fork", subgraph_entry="step", subgraph_exit="step", - parallelism=2, writes={"fork.json"}, - ), - "step": FnNode(id="step", writes={"s.txt"}), - }, - edges=[], - start_node="fork", - ) - - strategy_dir = tmp_project / ".factory" / "strategy" - (strategy_dir / "current.md").write_text( - "## Hypothesis 1\nH1\n\n## Hypothesis 2\nH2\n" - ) - - executor = WorkflowExecutor(wf, tmp_project, dry_run=False) - - with patch("subprocess.run") as mock_sp, \ - patch( - "factory.worktree.create_experiment_worktree", - side_effect=RuntimeError("worktree fail"), - ), \ - patch("factory.store.ExperimentStore.begin", new_callable=AsyncMock, return_value=1): - mock_sp.return_value = MagicMock(stdout="abc123\n") - - await executor._execute_subgraph_fork( - SubgraphForkNode( - id="fork", subgraph_entry="step", subgraph_exit="step", - parallelism=2, writes={"fork.json"}, - ), - ) - - results = json.loads(executor.result.node_outputs["fork"]) - assert len(results) == 2 - assert all(r["success"] is False for r in results) - assert all(r["halted"] is True for r in results) - - async def test_non_dry_run_calls_git_rev_parse(self, tmp_project: Path) -> None: - """Non-dry-run path resolves HEAD via git rev-parse.""" - wf = Workflow( - name="test-fork", - nodes={ - "fork": SubgraphForkNode( - id="fork", subgraph_entry="step", subgraph_exit="step", - parallelism=1, writes={"fork.json"}, - ), - "step": FnNode(id="step", writes={"s.txt"}), - }, - edges=[], - start_node="fork", - ) - - executor = WorkflowExecutor(wf, tmp_project, dry_run=False) - - calls = [] - - def track_sp(*args, **kwargs): - calls.append(args[0] if args else kwargs.get("args")) - result = MagicMock() - result.stdout = "abc123def456\n" - result.returncode = 0 - return result - - fake_wt_path = tmp_project / ".factory-worktrees" / "exp-1" - fake_wt_path.mkdir(parents=True) - - with patch("subprocess.run", side_effect=track_sp), \ - patch( - "factory.worktree.create_experiment_worktree", - return_value=(fake_wt_path, "factory/exp-1"), - ), \ - patch("factory.store.ExperimentStore.begin", new_callable=AsyncMock, return_value=1): - await executor._execute_subgraph_fork( - SubgraphForkNode( - id="fork", subgraph_entry="step", subgraph_exit="step", - parallelism=1, writes={"fork.json"}, - ), - ) - - assert any( - c and "rev-parse" in str(c) for c in calls - ), f"Expected git rev-parse call, got: {calls}" - - -# ── _collect_subgraph_nodes branching test ────────────────────── - - -class TestCollectSubgraphBranching: - def test_diamond_subgraph(self) -> None: - wf = Workflow( - name="test", - nodes={ - "a": FnNode(id="a", writes={"x"}), - "b": FnNode(id="b", reads={"x"}, writes={"y"}), - "c": FnNode(id="c", reads={"x"}, writes={"z"}), - "d": FnNode(id="d", reads={"y", "z"}, writes={"w"}), - }, - edges=[ - Edge(source="a", target="b"), - Edge(source="a", target="c"), - Edge(source="b", target="d"), - Edge(source="c", target="d"), - ], - start_node="a", - ) - result = _collect_subgraph_nodes(wf, "a", "d") - assert result == {"a", "b", "c", "d"} - - -# ── _parse_hypotheses edge cases ──────────────────────────────── - - -class TestParseHypothesesEdgeCases: - def test_numbered_bullets(self, tmp_path: Path) -> None: - f = tmp_path / "current.md" - f.write_text("1. **Optimize DB queries** for speed\n") - result = _parse_hypotheses(f) - assert len(result) == 1 - - def test_h3_headings(self, tmp_path: Path) -> None: - f = tmp_path / "current.md" - f.write_text( - "### Hypothesis 1\nFirst idea\n\n### Hypothesis 2\nSecond idea\n" - ) - result = _parse_hypotheses(f) - assert len(result) == 2 - - def test_hypothesis_followed_by_other_heading(self, tmp_path: Path) -> None: - f = tmp_path / "current.md" - f.write_text( - "## Hypothesis 1\nAdd caching\n\n## Summary\nDone.\n" - ) - result = _parse_hypotheses(f) - assert len(result) == 1 - assert "caching" in result[0].lower() - - -# ── Integration tests for live execution paths ───────────────── - - -def _git_project(tmp_path: Path) -> Path: - """Create a git-initialised project with .factory/ scaffolding for live tests.""" - import subprocess as sp - - project = tmp_path / "live-project" - project.mkdir() - sp.run(["git", "init"], cwd=project, capture_output=True, check=True) - sp.run( - ["git", "commit", "--allow-empty", "-m", "initial"], - cwd=project, capture_output=True, check=True, - env={ - "GIT_AUTHOR_NAME": "test", "GIT_AUTHOR_EMAIL": "t@t.com", - "GIT_COMMITTER_NAME": "test", "GIT_COMMITTER_EMAIL": "t@t.com", - "HOME": str(tmp_path), "PATH": os.environ.get("PATH", "/usr/bin:/bin"), - }, - ) - factory_dir = project / ".factory" - factory_dir.mkdir() - (factory_dir / "strategy").mkdir() - (factory_dir / "reviews").mkdir() - (factory_dir / "experiments").mkdir() - (factory_dir / "archive").mkdir() - (factory_dir / "config.json").write_text("{}") - (factory_dir / "results.tsv").write_text( - "id\ttimestamp\thypothesis\tchange_summary\tissue_number\tpr_number\t" - "score_before\tscore_after\tdelta\tverdict\tcost_usd\tnotes\tresearch_citations\n" - ) - return project - - -@pytest.mark.real_worktree -class TestSubgraphForkLiveExecution: - """Integration tests for _execute_subgraph_fork with real git worktrees.""" - - async def test_live_worktree_creation_and_cleanup(self, tmp_path: Path) -> None: - """Verify worktrees are actually created on disk and subgraph runs in them.""" - project = _git_project(tmp_path) - - (project / ".factory" / "strategy" / "current.md").write_text( - "## Hypothesis 1\nAdd logging\n\n## Hypothesis 2\nAdd metrics\n" - ) - - wf = Workflow( - name="test-live-fork", - nodes={ - "fork": SubgraphForkNode( - id="fork", subgraph_entry="step_a", subgraph_exit="step_b", - parallelism=2, writes={"fork.json"}, - ), - "step_a": FnNode(id="step_a", command="echo start", writes={"a.txt"}), - "step_b": FnNode(id="step_b", command="echo done", reads={"a.txt"}, writes={"b.txt"}), - }, - edges=[Edge(source="step_a", target="step_b")], - start_node="fork", - ) - - executor = WorkflowExecutor(wf, project, dry_run=False) - result = await executor.execute() - - results = json.loads(result.node_outputs["fork"]) - assert len(results) == 2 - for r in results: - assert r["success"] is True - assert r["branch"].startswith("factory/exp-") - wt = Path(r["worktree_path"]) - assert wt.name.startswith("exp-") - - async def test_live_fork_respects_parallelism_cap(self, tmp_path: Path) -> None: - """When hypotheses > parallelism, branch_count is capped at parallelism.""" - project = _git_project(tmp_path) - - (project / ".factory" / "strategy" / "current.md").write_text( - "## Hypothesis 1\nH1\n\n## Hypothesis 2\nH2\n\n## Hypothesis 3\nH3\n" - ) - - wf = Workflow( - name="test-cap", - nodes={ - "fork": SubgraphForkNode( - id="fork", subgraph_entry="step_a", subgraph_exit="step_b", - parallelism=2, writes={"fork.json"}, - ), - "step_a": FnNode(id="step_a", command="echo ok", writes={"a.txt"}), - "step_b": FnNode(id="step_b", command="echo done", reads={"a.txt"}, writes={"b.txt"}), - }, - edges=[Edge(source="step_a", target="step_b")], - start_node="fork", - ) - - executor = WorkflowExecutor(wf, project, dry_run=False) - result = await executor.execute() - - results = json.loads(result.node_outputs["fork"]) - assert len(results) == 2, "Should cap at parallelism=2 despite 3 hypotheses" - - async def test_live_fork_uses_real_head_commit(self, tmp_path: Path) -> None: - """Verify the live path resolves HEAD via git rev-parse (not a dummy hash).""" - import subprocess as sp - - project = _git_project(tmp_path) - - head = sp.run( - ["git", "rev-parse", "HEAD"], cwd=project, - capture_output=True, text=True, check=True, - ).stdout.strip() - - (project / ".factory" / "strategy" / "current.md").write_text( - "## Hypothesis 1\nTest commit resolution\n" - ) - - wf = Workflow( - name="test-head", - nodes={ - "fork": SubgraphForkNode( - id="fork", subgraph_entry="step_a", subgraph_exit="step_b", - parallelism=1, writes={"fork.json"}, - ), - "step_a": FnNode(id="step_a", command="echo ok", writes={"a.txt"}), - "step_b": FnNode(id="step_b", command="echo done", reads={"a.txt"}, writes={"b.txt"}), - }, - edges=[Edge(source="step_a", target="step_b")], - start_node="fork", - ) - - executor = WorkflowExecutor(wf, project, dry_run=False) - result = await executor.execute() - - results = json.loads(result.node_outputs["fork"]) - assert results[0]["success"] is True - branch = results[0]["branch"] - branch_commit = sp.run( - ["git", "rev-parse", branch], cwd=project, - capture_output=True, text=True, check=True, - ).stdout.strip() - assert branch_commit == head - - async def test_live_fork_no_strategy_file_defaults_to_parallelism( - self, tmp_path: Path, - ) -> None: - """When no strategy file exists, branch_count falls back to parallelism.""" - project = _git_project(tmp_path) - - wf = Workflow( - name="test-no-strat", - nodes={ - "fork": SubgraphForkNode( - id="fork", subgraph_entry="step_a", subgraph_exit="step_b", - parallelism=2, writes={"fork.json"}, - ), - "step_a": FnNode(id="step_a", command="echo ok", writes={"a.txt"}), - "step_b": FnNode(id="step_b", command="echo done", reads={"a.txt"}, writes={"b.txt"}), - }, - edges=[Edge(source="step_a", target="step_b")], - start_node="fork", - ) - - executor = WorkflowExecutor(wf, project, dry_run=False) - result = await executor.execute() - - results = json.loads(result.node_outputs["fork"]) - assert len(results) == 2 - - -@pytest.mark.real_worktree -class TestSelectionLiveExecution: - """Integration tests for _execute_selection with real git repos.""" - - async def test_live_merge_winner_into_baseline(self, tmp_path: Path) -> None: - """Verify the winning branch is actually merged into the project.""" - import subprocess as sp - - project = _git_project(tmp_path) - - head = sp.run( - ["git", "rev-parse", "HEAD"], cwd=project, - capture_output=True, text=True, check=True, - ).stdout.strip() - - from factory.worktree import create_experiment_worktree - - wt_path, branch = create_experiment_worktree(project, 1, head) - - (wt_path / "new_file.txt").write_text("winner content") - sp.run(["git", "add", "new_file.txt"], cwd=wt_path, capture_output=True, check=True) - sp.run( - ["git", "commit", "-m", "winner commit"], - cwd=wt_path, capture_output=True, check=True, - env={ - "GIT_AUTHOR_NAME": "test", "GIT_AUTHOR_EMAIL": "t@t.com", - "GIT_COMMITTER_NAME": "test", "GIT_COMMITTER_EMAIL": "t@t.com", - "HOME": str(tmp_path), "PATH": os.environ.get("PATH", "/usr/bin:/bin"), - }, - ) - - (wt_path / ".factory").mkdir(exist_ok=True) - (wt_path / ".factory" / "last_eval.json").write_text(json.dumps({"total": 0.95})) - - wf = Workflow( - name="test-merge", - nodes={ - "pre": FnNode(id="pre", command="echo pre", writes={"pre.txt"}), - "select": SelectionNode(id="select", reads={"pre.txt"}, writes={"result.json"}), - }, - edges=[Edge(source="pre", target="select")], - start_node="pre", - ) - executor = WorkflowExecutor(wf, project, dry_run=False) - executor.result.node_outputs["fork"] = json.dumps([{ - "exp_id": 1, "success": True, "halted": False, "halt_reason": "", - "worktree_path": str(wt_path), "branch": branch, "hypothesis": "winner", - }]) - executor.completed_files = {"pre.txt"} - - await executor._execute_selection( - SelectionNode(id="select", reads={"pre.txt"}, writes={"result.json"}), - ) - - assert not executor.result.halted - merged_file = project / "new_file.txt" - assert merged_file.exists(), "Winner's file should be merged into project" - assert merged_file.read_text() == "winner content" - - async def test_live_loser_worktree_removed(self, tmp_path: Path) -> None: - """Verify losing worktrees are cleaned up after selection.""" - import subprocess as sp - - project = _git_project(tmp_path) - - head = sp.run( - ["git", "rev-parse", "HEAD"], cwd=project, - capture_output=True, text=True, check=True, - ).stdout.strip() - - from factory.worktree import create_experiment_worktree - - wt1, br1 = create_experiment_worktree(project, 1, head) - wt2, br2 = create_experiment_worktree(project, 2, head) - - for wt in (wt1, wt2): - (wt / ".factory").mkdir(exist_ok=True) - - (wt1 / ".factory" / "last_eval.json").write_text(json.dumps({"total": 0.6})) - (wt2 / ".factory" / "last_eval.json").write_text(json.dumps({"total": 0.9})) - - for wt, name in ((wt1, "file1.txt"), (wt2, "file2.txt")): - (wt / name).write_text("content") - sp.run(["git", "add", name], cwd=wt, capture_output=True, check=True) - sp.run( - ["git", "commit", "-m", f"add {name}"], - cwd=wt, capture_output=True, check=True, - env={ - "GIT_AUTHOR_NAME": "test", "GIT_AUTHOR_EMAIL": "t@t.com", - "GIT_COMMITTER_NAME": "test", "GIT_COMMITTER_EMAIL": "t@t.com", - "HOME": str(tmp_path), "PATH": os.environ.get("PATH", "/usr/bin:/bin"), - }, - ) - - wf = Workflow( - name="test-cleanup", - nodes={ - "pre": FnNode(id="pre", command="echo pre", writes={"pre.txt"}), - "select": SelectionNode(id="select", reads={"pre.txt"}, writes={"result.json"}), - }, - edges=[Edge(source="pre", target="select")], - start_node="pre", - ) - executor = WorkflowExecutor(wf, project, dry_run=False) - executor.result.node_outputs["fork"] = json.dumps([ - {"exp_id": 1, "success": True, "halted": False, "halt_reason": "", - "worktree_path": str(wt1), "branch": br1, "hypothesis": "h1"}, - {"exp_id": 2, "success": True, "halted": False, "halt_reason": "", - "worktree_path": str(wt2), "branch": br2, "hypothesis": "h2"}, - ]) - executor.completed_files = {"pre.txt"} - - await executor._execute_selection( - SelectionNode(id="select", reads={"pre.txt"}, writes={"result.json"}), - ) - - assert not executor.result.halted - selection = json.loads(executor.result.node_outputs["select"]) - assert selection["winner_exp_id"] == 2 - assert not wt1.exists(), "Loser worktree should be removed" - - -@pytest.mark.real_worktree -class TestErrorRecoveryPaths: - """Integration tests for error handling and recovery in live execution.""" - - async def test_fork_worktree_creation_failure_captured(self, tmp_path: Path) -> None: - """When create_experiment_worktree raises, the branch result is marked failed.""" - project = _git_project(tmp_path) - - (project / ".factory" / "strategy" / "current.md").write_text( - "## Hypothesis 1\nH1\n" - ) - - wf = Workflow( - name="test-wt-fail", - nodes={ - "fork": SubgraphForkNode( - id="fork", subgraph_entry="step_a", subgraph_exit="step_b", - parallelism=1, writes={"fork.json"}, - ), - "step_a": FnNode(id="step_a", command="echo ok", writes={"a.txt"}), - "step_b": FnNode(id="step_b", command="echo done", reads={"a.txt"}, writes={"b.txt"}), - }, - edges=[Edge(source="step_a", target="step_b")], - start_node="fork", - ) - - executor = WorkflowExecutor(wf, project, dry_run=False) - - with patch( - "factory.worktree.create_experiment_worktree", - side_effect=RuntimeError("disk full"), - ): - result = await executor.execute() - - results = json.loads(result.node_outputs["fork"]) - assert len(results) == 1 - assert results[0]["success"] is False - assert results[0]["halted"] is True - - async def test_fork_subprocess_timeout_captured(self, tmp_path: Path) -> None: - """When git rev-parse times out, the fork halts gracefully.""" - import subprocess as sp - - project = _git_project(tmp_path) - - (project / ".factory" / "strategy" / "current.md").write_text( - "## Hypothesis 1\nH1\n" - ) - - wf = Workflow( - name="test-timeout", - nodes={ - "fork": SubgraphForkNode( - id="fork", subgraph_entry="step_a", subgraph_exit="step_b", - parallelism=1, writes={"fork.json"}, - ), - "step_a": FnNode(id="step_a", command="echo ok", writes={"a.txt"}), - "step_b": FnNode(id="step_b", command="echo done", reads={"a.txt"}, writes={"b.txt"}), - }, - edges=[Edge(source="step_a", target="step_b")], - start_node="fork", - ) - - executor = WorkflowExecutor(wf, project, dry_run=False) - - with patch( - "subprocess.run", - side_effect=sp.CalledProcessError(128, "git rev-parse"), - ): - result = await executor.execute() - - assert result.halted is True - - async def test_selection_merge_conflict_halts(self, tmp_path: Path) -> None: - """When merging the winner causes a conflict, selection halts.""" - import subprocess as sp - - project = _git_project(tmp_path) - - head = sp.run( - ["git", "rev-parse", "HEAD"], cwd=project, - capture_output=True, text=True, check=True, - ).stdout.strip() - - from factory.worktree import create_experiment_worktree - - wt, branch = create_experiment_worktree(project, 1, head) - - (project / "conflict.txt").write_text("base content") - sp.run(["git", "add", "conflict.txt"], cwd=project, capture_output=True, check=True) - sp.run( - ["git", "commit", "-m", "base change"], - cwd=project, capture_output=True, check=True, - env={ - "GIT_AUTHOR_NAME": "test", "GIT_AUTHOR_EMAIL": "t@t.com", - "GIT_COMMITTER_NAME": "test", "GIT_COMMITTER_EMAIL": "t@t.com", - "HOME": str(tmp_path), "PATH": os.environ.get("PATH", "/usr/bin:/bin"), - }, - ) - - (wt / "conflict.txt").write_text("branch content") - sp.run(["git", "add", "conflict.txt"], cwd=wt, capture_output=True, check=True) - sp.run( - ["git", "commit", "-m", "branch change"], - cwd=wt, capture_output=True, check=True, - env={ - "GIT_AUTHOR_NAME": "test", "GIT_AUTHOR_EMAIL": "t@t.com", - "GIT_COMMITTER_NAME": "test", "GIT_COMMITTER_EMAIL": "t@t.com", - "HOME": str(tmp_path), "PATH": os.environ.get("PATH", "/usr/bin:/bin"), - }, - ) - - wf = Workflow( - name="test-conflict", - nodes={ - "pre": FnNode(id="pre", command="echo pre", writes={"pre.txt"}), - "select": SelectionNode(id="select", reads={"pre.txt"}, writes={"result.json"}), - }, - edges=[Edge(source="pre", target="select")], - start_node="pre", - ) - executor = WorkflowExecutor(wf, project, dry_run=False) - executor.result.node_outputs["fork"] = json.dumps([{ - "exp_id": 1, "success": True, "halted": False, "halt_reason": "", - "worktree_path": str(wt), "branch": branch, "hypothesis": "h1", - }]) - executor.completed_files = {"pre.txt"} - - await executor._execute_selection( - SelectionNode(id="select", reads={"pre.txt"}, writes={"result.json"}), - ) - - assert executor.result.halted is True - assert "failed to merge winner branch" in executor.result.halt_reason - - async def test_selection_worktree_cleanup_failure_non_fatal( - self, tmp_path: Path, - ) -> None: - """When worktree removal fails, selection still succeeds.""" - import subprocess as sp - - project = _git_project(tmp_path) - - head = sp.run( - ["git", "rev-parse", "HEAD"], cwd=project, - capture_output=True, text=True, check=True, - ).stdout.strip() - - from factory.worktree import create_experiment_worktree - - wt, branch = create_experiment_worktree(project, 1, head) - - (wt / "ok.txt").write_text("ok") - sp.run(["git", "add", "ok.txt"], cwd=wt, capture_output=True, check=True) - sp.run( - ["git", "commit", "-m", "ok"], - cwd=wt, capture_output=True, check=True, - env={ - "GIT_AUTHOR_NAME": "test", "GIT_AUTHOR_EMAIL": "t@t.com", - "GIT_COMMITTER_NAME": "test", "GIT_COMMITTER_EMAIL": "t@t.com", - "HOME": str(tmp_path), "PATH": os.environ.get("PATH", "/usr/bin:/bin"), - }, - ) - (wt / ".factory").mkdir(exist_ok=True) - (wt / ".factory" / "last_eval.json").write_text(json.dumps({"total": 0.8})) - - wf = Workflow( - name="test-cleanup-fail", - nodes={ - "pre": FnNode(id="pre", command="echo pre", writes={"pre.txt"}), - "select": SelectionNode(id="select", reads={"pre.txt"}, writes={"result.json"}), - }, - edges=[Edge(source="pre", target="select")], - start_node="pre", - ) - executor = WorkflowExecutor(wf, project, dry_run=False) - executor.result.node_outputs["fork"] = json.dumps([{ - "exp_id": 1, "success": True, "halted": False, "halt_reason": "", - "worktree_path": str(wt), "branch": branch, "hypothesis": "h1", - }]) - executor.completed_files = {"pre.txt"} - - with patch("factory.worktree.remove_worktree", side_effect=OSError("perm denied")): - await executor._execute_selection( - SelectionNode(id="select", reads={"pre.txt"}, writes={"result.json"}), - ) - - assert not executor.result.halted - selection = json.loads(executor.result.node_outputs["select"]) - assert selection["winner_exp_id"] == 1 diff --git a/tests/test_plugins.py b/tests/test_plugins.py index df9f7ecb9..af5b18280 100644 --- a/tests/test_plugins.py +++ b/tests/test_plugins.py @@ -13,7 +13,13 @@ ) -def _make_ep(name: str, load_return=None, load_exc=None, dist_name: str | None = None, dist_version: str | None = "0.1.0"): +def _make_ep( + name: str, + load_return=None, + load_exc=None, + dist_name: str | None = None, + dist_version: str | None = "0.1.0", +): """Build a mock entry point.""" ep = MagicMock() ep.name = name @@ -60,6 +66,7 @@ def my_plugin(reg: PluginRegistry): class TestLoadPluginsBrokenImport: def test_import_error_isolated(self): good_called = [] + def good_plugin(reg: PluginRegistry): good_called.append(True) reg.add_commands({"good": CommandSpec(handler=lambda a: 0, help="Works")}) @@ -134,10 +141,12 @@ def plugin_b(reg: PluginRegistry): class TestCollisionWithBuiltinCommand: def test_builtin_command_skipped_with_warning(self): registry = PluginRegistry() - registry.add_commands({ - "eval": CommandSpec(handler=lambda a: 0, help="Shadow builtin eval"), - "my-new-cmd": CommandSpec(handler=lambda a: 0, help="Legit plugin cmd"), - }) + registry.add_commands( + { + "eval": CommandSpec(handler=lambda a: 0, help="Shadow builtin eval"), + "my-new-cmd": CommandSpec(handler=lambda a: 0, help="Legit plugin cmd"), + } + ) assert "eval" not in registry.commands assert "my-new-cmd" in registry.commands @@ -145,7 +154,7 @@ def test_builtin_command_skipped_with_warning(self): class TestCollisionDetectionModes: def test_collision_with_builtin_skipped(self): def plugin(reg: PluginRegistry): - reg.add_modes(["improve", "custom-mode"]) + reg.add_modes(["design", "custom-mode"]) registry = PluginRegistry() ep = _make_ep("mode-plugin", load_return=plugin) @@ -153,7 +162,7 @@ def plugin(reg: PluginRegistry): mock_eps.return_value = MagicMock() mock_eps.return_value.select.return_value = [ep] load_plugins(registry) - assert "improve" not in registry.modes + assert "design" not in registry.modes assert "custom-mode" in registry.modes @@ -161,7 +170,9 @@ class TestCmdPluginsOutput: def test_human_readable_format(self, capsys): results = [ PluginLoadResult(name="my-plugin", status="loaded", version="1.0.0"), - PluginLoadResult(name="bad-plugin", status="failed", reason="Import error", version="0.1.0"), + PluginLoadResult( + name="bad-plugin", status="failed", reason="Import error", version="0.1.0" + ), ] registry = PluginRegistry() @@ -237,6 +248,7 @@ def plugin(reg: PluginRegistry): ext_fn.assert_called_once() import argparse + assert isinstance(ext_fn.call_args[0][0], argparse.ArgumentParser) @@ -255,11 +267,28 @@ def test_pre_hook_invoked(self): patch("factory.user_config.load_config"), ): mock_validate.return_value = ( - "improve", False, False, False, None, None, None, None, False, None, False, + "design", + False, + False, + False, + None, + None, + None, + None, + False, + None, + False, ) mock_resolve.return_value = ( - "/tmp/proj", None, None, None, - None, False, False, None, None, + "/tmp/proj", + None, + None, + None, + None, + False, + False, + None, + None, ) from factory.cli.ceo import cmd_ceo @@ -271,15 +300,17 @@ def test_pre_hook_invoked(self): hook.assert_called_once() call_args = hook.call_args[0] - assert call_args[0] == "improve" + assert call_args[0] == "design" class TestDeterministicLoadOrder: def test_sorted_by_dist_name(self): def plugin_c(reg: PluginRegistry): pass + def plugin_a(reg: PluginRegistry): pass + def plugin_b(reg: PluginRegistry): pass @@ -298,6 +329,7 @@ def plugin_b(reg: PluginRegistry): # ── helpers used by tests ────────────────────────────────────── + def _cmd_plugins_text(results: list[PluginLoadResult], registry: PluginRegistry) -> None: if not results: print("No plugins discovered.") @@ -316,6 +348,7 @@ def _cmd_plugins_text(results: list[PluginLoadResult], registry: PluginRegistry) def _cmd_plugins_json(results: list[PluginLoadResult], registry: PluginRegistry) -> None: import dataclasses + data = [] for r in results: entry = dataclasses.asdict(r) diff --git a/tests/test_prompts.py b/tests/test_prompts.py index 5caa31461..fc34ded71 100644 --- a/tests/test_prompts.py +++ b/tests/test_prompts.py @@ -9,13 +9,13 @@ PROMPTS_DIR = Path(__file__).parent.parent / "factory" / "agents" / "prompts" -def _generate_build_skill() -> str: +def _generate_design_skill() -> str: from factory.workflow.definitions import register_all from factory.workflow.skill_export import workflow_to_skill_md from factory.workflow.splitter import resolve_to_clean wfs = register_all() - return resolve_to_clean(workflow_to_skill_md(wfs["build"])) + return resolve_to_clean(workflow_to_skill_md(wfs["design"])) @pytest.fixture @@ -42,9 +42,16 @@ def test_has_design_space_section(self, strategist_prompt: str) -> None: def test_lists_all_dimensions(self, strategist_prompt: str) -> None: dimensions = [ - "Features", "Bug fixes", "Instrumentation", "Flow changes", - "New agents", "Prompt engineering", "Eval improvements", - "Knowledge management", "Infrastructure", "Self-evolution", + "Features", + "Bug fixes", + "Instrumentation", + "Flow changes", + "New agents", + "Prompt engineering", + "Eval improvements", + "Knowledge management", + "Infrastructure", + "Self-evolution", ] for dim in dimensions: assert dim in strategist_prompt, f"Missing dimension: {dim}" @@ -113,11 +120,9 @@ def test_has_state_machine(self, ceo_prompt: str) -> None: def test_has_all_modes(self, ceo_prompt: str) -> None: """CEO routes to all modes via Skill Selection section.""" - assert "workflow-build" in ceo_prompt - assert "workflow-improve" in ceo_prompt + assert "workflow-design" in ceo_prompt assert "workflow-research" in ceo_prompt assert "workflow-meta" in ceo_prompt - assert "workflow-design" in ceo_prompt def test_has_sacred_rules(self, ceo_prompt: str) -> None: assert "## Sacred Rules" in ceo_prompt @@ -152,16 +157,16 @@ def test_ceo_notes_convention(self, ceo_prompt: str) -> None: assert "ceo:keep" in ceo_prompt assert "ceo:revert" in ceo_prompt - def test_build_mode_has_full_pipeline(self, ceo_prompt: str) -> None: - """Build workflow skill has researcher, strategist, and builder phases.""" - build_skill = _generate_build_skill() + def test_design_mode_has_full_pipeline(self, ceo_prompt: str) -> None: + """Design workflow skill has researcher, strategist, and builder phases.""" + build_skill = _generate_design_skill() assert "researcher" in build_skill.lower() assert "strategist" in build_skill.lower() assert "builder" in build_skill.lower() - def test_build_mode_does_not_skip_to_builder(self, ceo_prompt: str) -> None: - """Build workflow skill includes research and strategy phases before builder.""" - build_skill = _generate_build_skill() + def test_design_mode_does_not_skip_to_builder(self, ceo_prompt: str) -> None: + """Design workflow skill includes research and strategy phases before builder.""" + build_skill = _generate_design_skill() researcher_pos = build_skill.lower().index("researcher") builder_pos = build_skill.lower().index("builder") assert researcher_pos < builder_pos @@ -184,33 +189,36 @@ def test_strategist_hard_gate_in_plan_loop(self, ceo_prompt: str) -> None: assert "HARD GATE" in ceo_prompt assert "PLAN APPROVED" in ceo_prompt - def test_strategist_hard_gate_in_improve_mode(self, ceo_prompt: str) -> None: - """Improve workflow skill has gate node after strategist.""" + def test_strategist_hard_gate_in_design_mode(self, ceo_prompt: str) -> None: + """Design workflow has gate node after strategist.""" from factory.workflow.definitions import register_all + wfs = register_all() - improve = wfs["improve"] - gate_ids = [nid for nid, n in improve.nodes.items() if hasattr(n, "evaluator_type")] - assert len(gate_ids) > 0, "Improve workflow must have gate nodes" + design = wfs["design"] + gate_ids = [nid for nid, n in design.nodes.items() if hasattr(n, "evaluator_type")] + assert len(gate_ids) > 0, "Design workflow must have gate nodes" def test_plan_loop_has_research_review(self, ceo_prompt: str) -> None: """CEO prompt has review gate protocol for agent review.""" assert "ceo-verdict" in ceo_prompt - def test_build_mode_has_builder_review(self, ceo_prompt: str) -> None: - """Build workflow skill has deep-qa specialist agents after builder.""" + def test_design_mode_has_builder_review(self, ceo_prompt: str) -> None: + """Design workflow skill has deep-qa specialist agents after builder.""" from factory.workflow.definitions import register_all + wfs = register_all() - build = wfs["build"] + design = wfs["design"] deep_qa_roles = {"health_checker", "code_reviewer", "adversarial_tester"} has_deep_qa = any( - hasattr(n, "role") and n.role.value in deep_qa_roles - for n in build.nodes.values() + hasattr(n, "role") and n.role.value in deep_qa_roles for n in design.nodes.values() ) - assert has_deep_qa, "Build workflow must have deep-qa specialist nodes" + assert has_deep_qa, "Design workflow must have deep-qa specialist nodes" def test_improve_mode_has_builder_pr_review(self, ceo_prompt: str) -> None: """CEO prompt references PR review before proceeding.""" - assert "gh pr diff" in ceo_prompt or "PR diff" in ceo_prompt or "Builder review" in ceo_prompt + assert ( + "gh pr diff" in ceo_prompt or "PR diff" in ceo_prompt or "Builder review" in ceo_prompt + ) def test_improve_mode_has_qa_verification(self, ceo_prompt: str) -> None: """CEO prompt mandates QA via Sacred Rule 9.""" @@ -225,14 +233,14 @@ def test_review_assessment_criteria_table(self, ceo_prompt: str) -> None: # ── E2E Verification Gate tests ────────────────────────────── def test_build_mode_has_e2e_gate(self, ceo_prompt: str) -> None: - """Build workflow skill has deep-qa specialists for E2E verification.""" + """Design workflow skill has deep-qa specialists for E2E verification.""" from factory.workflow.definitions import register_all + wfs = register_all() - build = wfs["build"] + design = wfs["design"] deep_qa_roles = {"health_checker", "code_reviewer", "adversarial_tester"} has_deep_qa = any( - hasattr(n, "role") and n.role.value in deep_qa_roles - for n in build.nodes.values() + hasattr(n, "role") and n.role.value in deep_qa_roles for n in design.nodes.values() ) assert has_deep_qa @@ -240,9 +248,10 @@ def test_e2e_gate_before_improve(self, ceo_prompt: str) -> None: """Build workflow has fork_qa (QA entry) after builder in topological order.""" from factory.workflow.skill_export import _topological_sort from factory.workflow.definitions import register_all + wfs = register_all() - build = wfs["build"] - order = _topological_sort(build) + design = wfs["design"] + order = _topological_sort(design) builder_ids = [nid for nid in order if nid == "builder"] fork_ids = [nid for nid in order if nid == "fork_qa"] if builder_ids and fork_ids: @@ -265,22 +274,22 @@ def test_archivist_do_not_skip_labels(self, ceo_prompt: str) -> None: def test_archivist_in_build_mode(self, ceo_prompt: str) -> None: """Build workflow skill includes archivist node.""" from factory.workflow.definitions import register_all + wfs = register_all() - build = wfs["build"] + design = wfs["design"] has_archivist = any( - hasattr(n, "role") and n.role.value == "archivist" - for n in build.nodes.values() + hasattr(n, "role") and n.role.value == "archivist" for n in design.nodes.values() ) assert has_archivist def test_archivist_in_improve_mode(self, ceo_prompt: str) -> None: """Improve workflow skill includes archivist node.""" from factory.workflow.definitions import register_all + wfs = register_all() - improve = wfs["improve"] + design = wfs["design"] has_archivist = any( - hasattr(n, "role") and n.role.value == "archivist" - for n in improve.nodes.values() + hasattr(n, "role") and n.role.value == "archivist" for n in design.nodes.values() ) assert has_archivist @@ -298,45 +307,48 @@ def test_plan_loop_before_build_mode(self, ceo_prompt: str) -> None: """Build workflow has research before builder in graph.""" from factory.workflow.definitions import register_all from factory.workflow.skill_export import _topological_sort + wfs = register_all() - build = wfs["build"] - order = _topological_sort(build) + design = wfs["design"] + order = _topological_sort(design) researcher_ids = [nid for nid in order if "researcher" in nid] builder_ids = [nid for nid in order if "builder" in nid] if researcher_ids and builder_ids: assert order.index(researcher_ids[0]) < order.index(builder_ids[0]) def test_plan_loop_spawns_researcher(self, ceo_prompt: str) -> None: - """Build workflow skill includes researcher agent.""" - build_skill = _generate_build_skill() + """Design workflow skill includes researcher agent.""" + build_skill = _generate_design_skill() assert "researcher" in build_skill.lower() def test_plan_loop_spawns_strategist(self, ceo_prompt: str) -> None: """Build workflow skill includes strategist agent.""" - build_skill = _generate_build_skill() + build_skill = _generate_design_skill() assert "strategist" in build_skill.lower() def test_plan_loop_has_iteration_limit(self, ceo_prompt: str) -> None: """Build workflow has gate nodes with RELOOP edges (iteration limits).""" from factory.workflow.definitions import register_all from factory.workflow.primitives import VerdictType + wfs = register_all() - build = wfs["build"] - reloop_edges = [e for e in build.edges if e.condition == VerdictType.RELOOP] + design = wfs["design"] + reloop_edges = [e for e in design.edges if e.condition == VerdictType.RELOOP] assert len(reloop_edges) > 0, "Build workflow must have RELOOP edges" def test_plan_loop_persists_spec(self, ceo_prompt: str) -> None: """Build workflow skill references current.md for strategy.""" - build_skill = _generate_build_skill() + build_skill = _generate_design_skill() assert "current.md" in build_skill def test_plan_loop_transitions_to_build(self, ceo_prompt: str) -> None: """Build workflow has builder after strategist in graph order.""" from factory.workflow.definitions import register_all from factory.workflow.skill_export import _topological_sort + wfs = register_all() - build = wfs["build"] - order = _topological_sort(build) + design = wfs["design"] + order = _topological_sort(design) strat_ids = [nid for nid in order if "strategist" in nid] builder_ids = [nid for nid in order if "builder" in nid] if strat_ids and builder_ids: @@ -345,11 +357,11 @@ def test_plan_loop_transitions_to_build(self, ceo_prompt: str) -> None: def test_plan_loop_references_archivist(self, ceo_prompt: str) -> None: """Build workflow has archivist node.""" from factory.workflow.definitions import register_all + wfs = register_all() - build = wfs["build"] + design = wfs["design"] has_archivist = any( - hasattr(n, "role") and n.role.value == "archivist" - for n in build.nodes.values() + hasattr(n, "role") and n.role.value == "archivist" for n in design.nodes.values() ) assert has_archivist @@ -361,7 +373,7 @@ def test_forbids_native_agent_tool(self, ceo_prompt: str) -> None: def test_forbidden_actions_list_agent_tool(self, ceo_prompt: str) -> None: """The Forbidden Actions list includes native Agent tool prohibition.""" forbidden_section_start = ceo_prompt.index("**Forbidden Actions") - forbidden_section = ceo_prompt[forbidden_section_start:forbidden_section_start + 800] + forbidden_section = ceo_prompt[forbidden_section_start : forbidden_section_start + 800] assert "Agent" in forbidden_section assert "factory agent" in forbidden_section diff --git a/tests/test_runner.py b/tests/test_runner.py index 5f7efce6e..c9b4e0ea6 100644 --- a/tests/test_runner.py +++ b/tests/test_runner.py @@ -60,8 +60,10 @@ def test_use_profile_true_without_profile_file(self) -> None: def test_profile_after_playbook(self, tmp_path: Path) -> None: profile_path = tmp_path / "profile.md" profile_path.write_text("The user prefers small PRs.") - with patch("factory.profile._PROFILE_PATH", profile_path), \ - patch("factory.ace.injector.load_playbook", return_value="DO: write tests"): + with ( + patch("factory.profile._PROFILE_PATH", profile_path), + patch("factory.ace.injector.load_playbook", return_value="DO: write tests"), + ): prompt = resolve_prompt("ceo", use_profile=True) assert "Behavioral Playbook" in prompt playbook_idx = prompt.index("Behavioral Playbook") @@ -71,12 +73,12 @@ def test_profile_after_playbook(self, tmp_path: Path) -> None: class TestResolvePromptWithWorkflowMode: def test_ceo_with_workflow_mode_injects_skill(self, tmp_path: Path) -> None: - skill_dir = tmp_path / "skills" / "workflow-improve" + skill_dir = tmp_path / "skills" / "workflow-design" skill_dir.mkdir(parents=True) - (skill_dir / "SKILL.md").write_text("# Improve Workflow\n\nStep 1: study") - prompt = resolve_prompt("ceo", tmp_path, workflow_mode="improve") - assert "# Workflow Playbook (improve)" in prompt - assert "# Improve Workflow" in prompt + (skill_dir / "SKILL.md").write_text("# Design Workflow\n\nStep 1: study") + prompt = resolve_prompt("ceo", tmp_path, workflow_mode="design") + assert "# Workflow Playbook (design)" in prompt + assert "# Design Workflow" in prompt assert "Step 1: study" in prompt def test_ceo_without_workflow_mode_no_skill(self, tmp_path: Path) -> None: @@ -84,10 +86,10 @@ def test_ceo_without_workflow_mode_no_skill(self, tmp_path: Path) -> None: assert "# Workflow Playbook" not in prompt def test_non_ceo_role_ignores_workflow_mode(self, tmp_path: Path) -> None: - skill_dir = tmp_path / "skills" / "workflow-improve" + skill_dir = tmp_path / "skills" / "workflow-design" skill_dir.mkdir(parents=True) - (skill_dir / "SKILL.md").write_text("# Improve Workflow\n\nStep 1: study") - prompt = resolve_prompt("researcher", tmp_path, workflow_mode="improve") + (skill_dir / "SKILL.md").write_text("# Design Workflow\n\nStep 1: study") + prompt = resolve_prompt("researcher", tmp_path, workflow_mode="design") assert "# Workflow Playbook" not in prompt def test_missing_skill_file_raises_error(self, tmp_path: Path) -> None: diff --git a/tests/test_session_resume.py b/tests/test_session_resume.py index 0ec1d5b48..5a513c1be 100644 --- a/tests/test_session_resume.py +++ b/tests/test_session_resume.py @@ -302,12 +302,12 @@ def test_write_defaults_metadata(self, tmp_path: Path) -> None: def test_read_ceo_session_full(self, tmp_path: Path) -> None: from factory.ceo_completion import read_ceo_session, write_ceo_session_id - write_ceo_session_id(tmp_path, "sid-full", interactive=False, mode="improve") + write_ceo_session_id(tmp_path, "sid-full", interactive=False, mode="design") result = read_ceo_session(tmp_path) assert result is not None assert result["session_id"] == "sid-full" assert result["interactive"] is False - assert result["mode"] == "improve" + assert result["mode"] == "design" assert "created" in result def test_read_ceo_session_nonexistent(self, tmp_path: Path) -> None: @@ -373,7 +373,7 @@ async def mock_invoke(role, task, path, **kwargs): await run_ceo_with_completion_guard( tmp_path, "Initial task", - mode="improve", + mode="design", runner_name="claude", session_id="my-session-id", ) @@ -411,7 +411,7 @@ async def mock_invoke(role, task, path, **kwargs): await run_ceo_with_completion_guard( tmp_path, "Initial task", - mode="improve", + mode="design", runner_name="claude", session_id="initial-sid", ) @@ -455,7 +455,7 @@ async def mock_invoke(role, task, path, **kwargs): await run_ceo_with_completion_guard( tmp_path, "Task", - mode="improve", + mode="design", runner_name="claude", session_id="initial", ) @@ -526,7 +526,7 @@ def test_resume_headless_session_has_continuation(self, tmp_path: Path) -> None: """Headless sessions from session.json get a continuation prompt.""" from factory.ceo_completion import write_ceo_session_id - write_ceo_session_id(tmp_path, "headless-sid", interactive=False, mode="improve") + write_ceo_session_id(tmp_path, "headless-sid", interactive=False, mode="design") import argparse diff --git a/tests/test_skill_export.py b/tests/test_skill_export.py index 1ecd0541e..12aac6df8 100644 --- a/tests/test_skill_export.py +++ b/tests/test_skill_export.py @@ -374,10 +374,10 @@ def test_emits_annotation_comments(self) -> None: class TestWorkflowToSkillMd: def test_generates_valid_frontmatter(self) -> None: - wf = _minimal_workflow(name="build") + wf = _minimal_workflow(name="alpha") result = workflow_to_skill_md(wf) assert result.startswith("---") - assert "name: workflow-build" in result + assert "name: workflow-alpha" in result assert "description:" in result def test_contains_arguments_placeholder(self) -> None: @@ -457,17 +457,17 @@ def test_creates_skill_files(self, tmp_path: Path) -> None: assert "workflow-test_wf" in str(paths[0].parent) def test_creates_directory_structure(self, tmp_path: Path) -> None: - wf1 = _minimal_workflow(name="build") - wf2 = _minimal_workflow(name="improve") - paths = export_all_skills(tmp_path, workflows={"build": wf1, "improve": wf2}) + wf1 = _minimal_workflow(name="alpha") + wf2 = _minimal_workflow(name="beta") + paths = export_all_skills(tmp_path, workflows={"alpha": wf1, "beta": wf2}) assert len(paths) == 2 dirs = {p.parent.name for p in paths} - assert "workflow-build" in dirs - assert "workflow-improve" in dirs + assert "workflow-alpha" in dirs + assert "workflow-beta" in dirs def test_written_content_passes_validation(self, tmp_path: Path) -> None: - wf = _minimal_workflow(name="build") - paths = export_all_skills(tmp_path, workflows={"build": wf}) + wf = _minimal_workflow(name="alpha") + paths = export_all_skills(tmp_path, workflows={"alpha": wf}) content = paths[0].read_text() issues = validate_skill(content) assert issues == [], f"Validation issues: {issues}" diff --git a/tests/test_spec_generate.py b/tests/test_spec_generate.py index f4c8c8467..d97a99818 100644 --- a/tests/test_spec_generate.py +++ b/tests/test_spec_generate.py @@ -92,7 +92,7 @@ def test_register_all_includes_spec_generate(self) -> None: def test_register_all_count(self) -> None: all_wf = register_all() - assert len(all_wf) == 35 + assert len(all_wf) == 31 def test_all_workflows_validate(self) -> None: all_wf = register_all() diff --git a/tests/test_study.py b/tests/test_study.py index 2fd19aa4e..a8e31ea75 100644 --- a/tests/test_study.py +++ b/tests/test_study.py @@ -1419,7 +1419,7 @@ class TestBuildCeoTaskFocus: def test_focus_task_contains_targeted_mode(self, tmp_path): from factory.cli._task_builder import _build_ceo_task - task = _build_ceo_task(tmp_path, "improve", focus="Add caching") + task = _build_ceo_task(tmp_path, "design", focus="Add caching") assert "Targeted Mode" in task assert "exactly ONE hypothesis" in task assert "Add caching" in task @@ -1427,13 +1427,13 @@ def test_focus_task_contains_targeted_mode(self, tmp_path): def test_no_focus_no_targeted_mode(self, tmp_path): from factory.cli._task_builder import _build_ceo_task - task = _build_ceo_task(tmp_path, "improve") + task = _build_ceo_task(tmp_path, "design") assert "Targeted Mode" not in task def test_build_ceo_task_does_not_write_backlog(self, tmp_path): from factory.cli._task_builder import _build_ceo_task - _build_ceo_task(tmp_path, "improve", focus="Add caching") + _build_ceo_task(tmp_path, "design", focus="Add caching") backlog_path = tmp_path / ".factory" / "strategy" / "backlog.md" assert not backlog_path.exists() @@ -1459,7 +1459,7 @@ def test_focus_and_prompt_rejected_ceo(self, tmp_path): "--prompt", str(prompt_path), "--mode", - "improve", + "design", ] ) assert result == 1 @@ -1478,23 +1478,11 @@ def test_focus_and_prompt_rejected_run(self, tmp_path): "--prompt", str(prompt_path), "--mode", - "improve", + "design", ] ) assert result == 1 - def test_focus_rejected_in_build_mode(self): - from factory.cli import main - - result = main(["ceo", "/tmp/fake", "--focus", "fix bug", "--mode", "build"]) - assert result == 1 - - def test_focus_rejected_in_discover_mode(self): - from factory.cli import main - - result = main(["ceo", "/tmp/fake", "--focus", "fix bug", "--mode", "discover"]) - assert result == 1 - def test_focus_rejected_in_meta_mode(self): from factory.cli import main diff --git a/tests/test_summary.py b/tests/test_summary.py index e5e2b6890..5510bc92e 100644 --- a/tests/test_summary.py +++ b/tests/test_summary.py @@ -67,40 +67,101 @@ def summary_project(tmp_path: Path) -> Path: writer = csv.writer(buf, dialect="excel-tab") writer.writerow(TSV_COLUMNS) for row in [ - [1, "2026-04-26T10:00:00+00:00", "Add logging", "Added logging", "", "42", - "0.700", "0.745", "0.045", "keep", "1.50", "", ""], - [2, "2026-04-26T11:00:00+00:00", "Fix imports", "Fixed imports", "", "43", - "0.745", "0.760", "0.015", "keep", "0.80", "", ""], - [3, "2026-04-26T12:00:00+00:00", "Add caching", "Added caching", "", "", - "0.760", "0.755", "-0.005", "revert", "2.00", "", ""], - [4, "2026-04-26T13:00:00+00:00", "Broken refactor", "Refactored", "", "", - "0.760", "", "", "error", "0.50", "", ""], + [ + 1, + "2026-04-26T10:00:00+00:00", + "Add logging", + "Added logging", + "", + "42", + "0.700", + "0.745", + "0.045", + "keep", + "1.50", + "", + "", + ], + [ + 2, + "2026-04-26T11:00:00+00:00", + "Fix imports", + "Fixed imports", + "", + "43", + "0.745", + "0.760", + "0.015", + "keep", + "0.80", + "", + "", + ], + [ + 3, + "2026-04-26T12:00:00+00:00", + "Add caching", + "Added caching", + "", + "", + "0.760", + "0.755", + "-0.005", + "revert", + "2.00", + "", + "", + ], + [ + 4, + "2026-04-26T13:00:00+00:00", + "Broken refactor", + "Refactored", + "", + "", + "0.760", + "", + "", + "error", + "0.50", + "", + "", + ], ]: writer.writerow(row) (factory / "results.tsv").write_text(buf.getvalue()) # Write backlog (factory / "strategy" / "backlog.md").write_text( - "- Add rate limiting\n" - "- Improve test coverage\n" - "- Write API docs\n" + "- Add rate limiting\n- Improve test coverage\n- Write API docs\n" ) # Write eval_after with guard violations for experiment 3 exp3 = factory / "experiments" / "003" exp3.mkdir() - (exp3 / "eval_after.json").write_text(json.dumps({ - "total": 0.755, - "results": [], - "guard_violations": ["scope_check: modified files outside scope"], - "passed": False, - })) + (exp3 / "eval_after.json").write_text( + json.dumps( + { + "total": 0.755, + "results": [], + "guard_violations": ["scope_check: modified files outside scope"], + "passed": False, + } + ) + ) # Write events.jsonl with mode info (factory / "events.jsonl").write_text( - json.dumps({"type": "cycle.started", "timestamp": "2026-04-26T09:00:00Z", - "project": "summary-project", "agent": None, - "data": {"cycle": 1, "mode": "improve"}}) + "\n" + json.dumps( + { + "type": "cycle.started", + "timestamp": "2026-04-26T09:00:00Z", + "project": "summary-project", + "agent": None, + "data": {"cycle": 1, "mode": "design"}, + } + ) + + "\n" ) return project @@ -197,7 +258,7 @@ async def test_needs_human_input(summary_project: Path) -> None: async def test_mode_from_events(summary_project: Path) -> None: """Mode is detected from events.jsonl.""" summary = await generate_summary(summary_project) - assert summary.mode == "improve" + assert summary.mode == "design" async def test_session_scoping(summary_project: Path) -> None: @@ -206,13 +267,18 @@ async def test_session_scoping(summary_project: Path) -> None: # Add a second cycle.started at 11:30 — only experiments 3 and 4 should appear. events_path = summary_project / ".factory" / "events.jsonl" with open(events_path, "a") as f: - f.write(json.dumps({ - "type": "cycle.started", - "timestamp": "2026-04-26T11:30:00+00:00", - "project": "summary-project", - "agent": None, - "data": {"cycle": 2, "mode": "improve"}, - }) + "\n") + f.write( + json.dumps( + { + "type": "cycle.started", + "timestamp": "2026-04-26T11:30:00+00:00", + "project": "summary-project", + "agent": None, + "data": {"cycle": 2, "mode": "design"}, + } + ) + + "\n" + ) summary = await generate_summary(summary_project) all_ids = ( @@ -243,8 +309,23 @@ async def test_zero_cost_not_dropped(tmp_path: Path) -> None: buf = io.StringIO() writer = csv.writer(buf, dialect="excel-tab") writer.writerow(TSV_COLUMNS) - writer.writerow([1, "2026-04-26T10:00:00+00:00", "Free fix", "Fixed", - "", "", "0.7", "0.8", "0.1", "keep", "0.0", "", ""]) + writer.writerow( + [ + 1, + "2026-04-26T10:00:00+00:00", + "Free fix", + "Fixed", + "", + "", + "0.7", + "0.8", + "0.1", + "keep", + "0.0", + "", + "", + ] + ) (factory / "results.tsv").write_text(buf.getvalue()) summary = await generate_summary(project) @@ -259,7 +340,7 @@ def test_format_output() -> None: summary = SessionSummary( project_name="test-project", generated_at=datetime(2026, 4, 26, 12, 0, tzinfo=timezone.utc), - mode="improve", + mode="design", experiments_kept=[_make_record(id=1, verdict="keep")], experiments_reverted=[_make_record(id=2, verdict="revert", delta=-0.005)], experiments_errored=[], @@ -277,7 +358,7 @@ def test_format_output() -> None: assert "## What Was Deferred" in output assert "## Needs Your Input" in output assert "test-project" in output - assert "improve" in output + assert "design" in output assert "0.7000" in output assert "0.7450" in output assert "$2.30" in output @@ -290,7 +371,7 @@ def test_format_empty_kept() -> None: summary = SessionSummary( project_name="test", generated_at=datetime(2026, 4, 26, tzinfo=timezone.utc), - mode="improve", + mode="design", experiments_kept=[], experiments_reverted=[], experiments_errored=[], @@ -312,7 +393,7 @@ def test_format_no_scores() -> None: summary = SessionSummary( project_name="test", generated_at=datetime(2026, 4, 26, tzinfo=timezone.utc), - mode="build", + mode="design", experiments_kept=[], experiments_reverted=[], experiments_errored=[], @@ -338,7 +419,7 @@ async def test_save_creates_files(tmp_path: Path) -> None: summary = SessionSummary( project_name="save-project", generated_at=datetime(2026, 4, 26, 12, 0, tzinfo=timezone.utc), - mode="improve", + mode="design", experiments_kept=[_make_record()], experiments_reverted=[], experiments_errored=[], @@ -370,7 +451,7 @@ async def test_save_creates_reviews_dir(tmp_path: Path) -> None: summary = SessionSummary( project_name="no-reviews", generated_at=datetime(2026, 4, 26, tzinfo=timezone.utc), - mode="build", + mode="design", experiments_kept=[], experiments_reverted=[], experiments_errored=[], @@ -413,7 +494,7 @@ def test_session_summary_strict() -> None: SessionSummary( project_name="test", generated_at=datetime(2026, 4, 26, tzinfo=timezone.utc), - mode="improve", + mode="design", experiments_kept=[], experiments_reverted=[], experiments_errored=[], diff --git a/tests/test_tmux_cli.py b/tests/test_tmux_cli.py index fd52415b6..907f8598b 100644 --- a/tests/test_tmux_cli.py +++ b/tests/test_tmux_cli.py @@ -115,7 +115,7 @@ class TestBuildTmuxRunArgs: def test_propagates_all_flags(self) -> None: args = argparse.Namespace( path="/tmp/project", - mode="improve", + mode="design", loop=True, interval=900, max_cycles=5, @@ -136,7 +136,7 @@ def test_propagates_all_flags(self) -> None: ) result = _build_tmux_run_args(args, Path("/tmp/project"), "opus-4") - assert "--mode improve" in result + assert "--mode design" in result assert "--loop" not in result assert "--interval" not in result assert "--max-cycles" not in result @@ -158,22 +158,48 @@ def test_propagates_all_flags(self) -> None: def test_no_clean_pr(self) -> None: args = argparse.Namespace( - mode=None, loop=False, interval=0, max_cycles=None, - no_github=False, profile=None, focus=None, refine=None, - clean_pr=False, runner=None, prompt=None, branch=None, - min_growth=None, max_new=None, discover_only=False, - bg_agents=False, tmux_persist=False, use_profile=False, + mode=None, + loop=False, + interval=0, + max_cycles=None, + no_github=False, + profile=None, + focus=None, + refine=None, + clean_pr=False, + runner=None, + prompt=None, + branch=None, + min_growth=None, + max_new=None, + discover_only=False, + bg_agents=False, + tmux_persist=False, + use_profile=False, ) result = _build_tmux_run_args(args, Path("/tmp/p"), None) assert "--no-clean-pr" in result def test_minimal_args(self) -> None: args = argparse.Namespace( - mode=None, loop=False, interval=0, max_cycles=None, - no_github=False, profile=None, focus=None, refine=None, - clean_pr=None, runner=None, prompt=None, branch=None, - min_growth=None, max_new=None, discover_only=False, - bg_agents=False, tmux_persist=False, use_profile=False, + mode=None, + loop=False, + interval=0, + max_cycles=None, + no_github=False, + profile=None, + focus=None, + refine=None, + clean_pr=None, + runner=None, + prompt=None, + branch=None, + min_growth=None, + max_new=None, + discover_only=False, + bg_agents=False, + tmux_persist=False, + use_profile=False, ) result = _build_tmux_run_args(args, Path("/tmp/p"), None) assert result == "factory ceo /tmp/p" @@ -188,7 +214,9 @@ def test_requires_all_when_no_session_or_path(self) -> None: patch("subprocess.run") as mock_run, ): mock_run.return_value = MagicMock( - returncode=0, stdout="factory-app-abc123\n", stderr="", + returncode=0, + stdout="factory-app-abc123\n", + stderr="", ) rc = cmd_tmux_stop(args) @@ -327,7 +355,7 @@ def test_mapping_read_on_ls(self, tmp_path: Path) -> None: class TestTmuxModeChoices: - @pytest.mark.parametrize("mode", ["design", "interactive", "review", "create"]) + @pytest.mark.parametrize("mode", ["design", "founder", "research", "create"]) def test_tmux_accepts_ceo_only_modes(self, mode: str) -> None: parser = build_parser() args = parser.parse_args(["tmux", "/tmp/project", "--mode", mode]) @@ -367,7 +395,8 @@ def test_captures_with_session_name(self) -> None: patch("builtins.print") as mock_print, ): mock_run.return_value = MagicMock( - returncode=0, stdout="line1\nline2\n", + returncode=0, + stdout="line1\nline2\n", ) rc = cmd_tmux_capture(args) @@ -422,7 +451,10 @@ def test_path_based_lookup_from_mapping(self) -> None: with ( patch("factory.cli._tmux_commands._tmux_available", return_value=True), - patch("factory.cli._tmux_commands._load_tmux_session_mapping", return_value={"factory-myproject-abc123": "/tmp/myproject"}), + patch( + "factory.cli._tmux_commands._load_tmux_session_mapping", + return_value={"factory-myproject-abc123": "/tmp/myproject"}, + ), patch("factory.cli._tmux_commands._tmux_session_alive", return_value=True), patch("subprocess.run") as mock_run, patch("builtins.print"), @@ -508,7 +540,9 @@ def test_warns_when_error_markers_in_pane_output(self) -> None: mock_run.side_effect = [ MagicMock(returncode=1), # has-session (not found) MagicMock(returncode=0), # new-session - MagicMock(returncode=0, stdout="Error: something went wrong\n", stderr=""), # capture-pane + MagicMock( + returncode=0, stdout="Error: something went wrong\n", stderr="" + ), # capture-pane ] rc = cmd_tmux(args) @@ -565,7 +599,9 @@ def test_returns_error_when_session_dies_immediately(self) -> None: class TestCmdTmuxStopEdgeCases: def test_tmux_not_available(self) -> None: - args = argparse.Namespace(session="factory-app-abc123", path=None, stop_all=False, force=False) + args = argparse.Namespace( + session="factory-app-abc123", path=None, stop_all=False, force=False + ) with ( patch("factory.cli._tmux_commands._tmux_available", return_value=False), @@ -592,7 +628,9 @@ def test_path_derives_session_name(self) -> None: assert any("not found" in str(c) for c in mock_print.call_args_list) def test_session_not_found_in_tmux(self) -> None: - args = argparse.Namespace(session="factory-gone-abc123", path=None, stop_all=False, force=False) + args = argparse.Namespace( + session="factory-gone-abc123", path=None, stop_all=False, force=False + ) with ( patch("factory.cli._tmux_commands._tmux_available", return_value=True), @@ -608,7 +646,9 @@ def test_session_not_found_in_tmux(self) -> None: class TestCmdTmuxStopOwnership: def test_warns_and_blocks_unregistered_session(self) -> None: - args = argparse.Namespace(session="factory-mystery-abc123", path=None, stop_all=False, force=False) + args = argparse.Namespace( + session="factory-mystery-abc123", path=None, stop_all=False, force=False + ) with ( patch("factory.cli._tmux_commands._tmux_available", return_value=True), @@ -626,7 +666,9 @@ def test_warns_and_blocks_unregistered_session(self) -> None: assert len(kill_calls) == 0 def test_force_kills_unregistered_session(self) -> None: - args = argparse.Namespace(session="factory-mystery-abc123", path=None, stop_all=False, force=True) + args = argparse.Namespace( + session="factory-mystery-abc123", path=None, stop_all=False, force=True + ) with ( patch("factory.cli._tmux_commands._tmux_available", return_value=True), @@ -640,11 +682,16 @@ def test_force_kills_unregistered_session(self) -> None: assert rc == 0 def test_registered_session_killed_without_force(self) -> None: - args = argparse.Namespace(session="factory-app-abc123", path=None, stop_all=False, force=False) + args = argparse.Namespace( + session="factory-app-abc123", path=None, stop_all=False, force=False + ) with ( patch("factory.cli._tmux_commands._tmux_available", return_value=True), - patch("factory.cli._tmux_commands._load_tmux_session_mapping", return_value={"factory-app-abc123": "/tmp/app"}), + patch( + "factory.cli._tmux_commands._load_tmux_session_mapping", + return_value={"factory-app-abc123": "/tmp/app"}, + ), patch("subprocess.run") as mock_run, patch("builtins.print"), ): diff --git a/tests/test_visualizer.py b/tests/test_visualizer.py index 426136804..e7e619ebf 100644 --- a/tests/test_visualizer.py +++ b/tests/test_visualizer.py @@ -15,8 +15,20 @@ ) -def _event(event_type: str, *, agent: str | None = None, data: dict | None = None, ts: str = "2026-05-03T12:00:00Z") -> dict: - return {"type": event_type, "timestamp": ts, "project": "test-project", "agent": agent, "data": data or {}} +def _event( + event_type: str, + *, + agent: str | None = None, + data: dict | None = None, + ts: str = "2026-05-03T12:00:00Z", +) -> dict: + return { + "type": event_type, + "timestamp": ts, + "project": "test-project", + "agent": agent, + "data": data or {}, + } class TestInferStateEmpty: @@ -80,8 +92,8 @@ def test_task_truncated_to_100_chars(self): class TestPhaseInference: def test_detect_phase(self): state = infer_state([_event("detect", data={"state": "new"})]) - assert state.current_phase == "Research" - assert state.current_mode == "Build" + assert state.current_phase == "Observe" + assert state.current_mode == "Design" def test_discover_phase(self): state = infer_state([_event("discover.started")]) @@ -101,7 +113,9 @@ def test_agent_sets_phase(self): for agent, expected_phase in cases: events = [_event("agent.started", agent=agent, data={"task": "work"})] state = infer_state(events) - assert state.current_phase == expected_phase, f"Agent {agent} should set phase {expected_phase}" + assert state.current_phase == expected_phase, ( + f"Agent {agent} should set phase {expected_phase}" + ) def test_eval_events_set_phase(self): state = infer_state([_event("eval.started", data={"command": "python eval/score.py"})]) @@ -135,12 +149,12 @@ def test_mode_from_cycle_started(self): def test_mode_from_detect_new(self): events = [_event("detect", data={"state": "new"})] state = infer_state(events) - assert state.current_mode == "Build" + assert state.current_mode == "Design" def test_mode_from_detect_running(self): events = [_event("detect", data={"state": "running"})] state = infer_state(events) - assert state.current_mode == "Improve" + assert state.current_mode == "Design" def test_cycle_mode_overrides_detect(self): events = [ @@ -180,8 +194,8 @@ class TestUpdateState: def test_incremental_update(self): state = FactoryLiveState() state = update_state(state, _event("detect", data={"state": "new"})) - assert state.current_phase == "Research" - assert state.current_mode == "Build" + assert state.current_phase == "Observe" + assert state.current_mode == "Design" state = update_state(state, _event("agent.started", agent="builder", data={"task": "work"})) assert state.current_phase == "Build" @@ -194,7 +208,9 @@ def test_incremental_update(self): class TestToDict: def test_serialization(self): state = FactoryLiveState() - state.active_agents["builder"] = AgentActivity(role="builder", task="work", started_at="2026-05-03T12:00:00Z") + state.active_agents["builder"] = AgentActivity( + role="builder", task="work", started_at="2026-05-03T12:00:00Z" + ) state.current_phase = "Build" state.current_mode = "Improve" state.current_experiment = {"id": 1, "hypothesis": "test"} @@ -242,7 +258,15 @@ def test_middle_phase(self): def test_last_phase(self): state = FactoryLiveState(current_phase="Archive") - assert completed_phases(state) == ["Detect", "Discover", "Research", "Strategize", "Build", "Review", "Eval"] + assert completed_phases(state) == [ + "Detect", + "Discover", + "Research", + "Strategize", + "Build", + "Review", + "Eval", + ] class TestActiveAgentCount: @@ -251,8 +275,12 @@ def test_empty(self): def test_with_agents(self): state = FactoryLiveState() - state.active_agents["builder"] = AgentActivity(role="builder", task="work", started_at="2026-05-03T12:00:00Z") - state.active_agents["code_reviewer"] = AgentActivity(role="code_reviewer", task="review", started_at="2026-05-03T12:00:00Z") + state.active_agents["builder"] = AgentActivity( + role="builder", task="work", started_at="2026-05-03T12:00:00Z" + ) + state.active_agents["code_reviewer"] = AgentActivity( + role="code_reviewer", task="review", started_at="2026-05-03T12:00:00Z" + ) assert active_agent_count(state) == 2 @@ -265,6 +293,7 @@ def test_invalid_timestamp(self): def test_recent_timestamp(self): from datetime import datetime, timezone + now = datetime.now(timezone.utc).isoformat() result = format_elapsed(now) assert result.endswith("s") @@ -277,7 +306,7 @@ def test_old_timestamp(self): class TestModeAwarePhaseInference: def test_improve_researcher_sets_observe(self): events = [ - _event("cycle.started", data={"mode": "improve"}), + _event("cycle.started", data={"mode": "design"}), _event("agent.started", agent="researcher", data={"task": "study"}), ] state = infer_state(events) @@ -285,7 +314,7 @@ def test_improve_researcher_sets_observe(self): def test_improve_strategist_sets_hypothesize(self): events = [ - _event("cycle.started", data={"mode": "improve"}), + _event("cycle.started", data={"mode": "design"}), _event("agent.started", agent="strategist", data={"task": "plan"}), ] state = infer_state(events) @@ -307,13 +336,13 @@ def test_research_health_checker_sets_run(self): state = infer_state(events) assert state.current_phase == "Run" - def test_build_strategist_sets_plan(self): + def test_design_strategist_sets_hypothesize(self): events = [ - _event("cycle.started", data={"mode": "build"}), + _event("cycle.started", data={"mode": "design"}), _event("agent.started", agent="strategist", data={"task": "plan"}), ] state = infer_state(events) - assert state.current_phase == "Plan" + assert state.current_phase == "Hypothesize" def test_no_mode_uses_generic_mapping(self): events = [ @@ -332,7 +361,7 @@ def test_meta_ace_event_sets_ace_phase(self): def test_hypothesis_number_increments(self): events = [ - _event("cycle.started", data={"mode": "improve"}), + _event("cycle.started", data={"mode": "design"}), _event("experiment.begin", data={"exp_id": 1, "hypothesis": "H1"}), _event("experiment.finalize", data={"exp_id": 1, "verdict": "keep"}), _event("experiment.begin", data={"exp_id": 2, "hypothesis": "H2"}), @@ -343,15 +372,15 @@ def test_hypothesis_number_increments(self): def test_hypothesis_number_resets_on_new_cycle(self): events = [ - _event("cycle.started", data={"mode": "improve"}), + _event("cycle.started", data={"mode": "design"}), _event("experiment.begin", data={"exp_id": 1, "hypothesis": "H1"}), - _event("cycle.started", data={"mode": "improve"}), + _event("cycle.started", data={"mode": "design"}), ] state = infer_state(events) assert state.hypothesis_number == 0 def test_to_dict_includes_mode_phases(self): - events = [_event("cycle.started", data={"mode": "improve"})] + events = [_event("cycle.started", data={"mode": "design"})] state = infer_state(events) d = state.to_dict() assert d["phases"] == ["Observe", "Hypothesize", "Build", "Review", "Eval", "Archive"] @@ -374,19 +403,19 @@ def test_to_dict_no_mode_uses_generic(self): class TestModeAwarePhaseIndex: def test_improve_observe_index(self): - assert phase_index("Observe", mode="improve") == 0 + assert phase_index("Observe", mode="design") == 0 def test_improve_hypothesize_index(self): - assert phase_index("Hypothesize", mode="improve") == 1 + assert phase_index("Hypothesize", mode="design") == 1 def test_improve_research_not_found(self): - assert phase_index("Research", mode="improve") == -1 + assert phase_index("Research", mode="design") == -1 def test_generic_research_found(self): assert phase_index("Research", mode=None) == 2 def test_completed_phases_mode_aware(self): - state = FactoryLiveState(current_phase="Build", current_mode="improve") + state = FactoryLiveState(current_phase="Build", current_mode="design") assert completed_phases(state) == ["Observe", "Hypothesize"] def test_completed_phases_generic(self): @@ -395,8 +424,8 @@ def test_completed_phases_generic(self): class TestGetPhasesForMode: - def test_improve(self): - phases = get_phases_for_mode("improve") + def test_design(self): + phases = get_phases_for_mode("design") assert phases[0] == "Observe" assert "Archive" in phases @@ -417,7 +446,7 @@ def test_improve_from_config(self, tmp_path): factory_dir = tmp_path / ".factory" factory_dir.mkdir() (factory_dir / "config.json").write_text('{"goal":"test"}') - assert infer_mode_from_artifacts(factory_dir) == "improve" + assert infer_mode_from_artifacts(factory_dir) == "design" def test_research_from_target(self, tmp_path): factory_dir = tmp_path / ".factory" @@ -431,7 +460,7 @@ def test_discover_from_profile(self, tmp_path): factory_dir = tmp_path / ".factory" factory_dir.mkdir() (factory_dir / "eval_profile.json").write_text('{"project_type":"python"}') - assert infer_mode_from_artifacts(factory_dir) == "discover" + assert infer_mode_from_artifacts(factory_dir) == "design" def test_none_from_empty(self, tmp_path): assert infer_mode_from_artifacts(tmp_path / ".factory") is None diff --git a/tests/test_workflow_cli.py b/tests/test_workflow_cli.py index 9b8ac60fb..02d364835 100644 --- a/tests/test_workflow_cli.py +++ b/tests/test_workflow_cli.py @@ -84,10 +84,10 @@ def test_success_returns_0(self, tmp_path: Path) -> None: patch("factory.agents.runner.begin_cycle_session", return_value="span-123") as mock_begin, patch("factory.agents.runner.complete_cycle_session") as mock_complete, ): - result = _cmd_run(_make_args("build", str(tmp_path))) + result = _cmd_run(_make_args("design", str(tmp_path))) assert result == 0 - mock_begin.assert_called_once_with(tmp_path.resolve(), cycle_id="build") + mock_begin.assert_called_once_with(tmp_path.resolve(), cycle_id="design") mock_complete.assert_called_once_with(tmp_path.resolve(), "span-123") def test_failure_returns_1(self, tmp_path: Path) -> None: @@ -101,7 +101,7 @@ def test_failure_returns_1(self, tmp_path: Path) -> None: patch("factory.agents.runner.begin_cycle_session", return_value=None), patch("factory.agents.runner.complete_cycle_session"), ): - result = _cmd_run(_make_args("build", str(tmp_path))) + result = _cmd_run(_make_args("design", str(tmp_path))) assert result == 1 @@ -117,7 +117,7 @@ def test_complete_called_on_exception(self, tmp_path: Path) -> None: patch("factory.agents.runner.complete_cycle_session") as mock_complete, ): with pytest.raises(RuntimeError, match="boom"): - _cmd_run(_make_args("build", str(tmp_path))) + _cmd_run(_make_args("design", str(tmp_path))) mock_begin.assert_called_once() mock_complete.assert_called_once_with(tmp_path.resolve(), "span-456") @@ -133,7 +133,7 @@ def test_executor_receives_correct_params(self, tmp_path: Path) -> None: patch("factory.agents.runner.begin_cycle_session", return_value=None), patch("factory.agents.runner.complete_cycle_session"), ): - _cmd_run(_make_args("improve", str(tmp_path), dry_run=True)) + _cmd_run(_make_args("design", str(tmp_path), dry_run=True)) mock_cls.assert_called_once_with( mock_wf, @@ -196,13 +196,13 @@ def test_dispatches_to_list(self) -> None: m.assert_called_once_with(args) def test_dispatches_to_show(self) -> None: - args = argparse.Namespace(workflow_command="show", name="build", project_path=None) + args = argparse.Namespace(workflow_command="show", name="design", project_path=None) with patch("factory.workflow.cli._cmd_show", return_value=0) as m: assert cmd_workflow(args) == 0 m.assert_called_once_with(args) def test_dispatches_to_validate(self) -> None: - args = argparse.Namespace(workflow_command="validate", name="build", project_path=None) + args = argparse.Namespace(workflow_command="validate", name="design", project_path=None) with patch("factory.workflow.cli._cmd_validate", return_value=0) as m: assert cmd_workflow(args) == 0 m.assert_called_once_with(args) diff --git a/tests/test_workflow_deep_research.py b/tests/test_workflow_deep_research.py index 8b9ad4af0..ddad7eafc 100644 --- a/tests/test_workflow_deep_research.py +++ b/tests/test_workflow_deep_research.py @@ -265,7 +265,7 @@ def test_trigger_does_not_fire_for_other_modes(self) -> None: wf = deep_research_workflow() assert wf.trigger is not None assert not wf.trigger(ProjectState.HAS_FACTORY, {}) - assert not wf.trigger(ProjectState.HAS_FACTORY, {"mode": "improve"}) + assert not wf.trigger(ProjectState.HAS_FACTORY, {"mode": "design"}) assert not wf.trigger(ProjectState.HAS_FACTORY, {"mode": "research"}) assert not wf.trigger(ProjectState.HAS_FACTORY, {"mode": "founder"}) diff --git a/tests/test_workflow_definitions.py b/tests/test_workflow_definitions.py index 75cfaccbd..bd793a95c 100644 --- a/tests/test_workflow_definitions.py +++ b/tests/test_workflow_definitions.py @@ -327,16 +327,13 @@ def test_default_pool_models(self) -> None: class TestRegisterAll: def test_all_workflows_registered(self) -> None: all_wf = register_all() - assert len(all_wf) >= 13, f"Expected at least 13 workflows, got {len(all_wf)}" + assert len(all_wf) >= 9, f"Expected at least 9 workflows, got {len(all_wf)}" required = { - "build", "design", - "improve", "deep-qa", "deep-research", "research", "meta", - "discover", "review", "refine", "create", @@ -375,7 +372,7 @@ def test_trigger(self) -> None: wf = study_standalone_workflow() assert wf.trigger is not None assert wf.trigger(ProjectState.HAS_FACTORY, {"mode": "study"}) - assert not wf.trigger(ProjectState.HAS_FACTORY, {"mode": "improve"}) + assert not wf.trigger(ProjectState.HAS_FACTORY, {"mode": "design"}) assert not wf.trigger(ProjectState.NO_REPO, {"mode": "study"}) def test_terminal(self) -> None: @@ -417,7 +414,9 @@ def test_graph_explorer_prompt_includes_project_path_in_commands(self) -> None: assert "{project_path}/graph.json" in prompt, ( "prompt must reference graph.json with {project_path} prefix" ) - assert "NOT inside `.factory/`" in prompt, "prompt must clarify graph.json is not in .factory/" + assert "NOT inside `.factory/`" in prompt, ( + "prompt must clarify graph.json is not in .factory/" + ) def test_concat_study_writes_combined(self) -> None: wf = study_standalone_workflow() @@ -472,7 +471,7 @@ def test_create_trigger(self) -> None: assert wf.trigger(ProjectState.HAS_FACTORY, {"mode": "create"}) assert wf.trigger(ProjectState.NO_REPO, {"mode": "create"}) assert not wf.trigger(ProjectState.HAS_FACTORY, {}) - assert not wf.trigger(ProjectState.HAS_FACTORY, {"mode": "improve"}) + assert not wf.trigger(ProjectState.HAS_FACTORY, {"mode": "design"}) def test_create_name(self) -> None: wf = create_workflow() @@ -1017,7 +1016,7 @@ def test_founder_workflow_trigger(self) -> None: assert wf.trigger is not None assert wf.trigger(ProjectState.HAS_FACTORY, {"mode": "founder"}) assert not wf.trigger(ProjectState.HAS_FACTORY, {}) - assert not wf.trigger(ProjectState.HAS_FACTORY, {"mode": "improve"}) + assert not wf.trigger(ProjectState.HAS_FACTORY, {"mode": "design"}) assert not wf.trigger(ProjectState.NO_REPO, {"mode": "founder"}) def test_founder_name(self) -> None: diff --git a/tests/test_workflow_e2e.py b/tests/test_workflow_e2e.py index 7f701324a..6064426b7 100644 --- a/tests/test_workflow_e2e.py +++ b/tests/test_workflow_e2e.py @@ -316,25 +316,25 @@ def test_workflow_list(self) -> None: timeout=30, ) assert result.returncode == 0 - for name in ("build", "design", "improve", "research", "meta"): + for name in ("design", "research", "meta"): assert name in result.stdout - def test_workflow_show_build(self) -> None: - """factory workflow show build prints node/edge table.""" + def test_workflow_show_design(self) -> None: + """factory workflow show design prints node/edge table.""" result = subprocess.run( - ["python", "-m", "factory", "workflow", "show", "build"], + ["python", "-m", "factory", "workflow", "show", "design"], capture_output=True, text=True, timeout=30, ) assert result.returncode == 0 - assert "Workflow: build" in result.stdout + assert "Workflow: design" in result.stdout assert "Nodes:" in result.stdout assert "Edges:" in result.stdout def test_workflow_validate_all(self) -> None: """factory workflow validate passes for all workflows.""" - for name in ("build", "design", "improve", "research", "meta"): + for name in ("design", "research", "meta"): result = subprocess.run( ["python", "-m", "factory", "workflow", "validate", name], capture_output=True, @@ -360,7 +360,7 @@ def test_workflow_dry_run(self, tmp_path: Path) -> None: result = subprocess.run( [ "python", "-m", "factory", "workflow", "run", - "improve", str(project), "--dry-run", + "design", str(project), "--dry-run", ], capture_output=True, text=True, diff --git a/tests/test_workflow_frontend_design.py b/tests/test_workflow_frontend_design.py index baabd6b07..077853249 100644 --- a/tests/test_workflow_frontend_design.py +++ b/tests/test_workflow_frontend_design.py @@ -56,7 +56,7 @@ def test_matches_explicit_mode(self) -> None: def test_rejects_other_modes(self) -> None: wf = frontend_design_workflow() - assert not wf.trigger(ProjectState.HAS_FACTORY, {"mode": "improve"}) + assert not wf.trigger(ProjectState.HAS_FACTORY, {"mode": "design"}) assert not wf.trigger(ProjectState.HAS_FACTORY, {"mode": "design"}) assert not wf.trigger(ProjectState.HAS_FACTORY, {}) diff --git a/tests/test_workflow_frontend_design_discover.py b/tests/test_workflow_frontend_design_discover.py index 3020b20f9..dba9ff65d 100644 --- a/tests/test_workflow_frontend_design_discover.py +++ b/tests/test_workflow_frontend_design_discover.py @@ -57,7 +57,7 @@ def test_matches_explicit_mode(self) -> None: def test_rejects_other_modes(self) -> None: wf = frontend_design_discover_workflow() assert not wf.trigger(ProjectState.HAS_FACTORY, {"mode": "frontend-design"}) - assert not wf.trigger(ProjectState.HAS_FACTORY, {"mode": "improve"}) + assert not wf.trigger(ProjectState.HAS_FACTORY, {"mode": "design"}) assert not wf.trigger(ProjectState.HAS_FACTORY, {}) diff --git a/tests/test_workflow_frontend_design_scan.py b/tests/test_workflow_frontend_design_scan.py index 9418f8a2f..56a4dcca3 100644 --- a/tests/test_workflow_frontend_design_scan.py +++ b/tests/test_workflow_frontend_design_scan.py @@ -57,7 +57,7 @@ def test_matches_explicit_mode(self) -> None: def test_rejects_other_modes(self) -> None: wf = frontend_design_scan_workflow() assert not wf.trigger(ProjectState.HAS_FACTORY, {"mode": "frontend-design"}) - assert not wf.trigger(ProjectState.HAS_FACTORY, {"mode": "improve"}) + assert not wf.trigger(ProjectState.HAS_FACTORY, {"mode": "design"}) assert not wf.trigger(ProjectState.HAS_FACTORY, {}) diff --git a/tests/test_workflow_overwrite.py b/tests/test_workflow_overwrite.py index 3f2143075..1c0516fdf 100644 --- a/tests/test_workflow_overwrite.py +++ b/tests/test_workflow_overwrite.py @@ -161,7 +161,7 @@ def test_build_tmux_run_args_includes_overwrite(self) -> None: from factory.cli._tmux_commands import _build_tmux_run_args args = argparse.Namespace( - mode="improve", + mode="design", no_github=False, profile=None, focus=None, diff --git a/tests/test_workflow_qa.py b/tests/test_workflow_qa.py index 01a94c373..54db94631 100644 --- a/tests/test_workflow_qa.py +++ b/tests/test_workflow_qa.py @@ -150,7 +150,7 @@ def test_trigger(self) -> None: assert wf.trigger is not None assert wf.trigger(ProjectState.HAS_FACTORY, {"mode": "deep-qa"}) assert not wf.trigger(ProjectState.HAS_FACTORY, {}) - assert not wf.trigger(ProjectState.HAS_FACTORY, {"mode": "improve"}) + assert not wf.trigger(ProjectState.HAS_FACTORY, {"mode": "design"}) def test_registered(self) -> None: all_wf = register_all() diff --git a/tests/test_workflow_registry.py b/tests/test_workflow_registry.py index 1030f0765..a6e3082e9 100644 --- a/tests/test_workflow_registry.py +++ b/tests/test_workflow_registry.py @@ -23,9 +23,9 @@ def _reset_registry(): class TestDiscovery: def test_discovers_builtins(self) -> None: entries = WorkflowRegistry.discover() - assert "improve" in entries - assert "build" in entries - assert entries["improve"].source == "builtin" + assert "design" in entries + assert "research" in entries + assert entries["design"].source == "builtin" def test_discovers_from_project_path(self, tmp_path: Path) -> None: wf_dir = tmp_path / ".factory" / "workflows" @@ -54,9 +54,9 @@ def test_returns_none_for_unknown(self) -> None: assert wf is None def test_returns_builtin(self) -> None: - wf = WorkflowRegistry.get_workflow("improve") + wf = WorkflowRegistry.get_workflow("design") assert wf is not None - assert wf.name == "improve" + assert wf.name == "design" # ── list_workflows ─────────────────────────────────────────────── @@ -67,8 +67,8 @@ def test_returns_sorted_entries(self) -> None: workflows = WorkflowRegistry.list_workflows() names = [w.name for w in workflows] assert len(names) >= 11 # at least the built-ins - assert "improve" in names - assert "build" in names + assert "design" in names + assert "research" in names # ── reset ──────────────────────────────────────────────────────── diff --git a/tests/test_workflow_research.py b/tests/test_workflow_research.py index 2617d6fad..9574a89d9 100644 --- a/tests/test_workflow_research.py +++ b/tests/test_workflow_research.py @@ -301,7 +301,7 @@ def test_trigger_does_not_fire_for_other_modes(self) -> None: wf = self._get_wf() assert wf.trigger is not None assert not wf.trigger(ProjectState.HAS_FACTORY, {}) - assert not wf.trigger(ProjectState.HAS_FACTORY, {"mode": "improve"}) + assert not wf.trigger(ProjectState.HAS_FACTORY, {"mode": "design"}) assert not wf.trigger(ProjectState.HAS_FACTORY, {"mode": "research"}) def test_registered(self) -> None: