Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
# Changelog — ChannelGate

- Rename user-facing “local skills” to “gateway-owned skills” across skill management and guidance; stored ownership values and update behavior are unchanged.

- Queue Slack `/fork` automatically while the source thread is busy, with explicit acknowledgement, Stop cancellation and restart recovery.

- Suppress unfinished-thread nudges and follow-up digest entries when the Slack thread root or latest reply has a ✅ reaction; thumbs-up does not close a thread.
Expand Down
4 changes: 4 additions & 0 deletions FEATURES.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,10 @@

- Invalid legacy skill-folder grants are omitted from Claude plugins and the Codex personal catalog. The reply names folders missing a regular `SKILL.md`; remaining skills continue loading and source folders/grants remain intact.

## Skill ownership terminology

- Skills managed directly in the catalog are labelled **Gateway-owned** in the Skills catalog, owner filter, creation form, assignment picker and MCP guidance. Personal/channel/organization scope remains independent; internal `ownerKind: "local"` values remain compatible.

## Company email domain auto-approval

- Admin UI → Users → Auto-approval accepts exact company email domains, disabled by default.
Expand Down
7 changes: 7 additions & 0 deletions TEST-PLAN.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
# ChannelGate — Test Plan

## Skill ownership terminology (2026-11-04)

- Wording-only, engine-independent: open Skills with a gateway-owned fixture (`ownerKind: "local"`) and a Git-source fixture. Require **Gateway-owned** in the catalog owner column/filter and creation form, and **Gateway-owned skills** in the assignment picker. Filter/select/save the gateway-owned fixture; API owner/filter values must remain `local` and the grant must persist. Repository-backed labels must remain distinct.
- MCP: `list_skills` and `get_skill_info` on the same gateway-owned fixture must describe gateway ownership; catalog create/update/publish tool descriptions must use the same vocabulary. Existing skill CRUD, publishing and scope regression suites verify unchanged contracts.
- Passed: 31 focused skill CRUD, publishing, admin UI and picker regression checks; static validation. Disposable Chromium acceptance passed for catalog owner/filter/create labels, distinct Git-source label, raw `owner=local` filter request, picker source label/filter/add and unchanged stored ownership. This wording-only check is engine-independent.
- Existing full conversation/template browser suite: compact layout case passed; conversation navigation case timed out before reaching the skill picker (`#channel-detail .subtab[data-pane="skills"]`). Its unrelated full-flow acceptance remains unresolved.

## Slack email for password and code sign-in (2026-11-05)

- Local automated verification (2026-11-05): 4,246 passed / 40 opt-in skipped / zero failed in
Expand Down
16 changes: 9 additions & 7 deletions docs/SKILLS.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# Skills — the local catalog, channel profiles, templates, authoring and sources
# Skills — the gateway catalog, channel profiles, templates, authoring and sources

ChannelGate keeps its own **skill catalog** in the gateway database and materializes real skill
folders into every conversation that is granted them. Skills work with no external token and no
Expand Down Expand Up @@ -56,7 +56,9 @@ Every skill has one **owner**:
| bundled | shipped in this checkout (`src/gateway/skills/bundled/`), imported at boot | a new gateway release |
| folder | the host's `~/.claude/skills`, `~/.agents/skills` or `GATEWAY_SKILL_SOURCES`, imported at boot and re-read by content | edit the folder; the next boot / re-import picks it up |
| git | a GitHub source (below) | the source's next sync; a skill of the gateway's own publish repository also takes `update_skill` (pushed back) |
| local | authored from chat (`create_skill`) or the admin UI | `update_skill` — see *Who may change what* below |
| gateway-owned (`local` internally) | authored from chat (`create_skill`) or the admin UI | `update_skill` — see *Who may change what* below |

**Gateway-owned** means managed directly in this gateway's catalog, independently of whether the skill is personal, channel-scoped or organization-wide. It does not mean an unmanaged file installed in an engine skill folder. Publishing to the configured Git source adopts the skill into that source; it remains editable through `update_skill` when that source is the gateway's writable publishing repository. Internal API/database ownership values remain `local` for compatibility.

