mirror of
https://github.com/profullstack/logicsrc.git
synced 2026-10-01 20:33:50 +00:00
Add OpenSkill descriptions for human capabilities and OpenProfile discovery
This commit is contained in:
parent
ba69f58243
commit
25e18f0766
31 changed files with 878 additions and 3 deletions
56
apps/logicsrc-web/contract/openskill.contract.test.ts
Normal file
56
apps/logicsrc-web/contract/openskill.contract.test.ts
Normal 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 });
|
||||
});
|
||||
});
|
||||
|
|
@ -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" },
|
||||
|
|
|
|||
22
apps/logicsrc-web/e2e/openskill.spec.ts
Normal file
22
apps/logicsrc-web/e2e/openskill.spec.ts
Normal 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");
|
||||
});
|
||||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
});
|
||||
}
|
||||
38
apps/logicsrc-web/src/app/openskill/[slug]/page.tsx
Normal file
38
apps/logicsrc-web/src/app/openskill/[slug]/page.tsx
Normal 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>;
|
||||
}
|
||||
23
apps/logicsrc-web/src/app/openskill/catalog.md/route.ts
Normal file
23
apps/logicsrc-web/src/app/openskill/catalog.md/route.ts
Normal 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"
|
||||
}
|
||||
});
|
||||
}
|
||||
52
apps/logicsrc-web/src/app/openskill/page.tsx
Normal file
52
apps/logicsrc-web/src/app/openskill/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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];
|
||||
}
|
||||
|
|
|
|||
59
apps/logicsrc-web/src/lib/skills.ts
Normal file
59
apps/logicsrc-web/src/lib/skills.ts
Normal 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));
|
||||
}
|
||||
|
|
@ -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 }),
|
||||
|
|
|
|||
|
|
@ -662,7 +662,7 @@ pre {
|
|||
|
||||
@media (max-width: 720px) {
|
||||
.shell {
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.rail {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue