fix: parse YAML block scalar skill descriptions (#5046)

## Thinking Path

> - Paperclip is the open source control plane teams use to manage AI
agents for work.
> - Company skills are imported from `SKILL.md` files and rely on YAML
frontmatter to describe what each skill does.
> - Multi-line descriptions commonly use YAML block scalars (`>` and
`|`), but the broken parser path behind #4989 reduced those descriptions
to a literal `>` or `|`.
> - The earliest contributor fix for that bug was PR #5046, so this
branch keeps that PR as the canonical merge target instead of replacing
it.
> - Follow-up work from #5071 and #8258 was then transplanted onto this
earlier branch so the final PR preserves contributor credit while still
shipping the strongest complete fix.
> - The resulting change fixes block-scalar parsing in the shared
frontmatter path, aligns server company-skill imports with that shared
parser, and prevents already-stale stored markers from rendering as junk
in the UI.

## Linked Issues or Issue Description

- Fixes #4989
- Refs #2863
- Refs #788
- Related superseded PRs: #5071, #8258

## What Changed

- Kept the original PR #5046 server-side company-skill fix and
regression coverage as the base branch history.
- Added the missing YAML chomping and indicator hardening explored
further in #5071.
- Moved frontmatter parsing to the shared parser path so
`packages/shared`, `packages/skills-catalog`, and server company-skill
imports stay aligned.
- Added UI summary sanitization and fallback handling so stale stored
`>` / `|` values no longer render as visible junk in company-skill
cards.
- Added regression coverage for shared frontmatter parsing,
skills-catalog parsing, company-skill imports, and stale-summary
fallback behavior.

## Verification

- Passed locally: `pnpm exec vitest run
packages/shared/src/frontmatter.test.ts
packages/skills-catalog/src/frontmatter.test.ts
server/src/__tests__/company-skills.test.ts
ui/src/lib/company-skill-summary.test.ts`
- Passed locally: `pnpm --filter @paperclipai/shared typecheck`
- Passed locally: `pnpm --filter @paperclipai/skills-catalog typecheck`
- Not fully runnable in this worktree: `pnpm --filter
@paperclipai/server typecheck` currently fails in `packages/plugins/sdk`
before reaching server code because local workspace `node_modules` type
deps are missing (`TS2688` for `node` / `react`).
- GitHub Actions / PR checks are rerunning on PR #5046 head
`005290b7557725abf748d00f36dd24ea0d919aba`.

## Risks

- Medium-low risk: the fix now touches shared parser code, server
company-skill imports, and UI fallback display rather than only the
server import path.
- The parser is still intentionally narrower than a full YAML
implementation; this change focuses on block-scalar correctness and the
stale-description rendering path relevant to #4989 / #2863.
- This branch intentionally supersedes narrower overlapping work from
#5071 and duplicate work from #8258 once the survivor PR is green.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

- OpenAI Codex / GPT-5-based coding agent with local shell and
code-editing tools enabled.

## 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 run tests locally and they pass
- [x] I have added or updated tests where applicable
- [ ] If this change affects the UI, I have included before/after
screenshots
- [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: Devin Foley <devin@paperclip.ing>
This commit is contained in:
MrBob
2026-06-18 16:01:11 -03:00
committed by GitHub
parent 5320a44088
commit 5f16efb3d0
13 changed files with 496 additions and 316 deletions
+4 -2
View File
@@ -22,6 +22,7 @@ import { useCompany } from "../context/CompanyContext";
import { useToastActions } from "../context/ToastContext";
import { useBreadcrumbs } from "../context/BreadcrumbContext";
import { queryKeys } from "../lib/queryKeys";
import { resolveSkillSummaryText } from "../lib/company-skill-summary";
import { AgentConfigForm } from "../components/AgentConfigForm";
import { PageTabBar } from "../components/PageTabBar";
import { adapterLabels, roleLabels, help } from "../components/agent-config-primitives";
@@ -2781,6 +2782,7 @@ 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",
skill.readOnly ? "bg-muted/20" : "hover:bg-accent/20",
@@ -2800,9 +2802,9 @@ export function AgentSkillsTab({
</Link>
) : null}
</div>
{skill.description && (
{summaryText && (
<MarkdownBody className="mt-1 text-xs text-muted-foreground prose-p:my-1 prose-ul:my-1 prose-ol:my-1 prose-li:my-0 [&>*:first-child]:mt-0 [&>*:last-child]:mb-0">
{skill.description}
{summaryText}
</MarkdownBody>
)}
{skill.readOnly && skill.originLabel && (
+15 -17
View File
@@ -62,6 +62,7 @@ import { Checkbox } from "@/components/ui/checkbox";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { buildLineDiff, type DiffRow } from "../lib/line-diff";
import { cn, relativeTime } from "../lib/utils";
import { resolveSkillSummaryText } from "../lib/company-skill-summary";
import {
parseSkillRoute,
skillRoute,
@@ -496,18 +497,6 @@ function formatBytes(bytes: number) {
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
// Some bundled skills ship with a frontmatter-only SKILL.md whose extracted
// description is just punctuation (e.g. ">"). Strip leading markdown syntax and
// fall back to a neutral placeholder so cards don't render bare glyphs.
function cardDescriptionText(raw: string | null | undefined): string {
const cleaned = (raw ?? "")
.replace(/^[\s>#*_\-`>]+/, "")
.trim();
// Empty descriptions render as a blank line on cards so spacing stays
// consistent across the grid (PAP-10907).
return cleaned.length >= 3 ? cleaned : "";
}
// ---------------------------------------------------------------------------
// Skills Store discovery grid (PAP-10879)
// ---------------------------------------------------------------------------
@@ -536,6 +525,7 @@ export type DiscoveryCard = {
slug: string;
author: string;
version: string | null;
tagline: string | null;
description: string | null;
categories: string[];
iconUrl: string | null;
@@ -679,7 +669,8 @@ function buildDiscoveryCards(
slug: skill.slug,
author: skill.authorName ?? skill.sourceLabel ?? "you",
version: discoveryVersionLabel(skill, required),
description: skill.tagline ?? skill.description,
tagline: skill.tagline ?? null,
description: skill.description ?? null,
categories: uniqueCategories([...(skill.categories ?? []), catalogMatch?.category]),
iconUrl: skill.iconUrl,
color: skill.color,
@@ -706,6 +697,7 @@ function buildDiscoveryCards(
slug: entry.slug,
author: entry.packageName ?? "Paperclip",
version: discoveryVersionLabel({ packageVersion: entry.packageVersion ?? null, sourceRef: null }, required),
tagline: null,
description: entry.description,
categories: uniqueCategories([entry.category, ...(entry.tags ?? [])]),
iconUrl: null,
@@ -768,6 +760,7 @@ function discoveryMatchesSearch(card: DiscoveryCard, query: string): boolean {
card.name,
card.slug,
card.author,
card.tagline ?? "",
card.description ?? "",
card.categories.join(" "),
].join(" ").toLowerCase();
@@ -830,7 +823,12 @@ function SkillCard({ card, onOpen }: { card: DiscoveryCard; onOpen: (card: Disco
{/* Always reserve two lines so cards line up even without a description. */}
<p className="mt-2 line-clamp-2 min-h-8 text-xs text-muted-foreground">
{cardDescriptionText(card.description)}
{resolveSkillSummaryText({
tagline: card.tagline,
description: card.description,
key: card.key,
name: card.name,
}) ?? ""}
</p>
<div className="mt-auto pt-3">
@@ -1382,6 +1380,7 @@ function NewSkillWizard({
slug: effectiveSlug || "skill",
author: "you",
version: null,
tagline: draft.tagline || null,
description: draft.tagline,
categories: draft.categories,
iconUrl: null,
@@ -2673,9 +2672,7 @@ export function SkillDetailPage({
const currentPin = shortRef(skill.sourceRef);
const latestPin = shortRef(updateStatus?.latestRef);
const selectedVersion = versions.find((version) => version.id === currentVersionSelection(skill)) ?? null;
const subtitleText = skill.tagline || skill.description
? cardDescriptionText(skill.tagline ?? skill.description)
: source.label;
const subtitleText = resolveSkillSummaryText(skill) ?? source.label;
// Look up the richer agent record (icon, paused) for agents using this skill.
const attachAgentMetaById = new Map(attachAgents.map((agent) => [agent.id, agent]));
@@ -2958,6 +2955,7 @@ export function SkillDetailPage({
slug: detail.slug,
author: detail.authorName ?? source.label,
version: null,
tagline: detail.tagline,
description: detail.description,
categories: detail.categories,
iconUrl: detail.iconUrl,
+3 -3
View File
@@ -8,6 +8,7 @@ import { companySkillsApi } from "../api/companySkills";
import { issuesApi } from "../api/issues";
import { projectsApi } from "../api/projects";
import { queryKeys } from "../lib/queryKeys";
import { resolveSkillSummaryText } from "../lib/company-skill-summary";
import { AGENT_ROLES, type AdapterEnvironmentTestResult, type AgentPermissions } from "@paperclipai/shared";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
@@ -321,6 +322,7 @@ export function NewAgent() {
{availableSkills.map((skill) => {
const inputId = `skill-${skill.id}`;
const checked = selectedSkillKeys.includes(skill.key);
const summaryText = resolveSkillSummaryText(skill, { fallbackKey: true });
return (
<div key={skill.id} className="flex items-start gap-3">
<Checkbox
@@ -330,9 +332,7 @@ export function NewAgent() {
/>
<label htmlFor={inputId} className="grid gap-1 leading-none">
<span className="text-sm font-medium">{skill.name}</span>
<span className="text-xs text-muted-foreground">
{skill.description ?? skill.key}
</span>
{summaryText ? <span className="text-xs text-muted-foreground">{summaryText}</span> : null}
</label>
</div>
);