CLAUDE.md · git:20260829.9523d6f · 2026-08-29 · sha256 e1781b98f9e4782b

CLAUDE.md git:20260829.9523d6fA

Immutable. This exact content is served forever at /api/v1/blob/e1781b98f9e4782b.

# CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

## Build & Run

```bash
uv sync                          # Install all deps (including dev group)
factory --help                   # Verify CLI entry point
```

## Test

```bash
pytest -v                        # Full suite
pytest tests/test_models.py -v   # Single file
pytest -k "test_detect" -v       # By name pattern
pytest --cov                     # With coverage
```

Tests use `pytest-asyncio` with `asyncio_mode = "auto"` — async test functions run without `@pytest.mark.asyncio`. Shared fixtures (`tmp_project`, `sample_config`, `python_project`) live in `tests/conftest.py`. An autouse `_isolate_registry` fixture redirects the global registry to a temp directory during tests.

## Lint & Type Check

```bash
ruff check .                     # Lint
ruff check --fix .               # Lint with autofix
mypy factory/                    # Type check
```

## Style

- Python 3.11+ — use `X | Y` unions, not `Union[X, Y]`
- Snake_case everywhere
- 100 char line length (enforced by ruff)
- All Pydantic models use `ConfigDict(strict=True, extra="forbid")`
- Async/await by default — library functions in `store.py` and `eval/runner.py` are async, the CLI wraps them with `asyncio.run()`
- Structured logging via `structlog` — use `log = structlog.get_logger()` at module level

## Versioning

Version is derived from git tags via `hatch-vcs` at build time — no static `version =` in pyproject.toml.

- Tag pattern: `v*` (e.g., `v0.3.1`); `nightly-*` tags are ignored via `--match 'v*'`
- Dev installs show `X.Y.Z.devN+gSHA` between releases
- `factory/_version.py` is generated by the hatch-vcs build hook and gitignored
- After pulling new tags, re-run `uv sync` for editable installs to pick up the new version
- `fallback_version = "0.0.0"` is used in environments without git history (Docker builds, tarballs)
- Runtime version: `importlib.metadata.version("remote-factory")`
- CLI: `factory --version`

## Architecture (v2 — CEO Agent + Workflow Graph Engine)

The factory is a **four-layer system**:

### Layer 1: Python CLI (`factory/`)

Pure tools that don't make decisions. Entry point is `factory/cli.py` → `factory.cli:main` (registered as `factory` script in pyproject.toml). Each subcommand is a `cmd_*` function dispatched via a handler dict. Key modules include `factory/clean_pr.py` (Clean PR Mode — strips non-essential artifacts from PRs before pushing to external repos).

### Layer 2: Workflow Graph Engine (`factory/workflow/`)

All 10 factory modes (build, design, improve, research, meta, discover, review, refine, founder, plan) are defined as directed graphs of typed nodes in `factory/workflow/definitions.py`. Each graph is a `Workflow` Pydantic model with `AgentNode`, `FnNode`, `GateNode`, `ForkNode`, `JoinNode`, and `Study` primitives connected by `Edge` objects. See `factory/workflow/README.md` for full documentation.

The same graph definition produces two execution formats:
- **Headless:** `WorkflowExecutor` (`factory/workflow/executor.py`) walks the DAG deterministically — `factory workflow run <name> --project /path`
- **Interactive:** `skill_export.py` converts graphs to Claude Code `SKILL.md` files under `skills/workflow-*/` — the CEO agent reads these at runtime as mode-specific playbooks

**Package ecosystem** (`factory/workflow/package.py`): A `Package` wraps a workflow subgraph behind a typed interface (`Port`, `StateContract`, `OptKnob`, `MemoryDeclaration`) and composes with other Packages via `Sequential`, `Parallel`, `Conditional`, and `Loop` operators. `Package.compile()` lowers compositions to flat `Workflow` IR with `knob_values`, `knob_bounds`, and `knob_expandable` fields for optimizer-tunable parameters. See `docs/design/package-ecosystem.md` for the design doc.

### Layer 2b: Outer Loop — Evolutionary Workflow Search (`factory/outer_loop/`)

The outer loop evolves workflow *topologies* via MAP-Elites quality-diversity search. Given a base workflow (e.g. the single-builder FeatureBench seed), it produces a population of structurally diverse candidates, evaluates each via an inner loop (one full CEO cycle per candidate), and uses contrastive reflection to guide mutations toward higher fitness.

**Pipeline:** `calibrate → evolve → reflect → evaluate` (repeats until budget exhaustion, plateau, or target score).

