diff --git a/coworker/agent.py b/coworker/agent.py index 451871f8..1eb8b025 100644 --- a/coworker/agent.py +++ b/coworker/agent.py @@ -226,7 +226,7 @@ def build_engine( extra_skill_dirs: Optional[list[str | Path]] = None, ) -> TurnEngine: ws = Path(workspace).expanduser().resolve() if workspace else None - if agent.needs_workspace and ws is None: + if agent.requires_folder and ws is None: raise ValueError(f"agent '{agent.name}' requires a workspace") # The session's directories. Explicit `roots` (orphan Cowork: scratch + added folders) wins; @@ -241,9 +241,7 @@ def build_engine( workspace_trusted = bool(ws and WorkspaceTrustStore().is_trusted(ws)) config = load_config(ws, workspace_trusted=workspace_trusted) - executor = ( - LocalExecutor(cwd=ws) if (agent.needs_workspace and ws is not None) else None - ) + executor = LocalExecutor(cwd=ws) if ws is not None else None todo = TodoList() context = AgentContext( workspace=ws, executor=executor, todo=todo, roots=root_list or None @@ -275,8 +273,8 @@ def build_engine( routing_targets=routing_targets, ) ) - # Knowledge surfaces with a multi-root workspace can ask the user mid-task for another folder. - if agent.family == "knowledge" and root_list: + # Surfaces with a multi-root workspace can ask the user mid-task for another folder. + if root_list: registry.register(request_directory_tool()) # Anything with a shell can hit a missing CLI (a scanner, aws, kubectl). Give it a way to # ask instead of silently dropping the check that needed it (OPE-85). @@ -313,9 +311,9 @@ def build_engine( # passes its shared router; this fallback covers the TUI / direct build_engine() callers. # Resolved here (not at engine construction) because the explorer subagent captures it. provider = provider or ProviderRouter(secrets, default_provider="openai") - # Code-family personas can fan broad research out to read-only explorer subagents, keeping + # Repo-focused personas can fan broad research out to read-only explorer subagents, keeping # their own context for the actual change. - if agent.family == "code" and ws is not None: + if agent.subagents and ws is not None: registry.register_all( explorer_tools( workspace=ws, @@ -324,9 +322,9 @@ def build_engine( model_settings=model_settings, ) ) - # Scheduling: knowledge surfaces with a workspace can set up scheduled tasks (origin = this + # Scheduling: opted-in surfaces with a workspace can set up scheduled tasks (origin = this # session). Code stays out (it fans out to explorers instead). - if task_store is not None and ws is not None and agent.family == "knowledge": + if task_store is not None and ws is not None and agent.scheduling: origin = { "surface": agent.name, "session_id": session_id or "", @@ -336,9 +334,9 @@ def build_engine( registry.register_all( scheduling_tools(task_store, origin=origin, default_workspace=str(ws)) ) - # Self-wake: knowledge surfaces can suspend + schedule their own resumption (timer / + # Self-wake: scheduling surfaces can suspend + schedule their own resumption (timer / # on-completion / on-event). The scheduler tick resumes due wakes. - if wake_store is not None and session_id and agent.family == "knowledge": + if wake_store is not None and session_id and agent.scheduling: registry.register_all(selfwake_tools(wake_store, session_id)) instructions = f"{agent.system_prompt}\n\n{_NARRATION_GUIDANCE}" @@ -445,15 +443,11 @@ def build_engine( # Per-turn ephemeral context, appended to the latest user message since mid-thread system # messages aren't reliable across providers. Three producers: the plan-mode reminder (mode can # flip mid-session, so it's checked each turn, not baked into the instructions), the live - # directory list (orphan Cowork can gain folders mid-session; Cowork/MyHelper only), and the + # directory list (any multi-root session can gain folders mid-session), and the # memory-SAVING notice (same reason as plan mode — the switch flips either way mid-chat). # Note what is NOT here: the memories and the user's rules. Those are knowledge, fixed at # session start (§7.1). - roots_context = ( - (lambda: render_context(root_list)) - if root_list and agent.family == "knowledge" - else None - ) + roots_context = (lambda: render_context(root_list)) if root_list else None # Late-bound engine ref: the closure needs the conversation history (for the disable # countermand) but the engine is constructed after the closure. Filled below. diff --git a/coworker/agents/base.py b/coworker/agents/base.py index bfd11b0b..43ac03de 100644 --- a/coworker/agents/base.py +++ b/coworker/agents/base.py @@ -30,15 +30,19 @@ class Agent: name: str title: str system_prompt: str - needs_workspace: bool = False tool_factory: Optional[Callable[[AgentContext], list]] = None # Traits that replace the old per-agent-name branching in build_engine / manager. - # family: "code" gets explorer subagents; "knowledge" gets scheduling / request_directory / - # roots context (when it has a workspace). messaging: exposes send_message. connectors: - # loads the integration toolset — True = every connected connector (general builtins - # only), a tuple = allowlist (session gets declared ∩ connected; OPE-93), False = none. - # Defaults keep non-persona callers behaving as before. - family: str = "knowledge" + # requires_folder: the session cannot start without a user-picked primary folder + # (composer + engine gate; everything else starts on a scratch dir). subagents: + # read-only explorer fan-out. scheduling: scheduled tasks + self-wake. messaging: + # exposes send_message. connectors: loads the integration toolset — True = every + # connected connector (general builtins only), a tuple = allowlist (session gets + # declared ∩ connected; OPE-93), False = none. Defaults keep non-persona callers + # behaving as before. (The old family/needs_workspace/workspace trio collapsed into + # these — see ocw-context/docs/workspace-scratch-design.md.) + requires_folder: bool = False + subagents: bool = False + scheduling: bool = False messaging: bool = False connectors: bool | tuple[str, ...] = False # Team identity: "lead" | "worker" | None (solo-only). Gates the board/journal diff --git a/coworker/agents/chat.py b/coworker/agents/chat.py index 7343c0fd..4d2106e1 100644 --- a/coworker/agents/chat.py +++ b/coworker/agents/chat.py @@ -17,6 +17,5 @@ def chat_agent() -> Agent: name="chat", title="Chat", system_prompt=CHAT_INSTRUCTIONS, - needs_workspace=False, tool_factory=None, ) diff --git a/coworker/agents/code.py b/coworker/agents/code.py index 4455704a..41f29407 100644 --- a/coworker/agents/code.py +++ b/coworker/agents/code.py @@ -68,7 +68,7 @@ def code_agent() -> Agent: name="code", title="Code", system_prompt=CODE_INSTRUCTIONS, - needs_workspace=True, tool_factory=lambda context: expand(CODE_CAPABILITIES, context), - family="code", + requires_folder=True, + subagents=True, ) diff --git a/coworker/agents/cowork.py b/coworker/agents/cowork.py index cec60aa3..d72ddc6f 100644 --- a/coworker/agents/cowork.py +++ b/coworker/agents/cowork.py @@ -48,9 +48,8 @@ def cowork_agent() -> Agent: name="cowork", title="Cowork", system_prompt=COWORK_INSTRUCTIONS, - needs_workspace=True, tool_factory=cowork_tool_factory, - family="knowledge", + scheduling=True, messaging=True, connectors=True, ) diff --git a/coworker/agents/myhelper.py b/coworker/agents/myhelper.py index 6cd62121..07582c82 100644 --- a/coworker/agents/myhelper.py +++ b/coworker/agents/myhelper.py @@ -32,8 +32,7 @@ def myhelper_agent(name: str = DEFAULT_HELPER_NAME) -> Agent: name="myhelper", title=name, system_prompt=myhelper_instructions(name), - needs_workspace=True, tool_factory=cowork_tool_factory, - family="knowledge", + scheduling=True, messaging=True, ) diff --git a/coworker/personas/builtin/appsec-worker/manifest.md b/coworker/personas/builtin/appsec-worker/manifest.md index dd57bf73..75cea36b 100644 --- a/coworker/personas/builtin/appsec-worker/manifest.md +++ b/coworker/personas/builtin/appsec-worker/manifest.md @@ -4,7 +4,8 @@ id: appsec-worker name: AppSec Worker icon: code tagline: Code security review under a team lead — scan, triage, fix -family: code +requires_folder: true +subagents: true version: "1" team: worker tools: [code_files, git, search, shell, todo] diff --git a/coworker/personas/builtin/change-worker/manifest.md b/coworker/personas/builtin/change-worker/manifest.md index eb50467e..95940963 100644 --- a/coworker/personas/builtin/change-worker/manifest.md +++ b/coworker/personas/builtin/change-worker/manifest.md @@ -4,7 +4,8 @@ id: change-worker name: Change Worker icon: code tagline: Incident diagnosis from the change side — what shipped, when, and what it touched -family: code +requires_folder: true +subagents: true version: "1" team: worker tools: [shell, code_files, git, search, todo] diff --git a/coworker/personas/builtin/cloud-posture/manifest.md b/coworker/personas/builtin/cloud-posture/manifest.md index 165a2cae..85f99fbb 100644 --- a/coworker/personas/builtin/cloud-posture/manifest.md +++ b/coworker/personas/builtin/cloud-posture/manifest.md @@ -4,7 +4,8 @@ id: cloud-posture name: Cloud Posture Coworker icon: sliders tagline: Review Terraform & cloud config — read-only, evidence first -family: code +requires_folder: true +subagents: true version: "1" tools: [code_files, git, search, shell, todo] connectors: [github] diff --git a/coworker/personas/builtin/dep-audit/manifest.md b/coworker/personas/builtin/dep-audit/manifest.md index bc410009..85d38f83 100644 --- a/coworker/personas/builtin/dep-audit/manifest.md +++ b/coworker/personas/builtin/dep-audit/manifest.md @@ -4,7 +4,8 @@ id: dep-audit name: Dependency Audit Coworker icon: audit tagline: Vulnerable dependencies — audit, minimal upgrades, PRs -family: code +requires_folder: true +subagents: true version: "1" tools: [code_files, git, search, shell, todo] connectors: [github] diff --git a/coworker/personas/builtin/design-worker/manifest.md b/coworker/personas/builtin/design-worker/manifest.md index e6c5f841..9b4bbc33 100644 --- a/coworker/personas/builtin/design-worker/manifest.md +++ b/coworker/personas/builtin/design-worker/manifest.md @@ -4,7 +4,8 @@ id: design-worker name: Design Worker icon: layout tagline: UI/UX implementation under a team lead -family: code +requires_folder: true +subagents: true version: "1" team: worker tools: [code_files, git, search, shell, todo] diff --git a/coworker/personas/builtin/devops-lead/manifest.md b/coworker/personas/builtin/devops-lead/manifest.md index cf6fc42c..a3498f50 100644 --- a/coworker/personas/builtin/devops-lead/manifest.md +++ b/coworker/personas/builtin/devops-lead/manifest.md @@ -4,7 +4,8 @@ id: devops-lead name: DevOps Lead icon: audit tagline: Stands watch over production — correlates what broke with what shipped, staffs an incident team only when it matters -family: code +requires_folder: true +subagents: true version: "1" team: lead tools: [shell, code_files, search, todo] diff --git a/coworker/personas/builtin/devsecops-lead/manifest.md b/coworker/personas/builtin/devsecops-lead/manifest.md index cec3e010..131e8dc2 100644 --- a/coworker/personas/builtin/devsecops-lead/manifest.md +++ b/coworker/personas/builtin/devsecops-lead/manifest.md @@ -4,7 +4,8 @@ id: devsecops-lead name: DevSecOps Lead icon: shield tagline: Leads a security review team — scopes, staffs, assigns, verifies evidence -family: code +requires_folder: true +subagents: true version: "1" team: lead tools: [code_files, search, todo] diff --git a/coworker/personas/builtin/infra-worker/manifest.md b/coworker/personas/builtin/infra-worker/manifest.md index 0fc6c319..9db4ffb6 100644 --- a/coworker/personas/builtin/infra-worker/manifest.md +++ b/coworker/personas/builtin/infra-worker/manifest.md @@ -4,7 +4,8 @@ id: infra-worker name: Infra Worker icon: sliders tagline: Incident diagnosis from the platform side — resources, cloud state, IaC -family: code +requires_folder: true +subagents: true version: "1" team: worker tools: [shell, code_files, git, search, todo] diff --git a/coworker/personas/builtin/logs-worker/manifest.md b/coworker/personas/builtin/logs-worker/manifest.md index ebcf930a..d08a9873 100644 --- a/coworker/personas/builtin/logs-worker/manifest.md +++ b/coworker/personas/builtin/logs-worker/manifest.md @@ -4,7 +4,8 @@ id: logs-worker name: Logs Worker icon: search tagline: Incident diagnosis from the symptom side — errors, traces, reproduction -family: code +requires_folder: true +subagents: true version: "1" team: worker tools: [shell, code_files, git, search, todo] diff --git a/coworker/personas/builtin/ops.md b/coworker/personas/builtin/ops.md index 242baf10..f8ca9a39 100644 --- a/coworker/personas/builtin/ops.md +++ b/coworker/personas/builtin/ops.md @@ -4,7 +4,6 @@ id: ops name: Ops Coworker icon: wrench tagline: Operate and investigate — runbooks, logs, infrastructure -family: knowledge tools: [files, search, shell, todo] messaging: true connectors: true diff --git a/coworker/personas/builtin/posture-worker/manifest.md b/coworker/personas/builtin/posture-worker/manifest.md index 542daf1b..dbe0f891 100644 --- a/coworker/personas/builtin/posture-worker/manifest.md +++ b/coworker/personas/builtin/posture-worker/manifest.md @@ -4,7 +4,8 @@ id: posture-worker name: Posture Worker icon: sliders tagline: IaC & cloud posture under a team lead — read-only, evidence first -family: code +requires_folder: true +subagents: true version: "1" team: worker tools: [code_files, git, search, shell, todo] diff --git a/coworker/personas/builtin/secrets-worker/manifest.md b/coworker/personas/builtin/secrets-worker/manifest.md index 0880fab3..af4b4d63 100644 --- a/coworker/personas/builtin/secrets-worker/manifest.md +++ b/coworker/personas/builtin/secrets-worker/manifest.md @@ -4,7 +4,8 @@ id: secrets-worker name: Secrets Worker icon: search tagline: Secret hunting under a team lead — working tree and full git history -family: code +requires_folder: true +subagents: true version: "1" team: worker tools: [code_files, git, search, shell, todo] diff --git a/coworker/personas/builtin/security/manifest.md b/coworker/personas/builtin/security/manifest.md index 1c1dbd23..f6c0d2bb 100644 --- a/coworker/personas/builtin/security/manifest.md +++ b/coworker/personas/builtin/security/manifest.md @@ -4,7 +4,8 @@ id: security name: Security Coworker icon: shield tagline: Find and fix security issues — scan, triage, PR -family: code +requires_folder: true +subagents: true version: "1" tools: [code_files, git, search, shell, todo] connectors: [github] diff --git a/coworker/personas/builtin/swe-lead/manifest.md b/coworker/personas/builtin/swe-lead/manifest.md index 5627dde6..7be551b0 100644 --- a/coworker/personas/builtin/swe-lead/manifest.md +++ b/coworker/personas/builtin/swe-lead/manifest.md @@ -4,7 +4,8 @@ id: swe-lead name: SWE Lead icon: users tagline: Leads a software team — plans, staffs, assigns, verifies -family: code +requires_folder: true +subagents: true version: "1" team: lead tools: [code_files, search, todo] diff --git a/coworker/personas/builtin/swe-worker/manifest.md b/coworker/personas/builtin/swe-worker/manifest.md index 315bbaf2..1cb2fb7d 100644 --- a/coworker/personas/builtin/swe-worker/manifest.md +++ b/coworker/personas/builtin/swe-worker/manifest.md @@ -4,7 +4,8 @@ id: swe-worker name: SWE Worker icon: code tagline: Implements work items under a team lead -family: code +requires_folder: true +subagents: true version: "1" team: worker tools: [code_files, git, search, shell, todo] diff --git a/coworker/personas/builtin/test-worker/manifest.md b/coworker/personas/builtin/test-worker/manifest.md index 82ec780a..a76fc5b4 100644 --- a/coworker/personas/builtin/test-worker/manifest.md +++ b/coworker/personas/builtin/test-worker/manifest.md @@ -4,7 +4,8 @@ id: test-worker name: Test Worker icon: check tagline: Verifies teammates' work against acceptance criteria -family: code +requires_folder: true +subagents: true version: "1" team: worker tools: [code_files, git, search, shell, todo] diff --git a/coworker/personas/manifest.py b/coworker/personas/manifest.py index 80a81e15..45a88183 100644 --- a/coworker/personas/manifest.py +++ b/coworker/personas/manifest.py @@ -20,9 +20,8 @@ import yaml # (traversal), no `:*?"<>|` (invalid on Windows), bounded length. _ID_RE = re.compile(r"^[a-z0-9][a-z0-9_-]{0,63}$") -VALID_FAMILIES = {"code", "knowledge"} +VALID_FAMILIES = {"code", "knowledge"} # legacy key, shimmed in parse() VALID_TEAM = {"lead", "worker"} -VALID_WORKSPACES = {"git", "project", "deliverable", "none"} VALID_MODES = {"discuss", "plan", "interactive", "custom", "auto"} VALID_REC_KINDS = {"connector", "mcp"} VALID_REC_TIERS = {"core", "optional"} @@ -55,10 +54,14 @@ class PersonaManifest: tagline: str = "" description: str = "" tools: list[str] = field(default_factory=list) - family: str = "knowledge" # "code" | "knowledge" - # Derived from family since the enum collapse (§16): code → "git", knowledge → - # "deliverable". Builtins registered via builders may still carry "none" (Chat). - workspace: str = "deliverable" + # Workspace/toolset traits (workspace-scratch-design.md — replaces the old + # family/workspace pair). requires_folder: the composer/engine gate on a + # user-picked primary folder. subagents: explorer fan-out. scheduling: + # scheduled tasks + self-wake (defaults to the opposite of requires_folder + # when the manifest is silent — folder personas fan out instead). + requires_folder: bool = False + subagents: bool = False + scheduling: bool = True messaging: bool = False # Connector grant (OPE-93): False = none, a tuple = allowlist of connector ids # (session exposes declared ∩ connected), True = every connected connector — the @@ -93,10 +96,6 @@ class PersonaManifest: None # where it was loaded from (path / url), for provenance ) - @property - def needs_workspace(self) -> bool: - return self.workspace != "none" - def to_agent(self): """Materialize the runtime Agent (prompt + catalog-expanded tools + traits).""" from ..agents.base import Agent @@ -108,9 +107,10 @@ class PersonaManifest: name=self.id, title=self.name, system_prompt=self.system_prompt, - needs_workspace=self.needs_workspace, tool_factory=factory, - family=self.family, + requires_folder=self.requires_folder, + subagents=self.subagents, + scheduling=self.scheduling, messaging=self.messaging, connectors=self.connectors, team=self.team, @@ -273,22 +273,21 @@ def parse_manifest( if not body.strip(): raise ManifestError(f"persona {persona_id!r} has no body (the system prompt)") - family = str(meta.get("family", "knowledge")).strip().lower() - if family not in VALID_FAMILIES: + # Workspace/toolset traits (workspace-scratch-design.md). Legacy shim: pre-trait + # bundles declared `family: code|knowledge` (and a dead `workspace:` enum, ignored + # here) — when the new keys are absent, `family: code` maps to the folder-gated + # profile so an old bundle keeps its gate. New keys always win. + legacy_family = str(meta.get("family", "")).strip().lower() + if legacy_family and legacy_family not in VALID_FAMILIES: raise ManifestError( - f"persona {persona_id!r}: family must be one of {sorted(VALID_FAMILIES)}" + f"persona {persona_id!r}: family (legacy) must be one of {sorted(VALID_FAMILIES)}" ) - - # The workspace enum collapsed into family (owner decision 2026-07-03, UX-DECISIONS §16): - # knowledge → transparent scratch + user-added roots (no folder gate, ever); code → an - # explicit directory picked by the user. The manifest key is still accepted — and - # typo-checked — so older manifests parse, but it no longer drives behavior. - declared = str(meta.get("workspace", "")).strip().lower() - if declared and declared not in VALID_WORKSPACES: - raise ManifestError( - f"persona {persona_id!r}: workspace must be one of {sorted(VALID_WORKSPACES)}" - ) - workspace = "git" if family == "code" else "deliverable" + legacy_code = legacy_family == "code" + requires_folder = bool(meta.get("requires_folder", legacy_code)) + subagents = bool(meta.get("subagents", legacy_code)) + # Folder personas fan out to explorers instead of scheduling — the silent default + # mirrors that split; either can be declared explicitly. + scheduling = bool(meta.get("scheduling", not requires_folder)) mode = str(meta.get("default_permission_mode", "interactive")).strip().lower() if mode not in VALID_MODES: @@ -322,8 +321,9 @@ def parse_manifest( tagline=str(meta.get("tagline", "")).strip(), description=str(meta.get("description", "")).strip(), tools=tools, - family=family, - workspace=workspace, + requires_folder=requires_folder, + subagents=subagents, + scheduling=scheduling, messaging=bool(meta.get("messaging", False)), connectors=connectors, team=team_raw or None, diff --git a/coworker/personas/registry.py b/coworker/personas/registry.py index fdf9f84f..41e58aeb 100644 --- a/coworker/personas/registry.py +++ b/coworker/personas/registry.py @@ -49,13 +49,13 @@ class PersonaEntry: name: str icon: str = "" tagline: str = "" - needs_workspace: bool = True builtin: bool = True - family: str = "knowledge" - # The persona's workspace requirement (git|project|deliverable|none) — surfaced to the GUI so it - # can detect project-scoped personas (git/project) uniformly. Manifest-backed personas carry it - # verbatim; builtins set it at registration to match their family/needs_workspace. - workspace: str = "deliverable" + # Workspace/toolset traits (workspace-scratch-design.md): requires_folder is the + # composer/engine gate on a user-picked primary folder — surfaced to the GUI, which + # groups gated sessions by project. subagents/scheduling gate the matching toolsets. + requires_folder: bool = False + subagents: bool = False + scheduling: bool = True tools: list[str] = field(default_factory=list) default_surfaced: bool = ( True # whether it shows in the picker before any user choice @@ -117,10 +117,10 @@ class PersonaRegistry: icon, tagline, builder, - needs_workspace, - family, tools, - workspace="deliverable", + requires_folder=False, + subagents=False, + scheduling=True, default_surfaced=True, default_enabled=True, group="general", @@ -130,10 +130,10 @@ class PersonaRegistry: name=name, icon=icon, tagline=tagline, - needs_workspace=needs_workspace, builtin=True, - family=family, - workspace=workspace, + requires_folder=requires_folder, + subagents=subagents, + scheduling=scheduling, tools=list(tools), default_surfaced=default_surfaced, default_enabled=default_enabled, @@ -154,10 +154,7 @@ class PersonaRegistry: "cowork", "Produce a deliverable — research, analysis, scripts", cowork_agent, - True, - "knowledge", COWORK_CAPABILITIES, - workspace="deliverable", ) self._register_builder( "code", @@ -165,10 +162,10 @@ class PersonaRegistry: "code", "Work in a codebase — files, git, shell", code_agent, - True, - "code", CODE_CAPABILITIES, - workspace="git", + requires_folder=True, + subagents=True, + scheduling=False, default_surfaced=False, default_enabled=False, ) @@ -200,10 +197,10 @@ class PersonaRegistry: name=m.name, icon=m.icon, tagline=m.tagline, - needs_workspace=m.needs_workspace, builtin=builtin, - family=m.family, - workspace=m.workspace, + requires_folder=m.requires_folder, + subagents=m.subagents, + scheduling=m.scheduling, tools=list(m.tools), ships=m.ships, group=m.group, @@ -317,7 +314,7 @@ class PersonaRegistry: { "name": e.id, "title": e.name, - "needs_workspace": e.needs_workspace, + "requires_folder": e.requires_folder, "icon": e.icon, "tagline": e.tagline, "default": e.id == self.default_id(), @@ -333,10 +330,8 @@ class PersonaRegistry: "name": e.name, "icon": e.icon, "tagline": e.tagline, - "needs_workspace": e.needs_workspace, + "requires_folder": e.requires_folder, "builtin": e.builtin, - "family": e.family, - "workspace": e.workspace, "tools": e.tools, "enabled": self.is_enabled(e.id), "surfaced": self.is_surfaced(e.id), diff --git a/coworker/server/manager.py b/coworker/server/manager.py index 190ce748..76322867 100644 --- a/coworker/server/manager.py +++ b/coworker/server/manager.py @@ -481,8 +481,7 @@ class SessionManager: record = self.session_store.load(session_id) if record: return record.workspace or None - ag = get_agent(agent or "code") - return self.resolve_workspace(workspace) if ag.needs_workspace else None + return self.resolve_workspace(workspace) def get_engine( self, @@ -526,14 +525,14 @@ class SessionManager: ws = record.workspace or None model, mode, messages = record.model, Mode(record.mode), record.messages else: - ws = self.resolve_workspace(workspace) if ag.needs_workspace else None + ws = self.resolve_workspace(workspace) model, mode, messages = self.model, self.mode, None - if ag.needs_workspace and (not ws or not Path(ws).is_dir()): - # Knowledge surfaces (Cowork, Ops, …) start "orphan": no folder picked → - # auto-provision a per-conversation scratch directory (generalizes MyHelper's - # auto-workspace). Code-family surfaces still require a real repo; Chat needs none. - if ag.family == "knowledge": + if not ws or not Path(ws).is_dir(): + # Sessions without a folder start "orphan": auto-provision a per-conversation + # scratch directory (generalizes MyHelper's auto-workspace). Folder-gated + # personas (requires_folder) still demand a real directory picked by the user. + if not ag.requires_folder: ws = self._provision_scratch(session_id) else: return None @@ -541,9 +540,10 @@ class SessionManager: if ws: self.session_store.touch_workspace(ws) # Orphan surfaces are multi-root: the scratch (ws) is the primary writable root, plus any - # folders the user added (persisted per session). Code/Chat stay single-root (roots=None). + # folders the user added (persisted per session). Folder-gated personas stay single-root + # (roots=None) until universal scratch lands (workspace-scratch-design.md phase B). roots = None - if ag.family == "knowledge" and ws: + if not ag.requires_folder and ws: extra = [ r for r in ((record.extra_roots if record else []) or []) @@ -644,8 +644,11 @@ class SessionManager: from ..config import load_config entry = self.personas.get(persona_id) - family = entry.family if entry else "" - workspace_kind = entry.workspace if entry else "" + # Wire fields kept stable; both now carry the workspace shape ("folder" = gated + # primary folder, "scratch" = starts on the per-session scratch dir). + kind = ("folder" if entry.requires_folder else "scratch") if entry else "" + family = kind + workspace_kind = kind def _send() -> None: try: @@ -734,19 +737,6 @@ class SessionManager: return connector in self.effective_connectors(session_id) # -- persona + session connection surfaces (UI-REFRESH §5/§6) ---------------- - @staticmethod - def _workspace_kind(entry) -> str: - """The persona's workspace requirement as a stable string for the GUI. Manifest-backed - personas carry it verbatim (git|deliverable|none); builtins (which have no manifest) map - family/needs_workspace into the SAME vocabulary so the frontend reads one enum: - code-family → git, knowledge-family with a workspace → deliverable, none → none. - """ - if entry.manifest is not None: - return entry.manifest.workspace - if not entry.needs_workspace: - return "none" - return "git" if entry.family == "code" else "deliverable" - def _connected_connectors(self) -> set[str]: """The account-connected connector names (the first layer of the §4 hierarchy).""" return {c["name"] for c in connector_list(self.secrets) if c["connected"]} @@ -811,7 +801,7 @@ class SessionManager: "default_permission_mode": ( manifest.default_permission_mode if manifest else "interactive" ), - "workspace": self._workspace_kind(entry), + "requires_folder": entry.requires_folder, "recommends": recommends, "default_connections": self._persona_default_connections( persona_id, manifest, connected diff --git a/surfaces/gui/e2e/fixtures.ts b/surfaces/gui/e2e/fixtures.ts index 00a587d7..94c3e69f 100644 --- a/surfaces/gui/e2e/fixtures.ts +++ b/surfaces/gui/e2e/fixtures.ts @@ -58,13 +58,13 @@ const SETTINGS = { const PERSONAS = { internal: true, personas: [ - { id: "cowork", name: "OpenWorker", icon: "cowork", tagline: "Produce a deliverable — research, analysis, scripts", needs_workspace: true, builtin: true, family: "knowledge", workspace: "deliverable", tools: ["files", "search"], enabled: true, surfaced: true, default: true, ships: true, group: "general" }, - { id: "code", name: "Code", icon: "code", tagline: "Work in a codebase — files, git, shell", needs_workspace: true, builtin: true, family: "code", workspace: "git", tools: ["code_files", "git"], enabled: false, surfaced: false, default: false, ships: true, group: "general" }, - { id: "security", name: "Security Coworker", icon: "shield", tagline: "Find and fix security issues — scan, triage, PR", needs_workspace: true, builtin: true, family: "code", workspace: "git", tools: ["code_files", "git", "shell"], enabled: true, surfaced: true, default: false, ships: true, group: "security" }, - { id: "ops", name: "Ops Coworker", icon: "wrench", tagline: "Operate and investigate — runbooks, logs, infrastructure", needs_workspace: true, builtin: true, family: "knowledge", workspace: "deliverable", tools: ["files", "shell"], enabled: true, surfaced: true, default: false, ships: false, group: "general" }, + { id: "cowork", name: "OpenWorker", icon: "cowork", tagline: "Produce a deliverable — research, analysis, scripts", requires_folder: false, builtin: true, tools: ["files", "search"], enabled: true, surfaced: true, default: true, ships: true, group: "general" }, + { id: "code", name: "Code", icon: "code", tagline: "Work in a codebase — files, git, shell", requires_folder: true, builtin: true, tools: ["code_files", "git"], enabled: false, surfaced: false, default: false, ships: true, group: "general" }, + { id: "security", name: "Security Coworker", icon: "shield", tagline: "Find and fix security issues — scan, triage, PR", requires_folder: true, builtin: true, tools: ["code_files", "git", "shell"], enabled: true, surfaced: true, default: false, ships: true, group: "security" }, + { id: "ops", name: "Ops Coworker", icon: "wrench", tagline: "Operate and investigate — runbooks, logs, infrastructure", requires_folder: false, builtin: true, tools: ["files", "shell"], enabled: true, surfaced: true, default: false, ships: false, group: "general" }, // A non-builtin install (disabled pending consent — invisible to picker specs) so the // Personas page's delete/enable affordances have a target. - { id: "acme-notes", name: "Acme Notes", icon: "pencil", tagline: "Acme's note-taking coworker", needs_workspace: true, builtin: false, family: "knowledge", workspace: "deliverable", tools: ["files"], enabled: false, surfaced: false, default: false, ships: true, group: "general" }, + { id: "acme-notes", name: "Acme Notes", icon: "pencil", tagline: "Acme's note-taking coworker", requires_folder: false, builtin: false, tools: ["files"], enabled: false, surfaced: false, default: false, ships: true, group: "general" }, ], }; @@ -1334,8 +1334,8 @@ export async function mockApi(page: import("@playwright/test").Page) { // Sharing v1: a bundle zip import — consent with version + replaces + recommends. const imported = { id: "team-sec", name: "Team Security Coworker", icon: "shield", - tagline: "Our security playbook", needs_workspace: true, builtin: false, - family: "code", workspace: "git", tools: ["code_files", "search", "shell"], + tagline: "Our security playbook", requires_folder: true, builtin: false, + tools: ["code_files", "search", "shell"], enabled: false, surfaced: false, default: false, version: "2", }; if (!personas.some((x) => x.id === "team-sec")) personas.push(imported); diff --git a/surfaces/gui/src/App.tsx b/surfaces/gui/src/App.tsx index 06e84090..9fd31e3f 100644 --- a/surfaces/gui/src/App.tsx +++ b/surfaces/gui/src/App.tsx @@ -113,9 +113,8 @@ function normalizeTodos(raw: unknown): TodoItem[] { }); } -// Fallbacks used only before the persona list loads (the in-component, family-aware -// needsWorkspace/gatesWorkspace consult the real persona once available). -const needsWorkspaceFallback = (a: string) => a === "code" || a === "cowork"; +// Fallback used only before the persona list loads (the in-component gatesWorkspace +// consults the real persona's requires_folder once available). const gatesWorkspaceFallback = (a: string) => a === "code"; const LAST_SESSION_KEY = "coworker:last-session-by-agent:v1"; const NAV_COLLAPSED_KEY = "coworker:nav-collapsed:v1"; @@ -404,11 +403,9 @@ export function App() { getInbox(sessionId, "pending").then(setSessionInbox).catch(() => setSessionInbox([])); refreshSessions(); // attention badge should drop right away }; - // Shows a working-area chip / project grouping. Persona's needs_workspace; fallback before load. - const needsWorkspace = (a: string) => personaOf(a)?.needs_workspace ?? needsWorkspaceFallback(a); - // MUST pick a folder before starting — project-scoped personas (git-bound Code, project-bound - // Ops). Scratch/deliverable personas start orphan: the server auto-provisions a per-conversation - // scratch dir and reports it in the `ready` event. + // MUST pick a folder before starting — requires_folder personas (git-bound Code, the + // security coworkers). Everything else starts orphan: the server auto-provisions a + // per-conversation scratch dir and reports it in the `ready` event. const gatesWorkspace = (a: string) => { const p = personaOf(a); return p ? isProjectScoped(p) : gatesWorkspaceFallback(a); @@ -1343,9 +1340,7 @@ export function App() { // starts orphan ("" → server provisions). Chat has no workspace. const targetWorkspace = gatesWorkspace(name) ? target.workspace || fallbackWorkspace(inheritable, knownProjects) - : needsWorkspace(name) - ? target.workspace || "" - : ""; + : target.workspace || ""; if (targetWorkspace && targetWorkspace !== workspace) { setWorkspace(targetWorkspace); setBranch(null); @@ -1372,7 +1367,7 @@ export function App() { if (fallback && fallback !== workspace) { setWorkspace(fallback); setBranch(null); - } else if (!fallback && needsWorkspace(name)) { + } else if (!fallback) { setWorkspace(null); // orphan cowork: server provisions a fresh scratch on connect } setSessionId(id); @@ -1864,7 +1859,7 @@ export function App() { ✦ {agent === "chat" ? "How can I help?" : "Let's build something."} - {needsWorkspace(agent) && ( + {(