Add OpenSkill descriptions for human capabilities and OpenProfile discovery

This commit is contained in:
Anthony Ettinger 2026-09-13 15:47:58 +00:00
parent ba69f58243
commit 25e18f0766
31 changed files with 878 additions and 3 deletions

View file

@ -0,0 +1,56 @@
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { describe, expect, it } from "vitest";
import { readSkill, SKILL_SLUGS, listSkills, summarizeSkill } from "../src/lib/skills";
import { GET } from "../src/app/openskill/[slug]/openskill.md/route";
import { GET as catalogMarkdown } from "../src/app/openskill/catalog.md/route";
import { buildRedirects } from "../next.config";
describe("OpenSkill capability records", () => {
it("provides a portable index whose links resolve to the published record sources", async () => {
const response = catalogMarkdown();
expect(response.headers.get("content-type")).toBe("text/markdown; charset=utf-8");
const body = await response.text();
const paths = [...body.matchAll(/\]\((https?:\/\/[^)]+\/openskill\/([^/]+)\/openskill\.md)\)/g)];
expect(paths).toHaveLength(SKILL_SLUGS.length);
for (const [, , slug] of paths) expect(readSkill(slug)).not.toBeNull();
});
it("serves every published record losslessly as cross-origin Markdown", async () => {
for (const slug of SKILL_SLUGS) {
const source = readFileSync(resolve(process.cwd(), `../../docs/openskill/${slug}.md`), "utf8");
const response = await GET(new Request(`https://example.com/openskill/${slug}/openskill.md`), { params: Promise.resolve({ slug }) });
expect(response.status).toBe(200);
expect(response.headers.get("content-type")).toBe("text/markdown; charset=utf-8");
expect(response.headers.get("access-control-allow-origin")).toBe("*");
expect(response.headers.get("content-disposition")).toBeNull();
expect(await response.text()).toBe(source);
}
});
it("does not expose unlisted files or resolve request paths as filesystem paths", async () => {
for (const slug of ["unknown", "../openprofile", "../../package.json", "__proto__"]) {
expect(readSkill(slug)).toBeNull();
const response = await GET(new Request("https://example.com"), { params: Promise.resolve({ slug }) });
expect(response.status).toBe(404);
}
});
it("keeps knowledge, capabilities and occupations distinct in the catalog", () => {
const catalog = listSkills();
expect(catalog.find((c) => c.slug === "logo-design")?.kind).toBe("skill");
expect(catalog.find((c) => c.slug === "accounting")?.kind).toBe("knowledge");
expect(catalog.find((c) => c.slug === "accountant")?.kind).toBe("occupation");
expect(catalog.every((c) => c.name && c.description)).toBe(true);
});
it("does not infer a kind or consume metadata from examples or later sections", () => {
const source = "# Weaving\n\nMake interlaced textiles.\n\n```markdown\n- **Kind**: occupation\n```\n\n## Notes\n\n- **Kind**: knowledge\n";
expect(summarizeSkill(source, "weaving")).toEqual({ slug: "weaving", name: "Weaving", description: "Make interlaced textiles." });
expect(summarizeSkill("# Unusual craft\n\n- **Kind**: local-category\n\nA local practice.", "craft").kind).toBe("local-category");
});
it("lets the OpenProfile skills path discover the same concept catalog", () => {
expect(buildRedirects()).toContainEqual({ source: "/openprofile/skills", destination: "/openskill", permanent: true });
});
});

View file