**Key modules:**
- `engine.py` — `SwarmEngine` orchestrates the evolutionary loop: seeding, tournament selection, mutation, evaluation, convergence detection (plateau, diversity collapse, early stop)
- `evaluator.py` — `SwarmEvaluator` with `FitnessCache` (structural-hash dedup) and `CycleRecordCache` (content-hash dedup). Supports both `EvaluatorFn` protocol and `FeatureBenchInnerLoop` evaluation with git worktree isolation
- `mutations.py` — 8 structured mutation operators (`NODE_INSERT`, `NODE_REMOVE`, `EDGE_REDIRECT`, `PARALLELIZE`, `SERIALIZE`, `PARAM_MUTATE`, `PROMPT_MUTATE`, `KNOB_MUTATE`) with `WeightedRandomStrategy`, reflection-guided selection, and `default_knob_expander` (Opus via CLI) for runtime expansion of `OptKnob` bounds
- `population.py` — `Population` (collection management) and `MAPElitesArchive` (4D grid: depth × fork_degree × agent_count × gate_count)
- `similarity.py` — `structural_hash`, `graph_edit_distance`, `compute_features` (includes knob values as feature dimensions for MAP-Elites diversity), `NoveltyFilter`
- `reflector.py` — `OuterLoopReflector` performs two-stage contrastive reflection (top-K vs bottom-K) to identify failure/success patterns and generate mutation suggestions
- `mode_registry.py` — `EphemeralModeRegistry` registers candidate workflows as temporary modes (`evolve-gen{N}-{id[:8]}`) with content-hash integrity checking, target-dir mirroring, and promotion to permanent modes
- `designer.py` — `DesignerAgent` generates from-scratch workflow designs (minimal, thorough, custom variants)
- `models.py` — Pydantic models: `SwarmConfig`, `Individual`, `EvalResult`, `GenerationSummary`, `OuterLoopResult`, `HyperparameterRecord`, `MutationRecord`, `OuterLoopState`, `AuditResult`
- `overfit.py` — `OverfitDetector` compares training vs holdout scores to flag overfitting
- `subset.py` — `SubsetSelector` protocol and `FixedSubsetSelector` for training instance selection
- `filesystem.py` — Outer loop directory initialization, config/checkpoint persistence
- `featurebench_inner_loop.py` — Bridges outer loop evaluation to a full CEO cycle on a FeatureBench instance

**E2E finding:** On simple FeatureBench tasks, a single-builder topology (1 AgentNode, no fork/join) wins on parsimony + cost. The outer loop's value emerges on harder multi-agent problems where topology diversity matters.

### Layer 3: CEO Agent (`factory/agents/prompts/ceo.md` + `skills/workflow-*/SKILL.md`)

The CEO prompt is split into two parts:
- **`ceo.md` (501 lines)** — core identity, cross-cutting rules (Sacred Rules, FEEC, keep/revert framework, review gates, error recovery, self-learning). No mode-specific procedures.
- **`skills/workflow-*/SKILL.md` (8 files)** — each mode's full step-by-step playbook, auto-generated from the workflow graph definitions via `factory workflow export-skills`.

Spawned via `factory ceo /path` or `factory run /path`. The CEO receives `ceo.md` as its system prompt, detects project state, then reads the appropriate `SKILL.md` into its context and follows it as the mode-specific playbook.

### Layer 4: Specialist Agents (`factory/agents/`)

Eight specialist Claude Code subprocesses spawned by the CEO via `factory agent <role>`. Agent prompts are resolved via `factory/agents/runner.py` with a two-tier lookup: project-specific override (`.factory/agents/<role>.md`) then factory default (`factory/agents/prompts/<role>.md`). Evolved playbooks from `~/.factory/playbooks/<role>.md` (user-local, ACE-generated) are auto-injected, falling back to factory defaults in `factory/agents/playbooks/<role>.md`.

**Roles:** Researcher (observe), Strategist (hypothesize and refine ideas), Builder (implement), QA (health check + code review + adversarial QA), Archivist (record), Refiner (scope refinements), Failure Analyst (research mode), CEO (orchestrate).

### Key data flow

