[codex] Add teams catalog extraction (#7550)

Fixes #7551

## Thinking Path

> - Paperclip is the control plane for AI-agent companies, and reusable
company/team setup is part of making those companies faster to launch.
> - The teams catalog work introduces app-shipped team templates that
can be browsed, previewed, and installed into a company.
> - Catalog installation crosses several contracts: bundled package
contents, shared API types, server import/install behavior, CLI
workflows, and the board UI.
> - Agents also need a safe path through catalog installs: scoped
company selection, explicit source policy, approval fallback for agent
creation, and preserved catalog provenance.
> - This pull request extracts the completed teams catalog branch into
one reviewable PR on top of `public-gh/master`.
> - The benefit is a reusable teams catalog foundation with server, CLI,
package, docs, and hidden UI surfaces kept in sync.

## What Changed

- Added the `@paperclipai/teams-catalog` package with bundled/optional
team definitions, generated manifest, validators, catalog builder tests,
and migration notes.
- Added shared teams catalog types/validators plus server routes and
services for listing, previewing, and installing catalog teams.
- Integrated catalog install with company portability, skill/source
policy checks, provenance metadata, origin hashes, target-manager
reparenting, and installed/out-of-date detection.
- Added CLI `teams` commands and agent-safe company selection behavior,
including `company current` and approval fallback for forbidden
agent-run installs.
- Added hidden Team Catalog UI/API/query surfaces, Storybook fixtures,
and targeted UI tests while keeping the UI route out of primary
navigation.
- Added docs for CLI/company/teams catalog behavior and removed
generated screenshot artifacts from the PR diff.

## Verification

- `pnpm exec vitest run cli/src/__tests__/company.test.ts
cli/src/__tests__/teams.test.ts
packages/teams-catalog/src/catalog-builder.test.ts
packages/teams-catalog/src/shipped-catalog.test.ts
server/src/__tests__/agent-permissions-service.test.ts
server/src/__tests__/company-portability.test.ts
server/src/__tests__/company-skills-service.test.ts
server/src/__tests__/teams-catalog-routes.test.ts
server/src/__tests__/teams-catalog-service.test.ts
server/src/__tests__/teams-catalog-install-no-overrides.test.ts
ui/src/lib/company-routes.test.ts ui/src/pages/TeamCard.test.tsx
ui/src/pages/TeamCatalog.test.tsx
ui/src/pages/useInstallTeamCatalogEntry.test.tsx`
- `pnpm --filter @paperclipai/shared typecheck && pnpm --filter
@paperclipai/teams-catalog typecheck && pnpm --filter paperclipai
typecheck && pnpm --filter @paperclipai/server typecheck && pnpm
--filter @paperclipai/ui typecheck`
- Confirmed branch is rebased onto `public-gh/master` (`78dc3625a`) and
`public-gh/master` is an ancestor of `HEAD`.
- Confirmed PR diff excludes `pnpm-lock.yaml`, `.github/workflows/*`,
generated screenshot images, and screenshot helper scripts.

## Risks

- Medium review surface: this crosses package generation, shared
contracts, server install behavior, CLI, docs, and hidden UI code.
- Catalog install behavior creates agents/projects/tasks/skills and must
keep company scoping, permissions, source policy, and provenance checks
strict.
- `pnpm-lock.yaml` is intentionally excluded per repo policy;
CI/default-branch automation owns lockfile refresh.
- The Team Catalog UI is included but hidden from primary navigation, so
future enablement should re-check visual QA before exposure.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.
>
> ROADMAP checked: this aligns with reusable companies/templates and
plugin-adjacent onboarding work. This PR packages work already developed
on the Paperclip task branch for review.

## Model Used

- OpenAI Codex, GPT-5 series coding agent in this Paperclip session;
exact runtime context window was not exposed. Used shell, git, `gh`, and
local test/typecheck tooling.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] If this change affects the UI, I have included before/after
screenshots, or documented why screenshots are intentionally omitted
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Dotta
2026-06-05 07:55:49 -10:00
committed by GitHub
parent 3657854e5e
commit fff3832a01
80 changed files with 12449 additions and 470 deletions
@@ -0,0 +1,366 @@
import { useState } from "react";
import type { Meta, StoryObj } from "@storybook/react-vite";
import type {
Agent,
CatalogTeamImportPreviewResult,
CompanyPortabilityCollisionStrategy,
} from "@paperclipai/shared";
import {
ApplyProgress,
ApplySuccess,
StepPreview,
StepSkillPlan,
StepSourcePolicy,
StepTargetManager,
TeamCard,
TeamDetailPane,
TeamRow,
} from "@/pages/TeamCatalog";
import {
currentInstalledState,
onboardingTeams,
optionalTeam,
outOfDateInstalledState,
sampleTeam as baseTeam,
warnTeam,
} from "@/pages/TeamCatalog.fixtures";
// ---------------------------------------------------------------------------
// Fixtures
//
// Team fixtures (baseTeam/optionalTeam/warnTeam) are shared with the in-app
// /design-guide showcase via @/pages/TeamCatalog.fixtures so the two surfaces
// stay in sync. Preview/agent fixtures below are story-only.
// ---------------------------------------------------------------------------
const companyAgents: Agent[] = [
makeAgent("agent-1", "Founder", "ceo"),
makeAgent("agent-2", "Head of Eng", "engineer"),
makeAgent("agent-3", "Ops Lead", "general"),
];
function makeAgent(id: string, name: string, role: string): Agent {
return {
id,
companyId: "company-storybook",
name,
urlKey: name.toLowerCase().replace(/\s+/g, "-"),
role: role as Agent["role"],
title: null,
icon: null,
status: "active",
reportsTo: null,
capabilities: null,
adapterType: "claude_local" as Agent["adapterType"],
adapterConfig: {},
runtimeConfig: {} as Agent["runtimeConfig"],
budgetMonthlyCents: 0,
spentMonthlyCents: 0,
pauseReason: null,
pausedAt: null,
permissions: {} as Agent["permissions"],
lastHeartbeatAt: null,
metadata: null,
createdAt: new Date("2026-01-01T00:00:00.000Z"),
updatedAt: new Date("2026-01-01T00:00:00.000Z"),
};
}
function makePreview(errors: string[] = []): CatalogTeamImportPreviewResult {
return {
team: baseTeam,
portabilityPreview: {
include: { company: false, agents: true, projects: true, issues: false, skills: true },
targetCompanyId: "company-storybook",
targetCompanyName: "Paperclip",
collisionStrategy: "rename",
selectedAgentSlugs: ["ceo", "cto", "cmo"],
plan: {
companyAction: "none",
agentPlans: [
{ slug: "ceo", action: "create", plannedName: "CEO", existingAgentId: null, reason: null },
{ slug: "cto", action: "create", plannedName: "CTO", existingAgentId: null, reason: null },
{ slug: "cmo", action: "create", plannedName: "CMO (from Core Exec Team)", existingAgentId: "agent-x", reason: "Renamed — name collision with existing agent" },
],
projectPlans: [
{ slug: "launch", action: "create", plannedName: "Launch", existingProjectId: null, reason: null },
],
issuePlans: [
{ slug: "kickoff", action: "skip", plannedTitle: "Kickoff", reason: "Starter tasks not selected" },
],
},
manifest: {
schemaVersion: 1,
generatedAt: "2026-06-03T00:00:00.000Z",
source: null,
includes: { company: false, agents: true, projects: true, issues: false, skills: true },
company: null,
sidebar: null,
agents: [
manifestAgent("ceo", "CEO"),
manifestAgent("cto", "CTO"),
manifestAgent("cmo", "CMO"),
],
skills: [],
projects: [],
issues: [],
envInputs: [],
},
files: {},
envInputs: [
{ key: "OPENAI_API_KEY", description: "API key for the CTO agent", agentSlug: "cto", projectSlug: null, kind: "secret", requirement: "required", defaultValue: null, portability: "system_dependent" },
{ key: "DEFAULT_TIMEZONE", description: "Project timezone", agentSlug: null, projectSlug: "launch", kind: "plain", requirement: "optional", defaultValue: "UTC", portability: "portable" },
],
warnings: ["Skill acme/growth-playbook will be imported from an external GitHub source."],
errors,
},
skillPreparations: [
{ type: "catalog", ref: "engineering/code-review", agentSlugs: ["cto"], action: "already_in_package", catalogSkillId: "skill-1", catalogSkillKey: "engineering/code-review", sourceLocator: null, sourceRef: null, reason: null },
{ type: "github", ref: "acme/growth-playbook@v1.2.0", agentSlugs: ["cmo"], action: "external_import_required", catalogSkillId: null, catalogSkillKey: null, sourceLocator: "github.com/acme/growth-playbook", sourceRef: "v1.2.0", reason: "Resolved from GitHub at install time" },
],
warnings: [],
errors,
};
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function manifestAgent(slug: string, name: string): any {
return {
slug, name, path: `agents/${slug}/AGENTS.md`, skills: [], role: slug, title: null, icon: null,
capabilities: null, reportsToSlug: slug === "ceo" ? null : "ceo", adapterType: "claude_local",
adapterConfig: {}, runtimeConfig: {}, permissions: {}, budgetMonthlyCents: 0, metadata: null,
};
}
const noop = () => {};
function Frame({ children, width = "max-w-3xl" }: { children: React.ReactNode; width?: string }) {
return <div className={`mx-auto w-full ${width} rounded-lg border border-border bg-background p-5`}>{children}</div>;
}
const meta: Meta = {
title: "Surfaces/Team Catalog",
};
export default meta;
type Story = StoryObj;
export const BrowseList: Story = {
render: () => (
<div className="w-[28rem] border border-border">
<div className="px-3 py-2 text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">Bundled · 1</div>
<TeamRow team={baseTeam} selected onSelect={noop} />
<div className="px-3 py-2 text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">Optional · 2</div>
<TeamRow team={optionalTeam} selected={false} onSelect={noop} />
<TeamRow team={warnTeam} selected={false} onSelect={noop} />
</div>
),
};
export const DetailPane: Story = {
render: () => (
<div className="h-[760px] overflow-hidden border border-border">
<TeamDetailPane
team={baseTeam}
selectedPath={null}
onSelectFile={noop}
onInstall={noop}
canInstall
fileContent={null}
/>
</div>
),
};
// PAP-10256: installed/out-of-date surface driven by the server signal.
export const InstalledStates: Story = {
render: () => (
<div className="flex flex-col gap-6">
<div className="w-[28rem] border border-border">
<div className="px-3 py-2 text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">Bundled · 1</div>
<TeamRow team={optionalTeam} selected={false} onSelect={noop} />
<div className="px-3 py-2 text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">Installed · 2</div>
<TeamRow team={baseTeam} selected onSelect={noop} installed={outOfDateInstalledState} />
<TeamRow team={warnTeam} selected={false} onSelect={noop} installed={currentInstalledState} />
</div>
<div className="h-[760px] overflow-hidden border border-border">
<TeamDetailPane
team={baseTeam}
selectedPath={null}
onSelectFile={noop}
onInstall={noop}
canInstall
fileContent={null}
installed={outOfDateInstalledState}
/>
</div>
</div>
),
};
export const InstallTargetManager: Story = {
render: function Render() {
const [managerId, setManagerId] = useState<string | null>(null);
const [fullCompany, setFullCompany] = useState(false);
return (
<Frame>
<StepTargetManager
team={baseTeam}
agents={companyAgents}
targetManagerAgentId={managerId}
onPickManager={setManagerId}
fullCompany={fullCompany}
onToggleFullCompany={setFullCompany}
canBypassManager
/>
</Frame>
);
},
};
export const InstallSourcePolicy: Story = {
render: function Render() {
const [ext, setExt] = useState(false);
const [unpinned, setUnpinned] = useState(false);
const [local, setLocal] = useState(false);
return (
<Frame>
<StepSourcePolicy
team={warnTeam}
allowExternalSources={ext}
allowUnpinnedOptionalSources={unpinned}
allowLocalPathSources={local}
onChange={(key, v) => {
if (key === "external") setExt(v);
if (key === "unpinned") setUnpinned(v);
if (key === "localPath") setLocal(v);
}}
/>
</Frame>
);
},
};
export const InstallSkillPlan: Story = {
render: () => (
<Frame>
<StepSkillPlan team={baseTeam} preparations={makePreview().skillPreparations} />
</Frame>
),
};
export const InstallPreview: Story = {
render: function Render() {
const [collision, setCollision] = useState<CompanyPortabilityCollisionStrategy>("rename");
const [names, setNames] = useState<Record<string, string>>({});
const [adapters, setAdapters] = useState<Record<string, string>>({});
return (
<Frame>
<StepPreview
team={baseTeam}
loading={false}
error={null}
result={makePreview()}
collisionStrategy={collision}
onCollisionStrategyChange={setCollision}
nameOverrides={names}
onRename={(slug, name) => setNames((c) => ({ ...c, [slug]: name }))}
adapterOverrides={adapters}
onAdapterChange={(slug, t) => setAdapters((c) => ({ ...c, [slug]: t }))}
onRetry={noop}
/>
</Frame>
);
},
};
export const InstallPreviewBlocked: Story = {
render: () => (
<Frame>
<StepPreview
team={baseTeam}
loading={false}
error={null}
result={makePreview(["Target manager is required before this team can be installed.", "Skill acme/growth-playbook is blocked by source policy."])}
collisionStrategy="rename"
onCollisionStrategyChange={noop}
nameOverrides={{}}
onRename={noop}
adapterOverrides={{}}
onAdapterChange={noop}
onRetry={noop}
/>
</Frame>
),
};
export const InstallApplyProgress: Story = {
render: () => (
<Frame>
<ApplyProgress team={baseTeam} />
</Frame>
),
};
// Onboarding seam (design §6 + §12.5): the TeamCard tile rendered in the
// 3-col "Pick a starter team" grid, with the first defaultInstall tile selected.
export const OnboardingTeamGrid: Story = {
render: function Render() {
const [selectedId, setSelectedId] = useState(onboardingTeams[0]?.id ?? null);
return (
<Frame width="max-w-2xl">
<div className="space-y-1">
<h2 className="text-lg font-semibold">Pick a starter team</h2>
<p className="text-sm text-muted-foreground">
We&apos;ll set up agents, projects, and routines so you can start with a working team.
</p>
</div>
<div className="mt-4 grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{onboardingTeams.map((team) => (
<TeamCard
key={team.id}
team={team}
selected={team.id === selectedId}
onSelect={() => setSelectedId(team.id)}
/>
))}
</div>
</Frame>
);
},
};
// A single TeamCard in its selected state.
export const TeamCardSelected: Story = {
render: () => (
<div className="mx-auto w-64">
<TeamCard team={onboardingTeams[0]} selected onSelect={noop} />
</div>
),
};
export const InstallSuccess: Story = {
render: () => (
<Frame>
<ApplySuccess
team={baseTeam}
result={{
team: baseTeam,
portabilityImport: {
company: { id: "company-storybook", name: "Paperclip", action: "unchanged" },
agents: [
{ slug: "ceo", id: "a1", action: "created", name: "CEO", reason: null },
{ slug: "cto", id: "a2", action: "created", name: "CTO", reason: null },
{ slug: "cmo", id: "a3", action: "created", name: "CMO (from Core Exec Team)", reason: null },
],
projects: [{ slug: "launch", id: "p1", action: "created", name: "Launch", reason: null }],
envInputs: [],
warnings: [],
},
skillPreparations: makePreview().skillPreparations,
warnings: ["Skill acme/growth-playbook imported from GitHub — review pinned ref."],
}}
onClose={noop}
/>
</Frame>
),
};