@ -10,6 +10,7 @@ vi.mock("../src/lib/supabase", () => ({ publicClient: () => { throw new Error("o
describe.each([
{ slug: "openagent", name: "OpenAgent", family: "people" },
{ slug: "openskill", name: "OpenSkill", family: "people" },
{ slug: "openabtest", name: "OpenABTest", family: "process" },
{ slug: "openfleet", name: "OpenFleet", family: "process" },
{ slug: "openrental", name: "OpenRental", family: "catalogs" },

View file

@ -0,0 +1,22 @@
import { expect, test } from "@playwright/test";
test("human capabilities are discoverable and their Markdown can travel independently", async ({ page, request }) => {
await page.goto("/openprofile/skills");
await expect(page).toHaveURL(/\/openskill$/);
await expect(page.getByRole("heading", { name: "OpenSkill", exact: true })).toBeVisible();
expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true);
await page.getByRole("link", { name: "Logo design", exact: true }).click();
await expect(page.getByRole("heading", { name: "Logo design", exact: true })).toBeVisible();
const sourceLink = page.getByRole("link", { name: "Markdown source", exact: true });
const source = await request.get((await sourceLink.getAttribute("href"))!);
expect(source.status()).toBe(200);
expect(source.headers()["content-type"]).toBe("text/markdown; charset=utf-8");
expect(await source.text()).toContain("# Logo design");
expect(await source.text()).toContain("**Kind**: skill");
const index = await request.get("/openskill/catalog.md");
expect(index.status()).toBe(200);
expect(index.headers()["content-type"]).toBe("text/markdown; charset=utf-8");
expect(await index.text()).toContain("/openskill/accountant/openskill.md");
});

View file

@ -79,7 +79,10 @@ export function buildRewrites(
* page. Permanent, so the old URL in the sitemap and in links keeps working.
*/
export function buildRedirects() {
return [{ source: "/agent-swarm", destination: "/openfleet", permanent: true }];
return [
{ source: "/agent-swarm", destination: "/openfleet", permanent: true },
{ source: "/openprofile/skills", destination: "/openskill", permanent: true }
];
}
const nextConfig: NextConfig = {

View file

@ -31,6 +31,7 @@ Open coordination standards for humans, AI agents, plugins, payment systems and
- [OpenMCP](${SITE_URL}/openmcp): an open catalog of MCP relays, live at https://openmcp.logicsrc.com
- [OpenProfile.md](${SITE_URL}/openprofile): one Markdown file for who and where, people and agents alike
- [OpenSkill](${SITE_URL}/openskill): portable descriptions of human skills, knowledge and occupations
- [ASDLC](${SITE_URL}/asdlc): the agentic software development lifecycle
- [OpenCreds](${SITE_URL}/opencreds): end-to-end encrypted credential sharing
- [Docs](${SITE_URL}/docs): every specification LogicSRC publishes

View file

@ -33,6 +33,8 @@ export function GET(): Response {
- [Blog](${SITE_URL}/blog): Project notes and release announcements.
- [Blog RSS](${SITE_URL}/blog/rss.xml): Machine-readable feed of posts.
- [This site's OpenProfile.md](${SITE_URL}/.well-known/openprofile.md)
- [OpenSkill concept catalog](${SITE_URL}/openskill): Human skills, knowledge and occupations. Each concept page links its portable openskill.md source; these are capability descriptions, not executable agent instructions.
- [OpenSkill Markdown index](${SITE_URL}/openskill/catalog.md): Names, kinds, descriptions and direct links to every published concept record.
${families.join("\n\n")}

View file

@ -259,6 +259,10 @@ export default function OpenProfilePage(): ReactNode {
<h2>Where everything lives</h2>
</div>
<ul style={{ color: "#41505d", lineHeight: 1.9, paddingLeft: "1.1rem" }}>
<li>
<Link href="/openprofile/skills">Skills and knowledge</Link>: portable human capabilities,
occupations and taxonomy links through OpenSkill
</li>
<li>
<Link href="/docs/openprofile">Specification</Link>, with the eight rules, discovery,
verification and the version history

View file

@ -0,0 +1,21 @@
import { readSkill, SKILL_SLUGS } from "@/lib/skills";
export const dynamic = "force-static";
export const dynamicParams = false;
export function generateStaticParams() {
return SKILL_SLUGS.map((slug) => ({ slug }));
}
export async function GET(_request: Request, { params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params;
const source = readSkill(slug);
if (source === null) return new Response("Not found", { status: 404 });
return new Response(source, {
headers: {
"content-type": "text/markdown; charset=utf-8",
"access-control-allow-origin": "*",
"cache-control": "public, max-age=3600",
"x-content-type-options": "nosniff"
}
});
}

View file

@ -0,0 +1,38 @@
import Link from "next/link";
import { notFound } from "next/navigation";
import type { Metadata } from "next";
import { marked } from "marked";
import { SiteShell } from "@/components/site-shell";
import { sanitizeRenderedHtml } from "@/lib/html";
import { readSkill, SKILL_SLUGS, summarizeSkill } from "@/lib/skills";
export const dynamicParams = false;
export function generateStaticParams() {
return SKILL_SLUGS.map((slug) => ({ slug }));
}
export async function generateMetadata({ params }: { params: Promise<{ slug: string }> }): Promise<Metadata> {
const { slug } = await params;
const source = readSkill(slug);
if (!source) return { title: "Not found · OpenSkill" };
const concept = summarizeSkill(source, slug);
return {
title: `${concept.name} · OpenSkill · LogicSRC`,
description: concept.description,
alternates: { canonical: `/openskill/${slug}`, types: { "text/markdown": `/openskill/${slug}/openskill.md` } }
};
}
export default async function SkillPage({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params;
const source = readSkill(slug);
if (!source) notFound();
const concept = summarizeSkill(source, slug);
const html = sanitizeRenderedHtml(await marked.parse(source));
return <SiteShell crumbTitle={concept.name}>
<article className="band" style={{ maxWidth: "48rem" }}>
<p><Link href="/openskill">← All concepts</Link>{" · "}<a href={`/openskill/${slug}/openskill.md`}>Markdown source</a></p>
<div className="blog-content" style={{ lineHeight: 1.7 }} dangerouslySetInnerHTML={{ __html: html }} />
</article>
</SiteShell>;
}

View file

@ -0,0 +1,23 @@
import { listSkills } from "@/lib/skills";
export const dynamic = "force-static";
const SITE_URL = (process.env.PUBLIC_URL ?? "https://logicsrc.com").replace(/\/$/, "");
export function GET() {
const records = listSkills().map((concept) =>
`- [${concept.name}](${SITE_URL}/openskill/${concept.slug}/openskill.md) — ${concept.kind ?? "kind unstated"}: ${concept.description}`
);
return new Response([
"# OpenSkill catalog", "",
"Portable descriptions of human skills, knowledge and occupations. These are concept definitions; personal claims and evidence belong in OpenProfile.", "",
`Specification: ${SITE_URL}/docs/openskill`, "",
...records, ""
].join("\n"), {
headers: {
"content-type": "text/markdown; charset=utf-8",
"access-control-allow-origin": "*",
"cache-control": "public, max-age=3600",
"x-content-type-options": "nosniff"
}
});
}

View file

@ -0,0 +1,52 @@
import Link from "next/link";
import type { Metadata } from "next";
import { SiteShell } from "@/components/site-shell";
import { listSkills } from "@/lib/skills";
import { pre, table, td, th } from "../openontology/ui";
export const metadata: Metadata = {
title: "OpenSkill · Human skills and knowledge · LogicSRC",
description: "Portable descriptions of human skills, knowledge and occupations, linked to ESCO and O*NET and carried in OpenProfile.",
alternates: { canonical: "/openskill" }
};
export default function OpenSkillPage() {
const concepts = listSkills();
return (
<SiteShell active="OpenSkill">
<section className="band">
<div className="section-head">
<p className="eyebrow">People and agents · Draft 0.1</p>
<h1>OpenSkill</h1>
<p>What you know. What you can do.</p>
</div>
<p>Branding, accounting, software engineering, hardware architecture. Describe a human capability in a small Markdown file, connect it to an established taxonomy, and carry it between profiles, portfolios and job boards.</p>
<p><Link className="button-primary" href="/docs/openskill">Read the specification</Link>{" · "}<Link href="/openprofile">Use it in OpenProfile</Link>{" · "}<a href="/openskill/catalog.md">Markdown catalog</a></p>
</section>
<section className="band">
<div className="section-head"><h2>A starting vocabulary</h2><p>Skills describe activity; knowledge describes a subject; occupations describe roles. Each entry has its own editable Markdown source.</p></div>
<div style={{ overflowX: "auto" }}>
<table style={table}>
<thead><tr><th style={th}>Concept</th><th style={th}>Kind</th><th style={th}>Description</th></tr></thead>
<tbody>{concepts.map((concept) => <tr key={concept.slug}>
<td style={td}><Link href={`/openskill/${concept.slug}`}>{concept.name}</Link></td>
<td style={td}>{concept.kind ?? "Unstated"}</td>
<td style={td}>{concept.description}</td>
</tr>)}</tbody>
</table>
</div>
<p>These initial examples cover design, finance and computing. The format accepts other domains and local concepts, including concepts with no taxonomy match.</p>
</section>
<section className="band">
<div className="section-head"><h2>Add it to your profile</h2><p>Plain words work. Links add a shared meaning.</p></div>
<pre style={pre}>{"## Skills\n\n- [Branding](https://logicsrc.com/openskill/branding)\n- [Logo design](https://logicsrc.com/openskill/logo-design)\n- Accounting\n- [Software engineer](https://logicsrc.com/openskill/software-engineer)"}</pre>
<p>A profile entry is a claim. Experience, self-assessment and evidence stay with the person making it. An occupation label does not establish a credential or a license.</p>
</section>
<section className="band">
<div className="section-head"><h2>Connected to existing knowledge</h2></div>
<p><a href="https://esco.ec.europa.eu/en/use-esco/use-esco-services-api/esco-web-service-api">ESCO</a> provides linked skills, knowledge and occupations. <a href="https://www.onetcenter.org/content.html">O*NET</a> describes occupations and their requirements. OpenSkill records explicit mappings, while keeping the description small enough to read and carry on its own.</p>
<p>These are descriptive capability records. Executable agent procedures retain their separate <code>SKILL.md</code> format. Reading a capability never installs a tool or grants it permission to act.</p>
</section>
</SiteShell>
);
}

View file

@ -2,6 +2,7 @@ import type { MetadataRoute } from "next";
import { publicClient } from "@/lib/supabase";
import { DOC_SLUGS } from "@/lib/docs";
import { FAMILIES, allSpecs } from "@/lib/specs";
import { SKILL_SLUGS } from "@/lib/skills";
export const dynamic = "force-dynamic";
@ -76,5 +77,8 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
postEntries = [];
}
return [...staticEntries, ...docEntries, ...postEntries];
const skills: MetadataRoute.Sitemap = SKILL_SLUGS.map((slug) => ({
url: `${base}/openskill/${slug}`, changeFrequency: "monthly", priority: 0.6
}));
return [...staticEntries, ...docEntries, ...skills, ...postEntries];
}

View file

@ -0,0 +1,59 @@
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { marked } from "marked";
// Public concepts only. Read at build time; request paths never select files.
export const SKILL_SLUGS = [
"branding", "logo-design", "graphic-design", "investing", "investor",
"financial-advisor", "accounting", "accountant", "software-development",
"developer", "software-engineer", "computer-science", "hardware-architectures",
"hardware-architect"
] as const;
const SKILLS_DIR = resolve(process.cwd(), "../../docs/openskill");
export type SkillSummary = {
slug: string;
name: string;
description: string;
kind?: string;
domain?: string;
aliases?: string;
};
export function readSkill(slug: string): string | null {
if (!(SKILL_SLUGS as readonly string[]).includes(slug)) return null;
return readFileSync(resolve(SKILLS_DIR, `${slug}.md`), "utf8");
}
// A catalog view of the introduction, not a lossy replacement for the record.
// Markdown tokenization keeps headings or bullets inside code fences inert.
export function summarizeSkill(markdown: string, slug: string): SkillSummary {
const result: SkillSummary = { slug, name: slug, description: "" };
const tokens = marked.lexer(markdown);
const start = tokens.findIndex((token) => token.type === "heading" && token.depth === 1);
if (start < 0) return result;
const title = tokens[start];
if (title.type === "heading") result.name = title.text;
let identity = true;
for (const token of tokens.slice(start + 1)) {
if (token.type === "heading") break;
if (token.type === "space") continue;
if (token.type === "list" && identity) {
for (const item of token.items) {
const match = /^(?:\*\*)?(Kind|Domain|Aliases)(?:\*\*)?:\s*(.+)$/i.exec(item.text);
if (!match) continue;
const key = match[1].toLowerCase() as "kind" | "domain" | "aliases";
result[key] ??= match[2].trim();
}
} else {
identity = false;
if (token.type === "paragraph" && !result.description) result.description = token.text;
}
}
return result;
}
export function listSkills(): SkillSummary[] {
return SKILL_SLUGS.map((slug) => summarizeSkill(readSkill(slug)!, slug));
}

View file

@ -51,6 +51,7 @@ export const FAMILIES: Family[] = [
"One Markdown file for a person or an agent, served from their own domain and linked from every platform that has a page for them. The profile carries the identity, the accounts and the topics; the sections carry what a platform needs to match on, so a job board, a booking site or a dating app reads the file instead of asking forty questions again.",
specs: [
s("openprofile", "OpenProfile.md", "One Markdown file for who you are and where you are, people and agents alike", { status: "0.2" }),
s("openskill", "OpenSkill", "Portable descriptions of human skills, knowledge and occupations, linked from OpenProfile", { status: "draft" }),
s("openagent", "OpenAgent", "A portable agent profile: identity, owner, skills and requested permissions", { status: "0.1" }),
s("openwall", "OpenWall", "Consent-based broadcasts and direct messages across contact networks, with an AT Protocol mapping", { landing: undefined, status: "draft" }),
s("openresume", "OpenResume.md", "What you have done, in the same spirit, linked from the profile", { landing: undefined }),

View file

@ -662,7 +662,7 @@ pre {
@media (max-width: 720px) {
.shell {
grid-template-columns: 1fr;
grid-template-columns: minmax(0, 1fr);
}
.rail {