1. **State detection** (`factory/state.py`): Checks git, `.factory/config.json`, and `eval_profile.json` to determine one of 5 `ProjectState` enum values
2. **Discovery** (`factory/discovery/`): `introspect.py` → `profile.py` → `generate.py` — detects project language/framework, builds an `EvalProfile` of dimensions, generates `eval/score.py`
3. **Eval** (`factory/eval/`): `runner.py` executes the eval command as a subprocess, expects JSON stdout `{"results": [...]}`. Growth dimensions (`growth.py`) are computed locally and merged at 50/50 with project hygiene dimensions. `scorer.py` computes the weighted composite
4. **Strategy** (`factory/strategy.py`): FEEC priority heuristic (Fix > Exploit > Explore > Combine) classifies hypotheses by keyword matching, with stuck detection after 3+ consecutive same-category reverts
5. **Store** (`factory/store.py`): `ExperimentStore` manages the `.factory/` directory — config, TSV history, per-experiment dirs with hypothesis/eval/diff/verdict artifacts. Auto-registers projects in the global registry on `begin()` and updates stats on `finalize()`
6. **Registry** (`factory/registry.py`): Global project registry at `~/.factory/registry.json` — self-registration pattern, project discovery for ACE/insights without `--projects-dir`
7. **Report** (`factory/report.py`): Performance report generation — consolidates experiment records, CEO verdicts, and observations into `.factory/performance_report.json` for ACE consumption
8. **Checkpoint** (`factory/checkpoint.py`): Saves and loads CEO state for crash-resilient resume
9. **Analysis** (`factory/analysis.py`): Experiment comparison (`diff`) and FEEC analysis (`explain`)
10. **Adversarial** (`factory/adversarial.py`): GAN-style adversarial eval loop state machine — phase transitions with hysteresis, per-role streak counters, convergence detection. State persisted at `.factory/adversarial_state.json`
11. **Contained** (`factory/contained/` + `factory/podman.py` + `factory/cli/contained.py`): `factory contained [runtime flags] -- <any factory command>` runs the factory in a podman container (`--target local`) or a cluster pod (`--target k8s`). See "Contained runtimes" below.

### Target project's `.factory/` layout

```
.factory/
├── config.json               # Parsed from factory.md (FactoryConfig model)
├── eval_profile.json         # Discovered eval dimensions (EvalProfile model)
├── results.tsv               # Append-only experiment history
├── performance_report.json   # Consolidated project data for ACE (auto-generated)
├── experiments/
│   └── 001/                  # Per-experiment: hypothesis.md, eval_before.json, eval_after.json, changes.diff, verdict.json
├── strategy/                 # observations.md, current.md, backlog.md, insights.md, research.md
├── reviews/                  # Agent output capture + CEO review verdicts
│   ├── <role>-latest.md      # Auto-saved stdout from each agent invocation
│   └── ceo-verdict-<role>.md # CEO's review verdict (PROCEED/REDIRECT/ABORT)
├── adversarial_state.json    # Adversarial loop state (phase, streaks, history)
├── outer_loop/               # Evolutionary workflow search state
│   ├── config.json           # SwarmConfig for the current run
│   ├── state.json            # OuterLoopState for crash recovery
│   ├── population/           # Serialized Population (population.json)
│   ├── archive/              # Serialized MAPElitesArchive (grid.json)
│   ├── modes/                # Ephemeral mode JSONs (evolve-gen{N}-{id}.json)
│   ├── results/              # Per-generation eval results (gen{N}.json)
│   ├── reflections/          # Contrastive reflection reports (gen{N}.json, gen{N}.md)
│   ├── events.jsonl          # Per-generation best/mean/diversity metrics
│   ├── costs.jsonl           # Per-individual cost tracking
│   └── trajectory.jsonl      # Score trajectory over generations
├── workflows/                # Ephemeral .py wrappers for WorkflowRegistry discovery
├── archive/                  # Long-term knowledge store (Archivist notes)
│   ├── experiments/          # Per-experiment learnings and decision rationale
│   ├── patterns/             # Recurring patterns and anti-patterns
│   └── decisions/            # Major architectural and strategy decisions
└── agents/                   # Per-project agent prompt overrides
```

### Models

All domain models live in `factory/models.py` as strict Pydantic v2 models. Key types: `ProjectState` (enum), `FactoryConfig`, `EvalProfile` / `EvalDimension`, `CompositeScore` / `EvalResult`, `ExperimentRecord`, `CrossProjectInsights`, `AgentVerdict`, `Observation`, `PerformanceReport`, `ProjectEntry` / `ProjectRegistry`, `AdversarialConfig` / `AdversarialComponent` / `AdversarialState` / `AdversarialPhaseRecord`. The `Notifier` protocol defines the async notification interface. `FactoryConfig` includes `clean_pr` (bool), `clean_pr_include` (list[str]), and `clean_pr_exclude` (list[str]) for Clean PR Mode — stripping non-essential artifacts from PRs before pushing to external repos. `FactoryConfig.adversarial` (`AdversarialConfig | None`) holds the GAN-style adversarial eval loop configuration parsed from `factory.md`.

Outer loop models live in `factory/outer_loop/models.py`: `SwarmConfig` (evolutionary search configuration — benchmark, budget, population_size, mutation_rate, frozen_node_ids, training/holdout instances, convergence thresholds), `Individual` (candidate with workflow_data, score, features, lineage), `EvalResult` (benchmark_score + hygiene_score + cost + complexity), `GenerationSummary` (per-generation stats), `OuterLoopResult` (final run result with trajectory, pareto front, hyperparameter history), `HyperparameterRecord` (per-generation mutation_rate, operator_weights, diversity), `MutationRecord` (operator + target_node + before/after), `MutationType` (enum: 8 mutation operators including `KNOB_MUTATE`), `OuterLoopState` (checkpoint for crash recovery), `AuditResult` (overfit detection).

