From 7296276a445f9c6d0286bef53a67d3d5974b8ec4 Mon Sep 17 00:00:00 2001 From: wSedlacek Date: Wed, 11 Nov 2026 15:22:29 -0800 Subject: [PATCH] feat(orchestration): installable contributor bundles with project orientation Let maintainers ship a Workshop-style team plus a checkout-specific project map in t3.contributor-bundle.json, installable from the command palette so OSS repos can package competence rather than only generic seats. Co-authored-by: Cursor --- .../pages/schema/contributor-bundle.json.ts | 8 + apps/server/src/auth/RpcAuthorization.ts | 1 + .../src/environment/ServerEnvironment.test.ts | 4 +- .../src/environment/ServerEnvironment.ts | 1 + .../src/observability/RpcInstrumentation.ts | 1 + .../ContributorBundleService.test.ts | 187 ++++++++++++++++++ .../ContributorBundleService.ts | 181 +++++++++++++++++ .../src/orchestration-v2/runtimeLayer.ts | 14 ++ .../src/project/ContributorBundleLoader.ts | 93 +++++++++ apps/server/src/ws.test.ts | 3 + apps/server/src/ws.ts | 29 +++ apps/web/src/components/CommandPalette.tsx | 54 +++++ .../web/src/hooks/useContributorBundleFile.ts | 38 ++++ .../src/hooks/useInstallContributorBundle.ts | 133 +++++++++++++ docs/user/project-settings.md | 8 + .../src/state/threadCommands.ts | 9 + .../contracts/src/clientRpcPermissions.ts | 1 + .../contracts/src/contributorBundle.test.ts | 27 +++ packages/contracts/src/contributorBundle.ts | 152 ++++++++++++++ packages/contracts/src/environment.ts | 2 + packages/contracts/src/index.ts | 1 + packages/contracts/src/orchestrationV2.ts | 9 + packages/contracts/src/rpc.ts | 14 ++ packages/shared/package.json | 4 + packages/shared/src/contributorBundle.test.ts | 98 +++++++++ packages/shared/src/contributorBundle.ts | 83 ++++++++ t3.contributor-bundle.json | 53 +++++ 27 files changed, 1207 insertions(+), 1 deletion(-) create mode 100644 apps/marketing/src/pages/schema/contributor-bundle.json.ts create mode 100644 apps/server/src/orchestration-v2/ContributorBundleService.test.ts create mode 100644 apps/server/src/orchestration-v2/ContributorBundleService.ts create mode 100644 apps/server/src/project/ContributorBundleLoader.ts create mode 100644 apps/web/src/hooks/useContributorBundleFile.ts create mode 100644 apps/web/src/hooks/useInstallContributorBundle.ts create mode 100644 packages/contracts/src/contributorBundle.test.ts create mode 100644 packages/contracts/src/contributorBundle.ts create mode 100644 packages/shared/src/contributorBundle.test.ts create mode 100644 packages/shared/src/contributorBundle.ts create mode 100644 t3.contributor-bundle.json diff --git a/apps/marketing/src/pages/schema/contributor-bundle.json.ts b/apps/marketing/src/pages/schema/contributor-bundle.json.ts new file mode 100644 index 0000000000000..0d5eb1cfd6727 --- /dev/null +++ b/apps/marketing/src/pages/schema/contributor-bundle.json.ts @@ -0,0 +1,8 @@ +import type { APIRoute } from "astro"; + +import { buildContributorBundleJsonSchema } from "@t3tools/shared/contributorBundle"; + +export const GET: APIRoute = () => + new Response(`${JSON.stringify(buildContributorBundleJsonSchema(), null, 2)}\n`, { + headers: { "Content-Type": "application/json" }, + }); diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts index 180f053f111f4..c2aad50afb26d 100644 --- a/apps/server/src/auth/RpcAuthorization.ts +++ b/apps/server/src/auth/RpcAuthorization.ts @@ -58,6 +58,7 @@ export const RPC_REQUIRED_SCOPES = { [ORCHESTRATION_V2_WS_METHODS.getThreadProjection]: AuthOrchestrationReadScope, [ORCHESTRATION_V2_WS_METHODS.getTurnItem]: AuthOrchestrationReadScope, [ORCHESTRATION_V2_WS_METHODS.launchThread]: AuthOrchestrationOperateScope, + [ORCHESTRATION_V2_WS_METHODS.installContributorBundle]: AuthOrchestrationOperateScope, [ORCHESTRATION_V2_WS_METHODS.subscribeShell]: AuthOrchestrationReadScope, [ORCHESTRATION_V2_WS_METHODS.subscribeThread]: AuthOrchestrationReadScope, [ORCHESTRATION_V2_WS_METHODS.subscribeCommandOutput]: AuthOrchestrationReadScope, diff --git a/apps/server/src/environment/ServerEnvironment.test.ts b/apps/server/src/environment/ServerEnvironment.test.ts index dc719ed93c41b..adfc7065e5397 100644 --- a/apps/server/src/environment/ServerEnvironment.test.ts +++ b/apps/server/src/environment/ServerEnvironment.test.ts @@ -89,6 +89,8 @@ it.layer(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))("ServerEnvironmen '{"name":"t3","version":"0.0.45","bin":{"t3":"./dist/bin.mjs"}}', ); yield* fs.symlink(entry, `${prefix}/bin/t3`); + // macOS temp paths often resolve through /private; the descriptor uses the real path. + const resolvedPrefix = yield* fs.realPath(prefix); const config = yield* makeServerConfig(baseDir); yield* fs.makeDirectory(config.stateDir, { recursive: true }); for (const mode of ["web", "desktop"] as const) { @@ -108,7 +110,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))("ServerEnvironmen Effect.provideService(HostProcess.Environment, {}), ); expect(descriptor.capabilities.serverInstallation).toEqual( - mode === "web" ? { kind: "npm-global", prefix } : undefined, + mode === "web" ? { kind: "npm-global", prefix: resolvedPrefix } : undefined, ); expect(descriptor.capabilities.serverSelfUpdate).toBe( mode === "web" ? undefined : "desktop-managed", diff --git a/apps/server/src/environment/ServerEnvironment.ts b/apps/server/src/environment/ServerEnvironment.ts index 9e29b0fdedc95..21f8a4eeb6f5b 100644 --- a/apps/server/src/environment/ServerEnvironment.ts +++ b/apps/server/src/environment/ServerEnvironment.ts @@ -253,6 +253,7 @@ export const make = Effect.gen(function* () { usagePriceOverrides: true, usageModelAliases: true, threadPinning: true, + contributorBundles: true, threadPinReorder: true, threadActiveReorder: true, threadAutoSettleOptOut: true, diff --git a/apps/server/src/observability/RpcInstrumentation.ts b/apps/server/src/observability/RpcInstrumentation.ts index f9a861cca23fb..6a31cb1876ada 100644 --- a/apps/server/src/observability/RpcInstrumentation.ts +++ b/apps/server/src/observability/RpcInstrumentation.ts @@ -30,6 +30,7 @@ const RPC_AGGREGATES = { [ORCHESTRATION_V2_WS_METHODS.getOlderSettledThreads]: "orchestration", [ORCHESTRATION_V2_WS_METHODS.getThreadProjection]: "orchestrationV2", [ORCHESTRATION_V2_WS_METHODS.launchThread]: "orchestration", + [ORCHESTRATION_V2_WS_METHODS.installContributorBundle]: "orchestration", [ORCHESTRATION_V2_WS_METHODS.subscribeShell]: "orchestrationV2", [ORCHESTRATION_V2_WS_METHODS.subscribeThread]: "orchestrationV2", [ORCHESTRATION_V2_WS_METHODS.subscribeCommandOutput]: "orchestrationV2", diff --git a/apps/server/src/orchestration-v2/ContributorBundleService.test.ts b/apps/server/src/orchestration-v2/ContributorBundleService.test.ts new file mode 100644 index 0000000000000..9506ddc7cd96e --- /dev/null +++ b/apps/server/src/orchestration-v2/ContributorBundleService.test.ts @@ -0,0 +1,187 @@ +import { assert, it } from "@effect/vitest"; +import { CommandId, ProjectId, ProviderDriverKind, ProviderInstanceId } from "@t3tools/contracts"; +import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; +import type * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; + +import * as ContributorBundleLoader from "../project/ContributorBundleLoader.ts"; +import * as ProjectService from "../project/ProjectService.ts"; +import * as ProviderRegistryMock from "../provider/testUtils/providerRegistryMock.ts"; +import * as ServerSettings from "../serverSettings.ts"; +import { CodexProviderCapabilitiesV2 } from "./Adapters/CodexAdapterV2.ts"; +import * as CommandReceiptStore from "./CommandReceiptStore.ts"; +import * as ContributorBundle from "./ContributorBundleService.ts"; +import * as ProviderAdapterRegistry from "./ProviderAdapterRegistry.ts"; +import * as ProviderReplayHarness from "./testkit/ProviderReplayHarness.ts"; +import * as SqlitePersistence from "../persistence/Sqlite.ts"; +import * as ThreadManagement from "./ThreadManagementService.ts"; + +const projectId = ProjectId.make("project:contributor-bundle"); +const modelSelection = { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5.1-codex", +} as const; +const project = { + id: projectId, + title: "Project", + workspaceRoot: "/repo", + repositoryIdentity: null, + faviconPath: null, + defaultModelSelection: modelSelection, + defaultThreadEnvMode: null, + scripts: [], + pinnedAt: null, + archivedAt: null, + createdAt: "2026-06-20T00:00:00.000Z", + updatedAt: "2026-06-20T00:00:00.000Z", + deletedAt: null, +} as const; + +const workshopBundle = { + name: "T3 Workshop contributors", + description: "Repo-aware Workshop seats.", + orientation: { + summary: "T3 Code wraps provider CLIs for web, desktop, and mobile.", + map: [{ path: "apps/server", purpose: "Orchestration" }], + guidance: "Run vp run check:changed before push.", + }, + seats: [ + { + id: "lead", + title: "Workshop · Lead", + roleBrief: "Coordinate Builder and QA.", + }, + { + id: "builder", + title: "Workshop · Builder", + roleBrief: "Implement the change.", + }, + { + id: "qa", + title: "Workshop · QA", + roleBrief: "Verify the user path.", + }, + ], +} as const; + +const adapter = { + instanceId: modelSelection.instanceId, + driver: ProviderDriverKind.make("codex"), + getCapabilities: () => Effect.succeed(CodexProviderCapabilitiesV2), + planSelectionTransition: () => Effect.succeed({ type: "apply_on_next_turn" as const }), + openSession: () => Effect.die("provider execution is disabled in contributor bundle tests"), +} as ProviderAdapter.ProviderAdapterV2["Service"]; + +function makeHarness(bundle: typeof workshopBundle | null) { + const layerDatabase = SqlitePersistence.layerMemory; + const layerRegistry = ProviderAdapterRegistry.layerFromAdapters([adapter]); + const layerOrchestrator = ProviderReplayHarness.layerWithRegistry( + { name: "contributor-bundle" }, + layerRegistry, + { databaseLayer: layerDatabase, runEffectWorker: false }, + ); + const layerServerSettings = ServerSettings.layerTest(); + const layerReceipts = CommandReceiptStore.layer.pipe(Layer.provide(layerDatabase)); + const layerThreadManagement = ThreadManagement.layer.pipe( + Layer.provide(layerOrchestrator), + Layer.provide(layerServerSettings), + Layer.provide(layerReceipts), + ); + const layerProjects = Layer.succeed(ProjectService.ProjectService, { + create: () => Effect.die("unused"), + bootstrap: () => Effect.die("unused"), + update: () => Effect.die("unused"), + delete: () => Effect.die("unused"), + listPage: () => Effect.die("unused"), + getById: (id) => Effect.succeed(id === projectId ? Option.some(project) : Option.none()), + getByWorkspaceRoot: () => Effect.succeedSome(project), + workspaceScanDirectory: () => Effect.die("unused"), + snapshot: Effect.die("unused"), + getShell: () => Effect.die("unused"), + listShells: () => Effect.die("unused"), + }); + const layerBundleLoader = Layer.succeed(ContributorBundleLoader.ContributorBundleLoader, { + load: () => Effect.succeed(bundle === null ? Option.none() : Option.some(bundle)), + }); + const layerContributorBundle = ContributorBundle.layer.pipe( + Layer.provide( + Layer.mergeAll(layerProjects, layerBundleLoader, layerThreadManagement, IdAllocator.layer), + ), + ); + return Layer.mergeAll( + layerContributorBundle, + layerThreadManagement, + layerRegistry, + ProviderRegistryMock.layer(), + layerDatabase, + ); +} + +it.effect("installs pinned Workshop seats with project orientation in the lead prompt", () => + Effect.gen(function* () { + const service = yield* ContributorBundle.ContributorBundleService; + const threads = yield* ThreadManagement.ThreadManagementService; + const result = yield* service.install({ + commandId: CommandId.make("command:contributor-bundle"), + projectId, + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + }); + + assert.equal(result.name, "T3 Workshop contributors"); + assert.include(result.leadPrompt, "Project orientation"); + assert.include(result.leadPrompt, "apps/server: Orchestration"); + assert.include(result.leadPrompt, "Pinned teammates:"); + assert.deepEqual( + result.seats.map((seat) => seat.seatId), + ["lead", "builder", "qa"], + ); + assert.include(result.seats[0]?.roleBrief ?? "", "Project orientation"); + + for (const seat of result.seats) { + const shell = yield* threads.getThreadShell(seat.threadId); + assert.isNotNull(shell); + assert.equal(shell?.projectId, projectId); + assert.equal(shell?.title, seat.title); + assert.isNotNull(shell?.pinnedAt); + assert.equal(shell?.status, "idle"); + } + }).pipe(Effect.provide(makeHarness(workshopBundle))), +); + +it.effect("fails when the contributor bundle file is missing", () => + Effect.gen(function* () { + const service = yield* ContributorBundle.ContributorBundleService; + const error = yield* service + .install({ + commandId: CommandId.make("command:missing-bundle"), + projectId, + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + }) + .pipe(Effect.flip); + assert.equal(error._tag, "OrchestrationV2InstallContributorBundleError"); + assert.match(error.message, /No valid t3\.contributor-bundle\.json/); + }).pipe(Effect.provide(makeHarness(null))), +); + +it.effect("fails when the project is missing", () => + Effect.gen(function* () { + const service = yield* ContributorBundle.ContributorBundleService; + const error = yield* service + .install({ + commandId: CommandId.make("command:missing-project"), + projectId: ProjectId.make("project:missing"), + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + }) + .pipe(Effect.flip); + assert.equal(error._tag, "OrchestrationV2InstallContributorBundleError"); + assert.match(error.message, /Project not found/); + }).pipe(Effect.provide(makeHarness(workshopBundle))), +); diff --git a/apps/server/src/orchestration-v2/ContributorBundleService.ts b/apps/server/src/orchestration-v2/ContributorBundleService.ts new file mode 100644 index 0000000000000..5d03cb9ed46bc --- /dev/null +++ b/apps/server/src/orchestration-v2/ContributorBundleService.ts @@ -0,0 +1,181 @@ +import { + CommandId, + CONTRIBUTOR_BUNDLE_FILE_NAME, + type OrchestrationV2InstallContributorBundleInput, + type OrchestrationV2InstallContributorBundleResult, + OrchestrationV2InstallContributorBundleError, +} from "@t3tools/contracts"; +import { + formatContributorBundleLeadPrompt, + formatContributorBundleSeatBrief, +} from "@t3tools/shared/contributorBundle"; +import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as ContributorBundleLoader from "../project/ContributorBundleLoader.ts"; +import * as ProjectService from "../project/ProjectService.ts"; +import * as ThreadManagement from "./ThreadManagementService.ts"; + +export class ContributorBundleService extends Context.Service< + ContributorBundleService, + { + readonly install: ( + input: OrchestrationV2InstallContributorBundleInput, + ) => Effect.Effect< + OrchestrationV2InstallContributorBundleResult, + | OrchestrationV2InstallContributorBundleError + | ThreadManagement.ThreadManagementProviderInstanceDisabledError + >; + } +>()("t3/orchestration-v2/ContributorBundleService") {} + +const make = Effect.gen(function* () { + const projects = yield* ProjectService.ProjectService; + const bundles = yield* ContributorBundleLoader.ContributorBundleLoader; + const ids = yield* IdAllocator.IdAllocatorV2; + const threads = yield* ThreadManagement.ThreadManagementService; + + const install = Effect.fn("ContributorBundleService.install")(function* ( + input: OrchestrationV2InstallContributorBundleInput, + ) { + const project = yield* projects.getById(input.projectId).pipe( + Effect.mapError( + (cause) => + new OrchestrationV2InstallContributorBundleError({ + commandId: input.commandId, + projectId: input.projectId, + message: "Unable to read the project for this contributor bundle.", + cause, + }), + ), + Effect.flatMap((value) => + Effect.fromOption(value).pipe( + Effect.mapError( + () => + new OrchestrationV2InstallContributorBundleError({ + commandId: input.commandId, + projectId: input.projectId, + message: "Project not found.", + }), + ), + ), + ), + ); + + const bundle = yield* bundles.load(project.workspaceRoot).pipe( + Effect.flatMap((value) => + Effect.fromOption(value).pipe( + Effect.mapError( + () => + new OrchestrationV2InstallContributorBundleError({ + commandId: input.commandId, + projectId: input.projectId, + message: `No valid ${CONTRIBUTOR_BUNDLE_FILE_NAME} in this checkout.`, + }), + ), + ), + ), + ); + + const pin = input.pin !== false; + const launched = yield* Effect.forEach( + bundle.seats, + (seat, index) => + Effect.gen(function* () { + const threadId = yield* ids.allocate.thread({ projectId: input.projectId }).pipe( + Effect.mapError( + (cause) => + new OrchestrationV2InstallContributorBundleError({ + commandId: input.commandId, + projectId: input.projectId, + message: `Unable to allocate a thread for ${seat.title}.`, + cause, + }), + ), + ); + const createCommandId = CommandId.make(`${input.commandId}:seat:${index}:create`); + yield* threads + .dispatch({ + type: "thread.create", + commandId: createCommandId, + createdBy: "user", + creationSource: "web", + threadId, + projectId: project.id, + title: seat.title, + modelSelection: input.modelSelection, + runtimeMode: input.runtimeMode, + interactionMode: input.interactionMode, + branch: null, + worktreePath: null, + }) + .pipe( + Effect.mapError((error) => + error._tag === "ThreadManagementProviderInstanceDisabledError" + ? error + : new OrchestrationV2InstallContributorBundleError({ + commandId: input.commandId, + projectId: input.projectId, + message: `Unable to create ${seat.title}: ${error instanceof Error ? error.message : String(error)}`, + cause: error, + }), + ), + ); + if (pin) { + yield* threads + .dispatch({ + type: "thread.pin", + commandId: CommandId.make(`${input.commandId}:seat:${index}:pin`), + threadId, + }) + .pipe( + Effect.mapError( + (cause) => + new OrchestrationV2InstallContributorBundleError({ + commandId: input.commandId, + projectId: input.projectId, + message: `Created ${seat.title}, but pinning it failed.`, + cause, + }), + ), + ); + } + return { + seatId: seat.id, + title: seat.title, + roleBrief: formatContributorBundleSeatBrief(seat, bundle.orientation), + threadId, + }; + }), + { concurrency: 1 }, + ); + const [leadSeat, ...otherSeats] = launched; + if (leadSeat === undefined) { + return yield* new OrchestrationV2InstallContributorBundleError({ + commandId: input.commandId, + projectId: input.projectId, + message: "Contributor bundle produced no seats.", + }); + } + + return { + name: bundle.name, + ...(bundle.description === undefined ? {} : { description: bundle.description }), + orientation: bundle.orientation, + leadPrompt: formatContributorBundleLeadPrompt(bundle), + seats: [leadSeat, ...otherSeats], + } satisfies OrchestrationV2InstallContributorBundleResult; + }); + + return ContributorBundleService.of({ install }); +}); + +export const layer: Layer.Layer< + ContributorBundleService, + never, + | ProjectService.ProjectService + | ContributorBundleLoader.ContributorBundleLoader + | IdAllocator.IdAllocatorV2 + | ThreadManagement.ThreadManagementService +> = Layer.effect(ContributorBundleService, make); diff --git a/apps/server/src/orchestration-v2/runtimeLayer.ts b/apps/server/src/orchestration-v2/runtimeLayer.ts index 8434de806bf0d..d508a3a82e3f2 100644 --- a/apps/server/src/orchestration-v2/runtimeLayer.ts +++ b/apps/server/src/orchestration-v2/runtimeLayer.ts @@ -53,6 +53,8 @@ import * as RuntimePolicy from "./RuntimePolicy.ts"; import * as RuntimeRequestService from "./RuntimeRequestService.ts"; import * as ThreadManagementService from "./ThreadManagementService.ts"; import * as ThreadLaunchService from "./ThreadLaunchService.ts"; +import * as ContributorBundleService from "./ContributorBundleService.ts"; +import * as ContributorBundleLoader from "../project/ContributorBundleLoader.ts"; import * as ThreadForkService from "./ThreadForkService.ts"; import * as TurnItemPositionStore from "./TurnItemPositionStore.ts"; import * as ScheduledTaskService from "../scheduledTasks/ScheduledTaskService.ts"; @@ -281,6 +283,17 @@ const layerThreadLaunchProvided = ThreadLaunchService.layer.pipe( ), ), ); +const layerContributorBundleLoaderProvided = ContributorBundleLoader.layer; +const layerContributorBundleProvided = ContributorBundleService.layer.pipe( + Layer.provide( + Layer.mergeAll( + layerProjectService, + layerContributorBundleLoaderProvided, + layerThreadManagementProvided, + IdAllocator.layer, + ), + ), +); const layerSecretRequestsProvided = SecretRequests.layer.pipe( Layer.provide(layerThreadManagementProvided), ); @@ -379,6 +392,7 @@ export const layerProduction = Layer.mergeAll( layerProjectService, layerManagedProjectFoldersProvided, layerThreadLaunchProvided, + layerContributorBundleProvided, layerScheduledTaskProvided, layerScratchThreadPromotionProvided, layerSecretRequestsProvided, diff --git a/apps/server/src/project/ContributorBundleLoader.ts b/apps/server/src/project/ContributorBundleLoader.ts new file mode 100644 index 0000000000000..a66ed9715695e --- /dev/null +++ b/apps/server/src/project/ContributorBundleLoader.ts @@ -0,0 +1,93 @@ +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; + +import { CONTRIBUTOR_BUNDLE_FILE_NAME, type ContributorBundle } from "@t3tools/contracts"; +import { ContributorBundleFromJson } from "@t3tools/shared/contributorBundle"; + +const decodeContributorBundleJson = Schema.decodeEffect(ContributorBundleFromJson); + +export class ContributorBundleLoadError extends Schema.TaggedError()( + "ContributorBundleLoadError", + { + operation: Schema.Literals(["read", "decode"]), + workspaceRoot: Schema.String, + filePath: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to ${this.operation} ${CONTRIBUTOR_BUNDLE_FILE_NAME} at ${this.filePath}.`; + } +} + +export class ContributorBundleLoader extends Context.Service< + ContributorBundleLoader, + { + /** Never fails; missing/unreadable/invalid files return none, with warnings for invalid files. */ + readonly load: (workspaceRoot: string) => Effect.Effect>; + } +>()("t3/project/ContributorBundleLoader") {} + +const logContributorBundleLoadError = (error: ContributorBundleLoadError) => + Effect.logWarning(error).pipe( + Effect.annotateLogs({ + operation: error.operation, + workspaceRoot: error.workspaceRoot, + filePath: error.filePath, + errorTag: error._tag, + }), + ); + +/** @public */ +export const make = Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + + const load: ContributorBundleLoader["Service"]["load"] = Effect.fn( + "ContributorBundleLoader.load", + )(function* (workspaceRoot) { + const filePath = path.join(workspaceRoot, CONTRIBUTOR_BUNDLE_FILE_NAME); + const raw = yield* fileSystem.readFileString(filePath).pipe( + Effect.asSome, + Effect.catchTags({ + PlatformError: (error) => + error.reason._tag === "NotFound" + ? Effect.succeed(Option.none()) + : logContributorBundleLoadError( + new ContributorBundleLoadError({ + operation: "read", + workspaceRoot, + filePath, + cause: error, + }), + ).pipe(Effect.as(Option.none())), + }), + ); + if (Option.isNone(raw)) { + return Option.none(); + } + return yield* decodeContributorBundleJson(raw.value).pipe( + Effect.asSome, + Effect.catchTags({ + SchemaError: (error) => + logContributorBundleLoadError( + new ContributorBundleLoadError({ + operation: "decode", + workspaceRoot, + filePath, + cause: error, + }), + ).pipe(Effect.as(Option.none())), + }), + ); + }); + + return ContributorBundleLoader.of({ load }); +}); + +export const layer = Layer.effect(ContributorBundleLoader, make); diff --git a/apps/server/src/ws.test.ts b/apps/server/src/ws.test.ts index 2b64947dbc7df..c8a63ec59f05a 100644 --- a/apps/server/src/ws.test.ts +++ b/apps/server/src/ws.test.ts @@ -121,6 +121,7 @@ import * as StorageCleanup from "./storageCleanup.ts"; import * as DatabaseStorage from "./persistence/DatabaseStorage.ts"; import * as TerminalManager from "./terminal/Manager.ts"; import * as ThreadLaunchService from "./orchestration-v2/ThreadLaunchService.ts"; +import * as ContributorBundleService from "./orchestration-v2/ContributorBundleService.ts"; import * as ThreadSearch from "./orchestration-v2/ThreadSearch.ts"; import * as UsageLimitSources from "./usage/UsageLimitSources.ts"; import * as UsageService from "./usage/UsageService.ts"; @@ -851,6 +852,7 @@ const layerUnusedConfigRpcServices = Layer.mergeAll( Layer.mock(DatabaseStorage.DatabaseStorage)({}), Layer.mock(TerminalManager.TerminalManager)({}), Layer.mock(ThreadLaunchService.ThreadLaunchService)({}), + Layer.mock(ContributorBundleService.ContributorBundleService)({}), Layer.mock(ThreadManagement.ThreadManagementService)({}), Layer.mock(ThreadSearch.ThreadSearch)({}), Layer.mock(UsageService.UsageService)({}), @@ -2062,6 +2064,7 @@ it.effect("ACP session RPCs list a thread missing its history and repair it on i }), // Repairing an existing thread must not launch another one. Layer.mock(ThreadLaunchService.ThreadLaunchService)({}), + Layer.mock(ContributorBundleService.ContributorBundleService)({}), Layer.mock(AgentSessionImporter.AgentSessionImporter)({ importNativeHistory: ({ threadId }) => Effect.sync(() => repaired.push(threadId)).pipe(Effect.as(1)), diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 46d3f5c3ea5e9..72337e5c5b5e2 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -60,6 +60,7 @@ import { OrchestrationV2GetShellSnapshotError, OrchestrationV2GetThreadProjectionError, OrchestrationV2ThreadLaunchError, + OrchestrationV2InstallContributorBundleError, type OrchestrationProjectShell, type OrchestrationV2ShellSnapshot, type OrchestrationV2ShellStreamItem, @@ -117,6 +118,7 @@ import * as CommandOutputHub from "./orchestration-v2/CommandOutputHub.ts"; import * as McpAppRequests from "./mcpApps/McpAppRequests.ts"; import * as ProviderSessionManager from "./orchestration-v2/ProviderSessionManager.ts"; import * as ThreadLaunchService from "./orchestration-v2/ThreadLaunchService.ts"; +import * as ContributorBundleService from "./orchestration-v2/ContributorBundleService.ts"; import * as ThreadMessageIntake from "./orchestration-v2/ThreadMessageIntake.ts"; import * as DocumentText from "./provider/DocumentText.ts"; import * as ScheduledTasks from "./scheduledTasks/ScheduledTaskService.ts"; @@ -1440,6 +1442,7 @@ export const layerWsRpc = ( ).pipe(Effect.ignore); }; const threadLaunch = yield* ThreadLaunchService.ThreadLaunchService; + const contributorBundles = yield* ContributorBundleService.ContributorBundleService; const commandOutputHub = yield* CommandOutputHub.CommandOutputHub; const providerSessionManager = yield* ProviderSessionManager.ProviderSessionManagerV2; const scheduledTasks = yield* ScheduledTasks.ScheduledTaskService; @@ -2229,6 +2232,32 @@ export const layerWsRpc = ( ), ), ), + [ORCHESTRATION_V2_WS_METHODS.installContributorBundle]: (input) => + Effect.annotateCurrentSpan({ + "orchestration_v2.command_id": input.commandId, + "orchestration_v2.project_id": input.projectId, + }).pipe( + Effect.andThen( + startup.enqueueCommand(contributorBundles.install(input)).pipe( + Effect.catchTags({ + ThreadManagementProviderInstanceDisabledError: (cause) => + new OrchestrationV2InstallContributorBundleError({ + commandId: input.commandId, + projectId: input.projectId, + message: cause.message, + cause, + }), + ServerRuntimeStartupError: (cause) => + new OrchestrationV2InstallContributorBundleError({ + commandId: input.commandId, + projectId: input.projectId, + message: "Failed to install contributor bundle", + cause, + }), + }), + ), + ), + ), [ORCHESTRATION_V2_WS_METHODS.subscribeShell]: (input) => Stream.unwrap(subscribeOrchestrationV2Shell(input)), [ORCHESTRATION_V2_WS_METHODS.subscribeThread]: (input) => diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index 15c00fcba186d..d63352ff63799 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -79,6 +79,7 @@ import { SquarePenIcon, TextSearchIcon, UserPlusIcon, + UsersRoundIcon, } from "lucide-react"; import { openThreadFind } from "./chat/threadFindStore"; import { dispatchPreviewAction } from "./preview/previewActionBus"; @@ -126,6 +127,8 @@ import { useAtomCommand } from "../state/use-atom-command"; import { useOrchestrationCommand } from "../state/use-orchestration-command"; import { useAtomQueryRunner } from "../state/use-atom-query-runner"; import { useScratchProject } from "../hooks/useScratchProject"; +import { useContributorBundleFile } from "../hooks/useContributorBundleFile"; +import { useInstallContributorBundle } from "../hooks/useInstallContributorBundle"; import { useStartTriage } from "../hooks/useStartTriage"; import { pickTriageEnvironment } from "../lib/triageTarget"; import { useNewProject } from "../hooks/useNewProject"; @@ -457,6 +460,7 @@ function OpenCommandPaletteDialog(props: { }); const { scratchEnvironmentId, scratchWorkspaceRootFor, startScratchThread } = useScratchProject(); const startTriage = useStartTriage(); + const installContributorBundle = useInstallContributorBundle(); const lookupRepository = useAtomQueryRunner(sourceControlEnvironment.repository, { reportFailure: false, }); @@ -717,6 +721,32 @@ function OpenCommandPaletteDialog(props: { currentProjectEnvironmentId, currentProjectId, ); + const contributorBundleProjectRef = defaultProjectRef; + const contributorBundleEnvironmentId = contributorBundleProjectRef?.environmentId ?? null; + const supportsContributorBundles = + contributorBundleEnvironmentId !== null && + environments.find((environment) => environment.environmentId === contributorBundleEnvironmentId) + ?.serverConfig?.environment.capabilities.contributorBundles === true; + const canInstallContributorBundlePermission = useAtomValue( + threadEnvironment.installContributorBundle.permissionAtom(contributorBundleEnvironmentId), + ); + const contributorBundleProject = + contributorBundleProjectRef === null + ? null + : resolveCommandPaletteProject( + projectByKey, + contributorBundleProjectRef.environmentId, + contributorBundleProjectRef.projectId, + ); + const contributorBundleFile = useContributorBundleFile( + contributorBundleEnvironmentId, + contributorBundleProject?.workspaceRoot ?? null, + ); + const canInstallContributorBundle = + contributorBundleProjectRef !== null && + supportsContributorBundles && + canInstallContributorBundlePermission && + contributorBundleFile.status === "valid"; const currentProjectCwd = currentProject?.workspaceRoot ?? null; const currentProjectCwdForBrowse = browseEnvironmentId && currentProjectEnvironmentId === browseEnvironmentId @@ -1344,6 +1374,30 @@ function OpenCommandPaletteDialog(props: { }); } + if (contributorBundleProjectRef !== null && canInstallContributorBundle) { + const bundleName = contributorBundleFile.bundle?.name ?? "contributor bundle"; + actionItems.push({ + kind: "action", + value: "action:install-contributor-bundle", + searchTerms: [ + "contributor", + "bundle", + "workshop", + "team", + "orientation", + "project map", + "install", + "openrig", + "rig", + bundleName, + ], + title: `Install ${bundleName}`, + description: "Pinned specialist seats with this repository's project orientation", + icon: , + run: () => installContributorBundle(contributorBundleProjectRef), + }); + } + if (activeThreadKey !== null && supportsThreadFind) { actionItems.push({ kind: "action", diff --git a/apps/web/src/hooks/useContributorBundleFile.ts b/apps/web/src/hooks/useContributorBundleFile.ts new file mode 100644 index 0000000000000..b4a5353d7a825 --- /dev/null +++ b/apps/web/src/hooks/useContributorBundleFile.ts @@ -0,0 +1,38 @@ +import { + CONTRIBUTOR_BUNDLE_FILE_NAME, + type ContributorBundle, + type EnvironmentId, +} from "@t3tools/contracts"; +import { parseContributorBundle } from "@t3tools/shared/contributorBundle"; + +import { useProjectFileQuery } from "~/components/files/projectFilesQueryState"; + +export interface ContributorBundleFileState { + status: "loading" | "missing" | "invalid" | "valid"; + bundle: ContributorBundle | null; +} + +export function useContributorBundleFile( + environmentId: EnvironmentId | null, + cwd: string | null, +): ContributorBundleFileState { + const query = useProjectFileQuery( + environmentId ?? ("" as EnvironmentId), + cwd ?? "", + CONTRIBUTOR_BUNDLE_FILE_NAME, + environmentId !== null && cwd !== null, + ); + const contents = query.data && !query.data.truncated ? query.data.contents : null; + const isPending = query.isPending; + if (contents === null) { + return { + status: isPending ? "loading" : "missing", + bundle: null, + } as const; + } + const bundle = parseContributorBundle(contents); + if (bundle === null) { + return { status: "invalid", bundle: null } as const; + } + return { status: "valid", bundle } as const; +} diff --git a/apps/web/src/hooks/useInstallContributorBundle.ts b/apps/web/src/hooks/useInstallContributorBundle.ts new file mode 100644 index 0000000000000..ff3c7db2b54e6 --- /dev/null +++ b/apps/web/src/hooks/useInstallContributorBundle.ts @@ -0,0 +1,133 @@ +import { scopeThreadRef } from "@t3tools/client-runtime/environment"; +import { + isAtomCommandInterrupted, + squashAtomCommandFailure, +} from "@t3tools/client-runtime/state/runtime"; +import { CommandId, DEFAULT_SERVER_SETTINGS, type ScopedProjectRef } from "@t3tools/contracts"; +import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; +import { resolveNewThreadModelSelection } from "@t3tools/shared/usageLimits"; +import { useRouter } from "@tanstack/react-router"; + +import { useComposerDraftStore } from "../composerDraftStore"; +import { stackedThreadToast, toastManager } from "../components/ui/toast"; +import { resolveProjectDefaultProviderModelSelection } from "../providerInstances"; +import { randomUUID } from "../lib/utils"; +import { appAtomRegistry } from "../rpc/atomRegistry"; +import { readProject } from "../state/entities"; +import { environmentServerConfigsAtom } from "../state/server"; +import { threadEnvironment } from "../state/threads"; +import { buildThreadRouteParams } from "../threadRoutes"; +import { useAtomCommand } from "../state/use-atom-command"; + +/** + * Install a repo-shipped contributor bundle (t3.contributor-bundle.json): + * pinned specialist seats plus a lead composer draft that includes the + * project orientation map. + */ +export function useInstallContributorBundle() { + const router = useRouter(); + const install = useAtomCommand(threadEnvironment.installContributorBundle, { + reportFailure: false, + }); + + return async (projectRef: ScopedProjectRef) => { + const project = readProject(projectRef); + if (project === null) { + toastManager.add({ + type: "error", + title: "Could not install contributor bundle", + description: "That project is no longer available.", + }); + return; + } + + const environmentServerConfigs = appAtomRegistry.get(environmentServerConfigsAtom); + const serverConfig = environmentServerConfigs.get(projectRef.environmentId); + if (serverConfig?.environment.capabilities.contributorBundles !== true) { + toastManager.add({ + type: "error", + title: "Could not install contributor bundle", + description: "This environment does not support contributor bundles yet.", + }); + return; + } + + const settings = serverConfig.settings ?? DEFAULT_SERVER_SETTINGS; + const projectSettings = resolveProjectSettings(settings, project.id, project); + const modelResolution = resolveNewThreadModelSelection({ + defaultSelection: + projectSettings.settings.defaultModelSelection ?? + resolveProjectDefaultProviderModelSelection( + serverConfig.providers ?? [], + settings, + project.id, + ), + fallbackSelection: settings.fallbackModelSelection ?? null, + providers: serverConfig.providers ?? [], + settings, + projectId: project.id, + now: Date.now(), + }); + if (modelResolution.selection === null) { + toastManager.add({ + type: "error", + title: "Could not install contributor bundle", + description: "Pick a default model for this project, then try again.", + }); + return; + } + + const requestingRouteHref = router.state.location.href; + let pendingToastId: ReturnType | undefined; + const pendingTimer = setTimeout(() => { + pendingToastId = toastManager.add({ + type: "loading", + title: "Installing contributor bundle…", + timeout: 0, + }); + }, 400); + + const result = await install({ + environmentId: projectRef.environmentId, + input: { + commandId: CommandId.make(randomUUID()), + projectId: project.id, + modelSelection: modelResolution.selection, + runtimeMode: projectSettings.settings.defaultRuntimeMode, + interactionMode: "default", + }, + }).finally(() => { + clearTimeout(pendingTimer); + if (pendingToastId !== undefined) toastManager.close(pendingToastId); + }); + + if (result._tag === "Failure") { + if (isAtomCommandInterrupted(result)) return; + const error = squashAtomCommandFailure(result); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Could not install contributor bundle", + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + return; + } + + const lead = result.value.seats[0]; + if (lead === undefined) return; + if (router.state.location.href !== requestingRouteHref) return; + + const leadRef = scopeThreadRef(projectRef.environmentId, lead.threadId); + useComposerDraftStore.getState().setPrompt(leadRef, result.value.leadPrompt); + await router.navigate({ + to: "/$environmentId/$threadId", + params: buildThreadRouteParams(leadRef), + }); + toastManager.add({ + type: "success", + title: `${result.value.name} ready`, + description: `${result.value.seats.length} pinned seats · talk to ${lead.title}`, + }); + }; +} diff --git a/docs/user/project-settings.md b/docs/user/project-settings.md index 8880dcb3e64ca..b70122670795f 100644 --- a/docs/user/project-settings.md +++ b/docs/user/project-settings.md @@ -187,6 +187,14 @@ resolve in one order: a project override, then the environment setting, then `t3 built-in default. Leave a setting on **Inherit** to let the next tier decide. Browser access changes apply when an agent session next starts. +## Contributor bundles + +Maintainers can ship a specialist team plus a project map in +`t3.contributor-bundle.json` at the repository root. Open the project in T3 Code and run +**Install …** from the command palette. That creates pinned seat threads and opens the lead with +a draft that includes the orientation map. Use this when contributors need repo-specific +competence, not a generic starter team. + New worktrees initialize git submodules recursively. If that step is slow because the repository declares many nested submodules, set **Submodules** in **Settings → Worktrees** under Advanced (with the project selected to override it there) to **Top level only** to stop at the ones the repository declares diff --git a/packages/client-runtime/src/state/threadCommands.ts b/packages/client-runtime/src/state/threadCommands.ts index 3761d0fa6e715..746ea28e91926 100644 --- a/packages/client-runtime/src/state/threadCommands.ts +++ b/packages/client-runtime/src/state/threadCommands.ts @@ -4,6 +4,7 @@ import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; import { Atom } from "effect/reactivity"; import { + ORCHESTRATION_V2_WS_METHODS, WS_METHODS, type EnvironmentId, type OrchestrationV2ShellSnapshot, @@ -271,6 +272,14 @@ export function createThreadEnvironmentAtoms( scheduler, concurrency, }), + installContributorBundle: createEnvironmentRpcCommand(runtime, { + label: "environment-data:commands:thread:install-contributor-bundle", + tag: ORCHESTRATION_V2_WS_METHODS.installContributorBundle, + concurrency: { + mode: "serial", + key: ({ environmentId, input }) => JSON.stringify([environmentId, input.projectId]), + }, + }), interruptTurn: createEnvironmentCommand(runtime, { label: "environment-data:commands:thread:interrupt-turn", execute: interruptTurn, diff --git a/packages/contracts/src/clientRpcPermissions.ts b/packages/contracts/src/clientRpcPermissions.ts index 8b4a7d48d279c..f81cf25388285 100644 --- a/packages/contracts/src/clientRpcPermissions.ts +++ b/packages/contracts/src/clientRpcPermissions.ts @@ -71,6 +71,7 @@ export const CLIENT_GUARDED_RPC_SCOPES = { [ORCHESTRATION_V2_WS_METHODS.stopWorkflow]: AuthOrchestrationOperateScope, // Force stop kills agent processes, as Stop on a terminal does. [ORCHESTRATION_V2_WS_METHODS.forceStopThread]: AuthOrchestrationOperateScope, + [ORCHESTRATION_V2_WS_METHODS.installContributorBundle]: AuthOrchestrationOperateScope, // A guest link hands thread access to someone outside, like a pairing link. [WS_METHODS.guestShareCreate]: AuthAccessWriteScope, [WS_METHODS.peerLinksLink]: AuthAccessWriteScope, diff --git a/packages/contracts/src/contributorBundle.test.ts b/packages/contracts/src/contributorBundle.test.ts new file mode 100644 index 0000000000000..617f950e166a2 --- /dev/null +++ b/packages/contracts/src/contributorBundle.test.ts @@ -0,0 +1,27 @@ +import * as Schema from "effect/Schema"; +import { describe, expect, it } from "vite-plus/test"; + +import { ContributorBundle } from "./contributorBundle.ts"; + +const decode = Schema.decodeUnknownSync(ContributorBundle); + +describe("ContributorBundle", () => { + it("decodes a Workshop-like repo bundle with orientation", () => { + const decoded = decode({ + name: "T3 Code Workshop", + orientation: { + summary: "A GUI for coding agents.", + map: [{ path: "apps/server", purpose: "Orchestration" }], + guidance: "Run check:changed before push.", + }, + seats: [ + { id: "lead", title: "Workshop · Lead", roleBrief: "Coordinate." }, + { id: "builder", title: "Workshop · Builder", roleBrief: "Implement." }, + ], + }); + + expect(decoded.name).toBe("T3 Code Workshop"); + expect(decoded.orientation.map?.[0]?.path).toBe("apps/server"); + expect(decoded.seats).toHaveLength(2); + }); +}); diff --git a/packages/contracts/src/contributorBundle.ts b/packages/contracts/src/contributorBundle.ts new file mode 100644 index 0000000000000..d9a717d9e0cf4 --- /dev/null +++ b/packages/contracts/src/contributorBundle.ts @@ -0,0 +1,152 @@ +import * as Schema from "effect/Schema"; +import * as SchemaTransformation from "effect/SchemaTransformation"; + +import { CommandId, ProjectId, ThreadId, TrimmedNonEmptyString } from "./baseSchemas.ts"; +import { ModelSelection } from "./modelSelection.ts"; +import { ProviderInteractionMode, RuntimeMode } from "./providerPolicy.ts"; + +/** + * Repo-shippable contributor competence pack: a small specialist team plus the + * project-world map maintainers want every seat to share. Distinct from built-in + * starter team templates, which carry no checkout-specific orientation. + */ +export const CONTRIBUTOR_BUNDLE_FILE_NAME = "t3.contributor-bundle.json"; + +export const CONTRIBUTOR_BUNDLE_SCHEMA_URL = "https://t3.codes/schema/contributor-bundle.json"; + +const CONTRIBUTOR_BUNDLE_PATH_MAX_LENGTH = 512; +const CONTRIBUTOR_BUNDLE_MAX_MAP_ENTRIES = 50; +const CONTRIBUTOR_BUNDLE_MAX_SEATS = 12; + +const trimmedNonEmpty = (annotations: { readonly description: string }, maxLength?: number) => { + const annotated = Schema.String.annotate(annotations); + const encoded = + maxLength === undefined + ? annotated.check(Schema.isNonEmpty()) + : annotated.check(Schema.isNonEmpty(), Schema.isMaxLength(maxLength)); + return encoded.pipe(Schema.decodeTo(encoded, SchemaTransformation.trim())); +}; + +export const ContributorBundleMapEntry = Schema.Struct({ + path: trimmedNonEmpty( + { + description: 'Workspace-relative path or area label (e.g. "apps/server" or "docs/user").', + }, + CONTRIBUTOR_BUNDLE_PATH_MAX_LENGTH, + ), + purpose: trimmedNonEmpty({ + description: "What a contributor should know about this area of the project.", + }), +}).annotate({ + description: "One entry in the project map shipped with the contributor bundle.", +}); +export type ContributorBundleMapEntry = typeof ContributorBundleMapEntry.Type; + +export const ContributorBundleOrientation = Schema.Struct({ + summary: trimmedNonEmpty({ + description: "Short project-world briefing every seat should share.", + }), + map: Schema.optionalKey( + Schema.Array(ContributorBundleMapEntry) + .annotate({ + description: "Project map: paths and why they matter for contributors.", + }) + .check(Schema.isMaxLength(CONTRIBUTOR_BUNDLE_MAX_MAP_ENTRIES)), + ), + guidance: Schema.optionalKey( + trimmedNonEmpty({ + description: + "Working agreements for agents in this checkout (verify commands, PR habits, traps).", + }), + ), +}).annotate({ + description: "Checkout-specific orientation that generic starter teams do not include.", +}); +export type ContributorBundleOrientation = typeof ContributorBundleOrientation.Type; + +export const ContributorBundleSeat = Schema.Struct({ + id: trimmedNonEmpty( + { + description: 'Stable seat id within the bundle (e.g. "lead", "builder").', + }, + 64, + ), + title: trimmedNonEmpty({ + description: "Thread title for this seat when the bundle is installed.", + }), + roleBrief: trimmedNonEmpty({ + description: "Role instructions for this seat, written for this repository.", + }), +}).annotate({ + description: "One durable specialist seat the bundle installs as a pinned thread.", +}); +export type ContributorBundleSeat = typeof ContributorBundleSeat.Type; + +export const ContributorBundle = Schema.Struct({ + $schema: Schema.optionalKey( + Schema.String.annotate({ + description: `URL of the JSON Schema for this file, typically "${CONTRIBUTOR_BUNDLE_SCHEMA_URL}".`, + }), + ), + name: trimmedNonEmpty({ + description: "Display name for the contributor bundle.", + }), + description: Schema.optionalKey( + trimmedNonEmpty({ + description: "One-line summary of who the bundle is for.", + }), + ), + orientation: ContributorBundleOrientation, + seats: Schema.Array(ContributorBundleSeat) + .annotate({ + description: "Specialist seats installed as pinned project threads. First seat is the lead.", + }) + .check(Schema.isMinLength(1), Schema.isMaxLength(CONTRIBUTOR_BUNDLE_MAX_SEATS)), +}).annotate({ + title: "T3 contributor bundle", + description: + "Checked-in team + project orientation for T3 Code (t3.contributor-bundle.json). Install from the command palette after opening the repository.", +}); +export type ContributorBundle = typeof ContributorBundle.Type; + +export const OrchestrationV2InstallContributorBundleInput = Schema.Struct({ + commandId: CommandId, + projectId: ProjectId, + modelSelection: ModelSelection, + runtimeMode: RuntimeMode, + interactionMode: ProviderInteractionMode, + /** Pin each seat in the project sidebar. Defaults to true. */ + pin: Schema.optional(Schema.Boolean), +}); +export type OrchestrationV2InstallContributorBundleInput = + typeof OrchestrationV2InstallContributorBundleInput.Type; + +export const OrchestrationV2InstalledContributorSeat = Schema.Struct({ + seatId: TrimmedNonEmptyString, + title: TrimmedNonEmptyString, + roleBrief: TrimmedNonEmptyString, + threadId: ThreadId, +}); +export type OrchestrationV2InstalledContributorSeat = + typeof OrchestrationV2InstalledContributorSeat.Type; + +export const OrchestrationV2InstallContributorBundleResult = Schema.Struct({ + name: TrimmedNonEmptyString, + description: Schema.optional(TrimmedNonEmptyString), + orientation: ContributorBundleOrientation, + /** Composer draft for the lead seat: orientation + roster. */ + leadPrompt: TrimmedNonEmptyString, + seats: Schema.Array(OrchestrationV2InstalledContributorSeat).check(Schema.isMinLength(1)), +}); +export type OrchestrationV2InstallContributorBundleResult = + typeof OrchestrationV2InstallContributorBundleResult.Type; + +export class OrchestrationV2InstallContributorBundleError extends Schema.TaggedError()( + "OrchestrationV2InstallContributorBundleError", + { + commandId: CommandId, + projectId: ProjectId, + message: Schema.String, + cause: Schema.optional(Schema.Defect()), + }, +) {} diff --git a/packages/contracts/src/environment.ts b/packages/contracts/src/environment.ts index 5e9cca70752b6..2c4da87541559 100644 --- a/packages/contracts/src/environment.ts +++ b/packages/contracts/src/environment.ts @@ -184,6 +184,8 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({ /** Server can transcribe 16 kHz mono PCM audio on the environment machine. */ voiceTranscription: Schema.optionalKey(Schema.Boolean), cloudVoiceTranscription: Schema.optionalKey(Schema.Boolean), + /** Server answers orchestration.installContributorBundle for repo-shipped teams. */ + contributorBundles: Schema.optionalKey(Schema.Boolean), }); export type ExecutionEnvironmentCapabilities = typeof ExecutionEnvironmentCapabilities.Type; diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index 8dd47df33a21f..733cc11b35f32 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -50,6 +50,7 @@ export * from "./threadSearch.ts"; export * from "./threadTags.ts"; export * from "./threadTitle.ts"; export * from "./t3ProjectFile.ts"; +export * from "./contributorBundle.ts"; export * from "./editor.ts"; export * from "./project.ts"; export * from "./filesystem.ts"; diff --git a/packages/contracts/src/orchestrationV2.ts b/packages/contracts/src/orchestrationV2.ts index 3189bb3eb7e65..06f8b6e219ec5 100644 --- a/packages/contracts/src/orchestrationV2.ts +++ b/packages/contracts/src/orchestrationV2.ts @@ -85,6 +85,10 @@ import { ToolActivitySource, } from "./providerRuntime.ts"; import { ThreadTokenUsageSnapshot } from "./providerRuntime.ts"; +import { + OrchestrationV2InstallContributorBundleInput, + OrchestrationV2InstallContributorBundleResult, +} from "./contributorBundle.ts"; export const OrchestrationV2Actor = Schema.Literals(["user", "agent", "system"]); export type OrchestrationV2Actor = typeof OrchestrationV2Actor.Type; @@ -3656,6 +3660,7 @@ export const ORCHESTRATION_V2_WS_METHODS = { forceStopThread: "orchestration.forceStopThread", getTurnItem: "orchestration.getTurnItem", launchThread: "orchestration.launchThread", + installContributorBundle: "orchestration.installContributorBundle", subscribeShell: "orchestration.subscribeShell", subscribeThread: "orchestration.subscribeThread", subscribeCommandOutput: "orchestration.subscribeCommandOutput", @@ -4366,6 +4371,10 @@ export const OrchestrationV2RpcSchemas = { input: OrchestrationV2ThreadLaunchInput, output: OrchestrationV2ThreadLaunchResult, }, + installContributorBundle: { + input: OrchestrationV2InstallContributorBundleInput, + output: OrchestrationV2InstallContributorBundleResult, + }, subscribeShell: { input: OrchestrationV2SubscribeShellInput, output: OrchestrationV2ShellStreamItem, diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index 0f6af19188e18..3b599ea37283a 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -234,6 +234,7 @@ import { OrchestrationV2RpcSchemas, OrchestrationV2ThreadLaunchError, } from "./orchestrationV2.ts"; +import { OrchestrationV2InstallContributorBundleError } from "./contributorBundle.ts"; import { ProjectCreateNewInput, ProjectCreateNewResult, @@ -1911,6 +1912,18 @@ const WsOrchestrationV2LaunchThreadRpc = Rpc.make(ORCHESTRATION_V2_WS_METHODS.la error: Schema.Union([OrchestrationV2ThreadLaunchError, EnvironmentAuthorizationError]), }); +const WsOrchestrationV2InstallContributorBundleRpc = Rpc.make( + ORCHESTRATION_V2_WS_METHODS.installContributorBundle, + { + payload: OrchestrationV2RpcSchemas.installContributorBundle.input, + success: OrchestrationV2RpcSchemas.installContributorBundle.output, + error: Schema.Union([ + OrchestrationV2InstallContributorBundleError, + EnvironmentAuthorizationError, + ]), + }, +); + const WsOrchestrationV2SubscribeShellRpc = Rpc.make(ORCHESTRATION_V2_WS_METHODS.subscribeShell, { payload: OrchestrationV2RpcSchemas.subscribeShell.input, success: OrchestrationV2RpcSchemas.subscribeShell.output, @@ -2279,6 +2292,7 @@ export const WsRpcGroup = RpcGroup.make( WsOrchestrationV2GetOlderSettledThreadsRpc, WsOrchestrationV2GetThreadProjectionRpc, WsOrchestrationV2LaunchThreadRpc, + WsOrchestrationV2InstallContributorBundleRpc, WsOrchestrationV2SubscribeShellRpc, WsOrchestrationV2SubscribeThreadRpc, WsOrchestrationV2SubscribeCommandOutputRpc, diff --git a/packages/shared/package.json b/packages/shared/package.json index f5f769236f906..53c157ecb6131 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -175,6 +175,10 @@ "types": "./src/t3ProjectFile.ts", "import": "./src/t3ProjectFile.ts" }, + "./contributorBundle": { + "types": "./src/contributorBundle.ts", + "import": "./src/contributorBundle.ts" + }, "./orchestrationTiming": { "types": "./src/orchestrationTiming.ts", "import": "./src/orchestrationTiming.ts" diff --git a/packages/shared/src/contributorBundle.test.ts b/packages/shared/src/contributorBundle.test.ts new file mode 100644 index 0000000000000..0f65a5c2150f8 --- /dev/null +++ b/packages/shared/src/contributorBundle.test.ts @@ -0,0 +1,98 @@ +import * as Schema from "effect/Schema"; +import { describe, expect, it } from "vite-plus/test"; + +import { + ContributorBundleFromJson, + formatContributorBundleLeadPrompt, + formatContributorBundleOrientation, + parseContributorBundle, +} from "./contributorBundle.ts"; + +const decodeJson = Schema.decodeUnknownSync(ContributorBundleFromJson); + +const workshopLike = { + name: "Workshop contributors", + description: "Lead, builder, QA, and reviewer with this repo's map.", + orientation: { + summary: "T3 Code is a GUI for coding agents.", + map: [ + { path: "apps/server", purpose: "Orchestration and providers" }, + { path: "apps/web", purpose: "React UI" }, + ], + guidance: "Run vp run check:changed before push.", + }, + seats: [ + { + id: "lead", + title: "Workshop · Lead", + roleBrief: "Own the outcome and coordinate seats.", + }, + { + id: "builder", + title: "Workshop · Builder", + roleBrief: "Implement assigned changes.", + }, + ], +} as const; + +describe("ContributorBundleFromJson", () => { + it("decodes lenient JSONC with comments and trailing commas", () => { + const decoded = decodeJson(`{ + // OSS contributor pack + "name": "Workshop contributors", + "orientation": { + "summary": "Ship competence with the repo.", + "map": [ + { "path": "apps/server", "purpose": "Server", }, + ], + }, + "seats": [ + { "id": "lead", "title": "Lead", "roleBrief": "Coordinate.", }, + ], + }`); + + expect(decoded.name).toBe("Workshop contributors"); + expect(decoded.orientation.map?.[0]).toEqual({ + path: "apps/server", + purpose: "Server", + }); + expect(decoded.seats).toHaveLength(1); + }); +}); + +describe("parseContributorBundle", () => { + it("rejects a file with no seats", () => { + expect( + parseContributorBundle(`{ + "name": "Empty", + "orientation": { "summary": "Nope" }, + "seats": [] + }`), + ).toBeNull(); + }); + + it("reads a Workshop-like contributor bundle", () => { + expect(parseContributorBundle(JSON.stringify(workshopLike))).toEqual(workshopLike); + }); +}); + +describe("formatContributorBundleOrientation", () => { + it("includes summary, map, and guidance", () => { + expect(formatContributorBundleOrientation(workshopLike.orientation)).toContain( + "apps/server: Orchestration and providers", + ); + expect(formatContributorBundleOrientation(workshopLike.orientation)).toContain( + "Run vp run check:changed before push.", + ); + }); +}); + +describe("formatContributorBundleLeadPrompt", () => { + it("puts project orientation and specialist roster in the lead draft", () => { + const prompt = formatContributorBundleLeadPrompt(workshopLike); + expect(prompt).toContain("Project orientation"); + expect(prompt).toContain("Pinned teammates:"); + expect(prompt).toContain("Workshop · Builder"); + expect(prompt).toContain("apps/web: React UI"); + }); +}); diff --git a/packages/shared/src/contributorBundle.ts b/packages/shared/src/contributorBundle.ts new file mode 100644 index 0000000000000..0fb8bccce15a2 --- /dev/null +++ b/packages/shared/src/contributorBundle.ts @@ -0,0 +1,83 @@ +import * as Exit from "effect/Exit"; +import * as Schema from "effect/Schema"; + +import { + ContributorBundle, + CONTRIBUTOR_BUNDLE_SCHEMA_URL, + type ContributorBundleOrientation, + type ContributorBundleSeat, +} from "@t3tools/contracts"; + +import { fromLenientJson } from "./schemaJson.ts"; + +export const ContributorBundleFromJson = fromLenientJson(ContributorBundle); + +const decodeContributorBundle = Schema.decodeExit(ContributorBundleFromJson); + +export function parseContributorBundle(contents: string): ContributorBundle | null { + const decoded = decodeContributorBundle(contents); + return Exit.isSuccess(decoded) ? decoded.value : null; +} + +export function buildContributorBundleJsonSchema(): Record { + const document = Schema.toJsonSchemaDocument(ContributorBundle, { onExcessProperty: "error" }); + const jsonSchema: Record = { + $schema: "https://json-schema.org/draft/2020-12/schema", + $id: CONTRIBUTOR_BUNDLE_SCHEMA_URL, + ...document.schema, + }; + if (document.definitions && Object.keys(document.definitions).length > 0) { + jsonSchema.$defs = document.definitions; + } + return jsonSchema; +} + +export function formatContributorBundleOrientation( + orientation: ContributorBundleOrientation, +): string { + const lines = [orientation.summary]; + if (orientation.map !== undefined && orientation.map.length > 0) { + lines.push("", "Project map:"); + for (const entry of orientation.map) { + lines.push(`- ${entry.path}: ${entry.purpose}`); + } + } + if (orientation.guidance !== undefined) { + lines.push("", "Guidance:", orientation.guidance); + } + return lines.join("\n"); +} + +export function formatContributorBundleSeatBrief( + seat: ContributorBundleSeat, + orientation: ContributorBundleOrientation, +): string { + return [ + seat.roleBrief, + "", + "## Project orientation", + formatContributorBundleOrientation(orientation), + ].join("\n"); +} + +export function formatContributorBundleLeadPrompt(bundle: ContributorBundle): string { + const [lead, ...specialists] = bundle.seats; + const lines = [ + `You are the lead for the "${bundle.name}" contributor bundle in this repository.`, + ]; + if (lead !== undefined) { + lines.push("", lead.roleBrief); + } + lines.push("", "## Project orientation", formatContributorBundleOrientation(bundle.orientation)); + if (specialists.length > 0) { + lines.push("", "Pinned teammates:"); + for (const seat of specialists) { + lines.push(`- ${seat.title}: ${seat.roleBrief}`); + } + } + lines.push( + "", + "Ask what outcome I want, then coordinate the specialist seats. Prefer delegating specialist work over doing it yourself. Keep the project orientation in mind for every assignment.", + ); + return lines.join("\n"); +} diff --git a/t3.contributor-bundle.json b/t3.contributor-bundle.json new file mode 100644 index 0000000000000..705c8777265cc --- /dev/null +++ b/t3.contributor-bundle.json @@ -0,0 +1,53 @@ +{ + "$schema": "https://t3.codes/schema/contributor-bundle.json", + "name": "T3 Code Workshop", + "description": "Lead, builder, QA, and reviewer seats oriented to this repository's layout and contribution habits.", + "orientation": { + "summary": "T3 Code is a minimal GUI for coding agents. A Node WebSocket server wraps provider CLIs and serves web, desktop, and mobile clients. Open at the core, remote-ready, multi-surface.", + "map": [ + { + "path": "apps/server", + "purpose": "WebSocket server, orchestration, providers, checkpointing. Effect-heavy; read docs/internals/effect-services.md before adding server code." + }, + { + "path": "apps/web", + "purpose": "React/Vite UI. Desktop wraps it; shared client logic lives in packages/client-runtime." + }, + { + "path": "apps/mobile", + "purpose": "React Native client. Prefer vp run mobile:install over rebuilding from scratch." + }, + { + "path": "packages/contracts", + "purpose": "Effect/Schema contracts for anything crossing the wire. Change the schema and server, web, mobile, and desktop follow." + }, + { + "path": "AGENTS.md", + "purpose": "Contributor operating rules: worktrees, T3 home, check:changed, test taste, and what never to compromise." + } + ], + "guidance": "One problem per PR. Before push, run vp run check:changed. Prefer focused tests beside the change. Never write to ~/.t3/userdata. Never set VITE_HTTP_URL or VITE_WS_URL for dev. Kill only PIDs you captured at spawn." + }, + "seats": [ + { + "id": "lead", + "title": "Workshop · Lead", + "roleBrief": "You are the Workshop lead for T3 Code contributors. Own the outcome: clarify goals with the user, break work into seats, coordinate Builder / QA / Reviewer, and bring decisions back. Prefer delegating specialist work over doing it yourself." + }, + { + "id": "builder", + "title": "Workshop · Builder", + "roleBrief": "You are the Workshop builder for T3 Code. Implement assigned changes with the project map in mind. Keep scope to one problem, leave the tree reviewable, and report what changed when done." + }, + { + "id": "qa", + "title": "Workshop · QA", + "roleBrief": "You are Workshop QA for T3 Code. Exercise the user paths the change touches across the surfaces that apply (web, desktop, mobile). Prefer failing reproduction steps and concrete verification over abstract review." + }, + { + "id": "reviewer", + "title": "Workshop · Reviewer", + "roleBrief": "You are the Workshop reviewer for T3 Code. Independently review the implementation against AGENTS.md constraints (hit every surface, contracts, reverse states). Do not re-implement; report findings." + } + ] +}