diff --git a/CHANGELOG.md b/CHANGELOG.md
index 886a8f68..bbc402e1 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -10,6 +10,11 @@
- Phase A: `xyos-governance-mcp` default-denies high-risk tools and durable-pauses for human approval (`freeos org governance`)
- Phase A: module ↔ skill bridge generates catalog skills with tenant headers and a tenant-toggle publish draft (`freeos org skills`)
+- Phase B: `xyos2freeos` compiles `openxyos.agent-blueprint.v1` into tenant-scoped employee workspaces
+- Phase B: digital-colleague lifecycle (`draft`→`offboard`) with credential revoke + memory archive
+- Phase B: bidirectional asset factory (`freeos org assets publish|import`)
+- Phase B: imported openXYOS policy matrices load into `xyos-governance-mcp` (explicit allow still requires human approval)
+- Phase B: outbound `freeos.asset-pack.v1` writes `org-employees.publish.json` / `org-talent.publish.json` and redacts packed `.env` secrets
## [1.0.0] - 2026-09-14
diff --git a/NOTICE b/NOTICE
index dc33e2c1..26d443b9 100644
--- a/NOTICE
+++ b/NOTICE
@@ -45,7 +45,8 @@ License split
------------------------------------------------------------------------
- MIT applies to the FreeOS/Octop host platform and FreeOS-authored
bridge code under ``src/octop/modules/org_os/`` (including
- ``governance/`` / ``xyos-governance-mcp`` and ``skill_bridge/``),
+ ``governance/`` / ``xyos-governance-mcp``, ``skill_bridge/``,
+ ``compiler/`` / ``xyos2freeos``, ``lifecycle/``, and ``assets/``),
dashboard Organization pages, and FreeOS branding assets.
- Apache-2.0 applies to ``modules/openxyos/`` and unmodified copies of
that tree.
diff --git a/README.md b/README.md
index 0157d204..98983e05 100644
--- a/README.md
+++ b/README.md
@@ -64,7 +64,7 @@ The Vite app in `dashboard/` is what you edit; packaged builds land in `src/octo
### Tests
```bash
-uv run pytest tests/unit/test_org_module.py tests/unit/test_paths.py tests/unit/test_bundled_plugins_layout.py tests/unit/test_governance_mcp.py tests/unit/test_skill_bridge.py
+uv run pytest tests/unit/test_org_module.py tests/unit/test_paths.py tests/unit/test_bundled_plugins_layout.py tests/unit/test_governance_mcp.py tests/unit/test_skill_bridge.py tests/unit/test_blueprint_compiler.py tests/unit/test_lifecycle.py tests/unit/test_asset_loop.py
```
Full Octop-derived suite: `uv run pytest` / `make test-fast` (needs the usual extra services for marked tests).
@@ -116,7 +116,26 @@ 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 (blueprint compiler, digital-colleague lifecycle) and Phase C (chat routing, reflections, org-as-code) are specified in [docs/architecture-integration.md](docs/architecture-integration.md) only.
+### 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 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
+```
+
+- **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.
## Host platform features (from Octop)
diff --git a/README_CN.md b/README_CN.md
index 3a07514b..f5a98016 100644
--- a/README_CN.md
+++ b/README_CN.md
@@ -6,7 +6,7 @@
FreeOS — 自托管多用户、多 Agent 助手,可选组织 OS 模块。
-运行、组织模块、治理 MCP 与模块技能桥见 README.md;七条集成方向与 Phase A/B/C 见 docs/architecture-integration.md。
+运行、组织模块、治理 MCP、蓝图编译与资产环见 README.md;七条集成方向见 docs/architecture-integration.md;非开发者闭环见 docs/asset-loop.md。
diff --git a/docs/architecture-integration.md b/docs/architecture-integration.md
index e3b15c5b..dc16ee69 100644
--- a/docs/architecture-integration.md
+++ b/docs/architecture-integration.md
@@ -9,6 +9,22 @@ They are complementary, not competitors. FreeOS does **not** replace Octop’s
agent runtime with openXYOS chat. openXYOS is the organization control plane;
FreeOS/Octop remains the execution runtime.
+## North star — internally self-growing multi-agent management
+
+FreeOS grows its own AI workforce and capability catalog:
+
+1. The **data plane** (FreeOS/Octop) **produces** AI employees, skills,
+ plugins, MCPs, and related assets at scale.
+2. Those assets **assemble into openXYOS** so org modules get stronger
+ (tenant-toggleable drafts — never auto-on).
+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).
+
FreeOS is an independent project. It is not affiliated with Tencent Cloud,
Octop, XYAIStudio, or XYOS trademarks beyond accurate license attribution.
See [NOTICE](../NOTICE) and `modules/openxyos/TRADEMARKS.md`.
@@ -147,30 +163,56 @@ 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 — next (scaffold later; specified here)
+### Phase B — implemented (Directions 1 + 4 + asset loop)
-#### Direction 1 — `xyos2octop` / blueprint compiler
+#### Direction 1 — `xyos2freeos` / blueprint compiler
Compile `openxyos.agent-blueprint.v1` (see
`modules/openxyos/backend/routes/agent-studio.ts`) into a runnable FreeOS
agent workspace:
-- `SOUL.md`, `skills/`, `knowledge/`, MEMORY seed, `.env`
-- cron entries from job duties
-- reverse telemetry into openXYOS HR / capability profile
+- `SOUL.md`, `skills/`, `knowledge/`, MEMORY seed, tenant-scoped `.env`
+- `cron.json` from `job_duties` (enabled only in `active`)
+- reverse telemetry: `export-profile` → openXYOS HR payload (`OpenXyosHrClient`)
+
+| Piece | Path |
+|---|---|
+| Compiler | `src/octop/modules/org_os/compiler/` |
+| CLI | `xyos2freeos` · `freeos org compile-blueprint` · `freeos org employee spawn` |
+| How-to | `src/octop/modules/org_os/compiler/README.md` |
Do **not** start a second chat runtime. The compiler emits files the
existing Octop agent already knows how to run.
#### Direction 4 — Digital-colleague lifecycle
-Wire the openXYOS talent market to FreeOS agent instances:
+`draft → market → recruit → shadow → active → offboard`
+
+| FreeOS | openXYOS talent / employee |
+|---|---|
+| draft | not listed |
+| market | `talent_pool.status=available` |
+| recruit | `recruited` + `employment_category=reserve` |
+| shadow | probation, read-only + governance |
+| active | staff; cron enabled |
+| offboard | archived; `.env` revoked; MEMORY copied to org-knowledge |
+
+Persisted in `{FREEOS_HOME}/tenants//employees/registry.json`, not
+sidecar SQL.js.
+
+| Piece | Path |
+|---|---|
+| Store + transitions | `src/octop/modules/org_os/lifecycle/` |
+| CLI | `freeos org employee list\|transition\|export-profile` |
+
+#### Bidirectional asset factory
-`recruit → onboard → shadow/probation → promote → offboard`
+| Direction | CLI | Behavior |
+|---|---|---|
+| Outbound | `freeos org assets publish` | Pack skills/plugins/MCPs/agents as `freeos.asset-pack.v1` + `openxyos/` drafts |
+| Inbound | `freeos org assets import --catalog\|--blueprint\|--policies\|--from-sidecar` | Drive skill generator, compiler, and `imported-policies.json` (engine loads it; allow still needs human approval) |
-Each stage is a control-plane record plus a data-plane instance
-(workspace + sandbox). Offboard must revoke tools and destroy or archive
-the sandbox — not only flip a flag.
+High-risk runtime actions still pass through Phase A governance.
### Phase C — architecture only (do not implement in this pass)
@@ -233,6 +275,9 @@ The Phase A work sits on the existing FreeOS bootstrap:
| Enable / disable org module | BFF + plugin `org-os` | `PATCH /api/org-module` · `freeos org enable` · `/organization` |
| Governance MCP (Phase A) | Bridge | `xyos-governance-mcp` · `/api/org-module/governance/*` |
| 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/` |
| 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 |
@@ -261,6 +306,8 @@ Tenant hint: `FREEOS_ORG_TENANT_ID` or `modules.org_os.tenant_id`.
| Host users, agents, chats, plugins | `FREEOS_HOME` / legacy `OCTOP_HOME` |
| Governance pauses + audit | `{home}/governance/pauses/`, `{home}/governance/audit.jsonl` |
| Generated org skills | `{home}/org-skills/` (or `--out`) |
+| Digital colleagues | `{home}/tenants//employees//` |
+| Asset packs | `{home}/asset-packs/` |
| Org tenants, employees, matrix | sidecar DB — **not** the production SoT |
| Brand assets | `dashboard/public/`, `docs/assets/` |
@@ -295,8 +342,12 @@ copy the openXYOS working directory themselves.
| Enablement + health | `src/octop/modules/org_os/service.py` |
| Catalog | `src/octop/modules/org_os/catalog.py` |
| Proxy + tenant headers | `src/octop/modules/org_os/proxy.py` |
+| Self-growth loop (operators) | `docs/asset-loop.md` |
| Governance MCP / interceptor | `src/octop/modules/org_os/governance/` |
| Module ↔ skill bridge | `src/octop/modules/org_os/skill_bridge/` |
+| Blueprint compiler | `src/octop/modules/org_os/compiler/` |
+| Colleague lifecycle | `src/octop/modules/org_os/lifecycle/` |
+| Asset factory | `src/octop/modules/org_os/assets/` |
| 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
new file mode 100644
index 00000000..5881eea7
--- /dev/null
+++ b/docs/asset-loop.md
@@ -0,0 +1,130 @@
+# FreeOS self-growth loop (operator guide)
+
+FreeOS is meant to **grow its own AI workforce** over time. You do not
+need to write code. The loop has four moves:
+
+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.
+
+openXYOS does **not** replace Octop chat. Octop still runs the agents.
+Governance from Phase A still **blocks** high-risk tools until a human
+approves.
+
+## One-time enable
+
+```bash
+uv sync
+uv run freeos init
+uv run freeos org enable
+bash scripts/run-org-sidecar.sh # optional 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.
+
+## The loop, in commands
+
+### 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 --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
+```
+
+A blueprint is JSON with `"schema": "openxyos.agent-blueprint.v1"`
+(name, positioning, capabilities, optional experience / references /
+`job_duties` for cron).
+
+### Lifecycle — grow a colleague safely
+
+```
+draft → market → recruit → shadow → active → offboard
+```
+
+```bash
+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 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/`.
+
+### Outbound — runtime → control plane drafts
+
+```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 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).
+
+## Where files live
+
+| What | Path |
+|---|---|
+| Tenant tree | `{FREEOS_HOME}/tenants//` |
+| Colleague workspace | `…/employees//` (`SOUL.md`, `MEMORY.md`, `skills/`, `knowledge/`, `.env`, `cron.json`) |
+| Lifecycle registry | `…/employees/registry.json` |
+| Generated module skills | `{FREEOS_HOME}/org-skills/` |
+| Governance pauses / audit | `{FREEOS_HOME}/governance/` |
+| Imported policy matrix | `{FREEOS_HOME}/governance/imported-policies.json` (engine loads this) |
+| Asset pack | `{FREEOS_HOME}/asset-packs/latest/` |
+
+## 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.
diff --git a/pyproject.toml b/pyproject.toml
index 3d74878d..173067d3 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -96,6 +96,7 @@ dev = [
octop = "octop.cli.main:cli"
freeos = "octop.cli.main:cli"
xyos-governance-mcp = "octop.modules.org_os.governance.mcp_server:main"
+xyos2freeos = "octop.modules.org_os.compiler.cli:main"
[tool.hatch.build.targets.wheel]
packages = ["src/octop"]
diff --git a/src/octop/api/routers/org_module.py b/src/octop/api/routers/org_module.py
index cbafce64..fcc5413e 100644
--- a/src/octop/api/routers/org_module.py
+++ b/src/octop/api/routers/org_module.py
@@ -209,3 +209,135 @@ async def skills_publish(
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
return draft.to_dict()
+
+
+class BlueprintCompileBody(BaseModel):
+ blueprint: dict[str, Any] | None = None
+ blueprint_path: str | None = None
+ tenant_id: str = ""
+ out_dir: str | None = None
+
+
+class EmployeeTransitionBody(BaseModel):
+ slug: str
+ state: str
+ tenant_id: str = ""
+ reason: str = ""
+
+
+class AssetImportBody(BaseModel):
+ catalog: bool = False
+ blueprint_path: str | None = None
+ policies_path: str | None = None
+ from_sidecar: bool = False
+ tenant_id: str = ""
+
+
+@router.post("/blueprints/compile", summary="Compile openxyos.agent-blueprint.v1")
+async def compile_blueprint_api(
+ body: BlueprintCompileBody,
+ server: OctopServer = Depends(get_server),
+ _user: Any = Depends(require_permission("plugins")),
+) -> dict[str, Any]:
+ 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
+
+ service = _service(server)
+ source: dict[str, Any] | Path
+ if body.blueprint is not None:
+ source = body.blueprint
+ elif body.blueprint_path:
+ source = Path(body.blueprint_path)
+ else:
+ raise HTTPException(status_code=400, detail="blueprint or blueprint_path required")
+ tid = body.tenant_id or service.tenant_id() or "default"
+ compiled = compile_blueprint(
+ source,
+ home=service.home,
+ tenant_id=tid,
+ sidecar_url=service.sidecar_url(),
+ out_dir=Path(body.out_dir) if body.out_dir else None,
+ )
+ register_compiled(
+ LifecycleStore(service.home, tid),
+ slug=compiled.slug,
+ name=compiled.slug,
+ workspace=compiled.workspace,
+ lifecycle="draft",
+ )
+ return compiled.to_dict()
+
+
+@router.get("/employees", summary="List digital colleagues for the configured tenant")
+async def list_employees(
+ server: OctopServer = Depends(get_server),
+ _user: Any = Depends(current_user),
+) -> dict[str, Any]:
+ from octop.modules.org_os.lifecycle.store import LifecycleStore
+
+ service = _service(server)
+ tid = service.tenant_id() or "default"
+ return {
+ "tenant_id": tid,
+ "colleagues": [item.to_dict() for item in LifecycleStore(service.home, tid).list()],
+ }
+
+
+@router.post("/employees/transition", summary="Advance or offboard a digital colleague")
+async def transition_employee(
+ body: EmployeeTransitionBody,
+ 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.lifecycle.transitions import transition
+
+ service = _service(server)
+ tid = body.tenant_id or service.tenant_id() or "default"
+ try:
+ record = transition(
+ LifecycleStore(service.home, tid), body.slug, body.state, reason=body.reason
+ )
+ except ValueError as exc:
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
+ return record.to_dict()
+
+
+@router.post("/assets/publish", summary="Publish a FreeOS asset pack draft")
+async def publish_assets(
+ server: OctopServer = Depends(get_server),
+ _user: Any = Depends(require_permission("plugins")),
+) -> dict[str, Any]:
+ from octop.modules.org_os.assets.pack import publish_asset_pack
+
+ service = _service(server)
+ return publish_asset_pack(service.home, tenant_id=service.tenant_id()).to_dict()
+
+
+@router.post("/assets/import", summary="Import openXYOS catalog/blueprint/policies")
+async def import_assets(
+ body: AssetImportBody,
+ server: OctopServer = Depends(get_server),
+ _user: Any = Depends(require_permission("plugins")),
+) -> dict[str, Any]:
+ from pathlib import Path
+
+ from octop.modules.org_os.assets.importer import import_openxyos_assets
+
+ service = _service(server)
+ try:
+ imported = import_openxyos_assets(
+ service.home,
+ tenant_id=body.tenant_id or service.tenant_id() or "default",
+ sidecar_url=service.sidecar_url(),
+ catalog=body.catalog,
+ blueprint_path=Path(body.blueprint_path) if body.blueprint_path else None,
+ policies_path=Path(body.policies_path) if body.policies_path else None,
+ from_sidecar=body.from_sidecar,
+ )
+ except ValueError as exc:
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
+ return imported.to_dict()
diff --git a/src/octop/cli/commands/org.py b/src/octop/cli/commands/org.py
index 2fd984bf..61686127 100644
--- a/src/octop/cli/commands/org.py
+++ b/src/octop/cli/commands/org.py
@@ -175,3 +175,170 @@ def skills_publish(skill_dir: Path, out_dir: Path | None) -> None:
skill_dir = skill_dir.parent
draft = publish_skill(skill_dir, out_dir=out_dir)
click.echo(json.dumps(draft.to_dict(), indent=2))
+
+
+@org.command("compile-blueprint")
+@click.argument("blueprint", type=click.Path(path_type=Path, exists=True))
+@click.option("--tenant-id", default="")
+@click.option("--out", "out_dir", type=click.Path(path_type=Path), default=None)
+def compile_blueprint_cmd(blueprint: Path, tenant_id: str, out_dir: Path | None) -> None:
+ """Compile openxyos.agent-blueprint.v1 into a FreeOS employee workspace."""
+ 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
+
+ service = _service()
+ tid = tenant_id or service.tenant_id() or "default"
+ compiled = compile_blueprint(
+ blueprint,
+ home=service.home,
+ tenant_id=tid,
+ sidecar_url=service.sidecar_url(),
+ out_dir=out_dir,
+ )
+ store = LifecycleStore(service.home, tid)
+ register_compiled(
+ store,
+ slug=compiled.slug,
+ name=compiled.slug,
+ workspace=compiled.workspace,
+ lifecycle="draft",
+ )
+ click.echo(json.dumps(compiled.to_dict(), indent=2))
+
+
+@org.group("employee")
+def org_employee() -> None:
+ """Digital-colleague lifecycle: spawn, list, transition, export-profile."""
+
+
+@org_employee.command("spawn")
+@click.option(
+ "--blueprint", "blueprint_path", type=click.Path(path_type=Path, exists=True), required=True
+)
+@click.option("--tenant-id", default="")
+def employee_spawn(blueprint_path: Path, tenant_id: str) -> None:
+ """Compile a blueprint and register the colleague in draft (then recruit)."""
+ from octop.modules.org_os.compiler.blueprint import parse_blueprint
+ 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
+
+ service = _service()
+ tid = tenant_id or service.tenant_id() or "default"
+ blueprint = parse_blueprint(blueprint_path, tenant_id=tid)
+ compiled = compile_blueprint(
+ blueprint, home=service.home, tenant_id=tid, sidecar_url=service.sidecar_url()
+ )
+ store = LifecycleStore(service.home, tid)
+ record = register_compiled(
+ store,
+ slug=compiled.slug,
+ name=blueprint.name,
+ workspace=compiled.workspace,
+ lifecycle="draft",
+ )
+ click.echo(
+ json.dumps({"compiled": compiled.to_dict(), "lifecycle": record.to_dict()}, indent=2)
+ )
+ click.echo("Next: freeos org employee transition market|recruit|shadow|active")
+
+
+@org_employee.command("list")
+@click.option("--tenant-id", default="")
+def employee_list(tenant_id: str) -> None:
+ """List persisted colleagues for this tenant."""
+ from octop.modules.org_os.lifecycle.store import LifecycleStore
+
+ service = _service()
+ tid = tenant_id or service.tenant_id() or "default"
+ rows = [item.to_dict() for item in LifecycleStore(service.home, tid).list()]
+ click.echo(json.dumps(rows, indent=2))
+
+
+@org_employee.command("transition")
+@click.argument("slug")
+@click.argument("state")
+@click.option("--tenant-id", default="")
+@click.option("--reason", default="")
+def employee_transition(slug: str, state: str, tenant_id: str, reason: str) -> None:
+ """Move draft→market→recruit→shadow→active or offboard (revokes credentials)."""
+ from octop.modules.org_os.lifecycle.store import LifecycleStore
+ from octop.modules.org_os.lifecycle.transitions import transition
+
+ service = _service()
+ tid = tenant_id or service.tenant_id() or "default"
+ record = transition(LifecycleStore(service.home, tid), slug, state, reason=reason)
+ click.echo(json.dumps(record.to_dict(), indent=2))
+
+
+@org_employee.command("export-profile")
+@click.argument("slug")
+@click.option("--tenant-id", default="")
+def employee_export_profile(slug: str, tenant_id: str) -> None:
+ """Write a capability digest + openXYOS HR payload (local draft; sidecar optional)."""
+ from octop.modules.org_os.compiler.telemetry import (
+ OpenXyosHrClient,
+ export_capability_digest,
+ )
+ from octop.modules.org_os.lifecycle.store import LifecycleStore
+
+ service = _service()
+ tid = tenant_id or service.tenant_id() or "default"
+ record = LifecycleStore(service.home, tid).get(slug)
+ if record is None:
+ raise SystemExit(f"unknown colleague: {slug}")
+ digest = export_capability_digest(Path(record.workspace), lifecycle=record.lifecycle)
+ result = OpenXyosHrClient(service.sidecar_url()).upsert_employee_profile(
+ digest, draft_dir=service.home / "tenants" / tid / "hr-drafts"
+ )
+ click.echo(json.dumps({"digest": digest.to_dict(), "hr": result}, indent=2))
+
+
+@org.group("assets")
+def org_assets() -> None:
+ """Bidirectional asset factory: publish pack / import catalog+blueprints."""
+
+
+@org_assets.command("publish")
+@click.option("--out", "out_dir", type=click.Path(path_type=Path), default=None)
+def assets_publish(out_dir: Path | None) -> None:
+ """Package produced skills/plugins/MCPs/agents as an openXYOS-shaped draft."""
+ from octop.modules.org_os.assets.pack import publish_asset_pack
+
+ service = _service()
+ pack = publish_asset_pack(service.home, tenant_id=service.tenant_id(), out_dir=out_dir)
+ click.echo(json.dumps(pack.to_dict(), indent=2))
+
+
+@org_assets.command("import")
+@click.option("--catalog", is_flag=True, help="Generate skills from the openXYOS module catalog.")
+@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(
+ "--from-sidecar",
+ is_flag=True,
+ help="Best-effort GET /api/governance/permissions from the sidecar.",
+)
+@click.option("--tenant-id", default="")
+def assets_import(
+ catalog: bool,
+ blueprint_path: Path | None,
+ policies_path: Path | None,
+ from_sidecar: bool,
+ tenant_id: str,
+) -> None:
+ """Import control-plane catalog/blueprint/policies into FreeOS generators."""
+ from octop.modules.org_os.assets.importer import import_openxyos_assets
+
+ service = _service()
+ imported = import_openxyos_assets(
+ service.home,
+ tenant_id=tenant_id or service.tenant_id() or "default",
+ sidecar_url=service.sidecar_url(),
+ catalog=catalog,
+ blueprint_path=blueprint_path,
+ policies_path=policies_path,
+ from_sidecar=from_sidecar,
+ )
+ click.echo(json.dumps(imported.to_dict(), indent=2))
diff --git a/src/octop/infra/utils/paths.py b/src/octop/infra/utils/paths.py
index a7946f8a..bae368be 100644
--- a/src/octop/infra/utils/paths.py
+++ b/src/octop/infra/utils/paths.py
@@ -143,6 +143,32 @@ def ensure_org_skills_dir(self) -> Path:
out.mkdir(parents=True, exist_ok=True)
return out
+ @staticmethod
+ def _safe_id(value: str) -> str:
+ cleaned = "".join(ch if ch.isalnum() or ch in "-_" else "-" for ch in (value or "").strip())
+ return cleaned.strip("-_")[:80] or "default"
+
+ def tenant_dir(self, tenant_id: str) -> Path:
+ """One tenant = one tree: ``~/.freeos/tenants//``."""
+ return self.root / "tenants" / self._safe_id(tenant_id)
+
+ def employee_dir(self, tenant_id: str, slug: str) -> Path:
+ return self.tenant_dir(tenant_id) / "employees" / self._safe_id(slug)
+
+ def ensure_employee_dir(self, tenant_id: str, slug: str) -> Path:
+ out = self.employee_dir(tenant_id, slug)
+ out.mkdir(parents=True, exist_ok=True)
+ return out
+
+ @property
+ def asset_packs_dir(self) -> Path:
+ return self.root / "asset-packs"
+
+ def ensure_asset_packs_dir(self) -> Path:
+ out = self.asset_packs_dir
+ out.mkdir(parents=True, exist_ok=True)
+ return out
+
@property
def backups_dir(self) -> Path:
"""Stored system backup archives: ``~/.octop/backups/``."""
diff --git a/src/octop/modules/org_os/assets/__init__.py b/src/octop/modules/org_os/assets/__init__.py
new file mode 100644
index 00000000..d03c9e83
--- /dev/null
+++ b/src/octop/modules/org_os/assets/__init__.py
@@ -0,0 +1,11 @@
+"""Bidirectional asset factory: FreeOS ↔ openXYOS."""
+
+from octop.modules.org_os.assets.importer import ImportedAssets, import_openxyos_assets
+from octop.modules.org_os.assets.pack import AssetPack, publish_asset_pack
+
+__all__ = [
+ "AssetPack",
+ "ImportedAssets",
+ "import_openxyos_assets",
+ "publish_asset_pack",
+]
diff --git a/src/octop/modules/org_os/assets/importer.py b/src/octop/modules/org_os/assets/importer.py
new file mode 100644
index 00000000..747828b5
--- /dev/null
+++ b/src/octop/modules/org_os/assets/importer.py
@@ -0,0 +1,117 @@
+"""Inbound: openXYOS catalog / blueprints / policies → FreeOS generators."""
+
+from __future__ import annotations
+
+import json
+from dataclasses import dataclass, field
+from pathlib import Path
+from typing import Any
+from urllib.parse import urlparse
+
+import httpx
+
+from octop.modules.org_os.compiler.blueprint import parse_blueprint
+from octop.modules.org_os.compiler.compile import compile_blueprint
+from octop.modules.org_os.governance.imported import write_imported_policies
+from octop.modules.org_os.lifecycle.store import LifecycleStore
+from octop.modules.org_os.lifecycle.transitions import register_compiled
+from octop.modules.org_os.skill_bridge.generate import generate_module_skills
+
+
+@dataclass
+class ImportedAssets:
+ skills: list[str] = field(default_factory=list)
+ employees: list[str] = field(default_factory=list)
+ policies: str = ""
+ notes: list[str] = field(default_factory=list)
+
+ def to_dict(self) -> dict[str, Any]:
+ return {
+ "skills": list(self.skills),
+ "employees": list(self.employees),
+ "policies": self.policies,
+ "notes": list(self.notes),
+ }
+
+
+def _sidecar_get_json(sidecar_url: str, path: str, *, timeout: float = 2.0) -> Any | None:
+ parsed = urlparse(sidecar_url or "")
+ if parsed.scheme not in {"http", "https"} or not parsed.netloc:
+ return None
+ url = f"{sidecar_url.rstrip('/')}{path}"
+ try:
+ with httpx.Client(timeout=timeout, follow_redirects=True) as client:
+ response = client.get(url)
+ except httpx.HTTPError:
+ return None
+ if response.status_code >= 400:
+ return None
+ try:
+ body = response.json()
+ except ValueError:
+ return None
+ if isinstance(body, dict) and "data" in body:
+ return body["data"]
+ return body
+
+
+def import_openxyos_assets(
+ home: Path,
+ *,
+ tenant_id: str = "",
+ sidecar_url: str = "http://127.0.0.1:3780",
+ catalog: bool = False,
+ blueprint_path: Path | None = None,
+ policies_path: Path | None = None,
+ from_sidecar: bool = False,
+) -> ImportedAssets:
+ result = ImportedAssets()
+ tid = tenant_id or "default"
+
+ if catalog:
+ generated = generate_module_skills(home / "org-skills")
+ result.skills = [item.slug for item in generated]
+ result.notes.append(f"generated {len(generated)} module skills from the openXYOS catalog")
+
+ if blueprint_path is not None:
+ blueprint = parse_blueprint(blueprint_path, tenant_id=tid)
+ compiled = compile_blueprint(blueprint, home=home, tenant_id=tid, sidecar_url=sidecar_url)
+ store = LifecycleStore(home, tid)
+ register_compiled(
+ store,
+ slug=compiled.slug,
+ name=blueprint.name,
+ workspace=compiled.workspace,
+ lifecycle="draft",
+ )
+ result.employees.append(compiled.slug)
+ result.notes.append(f"compiled blueprint → {compiled.workspace}")
+
+ policies_raw: Any | None = None
+ policies_source = ""
+ if policies_path is not None:
+ policies_raw = json.loads(policies_path.read_text(encoding="utf-8"))
+ policies_source = str(policies_path)
+ elif from_sidecar:
+ fetched = _sidecar_get_json(sidecar_url, "/api/governance/permissions")
+ if fetched is not None:
+ policies_raw = fetched
+ policies_source = f"{sidecar_url.rstrip('/')}/api/governance/permissions"
+ else:
+ result.notes.append(
+ "sidecar permissions unreachable; pass --policies or retry when signed in"
+ )
+
+ if policies_raw is not None:
+ dest = write_imported_policies(
+ home / "governance",
+ policies_raw,
+ tenant_id=tid,
+ source=policies_source,
+ )
+ result.policies = str(dest)
+ result.notes.append("wrote imported governance policies (fail-closed; engine loads them)")
+
+ if not (catalog or blueprint_path or policies_path or from_sidecar):
+ raise ValueError("specify --catalog, --blueprint, --policies, and/or --from-sidecar")
+ return result
diff --git a/src/octop/modules/org_os/assets/pack.py b/src/octop/modules/org_os/assets/pack.py
new file mode 100644
index 00000000..b0858494
--- /dev/null
+++ b/src/octop/modules/org_os/assets/pack.py
@@ -0,0 +1,235 @@
+"""Outbound: package FreeOS-produced skills/plugins/MCPs/agents for openXYOS."""
+
+from __future__ import annotations
+
+import json
+import shutil
+from dataclasses import dataclass, field
+from pathlib import Path
+from typing import Any
+
+from octop.modules.org_os.compiler.telemetry import export_capability_digest
+from octop.modules.org_os.governance.mcp_server import stdio_spec
+from octop.modules.org_os.lifecycle.store import LifecycleStore
+from octop.modules.org_os.lifecycle.transitions import KEEP_ENV_KEYS
+from octop.modules.org_os.skill_bridge.publish import publish_skill
+
+ASSET_PACK_SCHEMA = "freeos.asset-pack.v1"
+
+
+@dataclass
+class AssetPack:
+ directory: Path
+ manifest: Path
+ skill_count: int
+ plugin_count: int
+ mcp_count: int
+ agent_count: int
+ notes: list[str] = field(default_factory=list)
+
+ def to_dict(self) -> dict[str, Any]:
+ return {
+ "directory": str(self.directory),
+ "manifest": str(self.manifest),
+ "skill_count": self.skill_count,
+ "plugin_count": self.plugin_count,
+ "mcp_count": self.mcp_count,
+ "agent_count": self.agent_count,
+ "notes": list(self.notes),
+ }
+
+
+def _copy_tree(source: Path, dest: Path) -> None:
+ dest.parent.mkdir(parents=True, exist_ok=True)
+ if source.is_file():
+ dest.write_bytes(source.read_bytes())
+ return
+ shutil.copytree(source, dest, dirs_exist_ok=True)
+
+
+def _redact_env(env_file: Path) -> None:
+ if not env_file.is_file():
+ return
+ lines: list[str] = []
+ for line in env_file.read_text(encoding="utf-8").splitlines():
+ if "=" in line and not line.lstrip().startswith("#"):
+ key, _sep, _value = line.partition("=")
+ if key in KEEP_ENV_KEYS:
+ lines.append(line)
+ else:
+ lines.append(f"{key}=")
+ else:
+ lines.append(line)
+ lines.append("# secrets stripped from freeos.asset-pack.v1")
+ env_file.write_text("\n".join(lines) + "\n", encoding="utf-8")
+
+
+def _iter_employee_dirs(home: Path, tenant_id: str) -> list[Path]:
+ tenants_root = home / "tenants"
+ if not tenants_root.is_dir():
+ return []
+ if tenant_id:
+ roots = [tenants_root / tenant_id / "employees"]
+ else:
+ roots = [path / "employees" for path in sorted(tenants_root.iterdir()) if path.is_dir()]
+ found: list[Path] = []
+ for root in roots:
+ if not root.is_dir():
+ continue
+ for employee_dir in sorted(root.iterdir()):
+ if employee_dir.is_dir() and (employee_dir / "SOUL.md").is_file():
+ found.append(employee_dir)
+ return found
+
+
+def _employee_payloads(home: Path, tenant_id: str) -> list[dict[str, Any]]:
+ payloads: list[dict[str, Any]] = []
+ tenants_root = home / "tenants"
+ if not tenants_root.is_dir():
+ return payloads
+ tenant_ids = (
+ [tenant_id]
+ if tenant_id
+ else [path.name for path in tenants_root.iterdir() if path.is_dir()]
+ )
+ for tid in tenant_ids:
+ for record in LifecycleStore(home, tid).list():
+ workspace = Path(record.workspace)
+ if not workspace.is_dir() or not (workspace / "SOUL.md").is_file():
+ continue
+ digest = export_capability_digest(workspace, lifecycle=record.lifecycle)
+ profile = digest.to_openxyos_profile()
+ profile["slug"] = record.slug
+ profile["lifecycle"] = record.lifecycle
+ profile["enabled_by_default"] = False
+ payloads.append(profile)
+ return payloads
+
+
+def publish_asset_pack(
+ home: Path,
+ *,
+ tenant_id: str = "",
+ out_dir: Path | None = None,
+) -> AssetPack:
+ """Assemble produced assets into an openXYOS-shaped draft pack (not auto-on)."""
+ dest = out_dir or (home / "asset-packs" / "latest")
+ if dest.exists():
+ shutil.rmtree(dest)
+ dest.mkdir(parents=True, exist_ok=True)
+ (dest / "skills").mkdir(exist_ok=True)
+ (dest / "plugins").mkdir(exist_ok=True)
+ (dest / "mcps").mkdir(exist_ok=True)
+ (dest / "agents").mkdir(exist_ok=True)
+ (dest / "openxyos").mkdir(exist_ok=True)
+
+ skill_count = 0
+ plugin_count = 0
+ skills_root = home / "org-skills"
+ if skills_root.is_dir():
+ 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
+ _copy_tree(skill_dir, dest / "skills" / skill_dir.name)
+ skill_count += 1
+ try:
+ draft = publish_skill(
+ skill_dir, out_dir=dest / "plugins" / f"{skill_dir.name}.plugin"
+ )
+ plugin_count += 1
+ _copy_tree(draft.payload_path, dest / "openxyos" / f"{skill_dir.name}.publish.json")
+ except ValueError:
+ continue
+
+ mcp_spec = {"xyos-governance-mcp": stdio_spec()}
+ mcp_path = dest / "mcps" / "xyos-governance-mcp.json"
+ mcp_path.write_text(json.dumps(mcp_spec, indent=2) + "\n", encoding="utf-8")
+ mcp_count = 1
+ _copy_tree(mcp_path, dest / "openxyos" / "xyos-governance-mcp.json")
+
+ agent_count = 0
+ for employee_dir in _iter_employee_dirs(home, tenant_id):
+ target = dest / "agents" / employee_dir.name
+ _copy_tree(employee_dir, target)
+ _redact_env(target / ".env")
+ agent_count += 1
+
+ employees = _employee_payloads(home, tenant_id)
+ employees_path = dest / "openxyos" / "org-employees.publish.json"
+ employees_path.write_text(
+ json.dumps(
+ {
+ "enabled_by_default": False,
+ "endpoint": "/api/employees",
+ "employees": employees,
+ },
+ indent=2,
+ )
+ + "\n",
+ encoding="utf-8",
+ )
+ talent = [
+ {
+ "name": item["name"],
+ "talent_type": "ai",
+ "agent_type": item.get("agent_type"),
+ "status": item.get("talent_status") or "draft",
+ "skills": item.get("skills"),
+ "source": "FreeOS",
+ "integration_type": "agent-blueprint-v1",
+ "tenant_id": item.get("tenant_id"),
+ "slug": item.get("slug"),
+ "enabled_by_default": False,
+ }
+ for item in employees
+ if item.get("lifecycle") in {"market", "recruit", "shadow", "active"}
+ ]
+ (dest / "openxyos" / "org-talent.publish.json").write_text(
+ json.dumps(
+ {
+ "enabled_by_default": False,
+ "endpoint": "/api/talent",
+ "talent": talent,
+ },
+ indent=2,
+ )
+ + "\n",
+ encoding="utf-8",
+ )
+
+ manifest = {
+ "schema": ASSET_PACK_SCHEMA,
+ "tenant_id": tenant_id,
+ "enabled_by_default": False,
+ "counts": {
+ "skills": skill_count,
+ "plugins": plugin_count,
+ "mcps": mcp_count,
+ "agents": agent_count,
+ "employees": len(employees),
+ "talent": len(talent),
+ },
+ "openxyos": {
+ "module_settings": "/api/module-settings",
+ "plugins": "/api/plugins",
+ "employees": "/api/employees",
+ "talent": "/api/talent",
+ "note": "Drafts only. Tenant must toggle each asset on.",
+ },
+ "governance": "xyos-governance-mcp remains required for high-risk tools.",
+ }
+ manifest_path = dest / "manifest.json"
+ manifest_path.write_text(json.dumps(manifest, indent=2) + "\n", encoding="utf-8")
+ return AssetPack(
+ directory=dest,
+ manifest=manifest_path,
+ skill_count=skill_count,
+ plugin_count=plugin_count,
+ mcp_count=mcp_count,
+ agent_count=agent_count,
+ notes=[
+ "Not installed into the sidecar. Operator reviews openxyos/ then enables per tenant.",
+ "One tenant = one workspace; do not unpack this pack into a shared sandbox.",
+ "Agent .env values are redacted except tenant/slug/schema keys.",
+ ],
+ )
diff --git a/src/octop/modules/org_os/compiler/README.md b/src/octop/modules/org_os/compiler/README.md
new file mode 100644
index 00000000..0f78244d
--- /dev/null
+++ b/src/octop/modules/org_os/compiler/README.md
@@ -0,0 +1,27 @@
+# xyos2freeos (Direction 1)
+
+Compiles `openxyos.agent-blueprint.v1` into a **tenant-scoped** FreeOS
+agent workspace. Octop remains the runtime.
+
+```bash
+uv run xyos2freeos blueprint.json --tenant-id 1
+# or
+uv run freeos org compile-blueprint blueprint.json --tenant-id 1
+uv run freeos org employee spawn --blueprint blueprint.json --tenant-id 1
+```
+
+Writes `{FREEOS_HOME}/tenants//employees//`:
+
+| File | Source |
+|---|---|
+| `SOUL.md` | name, positioning, capabilities, governance rules |
+| `MEMORY.md` | experience + reference excerpts |
+| `knowledge/` | one markdown file per blueprint reference |
+| `skills//SKILL.md` | one skill stub per capability |
+| `.env` | tenant id, sidecar URL, home — no shared secrets |
+| `cron.json` | `job_duties` (`name`, `schedule`, `prompt`); enabled only when lifecycle is `active` |
+| `blueprint.json` | normalized source |
+
+Reverse path: `freeos org employee export-profile ` writes an
+openXYOS-shaped HR payload (`OpenXyosHrClient`). HTTP is best-effort;
+the local draft is authoritative.
diff --git a/src/octop/modules/org_os/compiler/__init__.py b/src/octop/modules/org_os/compiler/__init__.py
new file mode 100644
index 00000000..70e180bf
--- /dev/null
+++ b/src/octop/modules/org_os/compiler/__init__.py
@@ -0,0 +1,24 @@
+"""xyos2freeos — compile ``openxyos.agent-blueprint.v1`` into a FreeOS workspace."""
+
+from octop.modules.org_os.compiler.blueprint import (
+ BLUEPRINT_SCHEMA,
+ AgentBlueprint,
+ parse_blueprint,
+)
+from octop.modules.org_os.compiler.compile import CompiledEmployee, compile_blueprint
+from octop.modules.org_os.compiler.telemetry import (
+ CapabilityDigest,
+ OpenXyosHrClient,
+ export_capability_digest,
+)
+
+__all__ = [
+ "BLUEPRINT_SCHEMA",
+ "AgentBlueprint",
+ "CapabilityDigest",
+ "CompiledEmployee",
+ "OpenXyosHrClient",
+ "compile_blueprint",
+ "export_capability_digest",
+ "parse_blueprint",
+]
diff --git a/src/octop/modules/org_os/compiler/blueprint.py b/src/octop/modules/org_os/compiler/blueprint.py
new file mode 100644
index 00000000..1ce192fa
--- /dev/null
+++ b/src/octop/modules/org_os/compiler/blueprint.py
@@ -0,0 +1,154 @@
+"""Parse ``openxyos.agent-blueprint.v1`` (see openXYOS agent-studio generate)."""
+
+from __future__ import annotations
+
+import json
+import re
+from dataclasses import dataclass, field
+from pathlib import Path
+from typing import Any
+
+BLUEPRINT_SCHEMA = "openxyos.agent-blueprint.v1"
+_SLUG_RE = re.compile(r"[^a-z0-9]+")
+
+
+@dataclass(frozen=True)
+class JobDuty:
+ name: str
+ schedule: str
+ prompt: str
+
+ def to_dict(self) -> dict[str, str]:
+ return {"name": self.name, "schedule": self.schedule, "prompt": self.prompt}
+
+
+@dataclass(frozen=True)
+class BlueprintReference:
+ name: str
+ excerpt: str
+ file_type: str = ""
+ scan: str = ""
+
+ def to_dict(self) -> dict[str, str]:
+ return {
+ "name": self.name,
+ "excerpt": self.excerpt,
+ "type": self.file_type,
+ "scan": self.scan,
+ }
+
+
+@dataclass(frozen=True)
+class AgentBlueprint:
+ schema: str
+ name: str
+ positioning: str
+ industry: str
+ capabilities: list[str]
+ experience: str = ""
+ references: list[BlueprintReference] = field(default_factory=list)
+ ima_url: str = ""
+ ima_status: str = ""
+ high_risk_requires_human_review: bool = True
+ external_write_disabled: bool = True
+ lifecycle: str = "talent_market"
+ job_duties: list[JobDuty] = field(default_factory=list)
+ creator_user_id: str = ""
+ tenant_id: str = ""
+ raw: dict[str, Any] = field(default_factory=dict)
+
+ @property
+ def slug(self) -> str:
+ ascii_name = _SLUG_RE.sub("-", self.name.lower()).strip("-")
+ return ascii_name or "digital-colleague"
+
+ def to_dict(self) -> dict[str, Any]:
+ return {
+ "schema": self.schema,
+ "name": self.name,
+ "slug": self.slug,
+ "positioning": self.positioning,
+ "industry": self.industry,
+ "capabilities": list(self.capabilities),
+ "experience": self.experience,
+ "references": [item.to_dict() for item in self.references],
+ "ima": {"url": self.ima_url, "status": self.ima_status} if self.ima_url else None,
+ "governance": {
+ "high_risk_requires_human_review": self.high_risk_requires_human_review,
+ "external_write_disabled": self.external_write_disabled,
+ },
+ "lifecycle": self.lifecycle,
+ "job_duties": [item.to_dict() for item in self.job_duties],
+ "tenant_id": self.tenant_id,
+ }
+
+
+def parse_blueprint(data: dict[str, Any] | str | Path, *, tenant_id: str = "") -> AgentBlueprint:
+ if isinstance(data, Path):
+ payload = json.loads(data.read_text(encoding="utf-8"))
+ elif isinstance(data, str):
+ payload = json.loads(data)
+ else:
+ payload = data
+ if not isinstance(payload, dict):
+ raise ValueError("blueprint must be a JSON object")
+ schema = str(payload.get("schema") or "")
+ if schema and schema != BLUEPRINT_SCHEMA:
+ raise ValueError(f"unsupported blueprint schema: {schema}")
+ name = str(payload.get("name") or "").strip()
+ positioning = str(payload.get("positioning") or "").strip()
+ raw_caps = payload.get("capabilities") or []
+ if not isinstance(raw_caps, list):
+ raise ValueError("capabilities must be a list")
+ capabilities = [str(item).strip() for item in raw_caps if str(item).strip()]
+ if not name or not positioning:
+ raise ValueError("blueprint requires name and positioning")
+ if not capabilities:
+ raise ValueError("blueprint requires at least one capability")
+
+ refs: list[BlueprintReference] = []
+ for item in payload.get("references") or []:
+ if not isinstance(item, dict):
+ continue
+ excerpt = str(item.get("excerpt") or item.get("extracted_text") or "")
+ refs.append(
+ BlueprintReference(
+ name=str(item.get("name") or item.get("original_name") or "reference"),
+ excerpt=excerpt[:6000],
+ file_type=str(item.get("type") or item.get("file_type") or ""),
+ scan=str(item.get("scan") or item.get("scan_status") or ""),
+ )
+ )
+
+ ima_raw = payload.get("ima")
+ ima: dict[str, Any] = ima_raw if isinstance(ima_raw, dict) else {}
+ gov_raw = payload.get("governance")
+ gov: dict[str, Any] = gov_raw if isinstance(gov_raw, dict) else {}
+ duties: list[JobDuty] = []
+ for item in payload.get("job_duties") or payload.get("duties") or []:
+ if not isinstance(item, dict):
+ continue
+ duty_name = str(item.get("name") or "").strip()
+ schedule = str(item.get("schedule") or item.get("cron") or "").strip()
+ prompt = str(item.get("prompt") or item.get("description") or "").strip()
+ if duty_name and schedule and prompt:
+ duties.append(JobDuty(name=duty_name, schedule=schedule, prompt=prompt))
+
+ return AgentBlueprint(
+ schema=schema or BLUEPRINT_SCHEMA,
+ name=name,
+ positioning=positioning,
+ industry=str(payload.get("industry") or "professional-services").strip(),
+ capabilities=capabilities,
+ experience=str(payload.get("experience") or ""),
+ references=refs,
+ ima_url=str(ima.get("url") or payload.get("ima_url") or ""),
+ ima_status=str(ima.get("status") or ""),
+ high_risk_requires_human_review=bool(gov.get("high_risk_requires_human_review", True)),
+ external_write_disabled=bool(gov.get("external_write_disabled", True)),
+ lifecycle=str(payload.get("lifecycle") or "talent_market"),
+ job_duties=duties,
+ creator_user_id=str(payload.get("creator_user_id") or ""),
+ tenant_id=tenant_id or str(payload.get("tenant_id") or ""),
+ raw=dict(payload),
+ )
diff --git a/src/octop/modules/org_os/compiler/cli.py b/src/octop/modules/org_os/compiler/cli.py
new file mode 100644
index 00000000..a090d330
--- /dev/null
+++ b/src/octop/modules/org_os/compiler/cli.py
@@ -0,0 +1,44 @@
+"""``xyos2freeos`` console entry — compile a blueprint file to a workspace."""
+
+from __future__ import annotations
+
+import argparse
+import json
+from pathlib import Path
+
+from octop.infra.utils.paths import PathLayout
+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.service import OrgModuleService
+
+
+def main() -> None:
+ parser = argparse.ArgumentParser(prog="xyos2freeos")
+ parser.add_argument("blueprint", type=Path)
+ parser.add_argument("--tenant-id", default="")
+ parser.add_argument("--out", type=Path, default=None)
+ args = parser.parse_args()
+ paths = PathLayout.from_env()
+ paths.ensure_root()
+ service = OrgModuleService(config_path=paths.config, home=paths.root)
+ tid = args.tenant_id or service.tenant_id() or "default"
+ compiled = compile_blueprint(
+ args.blueprint,
+ home=service.home,
+ tenant_id=tid,
+ sidecar_url=service.sidecar_url(),
+ out_dir=args.out,
+ )
+ register_compiled(
+ LifecycleStore(service.home, tid),
+ slug=compiled.slug,
+ name=compiled.slug,
+ workspace=compiled.workspace,
+ lifecycle="draft",
+ )
+ print(json.dumps(compiled.to_dict(), indent=2))
+
+
+if __name__ == "__main__":
+ main()
diff --git a/src/octop/modules/org_os/compiler/compile.py b/src/octop/modules/org_os/compiler/compile.py
new file mode 100644
index 00000000..4212f2e4
--- /dev/null
+++ b/src/octop/modules/org_os/compiler/compile.py
@@ -0,0 +1,216 @@
+"""Compile a blueprint into a tenant-scoped FreeOS agent workspace."""
+
+from __future__ import annotations
+
+import json
+from dataclasses import dataclass, field
+from pathlib import Path
+from typing import Any
+
+from octop.modules.org_os.compiler.blueprint import AgentBlueprint, parse_blueprint
+
+_CAP_SLUG_TRANS = str.maketrans({" ": "-", "/": "-", "_": "-"})
+
+
+@dataclass
+class CompiledEmployee:
+ slug: str
+ tenant_id: str
+ workspace: Path
+ soul: Path
+ memory: Path
+ env_file: Path
+ cron_file: Path
+ blueprint_file: Path
+ knowledge_files: list[Path] = field(default_factory=list)
+ skill_files: list[Path] = field(default_factory=list)
+
+ def to_dict(self) -> dict[str, Any]:
+ return {
+ "slug": self.slug,
+ "tenant_id": self.tenant_id,
+ "workspace": str(self.workspace),
+ "soul": str(self.soul),
+ "memory": str(self.memory),
+ "env_file": str(self.env_file),
+ "cron_file": str(self.cron_file),
+ "blueprint_file": str(self.blueprint_file),
+ "knowledge_files": [str(path) for path in self.knowledge_files],
+ "skill_files": [str(path) for path in self.skill_files],
+ }
+
+
+def _cap_slug(name: str) -> str:
+ raw = name.lower().translate(_CAP_SLUG_TRANS)
+ cleaned = "".join(ch if ch.isalnum() or ch == "-" else "-" for ch in raw)
+ return cleaned.strip("-")[:48] or "capability"
+
+
+def _render_soul(blueprint: AgentBlueprint) -> str:
+ caps = "\n".join(f"- {item}" for item in blueprint.capabilities)
+ lines = [
+ f"# {blueprint.name}",
+ "",
+ blueprint.positioning,
+ "",
+ f"**Industry:** {blueprint.industry}",
+ "",
+ "## Capabilities",
+ "",
+ caps,
+ "",
+ "## Governance (must follow)",
+ "",
+ "You run on the FreeOS/Octop **data plane**. openXYOS is the control plane.",
+ "Do not start a second chat runtime.",
+ "",
+ "- High-risk tools (outbound / delete / pay / prod) go through",
+ " `xyos-governance-mcp` first. If `execute` is false, **stop**.",
+ "- One tenant = one workspace/sandbox. Never use another tenant's files.",
+ f"- Tenant id: `{blueprint.tenant_id or 'unset'}`.",
+ ]
+ if blueprint.external_write_disabled:
+ lines.append("- External write / send / pay / prod-change is disabled until promoted.")
+ if blueprint.high_risk_requires_human_review:
+ lines.append("- High-risk conclusions require a human review (durable pause).")
+ if blueprint.ima_url:
+ lines.extend(["", f"Linked knowledge URL (unverified): {blueprint.ima_url}"])
+ lines.extend(
+ ["", "This colleague is produced by `xyos2freeos` from `openxyos.agent-blueprint.v1`."]
+ )
+ return "\n".join(lines) + "\n"
+
+
+def _render_memory(blueprint: AgentBlueprint) -> str:
+ parts = [
+ f"# MEMORY seed — {blueprint.name}",
+ "",
+ "Seeded from the control-plane blueprint. Runtime memory stays in this workspace.",
+ "",
+ ]
+ if blueprint.experience:
+ parts.extend(["## Experience / operating guidance", "", blueprint.experience[:4000], ""])
+ if blueprint.references:
+ parts.append("## Reference excerpts")
+ parts.append("")
+ for item in blueprint.references:
+ parts.append(f"### {item.name}")
+ parts.append(item.excerpt[:1500] or "(empty)")
+ parts.append("")
+ return "\n".join(parts)
+
+
+def _render_skill(blueprint: AgentBlueprint, capability: str) -> str:
+ slug = _cap_slug(capability)
+ return (
+ f"---\n"
+ f"name: {slug}\n"
+ f"description: {capability} — compiled from openXYOS blueprint {blueprint.name}.\n"
+ f"metadata:\n"
+ f" freeos:\n"
+ f" source: openxyos-blueprint\n"
+ f" colleague: {blueprint.slug}\n"
+ f"---\n\n"
+ f"# {capability}\n\n"
+ f"Use FreeOS org skills (`org-*`) and the BFF `/api/org-module/sidecar` "
+ f"with `X-FreeOS-Tenant-Id`. High-risk actions must call "
+ f"`xyos-governance-mcp` / `gate_tool_call` first.\n"
+ )
+
+
+def _render_env(blueprint: AgentBlueprint, *, sidecar_url: str, home: Path) -> str:
+ return (
+ f"FREEOS_ORG_TENANT_ID={blueprint.tenant_id}\n"
+ f"FREEOS_ORG_SIDECAR_URL={sidecar_url}\n"
+ f"FREEOS_HOME={home}\n"
+ f"XYOS_BLUEPRINT_SCHEMA={blueprint.schema}\n"
+ f"FREEOS_COLLEAGUE_SLUG={blueprint.slug}\n"
+ "# Tenant-scoped. Do not copy this file into another tenant workspace.\n"
+ )
+
+
+def compile_blueprint(
+ source: AgentBlueprint | dict[str, Any] | Path,
+ *,
+ home: Path,
+ tenant_id: str = "",
+ sidecar_url: str = "http://127.0.0.1:3780",
+ out_dir: Path | None = None,
+) -> CompiledEmployee:
+ """Write SOUL.md, skills, knowledge, MEMORY seed, .env, and cron.json."""
+ blueprint = (
+ source
+ if isinstance(source, AgentBlueprint)
+ else parse_blueprint(source, tenant_id=tenant_id)
+ )
+ if tenant_id and not blueprint.tenant_id:
+ blueprint = parse_blueprint(blueprint.to_dict(), tenant_id=tenant_id)
+ tid = blueprint.tenant_id or tenant_id or "default"
+ workspace = out_dir or (home / "tenants" / tid / "employees" / blueprint.slug)
+ workspace.mkdir(parents=True, exist_ok=True)
+ (workspace / "knowledge").mkdir(parents=True, exist_ok=True)
+ (workspace / "skills").mkdir(parents=True, exist_ok=True)
+
+ soul = workspace / "SOUL.md"
+ memory = workspace / "MEMORY.md"
+ env_file = workspace / ".env"
+ cron_file = workspace / "cron.json"
+ blueprint_file = workspace / "blueprint.json"
+ soul.write_text(_render_soul(blueprint), encoding="utf-8")
+ memory.write_text(_render_memory(blueprint), encoding="utf-8")
+ env_file.write_text(
+ _render_env(blueprint, sidecar_url=sidecar_url, home=home), encoding="utf-8"
+ )
+ cron_jobs = [
+ {
+ "name": duty.name,
+ "schedule_spec": duty.schedule,
+ "prompt": duty.prompt,
+ "enabled": False,
+ "note": "Enabled only in lifecycle=active. Disabled in shadow/offboard.",
+ }
+ for duty in blueprint.job_duties
+ ]
+ cron_file.write_text(json.dumps(cron_jobs, indent=2) + "\n", encoding="utf-8")
+ blueprint_file.write_text(json.dumps(blueprint.to_dict(), indent=2) + "\n", encoding="utf-8")
+
+ knowledge_files: list[Path] = []
+ for index, ref in enumerate(blueprint.references, start=1):
+ path = workspace / "knowledge" / f"{index:02d}-{_cap_slug(ref.name)}.md"
+ path.write_text(f"# {ref.name}\n\n{ref.excerpt}\n", encoding="utf-8")
+ knowledge_files.append(path)
+
+ skill_files: list[Path] = []
+ for capability in blueprint.capabilities:
+ skill_dir = workspace / "skills" / _cap_slug(capability)
+ skill_dir.mkdir(parents=True, exist_ok=True)
+ skill_md = skill_dir / "SKILL.md"
+ skill_md.write_text(_render_skill(blueprint, capability), encoding="utf-8")
+ skill_files.append(skill_md)
+
+ (workspace / "workspace.json").write_text(
+ json.dumps(
+ {
+ "kind": "freeos.employee-workspace.v1",
+ "slug": blueprint.slug,
+ "tenant_id": tid,
+ "runtime": "octop-data-plane",
+ "isolation": "one-tenant-one-workspace",
+ },
+ indent=2,
+ )
+ + "\n",
+ encoding="utf-8",
+ )
+ return CompiledEmployee(
+ slug=blueprint.slug,
+ tenant_id=tid,
+ workspace=workspace,
+ soul=soul,
+ memory=memory,
+ env_file=env_file,
+ cron_file=cron_file,
+ blueprint_file=blueprint_file,
+ knowledge_files=knowledge_files,
+ skill_files=skill_files,
+ )
diff --git a/src/octop/modules/org_os/compiler/telemetry.py b/src/octop/modules/org_os/compiler/telemetry.py
new file mode 100644
index 00000000..44152de7
--- /dev/null
+++ b/src/octop/modules/org_os/compiler/telemetry.py
@@ -0,0 +1,137 @@
+"""Reverse path: runtime capability digest → openXYOS HR/employee profile."""
+
+from __future__ import annotations
+
+import json
+from dataclasses import asdict, dataclass, field
+from pathlib import Path
+from typing import Any, TypedDict
+from urllib.parse import urlparse
+
+import httpx
+
+
+class OpenXyosStatus(TypedDict):
+ talent: str | None
+ employee: str | None
+ employment_category: str | None
+
+
+# Maps FreeOS lifecycle → openXYOS talent_pool.status / employees.employment_category
+OPENXYOS_STATUS_MAP: dict[str, OpenXyosStatus] = {
+ "draft": {"talent": "draft", "employee": None, "employment_category": None},
+ "market": {"talent": "available", "employee": None, "employment_category": None},
+ "recruit": {"talent": "recruited", "employee": "active", "employment_category": "reserve"},
+ "shadow": {"talent": "recruited", "employee": "active", "employment_category": "probation"},
+ "active": {"talent": "recruited", "employee": "active", "employment_category": "staff"},
+ "offboard": {"talent": "archived", "employee": "inactive", "employment_category": "offboarded"},
+}
+
+
+@dataclass
+class CapabilityDigest:
+ slug: str
+ name: str
+ tenant_id: str
+ lifecycle: str
+ capabilities: list[str]
+ skill_count: int
+ cron_count: int
+ knowledge_count: int
+ workspace: str
+ notes: list[str] = field(default_factory=list)
+
+ def to_openxyos_profile(self) -> dict[str, Any]:
+ mapping = OPENXYOS_STATUS_MAP.get(self.lifecycle, OPENXYOS_STATUS_MAP["draft"])
+ return {
+ "name": self.name,
+ "agent_type": f"freeos-{self.slug}",
+ "employee_type": "ai",
+ "skills": ", ".join(self.capabilities),
+ "capabilities": list(self.capabilities),
+ "status": mapping["employee"] or "active",
+ "employment_category": mapping["employment_category"],
+ "talent_status": mapping["talent"],
+ "description": (
+ f"FreeOS runtime colleague `{self.slug}` "
+ f"(lifecycle={self.lifecycle}, skills={self.skill_count})."
+ ),
+ "provider": "FreeOS",
+ "integration_type": "agent-blueprint-v1",
+ "tenant_id": self.tenant_id,
+ }
+
+ def to_dict(self) -> dict[str, Any]:
+ data = asdict(self)
+ data["openxyos_profile"] = self.to_openxyos_profile()
+ return data
+
+
+def export_capability_digest(workspace: Path, *, lifecycle: str = "draft") -> CapabilityDigest:
+ blueprint_path = workspace / "blueprint.json"
+ raw = json.loads(blueprint_path.read_text(encoding="utf-8")) if blueprint_path.is_file() else {}
+ skills_root = workspace / "skills"
+ skill_count = len(list(skills_root.glob("*/SKILL.md"))) if skills_root.is_dir() else 0
+ knowledge_root = workspace / "knowledge"
+ knowledge_count = len(list(knowledge_root.glob("*.md"))) if knowledge_root.is_dir() else 0
+ cron_path = workspace / "cron.json"
+ cron_count = 0
+ if cron_path.is_file():
+ try:
+ jobs = json.loads(cron_path.read_text(encoding="utf-8"))
+ cron_count = len(jobs) if isinstance(jobs, list) else 0
+ except json.JSONDecodeError:
+ cron_count = 0
+ return CapabilityDigest(
+ slug=str(raw.get("slug") or workspace.name),
+ name=str(raw.get("name") or workspace.name),
+ tenant_id=str(raw.get("tenant_id") or ""),
+ lifecycle=lifecycle,
+ capabilities=list(raw.get("capabilities") or []),
+ skill_count=skill_count,
+ cron_count=cron_count,
+ knowledge_count=knowledge_count,
+ workspace=str(workspace),
+ notes=[
+ "Sidecar SQL.js is not the database of record; this digest is written locally first.",
+ "POST to /api/employees when the sidecar session is available.",
+ ],
+ )
+
+
+class OpenXyosHrClient:
+ """Optional sidecar writer. Always persists a local draft; HTTP is best-effort."""
+
+ def __init__(self, sidecar_url: str = "", *, timeout: float = 2.0) -> None:
+ self.sidecar_url = (sidecar_url or "").rstrip("/")
+ self.timeout = timeout
+
+ def upsert_employee_profile(
+ self, digest: CapabilityDigest, *, draft_dir: Path | None = None
+ ) -> dict[str, Any]:
+ payload = digest.to_openxyos_profile()
+ result: dict[str, Any] = {
+ "ok": False,
+ "sidecar_reached": False,
+ "payload": payload,
+ "draft": "",
+ }
+ if draft_dir is not None:
+ draft_dir.mkdir(parents=True, exist_ok=True)
+ path = draft_dir / f"{digest.slug}.openxyos-hr.json"
+ path.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8")
+ result["draft"] = str(path)
+ parsed = urlparse(self.sidecar_url) if self.sidecar_url else None
+ if parsed is None or parsed.scheme not in {"http", "https"} or not parsed.netloc:
+ result["reason"] = "sidecar url unset; local draft only"
+ return result
+ try:
+ with httpx.Client(timeout=self.timeout, follow_redirects=True) as client:
+ response = client.post(f"{self.sidecar_url}/api/employees", json=payload)
+ except httpx.HTTPError:
+ result["reason"] = "sidecar unreachable; local draft only"
+ return result
+ result["sidecar_reached"] = True
+ result["ok"] = response.status_code < 400
+ result["status_code"] = response.status_code
+ return result
diff --git a/src/octop/modules/org_os/governance/engine.py b/src/octop/modules/org_os/governance/engine.py
index bab999fa..3a9ef8a2 100644
--- a/src/octop/modules/org_os/governance/engine.py
+++ b/src/octop/modules/org_os/governance/engine.py
@@ -9,6 +9,11 @@
import httpx
+from octop.modules.org_os.governance.imported import (
+ load_imported_rules,
+ match_imported_rule,
+ rule_is_allowed,
+)
from octop.modules.org_os.governance.policy import (
HIGH_RISK_CATEGORIES,
args_digest,
@@ -76,6 +81,33 @@ def evaluate(self, request: PolicyRequest) -> PolicyDecision:
)
return self._audit(request, decision, digest)
+ imported = match_imported_rule(
+ load_imported_rules(self.store.root),
+ tool_name=request.tool_name,
+ category=category,
+ action=request.action,
+ )
+ if imported is not None:
+ allowed = rule_is_allowed(imported)
+ if allowed is False:
+ decision = PolicyDecision(
+ status="deny",
+ execute=False,
+ blocked=True,
+ reason=str(imported.get("reason") or "imported policy denied"),
+ category=category,
+ rule_source="imported-policies",
+ )
+ return self._audit(request, decision, digest)
+ return self._pending_or_deny(
+ request,
+ category,
+ digest,
+ "imported rule matched; human approval required",
+ sidecar_reached=False,
+ rule_source="imported-policies",
+ )
+
sidecar = self._sidecar_validate(request)
if sidecar is not None:
if sidecar.get("allowed") is False:
@@ -179,6 +211,7 @@ def _pending_or_deny(
reason: str,
*,
sidecar_reached: bool,
+ rule_source: str = "local-default-deny",
) -> PolicyDecision:
if not request.auto_pause:
decision = PolicyDecision(
@@ -187,7 +220,7 @@ def _pending_or_deny(
blocked=True,
reason=reason,
category=category,
- rule_source="local-default-deny",
+ rule_source=rule_source,
sidecar_reached=sidecar_reached,
)
return self._audit(request, decision, digest)
@@ -208,7 +241,7 @@ def _pending_or_deny(
f"`freeos org governance approve {pause.pause_id}`",
category=category,
pause_id=pause.pause_id,
- rule_source="local-default-deny",
+ rule_source=rule_source,
sidecar_reached=sidecar_reached,
)
return self._audit(request, decision, digest)
diff --git a/src/octop/modules/org_os/governance/imported.py b/src/octop/modules/org_os/governance/imported.py
new file mode 100644
index 00000000..b17c1cef
--- /dev/null
+++ b/src/octop/modules/org_os/governance/imported.py
@@ -0,0 +1,116 @@
+"""Load operator-imported openXYOS policy matrices into the local engine.
+
+Imported allow rules never skip human approval. Unmatched high-risk
+actions still default-deny.
+"""
+
+from __future__ import annotations
+
+import json
+from pathlib import Path
+from typing import Any
+
+IMPORTED_POLICIES_NAME = "imported-policies.json"
+
+_MATCH_KEYS = (
+ "tool",
+ "tool_name",
+ "action",
+ "action_type",
+ "category",
+ "permission_type",
+ "permission_check",
+)
+
+
+def imported_policies_path(governance_dir: Path) -> Path:
+ return governance_dir / IMPORTED_POLICIES_NAME
+
+
+def normalize_rules(raw: Any) -> list[dict[str, Any]]:
+ """Accept a list, ``{rules: [...]}``, or a single rule object."""
+ if isinstance(raw, list):
+ return [item for item in raw if isinstance(item, dict)]
+ if not isinstance(raw, dict):
+ return []
+ nested = raw.get("rules")
+ if isinstance(nested, list):
+ return [item for item in nested if isinstance(item, dict)]
+ if isinstance(nested, dict):
+ return normalize_rules(nested)
+ matrix = raw.get("matrix")
+ if isinstance(matrix, list):
+ return [item for item in matrix if isinstance(item, dict)]
+ if any(key in raw for key in (*_MATCH_KEYS, "allow", "allowed", "is_allowed")):
+ return [raw]
+ return []
+
+
+def write_imported_policies(
+ governance_dir: Path,
+ raw: Any,
+ *,
+ tenant_id: str = "",
+ source: str = "",
+) -> Path:
+ dest = imported_policies_path(governance_dir)
+ dest.parent.mkdir(parents=True, exist_ok=True)
+ payload = {
+ "source": source,
+ "tenant_id": tenant_id,
+ "rules": normalize_rules(raw),
+ "note": (
+ "Imported for the xyos-governance-mcp default-deny engine. "
+ "Explicit allow still requires a durable human approval. "
+ "Unmatched high-risk actions still deny."
+ ),
+ }
+ dest.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8")
+ return dest
+
+
+def load_imported_rules(governance_dir: Path) -> list[dict[str, Any]]:
+ path = imported_policies_path(governance_dir)
+ if not path.is_file():
+ return []
+ try:
+ raw = json.loads(path.read_text(encoding="utf-8"))
+ except (OSError, json.JSONDecodeError):
+ return []
+ return normalize_rules(raw)
+
+
+def rule_is_allowed(rule: dict[str, Any]) -> bool | None:
+ for key in ("allow", "allowed", "is_allowed"):
+ if key not in rule:
+ continue
+ value = rule[key]
+ if isinstance(value, bool):
+ return value
+ if isinstance(value, (int, float)):
+ return bool(value)
+ if isinstance(value, str):
+ lowered = value.strip().lower()
+ if lowered in {"1", "true", "yes", "allow", "allowed"}:
+ return True
+ if lowered in {"0", "false", "no", "deny", "denied"}:
+ return False
+ return None
+
+
+def match_imported_rule(
+ rules: list[dict[str, Any]],
+ *,
+ tool_name: str,
+ category: str,
+ action: str,
+) -> dict[str, Any] | None:
+ haystack = {part.strip().lower() for part in (tool_name, category, action) if part.strip()}
+ if not haystack:
+ return None
+ for rule in rules:
+ for key in _MATCH_KEYS:
+ value = str(rule.get(key) or "").strip().lower()
+ if value and value in haystack:
+ return rule
+ return None
diff --git a/src/octop/modules/org_os/lifecycle/__init__.py b/src/octop/modules/org_os/lifecycle/__init__.py
new file mode 100644
index 00000000..4e2bbeb2
--- /dev/null
+++ b/src/octop/modules/org_os/lifecycle/__init__.py
@@ -0,0 +1,16 @@
+"""Digital-colleague lifecycle (Direction 4)."""
+
+from octop.modules.org_os.lifecycle.store import (
+ LIFECYCLE_STATES,
+ ColleagueRecord,
+ LifecycleStore,
+)
+from octop.modules.org_os.lifecycle.transitions import register_compiled, transition
+
+__all__ = [
+ "LIFECYCLE_STATES",
+ "ColleagueRecord",
+ "LifecycleStore",
+ "register_compiled",
+ "transition",
+]
diff --git a/src/octop/modules/org_os/lifecycle/store.py b/src/octop/modules/org_os/lifecycle/store.py
new file mode 100644
index 00000000..08977090
--- /dev/null
+++ b/src/octop/modules/org_os/lifecycle/store.py
@@ -0,0 +1,111 @@
+"""Persist colleague lifecycle under the FreeOS home (not sidecar SQL.js)."""
+
+from __future__ import annotations
+
+import json
+import time
+from dataclasses import asdict, dataclass, field
+from pathlib import Path
+from typing import Any
+
+LIFECYCLE_STATES = (
+ "draft",
+ "market",
+ "recruit",
+ "shadow",
+ "active",
+ "offboard",
+)
+
+
+@dataclass
+class ColleagueRecord:
+ slug: str
+ tenant_id: str
+ name: str
+ lifecycle: str
+ workspace: str
+ created_at: float
+ updated_at: float
+ history: list[dict[str, Any]] = field(default_factory=list)
+ read_only: bool = False
+ cron_enabled: bool = False
+ credentials_revoked: bool = False
+ memory_archived: str = ""
+ openxyos_talent_status: str = ""
+ openxyos_employment_category: str = ""
+
+ def to_dict(self) -> dict[str, Any]:
+ return asdict(self)
+
+ @classmethod
+ def from_dict(cls, data: dict[str, Any]) -> ColleagueRecord:
+ return cls(
+ slug=str(data.get("slug") or ""),
+ tenant_id=str(data.get("tenant_id") or ""),
+ name=str(data.get("name") or ""),
+ lifecycle=str(data.get("lifecycle") or "draft"),
+ workspace=str(data.get("workspace") or ""),
+ created_at=float(data.get("created_at") or time.time()),
+ updated_at=float(data.get("updated_at") or time.time()),
+ history=list(data.get("history") or []),
+ read_only=bool(data.get("read_only")),
+ cron_enabled=bool(data.get("cron_enabled")),
+ credentials_revoked=bool(data.get("credentials_revoked")),
+ 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 ""),
+ )
+
+
+class LifecycleStore:
+ def __init__(self, home: Path, tenant_id: str) -> None:
+ self.home = home
+ self.tenant_id = tenant_id or "default"
+ self.path = home / "tenants" / self.tenant_id / "employees" / "registry.json"
+ self.path.parent.mkdir(parents=True, exist_ok=True)
+
+ def _load(self) -> dict[str, Any]:
+ if not self.path.is_file():
+ return {"tenant_id": self.tenant_id, "colleagues": {}}
+ try:
+ raw = json.loads(self.path.read_text(encoding="utf-8"))
+ except json.JSONDecodeError:
+ return {"tenant_id": self.tenant_id, "colleagues": {}}
+ if not isinstance(raw, dict):
+ return {"tenant_id": self.tenant_id, "colleagues": {}}
+ colleagues = raw.get("colleagues")
+ if not isinstance(colleagues, dict):
+ raw["colleagues"] = {}
+ 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, record: ColleagueRecord) -> ColleagueRecord:
+ data = self._load()
+ data["colleagues"][record.slug] = record.to_dict()
+ self._save(data)
+ record_path = Path(record.workspace) / "lifecycle.json" if record.workspace else None
+ if record_path is not None:
+ record_path.parent.mkdir(parents=True, exist_ok=True)
+ record_path.write_text(json.dumps(record.to_dict(), indent=2) + "\n", encoding="utf-8")
+ return record
+
+ def get(self, slug: str) -> ColleagueRecord | None:
+ data = self._load()
+ raw = data["colleagues"].get(slug)
+ if not isinstance(raw, dict):
+ return None
+ return ColleagueRecord.from_dict(raw)
+
+ def list(self) -> list[ColleagueRecord]:
+ data = self._load()
+ rows = [
+ ColleagueRecord.from_dict(item)
+ for item in data["colleagues"].values()
+ if isinstance(item, dict)
+ ]
+ rows.sort(key=lambda item: item.updated_at, reverse=True)
+ return rows
diff --git a/src/octop/modules/org_os/lifecycle/transitions.py b/src/octop/modules/org_os/lifecycle/transitions.py
new file mode 100644
index 00000000..1fcd88ee
--- /dev/null
+++ b/src/octop/modules/org_os/lifecycle/transitions.py
@@ -0,0 +1,152 @@
+"""Lifecycle transitions with real workspace side effects."""
+
+from __future__ import annotations
+
+import json
+import shutil
+import time
+from pathlib import Path
+
+from octop.modules.org_os.compiler.telemetry import OPENXYOS_STATUS_MAP
+from octop.modules.org_os.lifecycle.store import (
+ LIFECYCLE_STATES,
+ ColleagueRecord,
+ LifecycleStore,
+)
+
+KEEP_ENV_KEYS = frozenset(
+ {"FREEOS_ORG_TENANT_ID", "FREEOS_COLLEAGUE_SLUG", "XYOS_BLUEPRINT_SCHEMA"}
+)
+
+ALLOWED: dict[str, frozenset[str]] = {
+ "draft": frozenset({"market", "offboard"}),
+ "market": frozenset({"recruit", "offboard"}),
+ "recruit": frozenset({"shadow", "offboard"}),
+ "shadow": frozenset({"active", "offboard"}),
+ "active": frozenset({"shadow", "offboard"}),
+ "offboard": frozenset(),
+}
+
+
+def _apply_flags(record: ColleagueRecord) -> None:
+ record.read_only = record.lifecycle in {"shadow", "offboard"}
+ record.cron_enabled = record.lifecycle == "active"
+ mapping = OPENXYOS_STATUS_MAP.get(record.lifecycle, OPENXYOS_STATUS_MAP["draft"])
+ record.openxyos_talent_status = str(mapping["talent"] or "")
+ record.openxyos_employment_category = str(mapping["employment_category"] or "")
+
+
+def _set_cron_enabled(workspace: Path, enabled: bool) -> None:
+ cron_file = workspace / "cron.json"
+ if not cron_file.is_file():
+ return
+ try:
+ jobs = json.loads(cron_file.read_text(encoding="utf-8"))
+ except json.JSONDecodeError:
+ return
+ if not isinstance(jobs, list):
+ return
+ for job in jobs:
+ if isinstance(job, dict):
+ job["enabled"] = bool(enabled)
+ cron_file.write_text(json.dumps(jobs, indent=2) + "\n", encoding="utf-8")
+
+
+def _revoke_credentials(workspace: Path) -> None:
+ env_file = workspace / ".env"
+ if not env_file.is_file():
+ return
+ lines: list[str] = []
+ for line in env_file.read_text(encoding="utf-8").splitlines():
+ if "=" in line and not line.lstrip().startswith("#"):
+ key, _sep, _value = line.partition("=")
+ if key in KEEP_ENV_KEYS:
+ lines.append(line)
+ else:
+ lines.append(f"{key}=")
+ else:
+ lines.append(line)
+ lines.append("# credentials revoked on offboard")
+ env_file.write_text("\n".join(lines) + "\n", encoding="utf-8")
+ (workspace / "OFFBOARDED").write_text("revoked\n", encoding="utf-8")
+
+
+def _archive_memory(workspace: Path, home: Path, tenant_id: str, slug: str) -> str:
+ memory = workspace / "MEMORY.md"
+ dest_dir = home / "tenants" / tenant_id / "org-knowledge" / "archived-colleagues"
+ dest_dir.mkdir(parents=True, exist_ok=True)
+ dest = dest_dir / f"{slug}-MEMORY.md"
+ if memory.is_file():
+ shutil.copy2(memory, dest)
+ else:
+ dest.write_text(f"# archived {slug}\n", encoding="utf-8")
+ return str(dest)
+
+
+def transition(
+ store: LifecycleStore,
+ slug: str,
+ target: str,
+ *,
+ reason: str = "",
+) -> ColleagueRecord:
+ if target not in LIFECYCLE_STATES:
+ raise ValueError(f"unknown lifecycle state: {target}")
+ record = store.get(slug)
+ if record is None:
+ raise ValueError(f"unknown colleague: {slug}")
+ if target == record.lifecycle:
+ return record
+ allowed = ALLOWED.get(record.lifecycle, frozenset())
+ if target not in allowed:
+ raise ValueError(f"cannot move {record.lifecycle} → {target}")
+ workspace = Path(record.workspace)
+ previous = record.lifecycle
+ record.lifecycle = target
+ record.updated_at = time.time()
+ record.history.append(
+ {
+ "from": previous,
+ "to": target,
+ "at": record.updated_at,
+ "reason": reason,
+ }
+ )
+ _apply_flags(record)
+ if workspace.is_dir():
+ _set_cron_enabled(workspace, record.cron_enabled)
+ if target == "offboard":
+ _revoke_credentials(workspace)
+ record.credentials_revoked = True
+ record.memory_archived = _archive_memory(
+ workspace, store.home, record.tenant_id, record.slug
+ )
+ return store.upsert(record)
+
+
+def register_compiled(
+ store: LifecycleStore,
+ *,
+ slug: str,
+ name: str,
+ workspace: Path,
+ lifecycle: str = "draft",
+) -> ColleagueRecord:
+ if lifecycle not in LIFECYCLE_STATES:
+ lifecycle = "draft"
+ now = time.time()
+ existing = store.get(slug)
+ record = ColleagueRecord(
+ slug=slug,
+ tenant_id=store.tenant_id,
+ name=name,
+ lifecycle=lifecycle,
+ workspace=str(workspace),
+ created_at=existing.created_at if existing else now,
+ updated_at=now,
+ history=list(existing.history) if existing else [],
+ )
+ _apply_flags(record)
+ if workspace.is_dir():
+ _set_cron_enabled(workspace, record.cron_enabled)
+ return store.upsert(record)
diff --git a/tests/fixtures/agent-blueprint.v1.json b/tests/fixtures/agent-blueprint.v1.json
new file mode 100644
index 00000000..6d25cd48
--- /dev/null
+++ b/tests/fixtures/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/unit/test_asset_loop.py b/tests/unit/test_asset_loop.py
new file mode 100644
index 00000000..ffe7e25a
--- /dev/null
+++ b/tests/unit/test_asset_loop.py
@@ -0,0 +1,100 @@
+"""Bidirectional asset factory: import catalog/blueprint and publish a pack."""
+
+from __future__ import annotations
+
+import json
+from pathlib import Path
+
+from octop.modules.org_os.assets.importer import import_openxyos_assets
+from octop.modules.org_os.assets.pack import ASSET_PACK_SCHEMA, publish_asset_pack
+from octop.modules.org_os.catalog import catalog_keys
+from octop.modules.org_os.governance.imported import load_imported_rules
+
+_FIXTURE = Path(__file__).resolve().parents[1] / "fixtures" / "agent-blueprint.v1.json"
+
+
+def test_import_from_sidecar_writes_policies(tmp_path: Path, monkeypatch) -> None:
+ class _Resp:
+ status_code = 200
+
+ def json(self) -> dict[str, object]:
+ return {"success": True, "data": [{"category": "pay", "allow": False}]}
+
+ class _Client:
+ def __init__(self, *args: object, **kwargs: object) -> None:
+ pass
+
+ def __enter__(self) -> _Client:
+ return self
+
+ def __exit__(self, *args: object) -> None:
+ return None
+
+ def get(self, url: str) -> _Resp:
+ assert url.endswith("/api/governance/permissions")
+ return _Resp()
+
+ import httpx
+
+ monkeypatch.setattr(httpx, "Client", _Client)
+ imported = import_openxyos_assets(
+ tmp_path, tenant_id="loop", sidecar_url="http://127.0.0.1:3780", from_sidecar=True
+ )
+ assert imported.policies
+ rules = load_imported_rules(tmp_path / "governance")
+ assert rules[0]["category"] == "pay"
+
+
+def test_import_catalog_and_blueprint(tmp_path: Path) -> None:
+ imported = import_openxyos_assets(
+ tmp_path,
+ tenant_id="loop",
+ catalog=True,
+ blueprint_path=_FIXTURE,
+ )
+ assert imported.skills == [f"org-{key}" for key in catalog_keys()]
+ assert "policy-analyst" in imported.employees
+ workspace = tmp_path / "tenants" / "loop" / "employees" / "policy-analyst"
+ assert (workspace / "SOUL.md").is_file()
+ assert (tmp_path / "org-skills" / "org-governance" / "SKILL.md").is_file()
+
+
+def test_import_policies(tmp_path: Path) -> None:
+ policies = tmp_path / "matrix.json"
+ policies.write_text(
+ json.dumps({"rules": [{"action": "delete", "allow": False}]}), encoding="utf-8"
+ )
+ imported = import_openxyos_assets(tmp_path, tenant_id="loop", policies_path=policies)
+ dest = Path(imported.policies)
+ assert dest.is_file()
+ raw = json.loads(dest.read_text(encoding="utf-8"))
+ assert raw["tenant_id"] == "loop"
+ assert raw["rules"][0]["allow"] is False
+
+
+def test_publish_asset_pack_includes_skills_mcp_agents(tmp_path: Path) -> None:
+ import_openxyos_assets(tmp_path, tenant_id="loop", catalog=True, blueprint_path=_FIXTURE)
+ workspace = tmp_path / "tenants" / "loop" / "employees" / "policy-analyst"
+ (workspace / ".env").write_text(
+ "FREEOS_ORG_TENANT_ID=loop\nSECRET=hunter2\nFREEOS_COLLEAGUE_SLUG=policy-analyst\n",
+ encoding="utf-8",
+ )
+ pack = publish_asset_pack(tmp_path, tenant_id="loop", out_dir=tmp_path / "pack")
+ assert pack.skill_count == len(catalog_keys())
+ assert pack.plugin_count == pack.skill_count
+ assert pack.mcp_count == 1
+ assert pack.agent_count >= 1
+ manifest = json.loads(pack.manifest.read_text(encoding="utf-8"))
+ assert manifest["schema"] == ASSET_PACK_SCHEMA
+ assert manifest["enabled_by_default"] is False
+ assert (pack.directory / "mcps" / "xyos-governance-mcp.json").is_file()
+ assert (pack.directory / "agents" / "policy-analyst" / "SOUL.md").is_file()
+ packed_env = (pack.directory / "agents" / "policy-analyst" / ".env").read_text(encoding="utf-8")
+ assert "hunter2" not in packed_env
+ assert "FREEOS_ORG_TENANT_ID=loop" in packed_env
+ employees = json.loads(
+ (pack.directory / "openxyos" / "org-employees.publish.json").read_text(encoding="utf-8")
+ )
+ assert employees["enabled_by_default"] is False
+ assert employees["employees"][0]["slug"] == "policy-analyst"
+ assert (pack.directory / "openxyos" / "org-talent.publish.json").is_file()
diff --git a/tests/unit/test_blueprint_compiler.py b/tests/unit/test_blueprint_compiler.py
new file mode 100644
index 00000000..3bd2bceb
--- /dev/null
+++ b/tests/unit/test_blueprint_compiler.py
@@ -0,0 +1,55 @@
+"""xyos2freeos blueprint compiler and reverse HR digest."""
+
+from __future__ import annotations
+
+import json
+from pathlib import Path
+
+import pytest
+
+from octop.modules.org_os.compiler.blueprint import parse_blueprint
+from octop.modules.org_os.compiler.compile import compile_blueprint
+from octop.modules.org_os.compiler.telemetry import (
+ export_capability_digest,
+)
+
+_FIXTURE = Path(__file__).resolve().parents[1] / "fixtures" / "agent-blueprint.v1.json"
+
+
+def test_parse_rejects_wrong_schema() -> None:
+ with pytest.raises(ValueError, match="unsupported"):
+ parse_blueprint(
+ {"schema": "other.v0", "name": "A", "positioning": "p", "capabilities": ["x"]}
+ )
+
+
+def test_compile_writes_soul_skills_knowledge_memory_env_cron(tmp_path: Path) -> None:
+ compiled = compile_blueprint(_FIXTURE, home=tmp_path, tenant_id="t1")
+ assert compiled.slug == "policy-analyst"
+ assert compiled.tenant_id == "t1"
+ assert compiled.soul.is_file()
+ soul = compiled.soul.read_text(encoding="utf-8")
+ assert "Policy Analyst" in soul
+ assert "xyos-governance-mcp" in soul
+ assert compiled.memory.is_file()
+ assert "Staff must not share" in compiled.memory.read_text(encoding="utf-8")
+ env = compiled.env_file.read_text(encoding="utf-8")
+ assert "FREEOS_ORG_TENANT_ID=t1" in env
+ cron = json.loads(compiled.cron_file.read_text(encoding="utf-8"))
+ assert cron[0]["schedule_spec"] == "0 9 * * 1-5"
+ assert cron[0]["enabled"] is False
+ assert any("policy-research" in str(path) for path in compiled.skill_files)
+ assert compiled.knowledge_files
+ assert "staff-handbook" in compiled.knowledge_files[0].name
+
+
+def test_capability_digest_maps_to_openxyos_profile(tmp_path: Path) -> None:
+ compiled = compile_blueprint(_FIXTURE, home=tmp_path, tenant_id="9")
+ digest = export_capability_digest(compiled.workspace, lifecycle="shadow")
+ profile = digest.to_openxyos_profile()
+ assert profile["employee_type"] == "ai"
+ assert "policy-research" in profile["skills"]
+ assert profile["employment_category"] == "probation"
+ assert profile["talent_status"] == "recruited"
+ assert digest.skill_count >= 2
+ assert digest.cron_count == 1
diff --git a/tests/unit/test_governance_mcp.py b/tests/unit/test_governance_mcp.py
index c45192c9..72278eff 100644
--- a/tests/unit/test_governance_mcp.py
+++ b/tests/unit/test_governance_mcp.py
@@ -7,6 +7,7 @@
import pytest
from octop.modules.org_os.governance.engine import GovernanceEngine
+from octop.modules.org_os.governance.imported import write_imported_policies
from octop.modules.org_os.governance.interceptor import (
GovernanceBlockedError,
GovernanceInterceptor,
@@ -167,6 +168,36 @@ def post(self, url: str, json: dict[str, object]) -> _Resp:
assert "matrix deny" in decision.reason
+def test_imported_deny_rule_blocks_without_sidecar(tmp_path: Path) -> None:
+ write_imported_policies(
+ tmp_path / "governance",
+ {"rules": [{"category": "delete", "allow": False, "reason": "matrix deny"}]},
+ tenant_id="loop",
+ source="test",
+ )
+ decision = _engine(tmp_path).evaluate(
+ PolicyRequest(tool_name="delete_employee", category="delete", auto_pause=False)
+ )
+ assert decision.status == "deny"
+ assert decision.execute is False
+ assert decision.rule_source == "imported-policies"
+ assert "matrix deny" in decision.reason
+
+
+def test_imported_allow_still_requires_human_approval(tmp_path: Path) -> None:
+ write_imported_policies(
+ tmp_path / "governance",
+ [{"category": "delete", "allow": True}],
+ tenant_id="loop",
+ )
+ decision = _engine(tmp_path).evaluate(
+ PolicyRequest(tool_name="delete_employee", category="delete")
+ )
+ assert decision.execute is False
+ assert decision.status == "pending"
+ assert decision.rule_source == "imported-policies"
+
+
def test_stdio_spec_is_stdio() -> None:
from octop.modules.org_os.governance.mcp_server import stdio_spec
diff --git a/tests/unit/test_lifecycle.py b/tests/unit/test_lifecycle.py
new file mode 100644
index 00000000..a06fac30
--- /dev/null
+++ b/tests/unit/test_lifecycle.py
@@ -0,0 +1,76 @@
+"""Digital-colleague lifecycle transitions and offboard side effects."""
+
+from __future__ import annotations
+
+from pathlib import Path
+
+import pytest
+
+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, transition
+
+_FIXTURE = Path(__file__).resolve().parents[1] / "fixtures" / "agent-blueprint.v1.json"
+
+
+def _spawn(tmp_path: Path) -> tuple[LifecycleStore, str, Path]:
+ compiled = compile_blueprint(_FIXTURE, home=tmp_path, tenant_id="acme")
+ store = LifecycleStore(tmp_path, "acme")
+ register_compiled(
+ store,
+ slug=compiled.slug,
+ name="Policy Analyst",
+ workspace=compiled.workspace,
+ lifecycle="draft",
+ )
+ return store, compiled.slug, compiled.workspace
+
+
+def test_happy_path_draft_to_active(tmp_path: Path) -> None:
+ store, slug, workspace = _spawn(tmp_path)
+ for state in ("market", "recruit", "shadow", "active"):
+ record = transition(store, slug, state)
+ assert record.lifecycle == state
+ active = store.get(slug)
+ assert active is not None
+ assert active.read_only is False
+ assert active.cron_enabled is True
+ cron = (workspace / "cron.json").read_text(encoding="utf-8")
+ assert '"enabled": true' in cron
+ assert active.openxyos_employment_category == "staff"
+
+
+def test_shadow_is_read_only(tmp_path: Path) -> None:
+ store, slug, _workspace = _spawn(tmp_path)
+ transition(store, slug, "market")
+ transition(store, slug, "recruit")
+ record = transition(store, slug, "shadow")
+ assert record.read_only is True
+ assert record.cron_enabled is False
+ assert record.openxyos_employment_category == "probation"
+
+
+def test_illegal_skip_rejected(tmp_path: Path) -> None:
+ store, slug, _workspace = _spawn(tmp_path)
+ with pytest.raises(ValueError, match="cannot move"):
+ transition(store, slug, "active")
+
+
+def test_offboard_revokes_and_archives_memory(tmp_path: Path) -> None:
+ store, slug, workspace = _spawn(tmp_path)
+ (workspace / ".env").write_text(
+ "FREEOS_ORG_TENANT_ID=acme\nFREEOS_ORG_SIDECAR_URL=http://127.0.0.1:3780\nSECRET=hunter2\n",
+ encoding="utf-8",
+ )
+ record = transition(store, slug, "offboard", reason="contract ended")
+ assert record.lifecycle == "offboard"
+ assert record.credentials_revoked is True
+ env = (workspace / ".env").read_text(encoding="utf-8")
+ assert "SECRET=" in env
+ assert "hunter2" not in env
+ assert (workspace / "OFFBOARDED").is_file()
+ assert Path(record.memory_archived).is_file()
+ assert "Policy Analyst" in Path(record.memory_archived).read_text(encoding="utf-8")
+ assert store.get(slug) is not None
+ with pytest.raises(ValueError, match="cannot move"):
+ transition(store, slug, "active")
diff --git a/tests/unit/test_paths.py b/tests/unit/test_paths.py
index baf0260a..b5504594 100644
--- a/tests/unit/test_paths.py
+++ b/tests/unit/test_paths.py
@@ -68,6 +68,13 @@ def test_governance_and_org_skills_dirs(tmp_path: Path) -> None:
assert p.ensure_org_skills_dir().is_dir()
+def test_tenant_employee_and_asset_pack_dirs(tmp_path: Path) -> None:
+ p = PathLayout(tmp_path / ".freeos")
+ assert p.tenant_dir("Acme Corp") == tmp_path / ".freeos" / "tenants" / "Acme-Corp"
+ assert p.ensure_employee_dir("t1", "policy-analyst").is_dir()
+ assert p.ensure_asset_packs_dir() == tmp_path / ".freeos" / "asset-packs"
+
+
def test_path_layout_from_env_defaults_to_dot_freeos(monkeypatch, tmp_path: Path) -> None:
monkeypatch.delenv("OCTOP_HOME", raising=False)
monkeypatch.delenv("FREEOS_HOME", raising=False)