## Environment

Requires Claude Code installed and authenticated. The factory spawns `claude` subprocesses — it does not call the API directly. Any Claude Code authentication method works (API key, Vertex AI, etc.).

### Configuration (`~/.factory/config.toml`)

All `FACTORY_*` environment variables can also be set in `~/.factory/config.toml`. Env vars remain supported — config.toml is additive. Five-tier precedence: CLI flag > env var > profile credential > config.toml default > hardcoded default.

```toml
[defaults]
runner = "claude"
model = ""
projects_dir = "~/factory-projects"

[credentials.vertex]
FACTORY_RUNNER = "claude"
ANTHROPIC_API_KEY = "sk-ant-..."
```

**Commands:**
- `factory config show [--reveal]` — show resolved config with secrets masked
- `factory config edit` — open `~/.factory/config.toml` in `$EDITOR`
- `factory config migrate` — create starter config from current env vars (requires `tomli_w`)

**Credential profiles:** Use `--profile <name>` with `factory ceo`, `factory run`, or `factory agent` to load a `[credentials.<name>]` section. Profile keys **override** existing env vars (explicit `--profile` opt-in means the profile is authoritative). CLI flags still win via 5-tier precedence.

**Env overlay features:**
- **Override:** Profile keys are set via `os.environ[k] = v`, not `setdefault` — the profile wins over shell env vars
- **Unset:** Add a `[credentials.<name>.unset]` sub-table with `vars = ["VAR1", "VAR2"]` to remove env vars before injection. Unsets are processed before sets.
- **Protected vars:** The following env vars cannot be set or unset via profiles — a `ValueError` is raised if attempted: `PATH`, `HOME`, `USER`, `SHELL`, `TMPDIR`, `TERM`, `PWD` (shell fundamentals); `LD_PRELOAD`, `LD_LIBRARY_PATH`, `DYLD_INSERT_LIBRARIES` (code execution vectors); `PYTHONPATH`, `GOPATH`, `CLASSPATH`, `NODE_PATH` (language path injection); `IFS` (shell parsing); `FACTORY_TRACE_ID`, `FACTORY_PARENT_SPAN_ID` (factory observability internals).
- **Unset vars validation:** The `[credentials.<name>.unset].vars` field must be a list — a `ValueError` is raised if it is a string or other non-list type.
- **Override warnings:** When a profile overrides an existing env var with a different value, a `log.warning("profile_override", key=k, profile=profile)` is emitted (values are not logged to avoid leaking secrets).

**Custom endpoint example** (e.g. a LiteLLM proxy):

You can use profiles to point the factory at a custom model endpoint:
```toml
[credentials.litellm-proxy]
FACTORY_RUNNER = "claude"
FACTORY_MODEL = "your-model-name"
ANTHROPIC_BASE_URL = "https://your-litellm-proxy.example.com"
ANTHROPIC_API_KEY = "your-api-key-here"
CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC = "1"

[credentials.litellm-proxy.unset]
vars = ["CLAUDE_CODE_USE_VERTEX", "CLAUDE_CODE_USE_BEDROCK", "ANTHROPIC_VERTEX_PROJECT_ID"]
```
Usage: `factory ceo /path --profile litellm-proxy`

**Implementation:** `factory/user_config.py` — `load_config()`, `resolve()`, `show_config()`, `migrate_env_to_config()`.

## Runners

The factory uses Claude Code (`claude` CLI) as its agent backend. The runner abstraction (`factory/runners/`) supports this via `ClaudeRunner` in `factory/runners/claude.py`. The runner protocol (`factory/runners/protocol.py`) defines the interface.

**Important:** Target projects should add `.factory/` to their `.gitignore`. The factory writes experiment data and usage logs to this directory. These are project-local artifacts that should not be committed to version control.

## Running the factory

