Personas: requires_folder/subagents/scheduling traits replace family and the workspace enum

Manifest keeps a legacy family shim; telemetry wire fields unchanged.
No behavior change; spec in ocw-context/docs/workspace-scratch-design.md.
This commit is contained in:
Rohit C Prasad
2026-08-20 21:19:56 -07:00
parent 9735d0c6e7
commit 1299760400
40 changed files with 192 additions and 205 deletions
+7 -7
View File
@@ -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);
+10 -15
View File
@@ -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() {
<span className="mark"></span>
{agent === "chat" ? "How can I help?" : "Let's build something."}
</h1>
{needsWorkspace(agent) && (
{(
<div className="suggestions">
<div className="suggest-head">Try a task</div>
{SUGGESTIONS.map((s, i) => (
@@ -1943,7 +1938,7 @@ export function App() {
<SessionSetupRow
personas={personas}
agent={agent}
showFolder={needsWorkspace(agent)}
showFolder
folderName={workspace && !tempWorkspace ? baseName(workspace) : null}
onPickCoworker={pickCoworker}
onPickFolder={pickDraftFolder}
@@ -2002,7 +1997,7 @@ export function App() {
onModeChange={changeMode}
onModelChange={changeModel}
sessionId={sessionId}
workspace={needsWorkspace(agent) ? workspace || "" : undefined}
workspace={workspace || ""}
unattended={unattended}
onUnattendedChange={agent !== "chat" ? toggleUnattended : undefined}
prefill={composerPrefill}
+2 -4
View File
@@ -1030,10 +1030,8 @@ export interface Persona {
name: string;
icon: string;
tagline: string;
needs_workspace: boolean;
requires_folder: boolean; // folder gate — drives project-scoping
builtin: boolean;
family: string;
workspace: string; // "git" | "project" | "deliverable" | "none" — drives project-scoping
tools: string[];
enabled: boolean;
surfaced: boolean;
@@ -1213,7 +1211,7 @@ export interface PersonaDetail {
tools: string[];
recommended_models: string[];
default_permission_mode: string;
workspace: string;
requires_folder: boolean; // folder gate (workspace-scratch-design.md)
recommends: PersonaRecommendation[];
default_connections: PersonaDefaultConnection[];
}
+4 -5
View File
@@ -357,11 +357,10 @@ export function PersonaView({
<span className="text-faint">Default mode</span> · {detail.default_permission_mode}
</div>
)}
{detail.workspace && (
<div>
<span className="text-faint">Workspace</span> · {detail.workspace}
</div>
)}
<div>
<span className="text-faint">Workspace</span> ·{" "}
{detail.requires_folder ? "picked folder" : "scratch"}
</div>
</section>
{/* management — the controls that left the list page (UX-035) */}
+4 -4
View File
@@ -24,10 +24,10 @@ function stubFetch(routes: { match: string; method?: string; json: any }[]) {
const PERSONAS = {
personas: [
{ id: "cowork", name: "OpenWorker", icon: "cowork", tagline: "general assistant", family: "knowledge", enabled: true, surfaced: true, default: true },
{ id: "ops", name: "Ops", icon: "ops", tagline: "incidents, runbooks", family: "code", enabled: true, surfaced: true, default: false },
{ id: "code", name: "Code", icon: "code", tagline: "repository work", family: "code", enabled: true, surfaced: true, default: false },
{ id: "secret", name: "Disabled One", icon: "cowork", tagline: "off", family: "knowledge", enabled: false, surfaced: false, default: false },
{ id: "cowork", name: "OpenWorker", icon: "cowork", tagline: "general assistant", requires_folder: false, enabled: true, surfaced: true, default: true },
{ id: "ops", name: "Ops", icon: "ops", tagline: "incidents, runbooks", requires_folder: true, enabled: true, surfaced: true, default: false },
{ id: "code", name: "Code", icon: "code", tagline: "repository work", requires_folder: true, enabled: true, surfaced: true, default: false },
{ id: "secret", name: "Disabled One", icon: "cowork", tagline: "off", requires_folder: false, enabled: false, surfaced: false, default: false },
],
};
+1 -1
View File
@@ -38,7 +38,7 @@ const SURFACES: { key: string; label: string; icon: IconName; cls: string }[] =
const surfaceFromPersona = (p: Persona) => ({
key: p.id,
label: shortPersonaName(p.name, p.id),
icon: personaGlyph(p.icon, p.family),
icon: personaGlyph(p.icon, p.requires_folder),
cls: `ico-${p.icon || "cowork"}`,
});
+5 -5
View File
@@ -39,24 +39,24 @@ export function isEmojiIcon(icon?: string): boolean {
return !!icon && /[^\x00-\x7F]/.test(icon);
}
export function personaGlyph(icon?: string, family?: string): IconName {
export function personaGlyph(icon?: string, folderScoped?: boolean): IconName {
if (icon && NAMED.has(icon)) return icon as IconName;
if (icon && LEGACY[icon]) return LEGACY[icon];
return family === "code" ? "code" : "sparkle";
return folderScoped ? "code" : "sparkle";
}
/** Renders a persona's icon: an emoji as text, otherwise the resolved line glyph. */
export function PersonaGlyph({
icon,
family,
folderScoped,
size = 14,
}: {
icon?: string;
family?: string;
folderScoped?: boolean;
size?: number;
}) {
if (isEmojiIcon(icon)) {
return <span style={{ fontSize: size, lineHeight: 1 }}>{icon}</span>;
}
return <Icon name={personaGlyph(icon, family)} size={size} />;
return <Icon name={personaGlyph(icon, folderScoped)} size={size} />;
}
+6 -7
View File
@@ -1,10 +1,9 @@
// A persona is "project-scoped" only when it's code-family: an explicit directory the user
// picks, sessions grouped by project in the sidebar. Everything else (knowledge, chat) runs on
// a transparent per-conversation scratch dir, with real folders added as roots when needed —
// no folder gate, ever. (The old workspace enum — git/project/deliverable/none — collapsed
// into family; owner decision 2026-07-03, UX-DECISIONS §16.)
export function isProjectScoped(p?: { workspace?: string; family?: string }): boolean {
return p?.family === "code";
// A persona is "project-scoped" when it declares requires_folder: an explicit directory the
// user picks, sessions grouped by project in the sidebar. Everything else runs on a transparent
// per-conversation scratch dir, with real folders added as roots when needed — no folder gate.
// (The old family/workspace-enum pair collapsed into this trait; workspace-scratch-design.md.)
export function isProjectScoped(p?: { requires_folder?: boolean }): boolean {
return p?.requires_folder === true;
}
// Persona naming: the product is "OpenWorker"; the personas are a "Coworker" family — Coworker