diff --git a/cli/src/__tests__/teams.test.ts b/cli/src/__tests__/teams.test.ts index 837277e4ad9..2ae71e45e02 100644 --- a/cli/src/__tests__/teams.test.ts +++ b/cli/src/__tests__/teams.test.ts @@ -56,6 +56,19 @@ function catalogTeam(overrides: Record = {}) { }; } +function installedCatalogTeam(overrides: Record = {}) { + return { + catalogId: "paperclipai:bundled:software-development:product-engineering", + catalogKey: "paperclipai/bundled/software-development/product-engineering", + present: true, + currentContentHash: "sha256:catalog-team", + installedOriginHashes: ["sha256:catalog-team"], + agentCount: 3, + outOfDate: false, + ...overrides, + }; +} + describe("teams CLI commands", () => { let fetchMock: ReturnType; let logSpy: ReturnType; @@ -130,6 +143,103 @@ describe("teams CLI commands", () => { expect(JSON.parse(String(logSpy.mock.calls[0]?.[0]))).toEqual(rows); }); + it("lists catalog teams with installed status for a company", async () => { + fetchMock + .mockResolvedValueOnce(jsonResponse([ + catalogTeam(), + catalogTeam({ + id: "paperclipai:optional:content:content-machine", + key: "paperclipai/optional/content/content-machine", + kind: "optional", + category: "content", + slug: "content-machine", + name: "Content Machine", + contentHash: "sha256:content-current", + }), + ])) + .mockResolvedValueOnce(jsonResponse([ + installedCatalogTeam({ + currentContentHash: "sha256:catalog-team", + installedOriginHashes: ["sha256:older"], + outOfDate: true, + }), + ])); + + await runCommand([ + "teams", + "list", + "--kind", + "bundled", + "--company-id", + "company-1", + "--api-base", + "http://paperclip.test", + "--api-key", + "token", + ]); + + expect(fetchMock).toHaveBeenNthCalledWith( + 1, + "http://paperclip.test/api/teams/catalog?kind=bundled", + expect.objectContaining({ method: "GET" }), + ); + expect(fetchMock).toHaveBeenNthCalledWith( + 2, + "http://paperclip.test/api/companies/company-1/teams/catalog/installed", + expect.objectContaining({ method: "GET" }), + ); + const rendered = logSpy.mock.calls.map((call) => String(call[0])).join("\n"); + expect(rendered).toContain("installedStatus"); + expect(rendered).toContain("out_of_date"); + expect(rendered).toContain("not_installed"); + }); + + it("lists installed status as JSON including removed catalog teams", async () => { + fetchMock + .mockResolvedValueOnce(jsonResponse([catalogTeam()])) + .mockResolvedValueOnce(jsonResponse([ + installedCatalogTeam(), + installedCatalogTeam({ + catalogId: "paperclipai:removed:team", + catalogKey: "paperclipai/removed/team", + present: false, + currentContentHash: null, + installedOriginHashes: ["sha256:removed"], + agentCount: 2, + }), + ])); + + await runCommand([ + "teams", + "list", + "--company-id", + "company-1", + "--api-base", + "http://paperclip.test", + "--api-key", + "token", + "--json", + ]); + + const rows = JSON.parse(String(logSpy.mock.calls[0]?.[0])); + expect(rows).toMatchObject([ + { + catalogId: "paperclipai:bundled:software-development:product-engineering", + catalogKey: "paperclipai/bundled/software-development/product-engineering", + installedStatus: "installed", + installedAgentCount: 3, + outOfDate: false, + }, + { + catalogId: "paperclipai:removed:team", + catalogKey: "paperclipai/removed/team", + installedStatus: "installed_missing", + installedAgentCount: 2, + present: false, + }, + ]); + }); + it("inspects catalog team detail by query ref so keys with slashes work", async () => { const detail = catalogTeam(); fetchMock.mockResolvedValueOnce(jsonResponse(detail)); diff --git a/cli/src/commands/client/teams.ts b/cli/src/commands/client/teams.ts index 5fa90c8446f..730073948e2 100644 --- a/cli/src/commands/client/teams.ts +++ b/cli/src/commands/client/teams.ts @@ -6,6 +6,7 @@ import type { CatalogTeamInstallOptions, CatalogTeamImportOptions, CatalogTeamSourcePolicy, + InstalledCatalogTeam, } from "@paperclipai/shared"; import { addCommonClientOptions, @@ -23,6 +24,8 @@ interface TeamBrowseOptions extends BaseClientOptions { query?: string; } +interface TeamListOptions extends TeamBrowseOptions {} + interface TeamPreviewOptions extends BaseClientOptions { companyId?: string; targetManagerAgentId?: string; @@ -63,6 +66,29 @@ export function registerTeamCommands(program: Command): void { }), ); + addCommonClientOptions( + teams + .command("list") + .description("List app-shipped catalog teams with installed status for a company") + .option("--kind ", "Catalog kind filter (bundled or optional)") + .option("--category ", "Catalog category filter") + .option("--query ", "Search catalog text") + .action(async (opts: TeamListOptions) => { + try { + const ctx = resolveCommandContext(opts, { requireCompany: true }); + const rows = await listCatalogTeamStatusRows(ctx, opts); + if (ctx.json) { + printOutput(rows, { json: true }); + return; + } + printCatalogTeamStatusRows(rows); + } catch (err) { + handleCommandError(err); + } + }), + { includeCompany: true }, + ); + addCommonClientOptions( teams .command("search") @@ -198,6 +224,58 @@ async function listCatalogTeams( return (await ctx.api.get(`/api/teams/catalog${query ? `?${query}` : ""}`)) ?? []; } +type CatalogTeamInstalledStatus = "not_installed" | "installed" | "out_of_date" | "installed_missing"; + +interface CatalogTeamStatusRow { + catalogId: string; + catalogKey: string | null; + kind: CatalogTeam["kind"] | null; + category: string | null; + slug: string | null; + name: string; + installedStatus: CatalogTeamInstalledStatus; + installedAgentCount: number; + catalogAgentCount: number | null; + projectCount: number | null; + trustLevel: CatalogTeam["trustLevel"] | null; + present: boolean; + outOfDate: boolean; + currentContentHash: string | null; + installedOriginHashes: string[]; +} + +async function listCatalogTeamStatusRows( + ctx: ResolvedClientContext, + opts: TeamListOptions, +): Promise { + if (!ctx.companyId) { + throw new Error("Company ID is required."); + } + + const [teams, installed] = await Promise.all([ + listCatalogTeams(ctx, opts), + ctx.api.get( + `/api/companies/${encodeURIComponent(ctx.companyId)}/teams/catalog/installed`, + ), + ]); + + const installedByCatalogId = new Map((installed ?? []).map((row) => [row.catalogId, row])); + const rows = teams.map((team) => { + const installedTeam = installedByCatalogId.get(team.id); + if (installedTeam) { + installedByCatalogId.delete(team.id); + } + return buildCatalogTeamStatusRow(team, installedTeam ?? null); + }); + + for (const installedTeam of installedByCatalogId.values()) { + if (installedTeam.present) continue; + rows.push(buildMissingInstalledTeamStatusRow(installedTeam)); + } + + return rows; +} + async function getCatalogTeam(ctx: ResolvedClientContext, catalogRef: string): Promise { const ref = catalogRef.trim(); if (!ref) { @@ -310,6 +388,70 @@ function printCatalogTeamRows(rows: CatalogTeam[]): void { }))); } +function buildCatalogTeamStatusRow( + team: CatalogTeam, + installed: InstalledCatalogTeam | null, +): CatalogTeamStatusRow { + return { + catalogId: team.id, + catalogKey: team.key, + kind: team.kind, + category: team.category, + slug: team.slug, + name: team.name, + installedStatus: installed ? (installed.outOfDate ? "out_of_date" : "installed") : "not_installed", + installedAgentCount: installed?.agentCount ?? 0, + catalogAgentCount: team.counts.agents, + projectCount: team.counts.projects, + trustLevel: team.trustLevel, + present: true, + outOfDate: installed?.outOfDate ?? false, + currentContentHash: team.contentHash, + installedOriginHashes: installed?.installedOriginHashes ?? [], + }; +} + +function buildMissingInstalledTeamStatusRow(installed: InstalledCatalogTeam): CatalogTeamStatusRow { + const name = installed.catalogKey ?? installed.catalogId; + return { + catalogId: installed.catalogId, + catalogKey: installed.catalogKey, + kind: null, + category: null, + slug: null, + name, + installedStatus: "installed_missing", + installedAgentCount: installed.agentCount, + catalogAgentCount: null, + projectCount: null, + trustLevel: null, + present: false, + outOfDate: false, + currentContentHash: installed.currentContentHash, + installedOriginHashes: installed.installedOriginHashes, + }; +} + +function printCatalogTeamStatusRows(rows: CatalogTeamStatusRow[]): void { + if (rows.length === 0) { + printOutput([], { json: false }); + return; + } + printTable(rows.map((row) => ({ + id: row.catalogId, + key: row.catalogKey, + kind: row.kind, + category: row.category, + slug: row.slug, + name: row.name, + installedStatus: row.installedStatus, + installedAgents: row.installedAgentCount, + catalogAgents: row.catalogAgentCount, + projects: row.projectCount, + trust: row.trustLevel, + }))); +} + function printCatalogTeamDetail(team: CatalogTeam): void { console.log( formatInlineRecord({ diff --git a/packages/shared/src/types/company-portability.ts b/packages/shared/src/types/company-portability.ts index 810b93d27c0..982a3f25564 100644 --- a/packages/shared/src/types/company-portability.ts +++ b/packages/shared/src/types/company-portability.ts @@ -138,6 +138,8 @@ export interface CompanyPortabilityAgentManifestEntry { icon: string | null; capabilities: string | null; reportsToSlug: string | null; + reportsToExistingAgentId: string | null; + reportsToExistingAgentSlug: string | null; adapterType: string; adapterConfig: Record; runtimeConfig: Record; diff --git a/packages/teams-catalog/catalog/optional/content/content-machine/TEAM.md b/packages/teams-catalog/catalog/optional/content/content-machine/TEAM.md index 7ea13e4649b..23b0d3a9ff2 100644 --- a/packages/teams-catalog/catalog/optional/content/content-machine/TEAM.md +++ b/packages/teams-catalog/catalog/optional/content/content-machine/TEAM.md @@ -8,7 +8,7 @@ key: paperclipai/optional/content/content-machine manager: agents/content-lead/AGENTS.md includes: - skills/content-calendar/SKILL.md - - tasks/weekly-content-review/TASK.md + - projects/content-operations/PROJECT.md defaultInstall: false recommendedForCompanyTypes: - agency @@ -22,3 +22,9 @@ tags: # Content Machine This optional fixture proves local skill resolution and recurring task inventory without introducing external source risk. + +## Contents + +- `ContentLead` — content operations lead responsible for calendar planning and publication workflow triage. +- `content-operations` project — rolling backlog for editorial planning and content production review. +- `weekly-content-review` routine — recurring content lead check-in to choose next posts and surface blocked publication work. diff --git a/packages/teams-catalog/catalog/optional/content/content-machine/projects/content-operations/PROJECT.md b/packages/teams-catalog/catalog/optional/content/content-machine/projects/content-operations/PROJECT.md new file mode 100644 index 00000000000..321774ea086 --- /dev/null +++ b/packages/teams-catalog/catalog/optional/content/content-machine/projects/content-operations/PROJECT.md @@ -0,0 +1,8 @@ +--- +name: Content Operations +slug: content-operations +description: Rolling content backlog for editorial planning, publication review, and campaign coordination. +owner: content-lead +--- + +Use this project for content calendar planning, recurring publication reviews, and follow-up tasks that unblock drafting, editing, or publishing work. diff --git a/packages/teams-catalog/catalog/optional/content/content-machine/tasks/weekly-content-review/TASK.md b/packages/teams-catalog/catalog/optional/content/content-machine/projects/content-operations/tasks/weekly-content-review/TASK.md similarity index 88% rename from packages/teams-catalog/catalog/optional/content/content-machine/tasks/weekly-content-review/TASK.md rename to packages/teams-catalog/catalog/optional/content/content-machine/projects/content-operations/tasks/weekly-content-review/TASK.md index 9de0eee7512..28d5e9fe7e8 100644 --- a/packages/teams-catalog/catalog/optional/content/content-machine/tasks/weekly-content-review/TASK.md +++ b/packages/teams-catalog/catalog/optional/content/content-machine/projects/content-operations/tasks/weekly-content-review/TASK.md @@ -2,6 +2,7 @@ name: Weekly Content Review slug: weekly-content-review assignee: content-lead +project: content-operations recurring: true --- diff --git a/packages/teams-catalog/generated/catalog.json b/packages/teams-catalog/generated/catalog.json index 03459ec0dfe..817772688fd 100644 --- a/packages/teams-catalog/generated/catalog.json +++ b/packages/teams-catalog/generated/catalog.json @@ -2,7 +2,7 @@ "schemaVersion": 1, "packageName": "@paperclipai/teams-catalog", "packageVersion": "0.1.0", - "generatedAt": "2026-06-03T17:31:32.494Z", + "generatedAt": "2026-06-03T19:51:52.952Z", "teams": [ { "id": "paperclipai:bundled:company-defaults:core-exec-team", @@ -392,7 +392,7 @@ ], "counts": { "agents": 1, - "projects": 0, + "projects": 1, "tasks": 0, "routines": 1, "localSkills": 1, @@ -405,7 +405,9 @@ "agentSlugs": [ "content-lead" ], - "projectSlugs": [], + "projectSlugs": [ + "content-operations" + ], "requiredSkills": [ { "type": "local", @@ -423,8 +425,8 @@ { "path": "TEAM.md", "kind": "team", - "sizeBytes": 670, - "sha256": "07a02f4a7503db77867a664bc0d30000bef19790f46b494a0ca94162cd378019" + "sizeBytes": 1032, + "sha256": "f7c272c8b0c183fc840d49eb4851b7cdd1a30a5aa8d6415d215110f3238f6f13" }, { "path": "agents/content-lead/AGENTS.md", @@ -432,22 +434,28 @@ "sizeBytes": 251, "sha256": "6fc2071ff463b6981780d5b40b2c0784cb119900062f8dc40ed372e7b8415bf7" }, + { + "path": "projects/content-operations/PROJECT.md", + "kind": "project", + "sizeBytes": 338, + "sha256": "1471f61cb0b3ffbb9c639a99d2d370f08217a0c63f97b3e24dbbd47861110206" + }, + { + "path": "projects/content-operations/tasks/weekly-content-review/TASK.md", + "kind": "task", + "sizeBytes": 236, + "sha256": "3777de716334f2c3f24176f0fe121584ce9c53a7f59e1fca942a2f57995cef77" + }, { "path": "skills/content-calendar/SKILL.md", "kind": "skill", "sizeBytes": 340, "sha256": "aa62a332fc3149d6c0d58ca589c6b3910a373d542f16f749ec68ef2f3d7f29e9" - }, - { - "path": "tasks/weekly-content-review/TASK.md", - "kind": "task", - "sizeBytes": 208, - "sha256": "b42faffd7ce3267bae5d04472bcba5a880c578a538ecc0339f8df90f352650d5" } ], "trustLevel": "markdown_only", "compatibility": "compatible", - "contentHash": "sha256:0127b5a93fc1efe8265a2720e4d18f34b9c9a7ba6dd54b2032c3eb2722af2e17" + "contentHash": "sha256:06822cec5f2b6910cb37ba0ebd5e04ed5c7e34f77225564ddddc2470779cdaa6" } ] } diff --git a/packages/teams-catalog/src/shipped-catalog.test.ts b/packages/teams-catalog/src/shipped-catalog.test.ts index 210fec6c1a9..e700d64fcfd 100644 --- a/packages/teams-catalog/src/shipped-catalog.test.ts +++ b/packages/teams-catalog/src/shipped-catalog.test.ts @@ -1,5 +1,9 @@ +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; import { describe, expect, it } from "vitest"; import { catalogManifest, catalogTeams, resolveCatalogTeamRef } from "./index.js"; +import { asBoolean, asString, parseFrontmatterMarkdown } from "./frontmatter.js"; import type { CatalogTeam } from "./types.js"; const EXPECTED_BUNDLED_KEYS = [ @@ -12,6 +16,8 @@ const EXPECTED_OPTIONAL_KEYS = [ "paperclipai/optional/content/content-machine", ]; +const PACKAGE_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); + describe("shipped teams catalog", () => { it("ships the expected bundled and optional team fixtures", () => { const bundledKeys = catalogTeams @@ -82,6 +88,29 @@ describe("shipped teams catalog", () => { expect(resolveCatalogTeamRef(sample.key)).toMatchObject({ key: sample.key }); expect(resolveCatalogTeamRef(sample.slug)).toMatchObject({ key: sample.key }); }); + + it("declares a valid project for every shipped recurring task", () => { + const issues: string[] = []; + + for (const team of catalogTeams) { + for (const file of team.files.filter((entry) => entry.kind === "task")) { + const absolutePath = path.join(PACKAGE_DIR, team.path, file.path); + const parsed = parseFrontmatterMarkdown(fs.readFileSync(absolutePath, "utf8")); + if (!asBoolean(parsed.frontmatter.recurring)) continue; + + const project = asString(parsed.frontmatter.project); + if (!project) { + issues.push(`${team.key}/${file.path} recurring task must declare a project`); + continue; + } + if (!team.projectSlugs.includes(project)) { + issues.push(`${team.key}/${file.path} project=${project} must match a team project`); + } + } + } + + expect(issues).toEqual([]); + }); }); function formatViolations(label: string, teams: CatalogTeam[]) { diff --git a/server/src/__tests__/teams-catalog-install-reparent.test.ts b/server/src/__tests__/teams-catalog-install-reparent.test.ts new file mode 100644 index 00000000000..17437552235 --- /dev/null +++ b/server/src/__tests__/teams-catalog-install-reparent.test.ts @@ -0,0 +1,159 @@ +import { randomUUID } from "node:crypto"; +import { promises as fs } from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { eq } from "drizzle-orm"; +import { agents, companies, createDb } from "@paperclipai/db"; +import { + getEmbeddedPostgresTestSupport, + startEmbeddedPostgresTestDatabase, +} from "./helpers/embedded-postgres.js"; +import { teamsCatalogService } from "../services/teams-catalog.js"; + +const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); +const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe.sequential : describe.skip; + +if (!embeddedPostgresSupport.supported) { + console.warn( + `Skipping embedded Postgres teams catalog install tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`, + ); +} + +describeEmbeddedPostgres("teams catalog target-manager reparent", () => { + let db!: ReturnType; + let tempDb: Awaited> | null = null; + let tempHome: string | null = null; + let oldPaperclipHome: string | undefined; + + beforeAll(async () => { + oldPaperclipHome = process.env.PAPERCLIP_HOME; + tempHome = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-teams-catalog-reparent-")); + process.env.PAPERCLIP_HOME = tempHome; + tempDb = await startEmbeddedPostgresTestDatabase("paperclip-teams-catalog-reparent-"); + db = createDb(tempDb.connectionString); + }, 20_000); + + afterAll(async () => { + if (oldPaperclipHome === undefined) delete process.env.PAPERCLIP_HOME; + else process.env.PAPERCLIP_HOME = oldPaperclipHome; + if (tempHome) await fs.rm(tempHome, { recursive: true, force: true }); + await tempDb?.cleanup(); + }); + + async function seedCompanyWithExistingExecTeam() { + const companyId = randomUUID(); + const existingCeoId = randomUUID(); + const existingCtoId = randomUUID(); + const existingQaId = randomUUID(); + + await db.insert(companies).values({ + id: companyId, + name: "Existing company", + issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, + requireBoardApprovalForNewAgents: false, + }); + await db.insert(agents).values([ + { + id: existingCeoId, + companyId, + name: "CEO", + role: "ceo", + title: "Chief Executive Officer", + status: "idle", + adapterType: "claude_local", + adapterConfig: {}, + runtimeConfig: {}, + permissions: {}, + }, + { + id: existingCtoId, + companyId, + name: "CTO", + role: "engineering-manager", + title: "Chief Technology Officer", + status: "idle", + reportsTo: existingCeoId, + adapterType: "claude_local", + adapterConfig: {}, + runtimeConfig: {}, + permissions: {}, + }, + { + id: existingQaId, + companyId, + name: "QA", + role: "qa", + title: "QA Engineer", + status: "idle", + reportsTo: existingCtoId, + adapterType: "claude_local", + adapterConfig: {}, + runtimeConfig: {}, + permissions: {}, + }, + ]); + + return { companyId, existingCeoId }; + } + + async function listCompanyAgents(companyId: string) { + const rows = await db + .select({ + id: agents.id, + name: agents.name, + reportsTo: agents.reportsTo, + }) + .from(agents) + .where(eq(agents.companyId, companyId)); + return new Map(rows.map((agent) => [agent.name, agent])); + } + + const adapterOverrides = { + ceo: { adapterType: "claude_local" }, + cto: { adapterType: "claude_local" }, + qa: { adapterType: "claude_local" }, + }; + + it("reparents the renamed imported root to targetManagerSlug while preserving renamed imported hierarchy", async () => { + const { companyId, existingCeoId } = await seedCompanyWithExistingExecTeam(); + const svc = teamsCatalogService(db); + + await svc.installCatalogTeam(companyId, "core-exec-team", { + targetManagerSlug: "ceo", + collisionStrategy: "rename", + include: { projects: false, issues: false }, + adapterOverrides, + }); + + const byName = await listCompanyAgents(companyId); + const importedCeo = byName.get("CEO 2"); + const importedCto = byName.get("CTO 2"); + const importedQa = byName.get("QA 2"); + + expect(importedCeo?.reportsTo).toBe(existingCeoId); + expect(importedCto?.reportsTo).toBe(importedCeo?.id); + expect(importedQa?.reportsTo).toBe(importedCto?.id); + }); + + it("reparents the renamed imported root to targetManagerAgentId while preserving renamed imported hierarchy", async () => { + const { companyId, existingCeoId } = await seedCompanyWithExistingExecTeam(); + const svc = teamsCatalogService(db); + + await svc.installCatalogTeam(companyId, "core-exec-team", { + targetManagerAgentId: existingCeoId, + collisionStrategy: "rename", + include: { projects: false, issues: false }, + adapterOverrides, + }); + + const byName = await listCompanyAgents(companyId); + const importedCeo = byName.get("CEO 2"); + const importedCto = byName.get("CTO 2"); + const importedQa = byName.get("QA 2"); + + expect(importedCeo?.reportsTo).toBe(existingCeoId); + expect(importedCto?.reportsTo).toBe(importedCeo?.id); + expect(importedQa?.reportsTo).toBe(importedCto?.id); + }); +}); diff --git a/server/src/__tests__/teams-catalog-service.test.ts b/server/src/__tests__/teams-catalog-service.test.ts index 55e7b87cef0..28b4d419d92 100644 --- a/server/src/__tests__/teams-catalog-service.test.ts +++ b/server/src/__tests__/teams-catalog-service.test.ts @@ -109,7 +109,34 @@ describe("teamsCatalogService", () => { expect(prepared.source.files["COMPANY.md"]).toEqual(expect.stringContaining("Core Exec Team")); expect(prepared.source.files["agents/ceo/AGENTS.md"]).toEqual(expect.stringContaining("paperclipai/bundled/paperclip-operations/task-planning")); expect(prepared.source.files["agents/cto/AGENTS.md"]).toEqual(expect.stringContaining("paperclipai/bundled/software-development/github-pr-workflow")); - expect(prepared.source.files[".paperclip.yaml"]).toEqual(expect.stringContaining("reportsTo: \"engineering-manager\"")); + expect(prepared.source.files[".paperclip.yaml"]).toEqual(expect.stringContaining("reportsToExistingAgentId: \"manager-1\"")); + expect(prepared.source.files[".paperclip.yaml"]).toEqual(expect.stringContaining("reportsToExistingAgentSlug: \"engineering-manager\"")); + }); + + it("resolves target-manager slug against same-company agents before rendering reparent metadata", async () => { + mockAgentService.list.mockResolvedValue([ + { id: "manager-1", companyId: "company-1", name: "CEO" }, + ]); + const svc = teamsCatalogService({} as any); + + const prepared = await svc.prepareCatalogTeamSource("company-1", "core-exec-team", { + targetManagerSlug: "ceo", + }); + + expect(mockAgentService.list).toHaveBeenCalledWith("company-1"); + expect(prepared.source.files[".paperclip.yaml"]).toEqual(expect.stringContaining("reportsToExistingAgentId: \"manager-1\"")); + expect(prepared.source.files[".paperclip.yaml"]).toEqual(expect.stringContaining("reportsToExistingAgentSlug: \"ceo\"")); + }); + + it("rejects missing target-manager slugs instead of emitting unresolved reparent metadata", async () => { + mockAgentService.list.mockResolvedValue([]); + const svc = teamsCatalogService({} as any); + + await expect( + svc.prepareCatalogTeamSource("company-1", "core-exec-team", { + targetManagerSlug: "missing-manager", + }), + ).rejects.toMatchObject({ status: 404 }); }); it("previews through company portability in agent-safe mode", async () => { diff --git a/server/src/services/company-portability.ts b/server/src/services/company-portability.ts index c370bbfa941..131500c3d3a 100644 --- a/server/src/services/company-portability.ts +++ b/server/src/services/company-portability.ts @@ -1896,6 +1896,8 @@ const YAML_KEY_PRIORITY = [ "kind", "slug", "reportsTo", + "reportsToExistingAgentId", + "reportsToExistingAgentSlug", "skills", "owner", "assignee", @@ -2699,6 +2701,8 @@ function buildManifestFromPackageFiles( icon: asString(extension.icon), capabilities: asString(extension.capabilities), reportsToSlug: asString(frontmatter.reportsTo) ?? asString(extension.reportsTo), + reportsToExistingAgentId: asString(extension.reportsToExistingAgentId), + reportsToExistingAgentSlug: asString(extension.reportsToExistingAgentSlug), adapterType: asString(extensionAdapter?.type) ?? "process", adapterConfig, runtimeConfig, @@ -4021,6 +4025,7 @@ export function companyPortabilityService(db: Db, storage?: StorageService) { const agentPlans: CompanyPortabilityPreviewAgentPlan[] = []; const existingSlugToAgent = new Map(); + const existingAgentIds = new Set(); const existingSlugs = new Set(); const projectPlans: CompanyPortabilityPreviewResult["plan"]["projectPlans"] = []; const issuePlans: CompanyPortabilityPreviewResult["plan"]["issuePlans"] = []; @@ -4032,6 +4037,7 @@ export function companyPortabilityService(db: Db, storage?: StorageService) { for (const existing of existingAgents) { const slug = normalizeAgentUrlKey(existing.name) ?? existing.id; if (!existingSlugToAgent.has(slug)) existingSlugToAgent.set(slug, existing); + existingAgentIds.add(existing.id); existingSlugs.add(slug); } const existingProjects = await projects.list(input.target.companyId); @@ -4058,6 +4064,22 @@ export function companyPortabilityService(db: Db, storage?: StorageService) { } for (const manifestAgent of selectedAgents) { + if ( + manifestAgent.reportsToExistingAgentId + && !existingAgentIds.has(manifestAgent.reportsToExistingAgentId) + ) { + warnings.push( + `Agent ${manifestAgent.slug} references existing manager id ${manifestAgent.reportsToExistingAgentId}, but that agent is not present in the target company.`, + ); + } + if ( + manifestAgent.reportsToExistingAgentSlug + && !existingSlugToAgent.has(manifestAgent.reportsToExistingAgentSlug) + ) { + warnings.push( + `Agent ${manifestAgent.slug} references existing manager slug ${manifestAgent.reportsToExistingAgentSlug}, but that agent is not present in the target company.`, + ); + } const existing = existingSlugToAgent.get(manifestAgent.slug) ?? null; if (!existing) { agentPlans.push({ @@ -4424,10 +4446,15 @@ export function companyPortabilityService(db: Db, storage?: StorageService) { const resultProjects: CompanyPortabilityImportResult["projects"] = []; const importedSlugToAgentId = new Map(); const existingSlugToAgentId = new Map(); + const preImportExistingSlugToAgentId = new Map(); + const preImportExistingAgentIds = new Set(); const agentStatusById = new Map(); const existingAgents = await agents.list(targetCompany.id); for (const existing of existingAgents) { - existingSlugToAgentId.set(normalizeAgentUrlKey(existing.name) ?? existing.id, existing.id); + const slug = normalizeAgentUrlKey(existing.name) ?? existing.id; + existingSlugToAgentId.set(slug, existing.id); + preImportExistingSlugToAgentId.set(slug, existing.id); + preImportExistingAgentIds.add(existing.id); agentStatusById.set(existing.id, existing.status); } const importedSlugToProjectId = new Map(); @@ -4610,13 +4637,31 @@ export function companyPortabilityService(db: Db, storage?: StorageService) { const agentId = importedSlugToAgentId.get(manifestAgent.slug); if (!agentId) continue; const managerSlug = manifestAgent.reportsToSlug; - if (!managerSlug) continue; - const managerId = importedSlugToAgentId.get(managerSlug) ?? existingSlugToAgentId.get(managerSlug) ?? null; + let existingManagerId: string | null = null; + if ( + manifestAgent.reportsToExistingAgentId + && preImportExistingAgentIds.has(manifestAgent.reportsToExistingAgentId) + ) { + existingManagerId = manifestAgent.reportsToExistingAgentId; + } else if (manifestAgent.reportsToExistingAgentSlug) { + existingManagerId = + preImportExistingSlugToAgentId.get(manifestAgent.reportsToExistingAgentSlug) ?? null; + } + if (!managerSlug && !existingManagerId) continue; + const managerId = + existingManagerId + ?? (managerSlug + ? importedSlugToAgentId.get(managerSlug) ?? existingSlugToAgentId.get(managerSlug) ?? null + : null); if (!managerId || managerId === agentId) continue; try { await agents.update(agentId, { reportsTo: managerId }); } catch { - warnings.push(`Could not assign manager ${managerSlug} for imported agent ${manifestAgent.slug}.`); + const managerRef = + managerSlug + ?? manifestAgent.reportsToExistingAgentSlug + ?? manifestAgent.reportsToExistingAgentId; + warnings.push(`Could not assign manager ${managerRef} for imported agent ${manifestAgent.slug}.`); } } } diff --git a/server/src/services/teams-catalog.ts b/server/src/services/teams-catalog.ts index e0663d2f71f..85d162a5c79 100644 --- a/server/src/services/teams-catalog.ts +++ b/server/src/services/teams-catalog.ts @@ -93,6 +93,11 @@ export interface CatalogTeamPreparedSource { errors: string[]; } +interface CatalogTargetManagerReference { + agentId: string; + slug: string; +} + export interface CatalogTeamImportPreviewResult { team: CatalogTeam; portabilityPreview: CompanyPortabilityPreviewResult; @@ -372,7 +377,7 @@ function catalogProvenance(team: CatalogTeam) { }; } -function renderCatalogProvenanceYaml(team: CatalogTeam, targetManagerSlug: string | null) { +function renderCatalogProvenanceYaml(team: CatalogTeam, targetManager: CatalogTargetManagerReference | null) { const provenance = catalogProvenance(team); const agentSlugs = Array.from(new Set(team.agentSlugs)).sort(); const projectSlugs = Array.from(new Set(team.projectSlugs)).sort(); @@ -383,7 +388,10 @@ function renderCatalogProvenanceYaml(team: CatalogTeam, targetManagerSlug: strin const renderEntity = (slug: string, opts?: { reparentRoot?: boolean }) => [ ` ${slug}:`, - ...(opts?.reparentRoot ? [` reportsTo: ${yamlScalar(targetManagerSlug)}`] : []), + ...(opts?.reparentRoot && targetManager ? [ + ` reportsToExistingAgentId: ${yamlScalar(targetManager.agentId)}`, + ` reportsToExistingAgentSlug: ${yamlScalar(targetManager.slug)}`, + ] : []), " metadata:", " paperclip:", " catalogTeam:", @@ -402,7 +410,7 @@ function renderCatalogProvenanceYaml(team: CatalogTeam, targetManagerSlug: strin "agents:", ...agentSlugs.flatMap((slug) => renderEntity(slug, { - reparentRoot: Boolean(targetManagerSlug && team.rootAgentSlugs.includes(slug)), + reparentRoot: Boolean(targetManager && team.rootAgentSlugs.includes(slug)), }), ), ]; @@ -708,11 +716,17 @@ export function teamsCatalogService(db: Db) { const companySkills = companySkillService(db); const agents = agentService(db); - async function resolveTargetManagerSlug(companyId: string, options: CatalogTeamImportOptions) { + async function resolveTargetManagerReference( + companyId: string, + options: CatalogTeamImportOptions, + ): Promise { if (options.targetManagerSlug) { const slug = normalizeAgentUrlKey(options.targetManagerSlug); if (!slug) throw unprocessable("Target manager slug is invalid."); - return slug; + const managers = await agents.list(companyId); + const manager = managers.find((candidate) => normalizeAgentUrlKey(candidate.name) === slug); + if (!manager) throw notFound("Target manager agent not found"); + return { agentId: manager.id, slug }; } if (!options.targetManagerAgentId) return null; const manager = await agents.getById(options.targetManagerAgentId); @@ -720,7 +734,10 @@ export function teamsCatalogService(db: Db) { if (manager.companyId !== companyId) { throw forbidden("Target manager agent must belong to the target company."); } - return normalizeAgentUrlKey(manager.name) ?? manager.id; + return { + agentId: manager.id, + slug: normalizeAgentUrlKey(manager.name) ?? manager.id, + }; } async function prepareCatalogTeamSource( @@ -746,9 +763,9 @@ export function teamsCatalogService(db: Db) { warnings.push(...skillPrep.warnings); errors.push(...skillPrep.errors); - const targetManagerSlug = await resolveTargetManagerSlug(companyId, options); + const targetManager = await resolveTargetManagerReference(companyId, options); const files = await readCatalogTeamSourceFiles(team); - files[".paperclip.yaml"] = renderCatalogProvenanceYaml(team, targetManagerSlug); + files[".paperclip.yaml"] = renderCatalogProvenanceYaml(team, targetManager); rewriteAgentCatalogSkillRefs(team, files); return { diff --git a/ui/src/pages/TeamCatalog.tsx b/ui/src/pages/TeamCatalog.tsx index 86df68c2a33..9ab4eb32b58 100644 --- a/ui/src/pages/TeamCatalog.tsx +++ b/ui/src/pages/TeamCatalog.tsx @@ -2182,6 +2182,17 @@ export function TeamCatalog() { const categoryFilter = searchParams.get("category") ?? ""; const riskFilter = (searchParams.get("risk") as RiskFilter) ?? "any"; + // Preserve the active filter query (search/kind/category/risk) across in-page + // navigation. Without this, auto-select and team-row / file-tree clicks rebuild + // a fresh `/teams/` path from `teamRoute()` and drop the query string, so a + // landing `?search=…` unfiltered the list and emptied the search box (PAP-10257 + // follow-up). `applyCompanyPrefix` already preserves query strings. + const filterQuery = searchParams.toString(); + const withFilters = useCallback( + (path: string) => (filterQuery ? `${path}?${filterQuery}` : path), + [filterQuery], + ); + const [installOpen, setInstallOpen] = useState(false); const isDesktop = useMediaQuery(`(min-width: ${DESKTOP_MIN}px)`); @@ -2225,9 +2236,9 @@ export function TeamCatalog() { // (design §11). useEffect(() => { if (isDesktop && !selectedRef && filtered[0]) { - navigate(teamRoute(filtered[0].id), { replace: true }); + navigate(withFilters(teamRoute(filtered[0].id)), { replace: true }); } - }, [isDesktop, selectedRef, filtered, navigate]); + }, [isDesktop, selectedRef, filtered, navigate, withFilters]); const fileQuery = useQuery({ queryKey: queryKeys.teamCatalog.catalogFile(selectedTeam?.id ?? "", selectedFilePath ?? ""), @@ -2423,7 +2434,7 @@ export function TeamCatalog() { key={team.id} team={team} selected={team.id === selectedTeam?.id} - onSelect={() => navigate(teamRoute(team.id))} + onSelect={() => navigate(withFilters(teamRoute(team.id)))} /> ))} @@ -2438,7 +2449,7 @@ export function TeamCatalog() { key={team.id} team={team} selected={team.id === selectedTeam?.id} - onSelect={() => navigate(teamRoute(team.id))} + onSelect={() => navigate(withFilters(teamRoute(team.id)))} /> ))} @@ -2453,7 +2464,7 @@ export function TeamCatalog() { key={team.id} team={team} selected={team.id === selectedTeam?.id} - onSelect={() => navigate(teamRoute(team.id))} + onSelect={() => navigate(withFilters(teamRoute(team.id)))} installed={installedById.get(team.id) ?? null} /> ))} @@ -2474,7 +2485,7 @@ export function TeamCatalog() { {!isDesktop && selectedTeam && (