```bash
# Build — from idea, spec file, or GitHub URL
factory ceo "Build a weather CLI"               # Raw idea → ~/factory-projects/weather-cli/
factory ceo "Build a weather CLI" --dir my-app  # Explicit dir name override
factory ceo ~/ideas/spec.md                     # Spec file → new project
factory ceo https://github.com/user/repo        # Clone and improve
factory ceo "distributed eval runner" --mode design  # Brainstorm → build
factory ceo ~/ideas/detailed-spec.md --mode design   # Long idea from file (no length limit)
factory ceo /path/to/project --mode design           # Discuss what to work on → improve
factory ceo /path/to/project --mode design --focus "auth"  # Discuss a specific topic
factory ceo "weather CLI" --mode design --auto-approve  # Design without user approval gate
factory ceo /path/to/project --mode design --from-plan .factory/strategy/current.md  # Build from local plan
factory ceo /path/to/project --mode design --from-plan 42  # Build from plan issue #42
factory ceo /path/to/project --mode design --from-plan 'auth dashboard'  # Fuzzy search plans
factory ceo "SWE-bench solver" --mode research            # Research ideation → build
factory ceo /path/to/factory --mode create --focus "mode description"  # Create a new factory mode
factory ceo /path/to/factory --mode create --focus "improve: add plateau detection"  # Update existing mode
factory ceo /path/to/factory --mode create --focus 'approval workflow' --plugin                           # Plugin package → ./approval-workflow-plugin/
factory ceo /path/to/factory --mode create --focus 'approval workflow' --plugin --folder ~/plugins/approval  # Explicit output dir
factory ceo /path/to/project --mode design --just-plan                    # Research + strategy, no implementation
factory ceo "distributed eval runner" --mode design --just-plan            # Plan a new idea
factory ceo /path/to/project --mode design --just-plan --focus "auth"      # Focused planning

# Design-v2 — inference-time scaling (dynamic directors, user intent ledger)
factory ceo "markdown link checker" --mode design-v2                      # Dynamic research + strategy
factory ceo /path/to/project --mode design-v2 --focus "auth"              # Existing project
factory ceo /path/to/project --mode design-v2 --auto-approve              # CEO acts as user at gates

# Improve — point at existing codebase
factory ceo /path/to/project                    # Single improvement cycle
factory run /path/to/project --loop --interval 1800  # Continuous heartbeat
factory tmux /path/to/project --loop            # In detached tmux session

# Focus — build exactly one thing
factory ceo /path/to/project --focus "dashboard UI"  # One item, one hypothesis, done
factory ceo /path/to/project --focus 42              # Target GitHub issue #42
factory ceo /path/to/project --focus "owner/repo#42" # Target issue by shorthand
factory ceo /path/to/project --focus '42 and 43'     # Multiple issues
factory ceo /path/to/project --focus 'issue 42, issue 43'  # With 'issue' keyword

# Founder — rapid prototyping (NOT for production)
factory ceo /path/to/project --mode founder                       # One fast hypothesis
factory ceo /path/to/project --mode founder --focus "auth flow"   # Targeted prototype
factory run /path/to/project --mode founder --loop --interval 300 # Rapid iteration

# Study — graph-powered codebase analysis
factory ceo /path/to/project --mode study                    # Graph-powered codebase study
factory ceo /path/to/project --mode study --focus "auth flow" # Focused study with graph context

# Meta — improve the factory's own agents
factory ceo /path/to/project --mode meta        # Improve + ACE playbook evolution

# Agents & analysis
factory agent researcher --task "..." --project /path  # Invoke a specialist directly
factory study /path                             # Analyze code + write observations
factory diff /path --exp1 N --exp2 M            # Compare two experiments
factory explain /path --exp N                   # Explain experiment with FEEC analysis

# Backlog
factory backlog-list /path                      # List pending backlog items
factory backlog-add /path "item text"           # Add a new item to the backlog
factory backlog-remove /path "item text"        # Remove a completed backlog item

# Adversarial eval loops
factory adversarial-state /path/to/project           # Inspect adversarial loop state
factory adversarial-state /path/to/project --reset   # Reset to defaults

# Outer loop — evolutionary workflow search
factory outer-loop calibrate /path --benchmark featurebench --budget 50 --population-size 4
factory outer-loop calibrate /path --training-instances t1 t2 --holdout-instances h1
factory outer-loop calibrate /path --project-dir /path/to/target  # Evaluate on a different project
factory outer-loop evaluate /path --generation 0                  # Evaluate current generation
factory outer-loop evaluate /path --generation 0 --project-dir /path/to/target
factory outer-loop reflect /path --generation 0                   # Contrastive reflection
factory outer-loop evolve /path --generation 0                    # Produce next generation
factory outer-loop status /path                                   # Show progress and metrics
factory outer-loop status /path --check-converge                  # Exit 0 if converged, 1 if not
factory outer-loop promote /path --mode-name evolve-gen5-abc12345 --permanent-name best-evolved

# Operations
factory dashboard --projects-dir ~/factory-projects    # Live web dashboard on :8420
factory export /path/to/project                 # Dump full project snapshot as JSON
factory checkpoint /path/to/project             # Save CEO state for crash recovery
factory resume /path/to/project                 # Resume an interrupted CEO session
factory precheck /path --score-before 0.7 --score-after 0.85  # Hard precheck gate
factory review --verdict KEEP --pr 42           # Post structured review on GitHub PR
```

