From 6432eb85db8336ea63eaf8879cdf37c52141871a Mon Sep 17 00:00:00 2001 From: wSedlacek Date: Sat, 14 Nov 2026 14:42:02 -0800 Subject: [PATCH] feat(orchestration): installable contributor bundles with project orientation Reopened from pingdotgg/t3code#34632 and squashed onto current main. Co-Authored-By: Claude Opus 5.5 --- .../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 | 13 ++ .../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, 1206 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 7823c3a146228..e2441601b7229 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 3a9bfafb82eaf..42501d22f5217 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 813efbe53ea30..cf254491ac179 100644 --- a/apps/server/src/environment/ServerEnvironment.ts +++ b/apps/server/src/environment/ServerEnvironment.ts @@ -256,6 +256,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 3f0fe7edd4aa0..5d2c0eadb89a6 100644 --- a/apps/server/src/observability/RpcInstrumentation.ts +++ b/apps/server/src/observability/RpcInstrumentation.ts @@ -31,6 +31,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 4f834535cc2d2..a863dab199363 100644 --- a/apps/server/src/orchestration-v2/runtimeLayer.ts +++ b/apps/server/src/orchestration-v2/runtimeLayer.ts @@ -55,6 +55,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"; @@ -292,6 +294,16 @@ const layerThreadLaunchProvided = ThreadLaunchService.layer.pipe( ).pipe(Layer.provideMerge(layerProjectService)), ), ); +const layerContributorBundleLoaderProvided = ContributorBundleLoader.layer; +const layerContributorBundleProvided = ContributorBundleService.layer.pipe( + Layer.provide( + Layer.mergeAll( + layerContributorBundleLoaderProvided, + layerThreadManagementProvided, + IdAllocator.layer, + ).pipe(Layer.provideMerge(layerProjectService)), + ), +); const layerSecretRequestsProvided = SecretRequests.layer.pipe( Layer.provide(layerThreadManagementProvided), ); @@ -393,6 +405,7 @@ export const layerProduction = Layer.mergeAll( layer, 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 660548c1dd68b..7e393e32fe779 100644 --- a/apps/server/src/ws.test.ts +++ b/apps/server/src/ws.test.ts @@ -123,6 +123,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"; @@ -865,6 +866,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)({}), @@ -2395,6 +2397,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 dfb96d3fc84f8..84ce3dbe127eb 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -62,6 +62,7 @@ import { OrchestrationV2GetShellSnapshotError, OrchestrationV2GetThreadProjectionError, OrchestrationV2ThreadLaunchError, + OrchestrationV2InstallContributorBundleError, type OrchestrationProjectShell, type OrchestrationV2ShellSnapshot, type OrchestrationV2ShellStreamItem, @@ -120,6 +121,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"; @@ -1466,6 +1468,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; @@ -2266,6 +2269,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 b0b8e510de5bb..2e06720796993 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"; @@ -461,6 +464,7 @@ function OpenCommandPaletteDialog(props: { }); const { scratchEnvironmentId, scratchWorkspaceRootFor, startScratchThread } = useScratchProject(); const startTriage = useStartTriage(); + const installContributorBundle = useInstallContributorBundle(); const lookupRepository = useAtomQueryRunner(sourceControlEnvironment.repository, { reportFailure: false, }); @@ -721,6 +725,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 @@ -1352,6 +1382,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 899640ad23590..c9489020f35df 100644 --- a/docs/user/project-settings.md +++ b/docs/user/project-settings.md @@ -190,6 +190,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 0e9de03f5c066..9b0fe3a244004 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 { AsyncResult, Atom, type AtomRegistry } from "effect/reactivity"; import { + ORCHESTRATION_V2_WS_METHODS, WS_METHODS, type EnvironmentId, type OrchestrationV2ShellSnapshot, @@ -311,6 +312,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 8681c1ea8abeb..22b2a885ac4bc 100644 --- a/packages/contracts/src/clientRpcPermissions.ts +++ b/packages/contracts/src/clientRpcPermissions.ts @@ -72,6 +72,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 ad1cfe30b0214..eb11ecfc65e63 100644 --- a/packages/contracts/src/environment.ts +++ b/packages/contracts/src/environment.ts @@ -206,6 +206,8 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({ shellOlderSettled: Schema.optionalKey(Schema.Boolean), /** Publishes a pull request refresh when linked-thread answers change; older servers need polling. */ linkedThreadRefreshes: 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 44e9aa8290e9a..e95856675806e 100644 --- a/packages/contracts/src/orchestrationV2.ts +++ b/packages/contracts/src/orchestrationV2.ts @@ -86,6 +86,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; @@ -3834,6 +3838,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", @@ -4549,6 +4554,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 0c0aeda6e559a..0a85a54012a5b 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -236,6 +236,7 @@ import { OrchestrationV2RpcSchemas, OrchestrationV2ThreadLaunchError, } from "./orchestrationV2.ts"; +import { OrchestrationV2InstallContributorBundleError } from "./contributorBundle.ts"; import { ProjectCreateNewInput, ProjectCreateNewResult, @@ -1942,6 +1943,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, @@ -2319,6 +2332,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 0e46f15e7aa45..4185176521eee 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -187,6 +187,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." + } + ] +}