diff --git a/.env.example b/.env.example
index 14657a6..f13836e 100644
--- a/.env.example
+++ b/.env.example
@@ -88,3 +88,37 @@ ERROR_LOG_ENABLED=1
# genuine bugs. Turn it on while debugging a misbehaving client.
# 5xx responses are always recorded, whatever this is set to.
ERROR_LOG_CLIENT_ERRORS=0
+
+# ===========================================================================
+# v2 workstream settings. Each agent edits ONLY its own block.
+# ===========================================================================
+# >>> agent-1 (org platform)
+MCP_GATEWAY_HOST=127.0.0.1
+MCP_GATEWAY_PORT=8765
+MCP_TOKEN_TTL_DAYS=90
+MEMORY_ORG_CHAR_CAP=60000
+PLATFORM_INVITE_TTL_HOURS=72
+# <<< agent-1
+
+# >>> agent-2 (AI workforce engine)
+ENGINE_MAX_TOOL_CALLS_PER_TURN=40
+ENGINE_TURN_TIMEOUT_SECONDS=900
+ENGINE_MAX_CYCLES=6
+ENGINE_MAX_EMPLOYEES=20
+ENGINE_TICK_SECONDS=10
+# <<< agent-2
+
+# >>> agent-3 (delivery & infra)
+SANDBOX_DOCKER_BIN=docker
+SANDBOX_WORKSPACES_ROOT=./.data/workspaces
+SANDBOX_DEFAULT_CPUS=1
+SANDBOX_DEFAULT_MEMORY_MB=1024
+DEMO_PORT_START=8100
+DEMO_PORT_END=8999
+DELIVERY_POLL_SECONDS=60
+DEPLOY_ALLOW_PRIVATE_HOSTS=false
+ARTIFACTS_ROOT=./.data/artifacts
+# fake (default, no git on the host) | local (real git workspaces)
+GIT_WORKSPACE=fake
+ALLOW_CUSTOM_DOMAINS=false
+# <<< agent-3
diff --git a/.gitignore b/.gitignore
index 6c45d1d..7f18480 100644
--- a/.gitignore
+++ b/.gitignore
@@ -54,3 +54,6 @@ htmlcov/
# agent working memory — session state, not project source
.workbuddy-ai/
+
+# v2 runtime data (workspaces, artifacts)
+.data/
diff --git a/apps/api/alembic/versions/a0f0e1d2c3b4_v2_foundation.py b/apps/api/alembic/versions/a0f0e1d2c3b4_v2_foundation.py
new file mode 100644
index 0000000..5553f20
--- /dev/null
+++ b/apps/api/alembic/versions/a0f0e1d2c3b4_v2_foundation.py
@@ -0,0 +1,126 @@
+"""v2 foundation: app profile, one app per org, external git, deploy targets, model grants
+
+Revision ID: a0f0e1d2c3b4
+Revises: d4e5f6a7b8c9
+Create Date: 2026-10-13 22:30:00.000000
+"""
+
+from __future__ import annotations
+
+from collections.abc import Sequence
+
+import sqlalchemy as sa
+from alembic import op
+
+revision: str = "a0f0e1d2c3b4"
+down_revision: str | None = "d4e5f6a7b8c9"
+branch_labels: str | Sequence[str] | None = None
+depends_on: str | Sequence[str] | None = None
+
+_PROFILE_COLUMNS: list[sa.Column] = [
+ sa.Column("app_type", sa.String(length=32), nullable=True),
+ sa.Column("language", sa.String(length=32), nullable=True),
+ sa.Column("framework", sa.String(length=48), nullable=True),
+ sa.Column("runtime_version", sa.String(length=32), nullable=True),
+ sa.Column("package_manager", sa.String(length=32), nullable=True),
+ sa.Column("setup_command", sa.Text(), nullable=True),
+ sa.Column("build_command", sa.Text(), nullable=True),
+ sa.Column("test_command", sa.Text(), nullable=True),
+ sa.Column("start_command", sa.Text(), nullable=True),
+ sa.Column("app_port", sa.Integer(), nullable=True),
+ sa.Column("healthcheck_path", sa.String(length=255), nullable=False, server_default="/"),
+ sa.Column("git_source", sa.String(length=16), nullable=False, server_default="platform"),
+ sa.Column("external_repo_url", sa.String(length=512), nullable=True),
+ sa.Column("wizard_completed_at", sa.DateTime(timezone=True), nullable=True),
+]
+
+
+def upgrade() -> None:
+ with op.batch_alter_table("projects") as batch:
+ for column in _PROFILE_COLUMNS:
+ batch.add_column(column)
+ batch.create_unique_constraint("uq_project_one_per_org", ["organization_id"])
+
+ op.create_table(
+ "project_external_git",
+ sa.Column(
+ "project_id",
+ sa.String(length=32),
+ sa.ForeignKey("projects.id", ondelete="CASCADE"),
+ primary_key=True,
+ ),
+ sa.Column("auth_type", sa.String(length=16), nullable=False, server_default="none"),
+ sa.Column("username", sa.String(length=160), nullable=True),
+ sa.Column("secret_encrypted", sa.Text(), nullable=True),
+ sa.Column("known_hosts", sa.Text(), nullable=True),
+ sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
+ )
+
+ op.create_table(
+ "deploy_targets",
+ sa.Column("id", sa.String(length=32), primary_key=True),
+ sa.Column(
+ "organization_id",
+ sa.String(length=32),
+ sa.ForeignKey("organizations.id", ondelete="CASCADE"),
+ nullable=False,
+ ),
+ sa.Column(
+ "project_id",
+ sa.String(length=32),
+ sa.ForeignKey("projects.id", ondelete="CASCADE"),
+ nullable=False,
+ ),
+ sa.Column("kind", sa.String(length=8), nullable=False),
+ sa.Column("host", sa.String(length=255), nullable=False),
+ sa.Column("port", sa.Integer(), nullable=False),
+ sa.Column("username", sa.String(length=160), nullable=False),
+ sa.Column("auth_type", sa.String(length=16), nullable=False, server_default="password"),
+ sa.Column("secret_encrypted", sa.Text(), nullable=True),
+ sa.Column("remote_path", sa.String(length=512), nullable=False),
+ sa.Column("post_deploy_command", sa.Text(), nullable=True),
+ sa.Column("host_fingerprint", sa.String(length=255), nullable=True),
+ sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.true()),
+ sa.Column("last_tested_at", sa.DateTime(timezone=True), nullable=True),
+ sa.Column("last_test_ok", sa.Boolean(), nullable=True),
+ sa.Column("last_test_message", sa.String(length=500), nullable=True),
+ sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
+ )
+ op.create_index("ix_deploy_targets_organization_id", "deploy_targets", ["organization_id"])
+ op.create_index("ix_deploy_target_project", "deploy_targets", ["project_id"])
+
+ op.create_table(
+ "org_model_grants",
+ sa.Column("id", sa.String(length=32), primary_key=True),
+ sa.Column(
+ "organization_id",
+ sa.String(length=32),
+ sa.ForeignKey("organizations.id", ondelete="CASCADE"),
+ nullable=False,
+ ),
+ sa.Column(
+ "ai_model_id",
+ sa.String(length=32),
+ sa.ForeignKey("ai_models.id", ondelete="CASCADE"),
+ nullable=False,
+ ),
+ sa.Column("daily_credit_cap", sa.Float(), nullable=True),
+ sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.true()),
+ sa.Column("created_by_user_id", sa.String(length=32), sa.ForeignKey("users.id")),
+ sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
+ sa.UniqueConstraint("organization_id", "ai_model_id", name="uq_org_model_grant"),
+ )
+ op.create_index("ix_org_model_grants_organization_id", "org_model_grants", ["organization_id"])
+
+
+def downgrade() -> None:
+ op.drop_index("ix_org_model_grants_organization_id", table_name="org_model_grants")
+ op.drop_table("org_model_grants")
+ op.drop_index("ix_deploy_target_project", table_name="deploy_targets")
+ op.drop_index("ix_deploy_targets_organization_id", table_name="deploy_targets")
+ op.drop_table("deploy_targets")
+ op.drop_table("project_external_git")
+ with op.batch_alter_table("projects") as batch:
+ batch.drop_constraint("uq_project_one_per_org", type_="unique")
+ for column in reversed(_PROFILE_COLUMNS):
+ batch.drop_column(column.name)
diff --git a/apps/api/app/application/container.py b/apps/api/app/application/container.py
index ab3af4c..0a684a2 100644
--- a/apps/api/app/application/container.py
+++ b/apps/api/app/application/container.py
@@ -15,6 +15,13 @@ from __future__ import annotations
from sqlalchemy.ext.asyncio import AsyncSession
+from app.application.container_delivery import (
+ build_delivery_hooks,
+ build_deploy_target_tester,
+ build_repo_workspace,
+)
+from app.application.container_engine import build_workforce_bootstrap
+from app.application.container_org import build_memory, build_model_grants
from app.application.services.billing_service import BillingService
from app.application.services.knowledge_service import KnowledgeService
from app.application.services.project_service import ProjectService
@@ -158,6 +165,12 @@ def build_sandbox_service(session: AsyncSession) -> SandboxService:
__all__ = [
"build_billing_service",
+ "build_delivery_hooks",
+ "build_deploy_target_tester",
+ "build_memory",
+ "build_model_grants",
+ "build_repo_workspace",
+ "build_workforce_bootstrap",
"build_knowledge_service",
"build_project_service",
"build_sandbox_service",
diff --git a/apps/api/app/application/container_delivery.py b/apps/api/app/application/container_delivery.py
new file mode 100644
index 0000000..51063b8
--- /dev/null
+++ b/apps/api/app/application/container_delivery.py
@@ -0,0 +1,29 @@
+"""Wiring for the delivery workstream (repo workspaces, delivery hooks, deploy tester).
+
+Foundation defaults are in-memory fakes. The delivery agent replaces the bodies (behind
+settings) with the real adapters; the function names and return types are FROZEN.
+"""
+
+from __future__ import annotations
+
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.application.ports.delivery_hooks import DeliveryHooks, NullDeliveryHooks
+from app.application.ports.deploy_target import DeployTargetTester, FakeDeployTargetTester
+from app.application.ports.repo_workspace import FakeRepoWorkspace, RepoWorkspacePort
+
+_fake_workspace = FakeRepoWorkspace()
+_null_hooks = NullDeliveryHooks()
+_fake_deploy_tester = FakeDeployTargetTester()
+
+
+def build_repo_workspace(session: AsyncSession) -> RepoWorkspacePort:
+ return _fake_workspace
+
+
+def build_delivery_hooks(session: AsyncSession) -> DeliveryHooks:
+ return _null_hooks
+
+
+def build_deploy_target_tester(session: AsyncSession) -> DeployTargetTester:
+ return _fake_deploy_tester
diff --git a/apps/api/app/application/container_engine.py b/apps/api/app/application/container_engine.py
new file mode 100644
index 0000000..1fdc496
--- /dev/null
+++ b/apps/api/app/application/container_engine.py
@@ -0,0 +1,17 @@
+"""Wiring for the AI-workforce workstream (team bootstrap).
+
+Foundation default: a no-op. The engine agent replaces the body with the real bootstrap;
+the function name and return type are FROZEN.
+"""
+
+from __future__ import annotations
+
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.application.ports.workforce_bootstrap import NullWorkforceBootstrap, WorkforceBootstrapPort
+
+_null_bootstrap = NullWorkforceBootstrap()
+
+
+def build_workforce_bootstrap(session: AsyncSession) -> WorkforceBootstrapPort:
+ return _null_bootstrap
diff --git a/apps/api/app/application/container_org.py b/apps/api/app/application/container_org.py
new file mode 100644
index 0000000..2a3acfc
--- /dev/null
+++ b/apps/api/app/application/container_org.py
@@ -0,0 +1,44 @@
+"""Wiring for the org-platform workstream (memory, model grants).
+
+Foundation defaults: no memory, and every active model granted to every org (today's
+behaviour). The org-platform agent replaces the bodies with the real services; the
+function names and return types are FROZEN so other workstreams can call them now.
+"""
+
+from __future__ import annotations
+
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.application.ports.memory import MemoryPort, NullMemory
+from app.application.ports.model_grants import (
+ AllActiveModelsGrants,
+ GrantedModel,
+ ModelGrantPort,
+ tier_for,
+)
+from app.infrastructure.db.models import AiModelRow
+
+_null_memory = NullMemory()
+
+
+def build_memory(session: AsyncSession) -> MemoryPort:
+ return _null_memory
+
+
+def build_model_grants(session: AsyncSession) -> ModelGrantPort:
+ async def _all_active() -> list[GrantedModel]:
+ rows = (await session.scalars(select(AiModelRow).where(AiModelRow.is_active))).all()
+ return [
+ GrantedModel(
+ ai_model_id=row.id,
+ tier=tier_for(
+ capabilities=list(row.capabilities or []),
+ output_price_per_1m=row.output_price_per_1m,
+ ),
+ capabilities=tuple(row.capabilities or []),
+ )
+ for row in rows
+ ]
+
+ return AllActiveModelsGrants(_all_active)
diff --git a/apps/api/app/application/ports/delivery_hooks.py b/apps/api/app/application/ports/delivery_hooks.py
new file mode 100644
index 0000000..5544439
--- /dev/null
+++ b/apps/api/app/application/ports/delivery_hooks.py
@@ -0,0 +1,48 @@
+"""Delivery hooks — how the org wizard and the engine tell the delivery side to act.
+
+Implemented by the delivery workstream; called by the org-platform workstream (app created)
+and the engine (branch pushed). FROZEN contract: see docs/plan/PLAN-v2.md §5.
+"""
+
+from __future__ import annotations
+
+from typing import Protocol, runtime_checkable
+
+
+@runtime_checkable
+class DeliveryHooks(Protocol):
+ async def on_app_created(self, *, organization_id: str, project_id: str) -> None: ...
+
+ async def on_branch_pushed(
+ self,
+ *,
+ organization_id: str,
+ project_id: str,
+ branch: str,
+ sha: str,
+ source: str, # "engine" | "poll" | "manual"
+ ) -> None: ...
+
+
+class NullDeliveryHooks:
+ """Records calls and does nothing else (default until the real hooks are wired)."""
+
+ def __init__(self) -> None:
+ self.created: list[dict] = []
+ self.pushed: list[dict] = []
+
+ async def on_app_created(self, *, organization_id: str, project_id: str) -> None:
+ self.created.append({"organization_id": organization_id, "project_id": project_id})
+
+ async def on_branch_pushed(
+ self, *, organization_id: str, project_id: str, branch: str, sha: str, source: str
+ ) -> None:
+ self.pushed.append(
+ {
+ "organization_id": organization_id,
+ "project_id": project_id,
+ "branch": branch,
+ "sha": sha,
+ "source": source,
+ }
+ )
diff --git a/apps/api/app/application/ports/deploy_target.py b/apps/api/app/application/ports/deploy_target.py
new file mode 100644
index 0000000..dd0b98c
--- /dev/null
+++ b/apps/api/app/application/ports/deploy_target.py
@@ -0,0 +1,51 @@
+"""Deploy target tester — checks an SSH/SFTP/FTP/FTPS production target.
+
+Implemented by the delivery workstream; called by the org wizard. FROZEN contract:
+see docs/plan/PLAN-v2.md §5.
+"""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+from typing import Protocol, runtime_checkable
+
+DEPLOY_KINDS = ("ssh", "sftp", "ftp", "ftps")
+
+
+@dataclass(slots=True, kw_only=True)
+class DeployTargetSpec:
+ kind: str
+ host: str
+ port: int
+ username: str
+ auth_type: str = "password" # password | key
+ secret: str | None = None # plaintext, in memory for one call only
+ remote_path: str = "/"
+ known_fingerprint: str | None = None
+
+
+@dataclass(slots=True, kw_only=True)
+class DeployCheck:
+ ok: bool
+ #: Plain-language Persian message; never contains credentials.
+ message: str = ""
+ #: Host key fingerprint seen (SSH/SFTP), to be pinned on first success.
+ fingerprint: str | None = None
+
+
+@runtime_checkable
+class DeployTargetTester(Protocol):
+ async def test_connection(self, *, target: DeployTargetSpec) -> DeployCheck: ...
+
+
+class FakeDeployTargetTester:
+ """OK unless the host contains ``fail``."""
+
+ def __init__(self) -> None:
+ self.calls: list[DeployTargetSpec] = []
+
+ async def test_connection(self, *, target: DeployTargetSpec) -> DeployCheck:
+ self.calls.append(target)
+ if "fail" in target.host:
+ return DeployCheck(ok=False, message="اتصال برقرار نشد")
+ return DeployCheck(ok=True, message="اتصال موفق", fingerprint="SHA256:fake")
diff --git a/apps/api/app/application/ports/memory.py b/apps/api/app/application/ports/memory.py
new file mode 100644
index 0000000..93d02c5
--- /dev/null
+++ b/apps/api/app/application/ports/memory.py
@@ -0,0 +1,76 @@
+"""Org memory port — the self-improving memory of an organization.
+
+Implemented by the org-platform workstream; used by the engine. FROZEN contract:
+see docs/plan/PLAN-v2.md §5.
+"""
+
+from __future__ import annotations
+
+from dataclasses import dataclass, field
+from typing import Protocol, runtime_checkable
+
+MEMORY_KINDS = ("rule", "preference", "lesson", "fact", "code_map")
+MEMORY_SOURCES = ("user", "manager", "system", "reflection")
+
+
+@dataclass(slots=True, kw_only=True)
+class TaskOutcome:
+ task_id: str
+ title: str
+ status: str # completed | failed | cancelled
+ summary: str = ""
+ changed_files: list[str] = field(default_factory=list)
+ cycles: int = 0
+ gate_failures: list[str] = field(default_factory=list)
+
+
+@runtime_checkable
+class MemoryPort(Protocol):
+ async def build_context(
+ self, *, organization_id: str, role: str, query: str, budget_chars: int = 2500
+ ) -> str: ...
+
+ async def record(
+ self,
+ *,
+ organization_id: str,
+ kind: str,
+ content: str,
+ source: str,
+ evidence_task_id: str | None = None,
+ key: str | None = None,
+ ) -> str | None: ...
+
+ async def on_task_finished(self, *, organization_id: str, task: TaskOutcome) -> None: ...
+
+
+class NullMemory:
+ """No memory: empty context, nothing stored (default until the real service is wired)."""
+
+ def __init__(self) -> None:
+ self.recorded: list[dict] = []
+ self.finished: list[TaskOutcome] = []
+
+ async def build_context(
+ self, *, organization_id: str, role: str, query: str, budget_chars: int = 2500
+ ) -> str:
+ return ""
+
+ async def record(
+ self,
+ *,
+ organization_id: str,
+ kind: str,
+ content: str,
+ source: str,
+ evidence_task_id: str | None = None,
+ key: str | None = None,
+ ) -> str | None:
+ self.recorded.append(
+ {"organization_id": organization_id, "kind": kind, "content": content,
+ "source": source, "evidence_task_id": evidence_task_id, "key": key}
+ )
+ return None
+
+ async def on_task_finished(self, *, organization_id: str, task: TaskOutcome) -> None:
+ self.finished.append(task)
diff --git a/apps/api/app/application/ports/model_grants.py b/apps/api/app/application/ports/model_grants.py
new file mode 100644
index 0000000..b0adc69
--- /dev/null
+++ b/apps/api/app/application/ports/model_grants.py
@@ -0,0 +1,50 @@
+"""Model grants port — which models the platform owner lets an organization use.
+
+Implemented by the org-platform workstream; used by the engine. FROZEN contract:
+see docs/plan/PLAN-v2.md §5. ``tier`` is a neutral label; users never see model names.
+"""
+
+from __future__ import annotations
+
+from dataclasses import dataclass, field
+from typing import Protocol, runtime_checkable
+
+TIERS = ("fast", "balanced", "strong")
+
+
+@dataclass(slots=True, frozen=True, kw_only=True)
+class GrantedModel:
+ ai_model_id: str
+ tier: str = "balanced" # fast | balanced | strong
+ capabilities: tuple[str, ...] = field(default_factory=tuple)
+ daily_credit_cap: float | None = None
+
+
+def tier_for(*, capabilities: list[str] | tuple[str, ...], output_price_per_1m: float) -> str:
+ """Neutral tier label from capabilities and price (never from the model name)."""
+ if "reasoning" in capabilities:
+ return "strong"
+ return "fast" if output_price_per_1m < 2.0 else "balanced"
+
+
+@runtime_checkable
+class ModelGrantPort(Protocol):
+ async def granted_models(self, *, organization_id: str) -> list[GrantedModel]: ...
+
+ async def is_granted(self, *, organization_id: str, ai_model_id: str) -> bool: ...
+
+
+class AllActiveModelsGrants:
+ """Today's behaviour: every active model is usable by every organization.
+
+ Reads active models through a callable so the application layer needs no ORM import.
+ """
+
+ def __init__(self, list_active_models) -> None: # noqa: ANN001 - async callable
+ self._list_active = list_active_models
+
+ async def granted_models(self, *, organization_id: str) -> list[GrantedModel]:
+ return list(await self._list_active())
+
+ async def is_granted(self, *, organization_id: str, ai_model_id: str) -> bool:
+ return any(m.ai_model_id == ai_model_id for m in await self._list_active())
diff --git a/apps/api/app/application/ports/repo_workspace.py b/apps/api/app/application/ports/repo_workspace.py
new file mode 100644
index 0000000..fd68ac4
--- /dev/null
+++ b/apps/api/app/application/ports/repo_workspace.py
@@ -0,0 +1,185 @@
+"""Repo workspace port — the PLATFORM runs git on the host; the AI never does.
+
+Implemented by the delivery workstream (``LocalGitWorkspaceAdapter``), used by the engine.
+``FakeRepoWorkspace`` is an in-memory implementation for tests and for wiring before the
+real adapter exists. FROZEN contract: see docs/plan/PLAN-v2.md §5.
+"""
+
+from __future__ import annotations
+
+from dataclasses import dataclass, field
+from typing import Protocol, runtime_checkable
+
+
+@dataclass(slots=True, frozen=True, kw_only=True)
+class WorkspaceRef:
+ project_id: str
+ host_path: str
+ sandbox_path: str = "/workspace"
+ default_branch: str = "dev"
+
+
+@dataclass(slots=True, frozen=True, kw_only=True)
+class WorktreeRef:
+ project_id: str
+ task_id: str
+ branch: str
+ host_path: str
+ #: Path of this worktree INSIDE the workspace container.
+ sandbox_path: str
+
+
+@dataclass(slots=True, kw_only=True)
+class DiffResult:
+ text: str = ""
+ changed_files: list[str] = field(default_factory=list)
+ #: Paths of symlinks that point outside the repo (guard input).
+ escaping_symlinks: list[str] = field(default_factory=list)
+ largest_file_bytes: int = 0
+
+ @property
+ def is_empty(self) -> bool:
+ return not self.changed_files
+
+
+@dataclass(slots=True, kw_only=True)
+class MergeOutcome:
+ ok: bool
+ sha: str | None = None
+ conflict_files: list[str] = field(default_factory=list)
+ #: True when the base moved, so the engine must re-run the tests.
+ base_changed: bool = False
+ message: str = ""
+
+
+@dataclass(slots=True, kw_only=True)
+class PushOutcome:
+ ok: bool
+ sha: str | None = None
+ rejected: bool = False
+ message: str = ""
+
+
+@dataclass(slots=True, kw_only=True)
+class RemoteCredentials:
+ auth_type: str = "none" # none | token | ssh_key
+ username: str | None = None
+ secret: str | None = None # plaintext, only ever held in memory for one call
+
+
+@dataclass(slots=True, kw_only=True)
+class RemoteCheck:
+ ok: bool
+ default_branch: str | None = None
+ branches: list[str] = field(default_factory=list)
+ #: Plain-language Persian message; never contains credentials or raw stderr.
+ message: str = ""
+
+
+@runtime_checkable
+class RepoWorkspacePort(Protocol):
+ async def ensure_workspace(self, *, project_id: str) -> WorkspaceRef: ...
+
+ async def create_task_worktree(
+ self, *, project_id: str, task_id: str, base: str = "dev"
+ ) -> WorktreeRef: ...
+
+ async def diff(self, *, worktree: WorktreeRef) -> DiffResult: ...
+
+ async def commit(self, *, worktree: WorktreeRef, message: str, author: str) -> str | None: ...
+
+ async def sync_with_base(self, *, worktree: WorktreeRef) -> MergeOutcome: ...
+
+ async def merge_into(
+ self, *, project_id: str, branch: str, target: str = "dev"
+ ) -> MergeOutcome: ...
+
+ async def push(self, *, project_id: str, branch: str) -> PushOutcome: ...
+
+ async def remove_worktree(self, *, worktree: WorktreeRef) -> None: ...
+
+ async def head(self, *, project_id: str, branch: str) -> str | None: ...
+
+ async def test_remote(self, *, url: str, credentials: RemoteCredentials) -> RemoteCheck: ...
+
+
+class FakeRepoWorkspace:
+ """In-memory ``RepoWorkspacePort`` that records every call.
+
+ Tests can pre-load ``diffs`` (task_id → DiffResult), ``merge_outcomes`` /
+ ``push_outcomes`` (queues consumed in order) and ``remote_checks`` (url → RemoteCheck).
+ """
+
+ def __init__(self) -> None:
+ self.calls: list[tuple[str, dict]] = []
+ self.diffs: dict[str, DiffResult] = {}
+ self.merge_outcomes: list[MergeOutcome] = []
+ self.sync_outcomes: list[MergeOutcome] = []
+ self.push_outcomes: list[PushOutcome] = []
+ self.remote_checks: dict[str, RemoteCheck] = {}
+ self.heads: dict[tuple[str, str], str] = {}
+ self._commits = 0
+
+ def _rec(self, name: str, **kw: object) -> None:
+ self.calls.append((name, kw))
+
+ async def ensure_workspace(self, *, project_id: str) -> WorkspaceRef:
+ self._rec("ensure_workspace", project_id=project_id)
+ return WorkspaceRef(project_id=project_id, host_path=f"/fake/{project_id}/work")
+
+ async def create_task_worktree(
+ self, *, project_id: str, task_id: str, base: str = "dev"
+ ) -> WorktreeRef:
+ self._rec("create_task_worktree", project_id=project_id, task_id=task_id, base=base)
+ return WorktreeRef(
+ project_id=project_id,
+ task_id=task_id,
+ branch=f"task/{task_id}",
+ host_path=f"/fake/{project_id}/work/.worktrees/{task_id}",
+ sandbox_path=f"/workspace/.worktrees/{task_id}",
+ )
+
+ async def diff(self, *, worktree: WorktreeRef) -> DiffResult:
+ self._rec("diff", task_id=worktree.task_id)
+ return self.diffs.get(worktree.task_id, DiffResult())
+
+ async def commit(self, *, worktree: WorktreeRef, message: str, author: str) -> str | None:
+ self._rec("commit", task_id=worktree.task_id, message=message, author=author)
+ if self.diffs.get(worktree.task_id, DiffResult()).is_empty:
+ return None
+ self._commits += 1
+ sha = f"{self._commits:040x}"
+ self.heads[(worktree.project_id, worktree.branch)] = sha
+ return sha
+
+ async def sync_with_base(self, *, worktree: WorktreeRef) -> MergeOutcome:
+ self._rec("sync_with_base", task_id=worktree.task_id)
+ return self.sync_outcomes.pop(0) if self.sync_outcomes else MergeOutcome(ok=True)
+
+ async def merge_into(
+ self, *, project_id: str, branch: str, target: str = "dev"
+ ) -> MergeOutcome:
+ self._rec("merge_into", project_id=project_id, branch=branch, target=target)
+ if self.merge_outcomes:
+ return self.merge_outcomes.pop(0)
+ sha = self.heads.get((project_id, branch)) or "0" * 40
+ self.heads[(project_id, target)] = sha
+ return MergeOutcome(ok=True, sha=sha)
+
+ async def push(self, *, project_id: str, branch: str) -> PushOutcome:
+ self._rec("push", project_id=project_id, branch=branch)
+ if self.push_outcomes:
+ return self.push_outcomes.pop(0)
+ return PushOutcome(ok=True, sha=self.heads.get((project_id, branch)))
+
+ async def remove_worktree(self, *, worktree: WorktreeRef) -> None:
+ self._rec("remove_worktree", task_id=worktree.task_id)
+
+ async def head(self, *, project_id: str, branch: str) -> str | None:
+ return self.heads.get((project_id, branch))
+
+ async def test_remote(self, *, url: str, credentials: RemoteCredentials) -> RemoteCheck:
+ self._rec("test_remote", url=url, auth_type=credentials.auth_type)
+ return self.remote_checks.get(
+ url, RemoteCheck(ok=True, default_branch="main", branches=["main"])
+ )
diff --git a/apps/api/app/application/ports/workforce_bootstrap.py b/apps/api/app/application/ports/workforce_bootstrap.py
new file mode 100644
index 0000000..d971751
--- /dev/null
+++ b/apps/api/app/application/ports/workforce_bootstrap.py
@@ -0,0 +1,22 @@
+"""Workforce bootstrap port — creates the starting AI team of a new organization.
+
+Implemented by the engine workstream; called by the org wizard after commit.
+FROZEN contract: see docs/plan/PLAN-v2.md §5.
+"""
+
+from __future__ import annotations
+
+from typing import Protocol, runtime_checkable
+
+
+@runtime_checkable
+class WorkforceBootstrapPort(Protocol):
+ async def bootstrap_team(self, *, organization_id: str, project_id: str) -> None: ...
+
+
+class NullWorkforceBootstrap:
+ def __init__(self) -> None:
+ self.calls: list[dict] = []
+
+ async def bootstrap_team(self, *, organization_id: str, project_id: str) -> None:
+ self.calls.append({"organization_id": organization_id, "project_id": project_id})
diff --git a/apps/api/app/core/config.py b/apps/api/app/core/config.py
index 5de0487..3bfeb57 100644
--- a/apps/api/app/core/config.py
+++ b/apps/api/app/core/config.py
@@ -127,6 +127,36 @@ class Settings(BaseSettings):
#: 5xx responses are recorded regardless of this setting.
error_log_client_errors: bool = False
+ # >>> agent-1 (org platform): add settings ONLY between these markers
+ mcp_gateway_host: str = "127.0.0.1"
+ mcp_gateway_port: int = 8765
+ mcp_token_ttl_days: int = 90
+ memory_org_char_cap: int = 60000
+ platform_invite_ttl_hours: int = 72
+ # <<< agent-1
+
+ # >>> agent-2 (AI workforce engine): add settings ONLY between these markers
+ engine_max_tool_calls_per_turn: int = 40
+ engine_turn_timeout_seconds: int = 900
+ engine_max_cycles: int = 6
+ engine_max_employees: int = 20
+ engine_tick_seconds: float = 10.0
+ # <<< agent-2
+
+ # >>> agent-3 (delivery & infra): add settings ONLY between these markers
+ sandbox_docker_bin: str = "docker"
+ sandbox_workspaces_root: str = "./.data/workspaces"
+ sandbox_default_cpus: float = 1.0
+ sandbox_default_memory_mb: int = 1024
+ demo_port_start: int = 8100
+ demo_port_end: int = 8999
+ delivery_poll_seconds: int = 60
+ deploy_allow_private_hosts: bool = False
+ artifacts_root: str = "./.data/artifacts"
+ git_workspace: str = "fake" # fake | local
+ allow_custom_domains: bool = False
+ # <<< agent-3
+
@property
def cors_origin_list(self) -> list[str]:
return [o.strip() for o in self.cors_origins.split(",") if o.strip()]
diff --git a/apps/api/app/domain/projects/profile.py b/apps/api/app/domain/projects/profile.py
new file mode 100644
index 0000000..cf19deb
--- /dev/null
+++ b/apps/api/app/domain/projects/profile.py
@@ -0,0 +1,57 @@
+"""The application profile of an organization's single app (filled by the wizard)."""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+from typing import Any
+
+APP_TYPES = ("web_app", "api", "static_site", "bot", "worker", "other")
+GIT_SOURCES = ("platform", "external")
+
+
+@dataclass(slots=True, frozen=True, kw_only=True)
+class AppProfile:
+ """Everything the engine, the sandbox and CI/CD need to build and run the app.
+
+ Secrets never live here: external git credentials and deploy targets have
+ their own encrypted tables.
+ """
+
+ project_id: str
+ app_type: str | None = None
+ language: str | None = None
+ framework: str | None = None
+ runtime_version: str | None = None
+ package_manager: str | None = None
+ setup_command: str | None = None
+ build_command: str | None = None
+ test_command: str | None = None
+ start_command: str | None = None
+ app_port: int | None = None
+ healthcheck_path: str = "/"
+ git_source: str = "platform"
+ external_repo_url: str | None = None
+ default_branch: str = "main"
+ wizard_completed: bool = False
+
+ @classmethod
+ def from_row(cls, row: Any) -> AppProfile:
+ """Build from a ``ProjectRow`` (duck-typed so the domain stays ORM-free)."""
+ return cls(
+ project_id=row.id,
+ app_type=row.app_type,
+ language=row.language,
+ framework=row.framework,
+ runtime_version=row.runtime_version,
+ package_manager=row.package_manager,
+ setup_command=row.setup_command,
+ build_command=row.build_command,
+ test_command=row.test_command,
+ start_command=row.start_command,
+ app_port=row.app_port,
+ healthcheck_path=row.healthcheck_path or "/",
+ git_source=row.git_source or "platform",
+ external_repo_url=row.external_repo_url,
+ default_branch=row.default_branch or "main",
+ wizard_completed=row.wizard_completed_at is not None,
+ )
diff --git a/apps/api/app/infrastructure/db/columns.py b/apps/api/app/infrastructure/db/columns.py
new file mode 100644
index 0000000..d624019
--- /dev/null
+++ b/apps/api/app/infrastructure/db/columns.py
@@ -0,0 +1,18 @@
+"""Column helpers shared by every ORM module (kept here to avoid import cycles)."""
+
+from __future__ import annotations
+
+from datetime import datetime
+
+from sqlalchemy import DateTime, String
+from sqlalchemy.orm import Mapped, mapped_column
+
+from app.domain.base import new_id, utcnow
+
+
+def pk() -> Mapped[str]:
+ return mapped_column(String(32), primary_key=True, default=new_id)
+
+
+def created() -> Mapped[datetime]:
+ return mapped_column(DateTime(timezone=True), default=utcnow, nullable=False)
diff --git a/apps/api/app/infrastructure/db/models.py b/apps/api/app/infrastructure/db/models.py
index 78c98e9..f6797f2 100644
--- a/apps/api/app/infrastructure/db/models.py
+++ b/apps/api/app/infrastructure/db/models.py
@@ -221,6 +221,8 @@ class ProjectRow(Base):
__tablename__ = "projects"
__table_args__ = (
UniqueConstraint("organization_id", "slug", name="uq_project_org_slug"),
+ # v2 rule: one organization has exactly one application.
+ UniqueConstraint("organization_id", name="uq_project_one_per_org"),
# The project list is always scoped to one organization and ordered by
# creation, so one composite index serves both the filter and the sort.
Index("ix_project_org_created", "organization_id", "created_at"),
@@ -237,6 +239,94 @@ class ProjectRow(Base):
ForeignKey("users.id"), nullable=True, index=True
)
default_branch: Mapped[str] = mapped_column(String(80), default="main", nullable=False)
+ # --- v2 application profile (filled by the organization wizard) ---
+ app_type: Mapped[str | None] = mapped_column(String(32), nullable=True)
+ language: Mapped[str | None] = mapped_column(String(32), nullable=True)
+ framework: Mapped[str | None] = mapped_column(String(48), nullable=True)
+ runtime_version: Mapped[str | None] = mapped_column(String(32), nullable=True)
+ package_manager: Mapped[str | None] = mapped_column(String(32), nullable=True)
+ setup_command: Mapped[str | None] = mapped_column(Text, nullable=True)
+ build_command: Mapped[str | None] = mapped_column(Text, nullable=True)
+ test_command: Mapped[str | None] = mapped_column(Text, nullable=True)
+ start_command: Mapped[str | None] = mapped_column(Text, nullable=True)
+ app_port: Mapped[int | None] = mapped_column(Integer, nullable=True)
+ healthcheck_path: Mapped[str] = mapped_column(String(255), default="/", nullable=False)
+ git_source: Mapped[str] = mapped_column(String(16), default="platform", nullable=False)
+ external_repo_url: Mapped[str | None] = mapped_column(String(512), nullable=True)
+ wizard_completed_at: Mapped[datetime | None] = mapped_column(
+ DateTime(timezone=True), nullable=True
+ )
+ created_at: Mapped[datetime] = _created()
+
+
+class ProjectExternalGitRow(Base):
+ """Credentials for a user-supplied git remote. Secrets are encrypted at rest."""
+
+ __tablename__ = "project_external_git"
+
+ project_id: Mapped[str] = mapped_column(
+ ForeignKey("projects.id", ondelete="CASCADE"), primary_key=True
+ )
+ auth_type: Mapped[str] = mapped_column(String(16), default="none", nullable=False)
+ username: Mapped[str | None] = mapped_column(String(160), nullable=True)
+ secret_encrypted: Mapped[str | None] = mapped_column(Text, nullable=True)
+ known_hosts: Mapped[str | None] = mapped_column(Text, nullable=True)
+ updated_at: Mapped[datetime] = mapped_column(
+ DateTime(timezone=True), default=utcnow, onupdate=utcnow, nullable=False
+ )
+
+
+class DeployTargetRow(Base):
+ """A production deploy target (ssh / sftp / ftp / ftps). Secrets are encrypted."""
+
+ __tablename__ = "deploy_targets"
+ __table_args__ = (Index("ix_deploy_target_project", "project_id"),)
+
+ id: Mapped[str] = _pk()
+ organization_id: Mapped[str] = mapped_column(
+ ForeignKey("organizations.id", ondelete="CASCADE"), nullable=False, index=True
+ )
+ project_id: Mapped[str] = mapped_column(
+ ForeignKey("projects.id", ondelete="CASCADE"), nullable=False
+ )
+ kind: Mapped[str] = mapped_column(String(8), nullable=False)
+ host: Mapped[str] = mapped_column(String(255), nullable=False)
+ port: Mapped[int] = mapped_column(Integer, nullable=False)
+ username: Mapped[str] = mapped_column(String(160), nullable=False)
+ auth_type: Mapped[str] = mapped_column(String(16), default="password", nullable=False)
+ secret_encrypted: Mapped[str | None] = mapped_column(Text, nullable=True)
+ remote_path: Mapped[str] = mapped_column(String(512), nullable=False)
+ post_deploy_command: Mapped[str | None] = mapped_column(Text, nullable=True)
+ host_fingerprint: Mapped[str | None] = mapped_column(String(255), nullable=True)
+ is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
+ last_tested_at: Mapped[datetime | None] = mapped_column(
+ DateTime(timezone=True), nullable=True
+ )
+ last_test_ok: Mapped[bool | None] = mapped_column(Boolean, nullable=True)
+ last_test_message: Mapped[str | None] = mapped_column(String(500), nullable=True)
+ created_at: Mapped[datetime] = _created()
+
+
+class OrgModelGrantRow(Base):
+ """A model the platform owner lets one organization's employees use."""
+
+ __tablename__ = "org_model_grants"
+ __table_args__ = (
+ UniqueConstraint("organization_id", "ai_model_id", name="uq_org_model_grant"),
+ )
+
+ id: Mapped[str] = _pk()
+ organization_id: Mapped[str] = mapped_column(
+ ForeignKey("organizations.id", ondelete="CASCADE"), nullable=False, index=True
+ )
+ ai_model_id: Mapped[str] = mapped_column(
+ ForeignKey("ai_models.id", ondelete="CASCADE"), nullable=False
+ )
+ daily_credit_cap: Mapped[float | None] = mapped_column(Float, nullable=True)
+ is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
+ created_by_user_id: Mapped[str | None] = mapped_column(
+ ForeignKey("users.id"), nullable=True
+ )
created_at: Mapped[datetime] = _created()
@@ -926,3 +1016,13 @@ class QualityMetricRow(Base):
value: Mapped[float] = mapped_column(Float, default=0.0, nullable=False)
dimensions_json: Mapped[dict] = mapped_column(JSON, default=dict, nullable=False)
created_at: Mapped[datetime] = _created()
+
+
+# ---------------------------------------------------------------------------
+# v2 tables owned by one workstream each (kept in separate modules so the three
+# parallel agents never edit the same file). Imported last so every table is
+# registered on ``Base.metadata`` before ``create_all`` / alembic autogenerate.
+# ---------------------------------------------------------------------------
+from app.infrastructure.db import models_delivery as _models_delivery # noqa: E402,F401
+from app.infrastructure.db import models_engine as _models_engine # noqa: E402,F401
+from app.infrastructure.db import models_org as _models_org # noqa: E402,F401
diff --git a/apps/api/app/infrastructure/db/models_delivery.py b/apps/api/app/infrastructure/db/models_delivery.py
new file mode 100644
index 0000000..57cf3f9
--- /dev/null
+++ b/apps/api/app/infrastructure/db/models_delivery.py
@@ -0,0 +1,10 @@
+"""ORM tables owned by Agent 3 (delivery & infra).
+
+Use ``from app.core.database import Base`` and the helpers in
+``app.infrastructure.db.columns`` (``pk()``, ``created()``). Do NOT import from
+``app.infrastructure.db.models`` here: that module imports this one (import cycle).
+Foreign keys to existing tables are written as plain strings, e.g.
+``ForeignKey("organizations.id", ondelete="CASCADE")``. See docs/plan/PLAN-v2.md §6.
+"""
+
+from __future__ import annotations
diff --git a/apps/api/app/infrastructure/db/models_engine.py b/apps/api/app/infrastructure/db/models_engine.py
new file mode 100644
index 0000000..d02adb1
--- /dev/null
+++ b/apps/api/app/infrastructure/db/models_engine.py
@@ -0,0 +1,10 @@
+"""ORM tables owned by Agent 2 (AI workforce engine).
+
+Use ``from app.core.database import Base`` and the helpers in
+``app.infrastructure.db.columns`` (``pk()``, ``created()``). Do NOT import from
+``app.infrastructure.db.models`` here: that module imports this one (import cycle).
+Foreign keys to existing tables are written as plain strings, e.g.
+``ForeignKey("organizations.id", ondelete="CASCADE")``. See docs/plan/PLAN-v2.md §6.
+"""
+
+from __future__ import annotations
diff --git a/apps/api/app/infrastructure/db/models_org.py b/apps/api/app/infrastructure/db/models_org.py
new file mode 100644
index 0000000..a62615c
--- /dev/null
+++ b/apps/api/app/infrastructure/db/models_org.py
@@ -0,0 +1,10 @@
+"""ORM tables owned by Agent 1 (org platform).
+
+Use ``from app.core.database import Base`` and the helpers in
+``app.infrastructure.db.columns`` (``pk()``, ``created()``). Do NOT import from
+``app.infrastructure.db.models`` here: that module imports this one (import cycle).
+Foreign keys to existing tables are written as plain strings, e.g.
+``ForeignKey("organizations.id", ondelete="CASCADE")``. See docs/plan/PLAN-v2.md §6.
+"""
+
+from __future__ import annotations
diff --git a/apps/api/app/infrastructure/repositories/app_profile.py b/apps/api/app/infrastructure/repositories/app_profile.py
new file mode 100644
index 0000000..1f44b92
--- /dev/null
+++ b/apps/api/app/infrastructure/repositories/app_profile.py
@@ -0,0 +1,82 @@
+"""Read/write access to the app profile columns of ``projects``.
+
+Foundation-owned and shared by all three workstreams (agents may call it, not edit it).
+Every lookup is scoped by ``organization_id``.
+"""
+
+from __future__ import annotations
+
+from datetime import datetime
+
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.domain.projects.profile import AppProfile
+from app.infrastructure.db.models import ProjectRow
+
+#: Columns a caller may change through ``update_profile``.
+EDITABLE_FIELDS = frozenset(
+ {
+ "app_type",
+ "language",
+ "framework",
+ "runtime_version",
+ "package_manager",
+ "setup_command",
+ "build_command",
+ "test_command",
+ "start_command",
+ "app_port",
+ "healthcheck_path",
+ "git_source",
+ "external_repo_url",
+ "default_branch",
+ }
+)
+
+
+class AppProfileRepository:
+ def __init__(self, session: AsyncSession) -> None:
+ self._session = session
+
+ async def get_for_org(self, organization_id: str) -> AppProfile | None:
+ row = await self._row_for_org(organization_id)
+ return AppProfile.from_row(row) if row else None
+
+ async def get(self, *, organization_id: str, project_id: str) -> AppProfile | None:
+ row = await self._session.scalar(
+ select(ProjectRow).where(
+ ProjectRow.id == project_id, ProjectRow.organization_id == organization_id
+ )
+ )
+ return AppProfile.from_row(row) if row else None
+
+ async def update_profile(
+ self,
+ *,
+ organization_id: str,
+ project_id: str,
+ values: dict[str, object],
+ mark_wizard_completed: datetime | None = None,
+ ) -> AppProfile | None:
+ unknown = set(values) - EDITABLE_FIELDS
+ if unknown:
+ raise ValueError(f"not editable profile fields: {sorted(unknown)}")
+ row = await self._session.scalar(
+ select(ProjectRow).where(
+ ProjectRow.id == project_id, ProjectRow.organization_id == organization_id
+ )
+ )
+ if row is None:
+ return None
+ for name, value in values.items():
+ setattr(row, name, value)
+ if mark_wizard_completed is not None:
+ row.wizard_completed_at = mark_wizard_completed
+ await self._session.flush()
+ return AppProfile.from_row(row)
+
+ async def _row_for_org(self, organization_id: str) -> ProjectRow | None:
+ return await self._session.scalar(
+ select(ProjectRow).where(ProjectRow.organization_id == organization_id)
+ )
diff --git a/apps/api/app/interfaces/http/routers/admin_domains.py b/apps/api/app/interfaces/http/routers/admin_domains.py
new file mode 100644
index 0000000..ceb7497
--- /dev/null
+++ b/apps/api/app/interfaces/http/routers/admin_domains.py
@@ -0,0 +1,11 @@
+"""owner domain routes: /admin/domains/*.
+
+Owned by Agent 3 (see docs/plan/PLAN-v2.md §6). Registered in ``rpc_app.py`` already; give each
+route its full path (no prefix is applied here besides ``/api``).
+"""
+
+from __future__ import annotations
+
+from fastapi import APIRouter
+
+router = APIRouter(tags=["admin-domains"])
diff --git a/apps/api/app/interfaces/http/routers/admin_grants.py b/apps/api/app/interfaces/http/routers/admin_grants.py
new file mode 100644
index 0000000..2914eb9
--- /dev/null
+++ b/apps/api/app/interfaces/http/routers/admin_grants.py
@@ -0,0 +1,11 @@
+"""owner model grants and org policies: /admin/model-grants/*, /admin/org-policies/*.
+
+Owned by Agent 1 (see docs/plan/PLAN-v2.md §6). Registered in ``rpc_app.py`` already; give each
+route its full path (no prefix is applied here besides ``/api``).
+"""
+
+from __future__ import annotations
+
+from fastapi import APIRouter
+
+router = APIRouter(tags=["admin-grants"])
diff --git a/apps/api/app/interfaces/http/routers/app_setup.py b/apps/api/app/interfaces/http/routers/app_setup.py
new file mode 100644
index 0000000..da34900
--- /dev/null
+++ b/apps/api/app/interfaces/http/routers/app_setup.py
@@ -0,0 +1,11 @@
+"""org wizard, app profile, deploy-target settings: /setup/*, /organizations/{org}/app/*.
+
+Owned by Agent 1 (see docs/plan/PLAN-v2.md §6). Registered in ``rpc_app.py`` already; give each
+route its full path (no prefix is applied here besides ``/api``).
+"""
+
+from __future__ import annotations
+
+from fastapi import APIRouter
+
+router = APIRouter(tags=["app-setup"])
diff --git a/apps/api/app/interfaces/http/routers/delivery.py b/apps/api/app/interfaces/http/routers/delivery.py
new file mode 100644
index 0000000..e2fb65a
--- /dev/null
+++ b/apps/api/app/interfaces/http/routers/delivery.py
@@ -0,0 +1,11 @@
+"""CI/CD runs, releases, production deploy: /organizations/{org}/delivery/*.
+
+Owned by Agent 3 (see docs/plan/PLAN-v2.md §6). Registered in ``rpc_app.py`` already; give each
+route its full path (no prefix is applied here besides ``/api``).
+"""
+
+from __future__ import annotations
+
+from fastapi import APIRouter
+
+router = APIRouter(tags=["delivery"])
diff --git a/apps/api/app/interfaces/http/routers/mcp_tokens.py b/apps/api/app/interfaces/http/routers/mcp_tokens.py
new file mode 100644
index 0000000..16e67ce
--- /dev/null
+++ b/apps/api/app/interfaces/http/routers/mcp_tokens.py
@@ -0,0 +1,11 @@
+"""MCP tokens: /organizations/{org}/mcp-tokens/*, /admin/mcp/*.
+
+Owned by Agent 1 (see docs/plan/PLAN-v2.md §6). Registered in ``rpc_app.py`` already; give each
+route its full path (no prefix is applied here besides ``/api``).
+"""
+
+from __future__ import annotations
+
+from fastapi import APIRouter
+
+router = APIRouter(tags=["mcp-tokens"])
diff --git a/apps/api/app/interfaces/http/routers/memory.py b/apps/api/app/interfaces/http/routers/memory.py
new file mode 100644
index 0000000..06db1cd
--- /dev/null
+++ b/apps/api/app/interfaces/http/routers/memory.py
@@ -0,0 +1,11 @@
+"""org memory: /organizations/{org}/memory/*.
+
+Owned by Agent 1 (see docs/plan/PLAN-v2.md §6). Registered in ``rpc_app.py`` already; give each
+route its full path (no prefix is applied here besides ``/api``).
+"""
+
+from __future__ import annotations
+
+from fastapi import APIRouter
+
+router = APIRouter(tags=["memory"])
diff --git a/apps/api/app/interfaces/mcp/__init__.py b/apps/api/app/interfaces/mcp/__init__.py
new file mode 100644
index 0000000..9726d5d
--- /dev/null
+++ b/apps/api/app/interfaces/mcp/__init__.py
@@ -0,0 +1 @@
+"""MCP gateway (owned by Agent 1)."""
diff --git a/apps/api/app/interfaces/mcp/server.py b/apps/api/app/interfaces/mcp/server.py
new file mode 100644
index 0000000..4fbd82e
--- /dev/null
+++ b/apps/api/app/interfaces/mcp/server.py
@@ -0,0 +1,14 @@
+"""MCP gateway entry point (owned by Agent 1). Foundation stub."""
+
+from __future__ import annotations
+
+
+def main() -> None:
+ raise SystemExit(
+ "MCP gateway is not implemented yet "
+ "(see docs/plan/TODO-agent-1-org-platform.md, Phase 5)"
+ )
+
+
+if __name__ == "__main__":
+ main()
diff --git a/apps/api/app/interfaces/realtime/rpc_app.py b/apps/api/app/interfaces/realtime/rpc_app.py
index 31eab12..0c233e4 100644
--- a/apps/api/app/interfaces/realtime/rpc_app.py
+++ b/apps/api/app/interfaces/realtime/rpc_app.py
@@ -23,13 +23,19 @@ from app.interfaces.http.errors import register_exception_handlers
from app.interfaces.http.request_context import RequestContextMiddleware
from app.interfaces.http.routers import (
admin,
+ admin_domains,
+ admin_grants,
admin_users,
agy,
+ app_setup,
auth,
billing,
+ delivery,
health,
invites,
knowledge,
+ mcp_tokens,
+ memory,
organizations,
projects,
sandbox,
@@ -71,6 +77,13 @@ def build_rpc_app() -> FastAPI:
application.include_router(admin.router, prefix="/api")
application.include_router(admin_users.router, prefix="/api")
application.include_router(agy.router, prefix="/api")
+ # v2 routers (one workstream each; see docs/plan/PLAN-v2.md §6).
+ application.include_router(app_setup.router, prefix="/api")
+ application.include_router(memory.router, prefix="/api")
+ application.include_router(mcp_tokens.router, prefix="/api")
+ application.include_router(admin_grants.router, prefix="/api")
+ application.include_router(delivery.router, prefix="/api")
+ application.include_router(admin_domains.router, prefix="/api")
application.include_router(realtime_router, prefix="/api")
return application
diff --git a/apps/api/app/workers/__main__.py b/apps/api/app/workers/__main__.py
index 301e5fb..865782b 100644
--- a/apps/api/app/workers/__main__.py
+++ b/apps/api/app/workers/__main__.py
@@ -3,6 +3,7 @@
python -m app.workers billing [--loop --interval 3600]
python -m app.workers ai-runtime [--loop --interval 30 --limit 10]
python -m app.workers sandbox-gateway [--loop --interval 600 --idle-minutes 120]
+ python -m app.workers delivery [--loop --interval 10 --limit 10]
Default is **one pass and exit**, which is the shape a cron entry wants. Pass
``--loop`` for a long-running deployment; SIGINT/SIGTERM then stop it cleanly.
@@ -24,7 +25,7 @@ def _build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(prog="app.workers", description="AGY background workers")
parser.add_argument(
"worker",
- choices=["billing", "ai-runtime", "sandbox-gateway"],
+ choices=["billing", "ai-runtime", "sandbox-gateway", "delivery"],
help="which worker to run",
)
parser.add_argument("--loop", action="store_true", help="keep running instead of one pass")
@@ -92,12 +93,25 @@ def _sandbox_gateway_pass(args: argparse.Namespace):
return one_pass
+def _delivery_pass(args: argparse.Namespace):
+ from app.workers.delivery import DeliveryWorker
+
+ worker = DeliveryWorker()
+
+ async def one_pass() -> WorkerOutcome:
+ return await worker.run_pass(limit=args.limit)
+
+ return one_pass
+
+
def _pass_function(args: argparse.Namespace):
"""Return a zero-argument coroutine function for one pass."""
if args.worker == "billing":
return _billing_pass(args)
if args.worker == "ai-runtime":
return _ai_runtime_pass(args)
+ if args.worker == "delivery":
+ return _delivery_pass(args)
return _sandbox_gateway_pass(args)
diff --git a/apps/api/app/workers/delivery.py b/apps/api/app/workers/delivery.py
new file mode 100644
index 0000000..395e209
--- /dev/null
+++ b/apps/api/app/workers/delivery.py
@@ -0,0 +1,15 @@
+"""Delivery worker — executes queued CI/CD pipeline runs and polls remotes for pushes.
+
+Owned by Agent 3 (see docs/plan/PLAN-v2.md). Foundation stub: one no-op pass.
+"""
+
+from __future__ import annotations
+
+from app.workers.base import WorkerOutcome
+
+
+class DeliveryWorker:
+ name = "delivery"
+
+ async def run_pass(self, *, limit: int = 10) -> WorkerOutcome:
+ return WorkerOutcome(name=self.name)
diff --git a/apps/api/pyproject.toml b/apps/api/pyproject.toml
index a2e7782..8a49a17 100644
--- a/apps/api/pyproject.toml
+++ b/apps/api/pyproject.toml
@@ -19,6 +19,9 @@ dependencies = [
"redis>=5.2.0",
"httpx>=0.28.0",
"python-multipart>=0.0.20",
+ "asyncssh>=2.17.0",
+ "aioftp>=0.23.0",
+ "mcp>=1.2.0",
]
[project.optional-dependencies]
diff --git a/apps/api/tests/test_foundation.py b/apps/api/tests/test_foundation.py
new file mode 100644
index 0000000..2092bb9
--- /dev/null
+++ b/apps/api/tests/test_foundation.py
@@ -0,0 +1,226 @@
+"""v2 foundation: schema invariants, shared repository, contracts and wiring."""
+
+from __future__ import annotations
+
+import pytest
+from sqlalchemy.exc import IntegrityError
+
+import app.application.container as container
+from app.application.ports.delivery_hooks import DeliveryHooks, NullDeliveryHooks
+from app.application.ports.deploy_target import (
+ DeployTargetSpec,
+ DeployTargetTester,
+ FakeDeployTargetTester,
+)
+from app.application.ports.memory import MemoryPort, NullMemory, TaskOutcome
+from app.application.ports.model_grants import ModelGrantPort, tier_for
+from app.application.ports.repo_workspace import (
+ DiffResult,
+ FakeRepoWorkspace,
+ MergeOutcome,
+ RemoteCredentials,
+ RepoWorkspacePort,
+)
+from app.application.ports.workforce_bootstrap import (
+ NullWorkforceBootstrap,
+ WorkforceBootstrapPort,
+)
+from app.domain.base import new_id
+from app.domain.identity.models import PlatformRole
+from app.infrastructure.db.models import (
+ AiModelRow,
+ AiProviderRow,
+ OrganizationRow,
+ ProjectRow,
+ UserRow,
+)
+from app.infrastructure.repositories.app_profile import AppProfileRepository
+
+
+async def _org_with_user(session, slug: str) -> tuple[str, str]:
+ user = UserRow(
+ id=new_id(),
+ email=f"{slug}@example.com",
+ password_hash="x",
+ name="t",
+ platform_role=PlatformRole.MANAGER.value,
+ )
+ session.add(user)
+ await session.flush()
+ org = OrganizationRow(id=new_id(), name=slug, slug=slug, owner_user_id=user.id)
+ session.add(org)
+ await session.flush()
+ return org.id, user.id
+
+
+@pytest.mark.asyncio
+async def test_one_project_per_organization(session_factory):
+ async with session_factory() as session:
+ org_id, _ = await _org_with_user(session, "solo")
+ session.add(ProjectRow(id=new_id(), organization_id=org_id, name="a", slug="a"))
+ await session.commit()
+ session.add(ProjectRow(id=new_id(), organization_id=org_id, name="b", slug="b"))
+ with pytest.raises(IntegrityError):
+ await session.commit()
+
+
+@pytest.mark.asyncio
+async def test_app_profile_repository_is_tenant_scoped(session_factory):
+ async with session_factory() as session:
+ org_a, _ = await _org_with_user(session, "org-a")
+ org_b, _ = await _org_with_user(session, "org-b")
+ project = ProjectRow(id=new_id(), organization_id=org_a, name="a", slug="a")
+ session.add(project)
+ await session.commit()
+
+ repo = AppProfileRepository(session)
+ profile = await repo.get_for_org(org_a)
+ assert profile is not None
+ assert profile.healthcheck_path == "/"
+ assert profile.git_source == "platform"
+ assert profile.wizard_completed is False
+
+ # Another org can neither read nor update this project's profile.
+ assert await repo.get_for_org(org_b) is None
+ assert await repo.get(organization_id=org_b, project_id=project.id) is None
+ assert (
+ await repo.update_profile(
+ organization_id=org_b, project_id=project.id, values={"language": "go"}
+ )
+ is None
+ )
+
+ updated = await repo.update_profile(
+ organization_id=org_a,
+ project_id=project.id,
+ values={"language": "python", "framework": "fastapi", "app_port": 8000},
+ )
+ assert updated is not None
+ assert (updated.language, updated.framework, updated.app_port) == (
+ "python",
+ "fastapi",
+ 8000,
+ )
+
+ with pytest.raises(ValueError):
+ await repo.update_profile(
+ organization_id=org_a, project_id=project.id, values={"organization_id": "x"}
+ )
+
+
+@pytest.mark.asyncio
+async def test_default_model_grants_return_active_models_with_tiers(session_factory):
+ async with session_factory() as session:
+ provider = AiProviderRow(id=new_id(), name="agy-test")
+ session.add(provider)
+ await session.flush()
+ session.add_all(
+ [
+ AiModelRow(
+ id="m-strong", provider_id=provider.id, internal_name="s",
+ display_name_for_admin="S", capabilities=["text", "reasoning"],
+ output_price_per_1m=10.0,
+ ),
+ AiModelRow(
+ id="m-fast", provider_id=provider.id, internal_name="f",
+ display_name_for_admin="F", capabilities=["text"], output_price_per_1m=0.5,
+ ),
+ AiModelRow(
+ id="m-off", provider_id=provider.id, internal_name="o",
+ display_name_for_admin="O", capabilities=["text"], is_active=False,
+ ),
+ ]
+ )
+ await session.commit()
+
+ grants = container.build_model_grants(session)
+ assert isinstance(grants, ModelGrantPort)
+ granted = {g.ai_model_id: g.tier for g in await grants.granted_models(organization_id="o")}
+ assert granted == {"m-strong": "strong", "m-fast": "fast"}
+ assert await grants.is_granted(organization_id="o", ai_model_id="m-fast")
+ assert not await grants.is_granted(organization_id="o", ai_model_id="m-off")
+
+
+def test_tier_for_never_uses_names():
+ assert tier_for(capabilities=["reasoning"], output_price_per_1m=99) == "strong"
+ assert tier_for(capabilities=["text"], output_price_per_1m=1) == "fast"
+ assert tier_for(capabilities=["text"], output_price_per_1m=5) == "balanced"
+
+
+@pytest.mark.asyncio
+async def test_contract_fakes_satisfy_their_protocols():
+ assert isinstance(FakeRepoWorkspace(), RepoWorkspacePort)
+ assert isinstance(NullDeliveryHooks(), DeliveryHooks)
+ assert isinstance(FakeDeployTargetTester(), DeployTargetTester)
+ assert isinstance(NullMemory(), MemoryPort)
+ assert isinstance(NullWorkforceBootstrap(), WorkforceBootstrapPort)
+
+
+@pytest.mark.asyncio
+async def test_fake_repo_workspace_full_flow():
+ repo = FakeRepoWorkspace()
+ ws = await repo.create_task_worktree(project_id="p", task_id="t1")
+ assert ws.branch == "task/t1" and ws.sandbox_path.endswith("/t1")
+ # An empty diff commits nothing.
+ assert await repo.commit(worktree=ws, message="m", author="a") is None
+ repo.diffs["t1"] = DiffResult(text="+x", changed_files=["a.py"])
+ sha = await repo.commit(worktree=ws, message="m", author="a")
+ assert sha
+ merged = await repo.merge_into(project_id="p", branch=ws.branch)
+ assert merged.ok and merged.sha == sha
+ assert (await repo.push(project_id="p", branch="dev")).ok
+ assert await repo.head(project_id="p", branch="dev") == sha
+ repo.merge_outcomes.append(MergeOutcome(ok=False, conflict_files=["a.py"]))
+ assert (await repo.merge_into(project_id="p", branch="x")).conflict_files == ["a.py"]
+ check = await repo.test_remote(url="https://x/y.git", credentials=RemoteCredentials())
+ assert check.ok
+
+
+@pytest.mark.asyncio
+async def test_fake_deploy_tester_and_hooks_and_memory_record_calls():
+ tester = FakeDeployTargetTester()
+ ok = await tester.test_connection(
+ target=DeployTargetSpec(kind="ssh", host="h.example.com", port=22, username="u")
+ )
+ bad = await tester.test_connection(
+ target=DeployTargetSpec(kind="ftp", host="fail.example.com", port=21, username="u")
+ )
+ assert ok.ok and ok.fingerprint and not bad.ok
+
+ hooks = NullDeliveryHooks()
+ await hooks.on_app_created(organization_id="o", project_id="p")
+ await hooks.on_branch_pushed(
+ organization_id="o", project_id="p", branch="dev", sha="abc", source="engine"
+ )
+ assert len(hooks.created) == 1 and hooks.pushed[0]["sha"] == "abc"
+
+ memory = NullMemory()
+ assert await memory.build_context(organization_id="o", role="developer", query="q") == ""
+ await memory.on_task_finished(
+ organization_id="o", task=TaskOutcome(task_id="t", title="x", status="completed")
+ )
+ assert memory.finished[0].task_id == "t"
+
+
+@pytest.mark.asyncio
+async def test_container_exposes_all_v2_builders(session_factory):
+ async with session_factory() as session:
+ assert isinstance(container.build_repo_workspace(session), RepoWorkspacePort)
+ assert isinstance(container.build_delivery_hooks(session), DeliveryHooks)
+ assert isinstance(container.build_deploy_target_tester(session), DeployTargetTester)
+ assert isinstance(container.build_memory(session), MemoryPort)
+ assert isinstance(container.build_workforce_bootstrap(session), WorkforceBootstrapPort)
+
+
+@pytest.mark.asyncio
+async def test_app_builds_with_v2_routers_and_delivery_worker_runs(client):
+ # The full handler app imports every v2 router; /health proves it assembled.
+ assert (await client.get("/health")).status_code == 200
+
+ from app.workers.__main__ import _build_parser, _pass_function
+ from app.workers.delivery import DeliveryWorker
+
+ assert (await DeliveryWorker().run_pass()).failed == 0
+ # ...and it is selectable from the CLI.
+ one_pass = _pass_function(_build_parser().parse_args(["delivery"]))
+ assert (await one_pass()).name == "delivery"
diff --git a/apps/web/src/app/(app)/admin/domains/page.tsx b/apps/web/src/app/(app)/admin/domains/page.tsx
new file mode 100644
index 0000000..208053c
--- /dev/null
+++ b/apps/web/src/app/(app)/admin/domains/page.tsx
@@ -0,0 +1,17 @@
+"use client";
+
+import { PlaceholderPage } from "@/components/PlaceholderPage";
+
+export default function AdminDomainsPage() {
+ return (
+
+ );
+}
diff --git a/apps/web/src/app/(app)/admin/layout.tsx b/apps/web/src/app/(app)/admin/layout.tsx
index 0de0ef9..b5e4517 100644
--- a/apps/web/src/app/(app)/admin/layout.tsx
+++ b/apps/web/src/app/(app)/admin/layout.tsx
@@ -4,6 +4,7 @@ import Link from "next/link";
import { usePathname } from "next/navigation";
import * as React from "react";
import { EmptyState, LoadingState } from "@agy/ui";
+import { ADMIN_ITEMS } from "@/lib/nav";
import { useSession } from "@/lib/session";
/**
@@ -14,17 +15,7 @@ import { useSession } from "@/lib/session";
* never the security boundary.
*/
-const SECTIONS = [
- { href: "/admin", label: "نمای کلی" },
- { href: "/admin/users", label: "کاربران و مدیران" },
- { href: "/admin/agy-accounts", label: "اکانتهای agy" },
- { href: "/admin/models", label: "مدلها" },
- { href: "/admin/routing", label: "مسیریابی" },
- { href: "/admin/rate-cards", label: "تعرفهها" },
- { href: "/admin/usage", label: "مصرف سراسری" },
- { href: "/admin/organizations", label: "سازمانها" },
- { href: "/admin/errors", label: "کنسول خطا" },
-] as const;
+const SECTIONS = ADMIN_ITEMS;
export default function AdminLayout({ children }: { children: React.ReactNode }) {
const { user, ready } = useSession();
diff --git a/apps/web/src/app/(app)/admin/mcp/page.tsx b/apps/web/src/app/(app)/admin/mcp/page.tsx
new file mode 100644
index 0000000..ce7bdf7
--- /dev/null
+++ b/apps/web/src/app/(app)/admin/mcp/page.tsx
@@ -0,0 +1,16 @@
+"use client";
+
+import { PlaceholderPage } from "@/components/PlaceholderPage";
+
+export default function AdminMcpPage() {
+ return (
+
+ );
+}
diff --git a/apps/web/src/app/(app)/admin/model-grants/page.tsx b/apps/web/src/app/(app)/admin/model-grants/page.tsx
new file mode 100644
index 0000000..4b50fbe
--- /dev/null
+++ b/apps/web/src/app/(app)/admin/model-grants/page.tsx
@@ -0,0 +1,17 @@
+"use client";
+
+import { PlaceholderPage } from "@/components/PlaceholderPage";
+
+export default function AdminModelGrantsPage() {
+ return (
+
+ );
+}
diff --git a/apps/web/src/app/(app)/admin/sandbox/page.tsx b/apps/web/src/app/(app)/admin/sandbox/page.tsx
new file mode 100644
index 0000000..256a365
--- /dev/null
+++ b/apps/web/src/app/(app)/admin/sandbox/page.tsx
@@ -0,0 +1,17 @@
+"use client";
+
+import { PlaceholderPage } from "@/components/PlaceholderPage";
+
+export default function AdminSandboxPage() {
+ return (
+
+ );
+}
diff --git a/apps/web/src/app/(app)/app/page.tsx b/apps/web/src/app/(app)/app/page.tsx
new file mode 100644
index 0000000..951abb3
--- /dev/null
+++ b/apps/web/src/app/(app)/app/page.tsx
@@ -0,0 +1,18 @@
+"use client";
+
+import { PlaceholderPage } from "@/components/PlaceholderPage";
+
+export default function AppPage() {
+ return (
+
+ );
+}
diff --git a/apps/web/src/app/(app)/memory/page.tsx b/apps/web/src/app/(app)/memory/page.tsx
new file mode 100644
index 0000000..834f98a
--- /dev/null
+++ b/apps/web/src/app/(app)/memory/page.tsx
@@ -0,0 +1,18 @@
+"use client";
+
+import { PlaceholderPage } from "@/components/PlaceholderPage";
+
+export default function MemoryPage() {
+ return (
+
+ );
+}
diff --git a/apps/web/src/lib/api-delivery.ts b/apps/web/src/lib/api-delivery.ts
new file mode 100644
index 0000000..a7da8bc
--- /dev/null
+++ b/apps/web/src/lib/api-delivery.ts
@@ -0,0 +1,10 @@
+/**
+ * Typed client functions for the delivery workstream (docs/plan/PLAN-v2.md §6).
+ *
+ * Add functions to this object only; ``lib/api.ts`` is locked. Use ``apiRequest`` for transport.
+ */
+import { apiRequest } from "./api";
+
+export const deliveryApi = {
+ // example: list: (orgId: string) => apiRequest(`/api/organizations/${orgId}/foo`),
+} satisfies Record ReturnType>;
diff --git a/apps/web/src/lib/api-engine.ts b/apps/web/src/lib/api-engine.ts
new file mode 100644
index 0000000..2e3159a
--- /dev/null
+++ b/apps/web/src/lib/api-engine.ts
@@ -0,0 +1,10 @@
+/**
+ * Typed client functions for the AI-workforce workstream (docs/plan/PLAN-v2.md §6).
+ *
+ * Add functions to this object only; ``lib/api.ts`` is locked. Use ``apiRequest`` for transport.
+ */
+import { apiRequest } from "./api";
+
+export const engineApi = {
+ // example: list: (orgId: string) => apiRequest(`/api/organizations/${orgId}/foo`),
+} satisfies Record ReturnType>;
diff --git a/apps/web/src/lib/api-org.ts b/apps/web/src/lib/api-org.ts
new file mode 100644
index 0000000..7a19232
--- /dev/null
+++ b/apps/web/src/lib/api-org.ts
@@ -0,0 +1,10 @@
+/**
+ * Typed client functions for the org-platform workstream (docs/plan/PLAN-v2.md §6).
+ *
+ * Add functions to this object only; ``lib/api.ts`` is locked. Use ``apiRequest`` for transport.
+ */
+import { apiRequest } from "./api";
+
+export const orgApi = {
+ // example: list: (orgId: string) => apiRequest(`/api/organizations/${orgId}/foo`),
+} satisfies Record ReturnType>;
diff --git a/apps/web/src/lib/nav.ts b/apps/web/src/lib/nav.ts
index 22b77fc..1f95371 100644
--- a/apps/web/src/lib/nav.ts
+++ b/apps/web/src/lib/nav.ts
@@ -16,10 +16,10 @@ export const NAV_ITEMS: NavItem[] = [
{ href: "/dashboard", label: "داشبورد", hint: "نمای کلی سازمان" },
{ href: "/manager", label: "مدیر AI", hint: "گفتگو با مدیر" },
{ href: "/workforce", label: "نیروی کار AI", hint: "کارمندها" },
- { href: "/projects", label: "پروژهها", hint: "Git و محیط اجرا" },
+ { href: "/app", label: "اپلیکیشن", hint: "دمو، استقرار، Git" },
{ href: "/tasks", label: "وظایف", hint: "وضعیت کارها" },
{ href: "/traces", label: "ردیابی", hint: "اجراها و رویدادها" },
- { href: "/knowledge", label: "دانش سازمانی", hint: "منابع و اسناد" },
+ { href: "/memory", label: "حافظهٔ سازمان", hint: "قوانین، درسها و دانش" },
{ href: "/billing", label: "مصرف و پرداخت", hint: "اعتبار و فاکتور" },
{ href: "/settings", label: "تنظیمات", hint: "سازمان و اعضا" },
];
@@ -39,5 +39,9 @@ export const ADMIN_ITEMS: NavItem[] = [
{ href: "/admin/rate-cards", label: "تعرفهها", hint: "قیمت واحدها" },
{ href: "/admin/usage", label: "مصرف سراسری", hint: "گزارش مصرف مستأجرها" },
{ href: "/admin/organizations", label: "سازمانها", hint: "مستأجرها" },
+ { href: "/admin/model-grants", label: "مدلهای سازمانها", hint: "تخصیص مدل به هر سازمان" },
+ { href: "/admin/domains", label: "دامنهها", hint: "مسیریابی Caddy" },
+ { href: "/admin/sandbox", label: "سندباکسها", hint: "کانتینرها و محدودیتها" },
+ { href: "/admin/mcp", label: "MCP", hint: "دسترسی عاملهای بیرونی" },
{ href: "/admin/errors", label: "کنسول خطا", hint: "ردیابی خطاها" },
];
diff --git a/apps/web/src/test/command-palette.test.tsx b/apps/web/src/test/command-palette.test.tsx
index e7a22aa..acfd3cc 100644
--- a/apps/web/src/test/command-palette.test.tsx
+++ b/apps/web/src/test/command-palette.test.tsx
@@ -45,10 +45,10 @@ describe("command palette", () => {
openWithShortcut();
fireEvent.change(screen.getByLabelText("جستوجوی بخشها"), {
- target: { value: "پروژه" },
+ target: { value: "اپلیکیشن" },
});
- expect(screen.getByRole("button", { name: /پروژه/ })).toBeInTheDocument();
+ expect(screen.getByRole("button", { name: /اپلیکیشن/ })).toBeInTheDocument();
expect(screen.queryByRole("button", { name: /داشبورد/ })).not.toBeInTheDocument();
});
diff --git a/docs/plan/PLAN-v2.md b/docs/plan/PLAN-v2.md
index ac94849..e76ed0b 100644
--- a/docs/plan/PLAN-v2.md
+++ b/docs/plan/PLAN-v2.md
@@ -7,7 +7,7 @@ its own TODO file:
| File | Agent | Area |
|---|---|---|
-| `docs/plan/TODO-0-foundation.md` | Claude (runs FIRST, before the agents) | shared contracts, DB foundation, wiring |
+| `docs/plan/TODO-0-foundation.md` | Claude (DONE before the agents start) | shared contracts, DB foundation, wiring |
| `docs/plan/TODO-agent-1-org-platform.md` | Agent 1 | invites, org wizard, app profile, sharing, owner model grants, memory, MCP |
| `docs/plan/TODO-agent-2-ai-workforce.md` | Agent 2 | admin (manager) AI, employees, dynamic pipelines, agent loop, test/commit/push |
| `docs/plan/TODO-agent-3-delivery-infra.md` | Agent 3 | docker sandbox, git workspaces, live CI/CD, demo server, Caddy domains, SSH/FTP deploy |
@@ -214,6 +214,14 @@ Log events are **batched** (at most one event per 500 ms per stream, max 200 lin
`*` = new file (Claude creates an empty stub in TODO-0, the agent fills it).
+**Foundation-owned shared helpers (read/call only, never edit):** `app/infrastructure/db/columns.py`
+(`pk()`, `created()` for your `models_*.py` files; never import from `db/models.py` there, it causes an import
+cycle), `app/infrastructure/repositories/app_profile.py` (`AppProfileRepository`: read the app profile, or write
+it with `update_profile`; this is how Agent 1 writes and Agents 2 and 3 read build/test commands, port, healthcheck),
+`app/domain/projects/profile.py` (`AppProfile`), `app/application/ports/model_grants.py::tier_for`.
+Wiring functions are in `container_org.py` / `container_engine.py` / `container_delivery.py`: you replace **the
+body** of your own builders (names and return types are frozen); `container.py` re-exports them.
+
**Locked files (Claude only):** `db/models.py`, `container.py`, `rpc_app.py`, `workers/__main__.py`,
`ports/*.py` contracts, `tests/conftest.py`, `pyproject.toml`, `package.json`, `pnpm-lock.yaml`,
`apps/web/src/lib/api.ts`, `apps/web/src/lib/nav.ts`, `components/AppShell.tsx`, `alembic/env.py`, root
@@ -222,11 +230,17 @@ Log events are **batched** (at most one event per 500 ms per stream, max 200 lin
**Append-only blocks.** `app/core/config.py` and `.env.example` each have three marked blocks
(`# >>> agent-1` … `# <<< agent-1`, and so on). You may add lines **only inside your own block**.
-Each new migration uses `down_revision = "a0f0e1d2c3b4"` (the foundation). Claude adds the merge
+Routers for every new area are already registered in `rpc_app.py` (empty stubs: `app_setup`, `memory`,
+`mcp_tokens`, `admin_grants`, `delivery`, `admin_domains`); the delivery worker is registered as `delivery` in
+`workers/__main__.py`. The admin console tab list now comes from `ADMIN_ITEMS` in `lib/nav.ts` (the layout no longer
+has its own copy). Each new migration uses `down_revision = "a0f0e1d2c3b4"` (the foundation). Claude adds the merge
revision when the branches are merged.
## 7. Working rules for every agent
+0. **Environment:** the main repo is on an exFAT disk (`/projects`, no symlinks, so no venv/node_modules there).
+ Your worktree lives on ext4 under `/home/mamad/work/smart-org-wt/` with `.venv` and `node_modules` already
+ installed. Use `pnpm` (in `~/.local/bin`) and `apps/api/.venv/bin/python`. Python is 3.13 via `uv`.
1. Work **only** in your own git worktree and branch (given in your TODO file). Never `cd` outside it and
never write to `/tmp`. Put scratch scripts in `./_scratch/` inside your worktree and delete them at the end.
2. **Do NOT commit, push, merge or rebase.** Claude reviews and commits each phase.
diff --git a/docs/plan/TODO-0-foundation.md b/docs/plan/TODO-0-foundation.md
index 4014cf0..93dd0db 100644
--- a/docs/plan/TODO-0-foundation.md
+++ b/docs/plan/TODO-0-foundation.md
@@ -52,9 +52,9 @@ Read `docs/plan/PLAN-v2.md` §4–§6 first. Everything here is mechanical; no p
- [ ] Commit on `dev`: `v2 foundation: shared contracts, app profile schema, wiring stubs`.
- [ ] Create the worktrees from that commit:
```bash
- git worktree add ../smart-org-wt/agent1 -b agent1/org-platform
- git worktree add ../smart-org-wt/agent2 -b agent2/ai-workforce
- git worktree add ../smart-org-wt/agent3 -b agent3/delivery-infra
+ git worktree add /home/mamad/work/smart-org-wt/agent1 -b agent1/org-platform
+ git worktree add /home/mamad/work/smart-org-wt/agent2 -b agent2/ai-workforce
+ git worktree add /home/mamad/work/smart-org-wt/agent3 -b agent3/delivery-infra
```
In each one: `pnpm install` and `pnpm api:venv` so the agent can run the checks immediately.
- [ ] `mkdir docs/plan/reports` (tracked with a `.gitkeep`).
diff --git a/docs/plan/TODO-agent-1-org-platform.md b/docs/plan/TODO-agent-1-org-platform.md
index 2ed3967..eb73230 100644
--- a/docs/plan/TODO-agent-1-org-platform.md
+++ b/docs/plan/TODO-agent-1-org-platform.md
@@ -1,13 +1,13 @@
# TODO — Agent 1: Org Platform (invites, wizard, app profile, sharing, model grants, memory, MCP)
-- **Worktree:** `/projects/smart-org-wt/agent1` · **Branch:** `agent1/org-platform` (already created, already installed)
+- **Worktree:** `/home/mamad/work/smart-org-wt/agent1` · **Branch:** `agent1/org-platform` (already created, already installed)
- **Read first:** `docs/plan/PLAN-v2.md` (all of it). Then skim: `apps/api/app/interfaces/http/routers/organizations.py`,
`invites.py`, `admin_users.py`, `app/application/services/platform_users.py`, `app/domain/identity/models.py`,
`apps/web/src/app/onboarding/page.tsx`, `apps/web/src/app/(app)/settings/`.
- **Your files:** only the "Agent 1" column of PLAN-v2 §6, your block in `config.py` / `.env.example`, and the
migration `alembic/versions/a1b2c3d4e5f1_org_platform.py` (`down_revision = "a0f0e1d2c3b4"`).
- **Never touch:** the locked files and the other agents' files (PLAN-v2 §6). Need something there? → `CHANGE REQUEST` in your report.
-- **Do NOT commit, push, merge or rebase.** Never `cd` outside the worktree, never write to `/tmp`.
+- **Do NOT commit, push, merge or rebase.** Never `cd` outside the worktree, never write to `/tmp`. The worktree is on ext4 (the main repo under /projects is exFAT: no symlinks), so `.venv` and `node_modules` work here; do not copy files to /projects.
- **After each phase:** run all checks (PLAN-v2 §7.4), write `docs/plan/reports/agent-1-phase-.md`, then STOP and wait.
You consume these contracts (do not change them): `DeployTargetTester`, `RepoWorkspacePort.test_remote`,
diff --git a/docs/plan/TODO-agent-2-ai-workforce.md b/docs/plan/TODO-agent-2-ai-workforce.md
index f046643..88eab64 100644
--- a/docs/plan/TODO-agent-2-ai-workforce.md
+++ b/docs/plan/TODO-agent-2-ai-workforce.md
@@ -1,6 +1,6 @@
# TODO — Agent 2: AI Workforce Engine (manager AI, employees, dynamic pipelines, test/commit/push)
-- **Worktree:** `/projects/smart-org-wt/agent2` · **Branch:** `agent2/ai-workforce` (already created, already installed)
+- **Worktree:** `/home/mamad/work/smart-org-wt/agent2` · **Branch:** `agent2/ai-workforce` (already created, already installed)
- **Read first:** `docs/plan/PLAN-v2.md` (all of it, especially §3 and §5). Then read fully:
`apps/api/app/application/services/workforce_service.py`, `app/domain/workforce/models.py` + `tools.py`,
`app/interfaces/http/routers/workforce.py`, `app/infrastructure/repositories/workforce.py`,
@@ -15,7 +15,7 @@
New tables go in `app/infrastructure/db/models_engine.py`. **`db/models.py` is locked**, so new task and
employee fields go into new 1:1 side tables (see 1.3), never into `tasks`/`employees` columns.
- **Never touch:** the locked files and the other agents' files. Need something there? → `CHANGE REQUEST` in your report.
-- **Do NOT commit, push, merge or rebase.** Never `cd` outside the worktree, never write to `/tmp`.
+- **Do NOT commit, push, merge or rebase.** Never `cd` outside the worktree, never write to `/tmp`. The worktree is on ext4 (the main repo under /projects is exFAT: no symlinks), so `.venv` and `node_modules` work here; do not copy files to /projects.
- **After each phase:** run all checks (PLAN-v2 §7.4), write `docs/plan/reports/agent-2-phase-.md`, then STOP and wait.
You consume: `ModelProviderPort` (existing), `ModelGrantPort`, `MemoryPort`, `RepoWorkspacePort`,
diff --git a/docs/plan/TODO-agent-3-delivery-infra.md b/docs/plan/TODO-agent-3-delivery-infra.md
index adfc4f2..2d5dae9 100644
--- a/docs/plan/TODO-agent-3-delivery-infra.md
+++ b/docs/plan/TODO-agent-3-delivery-infra.md
@@ -1,6 +1,6 @@
# TODO — Agent 3: Delivery & Infra (docker sandbox, git workspaces, live CI/CD, demo server, Caddy, SSH/FTP)
-- **Worktree:** `/projects/smart-org-wt/agent3` · **Branch:** `agent3/delivery-infra` (already created, already installed)
+- **Worktree:** `/home/mamad/work/smart-org-wt/agent3` · **Branch:** `agent3/delivery-infra` (already created, already installed)
- **Read first:** `docs/plan/PLAN-v2.md` (all of it, especially D5–D9). Then read fully:
`apps/api/app/application/ports/sandbox_provider.py`, `git_provider.py`, `domain_router.py`, `realtime.py`,
`app/infrastructure/adapters/registry.py`, `mock.py`, `caddy.py`, `gitea.py`, `sandbox_remote.py`,
diff --git a/docs/plan/reports/.gitkeep b/docs/plan/reports/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/package.json b/package.json
index 1fed07d..9609250 100644
--- a/package.json
+++ b/package.json
@@ -20,6 +20,8 @@
"worker:billing": "pnpm --filter @agy/billing-worker once",
"worker:ai-runtime": "pnpm --filter @agy/ai-runtime once",
"worker:sandbox-gateway": "pnpm --filter @agy/sandbox-gateway once",
+ "worker:delivery": "pnpm --filter @agy/delivery-worker once",
+ "mcp:dev": "pnpm --filter @agy/mcp-gateway dev",
"clean": "rm -rf node_modules apps/*/node_modules packages/*/node_modules services/*/node_modules"
},
"devDependencies": {
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index ec05644..b3e5b21 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -104,6 +104,10 @@ importers:
services/billing-worker: {}
+ services/delivery-worker: {}
+
+ services/mcp-gateway: {}
+
services/sandbox-gateway: {}
packages:
diff --git a/services/delivery-worker/README.md b/services/delivery-worker/README.md
new file mode 100644
index 0000000..f687b89
--- /dev/null
+++ b/services/delivery-worker/README.md
@@ -0,0 +1,8 @@
+# delivery-worker
+
+Runs queued CI/CD pipeline runs (checkout → setup → build → test → package → demo deploy →
+healthcheck → route) and polls git remotes for pushes made outside the engine.
+Shell around `app/workers/delivery.py`. Owned by the delivery workstream; see `docs/plan/`.
+
+ pnpm worker:delivery # one pass
+ pnpm --filter @agy/delivery-worker dev # loop
diff --git a/services/delivery-worker/package.json b/services/delivery-worker/package.json
new file mode 100644
index 0000000..ca95843
--- /dev/null
+++ b/services/delivery-worker/package.json
@@ -0,0 +1,11 @@
+{
+ "name": "@agy/delivery-worker",
+ "version": "0.1.0",
+ "private": true,
+ "description": "Delivery worker — runs queued CI/CD pipelines and polls remotes for pushes.",
+ "scripts": {
+ "once": "cd ../../apps/api && .venv/bin/python -m app.workers delivery",
+ "dev": "cd ../../apps/api && .venv/bin/python -m app.workers delivery --loop --interval 10",
+ "lint": "cd ../../apps/api && .venv/bin/python -m ruff check app/workers"
+ }
+}
diff --git a/services/mcp-gateway/README.md b/services/mcp-gateway/README.md
new file mode 100644
index 0000000..800d300
--- /dev/null
+++ b/services/mcp-gateway/README.md
@@ -0,0 +1,6 @@
+# mcp-gateway
+
+HTTP (streamable) MCP server. Shell around `apps/api/app/interfaces/mcp/server.py`.
+Owned by the org-platform workstream; see `docs/plan/TODO-agent-1-org-platform.md` Phase 5.
+
+ pnpm mcp:dev
diff --git a/services/mcp-gateway/package.json b/services/mcp-gateway/package.json
new file mode 100644
index 0000000..d8b891d
--- /dev/null
+++ b/services/mcp-gateway/package.json
@@ -0,0 +1,10 @@
+{
+ "name": "@agy/mcp-gateway",
+ "version": "0.1.0",
+ "private": true,
+ "description": "MCP gateway — lets AI agents control an organization through scoped tokens.",
+ "scripts": {
+ "dev": "cd ../../apps/api && .venv/bin/python -m app.interfaces.mcp.server",
+ "lint": "cd ../../apps/api && .venv/bin/python -m ruff check app/interfaces/mcp"
+ }
+}