`factory run` / `factory ceo` spawn the CEO agent as a subprocess using the Claude Code runner. The CEO owns the full workflow: state detection, agent spawning, experiment lifecycle, and mandatory archival. The `--loop` flag adds a heartbeat wrapper with configurable interval and max cycles. `--mode meta` runs the full Improve loop on the factory itself, then ACE playbook evolution for all agent roles. `--focus` activates targeted mode: builds exactly one item and exits. Accepts backlog names (`--focus "eval reliability"`), issue numbers (`--focus 42`), issue URLs, or `owner/repo#N` shorthand. Multiple issues can be specified in a single `--focus` string using commas, spaces, or "and" (e.g., `--focus "111 and 112"`, `--focus "issue 42, issue 43"`, `--focus "#111 #112"`). Each issue is fetched independently and added as a separate backlog item. Issue refs are auto-detected and fetched via `gh`/`glab` CLI. Works in improve, research, and create modes; mutually exclusive with `--loop`. In create mode, `--focus` provides the mode description; use `--focus "mode_name: change description"` to update an existing registered mode instead of creating a new one. `--mode design` enters ideation mode. For new ideas (e.g. `factory ceo "distributed eval runner" --mode design`), the CEO researches the space via the Researcher, then iteratively refines the idea with the Strategist through user feedback, producing a phased build plan before building. For existing projects (e.g. `factory ceo /path/to/project --mode design`), the CEO studies the project (backlog, eval scores, open issues, history), presents findings, and discusses what to work on, then continues to implementation automatically after approval. `--mode interactive` is accepted as a backward-compatible alias for `--mode design`. `--focus` is allowed on existing projects to seed the discussion topic. Incompatible with `--headless` unless `--auto-approve` is used. `--auto-approve` lifts the headless restriction for design mode, forcing headless execution and auto-approving user gates (e.g. strategy review) — useful for CI/CD and automated pipelines. `--from-plan <source>` loads an existing plan into design mode, skipping the research phase. Accepts a local file path, GitHub issue URL, issue number, or fuzzy search string (searches GitHub issues with the `plan` label). Requires `--mode design`; mutually exclusive with `--focus` and `--prompt`. When fetching from a GitHub issue, includes both the issue body and all comments. `--mode research` enters research ideation for new projects (e.g. `factory ceo "SWE-bench solver" --mode research`) — the Strategist collects research config (target metric, mutable/fixed surfaces, constraints) before building. For existing projects with `research_target` configured, runs the research improvement loop directly. Incompatible with `--headless` (for new projects) and `--prompt`. `--refine "<request>"` enters refinement mode — routes a single change request through the Refiner → Builder → full review pipeline. Mutually exclusive with `--mode`, `--prompt`, and `--focus`. Requires an existing project directory. In foreground mode, the CEO also enters the refinement loop automatically after completing a build/improve cycle, staying active for follow-up requests without `--refine`. `--mode founder` enters rapid prototyping mode — a stripped-down pipeline (Study → Strategist → Builder → health gate → record) with 2 agent calls and 1 test run. Skips research, code review, adversarial QA, and eval scoring. Designed for fast hypothesis iteration: test an idea, see if it works, pivot. Terminal mode — does not chain to other modes. Not for production use; run `--mode improve` afterward to harden what works. Compatible with `--focus` and `--loop`. `--just-plan` (requires `--mode design`) enters planning-only mode — research + strategy + optional GitHub publishing with no implementation. Three parallel researchers investigate domain, practices, and constraints. The Strategist synthesizes a phased plan. Single user gate: keep the plan? Approval auto-publishes to GitHub as an issue with the `plan` label and seeds the backlog with plan phases. Terminal mode — does not chain to other modes. Compatible with `--focus`. Mutually exclusive with `--from-plan` and `--prompt`.

## Contained runtimes

`factory contained` runs any factory command somewhere other than the developer's shell. Everything after `--` is handed inward **verbatim** except for path rewriting — the runtime is a place to run the factory, not a mode of it, so the host never parses the payload's semantics and cannot break when the CLI grows.

```bash
factory contained -- ceo ~/code/rta                      # local container, watch it
factory contained --division -- ceo ~/code/rta           # ...and let the agent build images
factory contained --target k8s --namespace ns -- run ~/code/rta --loop
factory contained --target k8s --division -- ceo ~/code/rta
factory contained ls | attach <name> | rm <name> | sync <name> | setup | verify | bundle
FACTORY_CONTAINED_DRY_RUN=1 factory contained -- study ~/code/rta   # compose, provision nothing
```

