openvibers/openvibe.codes Commits
The skills hub: a gallery, versioned skills, rules and MCP servers, shared four ways (#299)
Changes
@@@ -22,6 +22,7 @@ One place to send a coding task to the agent that fits it — by what the task n| Get started | `/start` | Install, the agents and what each needs, the commands, permission levels, hand-offs, the event format and using it from code. || Run on your machine | `/run`, `/run/tasks/:id` ([server/http/run.js](server/http/run.js)) | A signed-in person starts a coding task in any browser and their own machine runs it: an OpenVibe Run job (class `code`, artifact `[email protected]`, `trust: user-owned`) that Codes starts for them in their project (project delegation: `X-OV-Subject`, `X-OV-Project`; [server/domain/run-client.js](server/domain/run-client.js)). Run places it only on a machine of theirs (added on openvibe.bot/machines), whose OpenVibe Node runs `openvibe-codes job` ([harness/job.js](harness/job.js)) in its job sandbox: clone, run the agents with hand-offs, hand back the summary, the changed files and the patch, and, when asked and the machine holds `GITHUB_TOKEN`, push a new branch (never the one it started from) and open a pull request. The page streams the job's lines (Run's job stream through Codes) and keeps the result (`run_tasks`, migration 0003). Each task is its owner's; account export and deletion cover them (ADR-033, [server/account-data.js](server/account-data.js)). The task page shows the **timeline of attempts** above the agents' **session** (each attempt with its why: "Codex hit its limit, so Command Code took over with what it had done") and the session with openvibe-shared/agent-session.js: steps grouped, each step's command, change and output (live while it runs), helpers nested, hand-offs and the plan; after the end, the session from `codes_session_lines` (migration 0007): the lines are kept as the stream arrives, back-filled from the machine's final log, and replayed at `GET /run/tasks/:id/session.json?after=` (the owner, or a service with `codes.task.read` and `X-OV-Subject`); tasks from before keep the old `log`. Run's `run.job.*` events finalize a task at `POST /internal/events` ([server/events-consumer.js](server/events-consumer.js), no polling but a 10-minute fallback), and every state change is published as `codes.task.queued|running|succeeded|failed|cancelled` from an outbox ([server/events/outbox.js](server/events/outbox.js), contracts 0.232.0). || Knowledge | `/knowledge`, `GET/POST /api/v1/knowledge` ([server/http/knowledge.js](server/http/knowledge.js), [server/domain/knowledge.js](server/domain/knowledge.js)) | Workspace knowledge (Know K5): when a run ends, Codes captures what it taught — the agent's `Learned:` lines, a failure with its reason, the command that failed and the commands the repository uses — redacts secrets and stores it as a private row with its source run (`codes_knowledge`, migration 0025). The next run's job carries the relevant rows in its prompt before the brief, for every harness ([harness/job.js](harness/job.js) `agentPrompt`), so a pin bump, a test command or a trap is recalled without re-reading files. The page shows every row with its run, and edits, pins, shares (with a Network project's team) and deletes it; private by default. The same rows are exported and erased with the account ([server/account-data.js](server/account-data.js)); `ov-know`'s `knowledge_search`/`knowledge_add` ([know/knowledge.js](https://github.com/OpenVibers/openvibe-agents)) answer them for the person's own agents. |++| Skills hub | `/skills`, `/api/v1/skills`, `/api/v1/me/skills` ([server/http/skills-pages.js](server/http/skills-pages.js), [server/domain/skills.js](server/domain/skills.js)) | Skills, rules, MCP servers, commands and hooks kept per person and per project, versioned (`codes_skills`, `codes_skill_versions`, migration 0028): every save is an immutable version, a rollback copies an old version into a new one, and the body is rendered through the markup gate. Sharing is `openvibe-sdk/share`'s four audiences (`public`, `link`, `people`, `private`); only `public` is in the gallery, and the share sheet is the SDK's at `/api/v1/skill/:id/share`. A person adds a record to their sync, and `openvibe-node skills sync` writes it into Claude Code, Codex, OpenCode and Command Code inside one marked block, with a dry run, a diff and an undo. || The agents | `/harnesses` | Every harness in [`server/data/harness-offers.json`](server/data/harness-offers.json) (validated at boot as `platform.harness-offer@1`, each model as `platform.agent-offer@1`) with its provider, address kind, what it can do, price and limits, and its models with context limits and token prices. No local path is ever shown. || Task API (services and people) | `POST /api/v1/tasks`, `GET /api/v1/tasks`, `GET /api/v1/tasks/:id`, `GET /api/v1/tasks/:id/events`, `POST /api/v1/tasks/:id/continue`, `POST /api/v1/tasks/:id/stop`, `POST /api/v1/tasks/:id/cancel` ([server/http/tasks-api.js](server/http/tasks-api.js)) | Contracts 0.162 `codes.task.start` / `read` / `cancel`; `permission` is `review` (read-only), `edit` or `full`. A person may call the same routes with their own Network access token (`openvibe-codes task`, [docs/cli.md](docs/cli.md)): their token names them, so they see and act on every task they own, whoever started it. `GET /api/v1/tasks?state=running|needs|done` lists them. A first-party service (OpenVibe.Actor) holding the capability starts a coding task for a person named in `X-OV-Subject`, with a Network service token for audience `openvibe.codes` (`CODES_AUDIENCE`). The task is the same one `/run` starts ([server/domain/run-tasks.js](server/domain/run-tasks.js)): it runs on that person's own machine, in the project of their latest `/run` task (none yet → 409 `codes.no_project`). It shows on their `/run` page. `idempotency_key` answers a retry with the first task (200, `created: false`). A service reads and cancels only the tasks it started for that person (otherwise 404); the owner needs no token for their own tasks. The result the machine returned is passed on only as `codes.task@1` allows, each field checked and cut to its limit (migration 0005: `via`, `idempotency_key`). Calls count against the person's budget: starts and cancels 6 a minute, 60 an hour. **A task's attempts** (stage C2, migration 0010 `codes_attempts`): every Run job is one attempt, so a continue, a switch to another agent and a wrap-up are attempts on one timeline, never new tasks. `GET /api/v1/tasks/:id` answers `codes.task@2`: the task's own fields at the top level, exactly as `codes.task@1` had them, and, added to them, `attempts[]` (the timeline) and `outcome` — `done`, `unfinished` (with what is left) or `stopped`. `GET /api/v1/tasks/:id/events` is the task's event stream (server-sent events): the session's lines in order with the attempt each belongs to, the attempts themselves (started, ended with why), a heartbeat every 15 s, and a resume with `Last-Event-ID`. `POST /api/v1/tasks/:id/continue` starts the next attempt now (with `agent` it switches), `POST /api/v1/tasks/:id/stop` stops the running attempt and nothing carries it on. || Agent API | `GET /api/v1/harnesses`, `POST /api/v1/harnesses/route` | Public, no key. The router takes `{ task, requirements? }` (`task` is `edit`, `review`, `long` or `resume`; `requirements.capabilities` adds hard constraints) and answers `{ selected, reasons, candidates }` from `openvibe-sdk/placement` — hard constraints first (a missing capability excludes an agent, and says so), then cost. RFC 9457 problems (`harness.invalid`, `harness.task_unknown`). |nnew file mode 100644@@@ -0,0 +1,52 @@++-- phase: expand++-- Codes skills hub (the Compute plan §5, E6): skills, rules, MCP servers, commands and hooks kept per person, per++-- project and for the gallery; versioned, with the four sharing audiences of openvibe-sdk/share++-- (public, link, people, private). A record's body lives only in its versions: every save is an immutable row, and a++-- rollback copies an old version into a new one so a machine that synced version 3 can still name it.++--++-- Only public records are in the gallery; link, people and private ones are never listed. The Node sync reads the++-- person's assignments through GET /api/v1/me/skills, never the gallery.++CREATE TABLE IF NOT EXISTS codes_skills (++ id text COLLATE "C" PRIMARY KEY, -- skl_<ULID>++ owner_subject text COLLATE "C" NOT NULL, -- usr_<ULID>++ kind text COLLATE "C" NOT NULL++ CHECK (kind IN ('skill', 'rule', 'mcp', 'command', 'hook')),++ slug text COLLATE "C" NOT NULL, -- the SKILL.md folder name, stable for the owner++ title text NOT NULL,++ official boolean NOT NULL DEFAULT false, -- OpenVibe's own records, shown beside community ones++ tools jsonb NOT NULL DEFAULT '[]'::jsonb, -- harness ids this record is written for; [] = all++ latest_version integer NOT NULL DEFAULT 1 CHECK (latest_version >= 1),++ created_at timestamptz NOT NULL DEFAULT now(),++ updated_at timestamptz NOT NULL DEFAULT now(),++ UNIQUE (owner_subject, slug)++);++CREATE INDEX IF NOT EXISTS codes_skills_owner ON codes_skills (owner_subject, updated_at DESC);++-- The audience and the members are openvibe-sdk/share's (shares/share_members, service 'codes', kind 'skill'): this++-- hub registers every record there, so the same four audiences and the same access check apply as to a run task.++++-- One immutable version. The body, summary, tools and audience are copied here so a save never loses what an older++-- machine wrote; the skill row names the newest.++CREATE TABLE IF NOT EXISTS codes_skill_versions (++ skill_id text COLLATE "C" NOT NULL REFERENCES codes_skills(id) ON DELETE CASCADE,++ version integer NOT NULL CHECK (version >= 1),++ summary text NOT NULL DEFAULT '',++ body text NOT NULL CHECK (length(body) BETWEEN 1 AND 131072),++ tools jsonb NOT NULL DEFAULT '[]'::jsonb,++ audience text COLLATE "C" NOT NULL++ CHECK (audience IN ('public', 'link', 'people', 'private')),++ created_by text COLLATE "C" NOT NULL, -- usr_<ULID> or system++ note text NOT NULL DEFAULT '',++ created_at timestamptz NOT NULL DEFAULT now(),++ PRIMARY KEY (skill_id, version)++);++++-- What one person chose to sync, per project ('' is the person's own, everywhere). The Node asks for the person's++-- scope plus the slug of the project it is writing into.++CREATE TABLE IF NOT EXISTS codes_skill_assignments (++ subject text COLLATE "C" NOT NULL, -- usr_<ULID>++ project text COLLATE "C" NOT NULL DEFAULT '', -- '' or a project slug++ skill_id text COLLATE "C" NOT NULL REFERENCES codes_skills(id) ON DELETE CASCADE,++ created_at timestamptz NOT NULL DEFAULT now(),++ PRIMARY KEY (subject, project, skill_id)++);++CREATE INDEX IF NOT EXISTS codes_skill_assignments_subject ON codes_skill_assignments (subject, project);@@@ -63,6 +63,7 @@ form.stack button { align-self: flex-start; }form.inline-form { display: inline-flex; flex-wrap: wrap; gap: 6px; align-items: center; margin: 2px 0; }form.inline-box { border: 1px solid var(--border, #1f2d47); border-radius: 8px; padding: 10px 12px; }fieldset { border: 1px solid var(--border, #1f2d47); border-radius: 8px; display: flex; flex-direction: column; gap: 6px; }++.field { display: flex; flex-direction: column; gap: 4px; }.secret-box { display: flex; flex-direction: column; gap: 8px; border: 1px solid var(--warning, #f59e0b); border-radius: 10px; padding: 12px 14px; max-width: 760px; }.secret-box label { display: flex; flex-direction: column; gap: 4px; }.secret-box input { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; width: 100%; }@@@ -50,6 +50,14 @@ const TABLES = [{ table: 'codes_knowledge', subject: 'subject', file: 'knowledge.json', order: 'created_at', columns: ['id', 'kind', 'text', 'repo', 'paths', 'visibility', 'team_id', 'pinned', 'redacted', 'seen', 'source_task', 'source_agent', 'created_at', 'updated_at', 'last_seen_at'] },// The person's default audience for new run tasks (design 2026-11-06): what their next task starts as.{ table: 'codes_share_defaults', subject: 'subject', file: 'share-defaults.json', order: 'updated_at' },++ // The skills hub (Compute plan §5): the records they own and the ones they chose to sync. A record's body lives++ // in its versions, which leave with the record; the share rows are erased in extraErase with the record's ids.++ {++ table: 'codes_skills', subject: 'owner_subject', file: 'skills.json', order: 'created_at',++ columns: ['id', 'kind', 'slug', 'title', 'official', 'tools', 'latest_version', 'created_at', 'updated_at'],++ erase: { keep: 'deleted in extraErase with its versions, assignments and share rows' },++ },++ { table: 'codes_skill_assignments', subject: 'subject', file: 'skill-assignments.json', order: 'created_at', columns: ['skill_id', 'project', 'created_at'] },];// The rows that hang under a person's tasks: exported under the task, erased before it.@@@ -101,6 +109,15 @@ function create({ db, log = console }) {}delete retained.run_tasks;add(erased, 'run_tasks', await t.exec('DELETE FROM run_tasks WHERE subject = ANY($1::text[])', [subjects]));++ // The person's own skills, their versions (ON DELETE CASCADE) and the share rows of those records.++ const skills = await t.many('SELECT id FROM codes_skills WHERE owner_subject = ANY($1::text[])', [subjects]);++ if (skills.length) {++ const ids = skills.map((r) => r.id);++ await t.exec("DELETE FROM share_members WHERE service = 'codes' AND kind = 'skill' AND resource_id = ANY($1::text[])", [ids]);++ await t.exec("DELETE FROM share_access_requests WHERE service = 'codes' AND kind = 'skill' AND resource_id = ANY($1::text[])", [ids]);++ await t.exec("DELETE FROM shares WHERE service = 'codes' AND kind = 'skill' AND resource_id = ANY($1::text[])", [ids]);++ add(erased, 'codes_skills', await t.exec('DELETE FROM codes_skills WHERE id = ANY($1::text[])', [ids]));++ }// The sharing system's own rows (openvibe-sdk/share): a member or an access request is this person, and// the share rows they own carry their subject too. The subject column is jsonb, so the table map cannot// express it and they are erased here.@@@ -120,6 +137,9 @@ function create({ db, log = console }) {const EXEMPT = {share_members: 'the sharing system own member rows: the subject is a jsonb subject, so they are erased in extraErase',share_access_requests: 'the sharing system own access requests: the subject is a jsonb subject, so they are erased in extraErase',++ // created_by is the author of a version, not the person whose account is being erased: a record's versions leave++ // with the record (codes_skills), and a version never outlives it.++ codes_skill_versions: 'created_by is the author of a version, not a subject column; versions leave with their skill',};module.exports = { create, TABLES, TASK_KEYED, TOPICS, EXEMPT };@@@ -28,6 +28,9 @@ const { createSso } = require('./auth/sso');const { createHarnesses } = require('./domain/harnesses');const { createPageRoutes } = require('./http/pages');const { createSharing } = require('./sharing');++const { createSkills } = require('./domain/skills');++const { createSkillsApi } = require('./http/skills-api');++const { createSkillPageRoutes } = require('./http/skills-pages');const { createRunRoutes } = require('./http/run');const { createTasksApi } = require('./http/tasks-api');const { createConnectionsApi } = require('./http/connections-api');@@@ -132,8 +135,10 @@ async function createApp(opts = {}) {const knowledge = createKnowledge({ store, config, fetchImpl: fetchImpl || globalThis.fetch, log });// The one sharing system (design 2026-11-06): a run task is its owner's until the Share sheet says otherwise.const sharing = opts.sharing || createSharing({ db: store.db, config, fetchImpl: fetchImpl || globalThis.fetch, log });++ // The skills hub (Compute plan §5, E6): versioned skills, rules and MCP servers, shared through the same SDK.++ const skills = opts.skills || createSkills({ store, sharing, log });const tasks = createRunTasks({ store, run, config, log, sessions, outbox, runTokens, connections, attempts, fleet, notices, placementContext, harnesses, knowledge, sharing, benchAttemptEnded: opts.benchAttemptEnded || benchPublisher.onTaskTerminal });-- const ctx = { config, store, keys, aiCatalogConfig, sso, harnesses, log, sessions, outbox, run, tasks, connections, aiCatalog, runTokens, attempts, fleet, notices, notify, userSettings, benchPublisher, placementOutcomes, benchPriors, placementContext, knowledge, sharing };++ const ctx = { config, store, keys, aiCatalogConfig, sso, harnesses, log, sessions, outbox, run, tasks, connections, aiCatalog, runTokens, attempts, fleet, notices, notify, userSettings, benchPublisher, placementOutcomes, benchPriors, placementContext, knowledge, sharing, skills };ctx.forgejo = createForgejoClient({ config, log });// The C4b screens: the person's machines (from Network), their own choices, the GitHub reader and their usage.ctx.machines = createMachines({ config, fetchImpl: fetchImpl || globalThis.fetch, log });@@@ -294,6 +299,7 @@ async function createApp(opts = {}) {app.locals.sharingTimer = timer;}app.use('/api/v1', createApi(ctx));++ app.use('/api/v1', createSkillsApi({ config, skills: ctx.skills, actorLimits: ctx.actorLimits, log }));app.use('/api/v1', createTasksApi({ config, store, keys, run, harnesses, tasks, fleet: ctx.fleet, actorLimits: ctx.actorLimits, sharing, log }));app.use('/api/v1', createMeApi({ config, store, sso, run, harnesses, tasks, github: ctx.github, actorLimits: ctx.actorLimits, log }));// The Dock's agent view (Dock K-D3, Agent Streams S1): the person's own desk stream with a view ticket minted here.@@@ -312,6 +318,7 @@ async function createApp(opts = {}) {// ── Pages ───────────────────────────────────────────────app.use(rateLimit({ windowMs: 60_000, limit: Number(process.env.CODES_RATE_LIMIT_PER_MIN) || 300, standardHeaders: true, legacyHeaders: false })); // per address; tests raise it++ app.use(createSkillPageRoutes({ config, skills: ctx.skills }));const pageRoutes = createPageRoutes(ctx);// The agent layer (openvibe-shared/agent-layer): /.well-known/agent.json and the Markdown twins of the public// pages. The pages themselves keep their own routes — a signed-in visitor's account bar and forms are theirsnnew file mode 100644@@@ -0,0 +1,307 @@++'use strict';++++/**++ * The Codes skills hub (the Compute plan §5, E6): skills, rules, MCP servers, commands and hooks kept per person and++ * per project, versioned, and shared through openvibe-sdk/share — the same four audiences (public, link, people,++ * private), the same access check and the same members table as a run task.++ *++ * A record's content lives only in codes_skill_versions; codes_skills names the newest version and what is shown in++ * the gallery. Every save is a new immutable version, and a rollback copies an old version into a new one, so the Node++ * that synced version 3 can still name exactly what it wrote.++ *++ * Only public records are in the gallery. A link record is readable by its id, a people record by the owner and its++ * members, and a private record by the owner alone; a record that is not readable is a generic 404, never a hint that++ * it exists.++ */++++const KINDS = ['skill', 'rule', 'mcp', 'command', 'hook'];++const AUDIENCES = ['public', 'link', 'people', 'private'];++const TOOLS = ['claude-code', 'codex', 'opencode', 'command-code', 'minimax-code', 'aider', 'agents'];++const SLUG = /^[a-z0-9][a-z0-9-]{0,63}$/;++const SUBJECT = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;++const ID = /^skl_[0-9A-HJKMNP-TV-Z]{26}$/;++const PROJECT = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;++const MAX_BODY = 131072;++const MAX_TITLE = 120;++const MAX_SUMMARY = 500;++const SERVICE = 'codes';++const KIND = 'skill';++++class SkillError extends Error {++ constructor(status, code, message) {++ super(message);++ this.name = 'SkillError';++ this.status = status;++ this.code = code;++ }++}++++const fail = (status, code, message) => { throw new SkillError(status, code, message); };++const cleanText = (v) => (typeof v === 'string' ? v.replace(/\r\n/g, '\n').trim() : '');++const refOf = (id) => ({ service: SERVICE, kind: KIND, id: String(id) });++++/** Validate what a create or a new version carries; the caller supplies the fields that a version may omit. */++function check(input, { create = false, base = null } = {}) {++ const errors = [];++ const b = input && typeof input === 'object' && !Array.isArray(input) ? input : {};++ const out = {};++++ const title = b.title === undefined && base ? base.title : cleanText(b.title);++ if (!title) errors.push('title is required');++ else if (title.length > MAX_TITLE) errors.push(`title is at most ${MAX_TITLE} characters`);++ else out.title = title;++++ const summary = b.summary === undefined && base ? base.summary : cleanText(b.summary);++ if (summary.length > MAX_SUMMARY) errors.push(`summary is at most ${MAX_SUMMARY} characters`);++ else out.summary = summary;++++ const body = b.body === undefined && base ? base.body : (typeof b.body === 'string' ? b.body.replace(/\r\n/g, '\n') : '');++ if (!body.trim()) errors.push('body is required');++ else if (body.length > MAX_BODY) errors.push(`body is at most ${MAX_BODY} characters`);++ else out.body = body;++++ const audience = b.audience === undefined ? (base ? base.audience : 'private') : b.audience;++ if (!AUDIENCES.includes(audience)) errors.push(`audience is one of ${AUDIENCES.join(', ')}`);++ else out.audience = audience;++++ const tools = b.tools === undefined ? (base ? base.tools : []) : b.tools;++ if (!Array.isArray(tools) || tools.length > 8 || tools.some((t) => !TOOLS.includes(t)) || new Set(tools).size !== tools.length) {++ errors.push(`tools is up to eight of ${TOOLS.join(', ')}`);++ } else out.tools = tools;++++ if (create) {++ const kind = b.kind;++ if (!KINDS.includes(kind)) errors.push(`kind is one of ${KINDS.join(', ')}`);++ else out.kind = kind;++ const slug = typeof b.slug === 'string' ? b.slug.trim() : '';++ if (!SLUG.test(slug)) errors.push('slug is lowercase letters, digits and hyphens (at most 64)');++ else out.slug = slug;++ }++ return { ok: errors.length === 0, errors, value: out };++}++++/** One joined row, as codes.skill@1. */++function present(row) {++ const tools = Array.isArray(row.tools) ? row.tools : (typeof row.tools === 'string' ? JSON.parse(row.tools) : []);++ return {++ id: row.id,++ owner_subject: row.owner_subject,++ kind: row.kind,++ slug: row.slug,++ title: row.title,++ summary: row.summary || '',++ audience: row.audience || 'private',++ official: !!row.official,++ tools,++ version: Number(row.version),++ body: row.body,++ created_at: row.created_at instanceof Date ? row.created_at.toISOString() : String(row.created_at),++ updated_at: row.updated_at instanceof Date ? row.updated_at.toISOString() : String(row.updated_at),++ };++}++++const COLUMNS = 's.id, s.owner_subject, s.kind, s.slug, s.title, s.official, s.latest_version AS version, '++ + "v.summary, v.body, v.tools, COALESCE(sh.audience, 'private') AS audience, s.created_at, s.updated_at";++const FROM = 'FROM codes_skills s JOIN codes_skill_versions v ON v.skill_id = s.id AND v.version = s.latest_version '++ + "LEFT JOIN shares sh ON sh.service = 'codes' AND sh.kind = 'skill' AND sh.resource_id = s.id";++++function createSkills({ store, sharing, log = console }) {++ const db = store.db;++ const iso = store.iso;++ // The SDK's sharing model; app.js always builds it. Without it a record is owner-only, never public.++ const shares = sharing && sharing.shares;++ const shareViewer = sharing && sharing.shareViewer;++ const asShareViewer = (viewer) => (shareViewer ? shareViewer(viewer) : null);++++ function owned(row, subject) {++ if (!row || row.owner_subject !== subject) fail(404, 'skill.not_found', 'no such skill of yours');++ return row;++ }++++ /** Whether this viewer may read the record: openvibe-sdk/share decides (owner, public, link, people). */++ async function allowed(row, viewer) {++ if (!row) return false;++ if (viewer && viewer.subject && viewer.subject === row.owner_subject) return true;++ if (!shares) return false;++ try { return await shares.canView(asShareViewer(viewer), refOf(row.id)); } catch (err) {++ if (err && err.status === 404) return false;++ throw err;++ }++ }++++ async function load(id) {++ if (!ID.test(String(id || ''))) return null;++ return db.maybe(`SELECT ${COLUMNS} ${FROM} WHERE s.id = $1`, [id]);++ }++++ async function get(id, viewer) {++ const row = await load(id);++ return (await allowed(row, viewer)) ? present(row) : null;++ }++++ async function create({ subject, input }) {++ const checkIn = check(input, { create: true });++ if (!checkIn.ok) fail(422, 'skill.invalid', checkIn.errors.join('; '));++ const v = checkIn.value;++ const id = store.newId('skl');++ const at = iso();++ try {++ await store.tx(async () => {++ await db.exec('INSERT INTO codes_skills (id, owner_subject, kind, slug, title, tools, latest_version, created_at, updated_at) '++ + 'VALUES ($1,$2,$3,$4,$5,$6::jsonb,1,$7,$7)', [id, subject, v.kind, v.slug, v.title, JSON.stringify(v.tools), at]);++ await db.exec('INSERT INTO codes_skill_versions (skill_id, version, summary, body, tools, audience, created_by, note, created_at) '++ + 'VALUES ($1,1,$2,$3,$4::jsonb,$5,$6,$7,$8)', [id, v.summary, v.body, JSON.stringify(v.tools), v.audience, subject, 'created', at]);++ });++ } catch (err) {++ if (err && (err.code === '23505' || /duplicate key/i.test(String(err.message || '')))) {++ fail(409, 'skill.slug_taken', `you already have a ${v.kind} called ${v.slug}`);++ }++ throw err;++ }++ if (!shares) {++ await db.exec('DELETE FROM codes_skills WHERE id = $1', [id]);++ fail(503, 'skill.sharing_unavailable', 'sharing is not configured on this service');++ }++ try {++ await shares.register(refOf(id), { owner_subject: { type: 'user', id: subject }, audience: v.audience });++ } catch (err) {++ await db.exec('DELETE FROM codes_skills WHERE id = $1', [id]).catch(() => {});++ throw err;++ }++ return get(id, { subject });++ }++++ /** A new version of an owned record; the row's title, tools and timestamp follow it. Audience is separate. */++ async function addVersion({ subject, id, input, note = 'edited' }) {++ const row = owned(await load(id), subject);++ const checkIn = check({ ...(input || {}), audience: row.audience }, { base: present(row) });++ if (!checkIn.ok) fail(422, 'skill.invalid', checkIn.errors.join('; '));++ const v = checkIn.value;++ const version = Number(row.version) + 1;++ const at = iso();++ await store.tx(async () => {++ await db.exec('INSERT INTO codes_skill_versions (skill_id, version, summary, body, tools, audience, created_by, note, created_at) '++ + 'VALUES ($1,$2,$3,$4,$5::jsonb,$6,$7,$8,$9)', [id, version, v.summary, v.body, JSON.stringify(v.tools), v.audience, subject, String(note).slice(0, 200), at]);++ await db.exec('UPDATE codes_skills SET title = $2, tools = $3::jsonb, latest_version = $4, updated_at = $5 WHERE id = $1',++ [id, v.title, JSON.stringify(v.tools), version, at]);++ });++ return get(id, { subject });++ }++++ /** Copy an old version into a new one; history is never rewritten. */++ async function rollback({ subject, id, version }) {++ const row = owned(await load(id), subject);++ const n = Number(version);++ if (!Number.isInteger(n) || n < 1) fail(422, 'skill.invalid', 'version is a positive integer');++ const old = await db.maybe('SELECT * FROM codes_skill_versions WHERE skill_id = $1 AND version = $2', [id, n]);++ if (!old) fail(404, 'skill.version_not_found', 'no such version of this skill');++ return addVersion({ subject, id, input: { title: row.title, summary: old.summary, body: old.body, tools: old.tools }, note: `rolled back to ${n}` });++ }++++ /** The audience is the SDK's share row; the version history records which audience a body was saved under. */++ async function setAudience({ subject, id, audience }) {++ owned(await load(id), subject);++ if (!AUDIENCES.includes(audience)) fail(422, 'skill.invalid', `audience is one of ${AUDIENCES.join(', ')}`);++ if (!shares) fail(503, 'skill.sharing_unavailable', 'sharing is not configured on this service');++ await shares.setAudience(asShareViewer({ subject }), refOf(id), audience);++ const at = iso();++ const record = await db.maybe(`SELECT ${COLUMNS} ${FROM} WHERE s.id = $1`, [id]);++ const version = Number(record.version) + 1;++ await store.tx(async () => {++ await db.exec('INSERT INTO codes_skill_versions (skill_id, version, summary, body, tools, audience, created_by, note, created_at) '++ + 'VALUES ($1,$2,$3,$4,$5::jsonb,$6,$7,$8,$9)', [id, version, record.summary, record.body, JSON.stringify(record.tools),++ audience, subject, `audience ${audience}`, at]);++ await db.exec('UPDATE codes_skills SET latest_version = $2, updated_at = $3 WHERE id = $1', [id, version, at]);++ });++ return get(id, { subject });++ }++++ async function members(id, viewer) {++ owned(await load(id), viewer && viewer.subject);++ if (!shares) return [];++ const snap = await shares.get(asShareViewer(viewer), refOf(id));++ return (snap.members || []).map((m) => ({ subject: m.subject, role: m.role, created_at: m.added_at }));++ }++++ async function addMember({ subject, id, memberSubject }) {++ owned(await load(id), subject);++ if (!SUBJECT.test(String(memberSubject || ''))) fail(422, 'skill.invalid', 'member is a usr_… subject');++ if (!shares) fail(503, 'skill.sharing_unavailable', 'sharing is not configured on this service');++ await shares.addMembers(asShareViewer({ subject }), refOf(id), [{ type: 'user', id: memberSubject }]);++ return members(id, { subject });++ }++++ async function removeMember({ subject, id, memberSubject }) {++ owned(await load(id), subject);++ if (!shares) fail(503, 'skill.sharing_unavailable', 'sharing is not configured on this service');++ await shares.removeMember(asShareViewer({ subject }), refOf(id), { type: 'user', id: memberSubject });++ return members(id, { subject });++ }++++ async function listVersions(id, viewer) {++ const row = await load(id);++ if (!(await allowed(row, viewer))) return null;++ return (await db.many('SELECT skill_id, version, summary, body, tools, audience, created_by, note, created_at FROM codes_skill_versions WHERE skill_id = $1 ORDER BY version DESC', [id]))++ .map((v) => ({++ skill_id: v.skill_id,++ version: Number(v.version),++ summary: v.summary,++ body: v.body,++ tools: Array.isArray(v.tools) ? v.tools : JSON.parse(v.tools || '[]'),++ audience: v.audience,++ created_by: v.created_by,++ created_at: v.created_at instanceof Date ? v.created_at.toISOString() : String(v.created_at),++ ...(v.note ? { note: v.note } : {}),++ }));++ }++++ /** Public records only: the gallery, search and other sites never see link, people or private ones. */++ async function gallery({ kind, tool, q, limit = 60 } = {}) {++ const where = ["sh.audience = 'public'"];++ const params = [];++ if (kind && KINDS.includes(kind)) { params.push(kind); where.push(`s.kind = $${params.length}`); }++ if (tool && TOOLS.includes(tool)) { params.push(JSON.stringify([tool])); where.push(`v.tools @> $${params.length}::jsonb`); }++ const text = cleanText(q);++ if (text) { params.push(`%${text.slice(0, 80)}%`); where.push(`(s.title ILIKE $${params.length} OR s.summary ILIKE $${params.length})`); }++ params.push(Math.max(1, Math.min(100, Number(limit) || 60)));++ const rows = await db.many(`SELECT ${COLUMNS} ${FROM} WHERE ${where.join(' AND ')} ORDER BY s.official DESC, s.updated_at DESC, s.slug LIMIT $${params.length}`, params);++ return rows.map(present);++ }++++ async function mine(subject) {++ const rows = await db.many(`SELECT ${COLUMNS} ${FROM} WHERE s.owner_subject = $1 ORDER BY s.updated_at DESC, s.slug`, [subject]);++ return rows.map(present);++ }++++ async function assign({ subject, id, project = '' }) {++ const row = await load(id);++ if (!(await allowed(row, { subject }))) fail(404, 'skill.not_found', 'no such skill');++ const scope = String(project || '');++ if (scope && !PROJECT.test(scope)) fail(422, 'skill.invalid', 'project is a slug');++ await db.exec('INSERT INTO codes_skill_assignments (subject, project, skill_id) VALUES ($1, $2, $3) ON CONFLICT (subject, project, skill_id) DO NOTHING', [subject, scope, id]);++ return present(row);++ }++++ async function unassign({ subject, id, project = '' }) {++ const scope = String(project || '');++ await db.exec('DELETE FROM codes_skill_assignments WHERE subject = $1 AND project = $2 AND skill_id = $3', [subject, scope, id]);++ }++++ /** What the Node syncs: the person's picks, plus this project's picks, at their newest versions. */++ async function manifest({ subject, project = '' }) {++ const scope = String(project || '');++ const rows = await db.many(`SELECT ${COLUMNS} ${FROM}++ WHERE s.id IN (SELECT skill_id FROM codes_skill_assignments WHERE subject = $1 AND (project = '' OR project = $2))++ ORDER BY s.slug`, [subject, scope]);++ return { generated_at: iso(), skills: rows.map(present) };++ }++++ async function assignments(subject, project = '') {++ return (await db.many('SELECT skill_id, project, created_at FROM codes_skill_assignments WHERE subject = $1 AND project = $2 ORDER BY created_at', [subject, String(project || '')]))++ .map((r) => ({ skill_id: r.skill_id, project: r.project, created_at: r.created_at instanceof Date ? r.created_at.toISOString() : String(r.created_at) }));++ }++++ return { create, get, listVersions, gallery, mine, addVersion, rollback, setAudience, members, addMember, removeMember, assign, unassign, manifest, assignments };++}++++module.exports = { createSkills, SkillError, KINDS, AUDIENCES, TOOLS, MAX_BODY, MAX_TITLE, MAX_SUMMARY };@@@ -42,6 +42,11 @@ const BUDGETS = {'codes.knowledge.write': { minute: 30, hour: 600 },// The Dock's agent view controls (K-D3): Curtain now and End. The owner presses them now and then, never in a burst.'codes.agent_view.control': { minute: 20, hour: 120 },++ // The skills hub (Compute plan §5): save a version or share a record now and then; a script editing a library++ // may do a few a minute, not a flood.++ 'codes.skill.write': { minute: 20, hour: 300 },++ // Adding a skill to the sync is how the Node is set up; the first run adds several, then almost never.++ 'codes.skill.assign': { minute: 30, hour: 600 },};/**@@@ -23,7 +23,7 @@ const SERVICES_ORIGIN = 'https://openvibe.services';const DESCRIPTION = 'The open coding-agent harness: run a coding task on Claude Code, Codex, OpenCode, Command Code, Aider, the DeepSeek API or a model you host, with routing by capability and cost, hand-offs between agents, and the way to improve OpenVibe itself.';const { version: VERSION } = require('../../package.json');const INSTALL_HINT = `npm install -g https://codeload.github.com/OpenVibers/OpenVibe.Codes/tar.gz/refs/tags/v${VERSION}`;--const HARNESS_PAGES = ['/', '/start', '/harnesses', '/improve'];++const HARNESS_PAGES = ['/', '/start', '/harnesses', '/skills', '/improve'];// Sign-in and the API are not for crawlers. The console's old addresses redirect to openvibe.services.const DISALLOW = ['/auth/', '/api/'];@@@ -33,6 +33,7 @@ const PAGE_TEXT = {'/': ['OpenVibe.Codes home', 'Code with any agent: the coding-agent harness, how a task is routed and handed off, what works today, and how to improve OpenVibe.'],'/start': ['Get started with the coding-agent harness', 'Install openvibe-codes; the agents it can use and what each needs; the commands, permission levels, hand-offs, the event format and using it from your own code.'],'/harnesses': ['The coding agents', 'Every harness the router can pick (Claude Code, Codex, OpenCode, Command Code, Aider, DeepSeek, your own model) with its models, capabilities, prices and limits.'],++ '/skills': ['The skills hub', 'Skills, rules and MCP servers kept per person and per project, versioned, shared through the four audiences, and synced into each coding tool on your own machine with a dry run and an undo.'],'/improve': ['Improve OpenVibe', 'How OpenVibe is built in the open: every repository, and the path from a change to a reviewed pull request.'],'/updates': ['What shipped on OpenVibe.Codes', 'This site\'s update log, from the network changelog feed.'],'/policy': ['Community', 'The code of conduct, the contributing guide, the contributor ladder and the moderation policy.'],@@@ -68,6 +69,7 @@ function publicPages({ governance = [] }) {{ path: '/', changefreq: 'weekly', priority: 1.0 },{ path: '/start', changefreq: 'weekly', priority: 0.9 },{ path: '/harnesses', changefreq: 'weekly', priority: 0.9 },++ { path: '/skills', changefreq: 'weekly', priority: 0.9 },{ path: '/improve', changefreq: 'weekly', priority: 0.8 },{ path: '/updates', changefreq: 'daily', priority: 0.5 },{ path: '/policy', changefreq: 'monthly', priority: 0.4 },@@@ -21,7 +21,7 @@ const MOVED_EXACT = ['/policy/rfc', '/policy/compatibility', '/policy/licensing'// own API (/me), the Action Center read (codes.actions.read), a person's own connections (Codes C3), the conductor// with its approvals inbox (Codes C9b), the fleet their machines report (Codes C9c) and the workspace knowledge// their own agents read and add (Know K5).--const API_STAYS = /^\/api\/v1\/(harnesses|tasks|task\/[^/]+\/share|share|shared-with-me|actions|me|connections|conductor|approvals|fleet|knowledge)(\/|$)/;++const API_STAYS = /^\/api\/v1\/(harnesses|tasks|task\/[^/]+\/share|skill\/[^/]+\/share|skills|share|shared-with-me|actions|me|connections|conductor|approvals|fleet|knowledge)(\/|$)/;const under = (p, prefix) => p === prefix || p.startsWith(`${prefix}/`);nnew file mode 100644@@@ -0,0 +1,158 @@++'use strict';++++/**++ * The skills hub's JSON API (the Compute plan §5, E6).++ *++ * GET /api/v1/skills the public gallery (no auth); ?kind, ?tool, ?q, ?limit++ * GET /api/v1/skills?mine=1 the signed-in person's own records, every audience++ * GET /api/v1/skills/:id one record, if this viewer may read it++ * GET /api/v1/skills/:id/versions the versions, newest first (no bodies)++ * GET /api/v1/skills/:id/versions/:n one version, with its body++ * POST /api/v1/skills create a record and version 1++ * POST /api/v1/skills/:id/versions save a new version++ * POST /api/v1/skills/:id/rollback copy an old version into a new one++ * POST /api/v1/skills/:id/audience one of public, link, people, private++ * POST /api/v1/skills/:id/members add a person to a people-shared record++ * POST /api/v1/skills/:id/members/remove remove a person++ * GET /api/v1/me/skills the sync manifest: the person's picks + this project's, ?project=slug++ * POST /api/v1/me/skills/:id add a record to the sync++ * POST /api/v1/me/skills/:id/remove remove it++ *++ * The Node sync authenticates with the person's access token (Authorization: Bearer), which the same SSO middleware++ * verifies as a browser session. Only public records are in the gallery; the others answer 404 to everyone who is++ * not allowed to read them.++ */++const express = require('express');++const { http } = require('openvibe-contracts');++const { asyncRouter } = require('./router');++const { SkillError } = require('../domain/skills');++++const ID = /^skl_[0-9A-HJKMNP-TV-Z]{26}$/;++const PROJECT = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;++++function createSkillsApi({ config, skills, actorLimits, log = console }) {++ const r = asyncRouter();++ const json = express.json({ limit: '160kb' });++ const problem = (req, res, status, code, detail) => http.sendProblem(res, status, code, { detail, ctx: req.ov });++ const signed = (req) => req.viewer && req.viewer.kind === 'user' && req.viewer.subject;++ const viewer = (req) => (signed(req) ? { subject: req.viewer.subject, role: req.viewer.role } : null);++ const sameOrigin = (req) => {++ const origin = req.get('origin');++ if (!origin) return true;++ try { return new URL(origin).origin === new URL(config.baseUrl).origin; } catch { return false; }++ };++ const guard = (fn) => async (req, res) => {++ try { await fn(req, res); } catch (err) {++ if (err instanceof SkillError) return problem(req, res, err.status, err.code, err.message);++ log.error('[Codes] skills api error:', err && err.message ? err.message.slice(0, 200) : err);++ return problem(req, res, 500, 'internal.error', 'Internal error');++ }++ };++ const projectOf = (req) => {++ const p = req.query.project !== undefined ? String(req.query.project) : (req.body && req.body.project !== undefined ? String(req.body.project) : '');++ if (p && !PROJECT.test(p)) throw new SkillError(422, 'skill.invalid', 'project is a slug');++ return p;++ };++ const need = (req, res) => {++ if (!signed(req)) { problem(req, res, 401, 'auth.required', 'sign in to change your skills'); return false; }++ if (!sameOrigin(req)) { problem(req, res, 403, 'cross_site', 'cross-site JSON posts are refused'); return false; }++ return true;++ };++++ r.use((req, res, next) => { res.set('Cache-Control', 'no-store'); next(); });++++ r.get('/skills', actorLimits.reads('codes.read'), guard(async (req, res) => {++ if (req.query.mine === '1') {++ if (!signed(req)) return problem(req, res, 401, 'auth.required', 'sign in to see your skills');++ return res.json({ skills: await skills.mine(req.viewer.subject) });++ }++ const skillsOut = await skills.gallery({ kind: req.query.kind, tool: req.query.tool, q: req.query.q, limit: req.query.limit });++ return res.json({ skills: skillsOut, total: skillsOut.length });++ }));++++ r.get('/skills/:id', actorLimits.reads('codes.read'), guard(async (req, res) => {++ if (!ID.test(req.params.id)) return problem(req, res, 404, 'skill.not_found', 'no such skill');++ const skill = await skills.get(req.params.id, viewer(req));++ if (!skill) return problem(req, res, 404, 'skill.not_found', 'no such skill');++ const versions = await skills.listVersions(skill.id, viewer(req));++ return res.json({ skill, versions: (versions || []).map(({ body, ...rest }) => rest) });++ }));++++ r.get('/skills/:id/versions', actorLimits.reads('codes.read'), guard(async (req, res) => {++ if (!ID.test(req.params.id)) return problem(req, res, 404, 'skill.not_found', 'no such skill');++ const versions = await skills.listVersions(req.params.id, viewer(req));++ if (!versions) return problem(req, res, 404, 'skill.not_found', 'no such skill');++ return res.json({ versions: versions.map(({ body, ...rest }) => rest) });++ }));++++ r.get('/skills/:id/versions/:version', actorLimits.reads('codes.read'), guard(async (req, res) => {++ if (!ID.test(req.params.id)) return problem(req, res, 404, 'skill.not_found', 'no such skill');++ const versions = await skills.listVersions(req.params.id, viewer(req));++ if (!versions) return problem(req, res, 404, 'skill.not_found', 'no such skill');++ const version = versions.find((v) => v.version === Number(req.params.version));++ if (!version) return problem(req, res, 404, 'skill.version_not_found', 'no such version of this skill');++ return res.json({ version });++ }));++++ r.post('/skills', actorLimits.budget('codes.skill.write'), json, guard(async (req, res) => {++ if (!need(req, res)) return undefined;++ const skill = await skills.create({ subject: req.viewer.subject, input: req.body || {} });++ return res.status(201).json({ skill });++ }));++++ r.post('/skills/:id/versions', actorLimits.budget('codes.skill.write'), json, guard(async (req, res) => {++ if (!need(req, res)) return undefined;++ const skill = await skills.addVersion({ subject: req.viewer.subject, id: req.params.id, input: req.body || {}, note: (req.body && req.body.note) || 'edited' });++ return res.status(201).json({ skill });++ }));++++ r.post('/skills/:id/rollback', actorLimits.budget('codes.skill.write'), json, guard(async (req, res) => {++ if (!need(req, res)) return undefined;++ const skill = await skills.rollback({ subject: req.viewer.subject, id: req.params.id, version: req.body && req.body.version });++ return res.status(201).json({ skill });++ }));++++ r.post('/skills/:id/audience', actorLimits.budget('codes.skill.write'), json, guard(async (req, res) => {++ if (!need(req, res)) return undefined;++ const skill = await skills.setAudience({ subject: req.viewer.subject, id: req.params.id, audience: req.body && req.body.audience });++ return res.json({ skill });++ }));++++ r.post('/skills/:id/members', actorLimits.budget('codes.skill.write'), json, guard(async (req, res) => {++ if (!need(req, res)) return undefined;++ const members = await skills.addMember({ subject: req.viewer.subject, id: req.params.id, memberSubject: req.body && req.body.subject });++ return res.status(201).json({ members });++ }));++++ r.post('/skills/:id/members/remove', actorLimits.budget('codes.skill.write'), json, guard(async (req, res) => {++ if (!need(req, res)) return undefined;++ const members = await skills.removeMember({ subject: req.viewer.subject, id: req.params.id, memberSubject: req.body && req.body.subject });++ return res.json({ members });++ }));++++ r.get('/me/skills', actorLimits.reads('codes.read'), guard(async (req, res) => {++ if (!signed(req)) return problem(req, res, 401, 'auth.required', 'sign in to sync your skills');++ return res.json(await skills.manifest({ subject: req.viewer.subject, project: projectOf(req) }));++ }));++++ r.get('/me/skills/assignments', actorLimits.reads('codes.read'), guard(async (req, res) => {++ if (!signed(req)) return problem(req, res, 401, 'auth.required', 'sign in to see your sync');++ return res.json({ assignments: await skills.assignments(req.viewer.subject, projectOf(req)) });++ }));++++ r.post('/me/skills/:id', actorLimits.budget('codes.skill.assign'), json, guard(async (req, res) => {++ if (!need(req, res)) return undefined;++ const skill = await skills.assign({ subject: req.viewer.subject, id: req.params.id, project: projectOf(req) });++ return res.status(201).json({ skill });++ }));++++ r.post('/me/skills/:id/remove', actorLimits.budget('codes.skill.assign'), json, guard(async (req, res) => {++ if (!need(req, res)) return undefined;++ await skills.unassign({ subject: req.viewer.subject, id: req.params.id, project: projectOf(req) });++ return res.json({ removed: true });++ }));++++ return r;++}++++module.exports = { createSkillsApi };nnew file mode 100644@@@ -0,0 +1,231 @@++'use strict';++++/**++ * The skills hub's pages (the Compute plan §5, E6): the gallery at /skills and one record at /skills/:id.++ *++ * The page is server-rendered and complete without JavaScript. Only public records are in the gallery; a link record++ * opens by its URL, a people record for its members, and a private one for its owner. A skill's body is untrusted++ * text rendered through server/render/markdown.js, the markup gate (escape first, then headings, lists, code, tables,++ * bold, inline code and http(s) or relative links only), never as HTML.++ */++const express = require('express');++const { asyncRouter } = require('./router');++const { html, raw, table, notice, badge } = require('../render/html');++const { markdown } = require('../render/markdown');++const { send } = require('../render/layout');++const { KINDS, TOOLS, AUDIENCES, SkillError } = require('../domain/skills');++++const KIND_LABELS = { skill: 'Skill', rule: 'Rule', mcp: 'MCP server', command: 'Command', hook: 'Hook' };++const AUDIENCE_LABELS = {++ public: 'Public: in the gallery, search and agents',++ link: 'Link: anyone with the address, not listed',++ people: 'People: you and the members you add',++ private: 'Private: only you',++};++const TOOL_LABELS = {++ 'claude-code': 'Claude Code',++ codex: 'Codex',++ opencode: 'OpenCode',++ 'command-code': 'Command Code',++ 'minimax-code': 'MiniMax Code',++ aider: 'Aider',++ agents: 'AGENTS.md (Codex and friends)',++};++++function createSkillPageRoutes({ config, skills }) {++ const r = asyncRouter();++ const signedIn = (req) => req.viewer && req.viewer.kind === 'user' && req.viewer.subject;++ const sameOrigin = (req) => {++ const origin = req.get('origin');++ if (!origin) return true;++ try { return new URL(origin).origin === new URL(config.baseUrl).origin; } catch { return false; }++ };++ const form = express.urlencoded({ extended: false, limit: '200kb' });++ const page = (req, res, o, status = 200) => send(res, status, { viewer: req.viewer, config, path: req.originalUrl, ...o });++++ const toolsText = (tools) => (tools && tools.length ? tools.map((t) => TOOL_LABELS[t] || t).join(', ') : 'every tool');++ const skillCard = (s, extra = '') => html`<li>++<a href="/skills/${s.id}"><strong>${s.title}</strong></a>++<span>${s.summary || 'No summary yet.'}</span>++<span>${badge(KIND_LABELS[s.kind] || s.kind)} ${badge(`v${s.version}`)} ${s.official ? badge('OpenVibe', 'ok') : ''}</span>++<span class="muted small">${s.slug} · ${toolsText(s.tools)}</span>++${extra}++</li>`;++++ function formFields(s = null, { audience = true } = {}) {++ const v = s || { kind: 'skill', slug: '', title: '', summary: '', body: '', audience: 'private', tools: [] };++ return html`<div class="field"><label for="sk-title">Title</label><input id="sk-title" name="title" maxlength="120" required value="${v.title}"></div>++<div class="field"><label for="sk-slug">Slug (the folder name; lowercase letters, digits and hyphens)</label><input id="sk-slug" name="slug" maxlength="64" pattern="[a-z0-9][a-z0-9-]{0,63}" required value="${v.slug}"></div>++<div class="field"><label for="sk-kind">Kind</label><select id="sk-kind" name="kind">${KINDS.map((k) => html`<option value="${k}" ${k === v.kind ? raw('selected') : ''}>${KIND_LABELS[k]}</option>`)}</select></div>++<div class="field"><label for="sk-summary">Summary (one line, shown in the gallery)</label><input id="sk-summary" name="summary" maxlength="500" value="${v.summary}"></div>++<div class="field"><label for="sk-body">Body (SKILL.md content, Markdown)</label><textarea id="sk-body" name="body" rows="16" maxlength="131072" required>${v.body}</textarea></div>++${audience ? html`<div class="field"><label for="sk-audience">Who may read it</label><select id="sk-audience" name="audience">${AUDIENCES.map((a) => html`<option value="${a}" ${a === v.audience ? raw('selected') : ''}>${AUDIENCE_LABELS[a]}</option>`)}</select></div>` : ''}++<fieldset class="field"><legend>Tools</legend>${TOOLS.map((t) => html`<label class="check"><input type="checkbox" name="tools" value="${t}" ${v.tools.includes(t) ? raw('checked') : ''}> ${TOOL_LABELS[t]}</label>`)}</fieldset>`;++ }++++ async function galleryPage(req, res, { error = null, status = 200 } = {}) {++ const kind = KINDS.includes(String(req.query.kind || '')) ? String(req.query.kind) : '';++ const q = String(req.query.q || '').slice(0, 80);++ const shown = await skills.gallery({ kind, q });++ const mine = signedIn(req) ? await skills.mine(req.viewer.subject) : [];++ const assigned = signedIn(req) ? new Set((await skills.assignments(req.viewer.subject)).map((a) => a.skill_id)) : new Set();++ const create = signedIn(req)++ ? html`<section aria-labelledby="h-new"><h2 id="h-new">Publish a skill, a rule or an MCP server</h2>++<form class="stack" method="post" action="/skills/new">${formFields()}<button class="run-btn run-btn-primary" type="submit">Save version 1</button></form></section>`++ : html`<p><a class="run-btn" href="/auth/login?next=${encodeURIComponent('/skills')}">Sign in to publish and sync skills</a></p>`;++ return page(req, res, {++ title: 'Skills: the hub for your agents',++ description: 'Skills, rules and MCP servers kept per person and per project, versioned, and synced into Claude Code, Codex, OpenCode and Command Code on your machine.',++ crumbs: [{ label: 'Skills' }],++ body: html`<h1>Skills</h1>++<p class="lede">Skills, rules and MCP servers kept per person and per project, versioned, and synced into each agent tool's own folder on your machine. Add one from the gallery; <code>openvibe-node skills sync</code> writes it inside its marked block, with a dry run, a diff and an undo.</p>++${error ? notice(error, 'bad') : ''}++<form class="inline-form" method="get" action="/skills" role="search">++<label>Search the gallery <input name="q" value="${q}" maxlength="80"></label>++<label>Kind <select name="kind"><option value="">all</option>${KINDS.map((k) => html`<option value="${k}" ${k === kind ? raw('selected') : ''}>${KIND_LABELS[k]}</option>`)}</select></label>++<button class="run-btn" type="submit">Show</button></form>++<section aria-labelledby="h-gallery"><h2 id="h-gallery">The gallery</h2>++${shown.length ? html`<ul class="cards">${shown.map(skillCard)}</ul>` : html`<p class="muted">No public ${kind ? KIND_LABELS[kind].toLowerCase() + 's' : 'records'} yet. The first one published appears here.</p>`}++</section>++${create}++${signedIn(req) ? html`<section aria-labelledby="h-mine"><h2 id="h-mine">Yours</h2>++${mine.length ? html`<ul class="cards">${mine.map((s) => skillCard(s, html`<span class="muted small">${badge(AUDIENCE_LABELS[s.audience].split(':')[0])} ${assigned.has(s.id) ? badge('in your sync', 'ok') : ''}</span>`))}</ul>` : html`<p class="muted">Nothing yet. Publish one above.</p>`}++</section>` : ''}`,++ }, status);++ }++++ async function detailPage(req, res, { error = null, status = 200 } = {}) {++ const skill = await skills.get(req.params.id, req.viewer && req.viewer.kind === 'user' ? { subject: req.viewer.subject } : null);++ if (!skill) return page(req, res, { title: 'Not found', body: html`<h1>Not found</h1><p>No such skill. <a href="/skills">The gallery</a></p>` }, 404);++ const owner = signedIn(req) && req.viewer.subject === skill.owner_subject;++ const versions = await skills.listVersions(skill.id, owner ? { subject: req.viewer.subject } : (signedIn(req) ? { subject: req.viewer.subject } : null)) || [];++ const assigned = signedIn(req) ? (await skills.assignments(req.viewer.subject)).some((a) => a.skill_id === skill.id) : false;++ const members = owner && skill.audience === 'people' ? await skills.members(skill.id, { subject: req.viewer.subject }) : [];++ const controls = owner ? html`<section aria-labelledby="h-edit"><h2 id="h-edit">A new version</h2>++<form class="stack" method="post" action="/skills/${skill.id}/versions"><input type="hidden" name="slug" value="${skill.slug}">${formFields(skill, { audience: false })}<button class="run-btn run-btn-primary" type="submit">Save version ${skill.version + 1}</button></form>++<h2>Who may read it</h2>++<form class="inline-form" method="post" action="/skills/${skill.id}/audience">++<label>Audience <select name="audience">${AUDIENCES.map((a) => html`<option value="${a}" ${a === skill.audience ? raw('selected') : ''}>${AUDIENCE_LABELS[a]}</option>`)}</select></label>++<button class="run-btn" type="submit">Save</button></form>++${skill.audience === 'people' ? html`<h3>Members</h3>${members.length ? html`<ul>${members.map((m) => html`<li><code>${m.subject}</code> ${badge(m.role)} <form class="inline-form" method="post" action="/skills/${skill.id}/members/remove"><input type="hidden" name="subject" value="${m.subject}"><button class="run-btn" type="submit">Remove</button></form></li>`)}</ul>` : html`<p class="muted">Nobody added yet; only you can read it.</p>`}++<form class="inline-form" method="post" action="/skills/${skill.id}/members"><label>Person <input name="subject" placeholder="usr_…" pattern="usr_[0-9A-HJKMNP-TV-Z]{26}" required></label><button class="run-btn" type="submit">Add</button></form>` : ''}++</section>` : '';++ return page(req, res, {++ title: skill.title,++ description: skill.summary || `A ${KIND_LABELS[skill.kind]} on OpenVibe.Codes.`,++ crumbs: [{ label: 'Skills', href: '/skills' }, { label: skill.title }],++ body: html`${error ? notice(error, 'bad') : ''}++<h1>${skill.title}</h1>++<p class="lede">${skill.summary || ''}</p>++<p>${badge(KIND_LABELS[skill.kind])} ${badge(`v${skill.version}`)} ${badge(AUDIENCE_LABELS[skill.audience].split(':')[0])} ${skill.official ? badge('OpenVibe', 'ok') : ''} <span class="muted small">${skill.slug} · ${toolsText(skill.tools)} · updated ${skill.updated_at.replace('T', ' ').replace(/\.\d+Z$/, ' UTC')}</span></p>++${signedIn(req) ? html`<form class="inline-form" method="post" action="/skills/${skill.id}/add"><button class="run-btn run-btn-primary" type="submit">${assigned ? 'Remove from my sync' : 'Add to my sync'}</button></form>` : html`<p><a class="run-btn" href="/auth/login?next=${encodeURIComponent(`/skills/${skill.id}`)}">Sign in to add it to your sync</a></p>`}++<article class="prose">${raw(markdown(skill.body, { headingOffset: 1 }))}</article>++<section aria-labelledby="h-history"><h2 id="h-history">Versions</h2>++${table(['Version', 'When', 'Audience', 'Note'], versions.map((v) => [++ owner ? html`<form class="inline-form" method="post" action="/skills/${skill.id}/rollback"><input type="hidden" name="version" value="${v.version}"><button class="run-btn" type="submit" title="Copy this version into a new one">Roll back to v${v.version}</button></form>` : `v${v.version}`,++ v.created_at.replace('T', ' ').replace(/\.\d+Z$/, ' UTC'),++ v.audience,++ v.note || '—',++ ]))}++<p class="muted small">A rollback copies the chosen version into a new one, so history is never rewritten. The Node records the version it wrote.</p></section>++${controls}`,++ }, status);++ }++++ r.get('/skills', (req, res, next) => galleryPage(req, res).catch(next));++ r.get('/skills/:id', (req, res, next) => detailPage(req, res).catch(next));++++ r.post('/skills/new', form, async (req, res, next) => {++ try {++ if (!signedIn(req)) return res.redirect(303, `/auth/login?next=${encodeURIComponent('/skills')}`);++ if (!sameOrigin(req)) return res.status(403).type('text/plain').send('Cross-site form posts are refused.\n');++ const tools = [].concat(req.body.tools || []).filter((t) => TOOLS.includes(t));++ const input = { ...req.body, tools };++ const skill = await skills.create({ subject: req.viewer.subject, input });++ return res.redirect(303, `/skills/${skill.id}`);++ } catch (err) {++ if (err instanceof SkillError) return galleryPage(req, res, { error: err.message, status: err.status }).catch(next);++ return next(err);++ }++ });++++ r.post('/skills/:id/versions', form, async (req, res, next) => {++ try {++ if (!signedIn(req)) return res.redirect(303, `/auth/login?next=${encodeURIComponent(`/skills/${req.params.id}`)}`);++ if (!sameOrigin(req)) return res.status(403).type('text/plain').send('Cross-site form posts are refused.\n');++ const tools = [].concat(req.body.tools || []).filter((t) => TOOLS.includes(t));++ await skills.addVersion({ subject: req.viewer.subject, id: req.params.id, input: { ...req.body, tools } });++ return res.redirect(303, `/skills/${req.params.id}`);++ } catch (err) {++ if (err instanceof SkillError) return detailPage(req, res, { error: err.message, status: err.status }).catch(next);++ return next(err);++ }++ });++++ r.post('/skills/:id/rollback', form, async (req, res, next) => {++ try {++ if (!signedIn(req)) return res.status(401).type('text/plain').send('Sign in first.\n');++ if (!sameOrigin(req)) return res.status(403).type('text/plain').send('Cross-site form posts are refused.\n');++ await skills.rollback({ subject: req.viewer.subject, id: req.params.id, version: req.body.version });++ return res.redirect(303, `/skills/${req.params.id}`);++ } catch (err) {++ if (err instanceof SkillError) return detailPage(req, res, { error: err.message, status: err.status }).catch(next);++ return next(err);++ }++ });++++ r.post('/skills/:id/audience', form, async (req, res, next) => {++ try {++ if (!signedIn(req)) return res.status(401).type('text/plain').send('Sign in first.\n');++ if (!sameOrigin(req)) return res.status(403).type('text/plain').send('Cross-site form posts are refused.\n');++ await skills.setAudience({ subject: req.viewer.subject, id: req.params.id, audience: req.body.audience });++ return res.redirect(303, `/skills/${req.params.id}`);++ } catch (err) {++ if (err instanceof SkillError) return detailPage(req, res, { error: err.message, status: err.status }).catch(next);++ return next(err);++ }++ });++++ r.post('/skills/:id/members', form, async (req, res, next) => {++ try {++ if (!signedIn(req)) return res.status(401).type('text/plain').send('Sign in first.\n');++ if (!sameOrigin(req)) return res.status(403).type('text/plain').send('Cross-site form posts are refused.\n');++ await skills.addMember({ subject: req.viewer.subject, id: req.params.id, memberSubject: req.body.subject });++ return res.redirect(303, `/skills/${req.params.id}`);++ } catch (err) {++ if (err instanceof SkillError) return detailPage(req, res, { error: err.message, status: err.status }).catch(next);++ return next(err);++ }++ });++++ r.post('/skills/:id/members/remove', form, async (req, res, next) => {++ try {++ if (!signedIn(req)) return res.status(401).type('text/plain').send('Sign in first.\n');++ if (!sameOrigin(req)) return res.status(403).type('text/plain').send('Cross-site form posts are refused.\n');++ await skills.removeMember({ subject: req.viewer.subject, id: req.params.id, memberSubject: req.body.subject });++ return res.redirect(303, `/skills/${req.params.id}`);++ } catch (err) {++ if (err instanceof SkillError) return detailPage(req, res, { error: err.message, status: err.status }).catch(next);++ return next(err);++ }++ });++++ // The one page action that is not the owner's: add or remove a record from this person's sync.++ r.post('/skills/:id/add', form, async (req, res, next) => {++ try {++ if (!signedIn(req)) return res.redirect(303, `/auth/login?next=${encodeURIComponent(`/skills/${req.params.id}`)}`);++ if (!sameOrigin(req)) return res.status(403).type('text/plain').send('Cross-site form posts are refused.\n');++ const on = (await skills.assignments(req.viewer.subject)).some((a) => a.skill_id === req.params.id);++ if (on) await skills.unassign({ subject: req.viewer.subject, id: req.params.id });++ else await skills.assign({ subject: req.viewer.subject, id: req.params.id });++ return res.redirect(303, `/skills/${req.params.id}`);++ } catch (err) {++ if (err instanceof SkillError) return detailPage(req, res, { error: err.message, status: err.status }).catch(next);++ return next(err);++ }++ });++++ return r;++}++++module.exports = { createSkillPageRoutes };@@@ -27,6 +27,7 @@ const NAV = [{ label: 'Get started', href: '/start', icon: 'fa-play' },{ label: 'Run on your machine', href: '/run', icon: 'fa-microchip' },{ label: 'Agents', href: '/harnesses', icon: 'fa-robot' },++ { label: 'Skills', href: '/skills', icon: 'fa-puzzle-piece' },{ label: 'Improve OpenVibe', href: '/improve', icon: 'fa-code', more: true },{ label: 'Community', href: '/policy', icon: 'fa-people-group', more: true },];@@@ -146,17 +146,31 @@ function createSharing({ db, config, fetchImpl = globalThis.fetch, log = console}return true;},-- /** The uniform API's describe(ref): the task's first line and its page. */++ /** The uniform API's describe(ref): the item's title and its page. */describe: async (ref) => {-- if (ref.service !== SERVICE || ref.kind !== KIND) return {};-- const row = await db.maybe('SELECT prompt FROM run_tasks WHERE id = $1', [String(ref.id)]);-- if (!row) return {};-- return {-- title: String(row.prompt || 'A task').split('\n')[0].slice(0, 90),-- link: `${config.baseUrl}/run/tasks/${encodeURIComponent(ref.id)}`,-- roles: ['viewer'],-- transferable: false,-- };++ if (ref.service !== SERVICE) return {};++ if (ref.kind === KIND) {++ const row = await db.maybe('SELECT prompt FROM run_tasks WHERE id = $1', [String(ref.id)]);++ if (!row) return {};++ return {++ title: String(row.prompt || 'A task').split('\n')[0].slice(0, 90),++ link: `${config.baseUrl}/run/tasks/${encodeURIComponent(ref.id)}`,++ roles: ['viewer'],++ transferable: false,++ };++ }++ // A skill in the hub: the same four audiences and members, its own page, never transferable.++ if (ref.kind === 'skill') {++ const row = await db.maybe('SELECT title FROM codes_skills WHERE id = $1', [String(ref.id)]);++ if (!row) return {};++ return {++ title: String(row.title || 'A skill').slice(0, 90),++ link: `${config.baseUrl}/skills/${encodeURIComponent(ref.id)}`,++ roles: ['viewer'],++ transferable: false,++ };++ }++ return {};},/** Expire members and transfers, then send the queued events and notifications. */work: async ({ flush = true } = {}) => {nnew file mode 100644@@@ -0,0 +1,187 @@++'use strict';++/**++ * The skills hub (the Compute plan §5, E6): versioned skills, rules and MCP servers for people and projects, shared++ * through openvibe-sdk/share, with the gallery showing public records only.++ *++ * - a new record is private and is not in the gallery;++ * - public is listed and readable anonymously, link is readable by id but not listed, private answers 404;++ * - saving a version keeps the old one, and a rollback copies it into a new version (history is never rewritten);++ * - the Node's sync manifest is exactly the person's picks, at their newest version;++ * - the page renders a skill body through the markup gate, never as HTML.++ */++const assert = require('assert');++const { boot, check, done } = require('./helpers/boot');++++(async () => {++ const t = await boot();++ const ada = t.network.addUser('ada');++ const bo = t.network.addUser('bo');++++ const api = (p, o = {}) => t.get(p, { as: o.as, json: o.json, method: o.method, headers: o.headers });++ const bearer = t.network.userToken(ada);++ const create = async (as, body) => api('/api/v1/skills', { as, json: body, method: 'POST' });++ const skillBody = (name, note = '') => `---\nname: ${name}\ndescription: A test skill.\n---\n\n# ${name}\n\n${note}\n`;++ const base = (over = {}) => ({++ kind: 'skill', slug: `skill-${Math.random().toString(36).slice(2, 8)}`, title: 'A skill',++ summary: 'One line about the skill.', body: skillBody('a-skill'), audience: 'private', tools: ['claude-code'], ...over,++ });++ const gallery = async (o = {}) => (await api(`/api/v1/skills${o.query || ''}`, o)).json();++++ try {++ let pub;++ let priv;++ let link;++++ await check('a new record is private, v1, and not in the gallery', async () => {++ const made = await create(ada, base({ title: 'Private notes' }));++ assert.strictEqual(made.status, 201, made.text);++ priv = made.json().skill;++ assert.strictEqual(priv.audience, 'private');++ assert.strictEqual(priv.version, 1);++ assert.strictEqual(priv.owner_subject, ada.subject);++ const list = await gallery();++ assert.ok(!list.skills.some((s) => s.id === priv.id), 'a private record is not listed');++ const anon = await api(`/api/v1/skills/${priv.id}`);++ assert.strictEqual(anon.status, 404);++ const owner = await api(`/api/v1/skills/${priv.id}`, { as: ada });++ assert.strictEqual(owner.status, 200);++ });++++ await check('public is listed and readable; link is readable by id but not listed', async () => {++ pub = (await create(ada, base({ title: 'Public ship', slug: 'public-ship', audience: 'public' }))).json().skill;++ link = (await create(ada, base({ title: 'Secret link', slug: 'secret-link', audience: 'link' }))).json().skill;++ const list = await gallery();++ assert.ok(list.skills.some((s) => s.id === pub.id));++ assert.ok(!list.skills.some((s) => s.id === link.id), 'a link record is not in the gallery');++ const anon = await api(`/api/v1/skills/${link.id}`);++ assert.strictEqual(anon.status, 200);++ assert.strictEqual(anon.json().skill.audience, 'link');++ });++++ await check('versions grow and a rollback copies the old body into a new version', async () => {++ const v2 = await api(`/api/v1/skills/${pub.id}/versions`, {++ as: ada, method: 'POST', json: { body: skillBody('public-ship', 'The second version.'), summary: 'Second' },++ });++ assert.strictEqual(v2.status, 201, v2.text);++ assert.strictEqual(v2.json().skill.version, 2);++ assert.ok(v2.json().skill.body.includes('The second version.'));++ const first = await api(`/api/v1/skills/${pub.id}/versions/1`, { as: ada });++ assert.strictEqual(first.status, 200);++ const rolled = await api(`/api/v1/skills/${pub.id}/rollback`, { as: ada, method: 'POST', json: { version: 1 } });++ assert.strictEqual(rolled.status, 201, rolled.text);++ assert.strictEqual(rolled.json().skill.version, 3);++ assert.strictEqual(rolled.json().skill.body, first.json().version.body);++ const history = await api(`/api/v1/skills/${pub.id}/versions`, { as: ada });++ assert.deepStrictEqual(history.json().versions.map((v) => v.version), [3, 2, 1]);++ assert.strictEqual(history.json().versions[0].note, 'rolled back to 1');++ const old = await api(`/api/v1/skills/${pub.id}/versions/2`, { as: ada });++ assert.ok(old.json().version.body.includes('The second version.'), 'an old version is never rewritten');++ });++++ await check('the four audiences move the record in and out of the gallery', async () => {++ for (const audience of ['people', 'link', 'private']) {++ const moved = await api(`/api/v1/skills/${pub.id}/audience`, { as: ada, method: 'POST', json: { audience } });++ assert.strictEqual(moved.status, 200, moved.text);++ assert.strictEqual(moved.json().skill.audience, audience);++ const list = await gallery();++ assert.ok(!list.skills.some((s) => s.id === pub.id), `${audience} is not in the gallery`);++ }++ const back = await api(`/api/v1/skills/${pub.id}/audience`, { as: ada, method: 'POST', json: { audience: 'public' } });++ assert.strictEqual(back.status, 200);++ const list = await gallery();++ assert.ok(list.skills.some((s) => s.id === pub.id));++ });++++++ await check('the Share sheet is openvibe-sdk/share: the owner sets the audience, a stranger gets 404', async () => {++ const share = (o = {}) => t.get(`/api/v1/skill/${pub.id}/share`, {++ as: o.as, method: o.method, json: o.json,++ headers: { 'x-requested-with': 'ov-share', ...(o.headers || {}) },++ });++ const read = await share({ as: ada });++ assert.strictEqual(read.status, 200, read.text);++ assert.strictEqual(read.json().audience, 'public');++ const etag = read.headers.get('etag');++ assert.ok(etag, 'the share sheet carries an ETag');++ const moved = await share({ as: ada, method: 'PUT', json: { audience: 'people' }, headers: { 'if-match': etag } });++ assert.strictEqual(moved.status, 200, moved.text);++ assert.strictEqual(moved.json().audience, 'people');++ const listed = await gallery();++ assert.ok(!listed.skills.some((s) => s.id === pub.id), 'people is not in the gallery');++ const stranger = await share({ as: bo });++ assert.strictEqual(stranger.status, 404, 'a non-owner cannot read the share settings');++ const back = await share({ as: ada, method: 'PUT', json: { audience: 'public' } });++ assert.strictEqual(back.status, 200, back.text);++ });++++++ await check('a people record opens for a member and for nobody else', async () => {++ const people = (await create(ada, base({ title: 'For the team', slug: 'for-the-team', audience: 'people' }))).json().skill;++ const member = JSON.stringify({ type: 'user', id: bo.subject });++ const owner = JSON.stringify({ type: 'user', id: ada.subject });++ await t.ctx.store.db.exec(`INSERT INTO share_members (service, kind, resource_id, subject, role, added_by, added_at)++ VALUES ('codes', 'skill', $1, $2::jsonb, 'viewer', $3::jsonb, now())`, [people.id, member, owner]);++ const allowed = await api(`/api/v1/skills/${people.id}`, { as: bo });++ assert.strictEqual(allowed.status, 200, allowed.text);++ assert.strictEqual(allowed.json().skill.id, people.id);++ const anon = await api(`/api/v1/skills/${people.id}`);++ assert.strictEqual(anon.status, 404, 'a people record is hidden from a stranger');++ const listed = await gallery();++ assert.ok(!listed.skills.some((s) => s.id === people.id), 'a people record is not in the gallery');++ });++++ await check('the sync manifest is each person\'s own picks', async () => {++ const now = (await api(`/api/v1/skills/${pub.id}`, { as: ada })).json().skill;++ const added = await api(`/api/v1/me/skills/${pub.id}`, { as: ada, method: 'POST', json: { project: 'demo' } });++ assert.strictEqual(added.status, 201, added.text);++ const mine = (await api('/api/v1/me/skills?project=demo', { as: ada })).json();++ assert.ok(mine.skills.some((s) => s.id === pub.id), 'the project pick is in the manifest');++ assert.strictEqual(mine.skills.find((s) => s.id === pub.id).version, now.version);++ const other = (await api('/api/v1/me/skills?project=demo', { as: bo })).json();++ assert.ok(!other.skills.some((s) => s.id === pub.id), 'somebody else\'s picks are not in this manifest');++ const removed = await api(`/api/v1/me/skills/${pub.id}/remove`, { as: ada, method: 'POST', json: { project: 'demo' } });++ assert.strictEqual(removed.status, 200);++ const after = (await api('/api/v1/me/skills?project=demo', { as: ada })).json();++ assert.ok(!after.skills.some((s) => s.id === pub.id));++ });++++ await check('the Node\'s sync authenticates the manifest with a bearer token', async () => {++ const res = await t.get('/api/v1/me/skills?project=demo', { headers: { authorization: `Bearer ${bearer}` } });++ assert.strictEqual(res.status, 200, res.text);++ assert.ok(Array.isArray(res.json().skills), 'the manifest is a list');++ });++++ await check('a body is untrusted: the page goes through the markup gate', async () => {++ const made = (await create(ada, base({ title: 'Markup test', slug: 'markup-test', audience: 'public', body: '# Safe heading\n\n<script>alert(1)</script>\n\n[bad](javascript:alert(1))' }))).json().skill;++ const page = await t.get(`/skills/${made.id}`, { as: ada });++ assert.strictEqual(page.status, 200);++ assert.ok(page.text.includes('Safe heading'));++ assert.ok(page.text.includes('<script>alert(1)</script>'), 'the script is shown as text');++ assert.ok(!page.text.includes('<script>alert(1)</script>'));++ assert.ok(!page.text.includes('href="javascript:'), 'a javascript: link is not a link');++ });++++ await check('invalid input and a duplicate slug are refused', async () => {++ const badKind = await create(ada, base({ kind: 'plugin' }));++ assert.strictEqual(badKind.status, 422);++ assert.strictEqual(badKind.json().code, 'skill.invalid');++ const badAudience = await create(ada, base({ audience: 'everyone' }));++ assert.strictEqual(badAudience.status, 422);++ const dup = await create(ada, base({ slug: 'public-ship', title: 'Another' }));++ assert.strictEqual(dup.status, 409);++ assert.strictEqual(dup.json().code, 'skill.slug_taken');++ });++++ await check('the gallery page lists public records and not private ones', async () => {++ const page = await t.get('/skills');++ assert.strictEqual(page.status, 200);++ assert.ok(page.text.includes('Public ship'));++ assert.ok(!page.text.includes('Private notes'));++ const mine = await t.get('/skills', { as: ada });++ assert.ok(mine.text.includes('Private notes'), 'the owner sees their own record on the page');++ });++ } finally {++ await t.close();++ }++ done();++})().catch((err) => { console.error(err); process.exit(1); });
@@ -22,6 +22,7 @@ One place to send a coding task to the agent that fits it — by what the task n
@@ -22,6 +22,7 @@ One place to send a coding task to the agent that fits it — by what the task n
| Get started | `/start` | Install, the agents and what each needs, the commands, permission levels, hand-offs, the event format and using it from code. |
| Get started | `/start` | Install, the agents and what each needs, the commands, permission levels, hand-offs, the event format and using it from code. |
| Run on your machine | `/run`, `/run/tasks/:id` ([server/http/run.js](server/http/run.js)) | A signed-in person starts a coding task in any browser and their own machine runs it: an OpenVibe Run job (class `code`, artifact `[email protected]`, `trust: user-owned`) that Codes starts for them in their project (project delegation: `X-OV-Subject`, `X-OV-Project`; [server/domain/run-client.js](server/domain/run-client.js)). Run places it only on a machine of theirs (added on openvibe.bot/machines), whose OpenVibe Node runs `openvibe-codes job` ([harness/job.js](harness/job.js)) in its job sandbox: clone, run the agents with hand-offs, hand back the summary, the changed files and the patch, and, when asked and the machine holds `GITHUB_TOKEN`, push a new branch (never the one it started from) and open a pull request. The page streams the job's lines (Run's job stream through Codes) and keeps the result (`run_tasks`, migration 0003). Each task is its owner's; account export and deletion cover them (ADR-033, [server/account-data.js](server/account-data.js)). The task page shows the **timeline of attempts** above the agents' **session** (each attempt with its why: "Codex hit its limit, so Command Code took over with what it had done") and the session with openvibe-shared/agent-session.js: steps grouped, each step's command, change and output (live while it runs), helpers nested, hand-offs and the plan; after the end, the session from `codes_session_lines` (migration 0007): the lines are kept as the stream arrives, back-filled from the machine's final log, and replayed at `GET /run/tasks/:id/session.json?after=` (the owner, or a service with `codes.task.read` and `X-OV-Subject`); tasks from before keep the old `log`. Run's `run.job.*` events finalize a task at `POST /internal/events` ([server/events-consumer.js](server/events-consumer.js), no polling but a 10-minute fallback), and every state change is published as `codes.task.queued|running|succeeded|failed|cancelled` from an outbox ([server/events/outbox.js](server/events/outbox.js), contracts 0.232.0). |
| Run on your machine | `/run`, `/run/tasks/:id` ([server/http/run.js](server/http/run.js)) | A signed-in person starts a coding task in any browser and their own machine runs it: an OpenVibe Run job (class `code`, artifact `[email protected]`, `trust: user-owned`) that Codes starts for them in their project (project delegation: `X-OV-Subject`, `X-OV-Project`; [server/domain/run-client.js](server/domain/run-client.js)). Run places it only on a machine of theirs (added on openvibe.bot/machines), whose OpenVibe Node runs `openvibe-codes job` ([harness/job.js](harness/job.js)) in its job sandbox: clone, run the agents with hand-offs, hand back the summary, the changed files and the patch, and, when asked and the machine holds `GITHUB_TOKEN`, push a new branch (never the one it started from) and open a pull request. The page streams the job's lines (Run's job stream through Codes) and keeps the result (`run_tasks`, migration 0003). Each task is its owner's; account export and deletion cover them (ADR-033, [server/account-data.js](server/account-data.js)). The task page shows the **timeline of attempts** above the agents' **session** (each attempt with its why: "Codex hit its limit, so Command Code took over with what it had done") and the session with openvibe-shared/agent-session.js: steps grouped, each step's command, change and output (live while it runs), helpers nested, hand-offs and the plan; after the end, the session from `codes_session_lines` (migration 0007): the lines are kept as the stream arrives, back-filled from the machine's final log, and replayed at `GET /run/tasks/:id/session.json?after=` (the owner, or a service with `codes.task.read` and `X-OV-Subject`); tasks from before keep the old `log`. Run's `run.job.*` events finalize a task at `POST /internal/events` ([server/events-consumer.js](server/events-consumer.js), no polling but a 10-minute fallback), and every state change is published as `codes.task.queued|running|succeeded|failed|cancelled` from an outbox ([server/events/outbox.js](server/events/outbox.js), contracts 0.232.0). |
| Knowledge | `/knowledge`, `GET/POST /api/v1/knowledge` ([server/http/knowledge.js](server/http/knowledge.js), [server/domain/knowledge.js](server/domain/knowledge.js)) | Workspace knowledge (Know K5): when a run ends, Codes captures what it taught — the agent's `Learned:` lines, a failure with its reason, the command that failed and the commands the repository uses — redacts secrets and stores it as a private row with its source run (`codes_knowledge`, migration 0025). The next run's job carries the relevant rows in its prompt before the brief, for every harness ([harness/job.js](harness/job.js) `agentPrompt`), so a pin bump, a test command or a trap is recalled without re-reading files. The page shows every row with its run, and edits, pins, shares (with a Network project's team) and deletes it; private by default. The same rows are exported and erased with the account ([server/account-data.js](server/account-data.js)); `ov-know`'s `knowledge_search`/`knowledge_add` ([know/knowledge.js](https://github.com/OpenVibers/openvibe-agents)) answer them for the person's own agents. |
| Knowledge | `/knowledge`, `GET/POST /api/v1/knowledge` ([server/http/knowledge.js](server/http/knowledge.js), [server/domain/knowledge.js](server/domain/knowledge.js)) | Workspace knowledge (Know K5): when a run ends, Codes captures what it taught — the agent's `Learned:` lines, a failure with its reason, the command that failed and the commands the repository uses — redacts secrets and stores it as a private row with its source run (`codes_knowledge`, migration 0025). The next run's job carries the relevant rows in its prompt before the brief, for every harness ([harness/job.js](harness/job.js) `agentPrompt`), so a pin bump, a test command or a trap is recalled without re-reading files. The page shows every row with its run, and edits, pins, shares (with a Network project's team) and deletes it; private by default. The same rows are exported and erased with the account ([server/account-data.js](server/account-data.js)); `ov-know`'s `knowledge_search`/`knowledge_add` ([know/knowledge.js](https://github.com/OpenVibers/openvibe-agents)) answer them for the person's own agents. |
+| Skills hub | `/skills`, `/api/v1/skills`, `/api/v1/me/skills` ([server/http/skills-pages.js](server/http/skills-pages.js), [server/domain/skills.js](server/domain/skills.js)) | Skills, rules, MCP servers, commands and hooks kept per person and per project, versioned (`codes_skills`, `codes_skill_versions`, migration 0028): every save is an immutable version, a rollback copies an old version into a new one, and the body is rendered through the markup gate. Sharing is `openvibe-sdk/share`'s four audiences (`public`, `link`, `people`, `private`); only `public` is in the gallery, and the share sheet is the SDK's at `/api/v1/skill/:id/share`. A person adds a record to their sync, and `openvibe-node skills sync` writes it into Claude Code, Codex, OpenCode and Command Code inside one marked block, with a dry run, a diff and an undo. |
| The agents | `/harnesses` | Every harness in [`server/data/harness-offers.json`](server/data/harness-offers.json) (validated at boot as `platform.harness-offer@1`, each model as `platform.agent-offer@1`) with its provider, address kind, what it can do, price and limits, and its models with context limits and token prices. No local path is ever shown. |
| The agents | `/harnesses` | Every harness in [`server/data/harness-offers.json`](server/data/harness-offers.json) (validated at boot as `platform.harness-offer@1`, each model as `platform.agent-offer@1`) with its provider, address kind, what it can do, price and limits, and its models with context limits and token prices. No local path is ever shown. |
| Task API (services and people) | `POST /api/v1/tasks`, `GET /api/v1/tasks`, `GET /api/v1/tasks/:id`, `GET /api/v1/tasks/:id/events`, `POST /api/v1/tasks/:id/continue`, `POST /api/v1/tasks/:id/stop`, `POST /api/v1/tasks/:id/cancel` ([server/http/tasks-api.js](server/http/tasks-api.js)) | Contracts 0.162 `codes.task.start` / `read` / `cancel`; `permission` is `review` (read-only), `edit` or `full`. A person may call the same routes with their own Network access token (`openvibe-codes task`, [docs/cli.md](docs/cli.md)): their token names them, so they see and act on every task they own, whoever started it. `GET /api/v1/tasks?state=running|needs|done` lists them. A first-party service (OpenVibe.Actor) holding the capability starts a coding task for a person named in `X-OV-Subject`, with a Network service token for audience `openvibe.codes` (`CODES_AUDIENCE`). The task is the same one `/run` starts ([server/domain/run-tasks.js](server/domain/run-tasks.js)): it runs on that person's own machine, in the project of their latest `/run` task (none yet → 409 `codes.no_project`). It shows on their `/run` page. `idempotency_key` answers a retry with the first task (200, `created: false`). A service reads and cancels only the tasks it started for that person (otherwise 404); the owner needs no token for their own tasks. The result the machine returned is passed on only as `codes.task@1` allows, each field checked and cut to its limit (migration 0005: `via`, `idempotency_key`). Calls count against the person's budget: starts and cancels 6 a minute, 60 an hour. **A task's attempts** (stage C2, migration 0010 `codes_attempts`): every Run job is one attempt, so a continue, a switch to another agent and a wrap-up are attempts on one timeline, never new tasks. `GET /api/v1/tasks/:id` answers `codes.task@2`: the task's own fields at the top level, exactly as `codes.task@1` had them, and, added to them, `attempts[]` (the timeline) and `outcome` — `done`, `unfinished` (with what is left) or `stopped`. `GET /api/v1/tasks/:id/events` is the task's event stream (server-sent events): the session's lines in order with the attempt each belongs to, the attempts themselves (started, ended with why), a heartbeat every 15 s, and a resume with `Last-Event-ID`. `POST /api/v1/tasks/:id/continue` starts the next attempt now (with `agent` it switches), `POST /api/v1/tasks/:id/stop` stops the running attempt and nothing carries it on. |
| Task API (services and people) | `POST /api/v1/tasks`, `GET /api/v1/tasks`, `GET /api/v1/tasks/:id`, `GET /api/v1/tasks/:id/events`, `POST /api/v1/tasks/:id/continue`, `POST /api/v1/tasks/:id/stop`, `POST /api/v1/tasks/:id/cancel` ([server/http/tasks-api.js](server/http/tasks-api.js)) | Contracts 0.162 `codes.task.start` / `read` / `cancel`; `permission` is `review` (read-only), `edit` or `full`. A person may call the same routes with their own Network access token (`openvibe-codes task`, [docs/cli.md](docs/cli.md)): their token names them, so they see and act on every task they own, whoever started it. `GET /api/v1/tasks?state=running|needs|done` lists them. A first-party service (OpenVibe.Actor) holding the capability starts a coding task for a person named in `X-OV-Subject`, with a Network service token for audience `openvibe.codes` (`CODES_AUDIENCE`). The task is the same one `/run` starts ([server/domain/run-tasks.js](server/domain/run-tasks.js)): it runs on that person's own machine, in the project of their latest `/run` task (none yet → 409 `codes.no_project`). It shows on their `/run` page. `idempotency_key` answers a retry with the first task (200, `created: false`). A service reads and cancels only the tasks it started for that person (otherwise 404); the owner needs no token for their own tasks. The result the machine returned is passed on only as `codes.task@1` allows, each field checked and cut to its limit (migration 0005: `via`, `idempotency_key`). Calls count against the person's budget: starts and cancels 6 a minute, 60 an hour. **A task's attempts** (stage C2, migration 0010 `codes_attempts`): every Run job is one attempt, so a continue, a switch to another agent and a wrap-up are attempts on one timeline, never new tasks. `GET /api/v1/tasks/:id` answers `codes.task@2`: the task's own fields at the top level, exactly as `codes.task@1` had them, and, added to them, `attempts[]` (the timeline) and `outcome` — `done`, `unfinished` (with what is left) or `stopped`. `GET /api/v1/tasks/:id/events` is the task's event stream (server-sent events): the session's lines in order with the attempt each belongs to, the attempts themselves (started, ended with why), a heartbeat every 15 s, and a resume with `Last-Event-ID`. `POST /api/v1/tasks/:id/continue` starts the next attempt now (with `agent` it switches), `POST /api/v1/tasks/:id/stop` stops the running attempt and nothing carries it on. |
| Agent API | `GET /api/v1/harnesses`, `POST /api/v1/harnesses/route` | Public, no key. The router takes `{ task, requirements? }` (`task` is `edit`, `review`, `long` or `resume`; `requirements.capabilities` adds hard constraints) and answers `{ selected, reasons, candidates }` from `openvibe-sdk/placement` — hard constraints first (a missing capability excludes an agent, and says so), then cost. RFC 9457 problems (`harness.invalid`, `harness.task_unknown`). |
| Agent API | `GET /api/v1/harnesses`, `POST /api/v1/harnesses/route` | Public, no key. The router takes `{ task, requirements? }` (`task` is `edit`, `review`, `long` or `resume`; `requirements.capabilities` adds hard constraints) and answers `{ selected, reasons, candidates }` from `openvibe-sdk/placement` — hard constraints first (a missing capability excludes an agent, and says so), then cost. RFC 9457 problems (`harness.invalid`, `harness.task_unknown`). |
new file mode 100644
new file mode 100644
@@ -0,0 +1,52 @@
@@ -0,0 +1,52 @@
+-- phase: expand
+-- Codes skills hub (the Compute plan §5, E6): skills, rules, MCP servers, commands and hooks kept per person, per
+-- project and for the gallery; versioned, with the four sharing audiences of openvibe-sdk/share
+-- (public, link, people, private). A record's body lives only in its versions: every save is an immutable row, and a
+-- rollback copies an old version into a new one so a machine that synced version 3 can still name it.
+--
+-- Only public records are in the gallery; link, people and private ones are never listed. The Node sync reads the
+-- person's assignments through GET /api/v1/me/skills, never the gallery.
+CREATE TABLE IF NOT EXISTS codes_skills (
+ id text COLLATE "C" PRIMARY KEY, -- skl_<ULID>
+ owner_subject text COLLATE "C" NOT NULL, -- usr_<ULID>
+ kind text COLLATE "C" NOT NULL
+ CHECK (kind IN ('skill', 'rule', 'mcp', 'command', 'hook')),
+ slug text COLLATE "C" NOT NULL, -- the SKILL.md folder name, stable for the owner
+ title text NOT NULL,
+ official boolean NOT NULL DEFAULT false, -- OpenVibe's own records, shown beside community ones
+ tools jsonb NOT NULL DEFAULT '[]'::jsonb, -- harness ids this record is written for; [] = all
+ latest_version integer NOT NULL DEFAULT 1 CHECK (latest_version >= 1),
+ created_at timestamptz NOT NULL DEFAULT now(),
+ updated_at timestamptz NOT NULL DEFAULT now(),
+ UNIQUE (owner_subject, slug)
+);
+CREATE INDEX IF NOT EXISTS codes_skills_owner ON codes_skills (owner_subject, updated_at DESC);
+-- The audience and the members are openvibe-sdk/share's (shares/share_members, service 'codes', kind 'skill'): this
+-- hub registers every record there, so the same four audiences and the same access check apply as to a run task.
+
+-- One immutable version. The body, summary, tools and audience are copied here so a save never loses what an older
+-- machine wrote; the skill row names the newest.
+CREATE TABLE IF NOT EXISTS codes_skill_versions (
+ skill_id text COLLATE "C" NOT NULL REFERENCES codes_skills(id) ON DELETE CASCADE,
+ version integer NOT NULL CHECK (version >= 1),
+ summary text NOT NULL DEFAULT '',
+ body text NOT NULL CHECK (length(body) BETWEEN 1 AND 131072),
+ tools jsonb NOT NULL DEFAULT '[]'::jsonb,
+ audience text COLLATE "C" NOT NULL
+ CHECK (audience IN ('public', 'link', 'people', 'private')),
+ created_by text COLLATE "C" NOT NULL, -- usr_<ULID> or system
+ note text NOT NULL DEFAULT '',
+ created_at timestamptz NOT NULL DEFAULT now(),
+ PRIMARY KEY (skill_id, version)
+);
+
+-- What one person chose to sync, per project ('' is the person's own, everywhere). The Node asks for the person's
+-- scope plus the slug of the project it is writing into.
+CREATE TABLE IF NOT EXISTS codes_skill_assignments (
+ subject text COLLATE "C" NOT NULL, -- usr_<ULID>
+ project text COLLATE "C" NOT NULL DEFAULT '', -- '' or a project slug
+ skill_id text COLLATE "C" NOT NULL REFERENCES codes_skills(id) ON DELETE CASCADE,
+ created_at timestamptz NOT NULL DEFAULT now(),
+ PRIMARY KEY (subject, project, skill_id)
+);
+CREATE INDEX IF NOT EXISTS codes_skill_assignments_subject ON codes_skill_assignments (subject, project);
@@ -63,6 +63,7 @@ form.stack button { align-self: flex-start; }
@@ -63,6 +63,7 @@ form.stack button { align-self: flex-start; }
form.inline-form { display: inline-flex; flex-wrap: wrap; gap: 6px; align-items: center; margin: 2px 0; }
form.inline-form { display: inline-flex; flex-wrap: wrap; gap: 6px; align-items: center; margin: 2px 0; }
form.inline-box { border: 1px solid var(--border, #1f2d47); border-radius: 8px; padding: 10px 12px; }
form.inline-box { border: 1px solid var(--border, #1f2d47); border-radius: 8px; padding: 10px 12px; }
fieldset { border: 1px solid var(--border, #1f2d47); border-radius: 8px; display: flex; flex-direction: column; gap: 6px; }
fieldset { border: 1px solid var(--border, #1f2d47); border-radius: 8px; display: flex; flex-direction: column; gap: 6px; }
+.field { display: flex; flex-direction: column; gap: 4px; }
.secret-box { display: flex; flex-direction: column; gap: 8px; border: 1px solid var(--warning, #f59e0b); border-radius: 10px; padding: 12px 14px; max-width: 760px; }
.secret-box { display: flex; flex-direction: column; gap: 8px; border: 1px solid var(--warning, #f59e0b); border-radius: 10px; padding: 12px 14px; max-width: 760px; }
.secret-box label { display: flex; flex-direction: column; gap: 4px; }
.secret-box label { display: flex; flex-direction: column; gap: 4px; }
.secret-box input { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; width: 100%; }
.secret-box input { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; width: 100%; }
@@ -50,6 +50,14 @@ const TABLES = [
@@ -50,6 +50,14 @@ const TABLES = [
{ table: 'codes_knowledge', subject: 'subject', file: 'knowledge.json', order: 'created_at', columns: ['id', 'kind', 'text', 'repo', 'paths', 'visibility', 'team_id', 'pinned', 'redacted', 'seen', 'source_task', 'source_agent', 'created_at', 'updated_at', 'last_seen_at'] },
{ table: 'codes_knowledge', subject: 'subject', file: 'knowledge.json', order: 'created_at', columns: ['id', 'kind', 'text', 'repo', 'paths', 'visibility', 'team_id', 'pinned', 'redacted', 'seen', 'source_task', 'source_agent', 'created_at', 'updated_at', 'last_seen_at'] },
// The person's default audience for new run tasks (design 2026-11-06): what their next task starts as.
// The person's default audience for new run tasks (design 2026-11-06): what their next task starts as.
{ table: 'codes_share_defaults', subject: 'subject', file: 'share-defaults.json', order: 'updated_at' },
{ table: 'codes_share_defaults', subject: 'subject', file: 'share-defaults.json', order: 'updated_at' },
+ // The skills hub (Compute plan §5): the records they own and the ones they chose to sync. A record's body lives
+ // in its versions, which leave with the record; the share rows are erased in extraErase with the record's ids.
+ {
+ table: 'codes_skills', subject: 'owner_subject', file: 'skills.json', order: 'created_at',
+ columns: ['id', 'kind', 'slug', 'title', 'official', 'tools', 'latest_version', 'created_at', 'updated_at'],
+ erase: { keep: 'deleted in extraErase with its versions, assignments and share rows' },
+ },
+ { table: 'codes_skill_assignments', subject: 'subject', file: 'skill-assignments.json', order: 'created_at', columns: ['skill_id', 'project', 'created_at'] },
];
];
// The rows that hang under a person's tasks: exported under the task, erased before it.
// The rows that hang under a person's tasks: exported under the task, erased before it.
@@ -101,6 +109,15 @@ function create({ db, log = console }) {
@@ -101,6 +109,15 @@ function create({ db, log = console }) {
}
}
delete retained.run_tasks;
delete retained.run_tasks;
add(erased, 'run_tasks', await t.exec('DELETE FROM run_tasks WHERE subject = ANY($1::text[])', [subjects]));
add(erased, 'run_tasks', await t.exec('DELETE FROM run_tasks WHERE subject = ANY($1::text[])', [subjects]));
+ // The person's own skills, their versions (ON DELETE CASCADE) and the share rows of those records.
+ const skills = await t.many('SELECT id FROM codes_skills WHERE owner_subject = ANY($1::text[])', [subjects]);
+ if (skills.length) {
+ const ids = skills.map((r) => r.id);
+ await t.exec("DELETE FROM share_members WHERE service = 'codes' AND kind = 'skill' AND resource_id = ANY($1::text[])", [ids]);
+ await t.exec("DELETE FROM share_access_requests WHERE service = 'codes' AND kind = 'skill' AND resource_id = ANY($1::text[])", [ids]);
+ await t.exec("DELETE FROM shares WHERE service = 'codes' AND kind = 'skill' AND resource_id = ANY($1::text[])", [ids]);
+ add(erased, 'codes_skills', await t.exec('DELETE FROM codes_skills WHERE id = ANY($1::text[])', [ids]));
+ }
// The sharing system's own rows (openvibe-sdk/share): a member or an access request is this person, and
// The sharing system's own rows (openvibe-sdk/share): a member or an access request is this person, and
// the share rows they own carry their subject too. The subject column is jsonb, so the table map cannot
// the share rows they own carry their subject too. The subject column is jsonb, so the table map cannot
// express it and they are erased here.
// express it and they are erased here.
@@ -120,6 +137,9 @@ function create({ db, log = console }) {
@@ -120,6 +137,9 @@ function create({ db, log = console }) {
const EXEMPT = {
const EXEMPT = {
share_members: 'the sharing system own member rows: the subject is a jsonb subject, so they are erased in extraErase',
share_members: 'the sharing system own member rows: the subject is a jsonb subject, so they are erased in extraErase',
share_access_requests: 'the sharing system own access requests: the subject is a jsonb subject, so they are erased in extraErase',
share_access_requests: 'the sharing system own access requests: the subject is a jsonb subject, so they are erased in extraErase',
+ // created_by is the author of a version, not the person whose account is being erased: a record's versions leave
+ // with the record (codes_skills), and a version never outlives it.
+ codes_skill_versions: 'created_by is the author of a version, not a subject column; versions leave with their skill',
};
};
module.exports = { create, TABLES, TASK_KEYED, TOPICS, EXEMPT };
module.exports = { create, TABLES, TASK_KEYED, TOPICS, EXEMPT };
@@ -28,6 +28,9 @@ const { createSso } = require('./auth/sso');
@@ -28,6 +28,9 @@ const { createSso } = require('./auth/sso');
const { createHarnesses } = require('./domain/harnesses');
const { createHarnesses } = require('./domain/harnesses');
const { createPageRoutes } = require('./http/pages');
const { createPageRoutes } = require('./http/pages');
const { createSharing } = require('./sharing');
const { createSharing } = require('./sharing');
+const { createSkills } = require('./domain/skills');
+const { createSkillsApi } = require('./http/skills-api');
+const { createSkillPageRoutes } = require('./http/skills-pages');
const { createRunRoutes } = require('./http/run');
const { createRunRoutes } = require('./http/run');
const { createTasksApi } = require('./http/tasks-api');
const { createTasksApi } = require('./http/tasks-api');
const { createConnectionsApi } = require('./http/connections-api');
const { createConnectionsApi } = require('./http/connections-api');
@@ -132,8 +135,10 @@ async function createApp(opts = {}) {
@@ -132,8 +135,10 @@ async function createApp(opts = {}) {
const knowledge = createKnowledge({ store, config, fetchImpl: fetchImpl || globalThis.fetch, log });
const knowledge = createKnowledge({ store, config, fetchImpl: fetchImpl || globalThis.fetch, log });
// The one sharing system (design 2026-11-06): a run task is its owner's until the Share sheet says otherwise.
// The one sharing system (design 2026-11-06): a run task is its owner's until the Share sheet says otherwise.
const sharing = opts.sharing || createSharing({ db: store.db, config, fetchImpl: fetchImpl || globalThis.fetch, log });
const sharing = opts.sharing || createSharing({ db: store.db, config, fetchImpl: fetchImpl || globalThis.fetch, log });
+ // The skills hub (Compute plan §5, E6): versioned skills, rules and MCP servers, shared through the same SDK.
+ const skills = opts.skills || createSkills({ store, sharing, log });
const tasks = createRunTasks({ store, run, config, log, sessions, outbox, runTokens, connections, attempts, fleet, notices, placementContext, harnesses, knowledge, sharing, benchAttemptEnded: opts.benchAttemptEnded || benchPublisher.onTaskTerminal });
const tasks = createRunTasks({ store, run, config, log, sessions, outbox, runTokens, connections, attempts, fleet, notices, placementContext, harnesses, knowledge, sharing, benchAttemptEnded: opts.benchAttemptEnded || benchPublisher.onTaskTerminal });
- const ctx = { config, store, keys, aiCatalogConfig, sso, harnesses, log, sessions, outbox, run, tasks, connections, aiCatalog, runTokens, attempts, fleet, notices, notify, userSettings, benchPublisher, placementOutcomes, benchPriors, placementContext, knowledge, sharing };
+ const ctx = { config, store, keys, aiCatalogConfig, sso, harnesses, log, sessions, outbox, run, tasks, connections, aiCatalog, runTokens, attempts, fleet, notices, notify, userSettings, benchPublisher, placementOutcomes, benchPriors, placementContext, knowledge, sharing, skills };
ctx.forgejo = createForgejoClient({ config, log });
ctx.forgejo = createForgejoClient({ config, log });
// The C4b screens: the person's machines (from Network), their own choices, the GitHub reader and their usage.
// The C4b screens: the person's machines (from Network), their own choices, the GitHub reader and their usage.
ctx.machines = createMachines({ config, fetchImpl: fetchImpl || globalThis.fetch, log });
ctx.machines = createMachines({ config, fetchImpl: fetchImpl || globalThis.fetch, log });
@@ -294,6 +299,7 @@ async function createApp(opts = {}) {
@@ -294,6 +299,7 @@ async function createApp(opts = {}) {
app.locals.sharingTimer = timer;
app.locals.sharingTimer = timer;
}
}
app.use('/api/v1', createApi(ctx));
app.use('/api/v1', createApi(ctx));
+ app.use('/api/v1', createSkillsApi({ config, skills: ctx.skills, actorLimits: ctx.actorLimits, log }));
app.use('/api/v1', createTasksApi({ config, store, keys, run, harnesses, tasks, fleet: ctx.fleet, actorLimits: ctx.actorLimits, sharing, log }));
app.use('/api/v1', createTasksApi({ config, store, keys, run, harnesses, tasks, fleet: ctx.fleet, actorLimits: ctx.actorLimits, sharing, log }));
app.use('/api/v1', createMeApi({ config, store, sso, run, harnesses, tasks, github: ctx.github, actorLimits: ctx.actorLimits, log }));
app.use('/api/v1', createMeApi({ config, store, sso, run, harnesses, tasks, github: ctx.github, actorLimits: ctx.actorLimits, log }));
// The Dock's agent view (Dock K-D3, Agent Streams S1): the person's own desk stream with a view ticket minted here.
// The Dock's agent view (Dock K-D3, Agent Streams S1): the person's own desk stream with a view ticket minted here.
@@ -312,6 +318,7 @@ async function createApp(opts = {}) {
@@ -312,6 +318,7 @@ async function createApp(opts = {}) {
// ── Pages ───────────────────────────────────────────────
// ── Pages ───────────────────────────────────────────────
app.use(rateLimit({ windowMs: 60_000, limit: Number(process.env.CODES_RATE_LIMIT_PER_MIN) || 300, standardHeaders: true, legacyHeaders: false })); // per address; tests raise it
app.use(rateLimit({ windowMs: 60_000, limit: Number(process.env.CODES_RATE_LIMIT_PER_MIN) || 300, standardHeaders: true, legacyHeaders: false })); // per address; tests raise it
+ app.use(createSkillPageRoutes({ config, skills: ctx.skills }));
const pageRoutes = createPageRoutes(ctx);
const pageRoutes = createPageRoutes(ctx);
// The agent layer (openvibe-shared/agent-layer): /.well-known/agent.json and the Markdown twins of the public
// The agent layer (openvibe-shared/agent-layer): /.well-known/agent.json and the Markdown twins of the public
// pages. The pages themselves keep their own routes — a signed-in visitor's account bar and forms are theirs
// pages. The pages themselves keep their own routes — a signed-in visitor's account bar and forms are theirs
new file mode 100644
new file mode 100644
@@ -0,0 +1,307 @@
@@ -0,0 +1,307 @@
+'use strict';
+
+/**
+ * The Codes skills hub (the Compute plan §5, E6): skills, rules, MCP servers, commands and hooks kept per person and
+ * per project, versioned, and shared through openvibe-sdk/share — the same four audiences (public, link, people,
+ * private), the same access check and the same members table as a run task.
+ *
+ * A record's content lives only in codes_skill_versions; codes_skills names the newest version and what is shown in
+ * the gallery. Every save is a new immutable version, and a rollback copies an old version into a new one, so the Node
+ * that synced version 3 can still name exactly what it wrote.
+ *
+ * Only public records are in the gallery. A link record is readable by its id, a people record by the owner and its
+ * members, and a private record by the owner alone; a record that is not readable is a generic 404, never a hint that
+ * it exists.
+ */
+
+const KINDS = ['skill', 'rule', 'mcp', 'command', 'hook'];
+const AUDIENCES = ['public', 'link', 'people', 'private'];
+const TOOLS = ['claude-code', 'codex', 'opencode', 'command-code', 'minimax-code', 'aider', 'agents'];
+const SLUG = /^[a-z0-9][a-z0-9-]{0,63}$/;
+const SUBJECT = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
+const ID = /^skl_[0-9A-HJKMNP-TV-Z]{26}$/;
+const PROJECT = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
+const MAX_BODY = 131072;
+const MAX_TITLE = 120;
+const MAX_SUMMARY = 500;
+const SERVICE = 'codes';
+const KIND = 'skill';
+
+class SkillError extends Error {
+ constructor(status, code, message) {
+ super(message);
+ this.name = 'SkillError';
+ this.status = status;
+ this.code = code;
+ }
+}
+
+const fail = (status, code, message) => { throw new SkillError(status, code, message); };
+const cleanText = (v) => (typeof v === 'string' ? v.replace(/\r\n/g, '\n').trim() : '');
+const refOf = (id) => ({ service: SERVICE, kind: KIND, id: String(id) });
+
+/** Validate what a create or a new version carries; the caller supplies the fields that a version may omit. */
+function check(input, { create = false, base = null } = {}) {
+ const errors = [];
+ const b = input && typeof input === 'object' && !Array.isArray(input) ? input : {};
+ const out = {};
+
+ const title = b.title === undefined && base ? base.title : cleanText(b.title);
+ if (!title) errors.push('title is required');
+ else if (title.length > MAX_TITLE) errors.push(`title is at most ${MAX_TITLE} characters`);
+ else out.title = title;
+
+ const summary = b.summary === undefined && base ? base.summary : cleanText(b.summary);
+ if (summary.length > MAX_SUMMARY) errors.push(`summary is at most ${MAX_SUMMARY} characters`);
+ else out.summary = summary;
+
+ const body = b.body === undefined && base ? base.body : (typeof b.body === 'string' ? b.body.replace(/\r\n/g, '\n') : '');
+ if (!body.trim()) errors.push('body is required');
+ else if (body.length > MAX_BODY) errors.push(`body is at most ${MAX_BODY} characters`);
+ else out.body = body;
+
+ const audience = b.audience === undefined ? (base ? base.audience : 'private') : b.audience;
+ if (!AUDIENCES.includes(audience)) errors.push(`audience is one of ${AUDIENCES.join(', ')}`);
+ else out.audience = audience;
+
+ const tools = b.tools === undefined ? (base ? base.tools : []) : b.tools;
+ if (!Array.isArray(tools) || tools.length > 8 || tools.some((t) => !TOOLS.includes(t)) || new Set(tools).size !== tools.length) {
+ errors.push(`tools is up to eight of ${TOOLS.join(', ')}`);
+ } else out.tools = tools;
+
+ if (create) {
+ const kind = b.kind;
+ if (!KINDS.includes(kind)) errors.push(`kind is one of ${KINDS.join(', ')}`);
+ else out.kind = kind;
+ const slug = typeof b.slug === 'string' ? b.slug.trim() : '';
+ if (!SLUG.test(slug)) errors.push('slug is lowercase letters, digits and hyphens (at most 64)');
+ else out.slug = slug;
+ }
+ return { ok: errors.length === 0, errors, value: out };
+}
+
+/** One joined row, as codes.skill@1. */
+function present(row) {
+ const tools = Array.isArray(row.tools) ? row.tools : (typeof row.tools === 'string' ? JSON.parse(row.tools) : []);
+ return {
+ id: row.id,
+ owner_subject: row.owner_subject,
+ kind: row.kind,
+ slug: row.slug,
+ title: row.title,
+ summary: row.summary || '',
+ audience: row.audience || 'private',
+ official: !!row.official,
+ tools,
+ version: Number(row.version),
+ body: row.body,
+ created_at: row.created_at instanceof Date ? row.created_at.toISOString() : String(row.created_at),
+ updated_at: row.updated_at instanceof Date ? row.updated_at.toISOString() : String(row.updated_at),
+ };
+}
+
+const COLUMNS = 's.id, s.owner_subject, s.kind, s.slug, s.title, s.official, s.latest_version AS version, '
+ + "v.summary, v.body, v.tools, COALESCE(sh.audience, 'private') AS audience, s.created_at, s.updated_at";
+const FROM = 'FROM codes_skills s JOIN codes_skill_versions v ON v.skill_id = s.id AND v.version = s.latest_version '
+ + "LEFT JOIN shares sh ON sh.service = 'codes' AND sh.kind = 'skill' AND sh.resource_id = s.id";
+
+function createSkills({ store, sharing, log = console }) {
+ const db = store.db;
+ const iso = store.iso;
+ // The SDK's sharing model; app.js always builds it. Without it a record is owner-only, never public.
+ const shares = sharing && sharing.shares;
+ const shareViewer = sharing && sharing.shareViewer;
+ const asShareViewer = (viewer) => (shareViewer ? shareViewer(viewer) : null);
+
+ function owned(row, subject) {
+ if (!row || row.owner_subject !== subject) fail(404, 'skill.not_found', 'no such skill of yours');
+ return row;
+ }
+
+ /** Whether this viewer may read the record: openvibe-sdk/share decides (owner, public, link, people). */
+ async function allowed(row, viewer) {
+ if (!row) return false;
+ if (viewer && viewer.subject && viewer.subject === row.owner_subject) return true;
+ if (!shares) return false;
+ try { return await shares.canView(asShareViewer(viewer), refOf(row.id)); } catch (err) {
+ if (err && err.status === 404) return false;
+ throw err;
+ }
+ }
+
+ async function load(id) {
+ if (!ID.test(String(id || ''))) return null;
+ return db.maybe(`SELECT ${COLUMNS} ${FROM} WHERE s.id = $1`, [id]);
+ }
+
+ async function get(id, viewer) {
+ const row = await load(id);
+ return (await allowed(row, viewer)) ? present(row) : null;
+ }
+
+ async function create({ subject, input }) {
+ const checkIn = check(input, { create: true });
+ if (!checkIn.ok) fail(422, 'skill.invalid', checkIn.errors.join('; '));
+ const v = checkIn.value;
+ const id = store.newId('skl');
+ const at = iso();
+ try {
+ await store.tx(async () => {
+ await db.exec('INSERT INTO codes_skills (id, owner_subject, kind, slug, title, tools, latest_version, created_at, updated_at) '
+ + 'VALUES ($1,$2,$3,$4,$5,$6::jsonb,1,$7,$7)', [id, subject, v.kind, v.slug, v.title, JSON.stringify(v.tools), at]);
+ await db.exec('INSERT INTO codes_skill_versions (skill_id, version, summary, body, tools, audience, created_by, note, created_at) '
+ + 'VALUES ($1,1,$2,$3,$4::jsonb,$5,$6,$7,$8)', [id, v.summary, v.body, JSON.stringify(v.tools), v.audience, subject, 'created', at]);
+ });
+ } catch (err) {
+ if (err && (err.code === '23505' || /duplicate key/i.test(String(err.message || '')))) {
+ fail(409, 'skill.slug_taken', `you already have a ${v.kind} called ${v.slug}`);
+ }
+ throw err;
+ }
+ if (!shares) {
+ await db.exec('DELETE FROM codes_skills WHERE id = $1', [id]);
+ fail(503, 'skill.sharing_unavailable', 'sharing is not configured on this service');
+ }
+ try {
+ await shares.register(refOf(id), { owner_subject: { type: 'user', id: subject }, audience: v.audience });
+ } catch (err) {
+ await db.exec('DELETE FROM codes_skills WHERE id = $1', [id]).catch(() => {});
+ throw err;
+ }
+ return get(id, { subject });
+ }
+
+ /** A new version of an owned record; the row's title, tools and timestamp follow it. Audience is separate. */
+ async function addVersion({ subject, id, input, note = 'edited' }) {
+ const row = owned(await load(id), subject);
+ const checkIn = check({ ...(input || {}), audience: row.audience }, { base: present(row) });
+ if (!checkIn.ok) fail(422, 'skill.invalid', checkIn.errors.join('; '));
+ const v = checkIn.value;
+ const version = Number(row.version) + 1;
+ const at = iso();
+ await store.tx(async () => {
+ await db.exec('INSERT INTO codes_skill_versions (skill_id, version, summary, body, tools, audience, created_by, note, created_at) '
+ + 'VALUES ($1,$2,$3,$4,$5::jsonb,$6,$7,$8,$9)', [id, version, v.summary, v.body, JSON.stringify(v.tools), v.audience, subject, String(note).slice(0, 200), at]);
+ await db.exec('UPDATE codes_skills SET title = $2, tools = $3::jsonb, latest_version = $4, updated_at = $5 WHERE id = $1',
+ [id, v.title, JSON.stringify(v.tools), version, at]);
+ });
+ return get(id, { subject });
+ }
+
+ /** Copy an old version into a new one; history is never rewritten. */
+ async function rollback({ subject, id, version }) {
+ const row = owned(await load(id), subject);
+ const n = Number(version);
+ if (!Number.isInteger(n) || n < 1) fail(422, 'skill.invalid', 'version is a positive integer');
+ const old = await db.maybe('SELECT * FROM codes_skill_versions WHERE skill_id = $1 AND version = $2', [id, n]);
+ if (!old) fail(404, 'skill.version_not_found', 'no such version of this skill');
+ return addVersion({ subject, id, input: { title: row.title, summary: old.summary, body: old.body, tools: old.tools }, note: `rolled back to ${n}` });
+ }
+
+ /** The audience is the SDK's share row; the version history records which audience a body was saved under. */
+ async function setAudience({ subject, id, audience }) {
+ owned(await load(id), subject);
+ if (!AUDIENCES.includes(audience)) fail(422, 'skill.invalid', `audience is one of ${AUDIENCES.join(', ')}`);
+ if (!shares) fail(503, 'skill.sharing_unavailable', 'sharing is not configured on this service');
+ await shares.setAudience(asShareViewer({ subject }), refOf(id), audience);
+ const at = iso();
+ const record = await db.maybe(`SELECT ${COLUMNS} ${FROM} WHERE s.id = $1`, [id]);
+ const version = Number(record.version) + 1;
+ await store.tx(async () => {
+ await db.exec('INSERT INTO codes_skill_versions (skill_id, version, summary, body, tools, audience, created_by, note, created_at) '
+ + 'VALUES ($1,$2,$3,$4,$5::jsonb,$6,$7,$8,$9)', [id, version, record.summary, record.body, JSON.stringify(record.tools),
+ audience, subject, `audience ${audience}`, at]);
+ await db.exec('UPDATE codes_skills SET latest_version = $2, updated_at = $3 WHERE id = $1', [id, version, at]);
+ });
+ return get(id, { subject });
+ }
+
+ async function members(id, viewer) {
+ owned(await load(id), viewer && viewer.subject);
+ if (!shares) return [];
+ const snap = await shares.get(asShareViewer(viewer), refOf(id));
+ return (snap.members || []).map((m) => ({ subject: m.subject, role: m.role, created_at: m.added_at }));
+ }
+
+ async function addMember({ subject, id, memberSubject }) {
+ owned(await load(id), subject);
+ if (!SUBJECT.test(String(memberSubject || ''))) fail(422, 'skill.invalid', 'member is a usr_… subject');
+ if (!shares) fail(503, 'skill.sharing_unavailable', 'sharing is not configured on this service');
+ await shares.addMembers(asShareViewer({ subject }), refOf(id), [{ type: 'user', id: memberSubject }]);
+ return members(id, { subject });
+ }
+
+ async function removeMember({ subject, id, memberSubject }) {
+ owned(await load(id), subject);
+ if (!shares) fail(503, 'skill.sharing_unavailable', 'sharing is not configured on this service');
+ await shares.removeMember(asShareViewer({ subject }), refOf(id), { type: 'user', id: memberSubject });
+ return members(id, { subject });
+ }
+
+ async function listVersions(id, viewer) {
+ const row = await load(id);
+ if (!(await allowed(row, viewer))) return null;
+ return (await db.many('SELECT skill_id, version, summary, body, tools, audience, created_by, note, created_at FROM codes_skill_versions WHERE skill_id = $1 ORDER BY version DESC', [id]))
+ .map((v) => ({
+ skill_id: v.skill_id,
+ version: Number(v.version),
+ summary: v.summary,
+ body: v.body,
+ tools: Array.isArray(v.tools) ? v.tools : JSON.parse(v.tools || '[]'),
+ audience: v.audience,
+ created_by: v.created_by,
+ created_at: v.created_at instanceof Date ? v.created_at.toISOString() : String(v.created_at),
+ ...(v.note ? { note: v.note } : {}),
+ }));
+ }
+
+ /** Public records only: the gallery, search and other sites never see link, people or private ones. */
+ async function gallery({ kind, tool, q, limit = 60 } = {}) {
+ const where = ["sh.audience = 'public'"];
+ const params = [];
+ if (kind && KINDS.includes(kind)) { params.push(kind); where.push(`s.kind = $${params.length}`); }
+ if (tool && TOOLS.includes(tool)) { params.push(JSON.stringify([tool])); where.push(`v.tools @> $${params.length}::jsonb`); }
+ const text = cleanText(q);
+ if (text) { params.push(`%${text.slice(0, 80)}%`); where.push(`(s.title ILIKE $${params.length} OR s.summary ILIKE $${params.length})`); }
+ params.push(Math.max(1, Math.min(100, Number(limit) || 60)));
+ const rows = await db.many(`SELECT ${COLUMNS} ${FROM} WHERE ${where.join(' AND ')} ORDER BY s.official DESC, s.updated_at DESC, s.slug LIMIT $${params.length}`, params);
+ return rows.map(present);
+ }
+
+ async function mine(subject) {
+ const rows = await db.many(`SELECT ${COLUMNS} ${FROM} WHERE s.owner_subject = $1 ORDER BY s.updated_at DESC, s.slug`, [subject]);
+ return rows.map(present);
+ }
+
+ async function assign({ subject, id, project = '' }) {
+ const row = await load(id);
+ if (!(await allowed(row, { subject }))) fail(404, 'skill.not_found', 'no such skill');
+ const scope = String(project || '');
+ if (scope && !PROJECT.test(scope)) fail(422, 'skill.invalid', 'project is a slug');
+ await db.exec('INSERT INTO codes_skill_assignments (subject, project, skill_id) VALUES ($1, $2, $3) ON CONFLICT (subject, project, skill_id) DO NOTHING', [subject, scope, id]);
+ return present(row);
+ }
+
+ async function unassign({ subject, id, project = '' }) {
+ const scope = String(project || '');
+ await db.exec('DELETE FROM codes_skill_assignments WHERE subject = $1 AND project = $2 AND skill_id = $3', [subject, scope, id]);
+ }
+
+ /** What the Node syncs: the person's picks, plus this project's picks, at their newest versions. */
+ async function manifest({ subject, project = '' }) {
+ const scope = String(project || '');
+ const rows = await db.many(`SELECT ${COLUMNS} ${FROM}
+ WHERE s.id IN (SELECT skill_id FROM codes_skill_assignments WHERE subject = $1 AND (project = '' OR project = $2))
+ ORDER BY s.slug`, [subject, scope]);
+ return { generated_at: iso(), skills: rows.map(present) };
+ }
+
+ async function assignments(subject, project = '') {
+ return (await db.many('SELECT skill_id, project, created_at FROM codes_skill_assignments WHERE subject = $1 AND project = $2 ORDER BY created_at', [subject, String(project || '')]))
+ .map((r) => ({ skill_id: r.skill_id, project: r.project, created_at: r.created_at instanceof Date ? r.created_at.toISOString() : String(r.created_at) }));
+ }
+
+ return { create, get, listVersions, gallery, mine, addVersion, rollback, setAudience, members, addMember, removeMember, assign, unassign, manifest, assignments };
+}
+
+module.exports = { createSkills, SkillError, KINDS, AUDIENCES, TOOLS, MAX_BODY, MAX_TITLE, MAX_SUMMARY };
@@ -42,6 +42,11 @@ const BUDGETS = {
@@ -42,6 +42,11 @@ const BUDGETS = {
'codes.knowledge.write': { minute: 30, hour: 600 },
'codes.knowledge.write': { minute: 30, hour: 600 },
// The Dock's agent view controls (K-D3): Curtain now and End. The owner presses them now and then, never in a burst.
// The Dock's agent view controls (K-D3): Curtain now and End. The owner presses them now and then, never in a burst.
'codes.agent_view.control': { minute: 20, hour: 120 },
'codes.agent_view.control': { minute: 20, hour: 120 },
+ // The skills hub (Compute plan §5): save a version or share a record now and then; a script editing a library
+ // may do a few a minute, not a flood.
+ 'codes.skill.write': { minute: 20, hour: 300 },
+ // Adding a skill to the sync is how the Node is set up; the first run adds several, then almost never.
+ 'codes.skill.assign': { minute: 30, hour: 600 },
};
};
/**
/**
@@ -23,7 +23,7 @@ const SERVICES_ORIGIN = 'https://openvibe.services';
@@ -23,7 +23,7 @@ const SERVICES_ORIGIN = 'https://openvibe.services';
const DESCRIPTION = 'The open coding-agent harness: run a coding task on Claude Code, Codex, OpenCode, Command Code, Aider, the DeepSeek API or a model you host, with routing by capability and cost, hand-offs between agents, and the way to improve OpenVibe itself.';
const DESCRIPTION = 'The open coding-agent harness: run a coding task on Claude Code, Codex, OpenCode, Command Code, Aider, the DeepSeek API or a model you host, with routing by capability and cost, hand-offs between agents, and the way to improve OpenVibe itself.';
const { version: VERSION } = require('../../package.json');
const { version: VERSION } = require('../../package.json');
const INSTALL_HINT = `npm install -g https://codeload.github.com/OpenVibers/OpenVibe.Codes/tar.gz/refs/tags/v${VERSION}`;
const INSTALL_HINT = `npm install -g https://codeload.github.com/OpenVibers/OpenVibe.Codes/tar.gz/refs/tags/v${VERSION}`;
-const HARNESS_PAGES = ['/', '/start', '/harnesses', '/improve'];
+const HARNESS_PAGES = ['/', '/start', '/harnesses', '/skills', '/improve'];
// Sign-in and the API are not for crawlers. The console's old addresses redirect to openvibe.services.
// Sign-in and the API are not for crawlers. The console's old addresses redirect to openvibe.services.
const DISALLOW = ['/auth/', '/api/'];
const DISALLOW = ['/auth/', '/api/'];
@@ -33,6 +33,7 @@ const PAGE_TEXT = {
@@ -33,6 +33,7 @@ const PAGE_TEXT = {
'/': ['OpenVibe.Codes home', 'Code with any agent: the coding-agent harness, how a task is routed and handed off, what works today, and how to improve OpenVibe.'],
'/': ['OpenVibe.Codes home', 'Code with any agent: the coding-agent harness, how a task is routed and handed off, what works today, and how to improve OpenVibe.'],
'/start': ['Get started with the coding-agent harness', 'Install openvibe-codes; the agents it can use and what each needs; the commands, permission levels, hand-offs, the event format and using it from your own code.'],
'/start': ['Get started with the coding-agent harness', 'Install openvibe-codes; the agents it can use and what each needs; the commands, permission levels, hand-offs, the event format and using it from your own code.'],
'/harnesses': ['The coding agents', 'Every harness the router can pick (Claude Code, Codex, OpenCode, Command Code, Aider, DeepSeek, your own model) with its models, capabilities, prices and limits.'],
'/harnesses': ['The coding agents', 'Every harness the router can pick (Claude Code, Codex, OpenCode, Command Code, Aider, DeepSeek, your own model) with its models, capabilities, prices and limits.'],
+ '/skills': ['The skills hub', 'Skills, rules and MCP servers kept per person and per project, versioned, shared through the four audiences, and synced into each coding tool on your own machine with a dry run and an undo.'],
'/improve': ['Improve OpenVibe', 'How OpenVibe is built in the open: every repository, and the path from a change to a reviewed pull request.'],
'/improve': ['Improve OpenVibe', 'How OpenVibe is built in the open: every repository, and the path from a change to a reviewed pull request.'],
'/updates': ['What shipped on OpenVibe.Codes', 'This site\'s update log, from the network changelog feed.'],
'/updates': ['What shipped on OpenVibe.Codes', 'This site\'s update log, from the network changelog feed.'],
'/policy': ['Community', 'The code of conduct, the contributing guide, the contributor ladder and the moderation policy.'],
'/policy': ['Community', 'The code of conduct, the contributing guide, the contributor ladder and the moderation policy.'],
@@ -68,6 +69,7 @@ function publicPages({ governance = [] }) {
@@ -68,6 +69,7 @@ function publicPages({ governance = [] }) {
{ path: '/', changefreq: 'weekly', priority: 1.0 },
{ path: '/', changefreq: 'weekly', priority: 1.0 },
{ path: '/start', changefreq: 'weekly', priority: 0.9 },
{ path: '/start', changefreq: 'weekly', priority: 0.9 },
{ path: '/harnesses', changefreq: 'weekly', priority: 0.9 },
{ path: '/harnesses', changefreq: 'weekly', priority: 0.9 },
+ { path: '/skills', changefreq: 'weekly', priority: 0.9 },
{ path: '/improve', changefreq: 'weekly', priority: 0.8 },
{ path: '/improve', changefreq: 'weekly', priority: 0.8 },
{ path: '/updates', changefreq: 'daily', priority: 0.5 },
{ path: '/updates', changefreq: 'daily', priority: 0.5 },
{ path: '/policy', changefreq: 'monthly', priority: 0.4 },
{ path: '/policy', changefreq: 'monthly', priority: 0.4 },
@@ -21,7 +21,7 @@ const MOVED_EXACT = ['/policy/rfc', '/policy/compatibility', '/policy/licensing'
@@ -21,7 +21,7 @@ const MOVED_EXACT = ['/policy/rfc', '/policy/compatibility', '/policy/licensing'
// own API (/me), the Action Center read (codes.actions.read), a person's own connections (Codes C3), the conductor
// own API (/me), the Action Center read (codes.actions.read), a person's own connections (Codes C3), the conductor
// with its approvals inbox (Codes C9b), the fleet their machines report (Codes C9c) and the workspace knowledge
// with its approvals inbox (Codes C9b), the fleet their machines report (Codes C9c) and the workspace knowledge
// their own agents read and add (Know K5).
// their own agents read and add (Know K5).
-const API_STAYS = /^\/api\/v1\/(harnesses|tasks|task\/[^/]+\/share|share|shared-with-me|actions|me|connections|conductor|approvals|fleet|knowledge)(\/|$)/;
+const API_STAYS = /^\/api\/v1\/(harnesses|tasks|task\/[^/]+\/share|skill\/[^/]+\/share|skills|share|shared-with-me|actions|me|connections|conductor|approvals|fleet|knowledge)(\/|$)/;
const under = (p, prefix) => p === prefix || p.startsWith(`${prefix}/`);
const under = (p, prefix) => p === prefix || p.startsWith(`${prefix}/`);
new file mode 100644
new file mode 100644
@@ -0,0 +1,158 @@
@@ -0,0 +1,158 @@
+'use strict';
+
+/**
+ * The skills hub's JSON API (the Compute plan §5, E6).
+ *
+ * GET /api/v1/skills the public gallery (no auth); ?kind, ?tool, ?q, ?limit
+ * GET /api/v1/skills?mine=1 the signed-in person's own records, every audience
+ * GET /api/v1/skills/:id one record, if this viewer may read it
+ * GET /api/v1/skills/:id/versions the versions, newest first (no bodies)
+ * GET /api/v1/skills/:id/versions/:n one version, with its body
+ * POST /api/v1/skills create a record and version 1
+ * POST /api/v1/skills/:id/versions save a new version
+ * POST /api/v1/skills/:id/rollback copy an old version into a new one
+ * POST /api/v1/skills/:id/audience one of public, link, people, private
+ * POST /api/v1/skills/:id/members add a person to a people-shared record
+ * POST /api/v1/skills/:id/members/remove remove a person
+ * GET /api/v1/me/skills the sync manifest: the person's picks + this project's, ?project=slug
+ * POST /api/v1/me/skills/:id add a record to the sync
+ * POST /api/v1/me/skills/:id/remove remove it
+ *
+ * The Node sync authenticates with the person's access token (Authorization: Bearer), which the same SSO middleware
+ * verifies as a browser session. Only public records are in the gallery; the others answer 404 to everyone who is
+ * not allowed to read them.
+ */
+const express = require('express');
+const { http } = require('openvibe-contracts');
+const { asyncRouter } = require('./router');
+const { SkillError } = require('../domain/skills');
+
+const ID = /^skl_[0-9A-HJKMNP-TV-Z]{26}$/;
+const PROJECT = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
+
+function createSkillsApi({ config, skills, actorLimits, log = console }) {
+ const r = asyncRouter();
+ const json = express.json({ limit: '160kb' });
+ const problem = (req, res, status, code, detail) => http.sendProblem(res, status, code, { detail, ctx: req.ov });
+ const signed = (req) => req.viewer && req.viewer.kind === 'user' && req.viewer.subject;
+ const viewer = (req) => (signed(req) ? { subject: req.viewer.subject, role: req.viewer.role } : null);
+ const sameOrigin = (req) => {
+ const origin = req.get('origin');
+ if (!origin) return true;
+ try { return new URL(origin).origin === new URL(config.baseUrl).origin; } catch { return false; }
+ };
+ const guard = (fn) => async (req, res) => {
+ try { await fn(req, res); } catch (err) {
+ if (err instanceof SkillError) return problem(req, res, err.status, err.code, err.message);
+ log.error('[Codes] skills api error:', err && err.message ? err.message.slice(0, 200) : err);
+ return problem(req, res, 500, 'internal.error', 'Internal error');
+ }
+ };
+ const projectOf = (req) => {
+ const p = req.query.project !== undefined ? String(req.query.project) : (req.body && req.body.project !== undefined ? String(req.body.project) : '');
+ if (p && !PROJECT.test(p)) throw new SkillError(422, 'skill.invalid', 'project is a slug');
+ return p;
+ };
+ const need = (req, res) => {
+ if (!signed(req)) { problem(req, res, 401, 'auth.required', 'sign in to change your skills'); return false; }
+ if (!sameOrigin(req)) { problem(req, res, 403, 'cross_site', 'cross-site JSON posts are refused'); return false; }
+ return true;
+ };
+
+ r.use((req, res, next) => { res.set('Cache-Control', 'no-store'); next(); });
+
+ r.get('/skills', actorLimits.reads('codes.read'), guard(async (req, res) => {
+ if (req.query.mine === '1') {
+ if (!signed(req)) return problem(req, res, 401, 'auth.required', 'sign in to see your skills');
+ return res.json({ skills: await skills.mine(req.viewer.subject) });
+ }
+ const skillsOut = await skills.gallery({ kind: req.query.kind, tool: req.query.tool, q: req.query.q, limit: req.query.limit });
+ return res.json({ skills: skillsOut, total: skillsOut.length });
+ }));
+
+ r.get('/skills/:id', actorLimits.reads('codes.read'), guard(async (req, res) => {
+ if (!ID.test(req.params.id)) return problem(req, res, 404, 'skill.not_found', 'no such skill');
+ const skill = await skills.get(req.params.id, viewer(req));
+ if (!skill) return problem(req, res, 404, 'skill.not_found', 'no such skill');
+ const versions = await skills.listVersions(skill.id, viewer(req));
+ return res.json({ skill, versions: (versions || []).map(({ body, ...rest }) => rest) });
+ }));
+
+ r.get('/skills/:id/versions', actorLimits.reads('codes.read'), guard(async (req, res) => {
+ if (!ID.test(req.params.id)) return problem(req, res, 404, 'skill.not_found', 'no such skill');
+ const versions = await skills.listVersions(req.params.id, viewer(req));
+ if (!versions) return problem(req, res, 404, 'skill.not_found', 'no such skill');
+ return res.json({ versions: versions.map(({ body, ...rest }) => rest) });
+ }));
+
+ r.get('/skills/:id/versions/:version', actorLimits.reads('codes.read'), guard(async (req, res) => {
+ if (!ID.test(req.params.id)) return problem(req, res, 404, 'skill.not_found', 'no such skill');
+ const versions = await skills.listVersions(req.params.id, viewer(req));
+ if (!versions) return problem(req, res, 404, 'skill.not_found', 'no such skill');
+ const version = versions.find((v) => v.version === Number(req.params.version));
+ if (!version) return problem(req, res, 404, 'skill.version_not_found', 'no such version of this skill');
+ return res.json({ version });
+ }));
+
+ r.post('/skills', actorLimits.budget('codes.skill.write'), json, guard(async (req, res) => {
+ if (!need(req, res)) return undefined;
+ const skill = await skills.create({ subject: req.viewer.subject, input: req.body || {} });
+ return res.status(201).json({ skill });
+ }));
+
+ r.post('/skills/:id/versions', actorLimits.budget('codes.skill.write'), json, guard(async (req, res) => {
+ if (!need(req, res)) return undefined;
+ const skill = await skills.addVersion({ subject: req.viewer.subject, id: req.params.id, input: req.body || {}, note: (req.body && req.body.note) || 'edited' });
+ return res.status(201).json({ skill });
+ }));
+
+ r.post('/skills/:id/rollback', actorLimits.budget('codes.skill.write'), json, guard(async (req, res) => {
+ if (!need(req, res)) return undefined;
+ const skill = await skills.rollback({ subject: req.viewer.subject, id: req.params.id, version: req.body && req.body.version });
+ return res.status(201).json({ skill });
+ }));
+
+ r.post('/skills/:id/audience', actorLimits.budget('codes.skill.write'), json, guard(async (req, res) => {
+ if (!need(req, res)) return undefined;
+ const skill = await skills.setAudience({ subject: req.viewer.subject, id: req.params.id, audience: req.body && req.body.audience });
+ return res.json({ skill });
+ }));
+
+ r.post('/skills/:id/members', actorLimits.budget('codes.skill.write'), json, guard(async (req, res) => {
+ if (!need(req, res)) return undefined;
+ const members = await skills.addMember({ subject: req.viewer.subject, id: req.params.id, memberSubject: req.body && req.body.subject });
+ return res.status(201).json({ members });
+ }));
+
+ r.post('/skills/:id/members/remove', actorLimits.budget('codes.skill.write'), json, guard(async (req, res) => {
+ if (!need(req, res)) return undefined;
+ const members = await skills.removeMember({ subject: req.viewer.subject, id: req.params.id, memberSubject: req.body && req.body.subject });
+ return res.json({ members });
+ }));
+
+ r.get('/me/skills', actorLimits.reads('codes.read'), guard(async (req, res) => {
+ if (!signed(req)) return problem(req, res, 401, 'auth.required', 'sign in to sync your skills');
+ return res.json(await skills.manifest({ subject: req.viewer.subject, project: projectOf(req) }));
+ }));
+
+ r.get('/me/skills/assignments', actorLimits.reads('codes.read'), guard(async (req, res) => {
+ if (!signed(req)) return problem(req, res, 401, 'auth.required', 'sign in to see your sync');
+ return res.json({ assignments: await skills.assignments(req.viewer.subject, projectOf(req)) });
+ }));
+
+ r.post('/me/skills/:id', actorLimits.budget('codes.skill.assign'), json, guard(async (req, res) => {
+ if (!need(req, res)) return undefined;
+ const skill = await skills.assign({ subject: req.viewer.subject, id: req.params.id, project: projectOf(req) });
+ return res.status(201).json({ skill });
+ }));
+
+ r.post('/me/skills/:id/remove', actorLimits.budget('codes.skill.assign'), json, guard(async (req, res) => {
+ if (!need(req, res)) return undefined;
+ await skills.unassign({ subject: req.viewer.subject, id: req.params.id, project: projectOf(req) });
+ return res.json({ removed: true });
+ }));
+
+ return r;
+}
+
+module.exports = { createSkillsApi };
new file mode 100644
new file mode 100644
@@ -0,0 +1,231 @@
@@ -0,0 +1,231 @@
+'use strict';
+
+/**
+ * The skills hub's pages (the Compute plan §5, E6): the gallery at /skills and one record at /skills/:id.
+ *
+ * The page is server-rendered and complete without JavaScript. Only public records are in the gallery; a link record
+ * opens by its URL, a people record for its members, and a private one for its owner. A skill's body is untrusted
+ * text rendered through server/render/markdown.js, the markup gate (escape first, then headings, lists, code, tables,
+ * bold, inline code and http(s) or relative links only), never as HTML.
+ */
+const express = require('express');
+const { asyncRouter } = require('./router');
+const { html, raw, table, notice, badge } = require('../render/html');
+const { markdown } = require('../render/markdown');
+const { send } = require('../render/layout');
+const { KINDS, TOOLS, AUDIENCES, SkillError } = require('../domain/skills');
+
+const KIND_LABELS = { skill: 'Skill', rule: 'Rule', mcp: 'MCP server', command: 'Command', hook: 'Hook' };
+const AUDIENCE_LABELS = {
+ public: 'Public: in the gallery, search and agents',
+ link: 'Link: anyone with the address, not listed',
+ people: 'People: you and the members you add',
+ private: 'Private: only you',
+};
+const TOOL_LABELS = {
+ 'claude-code': 'Claude Code',
+ codex: 'Codex',
+ opencode: 'OpenCode',
+ 'command-code': 'Command Code',
+ 'minimax-code': 'MiniMax Code',
+ aider: 'Aider',
+ agents: 'AGENTS.md (Codex and friends)',
+};
+
+function createSkillPageRoutes({ config, skills }) {
+ const r = asyncRouter();
+ const signedIn = (req) => req.viewer && req.viewer.kind === 'user' && req.viewer.subject;
+ const sameOrigin = (req) => {
+ const origin = req.get('origin');
+ if (!origin) return true;
+ try { return new URL(origin).origin === new URL(config.baseUrl).origin; } catch { return false; }
+ };
+ const form = express.urlencoded({ extended: false, limit: '200kb' });
+ const page = (req, res, o, status = 200) => send(res, status, { viewer: req.viewer, config, path: req.originalUrl, ...o });
+
+ const toolsText = (tools) => (tools && tools.length ? tools.map((t) => TOOL_LABELS[t] || t).join(', ') : 'every tool');
+ const skillCard = (s, extra = '') => html`<li>
+<a href="/skills/${s.id}"><strong>${s.title}</strong></a>
+<span>${s.summary || 'No summary yet.'}</span>
+<span>${badge(KIND_LABELS[s.kind] || s.kind)} ${badge(`v${s.version}`)} ${s.official ? badge('OpenVibe', 'ok') : ''}</span>
+<span class="muted small">${s.slug} · ${toolsText(s.tools)}</span>
+${extra}
+</li>`;
+
+ function formFields(s = null, { audience = true } = {}) {
+ const v = s || { kind: 'skill', slug: '', title: '', summary: '', body: '', audience: 'private', tools: [] };
+ return html`<div class="field"><label for="sk-title">Title</label><input id="sk-title" name="title" maxlength="120" required value="${v.title}"></div>
+<div class="field"><label for="sk-slug">Slug (the folder name; lowercase letters, digits and hyphens)</label><input id="sk-slug" name="slug" maxlength="64" pattern="[a-z0-9][a-z0-9-]{0,63}" required value="${v.slug}"></div>
+<div class="field"><label for="sk-kind">Kind</label><select id="sk-kind" name="kind">${KINDS.map((k) => html`<option value="${k}" ${k === v.kind ? raw('selected') : ''}>${KIND_LABELS[k]}</option>`)}</select></div>
+<div class="field"><label for="sk-summary">Summary (one line, shown in the gallery)</label><input id="sk-summary" name="summary" maxlength="500" value="${v.summary}"></div>
+<div class="field"><label for="sk-body">Body (SKILL.md content, Markdown)</label><textarea id="sk-body" name="body" rows="16" maxlength="131072" required>${v.body}</textarea></div>
+${audience ? html`<div class="field"><label for="sk-audience">Who may read it</label><select id="sk-audience" name="audience">${AUDIENCES.map((a) => html`<option value="${a}" ${a === v.audience ? raw('selected') : ''}>${AUDIENCE_LABELS[a]}</option>`)}</select></div>` : ''}
+<fieldset class="field"><legend>Tools</legend>${TOOLS.map((t) => html`<label class="check"><input type="checkbox" name="tools" value="${t}" ${v.tools.includes(t) ? raw('checked') : ''}> ${TOOL_LABELS[t]}</label>`)}</fieldset>`;
+ }
+
+ async function galleryPage(req, res, { error = null, status = 200 } = {}) {
+ const kind = KINDS.includes(String(req.query.kind || '')) ? String(req.query.kind) : '';
+ const q = String(req.query.q || '').slice(0, 80);
+ const shown = await skills.gallery({ kind, q });
+ const mine = signedIn(req) ? await skills.mine(req.viewer.subject) : [];
+ const assigned = signedIn(req) ? new Set((await skills.assignments(req.viewer.subject)).map((a) => a.skill_id)) : new Set();
+ const create = signedIn(req)
+ ? html`<section aria-labelledby="h-new"><h2 id="h-new">Publish a skill, a rule or an MCP server</h2>
+<form class="stack" method="post" action="/skills/new">${formFields()}<button class="run-btn run-btn-primary" type="submit">Save version 1</button></form></section>`
+ : html`<p><a class="run-btn" href="/auth/login?next=${encodeURIComponent('/skills')}">Sign in to publish and sync skills</a></p>`;
+ return page(req, res, {
+ title: 'Skills: the hub for your agents',
+ description: 'Skills, rules and MCP servers kept per person and per project, versioned, and synced into Claude Code, Codex, OpenCode and Command Code on your machine.',
+ crumbs: [{ label: 'Skills' }],
+ body: html`<h1>Skills</h1>
+<p class="lede">Skills, rules and MCP servers kept per person and per project, versioned, and synced into each agent tool's own folder on your machine. Add one from the gallery; <code>openvibe-node skills sync</code> writes it inside its marked block, with a dry run, a diff and an undo.</p>
+${error ? notice(error, 'bad') : ''}
+<form class="inline-form" method="get" action="/skills" role="search">
+<label>Search the gallery <input name="q" value="${q}" maxlength="80"></label>
+<label>Kind <select name="kind"><option value="">all</option>${KINDS.map((k) => html`<option value="${k}" ${k === kind ? raw('selected') : ''}>${KIND_LABELS[k]}</option>`)}</select></label>
+<button class="run-btn" type="submit">Show</button></form>
+<section aria-labelledby="h-gallery"><h2 id="h-gallery">The gallery</h2>
+${shown.length ? html`<ul class="cards">${shown.map(skillCard)}</ul>` : html`<p class="muted">No public ${kind ? KIND_LABELS[kind].toLowerCase() + 's' : 'records'} yet. The first one published appears here.</p>`}
+</section>
+${create}
+${signedIn(req) ? html`<section aria-labelledby="h-mine"><h2 id="h-mine">Yours</h2>
+${mine.length ? html`<ul class="cards">${mine.map((s) => skillCard(s, html`<span class="muted small">${badge(AUDIENCE_LABELS[s.audience].split(':')[0])} ${assigned.has(s.id) ? badge('in your sync', 'ok') : ''}</span>`))}</ul>` : html`<p class="muted">Nothing yet. Publish one above.</p>`}
+</section>` : ''}`,
+ }, status);
+ }
+
+ async function detailPage(req, res, { error = null, status = 200 } = {}) {
+ const skill = await skills.get(req.params.id, req.viewer && req.viewer.kind === 'user' ? { subject: req.viewer.subject } : null);
+ if (!skill) return page(req, res, { title: 'Not found', body: html`<h1>Not found</h1><p>No such skill. <a href="/skills">The gallery</a></p>` }, 404);
+ const owner = signedIn(req) && req.viewer.subject === skill.owner_subject;
+ const versions = await skills.listVersions(skill.id, owner ? { subject: req.viewer.subject } : (signedIn(req) ? { subject: req.viewer.subject } : null)) || [];
+ const assigned = signedIn(req) ? (await skills.assignments(req.viewer.subject)).some((a) => a.skill_id === skill.id) : false;
+ const members = owner && skill.audience === 'people' ? await skills.members(skill.id, { subject: req.viewer.subject }) : [];
+ const controls = owner ? html`<section aria-labelledby="h-edit"><h2 id="h-edit">A new version</h2>
+<form class="stack" method="post" action="/skills/${skill.id}/versions"><input type="hidden" name="slug" value="${skill.slug}">${formFields(skill, { audience: false })}<button class="run-btn run-btn-primary" type="submit">Save version ${skill.version + 1}</button></form>
+<h2>Who may read it</h2>
+<form class="inline-form" method="post" action="/skills/${skill.id}/audience">
+<label>Audience <select name="audience">${AUDIENCES.map((a) => html`<option value="${a}" ${a === skill.audience ? raw('selected') : ''}>${AUDIENCE_LABELS[a]}</option>`)}</select></label>
+<button class="run-btn" type="submit">Save</button></form>
+${skill.audience === 'people' ? html`<h3>Members</h3>${members.length ? html`<ul>${members.map((m) => html`<li><code>${m.subject}</code> ${badge(m.role)} <form class="inline-form" method="post" action="/skills/${skill.id}/members/remove"><input type="hidden" name="subject" value="${m.subject}"><button class="run-btn" type="submit">Remove</button></form></li>`)}</ul>` : html`<p class="muted">Nobody added yet; only you can read it.</p>`}
+<form class="inline-form" method="post" action="/skills/${skill.id}/members"><label>Person <input name="subject" placeholder="usr_…" pattern="usr_[0-9A-HJKMNP-TV-Z]{26}" required></label><button class="run-btn" type="submit">Add</button></form>` : ''}
+</section>` : '';
+ return page(req, res, {
+ title: skill.title,
+ description: skill.summary || `A ${KIND_LABELS[skill.kind]} on OpenVibe.Codes.`,
+ crumbs: [{ label: 'Skills', href: '/skills' }, { label: skill.title }],
+ body: html`${error ? notice(error, 'bad') : ''}
+<h1>${skill.title}</h1>
+<p class="lede">${skill.summary || ''}</p>
+<p>${badge(KIND_LABELS[skill.kind])} ${badge(`v${skill.version}`)} ${badge(AUDIENCE_LABELS[skill.audience].split(':')[0])} ${skill.official ? badge('OpenVibe', 'ok') : ''} <span class="muted small">${skill.slug} · ${toolsText(skill.tools)} · updated ${skill.updated_at.replace('T', ' ').replace(/\.\d+Z$/, ' UTC')}</span></p>
+${signedIn(req) ? html`<form class="inline-form" method="post" action="/skills/${skill.id}/add"><button class="run-btn run-btn-primary" type="submit">${assigned ? 'Remove from my sync' : 'Add to my sync'}</button></form>` : html`<p><a class="run-btn" href="/auth/login?next=${encodeURIComponent(`/skills/${skill.id}`)}">Sign in to add it to your sync</a></p>`}
+<article class="prose">${raw(markdown(skill.body, { headingOffset: 1 }))}</article>
+<section aria-labelledby="h-history"><h2 id="h-history">Versions</h2>
+${table(['Version', 'When', 'Audience', 'Note'], versions.map((v) => [
+ owner ? html`<form class="inline-form" method="post" action="/skills/${skill.id}/rollback"><input type="hidden" name="version" value="${v.version}"><button class="run-btn" type="submit" title="Copy this version into a new one">Roll back to v${v.version}</button></form>` : `v${v.version}`,
+ v.created_at.replace('T', ' ').replace(/\.\d+Z$/, ' UTC'),
+ v.audience,
+ v.note || '—',
+ ]))}
+<p class="muted small">A rollback copies the chosen version into a new one, so history is never rewritten. The Node records the version it wrote.</p></section>
+${controls}`,
+ }, status);
+ }
+
+ r.get('/skills', (req, res, next) => galleryPage(req, res).catch(next));
+ r.get('/skills/:id', (req, res, next) => detailPage(req, res).catch(next));
+
+ r.post('/skills/new', form, async (req, res, next) => {
+ try {
+ if (!signedIn(req)) return res.redirect(303, `/auth/login?next=${encodeURIComponent('/skills')}`);
+ if (!sameOrigin(req)) return res.status(403).type('text/plain').send('Cross-site form posts are refused.\n');
+ const tools = [].concat(req.body.tools || []).filter((t) => TOOLS.includes(t));
+ const input = { ...req.body, tools };
+ const skill = await skills.create({ subject: req.viewer.subject, input });
+ return res.redirect(303, `/skills/${skill.id}`);
+ } catch (err) {
+ if (err instanceof SkillError) return galleryPage(req, res, { error: err.message, status: err.status }).catch(next);
+ return next(err);
+ }
+ });
+
+ r.post('/skills/:id/versions', form, async (req, res, next) => {
+ try {
+ if (!signedIn(req)) return res.redirect(303, `/auth/login?next=${encodeURIComponent(`/skills/${req.params.id}`)}`);
+ if (!sameOrigin(req)) return res.status(403).type('text/plain').send('Cross-site form posts are refused.\n');
+ const tools = [].concat(req.body.tools || []).filter((t) => TOOLS.includes(t));
+ await skills.addVersion({ subject: req.viewer.subject, id: req.params.id, input: { ...req.body, tools } });
+ return res.redirect(303, `/skills/${req.params.id}`);
+ } catch (err) {
+ if (err instanceof SkillError) return detailPage(req, res, { error: err.message, status: err.status }).catch(next);
+ return next(err);
+ }
+ });
+
+ r.post('/skills/:id/rollback', form, async (req, res, next) => {
+ try {
+ if (!signedIn(req)) return res.status(401).type('text/plain').send('Sign in first.\n');
+ if (!sameOrigin(req)) return res.status(403).type('text/plain').send('Cross-site form posts are refused.\n');
+ await skills.rollback({ subject: req.viewer.subject, id: req.params.id, version: req.body.version });
+ return res.redirect(303, `/skills/${req.params.id}`);
+ } catch (err) {
+ if (err instanceof SkillError) return detailPage(req, res, { error: err.message, status: err.status }).catch(next);
+ return next(err);
+ }
+ });
+
+ r.post('/skills/:id/audience', form, async (req, res, next) => {
+ try {
+ if (!signedIn(req)) return res.status(401).type('text/plain').send('Sign in first.\n');
+ if (!sameOrigin(req)) return res.status(403).type('text/plain').send('Cross-site form posts are refused.\n');
+ await skills.setAudience({ subject: req.viewer.subject, id: req.params.id, audience: req.body.audience });
+ return res.redirect(303, `/skills/${req.params.id}`);
+ } catch (err) {
+ if (err instanceof SkillError) return detailPage(req, res, { error: err.message, status: err.status }).catch(next);
+ return next(err);
+ }
+ });
+
+ r.post('/skills/:id/members', form, async (req, res, next) => {
+ try {
+ if (!signedIn(req)) return res.status(401).type('text/plain').send('Sign in first.\n');
+ if (!sameOrigin(req)) return res.status(403).type('text/plain').send('Cross-site form posts are refused.\n');
+ await skills.addMember({ subject: req.viewer.subject, id: req.params.id, memberSubject: req.body.subject });
+ return res.redirect(303, `/skills/${req.params.id}`);
+ } catch (err) {
+ if (err instanceof SkillError) return detailPage(req, res, { error: err.message, status: err.status }).catch(next);
+ return next(err);
+ }
+ });
+
+ r.post('/skills/:id/members/remove', form, async (req, res, next) => {
+ try {
+ if (!signedIn(req)) return res.status(401).type('text/plain').send('Sign in first.\n');
+ if (!sameOrigin(req)) return res.status(403).type('text/plain').send('Cross-site form posts are refused.\n');
+ await skills.removeMember({ subject: req.viewer.subject, id: req.params.id, memberSubject: req.body.subject });
+ return res.redirect(303, `/skills/${req.params.id}`);
+ } catch (err) {
+ if (err instanceof SkillError) return detailPage(req, res, { error: err.message, status: err.status }).catch(next);
+ return next(err);
+ }
+ });
+
+ // The one page action that is not the owner's: add or remove a record from this person's sync.
+ r.post('/skills/:id/add', form, async (req, res, next) => {
+ try {
+ if (!signedIn(req)) return res.redirect(303, `/auth/login?next=${encodeURIComponent(`/skills/${req.params.id}`)}`);
+ if (!sameOrigin(req)) return res.status(403).type('text/plain').send('Cross-site form posts are refused.\n');
+ const on = (await skills.assignments(req.viewer.subject)).some((a) => a.skill_id === req.params.id);
+ if (on) await skills.unassign({ subject: req.viewer.subject, id: req.params.id });
+ else await skills.assign({ subject: req.viewer.subject, id: req.params.id });
+ return res.redirect(303, `/skills/${req.params.id}`);
+ } catch (err) {
+ if (err instanceof SkillError) return detailPage(req, res, { error: err.message, status: err.status }).catch(next);
+ return next(err);
+ }
+ });
+
+ return r;
+}
+
+module.exports = { createSkillPageRoutes };
@@ -27,6 +27,7 @@ const NAV = [
@@ -27,6 +27,7 @@ const NAV = [
{ label: 'Get started', href: '/start', icon: 'fa-play' },
{ label: 'Get started', href: '/start', icon: 'fa-play' },
{ label: 'Run on your machine', href: '/run', icon: 'fa-microchip' },
{ label: 'Run on your machine', href: '/run', icon: 'fa-microchip' },
{ label: 'Agents', href: '/harnesses', icon: 'fa-robot' },
{ label: 'Agents', href: '/harnesses', icon: 'fa-robot' },
+ { label: 'Skills', href: '/skills', icon: 'fa-puzzle-piece' },
{ label: 'Improve OpenVibe', href: '/improve', icon: 'fa-code', more: true },
{ label: 'Improve OpenVibe', href: '/improve', icon: 'fa-code', more: true },
{ label: 'Community', href: '/policy', icon: 'fa-people-group', more: true },
{ label: 'Community', href: '/policy', icon: 'fa-people-group', more: true },
];
];
@@ -146,17 +146,31 @@ function createSharing({ db, config, fetchImpl = globalThis.fetch, log = console
@@ -146,17 +146,31 @@ function createSharing({ db, config, fetchImpl = globalThis.fetch, log = console
}
}
return true;
return true;
},
},
- /** The uniform API's describe(ref): the task's first line and its page. */
+ /** The uniform API's describe(ref): the item's title and its page. */
describe: async (ref) => {
describe: async (ref) => {
- if (ref.service !== SERVICE || ref.kind !== KIND) return {};
- const row = await db.maybe('SELECT prompt FROM run_tasks WHERE id = $1', [String(ref.id)]);
- if (!row) return {};
- return {
- title: String(row.prompt || 'A task').split('\n')[0].slice(0, 90),
- link: `${config.baseUrl}/run/tasks/${encodeURIComponent(ref.id)}`,
- roles: ['viewer'],
- transferable: false,
- };
+ if (ref.service !== SERVICE) return {};
+ if (ref.kind === KIND) {
+ const row = await db.maybe('SELECT prompt FROM run_tasks WHERE id = $1', [String(ref.id)]);
+ if (!row) return {};
+ return {
+ title: String(row.prompt || 'A task').split('\n')[0].slice(0, 90),
+ link: `${config.baseUrl}/run/tasks/${encodeURIComponent(ref.id)}`,
+ roles: ['viewer'],
+ transferable: false,
+ };
+ }
+ // A skill in the hub: the same four audiences and members, its own page, never transferable.
+ if (ref.kind === 'skill') {
+ const row = await db.maybe('SELECT title FROM codes_skills WHERE id = $1', [String(ref.id)]);
+ if (!row) return {};
+ return {
+ title: String(row.title || 'A skill').slice(0, 90),
+ link: `${config.baseUrl}/skills/${encodeURIComponent(ref.id)}`,
+ roles: ['viewer'],
+ transferable: false,
+ };
+ }
+ return {};
},
},
/** Expire members and transfers, then send the queued events and notifications. */
/** Expire members and transfers, then send the queued events and notifications. */
work: async ({ flush = true } = {}) => {
work: async ({ flush = true } = {}) => {
new file mode 100644
new file mode 100644
@@ -0,0 +1,187 @@
@@ -0,0 +1,187 @@
+'use strict';
+/**
+ * The skills hub (the Compute plan §5, E6): versioned skills, rules and MCP servers for people and projects, shared
+ * through openvibe-sdk/share, with the gallery showing public records only.
+ *
+ * - a new record is private and is not in the gallery;
+ * - public is listed and readable anonymously, link is readable by id but not listed, private answers 404;
+ * - saving a version keeps the old one, and a rollback copies it into a new version (history is never rewritten);
+ * - the Node's sync manifest is exactly the person's picks, at their newest version;
+ * - the page renders a skill body through the markup gate, never as HTML.
+ */
+const assert = require('assert');
+const { boot, check, done } = require('./helpers/boot');
+
+(async () => {
+ const t = await boot();
+ const ada = t.network.addUser('ada');
+ const bo = t.network.addUser('bo');
+
+ const api = (p, o = {}) => t.get(p, { as: o.as, json: o.json, method: o.method, headers: o.headers });
+ const bearer = t.network.userToken(ada);
+ const create = async (as, body) => api('/api/v1/skills', { as, json: body, method: 'POST' });
+ const skillBody = (name, note = '') => `---\nname: ${name}\ndescription: A test skill.\n---\n\n# ${name}\n\n${note}\n`;
+ const base = (over = {}) => ({
+ kind: 'skill', slug: `skill-${Math.random().toString(36).slice(2, 8)}`, title: 'A skill',
+ summary: 'One line about the skill.', body: skillBody('a-skill'), audience: 'private', tools: ['claude-code'], ...over,
+ });
+ const gallery = async (o = {}) => (await api(`/api/v1/skills${o.query || ''}`, o)).json();
+
+ try {
+ let pub;
+ let priv;
+ let link;
+
+ await check('a new record is private, v1, and not in the gallery', async () => {
+ const made = await create(ada, base({ title: 'Private notes' }));
+ assert.strictEqual(made.status, 201, made.text);
+ priv = made.json().skill;
+ assert.strictEqual(priv.audience, 'private');
+ assert.strictEqual(priv.version, 1);
+ assert.strictEqual(priv.owner_subject, ada.subject);
+ const list = await gallery();
+ assert.ok(!list.skills.some((s) => s.id === priv.id), 'a private record is not listed');
+ const anon = await api(`/api/v1/skills/${priv.id}`);
+ assert.strictEqual(anon.status, 404);
+ const owner = await api(`/api/v1/skills/${priv.id}`, { as: ada });
+ assert.strictEqual(owner.status, 200);
+ });
+
+ await check('public is listed and readable; link is readable by id but not listed', async () => {
+ pub = (await create(ada, base({ title: 'Public ship', slug: 'public-ship', audience: 'public' }))).json().skill;
+ link = (await create(ada, base({ title: 'Secret link', slug: 'secret-link', audience: 'link' }))).json().skill;
+ const list = await gallery();
+ assert.ok(list.skills.some((s) => s.id === pub.id));
+ assert.ok(!list.skills.some((s) => s.id === link.id), 'a link record is not in the gallery');
+ const anon = await api(`/api/v1/skills/${link.id}`);
+ assert.strictEqual(anon.status, 200);
+ assert.strictEqual(anon.json().skill.audience, 'link');
+ });
+
+ await check('versions grow and a rollback copies the old body into a new version', async () => {
+ const v2 = await api(`/api/v1/skills/${pub.id}/versions`, {
+ as: ada, method: 'POST', json: { body: skillBody('public-ship', 'The second version.'), summary: 'Second' },
+ });
+ assert.strictEqual(v2.status, 201, v2.text);
+ assert.strictEqual(v2.json().skill.version, 2);
+ assert.ok(v2.json().skill.body.includes('The second version.'));
+ const first = await api(`/api/v1/skills/${pub.id}/versions/1`, { as: ada });
+ assert.strictEqual(first.status, 200);
+ const rolled = await api(`/api/v1/skills/${pub.id}/rollback`, { as: ada, method: 'POST', json: { version: 1 } });
+ assert.strictEqual(rolled.status, 201, rolled.text);
+ assert.strictEqual(rolled.json().skill.version, 3);
+ assert.strictEqual(rolled.json().skill.body, first.json().version.body);
+ const history = await api(`/api/v1/skills/${pub.id}/versions`, { as: ada });
+ assert.deepStrictEqual(history.json().versions.map((v) => v.version), [3, 2, 1]);
+ assert.strictEqual(history.json().versions[0].note, 'rolled back to 1');
+ const old = await api(`/api/v1/skills/${pub.id}/versions/2`, { as: ada });
+ assert.ok(old.json().version.body.includes('The second version.'), 'an old version is never rewritten');
+ });
+
+ await check('the four audiences move the record in and out of the gallery', async () => {
+ for (const audience of ['people', 'link', 'private']) {
+ const moved = await api(`/api/v1/skills/${pub.id}/audience`, { as: ada, method: 'POST', json: { audience } });
+ assert.strictEqual(moved.status, 200, moved.text);
+ assert.strictEqual(moved.json().skill.audience, audience);
+ const list = await gallery();
+ assert.ok(!list.skills.some((s) => s.id === pub.id), `${audience} is not in the gallery`);
+ }
+ const back = await api(`/api/v1/skills/${pub.id}/audience`, { as: ada, method: 'POST', json: { audience: 'public' } });
+ assert.strictEqual(back.status, 200);
+ const list = await gallery();
+ assert.ok(list.skills.some((s) => s.id === pub.id));
+ });
+
+
+ await check('the Share sheet is openvibe-sdk/share: the owner sets the audience, a stranger gets 404', async () => {
+ const share = (o = {}) => t.get(`/api/v1/skill/${pub.id}/share`, {
+ as: o.as, method: o.method, json: o.json,
+ headers: { 'x-requested-with': 'ov-share', ...(o.headers || {}) },
+ });
+ const read = await share({ as: ada });
+ assert.strictEqual(read.status, 200, read.text);
+ assert.strictEqual(read.json().audience, 'public');
+ const etag = read.headers.get('etag');
+ assert.ok(etag, 'the share sheet carries an ETag');
+ const moved = await share({ as: ada, method: 'PUT', json: { audience: 'people' }, headers: { 'if-match': etag } });
+ assert.strictEqual(moved.status, 200, moved.text);
+ assert.strictEqual(moved.json().audience, 'people');
+ const listed = await gallery();
+ assert.ok(!listed.skills.some((s) => s.id === pub.id), 'people is not in the gallery');
+ const stranger = await share({ as: bo });
+ assert.strictEqual(stranger.status, 404, 'a non-owner cannot read the share settings');
+ const back = await share({ as: ada, method: 'PUT', json: { audience: 'public' } });
+ assert.strictEqual(back.status, 200, back.text);
+ });
+
+
+ await check('a people record opens for a member and for nobody else', async () => {
+ const people = (await create(ada, base({ title: 'For the team', slug: 'for-the-team', audience: 'people' }))).json().skill;
+ const member = JSON.stringify({ type: 'user', id: bo.subject });
+ const owner = JSON.stringify({ type: 'user', id: ada.subject });
+ await t.ctx.store.db.exec(`INSERT INTO share_members (service, kind, resource_id, subject, role, added_by, added_at)
+ VALUES ('codes', 'skill', $1, $2::jsonb, 'viewer', $3::jsonb, now())`, [people.id, member, owner]);
+ const allowed = await api(`/api/v1/skills/${people.id}`, { as: bo });
+ assert.strictEqual(allowed.status, 200, allowed.text);
+ assert.strictEqual(allowed.json().skill.id, people.id);
+ const anon = await api(`/api/v1/skills/${people.id}`);
+ assert.strictEqual(anon.status, 404, 'a people record is hidden from a stranger');
+ const listed = await gallery();
+ assert.ok(!listed.skills.some((s) => s.id === people.id), 'a people record is not in the gallery');
+ });
+
+ await check('the sync manifest is each person\'s own picks', async () => {
+ const now = (await api(`/api/v1/skills/${pub.id}`, { as: ada })).json().skill;
+ const added = await api(`/api/v1/me/skills/${pub.id}`, { as: ada, method: 'POST', json: { project: 'demo' } });
+ assert.strictEqual(added.status, 201, added.text);
+ const mine = (await api('/api/v1/me/skills?project=demo', { as: ada })).json();
+ assert.ok(mine.skills.some((s) => s.id === pub.id), 'the project pick is in the manifest');
+ assert.strictEqual(mine.skills.find((s) => s.id === pub.id).version, now.version);
+ const other = (await api('/api/v1/me/skills?project=demo', { as: bo })).json();
+ assert.ok(!other.skills.some((s) => s.id === pub.id), 'somebody else\'s picks are not in this manifest');
+ const removed = await api(`/api/v1/me/skills/${pub.id}/remove`, { as: ada, method: 'POST', json: { project: 'demo' } });
+ assert.strictEqual(removed.status, 200);
+ const after = (await api('/api/v1/me/skills?project=demo', { as: ada })).json();
+ assert.ok(!after.skills.some((s) => s.id === pub.id));
+ });
+
+ await check('the Node\'s sync authenticates the manifest with a bearer token', async () => {
+ const res = await t.get('/api/v1/me/skills?project=demo', { headers: { authorization: `Bearer ${bearer}` } });
+ assert.strictEqual(res.status, 200, res.text);
+ assert.ok(Array.isArray(res.json().skills), 'the manifest is a list');
+ });
+
+ await check('a body is untrusted: the page goes through the markup gate', async () => {
+ const made = (await create(ada, base({ title: 'Markup test', slug: 'markup-test', audience: 'public', body: '# Safe heading\n\n<script>alert(1)</script>\n\n[bad](javascript:alert(1))' }))).json().skill;
+ const page = await t.get(`/skills/${made.id}`, { as: ada });
+ assert.strictEqual(page.status, 200);
+ assert.ok(page.text.includes('Safe heading'));
+ assert.ok(page.text.includes('<script>alert(1)</script>'), 'the script is shown as text');
+ assert.ok(!page.text.includes('<script>alert(1)</script>'));
+ assert.ok(!page.text.includes('href="javascript:'), 'a javascript: link is not a link');
+ });
+
+ await check('invalid input and a duplicate slug are refused', async () => {
+ const badKind = await create(ada, base({ kind: 'plugin' }));
+ assert.strictEqual(badKind.status, 422);
+ assert.strictEqual(badKind.json().code, 'skill.invalid');
+ const badAudience = await create(ada, base({ audience: 'everyone' }));
+ assert.strictEqual(badAudience.status, 422);
+ const dup = await create(ada, base({ slug: 'public-ship', title: 'Another' }));
+ assert.strictEqual(dup.status, 409);
+ assert.strictEqual(dup.json().code, 'skill.slug_taken');
+ });
+
+ await check('the gallery page lists public records and not private ones', async () => {
+ const page = await t.get('/skills');
+ assert.strictEqual(page.status, 200);
+ assert.ok(page.text.includes('Public ship'));
+ assert.ok(!page.text.includes('Private notes'));
+ const mine = await t.get('/skills', { as: ada });
+ assert.ok(mine.text.includes('Private notes'), 'the owner sees their own record on the page');
+ });
+ } finally {
+ await t.close();
+ }
+ done();
+})().catch((err) => { console.error(err); process.exit(1); });