` (or IM `/approve` once wired) and the agent **re-checks** with the same args. How-to: [src/octop/modules/org_os/governance/README.md](src/octop/modules/org_os/governance/README.md).
-
-### Direction 3 — module ↔ skill bridge
-
-```bash
-uv run freeos org skills generate
-uv run freeos org skills publish ~/.freeos/org-skills/org-employees
-```
-
-Each generated `SKILL.md` calls real `/api/…` routes through `/api/org-module/sidecar` with tenant headers. Publish writes a **disabled** tenant-toggleable plugin draft — it does not auto-enable. How-to: [src/octop/modules/org_os/skill_bridge/README.md](src/octop/modules/org_os/skill_bridge/README.md).
-
-### Phase B — grow the AI workforce (blueprints, lifecycle, asset loop)
-
-Non-developer loop (details in [docs/asset-loop.md](docs/asset-loop.md)):
-
-```bash
-uv run freeos org assets import --catalog --blueprint tests/fixtures/agent-blueprint.v1.json --tenant-id 1
+uv run freeos org enable
+uv run freeos org governance enable --tenant-id 1
+uv run freeos org assets import --catalog --blueprint tests/fixtures/org-loop/agent-blueprint.v1.json --tenant-id 1
uv run freeos org employee transition policy-analyst market
uv run freeos org employee transition policy-analyst recruit
uv run freeos org employee transition policy-analyst shadow
uv run freeos org employee transition policy-analyst active
uv run freeos org assets publish
-uv run freeos org employee export-profile policy-analyst --tenant-id 1
+uv run freeos org assets apply
```
-- **Inbound:** catalog / blueprint / policies → skills + employee workspace (`xyos2freeos`)
-- **Lifecycle:** `draft → market → recruit → shadow → active → offboard` (offboard revokes `.env` and archives MEMORY)
-- **Outbound:** asset pack + openXYOS-shaped drafts (not auto-enabled)
-- High-risk tools still go through Phase A governance
-
-Phase C (chat routing, reflections, org-as-code) stays architecture-only.
+Inbound import compiles the blueprint **and** registers a FreeOS agent (`org-policy-analyst`) so the colleague is addressable in chat. Governance is spliced into the host tool path — a pending/deny decision is a hard stop.
## Host platform features (from Octop)
@@ -153,12 +147,11 @@ Longer host docs: [docs/user-guide.md](docs/user-guide.md), [docs/configuration.
## Remaining `octop` identifiers
-Intentional for MVP compatibility (not a rebrand miss):
+Intentional for package compatibility (not a rebrand miss):
- Python package name `octop` and `import octop`
- CLI `octop` alongside `freeos`
- `OCTOP_*` environment variables
- SQLite file `octop.db` inside the home directory
-- Many internal tests and IM strings
-See [docs/architecture-integration.md](docs/architecture-integration.md) for the seven integration directions, Phase A/B/C, and the chosen MVP topology.
+See [docs/architecture-integration.md](docs/architecture-integration.md) for the control/data-plane split and the running self-growth loop.
diff --git a/README_CN.md b/README_CN.md
index f5a98016..5607704a 100644
--- a/README_CN.md
+++ b/README_CN.md
@@ -3,10 +3,10 @@
- FreeOS — 自托管多用户、多 Agent 助手,可选组织 OS 模块。
+ FreeOS — 自托管多智能体 OS,能生产并回收自己的 AI 员工。
-运行、组织模块、治理 MCP、蓝图编译与资产环见 README.md;七条集成方向见 docs/architecture-integration.md;非开发者闭环见 docs/asset-loop.md。
+产品闭环:uv run freeos org loop run。详见 README.md 与 docs/asset-loop.md。
diff --git a/dashboard/src/locales/en.json b/dashboard/src/locales/en.json
index 335a2513..6a6902ce 100644
--- a/dashboard/src/locales/en.json
+++ b/dashboard/src/locales/en.json
@@ -8,13 +8,13 @@
},
"organization": {
"title": "Organization OS",
- "subtitle": "Enable the openXYOS organization module as a FreeOS sidecar.",
+ "subtitle": "Grow AI employees through the FreeOS ↔ openXYOS loop. Enable the organization control plane as a sidecar.",
"toggleLabel": "Enable organization module",
"on": "Enabled",
"off": "Disabled",
"sidecarUp": "Sidecar reachable",
"sidecarDown": "Sidecar offline",
- "bridgeHint": "This is a modular bridge: FreeOS keeps the host identity and plugins; openXYOS serves org APIs and UI on a sidecar.",
+ "bridgeHint": "FreeOS produces employees, skills, plugins, and MCPs. Those assets assemble into openXYOS; org blueprints and policies feed back into FreeOS. Run `freeos org loop run` to prove the loop.",
"home": "Data home",
"sidecar": "Sidecar URL",
"startSidecarTitle": "Start the organization sidecar",
diff --git a/dashboard/src/locales/zh.json b/dashboard/src/locales/zh.json
index c8c2ab6e..92f28857 100644
--- a/dashboard/src/locales/zh.json
+++ b/dashboard/src/locales/zh.json
@@ -8,13 +8,13 @@
},
"organization": {
"title": "组织 OS",
- "subtitle": "将 openXYOS 组织能力作为 FreeOS 模块启用或关闭。",
+ "subtitle": "通过 FreeOS ↔ openXYOS 闭环生产 AI 员工。将组织控制面作为边车启用。",
"toggleLabel": "启用组织模块",
"on": "已启用",
"off": "已关闭",
"sidecarUp": "边车可达",
"sidecarDown": "边车未运行",
- "bridgeHint": "这是模块化桥接:FreeOS 保留宿主身份与插件;openXYOS 在边车中提供组织 API 与界面。",
+ "bridgeHint": "FreeOS 生产员工、技能、插件与 MCP;这些资产组装进 openXYOS,组织蓝图与策略再回流到 FreeOS。用 `freeos org loop run` 跑通闭环。",
"home": "数据目录",
"sidecar": "边车地址",
"startSidecarTitle": "启动组织边车",
diff --git a/docs/architecture-integration.md b/docs/architecture-integration.md
index dc16ee69..57f51c27 100644
--- a/docs/architecture-integration.md
+++ b/docs/architecture-integration.md
@@ -20,10 +20,8 @@ FreeOS grows its own AI workforce and capability catalog:
3. openXYOS assets (org tree, `openxyos.agent-blueprint.v1`, governance,
module catalog, talent market, knowledge/reflections) **feed back**
into FreeOS to spawn or upgrade more runtime agents and skills.
-4. The loop repeats. Phase A is the safety gate + module↔skill pipe.
- Phase B is the blueprint compiler, colleague lifecycle, and the
- bidirectional asset factory. Operator guide:
- [asset-loop.md](asset-loop.md).
+4. The loop repeats and is **runnable**: `freeos org loop run`. Operator
+ guide: [asset-loop.md](asset-loop.md).
FreeOS is an independent project. It is not affiliated with Tencent Cloud,
Octop, XYAIStudio, or XYOS trademarks beyond accurate license attribution.
@@ -46,11 +44,12 @@ See [NOTICE](../NOTICE) and `modules/openxyos/TRADEMARKS.md`.
talent market running agent instances
```
-## Chosen MVP topology (Phase A — Directions 2 + 3)
+## Running topology
-Phase A does **not** compile blueprints or merge chat UIs. It inserts
-governance in front of high-risk data-plane tools, and generates real
-module skills that call sidecar `/api/*` with tenant headers.
+The host inserts governance in front of high-risk data-plane tools,
+generates module skills that call sidecar `/api/*` with tenant headers,
+compiles blueprints into chat-usable colleagues, and applies asset packs
+onto openXYOS-shaped surfaces.
```
┌─────────────────────────────────────────────┐
@@ -127,11 +126,9 @@ Polished → freeos org skills publish
→ operator enables per tenant; not auto-on
```
-## Seven directions
+## Seven directions (all required for the running loop)
-### Phase A — implement now (highest value / verifiable)
-
-#### Direction 2 — `xyos-governance-mcp`
+#### Direction 2 — `xyos-governance-mcp` + host tool_guard
Wrap openXYOS governance/audit as an MCP server and insert it **before**
high-risk tool calls.
@@ -163,7 +160,7 @@ until the pause is approved for the same action digest.
| CLI | `freeos org skills generate` · `freeos org skills publish` |
| How-to | `src/octop/modules/org_os/skill_bridge/README.md` |
-### Phase B — implemented (Directions 1 + 4 + asset loop)
+### Directions 1 + 4 + asset loop (implemented)
#### Direction 1 — `xyos2freeos` / blueprint compiler
@@ -214,25 +211,25 @@ sidecar SQL.js.
High-risk runtime actions still pass through Phase A governance.
-### Phase C — architecture only (do not implement in this pass)
-
-#### Direction 5 — Chat / session routing Web ↔ IM
+#### Direction 5 — Chat / session routing (implemented as far as the loop needs)
-One conversation identity across FreeOS dashboard chat and Octop IM
-channels. Route; do not duplicate openXYOS chat as the agent loop.
+Compiled colleagues are registered as FreeOS agents (`org-`) with a
+tenant routing table (`tenants//routing.json`). `AgentManager.resolve_user_agent`
+resolves colleague slugs and display names so produced employees are
+usable in FreeOS chat. This does **not** duplicate openXYOS chat.
-#### Direction 6 — Reflections ↔ harness-memory
+#### Direction 6 — Org memory hooks (implemented as far as the loop needs)
-openXYOS reflections become an org learning loop that writes into
-harness-memory (and back). Keep tenant isolation at the workspace
-boundary, not in the prompt.
+Colleague `MEMORY.md` + `knowledge/` sync into
+`tenants//org-knowledge/live//`. Offboard still archives into
+`org-knowledge/archived-colleagues/`. Tenant isolation stays at the
+workspace boundary.
-#### Direction 7 — Org-as-code GitOps (end-state, not MVP start)
+#### Direction 7 — Org-as-code GitOps (beyond the running loop)
-Reconcile control-plane YAML (modules, blueprints, policy) with running
-agents. Desired end-state: git is the source of org intent; FreeOS
-instances converge. Not the starting point — the contract and runtime
-must exist first (Phases A/B).
+Reconcile control-plane YAML with running agents. The contract and
+runtime now exist; GitOps convergence remains an operator workflow on
+top of `freeos org loop run`, not a substitute for it.
## Risks (encoded in design)
@@ -277,7 +274,9 @@ The Phase A work sits on the existing FreeOS bootstrap:
| Module ↔ skill bridge (Phase A) | Bridge | `freeos org skills generate|publish` |
| Blueprint compiler (Phase B) | Bridge | `xyos2freeos` · `/api/org-module/blueprints/compile` |
| Colleague lifecycle (Phase B) | Bridge | `freeos org employee *` · `{home}/tenants//employees/` |
-| Asset factory (Phase B) | Bridge | `freeos org assets publish\|import` · `{home}/asset-packs/` |
+| Asset factory | Bridge | `freeos org assets publish\|import\|apply` · `{home}/asset-packs/` · `{home}/openxyos-mirror/` |
+| Self-growth loop | Bridge | `freeos org loop run` · `/api/org-module/loop/run` |
+| Colleague agents | Host agents table | `org-` · `{home}/org-agents/` · tenant `routing.json` |
| Org APIs | openXYOS sidecar | `/api/org`, `/api/employees`, `/api/governance`, `/api/module-settings`, … |
| Org UI | openXYOS Vite/Express | sidecar `:3780`; iframe on `/organization` when healthy |
@@ -348,6 +347,10 @@ copy the openXYOS working directory themselves.
| Blueprint compiler | `src/octop/modules/org_os/compiler/` |
| Colleague lifecycle | `src/octop/modules/org_os/lifecycle/` |
| Asset factory | `src/octop/modules/org_os/assets/` |
+| Apply + mirror | `src/octop/modules/org_os/apply/` |
+| Loop orchestrator | `src/octop/modules/org_os/loop/` |
+| Colleague spawn / routing / memory | `src/octop/modules/org_os/runtime/` |
+| Host governance middleware | `src/octop/infra/agents/middleware/org_governance.py` |
| Bundled plugin | `src/octop/infra/agents/plugins/bundled/org-os/` |
| Dashboard page | `dashboard/src/pages/Organization/index.tsx` |
| CLI | `src/octop/cli/commands/org.py` |
diff --git a/docs/asset-loop.md b/docs/asset-loop.md
index 5881eea7..4ec967c1 100644
--- a/docs/asset-loop.md
+++ b/docs/asset-loop.md
@@ -1,55 +1,62 @@
-# FreeOS self-growth loop (operator guide)
+# FreeOS self-growth loop
-FreeOS is meant to **grow its own AI workforce** over time. You do not
-need to write code. The loop has four moves:
+FreeOS **grows its own AI workforce**. The loop is the product:
-1. **Produce** runtime assets on FreeOS/Octop (the data plane): AI
- employees, skills, plugins, MCP connectors.
-2. **Assemble** those assets into openXYOS (the control plane) so org
- modules get stronger.
-3. **Feed back** openXYOS org structure, blueprints, governance, the
- module catalog, and the talent market into FreeOS to spawn or upgrade
- more agents and skills.
-4. **Repeat.** Each cycle adds colleagues and capabilities.
+1. **Produce** runtime assets on FreeOS (the data plane): AI employees, skills, plugins, MCP connectors.
+2. **Assemble** those assets into openXYOS (the control plane) so org modules get stronger.
+3. **Feed back** openXYOS org structure, blueprints, governance, the module catalog, and the talent market into FreeOS to spawn or upgrade more agents and skills.
+4. **Repeat.** Each cycle adds colleagues and capabilities that are usable in FreeOS chat and governed.
-openXYOS does **not** replace Octop chat. Octop still runs the agents.
-Governance from Phase A still **blocks** high-risk tools until a human
-approves.
+openXYOS does **not** replace FreeOS chat. FreeOS still runs the agents. Governance **blocks** high-risk tools until a human approves.
-## One-time enable
+## One command
```bash
uv sync
+uv run freeos org loop run
+# or: bash scripts/org-loop.sh
+```
+
+That path:
+
+- enables the org module + governance gate
+- imports the demo catalog / blueprint / policies (`tests/fixtures/org-loop/`)
+- compiles colleagues and promotes `draft → market → recruit → shadow → active`
+- **registers real FreeOS agents** (`org-`) so they appear in chat routing
+- publishes `freeos.asset-pack.v1`
+- **applies** the pack to openXYOS-shaped surfaces (`/api/employees`, `/api/talent`, `/api/plugins`, `/api/module-settings`) — live HTTP when `OPENXYOS_BASE_URL` is set, always a durable `{FREEOS_HOME}/openxyos-mirror/`
+- imports the applied surfaces back
+- proves a high-risk tool is blocked (`execute=false`)
+
+Proof lands at `{FREEOS_HOME}/asset-packs/loop-proof.json`.
+
+## One-time enable (optional, already done by `loop run`)
+
+```bash
uv run freeos init
uv run freeos org enable
-bash scripts/run-org-sidecar.sh # optional control-plane UI
+bash scripts/run-org-sidecar.sh # optional live control-plane UI
uv run freeos org governance enable --tenant-id 1
```
-Set a tenant and keep it. **One tenant = one workspace/sandbox.** Do not
-share a home directory across companies.
+Set a tenant and keep it. **One tenant = one workspace/sandbox.** Do not share a home directory across companies.
-## The loop, in commands
+## Manual steps (same loop, split out)
### Inbound — control plane → runtime
-Import the module catalog (makes `org-*` skills) and/or a blueprint
-(makes an AI employee workspace):
-
```bash
uv run freeos org assets import --catalog
-uv run freeos org assets import --blueprint path/to/blueprint.json --tenant-id 1
-uv run freeos org assets import --policies path/to/matrix.json --tenant-id 1
-# optional: GET sidecar /api/governance/permissions (best-effort)
+uv run freeos org assets import --blueprint tests/fixtures/org-loop/agent-blueprint.v1.json --tenant-id 1
+uv run freeos org assets import --policies tests/fixtures/org-loop/policies.json --tenant-id 1
uv run freeos org assets import --from-sidecar --tenant-id 1
# same as:
-uv run xyos2freeos path/to/blueprint.json --tenant-id 1
-uv run freeos org employee spawn --blueprint path/to/blueprint.json --tenant-id 1
+uv run xyos2freeos tests/fixtures/org-loop/agent-blueprint.v1.json --tenant-id 1
+uv run freeos org employee spawn --blueprint tests/fixtures/org-loop/agent-blueprint.v1.json --tenant-id 1
```
-A blueprint is JSON with `"schema": "openxyos.agent-blueprint.v1"`
-(name, positioning, capabilities, optional experience / references /
-`job_duties` for cron).
+A blueprint is JSON with `"schema": "openxyos.agent-blueprint.v1"`.
+Import **spawns a FreeOS agent** (workspace + `octop.db` row + routing table), not only files on disk.
### Lifecycle — grow a colleague safely
@@ -61,70 +68,40 @@ draft → market → recruit → shadow → active → offboard
uv run freeos org employee list --tenant-id 1
uv run freeos org employee transition my-analyst market
uv run freeos org employee transition my-analyst recruit
-uv run freeos org employee transition my-analyst shadow # read-only + human review
+uv run freeos org employee transition my-analyst shadow # read-only + human review; agent registered
uv run freeos org employee transition my-analyst active # cron from job duties turns on
uv run freeos org employee transition my-analyst offboard # revoke .env + archive MEMORY
```
-| FreeOS state | Meaning | openXYOS mapping |
-|---|---|---|
-| `draft` | Workspace compiled, not listed | not in talent market |
-| `market` | Listed for hire | `talent_pool.status = available` |
-| `recruit` | Hired into reserve | `recruited` + `employment_category = reserve` |
-| `shadow` | Probation: read-only, governance on | `probation` |
-| `active` | Full runtime colleague | `staff` |
-| `offboard` | Credentials cleared, memory archived | `archived` / `inactive` |
+Shadow and offboard keep cron **disabled**. Offboard copies `MEMORY.md` into `{FREEOS_HOME}/tenants//org-knowledge/archived-colleagues/`. Live colleagues sync MEMORY + knowledge into `org-knowledge/live//` so chat has org memory.
-Shadow and offboard keep cron **disabled**. Offboard copies `MEMORY.md`
-into `{FREEOS_HOME}/tenants//org-knowledge/archived-colleagues/`.
-
-### Outbound — runtime → control plane drafts
+### Outbound — runtime → control plane
```bash
-uv run freeos org skills generate # if you have not imported the catalog yet
uv run freeos org assets publish
+uv run freeos org assets apply
uv run freeos org employee export-profile my-analyst --tenant-id 1
```
-`assets publish` writes `{FREEOS_HOME}/asset-packs/latest/`:
-
-- `skills/` — generated / polished SKILL.md trees
-- `plugins/` — tenant-toggleable plugin drafts (`enabled: false`)
-- `mcps/` — `xyos-governance-mcp` stdio snippet
-- `agents/` — compiled colleague workspaces
-- `openxyos/` — payloads shaped for `/api/plugins`, `/api/module-settings`, `/api/employees`, `/api/talent` (`org-employees.publish.json`, `org-talent.publish.json`)
-- `manifest.json` — `freeos.asset-pack.v1`
-
-Packed agent `.env` files keep only tenant/slug/schema keys. Secrets are stripped.
-
-Nothing is auto-enabled on the sidecar. You review the draft, then toggle
-per tenant.
-
-`export-profile` writes an HR/capability digest locally and **tries**
-`POST /api/employees`. If the sidecar is down, the local draft is the
-record (SQL.js is not the production database).
+`assets publish` writes `{FREEOS_HOME}/asset-packs/latest/`.
+`assets apply` POSTs/PUTs those drafts to the control plane when reachable and always writes `{FREEOS_HOME}/openxyos-mirror//`. Nothing is auto-enabled on the sidecar.
## Where files live
| What | Path |
|---|---|
| Tenant tree | `{FREEOS_HOME}/tenants//` |
-| Colleague workspace | `…/employees//` (`SOUL.md`, `MEMORY.md`, `skills/`, `knowledge/`, `.env`, `cron.json`) |
+| Colleague workspace | `…/employees//` (`SOUL.md`, `MEMORY.md`, `skills/`, `knowledge/`, `.env`, `cron.json`, `agent.json`) |
| Lifecycle registry | `…/employees/registry.json` |
+| Chat routing | `…/routing.json` |
| Generated module skills | `{FREEOS_HOME}/org-skills/` |
+| Spawned agents | `{FREEOS_HOME}/org-agents/` and the host `agents` table |
| Governance pauses / audit | `{FREEOS_HOME}/governance/` |
-| Imported policy matrix | `{FREEOS_HOME}/governance/imported-policies.json` (engine loads this) |
+| Imported policy matrix | `{FREEOS_HOME}/governance/imported-policies.json` |
| Asset pack | `{FREEOS_HOME}/asset-packs/latest/` |
+| Control-plane mirror | `{FREEOS_HOME}/openxyos-mirror//` |
+| Loop proof | `{FREEOS_HOME}/asset-packs/loop-proof.json` |
## High-risk actions
-Outbound, delete, pay, and production changes still go through
-`xyos-governance-mcp`. If `execute` is false, the tool must not run.
-See `src/octop/modules/org_os/governance/README.md`.
-
-## What this run does not do
-
-Chat/session routing (Web ↔ IM), reflections ↔ harness-memory as a live
-learning loop, and org-as-code GitOps stay **Phase C / architecture
-only**. The loop above is enough to grow colleagues and a capability
-catalog on disk.
+Outbound, delete, pay, and production changes go through `OrgGovernanceMiddleware` on the host tool path and `xyos-governance-mcp`. If `execute` is false, the tool does not run. See `src/octop/modules/org_os/governance/README.md`.
diff --git a/scripts/org-loop.sh b/scripts/org-loop.sh
new file mode 100755
index 00000000..eb334fcb
--- /dev/null
+++ b/scripts/org-loop.sh
@@ -0,0 +1,6 @@
+#!/usr/bin/env bash
+# Prove the finished FreeOS self-growth loop (fixtures when openXYOS is down).
+set -euo pipefail
+ROOT="$(cd "$(dirname "$0")/.." && pwd)"
+cd "$ROOT"
+uv run freeos org loop run "$@"
diff --git a/src/octop/api/routers/org_module.py b/src/octop/api/routers/org_module.py
index fcc5413e..e557c447 100644
--- a/src/octop/api/routers/org_module.py
+++ b/src/octop/api/routers/org_module.py
@@ -341,3 +341,80 @@ async def import_assets(
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
return imported.to_dict()
+
+
+class AssetApplyBody(BaseModel):
+ pack_dir: str | None = None
+ tenant_id: str = ""
+ base_url: str = ""
+
+
+class LoopRunBody(BaseModel):
+ tenant_id: str = "1"
+ blueprint_path: str | None = None
+ policies_path: str | None = None
+ base_url: str = ""
+
+
+class EmployeeSpawnBody(BaseModel):
+ slug: str
+ tenant_id: str = ""
+
+
+@router.post("/assets/apply", summary="Apply an asset pack to openXYOS (HTTP + local mirror)")
+async def apply_assets(
+ body: AssetApplyBody,
+ server: OctopServer = Depends(get_server),
+ _user: Any = Depends(require_permission("plugins")),
+) -> dict[str, Any]:
+ from pathlib import Path
+
+ from octop.modules.org_os.apply.apply import apply_asset_pack
+
+ service = _service(server)
+ dest = Path(body.pack_dir) if body.pack_dir else service.home / "asset-packs" / "latest"
+ return apply_asset_pack(
+ dest,
+ home=service.home,
+ tenant_id=body.tenant_id or service.tenant_id() or "default",
+ base_url=body.base_url,
+ ).to_dict()
+
+
+@router.post("/employees/spawn", summary="Register a compiled colleague as a FreeOS chat agent")
+async def spawn_employee(
+ body: EmployeeSpawnBody,
+ server: OctopServer = Depends(get_server),
+ _user: Any = Depends(require_permission("plugins")),
+) -> dict[str, Any]:
+ from octop.modules.org_os.lifecycle.store import LifecycleStore
+ from octop.modules.org_os.runtime.spawn import spawn_colleague_agent
+
+ service = _service(server)
+ tid = body.tenant_id or service.tenant_id() or "default"
+ record = LifecycleStore(service.home, tid).get(body.slug)
+ if record is None:
+ raise HTTPException(status_code=404, detail=f"unknown colleague: {body.slug}")
+ return spawn_colleague_agent(service.home, record).to_dict()
+
+
+@router.post("/loop/run", summary="Run the finished FreeOS self-growth loop")
+async def run_loop(
+ body: LoopRunBody,
+ server: OctopServer = Depends(get_server),
+ _user: Any = Depends(require_permission("plugins")),
+) -> dict[str, Any]:
+ from pathlib import Path
+
+ from octop.modules.org_os.loop.run import run_growth_loop
+
+ service = _service(server)
+ proof = run_growth_loop(
+ service.home,
+ tenant_id=body.tenant_id or service.tenant_id() or "1",
+ blueprint_path=Path(body.blueprint_path) if body.blueprint_path else None,
+ policies_path=Path(body.policies_path) if body.policies_path else None,
+ sidecar_url=body.base_url or service.sidecar_url(),
+ config_path=service.config_path,
+ )
+ return proof.to_dict()
diff --git a/src/octop/cli/commands/org.py b/src/octop/cli/commands/org.py
index 61686127..7283568f 100644
--- a/src/octop/cli/commands/org.py
+++ b/src/octop/cli/commands/org.py
@@ -1,4 +1,4 @@
-"""``freeos org`` / ``octop org`` — organization module, governance, skills."""
+"""``freeos org`` — organization self-growth loop, governance, and skills."""
from __future__ import annotations
@@ -238,8 +238,18 @@ def employee_spawn(blueprint_path: Path, tenant_id: str) -> None:
workspace=compiled.workspace,
lifecycle="draft",
)
+ from octop.modules.org_os.runtime.spawn import spawn_colleague_agent
+
+ spawned = spawn_colleague_agent(service.home, record)
click.echo(
- json.dumps({"compiled": compiled.to_dict(), "lifecycle": record.to_dict()}, indent=2)
+ json.dumps(
+ {
+ "compiled": compiled.to_dict(),
+ "lifecycle": record.to_dict(),
+ "agent": spawned.to_dict(),
+ },
+ indent=2,
+ )
)
click.echo("Next: freeos org employee transition market|recruit|shadow|active")
@@ -342,3 +352,56 @@ def assets_import(
from_sidecar=from_sidecar,
)
click.echo(json.dumps(imported.to_dict(), indent=2))
+
+
+@org_assets.command("apply")
+@click.option("--pack", "pack_dir", type=click.Path(path_type=Path, exists=True), default=None)
+@click.option("--tenant-id", default="")
+@click.option("--base-url", default="", help="openXYOS origin; also reads OPENXYOS_BASE_URL.")
+def assets_apply(pack_dir: Path | None, tenant_id: str, base_url: str) -> None:
+ """Apply a published pack to openXYOS (HTTP when reachable, always a local mirror)."""
+ from octop.modules.org_os.apply.apply import apply_asset_pack
+
+ service = _service()
+ dest = pack_dir or (service.home / "asset-packs" / "latest")
+ result = apply_asset_pack(
+ dest,
+ home=service.home,
+ tenant_id=tenant_id or service.tenant_id() or "default",
+ base_url=base_url,
+ )
+ click.echo(json.dumps(result.to_dict(), indent=2))
+
+
+@org.group("loop")
+def org_loop() -> None:
+ """Finished self-growth loop: produce → assemble → feed back → govern."""
+
+
+@org_loop.command("run")
+@click.option("--tenant-id", default="1")
+@click.option("--blueprint", "blueprint_path", type=click.Path(path_type=Path, exists=True))
+@click.option("--policies", "policies_path", type=click.Path(path_type=Path, exists=True))
+@click.option(
+ "--base-url",
+ default="",
+ help="openXYOS origin. Defaults to OPENXYOS_BASE_URL / FREEOS_ORG_SIDECAR_URL.",
+)
+def loop_run(
+ tenant_id: str, blueprint_path: Path | None, policies_path: Path | None, base_url: str
+) -> None:
+ """Run the full FreeOS ↔ openXYOS loop with fixtures when the sidecar is down."""
+ from octop.modules.org_os.loop.run import run_growth_loop
+
+ service = _service()
+ proof = run_growth_loop(
+ service.home,
+ tenant_id=tenant_id or service.tenant_id() or "1",
+ blueprint_path=blueprint_path,
+ policies_path=policies_path,
+ sidecar_url=base_url or service.sidecar_url(),
+ config_path=service.config_path,
+ )
+ click.echo(json.dumps(proof.to_dict(), indent=2))
+ if not proof.ok:
+ raise SystemExit(2)
diff --git a/src/octop/cli/main.py b/src/octop/cli/main.py
index bd04c135..8f7a60d1 100644
--- a/src/octop/cli/main.py
+++ b/src/octop/cli/main.py
@@ -94,7 +94,7 @@ def format_commands(self, ctx: click.Context, formatter: click.HelpFormatter) ->
is_eager=True,
expose_value=False,
callback=_print_version,
- help="Show the installed octop version.",
+ help="Show the installed FreeOS / octop version.",
)
@click.option(
"--user",
@@ -124,7 +124,7 @@ def cli(
agent_id: str | None,
json_out: bool,
) -> None:
- """Octop command-line interface."""
+ """FreeOS command-line interface (octop-compatible)."""
_ensure_utf8_stdio()
ctx.ensure_object(dict)
ctx.obj["as_user"] = as_user
diff --git a/src/octop/cli/registry.py b/src/octop/cli/registry.py
index c2bda4b3..815890b7 100644
--- a/src/octop/cli/registry.py
+++ b/src/octop/cli/registry.py
@@ -31,6 +31,6 @@
"org": (
".commands.org",
"org",
- "Organization module, governance MCP, and module↔skill bridge.",
+ "Organization self-growth loop, governance, and module↔skill bridge.",
),
}
diff --git a/src/octop/infra/agents/manager.py b/src/octop/infra/agents/manager.py
index 40ff4db0..8488dea8 100644
--- a/src/octop/infra/agents/manager.py
+++ b/src/octop/infra/agents/manager.py
@@ -845,7 +845,14 @@ def resolve_user_agent(self, user_id: int, query: str) -> AgentRow | None:
if row.name.lower() == ql:
return row
partial = [r for r in rows if ql in r.name.lower()]
- return partial[0] if len(partial) == 1 else None
+ if len(partial) == 1:
+ return partial[0]
+ from octop.modules.org_os.runtime.routing import resolve_routed_agent
+
+ routed = resolve_routed_agent(self._paths.root, q)
+ if routed:
+ return self.get_row(routed)
+ return None
def get_config(self, agent_id: str) -> dict[str, Any]:
"""Return harness config for agent_id, overlaying column skill_package_ids."""
@@ -2749,6 +2756,7 @@ def _build_harness_config(self, row: AgentRow) -> HarnessAgentConfig:
from octop.infra.agents.middleware.binary_read_guard import BinaryReadGuardMiddleware
from octop.infra.agents.middleware.browser_profile import BrowserProfileMiddleware
+ from octop.infra.agents.middleware.org_governance import OrgGovernanceMiddleware
from octop.infra.agents.middleware.reasoning import ReasoningRequestMiddleware
from octop.infra.agents.middleware.thread_artifacts import ThreadArtifactsMiddleware
from octop.infra.agents.middleware.token_quota import TokenQuotaMiddleware
@@ -2763,6 +2771,7 @@ def _build_harness_config(self, row: AgentRow) -> HarnessAgentConfig:
# WorkspaceImageMaterialize expands path-only vision refs at model-call time.
agent_middleware: list[Any] = [
*plugin_middleware,
+ OrgGovernanceMiddleware.from_home(self._paths.root, config_path=self._paths.config),
TokenQuotaMiddleware(
policy_repo=self._repos.user_policy_repo,
usage_repo=self._repos.usage_repo,
diff --git a/src/octop/infra/agents/middleware/org_governance.py b/src/octop/infra/agents/middleware/org_governance.py
new file mode 100644
index 00000000..d5cec446
--- /dev/null
+++ b/src/octop/infra/agents/middleware/org_governance.py
@@ -0,0 +1,129 @@
+"""Host-wide governance gate on the FreeOS/Octop tool path.
+
+High-risk tool calls (outbound / delete / pay / prod) go through
+``xyos-governance-mcp`` before the handler runs. A pending or deny
+decision returns an error ToolMessage — the tool does not execute.
+"""
+
+from __future__ import annotations
+
+import logging
+from collections.abc import Awaitable, Callable
+from pathlib import Path
+from typing import Any
+
+from langchain.agents.middleware import AgentMiddleware
+from langchain_core.messages import ToolMessage
+from langgraph.prebuilt.tool_node import ToolCallRequest
+from langgraph.types import Command
+
+from octop.modules.org_os.governance.interceptor import (
+ GovernanceBlockedError,
+ gate_tool_call,
+)
+from octop.modules.org_os.governance.policy import is_high_risk
+from octop.modules.org_os.service import OrgModuleService
+
+logger = logging.getLogger(__name__)
+
+
+def host_gate_tool(
+ *,
+ home: Path,
+ tool_name: str,
+ args: dict[str, Any] | None = None,
+ tenant_id: str = "",
+ actor_id: str = "",
+ approval_id: str = "",
+ sidecar_url: str = "",
+ enabled: bool = True,
+) -> None:
+ """Raise ``GovernanceBlockedError`` when a high-risk host tool must not run."""
+ if not enabled or not is_high_risk(tool_name):
+ return
+ gate_tool_call(
+ home=home,
+ tool_name=tool_name,
+ action=tool_name,
+ tenant_id=tenant_id,
+ actor_id=actor_id,
+ args=args or {},
+ approval_id=approval_id,
+ sidecar_url=sidecar_url,
+ enabled=True,
+ )
+
+
+class OrgGovernanceMiddleware(AgentMiddleware[Any, Any]):
+ """Block high-risk harness tool calls when org governance is enabled."""
+
+ def __init__(
+ self,
+ *,
+ home: Path,
+ sidecar_url: str = "",
+ tenant_id: str = "",
+ enabled: bool = True,
+ actor_id: str = "",
+ ) -> None:
+ super().__init__()
+ self.home = Path(home)
+ self.sidecar_url = sidecar_url
+ self.tenant_id = tenant_id
+ self.enabled = enabled
+ self.actor_id = actor_id
+
+ @classmethod
+ def from_home(cls, home: Path, *, config_path: Path | None = None) -> OrgGovernanceMiddleware:
+ service = OrgModuleService(config_path=config_path or (home / "config.json"), home=home)
+ return cls(
+ home=home,
+ sidecar_url=service.sidecar_url(),
+ tenant_id=service.tenant_id(),
+ enabled=service.governance_enabled() or service.is_enabled(),
+ )
+
+ async def awrap_tool_call(
+ self,
+ request: ToolCallRequest,
+ handler: Callable[[ToolCallRequest], Awaitable[ToolMessage | Command[Any]]],
+ ) -> ToolMessage | Command[Any]:
+ tool_call = request.tool_call
+ tool_name = str(tool_call.get("name") or "")
+ raw_args = tool_call.get("args")
+ args = raw_args if isinstance(raw_args, dict) else {}
+ approval_id = str(args.get("approval_id") or args.get("governance_approval_id") or "")
+ try:
+ host_gate_tool(
+ home=self.home,
+ tool_name=tool_name,
+ args=args,
+ tenant_id=self.tenant_id,
+ actor_id=self.actor_id,
+ approval_id=approval_id,
+ sidecar_url=self.sidecar_url,
+ enabled=self.enabled,
+ )
+ except GovernanceBlockedError as exc:
+ logger.info("OrgGovernance blocked %s: %s", tool_name, exc.decision.reason)
+ payload = json_payload(exc.decision.to_dict())
+ return ToolMessage(
+ content=payload,
+ tool_call_id=str(tool_call.get("id") or ""),
+ status="error",
+ )
+ return await handler(request)
+
+
+def json_payload(data: dict[str, Any]) -> str:
+ import json
+
+ return json.dumps(
+ {
+ "blocked": True,
+ "execute": False,
+ "governance": data,
+ "message": data.get("reason") or "FreeOS governance blocked this high-risk tool.",
+ },
+ ensure_ascii=False,
+ )
diff --git a/src/octop/modules/org_os/apply/__init__.py b/src/octop/modules/org_os/apply/__init__.py
new file mode 100644
index 00000000..b53df786
--- /dev/null
+++ b/src/octop/modules/org_os/apply/__init__.py
@@ -0,0 +1,16 @@
+"""Apply FreeOS asset packs onto openXYOS-shaped control-plane surfaces."""
+
+from octop.modules.org_os.apply.apply import apply_asset_pack, import_applied_surfaces
+from octop.modules.org_os.apply.client import (
+ ApplyResult,
+ OpenXyosControlClient,
+ resolve_control_plane_url,
+)
+
+__all__ = [
+ "ApplyResult",
+ "OpenXyosControlClient",
+ "apply_asset_pack",
+ "import_applied_surfaces",
+ "resolve_control_plane_url",
+]
diff --git a/src/octop/modules/org_os/apply/apply.py b/src/octop/modules/org_os/apply/apply.py
new file mode 100644
index 00000000..ca378917
--- /dev/null
+++ b/src/octop/modules/org_os/apply/apply.py
@@ -0,0 +1,200 @@
+"""Apply a ``freeos.asset-pack.v1`` onto openXYOS-shaped surfaces."""
+
+from __future__ import annotations
+
+import json
+from pathlib import Path
+from typing import Any
+
+from octop.modules.org_os.apply.client import (
+ ApplyReceipt,
+ ApplyResult,
+ OpenXyosControlClient,
+ mirror_root,
+)
+from octop.modules.org_os.catalog import catalog_keys
+
+
+def _load_json(path: Path) -> Any:
+ if not path.is_file():
+ return None
+ try:
+ return json.loads(path.read_text(encoding="utf-8"))
+ except json.JSONDecodeError:
+ return None
+
+
+def _module_settings_payload(tenant_id: str) -> dict[str, Any]:
+ updates = dict.fromkeys(catalog_keys(), False)
+ return {
+ "enabled_by_default": False,
+ "tenant_id": tenant_id,
+ "updates": updates,
+ "note": "Drafts only. Operator must toggle each module on.",
+ }
+
+
+def apply_asset_pack(
+ pack_dir: Path,
+ *,
+ home: Path,
+ tenant_id: str = "",
+ base_url: str = "",
+ headers: dict[str, str] | None = None,
+) -> ApplyResult:
+ """Write local mirror + POST/PUT drafts to module-settings/plugins/employees/talent."""
+ dest = pack_dir if pack_dir.is_dir() else Path(pack_dir)
+ tid = tenant_id or "default"
+ mirror = mirror_root(home, tid)
+ mirror.mkdir(parents=True, exist_ok=True)
+ client = OpenXyosControlClient(base_url, headers=headers)
+ result = ApplyResult(
+ pack_dir=dest,
+ mirror_dir=mirror,
+ control_plane_url=client.base_url,
+ notes=[
+ "Local mirror is the durable record. HTTP apply is best-effort.",
+ "Nothing is auto-enabled on the control plane.",
+ ],
+ )
+
+ employees_src = dest / "openxyos" / "org-employees.publish.json"
+ employees = _load_json(employees_src) or {"employees": []}
+ emp_mirror = client.write_mirror(mirror / "employees.json", employees)
+ emp_items = employees.get("employees") if isinstance(employees, dict) else []
+ if not isinstance(emp_items, list):
+ emp_items = []
+ http = client.request(
+ "POST",
+ "/api/employees",
+ payload={
+ "employees": emp_items,
+ "enabled_by_default": False,
+ "source": "freeos.asset-pack.v1",
+ },
+ )
+ if emp_items and client.base_url:
+ # Also upsert each employee the way openXYOS POST /api/employees expects.
+ for item in emp_items:
+ if isinstance(item, dict):
+ client.request("POST", "/api/employees", payload=item)
+ result.receipts.append(
+ ApplyReceipt(
+ surface="employees",
+ endpoint="/api/employees",
+ mirrored=str(emp_mirror),
+ http=http,
+ item_count=len(emp_items),
+ )
+ )
+
+ talent_src = dest / "openxyos" / "org-talent.publish.json"
+ talent = _load_json(talent_src) or {"talent": []}
+ talent_mirror = client.write_mirror(mirror / "talent.json", talent)
+ talent_items = talent.get("talent") if isinstance(talent, dict) else []
+ if not isinstance(talent_items, list):
+ talent_items = []
+ http = client.request(
+ "POST",
+ "/api/talent",
+ payload={
+ "talent": talent_items,
+ "enabled_by_default": False,
+ "source": "freeos.asset-pack.v1",
+ },
+ )
+ result.receipts.append(
+ ApplyReceipt(
+ surface="talent",
+ endpoint="/api/talent",
+ mirrored=str(talent_mirror),
+ http=http,
+ item_count=len(talent_items),
+ )
+ )
+
+ plugin_payloads: list[dict[str, Any]] = []
+ openxyos_dir = dest / "openxyos"
+ if openxyos_dir.is_dir():
+ for path in sorted(openxyos_dir.glob("*.publish.json")):
+ if path.name.startswith("org-employees") or path.name.startswith("org-talent"):
+ continue
+ raw = _load_json(path)
+ if isinstance(raw, dict):
+ plugin_payloads.append(raw)
+ plugins_doc = {"enabled_by_default": False, "plugins": plugin_payloads}
+ plugins_mirror = client.write_mirror(mirror / "plugins.json", plugins_doc)
+ http = client.request("POST", "/api/plugins", payload=plugins_doc)
+ result.receipts.append(
+ ApplyReceipt(
+ surface="plugins",
+ endpoint="/api/plugins",
+ mirrored=str(plugins_mirror),
+ http=http,
+ item_count=len(plugin_payloads),
+ )
+ )
+
+ settings = _module_settings_payload(tid)
+ settings_mirror = client.write_mirror(mirror / "module-settings.json", settings)
+ http = client.request("PUT", "/api/module-settings", payload=settings)
+ result.receipts.append(
+ ApplyReceipt(
+ surface="module-settings",
+ endpoint="/api/module-settings",
+ mirrored=str(settings_mirror),
+ http=http,
+ item_count=len(settings.get("updates") or {}),
+ )
+ )
+
+ receipt_path = mirror / "apply-receipt.json"
+ client.write_mirror(receipt_path, result.to_dict())
+ if client.base_url and not result.remote_applied:
+ result.notes.append(
+ f"control plane at {client.base_url} did not accept drafts; mirror is complete"
+ )
+ elif not client.base_url:
+ result.notes.append("OPENXYOS_BASE_URL unset; applied to local mirror only")
+ return result
+
+
+def import_applied_surfaces(
+ home: Path,
+ *,
+ tenant_id: str = "",
+ base_url: str = "",
+) -> dict[str, Any]:
+ """Read the durable mirror (and live HTTP when up) after apply.
+
+ This is the inbound half of the apply+import roundtrip harness.
+ """
+ tid = tenant_id or "default"
+ mirror = mirror_root(home, tid)
+ client = OpenXyosControlClient(base_url)
+ remote: dict[str, Any] = {}
+ if client.base_url:
+ for key, path in (
+ ("employees", "/api/employees"),
+ ("talent", "/api/talent"),
+ ("plugins", "/api/plugins"),
+ ("module_settings", "/api/module-settings"),
+ ("permissions", "/api/governance/permissions"),
+ ):
+ fetched = client.get_json(path)
+ if fetched is not None:
+ remote[key] = fetched
+ client.write_mirror(mirror / f"imported-{key}.json", fetched)
+
+ local: dict[str, Any] = {}
+ for name in ("employees", "talent", "plugins", "module-settings", "apply-receipt"):
+ raw = _load_json(mirror / f"{name}.json")
+ if raw is not None:
+ local[name.replace("-", "_")] = raw
+ return {
+ "tenant_id": tid,
+ "mirror_dir": str(mirror),
+ "control_plane_url": client.base_url,
+ "local": local,
+ "remote": remote,
+ }
diff --git a/src/octop/modules/org_os/apply/client.py b/src/octop/modules/org_os/apply/client.py
new file mode 100644
index 00000000..3d8a9a7a
--- /dev/null
+++ b/src/octop/modules/org_os/apply/client.py
@@ -0,0 +1,185 @@
+"""openXYOS control-plane HTTP client with a durable local mirror.
+
+Live HTTP is used when ``OPENXYOS_BASE_URL`` / ``FREEOS_ORG_SIDECAR_URL`` is a
+real origin. A down sidecar never fails the loop open: every payload is written
+under ``{FREEOS_HOME}/openxyos-mirror//`` first.
+"""
+
+from __future__ import annotations
+
+import json
+import os
+from dataclasses import dataclass, field
+from pathlib import Path
+from typing import Any
+from urllib.parse import urlparse
+
+import httpx
+
+DEFAULT_CONTROL_URL = "http://127.0.0.1:3780"
+
+
+def resolve_control_plane_url(*candidates: str) -> str:
+ """Prefer ``OPENXYOS_BASE_URL``, then explicit args, then sidecar env."""
+ ordered = [
+ os.environ.get("OPENXYOS_BASE_URL", "").strip(),
+ *candidates,
+ os.environ.get("FREEOS_ORG_SIDECAR_URL", "").strip(),
+ ]
+ for raw in ordered:
+ cleaned = (raw or "").strip().rstrip("/")
+ parsed = urlparse(cleaned)
+ if parsed.scheme in {"http", "https"} and parsed.netloc:
+ return cleaned
+ return ""
+
+
+def mirror_root(home: Path, tenant_id: str) -> Path:
+ tid = tenant_id or "default"
+ return home / "openxyos-mirror" / tid
+
+
+@dataclass
+class HttpResult:
+ ok: bool
+ reached: bool
+ method: str
+ path: str
+ status_code: int = 0
+ reason: str = ""
+ body: Any = None
+
+ def to_dict(self) -> dict[str, Any]:
+ return {
+ "ok": self.ok,
+ "reached": self.reached,
+ "method": self.method,
+ "path": self.path,
+ "status_code": self.status_code,
+ "reason": self.reason,
+ }
+
+
+class OpenXyosControlClient:
+ """POST/PUT/GET openXYOS-shaped surfaces. Always persist a local mirror."""
+
+ def __init__(
+ self,
+ base_url: str = "",
+ *,
+ timeout: float = 3.0,
+ headers: dict[str, str] | None = None,
+ ) -> None:
+ self.base_url = resolve_control_plane_url(base_url)
+ self.timeout = timeout
+ self.headers = dict(headers or {})
+
+ def write_mirror(self, dest: Path, payload: Any) -> Path:
+ dest.parent.mkdir(parents=True, exist_ok=True)
+ dest.write_text(json.dumps(payload, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
+ return dest
+
+ def request(
+ self,
+ method: str,
+ path: str,
+ *,
+ payload: Any | None = None,
+ mirror_path: Path | None = None,
+ ) -> HttpResult:
+ if mirror_path is not None and payload is not None:
+ self.write_mirror(mirror_path, payload)
+ if not self.base_url:
+ return HttpResult(
+ ok=False,
+ reached=False,
+ method=method,
+ path=path,
+ reason="control-plane URL unset; local mirror only",
+ )
+ url = f"{self.base_url}{path if path.startswith('/') else '/' + path}"
+ try:
+ with httpx.Client(timeout=self.timeout, follow_redirects=True) as client:
+ response = client.request(
+ method.upper(),
+ url,
+ json=payload,
+ headers=self.headers or None,
+ )
+ except httpx.HTTPError as exc:
+ return HttpResult(
+ ok=False,
+ reached=False,
+ method=method,
+ path=path,
+ reason=f"control plane unreachable: {exc}",
+ )
+ body: Any
+ try:
+ body = response.json()
+ except ValueError:
+ body = response.text
+ return HttpResult(
+ ok=response.status_code < 400,
+ reached=True,
+ method=method,
+ path=path,
+ status_code=response.status_code,
+ reason="" if response.status_code < 400 else f"HTTP {response.status_code}",
+ body=body,
+ )
+
+ def get_json(self, path: str) -> Any | None:
+ result = self.request("GET", path)
+ if not result.ok:
+ return None
+ body = result.body
+ if isinstance(body, dict) and "data" in body:
+ return body["data"]
+ return body
+
+
+@dataclass
+class ApplyReceipt:
+ surface: str
+ endpoint: str
+ mirrored: str
+ http: HttpResult
+ item_count: int = 0
+
+ def to_dict(self) -> dict[str, Any]:
+ return {
+ "surface": self.surface,
+ "endpoint": self.endpoint,
+ "mirrored": self.mirrored,
+ "item_count": self.item_count,
+ "http": self.http.to_dict(),
+ }
+
+
+@dataclass
+class ApplyResult:
+ pack_dir: Path
+ mirror_dir: Path
+ receipts: list[ApplyReceipt] = field(default_factory=list)
+ notes: list[str] = field(default_factory=list)
+ control_plane_url: str = ""
+
+ @property
+ def remote_applied(self) -> bool:
+ return any(item.http.ok for item in self.receipts)
+
+ @property
+ def mirrored(self) -> bool:
+ return self.mirror_dir.is_dir() and any(self.mirror_dir.rglob("*.json"))
+
+ def to_dict(self) -> dict[str, Any]:
+ return {
+ "pack_dir": str(self.pack_dir),
+ "mirror_dir": str(self.mirror_dir),
+ "control_plane_url": self.control_plane_url,
+ "remote_applied": self.remote_applied,
+ "mirrored": self.mirrored,
+ "receipts": [item.to_dict() for item in self.receipts],
+ "notes": list(self.notes),
+ }
diff --git a/src/octop/modules/org_os/assets/importer.py b/src/octop/modules/org_os/assets/importer.py
index 747828b5..d1132a5e 100644
--- a/src/octop/modules/org_os/assets/importer.py
+++ b/src/octop/modules/org_os/assets/importer.py
@@ -22,6 +22,7 @@
class ImportedAssets:
skills: list[str] = field(default_factory=list)
employees: list[str] = field(default_factory=list)
+ agents: list[str] = field(default_factory=list)
policies: str = ""
notes: list[str] = field(default_factory=list)
@@ -29,6 +30,7 @@ def to_dict(self) -> dict[str, Any]:
return {
"skills": list(self.skills),
"employees": list(self.employees),
+ "agents": list(self.agents),
"policies": self.policies,
"notes": list(self.notes),
}
@@ -64,6 +66,7 @@ def import_openxyos_assets(
blueprint_path: Path | None = None,
policies_path: Path | None = None,
from_sidecar: bool = False,
+ spawn_agents: bool = True,
) -> ImportedAssets:
result = ImportedAssets()
tid = tenant_id or "default"
@@ -86,6 +89,14 @@ def import_openxyos_assets(
)
result.employees.append(compiled.slug)
result.notes.append(f"compiled blueprint → {compiled.workspace}")
+ if spawn_agents:
+ from octop.modules.org_os.runtime.spawn import spawn_colleague_agent
+
+ record = store.get(compiled.slug)
+ if record is not None:
+ spawned = spawn_colleague_agent(home, record)
+ result.agents.append(spawned.agent_id)
+ result.notes.append(f"spawned FreeOS agent {spawned.agent_id}")
policies_raw: Any | None = None
policies_source = ""
diff --git a/src/octop/modules/org_os/compiler/compile.py b/src/octop/modules/org_os/compiler/compile.py
index 4212f2e4..3727bab1 100644
--- a/src/octop/modules/org_os/compiler/compile.py
+++ b/src/octop/modules/org_os/compiler/compile.py
@@ -61,7 +61,7 @@ def _render_soul(blueprint: AgentBlueprint) -> str:
"",
"## Governance (must follow)",
"",
- "You run on the FreeOS/Octop **data plane**. openXYOS is the control plane.",
+ "You run on the FreeOS **data plane**. openXYOS is the control plane.",
"Do not start a second chat runtime.",
"",
"- High-risk tools (outbound / delete / pay / prod) go through",
diff --git a/src/octop/modules/org_os/governance/engine.py b/src/octop/modules/org_os/governance/engine.py
index 3a9ef8a2..13d35b03 100644
--- a/src/octop/modules/org_os/governance/engine.py
+++ b/src/octop/modules/org_os/governance/engine.py
@@ -237,7 +237,7 @@ def _pending_or_deny(
status="pending",
execute=False,
blocked=True,
- reason=f"{reason}; paused for human approval via FreeOS/Octop IM or "
+ reason=f"{reason}; paused for human approval via FreeOS IM or "
f"`freeos org governance approve {pause.pause_id}`",
category=category,
pause_id=pause.pause_id,
diff --git a/src/octop/modules/org_os/lifecycle/store.py b/src/octop/modules/org_os/lifecycle/store.py
index 08977090..e11c1a6f 100644
--- a/src/octop/modules/org_os/lifecycle/store.py
+++ b/src/octop/modules/org_os/lifecycle/store.py
@@ -34,6 +34,7 @@ class ColleagueRecord:
memory_archived: str = ""
openxyos_talent_status: str = ""
openxyos_employment_category: str = ""
+ agent_id: str = ""
def to_dict(self) -> dict[str, Any]:
return asdict(self)
@@ -55,6 +56,7 @@ def from_dict(cls, data: dict[str, Any]) -> ColleagueRecord:
memory_archived=str(data.get("memory_archived") or ""),
openxyos_talent_status=str(data.get("openxyos_talent_status") or ""),
openxyos_employment_category=str(data.get("openxyos_employment_category") or ""),
+ agent_id=str(data.get("agent_id") or ""),
)
diff --git a/src/octop/modules/org_os/lifecycle/transitions.py b/src/octop/modules/org_os/lifecycle/transitions.py
index 1fcd88ee..a70a99ef 100644
--- a/src/octop/modules/org_os/lifecycle/transitions.py
+++ b/src/octop/modules/org_os/lifecycle/transitions.py
@@ -115,6 +115,14 @@ def transition(
_apply_flags(record)
if workspace.is_dir():
_set_cron_enabled(workspace, record.cron_enabled)
+ from octop.modules.org_os.runtime.memory import sync_colleague_memory
+
+ sync_colleague_memory(record, store.home)
+ if target in {"shadow", "active"} and not record.agent_id:
+ from octop.modules.org_os.runtime.spawn import spawn_colleague_agent
+
+ spawned = spawn_colleague_agent(store.home, record)
+ record.agent_id = spawned.agent_id
if target == "offboard":
_revoke_credentials(workspace)
record.credentials_revoked = True
diff --git a/src/octop/modules/org_os/loop/__init__.py b/src/octop/modules/org_os/loop/__init__.py
new file mode 100644
index 00000000..9c79b637
--- /dev/null
+++ b/src/octop/modules/org_os/loop/__init__.py
@@ -0,0 +1,5 @@
+"""Finished FreeOS self-growth loop."""
+
+from octop.modules.org_os.loop.run import LoopProof, run_growth_loop
+
+__all__ = ["LoopProof", "run_growth_loop"]
diff --git a/src/octop/modules/org_os/loop/data/agent-blueprint.v1.json b/src/octop/modules/org_os/loop/data/agent-blueprint.v1.json
new file mode 100644
index 00000000..6d25cd48
--- /dev/null
+++ b/src/octop/modules/org_os/loop/data/agent-blueprint.v1.json
@@ -0,0 +1,28 @@
+{
+ "schema": "openxyos.agent-blueprint.v1",
+ "name": "Policy Analyst",
+ "industry": "public-sector",
+ "positioning": "Reads org policy and drafts briefings; never sends mail or changes production.",
+ "capabilities": ["policy-research", "briefing"],
+ "experience": "Summarize policy diffs. Cite sources. Escalate high-risk asks.",
+ "references": [
+ {
+ "name": "staff-handbook.md",
+ "type": ".md",
+ "scan": "clean",
+ "excerpt": "Staff must not share tenant data across workspaces."
+ }
+ ],
+ "governance": {
+ "high_risk_requires_human_review": true,
+ "external_write_disabled": true
+ },
+ "lifecycle": "talent_market",
+ "job_duties": [
+ {
+ "name": "morning-brief",
+ "schedule": "0 9 * * 1-5",
+ "prompt": "Draft the weekday policy briefing from knowledge/."
+ }
+ ]
+}
diff --git a/src/octop/modules/org_os/loop/data/ops-coordinator.blueprint.json b/src/octop/modules/org_os/loop/data/ops-coordinator.blueprint.json
new file mode 100644
index 00000000..23574eb7
--- /dev/null
+++ b/src/octop/modules/org_os/loop/data/ops-coordinator.blueprint.json
@@ -0,0 +1,28 @@
+{
+ "schema": "openxyos.agent-blueprint.v1",
+ "name": "Ops Coordinator",
+ "industry": "professional-services",
+ "positioning": "Coordinates org-module work and drafts runbooks; never deploys or pays.",
+ "capabilities": ["runbook-draft", "status-digest"],
+ "experience": "Keep a live ops digest. Escalate high-risk production or payment asks.",
+ "references": [
+ {
+ "name": "ops-guardrails.md",
+ "type": ".md",
+ "scan": "clean",
+ "excerpt": "High-risk tools require a durable FreeOS governance approval."
+ }
+ ],
+ "governance": {
+ "high_risk_requires_human_review": true,
+ "external_write_disabled": true
+ },
+ "lifecycle": "talent_market",
+ "job_duties": [
+ {
+ "name": "ops-digest",
+ "schedule": "0 18 * * 1-5",
+ "prompt": "Summarize today's org-module activity from knowledge/."
+ }
+ ]
+}
diff --git a/src/octop/modules/org_os/loop/data/policies.json b/src/octop/modules/org_os/loop/data/policies.json
new file mode 100644
index 00000000..b4ab68dc
--- /dev/null
+++ b/src/octop/modules/org_os/loop/data/policies.json
@@ -0,0 +1,26 @@
+{
+ "source": "freeos.demo-fixture",
+ "rules": [
+ {
+ "category": "delete",
+ "action": "delete_employee",
+ "allow": false,
+ "reason": "Demo policy: deleting employees is denied until a human approves."
+ },
+ {
+ "category": "pay",
+ "allow": false,
+ "reason": "Demo policy: payments stay default-deny."
+ },
+ {
+ "category": "outbound",
+ "allow": false,
+ "reason": "Demo policy: outbound send is gated."
+ },
+ {
+ "category": "prod",
+ "allow": false,
+ "reason": "Demo policy: production changes stay gated."
+ }
+ ]
+}
diff --git a/src/octop/modules/org_os/loop/fixtures.py b/src/octop/modules/org_os/loop/fixtures.py
new file mode 100644
index 00000000..cbb71ad5
--- /dev/null
+++ b/src/octop/modules/org_os/loop/fixtures.py
@@ -0,0 +1,58 @@
+"""Demo fixture dataset for the finished FreeOS self-growth loop."""
+
+from __future__ import annotations
+
+from pathlib import Path
+
+_PACKAGED = Path(__file__).resolve().parent / "data"
+
+
+def _repo_root() -> Path | None:
+ here = Path(__file__).resolve()
+ for parent in here.parents:
+ if (parent / "tests" / "fixtures").is_dir() and (parent / "pyproject.toml").is_file():
+ return parent
+ return None
+
+
+def _repo_loop_dir() -> Path | None:
+ root = _repo_root()
+ if root is None:
+ return None
+ path = root / "tests" / "fixtures" / "org-loop"
+ return path if path.is_dir() else None
+
+
+def blueprint_fixture() -> Path:
+ for candidate in (
+ _PACKAGED / "agent-blueprint.v1.json",
+ (_repo_loop_dir() or Path()) / "agent-blueprint.v1.json",
+ ):
+ if candidate.is_file():
+ return candidate
+ raise FileNotFoundError("bundled agent-blueprint.v1.json is missing")
+
+
+def policies_fixture() -> Path:
+ for candidate in (
+ _PACKAGED / "policies.json",
+ (_repo_loop_dir() or Path()) / "policies.json",
+ ):
+ if candidate.is_file():
+ return candidate
+ raise FileNotFoundError("bundled policies.json is missing")
+
+
+def extra_blueprint_fixtures() -> list[Path]:
+ found: list[Path] = []
+ seen_names: set[str] = set()
+ for path in sorted(_PACKAGED.glob("*.blueprint.json")):
+ if path.name in seen_names:
+ continue
+ seen_names.add(path.name)
+ found.append(path)
+ return found
+
+
+def fixture_dir() -> Path:
+ return _repo_loop_dir() or _PACKAGED
diff --git a/src/octop/modules/org_os/loop/run.py b/src/octop/modules/org_os/loop/run.py
new file mode 100644
index 00000000..2653af69
--- /dev/null
+++ b/src/octop/modules/org_os/loop/run.py
@@ -0,0 +1,208 @@
+"""One operator path: blueprint → employee → publish → apply → import → agents."""
+
+from __future__ import annotations
+
+import json
+from dataclasses import dataclass, field
+from pathlib import Path
+from typing import Any
+
+from octop.modules.org_os.apply.apply import apply_asset_pack, import_applied_surfaces
+from octop.modules.org_os.apply.client import resolve_control_plane_url
+from octop.modules.org_os.assets.importer import import_openxyos_assets
+from octop.modules.org_os.assets.pack import publish_asset_pack
+from octop.modules.org_os.governance.interceptor import (
+ GovernanceBlockedError,
+ gate_tool_call,
+)
+from octop.modules.org_os.lifecycle.store import LifecycleStore
+from octop.modules.org_os.lifecycle.transitions import transition
+from octop.modules.org_os.loop.fixtures import (
+ blueprint_fixture,
+ extra_blueprint_fixtures,
+ policies_fixture,
+)
+from octop.modules.org_os.runtime.spawn import SpawnedAgent, spawn_colleague_agent
+from octop.modules.org_os.service import OrgModuleService
+
+
+@dataclass
+class LoopProof:
+ tenant_id: str
+ home: str
+ skills: list[str] = field(default_factory=list)
+ employees: list[str] = field(default_factory=list)
+ agents: list[dict[str, Any]] = field(default_factory=list)
+ lifecycle: dict[str, str] = field(default_factory=dict)
+ pack_dir: str = ""
+ mirror_dir: str = ""
+ remote_applied: bool = False
+ imported_roundtrip: dict[str, Any] = field(default_factory=dict)
+ governance_blocked: bool = False
+ governance: dict[str, Any] = field(default_factory=dict)
+ notes: list[str] = field(default_factory=list)
+ ok: bool = False
+
+ def to_dict(self) -> dict[str, Any]:
+ return {
+ "ok": self.ok,
+ "tenant_id": self.tenant_id,
+ "home": self.home,
+ "skills": list(self.skills),
+ "employees": list(self.employees),
+ "agents": list(self.agents),
+ "lifecycle": dict(self.lifecycle),
+ "pack_dir": self.pack_dir,
+ "mirror_dir": self.mirror_dir,
+ "remote_applied": self.remote_applied,
+ "imported_roundtrip": dict(self.imported_roundtrip),
+ "governance_blocked": self.governance_blocked,
+ "governance": dict(self.governance),
+ "notes": list(self.notes),
+ }
+
+
+def _promote(store: LifecycleStore, slug: str) -> dict[str, str]:
+ states = ("market", "recruit", "shadow", "active")
+ seen: dict[str, str] = {"draft": "draft"}
+ for state in states:
+ record = transition(store, slug, state, reason="org loop run")
+ seen[state] = record.lifecycle
+ return seen
+
+
+def run_growth_loop(
+ home: Path,
+ *,
+ tenant_id: str = "1",
+ blueprint_path: Path | None = None,
+ policies_path: Path | None = None,
+ sidecar_url: str = "",
+ config_path: Path | None = None,
+) -> LoopProof:
+ """Run the finished self-growth loop against *home*.
+
+ Uses bundled fixtures when live openXYOS is down. When
+ ``OPENXYOS_BASE_URL`` (or *sidecar_url*) is set, outbound packs are
+ POSTed to the real control plane as well as the durable local mirror.
+ """
+ home = Path(home)
+ home.mkdir(parents=True, exist_ok=True)
+ tid = tenant_id or "1"
+ service = OrgModuleService(config_path=config_path or (home / "config.json"), home=home)
+ service.set_enabled(True, sidecar_url=sidecar_url or None)
+ service.set_governance_enabled(True, tenant_id=tid)
+
+ blueprint = Path(blueprint_path) if blueprint_path else blueprint_fixture()
+ policies = Path(policies_path) if policies_path else policies_fixture()
+ if not blueprint.is_file():
+ raise FileNotFoundError(f"blueprint fixture missing: {blueprint}")
+ control_url = resolve_control_plane_url(sidecar_url, service.sidecar_url())
+
+ imported = import_openxyos_assets(
+ home,
+ tenant_id=tid,
+ sidecar_url=control_url or service.sidecar_url(),
+ catalog=True,
+ blueprint_path=blueprint,
+ policies_path=policies if policies.is_file() else None,
+ from_sidecar=bool(control_url),
+ spawn_agents=False,
+ )
+ for extra in extra_blueprint_fixtures():
+ more = import_openxyos_assets(
+ home,
+ tenant_id=tid,
+ sidecar_url=control_url or service.sidecar_url(),
+ blueprint_path=extra,
+ spawn_agents=False,
+ )
+ imported.employees.extend(more.employees)
+ imported.notes.extend(more.notes)
+
+ store = LifecycleStore(home, tid)
+ lifecycle: dict[str, str] = {}
+ spawned: list[SpawnedAgent] = []
+ for slug in imported.employees:
+ lifecycle.update(_promote(store, slug))
+ record = store.get(slug)
+ if record is None:
+ continue
+ spawned.append(spawn_colleague_agent(home, record))
+
+ pack = publish_asset_pack(home, tenant_id=tid)
+ applied = apply_asset_pack(
+ pack.directory,
+ home=home,
+ tenant_id=tid,
+ base_url=control_url,
+ )
+ roundtrip = import_applied_surfaces(home, tenant_id=tid, base_url=control_url)
+
+ # Inbound feedback: re-import catalog/policies from the applied pack so
+ # the control plane strengthens the next FreeOS generation.
+ feedback = import_openxyos_assets(
+ home,
+ tenant_id=tid,
+ sidecar_url=control_url or service.sidecar_url(),
+ catalog=True,
+ from_sidecar=bool(roundtrip.get("remote")),
+ spawn_agents=True,
+ )
+
+ governance: dict[str, Any] = {}
+ blocked = False
+ try:
+ gate_tool_call(
+ home=home,
+ tool_name="delete_employee",
+ category="delete",
+ action="delete",
+ tenant_id=tid,
+ actor_id="org-loop",
+ args={"slug": imported.employees[0] if imported.employees else "demo"},
+ sidecar_url=control_url,
+ enabled=True,
+ )
+ except GovernanceBlockedError as exc:
+ blocked = True
+ governance = exc.decision.to_dict()
+
+ proof = LoopProof(
+ tenant_id=tid,
+ home=str(home),
+ skills=list(dict.fromkeys([*imported.skills, *feedback.skills])),
+ employees=list(imported.employees),
+ agents=[item.to_dict() for item in spawned],
+ lifecycle=lifecycle,
+ pack_dir=str(pack.directory),
+ mirror_dir=str(applied.mirror_dir),
+ remote_applied=applied.remote_applied,
+ imported_roundtrip={
+ "local_surfaces": sorted((roundtrip.get("local") or {}).keys()),
+ "remote_surfaces": sorted((roundtrip.get("remote") or {}).keys()),
+ "feedback_skills": list(feedback.skills),
+ "feedback_employees": list(feedback.employees),
+ },
+ governance_blocked=blocked,
+ governance=governance,
+ notes=[
+ *imported.notes,
+ *applied.notes,
+ *feedback.notes,
+ "Loop produced employees, published an asset pack, applied it, and imported back.",
+ ],
+ )
+ proof.ok = bool(
+ proof.skills
+ and proof.employees
+ and proof.agents
+ and proof.lifecycle.get("active") == "active"
+ and proof.pack_dir
+ and applied.mirrored
+ and proof.governance_blocked
+ )
+ dest = home / "asset-packs" / "loop-proof.json"
+ dest.parent.mkdir(parents=True, exist_ok=True)
+ dest.write_text(json.dumps(proof.to_dict(), indent=2) + "\n", encoding="utf-8")
+ return proof
diff --git a/src/octop/modules/org_os/runtime/__init__.py b/src/octop/modules/org_os/runtime/__init__.py
new file mode 100644
index 00000000..6ad1f0ce
--- /dev/null
+++ b/src/octop/modules/org_os/runtime/__init__.py
@@ -0,0 +1,18 @@
+"""Runtime hooks: spawn colleagues as FreeOS agents, route chat, sync org memory."""
+
+from octop.modules.org_os.runtime.memory import sync_colleague_memory
+from octop.modules.org_os.runtime.routing import ColleagueRouter, resolve_routed_agent
+from octop.modules.org_os.runtime.spawn import (
+ SpawnedAgent,
+ list_spawned_agents,
+ spawn_colleague_agent,
+)
+
+__all__ = [
+ "ColleagueRouter",
+ "SpawnedAgent",
+ "list_spawned_agents",
+ "resolve_routed_agent",
+ "spawn_colleague_agent",
+ "sync_colleague_memory",
+]
diff --git a/src/octop/modules/org_os/runtime/memory.py b/src/octop/modules/org_os/runtime/memory.py
new file mode 100644
index 00000000..149f9624
--- /dev/null
+++ b/src/octop/modules/org_os/runtime/memory.py
@@ -0,0 +1,48 @@
+"""Org memory hooks: colleague MEMORY/knowledge feed the tenant knowledge plane."""
+
+from __future__ import annotations
+
+import shutil
+from pathlib import Path
+
+from octop.modules.org_os.lifecycle.store import ColleagueRecord
+
+
+def org_knowledge_root(home: Path, tenant_id: str) -> Path:
+ return home / "tenants" / (tenant_id or "default") / "org-knowledge"
+
+
+def live_memory_dir(home: Path, tenant_id: str, slug: str) -> Path:
+ return org_knowledge_root(home, tenant_id) / "live" / slug
+
+
+def sync_colleague_memory(record: ColleagueRecord, home: Path) -> Path:
+ """Copy workspace MEMORY + knowledge into the tenant org-knowledge live tree."""
+ dest = live_memory_dir(home, record.tenant_id, record.slug)
+ dest.mkdir(parents=True, exist_ok=True)
+ workspace = Path(record.workspace)
+ memory = workspace / "MEMORY.md"
+ if memory.is_file():
+ shutil.copy2(memory, dest / "MEMORY.md")
+ else:
+ (dest / "MEMORY.md").write_text(f"# {record.name}\n", encoding="utf-8")
+ knowledge = workspace / "knowledge"
+ knowledge_dest = dest / "knowledge"
+ if knowledge.is_dir():
+ if knowledge_dest.exists():
+ shutil.rmtree(knowledge_dest)
+ shutil.copytree(knowledge, knowledge_dest)
+ soul = workspace / "SOUL.md"
+ if soul.is_file():
+ shutil.copy2(soul, dest / "SOUL.md")
+ index = dest / "INDEX.md"
+ index.write_text(
+ f"# {record.name}\n\n"
+ f"- slug: `{record.slug}`\n"
+ f"- lifecycle: `{record.lifecycle}`\n"
+ f"- workspace: `{record.workspace}`\n"
+ f"- agent_id: `{getattr(record, 'agent_id', '')}`\n"
+ "\nLive org memory used by FreeOS chat routing for this colleague.\n",
+ encoding="utf-8",
+ )
+ return dest
diff --git a/src/octop/modules/org_os/runtime/routing.py b/src/octop/modules/org_os/runtime/routing.py
new file mode 100644
index 00000000..978bbf74
--- /dev/null
+++ b/src/octop/modules/org_os/runtime/routing.py
@@ -0,0 +1,115 @@
+"""Session / chat routing table for produced digital colleagues.
+
+Maps colleague slugs and display names onto FreeOS ``agent_id`` values so
+dashboard chat and IM can address a spawned employee without a second runtime.
+"""
+
+from __future__ import annotations
+
+import json
+import time
+from dataclasses import asdict, dataclass
+from pathlib import Path
+from typing import Any
+
+
+def routing_path(home: Path, tenant_id: str) -> Path:
+ return home / "tenants" / (tenant_id or "default") / "routing.json"
+
+
+@dataclass
+class RouteEntry:
+ slug: str
+ agent_id: str
+ name: str
+ tenant_id: str
+ workspace: str
+ lifecycle: str = "draft"
+ updated_at: float = 0.0
+
+ def to_dict(self) -> dict[str, Any]:
+ return asdict(self)
+
+ @classmethod
+ def from_dict(cls, data: dict[str, Any]) -> RouteEntry:
+ return cls(
+ slug=str(data.get("slug") or ""),
+ agent_id=str(data.get("agent_id") or ""),
+ name=str(data.get("name") or ""),
+ tenant_id=str(data.get("tenant_id") or ""),
+ workspace=str(data.get("workspace") or ""),
+ lifecycle=str(data.get("lifecycle") or "draft"),
+ updated_at=float(data.get("updated_at") or 0.0),
+ )
+
+
+class ColleagueRouter:
+ def __init__(self, home: Path, tenant_id: str) -> None:
+ self.home = home
+ self.tenant_id = tenant_id or "default"
+ self.path = routing_path(home, self.tenant_id)
+
+ def _load(self) -> dict[str, Any]:
+ if not self.path.is_file():
+ return {"tenant_id": self.tenant_id, "routes": {}}
+ try:
+ raw = json.loads(self.path.read_text(encoding="utf-8"))
+ except json.JSONDecodeError:
+ return {"tenant_id": self.tenant_id, "routes": {}}
+ if not isinstance(raw, dict):
+ return {"tenant_id": self.tenant_id, "routes": {}}
+ if not isinstance(raw.get("routes"), dict):
+ raw["routes"] = {}
+ return raw
+
+ def _save(self, data: dict[str, Any]) -> None:
+ self.path.parent.mkdir(parents=True, exist_ok=True)
+ self.path.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
+
+ def upsert(self, entry: RouteEntry) -> RouteEntry:
+ data = self._load()
+ entry.updated_at = entry.updated_at or time.time()
+ data["routes"][entry.slug] = entry.to_dict()
+ self._save(data)
+ return entry
+
+ def get(self, slug: str) -> RouteEntry | None:
+ raw = self._load()["routes"].get(slug)
+ return RouteEntry.from_dict(raw) if isinstance(raw, dict) else None
+
+ def list(self) -> list[RouteEntry]:
+ rows = [
+ RouteEntry.from_dict(item)
+ for item in self._load()["routes"].values()
+ if isinstance(item, dict)
+ ]
+ rows.sort(key=lambda item: item.updated_at, reverse=True)
+ return rows
+
+ def resolve(self, query: str) -> RouteEntry | None:
+ q = (query or "").strip()
+ if not q:
+ return None
+ ql = q.lower().lstrip("@")
+ for entry in self.list():
+ if entry.slug == ql or entry.agent_id == q or entry.agent_id.lower() == ql:
+ return entry
+ if entry.name.lower() == ql:
+ return entry
+ partial = [item for item in self.list() if ql in item.slug or ql in item.name.lower()]
+ return partial[0] if len(partial) == 1 else None
+
+
+def resolve_routed_agent(home: Path, query: str, *, tenant_id: str = "") -> str:
+ """Return an agent_id for a colleague slug/name, searching one or all tenants."""
+ tenants_root = home / "tenants"
+ tenant_ids = [tenant_id] if tenant_id else []
+ if not tenant_ids and tenants_root.is_dir():
+ tenant_ids = [path.name for path in tenants_root.iterdir() if path.is_dir()]
+ if not tenant_ids:
+ tenant_ids = ["default"]
+ for tid in tenant_ids:
+ entry = ColleagueRouter(home, tid).resolve(query)
+ if entry is not None and entry.agent_id:
+ return entry.agent_id
+ return ""
diff --git a/src/octop/modules/org_os/runtime/spawn.py b/src/octop/modules/org_os/runtime/spawn.py
new file mode 100644
index 00000000..c66aa78b
--- /dev/null
+++ b/src/octop/modules/org_os/runtime/spawn.py
@@ -0,0 +1,253 @@
+"""Register a compiled colleague as a real FreeOS chat agent."""
+
+from __future__ import annotations
+
+import json
+import logging
+from dataclasses import asdict, dataclass, field
+from pathlib import Path
+from typing import Any
+
+from octop.infra.utils.ulid import new_short_id
+from octop.modules.org_os.lifecycle.store import ColleagueRecord, LifecycleStore
+from octop.modules.org_os.runtime.memory import sync_colleague_memory
+from octop.modules.org_os.runtime.routing import ColleagueRouter, RouteEntry
+
+logger = logging.getLogger(__name__)
+
+
+def agents_registry_path(home: Path) -> Path:
+ return home / "org-agents" / "registry.json"
+
+
+@dataclass
+class SpawnedAgent:
+ agent_id: str
+ slug: str
+ name: str
+ tenant_id: str
+ workspace: str
+ system_prompt: str
+ skill_package_id: str = ""
+ skill_count: int = 0
+ persisted_to_db: bool = False
+ notes: list[str] = field(default_factory=list)
+
+ def to_dict(self) -> dict[str, Any]:
+ return asdict(self)
+
+
+def _agent_id_for(slug: str, existing: str = "") -> str:
+ if existing:
+ return existing
+ cleaned = "".join(ch if ch.isalnum() or ch in "-_" else "-" for ch in slug.lower())
+ candidate = f"org-{cleaned}".strip("-")
+ if len(candidate) < 3:
+ candidate = f"org-{new_short_id(6).lower()}"
+ return candidate[:64]
+
+
+def _system_prompt(workspace: Path, name: str) -> str:
+ soul = workspace / "SOUL.md"
+ if soul.is_file():
+ return soul.read_text(encoding="utf-8")
+ return f"You are {name}, a FreeOS digital colleague. Follow workspace MEMORY.md."
+
+
+def _write_file_registry(home: Path, spawned: SpawnedAgent) -> Path:
+ path = agents_registry_path(home)
+ path.parent.mkdir(parents=True, exist_ok=True)
+ data: dict[str, Any] = {"agents": {}}
+ if path.is_file():
+ try:
+ raw = json.loads(path.read_text(encoding="utf-8"))
+ if isinstance(raw, dict) and isinstance(raw.get("agents"), dict):
+ data = raw
+ except json.JSONDecodeError:
+ pass
+ data["agents"][spawned.agent_id] = spawned.to_dict()
+ path.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
+ per_agent = path.parent / f"{spawned.agent_id}.json"
+ per_agent.write_text(json.dumps(spawned.to_dict(), indent=2) + "\n", encoding="utf-8")
+ return path
+
+
+def _copy_skills_to_packages(home: Path, agent_id: str, workspace: Path) -> tuple[str, int]:
+ skills_root = workspace / "skills"
+ if not skills_root.is_dir():
+ return "", 0
+ package_id = (agent_id if agent_id.startswith("org-") else f"org-{agent_id}")[:64]
+ dest = home / "skill-packages" / package_id / "skills"
+ dest.mkdir(parents=True, exist_ok=True)
+ count = 0
+ for skill_dir in sorted(path for path in skills_root.iterdir() if path.is_dir()):
+ if not (skill_dir / "SKILL.md").is_file():
+ continue
+ target = dest / skill_dir.name
+ target.mkdir(parents=True, exist_ok=True)
+ (target / "SKILL.md").write_bytes((skill_dir / "SKILL.md").read_bytes())
+ count += 1
+ # Also expose catalog org-* skills so the colleague can call the BFF.
+ org_skills = home / "org-skills"
+ if org_skills.is_dir():
+ for skill_dir in sorted(path for path in org_skills.iterdir() if path.is_dir()):
+ if not (skill_dir / "SKILL.md").is_file():
+ continue
+ target = dest / skill_dir.name
+ target.mkdir(parents=True, exist_ok=True)
+ (target / "SKILL.md").write_bytes((skill_dir / "SKILL.md").read_bytes())
+ count += 1
+ return package_id if count else "", count
+
+
+def _persist_agent_row(home: Path, spawned: SpawnedAgent) -> bool:
+ """Insert/update the host agents table so the colleague appears in FreeOS chat."""
+ try:
+ from octop.cli.support.db import open_cli_services
+ from octop.infra.agents.profile import dump_skill_package_ids
+ except Exception as exc: # pragma: no cover - import graph
+ logger.debug("agent DB helpers unavailable: %s", exc)
+ return False
+ config = {
+ "workspace_dir": spawned.workspace,
+ "org": {
+ "colleague_slug": spawned.slug,
+ "tenant_id": spawned.tenant_id,
+ "kind": "freeos.digital-colleague",
+ },
+ "skills": {"extra_dirs": [str(Path(spawned.workspace) / "skills")]},
+ }
+ try:
+ with open_cli_services(home) as services:
+ repo = services.agent_repo
+ existing = repo.get(spawned.agent_id)
+ skill_ids = (
+ dump_skill_package_ids([spawned.skill_package_id])
+ if spawned.skill_package_id
+ else None
+ )
+ if existing is None:
+ repo.create(
+ agent_id=spawned.agent_id,
+ user_id=None,
+ name=spawned.name,
+ description=f"FreeOS digital colleague `{spawned.slug}`",
+ system_prompt=spawned.system_prompt,
+ config_json=json.dumps(config, ensure_ascii=False),
+ template_name="org-colleague",
+ skill_package_ids=skill_ids,
+ welcome_message=f"{spawned.name} is on duty. High-risk tools stay gated.",
+ )
+ try:
+ repo.set_shared(spawned.agent_id, True)
+ except Exception:
+ logger.debug("set_shared skipped", exc_info=True)
+ else:
+ repo.update_config(
+ spawned.agent_id,
+ config_json=json.dumps(config, ensure_ascii=False),
+ system_prompt=spawned.system_prompt,
+ skill_package_ids=skill_ids,
+ )
+ if spawned.skill_package_id and hasattr(services, "skill_package_repo"):
+ pkg_repo = services.skill_package_repo
+ if pkg_repo.get(spawned.skill_package_id) is None:
+ try:
+ pkg_repo.create(
+ id=spawned.skill_package_id,
+ name=f"{spawned.name} skills",
+ description=f"Skills compiled for colleague {spawned.slug}",
+ created_by="org-loop",
+ )
+ except Exception:
+ logger.debug("skill package create skipped", exc_info=True)
+ except Exception:
+ logger.info(
+ "could not persist colleague into octop.db; file registry remains", exc_info=True
+ )
+ return False
+ return True
+
+
+def spawn_colleague_agent(
+ home: Path,
+ record: ColleagueRecord,
+ *,
+ agent_id: str = "",
+) -> SpawnedAgent:
+ """Create a FreeOS agent from a compiled colleague workspace.
+
+ Always writes the org-agents registry + routing table. When ``octop.db``
+ can be opened, also inserts an ``agents`` row so the host chat list sees
+ the employee.
+ """
+ workspace = Path(record.workspace)
+ package_id, skill_count = _copy_skills_to_packages(home, _agent_id_for(record.slug), workspace)
+ spawned = SpawnedAgent(
+ agent_id=_agent_id_for(record.slug, agent_id or record.agent_id),
+ slug=record.slug,
+ name=record.name or record.slug,
+ tenant_id=record.tenant_id,
+ workspace=str(workspace),
+ system_prompt=_system_prompt(workspace, record.name or record.slug),
+ skill_package_id=package_id,
+ skill_count=skill_count,
+ notes=["Registered for FreeOS chat. Governance middleware gates high-risk tools."],
+ )
+ persisted = _persist_agent_row(home, spawned)
+ spawned.persisted_to_db = persisted
+ if persisted:
+ spawned.notes.append("Inserted into FreeOS agents table (octop.db).")
+ else:
+ spawned.notes.append("File registry written; host DB insert deferred until init.")
+
+ _write_file_registry(home, spawned)
+ (workspace / "agent.json").write_text(
+ json.dumps(spawned.to_dict(), indent=2) + "\n", encoding="utf-8"
+ )
+ record.agent_id = spawned.agent_id
+ LifecycleStore(home, record.tenant_id).upsert(record)
+ ColleagueRouter(home, record.tenant_id).upsert(
+ RouteEntry(
+ slug=record.slug,
+ agent_id=spawned.agent_id,
+ name=record.name,
+ tenant_id=record.tenant_id,
+ workspace=str(workspace),
+ lifecycle=record.lifecycle,
+ )
+ )
+ sync_colleague_memory(record, home)
+ return spawned
+
+
+def list_spawned_agents(home: Path) -> list[SpawnedAgent]:
+ path = agents_registry_path(home)
+ if not path.is_file():
+ return []
+ try:
+ raw = json.loads(path.read_text(encoding="utf-8"))
+ except json.JSONDecodeError:
+ return []
+ agents = raw.get("agents") if isinstance(raw, dict) else {}
+ if not isinstance(agents, dict):
+ return []
+ rows: list[SpawnedAgent] = []
+ for item in agents.values():
+ if not isinstance(item, dict):
+ continue
+ rows.append(
+ SpawnedAgent(
+ agent_id=str(item.get("agent_id") or ""),
+ slug=str(item.get("slug") or ""),
+ name=str(item.get("name") or ""),
+ tenant_id=str(item.get("tenant_id") or ""),
+ workspace=str(item.get("workspace") or ""),
+ system_prompt=str(item.get("system_prompt") or ""),
+ skill_package_id=str(item.get("skill_package_id") or ""),
+ skill_count=int(item.get("skill_count") or 0),
+ persisted_to_db=bool(item.get("persisted_to_db")),
+ notes=list(item.get("notes") or []),
+ )
+ )
+ return rows
diff --git a/src/octop/modules/org_os/service.py b/src/octop/modules/org_os/service.py
index 1ee20e51..369c8549 100644
--- a/src/octop/modules/org_os/service.py
+++ b/src/octop/modules/org_os/service.py
@@ -201,13 +201,13 @@ def status(self) -> OrgModuleStatus:
enabled = self.is_enabled()
notes = [
"Host identity stays in FreeOS (JWT users under the platform home).",
- "openXYOS is the control plane; FreeOS/Octop is the data plane. "
- "Do not replace the Octop agent runtime with openXYOS chat.",
+ "openXYOS is the control plane; FreeOS is the data plane. "
+ "Do not replace the FreeOS agent runtime with openXYOS chat.",
"Proxy forwards X-FreeOS-User* and X-FreeOS-Tenant-Id; sign in to "
"the sidecar separately for mutating org routes.",
"Plugin id org-os is seeded disabled; enable it here or via "
"Admin → Plugins / `freeos org enable`.",
- "Phase A: `freeos org governance enable` and `freeos org skills generate`.",
+ "Run the finished loop with `freeos org loop run`.",
]
if not sidecar.reachable and enabled:
notes.append(
diff --git a/tests/e2e/test_org_growth_loop.py b/tests/e2e/test_org_growth_loop.py
new file mode 100644
index 00000000..b4021681
--- /dev/null
+++ b/tests/e2e/test_org_growth_loop.py
@@ -0,0 +1,52 @@
+"""Scripted proof: blueprint → compile → recruit→active → publish → apply → import."""
+
+from __future__ import annotations
+
+import json
+from pathlib import Path
+
+import pytest
+from click.testing import CliRunner
+from tests.support.openxyos_harness import ControlPlaneState, start_control_plane
+
+from octop.cli.main import cli
+from octop.modules.org_os.catalog import catalog_keys
+from octop.modules.org_os.loop.run import run_growth_loop
+
+
+def test_e2e_loop_command_and_artifacts(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
+
+ state = ControlPlaneState()
+ url, server = start_control_plane(state)
+ monkeypatch.setenv("FREEOS_HOME", str(tmp_path))
+ monkeypatch.setenv("OPENXYOS_BASE_URL", url)
+ monkeypatch.setenv("FREEOS_ORG_TENANT_ID", "e2e")
+ try:
+ runner = CliRunner()
+ result = runner.invoke(cli, ["org", "loop", "run", "--tenant-id", "e2e", "--base-url", url])
+ assert result.exit_code == 0, result.output
+ proof = json.loads(result.output)
+ assert proof["ok"] is True
+ assert proof["governance_blocked"] is True
+ assert "policy-analyst" in proof["employees"]
+ assert proof["lifecycle"]["active"] == "active"
+ assert proof["remote_applied"] is True
+ assert (tmp_path / "tenants" / "e2e" / "employees" / "policy-analyst" / "SOUL.md").is_file()
+ assert (tmp_path / "org-skills" / "org-employees" / "SKILL.md").is_file()
+ assert (tmp_path / "org-agents" / "registry.json").is_file()
+ assert (
+ tmp_path / "asset-packs" / "latest" / "openxyos" / "org-employees.publish.json"
+ ).is_file()
+ assert (tmp_path / "openxyos-mirror" / "e2e" / "apply-receipt.json").is_file()
+ assert len(proof["skills"]) >= len(catalog_keys())
+ assert state.employees
+ assert state.module_settings
+ finally:
+ server.shutdown()
+ server.server_close()
+
+
+def test_e2e_library_path_matches_cli(tmp_path: Path) -> None:
+ proof = run_growth_loop(tmp_path, tenant_id="lib")
+ assert proof.ok
+ assert (tmp_path / "asset-packs" / "loop-proof.json").is_file()
diff --git a/tests/fixtures/org-loop/agent-blueprint.v1.json b/tests/fixtures/org-loop/agent-blueprint.v1.json
new file mode 100644
index 00000000..6d25cd48
--- /dev/null
+++ b/tests/fixtures/org-loop/agent-blueprint.v1.json
@@ -0,0 +1,28 @@
+{
+ "schema": "openxyos.agent-blueprint.v1",
+ "name": "Policy Analyst",
+ "industry": "public-sector",
+ "positioning": "Reads org policy and drafts briefings; never sends mail or changes production.",
+ "capabilities": ["policy-research", "briefing"],
+ "experience": "Summarize policy diffs. Cite sources. Escalate high-risk asks.",
+ "references": [
+ {
+ "name": "staff-handbook.md",
+ "type": ".md",
+ "scan": "clean",
+ "excerpt": "Staff must not share tenant data across workspaces."
+ }
+ ],
+ "governance": {
+ "high_risk_requires_human_review": true,
+ "external_write_disabled": true
+ },
+ "lifecycle": "talent_market",
+ "job_duties": [
+ {
+ "name": "morning-brief",
+ "schedule": "0 9 * * 1-5",
+ "prompt": "Draft the weekday policy briefing from knowledge/."
+ }
+ ]
+}
diff --git a/tests/fixtures/org-loop/ops-coordinator.blueprint.json b/tests/fixtures/org-loop/ops-coordinator.blueprint.json
new file mode 100644
index 00000000..23574eb7
--- /dev/null
+++ b/tests/fixtures/org-loop/ops-coordinator.blueprint.json
@@ -0,0 +1,28 @@
+{
+ "schema": "openxyos.agent-blueprint.v1",
+ "name": "Ops Coordinator",
+ "industry": "professional-services",
+ "positioning": "Coordinates org-module work and drafts runbooks; never deploys or pays.",
+ "capabilities": ["runbook-draft", "status-digest"],
+ "experience": "Keep a live ops digest. Escalate high-risk production or payment asks.",
+ "references": [
+ {
+ "name": "ops-guardrails.md",
+ "type": ".md",
+ "scan": "clean",
+ "excerpt": "High-risk tools require a durable FreeOS governance approval."
+ }
+ ],
+ "governance": {
+ "high_risk_requires_human_review": true,
+ "external_write_disabled": true
+ },
+ "lifecycle": "talent_market",
+ "job_duties": [
+ {
+ "name": "ops-digest",
+ "schedule": "0 18 * * 1-5",
+ "prompt": "Summarize today's org-module activity from knowledge/."
+ }
+ ]
+}
diff --git a/tests/fixtures/org-loop/policies.json b/tests/fixtures/org-loop/policies.json
new file mode 100644
index 00000000..b4ab68dc
--- /dev/null
+++ b/tests/fixtures/org-loop/policies.json
@@ -0,0 +1,26 @@
+{
+ "source": "freeos.demo-fixture",
+ "rules": [
+ {
+ "category": "delete",
+ "action": "delete_employee",
+ "allow": false,
+ "reason": "Demo policy: deleting employees is denied until a human approves."
+ },
+ {
+ "category": "pay",
+ "allow": false,
+ "reason": "Demo policy: payments stay default-deny."
+ },
+ {
+ "category": "outbound",
+ "allow": false,
+ "reason": "Demo policy: outbound send is gated."
+ },
+ {
+ "category": "prod",
+ "allow": false,
+ "reason": "Demo policy: production changes stay gated."
+ }
+ ]
+}
diff --git a/tests/support/openxyos_harness.py b/tests/support/openxyos_harness.py
new file mode 100644
index 00000000..704b3753
--- /dev/null
+++ b/tests/support/openxyos_harness.py
@@ -0,0 +1,118 @@
+"""In-process openXYOS-shaped HTTP harness for apply+import roundtrip tests."""
+
+from __future__ import annotations
+
+import json
+from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
+from threading import Thread
+from typing import Any
+from urllib.parse import urlparse
+
+
+class ControlPlaneState:
+ def __init__(self) -> None:
+ self.employees: list[dict[str, Any]] = []
+ self.talent: list[dict[str, Any]] = []
+ self.plugins: list[dict[str, Any]] = []
+ self.module_settings: dict[str, Any] = {}
+ self.permissions: list[dict[str, Any]] = [
+ {"category": "delete", "allow": False, "reason": "harness deny"}
+ ]
+ self.posts: list[str] = []
+
+
+def start_control_plane(state: ControlPlaneState | None = None) -> tuple[str, ThreadingHTTPServer]:
+ store = state or ControlPlaneState()
+
+ class Handler(BaseHTTPRequestHandler):
+ def log_message(self, format: str, *args: object) -> None: # noqa: A003
+ return
+
+ def _json(self, status: int, payload: Any) -> None:
+ body = json.dumps(payload).encode("utf-8")
+ self.send_response(status)
+ self.send_header("Content-Type", "application/json")
+ self.send_header("Content-Length", str(len(body)))
+ self.end_headers()
+ self.wfile.write(body)
+
+ def _read_json(self) -> Any:
+ length = int(self.headers.get("Content-Length") or 0)
+ if length <= 0:
+ return {}
+ raw = self.rfile.read(length)
+ try:
+ return json.loads(raw.decode("utf-8"))
+ except json.JSONDecodeError:
+ return {}
+
+ def do_GET(self) -> None: # noqa: N802
+ path = urlparse(self.path).path
+ if path == "/api/health/livez":
+ self._json(200, {"ok": True})
+ return
+ if path == "/api/employees":
+ self._json(200, {"success": True, "data": store.employees})
+ return
+ if path == "/api/talent":
+ self._json(200, {"success": True, "data": store.talent})
+ return
+ if path == "/api/plugins":
+ self._json(200, {"success": True, "data": store.plugins})
+ return
+ if path == "/api/module-settings":
+ self._json(200, {"success": True, "data": store.module_settings})
+ return
+ if path == "/api/governance/permissions":
+ self._json(200, {"success": True, "data": store.permissions})
+ return
+ self._json(404, {"success": False, "error": "not found"})
+
+ def do_POST(self) -> None: # noqa: N802
+ path = urlparse(self.path).path
+ payload = self._read_json()
+ store.posts.append(path)
+ if path == "/api/employees":
+ if isinstance(payload, dict) and isinstance(payload.get("employees"), list):
+ store.employees.extend(
+ item for item in payload["employees"] if isinstance(item, dict)
+ )
+ elif isinstance(payload, dict) and payload.get("name"):
+ store.employees.append(payload)
+ self._json(200, {"success": True, "data": {"id": len(store.employees)}})
+ return
+ if path == "/api/talent":
+ if isinstance(payload, dict) and isinstance(payload.get("talent"), list):
+ store.talent.extend(
+ item for item in payload["talent"] if isinstance(item, dict)
+ )
+ self._json(200, {"success": True, "data": {"ok": True}})
+ return
+ if path == "/api/plugins":
+ if isinstance(payload, dict) and isinstance(payload.get("plugins"), list):
+ store.plugins.extend(
+ item for item in payload["plugins"] if isinstance(item, dict)
+ )
+ self._json(200, {"success": True, "data": {"ok": True}})
+ return
+ if path == "/api/governance/validate":
+ self._json(200, {"success": True, "data": {"allowed": False, "reason": "harness"}})
+ return
+ self._json(404, {"success": False, "error": "not found"})
+
+ def do_PUT(self) -> None: # noqa: N802
+ path = urlparse(self.path).path
+ payload = self._read_json()
+ store.posts.append(path)
+ if path == "/api/module-settings":
+ if isinstance(payload, dict):
+ store.module_settings = payload
+ self._json(200, {"success": True, "data": store.module_settings})
+ return
+ self._json(404, {"success": False, "error": "not found"})
+
+ server = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
+ thread = Thread(target=server.serve_forever, daemon=True)
+ thread.start()
+ host, port = server.server_address[:2]
+ return f"http://{host}:{port}", server
diff --git a/tests/unit/test_colleague_spawn.py b/tests/unit/test_colleague_spawn.py
new file mode 100644
index 00000000..0707637e
--- /dev/null
+++ b/tests/unit/test_colleague_spawn.py
@@ -0,0 +1,41 @@
+"""Inbound compile/import registers a real FreeOS agent, not just files."""
+
+from __future__ import annotations
+
+from pathlib import Path
+
+from octop.modules.org_os.compiler.compile import compile_blueprint
+from octop.modules.org_os.lifecycle.store import LifecycleStore
+from octop.modules.org_os.lifecycle.transitions import register_compiled
+from octop.modules.org_os.runtime.routing import resolve_routed_agent
+from octop.modules.org_os.runtime.spawn import spawn_colleague_agent
+
+_FIXTURE = Path(__file__).resolve().parents[1] / "fixtures" / "org-loop" / "agent-blueprint.v1.json"
+
+
+def test_spawn_writes_registry_and_agent_row(tmp_path: Path) -> None:
+ compiled = compile_blueprint(_FIXTURE, home=tmp_path, tenant_id="acme")
+ store = LifecycleStore(tmp_path, "acme")
+ record = register_compiled(
+ store,
+ slug=compiled.slug,
+ name="Policy Analyst",
+ workspace=compiled.workspace,
+ lifecycle="draft",
+ )
+ spawned = spawn_colleague_agent(tmp_path, record)
+ assert spawned.agent_id == "org-policy-analyst"
+ assert spawned.persisted_to_db is True
+ assert (tmp_path / "org-agents" / "registry.json").is_file()
+ assert (compiled.workspace / "agent.json").is_file()
+ assert (tmp_path / "skill-packages" / spawned.skill_package_id / "skills").is_dir()
+ assert resolve_routed_agent(tmp_path, "policy-analyst") == spawned.agent_id
+
+ from octop.cli.support.db import open_cli_services
+
+ with open_cli_services(tmp_path) as services:
+ row = services.agent_repo.get(spawned.agent_id)
+ assert row is not None
+ assert row.name == "Policy Analyst"
+ assert row.system_prompt and "Policy Analyst" in row.system_prompt
+ assert compiled.workspace.as_posix() in (row.config_json or "")
diff --git a/tests/unit/test_host_governance.py b/tests/unit/test_host_governance.py
new file mode 100644
index 00000000..d49167b1
--- /dev/null
+++ b/tests/unit/test_host_governance.py
@@ -0,0 +1,42 @@
+"""Host tool path must actually block high-risk calls."""
+
+from __future__ import annotations
+
+from pathlib import Path
+
+import pytest
+
+from octop.infra.agents.middleware.org_governance import (
+ OrgGovernanceMiddleware,
+ host_gate_tool,
+)
+from octop.modules.org_os.governance.interceptor import GovernanceBlockedError
+from octop.modules.org_os.service import OrgModuleService
+
+
+def test_host_gate_blocks_high_risk_delete(tmp_path: Path) -> None:
+ service = OrgModuleService(config_path=tmp_path / "config.json", home=tmp_path)
+ service.set_governance_enabled(True, tenant_id="1")
+ with pytest.raises(GovernanceBlockedError) as exc:
+ host_gate_tool(
+ home=tmp_path,
+ tool_name="delete_employee",
+ args={"id": "9"},
+ tenant_id="1",
+ enabled=True,
+ )
+ assert exc.value.decision.execute is False
+ assert exc.value.decision.blocked is True
+
+
+def test_host_gate_allows_low_risk(tmp_path: Path) -> None:
+ host_gate_tool(home=tmp_path, tool_name="list_employees", enabled=True)
+
+
+def test_middleware_from_home_follows_org_enablement(tmp_path: Path) -> None:
+ service = OrgModuleService(config_path=tmp_path / "config.json", home=tmp_path)
+ service.set_enabled(True)
+ service.set_governance_enabled(True)
+ mw = OrgGovernanceMiddleware.from_home(tmp_path, config_path=tmp_path / "config.json")
+ assert mw.enabled is True
+ assert mw.home == tmp_path
diff --git a/tests/unit/test_openxyos_apply.py b/tests/unit/test_openxyos_apply.py
new file mode 100644
index 00000000..4323c7bc
--- /dev/null
+++ b/tests/unit/test_openxyos_apply.py
@@ -0,0 +1,49 @@
+"""Asset-pack apply writes a durable mirror and talks HTTP when the plane is up."""
+
+from __future__ import annotations
+
+import json
+from pathlib import Path
+
+from octop.modules.org_os.apply.apply import apply_asset_pack, import_applied_surfaces
+from octop.modules.org_os.assets.importer import import_openxyos_assets
+from octop.modules.org_os.assets.pack import publish_asset_pack
+from tests.support.openxyos_harness import ControlPlaneState, start_control_plane
+
+_FIXTURE = Path(__file__).resolve().parents[1] / "fixtures" / "org-loop" / "agent-blueprint.v1.json"
+
+
+def test_apply_without_url_is_mirror_only(tmp_path: Path, monkeypatch) -> None:
+ monkeypatch.delenv("OPENXYOS_BASE_URL", raising=False)
+ monkeypatch.delenv("FREEOS_ORG_SIDECAR_URL", raising=False)
+ import_openxyos_assets(
+ tmp_path, tenant_id="acme", catalog=True, blueprint_path=_FIXTURE, spawn_agents=False
+ )
+ pack = publish_asset_pack(tmp_path, tenant_id="acme", out_dir=tmp_path / "pack")
+ result = apply_asset_pack(pack.directory, home=tmp_path, tenant_id="acme", base_url="")
+ assert result.mirrored is True
+ assert result.remote_applied is False
+ employees = json.loads((result.mirror_dir / "employees.json").read_text(encoding="utf-8"))
+ assert employees["employees"][0]["slug"] == "policy-analyst"
+ roundtrip = import_applied_surfaces(tmp_path, tenant_id="acme")
+ assert "employees" in roundtrip["local"]
+
+
+def test_apply_import_roundtrip_against_harness(tmp_path: Path) -> None:
+ import_openxyos_assets(
+ tmp_path, tenant_id="acme", catalog=True, blueprint_path=_FIXTURE, spawn_agents=False
+ )
+ pack = publish_asset_pack(tmp_path, tenant_id="acme", out_dir=tmp_path / "pack")
+ state = ControlPlaneState()
+ url, server = start_control_plane(state)
+ try:
+ result = apply_asset_pack(pack.directory, home=tmp_path, tenant_id="acme", base_url=url)
+ assert result.remote_applied is True
+ assert state.employees
+ assert state.plugins
+ imported = import_applied_surfaces(tmp_path, tenant_id="acme", base_url=url)
+ assert imported["remote"]["employees"]
+ assert imported["local"]["module_settings"]["enabled_by_default"] is False
+ finally:
+ server.shutdown()
+ server.server_close()
diff --git a/tests/unit/test_org_loop.py b/tests/unit/test_org_loop.py
new file mode 100644
index 00000000..35629250
--- /dev/null
+++ b/tests/unit/test_org_loop.py
@@ -0,0 +1,57 @@
+"""Finished self-growth loop: fixtures prove produce → apply → import → agents."""
+
+from __future__ import annotations
+
+import json
+from pathlib import Path
+
+from octop.modules.org_os.catalog import catalog_keys
+from octop.modules.org_os.loop.run import run_growth_loop
+from octop.modules.org_os.runtime.routing import ColleagueRouter, resolve_routed_agent
+from octop.modules.org_os.runtime.spawn import list_spawned_agents
+from tests.support.openxyos_harness import ControlPlaneState, start_control_plane
+
+
+def test_growth_loop_with_fixtures(tmp_path: Path) -> None:
+ proof = run_growth_loop(tmp_path, tenant_id="loop")
+ assert proof.ok is True
+ assert proof.governance_blocked is True
+ assert "policy-analyst" in proof.employees
+ assert "ops-coordinator" in proof.employees
+ assert proof.lifecycle.get("active") == "active"
+ assert set(proof.skills) >= {f"org-{key}" for key in catalog_keys()}
+ assert proof.agents
+ workspace = tmp_path / "tenants" / "loop" / "employees" / "policy-analyst"
+ assert (workspace / "SOUL.md").is_file()
+ assert (workspace / "agent.json").is_file()
+ assert (tmp_path / "org-skills" / "org-governance" / "SKILL.md").is_file()
+ assert (tmp_path / "asset-packs" / "latest" / "manifest.json").is_file()
+ assert (tmp_path / "openxyos-mirror" / "loop" / "employees.json").is_file()
+ assert (tmp_path / "openxyos-mirror" / "loop" / "module-settings.json").is_file()
+ assert (
+ tmp_path / "tenants" / "loop" / "org-knowledge" / "live" / "policy-analyst" / "MEMORY.md"
+ ).is_file()
+ routed = resolve_routed_agent(tmp_path, "Policy Analyst", tenant_id="loop")
+ assert routed.startswith("org-")
+ assert ColleagueRouter(tmp_path, "loop").get("policy-analyst") is not None
+ spawned = list_spawned_agents(tmp_path)
+ assert {item.slug for item in spawned} >= {"policy-analyst", "ops-coordinator"}
+ saved = json.loads((tmp_path / "asset-packs" / "loop-proof.json").read_text(encoding="utf-8"))
+ assert saved["ok"] is True
+
+
+def test_growth_loop_applies_to_live_control_plane(tmp_path: Path) -> None:
+ state = ControlPlaneState()
+ url, server = start_control_plane(state)
+ try:
+ proof = run_growth_loop(tmp_path, tenant_id="live", sidecar_url=url)
+ assert proof.ok is True
+ assert proof.remote_applied is True
+ assert state.employees
+ assert state.module_settings.get("updates")
+ assert "/api/employees" in state.posts
+ assert "/api/module-settings" in state.posts
+ assert "employees" in proof.imported_roundtrip["remote_surfaces"]
+ finally:
+ server.shutdown()
+ server.server_close()