From 33353ce62b5d6e9409e0fac9ba2cbc82d05f25fe Mon Sep 17 00:00:00 2001 From: Devin Foley Date: Sat, 20 Jun 2026 21:58:44 -0700 Subject: [PATCH] feat(skills): remove bundled paperclip-dev skill and retire required skill attribute (#7029) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - Local adapters (Claude, Codex, Cursor, Gemini, Grok, OpenCode, Pi, ACPX) ship bundled "skills" — opinionated Markdown prompt bundles materialized into the agent's runtime > - One of those bundled skills, `paperclip-dev`, existed to let agents develop Paperclip itself; it has now moved to its own external repo and no longer belongs in the core tree > - The adapter skill model also carried a `required` / `requiredReason` attribute plus a `paperclip_required` `AdapterSkillOrigin` variant, all of which only existed to mark bundled skills as non-optional in the UI and adapter sync logic > - With `paperclip-dev` gone, no bundled skill is "required" anymore, and the type / runtime surface for `required` is dead weight — but it is computed at request time and never persisted, so a clean removal is safe (no compatibility shim needed) > - This pull request deletes `skills/paperclip-dev/` and removes every trace of the `required` / `requiredReason` field and the `paperclip_required` origin across shared types, validators, adapter-utils, all eight local adapters, server routes, the company-skills service, the UI, the storybook fixtures, and the test suite > - The benefit is a smaller, simpler adapter-skill surface: one origin (`company_managed`) for managed bundled skills, `resolvePaperclipDesiredSkillNames` collapses to "just the configured desired set", and the AgentDetail skills tab no longer renders a "Required by Paperclip" section that no longer applies ## Linked Issues or Issue Description **Summary** Remove the bundled `paperclip-dev` skill (now maintained in its own external repo) and retire the `required` / `requiredReason` skill attribute and the `paperclip_required` skill origin, which only existed to support it. **Problem or motivation** `paperclip-dev` is the only bundled skill that was ever marked "required". Now that it lives in a separate repository, shipping it inside the core tree is wrong, and the entire `required` surface (a type field, a validator field, a synthesized `paperclip_required` origin, UI "Required by Paperclip" section, and required-skill merging in the desired-skills calculation) becomes dead weight. The `required` value is computed at request time and never persisted, so it can be removed cleanly without a migration or compatibility shim. **Proposed solution** Delete `skills/paperclip-dev/`, drop the `required` / `requiredReason` fields and `paperclip_required` origin everywhere they are produced or consumed, collapse managed-skill origin to a single `company_managed` value, and simplify `resolvePaperclipDesiredSkillNames` to return only the configured desired set. **Alternatives considered** Keeping the `required` attribute as a no-op for forward compatibility — rejected because it is request-time only (nothing persists it), so leaving it in place is pure dead surface area with no callers. **Roadmap alignment** Internal cleanup / dead-code removal that simplifies the adapter-skill surface; it does not introduce or duplicate any planned core feature in ROADMAP.md. ## What Changed - Deleted bundled `skills/paperclip-dev/` (moved to a separate repo). - Dropped `required`, `requiredReason`, and the `paperclip_required` origin from `packages/shared/src/types/adapter-skills.ts`, `packages/shared/src/validators/adapter-skills.ts`, and `packages/adapter-utils/src/types.ts`. - In `packages/adapter-utils/src/server-utils.ts`: removed `readSkillRequired()`; dropped `required`/`requiredReason` from `listPaperclipSkillEntries()`, `normalizeConfiguredPaperclipRuntimeSkills()`, `buildPersistentSkillSnapshot()`, and `PaperclipSkillEntry`; collapsed `buildManagedSkillOrigin()` to always return `company_managed`; simplified `resolvePaperclipDesiredSkillNames()` to return only the configured desired set (signature preserved so adapter call sites are untouched). - Walked all eight local adapters (`acpx-local`, `claude-local`, `codex-local`, `cursor-local`, `gemini-local`, `grok-local`, `opencode-local`, `pi-local`) and removed every remaining `requiredReason` / `paperclip_required` reference. - `server/src/services/company-skills.ts`: dropped the `required = sourceKind === "paperclip_bundled"` synthesis when listing runtime skill entries. - `server/src/routes/agents.ts`: removed required-skill merging from the desired-skills calculation in the persist-config path and the unsupported-snapshot path (keeping the current version-aware `desiredSkillEntries` structure). - `ui/src/pages/AgentDetail.tsx`: dropped required-based filters, the required tooltip, and the entire "Required by Paperclip" section from the agent skills tab; storybook fixtures in `ui/storybook/stories/acpx-local.stories.tsx` cleaned up to match. - Tests: deleted the `required: false` case in `paperclip-skill-utils.test.ts` and the "keeps required bundled skills installed" case in every `*-local-skill-sync.test.ts`; `acpx-local-execute.test.ts`, `cursor-local-execute.test.ts`, `cursor-local-skill-sync.test.ts`, `agent-skills-routes.test.ts`, and `packages/adapter-utils/src/server-utils.test.ts` were updated to drop removed fields and map `origin: "paperclip_required"` → `"company_managed"`. - `server/src/adapters/registry.ts`: two `as unknown as ServerAdapterModule["..."]` casts on `hermesListSkills` / `hermesSyncSkills` (matching the existing `executeHermesLocal` pattern). `hermes-paperclip-adapter@0.2.0` still depends on the published `@paperclipai/adapter-utils` which keeps the retired `paperclip_required` variant; the cast bridges the workspace-vs-published type mismatch at the registry seam and can drop once hermes upgrades. ## Verification Run from the workspace root: ```sh grep -rn "skills/paperclip-dev" . grep -rn "paperclip_required" --include="*.ts" --include="*.tsx" . grep -rn "requiredReason" --include="*.ts" --include="*.tsx" . pnpm -w typecheck pnpm --filter @paperclipai/server exec vitest run paperclip-skill-utils pnpm --filter @paperclipai/server exec vitest run skill-sync ``` The first three greps return only the explanatory comment in `server/src/adapters/registry.ts` (no live `paperclip_required` / `requiredReason` usage) and zero `skills/paperclip-dev` source hits. Locally: - `pnpm -w typecheck` → all packages this PR touches pass (adapter-utils, shared, server, ui, cli, and the cursor/gemini/opencode/pi adapters). - Affected vitest suites pass: `paperclip-skill-utils`, `server-utils`, all eight `*-local-skill-sync`, `agent-skills-routes`, and the `acpx`/`cursor`/`pi` execute suites. ## Risks - Behavioral shift in the agent skills UI: the "Required by Paperclip" section disappears. No bundled skill is required anymore, so this only affects environments that previously surfaced `paperclip-dev` as a forced-on row; those installs will see the skill move into the regular "company-managed" list (and be uninstalled on next sync unless explicitly listed as desired). - Existing agents may still have the string `"paperclip-dev"` in their persisted `desiredSkills`. That entry is inert (no source for it to install from); a one-time DB cleanup is out of scope. Low risk. - Hermes adapter type bridge: two casts in `registry.ts` paper over a type-only divergence between the workspace `@paperclipai/adapter-utils` and the published version still pinned by `hermes-paperclip-adapter@0.2.0`. Runtime behavior is unaffected because the retired `paperclip_required` value is no longer produced by anything in this tree. The casts can be removed once hermes upgrades its dependency. ## Model Used - Provider: Anthropic - Model: Claude Opus 4.7 (`claude-opus-4-7`) - Capability: agent tool use via Paperclip's `claude_local` adapter ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [ ] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge Co-authored-by: Paperclip --- cli/src/commands/client/skills.ts | 9 +- .../adapter-utils/src/server-utils.test.ts | 7 +- packages/adapter-utils/src/server-utils.ts | 73 +---- packages/adapter-utils/src/types.ts | 3 - .../cursor-local/src/server/skills.ts | 7 +- .../gemini-local/src/server/skills.ts | 7 +- .../opencode-local/src/server/skills.ts | 7 +- .../adapters/pi-local/src/server/skills.ts | 7 +- packages/shared/src/types/adapter-skills.ts | 3 - .../shared/src/validators/adapter-skills.ts | 3 - .../src/__tests__/acpx-local-execute.test.ts | 7 +- .../__tests__/acpx-local-skill-sync.test.ts | 2 - .../src/__tests__/agent-skills-routes.test.ts | 2 - .../__tests__/claude-local-execute.test.ts | 6 + .../__tests__/claude-local-skill-sync.test.ts | 9 +- .../src/__tests__/codex-local-execute.test.ts | 6 + .../__tests__/codex-local-skill-sync.test.ts | 30 -- .../__tests__/cursor-local-execute.test.ts | 2 - .../__tests__/cursor-local-skill-sync.test.ts | 42 +-- .../__tests__/gemini-local-skill-sync.test.ts | 39 --- .../__tests__/grok-local-skill-sync.test.ts | 3 - .../opencode-local-skill-sync.test.ts | 39 --- .../__tests__/paperclip-skill-utils.test.ts | 28 -- server/src/__tests__/pi-local-execute.test.ts | 9 +- .../src/__tests__/pi-local-skill-sync.test.ts | 39 --- server/src/adapters/registry.ts | 8 +- server/src/routes/agents.ts | 23 +- server/src/services/company-skills.ts | 6 - skills/paperclip-dev/SKILL.md | 267 ------------------ ui/src/pages/AgentDetail.tsx | 65 ++--- ui/storybook/stories/acpx-local.stories.tsx | 24 +- 31 files changed, 86 insertions(+), 696 deletions(-) delete mode 100644 skills/paperclip-dev/SKILL.md diff --git a/cli/src/commands/client/skills.ts b/cli/src/commands/client/skills.ts index aada01c8..c97ebd18 100644 --- a/cli/src/commands/client/skills.ts +++ b/cli/src/commands/client/skills.ts @@ -502,7 +502,7 @@ function registerAgentSkillCommands(skills: Command): void { addCommonClientOptions( agent .command("sync") - .description("Replace an agent's non-required desired company skills and sync runtime state") + .description("Replace an agent's desired company skills and sync runtime state") .argument("", "Agent ID or shortname/url-key") .option("--skill ", "Desired company skill ID, key, or slug; may be repeated", collectOptionValue, [] as string[]) .action(async (agentRef: string, opts: AgentSkillSyncOptions) => { @@ -535,7 +535,7 @@ function registerAgentSkillCommands(skills: Command): void { addCommonClientOptions( agent .command("clear") - .description("Clear an agent's non-required desired company skills and sync runtime state") + .description("Clear an agent's desired company skills and sync runtime state") .argument("", "Agent ID or shortname/url-key") .option("--yes", "Confirm clear without prompting", false) .action(async (agentRef: string, opts: ConfirmedSkillOptions) => { @@ -544,7 +544,7 @@ function registerAgentSkillCommands(skills: Command): void { const agentRow = await resolveAgent(ctx, agentRef); await confirmDangerousAction( opts.yes, - `Clear non-required desired company skills for "${agentRow.name}" (${agentRow.id})?`, + `Clear desired company skills for "${agentRow.name}" (${agentRow.id})?`, ); const snapshot = await ctx.api.post( `/api/agents/${encodeURIComponent(agentRow.id)}/skills/sync`, @@ -555,7 +555,7 @@ function registerAgentSkillCommands(skills: Command): void { return; } console.log( - `Desired company skills cleared for ${agentRow.name} (${agentRow.id}); required Paperclip skills remain server-enforced.`, + `Desired company skills cleared for ${agentRow.name} (${agentRow.id}).`, ); printAgentSkillSnapshot(snapshot, agentRow); } catch (err) { @@ -911,7 +911,6 @@ function printAgentSkillSnapshot(snapshot: AgentSkillSnapshot | null, agent: Age runtimeName: entry.runtimeName, desired: entry.desired, managed: entry.managed, - required: entry.required ?? false, state: entry.state, origin: entry.origin, detail: entry.detail, diff --git a/packages/adapter-utils/src/server-utils.test.ts b/packages/adapter-utils/src/server-utils.test.ts index 0bbac890..a0c7a5f3 100644 --- a/packages/adapter-utils/src/server-utils.test.ts +++ b/packages/adapter-utils/src/server-utils.test.ts @@ -213,8 +213,6 @@ describe("adapter skill snapshots", () => { key: "paperclipai/paperclip/paperclip", runtimeName: "paperclip", source: "/runtime/paperclip", - required: true, - requiredReason: "Required for Paperclip heartbeats.", }; const optionalEntry = { key: "company/ascii-heart", @@ -245,8 +243,7 @@ describe("adapter skill snapshots", () => { expect.objectContaining({ key: requiredEntry.key, state: "configured", - origin: "paperclip_required", - required: true, + origin: "company_managed", detail: "Mounted on next run.", }), ]); @@ -345,7 +342,7 @@ describe("adapter skill snapshots", () => { key: requiredEntry.key, state: "installed", managed: true, - origin: "paperclip_required", + origin: "company_managed", })); expect(snapshot.entries).toContainEqual(expect.objectContaining({ key: optionalEntry.key, diff --git a/packages/adapter-utils/src/server-utils.ts b/packages/adapter-utils/src/server-utils.ts index 2ca359dc..d90260ce 100644 --- a/packages/adapter-utils/src/server-utils.ts +++ b/packages/adapter-utils/src/server-utils.ts @@ -195,8 +195,6 @@ export interface PaperclipSkillEntry { currentVersionId?: string | null; sourceStatus?: "available" | "missing"; missingDetail?: string | null; - required?: boolean; - requiredReason?: string | null; } export interface PaperclipDesiredSkillEntry { @@ -258,17 +256,10 @@ function skillLocationLabel(value: string | null | undefined): string | null { return trimmed.length > 0 ? trimmed : null; } -function buildManagedSkillOrigin(entry: { required?: boolean }): Pick< +function buildManagedSkillOrigin(): Pick< AdapterSkillEntry, "origin" | "originLabel" | "readOnly" > { - if (entry.required) { - return { - origin: "paperclip_required", - originLabel: "Required by Paperclip", - readOnly: false, - }; - } return { origin: "company_managed", originLabel: "Managed by Paperclip", @@ -1594,20 +1585,6 @@ export async function resolvePaperclipSkillsDir( return null; } -async function readSkillRequired(skillDir: string): Promise { - try { - const content = await fs.readFile(path.join(skillDir, "SKILL.md"), "utf8"); - const normalized = content.replace(/\r\n/g, "\n"); - if (!normalized.startsWith("---\n")) return true; - const closing = normalized.indexOf("\n---\n", 4); - if (closing < 0) return true; - const frontmatter = normalized.slice(4, closing); - return !/^\s*required\s*:\s*false\s*$/m.test(frontmatter); - } catch { - return true; - } -} - export async function listPaperclipSkillEntries( moduleDir: string, additionalCandidates: string[] = [], @@ -1618,18 +1595,10 @@ export async function listPaperclipSkillEntries( try { const entries = await fs.readdir(root, { withFileTypes: true }); const dirs = entries.filter((entry) => entry.isDirectory()); - return Promise.all(dirs.map(async (entry) => { - const skillDir = path.join(root, entry.name); - const required = await readSkillRequired(skillDir); - return { - key: `paperclipai/paperclip/${entry.name}`, - runtimeName: entry.name, - source: skillDir, - required, - requiredReason: required - ? "Bundled Paperclip skills are always available for local adapters." - : null, - }; + return dirs.map((entry) => ({ + key: `paperclipai/paperclip/${entry.name}`, + runtimeName: entry.name, + source: path.join(root, entry.name), })); } catch { return []; @@ -1682,9 +1651,7 @@ export function buildRuntimeMountedSkillSnapshot( sourcePath: null, targetPath: null, detail: resolvePaperclipSkillMissingDetail(available, missingDetail), - required: Boolean(available.required), - requiredReason: available.requiredReason ?? null, - ...buildManagedSkillOrigin(available), + ...buildManagedSkillOrigin(), }); continue; } @@ -1709,9 +1676,7 @@ export function buildRuntimeMountedSkillSnapshot( available, ) : null, - required: Boolean(available.required), - requiredReason: available.requiredReason ?? null, - ...buildManagedSkillOrigin(available), + ...buildManagedSkillOrigin(), }); } @@ -1807,9 +1772,7 @@ export function buildPersistentSkillSnapshot( available, missingDetail, ), - required: Boolean(available.required), - requiredReason: available.requiredReason ?? null, - ...buildManagedSkillOrigin(available), + ...buildManagedSkillOrigin(), }); continue; } @@ -1841,9 +1804,7 @@ export function buildPersistentSkillSnapshot( sourcePath: available.source, targetPath: path.join(skillsHome, available.runtimeName), detail, - required: Boolean(available.required), - requiredReason: available.requiredReason ?? null, - ...buildManagedSkillOrigin(available), + ...buildManagedSkillOrigin(), }); } @@ -1925,11 +1886,6 @@ function normalizeConfiguredPaperclipRuntimeSkills(value: unknown): PaperclipSki typeof entry.missingDetail === "string" && entry.missingDetail.trim().length > 0 ? entry.missingDetail.trim() : null, - required: asBoolean(entry.required, false), - requiredReason: - typeof entry.requiredReason === "string" && entry.requiredReason.trim().length > 0 - ? entry.requiredReason.trim() - : null, }); } return out; @@ -2029,19 +1985,14 @@ function canonicalizeDesiredPaperclipSkillReference( export function resolvePaperclipDesiredSkillNames( config: Record, - availableEntries: Array<{ key: string; runtimeName?: string | null; required?: boolean }>, + availableEntries: Array<{ key: string; runtimeName?: string | null }>, ): string[] { const preference = readPaperclipSkillSyncPreference(config); - const requiredSkills = availableEntries - .filter((entry) => entry.required) - .map((entry) => entry.key); - if (!preference.explicit) { - return Array.from(new Set(requiredSkills)); - } + if (!preference.explicit) return []; const desiredSkills = preference.desiredSkills .map((reference) => canonicalizeDesiredPaperclipSkillReference(reference, availableEntries)) .filter(Boolean); - return Array.from(new Set([...requiredSkills, ...desiredSkills])); + return Array.from(new Set(desiredSkills)); } export function writePaperclipSkillSyncPreference( diff --git a/packages/adapter-utils/src/types.ts b/packages/adapter-utils/src/types.ts index 5314f7b1..668dab92 100644 --- a/packages/adapter-utils/src/types.ts +++ b/packages/adapter-utils/src/types.ts @@ -186,7 +186,6 @@ export type AdapterSkillState = export type AdapterSkillOrigin = | "company_managed" - | "paperclip_required" | "user_installed" | "external_unknown"; @@ -197,8 +196,6 @@ export interface AdapterSkillEntry { currentVersionId?: string | null; desired: boolean; managed: boolean; - required?: boolean; - requiredReason?: string | null; state: AdapterSkillState; origin?: AdapterSkillOrigin; originLabel?: string | null; diff --git a/packages/adapters/cursor-local/src/server/skills.ts b/packages/adapters/cursor-local/src/server/skills.ts index 7e43c333..854fc133 100644 --- a/packages/adapters/cursor-local/src/server/skills.ts +++ b/packages/adapters/cursor-local/src/server/skills.ts @@ -57,10 +57,7 @@ export async function syncCursorSkills( desiredSkills: string[], ): Promise { const availableEntries = await readPaperclipRuntimeSkillEntries(ctx.config, __moduleDir); - const desiredSet = new Set([ - ...desiredSkills, - ...availableEntries.filter((entry) => entry.required).map((entry) => entry.key), - ]); + const desiredSet = new Set(desiredSkills); const skillsHome = resolveCursorSkillsHome(ctx.config); await fs.mkdir(skillsHome, { recursive: true }); const installed = await readInstalledSkillTargets(skillsHome); @@ -85,7 +82,7 @@ export async function syncCursorSkills( export function resolveCursorDesiredSkillNames( config: Record, - availableEntries: Array<{ key: string; required?: boolean }>, + availableEntries: Array<{ key: string }>, ) { return resolvePaperclipDesiredSkillNames(config, availableEntries); } diff --git a/packages/adapters/gemini-local/src/server/skills.ts b/packages/adapters/gemini-local/src/server/skills.ts index 51253b33..246d6eb4 100644 --- a/packages/adapters/gemini-local/src/server/skills.ts +++ b/packages/adapters/gemini-local/src/server/skills.ts @@ -57,10 +57,7 @@ export async function syncGeminiSkills( desiredSkills: string[], ): Promise { const availableEntries = await readPaperclipRuntimeSkillEntries(ctx.config, __moduleDir); - const desiredSet = new Set([ - ...desiredSkills, - ...availableEntries.filter((entry) => entry.required).map((entry) => entry.key), - ]); + const desiredSet = new Set(desiredSkills); const skillsHome = resolveGeminiSkillsHome(ctx.config); await fs.mkdir(skillsHome, { recursive: true }); const installed = await readInstalledSkillTargets(skillsHome); @@ -85,7 +82,7 @@ export async function syncGeminiSkills( export function resolveGeminiDesiredSkillNames( config: Record, - availableEntries: Array<{ key: string; required?: boolean }>, + availableEntries: Array<{ key: string }>, ) { return resolvePaperclipDesiredSkillNames(config, availableEntries); } diff --git a/packages/adapters/opencode-local/src/server/skills.ts b/packages/adapters/opencode-local/src/server/skills.ts index 12ea6068..1cd1f18a 100644 --- a/packages/adapters/opencode-local/src/server/skills.ts +++ b/packages/adapters/opencode-local/src/server/skills.ts @@ -61,10 +61,7 @@ export async function syncOpenCodeSkills( desiredSkills: string[], ): Promise { const availableEntries = await readPaperclipRuntimeSkillEntries(ctx.config, __moduleDir); - const desiredSet = new Set([ - ...desiredSkills, - ...availableEntries.filter((entry) => entry.required).map((entry) => entry.key), - ]); + const desiredSet = new Set(desiredSkills); const skillsHome = resolveOpenCodeSkillsHome(ctx.config); await fs.mkdir(skillsHome, { recursive: true }); const installed = await readInstalledSkillTargets(skillsHome); @@ -89,7 +86,7 @@ export async function syncOpenCodeSkills( export function resolveOpenCodeDesiredSkillNames( config: Record, - availableEntries: Array<{ key: string; required?: boolean }>, + availableEntries: Array<{ key: string }>, ) { return resolvePaperclipDesiredSkillNames(config, availableEntries); } diff --git a/packages/adapters/pi-local/src/server/skills.ts b/packages/adapters/pi-local/src/server/skills.ts index 8a13da9c..cae3915b 100644 --- a/packages/adapters/pi-local/src/server/skills.ts +++ b/packages/adapters/pi-local/src/server/skills.ts @@ -57,10 +57,7 @@ export async function syncPiSkills( desiredSkills: string[], ): Promise { const availableEntries = await readPaperclipRuntimeSkillEntries(ctx.config, __moduleDir); - const desiredSet = new Set([ - ...desiredSkills, - ...availableEntries.filter((entry) => entry.required).map((entry) => entry.key), - ]); + const desiredSet = new Set(desiredSkills); const skillsHome = resolvePiSkillsHome(ctx.config); await fs.mkdir(skillsHome, { recursive: true }); const installed = await readInstalledSkillTargets(skillsHome); @@ -85,7 +82,7 @@ export async function syncPiSkills( export function resolvePiDesiredSkillNames( config: Record, - availableEntries: Array<{ key: string; required?: boolean }>, + availableEntries: Array<{ key: string }>, ) { return resolvePaperclipDesiredSkillNames(config, availableEntries); } diff --git a/packages/shared/src/types/adapter-skills.ts b/packages/shared/src/types/adapter-skills.ts index 36d6d4fd..998dda9a 100644 --- a/packages/shared/src/types/adapter-skills.ts +++ b/packages/shared/src/types/adapter-skills.ts @@ -10,7 +10,6 @@ export type AgentSkillState = export type AgentSkillOrigin = | "company_managed" - | "paperclip_required" | "user_installed" | "external_unknown"; @@ -26,8 +25,6 @@ export interface AgentSkillEntry { currentVersionId?: string | null; desired: boolean; managed: boolean; - required?: boolean; - requiredReason?: string | null; state: AgentSkillState; origin?: AgentSkillOrigin; originLabel?: string | null; diff --git a/packages/shared/src/validators/adapter-skills.ts b/packages/shared/src/validators/adapter-skills.ts index 95c25c25..96c0c499 100644 --- a/packages/shared/src/validators/adapter-skills.ts +++ b/packages/shared/src/validators/adapter-skills.ts @@ -11,7 +11,6 @@ export const agentSkillStateSchema = z.enum([ export const agentSkillOriginSchema = z.enum([ "company_managed", - "paperclip_required", "user_installed", "external_unknown", ]); @@ -39,8 +38,6 @@ export const agentSkillEntrySchema = z.object({ currentVersionId: z.string().uuid().nullable().optional(), desired: z.boolean(), managed: z.boolean(), - required: z.boolean().optional(), - requiredReason: z.string().nullable().optional(), state: agentSkillStateSchema, origin: agentSkillOriginSchema.optional(), originLabel: z.string().nullable().optional(), diff --git a/server/src/__tests__/acpx-local-execute.test.ts b/server/src/__tests__/acpx-local-execute.test.ts index 6e3922e3..e4576beb 100644 --- a/server/src/__tests__/acpx-local-execute.test.ts +++ b/server/src/__tests__/acpx-local-execute.test.ts @@ -128,12 +128,11 @@ async function createRuntimeSkill(root: string, input: { const key = input.key ?? `company/${runtimeName}`; const source = path.join(root, "skills", runtimeName); await fs.mkdir(source, { recursive: true }); - await fs.writeFile(path.join(source, "SKILL.md"), input.body ?? "---\nrequired: false\n---\nUse the test skill.\n", "utf8"); + await fs.writeFile(path.join(source, "SKILL.md"), input.body ?? "---\n---\nUse the test skill.\n", "utf8"); return { key, runtimeName, source, - required: false, }; } @@ -634,7 +633,7 @@ describe("acpx_local execute", () => { it("includes skill content in the ACPX Claude session fingerprint", async () => { const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-acpx-claude-fingerprint-")); try { - const skill = await createRuntimeSkill(root, { body: "---\nrequired: false\n---\nFirst version.\n" }); + const skill = await createRuntimeSkill(root, { body: "---\n---\nFirst version.\n" }); const runtimes: FakeRuntime[] = []; const execute = createAcpxLocalExecutor({ createRuntime: (options) => { @@ -657,7 +656,7 @@ describe("acpx_local execute", () => { }); const first = await execute(context); - await fs.writeFile(path.join(skill.source, "SKILL.md"), "---\nrequired: false\n---\nSecond version.\n", "utf8"); + await fs.writeFile(path.join(skill.source, "SKILL.md"), "---\n---\nSecond version.\n", "utf8"); const second = await execute({ ...context, runtime: { diff --git a/server/src/__tests__/acpx-local-skill-sync.test.ts b/server/src/__tests__/acpx-local-skill-sync.test.ts index a25ed74f..d3583b4a 100644 --- a/server/src/__tests__/acpx-local-skill-sync.test.ts +++ b/server/src/__tests__/acpx-local-skill-sync.test.ts @@ -6,7 +6,6 @@ import { describe("acpx local skill sync", () => { const paperclipKey = "paperclipai/paperclip/paperclip"; - const createAgentKey = "paperclipai/paperclip/paperclip-create-agent"; it("reports ACPX Claude skills as supported runtime-mounted state", async () => { const snapshot = await listAcpxSkills({ @@ -25,7 +24,6 @@ describe("acpx local skill sync", () => { expect(snapshot.supported).toBe(true); expect(snapshot.mode).toBe("ephemeral"); expect(snapshot.desiredSkills).toContain(paperclipKey); - expect(snapshot.desiredSkills).toContain(createAgentKey); expect(snapshot.entries.find((entry) => entry.key === paperclipKey)?.state).toBe("configured"); expect(snapshot.entries.find((entry) => entry.key === paperclipKey)?.detail).toContain("ACPX Claude session"); expect(snapshot.warnings).toEqual([]); diff --git a/server/src/__tests__/agent-skills-routes.test.ts b/server/src/__tests__/agent-skills-routes.test.ts index 2c27a910..323d2ddd 100644 --- a/server/src/__tests__/agent-skills-routes.test.ts +++ b/server/src/__tests__/agent-skills-routes.test.ts @@ -264,8 +264,6 @@ describe.sequential("agent skill routes", () => { key: "paperclipai/paperclip/paperclip", runtimeName: "paperclip", source: "/tmp/paperclip", - required: true, - requiredReason: "required", }, ]); mockCompanySkillService.resolveRequestedSkillKeys.mockImplementation( diff --git a/server/src/__tests__/claude-local-execute.test.ts b/server/src/__tests__/claude-local-execute.test.ts index 281c8527..ba34ae0d 100644 --- a/server/src/__tests__/claude-local-execute.test.ts +++ b/server/src/__tests__/claude-local-execute.test.ts @@ -1046,6 +1046,9 @@ describe("claude execute", () => { PAPERCLIP_TEST_CAPTURE_PATH: capturePath1, }, promptTemplate: "Follow the paperclip heartbeat.", + paperclipSkillSync: { + desiredSkills: ["paperclip"], + }, }, context: {}, authToken: "run-jwt-token", @@ -1083,6 +1086,9 @@ describe("claude execute", () => { PAPERCLIP_TEST_CAPTURE_PATH: capturePath2, }, promptTemplate: "Follow the paperclip heartbeat.", + paperclipSkillSync: { + desiredSkills: ["paperclip"], + }, }, context: { issueId: "issue-1", diff --git a/server/src/__tests__/claude-local-skill-sync.test.ts b/server/src/__tests__/claude-local-skill-sync.test.ts index 7f47cba0..ce5b4284 100644 --- a/server/src/__tests__/claude-local-skill-sync.test.ts +++ b/server/src/__tests__/claude-local-skill-sync.test.ts @@ -28,7 +28,7 @@ describe("claude local skill sync", () => { cleanupDirs.clear(); }); - it("defaults to mounting all built-in Paperclip skills when no explicit selection exists", async () => { + it("reports built-in Paperclip skills as available when no explicit selection exists", async () => { const snapshot = await listClaudeSkills({ agentId: "agent-1", companyId: "company-1", @@ -38,9 +38,8 @@ describe("claude local skill sync", () => { expect(snapshot.mode).toBe("ephemeral"); expect(snapshot.supported).toBe(true); - expect(snapshot.desiredSkills).toContain(paperclipKey); - expect(snapshot.entries.find((entry) => entry.key === paperclipKey)?.required).toBe(true); - expect(snapshot.entries.find((entry) => entry.key === paperclipKey)?.state).toBe("configured"); + expect(snapshot.desiredSkills).toEqual([]); + expect(snapshot.entries.find((entry) => entry.key === paperclipKey)?.state).toBe("available"); }); it("respects an explicit desired skill list without mutating a persistent home", async () => { @@ -57,7 +56,7 @@ describe("claude local skill sync", () => { expect(snapshot.desiredSkills).toContain(paperclipKey); expect(snapshot.entries.find((entry) => entry.key === paperclipKey)?.state).toBe("configured"); - expect(snapshot.entries.find((entry) => entry.key === createAgentKey)?.state).toBe("configured"); + expect(snapshot.entries.find((entry) => entry.key === createAgentKey)?.state).toBe("available"); }); it("normalizes legacy flat Paperclip skill refs to canonical keys", async () => { diff --git a/server/src/__tests__/codex-local-execute.test.ts b/server/src/__tests__/codex-local-execute.test.ts index 80c11f0a..a4aff6c8 100644 --- a/server/src/__tests__/codex-local-execute.test.ts +++ b/server/src/__tests__/codex-local-execute.test.ts @@ -1114,6 +1114,9 @@ describe("codex execute", () => { PAPERCLIP_TEST_CAPTURE_PATH: capturePath, }, promptTemplate: "Follow the paperclip heartbeat.", + paperclipSkillSync: { + desiredSkills: ["paperclip"], + }, }, context: {}, authToken: "run-jwt-token", @@ -1222,6 +1225,9 @@ describe("codex execute", () => { CODEX_HOME: explicitCodexHome, }, promptTemplate: "Follow the paperclip heartbeat.", + paperclipSkillSync: { + desiredSkills: ["paperclip"], + }, }, context: {}, authToken: "run-jwt-token", diff --git a/server/src/__tests__/codex-local-skill-sync.test.ts b/server/src/__tests__/codex-local-skill-sync.test.ts index 5f830745..94862a01 100644 --- a/server/src/__tests__/codex-local-skill-sync.test.ts +++ b/server/src/__tests__/codex-local-skill-sync.test.ts @@ -13,7 +13,6 @@ async function makeTempDir(prefix: string): Promise { describe("codex local skill sync", () => { const paperclipKey = "paperclipai/paperclip/paperclip"; - const createAgentKey = "paperclipai/paperclip/paperclip-create-agent"; const cleanupDirs = new Set(); afterEach(async () => { @@ -42,11 +41,7 @@ describe("codex local skill sync", () => { const before = await listCodexSkills(ctx); expect(before.mode).toBe("ephemeral"); expect(before.desiredSkills).toContain(paperclipKey); - expect(before.desiredSkills).toContain(createAgentKey); - expect(before.entries.find((entry) => entry.key === paperclipKey)?.required).toBe(true); expect(before.entries.find((entry) => entry.key === paperclipKey)?.state).toBe("configured"); - expect(before.entries.find((entry) => entry.key === createAgentKey)?.required).toBe(true); - expect(before.entries.find((entry) => entry.key === createAgentKey)?.state).toBe("configured"); expect(before.entries.find((entry) => entry.key === paperclipKey)?.detail).toContain("CODEX_HOME/skills/"); }); @@ -76,31 +71,6 @@ describe("codex local skill sync", () => { }); }); - it("keeps required bundled Paperclip skills configured even when the desired set is emptied", async () => { - const codexHome = await makeTempDir("paperclip-codex-skill-required-"); - cleanupDirs.add(codexHome); - - const configuredCtx = { - agentId: "agent-2", - companyId: "company-1", - adapterType: "codex_local", - config: { - env: { - CODEX_HOME: codexHome, - }, - paperclipSkillSync: { - desiredSkills: [], - }, - }, - } as const; - - const after = await syncCodexSkills(configuredCtx, []); - expect(after.desiredSkills).toContain(paperclipKey); - expect(after.desiredSkills).toContain(createAgentKey); - expect(after.entries.find((entry) => entry.key === paperclipKey)?.state).toBe("configured"); - expect(after.entries.find((entry) => entry.key === createAgentKey)?.state).toBe("configured"); - }); - it("normalizes legacy flat Paperclip skill refs before reporting configured state", async () => { const codexHome = await makeTempDir("paperclip-codex-legacy-skill-sync-"); cleanupDirs.add(codexHome); diff --git a/server/src/__tests__/cursor-local-execute.test.ts b/server/src/__tests__/cursor-local-execute.test.ts index 67192ab6..e37f12b4 100644 --- a/server/src/__tests__/cursor-local-execute.test.ts +++ b/server/src/__tests__/cursor-local-execute.test.ts @@ -288,8 +288,6 @@ describe("cursor execute", () => { { name: "paperclip", source: paperclipDir, - required: true, - requiredReason: "Bundled Paperclip skills are always available for local adapters.", }, { name: "ascii-heart", diff --git a/server/src/__tests__/cursor-local-skill-sync.test.ts b/server/src/__tests__/cursor-local-skill-sync.test.ts index f0aa23d5..3660f8b2 100644 --- a/server/src/__tests__/cursor-local-skill-sync.test.ts +++ b/server/src/__tests__/cursor-local-skill-sync.test.ts @@ -48,7 +48,6 @@ describe("cursor local skill sync", () => { const before = await listCursorSkills(ctx); expect(before.mode).toBe("persistent"); expect(before.desiredSkills).toContain(paperclipKey); - expect(before.entries.find((entry) => entry.key === paperclipKey)?.required).toBe(true); expect(before.entries.find((entry) => entry.key === paperclipKey)?.state).toBe("missing"); const after = await syncCursorSkills(ctx, [paperclipKey]); @@ -78,8 +77,6 @@ describe("cursor local skill sync", () => { key: "paperclip", runtimeName: "paperclip", source: paperclipDir, - required: true, - requiredReason: "Bundled Paperclip skills are always available for local adapters.", }, { key: "ascii-heart", @@ -95,7 +92,7 @@ describe("cursor local skill sync", () => { const before = await listCursorSkills(ctx); expect(before.warnings).toEqual([]); - expect(before.desiredSkills).toEqual(["paperclip", "ascii-heart"]); + expect(before.desiredSkills).toEqual(["ascii-heart"]); expect(before.entries.find((entry) => entry.key === "ascii-heart")?.state).toBe("missing"); const after = await syncCursorSkills(ctx, ["ascii-heart"]); @@ -104,41 +101,4 @@ describe("cursor local skill sync", () => { expect((await fs.lstat(path.join(home, ".cursor", "skills", "ascii-heart"))).isSymbolicLink()).toBe(true); }); - it("keeps required bundled Paperclip skills installed even when the desired set is emptied", async () => { - const home = await makeTempDir("paperclip-cursor-skill-prune-"); - cleanupDirs.add(home); - - const configuredCtx = { - agentId: "agent-2", - companyId: "company-1", - adapterType: "cursor", - config: { - env: { - HOME: home, - }, - paperclipSkillSync: { - desiredSkills: [paperclipKey], - }, - }, - } as const; - - await syncCursorSkills(configuredCtx, [paperclipKey]); - - const clearedCtx = { - ...configuredCtx, - config: { - env: { - HOME: home, - }, - paperclipSkillSync: { - desiredSkills: [], - }, - }, - } as const; - - const after = await syncCursorSkills(clearedCtx, []); - expect(after.desiredSkills).toContain(paperclipKey); - expect(after.entries.find((entry) => entry.key === paperclipKey)?.state).toBe("installed"); - expect((await fs.lstat(path.join(home, ".cursor", "skills", "paperclip"))).isSymbolicLink()).toBe(true); - }); }); diff --git a/server/src/__tests__/gemini-local-skill-sync.test.ts b/server/src/__tests__/gemini-local-skill-sync.test.ts index d11f2eec..646ac45a 100644 --- a/server/src/__tests__/gemini-local-skill-sync.test.ts +++ b/server/src/__tests__/gemini-local-skill-sync.test.ts @@ -41,49 +41,10 @@ describe("gemini local skill sync", () => { const before = await listGeminiSkills(ctx); expect(before.mode).toBe("persistent"); expect(before.desiredSkills).toContain(paperclipKey); - expect(before.entries.find((entry) => entry.key === paperclipKey)?.required).toBe(true); expect(before.entries.find((entry) => entry.key === paperclipKey)?.state).toBe("missing"); const after = await syncGeminiSkills(ctx, [paperclipKey]); expect(after.entries.find((entry) => entry.key === paperclipKey)?.state).toBe("installed"); expect((await fs.lstat(path.join(home, ".gemini", "skills", "paperclip"))).isSymbolicLink()).toBe(true); }); - - it("keeps required bundled Paperclip skills installed even when the desired set is emptied", async () => { - const home = await makeTempDir("paperclip-gemini-skill-prune-"); - cleanupDirs.add(home); - - const configuredCtx = { - agentId: "agent-2", - companyId: "company-1", - adapterType: "gemini_local", - config: { - env: { - HOME: home, - }, - paperclipSkillSync: { - desiredSkills: [paperclipKey], - }, - }, - } as const; - - await syncGeminiSkills(configuredCtx, [paperclipKey]); - - const clearedCtx = { - ...configuredCtx, - config: { - env: { - HOME: home, - }, - paperclipSkillSync: { - desiredSkills: [], - }, - }, - } as const; - - const after = await syncGeminiSkills(clearedCtx, []); - expect(after.desiredSkills).toContain(paperclipKey); - expect(after.entries.find((entry) => entry.key === paperclipKey)?.state).toBe("installed"); - expect((await fs.lstat(path.join(home, ".gemini", "skills", "paperclip"))).isSymbolicLink()).toBe(true); - }); }); diff --git a/server/src/__tests__/grok-local-skill-sync.test.ts b/server/src/__tests__/grok-local-skill-sync.test.ts index cc36b613..64c0fe76 100644 --- a/server/src/__tests__/grok-local-skill-sync.test.ts +++ b/server/src/__tests__/grok-local-skill-sync.test.ts @@ -6,7 +6,6 @@ import { describe("grok local skill sync", () => { const paperclipKey = "paperclipai/paperclip/paperclip"; - const createAgentKey = "paperclipai/paperclip/paperclip-create-agent"; it("reports Grok skills as ephemeral workspace-mounted state", async () => { const snapshot = await listGrokSkills({ @@ -24,9 +23,7 @@ describe("grok local skill sync", () => { expect(snapshot.supported).toBe(true); expect(snapshot.mode).toBe("ephemeral"); expect(snapshot.desiredSkills).toContain(paperclipKey); - expect(snapshot.desiredSkills).toContain(createAgentKey); expect(snapshot.entries.find((entry) => entry.key === paperclipKey)).toMatchObject({ - required: true, state: "configured", detail: "Will be copied into `.claude/skills` in the execution workspace on the next run.", }); diff --git a/server/src/__tests__/opencode-local-skill-sync.test.ts b/server/src/__tests__/opencode-local-skill-sync.test.ts index 7898a77a..48d97523 100644 --- a/server/src/__tests__/opencode-local-skill-sync.test.ts +++ b/server/src/__tests__/opencode-local-skill-sync.test.ts @@ -42,49 +42,10 @@ describe("opencode local skill sync", () => { expect(before.mode).toBe("persistent"); expect(before.warnings).toContain("OpenCode currently uses the shared Claude skills home (~/.claude/skills)."); expect(before.desiredSkills).toContain(paperclipKey); - expect(before.entries.find((entry) => entry.key === paperclipKey)?.required).toBe(true); expect(before.entries.find((entry) => entry.key === paperclipKey)?.state).toBe("missing"); const after = await syncOpenCodeSkills(ctx, [paperclipKey]); expect(after.entries.find((entry) => entry.key === paperclipKey)?.state).toBe("installed"); expect((await fs.lstat(path.join(home, ".claude", "skills", "paperclip"))).isSymbolicLink()).toBe(true); }); - - it("keeps required bundled Paperclip skills installed even when the desired set is emptied", async () => { - const home = await makeTempDir("paperclip-opencode-skill-prune-"); - cleanupDirs.add(home); - - const configuredCtx = { - agentId: "agent-2", - companyId: "company-1", - adapterType: "opencode_local", - config: { - env: { - HOME: home, - }, - paperclipSkillSync: { - desiredSkills: [paperclipKey], - }, - }, - } as const; - - await syncOpenCodeSkills(configuredCtx, [paperclipKey]); - - const clearedCtx = { - ...configuredCtx, - config: { - env: { - HOME: home, - }, - paperclipSkillSync: { - desiredSkills: [], - }, - }, - } as const; - - const after = await syncOpenCodeSkills(clearedCtx, []); - expect(after.desiredSkills).toContain(paperclipKey); - expect(after.entries.find((entry) => entry.key === paperclipKey)?.state).toBe("installed"); - expect((await fs.lstat(path.join(home, ".claude", "skills", "paperclip"))).isSymbolicLink()).toBe(true); - }); }); diff --git a/server/src/__tests__/paperclip-skill-utils.test.ts b/server/src/__tests__/paperclip-skill-utils.test.ts index a8415c20..df10c1a3 100644 --- a/server/src/__tests__/paperclip-skill-utils.test.ts +++ b/server/src/__tests__/paperclip-skill-utils.test.ts @@ -79,34 +79,6 @@ describe("paperclip skill utils", () => { await expect(fs.access(path.resolve("skills/create-issue-interaction-ui/SKILL.md"))).rejects.toThrow(); }); - it("marks skills with required: false in SKILL.md frontmatter as optional", async () => { - const root = await makeTempDir("paperclip-skill-optional-"); - cleanupDirs.add(root); - - const moduleDir = path.join(root, "a", "b", "c", "d", "e"); - await fs.mkdir(moduleDir, { recursive: true }); - - // Required skill (no frontmatter flag) - const requiredDir = path.join(root, "skills", "paperclip"); - await fs.mkdir(requiredDir, { recursive: true }); - await fs.writeFile(path.join(requiredDir, "SKILL.md"), "---\nname: paperclip\n---\n\n# Paperclip\n"); - - // Optional skill (required: false) - const optionalDir = path.join(root, "skills", "paperclip-dev"); - await fs.mkdir(optionalDir, { recursive: true }); - await fs.writeFile(path.join(optionalDir, "SKILL.md"), "---\nname: paperclip-dev\nrequired: false\n---\n\n# Dev\n"); - - const entries = await listPaperclipSkillEntries(moduleDir); - entries.sort((a, b) => a.runtimeName.localeCompare(b.runtimeName)); - - expect(entries).toHaveLength(2); - expect(entries[0]?.runtimeName).toBe("paperclip"); - expect(entries[0]?.required).toBe(true); - expect(entries[1]?.runtimeName).toBe("paperclip-dev"); - expect(entries[1]?.required).toBe(false); - expect(entries[1]?.requiredReason).toBeNull(); - }); - it("removes stale maintainer-only symlinks from a shared skills home", async () => { const root = await makeTempDir("paperclip-skill-cleanup-"); cleanupDirs.add(root); diff --git a/server/src/__tests__/pi-local-execute.test.ts b/server/src/__tests__/pi-local-execute.test.ts index 03d1a0ee..f9fe2d3f 100644 --- a/server/src/__tests__/pi-local-execute.test.ts +++ b/server/src/__tests__/pi-local-execute.test.ts @@ -130,8 +130,11 @@ describe("pi_local execute", () => { model: "google/gemini-3-flash-preview", promptTemplate: "Keep working.", paperclipRuntimeSkills: [ - { key: "demo-skill", runtimeName: "demo-skill", source: skillDir, required: true }, + { key: "demo-skill", runtimeName: "demo-skill", source: skillDir }, ], + paperclipSkillSync: { + desiredSkills: ["demo-skill"], + }, }, context: {}, authToken: "run-jwt-token", @@ -185,10 +188,10 @@ describe("pi_local execute", () => { cwd: workspace, model: "google/gemini-3-flash-preview", promptTemplate: "Keep working.", - // required:false with no explicit paperclipSkillSync preference → + // No explicit paperclipSkillSync preference → // resolvePaperclipDesiredSkillNames returns [] → skill is not injected. paperclipRuntimeSkills: [ - { key: "not-injected", runtimeName: "not-injected", source: nonInjectedSkillDir, required: false }, + { key: "not-injected", runtimeName: "not-injected", source: nonInjectedSkillDir }, ], }, context: {}, diff --git a/server/src/__tests__/pi-local-skill-sync.test.ts b/server/src/__tests__/pi-local-skill-sync.test.ts index def73005..a49b202e 100644 --- a/server/src/__tests__/pi-local-skill-sync.test.ts +++ b/server/src/__tests__/pi-local-skill-sync.test.ts @@ -41,49 +41,10 @@ describe("pi local skill sync", () => { const before = await listPiSkills(ctx); expect(before.mode).toBe("persistent"); expect(before.desiredSkills).toContain(paperclipKey); - expect(before.entries.find((entry) => entry.key === paperclipKey)?.required).toBe(true); expect(before.entries.find((entry) => entry.key === paperclipKey)?.state).toBe("missing"); const after = await syncPiSkills(ctx, [paperclipKey]); expect(after.entries.find((entry) => entry.key === paperclipKey)?.state).toBe("installed"); expect((await fs.lstat(path.join(home, ".pi", "agent", "skills", "paperclip"))).isSymbolicLink()).toBe(true); }); - - it("keeps required bundled Paperclip skills installed even when the desired set is emptied", async () => { - const home = await makeTempDir("paperclip-pi-skill-prune-"); - cleanupDirs.add(home); - - const configuredCtx = { - agentId: "agent-2", - companyId: "company-1", - adapterType: "pi_local", - config: { - env: { - HOME: home, - }, - paperclipSkillSync: { - desiredSkills: [paperclipKey], - }, - }, - } as const; - - await syncPiSkills(configuredCtx, [paperclipKey]); - - const clearedCtx = { - ...configuredCtx, - config: { - env: { - HOME: home, - }, - paperclipSkillSync: { - desiredSkills: [], - }, - }, - } as const; - - const after = await syncPiSkills(clearedCtx, []); - expect(after.desiredSkills).toContain(paperclipKey); - expect(after.entries.find((entry) => entry.key === paperclipKey)?.state).toBe("installed"); - expect((await fs.lstat(path.join(home, ".pi", "agent", "skills", "paperclip"))).isSymbolicLink()).toBe(true); - }); }); diff --git a/server/src/adapters/registry.ts b/server/src/adapters/registry.ts index 1c232d7d..315f02dd 100644 --- a/server/src/adapters/registry.ts +++ b/server/src/adapters/registry.ts @@ -456,6 +456,10 @@ const piLocalAdapter: ServerAdapterModule = { // hermes-paperclip-adapter v0.2.0 predates the authToken field; cast is // intentional until hermes ships a matching AdapterExecutionContext type. const executeHermesLocal = hermesExecute as unknown as ServerAdapterModule["execute"]; +// hermes-paperclip-adapter v0.2.0 still depends on the published @paperclipai/adapter-utils +// that ships the "paperclip_required" origin; casts bridge until hermes upgrades. +const listHermesSkills = hermesListSkills as unknown as ServerAdapterModule["listSkills"]; +const syncHermesSkills = hermesSyncSkills as unknown as ServerAdapterModule["syncSkills"]; const hermesLocalAdapter: ServerAdapterModule = { type: "hermes_local", @@ -510,8 +514,8 @@ const hermesLocalAdapter: ServerAdapterModule = { }, testEnvironment: (ctx) => hermesTestEnvironment(normalizeHermesConfig(ctx) as never), sessionCodec: hermesSessionCodec, - listSkills: hermesListSkills, - syncSkills: hermesSyncSkills, + listSkills: listHermesSkills, + syncSkills: syncHermesSkills, models: hermesModels, supportsLocalAgentJwt: true, supportsInstructionsBundle: false, diff --git a/server/src/routes/agents.ts b/server/src/routes/agents.ts index 07b7ecf4..fa12da1d 100644 --- a/server/src/routes/agents.ts +++ b/server/src/routes/agents.ts @@ -1480,18 +1480,13 @@ export function agentRoutes( companyId, requestedDesiredSkills, ); - const resolvedRequestedSkills = resolvedRequestedSkillEntries.map((entry) => entry.key); const runtimeSkillEntries = await companySkills.listRuntimeSkillEntries(companyId, { materializeMissing: shouldMaterializeRuntimeSkillsForAdapter(adapterType), versionSelections: skillVersionSelectionMap(resolvedRequestedSkillEntries), }); - const requiredSkills = runtimeSkillEntries - .filter((entry) => entry.required) - .map((entry) => entry.key); - const desiredSkillEntries = [ - ...requiredSkills.map((key) => ({ key, versionId: null })), - ...resolvedRequestedSkillEntries, - ].filter((entry, index, entries) => entries.findIndex((candidate) => candidate.key === entry.key) === index); + const desiredSkillEntries = resolvedRequestedSkillEntries.filter( + (entry, index, entries) => entries.findIndex((candidate) => candidate.key === entry.key) === index, + ); const desiredSkills = desiredSkillEntries.map((entry) => entry.key); return { @@ -1712,15 +1707,9 @@ export function agentRoutes( const preference = readPaperclipSkillSyncPreference( agent.adapterConfig as Record, ); - const runtimeSkillEntries = await companySkills.listRuntimeSkillEntries(agent.companyId, { - materializeMissing: false, - versionSelections: skillVersionSelectionMap(preference.desiredSkillEntries), - }); - const requiredSkills = runtimeSkillEntries.filter((entry) => entry.required).map((entry) => entry.key); - const desiredSkillEntries = [ - ...requiredSkills.map((key) => ({ key, versionId: null })), - ...preference.desiredSkillEntries, - ].filter((entry, index, entries) => entries.findIndex((candidate) => candidate.key === entry.key) === index); + const desiredSkillEntries = preference.desiredSkillEntries.filter( + (entry, index, entries) => entries.findIndex((candidate) => candidate.key === entry.key) === index, + ); res.json(buildUnsupportedSkillSnapshot(agent.adapterType, desiredSkillEntries)); return; } diff --git a/server/src/services/company-skills.ts b/server/src/services/company-skills.ts index db14275b..712e5a26 100644 --- a/server/src/services/company-skills.ts +++ b/server/src/services/company-skills.ts @@ -4117,11 +4117,9 @@ export function companySkillService(db: Db) { const out: PaperclipSkillEntry[] = []; for (const skill of skills) { - const sourceKind = asString(getSkillMeta(skill).sourceKind); const sourceResolution = await resolveRuntimeSkillSource(companyId, skill, options); if (!sourceResolution) continue; - const required = sourceKind === "paperclip_bundled"; out.push({ key: skill.key, runtimeName: buildSkillRuntimeName(skill.key, skill.slug), @@ -4130,10 +4128,6 @@ export function companySkillService(db: Db) { currentVersionId: skill.currentVersionId, sourceStatus: sourceResolution.status, missingDetail: sourceResolution.status === "missing" ? sourceResolution.detail : null, - required, - requiredReason: required - ? "Bundled Paperclip skills are always available for local adapters." - : null, }); } diff --git a/skills/paperclip-dev/SKILL.md b/skills/paperclip-dev/SKILL.md deleted file mode 100644 index d392d327..00000000 --- a/skills/paperclip-dev/SKILL.md +++ /dev/null @@ -1,267 +0,0 @@ ---- -name: paperclip-dev -required: false -description: > - Develop and operate a local Paperclip instance — start and stop servers, - pull updates from master, run builds and tests, manage worktrees, back up - databases, and diagnose problems. Use whenever you need to work on the - Paperclip codebase itself or keep a running instance healthy. ---- - -# Paperclip Dev - -This skill covers the day-to-day workflows for developing and operating a local Paperclip instance. It assumes you are working inside the Paperclip repo checkout with `origin` pointing to `git@github.com:paperclipai/paperclip.git`. - -> **OPEN SOURCE HYGIENE:** This repository is public-facing. Treat anything you push to `origin` as publishable. Never commit or push secrets, API keys, tokens, private logs, PII, customer data, or machine-local configuration that should stay private. Keep git history tidy as well: avoid pushing throwaway branches, noisy checkpoint commits, or speculative work that does not need to be shared upstream. - -> **MANDATORY:** Before running any CLI command, building, testing, or managing worktrees, you MUST read `doc/DEVELOPING.md` in the Paperclip repo. It is the canonical reference for all `paperclipai` CLI commands, their options, build/test workflows, database operations, worktree management, and diagnostics. Do NOT guess at flags or options — read the doc first. - -## Quick Command Reference - -These are the most common commands. For full option tables and details, see `doc/DEVELOPING.md`. - -| Task | Command | -|------|---------| -| Start server (first time or normal) | `npx paperclipai run` | -| Dev mode with hot reload | `pnpm dev` | -| Stop dev server | `pnpm dev:stop` | -| Build | `pnpm build` | -| Type-check | `pnpm typecheck` | -| Run tests | `pnpm test` | -| Run migrations | `pnpm db:migrate` | -| Regenerate Drizzle client | `pnpm db:generate` | -| Back up database | `npx paperclipai db:backup` | -| Health check | `npx paperclipai doctor --repair` | -| Print env vars | `npx paperclipai env` | -| Trigger agent heartbeat | `npx paperclipai heartbeat run --agent-id ` | -| Install agent skills locally | `npx paperclipai agent local-cli --company-id ` | - -## Pulling from Master - -```bash -git fetch origin && git pull origin master -pnpm install && pnpm build -``` - -If schema changes landed, also run `pnpm db:generate && pnpm db:migrate`. - -## Worktrees - -Paperclip worktrees combine git worktrees with isolated Paperclip instances — each gets its own database, server port, and environment seeded from the primary instance. - -> **MANDATORY:** Before creating or managing worktrees, you MUST read the "Worktree-local Instances" and "Worktree CLI Reference" sections in `doc/DEVELOPING.md`. That is the canonical reference for all worktree commands, their options, seed modes, and environment variables. - -### When to Use Worktrees - -- Starting a feature branch that needs its own Paperclip environment -- Running parallel agent work without cross-contaminating the primary instance -- Testing Paperclip changes in isolation before merging - -### Command Overview - -The CLI has two tiers (see `doc/DEVELOPING.md` for full option tables): - -| Command | Purpose | -|---------|---------| -| `worktree:make ` | Create worktree + isolated instance in one step | -| `worktree:list` | List worktrees and their Paperclip status | -| `worktree:merge-history` | Preview/import issue history between worktrees | -| `worktree:cleanup ` | Remove worktree, branch, and instance data | -| `worktree init` | Bootstrap instance inside existing worktree | -| `worktree env` | Print shell exports for worktree instance | -| `worktree reseed` | Refresh worktree DB from another instance | -| `worktree repair` | Fix broken/missing worktree instance metadata | - -### Typical Workflow - -```bash -# 1. Create a worktree for a feature -npx paperclipai worktree:make my-feature --start-point origin/main - -# 2. Move into the worktree (path printed by worktree:make) and source the environment -cd -eval "$(npx paperclipai worktree env)" - -# 3. Start the isolated Paperclip server -npx paperclipai run - -# 4. Do your work - -# 5. When done, merge history back if needed -npx paperclipai worktree:merge-history --from paperclip-my-feature --to current --apply - -# 6. Clean up -npx paperclipai worktree:cleanup my-feature -``` - -## Forks — Prefer Pushing to a User Fork - -If the user has a personal fork of `paperclipai/paperclip` configured as a git remote, push your feature branches to **that fork** instead of creating branches on the main repo. This keeps the upstream branch list clean and matches the standard open-source contribution flow. - -### Detect a fork remote - -Before pushing or creating a PR, list remotes and check for one that points at a non-`paperclipai` GitHub fork: - -```bash -git remote -v -``` - -Treat any remote whose URL points to `github.com:/paperclip` (or `github.com//paperclip.git`) as the user's fork. Common names are `fork`, ``, or `myfork`. The remote named `origin` or `upstream` that points at `paperclipai/paperclip` is the canonical upstream — do not push feature branches there if a fork exists. - -### Pushing to the fork - -```bash -# Push the current branch to the user's fork and set upstream -git push -u HEAD -``` - -Then create the PR from the fork branch: - -```bash -gh pr create --repo paperclipai/paperclip --head : ... -``` - -`gh pr create` usually figures out the head ref automatically when run from a branch tracking the fork; the explicit `--head :` form is the reliable fallback when it does not. - -### When no fork exists - -If `git remote -v` shows only `paperclipai/paperclip` remotes (no user fork), fall back to pushing branches to `origin` as before. Do NOT create a fork on the user's behalf — ask first. - -### Keeping the fork up to date - -The canonical remote that points at `paperclipai/paperclip` may be named `origin` **or** `upstream` depending on how the user set up the repo. Detect it the same way as in the "Detect a fork remote" step, then fetch and push from/with that remote so the sync works under either convention: - -```bash -UPSTREAM_REMOTE=$(git remote -v | awk '/paperclipai\/paperclip.*\(fetch\)/{print $1; exit}') -git fetch "$UPSTREAM_REMOTE" -git push "${UPSTREAM_REMOTE}/master:master" -``` - -## Pull Requests - -> **MANDATORY PRE-FLIGHT:** Before creating ANY pull request, you MUST read the canonical source files listed below. Do NOT run `gh pr create` until you have read these files and verified your PR body matches every required section. - -### Step 1 — Read the canonical files - -You MUST read all three of these files before creating a PR: - -1. **`.github/PULL_REQUEST_TEMPLATE.md`** — the required PR body structure -2. **`CONTRIBUTING.md`** — contribution conventions, PR requirements, and thinking-path examples -3. **`.github/workflows/pr.yml`** — CI checks that gate merge - -### Step 2 — Validate your PR body against this checklist - -After reading the template, verify your `--body` includes every one of these sections (names must match exactly): - -- [ ] `## Thinking Path` — blockquote style, 5-8 reasoning steps -- [ ] `## What Changed` — bullet list of concrete changes -- [ ] `## Verification` — how a reviewer confirms this works -- [ ] `## Risks` — what could go wrong -- [ ] `## Model Used` — provider, model ID, version, capabilities -- [ ] `## Checklist` — copied from the template, items checked off - -If any section is missing or empty, do NOT submit the PR. Go back and fill it in. - -### Step 3 — Create the PR - -Only after completing Steps 1 and 2, run `gh pr create`. Use the template contents as the structure for `--body` — do not write a freeform summary. - -## Hard Rules — Do NOT Bypass - -These rules exist because agents have caused real damage by improvising around CLI failures. Follow them exactly. - -1. **CLI is the only interface to worktrees and databases.** All worktree and database operations MUST go through `npx paperclipai` / `pnpm paperclipai` commands. You MUST NOT: - - Run `pg_dump`, `pg_restore`, `psql`, `createdb`, `dropdb`, or any raw postgres commands - - Manually set `DATABASE_URL` to point a worktree server at another instance's database - - Run `rm -rf` on any `.paperclip/`, `.paperclip-worktrees/`, or `db/` directory - - Directly manipulate embedded postgres data directories - - Kill postgres processes by PID - -2. **If a CLI command fails, stop and report.** Do NOT attempt workarounds. If `worktree:make`, `worktree reseed`, `worktree init`, `worktree:cleanup`, or any other `paperclipai` command fails: - - Report the exact error message in your task comment - - Set the task to `blocked` - - Suggest running `npx paperclipai doctor --repair` or recreating the worktree from scratch - - Do NOT try to manually replicate what the CLI does - -3. **Never share databases between instances.** Each worktree instance gets its own isolated database. Never override `DATABASE_URL` to point one instance at another's database. This destroys isolation and can corrupt production data. - -4. **Starting a dev server in a worktree requires setup first.** The correct sequence is: - ```bash - # If the worktree already exists but has no running instance: - cd - eval "$(npx paperclipai worktree env)" - pnpm install && pnpm build - npx paperclipai run # or pnpm dev - - # If the worktree needs a fresh database: - npx paperclipai worktree reseed --seed-mode full - - # If the worktree is broken beyond repair: - npx paperclipai worktree:cleanup - npx paperclipai worktree:make --seed-mode full - ``` - If any step fails, follow rule 2 — stop and report. - -5. **Seeding is a CLI operation.** When asked to seed a worktree database from the main instance, use `worktree reseed` or recreate with `worktree:make --seed-mode full`. Read `doc/DEVELOPING.md` for the full option tables. Never attempt manual database copying. - -## Persistent Dev Servers (for Manual Testing) - -When an agent needs to start a dev server that outlives the current heartbeat — for example, so a human or QA agent can manually test against it — the server process **must** be launched in a detached session. A process started directly from a heartbeat shell is killed when the heartbeat exits. - -### Use `tmux` for persistent servers - -```bash -# 1. cd into the worktree (or main repo) and source the environment -cd -eval "$(npx paperclipai worktree env)" # skip if using the primary instance - -# 2. Start the dev server in a named, detached tmux session -tmux new-session -d -s 'pnpm dev' - -# Example with a descriptive name: -tmux new-session -d -s auth-fix-3102 'pnpm dev' -``` - -### Managing the session - -| Task | Command | -|------|---------| -| Check if the session is alive | `tmux has-session -t 2>/dev/null && echo running` | -| View server output | `tmux capture-pane -t -p` | -| Kill the session | `tmux kill-session -t ` | -| List all tmux sessions | `tmux list-sessions` | - -### Verifying the server is reachable - -After launching, confirm the port is listening before reporting success: - -```bash -# Wait briefly for startup, then verify -sleep 3 -curl -sf http://127.0.0.1:/api/health && echo "Server is up" -lsof -nP -iTCP: -sTCP:LISTEN -``` - -### Key rules - -1. **Always use `tmux` (or equivalent)** when a dev server needs to stay running after the heartbeat ends. A server started directly from the agent shell will die when the heartbeat exits, even if it appeared healthy moments before. -2. **Name the session descriptively** — include the worktree name and port (e.g., `auth-fix-3102`). -3. **Verify the server is listening** before reporting the URL to anyone. -4. **Do not use `nohup` or `&` alone** — these are unreliable for agent shells that may have their entire process group killed. -5. **Clean up when done** — kill the tmux session when the testing is complete. - -## Common Mistakes - -| Mistake | Fix | -|---------|-----| -| Server won't start | Run `npx paperclipai doctor --repair` to diagnose and auto-fix | -| Forgetting to source worktree env | Run `eval "$(npx paperclipai worktree env)"` after cd-ing into the worktree | -| Stale dependencies after pull | Run `pnpm install && pnpm build` after pulling | -| Schema out of date after pull | Run `pnpm db:generate && pnpm db:migrate` | -| Reseeding while target DB is running | Stop the target server first, or use `--allow-live-target` | -| Cleaning up with unmerged commits | Merge or push first, or use `--force` if intentionally discarding | -| Running agents against wrong instance | Verify `PAPERCLIP_API_URL` points to the correct port | -| CLI command fails | Do NOT work around it — report the error and block (see Hard Rules above) | -| Agent tries manual postgres operations | NEVER do this — all DB ops go through the CLI (see Hard Rules above) | -| Dev server dies between heartbeats | Launch in a detached `tmux` session — see "Persistent Dev Servers" above | -| Pushed feature branch to `paperclipai/paperclip` when a fork exists | Push to the user's fork remote instead — see "Forks" above | diff --git a/ui/src/pages/AgentDetail.tsx b/ui/src/pages/AgentDetail.tsx index 2d446f5e..97edf8dc 100644 --- a/ui/src/pages/AgentDetail.tsx +++ b/ui/src/pages/AgentDetail.tsx @@ -2643,43 +2643,20 @@ export function AgentSkillsTab({ ); const optionalSkillRows = useMemo( () => - (companySkills ?? []) - .filter((skill) => !adapterEntryByKey.get(skill.key)?.required) - .map((skill) => ({ - id: skill.id, - key: skill.key, - name: skill.name, - description: skill.description, - detail: adapterEntryByKey.get(skill.key)?.detail ?? null, - locationLabel: adapterEntryByKey.get(skill.key)?.locationLabel ?? null, - originLabel: adapterEntryByKey.get(skill.key)?.originLabel ?? null, - linkTo: `/skills/${skill.id}`, - readOnly: false, - adapterEntry: adapterEntryByKey.get(skill.key) ?? null, - })), + (companySkills ?? []).map((skill) => ({ + id: skill.id, + key: skill.key, + name: skill.name, + description: skill.description, + detail: adapterEntryByKey.get(skill.key)?.detail ?? null, + locationLabel: adapterEntryByKey.get(skill.key)?.locationLabel ?? null, + originLabel: adapterEntryByKey.get(skill.key)?.originLabel ?? null, + linkTo: `/skills/${skill.id}`, + readOnly: false, + adapterEntry: adapterEntryByKey.get(skill.key) ?? null, + })), [adapterEntryByKey, companySkills], ); - const requiredSkillRows = useMemo( - () => - (skillSnapshot?.entries ?? []) - .filter((entry) => entry.required) - .map((entry) => { - const companySkill = companySkillByKey.get(entry.key); - return { - id: companySkill?.id ?? `required:${entry.key}`, - key: entry.key, - name: companySkill?.name ?? entry.key, - description: companySkill?.description ?? null, - detail: entry.detail ?? null, - locationLabel: entry.locationLabel ?? null, - originLabel: entry.originLabel ?? null, - linkTo: companySkill ? `/skills/${companySkill.id}` : null, - readOnly: false, - adapterEntry: entry, - }; - }), - [companySkillByKey, skillSnapshot], - ); const unmanagedSkillRows = useMemo( () => (skillSnapshot?.entries ?? []) @@ -2780,8 +2757,6 @@ export function AgentSkillsTab({ <> {(() => { const renderSkillRow = (skill: SkillRow) => { - const adapterEntry = skill.adapterEntry ?? adapterEntryByKey.get(skill.key); - const required = Boolean(adapterEntry?.required); const summaryText = resolveSkillSummaryText(skill, { fallbackKey: true }); const rowClassName = cn( "flex items-start gap-3 border-b border-border px-3 py-3 text-sm last:border-b-0", @@ -2828,8 +2803,8 @@ export function AgentSkillsTab({ ); } - const checked = required || skillDraft.includes(skill.key); - const disabled = required || skillSnapshot?.mode === "unsupported"; + const checked = skillDraft.includes(skill.key); + const disabled = skillSnapshot?.mode === "unsupported"; const checkbox = ( - {required && adapterEntry?.requiredReason ? ( - - - {checkbox} - - {adapterEntry.requiredReason} - - ) : skillSnapshot?.mode === "unsupported" ? ( + {skillSnapshot?.mode === "unsupported" ? ( {checkbox} @@ -2895,7 +2863,7 @@ export function AgentSkillsTab({ ); }; - if (optionalSkillRows.length === 0 && requiredSkillRows.length === 0 && unmanagedSkillRows.length === 0) { + if (optionalSkillRows.length === 0 && unmanagedSkillRows.length === 0) { return (
@@ -2917,7 +2885,6 @@ export function AgentSkillsTab({ {renderSkillSection("Other skills", otherSkillRows)} - {renderSkillSection("Required by Paperclip", requiredSkillRows)} {unmanagedSkillRows.length > 0 && (
diff --git a/ui/storybook/stories/acpx-local.stories.tsx b/ui/storybook/stories/acpx-local.stories.tsx index 7baf5d9e..d2b9daee 100644 --- a/ui/storybook/stories/acpx-local.stories.tsx +++ b/ui/storybook/stories/acpx-local.stories.tsx @@ -583,11 +583,9 @@ function buildAcpxClaudeSnapshot(): AgentSkillSnapshot { runtimeName: "paperclip", desired: true, managed: true, - required: true, - requiredReason: "Paperclip coordination skill is mandatory for control-plane agents.", state: "configured", - origin: "paperclip_required", - originLabel: "Required by Paperclip", + origin: "company_managed", + originLabel: "Managed by Paperclip", readOnly: false, sourcePath: "skills/paperclip", targetPath: null, @@ -598,7 +596,6 @@ function buildAcpxClaudeSnapshot(): AgentSkillSnapshot { runtimeName: "design-guide", desired: true, managed: true, - required: false, state: "configured", origin: "company_managed", originLabel: "Managed by Paperclip", @@ -612,7 +609,6 @@ function buildAcpxClaudeSnapshot(): AgentSkillSnapshot { runtimeName: "mobile-app-qa", desired: false, managed: true, - required: false, state: "available", origin: "company_managed", originLabel: "Managed by Paperclip", @@ -638,11 +634,9 @@ function buildAcpxCodexSnapshot(): AgentSkillSnapshot { runtimeName: "paperclip", desired: true, managed: true, - required: true, - requiredReason: "Paperclip coordination skill is mandatory for control-plane agents.", state: "configured", - origin: "paperclip_required", - originLabel: "Required by Paperclip", + origin: "company_managed", + originLabel: "Managed by Paperclip", readOnly: false, sourcePath: "skills/paperclip", targetPath: null, @@ -653,7 +647,6 @@ function buildAcpxCodexSnapshot(): AgentSkillSnapshot { runtimeName: "design-guide", desired: false, managed: true, - required: false, state: "available", origin: "company_managed", originLabel: "Managed by Paperclip", @@ -667,7 +660,6 @@ function buildAcpxCodexSnapshot(): AgentSkillSnapshot { runtimeName: "mobile-app-qa", desired: false, managed: true, - required: false, state: "available", origin: "company_managed", originLabel: "Managed by Paperclip", @@ -695,11 +687,9 @@ function buildAcpxCustomSnapshot(): AgentSkillSnapshot { runtimeName: "paperclip", desired: false, managed: true, - required: true, - requiredReason: "Paperclip coordination skill is mandatory for control-plane agents.", state: "available", - origin: "paperclip_required", - originLabel: "Required by Paperclip", + origin: "company_managed", + originLabel: "Managed by Paperclip", readOnly: false, sourcePath: "skills/paperclip", targetPath: null, @@ -710,7 +700,6 @@ function buildAcpxCustomSnapshot(): AgentSkillSnapshot { runtimeName: "design-guide", desired: true, managed: true, - required: false, state: "configured", origin: "company_managed", originLabel: "Managed by Paperclip", @@ -725,7 +714,6 @@ function buildAcpxCustomSnapshot(): AgentSkillSnapshot { runtimeName: "mobile-app-qa", desired: false, managed: true, - required: false, state: "available", origin: "company_managed", originLabel: "Managed by Paperclip",