diff --git a/packages/core/src/sessions/cloudArtifactService.test.ts b/packages/core/src/sessions/cloudArtifactService.test.ts index d76644491c..d8c0789f53 100644 --- a/packages/core/src/sessions/cloudArtifactService.test.ts +++ b/packages/core/src/sessions/cloudArtifactService.test.ts @@ -165,7 +165,13 @@ describe("CloudArtifactService", () => { "task-1", "run-1", [], - [{ name: "local-skill", source: "user", path: "/tmp/local-skill" }], + [ + { + name: "local-skill", + source: "user", + path: "/tmp/local-skill", + }, + ], ); expect(ids).toEqual(["skill-artifact-1"]); diff --git a/packages/core/src/sessions/cloudArtifactService.ts b/packages/core/src/sessions/cloudArtifactService.ts index 8769c7f305..cd06005430 100644 --- a/packages/core/src/sessions/cloudArtifactService.ts +++ b/packages/core/src/sessions/cloudArtifactService.ts @@ -232,8 +232,6 @@ export class CloudArtifactService { if (skillBundleRefs.length === 0) { return []; } - // Pull in dependency skills the tagged ones declare, so a skill that needs - // another arrives in the sandbox together with it. const expandedRefs = await this.resolveSkillBundleDependencies(skillBundleRefs); return Promise.all( diff --git a/packages/core/src/task-detail/taskCreationHost.ts b/packages/core/src/task-detail/taskCreationHost.ts index 2d29814552..efc9685264 100644 --- a/packages/core/src/task-detail/taskCreationHost.ts +++ b/packages/core/src/task-detail/taskCreationHost.ts @@ -1,6 +1,11 @@ import type { ContentBlock } from "@agentclientprotocol/sdk"; import type { CloudSkillBundleRef } from "@posthog/core/sessions/cloudArtifactIdentifiers"; -import type { Workspace, WorkspaceInfo, WorkspaceMode } from "@posthog/shared"; +import type { + AlwaysOnSkillRef, + Workspace, + WorkspaceInfo, + WorkspaceMode, +} from "@posthog/shared"; import type { TaskCreationApiClient } from "./taskCreationApiClient"; export interface CloudPromptTransport { @@ -103,6 +108,9 @@ export interface ITaskCreationHost { * too, or a typed `/my-skill` reaches the sandbox with no bundle attached. */ resolveLocalSkillCommandPrompt(prompt: string): Promise; + renderAlwaysOnSkillInstructions( + skills: AlwaysOnSkillRef[], + ): Promise; /** * Return-and-clear the pre-warmed sandbox lease matching the composer * selection, if one was provisioned while the user typed. The saga uploads diff --git a/packages/core/src/task-detail/taskCreationSaga.test.ts b/packages/core/src/task-detail/taskCreationSaga.test.ts index 1d3849110d..5cdbabafb5 100644 --- a/packages/core/src/task-detail/taskCreationSaga.test.ts +++ b/packages/core/src/task-detail/taskCreationSaga.test.ts @@ -21,6 +21,7 @@ const mockHost = vi.hoisted(() => ({ detectRepo: vi.fn(), getCloudPromptTransport: vi.fn(), resolveLocalSkillCommandPrompt: vi.fn(async (prompt: string) => prompt), + renderAlwaysOnSkillInstructions: vi.fn(), takeWarmTaskLease: vi.fn( (): { taskId: string; runId: string } | null => null, ), @@ -118,6 +119,7 @@ describe("TaskCreationSaga", () => { mockHost.getWorkspace.mockResolvedValue(null); mockHost.getFolders.mockResolvedValue([]); mockHost.uploadRunAttachments.mockResolvedValue([]); + mockHost.renderAlwaysOnSkillInstructions.mockResolvedValue(undefined); mockHost.linkTaskBranch.mockResolvedValue(undefined); mockHost.recordClaudeCliImport.mockResolvedValue(undefined); mockHost.deleteClaudeCliImport.mockResolvedValue(undefined); @@ -243,6 +245,49 @@ describe("TaskCreationSaga", () => { ); }); + it("folds always-on skill instructions into the first cloud message", async () => { + const startedTask = createTask({ latest_run: createRun() }); + const startTaskRun = vi.fn().mockResolvedValue(startedTask); + mockHost.renderAlwaysOnSkillInstructions.mockResolvedValue( + "Be concise.", + ); + + const saga = makeSaga({ + createTask: vi.fn().mockResolvedValue(createTask()), + createTaskRun: vi.fn().mockResolvedValue(createRun()), + startTaskRun, + }); + const skill = { + name: "concise", + source: "user" as const, + path: "/skills/concise", + }; + + const result = await saga.run({ + content: "Ship the fix", + repository: "posthog/posthog", + workspaceMode: "cloud", + alwaysOnSkills: [skill], + }); + + expect(result.success).toBe(true); + expect(mockHost.renderAlwaysOnSkillInstructions).toHaveBeenCalledWith([ + skill, + ]); + expect(startTaskRun).toHaveBeenCalledWith( + "task-123", + "run-123", + expect.objectContaining({ + pendingUserMessage: + "Ship the fix\n\nBe concise.", + }), + ); + expect(mockHost.getCloudPromptTransport).toHaveBeenCalledWith( + "Ship the fix", + undefined, + ); + }); + it("folds custom personalization into the cloud prompt and stashes it for the optimistic placeholder", async () => { const createdTask = createTask(); const startedTask = createTask({ latest_run: createRun() }); @@ -353,6 +398,37 @@ describe("TaskCreationSaga", () => { ); }); + it("adds always-on skill instructions to the initial local prompt", async () => { + mockHost.renderAlwaysOnSkillInstructions.mockResolvedValue( + "Be concise.", + ); + const saga = makeSaga({ + createTask: vi.fn().mockResolvedValue(createTask()), + }); + + const result = await saga.run({ + content: "Ship the fix", + workspaceMode: "local", + allowNoRepo: true, + alwaysOnSkills: [ + { name: "concise", source: "user", path: "/skills/concise" }, + ], + }); + + expect(result.success).toBe(true); + const connectParams = vi.mocked(sessionService.connectToTask).mock + .calls[0][0]; + expect(connectParams.initialPrompt).toEqual( + expect.arrayContaining([ + { + type: "text", + text: "Be concise.", + }, + ]), + ); + expect(connectParams).not.toHaveProperty("alwaysOnSkills"); + }); + it("starts a Pi session without creating an ACP session", async () => { const createdTask = createTask({ repository: undefined }); const createTaskRequest = vi.fn().mockResolvedValue(createdTask); diff --git a/packages/core/src/task-detail/taskCreationSaga.ts b/packages/core/src/task-detail/taskCreationSaga.ts index da3f55a9ca..87f284eb7a 100644 --- a/packages/core/src/task-detail/taskCreationSaga.ts +++ b/packages/core/src/task-detail/taskCreationSaga.ts @@ -1,6 +1,5 @@ import { PI_THINKING_LEVELS } from "@posthog/agent/pi/types"; import { - buildChannelContextBlock, buildChannelContextText, buildCustomInstructionsText, buildPromptBlocks, @@ -54,6 +53,7 @@ interface WarmActivationPayload { function buildCloudFirstMessage( messageText: string | undefined, input: TaskCreationInput, + alwaysOnSkillInstructions?: string, ): { pendingUserMessage?: string; augmented: boolean } { const customInstructionsText = messageText ? buildCustomInstructionsText(input.customInstructions) @@ -64,12 +64,21 @@ function buildCloudFirstMessage( input.channelContextId, ); const pendingUserMessage = - [messageText, customInstructionsText, channelContextText] + [ + messageText, + customInstructionsText, + alwaysOnSkillInstructions, + channelContextText, + ] .filter((part): part is string => !!part) .join("\n\n") || undefined; return { pendingUserMessage, - augmented: !!(customInstructionsText || channelContextText), + augmented: !!( + customInstructionsText || + alwaysOnSkillInstructions || + channelContextText + ), }; } @@ -99,10 +108,16 @@ export class TaskCreationSaga extends Saga< const importedClaude = isPiRuntime ? undefined : await this.importClaudeSession(input); + const alwaysOnSkills = input.alwaysOnSkills; + const alwaysOnSkillInstructions = alwaysOnSkills?.length + ? await this.readOnlyStep("render_always_on_skills", () => + this.deps.host.renderAlwaysOnSkillInstructions(alwaysOnSkills), + ) + : undefined; const warmPayload = !isPiRuntime && !taskId && input.workspaceMode === "cloud" - ? await this.prepareWarmActivation(input) + ? await this.prepareWarmActivation(input, alwaysOnSkillInstructions) : null; let task = taskId @@ -387,7 +402,11 @@ export class TaskCreationSaga extends Saga< const { pendingUserMessage, augmented } = warmPayload ? warmPayload - : buildCloudFirstMessage(transport?.messageText, input); + : buildCloudFirstMessage( + transport?.messageText, + input, + alwaysOnSkillInstructions, + ); // The sandbox echoes pendingUserMessage back once it boots; until then // the optimistic placeholder would show the bare task description with @@ -511,13 +530,22 @@ export class TaskCreationSaga extends Saga< // Append the channel's CONTEXT.md as optional background, so tasks made // in a channel start with the shared context the agent would otherwise // have to rediscover. Kept after the user's prompt so the request leads. - const channelContextBlock = buildChannelContextBlock( + const channelContextText = buildChannelContextText( input.channelContext, input.channelName, input.channelContextId, ); - if (initialPrompt && channelContextBlock) { - initialPrompt.push(channelContextBlock); + const supplementaryContext = [ + alwaysOnSkillInstructions, + channelContextText, + ].filter((text): text is string => !!text); + if (initialPrompt) { + initialPrompt.push( + ...supplementaryContext.map((text) => ({ + type: "text" as const, + text, + })), + ); } await this.step({ @@ -531,7 +559,9 @@ export class TaskCreationSaga extends Saga< await this.deps.piRunner.create({ taskId: task.id, cwd: agentCwd ?? "", - prompt: input.content ?? "", + prompt: [input.content, ...supplementaryContext] + .filter((text): text is string => !!text) + .join("\n\n"), model: input.model, thinkingLevel, }); @@ -708,6 +738,7 @@ export class TaskCreationSaga extends Saga< // deliver the first message without its attachments. private async prepareWarmActivation( input: TaskCreationInput, + alwaysOnSkillInstructions?: string, ): Promise { if (!input.content && !input.filePaths?.length) { return null; @@ -723,6 +754,7 @@ export class TaskCreationSaga extends Saga< const { pendingUserMessage, augmented } = buildCloudFirstMessage( transport.messageText, input, + alwaysOnSkillInstructions, ); const base: WarmActivationPayload = { transport, diff --git a/packages/core/src/task-detail/taskInput.ts b/packages/core/src/task-detail/taskInput.ts index 64544f9f32..45d2fbf9fc 100644 --- a/packages/core/src/task-detail/taskInput.ts +++ b/packages/core/src/task-detail/taskInput.ts @@ -35,6 +35,7 @@ export interface PrepareTaskInputOptions { channelId?: string; channelContextId?: string; customInstructions?: string; + alwaysOnSkills?: TaskCreationInput["alwaysOnSkills"]; autoPublishCloudRuns?: boolean; rtkEnabledCloud?: boolean; allowNoRepo?: boolean; @@ -85,6 +86,7 @@ export function prepareTaskInput( channelId: options.channelId, channelContextId: options.channelContextId, customInstructions: isCloud ? options.customInstructions : undefined, + alwaysOnSkills: options.alwaysOnSkills, allowNoRepo: options.allowNoRepo, importedMcpServers: isCloud ? options.importedMcpServers : undefined, relayedMcpServers: isCloud ? options.relayedMcpServers : undefined, diff --git a/packages/host-router/src/routers/skills.router.ts b/packages/host-router/src/routers/skills.router.ts index 75e9a94e8e..13bc26d8c5 100644 --- a/packages/host-router/src/routers/skills.router.ts +++ b/packages/host-router/src/routers/skills.router.ts @@ -14,6 +14,7 @@ import { readSkillFileInput, readSkillFileOutput, renameSkillFileInput, + renderAlwaysOnSkillsOutput, resolveSkillDependenciesInput, resolveSkillDependenciesOutput, saveSkillFileInput, @@ -54,6 +55,14 @@ export const skillsRouter = router({ .get(SKILLS_SERVICE) .resolveSkillBundleDependencies(input), ), + renderAlwaysOn: publicProcedure + .input(resolveSkillDependenciesInput) + .output(renderAlwaysOnSkillsOutput) + .query(({ ctx, input }) => + ctx.container + .get(SKILLS_SERVICE) + .renderAlwaysOnSkillInstructions(input), + ), contents: publicProcedure .input(skillContentsInput) .output(skillContentsOutput) diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 56ce3353c3..4e11bb6767 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -328,6 +328,7 @@ export type { SignalReportStatus, } from "./signal-types"; export type { + AlwaysOnSkillTarget, ExportedSkill, ExportedSkillFile, SkillFileEntry, @@ -336,6 +337,7 @@ export type { UploadableSkillSource, } from "./skills"; export { + getApplicableAlwaysOnSkills, SKILL_EXISTS_MARKER, serializeSkillMarkdown, stripFrontmatter, @@ -354,6 +356,7 @@ export { updateTaskAutomationSchema, } from "./task-automation"; export type { + AlwaysOnSkillRef, TaskCreationInput, TaskCreationOutput, } from "./task-creation-domain"; diff --git a/packages/shared/src/skills.test.ts b/packages/shared/src/skills.test.ts new file mode 100644 index 0000000000..5578a2a9e0 --- /dev/null +++ b/packages/shared/src/skills.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it } from "vitest"; +import type { SkillInfo } from "./skills"; +import { getApplicableAlwaysOnSkills } from "./skills"; + +const globalSkill: SkillInfo = { + name: "global", + description: "", + source: "user", + path: "/home/user/.claude/skills/global", + editable: true, + skillMdBytes: 10, +}; + +const repoSkill: SkillInfo = { + name: "repo", + description: "", + source: "repo", + path: "/repos/code/.claude/skills/repo", + repoName: "code", + repoPath: "/repos/code", + repository: "PostHog/code", + editable: true, + skillMdBytes: 10, +}; + +const preferences = [globalSkill, repoSkill].map( + ({ name, source, path, repoPath, repository }) => ({ + name, + source, + path, + repoPath, + repository, + }), +); + +describe("getApplicableAlwaysOnSkills", () => { + it("applies repo skills only to their local or cloud repository", () => { + expect( + getApplicableAlwaysOnSkills(preferences, [globalSkill, repoSkill], { + repoPath: "/repos/code", + }).applicable, + ).toEqual(preferences); + expect( + getApplicableAlwaysOnSkills(preferences, [globalSkill, repoSkill], { + repository: "posthog/code", + }).applicable, + ).toEqual(preferences); + expect( + getApplicableAlwaysOnSkills(preferences, [globalSkill, repoSkill], {}) + .applicable, + ).toEqual([preferences[0]]); + }); + + it("reports missing applicable skills without treating other repo skills as unavailable", () => { + const result = getApplicableAlwaysOnSkills(preferences, [], { + repoPath: "/repos/other", + }); + expect(result.applicable).toEqual([]); + expect(result.unavailable).toEqual([preferences[0]]); + }); + + it("honors per-task exclusions", () => { + const result = getApplicableAlwaysOnSkills( + preferences, + [globalSkill, repoSkill], + { repoPath: "/repos/code" }, + new Set([`user:${globalSkill.path}`]), + ); + expect(result.applicable).toEqual([preferences[1]]); + }); + + it("does not drop applicable skills while discovery is loading", () => { + expect( + getApplicableAlwaysOnSkills(preferences, undefined, { + repository: "posthog/code", + }).applicable, + ).toEqual(preferences); + }); +}); diff --git a/packages/shared/src/skills.ts b/packages/shared/src/skills.ts index 540b92eba5..993c8b7ce7 100644 --- a/packages/shared/src/skills.ts +++ b/packages/shared/src/skills.ts @@ -7,12 +7,71 @@ export interface SkillInfo { source: SkillSource; path: string; repoName?: string; + repoPath?: string; + repository?: string; /** Whether the skill lives in a directory we own on the user's behalf. */ editable: boolean; /** Size of SKILL.md in bytes (context-cost signal). */ skillMdBytes: number; } +export interface AlwaysOnSkillTarget { + repoPath?: string; + repository?: string | null; +} + +export function getApplicableAlwaysOnSkills< + T extends { + source: string; + path: string; + repoPath?: string; + repository?: string; + }, +>( + preferences: T[], + discoveredSkills: SkillInfo[] | undefined, + target: AlwaysOnSkillTarget, + excludedKeys: ReadonlySet = new Set(), +): { applicable: T[]; unavailable: T[] } { + const discoveredByKey = new Map( + discoveredSkills?.map((skill) => [ + `${skill.source}:${skill.path}`, + skill, + ]) ?? [], + ); + const normalizedRepoPath = target.repoPath?.replace(/[\\/]+$/, ""); + const normalizedRepository = target.repository?.toLowerCase(); + const applicable: T[] = []; + const unavailable: T[] = []; + + for (const preference of preferences) { + const key = `${preference.source}:${preference.path}`; + if (excludedKeys.has(key)) continue; + const discovered = discoveredByKey.get(key); + + if (preference.source === "repo") { + const belongsToLocalRepo = + !!normalizedRepoPath && + ((discovered?.repoPath ?? preference.repoPath) === normalizedRepoPath || + preference.path.startsWith(`${normalizedRepoPath}/.claude/skills/`) || + preference.path.startsWith( + `${normalizedRepoPath}\\.claude\\skills\\`, + )); + const belongsToCloudRepo = + !!normalizedRepository && + (discovered?.repository ?? preference.repository)?.toLowerCase() === + normalizedRepository; + if (!belongsToLocalRepo && !belongsToCloudRepo) continue; + } + + if (discovered || discoveredSkills === undefined) + applicable.push(preference); + else unavailable.push(preference); + } + + return { applicable, unavailable }; +} + export interface SkillFileEntry { /** Path relative to the skill directory, using "/" separators. */ path: string; diff --git a/packages/shared/src/task-creation-domain.ts b/packages/shared/src/task-creation-domain.ts index a3e9ca4644..ce68db8472 100644 --- a/packages/shared/src/task-creation-domain.ts +++ b/packages/shared/src/task-creation-domain.ts @@ -10,6 +10,14 @@ import type { import type { WorkspaceMode } from "./workspace"; import type { Workspace } from "./workspace-domain"; +export interface AlwaysOnSkillRef { + name: string; + source: "user" | "repo" | "marketplace" | "codex"; + path: string; + repoPath?: string; + repository?: string; +} + // Host-agnostic input/output for the task-creation flow. The renderer // TaskCreationSaga owns the orchestration; these are the plain data shapes its // consumers (inbox direct-create hooks, deep-link open, task-input) pass and @@ -83,6 +91,7 @@ export interface TaskCreationInput { * first message instead, to avoid double-injecting. */ customInstructions?: string; + alwaysOnSkills?: AlwaysOnSkillRef[]; /** * Local (~/.claude.json) MCP servers classified as importable, forwarded to * the cloud sandbox in the run-creation payload. Cloud-only; local sessions diff --git a/packages/ui/src/features/canvas/components/ChannelHomeComposer.tsx b/packages/ui/src/features/canvas/components/ChannelHomeComposer.tsx index 1d11900152..2948c8adf6 100644 --- a/packages/ui/src/features/canvas/components/ChannelHomeComposer.tsx +++ b/packages/ui/src/features/canvas/components/ChannelHomeComposer.tsx @@ -25,6 +25,12 @@ import { type AgentAdapter, useSettingsStore, } from "../../settings/settingsStore"; +import { useSkills } from "../../skills/useSkills"; +import { + AlwaysOnSkillChips, + UnavailableAlwaysOnSkills, + useAlwaysOnSkillSelection, +} from "../../task-detail/components/AlwaysOnSkillChips"; import { type WorkspaceMode, WorkspaceModeSelect, @@ -277,6 +283,17 @@ export const ChannelHomeComposer = forwardRef< // task-ready callback matches create order and keeps adds/removes balanced — // no row is ever orphaned, even if two creates briefly overlap. const pendingIdsRef = useRef([]); + const { data: skills } = useSkills(); + const { + includedSkills: includedAlwaysOnSkills, + unavailable: unavailableAlwaysOnSkills, + exclude: excludeAlwaysOnSkill, + reset: resetAlwaysOnSkillSelection, + } = useAlwaysOnSkillSelection({ + discoveredSkills: skills, + target: {}, + draftKey: `${sessionId}:${backendChannelId ?? channelId}`, + }); const handleTaskCreated = useCallback( (task: Task) => { @@ -310,6 +327,7 @@ export const ChannelHomeComposer = forwardRef< channelName, channelId: backendChannelId, channelContextId: channelId, + alwaysOnSkills: includedAlwaysOnSkills, onTaskCreated: handleTaskCreated, }); @@ -332,6 +350,7 @@ export const ChannelHomeComposer = forwardRef< onPendingStart({ id, prompt }); const created = await handleSubmit(content); + if (created) resetAlwaysOnSkillSelection(); if (!created) { // Creation failed — onTaskCreated never fired, so this id is still // queued. Pull its row and give the full structured prompt (chips and @@ -340,7 +359,13 @@ export const ChannelHomeComposer = forwardRef< onPendingEnd(id); editor.insertEditorContent(content); } - }, [canSubmit, handleSubmit, onPendingStart, onPendingEnd]); + }, [ + canSubmit, + handleSubmit, + onPendingStart, + onPendingEnd, + resetAlwaysOnSkillSelection, + ]); const handleModeChange = useCallback( (value: string) => { @@ -395,16 +420,30 @@ export const ChannelHomeComposer = forwardRef< and the trigger's own fill is translucent, so it carries an opaque backdrop at the button's radius to stop messages showing through. */} {!canvasArmed && ( -
- +
+
+ +
+ {(includedAlwaysOnSkills.length > 0 || + unavailableAlwaysOnSkills.length > 0) && ( +
+ Using: + + +
+ )}
)} diff --git a/packages/ui/src/features/scouts/components/ScoutHelperSkillLinks.tsx b/packages/ui/src/features/scouts/components/ScoutHelperSkillLinks.tsx index b7fe4d6f49..a64b3bae1c 100644 --- a/packages/ui/src/features/scouts/components/ScoutHelperSkillLinks.tsx +++ b/packages/ui/src/features/scouts/components/ScoutHelperSkillLinks.tsx @@ -15,7 +15,7 @@ const HELPER_SKILLS = [ /** One-line pointer to the two official scout helper skills, opened in-app. */ export function ScoutHelperSkillLinks({ surface }: { surface: ScoutSurface }) { - const { requestSkill } = useSkillsSelectionActions(); + const { requestSkillByName } = useSkillsSelectionActions(); return ( Helper skills:{" "} @@ -31,7 +31,7 @@ export function ScoutHelperSkillLinks({ surface }: { surface: ScoutSurface }) { surface, helper_skill: skill.label, }); - requestSkill(skill.name); + requestSkillByName(skill.name); }} className="text-accent-11 no-underline hover:text-accent-12" > diff --git a/packages/ui/src/features/sessions/sessionConfigStore.ts b/packages/ui/src/features/sessions/sessionConfigStore.ts index 45bc88d887..7f01a3a58f 100644 --- a/packages/ui/src/features/sessions/sessionConfigStore.ts +++ b/packages/ui/src/features/sessions/sessionConfigStore.ts @@ -4,7 +4,6 @@ import { create } from "zustand"; import { persist } from "zustand/middleware"; interface SessionConfigState { - /** Map of taskRunId -> persisted config options */ configsByRunId: Record; } @@ -40,7 +39,9 @@ export const useSessionConfigStore = create()( { name: "session-config-storage", storage: electronStorage, - partialize: (state) => ({ configsByRunId: state.configsByRunId }), + partialize: (state) => ({ + configsByRunId: state.configsByRunId, + }), }, ), ); diff --git a/packages/ui/src/features/settings/settingsStore.test.ts b/packages/ui/src/features/settings/settingsStore.test.ts index 917bf24fa7..d37e9557b8 100644 --- a/packages/ui/src/features/settings/settingsStore.test.ts +++ b/packages/ui/src/features/settings/settingsStore.test.ts @@ -599,3 +599,26 @@ describe("feature settingsStore hydration", () => { expect(useSettingsStore.getState()._hasHydrated).toBe(true); }); }); + +describe("feature settingsStore always-on skills", () => { + it("persists enabled skills and removes them by source and path", async () => { + await resetPersistenceMocks(); + useSettingsStore.setState({ alwaysOnSkills: [] }); + const skill = { + name: "i-have-adhd", + source: "user" as const, + path: "/home/u/.claude/skills/i-have-adhd", + }; + + useSettingsStore.getState().setSkillAlwaysOn(skill, true); + useSettingsStore.getState().setSkillAlwaysOn(skill, true); + expect(useSettingsStore.getState().alwaysOnSkills).toEqual([skill]); + + await waitForPersistedWrite(); + const lastCall = setItem.mock.calls[setItem.mock.calls.length - 1]; + expect(JSON.parse(lastCall[1]).state.alwaysOnSkills).toEqual([skill]); + + useSettingsStore.getState().setSkillAlwaysOn(skill, false); + expect(useSettingsStore.getState().alwaysOnSkills).toEqual([]); + }); +}); diff --git a/packages/ui/src/features/settings/settingsStore.ts b/packages/ui/src/features/settings/settingsStore.ts index 806000165e..97503d0cce 100644 --- a/packages/ui/src/features/settings/settingsStore.ts +++ b/packages/ui/src/features/settings/settingsStore.ts @@ -2,6 +2,7 @@ import type { UserRepositoryIntegrationRef } from "@posthog/core/integrations/re import type { Adapter, AgentRuntime, + AlwaysOnSkillRef, ExecutionMode, WorkspaceMode, } from "@posthog/shared"; @@ -95,6 +96,8 @@ export interface SyncedCustomInstructions { truncated: boolean; } +export type AlwaysOnSkillPreference = AlwaysOnSkillRef; + // ---------- Store shape ---------- interface SettingsStore { @@ -198,6 +201,7 @@ interface SettingsStore { // instead of the hand-typed customInstructions above. syncCustomInstructionsFromFile: boolean; syncedCustomInstructions: SyncedCustomInstructions | null; + alwaysOnSkills: AlwaysOnSkillPreference[]; setAutoConvertLongText: (value: AutoConvertLongText) => void; setSendMessagesWith: (mode: SendMessagesWith) => void; setCustomInstructions: (instructions: string) => void; @@ -205,6 +209,7 @@ interface SettingsStore { setSyncedCustomInstructions: ( synced: SyncedCustomInstructions | null, ) => void; + setSkillAlwaysOn: (skill: AlwaysOnSkillPreference, enabled: boolean) => void; // Diff viewer diffOpenMode: DiffOpenMode; @@ -432,6 +437,7 @@ export const useSettingsStore = create()( customInstructions: "", syncCustomInstructionsFromFile: false, syncedCustomInstructions: null, + alwaysOnSkills: [], setAutoConvertLongText: (value) => set({ autoConvertLongText: value }), setSendMessagesWith: (mode) => set({ sendMessagesWith: mode }), setCustomInstructions: (instructions) => @@ -440,6 +446,21 @@ export const useSettingsStore = create()( set({ syncCustomInstructionsFromFile: enabled }), setSyncedCustomInstructions: (synced) => set({ syncedCustomInstructions: synced }), + setSkillAlwaysOn: (skill, enabled) => + set((state) => ({ + alwaysOnSkills: enabled + ? [ + ...state.alwaysOnSkills.filter( + (item) => + item.source !== skill.source || item.path !== skill.path, + ), + skill, + ] + : state.alwaysOnSkills.filter( + (item) => + item.source !== skill.source || item.path !== skill.path, + ), + })), // Diff viewer diffOpenMode: "auto", @@ -597,6 +618,7 @@ export const useSettingsStore = create()( sendMessagesWith: state.sendMessagesWith, customInstructions: state.customInstructions, syncCustomInstructionsFromFile: state.syncCustomInstructionsFromFile, + alwaysOnSkills: state.alwaysOnSkills, // Diff viewer diffOpenMode: state.diffOpenMode, diff --git a/packages/ui/src/features/skills/SkillCard.tsx b/packages/ui/src/features/skills/SkillCard.tsx index 5f896404a9..ad1ad36ba0 100644 --- a/packages/ui/src/features/skills/SkillCard.tsx +++ b/packages/ui/src/features/skills/SkillCard.tsx @@ -11,6 +11,7 @@ import type { SkillIssue, } from "@posthog/core/skills/analyzeSkills"; import type { SkillInfo, SkillSource } from "@posthog/shared"; +import { useSettingsStore } from "@posthog/ui/features/settings/settingsStore"; import { Badge, Flex, Text, Tooltip } from "@radix-ui/themes"; import { useEffect, useRef } from "react"; import { SkillListCard } from "./SkillListCard"; @@ -54,6 +55,11 @@ export function SkillCard({ }: SkillCardProps) { const config = SOURCE_CONFIG[skill.source]; const Icon = config?.icon ?? Package; + const alwaysOn = useSettingsStore((state) => + state.alwaysOnSkills.some( + (item) => item.source === skill.source && item.path === skill.path, + ), + ); const ref = useRef(null); useEffect(() => { @@ -72,6 +78,11 @@ export function SkillCard({ onClick={onClick} trailing={ <> + {alwaysOn && ( + + Always on + + )} {issues.length > 0 && ( state.alwaysOnSkills); + const setSkillAlwaysOn = useSettingsStore((state) => state.setSkillAlwaysOn); + const alwaysOn = alwaysOnSkills.some( + (item) => item.source === skill.source && item.path === skill.path, + ); const files = contents?.files ?? []; const isSkillMd = selectedFile === "SKILL.md"; @@ -299,6 +306,48 @@ export function SkillDetailPanel({ )} + + + + Always on for new tasks + + + Apply this skill to every new local and cloud task + + + + + + setSkillAlwaysOn( + { + name: skill.name, + source: skill.source as Exclude< + typeof skill.source, + "bundled" + >, + path: skill.path, + ...(skill.repoPath ? { repoPath: skill.repoPath } : {}), + ...(skill.repository + ? { repository: skill.repository } + : {}), + }, + checked, + ) + } + /> + + + + {issues.length > 0 && ( {issues.map((issue) => ( diff --git a/packages/ui/src/features/skills/SkillsView.tsx b/packages/ui/src/features/skills/SkillsView.tsx index 371dce5002..11b4d9a007 100644 --- a/packages/ui/src/features/skills/SkillsView.tsx +++ b/packages/ui/src/features/skills/SkillsView.tsx @@ -17,6 +17,7 @@ import { NewSkillDialog } from "./NewSkillDialog"; import { SkillSection, SOURCE_CONFIG } from "./SkillCard"; import { SkillDetailPanel } from "./SkillDetailPanel"; import { + useRequestedSkill, useRequestedSkillName, useSkillsSelectionActions, } from "./skillsSelectionStore"; @@ -73,16 +74,23 @@ export function SkillsView() { // Another surface (e.g. the scout helper links) can ask to open a specific // skill by name; honor it once the skill list has loaded, then clear it. const requestedSkillName = useRequestedSkillName(); + const requestedSkill = useRequestedSkill(); const { clearRequestedSkill } = useSkillsSelectionActions(); useEffect(() => { - if (!requestedSkillName || skills.length === 0) return; - const match = skills.find((s) => s.name === requestedSkillName); + if ((!requestedSkill && !requestedSkillName) || skills.length === 0) return; + const match = requestedSkill + ? skills.find( + (skill) => + skill.source === requestedSkill.source && + skill.path === requestedSkill.path, + ) + : skills.find((skill) => skill.name === requestedSkillName); if (match) { setSelectedPath(match.path); setScrollToPath(match.path); } clearRequestedSkill(); - }, [requestedSkillName, skills, clearRequestedSkill]); + }, [requestedSkill, requestedSkillName, skills, clearRequestedSkill]); const handleScrolledIntoView = useCallback(() => setScrollToPath(null), []); diff --git a/packages/ui/src/features/skills/skillsSelectionStore.ts b/packages/ui/src/features/skills/skillsSelectionStore.ts index 5a6c7c792b..cac4ea0970 100644 --- a/packages/ui/src/features/skills/skillsSelectionStore.ts +++ b/packages/ui/src/features/skills/skillsSelectionStore.ts @@ -7,10 +7,12 @@ interface SkillsSelectionState { * clears it so a later plain visit to /skills opens nothing. */ requestedSkillName: string | null; + requestedSkill: { source: string; path: string } | null; } interface SkillsSelectionActions { - requestSkill: (name: string) => void; + requestSkill: (skill: { source: string; path: string }) => void; + requestSkillByName: (name: string) => void; clearRequestedSkill: () => void; } @@ -20,12 +22,16 @@ type SkillsSelectionStore = SkillsSelectionState & { const useStore = create((set) => ({ requestedSkillName: null, + requestedSkill: null, actions: { - requestSkill: (name) => set({ requestedSkillName: name }), - clearRequestedSkill: () => set({ requestedSkillName: null }), + requestSkill: (skill) => set({ requestedSkill: skill }), + requestSkillByName: (name) => set({ requestedSkillName: name }), + clearRequestedSkill: () => + set({ requestedSkill: null, requestedSkillName: null }), }, })); export const useRequestedSkillName = () => useStore((s) => s.requestedSkillName); +export const useRequestedSkill = () => useStore((s) => s.requestedSkill); export const useSkillsSelectionActions = () => useStore((s) => s.actions); diff --git a/packages/ui/src/features/task-detail/components/AlwaysOnSkillChips.tsx b/packages/ui/src/features/task-detail/components/AlwaysOnSkillChips.tsx new file mode 100644 index 0000000000..6a011ac52e --- /dev/null +++ b/packages/ui/src/features/task-detail/components/AlwaysOnSkillChips.tsx @@ -0,0 +1,119 @@ +import { Lightbulb, Warning, X } from "@phosphor-icons/react"; +import { + type AlwaysOnSkillRef, + type AlwaysOnSkillTarget, + getApplicableAlwaysOnSkills, + type SkillInfo, +} from "@posthog/shared"; +import { openSettings } from "@posthog/ui/features/settings/hooks/useOpenSettings"; +import { useSettingsStore } from "@posthog/ui/features/settings/settingsStore"; +import { useSkillsSelectionActions } from "@posthog/ui/features/skills/skillsSelectionStore"; +import { Tooltip } from "@radix-ui/themes"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; + +export function useAlwaysOnSkillSelection({ + discoveredSkills, + target, + draftKey, +}: { + discoveredSkills: SkillInfo[] | undefined; + target: AlwaysOnSkillTarget; + draftKey: string; +}) { + const alwaysOnSkills = useSettingsStore((state) => state.alwaysOnSkills); + const [excludedKeys, setExcludedKeys] = useState(() => new Set()); + const { applicable: includedSkills, unavailable } = useMemo( + () => + getApplicableAlwaysOnSkills( + alwaysOnSkills, + discoveredSkills, + target, + excludedKeys, + ), + [alwaysOnSkills, discoveredSkills, target, excludedKeys], + ); + const exclude = useCallback((skill: AlwaysOnSkillRef) => { + setExcludedKeys((current) => { + const next = new Set(current); + next.add(`${skill.source}:${skill.path}`); + return next; + }); + }, []); + const reset = useCallback(() => setExcludedKeys(new Set()), []); + const previousDraftKey = useRef(draftKey); + useEffect(() => { + if (previousDraftKey.current === draftKey) return; + previousDraftKey.current = draftKey; + reset(); + }, [draftKey, reset]); + + return { includedSkills, unavailable, excludedKeys, exclude, reset }; +} + +export function AlwaysOnSkillChips({ + skills, + onExclude, + disabled = false, +}: { + skills: AlwaysOnSkillRef[]; + onExclude: (skill: AlwaysOnSkillRef) => void; + disabled?: boolean; +}) { + const { requestSkill } = useSkillsSelectionActions(); + const openSkill = useCallback( + (skill: AlwaysOnSkillRef) => { + requestSkill({ source: skill.source, path: skill.path }); + openSettings("skills"); + }, + [requestSkill], + ); + + return skills.map((skill) => ( + + + + + + + + + )); +} + +export function UnavailableAlwaysOnSkills({ + skills, +}: { + skills: AlwaysOnSkillRef[]; +}) { + if (skills.length === 0) return null; + const names = skills.map((skill) => skill.name).join(", "); + return ( + + + + {skills.length === 1 + ? `${skills[0].name} unavailable` + : `${skills.length} skills unavailable`} + + + ); +} diff --git a/packages/ui/src/features/task-detail/components/TaskInput.tsx b/packages/ui/src/features/task-detail/components/TaskInput.tsx index 2c0a9a8dc8..c8dab58b73 100644 --- a/packages/ui/src/features/task-detail/components/TaskInput.tsx +++ b/packages/ui/src/features/task-detail/components/TaskInput.tsx @@ -97,6 +97,11 @@ import { useTaskCreation } from "../hooks/useTaskCreation"; import { useWarmTask } from "../hooks/useWarmTask"; import { resolveWorkspaceModePreference } from "../hooks/workspaceModePreference"; import { AgentRuntimeSelect } from "./AgentRuntimeSelect"; +import { + AlwaysOnSkillChips, + UnavailableAlwaysOnSkills, + useAlwaysOnSkillSelection, +} from "./AlwaysOnSkillChips"; import { CloudGithubMissingNotice } from "./CloudGithubMissingNotice"; import { NewTaskSuggestions } from "./ContinueCliSessions"; import { @@ -718,6 +723,19 @@ export function TaskInput({ const effectiveRepoPath = workspaceMode === "cloud" ? selectedCloudRepository : selectedDirectory; + const { + includedSkills: includedAlwaysOnSkills, + unavailable: unavailableAlwaysOnSkills, + exclude: excludeAlwaysOnSkill, + reset: resetAlwaysOnSkillSelection, + } = useAlwaysOnSkillSelection({ + discoveredSkills: skills, + target: + workspaceMode === "cloud" + ? { repository: selectedCloudRepository } + : { repoPath: selectedDirectory }, + draftKey: `${sessionId}:${channelId ?? ""}:${effectiveRepoPath ?? ""}`, + }); const setSelectedEnvironment = useCallback( (envId: string | null) => { @@ -918,7 +936,7 @@ export function TaskInput({ const { isCreatingTask, canSubmit, - handleSubmit, + handleSubmit: createTask, additionalDirectories, setAdditionalDirectories, } = useTaskCreation({ @@ -953,9 +971,19 @@ export function TaskInput({ channelName, channelId, channelContextId, + alwaysOnSkills: includedAlwaysOnSkills, allowNoRepo, }); + const handleSubmit = useCallback( + async (contentOverride?: EditorContent) => { + const submitted = await createTask(contentOverride); + if (submitted) resetAlwaysOnSkillSelection(); + return submitted; + }, + [createTask, resetAlwaysOnSkillSelection], + ); + // Wraps the prompt in the autoresearch kickoff: protocol preamble first, // the user's composer content (chips intact) as the optimization brief. const handleAutoresearchSubmit = useCallback(async (): Promise => { @@ -1477,42 +1505,55 @@ export function TaskInput({
)} - {includeChannelContext && ( + {(includeChannelContext || + includedAlwaysOnSkills.length > 0 || + unavailableAlwaysOnSkills.length > 0) && (
Using: - - {onContextChipClick ? ( - - + + ) : ( + <> {channelName ? `#${channelName} ` : ""}CONTEXT.md + + )} + + - ) : ( - <> - - - {channelName ? `#${channelName} ` : ""}CONTEXT.md - - - )} - - - - + + )} + +
)} {effectiveWorkspaceMode === "cloud" && diff --git a/packages/ui/src/features/task-detail/hooks/useTaskCreation.ts b/packages/ui/src/features/task-detail/hooks/useTaskCreation.ts index 9bbfaf88cd..418b9b8e7e 100644 --- a/packages/ui/src/features/task-detail/hooks/useTaskCreation.ts +++ b/packages/ui/src/features/task-detail/hooks/useTaskCreation.ts @@ -14,6 +14,7 @@ import { useHostTRPC, useHostTRPCClient } from "@posthog/host-router/react"; import { type Adapter, type AgentRuntime, + type AlwaysOnSkillRef, ANALYTICS_EVENTS, PROJECT_BLUEBIRD_FLAG, type TaskCreationInput, @@ -96,6 +97,7 @@ interface UseTaskCreationOptions { * injected context address CONTEXT.md upkeep writes by a stable id. */ channelContextId?: string; + alwaysOnSkills?: AlwaysOnSkillRef[]; /** * Channels "generic chat box" mode: drop the repo/branch requirement so a * task can be submitted without picking a repo. The agent decides at runtime @@ -194,6 +196,7 @@ export function useTaskCreation({ channelName, channelId, channelContextId, + alwaysOnSkills = [], allowNoRepo, onTaskCreated, onTaskCreatedEffect, @@ -323,7 +326,7 @@ export function useTaskCreation({ const plainPromptText = contentToPlainText(content).trim(); const serializedContent = contentToXml(content).trim(); const filePaths = extractFilePaths(content); - + const settings = useSettingsStore.getState(); const shouldShowPendingView = !onTaskCreated && !!plainPromptText; const pendingTaskKey = shouldShowPendingView ? generatePendingTaskKey() @@ -353,7 +356,6 @@ export function useTaskCreation({ } } - const settings = useSettingsStore.getState(); const defaultedChannelId = bluebirdEnabled && !channelId && !channelName ? personalChannel?.id @@ -391,6 +393,7 @@ export function useTaskCreation({ channelId: channelId ?? defaultedChannelId, channelContextId, customInstructions: getEffectiveCustomInstructions(settings), + alwaysOnSkills, autoPublishCloudRuns: settings.autoPublishCloudRuns, rtkEnabledCloud: settings.rtkEnabledCloud, allowNoRepo, @@ -567,6 +570,7 @@ export function useTaskCreation({ channelName, channelId, channelContextId, + alwaysOnSkills, allowNoRepo, bluebirdEnabled, personalChannel?.id, diff --git a/packages/ui/src/features/task-detail/taskCreationHostImpl.ts b/packages/ui/src/features/task-detail/taskCreationHostImpl.ts index 6862976fe6..5b11b398a3 100644 --- a/packages/ui/src/features/task-detail/taskCreationHostImpl.ts +++ b/packages/ui/src/features/task-detail/taskCreationHostImpl.ts @@ -156,6 +156,13 @@ export class TrpcTaskCreationHost implements ITaskCreationHost { ); } + async renderAlwaysOnSkillInstructions( + skills: Parameters[0], + ): Promise { + const rendered = await hostClient().skills.renderAlwaysOn.query(skills); + return rendered.instructions; + } + takeWarmTaskLease(args: { repository: string; branch?: string | null; diff --git a/packages/workspace-server/src/services/skills/schemas.ts b/packages/workspace-server/src/services/skills/schemas.ts index 7c08afadd5..7489e604a4 100644 --- a/packages/workspace-server/src/services/skills/schemas.ts +++ b/packages/workspace-server/src/services/skills/schemas.ts @@ -15,6 +15,8 @@ export const skillInfo = z.object({ source: skillSource, path: z.string(), repoName: z.string().optional(), + repoPath: z.string().optional(), + repository: z.string().optional(), editable: z.boolean(), skillMdBytes: z.number(), }); @@ -136,6 +138,15 @@ export const bundleLocalSkillOutput = z.object({ export const resolveSkillDependenciesInput = z.array(bundleLocalSkillInput); export const resolveSkillDependenciesOutput = z.array(bundleLocalSkillInput); +export const renderAlwaysOnSkillsOutput = z.object({ + instructions: z.string().optional(), + failures: z.array( + z.object({ + skill: bundleLocalSkillInput, + error: z.string(), + }), + ), +}); export type BundleLocalSkillInput = z.infer; export type BundleLocalSkillOutput = z.infer; diff --git a/packages/workspace-server/src/services/skills/skill-discovery.ts b/packages/workspace-server/src/services/skills/skill-discovery.ts index 7bb0f7c679..bb6a512fa5 100644 --- a/packages/workspace-server/src/services/skills/skill-discovery.ts +++ b/packages/workspace-server/src/services/skills/skill-discovery.ts @@ -170,7 +170,7 @@ export async function listSkillFiles( export async function readSkillMetadataFromDir( skillsDir: string, source: SkillSource, - repoName?: string, + repo?: string | { name: string; path: string; repository?: string }, ): Promise { const skillNames = await findSkillDirs(skillsDir); if (skillNames.length === 0) return []; @@ -189,7 +189,15 @@ export async function readSkillMetadataFromDir( description: frontmatter?.description ?? "", source, path: skillPath, - ...(repoName ? { repoName } : {}), + ...(typeof repo === "string" + ? { repoName: repo } + : repo + ? { + repoName: repo.name, + repoPath: repo.path, + ...(repo.repository ? { repository: repo.repository } : {}), + } + : {}), editable: isEditableSource(source), skillMdBytes: Buffer.byteLength(content, "utf-8"), } satisfies SkillInfo; diff --git a/packages/workspace-server/src/services/skills/skills.test.ts b/packages/workspace-server/src/services/skills/skills.test.ts index 8feeb6a074..88f840b810 100644 --- a/packages/workspace-server/src/services/skills/skills.test.ts +++ b/packages/workspace-server/src/services/skills/skills.test.ts @@ -911,3 +911,38 @@ describe("resolveSkillBundleDependencies", () => { ).rejects.toThrow(/exceeds the 50-skill limit/); }); }); + +describe("renderAlwaysOnSkillInstructions", () => { + it("renders validated skills in canonical order without frontmatter", async () => { + const first = await createSkill(repoSkillsDir, "first"); + const second = await createSkill(repoSkillsDir, "second"); + + const rendered = await makeService().renderAlwaysOnSkillInstructions([ + { name: "second", source: "repo", path: second }, + { name: "first", source: "repo", path: first }, + ]); + + expect(rendered.instructions?.indexOf("## first")).toBeLessThan( + rendered.instructions?.indexOf("## second") ?? -1, + ); + expect(rendered.instructions).not.toContain("description: about first"); + expect(rendered.failures).toEqual([]); + }); + + it("renders readable skills and reports unreadable skills separately", async () => { + const readable = await createSkill(repoSkillsDir, "readable"); + + const rendered = await makeService().renderAlwaysOnSkillInstructions([ + { + name: "missing", + source: "repo", + path: path.join(repoSkillsDir, "missing"), + }, + { name: "readable", source: "repo", path: readable }, + ]); + + expect(rendered.instructions).toContain("## readable"); + expect(rendered.failures).toHaveLength(1); + expect(rendered.failures[0]?.skill.name).toBe("missing"); + }); +}); diff --git a/packages/workspace-server/src/services/skills/skills.ts b/packages/workspace-server/src/services/skills/skills.ts index 039a875935..884427388a 100644 --- a/packages/workspace-server/src/services/skills/skills.ts +++ b/packages/workspace-server/src/services/skills/skills.ts @@ -1,6 +1,10 @@ import * as fs from "node:fs"; import * as path from "node:path"; -import { SKILL_EXISTS_MARKER, stripFrontmatter } from "@posthog/shared"; +import { + normalizeRepoKey, + SKILL_EXISTS_MARKER, + stripFrontmatter, +} from "@posthog/shared"; import { inject, injectable } from "inversify"; import { WATCHER_SERVICE } from "../../di/tokens"; import type { FoldersService } from "../folders/folders"; @@ -56,7 +60,7 @@ const SKILL_MD_TEMPLATE_BODY = `Explain when this skill applies and how to use i interface SkillRoot { dir: string; source: SkillSource; - repoName?: string; + repo?: { name: string; path: string; repository?: string }; } @injectable() @@ -74,7 +78,7 @@ export class SkillsService { const roots = await this.getSkillRoots(); const results = await Promise.all( roots.map((root) => - readSkillMetadataFromDir(root.dir, root.source, root.repoName), + readSkillMetadataFromDir(root.dir, root.source, root.repo), ), ); const skills = results.flat(); @@ -417,7 +421,11 @@ export class SkillsService { ...folders.map((f) => ({ dir: path.join(f.path, ".claude", "skills"), source: "repo" as const, - repoName: f.name, + repo: { + name: f.name, + path: f.path, + ...(f.remoteUrl ? { repository: normalizeRepoKey(f.remoteUrl) } : {}), + }, })), ...marketplacePaths.map((p) => ({ dir: path.join(p, "skills"), @@ -509,6 +517,51 @@ export class SkillsService { }); } + async renderAlwaysOnSkillInstructions(refs: SkillBundleRef[]): Promise<{ + instructions?: string; + failures: { skill: SkillBundleRef; error: string }[]; + }> { + const results = await Promise.all( + [...refs] + .sort((left, right) => + `${left.source}:${left.name}`.localeCompare( + `${right.source}:${right.name}`, + ), + ) + .map(async (ref) => { + try { + const skillDir = await this.resolveKnownSkillDir(ref.path); + const manifest = await fs.promises.readFile( + path.join(skillDir, "SKILL.md"), + "utf-8", + ); + return { + block: `## ${ref.name}\n\n${stripFrontmatter(manifest).trim()}`, + }; + } catch (error) { + return { + failure: { + skill: ref, + error: error instanceof Error ? error.message : String(error), + }, + }; + } + }), + ); + const blocks = results.flatMap((result) => + result.block ? [result.block] : [], + ); + return { + instructions: + blocks.length > 0 + ? `\nThe user has configured these skills to apply to this task. Follow their instructions.\n\n${blocks.join("\n\n---\n\n")}\n` + : undefined, + failures: results.flatMap((result) => + result.failure ? [result.failure] : [], + ), + }; + } + /** * A repository can commit any ancestor of its skills (`.claude` or * `.claude/skills`) as a symlink pointing outside the repo, which passes the