**The two targets share a command surface and an image, not a threat model.** Neither confines agent-authored code, and neither replaces review. Local is the *weaker* of the two: no egress control, and credentials live inside the container. K8s keeps a restricted SCC and namespace-scoped RBAC. None of this reaches user-facing output in these terms — `--help` says "not a security sandbox" and leaves it there, because a security comparison is not an orientation.

Six things are load-bearing and fail quietly if broken:

- **Provenance.** A run always starts from the files on this machine, uncommitted changes included — never `HEAD`, never a fresh clone. The workspace is a git worktree with the working tree rsynced over the top, because a HEAD checkout silently drops the gitignored `.factory/` the whole experiment history lives in. Five assertions then run between provisioning and the first agent call (`factory/contained/provenance.py`); a failure aborts naming the file and the likely cause, and leaves the runtime up for inspection.
- **Identity.** A bind mount carries ownership through unchanged, so a container whose UID does not own the tree gets a *silently read-only* workspace. The rule differs between rootless, rootful and macOS, so `factory/contained/identity.py` **probes** rather than deciding: a throwaway container reports the mount's owner as the kernel inside sees it, and the run matches. The runtime image is built for arbitrary UIDs (group 0, `chmod g=u`), which is also what OpenShift's restricted SCC needs.
- **PID 1.** The factory spawns agent subprocesses and is not a well-behaved init, so the container runs `--init` around `sleep infinity` and the run itself lives in tmux. The runtime persists after the run — a failed run is exactly when its state is worth reading.
- **Credentials cross the boundary, by design.** There is no gateway. The policy is `FACTORY_` by default, plus exactly what `--forward` names, plus the backend variables the resolved shape requires (`factory/contained/credentials.py`) — nothing implicit. `verify` reports credential *shape*, never material, and secret-looking values are redacted anywhere a command is printed. On k8s the credentials come from a namespace Secret the user creates; the factory references it by name and never handles the material.
- **Both divisions reach outward, and that is the point.** Builds cannot happen inside either boundary, so `--division` is opt-in and separately named. Locally it starts an **unauthenticated** `podman-mcp-server` on `0.0.0.0:8430` — every interface, because the tool has no bind flag and the container reaches the host through a gateway address rather than loopback — detached into its own process group, because the run outlives the launch, and stopped by `factory contained rm`. On the cluster it goes through OpenShift `Build` objects behind a sidecar container that is the only holder of `oc` and the ServiceAccount token; that separation is a boundary only while the Role excludes `pods/exec`, which `verify` asserts via a **SubjectAccessReview API object** — `oc auth can-i --as` collapses `pods/exec` onto `pods` and answers "yes" where RBAC says no. The sidecar runs a **different image** (`FACTORY_CONTAINED_SIDECAR_IMAGE`, an `oc` image) from the agent's; one image for both silently collapses the boundary.
- **Interactive prompts stall an unattended run.** A fresh `~/.claude` makes Claude Code ask about folder trust, project MCP servers, and Bypass Permissions mode — all interactive-only, so headless agents never hit them and the interactive CEO does, and the run then sits at a menu nobody is watching. `factory/contained/claude_state.py` pre-records those answers, which the invocation already implies.
- **All podman knowledge lives in `factory/podman.py` and all cluster knowledge in `factory/contained/k8s.py`.** Both **compose** commands and do not execute them, which is what makes `FACTORY_CONTAINED_DRY_RUN=1` print the same argv the real path runs rather than a separate rendering that drifts.

The runtime image (`containers/factory/Containerfile`) is UBI9 + the factory wheel + the agent CLIs + tmux, published multi-arch by CI (`.github/workflows/runtime-image.yml`) — amd64 for cluster nodes, arm64 for a Mac laptop. It publishes on pushes to `main` (`:latest`), on **published releases** (`:<tag>`, plus `:latest` unless the release is a prerelease — nightlies are, so they never move `:latest`), and on dispatch. The release trigger is load-bearing: `factory contained setup` pulls and does not build, so a release whose image was never built breaks every new user's first command. Release and dispatch tag names reach the shell through `env:` and are validated against the legal image-tag character set before use.

`setup` is a numbered wizard rather than a column of output (`factory/contained/style.py`): step rules, `[ ok ]`/`[FAIL]` marks, and — the part that caused real confusion — every resolved value printed quoted and coloured, because "in namespace default" gives the reader no way to tell the name from the sentence. Colour obeys `NO_COLOR` > `FORCE_COLOR` > TTY detection, so the same strings stay plain in pipes, logs and CI. The cluster half **asks** which namespace to prepare when `--namespace` was not given (the current context supplies the default, not the answer) and names the **cluster** alongside it — a namespace alone identifies nothing, since `default` exists on every cluster. Only names are read from the kubeconfig, never the `users` section.