A slug is owned by exactly one owner. A sync or an import that finds a slug another owner holds
reports a **conflict** and writes nothing. A skill whose source this gateway cannot write to
Expand Down Expand Up @@ -113,7 +115,7 @@ in every other conversation) and back on with `add_channel_skills`. `set_skill_s
`POST /api/skills/catalog/:slug/scope` move a skill either way: **promoting** a customer skill to
the library moves its files in the repository and leaves the channel with an explicit grant, so
nothing changes there; demoting keeps it for that channel only. Only skills the publish
repository owns (or unpublished local ones) move — a skill synced from another source stays with
repository owns (or unpublished gateway-owned ones) move — a skill synced from another source stays with
its source. Names must be unique across the whole repository (the slug comes from the skill's
name), so customer skills are prefixed with the customer.

Expand Down Expand Up @@ -249,7 +251,7 @@ it can never reach the organization tier.
above). The bundled `skill-authoring` skill teaches the contract and how to write a
description that triggers.
- `update_skill` — a new revision. Pass only the files that change; the rest are carried over
(`remove` drops files). A local skill is revised in place; a skill synced from the configured
(`remove` drops files). A gateway-owned skill is revised in place; a skill synced from the configured
publish repository is revised and pushed back to its own folder (if the push fails the new
revision gets a publication recovery pin, so the next sync cannot revert it). A successful
retry through `publish_skill` or a later successful update clears that recovery pin and follows
Expand All @@ -267,7 +269,7 @@ it can never reach the organization tier.
organization skill gets granted everywhere), a catalog delete (`kind: delete`), or feedback.
- Admins review with `list_skill_proposals` / `decide_skill_proposal` in chat, or in the admin UI
(Skills → Review). An approved change becomes one revision; when the skill is source-owned the
revision is **pinned** as a local override, so the source keeps flowing into later revisions
revision is **pinned** as a gateway override, so the source keeps flowing into later revisions
and the pin holds until an admin unpins. An approved promotion moves a channel skill into the
library (the channel keeps it as an explicit grant), makes a personal skill an organization
skill, or adds an organization skill to the organization tier; an approved `delete` tombstones
Expand Down Expand Up @@ -297,7 +299,7 @@ publishes it). Any approved member can also carry organization skills in their o
## Publishing to Git

Admin UI → Skills → Sync settings → *Publishing to Git*: a GitHub repository, folder and its own
write-only token. Publishing follows `main`. Every new revision of a local skill — created,
write-only token. Publishing follows `main`. Every new revision of a gateway-owned skill — created,
updated, an approved change, an approved promotion — is pushed with the GitHub Contents API, one
commit per file under `<folder>/<slug>/` (`.` as the folder = the repository root) — or under
`channels/<channel id>/<slug>/` for a channel-scoped skill, with a README naming the channel — and
Expand Down Expand Up @@ -363,7 +365,7 @@ with the gateway's environment, uses the Skills Manager tokens the old integrati
3. with `SM_SUPABASE_URL` + `SM_SUPABASE_SERVICE_KEY` (Skills Manager's service role): team
favorites become templates (Development; Sales & Marketing → sales and marketing; Management;
Admin), admin exclusions become tombstones, and each token user's personal skills become
personal local skills.
personal gateway-owned skills.

The script is idempotent and prints what it would do with `--dry-run`. Stub folders the old
integration left in channel folders are pruned automatically on each channel's next message.
Expand Down
13 changes: 7 additions & 6 deletions public/admin-skills.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// The Skills view of the admin UI: the local skill catalog (browse, read, author, pin/rollback,
// The Skills view of the admin UI: the gateway skill catalog (browse, read, author, pin/rollback,
// remove), the review queue (staged source revisions + proposals), sources (GitHub / gateway,
// sync, settings), templates (edit, preview and apply to a conversation) and usage. Talks to
// src/web/routes/skills.js. No framework — one delegated click handler per panel.
Expand Down Expand Up @@ -52,7 +52,8 @@ const state = {

const body = () => document.getElementById("skills-body");
const fmtWhen = (ts) => (ts ? String(ts).replace("T", " ").slice(0, 16) : "—");
const ownerLabel = (s) => (s.ownerKind === "git" ? `git source #${s.sourceId}` : s.ownerKind);
const ownerKindLabel = (kind) => kind === "local" ? "Gateway-owned" : kind;
const ownerLabel = (s) => (s.ownerKind === "git" ? `git source #${s.sourceId}` : ownerKindLabel(s.ownerKind));

function setMessage(msg, isError = false) {
state.message = isError ? "" : msg;
Expand Down Expand Up @@ -268,7 +269,7 @@ function renderCatalog() {
return `
<div class="skills-toolbar">
${searchBox("skills-q", { label: "Search the skill catalog", placeholder: "Search slug, name, description, tags…" })}
<select id="skills-owner">${owners.map((o) => `<option value="${o}"${o === state.owner ? " selected" : ""}>${o ? esc(o) : "every owner"}</option>`).join("")}</select>
<select id="skills-owner">${owners.map((o) => `<option value="${o}"${o === state.owner ? " selected" : ""}>${o ? esc(ownerKindLabel(o)) : "every owner"}</option>`).join("")}</select>
<select id="skills-source" aria-label="Source"><option value="">Every source</option>${(state.overview?.sources || []).map((source) => `<option value="${source.id}"${String(source.id) === state.source ? " selected" : ""}>${esc(source.label || source.url)}</option>`).join("")}</select>
<select id="skills-category" aria-label="Category"><option value="">Every category</option>${(state.catalog?.categories || []).map((category) => `<option value="${esc(category.category)}"${category.category === state.category ? " selected" : ""}>${esc(category.category)}</option>`).join("")}</select>
<select id="skills-missing-manifest" aria-label="Skill file status">${binaryOptions(state.missingSkillMd, "Every skill file status", "Missing SKILL.md", "Has SKILL.md")}</select>
Expand All @@ -290,7 +291,7 @@ function renderCatalog() {
function renderNewSkillForm() {
return `
<div class="card skills-detail">
<h3>New local skill</h3>
<h3>New gateway-owned skill</h3>
<div class="skills-form">
<label class="field"><span>Slug (folder name; empty = from the frontmatter name)</span><input id="ns-slug" placeholder="customer-record" /></label>
<label class="field"><span>Note</span><input id="ns-note" placeholder="why this skill exists" /></label>
Expand Down Expand Up @@ -364,7 +365,7 @@ function renderReview() {
<tr>
<td><code>${esc(r.slug)}</code>${pluginBadge(r)} <span class="muted">${esc(r.skillName || "")}</span>${pluginSummary(r)}</td>
<td>#${r.revisionNo}${r.version ? ` v${esc(r.version)}` : ""} · ${r.fileCount} file(s)</td>
<td class="muted">${esc(r.ownerKind === "git" ? `git source #${r.sourceId}` : r.ownerKind)} · ${esc(r.sourceRef.slice(0, 12))}</td>
<td class="muted">${esc(ownerLabel(r))} · ${esc(r.sourceRef.slice(0, 12))}</td>
<td class="muted">${fmtWhen(r.createdAt)}</td>
<td><span class="skills-inline"><button type="button" class="ghost" data-action="view-rev" data-id="${r.id}">files</button><button type="button" data-action="approve-rev" data-id="${r.id}">Approve</button><button type="button" class="ghost" data-action="reject-rev" data-id="${r.id}">Reject</button></span></td>
</tr>`).join("");
Expand All @@ -381,7 +382,7 @@ function renderReview() {
<table class="skills-table"><thead><tr><th>Skill or plugin</th><th>Revision</th><th>From</th><th>Received</th><th></th></tr></thead><tbody>${stagedRows || '<tr><td colspan="5" class="muted">Nothing staged.</td></tr>'}</tbody></table>
${state.fileView?.kind === "revision" ? `<div class="card skills-detail"><h3>Revision #${state.fileView.revisionNo} files</h3>${state.fileView.files.map((f) => `<p><strong>${esc(f.path)}</strong> <span class="skills-muted">${f.size} B</span></p>${f.content != null ? `<pre>${esc(f.content)}</pre>` : ""}`).join("")}</div>` : ""}
<h3 style="margin-top:18px">Proposals</h3>
<p class="skills-note">Changes members proposed from chat. Approving a change publishes a revision (pinned as a local override when the skill comes from a source); approving a promotion grants the skill organization-wide.</p>
<p class="skills-note">Changes members proposed from chat. Approving a change publishes a revision (pinned as a gateway override when the skill comes from a source); approving a promotion grants the skill organization-wide.</p>
<table class="skills-table"><thead><tr><th>Proposal</th><th>Note / files</th><th>By</th><th></th></tr></thead><tbody>${proposalRows || '<tr><td colspan="4" class="muted">No pending proposals.</td></tr>'}</tbody></table>
${state.fileView?.kind === "proposal" ? `<div class="card skills-detail"><h3>Proposal #${state.fileView.id} files</h3>${state.fileView.files.map((f) => `<p><strong>${esc(f.path)}</strong></p><pre>${esc(f.encoding === "base64" ? "(binary)" : f.content)}</pre>`).join("")}</div>` : ""}`;
}
Expand Down
2 changes: 1 addition & 1 deletion public/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -140,7 +140,7 @@ <h2>Users</h2>
</div>
</section>

<!-- Skills: the local skill catalog, its sources, the review queue, templates and usage -->
<!-- Skills: the gateway skill catalog, its sources, the review queue, templates and usage -->
<section id="view-skills" class="view full">
<div class="view-head dash-head">
<div>
Expand Down
4 changes: 2 additions & 2 deletions public/skill-assignment-picker.js
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,8 @@ export function mountSkillAssignmentPicker(root, options) {
const source = root.querySelector("[data-picker-source]");
const sourceLabel = (skill) => config.sources.find((s) => String(s.id) === String(skill.sourceId))?.label
|| config.sources.find((s) => String(s.id) === String(skill.sourceId))?.url
|| ({ local: "Local skills", bundled: "Built-in skills", folder: "Host folders" }[skill.ownerKind])
|| (skill.sourceId != null ? `Source #${skill.sourceId}` : "Local skills");
|| ({ local: "Gateway-owned skills", bundled: "Built-in skills", folder: "Host folders" }[skill.ownerKind])
|| (skill.sourceId != null ? `Source #${skill.sourceId}` : "Gateway-owned skills");
function paintSources() {
const previous = source.value;
const sources = new Map(config.skills.map((skill) => [sourceKey(skill), sourceLabel(skill)]));
Expand Down
6 changes: 4 additions & 2 deletions src/gateway/gateway-usage/references/skills.md
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
# Skills: what is active here, templates, authoring and proposals

Skills are reusable procedures (a `SKILL.md` plus references/scripts) that the gateway keeps in
its own local **catalog** and materializes into this conversation's folder as real files. You
its own gateway **catalog** and materializes into this conversation's folder as real files. You
discover them natively — the always-on list is each skill's name + description; the body loads
when a skill fires. No token or network call is involved.

**Gateway-owned** skills are managed directly in the catalog (internal owner value `local`). This ownership is independent of personal/channel/organization scope and does not mean unmanaged files installed in an engine skill folder.

## Where agents must save skills

Create and grant skills through the ChannelGate catalog using `create_skill` and the gateway grant tools. Do not install active skills by writing directly into `.claude/skills`, `.agents/skills`, or engine HOME skill folders: gateway reconciliation replaces managed copies and archives unmanaged workspace entries. An on-disk export is only an export, not a durable active channel skill. This rule overrides a generic skill creator's local-folder default.
Expand Down Expand Up @@ -143,7 +145,7 @@ never-expiring) card; the rest are automatic for an admin (`references/administr
- `set_skill_governance` — enable/disable a catalog skill, approve it for organization-wide
discovery, or make it mandatory. Mandatory implies enabled and discoverable.
- `set_skill_excluded` — hide a synced/bundled skill from the catalog (it stays hidden across syncs) or bring it back.
- `publish_skill` (managers too) — push a local skill to the configured Git repository now.
- `publish_skill` (managers too) — push a gateway-owned or writable publishing-repository skill to the configured Git repository now.
- `set_skill_scope` (admins) — move a skill between the shared library and a channel's section
(`scope: "library"` promotes a customer skill for everyone; the channel it leaves keeps it as
an explicit grant; `scope: "channel"` keeps it for one customer). The files move in the
Expand Down
2 changes: 1 addition & 1 deletion src/gateway/skills/authoring.js
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ export function describeOwner(skill) {
case "bundled":
return "bundled with the gateway";
default:
return `${skill?.visibility === "personal" ? "personal, " : ""}authored locally${skill?.createdBy ? ` by ${skill.createdBy}` : ""}`;
return `${skill?.visibility === "personal" ? "personal, " : ""}gateway-owned${skill?.createdBy ? `, created by ${skill.createdBy}` : ""}`;
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ See [folder and headless configuration](folder-headless.md) for the low-level po
## Local governed skill catalog

ChannelGate's SQLite catalog stores exact, content-hashed skill revisions and bundled resources.
Skills may be bundled, locally authored, imported from reviewed host folders, synchronized from Git
Skills may be bundled, gateway-owned, imported from reviewed host folders, synchronized from Git
sources, or synchronized from a peer gateway. Review mode stages source changes; auto mode
activates them. Excluding a skill is sticky across future syncs; deleting/tombstoning and restoring
retain revision history.
Expand Down
2 changes: 1 addition & 1 deletion src/gateway/skills/bundled/skill-authoring/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ tags:
# Authoring skills

Skills are folders with a `SKILL.md` (YAML frontmatter + Markdown body) plus optional
`references/`, `scripts/` and `assets/` files. The gateway keeps them in its local catalog and
`references/`, `scripts/` and `assets/` files. The gateway keeps them in its gateway catalog and
materializes them into every conversation that is granted them — Claude and Codex both discover
them natively by their frontmatter `name` and `description`.

Expand Down
Loading
Loading