**Which cluster is chosen, not assumed.** `setup` lists the kubeconfig's contexts and lets one be picked (`--context NAME` skips the question). The choice is applied as `--context` on *every* cluster command via `k8s.cli()` — a process-global `_ACTIVE_CONTEXT` set once at entry, because threading it through forty call sites means forty chances to forget, and `cli()` is a single auditable application point. It never rewrites the kubeconfig; switching the default is offered separately at the end, with the `oc config use-context` command printed either way.

The chosen namespace is checked for existence (`_namespace_status`) even when passed via `--namespace`, and creation is offered — `oc new-project`, not `create namespace`, because a regular user is usually denied the second. On OpenShift a Forbidden on `get namespace` says nothing about existence, so it falls back to `get project` and reports `unreadable` rather than `absent`.

The cluster review is object-by-object, not a wall of YAML (`factory/contained/k8s_review.py`). The bundle exists as a list (`bundle_objects()`) before it exists as a blob; `render_bundle` joins that list, and `verify`'s per-object checks are derived from it, so the three can never describe different object sets. Each object is compared against the namespace with `oc diff` (server-side, so cluster-defaulted fields do not read as user changes), producing `current` / `absent` / `differs` / `unknown`. Only the ones needing a decision are walked, each showing its purpose plus its diff (for `differs`) or its manifest (for `absent`). `current` is never prompted about — a prompt whose only sane answer is yes teaches people to stop reading prompts — and `unknown` is never silently skipped.

**Each object is applied at the moment it is accepted, never batched.** Batching made `q` report "nothing was applied" to a user who had already said yes, which is false; `WalkResult` records what actually happened and the abort message says how much survives. A failed apply names itself and does not stop the walk. There is no second blanket confirm after the walk.

Prompt options are spelled out (`[y]es [n]o [a]ll remaining [q]uit`), not `[y/n/a/q]`. `style.read_key` and `style.read_line` put the terminal in cbreak mode, which is the only way **Escape** can cancel — a line-buffered `input()` only ever sees the `^[` characters it inserts. `read_line` is a small line editor (echo, Backspace, arrow-key drain) because cbreak turns off the line discipline that normally provides them. Both return `None` when raw reading is impossible (pipe, non-POSIX) and callers fall back to `input()` plus `style.is_escape()`; `input()` raises **OSError** under pytest capture, not `EOFError`, so both are caught. Ctrl-C is caught in `cmd_contained` and exits 130 with a message — backing out of a wizard is ordinary, not a crash.

**`verify_k8s` streams.** It takes an `on_check` callback and reports each result the moment it is known; `prereq.format_check` / `summary_line` are split out of `render_checks` so a caller can print per-result and add the verdict at the end. Without this the Verify step printed nothing for minutes — several checks are a cluster round trip, and the in-cluster inference probe creates a pod and waits up to 180s — and it was reported as a hang. Every result goes through the local `record()` helper, including the early `cli_binary()` failure, because a streaming caller prints only the summary afterwards and a check that skips the callback is never seen. The inference probe is **skipped when the credentials Secret is missing**: the probe pod mounts it, so it could only burn its full timeout rediscovering what the Secret check just reported — which is the state every freshly prepared namespace is in.

**Tests must never reach a raw prompt.** `tests/conftest.py` has an autouse fixture forcing `style._raw_session` to `None`; without it a prompt blocks forever on a keypress, ignoring `builtins.input` patches, because the raw path does not call `input()`. `tests/test_contained_k8s.py` additionally stubs `list_contexts`/`cluster_context`/`current_namespace` — they shell out to real `oc`, which cost that file seven minutes before being stubbed. tmux is compiled in a builder stage because neither the UBI repositories nor EPEL ship it (EPEL never duplicates a package RHEL carries, and UBI's subset omits it).

Two cluster-side details that fail quietly: a PVC mounts root-owned, so the pod needs an `fsGroup` read from the namespace's allocated range (hardcoding one fails admission under a `MustRunAs` SCC); and the workspace unpack marker is **per-run**, because the PVC outlives the run that filled it and a shared marker makes the next run skip its own upload and execute against stale files.

User-facing guide: `docs/contained/index.md`.

## Observability

**Events**: All agent invocations and cycle transitions are logged to `.factory/events.jsonl` as append-only structured events. The agent runner (`factory/agents/runner.py`) emits `agent.started`, `agent.completed`, `agent.failed`, and `agent.timeout` events automatically. The heartbeat loop emits `cycle.started` and `cycle.completed`.

**Dashboard**: `factory dashboard` starts a FastAPI server (default port 8420) that serves a live web UI with SSE-powered event streaming. It scans a projects directory for all `.factory/`-managed projects and shows real-time agent activity, experiment history, and project scores. Designed to run on an always-on machine.