Ver código fonte

feat: 技能发布平台——项目用发布密钥推送技能,管理员审核后新建或覆盖(自动检查、差异与二次确认、拒绝原因、记录删除);去掉目录技能源与上传 zip,所有自定义技能可编辑;API 协议增加"技能发布 API"

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Jerry Liang 4 horas atrás
pai
commit
1df6f79373
38 arquivos alterados com 8191 adições e 1086 exclusões
  1. 12 3
      apps/admin/src/components/ApiTryPanel.vue
  2. 87 202
      apps/admin/src/components/CustomSkillsPanel.vue
  3. 3 11
      apps/admin/src/components/DirPathInput.vue
  4. 22 0
      apps/admin/src/components/FileDiffList.vue
  5. 133 0
      apps/admin/src/components/SkillPublishKeys.vue
  6. 267 0
      apps/admin/src/components/SkillReviewPane.vue
  7. 22 16
      apps/admin/src/lib/api.ts
  8. 24 12
      apps/admin/src/views/ApiDocsView.vue
  9. 5 2
      apps/admin/src/views/PluginsView.vue
  10. 42 0
      apps/server/drizzle/0019_skill_registry.sql
  11. 3 0
      apps/server/drizzle/0020_drop_skill_sources.sql
  12. 3077 0
      apps/server/drizzle/meta/0019_snapshot.json
  13. 2956 0
      apps/server/drizzle/meta/0020_snapshot.json
  14. 14 0
      apps/server/drizzle/meta/_journal.json
  15. 8 2
      apps/server/src/api-reference/reference.ts
  16. 123 0
      apps/server/src/api-reference/registry-reference.ts
  17. 3 2
      apps/server/src/api-reference/render.ts
  18. 3 3
      apps/server/src/app.ts
  19. 0 1
      apps/server/src/config.ts
  20. 148 0
      apps/server/src/custom-skills/package.ts
  21. 397 0
      apps/server/src/custom-skills/registry.ts
  22. 65 73
      apps/server/src/custom-skills/service.ts
  23. 0 485
      apps/server/src/custom-skills/sources.ts
  24. 65 14
      apps/server/src/db/schema.ts
  25. 33 32
      apps/server/src/http/routes/admin.ts
  26. 63 0
      apps/server/src/http/routes/registry.ts
  27. 2 0
      apps/server/src/http/server.ts
  28. 1 1
      apps/server/src/lib/ids.ts
  29. 0 3
      apps/server/src/main.ts
  30. 40 0
      apps/server/src/services/server-dirs.ts
  31. 16 1
      apps/server/test/api-reference.test.ts
  32. 174 159
      apps/server/test/custom-skills.test.ts
  33. 1 1
      apps/server/test/team-jobs.test.ts
  34. 4 2
      docs/README.md
  35. 18 0
      docs/adr/0027-skill-registry-push-and-review.md
  36. 3 1
      docs/design/12-custom-skills.md
  37. 255 0
      docs/design/18-skill-registry.md
  38. 102 60
      packages/shared/src/api.ts

+ 12 - 3
apps/admin/src/components/ApiTryPanel.vue

@@ -16,6 +16,9 @@ const emit = defineEmits<{ ids: [ids: Record<string, string>] }>()
 const ep = props.endpoint
 const isStream = ep.path.endsWith('/stream')
 const isUpload = ep.method === 'POST' && ep.path.endsWith('/files')
+/** 技能发布 API 的推送:上传一个 zip,附提交说明 */
+const isPush = ep.method === 'POST' && ep.path === '/skills/registry/v1/submissions'
+const pushMessage = ref('')
 const isDownload = ep.method === 'GET' && (ep.path === '/v1/files/:id' || ep.path === '/bot/v1/files/:id')
 const startsRun = ep.path.endsWith('/messages')
 
@@ -24,6 +27,7 @@ const PREFIX: Record<string, { prefix: string; label: string }> = {
   sessions: { prefix: 'ses_', label: '会话 ID' }, runs: { prefix: 'run_', label: 'Run ID' },
   interactions: { prefix: 'itx_', label: '交互 ID' }, files: { prefix: 'file_', label: '文件 ID' },
   teams: { prefix: 'team_', label: '工作组 ID' }, bots: { prefix: 'bot_', label: '机器人 ID' },
+  submissions: { prefix: 'sub_', label: '提交 ID' },
 }
 const pathParams = [...ep.path.matchAll(/([a-z-]+)\/:(\w+)/g)].map(m => ({ name: m[2]!, ...(PREFIX[m[1]!] ?? { prefix: '', label: m[2]! }) }))
 const params = reactive<Record<string, string>>(Object.fromEntries(pathParams.map(p => [p.name, props.recentIds[p.prefix] ?? ''])))
@@ -50,7 +54,7 @@ function collectIds(v: unknown, out: Record<string, string> = {}, depth = 0): Re
   if (depth > 4 || v === null || typeof v !== 'object') return out
   for (const [k, x] of Object.entries(v as Record<string, unknown>)) {
     if (typeof x === 'string' && /^(id|sessionId|runId|interactionId|fileId|activeRunId|botId|teamId)$/.test(k)) {
-      const m = /^(ses|run|itx|file|team|bot)_[0-9a-z]+$/.exec(x)
+      const m = /^(ses|run|itx|file|team|bot|sub)_[0-9a-z]+$/.exec(x)
       if (m && !out[`${m[1]}_`]) out[`${m[1]}_`] = x
     } else if (typeof x === 'object') collectIds(x, out, depth + 1)
   }
@@ -64,10 +68,11 @@ async function send() {
   }
   let payload: BodyInit | undefined
   const headers: Record<string, string> = {}
-  if (isUpload) {
+  if (isUpload || isPush) {
     if (!files.value.length) return window.alert('请先选择文件')
     const form = new FormData()
-    for (const f of files.value) form.append('file', f, f.name)
+    for (const f of isPush ? files.value.slice(0, 1) : files.value) form.append('file', f, f.name)
+    if (isPush && pushMessage.value.trim()) form.append('message', pushMessage.value.trim())
     payload = form
   } else if (hasBody && body.value.trim()) {
     try {
@@ -179,6 +184,10 @@ onBeforeUnmount(() => {
     <div v-if="isUpload">
       <input type="file" multiple aria-label="选择上传的文件" @change="files = [...(($event.target as HTMLInputElement).files ?? [])]">
     </div>
+    <div v-else-if="isPush" class="flex flex-col gap-1.5">
+      <input type="file" accept=".zip,application/zip" aria-label="选择技能包(zip)" @change="files = [...(($event.target as HTMLInputElement).files ?? [])]">
+      <input v-model="pushMessage" class="field" placeholder="提交说明(可选)" aria-label="提交说明">
+    </div>
     <div v-else-if="hasBody">
       <div class="label">请求体(JSON)</div>
       <textarea v-model="body" class="field-area h-24" spellcheck="false" aria-label="请求体" />

+ 87 - 202
apps/admin/src/components/CustomSkillsPanel.vue

@@ -1,24 +1,35 @@
 <script setup lang="ts">
-// 自定义技能(docs/design/12-custom-skills.md):左侧技能列表与技能源,右侧文件编辑;导入审阅、版本历史与对比用对话框
+// 自定义技能(docs/design/12-custom-skills.md):左侧技能列表、推送的技能(18-skill-registry.md)与发布密钥,右侧文件编辑或审核;版本历史与对比用对话框
 import { computed, defineAsyncComponent, onMounted, ref } from 'vue'
-import type { CustomSkillDetailDto, CustomSkillDto, CustomSkillFileDto, FileDiffDto, SkillScanDto, SkillScanItemDto, SkillSourceDto, SkillSourceInput } from '@agent-server/shared'
+import type { CustomSkillDetailDto, CustomSkillDto, CustomSkillFileDto, FileDiffDto, SkillSubmissionItemDto } from '@agent-server/shared'
 import { admin, errorMessage } from '../lib/api'
 import { formatTime, toast } from '../lib/ui'
-import DirPathInput from './DirPathInput.vue'
+import FileDiffList from './FileDiffList.vue'
+import SkillPublishKeys from './SkillPublishKeys.vue'
+import SkillReviewPane from './SkillReviewPane.vue'
 
 // 编辑器按需加载
 const MonacoEditor = defineAsyncComponent(() => import('./MonacoEditor.vue'))
 
 const skills = ref<CustomSkillDto[]>([])
-const sources = ref<SkillSourceDto[]>([])
+const submissions = ref<SkillSubmissionItemDto[]>([])
+const subTab = ref<'pending' | 'done'>('pending')
+const pendingCount = ref(0)
+/** 正在审核的提交项(与 current 互斥) */
+const reviewId = ref<string | null>(null)
+const keysPanel = ref<InstanceType<typeof SkillPublishKeys>>()
+const emit = defineEmits<{ pending: [n: number] }>()
 const current = ref<CustomSkillDetailDto | null>(null)
 const busy = ref<string | null>(null)
 
-const REASON: Record<string, string> = { create: '新建', edit: '编辑', import: '导入', sync: '同步', restore: '恢复' }
+const REASON: Record<string, string> = { create: '新建', edit: '编辑', import: '导入', sync: '同步', restore: '恢复', submission: '推送' }
 
 async function load() {
   try {
-    ;[skills.value, sources.value] = await Promise.all([admin.customSkills.list(), admin.skillSources.list()])
+    ;[skills.value, submissions.value, pendingCount.value] = await Promise.all([
+      admin.customSkills.list(), admin.skillSubmissions.list(subTab.value), admin.skillSubmissions.count().then(r => r.pending),
+    ])
+    emit('pending', pendingCount.value)
     if (current.value) current.value = await admin.customSkills.get(current.value.id)
   } catch (err) {
     toast(errorMessage(err), 'error')
@@ -41,6 +52,7 @@ async function run<T>(key: string, fn: () => Promise<T>, done?: string): Promise
 
 /** 更新当前技能并同步列表 */
 function apply(d: CustomSkillDetailDto) {
+  reviewId.value = null
   current.value = d
   const i = skills.value.findIndex(s => s.id === d.id)
   if (i >= 0) skills.value[i] = d
@@ -52,12 +64,12 @@ const openPath = ref<string | null>(null)
 const editorText = ref('')
 const editorOrig = ref('')
 const editorDirty = computed(() => editorText.value !== editorOrig.value)
-const readonlySkill = computed(() => current.value?.origin === 'imported')
 
 async function openSkill(s: CustomSkillDto) {
   if (!(await flush())) return
   const d = await run('open', () => admin.customSkills.get(s.id))
   if (!d) return
+  reviewId.value = null
   current.value = d
   await openFile(d.files.find(f => f.path === 'SKILL.md') ?? d.files[0] ?? null)
 }
@@ -79,7 +91,7 @@ async function openFile(f: CustomSkillFileDto | null) {
 let flushing: Promise<boolean> | null = null
 function flush(): Promise<boolean> {
   if (flushing) return flushing.then(() => flush())
-  if (!current.value || !openPath.value || !editorDirty.value || readonlySkill.value) return Promise.resolve(true)
+  if (!current.value || !openPath.value || !editorDirty.value) return Promise.resolve(true)
   const id = current.value.id
   const p = openPath.value
   const text = editorText.value
@@ -169,11 +181,6 @@ async function setFlag(flags: { enabled?: boolean; includeInAll?: boolean }) {
   const d = await run('flags', () => admin.customSkills.setFlags(current.value!.id, flags))
   if (d) apply(d)
 }
-async function detach() {
-  if (!current.value || !window.confirm('转为可编辑后,这个技能与来源脱钩,之后不再从来源同步。继续?')) return
-  const d = await run('detach', () => admin.customSkills.detach(current.value!.id), '已转为可编辑')
-  if (d) apply(d)
-}
 async function removeSkill() {
   const s = current.value
   if (!s) return
@@ -220,78 +227,37 @@ async function publishVersion(v: number) {
   const d = await run('publish', () => admin.customSkills.publish(current.value!.id, v), `已发布版本 ${v}`)
   if (d) apply(d)
 }
-const lineClass = (l: string) => (l.startsWith('+') && !l.startsWith('+++') ? 'text-success' : l.startsWith('-') && !l.startsWith('---') ? 'text-error' : l.startsWith('@@') ? 'text-link' : 'text-fg-muted')
 
-// ---- 技能源 / 导入 ----
-const sourceDraft = ref<(SkillSourceInput & { id: string | null }) | null>(null)
-function editSource(s?: SkillSourceDto) {
-  sourceDraft.value = s
-    ? { id: s.id, name: s.name, description: s.description, path: s.path, syncMode: s.syncMode, intervalMin: s.intervalMin }
-    : { id: null, name: '', description: '', path: '', syncMode: 'manual', intervalMin: 10 }
-}
-async function saveSource() {
-  const d = sourceDraft.value!
-  const input: SkillSourceInput = { name: d.name, description: d.description, path: d.path, syncMode: d.syncMode, intervalMin: Number(d.intervalMin) }
-  const r = await run('source', () => (d.id ? admin.skillSources.update(d.id, input) : admin.skillSources.create(input)), '已保存')
-  if (!r) return
-  sourceDraft.value = null
-  await load()
-  if (!d.id) await scan(r)
-}
-async function removeSource(s: SkillSourceDto) {
-  const note = s.skillNames.length ? `\n从它导入的技能(${s.skillNames.join('、')})会保留,并转为可编辑。` : ''
-  if (!window.confirm(`删除技能源「${s.name}」?${note}`)) return
-  await run('source', () => admin.skillSources.remove(s.id), '已删除')
-  await load()
-}
-
-/** 扫描结果:技能源(source)或上传的 zip(upload,一次性:同名技能选择覆盖或保留) */
-const scanResult = ref<{
-  source: SkillSourceDto | null; upload: { id: string; name: string } | null; scan: SkillScanDto; selected: Record<string, boolean>; open: Record<string, boolean>
-} | null>(null)
-function showScan(source: SkillSourceDto | null, scan: SkillScanDto, upload: { id: string; name: string } | null = null) {
-  const selected = Object.fromEntries(scan.skills.filter(s => s.status === 'new' || s.status === 'changed').map(s => [s.name, true]))
-  scanResult.value = { source, upload, scan, selected, open: {} }
+// ---- 推送的技能(审核) ----
+async function showSubmissions(tab: 'pending' | 'done') {
+  subTab.value = tab
+  try {
+    submissions.value = await admin.skillSubmissions.list(tab)
+  } catch (err) {
+    toast(errorMessage(err), 'error')
+  }
 }
-function closeScan() {
-  const u = scanResult.value?.upload
-  scanResult.value = null
-  if (u) void admin.skillSources.discardUpload(u.id).catch(() => {})
+async function openReview(i: SkillSubmissionItemDto) {
+  if (!(await flush())) return
+  current.value = null
+  openPath.value = null
+  reviewId.value = i.id
 }
-async function scan(s: SkillSourceDto) {
-  const r = await run(`scan:${s.id}`, () => admin.skillSources.scan(s.id))
-  if (r) showScan(s, r)
+async function onReviewChanged() {
   await load()
+  keysPanel.value?.load()
 }
-const zipInput = ref<HTMLInputElement>()
-async function onZip(e: Event) {
-  const file = (e.target as HTMLInputElement).files?.[0]
-  ;(e.target as HTMLInputElement).value = ''
-  if (!file) return
-  const r = await run('zip', () => admin.skillSources.upload(file))
-  if (r) showScan(null, r.scan, { id: r.uploadId, name: r.name })
-}
-async function doImport() {
-  const s = scanResult.value!
-  const names = Object.entries(s.selected).filter(([, v]) => v).map(([k]) => k)
-  if (!names.length) return
-  const r = await run('import', () => (s.upload ? admin.skillSources.importUpload(s.upload.id, names, s.scan.digest) : admin.skillSources.import(s.source!.id, names, s.scan.digest)))
-  if (!r) return
-  toast(`已导入并发布:${r.imported.map(x => `${x.name}${!x.created && s.upload ? '(覆盖)' : ''} v${x.version}`).join('、')}`, 'success')
-  scanResult.value = null
-  await load()
+async function onReviewDeleted() {
+  reviewId.value = null
+  await onReviewChanged()
 }
-const STATUS: Record<string, [string, string]> = {
-  new: ['新技能', 'text-success'], changed: ['有变化', 'text-link'], unchanged: ['无变化', 'text-fg-muted'], conflict: ['同名冲突', 'text-error'], invalid: ['不能导入', 'text-error'],
+async function openSkillById(id: string) {
+  const s = skills.value.find(x => x.id === id)
+  if (s) await openSkill(s)
 }
-/** 上传的 zip 中与已有技能同名:显示为"已存在",由管理员选择覆盖或保留 */
-const statusLabel = (s: SkillScanItemDto, upload: boolean): [string, string] =>
-  upload && s.status === 'changed' ? ['已存在同名技能', 'text-warning'] : upload && s.status === 'unchanged' ? ['与已有技能相同', 'text-fg-muted'] : STATUS[s.status]!
-/** 版本对比:SKILL.md 声明的版本号与平台中的版本 */
-function versionLine(s: SkillScanItemDto) {
-  const e = s.existing!
-  const now = `v${e.version}${e.declaredVersion ? `(声明版本 ${e.declaredVersion})` : ''} · ${formatTime(e.updatedAt)} 更新`
-  return `已有:${now};上传的:${s.declaredVersion ? `声明版本 ${s.declaredVersion}` : '未声明版本号'}`
+const SUB_STATUS: Record<string, [string, string]> = {
+  pending: ['待审核', 'text-warning'], approved: ['已批准', 'text-success'], rejected: ['已拒绝', 'text-error'], withdrawn: ['已撤回', 'text-fg-muted'],
+  superseded: ['已替代', 'text-fg-muted'], invalid: ['不能导入', 'text-error'], unchanged: ['无变化', 'text-fg-muted'],
 }
 
 const fmtSize = (n: number) => (n < 1024 ? `${n} B` : n < 1048576 ? `${(n / 1024).toFixed(1)} KB` : `${(n / 1048576).toFixed(1)} MB`)
@@ -303,14 +269,12 @@ defineExpose({ load })
 
 <template>
   <div class="flex-1 min-h-0 flex">
-    <!-- 左:技能与技能源 -->
+    <!-- 左:技能、推送的技能与发布密钥 -->
     <aside class="w-72 flex-none border-r border-r-solid border-border overflow-y-auto p-3 flex flex-col gap-4 text-[12.5px]">
       <div class="flex flex-col gap-1">
         <div class="flex items-center gap-1">
           <span class="section-title flex-1">自定义技能</span>
           <button type="button" class="icon-btn" title="新建技能" @click="createSkill"><span class="codicon codicon-add" /></button>
-          <button type="button" class="icon-btn" title="上传 zip 导入" @click="zipInput?.click()"><span class="codicon" :class="busy === 'zip' ? 'codicon-loading codicon-modifier-spin' : 'codicon-cloud-upload'" /></button>
-          <input ref="zipInput" type="file" accept=".zip,application/zip" class="hidden" aria-label="上传技能 zip" @change="onZip">
         </div>
         <button
           v-for="s in skills" :key="s.id" type="button"
@@ -318,49 +282,54 @@ defineExpose({ load })
           :class="current?.id === s.id ? 'bg-active' : 'bg-transparent hover:bg-hover'" @click="openSkill(s)"
         >
           <span class="flex items-center gap-1.5">
-            <span class="codicon text-[12px]" :class="s.origin === 'imported' ? 'codicon-cloud-download text-link' : 'codicon-edit text-fg-muted'" :title="s.origin === 'imported' ? '导入' : '编写'" />
+            <span class="codicon codicon-symbol-misc text-[12px] text-fg-muted" />
             <span class="font-mono truncate" :class="{ 'line-through opacity-60': !s.enabled }">{{ s.name }}</span>
             <span v-if="s.hasUnsavedChanges" class="w-1.5 h-1.5 rounded-full bg-warning flex-none" title="有未保存的改动" />
           </span>
-          <span class="hint pl-4.5">{{ statusOf(s) }}<template v-if="s.sourceName"> · {{ s.sourceName }}</template></span>
+          <span class="hint pl-4.5">{{ statusOf(s) }}</span>
         </button>
-        <div v-if="!skills.length" class="hint px-2">还没有自定义技能。可以新建,或从技能源 / zip 导入。</div>
+        <div v-if="!skills.length" class="hint px-2">还没有自定义技能。可以新建,或由项目用发布密钥推送、审核后生成。</div>
       </div>
 
       <div class="flex flex-col gap-1">
         <div class="flex items-center gap-1">
-          <span class="section-title flex-1">技能源</span>
-          <button type="button" class="icon-btn" title="登记服务器目录" @click="editSource()"><span class="codicon codicon-add" /></button>
+          <span class="section-title flex-1">推送的技能</span>
+          <button type="button" class="icon-btn" title="刷新" @click="load"><span class="codicon codicon-refresh" /></button>
         </div>
-        <div v-for="s in sources" :key="s.id" class="rounded-md border border-solid border-border p-2 flex flex-col gap-1">
-          <div class="flex items-center gap-1">
-            <span class="codicon codicon-folder text-[12px]" />
-            <span class="font-500 truncate flex-1">{{ s.name }}</span>
-            <button type="button" class="icon-btn" title="扫描并同步" :disabled="!!busy" @click="scan(s)">
-              <span class="codicon" :class="busy === `scan:${s.id}` ? 'codicon-loading codicon-modifier-spin' : 'codicon-sync'" />
-            </button>
-            <button type="button" class="icon-btn" title="编辑" @click="editSource(s)"><span class="codicon codicon-edit" /></button>
-            <button type="button" class="icon-btn" title="删除" @click="removeSource(s)"><span class="codicon codicon-trash" /></button>
-          </div>
-          <div class="hint font-mono break-all">{{ s.path }}</div>
-          <div class="hint">
-            {{ s.syncMode === 'auto' ? `自动同步(每 ${s.intervalMin} 分钟)` : '手动同步' }}
-            <template v-if="s.lastSyncAt"> · {{ formatTime(s.lastSyncAt) }}</template>
-          </div>
-          <div v-if="s.status === 'error'" class="text-error break-all">{{ s.lastError }}</div>
-          <div v-if="s.skillNames.length" class="hint">技能:{{ s.skillNames.join('、') }}</div>
+        <div class="flex gap-1 px-1">
+          <button
+            v-for="t in ([['pending', `待审核${pendingCount ? ` (${pendingCount})` : ''}`], ['done', '已处理']] as const)" :key="t[0]" type="button"
+            class="h-6 px-2 rounded-sm border-none cursor-pointer text-[12px]" :class="subTab === t[0] ? 'bg-active text-fg' : 'bg-transparent text-fg-muted hover:bg-hover'"
+            @click="showSubmissions(t[0])"
+          >{{ t[1] }}</button>
         </div>
-        <div v-if="!sources.length" class="hint px-2">登记服务器上的技能目录(插件格式、技能集合或单个技能),之后可以随时同步。服务看不到 home 下的目录。</div>
+        <button
+          v-for="i in submissions" :key="i.id" type="button"
+          class="text-left border-none rounded-md px-2 py-1.5 cursor-pointer flex flex-col gap-0.5"
+          :class="reviewId === i.id ? 'bg-active' : 'bg-transparent hover:bg-hover'" @click="openReview(i)"
+        >
+          <span class="flex items-center gap-1.5">
+            <span class="codicon codicon-cloud-upload text-[12px] text-fg-muted" />
+            <span class="font-mono truncate flex-1">{{ i.name }}</span>
+            <span v-if="i.checks.some(c => c.level === 'danger')" class="codicon codicon-error text-[12px] text-error" title="自动检查有需要注意的问题" />
+            <span class="text-[11px]" :class="SUB_STATUS[i.status]![1]">{{ SUB_STATUS[i.status]![0] }}</span>
+          </span>
+          <span class="hint pl-4.5 truncate">{{ i.keyName }} · {{ formatTime(i.createdAt) }}</span>
+          <span v-if="i.status === 'pending'" class="hint pl-4.5">{{ i.existing ? `已有同名技能(v${i.existing.version})` : '新技能' }}</span>
+        </button>
+        <div v-if="!submissions.length" class="hint px-2">{{ subTab === 'pending' ? '没有待审核的推送。' : '没有已处理的推送。' }}</div>
       </div>
+
+      <SkillPublishKeys ref="keysPanel" />
     </aside>
 
-    <!-- 右:技能详情与编辑 -->
-    <section v-if="current" class="flex-1 min-w-0 flex flex-col">
+    <!-- 右:审核推送的技能 / 技能详情与编辑 -->
+    <SkillReviewPane v-if="reviewId" :id="reviewId" :skills="skills" @changed="onReviewChanged" @deleted="onReviewDeleted" @open-skill="openSkillById" />
+    <section v-else-if="current" class="flex-1 min-w-0 flex flex-col">
       <div class="flex-none border-b border-b-solid border-border px-4 py-2.5 flex flex-col gap-1.5 text-[12.5px]">
         <div class="flex items-center gap-2 flex-wrap">
           <span class="font-600 text-[14px] font-mono">{{ current.name }}</span>
           <code class="font-mono text-[11.5px] text-fg-muted">custom-skills:{{ current.name }}</code>
-          <span class="badge bg-code">{{ current.origin === 'imported' ? `导入 · ${current.sourceName ?? '来源已删除'}` : '编写' }}</span>
           <span class="badge bg-code" :class="current.publishedVersion === null ? 'text-warning' : 'text-success'">{{ current.publishedVersion === null ? '未发布' : `生效版本 v${current.publishedVersion}` }}</span>
           <span class="hint">最新 v{{ current.latestVersion }}</span>
           <span class="flex-1" />
@@ -370,26 +339,22 @@ defineExpose({ load })
         <div class="hint line-clamp-2">{{ current.description }}</div>
         <div class="hint">{{ current.profileNames.length ? `使用它的 Profile:${current.profileNames.join('、')}` : '还没有 Profile 勾选这个技能' }}</div>
         <div class="flex items-center gap-1.5 flex-wrap">
-          <template v-if="!readonlySkill">
-            <button type="button" class="btn-primary" :disabled="!!busy" @click="save(true)">保存并发布</button>
-            <button type="button" class="btn-secondary" :disabled="!!busy" @click="save(false)">保存草稿</button>
-            <button type="button" class="btn-ghost" :disabled="!!busy || (!current.hasUnsavedChanges && !editorDirty)" @click="discard">放弃改动</button>
-          </template>
+          <button type="button" class="btn-primary" :disabled="!!busy" @click="save(true)">保存并发布</button>
+          <button type="button" class="btn-secondary" :disabled="!!busy" @click="save(false)">保存草稿</button>
+          <button type="button" class="btn-ghost" :disabled="!!busy || (!current.hasUnsavedChanges && !editorDirty)" @click="discard">放弃改动</button>
           <button v-if="current.publishedVersion !== current.latestVersion" type="button" class="btn-secondary" :disabled="!!busy" @click="publishLatest">发布 v{{ current.latestVersion }}</button>
           <button type="button" class="btn-ghost" @click="showVersions = true"><span class="codicon codicon-history" />版本历史</button>
-          <button v-if="!readonlySkill && (current.hasUnsavedChanges || editorDirty)" type="button" class="btn-ghost" @click="showDiff(current.latestVersion, 'working', '未保存的改动')"><span class="codicon codicon-diff" />查看改动</button>
+          <button v-if="current.hasUnsavedChanges || editorDirty" type="button" class="btn-ghost" @click="showDiff(current.latestVersion, 'working', '未保存的改动')"><span class="codicon codicon-diff" />查看改动</button>
           <a :href="admin.customSkills.exportUrl(current.id)" class="btn-ghost no-underline"><span class="codicon codicon-desktop-download" />导出 zip</a>
-          <button v-if="readonlySkill" type="button" class="btn-ghost" @click="detach"><span class="codicon codicon-unlock" />转为可编辑</button>
           <span class="flex-1" />
           <button type="button" class="icon-btn" title="删除技能" @click="removeSkill"><span class="codicon codicon-trash" /></button>
         </div>
-        <div v-if="readonlySkill" class="hint text-warning">导入的技能只读,内容与来源保持一致;修改请在来源中进行后同步,或"转为可编辑"。</div>
       </div>
 
       <div class="flex-1 min-h-0 flex">
         <!-- 文件树 -->
         <div class="w-60 flex-none border-r border-r-solid border-border overflow-y-auto p-2 flex flex-col gap-0.5 text-[12.5px]">
-          <div v-if="!readonlySkill" class="flex gap-1 mb-1">
+          <div class="flex gap-1 mb-1">
             <button type="button" class="btn-ghost !h-6 !px-2" @click="newFile"><span class="codicon codicon-new-file" />新文件</button>
             <button type="button" class="btn-ghost !h-6 !px-2" @click="pickUpload(null)"><span class="codicon codicon-cloud-upload" />上传</button>
             <input ref="uploadInput" type="file" class="hidden" aria-label="上传文件" @change="onUpload">
@@ -400,7 +365,7 @@ defineExpose({ load })
           >
             <span class="codicon text-[12px] text-fg-muted" :class="f.text ? 'codicon-file' : 'codicon-file-binary'" />
             <span class="truncate flex-1 font-mono text-[12px]" :title="f.path"><span class="text-fg-muted">{{ f.dir ? `${f.dir}/` : '' }}</span>{{ f.path.split('/').pop() }}</span>
-            <template v-if="!readonlySkill && f.path !== 'SKILL.md'">
+            <template v-if="f.path !== 'SKILL.md'">
               <button type="button" class="icon-btn !w-5 !h-5 opacity-0 group-hover:opacity-100" title="重命名" @click.stop="renameFile(f)"><span class="codicon codicon-edit text-[11px]" /></button>
               <button type="button" class="icon-btn !w-5 !h-5 opacity-0 group-hover:opacity-100" title="删除" @click.stop="deleteFile(f)"><span class="codicon codicon-trash text-[11px]" /></button>
             </template>
@@ -415,10 +380,10 @@ defineExpose({ load })
               <span v-if="editorDirty" class="badge bg-code text-warning">已修改</span>
               <span class="flex-1" />
               <a :href="admin.customSkills.fileUrl(current.id, openFileInfo.path)" target="_blank" rel="noopener" class="btn-ghost !h-6 no-underline"><span class="codicon codicon-desktop-download" />下载</a>
-              <button v-if="!readonlySkill && !openFileInfo.text" type="button" class="btn-ghost !h-6" @click="pickUpload(openFileInfo.path)"><span class="codicon codicon-replace" />替换</button>
+              <button v-if="!openFileInfo.text" type="button" class="btn-ghost !h-6" @click="pickUpload(openFileInfo.path)"><span class="codicon codicon-replace" />替换</button>
             </div>
             <div v-if="openFileInfo.text" class="flex-1 min-h-0">
-              <MonacoEditor v-model="editorText" :filename="openFileInfo.path" :readonly="readonlySkill" @blur="flush" @save="flush" />
+              <MonacoEditor v-model="editorText" :filename="openFileInfo.path"  @blur="flush" @save="flush" />
             </div>
             <div v-else class="flex-1 flex items-center justify-center hint">二进制文件(模版、图片等)不能在线编辑,可以下载或替换。</div>
             <div v-if="openFileInfo.path === 'SKILL.md'" class="hint">frontmatter 必须包含 name(与技能名一致)和 description;description 决定 Agent 是否会使用这个技能。</div>
@@ -428,82 +393,9 @@ defineExpose({ load })
       </div>
     </section>
     <section v-else class="flex-1 flex items-center justify-center hint text-[12.5px] px-8 text-center">
-      选择左侧的技能进行编辑。自定义技能两个引擎通用(ID 为 custom-skills:&lt;名称&gt;),需要在 Profile 中勾选后才会使用。
+      选择左侧的技能进行编辑,或审核项目推送的技能。自定义技能两个引擎通用(ID 为 custom-skills:&lt;名称&gt;),需要在 Profile 中勾选后才会使用。
     </section>
 
-    <!-- 技能源编辑 -->
-    <div v-if="sourceDraft" class="fixed inset-0 z-40 flex items-center justify-center bg-[var(--app-overlay)]" @click.self="sourceDraft = null">
-      <form class="w-[min(560px,92vw)] card bg-input-alt p-5 flex flex-col gap-3 shadow-xl text-[12.5px]" @submit.prevent="saveSource">
-        <div class="font-600 text-[14px]">{{ sourceDraft.id ? '编辑技能源' : '登记技能源(服务器目录)' }}</div>
-        <div><label class="label" for="ss-name">名称</label><input id="ss-name" v-model="sourceDraft.name" class="field" required></div>
-        <div>
-          <label class="label" for="ss-path">目录(绝对路径)</label>
-          <DirPathInput id="ss-path" v-model="sourceDraft.path" placeholder="/srv/toolbox/skills" skills />
-          <div class="hint mt-1">输入或从下拉框中逐级选择(列出的是服务能看到的目录)。支持插件格式(skills/&lt;名称&gt;/SKILL.md)、技能集合(&lt;名称&gt;/SKILL.md)或单个技能。服务看不到 home 下的目录,需要放到 home 以外或配置绑定挂载。</div>
-        </div>
-        <div><label class="label" for="ss-desc">说明(可选)</label><input id="ss-desc" v-model="sourceDraft.description" class="field"></div>
-        <div class="flex items-center gap-4">
-          <label class="flex items-center gap-1.5 cursor-pointer"><input v-model="sourceDraft.syncMode" type="radio" value="manual">手动同步(导入前审阅差异)</label>
-          <label class="flex items-center gap-1.5 cursor-pointer"><input v-model="sourceDraft.syncMode" type="radio" value="auto">自动同步</label>
-          <template v-if="sourceDraft.syncMode === 'auto'">
-            <span>每</span><input v-model.number="sourceDraft.intervalMin" type="number" min="1" max="1440" class="field w-20"><span>分钟</span>
-          </template>
-        </div>
-        <div v-if="sourceDraft.syncMode === 'auto'" class="hint">自动同步会直接导入并发布有变化的技能(不经审阅);来源中删除的技能不会自动删除。</div>
-        <div class="flex justify-end gap-2">
-          <button type="button" class="btn-secondary" @click="sourceDraft = null">取消</button>
-          <button type="submit" class="btn-primary" :disabled="!!busy">保存并扫描</button>
-        </div>
-      </form>
-    </div>
-
-    <!-- 扫描结果 / 导入审阅 -->
-    <div v-if="scanResult" class="fixed inset-0 z-40 flex items-center justify-center bg-[var(--app-overlay)]" @click.self="closeScan">
-      <div class="w-[min(760px,94vw)] max-h-[86vh] overflow-y-auto card bg-input-alt p-5 flex flex-col gap-3 shadow-xl text-[12.5px]">
-        <div class="flex items-center gap-2">
-          <span class="font-600 text-[14px] flex-1">{{ scanResult.upload ? `导入技能包 ${scanResult.upload.name}` : `${scanResult.source!.name}:扫描结果` }}</span>
-          <button type="button" class="icon-btn" title="关闭" @click="closeScan"><span class="codicon codicon-close" /></button>
-        </div>
-        <div v-if="!scanResult.scan.skills.length" class="hint">没有找到技能(需要包含 SKILL.md 的目录)。</div>
-        <div v-for="s in scanResult.scan.skills" :key="s.name" class="rounded-md border border-solid border-border p-2.5 flex flex-col gap-1">
-          <div class="flex items-center gap-2">
-            <input v-if="!(scanResult.upload && s.status === 'changed')" v-model="scanResult.selected[s.name]" type="checkbox" :disabled="s.status !== 'new' && s.status !== 'changed'" :aria-label="`导入 ${s.name}`">
-            <span class="font-mono font-500">{{ s.name }}</span>
-            <span class="badge bg-code" :class="statusLabel(s, !!scanResult.upload)[1]">{{ statusLabel(s, !!scanResult.upload)[0] }}</span>
-            <span class="hint">{{ s.path }} · {{ s.fileCount }} 个文件 · {{ fmtSize(s.size) }}</span>
-          </div>
-          <div class="hint line-clamp-2">{{ s.description }}</div>
-          <template v-if="scanResult.upload && s.existing && s.status !== 'conflict'">
-            <div class="hint">{{ versionLine(s) }}</div>
-            <div v-if="s.status === 'changed'" class="flex items-center gap-4 pl-0.5">
-              <label class="flex items-center gap-1.5 cursor-pointer"><input v-model="scanResult.selected[s.name]" type="radio" :value="true" :name="`ow-${s.name}`">覆盖(作为新版本发布,旧版本可在版本历史中恢复)</label>
-              <label class="flex items-center gap-1.5 cursor-pointer"><input v-model="scanResult.selected[s.name]" type="radio" :value="false" :name="`ow-${s.name}`">保留原版本</label>
-            </div>
-          </template>
-          <div v-if="s.reason" class="text-error">{{ s.reason }}</div>
-          <div v-for="w in s.warnings" :key="w" class="text-warning">{{ w }}</div>
-          <div v-if="s.status === 'changed' || s.status === 'new'" class="hint">
-            <template v-if="s.changes.added.length">新增 {{ s.changes.added.length }}</template>
-            <template v-if="s.changes.modified.length"> · 修改 {{ s.changes.modified.length }}</template>
-            <template v-if="s.changes.removed.length"> · 删除 {{ s.changes.removed.length }}</template>
-            <button type="button" class="ml-2 border-none bg-transparent p-0 cursor-pointer text-link" @click="scanResult.open[s.name] = !scanResult.open[s.name]">{{ scanResult.open[s.name] ? '收起' : '查看文件' }}</button>
-          </div>
-          <div v-if="scanResult.open[s.name]" class="font-mono text-[11.5px] flex flex-col pl-6">
-            <span v-for="p in s.changes.added" :key="`a${p}`" class="text-success">+ {{ p }}</span>
-            <span v-for="p in s.changes.modified" :key="`m${p}`" class="text-link">~ {{ p }}</span>
-            <span v-for="p in s.changes.removed" :key="`r${p}`" class="text-error">- {{ p }}</span>
-          </div>
-        </div>
-        <div v-if="scanResult.scan.missing.length" class="text-warning">来源中已不存在:{{ scanResult.scan.missing.join('、') }}(保留;不需要时可以在左侧删除)</div>
-        <div v-if="scanResult.scan.skipped.length" class="hint">跳过的条目:{{ scanResult.scan.skipped.join('、') }}</div>
-        <div class="hint">{{ scanResult.upload ? '导入后立即发布;从 zip 导入的技能可以在后台继续编辑。上传的文件在导入或关闭后删除,同一个技能只保留一份。' : '导入后立即发布为新版本;导入的技能只读,与来源保持一致。' }}</div>
-        <div class="flex justify-end gap-2">
-          <button type="button" class="btn-secondary" @click="closeScan">{{ scanResult.upload ? '取消' : '关闭' }}</button>
-          <button type="button" class="btn-primary" :disabled="!!busy || !Object.values(scanResult.selected).some(Boolean)" @click="doImport">导入所选</button>
-        </div>
-      </div>
-    </div>
-
     <!-- 版本历史 -->
     <div v-if="showVersions && current" class="fixed inset-0 z-40 flex items-center justify-center bg-[var(--app-overlay)]" @click.self="showVersions = false">
       <div class="w-[min(620px,92vw)] max-h-[82vh] overflow-y-auto card bg-input-alt p-5 flex flex-col gap-2 shadow-xl text-[12.5px]">
@@ -519,6 +411,7 @@ defineExpose({ load })
               <span v-if="v.version === current.publishedVersion" class="badge bg-code text-success">生效中</span>
               <span class="hint">{{ formatTime(v.createdAt) }} · {{ v.fileCount }} 个文件 · {{ fmtSize(v.size) }}</span>
             </div>
+            <div v-if="v.note" class="hint">来自 {{ v.note.keyName }} 的推送<template v-if="v.note.message">:{{ v.note.message }}</template></div>
             <div class="hint">
               <template v-if="v.changed.added.length">新增 {{ v.changed.added.join('、') }} </template>
               <template v-if="v.changed.modified.length">修改 {{ v.changed.modified.join('、') }} </template>
@@ -540,15 +433,7 @@ defineExpose({ load })
           <span class="font-600 text-[14px] flex-1">差异:{{ diff.title }}</span>
           <button type="button" class="icon-btn" title="关闭" @click="diff = null"><span class="codicon codicon-close" /></button>
         </div>
-        <div v-if="!diff.files.length" class="hint">没有差异</div>
-        <div v-for="f in diff.files" :key="f.path" class="flex flex-col gap-1">
-          <div class="flex items-center gap-2">
-            <span class="badge bg-code" :class="f.status === 'added' ? 'text-success' : f.status === 'removed' ? 'text-error' : 'text-link'">{{ { added: '新增', removed: '删除', modified: '修改' }[f.status] }}</span>
-            <span class="font-mono">{{ f.path }}</span>
-          </div>
-          <div v-if="f.binary" class="hint pl-2">二进制文件</div>
-          <pre v-else-if="f.patch" class="m-0 p-2 rounded-md bg-code font-mono text-[11.5px] overflow-x-auto"><span v-for="(l, i) in f.patch.split('\n').slice(4)" :key="i" class="block" :class="lineClass(l)">{{ l || ' ' }}</span></pre>
-        </div>
+        <FileDiffList :files="diff.files" />
       </div>
     </div>
   </div>

+ 3 - 11
apps/admin/src/components/DirPathInput.vue

@@ -1,13 +1,12 @@
 <script setup lang="ts">
-// 选择服务器目录(类似 code-server 的打开文件夹,技能源与资料库共用):输入路径时在下拉框中列出当前层级的子目录(服务进程看到的),
+// 选择服务器目录(类似 code-server 的打开文件夹,登记资料库时使用):输入路径时在下拉框中列出当前层级的子目录(服务进程看到的),
 // 选中后进入下一层;↑↓ 选择,Enter / Tab 确认,Esc 关闭
 import { computed, ref, watch } from 'vue'
 import type { ServerDirListDto } from '@agent-server/shared'
 import { admin, errorMessage } from '../lib/api'
 
 const model = defineModel<string>({ required: true })
-/** skills:标出含 SKILL.md 的目录并显示找到的技能数(登记技能源时) */
-defineProps<{ id?: string; placeholder?: string; skills?: boolean }>()
+defineProps<{ id?: string; placeholder?: string }>()
 
 const open = ref(false)
 const active = ref(-1)
@@ -43,7 +42,7 @@ async function load(dir: string) {
   const n = ++seq
   loading.value = true
   try {
-    const r = await admin.skillSources.dirs(dir)
+    const r = await admin.serverDirs(dir)
     cache.set(dir, r)
     if (n === seq) {
       listing.value = r
@@ -107,9 +106,6 @@ function onBlur() {
     if (document.activeElement !== input.value) open.value = false
   }, 150)
 }
-
-/** 当前路径就是列出的目录时,显示其中找到的技能数 */
-const here = computed(() => (listing.value && !parts.value.prefix ? listing.value : null))
 </script>
 
 <template>
@@ -134,13 +130,9 @@ const here = computed(() => (listing.value && !parts.value.prefix ? listing.valu
         >
           <span class="codicon codicon-folder text-[13px] text-fg-muted" />
           <span class="font-mono truncate flex-1">{{ d.name }}</span>
-          <span v-if="skills && d.skill" class="text-[11px] text-success">SKILL.md</span>
         </div>
         <div v-if="listing?.truncated" class="px-3 py-1 text-fg-muted">只显示前 {{ listing.dirs.length }} 个目录,请继续输入名称筛选</div>
       </div>
     </div>
-    <div v-if="skills && here" class="mt-1 text-[12px]" :class="here.skillCount ? 'text-success' : 'text-fg-muted'">
-      {{ here.skillCount ? `这个目录中找到 ${here.skillCount} 个技能` : '这个目录中没有找到技能' }}
-    </div>
   </div>
 </template>

+ 22 - 0
apps/admin/src/components/FileDiffList.vue

@@ -0,0 +1,22 @@
+<script setup lang="ts">
+// 文件差异列表:自定义技能的版本对比、推送技能的审核共用
+import type { FileDiffDto } from '@agent-server/shared'
+
+defineProps<{ files: FileDiffDto[] }>()
+
+const lineClass = (l: string) => (l.startsWith('+') && !l.startsWith('+++') ? 'text-success' : l.startsWith('-') && !l.startsWith('---') ? 'text-error' : l.startsWith('@@') ? 'text-link' : 'text-fg-muted')
+</script>
+
+<template>
+  <div class="flex flex-col gap-3">
+    <div v-if="!files.length" class="hint">没有差异</div>
+    <div v-for="f in files" :key="f.path" class="flex flex-col gap-1">
+      <div class="flex items-center gap-2">
+        <span class="badge bg-code" :class="f.status === 'added' ? 'text-success' : f.status === 'removed' ? 'text-error' : 'text-link'">{{ { added: '新增', removed: '删除', modified: '修改' }[f.status] }}</span>
+        <span class="font-mono">{{ f.path }}</span>
+      </div>
+      <div v-if="f.binary" class="hint pl-2">二进制文件</div>
+      <pre v-else-if="f.patch" class="m-0 p-2 rounded-md bg-code font-mono text-[11.5px] overflow-x-auto"><span v-for="(l, i) in f.patch.split('\n').slice(4)" :key="i" class="block" :class="lineClass(l)">{{ l || ' ' }}</span></pre>
+    </div>
+  </div>
+</template>

+ 133 - 0
apps/admin/src/components/SkillPublishKeys.vue

@@ -0,0 +1,133 @@
+<script setup lang="ts">
+// 技能发布密钥(docs/design/18-skill-registry.md):名称即推送的来源;明文只在创建时显示一次
+import { onMounted, ref } from 'vue'
+import type { SkillPublishKeyDto } from '@agent-server/shared'
+import { admin, errorMessage } from '../lib/api'
+import { formatTime, toast } from '../lib/ui'
+
+const keys = ref<SkillPublishKeyDto[]>([])
+const editor = ref<{ name: string; description: string; expiresInDays: number | null } | null>(null)
+const created = ref<{ key: string; name: string } | null>(null)
+const busy = ref(false)
+const endpoint = `${window.location.origin}/skills/registry/v1/submissions`
+
+async function load() {
+  try {
+    keys.value = await admin.skillKeys.list()
+  } catch (err) {
+    toast(errorMessage(err), 'error')
+  }
+}
+
+async function create() {
+  const e = editor.value!
+  busy.value = true
+  try {
+    const r = await admin.skillKeys.create({ name: e.name, description: e.description, expiresInDays: e.expiresInDays })
+    created.value = { key: r.key, name: r.publishKey.name }
+    editor.value = null
+    await load()
+  } catch (err) {
+    toast(errorMessage(err), 'error')
+  } finally {
+    busy.value = false
+  }
+}
+async function toggle(k: SkillPublishKeyDto) {
+  await admin.skillKeys.update(k.id, { enabled: !k.enabled }).catch(err => toast(errorMessage(err), 'error'))
+  await load()
+}
+async function rename(k: SkillPublishKeyDto) {
+  const name = window.prompt('名称(表示推送的来源)', k.name)?.trim()
+  if (!name || name === k.name) return
+  await admin.skillKeys.update(k.id, { name }).catch(err => toast(errorMessage(err), 'error'))
+  await load()
+}
+async function remove(k: SkillPublishKeyDto) {
+  if (!window.confirm(`删除发布密钥「${k.name}」?使用它的项目将无法再推送;已批准的技能不受影响,待审核的提交仍可处理。`)) return
+  await admin.skillKeys.remove(k.id).catch(err => toast(errorMessage(err), 'error'))
+  await load()
+}
+async function copy(text: string) {
+  try {
+    await navigator.clipboard.writeText(text)
+    toast('已复制', 'success')
+  } catch {
+    toast('复制失败,请手动选择复制', 'error')
+  }
+}
+
+const expired = (k: SkillPublishKeyDto) => !!k.expiresAt && Date.parse(k.expiresAt) <= Date.now()
+const curl = `cd skills && zip -qr /tmp/skills.zip . && \\
+curl -fsS -H "Authorization: Bearer $AGENT_SKILLS_TOKEN" \\
+  -F file=@/tmp/skills.zip -F message="提交说明" \\
+  ${endpoint}`
+
+onMounted(load)
+defineExpose({ load })
+</script>
+
+<template>
+  <div class="flex flex-col gap-1">
+    <div class="flex items-center gap-1">
+      <span class="section-title flex-1">发布密钥</span>
+      <button type="button" class="icon-btn" title="新建发布密钥" @click="editor = { name: '', description: '', expiresInDays: 365 }"><span class="codicon codicon-add" /></button>
+    </div>
+    <div v-for="k in keys" :key="k.id" class="rounded-md border border-solid border-border p-2 flex flex-col gap-0.5">
+      <div class="flex items-center gap-1">
+        <span class="codicon codicon-key text-[12px]" :class="k.enabled && !expired(k) ? 'text-fg-muted' : 'text-error'" />
+        <span class="font-500 truncate flex-1" :class="{ 'line-through opacity-60': !k.enabled }" :title="k.description || k.name">{{ k.name }}</span>
+        <button type="button" class="icon-btn" title="改名" @click="rename(k)"><span class="codicon codicon-edit" /></button>
+        <button type="button" class="icon-btn" :title="k.enabled ? '停用' : '启用'" @click="toggle(k)"><span class="codicon" :class="k.enabled ? 'codicon-circle-slash' : 'codicon-check'" /></button>
+        <button type="button" class="icon-btn" title="删除" @click="remove(k)"><span class="codicon codicon-trash" /></button>
+      </div>
+      <div class="hint font-mono">{{ k.prefix }}…</div>
+      <div class="hint">
+        {{ !k.enabled ? '已停用' : expired(k) ? '已过期' : k.expiresAt ? `${formatTime(k.expiresAt)} 到期` : '永久有效' }}
+        · 推送 {{ k.submissions }} 次<template v-if="k.pending"> · <span class="text-warning">待审核 {{ k.pending }}</span></template>
+      </div>
+      <div v-if="k.lastUsedAt" class="hint">最近使用 {{ formatTime(k.lastUsedAt) }}<template v-if="k.lastUsedIp"> · {{ k.lastUsedIp }}</template></div>
+    </div>
+    <div v-if="!keys.length" class="hint px-2">项目用发布密钥推送技能,审核通过后才会生效。密钥名称表示来源,例如"fwh-app · GitHub Actions"。</div>
+
+    <!-- 新建 -->
+    <div v-if="editor" class="fixed inset-0 z-40 flex items-center justify-center bg-[var(--app-overlay)]" @click.self="editor = null">
+      <form class="w-[min(520px,92vw)] card bg-input-alt p-5 flex flex-col gap-3 shadow-xl text-[12.5px]" @submit.prevent="create">
+        <div class="font-600 text-[14px]">新建发布密钥</div>
+        <div>
+          <label class="label" for="spk-name">名称(表示推送的来源)</label>
+          <input id="spk-name" v-model="editor.name" class="field" placeholder="例如:fwh-app · GitHub Actions、张三 · 本机" required autofocus>
+        </div>
+        <div><label class="label" for="spk-desc">说明(可选)</label><input id="spk-desc" v-model="editor.description" class="field"></div>
+        <div>
+          <label class="label" for="spk-exp">有效期</label>
+          <select id="spk-exp" v-model="editor.expiresInDays" class="field w-40">
+            <option :value="90">90 天</option>
+            <option :value="365">1 年</option>
+            <option :value="730">2 年</option>
+            <option :value="null">永久</option>
+          </select>
+        </div>
+        <div class="hint">发布密钥只能推送待审核的技能,不能直接修改任何技能。请存放在 CI 的加密变量或本机用户目录的配置中,不要提交到代码仓库。</div>
+        <div class="flex justify-end gap-2">
+          <button type="button" class="btn-secondary" @click="editor = null">取消</button>
+          <button type="submit" class="btn-primary" :disabled="busy || !editor.name.trim()">创建</button>
+        </div>
+      </form>
+    </div>
+
+    <!-- 明文只显示一次 -->
+    <div v-if="created" class="fixed inset-0 z-40 flex items-center justify-center bg-[var(--app-overlay)]">
+      <div class="w-[min(640px,94vw)] card bg-input-alt p-5 flex flex-col gap-3 shadow-xl text-[12.5px]">
+        <div class="font-600 text-[14px]">「{{ created.name }}」的发布密钥(只显示这一次,请立即保存)</div>
+        <div class="flex gap-2">
+          <code class="flex-1 font-mono text-[12.5px] p-2 rounded-md bg-code break-all select-all">{{ created.key }}</code>
+          <button type="button" class="btn-secondary" @click="copy(created.key)"><span class="codicon codicon-copy" />复制</button>
+        </div>
+        <div class="hint">把密钥存为环境变量 AGENT_SKILLS_TOKEN(CI 的加密变量,或本机用户目录的配置中),推送示例(在项目目录中执行;地址后加 <code>?dryRun=1</code> 只做校验):</div>
+        <pre class="m-0 p-2 rounded-md bg-code font-mono text-[11.5px] whitespace-pre-wrap break-all">{{ curl }}</pre>
+        <div class="flex justify-end"><button type="button" class="btn-primary" @click="created = null">我已保存</button></div>
+      </div>
+    </div>
+  </div>
+</template>

+ 267 - 0
apps/admin/src/components/SkillReviewPane.vue

@@ -0,0 +1,267 @@
+<script setup lang="ts">
+// 审核推送的技能(docs/design/18-skill-registry.md):查看内容与检查结果,批准为新技能、覆盖已有技能(看差异并二次确认)或拒绝;
+// 已处理的提交项可以查看与删除
+import { computed, ref, watch } from 'vue'
+import type { CustomSkillDto, SkillSubmissionDiffDto, SkillSubmissionItemDetailDto, SkillSubmissionStatus } from '@agent-server/shared'
+import { admin, errorMessage } from '../lib/api'
+import { formatTime, toast } from '../lib/ui'
+import FileDiffList from './FileDiffList.vue'
+
+const props = defineProps<{ id: string; skills: CustomSkillDto[] }>()
+const emit = defineEmits<{ changed: []; openSkill: [id: string]; deleted: [] }>()
+
+type ReviewMode = 'create' | 'overwrite' | 'reject'
+
+const item = ref<SkillSubmissionItemDetailDto | null>(null)
+const busy = ref(false)
+const openPath = ref<string | null>(null)
+const fileText = ref<string | null>(null)
+
+const mode = ref<ReviewMode>('create')
+const newName = ref('')
+const targetId = ref('')
+const publish = ref(true)
+const reason = ref('')
+const diff = ref<SkillSubmissionDiffDto | null>(null)
+const diffLoading = ref(false)
+const confirming = ref(false)
+
+const STATUS: Record<SkillSubmissionStatus, [string, string]> = {
+  pending: ['待审核', 'text-warning'], approved: ['已批准', 'text-success'], rejected: ['已拒绝', 'text-error'], withdrawn: ['已撤回', 'text-fg-muted'],
+  superseded: ['已被新的推送替代', 'text-fg-muted'], invalid: ['不能导入', 'text-error'], unchanged: ['与当前版本相同', 'text-fg-muted'],
+}
+const LEVEL: Record<string, string> = { danger: 'text-error codicon-error', warning: 'text-warning codicon-warning', info: 'text-fg-muted codicon-info' }
+
+async function load() {
+  item.value = null
+  diff.value = null
+  confirming.value = false
+  reason.value = ''
+  try {
+    const d = await admin.skillSubmissions.get(props.id)
+    item.value = d
+    newName.value = d.name
+    targetId.value = d.existing?.id ?? ''
+    mode.value = d.existing ? 'overwrite' : 'create'
+    publish.value = true
+    await openFile(d.files[0]?.path ?? null)
+    if (d.status === 'pending' && mode.value === 'overwrite') await loadDiff()
+  } catch (err) {
+    toast(errorMessage(err), 'error')
+  }
+}
+watch(() => props.id, load, { immediate: true })
+
+async function openFile(p: string | null) {
+  openPath.value = p
+  fileText.value = null
+  const f = item.value?.files.find(x => x.path === p)
+  if (!f || !f.text) return
+  try {
+    fileText.value = await admin.skillSubmissions.readText(props.id, f.path)
+  } catch (err) {
+    toast(errorMessage(err), 'error')
+  }
+}
+
+async function loadDiff() {
+  if (!item.value) return
+  diffLoading.value = true
+  try {
+    diff.value = mode.value === 'overwrite'
+      ? targetId.value ? await admin.skillSubmissions.diff(props.id, { target: targetId.value }) : null
+      : await admin.skillSubmissions.diff(props.id, { name: newName.value })
+  } catch (err) {
+    diff.value = null
+    toast(errorMessage(err), 'error')
+  } finally {
+    diffLoading.value = false
+  }
+}
+watch([mode, targetId], () => {
+  confirming.value = false
+  diff.value = null
+  if (mode.value === 'overwrite') void loadDiff()
+})
+
+const nameTaken = computed(() => mode.value === 'create' && props.skills.some(s => s.name === newName.value.trim()))
+const target = computed(() => props.skills.find(s => s.id === targetId.value) ?? null)
+
+async function act<T>(fn: () => Promise<T>, done: string) {
+  busy.value = true
+  try {
+    await fn()
+    toast(done, 'success')
+    emit('changed')
+    await load()
+  } catch (err) {
+    toast(errorMessage(err), 'error')
+  } finally {
+    busy.value = false
+  }
+}
+function approveCreate() {
+  const name = newName.value.trim()
+  void act(() => admin.skillSubmissions.approve(props.id, { action: 'create', name, publish: publish.value }), publish.value ? `已新建并发布技能 ${name}` : `已新建技能 ${name}(未发布)`)
+}
+function approveOverwrite() {
+  const d = diff.value
+  if (!d?.target) return
+  confirming.value = false
+  void act(
+    () => admin.skillSubmissions.approve(props.id, { action: 'overwrite', targetId: d.target!.id, expectedVersion: d.target!.version, publish: publish.value }),
+    `已覆盖 ${d.target.name}(v${d.target.version + 1}${publish.value ? ',已发布' : ',未发布'})`,
+  )
+}
+function reject() {
+  void act(() => admin.skillSubmissions.reject(props.id, reason.value), '已拒绝')
+}
+async function remove() {
+  if (!item.value || !window.confirm(`删除这条提交记录(${item.value.name})?已生成的技能版本不受影响。`)) return
+  busy.value = true
+  try {
+    await admin.skillSubmissions.remove(props.id)
+    toast('已删除', 'success')
+    emit('deleted')
+  } catch (err) {
+    toast(errorMessage(err), 'error')
+  } finally {
+    busy.value = false
+  }
+}
+
+const fmtSize = (n: number) => (n < 1024 ? `${n} B` : n < 1048576 ? `${(n / 1024).toFixed(1)} KB` : `${(n / 1048576).toFixed(1)} MB`)
+const changeCount = computed(() => diff.value?.files.length ?? 0)
+</script>
+
+<template>
+  <section class="flex-1 min-w-0 flex flex-col overflow-y-auto text-[12.5px]">
+    <div v-if="!item" class="flex-1 flex items-center justify-center hint">加载中…</div>
+    <template v-else>
+      <!-- 提交信息 -->
+      <div class="flex-none border-b border-b-solid border-border px-4 py-3 flex flex-col gap-1.5">
+        <div class="flex items-center gap-2 flex-wrap">
+          <span class="font-mono font-600 text-[14px]">{{ item.name }}</span>
+          <span class="badge bg-code" :class="STATUS[item.status][1]">{{ STATUS[item.status][0] }}</span>
+          <span v-if="item.declaredVersion" class="badge bg-code">声明版本 {{ item.declaredVersion }}</span>
+          <span class="hint">{{ item.fileCount }} 个文件 · {{ fmtSize(item.size) }}</span>
+          <span class="flex-1" />
+          <button v-if="item.status !== 'pending'" type="button" class="btn-ghost" :disabled="busy" @click="remove"><span class="codicon codicon-trash" />删除记录</button>
+        </div>
+        <div class="hint line-clamp-2">{{ item.description }}</div>
+        <div class="flex flex-wrap gap-x-4 gap-y-0.5">
+          <span><span class="text-fg-muted">来源:</span>{{ item.keyName }}</span>
+          <span><span class="text-fg-muted">推送时间:</span>{{ formatTime(item.createdAt) }}</span>
+          <span v-for="(v, k) in item.meta" :key="k"><span class="text-fg-muted">{{ k }}:</span><span class="font-mono">{{ v }}</span></span>
+        </div>
+        <div v-if="item.message" class="whitespace-pre-wrap"><span class="text-fg-muted">提交说明:</span>{{ item.message }}</div>
+        <div v-if="item.reason" class="text-error">{{ item.reason }}</div>
+        <div v-if="item.status === 'rejected'" class="text-error">拒绝原因:{{ item.reviewNote }}<span class="hint">({{ item.reviewedAt && formatTime(item.reviewedAt) }})</span></div>
+        <div v-if="item.result" class="flex items-center gap-2">
+          <span class="text-success">{{ item.result.action === 'create' ? '已新建技能' : '已覆盖技能' }} {{ item.result.skillName }} v{{ item.result.version }}{{ item.result.published ? '(已发布)' : '(未发布)' }}</span>
+          <button v-if="skills.some(s => s.id === item!.result!.skillId)" type="button" class="btn-ghost !h-6" @click="emit('openSkill', item.result.skillId)">打开技能</button>
+        </div>
+      </div>
+
+      <!-- 自动检查 -->
+      <div v-if="item.checks.length" class="flex-none border-b border-b-solid border-border px-4 py-2.5 flex flex-col gap-1">
+        <div class="section-title">自动检查</div>
+        <div v-for="(c, i) in item.checks" :key="i" class="flex items-start gap-1.5">
+          <span class="codicon mt-0.5" :class="LEVEL[c.level]" />
+          <span>{{ c.message }}<span v-if="c.files?.length" class="font-mono text-fg-muted">:{{ c.files.join('、') }}</span></span>
+        </div>
+      </div>
+
+      <!-- 内容 -->
+      <div v-if="item.files.length" class="flex-none border-b border-b-solid border-border flex min-h-60 max-h-[46vh]">
+        <div class="w-56 flex-none border-r border-r-solid border-border overflow-y-auto p-2 flex flex-col">
+          <button
+            v-for="f in item.files" :key="f.path" type="button"
+            class="text-left border-none rounded-sm px-1.5 py-0.5 cursor-pointer font-mono text-[12px] truncate"
+            :class="openPath === f.path ? 'bg-active' : 'bg-transparent hover:bg-hover'" :title="f.path" @click="openFile(f.path)"
+          >{{ f.path }}</button>
+        </div>
+        <div class="flex-1 min-w-0 overflow-auto p-2">
+          <pre v-if="fileText !== null" class="m-0 font-mono text-[12px] whitespace-pre-wrap break-words">{{ fileText }}</pre>
+          <div v-else-if="openPath" class="h-full flex flex-col items-center justify-center gap-2 hint">
+            二进制文件
+            <a :href="admin.skillSubmissions.fileUrl(item.id, openPath)" class="btn-ghost no-underline"><span class="codicon codicon-desktop-download" />下载</a>
+          </div>
+        </div>
+      </div>
+
+      <!-- 审核 -->
+      <div v-if="item.status === 'pending'" class="px-4 py-3 flex flex-col gap-3">
+        <div class="section-title">审核</div>
+        <div class="flex items-center gap-4 flex-wrap">
+          <label class="flex items-center gap-1.5 cursor-pointer"><input v-model="mode" type="radio" value="create">批准为新技能</label>
+          <label class="flex items-center gap-1.5 cursor-pointer"><input v-model="mode" type="radio" value="overwrite" :disabled="!skills.length">覆盖已有技能</label>
+          <label class="flex items-center gap-1.5 cursor-pointer"><input v-model="mode" type="radio" value="reject">拒绝</label>
+        </div>
+
+        <template v-if="mode === 'create'">
+          <div class="flex items-end gap-2 flex-wrap">
+            <div>
+              <label class="label" for="rv-name">技能名</label>
+              <input id="rv-name" v-model="newName" class="field font-mono w-64" spellcheck="false">
+            </div>
+            <button type="button" class="btn-ghost" :disabled="diffLoading" @click="loadDiff">查看内容(改名后)</button>
+          </div>
+          <div v-if="nameTaken" class="text-warning">已存在同名技能 {{ newName.trim() }}:请改名,或选择"覆盖已有技能"。</div>
+          <div v-else-if="newName.trim() !== item.name" class="hint">批准时会把 SKILL.md 中的 name 改为 {{ newName.trim() }}。</div>
+          <FileDiffList v-if="diff && !diff.target" :files="diff.files" />
+          <label class="flex items-center gap-1.5 cursor-pointer"><input v-model="publish" type="checkbox">立即发布(不勾选则先保存为版本,之后在技能详情中发布)</label>
+          <div><button type="button" class="btn-primary" :disabled="busy || nameTaken || !newName.trim()" @click="approveCreate">批准为新技能</button></div>
+        </template>
+
+        <template v-else-if="mode === 'overwrite'">
+          <div>
+            <label class="label" for="rv-target">覆盖的技能</label>
+            <select id="rv-target" v-model="targetId" class="field w-64 font-mono">
+              <option value="" disabled>选择技能</option>
+              <option v-for="s in skills" :key="s.id" :value="s.id">{{ s.name }}{{ s.name === item.name ? '(同名)' : '' }}</option>
+            </select>
+          </div>
+          <div v-if="diffLoading" class="hint">正在计算差异…</div>
+          <template v-else-if="diff?.target">
+            <div class="rounded-md border border-solid border-border p-2.5 flex flex-col gap-1">
+              <div>
+                <span class="font-mono">{{ diff.target.name }}</span> 当前最新版本 v{{ diff.target.version }}({{ formatTime(diff.target.updatedAt) }}),覆盖后为 v{{ diff.target.version + 1 }}。
+                <span v-if="target && target.publishedVersion !== null && target.publishedVersion !== diff.target.version" class="hint">生效中的是 v{{ target.publishedVersion }}。</span>
+              </div>
+              <div>声明版本号:{{ diff.target.declaredVersion ?? '未声明' }} → {{ item.declaredVersion ?? '未声明' }}<span v-if="diff.target.declaredVersion && diff.target.declaredVersion === item.declaredVersion" class="text-warning">(没有变化,但内容不同)</span></div>
+              <div v-if="diff.renamed" class="text-warning">技能名不同:批准时会把 SKILL.md 中的 name 改为 {{ diff.target.name }}。</div>
+              <div v-if="diff.target.unsaved" class="text-warning">这个技能有尚未保存的编辑,覆盖后这些编辑会丢失。</div>
+              <div class="hint">以下是与 v{{ diff.target.version }} 的差异(共 {{ changeCount }} 个文件);旧版本可以在版本历史中恢复。</div>
+            </div>
+            <FileDiffList :files="diff.files" />
+            <label class="flex items-center gap-1.5 cursor-pointer"><input v-model="publish" type="checkbox">立即发布(不勾选则先保存为版本,之后在技能详情中发布)</label>
+            <div><button type="button" class="btn-primary" :disabled="busy || !changeCount" @click="confirming = true">覆盖…</button></div>
+          </template>
+        </template>
+
+        <template v-else>
+          <div>
+            <label class="label" for="rv-reason">拒绝原因(推送方可以看到)</label>
+            <textarea id="rv-reason" v-model="reason" class="field-area h-20 font-sans" placeholder="例如:脚本中包含测试环境地址,请改为参数后重新推送" />
+          </div>
+          <div><button type="button" class="btn-danger" :disabled="busy || !reason.trim()" @click="reject">拒绝</button></div>
+        </template>
+      </div>
+    </template>
+
+    <!-- 覆盖的二次确认 -->
+    <div v-if="confirming && diff?.target && item" class="fixed inset-0 z-50 flex items-center justify-center bg-[var(--app-overlay)]" @click.self="confirming = false">
+      <div class="w-[min(480px,92vw)] card bg-input-alt p-5 flex flex-col gap-3 shadow-xl text-[12.5px]">
+        <div class="font-600 text-[14px]">确认覆盖 {{ diff.target.name }}?</div>
+        <div>将用 {{ item.keyName }} 推送的内容覆盖 <span class="font-mono">{{ diff.target.name }}</span>(v{{ diff.target.version }} → v{{ diff.target.version + 1 }}),{{ changeCount }} 个文件有变化{{ publish ? ',并立即发布' : ',暂不发布' }}。</div>
+        <div v-if="diff.target.unsaved" class="text-warning">这个技能尚未保存的编辑会丢失。</div>
+        <div class="hint">旧版本可以在版本历史中恢复。</div>
+        <div class="flex justify-end gap-2">
+          <button type="button" class="btn-secondary" @click="confirming = false">取消</button>
+          <button type="button" class="btn-primary" :disabled="busy" @click="approveOverwrite">确认覆盖</button>
+        </div>
+      </div>
+    </div>
+  </section>
+</template>

+ 22 - 16
apps/admin/src/lib/api.ts

@@ -1,7 +1,7 @@
 // 后台 API:同源 Cookie 鉴权,复用 AgentClient 的请求与错误处理
 import { AgentClient, BotClient } from '@agent-server/client'
 import type {
-  ApiKeyDto, ApiKeyProfiles, BotDto, BotInput, BotKeyDto, ApiReferenceDto, AppSettings, BrowserTokenDto, CodexPluginCatalogDto, CodexPluginDetailDto, CodexPluginInstallResultDto, CodexSkillCatalogDto, CommunitySearchResultDto, CustomSkillDetailDto, CustomSkillDto, FileDiffDto, ServerDirListDto, SkillScanDto, SkillSourceDto, SkillSourceInput, SkillUploadDto, CommunitySkillDto, CommunitySkillPreviewDto, DeepPartial, FileAccessPolicy, FsEntryDto, FsRootDto, FsShortcutDto, KnowledgeRootDto, McpServerDto, McpServerInput, McpStateDto, McpTestResultDto, McpToolHints, McpToolPolicy, PathCheck, PluginAction, PluginStateDto, ProfileDto, ProfileInput, ProfileVersionDto,
+  ApiKeyDto, ApiKeyProfiles, BotDto, BotInput, BotKeyDto, ApiReferenceDto, AppSettings, BrowserTokenDto, CodexPluginCatalogDto, CodexPluginDetailDto, CodexPluginInstallResultDto, CodexSkillCatalogDto, CommunitySearchResultDto, CustomSkillDetailDto, CustomSkillDto, FileDiffDto, ServerDirListDto, SkillApproveInput, SkillPublishKeyDto, SkillPublishKeyInput, SkillSubmissionDiffDto, SkillSubmissionItemDetailDto, SkillSubmissionItemDto, CommunitySkillDto, CommunitySkillPreviewDto, DeepPartial, FileAccessPolicy, FsEntryDto, FsRootDto, FsShortcutDto, KnowledgeRootDto, McpServerDto, McpServerInput, McpStateDto, McpTestResultDto, McpToolHints, McpToolPolicy, PathCheck, PluginAction, PluginStateDto, ProfileDto, ProfileInput, ProfileVersionDto,
   ProviderQuotaDto, ProviderStatusDto, RecentErrorDto, ResolvedAccessDto, SkillInfo, UsageSummaryDto, TeamCaseDto, TeamCaseRunDto, TeamDto, ScheduleOverviewDto, ScheduleRunDto, ScheduleDto, BackgroundCallDto, BackgroundCallOverviewDto,
 } from '@agent-server/shared'
 
@@ -99,24 +99,30 @@ export const admin = {
     restore: (id: string, version: number) => req<CustomSkillDetailDto>('POST', `/custom-skills/${id}/versions/${version}/restore`),
     diff: (id: string, from: number | 'working', to: number | 'working') => req<FileDiffDto[]>('GET', `/custom-skills/${id}/diff?from=${from}&to=${to}`),
     exportUrl: (id: string, version?: number) => `/admin/v1/custom-skills/${id}/export${version === undefined ? '' : `?version=${version}`}`,
-    detach: (id: string) => req<CustomSkillDetailDto>('POST', `/custom-skills/${id}/detach`),
   },
-  skillSources: {
-    list: () => req<SkillSourceDto[]>('GET', '/skill-sources'),
-    create: (input: SkillSourceInput) => req<SkillSourceDto>('POST', '/skill-sources', input),
-    update: (id: string, input: SkillSourceInput) => req<SkillSourceDto>('PUT', `/skill-sources/${id}`, input),
-    remove: (id: string) => req<void>('DELETE', `/skill-sources/${id}`),
-    scan: (id: string) => req<SkillScanDto>('POST', `/skill-sources/${id}/scan`),
-    import: (id: string, skills: string[], digest: string) => req<{ imported: { name: string; version: number; created: boolean }[]; scan: SkillScanDto }>('POST', `/skill-sources/${id}/import`, { skills, digest }),
-    upload: (file: File) => {
-      const form = new FormData()
-      form.append('file', file, file.name)
-      return req<SkillUploadDto>('POST', '/skill-sources/upload', form)
+  skillKeys: {
+    list: () => req<SkillPublishKeyDto[]>('GET', '/skill-keys'),
+    create: (input: SkillPublishKeyInput) => req<{ key: string; publishKey: SkillPublishKeyDto }>('POST', '/skill-keys', input),
+    update: (id: string, input: { name?: string; description?: string; enabled?: boolean }) => req<SkillPublishKeyDto>('PATCH', `/skill-keys/${id}`, input),
+    remove: (id: string) => req<void>('DELETE', `/skill-keys/${id}`),
+  },
+  skillSubmissions: {
+    count: () => req<{ pending: number }>('GET', '/skill-submissions/count'),
+    list: (status: 'pending' | 'done') => req<SkillSubmissionItemDto[]>('GET', `/skill-submissions?status=${status}`),
+    get: (id: string) => req<SkillSubmissionItemDetailDto>('GET', `/skill-submissions/${id}`),
+    fileUrl: (id: string, path: string) => `/admin/v1/skill-submissions/${id}/file?path=${encodeURIComponent(path)}`,
+    readText: async (id: string, path: string) => {
+      const r = await fetch(`/admin/v1/skill-submissions/${id}/file?path=${encodeURIComponent(path)}`, { credentials: 'same-origin' })
+      if (!r.ok) throw new Error((await r.json().catch(() => null))?.error?.message ?? `HTTP ${r.status}`)
+      return r.text()
     },
-    importUpload: (id: string, skills: string[], digest: string) => req<{ imported: { name: string; version: number; created: boolean }[] }>('POST', `/skill-sources/uploads/${id}/import`, { skills, digest }),
-    discardUpload: (id: string) => req<void>('DELETE', `/skill-sources/uploads/${id}`),
-    dirs: (path: string) => req<ServerDirListDto>('GET', `/server-dirs?path=${encodeURIComponent(path)}`),
+    diff: (id: string, opts: { target?: string; name?: string }) =>
+      req<SkillSubmissionDiffDto>('GET', `/skill-submissions/${id}/diff?target=${encodeURIComponent(opts.target ?? '')}&name=${encodeURIComponent(opts.name ?? '')}`),
+    approve: (id: string, input: SkillApproveInput) => req<SkillSubmissionItemDto>('POST', `/skill-submissions/${id}/approve`, input),
+    reject: (id: string, reason: string) => req<SkillSubmissionItemDto>('POST', `/skill-submissions/${id}/reject`, { reason }),
+    remove: (id: string) => req<void>('DELETE', `/skill-submissions/${id}`),
   },
+  serverDirs: (path: string) => req<ServerDirListDto>('GET', `/server-dirs?path=${encodeURIComponent(path)}`),
   mcp: {
     state: () => req<McpStateDto>('GET', '/mcp'),
     create: (input: McpServerInput) => req<McpServerDto>('POST', '/mcp/servers', input),

+ 24 - 12
apps/admin/src/views/ApiDocsView.vue

@@ -1,6 +1,6 @@
 <script setup lang="ts">
-// 对外 API 协议:Agent API(/v1)与 Bot API(/bot/v1)两套,按服务端的协议定义渲染(与实际接口一致,由测试保证),
-// 右侧目录导航,每个接口可以直接调试。下载 PDF 与"新窗口打开"使用服务端渲染的同一份内容(包含两套协议)
+// 对外 API 协议:Agent API(/v1)、Bot API(/bot/v1)与技能发布 API(/skills/registry/v1),按服务端的协议定义渲染(与实际接口一致,由测试保证),
+// 右侧目录导航,每个接口可以直接调试。下载 PDF 与"新窗口打开"使用服务端渲染的同一份内容(包含全部协议)
 import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
 import { useRoute, useRouter } from 'vue-router'
 import type { ApiEndpoint, ApiKeyDto, ApiReferenceDto, ApiSpec, BotKeyDto } from '@agent-server/shared'
@@ -13,12 +13,12 @@ const ref_ = ref<ApiReferenceDto | null>(null)
 const keys = ref<ApiKeyDto[]>([])
 const botKeys = ref<BotKeyDto[]>([])
 
-// 当前查看的协议(地址栏 ?api=bot 可以直接打开 Bot API)
+// 当前查看的协议(地址栏 ?api=bot / ?api=registry 可以直接打开)
 const route = useRoute()
 const router = useRouter()
 const specId = computed<ApiSpec['id']>({
-  get: () => (route.query.api === 'bot' ? 'bot' : 'agent'),
-  set: v => void router.replace({ query: v === 'bot' ? { api: 'bot' } : {} }),
+  get: () => (route.query.api === 'bot' || route.query.api === 'registry' ? route.query.api : 'agent'),
+  set: v => void router.replace({ query: v === 'agent' ? {} : { api: v } }),
 })
 const spec = computed(() => ref_.value?.specs.find(x => x.id === specId.value) ?? null)
 const pdfAvailable = ref(false)
@@ -40,7 +40,7 @@ const toc = computed(() => {
   return [
     { id: 'overview', title: '概述', children: [] as { id: string; title: string; method: string }[] },
     ...r.groups.map(g => ({ id: g.id, title: g.title, children: g.endpoints.map(e => ({ id: anchor(e), title: e.summary, method: e.method })) })),
-    { id: 'event-types', title: '事件类型', children: [] },
+    ...(r.events.length ? [{ id: 'event-types', title: '事件类型', children: [] }] : []),
     { id: 'types', title: '数据结构', children: [] },
     { id: 'errors', title: '错误码', children: [] },
   ]
@@ -77,10 +77,15 @@ const activeGroup = computed(() => toc.value.find(t => t.id === active.value ||
 const trying = reactive<Record<string, boolean>>({})
 /** 调试身份:'' 为后台管理员;否则为当前协议对应的 Key id(Agent Key 或 Bot Key,服务端签发 10 分钟的浏览器令牌,Key 不离开服务端) */
 const identity = ref('')
+/** 技能发布 API 只接受发布密钥:调试时由管理员填入(只保存在当前页面) */
+const publishKey = ref('')
 const identityKeys = computed(() => (specId.value === 'bot' ? botKeys.value : keys.value).filter(k => k.enabled))
-const identityLabel = computed(() => (identity.value ? `平台「${identityKeys.value.find(k => k.id === identity.value)?.name ?? ''}」` : '后台管理员'))
+const identityLabel = computed(() => (specId.value === 'registry'
+  ? (publishKey.value.trim() ? `发布密钥 ${publishKey.value.trim().slice(0, 12)}…` : '(未填写发布密钥)')
+  : identity.value ? `平台「${identityKeys.value.find(k => k.id === identity.value)?.name ?? ''}」` : '后台管理员'))
 const tokens = new Map<string, { token: string; exp: number }>()
 async function auth(): Promise<string | null> {
+  if (specId.value === 'registry') return publishKey.value.trim() || null
   const id = identity.value
   if (!id) return null
   const cacheKey = `${specId.value}:${id}`
@@ -208,10 +213,12 @@ onBeforeUnmount(() => observer?.disconnect())
             </section>
           </template>
 
-          <h2 id="event-types" data-toc>事件类型</h2>
-          <p class="muted">事件信封见"数据结构 › {{ spec.eventEnvelope }}"。"落库"为否的事件只在实时事件流中推送,不出现在历史事件中。</p>
-          <table><thead><tr><th>type</th><th>payload</th><th>说明</th><th>落库</th></tr></thead>
-            <tbody><tr v-for="ev in spec.events" :key="ev.type"><td><code>{{ ev.type }}</code></td><td><code class="muted">{{ ev.payload }}</code></td><td v-html="marked(ev.desc)" /><td>{{ ev.persisted ? '是' : '否' }}</td></tr></tbody></table>
+          <template v-if="spec.events.length">
+            <h2 id="event-types" data-toc>事件类型</h2>
+            <p class="muted">事件信封见"数据结构 › {{ spec.eventEnvelope }}"。"落库"为否的事件只在实时事件流中推送,不出现在历史事件中。</p>
+            <table><thead><tr><th>type</th><th>payload</th><th>说明</th><th>落库</th></tr></thead>
+              <tbody><tr v-for="ev in spec.events" :key="ev.type"><td><code>{{ ev.type }}</code></td><td><code class="muted">{{ ev.payload }}</code></td><td v-html="marked(ev.desc)" /><td>{{ ev.persisted ? '是' : '否' }}</td></tr></tbody></table>
+          </template>
 
           <h2 id="types" data-toc>数据结构</h2>
           <template v-for="t in spec.types" :key="t.name">
@@ -229,7 +236,12 @@ onBeforeUnmount(() => observer?.disconnect())
 
       <!-- 右侧:调试身份 + 目录导航 -->
       <aside class="w-64 flex-none border-l border-l-solid border-border overflow-y-auto py-4 px-3 flex flex-col gap-4 text-[12.5px]">
-        <div class="flex flex-col gap-1">
+        <div v-if="specId === 'registry'" class="flex flex-col gap-1">
+          <label class="label m-0" for="try-publish-key">调试用的发布密钥</label>
+          <input id="try-publish-key" v-model="publishKey" type="password" class="field font-mono" placeholder="sk_pub_…" autocomplete="off">
+          <div class="hint">技能发布 API 只接受发布密钥(后台登录态不能调用)。在"技能与插件 › 自定义技能 › 发布密钥"中创建;这里填写的密钥只保存在当前页面。推送的技能会出现在"待审核"中。</div>
+        </div>
+        <div v-else class="flex flex-col gap-1">
           <label class="label m-0" for="try-identity">调试身份</label>
           <select id="try-identity" v-model="identity" class="field">
             <option value="">后台管理员</option>

+ 5 - 2
apps/admin/src/views/PluginsView.vue

@@ -237,7 +237,10 @@ function addMarketplace(source: string) {
   void act({ action: 'marketplace-add', source: source.trim() }, `mkt:${source}`).then(() => (newSource.value = ''))
 }
 
+/** 推送的技能中待审核的数量,显示在"自定义技能"标签上 */
+const pendingSkills = ref(0)
 onMounted(() => {
+  void admin.skillSubmissions.count().then(r => (pendingSkills.value = r.pending)).catch(() => {})
   void load()
   void loadCodex()
   void loadCommunity()
@@ -254,9 +257,9 @@ onMounted(() => {
         v-for="t in ([['claude', 'Claude · Anthropic 官方技能'], ['codex', 'Codex · OpenAI 官方技能'], ['codex-plugins', 'Codex · OpenAI 插件'], ['community', '第三方技能 · 两个引擎'], ['custom', '自定义技能 · 两个引擎']] as const)" :key="t[0]" type="button"
         class="h-9 px-3 border-none bg-transparent cursor-pointer text-fg-muted hover:text-fg border-b-solid border-b-2"
         :class="tab === t[0] ? '!text-fg border-b-accent' : 'border-b-transparent'" @click="tab = t[0]"
-      >{{ t[1] }}</button>
+      >{{ t[1] }}<span v-if="t[0] === 'custom' && pendingSkills" class="ml-1.5 px-1.5 rounded-full bg-accent text-white text-[11px]" :title="`${pendingSkills} 个推送的技能待审核`">{{ pendingSkills }}</span></button>
     </div>
-    <CustomSkillsPanel v-if="tab === 'custom'" />
+    <CustomSkillsPanel v-if="tab === 'custom'" @pending="n => (pendingSkills = n)" />
     <div v-else class="flex-1 overflow-y-auto">
       <!-- Codex 插件:openai-curated 目录,只在 Playground 中使用 -->
       <div v-if="tab === 'codex-plugins'" class="max-w-240 p-6 flex flex-col gap-5">

+ 42 - 0
apps/server/drizzle/0019_skill_registry.sql

@@ -0,0 +1,42 @@
+CREATE TABLE `skill_publish_keys` (
+	`id` text PRIMARY KEY NOT NULL,
+	`name` text NOT NULL,
+	`description` text DEFAULT '' NOT NULL,
+	`key_hash` text NOT NULL,
+	`key_prefix` text NOT NULL,
+	`enabled` integer DEFAULT true NOT NULL,
+	`expires_at` text,
+	`created_at` text DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) NOT NULL,
+	`last_used_at` text,
+	`last_used_ip` text
+);
+--> statement-breakpoint
+CREATE UNIQUE INDEX `skill_publish_keys_key_hash_unique` ON `skill_publish_keys` (`key_hash`);--> statement-breakpoint
+CREATE TABLE `skill_submission_items` (
+	`id` text PRIMARY KEY NOT NULL,
+	`submission_id` text NOT NULL,
+	`name` text NOT NULL,
+	`description` text DEFAULT '' NOT NULL,
+	`declared_version` text,
+	`files` text NOT NULL,
+	`checks` text DEFAULT '[]' NOT NULL,
+	`status` text NOT NULL,
+	`reason` text,
+	`review_note` text,
+	`reviewed_at` text,
+	`result` text,
+	`created_at` text DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) NOT NULL
+);
+--> statement-breakpoint
+CREATE TABLE `skill_submissions` (
+	`id` text PRIMARY KEY NOT NULL,
+	`key_id` text NOT NULL,
+	`key_name` text NOT NULL,
+	`message` text DEFAULT '' NOT NULL,
+	`meta` text DEFAULT '{}' NOT NULL,
+	`package_sha` text NOT NULL,
+	`package_size` integer NOT NULL,
+	`created_at` text DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) NOT NULL
+);
+--> statement-breakpoint
+ALTER TABLE `custom_skill_versions` ADD `note` text;

+ 3 - 0
apps/server/drizzle/0020_drop_skill_sources.sql

@@ -0,0 +1,3 @@
+DROP TABLE `skill_sources`;--> statement-breakpoint
+ALTER TABLE `custom_skills` DROP COLUMN `origin`;--> statement-breakpoint
+ALTER TABLE `custom_skills` DROP COLUMN `source_id`;

+ 3077 - 0
apps/server/drizzle/meta/0019_snapshot.json

@@ -0,0 +1,3077 @@
+{
+  "version": "6",
+  "dialect": "sqlite",
+  "id": "15bbdbd8-2521-4480-90a6-f188821bce85",
+  "prevId": "452d9b92-c60b-4563-a10e-5fc573e69617",
+  "tables": {
+    "admin_sessions": {
+      "name": "admin_sessions",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "text",
+          "primaryKey": true,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        },
+        "expires_at": {
+          "name": "expires_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        }
+      },
+      "indexes": {},
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "api_keys": {
+      "name": "api_keys",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "text",
+          "primaryKey": true,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "name": {
+          "name": "name",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "key_hash": {
+          "name": "key_hash",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "key_prefix": {
+          "name": "key_prefix",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "enabled": {
+          "name": "enabled",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": true
+        },
+        "allowed_profile_ids": {
+          "name": "allowed_profile_ids",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'[]'"
+        },
+        "default_profile_id": {
+          "name": "default_profile_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        },
+        "last_used_at": {
+          "name": "last_used_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        }
+      },
+      "indexes": {
+        "api_keys_key_hash_unique": {
+          "name": "api_keys_key_hash_unique",
+          "columns": [
+            "key_hash"
+          ],
+          "isUnique": true
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "audit_logs": {
+      "name": "audit_logs",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "integer",
+          "primaryKey": true,
+          "notNull": true,
+          "autoincrement": true
+        },
+        "ts": {
+          "name": "ts",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        },
+        "actor": {
+          "name": "actor",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "session_id": {
+          "name": "session_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "kind": {
+          "name": "kind",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "summary": {
+          "name": "summary",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "detail": {
+          "name": "detail",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        }
+      },
+      "indexes": {
+        "audit_ts_idx": {
+          "name": "audit_ts_idx",
+          "columns": [
+            "ts"
+          ],
+          "isUnique": false
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "background_calls": {
+      "name": "background_calls",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "text",
+          "primaryKey": true,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "session_id": {
+          "name": "session_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "team_id": {
+          "name": "team_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "bot_id": {
+          "name": "bot_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "task_id": {
+          "name": "task_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "server_id": {
+          "name": "server_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "server_name": {
+          "name": "server_name",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "server_title": {
+          "name": "server_title",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "tool": {
+          "name": "tool",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "args": {
+          "name": "args",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "status": {
+          "name": "status",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'running'"
+        },
+        "started_at": {
+          "name": "started_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        },
+        "ended_at": {
+          "name": "ended_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "result": {
+          "name": "result",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "error": {
+          "name": "error",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "delivered": {
+          "name": "delivered",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": false
+        },
+        "mode": {
+          "name": "mode",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'wait'"
+        },
+        "mcp_task_id": {
+          "name": "mcp_task_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "conn_scope": {
+          "name": "conn_scope",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "poll_after": {
+          "name": "poll_after",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "poll_error_since": {
+          "name": "poll_error_since",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "status_message": {
+          "name": "status_message",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        }
+      },
+      "indexes": {
+        "background_calls_session_idx": {
+          "name": "background_calls_session_idx",
+          "columns": [
+            "session_id"
+          ],
+          "isUnique": false
+        },
+        "background_calls_team_idx": {
+          "name": "background_calls_team_idx",
+          "columns": [
+            "team_id"
+          ],
+          "isUnique": false
+        },
+        "background_calls_status_idx": {
+          "name": "background_calls_status_idx",
+          "columns": [
+            "status",
+            "delivered"
+          ],
+          "isUnique": false
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "bot_api_keys": {
+      "name": "bot_api_keys",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "text",
+          "primaryKey": true,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "name": {
+          "name": "name",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "key_hash": {
+          "name": "key_hash",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "key_prefix": {
+          "name": "key_prefix",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "enabled": {
+          "name": "enabled",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": true
+        },
+        "allowed_bot_ids": {
+          "name": "allowed_bot_ids",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'[]'"
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        },
+        "last_used_at": {
+          "name": "last_used_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        }
+      },
+      "indexes": {
+        "bot_api_keys_key_hash_unique": {
+          "name": "bot_api_keys_key_hash_unique",
+          "columns": [
+            "key_hash"
+          ],
+          "isUnique": true
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "bots": {
+      "name": "bots",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "text",
+          "primaryKey": true,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "name": {
+          "name": "name",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "avatar": {
+          "name": "avatar",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'🤖'"
+        },
+        "kind": {
+          "name": "kind",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "description": {
+          "name": "description",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "''"
+        },
+        "profile_id": {
+          "name": "profile_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "enabled": {
+          "name": "enabled",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": true
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        },
+        "updated_at": {
+          "name": "updated_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        }
+      },
+      "indexes": {
+        "bots_name_unique": {
+          "name": "bots_name_unique",
+          "columns": [
+            "name"
+          ],
+          "isUnique": true
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "custom_skill_versions": {
+      "name": "custom_skill_versions",
+      "columns": {
+        "skill_id": {
+          "name": "skill_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "version": {
+          "name": "version",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "reason": {
+          "name": "reason",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "files": {
+          "name": "files",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "changed": {
+          "name": "changed",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "note": {
+          "name": "note",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        }
+      },
+      "indexes": {},
+      "foreignKeys": {},
+      "compositePrimaryKeys": {
+        "custom_skill_versions_skill_id_version_pk": {
+          "columns": [
+            "skill_id",
+            "version"
+          ],
+          "name": "custom_skill_versions_skill_id_version_pk"
+        }
+      },
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "custom_skills": {
+      "name": "custom_skills",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "text",
+          "primaryKey": true,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "name": {
+          "name": "name",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "description": {
+          "name": "description",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "''"
+        },
+        "origin": {
+          "name": "origin",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "source_id": {
+          "name": "source_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "enabled": {
+          "name": "enabled",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": true
+        },
+        "include_in_all": {
+          "name": "include_in_all",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": false
+        },
+        "published_version": {
+          "name": "published_version",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "latest_version": {
+          "name": "latest_version",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": 0
+        },
+        "working_files": {
+          "name": "working_files",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'{}'"
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        },
+        "updated_at": {
+          "name": "updated_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        }
+      },
+      "indexes": {
+        "custom_skills_name_unique": {
+          "name": "custom_skills_name_unique",
+          "columns": [
+            "name"
+          ],
+          "isUnique": true
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "events": {
+      "name": "events",
+      "columns": {
+        "session_id": {
+          "name": "session_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "seq": {
+          "name": "seq",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "run_id": {
+          "name": "run_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "type": {
+          "name": "type",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "payload": {
+          "name": "payload",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "ts": {
+          "name": "ts",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        }
+      },
+      "indexes": {},
+      "foreignKeys": {},
+      "compositePrimaryKeys": {
+        "events_session_id_seq_pk": {
+          "columns": [
+            "session_id",
+            "seq"
+          ],
+          "name": "events_session_id_seq_pk"
+        }
+      },
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "files": {
+      "name": "files",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "text",
+          "primaryKey": true,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "session_id": {
+          "name": "session_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "rel_path": {
+          "name": "rel_path",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "origin": {
+          "name": "origin",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "mime": {
+          "name": "mime",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "size": {
+          "name": "size",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "mtime_ms": {
+          "name": "mtime_ms",
+          "type": "real",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        },
+        "updated_at": {
+          "name": "updated_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        }
+      },
+      "indexes": {
+        "files_session_path_idx": {
+          "name": "files_session_path_idx",
+          "columns": [
+            "session_id",
+            "rel_path"
+          ],
+          "isUnique": false
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "interactions": {
+      "name": "interactions",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "text",
+          "primaryKey": true,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "session_id": {
+          "name": "session_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "run_id": {
+          "name": "run_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "kind": {
+          "name": "kind",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "request": {
+          "name": "request",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "response": {
+          "name": "response",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "status": {
+          "name": "status",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        },
+        "resolved_at": {
+          "name": "resolved_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "expires_at": {
+          "name": "expires_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        }
+      },
+      "indexes": {
+        "interactions_session_idx": {
+          "name": "interactions_session_idx",
+          "columns": [
+            "session_id",
+            "status"
+          ],
+          "isUnique": false
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "jobs": {
+      "name": "jobs",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "text",
+          "primaryKey": true,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "team_id": {
+          "name": "team_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "no": {
+          "name": "no",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "title": {
+          "name": "title",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "plan": {
+          "name": "plan",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "''"
+        },
+        "status": {
+          "name": "status",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'running'"
+        },
+        "summary": {
+          "name": "summary",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "files": {
+          "name": "files",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'[]'"
+        },
+        "blocked_by": {
+          "name": "blocked_by",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "limits": {
+          "name": "limits",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "lead_runs": {
+          "name": "lead_runs",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": 0
+        },
+        "run_ids": {
+          "name": "run_ids",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'[]'"
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        },
+        "ended_at": {
+          "name": "ended_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        }
+      },
+      "indexes": {
+        "jobs_team_idx": {
+          "name": "jobs_team_idx",
+          "columns": [
+            "team_id",
+            "no"
+          ],
+          "isUnique": false
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "knowledge_roots": {
+      "name": "knowledge_roots",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "text",
+          "primaryKey": true,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "name": {
+          "name": "name",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "path": {
+          "name": "path",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "description": {
+          "name": "description",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "''"
+        },
+        "access": {
+          "name": "access",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'read'"
+        },
+        "enabled": {
+          "name": "enabled",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": true
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        },
+        "updated_at": {
+          "name": "updated_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        }
+      },
+      "indexes": {},
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "mcp_servers": {
+      "name": "mcp_servers",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "text",
+          "primaryKey": true,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "name": {
+          "name": "name",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "title": {
+          "name": "title",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "description": {
+          "name": "description",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "''"
+        },
+        "transport": {
+          "name": "transport",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "enabled": {
+          "name": "enabled",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": true
+        },
+        "url": {
+          "name": "url",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "''"
+        },
+        "headers": {
+          "name": "headers",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'{}'"
+        },
+        "command": {
+          "name": "command",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "''"
+        },
+        "args": {
+          "name": "args",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'[]'"
+        },
+        "env": {
+          "name": "env",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'{}'"
+        },
+        "allow_network": {
+          "name": "allow_network",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": false
+        },
+        "read_paths": {
+          "name": "read_paths",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'[]'"
+        },
+        "write_paths": {
+          "name": "write_paths",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'[]'"
+        },
+        "tool_policies": {
+          "name": "tool_policies",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'{}'"
+        },
+        "background_tools": {
+          "name": "background_tools",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'[]'"
+        },
+        "tool_hints": {
+          "name": "tool_hints",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'{}'"
+        },
+        "oauth": {
+          "name": "oauth",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "tools_cache": {
+          "name": "tools_cache",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'[]'"
+        },
+        "tools_updated_at": {
+          "name": "tools_updated_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "features": {
+          "name": "features",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'{}'"
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        },
+        "updated_at": {
+          "name": "updated_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        }
+      },
+      "indexes": {
+        "mcp_servers_name_unique": {
+          "name": "mcp_servers_name_unique",
+          "columns": [
+            "name"
+          ],
+          "isUnique": true
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "profile_versions": {
+      "name": "profile_versions",
+      "columns": {
+        "profile_id": {
+          "name": "profile_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "version": {
+          "name": "version",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "snapshot": {
+          "name": "snapshot",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        }
+      },
+      "indexes": {},
+      "foreignKeys": {},
+      "compositePrimaryKeys": {
+        "profile_versions_profile_id_version_pk": {
+          "columns": [
+            "profile_id",
+            "version"
+          ],
+          "name": "profile_versions_profile_id_version_pk"
+        }
+      },
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "profiles": {
+      "name": "profiles",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "text",
+          "primaryKey": true,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "name": {
+          "name": "name",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "description": {
+          "name": "description",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "''"
+        },
+        "provider": {
+          "name": "provider",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "model": {
+          "name": "model",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "system_prompt": {
+          "name": "system_prompt",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "''"
+        },
+        "permission_mode": {
+          "name": "permission_mode",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'ask'"
+        },
+        "allowed_tools": {
+          "name": "allowed_tools",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'[]'"
+        },
+        "disallowed_tools": {
+          "name": "disallowed_tools",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'[]'"
+        },
+        "skills": {
+          "name": "skills",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'\"all\"'"
+        },
+        "file_access": {
+          "name": "file_access",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "knowledge_root_ids": {
+          "name": "knowledge_root_ids",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'[]'"
+        },
+        "mcp_server_ids": {
+          "name": "mcp_server_ids",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'[]'"
+        },
+        "codex_plugin_ids": {
+          "name": "codex_plugin_ids",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'[]'"
+        },
+        "is_default": {
+          "name": "is_default",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": false
+        },
+        "builtin": {
+          "name": "builtin",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": false
+        },
+        "builtin_key": {
+          "name": "builtin_key",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "version": {
+          "name": "version",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": 1
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        },
+        "updated_at": {
+          "name": "updated_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        }
+      },
+      "indexes": {},
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "runs": {
+      "name": "runs",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "text",
+          "primaryKey": true,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "session_id": {
+          "name": "session_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "status": {
+          "name": "status",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "started_at": {
+          "name": "started_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "ended_at": {
+          "name": "ended_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "input_tokens": {
+          "name": "input_tokens",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "output_tokens": {
+          "name": "output_tokens",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "cache_read_tokens": {
+          "name": "cache_read_tokens",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "cache_write_tokens": {
+          "name": "cache_write_tokens",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "cost_usd": {
+          "name": "cost_usd",
+          "type": "real",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "context_tokens": {
+          "name": "context_tokens",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "error": {
+          "name": "error",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        }
+      },
+      "indexes": {
+        "runs_session_idx": {
+          "name": "runs_session_idx",
+          "columns": [
+            "session_id"
+          ],
+          "isUnique": false
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "schedule_runs": {
+      "name": "schedule_runs",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "integer",
+          "primaryKey": true,
+          "notNull": true,
+          "autoincrement": true
+        },
+        "schedule_id": {
+          "name": "schedule_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "ts": {
+          "name": "ts",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        },
+        "outcome": {
+          "name": "outcome",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "run_id": {
+          "name": "run_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "cost_usd": {
+          "name": "cost_usd",
+          "type": "real",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "note": {
+          "name": "note",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        }
+      },
+      "indexes": {
+        "schedule_runs_idx": {
+          "name": "schedule_runs_idx",
+          "columns": [
+            "schedule_id",
+            "id"
+          ],
+          "isUnique": false
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "schedules": {
+      "name": "schedules",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "text",
+          "primaryKey": true,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "session_id": {
+          "name": "session_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "team_id": {
+          "name": "team_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "created_by_bot_id": {
+          "name": "created_by_bot_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "title": {
+          "name": "title",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "prompt": {
+          "name": "prompt",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "spec": {
+          "name": "spec",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "timezone": {
+          "name": "timezone",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "status": {
+          "name": "status",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'active'"
+        },
+        "status_note": {
+          "name": "status_note",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "next_run_at": {
+          "name": "next_run_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "last_run_at": {
+          "name": "last_run_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "last_outcome": {
+          "name": "last_outcome",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "run_count": {
+          "name": "run_count",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": 0
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        },
+        "updated_at": {
+          "name": "updated_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        }
+      },
+      "indexes": {
+        "schedules_due_idx": {
+          "name": "schedules_due_idx",
+          "columns": [
+            "status",
+            "next_run_at"
+          ],
+          "isUnique": false
+        },
+        "schedules_session_idx": {
+          "name": "schedules_session_idx",
+          "columns": [
+            "session_id"
+          ],
+          "isUnique": false
+        },
+        "schedules_team_idx": {
+          "name": "schedules_team_idx",
+          "columns": [
+            "team_id"
+          ],
+          "isUnique": false
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "sessions": {
+      "name": "sessions",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "text",
+          "primaryKey": true,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "source": {
+          "name": "source",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "profile_id": {
+          "name": "profile_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "provider": {
+          "name": "provider",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "model": {
+          "name": "model",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "permission_mode": {
+          "name": "permission_mode",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "system_prompt_append": {
+          "name": "system_prompt_append",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "''"
+        },
+        "provider_session_id": {
+          "name": "provider_session_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "workspace_dir": {
+          "name": "workspace_dir",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "title": {
+          "name": "title",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "''"
+        },
+        "last_seq": {
+          "name": "last_seq",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": 0
+        },
+        "archived": {
+          "name": "archived",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": false
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        },
+        "updated_at": {
+          "name": "updated_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        }
+      },
+      "indexes": {
+        "sessions_source_idx": {
+          "name": "sessions_source_idx",
+          "columns": [
+            "source",
+            "updated_at"
+          ],
+          "isUnique": false
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "settings": {
+      "name": "settings",
+      "columns": {
+        "key": {
+          "name": "key",
+          "type": "text",
+          "primaryKey": true,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "value": {
+          "name": "value",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "updated_at": {
+          "name": "updated_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        }
+      },
+      "indexes": {},
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "skill_publish_keys": {
+      "name": "skill_publish_keys",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "text",
+          "primaryKey": true,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "name": {
+          "name": "name",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "description": {
+          "name": "description",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "''"
+        },
+        "key_hash": {
+          "name": "key_hash",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "key_prefix": {
+          "name": "key_prefix",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "enabled": {
+          "name": "enabled",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": true
+        },
+        "expires_at": {
+          "name": "expires_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        },
+        "last_used_at": {
+          "name": "last_used_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "last_used_ip": {
+          "name": "last_used_ip",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        }
+      },
+      "indexes": {
+        "skill_publish_keys_key_hash_unique": {
+          "name": "skill_publish_keys_key_hash_unique",
+          "columns": [
+            "key_hash"
+          ],
+          "isUnique": true
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "skill_sources": {
+      "name": "skill_sources",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "text",
+          "primaryKey": true,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "name": {
+          "name": "name",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "description": {
+          "name": "description",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "''"
+        },
+        "kind": {
+          "name": "kind",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "path": {
+          "name": "path",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "sync_mode": {
+          "name": "sync_mode",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'manual'"
+        },
+        "interval_min": {
+          "name": "interval_min",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": 10
+        },
+        "last_sync_at": {
+          "name": "last_sync_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "last_sync_digest": {
+          "name": "last_sync_digest",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "status": {
+          "name": "status",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'never'"
+        },
+        "last_error": {
+          "name": "last_error",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        },
+        "updated_at": {
+          "name": "updated_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        }
+      },
+      "indexes": {},
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "skill_submission_items": {
+      "name": "skill_submission_items",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "text",
+          "primaryKey": true,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "submission_id": {
+          "name": "submission_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "name": {
+          "name": "name",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "description": {
+          "name": "description",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "''"
+        },
+        "declared_version": {
+          "name": "declared_version",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "files": {
+          "name": "files",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "checks": {
+          "name": "checks",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'[]'"
+        },
+        "status": {
+          "name": "status",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "reason": {
+          "name": "reason",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "review_note": {
+          "name": "review_note",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "reviewed_at": {
+          "name": "reviewed_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "result": {
+          "name": "result",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        }
+      },
+      "indexes": {},
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "skill_submissions": {
+      "name": "skill_submissions",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "text",
+          "primaryKey": true,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "key_id": {
+          "name": "key_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "key_name": {
+          "name": "key_name",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "message": {
+          "name": "message",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "''"
+        },
+        "meta": {
+          "name": "meta",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'{}'"
+        },
+        "package_sha": {
+          "name": "package_sha",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "package_size": {
+          "name": "package_size",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        }
+      },
+      "indexes": {},
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "tasks": {
+      "name": "tasks",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "text",
+          "primaryKey": true,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "job_id": {
+          "name": "job_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "team_id": {
+          "name": "team_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "no": {
+          "name": "no",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "bot_id": {
+          "name": "bot_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "title": {
+          "name": "title",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "instructions": {
+          "name": "instructions",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "depends_on": {
+          "name": "depends_on",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'[]'"
+        },
+        "status": {
+          "name": "status",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "follow_up": {
+          "name": "follow_up",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "result": {
+          "name": "result",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "files": {
+          "name": "files",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'[]'"
+        },
+        "run_id": {
+          "name": "run_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "session_id": {
+          "name": "session_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        },
+        "started_at": {
+          "name": "started_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "ended_at": {
+          "name": "ended_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        }
+      },
+      "indexes": {
+        "tasks_team_idx": {
+          "name": "tasks_team_idx",
+          "columns": [
+            "team_id",
+            "no"
+          ],
+          "isUnique": false
+        },
+        "tasks_job_idx": {
+          "name": "tasks_job_idx",
+          "columns": [
+            "job_id"
+          ],
+          "isUnique": false
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "team_bot_sessions": {
+      "name": "team_bot_sessions",
+      "columns": {
+        "team_id": {
+          "name": "team_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "bot_id": {
+          "name": "bot_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "session_id": {
+          "name": "session_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "last_read_seq": {
+          "name": "last_read_seq",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": 0
+        },
+        "reset_requested": {
+          "name": "reset_requested",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": false
+        }
+      },
+      "indexes": {
+        "team_bot_sessions_session_id_unique": {
+          "name": "team_bot_sessions_session_id_unique",
+          "columns": [
+            "session_id"
+          ],
+          "isUnique": true
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {
+        "team_bot_sessions_team_id_bot_id_pk": {
+          "columns": [
+            "team_id",
+            "bot_id"
+          ],
+          "name": "team_bot_sessions_team_id_bot_id_pk"
+        }
+      },
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "team_bots": {
+      "name": "team_bots",
+      "columns": {
+        "team_id": {
+          "name": "team_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "bot_id": {
+          "name": "bot_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "role": {
+          "name": "role",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "joined_at": {
+          "name": "joined_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        }
+      },
+      "indexes": {
+        "team_bots_bot_idx": {
+          "name": "team_bots_bot_idx",
+          "columns": [
+            "bot_id"
+          ],
+          "isUnique": false
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {
+        "team_bots_team_id_bot_id_pk": {
+          "columns": [
+            "team_id",
+            "bot_id"
+          ],
+          "name": "team_bots_team_id_bot_id_pk"
+        }
+      },
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "team_cases": {
+      "name": "team_cases",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "text",
+          "primaryKey": true,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "name": {
+          "name": "name",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "lead_bot_id": {
+          "name": "lead_bot_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "worker_bot_ids": {
+          "name": "worker_bot_ids",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'[]'"
+        },
+        "stub_bot_ids": {
+          "name": "stub_bot_ids",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'[]'"
+        },
+        "limits": {
+          "name": "limits",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "human_name": {
+          "name": "human_name",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "text": {
+          "name": "text",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "mentions": {
+          "name": "mentions",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'[]'"
+        },
+        "files": {
+          "name": "files",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'[]'"
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        },
+        "updated_at": {
+          "name": "updated_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        }
+      },
+      "indexes": {},
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "team_events": {
+      "name": "team_events",
+      "columns": {
+        "team_id": {
+          "name": "team_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "seq": {
+          "name": "seq",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "type": {
+          "name": "type",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "payload": {
+          "name": "payload",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "ts": {
+          "name": "ts",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        }
+      },
+      "indexes": {},
+      "foreignKeys": {},
+      "compositePrimaryKeys": {
+        "team_events_team_id_seq_pk": {
+          "columns": [
+            "team_id",
+            "seq"
+          ],
+          "name": "team_events_team_id_seq_pk"
+        }
+      },
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "teams": {
+      "name": "teams",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "text",
+          "primaryKey": true,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "source": {
+          "name": "source",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "title": {
+          "name": "title",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "''"
+        },
+        "human": {
+          "name": "human",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "status": {
+          "name": "status",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'active'"
+        },
+        "workspace_dir": {
+          "name": "workspace_dir",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "limits": {
+          "name": "limits",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "debug": {
+          "name": "debug",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'{\"stepMode\":false,\"stubBotIds\":[]}'"
+        },
+        "last_seq": {
+          "name": "last_seq",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": 0
+        },
+        "case_id": {
+          "name": "case_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        },
+        "updated_at": {
+          "name": "updated_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        }
+      },
+      "indexes": {
+        "teams_source_idx": {
+          "name": "teams_source_idx",
+          "columns": [
+            "source",
+            "updated_at"
+          ],
+          "isUnique": false
+        },
+        "teams_case_idx": {
+          "name": "teams_case_idx",
+          "columns": [
+            "case_id"
+          ],
+          "isUnique": false
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "usage_daily": {
+      "name": "usage_daily",
+      "columns": {
+        "date": {
+          "name": "date",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "source": {
+          "name": "source",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "provider": {
+          "name": "provider",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "model": {
+          "name": "model",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "runs": {
+          "name": "runs",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": 0
+        },
+        "failed": {
+          "name": "failed",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": 0
+        },
+        "interrupted": {
+          "name": "interrupted",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": 0
+        },
+        "input_tokens": {
+          "name": "input_tokens",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": 0
+        },
+        "output_tokens": {
+          "name": "output_tokens",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": 0
+        },
+        "cache_read_tokens": {
+          "name": "cache_read_tokens",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": 0
+        },
+        "cache_write_tokens": {
+          "name": "cache_write_tokens",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": 0
+        },
+        "cost_usd": {
+          "name": "cost_usd",
+          "type": "real",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": 0
+        },
+        "duration_ms": {
+          "name": "duration_ms",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": 0
+        }
+      },
+      "indexes": {},
+      "foreignKeys": {},
+      "compositePrimaryKeys": {
+        "usage_daily_date_source_provider_model_pk": {
+          "columns": [
+            "date",
+            "source",
+            "provider",
+            "model"
+          ],
+          "name": "usage_daily_date_source_provider_model_pk"
+        }
+      },
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    }
+  },
+  "views": {},
+  "enums": {},
+  "_meta": {
+    "schemas": {},
+    "tables": {},
+    "columns": {}
+  },
+  "internal": {
+    "indexes": {}
+  }
+}

+ 2956 - 0
apps/server/drizzle/meta/0020_snapshot.json

@@ -0,0 +1,2956 @@
+{
+  "version": "6",
+  "dialect": "sqlite",
+  "id": "2af77c7c-70bc-43d1-9b96-f00f95309921",
+  "prevId": "15bbdbd8-2521-4480-90a6-f188821bce85",
+  "tables": {
+    "admin_sessions": {
+      "name": "admin_sessions",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "text",
+          "primaryKey": true,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        },
+        "expires_at": {
+          "name": "expires_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        }
+      },
+      "indexes": {},
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "api_keys": {
+      "name": "api_keys",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "text",
+          "primaryKey": true,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "name": {
+          "name": "name",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "key_hash": {
+          "name": "key_hash",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "key_prefix": {
+          "name": "key_prefix",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "enabled": {
+          "name": "enabled",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": true
+        },
+        "allowed_profile_ids": {
+          "name": "allowed_profile_ids",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'[]'"
+        },
+        "default_profile_id": {
+          "name": "default_profile_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        },
+        "last_used_at": {
+          "name": "last_used_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        }
+      },
+      "indexes": {
+        "api_keys_key_hash_unique": {
+          "name": "api_keys_key_hash_unique",
+          "columns": [
+            "key_hash"
+          ],
+          "isUnique": true
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "audit_logs": {
+      "name": "audit_logs",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "integer",
+          "primaryKey": true,
+          "notNull": true,
+          "autoincrement": true
+        },
+        "ts": {
+          "name": "ts",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        },
+        "actor": {
+          "name": "actor",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "session_id": {
+          "name": "session_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "kind": {
+          "name": "kind",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "summary": {
+          "name": "summary",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "detail": {
+          "name": "detail",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        }
+      },
+      "indexes": {
+        "audit_ts_idx": {
+          "name": "audit_ts_idx",
+          "columns": [
+            "ts"
+          ],
+          "isUnique": false
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "background_calls": {
+      "name": "background_calls",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "text",
+          "primaryKey": true,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "session_id": {
+          "name": "session_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "team_id": {
+          "name": "team_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "bot_id": {
+          "name": "bot_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "task_id": {
+          "name": "task_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "server_id": {
+          "name": "server_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "server_name": {
+          "name": "server_name",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "server_title": {
+          "name": "server_title",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "tool": {
+          "name": "tool",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "args": {
+          "name": "args",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "status": {
+          "name": "status",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'running'"
+        },
+        "started_at": {
+          "name": "started_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        },
+        "ended_at": {
+          "name": "ended_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "result": {
+          "name": "result",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "error": {
+          "name": "error",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "delivered": {
+          "name": "delivered",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": false
+        },
+        "mode": {
+          "name": "mode",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'wait'"
+        },
+        "mcp_task_id": {
+          "name": "mcp_task_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "conn_scope": {
+          "name": "conn_scope",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "poll_after": {
+          "name": "poll_after",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "poll_error_since": {
+          "name": "poll_error_since",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "status_message": {
+          "name": "status_message",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        }
+      },
+      "indexes": {
+        "background_calls_session_idx": {
+          "name": "background_calls_session_idx",
+          "columns": [
+            "session_id"
+          ],
+          "isUnique": false
+        },
+        "background_calls_team_idx": {
+          "name": "background_calls_team_idx",
+          "columns": [
+            "team_id"
+          ],
+          "isUnique": false
+        },
+        "background_calls_status_idx": {
+          "name": "background_calls_status_idx",
+          "columns": [
+            "status",
+            "delivered"
+          ],
+          "isUnique": false
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "bot_api_keys": {
+      "name": "bot_api_keys",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "text",
+          "primaryKey": true,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "name": {
+          "name": "name",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "key_hash": {
+          "name": "key_hash",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "key_prefix": {
+          "name": "key_prefix",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "enabled": {
+          "name": "enabled",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": true
+        },
+        "allowed_bot_ids": {
+          "name": "allowed_bot_ids",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'[]'"
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        },
+        "last_used_at": {
+          "name": "last_used_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        }
+      },
+      "indexes": {
+        "bot_api_keys_key_hash_unique": {
+          "name": "bot_api_keys_key_hash_unique",
+          "columns": [
+            "key_hash"
+          ],
+          "isUnique": true
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "bots": {
+      "name": "bots",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "text",
+          "primaryKey": true,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "name": {
+          "name": "name",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "avatar": {
+          "name": "avatar",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'🤖'"
+        },
+        "kind": {
+          "name": "kind",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "description": {
+          "name": "description",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "''"
+        },
+        "profile_id": {
+          "name": "profile_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "enabled": {
+          "name": "enabled",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": true
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        },
+        "updated_at": {
+          "name": "updated_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        }
+      },
+      "indexes": {
+        "bots_name_unique": {
+          "name": "bots_name_unique",
+          "columns": [
+            "name"
+          ],
+          "isUnique": true
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "custom_skill_versions": {
+      "name": "custom_skill_versions",
+      "columns": {
+        "skill_id": {
+          "name": "skill_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "version": {
+          "name": "version",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "reason": {
+          "name": "reason",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "files": {
+          "name": "files",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "changed": {
+          "name": "changed",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "note": {
+          "name": "note",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        }
+      },
+      "indexes": {},
+      "foreignKeys": {},
+      "compositePrimaryKeys": {
+        "custom_skill_versions_skill_id_version_pk": {
+          "columns": [
+            "skill_id",
+            "version"
+          ],
+          "name": "custom_skill_versions_skill_id_version_pk"
+        }
+      },
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "custom_skills": {
+      "name": "custom_skills",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "text",
+          "primaryKey": true,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "name": {
+          "name": "name",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "description": {
+          "name": "description",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "''"
+        },
+        "enabled": {
+          "name": "enabled",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": true
+        },
+        "include_in_all": {
+          "name": "include_in_all",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": false
+        },
+        "published_version": {
+          "name": "published_version",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "latest_version": {
+          "name": "latest_version",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": 0
+        },
+        "working_files": {
+          "name": "working_files",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'{}'"
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        },
+        "updated_at": {
+          "name": "updated_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        }
+      },
+      "indexes": {
+        "custom_skills_name_unique": {
+          "name": "custom_skills_name_unique",
+          "columns": [
+            "name"
+          ],
+          "isUnique": true
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "events": {
+      "name": "events",
+      "columns": {
+        "session_id": {
+          "name": "session_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "seq": {
+          "name": "seq",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "run_id": {
+          "name": "run_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "type": {
+          "name": "type",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "payload": {
+          "name": "payload",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "ts": {
+          "name": "ts",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        }
+      },
+      "indexes": {},
+      "foreignKeys": {},
+      "compositePrimaryKeys": {
+        "events_session_id_seq_pk": {
+          "columns": [
+            "session_id",
+            "seq"
+          ],
+          "name": "events_session_id_seq_pk"
+        }
+      },
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "files": {
+      "name": "files",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "text",
+          "primaryKey": true,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "session_id": {
+          "name": "session_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "rel_path": {
+          "name": "rel_path",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "origin": {
+          "name": "origin",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "mime": {
+          "name": "mime",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "size": {
+          "name": "size",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "mtime_ms": {
+          "name": "mtime_ms",
+          "type": "real",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        },
+        "updated_at": {
+          "name": "updated_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        }
+      },
+      "indexes": {
+        "files_session_path_idx": {
+          "name": "files_session_path_idx",
+          "columns": [
+            "session_id",
+            "rel_path"
+          ],
+          "isUnique": false
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "interactions": {
+      "name": "interactions",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "text",
+          "primaryKey": true,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "session_id": {
+          "name": "session_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "run_id": {
+          "name": "run_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "kind": {
+          "name": "kind",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "request": {
+          "name": "request",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "response": {
+          "name": "response",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "status": {
+          "name": "status",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        },
+        "resolved_at": {
+          "name": "resolved_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "expires_at": {
+          "name": "expires_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        }
+      },
+      "indexes": {
+        "interactions_session_idx": {
+          "name": "interactions_session_idx",
+          "columns": [
+            "session_id",
+            "status"
+          ],
+          "isUnique": false
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "jobs": {
+      "name": "jobs",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "text",
+          "primaryKey": true,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "team_id": {
+          "name": "team_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "no": {
+          "name": "no",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "title": {
+          "name": "title",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "plan": {
+          "name": "plan",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "''"
+        },
+        "status": {
+          "name": "status",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'running'"
+        },
+        "summary": {
+          "name": "summary",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "files": {
+          "name": "files",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'[]'"
+        },
+        "blocked_by": {
+          "name": "blocked_by",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "limits": {
+          "name": "limits",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "lead_runs": {
+          "name": "lead_runs",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": 0
+        },
+        "run_ids": {
+          "name": "run_ids",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'[]'"
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        },
+        "ended_at": {
+          "name": "ended_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        }
+      },
+      "indexes": {
+        "jobs_team_idx": {
+          "name": "jobs_team_idx",
+          "columns": [
+            "team_id",
+            "no"
+          ],
+          "isUnique": false
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "knowledge_roots": {
+      "name": "knowledge_roots",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "text",
+          "primaryKey": true,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "name": {
+          "name": "name",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "path": {
+          "name": "path",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "description": {
+          "name": "description",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "''"
+        },
+        "access": {
+          "name": "access",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'read'"
+        },
+        "enabled": {
+          "name": "enabled",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": true
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        },
+        "updated_at": {
+          "name": "updated_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        }
+      },
+      "indexes": {},
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "mcp_servers": {
+      "name": "mcp_servers",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "text",
+          "primaryKey": true,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "name": {
+          "name": "name",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "title": {
+          "name": "title",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "description": {
+          "name": "description",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "''"
+        },
+        "transport": {
+          "name": "transport",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "enabled": {
+          "name": "enabled",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": true
+        },
+        "url": {
+          "name": "url",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "''"
+        },
+        "headers": {
+          "name": "headers",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'{}'"
+        },
+        "command": {
+          "name": "command",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "''"
+        },
+        "args": {
+          "name": "args",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'[]'"
+        },
+        "env": {
+          "name": "env",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'{}'"
+        },
+        "allow_network": {
+          "name": "allow_network",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": false
+        },
+        "read_paths": {
+          "name": "read_paths",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'[]'"
+        },
+        "write_paths": {
+          "name": "write_paths",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'[]'"
+        },
+        "tool_policies": {
+          "name": "tool_policies",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'{}'"
+        },
+        "background_tools": {
+          "name": "background_tools",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'[]'"
+        },
+        "tool_hints": {
+          "name": "tool_hints",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'{}'"
+        },
+        "oauth": {
+          "name": "oauth",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "tools_cache": {
+          "name": "tools_cache",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'[]'"
+        },
+        "tools_updated_at": {
+          "name": "tools_updated_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "features": {
+          "name": "features",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'{}'"
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        },
+        "updated_at": {
+          "name": "updated_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        }
+      },
+      "indexes": {
+        "mcp_servers_name_unique": {
+          "name": "mcp_servers_name_unique",
+          "columns": [
+            "name"
+          ],
+          "isUnique": true
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "profile_versions": {
+      "name": "profile_versions",
+      "columns": {
+        "profile_id": {
+          "name": "profile_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "version": {
+          "name": "version",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "snapshot": {
+          "name": "snapshot",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        }
+      },
+      "indexes": {},
+      "foreignKeys": {},
+      "compositePrimaryKeys": {
+        "profile_versions_profile_id_version_pk": {
+          "columns": [
+            "profile_id",
+            "version"
+          ],
+          "name": "profile_versions_profile_id_version_pk"
+        }
+      },
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "profiles": {
+      "name": "profiles",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "text",
+          "primaryKey": true,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "name": {
+          "name": "name",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "description": {
+          "name": "description",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "''"
+        },
+        "provider": {
+          "name": "provider",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "model": {
+          "name": "model",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "system_prompt": {
+          "name": "system_prompt",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "''"
+        },
+        "permission_mode": {
+          "name": "permission_mode",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'ask'"
+        },
+        "allowed_tools": {
+          "name": "allowed_tools",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'[]'"
+        },
+        "disallowed_tools": {
+          "name": "disallowed_tools",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'[]'"
+        },
+        "skills": {
+          "name": "skills",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'\"all\"'"
+        },
+        "file_access": {
+          "name": "file_access",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "knowledge_root_ids": {
+          "name": "knowledge_root_ids",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'[]'"
+        },
+        "mcp_server_ids": {
+          "name": "mcp_server_ids",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'[]'"
+        },
+        "codex_plugin_ids": {
+          "name": "codex_plugin_ids",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'[]'"
+        },
+        "is_default": {
+          "name": "is_default",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": false
+        },
+        "builtin": {
+          "name": "builtin",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": false
+        },
+        "builtin_key": {
+          "name": "builtin_key",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "version": {
+          "name": "version",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": 1
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        },
+        "updated_at": {
+          "name": "updated_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        }
+      },
+      "indexes": {},
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "runs": {
+      "name": "runs",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "text",
+          "primaryKey": true,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "session_id": {
+          "name": "session_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "status": {
+          "name": "status",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "started_at": {
+          "name": "started_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "ended_at": {
+          "name": "ended_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "input_tokens": {
+          "name": "input_tokens",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "output_tokens": {
+          "name": "output_tokens",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "cache_read_tokens": {
+          "name": "cache_read_tokens",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "cache_write_tokens": {
+          "name": "cache_write_tokens",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "cost_usd": {
+          "name": "cost_usd",
+          "type": "real",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "context_tokens": {
+          "name": "context_tokens",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "error": {
+          "name": "error",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        }
+      },
+      "indexes": {
+        "runs_session_idx": {
+          "name": "runs_session_idx",
+          "columns": [
+            "session_id"
+          ],
+          "isUnique": false
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "schedule_runs": {
+      "name": "schedule_runs",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "integer",
+          "primaryKey": true,
+          "notNull": true,
+          "autoincrement": true
+        },
+        "schedule_id": {
+          "name": "schedule_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "ts": {
+          "name": "ts",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        },
+        "outcome": {
+          "name": "outcome",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "run_id": {
+          "name": "run_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "cost_usd": {
+          "name": "cost_usd",
+          "type": "real",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "note": {
+          "name": "note",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        }
+      },
+      "indexes": {
+        "schedule_runs_idx": {
+          "name": "schedule_runs_idx",
+          "columns": [
+            "schedule_id",
+            "id"
+          ],
+          "isUnique": false
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "schedules": {
+      "name": "schedules",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "text",
+          "primaryKey": true,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "session_id": {
+          "name": "session_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "team_id": {
+          "name": "team_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "created_by_bot_id": {
+          "name": "created_by_bot_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "title": {
+          "name": "title",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "prompt": {
+          "name": "prompt",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "spec": {
+          "name": "spec",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "timezone": {
+          "name": "timezone",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "status": {
+          "name": "status",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'active'"
+        },
+        "status_note": {
+          "name": "status_note",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "next_run_at": {
+          "name": "next_run_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "last_run_at": {
+          "name": "last_run_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "last_outcome": {
+          "name": "last_outcome",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "run_count": {
+          "name": "run_count",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": 0
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        },
+        "updated_at": {
+          "name": "updated_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        }
+      },
+      "indexes": {
+        "schedules_due_idx": {
+          "name": "schedules_due_idx",
+          "columns": [
+            "status",
+            "next_run_at"
+          ],
+          "isUnique": false
+        },
+        "schedules_session_idx": {
+          "name": "schedules_session_idx",
+          "columns": [
+            "session_id"
+          ],
+          "isUnique": false
+        },
+        "schedules_team_idx": {
+          "name": "schedules_team_idx",
+          "columns": [
+            "team_id"
+          ],
+          "isUnique": false
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "sessions": {
+      "name": "sessions",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "text",
+          "primaryKey": true,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "source": {
+          "name": "source",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "profile_id": {
+          "name": "profile_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "provider": {
+          "name": "provider",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "model": {
+          "name": "model",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "permission_mode": {
+          "name": "permission_mode",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "system_prompt_append": {
+          "name": "system_prompt_append",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "''"
+        },
+        "provider_session_id": {
+          "name": "provider_session_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "workspace_dir": {
+          "name": "workspace_dir",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "title": {
+          "name": "title",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "''"
+        },
+        "last_seq": {
+          "name": "last_seq",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": 0
+        },
+        "archived": {
+          "name": "archived",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": false
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        },
+        "updated_at": {
+          "name": "updated_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        }
+      },
+      "indexes": {
+        "sessions_source_idx": {
+          "name": "sessions_source_idx",
+          "columns": [
+            "source",
+            "updated_at"
+          ],
+          "isUnique": false
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "settings": {
+      "name": "settings",
+      "columns": {
+        "key": {
+          "name": "key",
+          "type": "text",
+          "primaryKey": true,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "value": {
+          "name": "value",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "updated_at": {
+          "name": "updated_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        }
+      },
+      "indexes": {},
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "skill_publish_keys": {
+      "name": "skill_publish_keys",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "text",
+          "primaryKey": true,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "name": {
+          "name": "name",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "description": {
+          "name": "description",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "''"
+        },
+        "key_hash": {
+          "name": "key_hash",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "key_prefix": {
+          "name": "key_prefix",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "enabled": {
+          "name": "enabled",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": true
+        },
+        "expires_at": {
+          "name": "expires_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        },
+        "last_used_at": {
+          "name": "last_used_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "last_used_ip": {
+          "name": "last_used_ip",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        }
+      },
+      "indexes": {
+        "skill_publish_keys_key_hash_unique": {
+          "name": "skill_publish_keys_key_hash_unique",
+          "columns": [
+            "key_hash"
+          ],
+          "isUnique": true
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "skill_submission_items": {
+      "name": "skill_submission_items",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "text",
+          "primaryKey": true,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "submission_id": {
+          "name": "submission_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "name": {
+          "name": "name",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "description": {
+          "name": "description",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "''"
+        },
+        "declared_version": {
+          "name": "declared_version",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "files": {
+          "name": "files",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "checks": {
+          "name": "checks",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'[]'"
+        },
+        "status": {
+          "name": "status",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "reason": {
+          "name": "reason",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "review_note": {
+          "name": "review_note",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "reviewed_at": {
+          "name": "reviewed_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "result": {
+          "name": "result",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        }
+      },
+      "indexes": {},
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "skill_submissions": {
+      "name": "skill_submissions",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "text",
+          "primaryKey": true,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "key_id": {
+          "name": "key_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "key_name": {
+          "name": "key_name",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "message": {
+          "name": "message",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "''"
+        },
+        "meta": {
+          "name": "meta",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'{}'"
+        },
+        "package_sha": {
+          "name": "package_sha",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "package_size": {
+          "name": "package_size",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        }
+      },
+      "indexes": {},
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "tasks": {
+      "name": "tasks",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "text",
+          "primaryKey": true,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "job_id": {
+          "name": "job_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "team_id": {
+          "name": "team_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "no": {
+          "name": "no",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "bot_id": {
+          "name": "bot_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "title": {
+          "name": "title",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "instructions": {
+          "name": "instructions",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "depends_on": {
+          "name": "depends_on",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'[]'"
+        },
+        "status": {
+          "name": "status",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "follow_up": {
+          "name": "follow_up",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "result": {
+          "name": "result",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "files": {
+          "name": "files",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'[]'"
+        },
+        "run_id": {
+          "name": "run_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "session_id": {
+          "name": "session_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        },
+        "started_at": {
+          "name": "started_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "ended_at": {
+          "name": "ended_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        }
+      },
+      "indexes": {
+        "tasks_team_idx": {
+          "name": "tasks_team_idx",
+          "columns": [
+            "team_id",
+            "no"
+          ],
+          "isUnique": false
+        },
+        "tasks_job_idx": {
+          "name": "tasks_job_idx",
+          "columns": [
+            "job_id"
+          ],
+          "isUnique": false
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "team_bot_sessions": {
+      "name": "team_bot_sessions",
+      "columns": {
+        "team_id": {
+          "name": "team_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "bot_id": {
+          "name": "bot_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "session_id": {
+          "name": "session_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "last_read_seq": {
+          "name": "last_read_seq",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": 0
+        },
+        "reset_requested": {
+          "name": "reset_requested",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": false
+        }
+      },
+      "indexes": {
+        "team_bot_sessions_session_id_unique": {
+          "name": "team_bot_sessions_session_id_unique",
+          "columns": [
+            "session_id"
+          ],
+          "isUnique": true
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {
+        "team_bot_sessions_team_id_bot_id_pk": {
+          "columns": [
+            "team_id",
+            "bot_id"
+          ],
+          "name": "team_bot_sessions_team_id_bot_id_pk"
+        }
+      },
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "team_bots": {
+      "name": "team_bots",
+      "columns": {
+        "team_id": {
+          "name": "team_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "bot_id": {
+          "name": "bot_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "role": {
+          "name": "role",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "joined_at": {
+          "name": "joined_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        }
+      },
+      "indexes": {
+        "team_bots_bot_idx": {
+          "name": "team_bots_bot_idx",
+          "columns": [
+            "bot_id"
+          ],
+          "isUnique": false
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {
+        "team_bots_team_id_bot_id_pk": {
+          "columns": [
+            "team_id",
+            "bot_id"
+          ],
+          "name": "team_bots_team_id_bot_id_pk"
+        }
+      },
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "team_cases": {
+      "name": "team_cases",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "text",
+          "primaryKey": true,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "name": {
+          "name": "name",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "lead_bot_id": {
+          "name": "lead_bot_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "worker_bot_ids": {
+          "name": "worker_bot_ids",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'[]'"
+        },
+        "stub_bot_ids": {
+          "name": "stub_bot_ids",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'[]'"
+        },
+        "limits": {
+          "name": "limits",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "human_name": {
+          "name": "human_name",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "text": {
+          "name": "text",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "mentions": {
+          "name": "mentions",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'[]'"
+        },
+        "files": {
+          "name": "files",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'[]'"
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        },
+        "updated_at": {
+          "name": "updated_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        }
+      },
+      "indexes": {},
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "team_events": {
+      "name": "team_events",
+      "columns": {
+        "team_id": {
+          "name": "team_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "seq": {
+          "name": "seq",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "type": {
+          "name": "type",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "payload": {
+          "name": "payload",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "ts": {
+          "name": "ts",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        }
+      },
+      "indexes": {},
+      "foreignKeys": {},
+      "compositePrimaryKeys": {
+        "team_events_team_id_seq_pk": {
+          "columns": [
+            "team_id",
+            "seq"
+          ],
+          "name": "team_events_team_id_seq_pk"
+        }
+      },
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "teams": {
+      "name": "teams",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "text",
+          "primaryKey": true,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "source": {
+          "name": "source",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "title": {
+          "name": "title",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "''"
+        },
+        "human": {
+          "name": "human",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "status": {
+          "name": "status",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'active'"
+        },
+        "workspace_dir": {
+          "name": "workspace_dir",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "limits": {
+          "name": "limits",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "debug": {
+          "name": "debug",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'{\"stepMode\":false,\"stubBotIds\":[]}'"
+        },
+        "last_seq": {
+          "name": "last_seq",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": 0
+        },
+        "case_id": {
+          "name": "case_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        },
+        "updated_at": {
+          "name": "updated_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        }
+      },
+      "indexes": {
+        "teams_source_idx": {
+          "name": "teams_source_idx",
+          "columns": [
+            "source",
+            "updated_at"
+          ],
+          "isUnique": false
+        },
+        "teams_case_idx": {
+          "name": "teams_case_idx",
+          "columns": [
+            "case_id"
+          ],
+          "isUnique": false
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "usage_daily": {
+      "name": "usage_daily",
+      "columns": {
+        "date": {
+          "name": "date",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "source": {
+          "name": "source",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "provider": {
+          "name": "provider",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "model": {
+          "name": "model",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "runs": {
+          "name": "runs",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": 0
+        },
+        "failed": {
+          "name": "failed",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": 0
+        },
+        "interrupted": {
+          "name": "interrupted",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": 0
+        },
+        "input_tokens": {
+          "name": "input_tokens",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": 0
+        },
+        "output_tokens": {
+          "name": "output_tokens",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": 0
+        },
+        "cache_read_tokens": {
+          "name": "cache_read_tokens",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": 0
+        },
+        "cache_write_tokens": {
+          "name": "cache_write_tokens",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": 0
+        },
+        "cost_usd": {
+          "name": "cost_usd",
+          "type": "real",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": 0
+        },
+        "duration_ms": {
+          "name": "duration_ms",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": 0
+        }
+      },
+      "indexes": {},
+      "foreignKeys": {},
+      "compositePrimaryKeys": {
+        "usage_daily_date_source_provider_model_pk": {
+          "columns": [
+            "date",
+            "source",
+            "provider",
+            "model"
+          ],
+          "name": "usage_daily_date_source_provider_model_pk"
+        }
+      },
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    }
+  },
+  "views": {},
+  "enums": {},
+  "_meta": {
+    "schemas": {},
+    "tables": {},
+    "columns": {}
+  },
+  "internal": {
+    "indexes": {}
+  }
+}

+ 14 - 0
apps/server/drizzle/meta/_journal.json

@@ -134,6 +134,20 @@
       "when": 1793264349381,
       "tag": "0018_mcp_features",
       "breakpoints": true
+    },
+    {
+      "idx": 19,
+      "version": "6",
+      "when": 1793697035989,
+      "tag": "0019_skill_registry",
+      "breakpoints": true
+    },
+    {
+      "idx": 20,
+      "version": "6",
+      "when": 1793697036854,
+      "tag": "0020_drop_skill_sources",
+      "breakpoints": true
     }
   ]
 }

+ 8 - 2
apps/server/src/api-reference/reference.ts

@@ -1,9 +1,11 @@
-// 对外 API 协议参考:后台"API 协议"页面与 PDF 的唯一数据来源。包括 Agent API(/v1,本文件)与 Bot API(/bot/v1,bot-reference.ts)。
+// 对外 API 协议参考:后台"API 协议"页面与 PDF 的唯一数据来源。包括 Agent API(/v1,本文件)、Bot API(/bot/v1,bot-reference.ts)
+// 与技能发布 API(/skills/registry/v1,registry-reference.ts)。
 // test/api-reference.test.ts 会核对这里列出的接口与实际注册的 /v1 路由完全一致,新增或删除接口时必须同步修改。
 
 import type { ApiEndpointGroup, ApiErrorDef, ApiEventDef, ApiOverview, ApiSpec, ApiTypeDef } from '@agent-server/shared'
 import { BOT_ERRORS, BOT_EVENTS, BOT_GROUPS, BOT_OVERVIEW, BOT_TYPES } from './bot-reference'
 import { BACKGROUND_DESC, BACKGROUND_TYPES } from './background-reference'
+import { REGISTRY_ERRORS, REGISTRY_GROUPS, REGISTRY_OVERVIEW, REGISTRY_TYPES } from './registry-reference'
 import { SCHEDULE_DESC, SCHEDULE_TYPES } from './schedule-reference'
 
 export type { ApiEndpoint as Endpoint, ApiField as Field } from '@agent-server/shared'
@@ -13,7 +15,7 @@ export const API_OVERVIEW: ApiOverview = {
     '**基础地址**:`$BASE`,所有接口位于 `/v1` 下;请求和响应为 JSON(UTF-8),时间为 ISO 8601。',
     '**鉴权**:`Authorization: Bearer <凭证>`。**Agent Key**(`ask_…`)每个业务平台一个,在后台签发,只在平台后端使用;**浏览器令牌**(`bt_…`)由平台后端调用 `POST /v1/browser-tokens` 换取,交给浏览器使用,默认 1 小时有效。',
     '**可见范围**:每个 Agent Key 只能使用后台为它勾选的 Profile,只能看到自己创建的会话。',
-    '**两种 Key 互不通用**:Agent Key 只能调用 `/v1`;机器人与工作组使用 Bot API(`/bot/v1`)和单独签发的 Bot Key(`bk_…`),见"Bot API"。',
+    '**两种 Key 互不通用**:Agent Key 只能调用 `/v1`;机器人与工作组使用 Bot API(`/bot/v1`)和单独签发的 Bot Key(`bk_…`),见"Bot API";推送技能使用技能发布 API 与发布密钥(`sk_pub_…`),见"技能发布 API"。',
     '**ID 前缀**:会话 `ses_`、Run `run_`、交互 `itx_`、文件 `file_`、Profile `prf_`。',
     '**错误格式**:`{ "error": { "code": "session_busy", "message": "…", "details": {} } }`,错误码见最后一节。',
     '**跨域**:浏览器直接调用时,需在后台"设置 › 安全 › CORS 来源"中加入平台地址;请求不携带 Cookie。',
@@ -457,4 +459,8 @@ export const API_SPECS: ApiSpec[] = [
     id: 'bot', title: 'Bot API', summary: '`/bot/v1`:机器人与工作组;使用 Bot Key(`bk_…`)',
     overview: BOT_OVERVIEW, groups: BOT_GROUPS, eventEnvelope: 'TeamEvent', types: BOT_TYPES, events: BOT_EVENTS, errors: BOT_ERRORS,
   },
+  {
+    id: 'registry', title: '技能发布 API', summary: '`/skills/registry/v1`:项目推送技能,审核后生效;使用发布密钥(`sk_pub_…`)',
+    overview: REGISTRY_OVERVIEW, groups: REGISTRY_GROUPS, eventEnvelope: '', types: REGISTRY_TYPES, events: [], errors: REGISTRY_ERRORS,
+  },
 ]

+ 123 - 0
apps/server/src/api-reference/registry-reference.ts

@@ -0,0 +1,123 @@
+// 技能发布 API(/skills/registry/v1)协议参考(docs/design/18-skill-registry.md、ADR-0027)。
+// test/api-reference.test.ts 会核对这里列出的接口与实际注册的 /skills/registry/v1 路由完全一致,新增或删除接口时必须同步修改。
+
+import type { ApiEndpointGroup, ApiErrorDef, ApiOverview, ApiTypeDef } from '@agent-server/shared'
+
+export const REGISTRY_OVERVIEW: ApiOverview = {
+  points: [
+    '**用途**:项目把自己维护或生成的技能推送到平台,成为自定义技能(两个引擎共用,在 Profile 中勾选后使用)。推送**不会直接改变任何技能**:每个技能都要由管理员审核,批准为新技能或覆盖已有技能后才生效。',
+    '**基础地址**:`$BASE`,所有接口位于 `/skills/registry/v1` 下;响应为 JSON(UTF-8),时间为 ISO 8601。',
+    '**鉴权**:`Authorization: Bearer <发布密钥>`。发布密钥(`sk_pub_…`)在后台"技能与插件 › 自定义技能 › 发布密钥"中创建,**名称表示推送的来源**(例如"fwh-app · GitHub Actions"),有有效期。只能调用本组接口,与 Agent Key、Bot Key 互不通用。',
+    '**密钥保管**:存为 CI 的加密变量,或本机用户目录配置中的环境变量(如 `AGENT_SKILLS_TOKEN`),不要提交到代码仓库。密钥只能产生待审核的提交,泄露时在后台停用或删除即可。',
+    '**技能包**:zip,可以是单个技能(根目录有 `SKILL.md`)、技能集合(`<名称>/SKILL.md`)或插件格式(`skills/<名称>/SKILL.md`);根目录只有一个文件夹时会先进入它。技能名取 SKILL.md 中的 `name`(缺省用目录名),只能用小写字母、数字和连字符。只取普通文件,符号链接与不安全的路径会被跳过。',
+    '**限额**:技能包 ≤ 50 MB;单个技能 ≤ 500 个文件、≤ 20 MB,单个文件 ≤ 10 MB;每个密钥每小时最多推送 30 次,待审核的技能最多 50 个。',
+    '**可见范围**:推送方只能看到用自己的密钥提交的内容,看不到其他密钥的提交,也不能下载平台上的技能。',
+    '**ID 前缀**:提交 `sub_`、提交项 `sbi_`。',
+    '**错误格式**:`{ "error": { "code": "invalid_key", "message": "…" } }`,错误码见最后一节。',
+  ],
+  flow: [
+    '(可选)`POST /skills/registry/v1/submissions?dryRun=1` 只做校验,在 CI 中先检查技能包;',
+    '`POST /skills/registry/v1/submissions` 推送技能包,附提交说明与来源信息,返回每个技能的状态(待审核 / 无变化 / 不能导入);',
+    '管理员在后台审核:批准为新技能(可以改名)、覆盖已有技能,或拒绝并填写原因;',
+    '`GET /skills/registry/v1/submissions/:id` 查询结果:批准后可以看到生成的技能名与版本号,拒绝时可以看到原因;',
+    '需要修改时重新推送:同一密钥推送的同名技能会替代之前仍在待审核的那一份。',
+  ],
+}
+
+export const REGISTRY_GROUPS: ApiEndpointGroup[] = [
+  {
+    id: 'submissions',
+    title: '推送与查询',
+    endpoints: [
+      {
+        method: 'POST', path: '/skills/registry/v1/submissions', summary: '推送技能包',
+        desc: '每个技能单独判定:校验不通过为 `invalid`(附原因),与同名技能当前版本完全相同为 `unchanged`,其余为 `pending`(附自动检查结果,等待管理员审核)。同一密钥之前推送的同名技能如果仍在待审核,会变为 `superseded`。`dryRun=1` 时只校验、不保存(返回 200,`id` 为空)。',
+        query: [{ name: 'dryRun', type: '"1"', desc: '只校验,不生成提交' }],
+        bodyNote: '`multipart/form-data`:`file`(必填,zip 技能包)、`message`(提交说明,可选,≤ 2000 字)、`meta`(来源信息,可选,JSON 对象,如 `{"repo":"fwh-app","branch":"main","commit":"abc123"}`,最多 20 项)。',
+        response: { status: 201, type: 'RegistrySubmission', desc: '试运行时为 200' },
+        errors: [
+          '400 bad_request:不是有效的 zip、没有找到技能、解压后超过大小上限、meta 不是 JSON 对象',
+          '401 invalid_key:密钥无效、已停用或已过期',
+          '413 file_too_large:技能包超过 50 MB',
+          '429 rate_limited:超过每小时推送次数', '429 too_many_pending:待审核的技能过多',
+        ],
+        example: `cd skills && zip -qr /tmp/skills.zip . && \\
+curl -X POST $BASE/skills/registry/v1/submissions \\
+  -H "Authorization: Bearer $AGENT_SKILLS_TOKEN" \\
+  -F file=@/tmp/skills.zip -F message="$(git log -1 --pretty=%s)" \\
+  -F meta="{\\"repo\\":\\"fwh-app\\",\\"commit\\":\\"$(git rev-parse HEAD)\\"}"
+# → 201 {"id":"sub_…","status":"pending","items":[{"id":"sbi_…","name":"fwh-assistant","status":"pending",…}],…}`,
+      },
+      {
+        method: 'GET', path: '/skills/registry/v1/submissions', summary: '我的提交',
+        desc: '用当前密钥推送的提交,按时间倒序。',
+        query: [
+          { name: 'status', type: '"pending" | "done"', desc: '只看有待审核技能的提交 / 已全部处理的提交' },
+          { name: 'limit', type: 'number', desc: '1 ~ 100,默认 20' },
+        ],
+        response: { status: 200, type: 'RegistrySubmission[]' },
+      },
+      {
+        method: 'GET', path: '/skills/registry/v1/submissions/:id', summary: '提交详情(审核结果)',
+        desc: '每个技能的状态、自动检查结果、审核意见;批准后 `result` 中有生成的技能名与版本号。',
+        response: { status: 200, type: 'RegistrySubmission' },
+        errors: ['404 not_found:提交不存在,或不是用当前密钥推送的'],
+      },
+      {
+        method: 'DELETE', path: '/skills/registry/v1/submissions/:id', summary: '撤回',
+        desc: '撤回提交中仍在待审核的技能(变为 `withdrawn`);已处理的不受影响。',
+        response: { status: 200, type: 'RegistrySubmission' },
+        errors: ['404 not_found:提交不存在,或不是用当前密钥推送的'],
+      },
+    ],
+  },
+]
+
+export const REGISTRY_TYPES: ApiTypeDef[] = [
+  {
+    name: 'RegistrySubmission', desc: '一次推送', fields: [
+      { name: 'id', type: 'string', desc: 'sub_ 开头;试运行时为空字符串' },
+      { name: 'status', type: '"pending" | "done"', desc: '还有待审核的技能 / 全部已处理' },
+      { name: 'message', type: 'string', desc: '提交说明' },
+      { name: 'meta', type: 'Record<string, string>', desc: '来源信息' },
+      { name: 'createdAt', type: 'string', desc: '' },
+      { name: 'dryRun', type: 'boolean', desc: '是否为试运行' },
+      { name: 'items', type: 'RegistryItem[]', desc: '包中的每个技能' },
+      { name: 'skipped', type: 'string[]', desc: '推送时跳过的条目(符号链接、不安全的路径等),只在推送的响应中返回' },
+    ],
+  },
+  {
+    name: 'RegistryItem', desc: '提交中的一个技能(审核的单位)', fields: [
+      { name: 'id', type: 'string', desc: 'sbi_ 开头' },
+      { name: 'name', type: 'string', desc: '技能名(SKILL.md 中的 name)' },
+      { name: 'status', type: '"pending" | "approved" | "rejected" | "withdrawn" | "superseded" | "invalid" | "unchanged"', desc: '待审核 / 已批准 / 已拒绝 / 已撤回 / 被同一密钥更新的推送替代 / 校验不通过 / 与同名技能当前版本相同' },
+      { name: 'reason', type: 'string | null', desc: '`invalid` 的原因' },
+      { name: 'declaredVersion', type: 'string | null', desc: 'SKILL.md frontmatter 中的 `version` 或 `metadata.version`' },
+      { name: 'existing', type: '{ version, declaredVersion } | null', desc: '平台上的同名技能:最新版本号与声明的版本号' },
+      { name: 'changes', type: '{ added, modified, removed } | null', desc: '待审核时与同名技能当前版本相比变化的文件(没有同名技能时全部为新增)' },
+      { name: 'checks', type: 'SkillCheck[]', desc: '自动检查结果(辅助审核,不会自动拒绝)' },
+      { name: 'reviewNote', type: 'string | null', desc: '拒绝的原因' },
+      { name: 'result', type: '{ action, skill, version, published } | null', desc: '批准后:`action` 为 `create`(新建技能)或 `overwrite`(覆盖已有技能);`skill` 为技能名(管理员可能改名);`version` 为生成的版本号;`published` 是否已发布生效' },
+    ],
+  },
+  {
+    name: 'SkillCheck', fields: [
+      { name: 'level', type: '"info" | "warning" | "danger"', desc: '' },
+      { name: 'code', type: '"script" | "secret" | "url" | "binary" | "version" | "skill_md"', desc: '脚本文件 / 疑似密钥 / 外部地址 / 二进制文件 / 声明版本号 / SKILL.md 提示' },
+      { name: 'message', type: 'string', desc: '' },
+      { name: 'files', type: 'string[]', desc: '涉及的文件(可选)' },
+    ],
+  },
+]
+
+export const REGISTRY_ERRORS: ApiErrorDef[] = [
+  { status: 400, code: 'bad_request', desc: '请求不正确(不是有效的 zip、没有找到技能、超过大小上限等)' },
+  { status: 401, code: 'unauthorized', desc: '缺少发布密钥' },
+  { status: 401, code: 'invalid_key', desc: '发布密钥无效、已停用或已过期' },
+  { status: 404, code: 'not_found', desc: '提交不存在,或不是用当前密钥推送的' },
+  { status: 409, code: 'package_changed', desc: '技能包在处理过程中发生了变化,请重新推送' },
+  { status: 413, code: 'file_too_large', desc: '技能包超过 50 MB' },
+  { status: 429, code: 'rate_limited', desc: '超过每个密钥每小时的推送次数' },
+  { status: 429, code: 'too_many_pending', desc: '待审核的技能过多,请等管理员处理后再推送' },
+  { status: 500, code: 'internal_error', desc: '服务器内部错误' },
+]

+ 3 - 2
apps/server/src/api-reference/render.ts

@@ -47,7 +47,7 @@ function sections(spec: ApiSpec) {
   return [
     ['overview', '概述'],
     ...spec.groups.map(g => [g.id, g.title]),
-    ['event-types', '事件类型'],
+    ...(spec.events.length ? [['event-types', '事件类型']] : []),
     ['types', '数据结构'],
     ['errors', '错误码'],
   ].map(([id, title], k) => ({ id: `${spec.id}-${id}`, title: title!, no: k + 1 }))
@@ -69,11 +69,12 @@ ${spec.groups.map(g => `<h2 id="${spec.id}-${g.id}">${no(g.id)} ${esc(g.title)}<
 ${g.desc ? `<p class="group-desc">${inline(g.desc)}</p>` : ''}
 ${g.endpoints.map(e => endpoint(e, base)).join('\n')}`).join('\n')}
 
-<h2 id="${spec.id}-event-types">${no('event-types')} 事件类型</h2>
+${spec.events.length ? `<h2 id="${spec.id}-event-types">${no('event-types')} 事件类型</h2>
 <p>事件信封见"数据结构 › ${esc(spec.eventEnvelope)}"。"落库"为否的事件只在实时事件流中推送,不出现在历史事件中。</p>
 <table><thead><tr><th>type</th><th>payload</th><th>说明</th><th>落库</th></tr></thead><tbody>
 ${spec.events.map(e => `<tr><td><code>${esc(e.type)}</code></td><td><code class="type">${esc(e.payload)}</code></td><td>${inline(e.desc)}</td><td>${e.persisted ? '是' : '<span class="no">否</span>'}</td></tr>`).join('\n')}
 </tbody></table>
+` : ''}
 
 <h2 id="${spec.id}-types">${no('types')} 数据结构</h2>
 ${spec.types.map(t => `<h3 id="${spec.id}-type-${slug(t.name)}"><code class="path">${esc(t.name)}</code></h3>

+ 3 - 3
apps/server/src/app.ts

@@ -28,7 +28,7 @@ import { McpServerService } from './services/mcp-servers'
 import { McpOAuthService } from './services/mcp-oauth'
 import { McpContentService } from './services/mcp-content'
 import { CustomSkillService } from './custom-skills/service'
-import { SkillSourceService } from './custom-skills/sources'
+import { SkillRegistryService } from './custom-skills/registry'
 import { FileManagerService } from './services/file-manager'
 import { BrowserTokenService } from './services/browser-tokens'
 import { BotKeyService, BotService } from './teams/bots'
@@ -71,7 +71,7 @@ export function createApp(log: AppLogger, opts: { db?: DB; providers?: Partial<R
   const customSkills = new CustomSkillService(db, adminAudit)
   // 按数据库重建生效目录(缺失或被改动的技能目录重新发布)
   customSkills.reconcile(msg => log.warn({}, msg))
-  const skillSources = new SkillSourceService(db, customSkills, () => settings.get(), adminAudit)
+  const skillRegistry = new SkillRegistryService(db, customSkills, adminAudit)
   const fileManager = new FileManagerService(db, () => settings.get(), () => knowledge.list(), files, adminAudit)
   const interactions = new InteractionService(db, events, settings)
   const sessions = new SessionService(db, settings, profiles, apiKeys, files, events)
@@ -110,7 +110,7 @@ export function createApp(log: AppLogger, opts: { db?: DB; providers?: Partial<R
   mcpUpstream.onElicit = e => elicitation.handle(e)
   const schedules = new ScheduleService({ db, log, settings, sessions, runs, events, teams, bots, profiles })
 
-  return { db, log, settings, events, files, profiles, knowledge, apiKeys, audit, interactions, sessions, usage, browserTokens, apiPdf, mcpUpstream, mcpServers, mcpOAuth, mcpContent, customSkills, skillSources, fileManager, adminAuth, plugins, claudeLogin, codexSkills, communitySkills, codexPlugins, gatewayTokens, providers, runs, bots, botKeys, teams, teamCards, teamCases, schedules, backgroundCalls, mcpTasks, elicitation, skillCatalog }
+  return { db, log, settings, events, files, profiles, knowledge, apiKeys, audit, interactions, sessions, usage, browserTokens, apiPdf, mcpUpstream, mcpServers, mcpOAuth, mcpContent, customSkills, skillRegistry, fileManager, adminAuth, plugins, claudeLogin, codexSkills, communitySkills, codexPlugins, gatewayTokens, providers, runs, bots, botKeys, teams, teamCards, teamCases, schedules, backgroundCalls, mcpTasks, elicitation, skillCatalog }
 }
 
 export type App = ReturnType<typeof createApp>

+ 0 - 1
apps/server/src/config.ts

@@ -53,7 +53,6 @@ export const communitySkillsDir = () => path.join(managedSkillsDir(), 'community
 /** 自定义技能:生效目录(Claude 插件格式,两个引擎共用)、按内容寻址的文件库、上传的 zip 解压目录(docs/design/12-custom-skills.md) */
 export const customSkillsDir = () => path.join(managedSkillsDir(), 'custom')
 export const customSkillStoreDir = () => path.join(config.dataDir, 'data', 'custom-skill-store')
-export const skillUploadsDir = () => path.join(config.dataDir, 'data', 'skill-uploads')
 /** 回归用例的附件(docs/design/13-bot-teams.md §12.2) */
 export const teamCasesDir = () => path.join(config.dataDir, 'data', 'team-cases')
 

+ 148 - 0
apps/server/src/custom-skills/package.ts

@@ -0,0 +1,148 @@
+// 技能包(docs/design/18-skill-registry.md):解压推送的 zip、识别其中的技能目录、读取并校验每个技能。
+// 只取普通文件,跳过符号链接;zip 按实际解压字节数限制大小,拒绝越出解压目录的路径
+import fs from 'node:fs'
+import path from 'node:path'
+import { Unzip, UnzipInflate } from 'fflate'
+import type { FileMap } from '../db/schema'
+import { badRequest } from '../lib/errors'
+import { isWithin } from '../lib/paths'
+import { declaredVersion, frontmatter } from '../lib/skill-files'
+import { checkSkillMd } from './service'
+import { checkSkillLimits, LIMITS, listRegularFiles, NAME_RE, normalizeRelPath, safeEntries, sha256, type ScannedFile } from './store'
+
+/** 包中的一个技能:invalid 时 reason 为原因 */
+export interface PackageSkill {
+  name: string
+  /** 在包中的相对路径 */
+  path: string
+  description: string
+  declaredVersion: string | null
+  files: ScannedFile[]
+  map: FileMap
+  warnings: string[]
+  reason: string | null
+}
+
+/** 读取一个技能目录:名称取 SKILL.md 的 name(缺省用目录名),校验路径、大小与 SKILL.md */
+export function readSkillDir(d: { rel: string; abs: string }, skipped: string[]): PackageSkill {
+  const local: string[] = []
+  const files = listRegularFiles(d.abs, local)
+  skipped.push(...local.map(x => `${d.rel === '.' ? '' : `${d.rel}/`}${x}`))
+  const md = files.find(f => f.rel === 'SKILL.md')
+  const mdText = md ? fs.readFileSync(md.abs, 'utf8').slice(0, 20_000) : ''
+  const fm = frontmatter(mdText)
+  const name = fm.name && NAME_RE.test(fm.name) ? fm.name : path.basename(d.rel === '.' ? d.abs : d.rel)
+  const out: PackageSkill = { name, path: d.rel, description: fm.description ?? '', declaredVersion: declaredVersion(mdText), files, map: {}, warnings: [], reason: null }
+  const invalid = (reason: string) => ({ ...out, reason })
+  if (!NAME_RE.test(name)) return invalid(`技能名 ${name} 不符合要求(小写字母、数字、连字符,1-64 位)`)
+  for (const f of files) {
+    try {
+      normalizeRelPath(f.rel)
+    } catch {
+      return invalid(`文件路径不正确:${f.rel}`)
+    }
+    if (f.size > LIMITS.bytesPerFile) return invalid(`文件 ${f.rel} 超过 ${LIMITS.bytesPerFile / 1048576} MB`)
+  }
+  try {
+    checkSkillLimits(Object.fromEntries(files.map(f => [f.rel, { sha: '', size: f.size }])))
+    const c = checkSkillMd(name, md ? fs.readFileSync(md.abs) : undefined, false)
+    out.description = c.description
+    out.warnings.push(...c.warnings)
+  } catch (err) {
+    return invalid((err as Error).message)
+  }
+  for (const f of files) out.map[f.rel] = { sha: sha256(fs.readFileSync(f.abs)), size: f.size }
+  return out
+}
+
+/**
+ * 在来源中找技能目录:单个技能(根目录有 SKILL.md)、插件格式(skills/<name>/SKILL.md)或技能集合(<name>/SKILL.md)。
+ * 根目录只有一个子目录时(zip 常见的顶层目录)先进入它
+ */
+export function detectSkillDirs(root: string): { rel: string; abs: string }[] {
+  const has = (d: string) => fs.existsSync(path.join(d, 'SKILL.md')) && fs.lstatSync(path.join(d, 'SKILL.md')).isFile()
+  if (has(root)) return [{ rel: '.', abs: root }]
+  const found: { rel: string; abs: string }[] = []
+  for (const parent of ['skills', '.']) {
+    const dir = path.join(root, parent)
+    for (const e of safeEntries(dir)) {
+      if (!e.isDirectory() || e.name.startsWith('.')) continue
+      const abs = path.join(dir, e.name)
+      const rel = parent === '.' ? e.name : `${parent}/${e.name}`
+      if (has(abs) && !found.some(f => f.rel === rel)) found.push({ rel, abs })
+    }
+  }
+  if (!found.length) {
+    const entries = safeEntries(root).filter(e => !e.name.startsWith('.'))
+    if (entries.length === 1 && entries[0]!.isDirectory()) {
+      const only = entries[0]!.name
+      return detectSkillDirs(path.join(root, only)).map(d => ({ rel: d.rel === '.' ? only : `${only}/${d.rel}`, abs: d.abs }))
+    }
+  }
+  return found.sort((a, b) => a.rel.localeCompare(b.rel))
+}
+
+/**
+ * 解压 zip:按实际解压出的字节数计算大小(不信任文件头中的声明,防压缩炸弹);
+ * 路径不安全的条目跳过并返回;写入只发生在 dir 之内
+ */
+export function extractZip(data: Buffer, dir: string): string[] {
+  const skipped: string[] = []
+  const files = new Map<string, Uint8Array[]>()
+  let total = 0
+  let entries = 0
+  let error: Error | null = null
+  const unzip = new Unzip(file => {
+    if (error) return
+    if (file.name.endsWith('/')) return
+    if (++entries > LIMITS.zipEntries) {
+      error = badRequest(`zip 中的文件超过 ${LIMITS.zipEntries} 个`)
+      return
+    }
+    let rel: string
+    try {
+      rel = normalizeRelPath(file.name)
+    } catch {
+      skipped.push(`${file.name}(路径不安全)`)
+      return
+    }
+    if (rel.split('/').some(s => s === '__MACOSX')) return
+    const chunks: Uint8Array[] = []
+    files.set(rel, chunks)
+    file.ondata = (err, chunk, final) => {
+      if (err) {
+        error = badRequest(`zip 解压失败:${err.message}`)
+        return
+      }
+      total += chunk.length
+      if (total > LIMITS.unzipBytes) {
+        error = badRequest(`zip 解压后超过 ${LIMITS.unzipBytes / 1048576} MB`)
+        file.terminate()
+        return
+      }
+      chunks.push(chunk)
+      void final
+    }
+    file.start()
+  })
+  unzip.register(UnzipInflate)
+  try {
+    unzip.push(new Uint8Array(data), true)
+  } catch (err) {
+    throw badRequest(`不是有效的 zip 文件:${(err as Error).message}`)
+  }
+  if (error) throw error
+  if (!files.size) throw badRequest('zip 中没有文件')
+  fs.mkdirSync(dir, { recursive: true })
+  const root = path.resolve(dir)
+  for (const [rel, chunks] of files) {
+    const target = path.resolve(root, ...rel.split('/'))
+    if (!isWithin(target, root)) {
+      skipped.push(`${rel}(路径不安全)`)
+      continue
+    }
+    fs.mkdirSync(path.dirname(target), { recursive: true })
+    fs.writeFileSync(target, Buffer.concat(chunks))
+  }
+  return skipped
+}

+ 397 - 0
apps/server/src/custom-skills/registry.ts

@@ -0,0 +1,397 @@
+// 技能发布平台(docs/design/18-skill-registry.md):项目用发布密钥推送技能包,管理员审核后新建技能或覆盖已有技能。
+// 推送只产生待审核的提交项,不改变任何技能;提交项的文件清单在推送时存入文件库且不可修改,批准的就是审核时看到的内容
+import crypto from 'node:crypto'
+import fs from 'node:fs'
+import os from 'node:os'
+import path from 'node:path'
+import { and, desc, eq, gte, inArray } from 'drizzle-orm'
+import type {
+  RegistryItemDto, RegistrySubmissionDto, SkillApproveInput, SkillCheckDto, SkillPublishKeyDto, SkillPublishKeyInput, SkillSubmissionDiffDto,
+  SkillSubmissionItemDetailDto, SkillSubmissionItemDto, SkillSubmissionStatus,
+} from '@agent-server/shared'
+import type { DB } from '../db'
+import { skillPublishKeys, skillSubmissionItems, skillSubmissions, type FileMap, type SkillCheck } from '../db/schema'
+import { AppError, badRequest, conflict, notFound } from '../lib/errors'
+import { newId, nowIso } from '../lib/ids'
+import { hashKey } from '../services/catalog'
+import { detectSkillDirs, extractZip, readSkillDir, type PackageSkill } from './package'
+import type { CustomSkillService } from './service'
+import { changesBetween, isTextPath, LIMITS, mapDigest, NAME_RE, normalizeRelPath, sha256 } from './store'
+
+type KeyRow = typeof skillPublishKeys.$inferSelect
+type ItemRow = typeof skillSubmissionItems.$inferSelect
+type SubmissionRow = typeof skillSubmissions.$inferSelect
+
+export const PUBLISH_KEY_PREFIX = 'sk_pub_'
+
+/** 每个密钥每小时的推送次数、待审核的提交项数 */
+export const REGISTRY_LIMITS = { submissionsPerHour: 30, pendingPerKey: 50 }
+
+const SCRIPT_RE = /\.(sh|bash|zsh|py|js|mjs|cjs|ts|rb|pl|ps1|bat|cmd|php)$/i
+const SECRET_PATTERNS: [RegExp, string][] = [
+  [/-----BEGIN [A-Z ]*PRIVATE KEY-----/, '私钥'],
+  [/\bAKIA[0-9A-Z]{16}\b/, 'AWS Access Key'],
+  [/\bsk-(?:ant-|proj-)?[A-Za-z0-9_-]{20,}/, 'API Key(sk-…)'],
+  [/\bgh[pousr]_[A-Za-z0-9]{30,}\b/, 'GitHub 令牌'],
+  [/\bxox[abpr]-[A-Za-z0-9-]{10,}/, 'Slack 令牌'],
+  [/\b(?:ak|bk|sk_pub)_[A-Za-z0-9_-]{24,}/, '本平台的密钥'],
+  [/\b[a-z][a-z0-9+.-]*:\/\/[^\s:/@]+:[^\s@/]{3,}@/i, '带密码的连接串'],
+  [/(?:password|passwd|secret|token|api[_-]?key)\s*[:=]\s*["']?[A-Za-z0-9_\-/+=]{12,}/i, '疑似密码或密钥赋值'],
+]
+const URL_RE = /\bhttps?:\/\/([a-z0-9.-]+\.[a-z]{2,}|\d{1,3}(?:\.\d{1,3}){3})(?::\d+)?/gi
+
+const DONE: SkillSubmissionStatus[] = ['approved', 'rejected', 'withdrawn', 'superseded', 'invalid', 'unchanged']
+
+export class SkillRegistryService {
+  constructor(
+    private db: DB,
+    private skills: CustomSkillService,
+    private audit: (summary: string, detail?: unknown) => void,
+  ) {}
+
+  // ---- 发布密钥 ----
+
+  listKeys(): SkillPublishKeyDto[] {
+    const counts = new Map<string, { submissions: number; pending: number }>()
+    const subs = new Map(this.db.select({ id: skillSubmissions.id, keyId: skillSubmissions.keyId }).from(skillSubmissions).all().map(s => [s.id, s.keyId]))
+    for (const s of subs.values()) counts.set(s, { submissions: (counts.get(s)?.submissions ?? 0) + 1, pending: counts.get(s)?.pending ?? 0 })
+    for (const i of this.db.select({ submissionId: skillSubmissionItems.submissionId }).from(skillSubmissionItems).where(eq(skillSubmissionItems.status, 'pending')).all()) {
+      const k = subs.get(i.submissionId)
+      if (k && counts.get(k)) counts.get(k)!.pending++
+    }
+    return this.db.select().from(skillPublishKeys).orderBy(desc(skillPublishKeys.createdAt)).all().map(r => ({
+      id: r.id, name: r.name, description: r.description, prefix: r.keyPrefix, enabled: r.enabled, expiresAt: r.expiresAt,
+      createdAt: r.createdAt, lastUsedAt: r.lastUsedAt, lastUsedIp: r.lastUsedIp, ...(counts.get(r.id) ?? { submissions: 0, pending: 0 }),
+    }))
+  }
+
+  /** 返回明文密钥,只在创建时出现一次 */
+  createKey(input: SkillPublishKeyInput): { key: string; publishKey: SkillPublishKeyDto } {
+    const name = input.name?.trim()
+    if (!name || name.length > 80) throw badRequest('请填写名称(不超过 80 个字),它表示推送的来源,例如"fwh-app · GitHub Actions"')
+    const days = input.expiresInDays
+    if (days !== null && !(Number.isInteger(days) && days >= 1 && days <= 3650)) throw badRequest('有效期应在 1 ~ 3650 天之间,或选择永久')
+    const key = `${PUBLISH_KEY_PREFIX}${crypto.randomBytes(24).toString('base64url')}`
+    const id = newId('spk')
+    this.db.insert(skillPublishKeys).values({
+      id, name, description: (input.description ?? '').slice(0, 500), keyHash: hashKey(key), keyPrefix: key.slice(0, 12),
+      expiresAt: days === null ? null : new Date(Date.now() + days * 86_400_000).toISOString(),
+    }).run()
+    this.audit(`新建技能发布密钥 ${name}(${key.slice(0, 12)}…)`)
+    return { key, publishKey: this.listKeys().find(k => k.id === id)! }
+  }
+
+  updateKey(id: string, input: { name?: string; description?: string; enabled?: boolean }): SkillPublishKeyDto {
+    const r = this.keyRow(id)
+    const name = input.name === undefined ? r.name : input.name.trim()
+    if (!name || name.length > 80) throw badRequest('请填写名称(不超过 80 个字)')
+    const enabled = input.enabled ?? r.enabled
+    this.db.update(skillPublishKeys).set({ name, description: (input.description ?? r.description).slice(0, 500), enabled }).where(eq(skillPublishKeys.id, id)).run()
+    if (enabled !== r.enabled) this.audit(`${enabled ? '启用' : '停用'}技能发布密钥 ${name}`)
+    else if (name !== r.name) this.audit(`技能发布密钥 ${r.name} 改名为 ${name}`)
+    return this.listKeys().find(k => k.id === id)!
+  }
+
+  /** 删除密钥:已批准的技能不受影响,待审核的提交项保留 */
+  deleteKey(id: string) {
+    const r = this.keyRow(id)
+    this.db.delete(skillPublishKeys).where(eq(skillPublishKeys.id, id)).run()
+    this.audit(`删除技能发布密钥 ${r.name}(${r.keyPrefix}…)`)
+  }
+
+  /** 发布接口鉴权:返回密钥(启用且未过期),并记录使用时间与来源 IP */
+  verify(key: string, ip: string): KeyRow | null {
+    if (!key.startsWith(PUBLISH_KEY_PREFIX)) return null
+    const r = this.db.select().from(skillPublishKeys).where(eq(skillPublishKeys.keyHash, hashKey(key))).get()
+    if (!r || !r.enabled || (r.expiresAt && Date.parse(r.expiresAt) <= Date.now())) return null
+    this.db.update(skillPublishKeys).set({ lastUsedAt: nowIso(), lastUsedIp: ip }).where(eq(skillPublishKeys.id, r.id)).run()
+    return r
+  }
+
+  // ---- 推送(发布接口) ----
+
+  /** 推送一个技能包;dryRun 只校验不保存 */
+  submit(key: KeyRow, pkg: { data: Buffer; message: string; meta: Record<string, string> }, dryRun: boolean): RegistrySubmissionDto {
+    if (pkg.data.length > LIMITS.zipBytes) throw badRequest(`技能包不能超过 ${LIMITS.zipBytes / 1048576} MB`)
+    const message = pkg.message.slice(0, 2000)
+    const meta = Object.fromEntries(Object.entries(pkg.meta).slice(0, 20).map(([k, v]) => [k.slice(0, 40), String(v).slice(0, 300)]))
+    if (!dryRun) {
+      const since = new Date(Date.now() - 3600_000).toISOString()
+      const recent = this.db.select({ id: skillSubmissions.id }).from(skillSubmissions).where(and(eq(skillSubmissions.keyId, key.id), gte(skillSubmissions.createdAt, since))).all().length
+      if (recent >= REGISTRY_LIMITS.submissionsPerHour) throw new AppError(429, 'rate_limited', `每个密钥每小时最多推送 ${REGISTRY_LIMITS.submissionsPerHour} 次,请稍后再试`)
+    }
+    const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'skill-push-'))
+    try {
+      const skipped = extractZip(pkg.data, tmp)
+      const dirs = detectSkillDirs(tmp)
+      if (!dirs.length) throw badRequest('技能包中没有找到技能(需要包含 SKILL.md 的目录:单个技能、<名称>/SKILL.md 或 skills/<名称>/SKILL.md)')
+      const seen = new Set<string>()
+      const evaluated = dirs.map(d => {
+        const sk = readSkillDir(d, skipped)
+        if (!sk.reason && seen.has(sk.name)) sk.reason = `技能包中有多个名为 ${sk.name} 的技能`
+        seen.add(sk.name)
+        return this.evaluate(sk)
+      })
+      const newPending = evaluated.filter(e => e.status === 'pending')
+      if (!dryRun && newPending.length) {
+        const replaced = new Set(newPending.map(e => e.skill.name))
+        const pending = this.pendingOfKey(key.id).filter(i => !replaced.has(i.name)).length
+        if (pending + newPending.length > REGISTRY_LIMITS.pendingPerKey) {
+          throw new AppError(429, 'too_many_pending', `待审核的技能最多 ${REGISTRY_LIMITS.pendingPerKey} 个,请等管理员处理后再推送`)
+        }
+      }
+      const now = nowIso()
+      if (dryRun) {
+        return {
+          id: '', status: newPending.length ? 'pending' : 'done', message, meta, createdAt: now, dryRun: true, skipped,
+          items: evaluated.map(e => this.toRegistryItem({ id: '', status: e.status, reason: e.skill.reason, name: e.skill.name, declaredVersion: e.skill.declaredVersion, checks: e.checks, reviewNote: null, result: null, files: e.skill.map } as ItemRow)),
+        }
+      }
+      // 文件存入文件库(按推送时的摘要核对)
+      for (const e of evaluated) {
+        if (e.skill.reason) continue
+        for (const f of e.skill.files) {
+          const stored = this.skills.store.put(fs.readFileSync(f.abs))
+          if (stored.sha !== e.skill.map[f.rel]!.sha) throw conflict('package_changed', '技能包在处理过程中发生了变化,请重新推送')
+        }
+      }
+      const id = newId('sub')
+      this.db.transaction(tx => {
+        tx.insert(skillSubmissions).values({ id, keyId: key.id, keyName: key.name, message, meta, packageSha: sha256(pkg.data), packageSize: pkg.data.length, createdAt: now }).run()
+        for (const e of evaluated) {
+          if (e.status === 'pending') {
+            // 同一密钥推送的同名技能只审核最新的一份
+            const old = this.pendingOfKey(key.id).filter(i => i.name === e.skill.name).map(i => i.id)
+            if (old.length) tx.update(skillSubmissionItems).set({ status: 'superseded', reviewedAt: now }).where(inArray(skillSubmissionItems.id, old)).run()
+          }
+          tx.insert(skillSubmissionItems).values({
+            id: newId('sbi'), submissionId: id, name: e.skill.name, description: e.skill.description, declaredVersion: e.skill.declaredVersion,
+            files: e.skill.reason ? {} : e.skill.map, checks: e.checks, status: e.status, reason: e.skill.reason, createdAt: now,
+          }).run()
+        }
+      })
+      this.audit(`推送技能包(${key.name},${(pkg.data.length / 1024).toFixed(1)} KB,sha256 ${sha256(pkg.data).slice(0, 16)}…):${evaluated.map(e => `${e.skill.name}(${STATUS_TEXT[e.status]})`).join('、')}`,
+        { submissionId: id, keyPrefix: key.keyPrefix, message, meta, skipped })
+      return { ...this.getSubmission(key.id, id), skipped }
+    } finally {
+      fs.rmSync(tmp, { recursive: true, force: true })
+    }
+  }
+
+  listSubmissions(keyId: string, opts: { status?: 'pending' | 'done'; limit?: number }): RegistrySubmissionDto[] {
+    const subs = this.db.select().from(skillSubmissions).where(eq(skillSubmissions.keyId, keyId)).orderBy(desc(skillSubmissions.createdAt)).all()
+    const out = subs.map(s => this.toSubmission(s)).filter(s => !opts.status || s.status === opts.status)
+    return out.slice(0, Math.min(Math.max(opts.limit ?? 20, 1), 100))
+  }
+
+  getSubmission(keyId: string, id: string): RegistrySubmissionDto {
+    const s = this.db.select().from(skillSubmissions).where(and(eq(skillSubmissions.id, id), eq(skillSubmissions.keyId, keyId))).get()
+    if (!s) throw notFound('提交')
+    return this.toSubmission(s)
+  }
+
+  /** 撤回提交中仍在待审核的技能 */
+  withdraw(keyId: string, id: string): RegistrySubmissionDto {
+    const s = this.getSubmission(keyId, id)
+    const ids = s.items.filter(i => i.status === 'pending').map(i => i.id)
+    if (ids.length) {
+      this.db.update(skillSubmissionItems).set({ status: 'withdrawn', reviewedAt: nowIso() }).where(inArray(skillSubmissionItems.id, ids)).run()
+      this.audit(`撤回推送 ${id}:${s.items.filter(i => ids.includes(i.id)).map(i => i.name).join('、')}`)
+    }
+    return this.getSubmission(keyId, id)
+  }
+
+  // ---- 审核(后台) ----
+
+  pendingCount(): number {
+    return this.db.select({ id: skillSubmissionItems.id }).from(skillSubmissionItems).where(eq(skillSubmissionItems.status, 'pending')).all().length
+  }
+
+  listItems(filter: 'pending' | 'done'): SkillSubmissionItemDto[] {
+    const rows = this.db.select().from(skillSubmissionItems)
+      .where(filter === 'pending' ? eq(skillSubmissionItems.status, 'pending') : inArray(skillSubmissionItems.status, DONE))
+      .orderBy(desc(skillSubmissionItems.createdAt)).all()
+    const subs = this.submissionsById(rows.map(r => r.submissionId))
+    return rows.map(r => this.toItem(r, subs.get(r.submissionId)!))
+  }
+
+  item(id: string): SkillSubmissionItemDetailDto {
+    const r = this.itemRow(id)
+    return {
+      ...this.toItem(r, this.submissionsById([r.submissionId]).get(r.submissionId)!),
+      files: Object.entries(r.files).map(([p, f]) => ({ path: p, size: f.size, sha: f.sha, text: isTextPath(p) }))
+        .sort((a, b) => Number(b.path === 'SKILL.md') - Number(a.path === 'SKILL.md') || a.path.localeCompare(b.path)),
+    }
+  }
+
+  readFile(id: string, rawPath: string): { data: Buffer; name: string; text: boolean } {
+    const r = this.itemRow(id)
+    const rel = normalizeRelPath(rawPath)
+    const f = r.files[rel]
+    if (!f) throw notFound(`文件 ${rel}`)
+    return { data: this.skills.store.get(f.sha), name: path.posix.basename(rel), text: isTextPath(rel) }
+  }
+
+  /** 差异:覆盖时与目标技能的最新版本对比;新建时与空技能对比。技能名与 SKILL.md 中的不同时按改写后的内容对比 */
+  diff(id: string, opts: { targetId?: string; name?: string }): SkillSubmissionDiffDto {
+    const r = this.itemRow(id)
+    const target = opts.targetId ? this.skills.latest(opts.targetId) : null
+    const name = target?.name ?? opts.name?.trim() ?? r.name
+    const files = this.withName(r.files, name)
+    return {
+      target: target && { id: target.id, name: target.name, version: target.version, declaredVersion: target.declaredVersion, unsaved: target.unsaved, updatedAt: target.updatedAt },
+      renamed: name !== r.name,
+      files: this.skills.diffFiles(target?.files ?? {}, files, target ? `v${target.version}` : '(空)', '提交'),
+    }
+  }
+
+  approve(id: string, input: SkillApproveInput): SkillSubmissionItemDto {
+    const r = this.itemRow(id)
+    if (r.status !== 'pending') throw conflict('not_pending', '这个提交项已经处理过了')
+    const sub = this.submissionsById([r.submissionId]).get(r.submissionId)!
+    const name = input.action === 'create' ? input.name.trim() : this.skills.latest(input.targetId).name
+    if (input.action === 'create' && !NAME_RE.test(name)) throw badRequest('技能名只能用小写字母、数字和连字符(1-64 位,以字母或数字开头)')
+    const res = this.skills.applySubmission(
+      input.action === 'create' ? { action: 'create', name } : { action: 'overwrite', id: input.targetId, expectedVersion: input.expectedVersion },
+      this.withName(r.files, name), { submissionId: sub.id, keyName: sub.keyName, message: sub.message }, input.publish,
+    )
+    const result = { action: input.action, skillId: res.id, skillName: res.name, version: res.version, published: input.publish }
+    this.db.update(skillSubmissionItems).set({ status: 'approved', result, reviewedAt: nowIso() }).where(eq(skillSubmissionItems.id, id)).run()
+    this.audit(`批准推送的技能 ${r.name}(${sub.keyName}):${input.action === 'create' ? `新建技能 ${res.name}` : `覆盖技能 ${res.name}`} v${res.version}${input.publish ? ',已发布' : ',暂不发布'}`, { itemId: id, submissionId: sub.id })
+    return this.toItem(this.itemRow(id), sub)
+  }
+
+  reject(id: string, reason: string): SkillSubmissionItemDto {
+    const r = this.itemRow(id)
+    if (r.status !== 'pending') throw conflict('not_pending', '这个提交项已经处理过了')
+    const note = reason.trim()
+    if (!note) throw badRequest('请填写拒绝的原因(推送方可以看到)')
+    this.db.update(skillSubmissionItems).set({ status: 'rejected', reviewNote: note.slice(0, 2000), reviewedAt: nowIso() }).where(eq(skillSubmissionItems.id, id)).run()
+    const sub = this.submissionsById([r.submissionId]).get(r.submissionId)!
+    this.audit(`拒绝推送的技能 ${r.name}(${sub.keyName}):${note}`, { itemId: id, submissionId: sub.id })
+    return this.toItem(this.itemRow(id), sub)
+  }
+
+  /** 删除已处理的提交项:不影响已生成的技能版本;提交中没有其他项时一并删除提交 */
+  deleteItem(id: string) {
+    const r = this.itemRow(id)
+    if (r.status === 'pending') throw conflict('pending', '待审核的提交项请先批准或拒绝')
+    this.db.delete(skillSubmissionItems).where(eq(skillSubmissionItems.id, id)).run()
+    if (!this.db.select({ id: skillSubmissionItems.id }).from(skillSubmissionItems).where(eq(skillSubmissionItems.submissionId, r.submissionId)).get()) {
+      this.db.delete(skillSubmissions).where(eq(skillSubmissions.id, r.submissionId)).run()
+    }
+    this.skills.gc()
+    this.audit(`删除技能提交记录 ${r.name}(${STATUS_TEXT[r.status]})`)
+  }
+
+  // ---- 内部 ----
+
+  /** 推送时判定:校验不通过、与同名技能当前版本相同,或待审核(附自动检查) */
+  private evaluate(sk: PackageSkill): { skill: PackageSkill; status: SkillSubmissionStatus; checks: SkillCheck[] } {
+    if (sk.reason) return { skill: sk, status: 'invalid', checks: [] }
+    const existing = this.skills.byName(sk.name)
+    const latest = existing ? this.skills.latest(existing.id) : null
+    if (latest && mapDigest(latest.files) === mapDigest(sk.map)) return { skill: sk, status: 'unchanged', checks: [] }
+    const checks: SkillCheck[] = sk.warnings.map(w => ({ level: 'warning' as const, code: 'skill_md' as const, message: w }))
+    const scripts = sk.files.filter(f => SCRIPT_RE.test(f.rel)).map(f => f.rel)
+    if (scripts.length) checks.push({ level: 'warning', code: 'script', message: `包含 ${scripts.length} 个脚本文件,请重点审核`, files: scripts })
+    const binaries = sk.files.filter(f => !isTextPath(f.rel)).map(f => f.rel)
+    if (binaries.length) checks.push({ level: 'info', code: 'binary', message: `包含 ${binaries.length} 个二进制文件`, files: binaries })
+    const secrets = new Map<string, string[]>()
+    const hosts = new Set<string>()
+    for (const f of sk.files) {
+      if (!isTextPath(f.rel) || f.size > 1048576) continue
+      const text = fs.readFileSync(f.abs, 'utf8')
+      for (const [re, label] of SECRET_PATTERNS) if (re.test(text)) secrets.set(label, [...(secrets.get(label) ?? []), f.rel])
+      for (const m of text.matchAll(URL_RE)) hosts.add(m[1]!.toLowerCase())
+    }
+    for (const [label, files] of secrets) checks.push({ level: 'danger', code: 'secret', message: `疑似包含${label}`, files })
+    if (hosts.size) checks.push({ level: 'info', code: 'url', message: `引用的外部地址:${[...hosts].slice(0, 20).join('、')}${hosts.size > 20 ? ` 等 ${hosts.size} 个` : ''}` })
+    if (latest && sk.declaredVersion && sk.declaredVersion === latest.declaredVersion) {
+      checks.push({ level: 'warning', code: 'version', message: `声明版本号仍为 ${sk.declaredVersion},但内容有变化` })
+    } else if (latest && (sk.declaredVersion || latest.declaredVersion)) {
+      checks.push({ level: 'info', code: 'version', message: `声明版本号:${latest.declaredVersion ?? '未声明'} → ${sk.declaredVersion ?? '未声明'}` })
+    }
+    return { skill: sk, status: 'pending', checks }
+  }
+
+  /** 技能名与 SKILL.md 中的 name 不同时,改写 SKILL.md 的 name */
+  private withName(files: FileMap, name: string): FileMap {
+    const md = files['SKILL.md']
+    if (!md) return files
+    const text = this.skills.store.get(md.sha).toString('utf8')
+    const next = renameSkillMd(text, name)
+    return next === text ? files : { ...files, 'SKILL.md': this.skills.store.put(next) }
+  }
+
+  private pendingOfKey(keyId: string): ItemRow[] {
+    const subs = this.db.select({ id: skillSubmissions.id }).from(skillSubmissions).where(eq(skillSubmissions.keyId, keyId)).all().map(s => s.id)
+    if (!subs.length) return []
+    return this.db.select().from(skillSubmissionItems).where(and(inArray(skillSubmissionItems.submissionId, subs), eq(skillSubmissionItems.status, 'pending'))).all()
+  }
+
+  private keyRow(id: string): KeyRow {
+    const r = this.db.select().from(skillPublishKeys).where(eq(skillPublishKeys.id, id)).get()
+    if (!r) throw notFound('发布密钥')
+    return r
+  }
+
+  private itemRow(id: string): ItemRow {
+    const r = this.db.select().from(skillSubmissionItems).where(eq(skillSubmissionItems.id, id)).get()
+    if (!r) throw notFound('提交项')
+    return r
+  }
+
+  private submissionsById(ids: string[]): Map<string, SubmissionRow> {
+    if (!ids.length) return new Map()
+    return new Map(this.db.select().from(skillSubmissions).where(inArray(skillSubmissions.id, [...new Set(ids)])).all().map(s => [s.id, s]))
+  }
+
+  private existingOf(name: string) {
+    const r = this.skills.byName(name)
+    if (!r) return null
+    const l = this.skills.latest(r.id)
+    return { id: r.id, version: l.version, declaredVersion: l.declaredVersion, files: l.files }
+  }
+
+  private toItem(r: ItemRow, s: SubmissionRow): SkillSubmissionItemDto {
+    const e = r.status === 'pending' ? this.existingOf(r.name) : null
+    const files = Object.values(r.files)
+    return {
+      id: r.id, submissionId: r.submissionId, name: r.name, description: r.description, declaredVersion: r.declaredVersion, status: r.status, reason: r.reason,
+      checks: r.checks, fileCount: files.length, size: files.reduce((n, f) => n + f.size, 0), keyName: s.keyName, message: s.message, meta: s.meta,
+      createdAt: r.createdAt, existing: e && { id: e.id, version: e.version, declaredVersion: e.declaredVersion }, reviewNote: r.reviewNote, reviewedAt: r.reviewedAt, result: r.result,
+    }
+  }
+
+  private toRegistryItem(r: ItemRow): RegistryItemDto {
+    const e = r.status === 'pending' || r.status === 'unchanged' || !r.id ? this.existingOf(r.name) : null
+    return {
+      id: r.id, name: r.name, status: r.status, reason: r.reason, declaredVersion: r.declaredVersion,
+      existing: e && { version: e.version, declaredVersion: e.declaredVersion },
+      changes: r.status === 'pending' ? changesBetween(e?.files ?? {}, r.files) : null,
+      checks: r.checks, reviewNote: r.reviewNote,
+      result: r.result && { action: r.result.action, skill: r.result.skillName, version: r.result.version, published: r.result.published },
+    }
+  }
+
+  private toSubmission(s: SubmissionRow): RegistrySubmissionDto {
+    const items = this.db.select().from(skillSubmissionItems).where(eq(skillSubmissionItems.submissionId, s.id)).all()
+    return {
+      id: s.id, status: items.some(i => i.status === 'pending') ? 'pending' : 'done', message: s.message, meta: s.meta, createdAt: s.createdAt, dryRun: false, skipped: [],
+      items: items.map(i => this.toRegistryItem(i)),
+    }
+  }
+}
+
+const STATUS_TEXT: Record<SkillSubmissionStatus, string> = {
+  pending: '待审核', approved: '已批准', rejected: '已拒绝', withdrawn: '已撤回', superseded: '已替代', invalid: '不能导入', unchanged: '无变化',
+}
+
+/** 改写 SKILL.md frontmatter 中的 name(没有 frontmatter 时补上) */
+export function renameSkillMd(md: string, name: string): string {
+  const m = /^(---\r?\n)([\s\S]*?)(\r?\n---)/.exec(md)
+  if (!m) return `---\nname: ${name}\n---\n${md}`
+  const body = /^name:.*$/m.test(m[2]!) ? m[2]!.replace(/^name:.*$/m, `name: ${name}`) : `name: ${name}\n${m[2]}`
+  return m[1] + body + m[3] + md.slice(m[0].length)
+}

+ 65 - 73
apps/server/src/custom-skills/service.ts

@@ -1,4 +1,4 @@
-// 自定义技能(docs/design/12-custom-skills.md):后台编写或从技能源导入,统一存为版本(文件清单 + 按内容寻址的文件库),
+// 自定义技能(docs/design/12-custom-skills.md):后台编写,或由项目推送、审核通过后生成(18-skill-registry.md),统一存为版本(文件清单 + 按内容寻址的文件库),
 // 发布时把指定版本整体写入 custom-skills/skills/<name>(Claude 插件格式,两个引擎共用,技能 ID 为 custom-skills:<name>)。
 // 运行时只读生效目录;启用、发布、停用都通过整体替换目录完成,运行中的对话不会读到一半新一半旧的文件
 import crypto from 'node:crypto'
@@ -12,8 +12,8 @@ import {
 } from '@agent-server/shared'
 import { customSkillsDir, customSkillStoreDir } from '../config'
 import type { DB } from '../db'
-import { customSkills, customSkillVersions, profiles, skillSources, type FileMap } from '../db/schema'
-import { AppError, badRequest, conflict, notFound } from '../lib/errors'
+import { customSkills, customSkillVersions, profiles, skillSubmissionItems, type FileMap, type SkillVersionNote } from '../db/schema'
+import { badRequest, conflict, notFound } from '../lib/errors'
 import { newId, nowIso } from '../lib/ids'
 import { declaredVersion, frontmatter } from '../lib/skill-files'
 import type { PluginRef } from '../providers/claude/skills'
@@ -103,15 +103,14 @@ export class CustomSkillService {
 
   list(): CustomSkillDto[] {
     const usage = this.usage()
-    const sources = this.sourceNames()
-    return this.db.select().from(customSkills).orderBy(customSkills.name).all().map(r => this.toDto(r, usage, sources))
+    return this.db.select().from(customSkills).orderBy(customSkills.name).all().map(r => this.toDto(r, usage))
   }
 
   detail(id: string): CustomSkillDetailDto {
     const r = this.row(id)
     const versions = this.db.select().from(customSkillVersions).where(eq(customSkillVersions.skillId, id)).orderBy(customSkillVersions.version).all()
     return {
-      ...this.toDto(r, this.usage(), this.sourceNames()),
+      ...this.toDto(r, this.usage()),
       files: fileList(r.workingFiles),
       versions: versions.map(toVersionDto).reverse(),
     }
@@ -136,7 +135,7 @@ export class CustomSkillService {
     const files: FileMap = { 'SKILL.md': this.store.put(skillTemplate(name, input.description.trim())) }
     const id = newId('csk')
     this.db.transaction(tx => {
-      tx.insert(customSkills).values({ id, name, description: input.description.trim(), origin: 'authored', latestVersion: 1, workingFiles: files }).run()
+      tx.insert(customSkills).values({ id, name, description: input.description.trim(), latestVersion: 1, workingFiles: files }).run()
       tx.insert(customSkillVersions).values({ skillId: id, version: 1, reason: 'create', files, changed: changesBetween({}, files) }).run()
     })
     this.audit(`新建自定义技能 ${name}`)
@@ -144,7 +143,7 @@ export class CustomSkillService {
   }
 
   writeFile(id: string, rawPath: string, data: Buffer): CustomSkillDetailDto {
-    const r = this.editable(id)
+    const r = this.row(id)
     const rel = normalizeRelPath(rawPath)
     if (data.length > LIMITS.bytesPerFile) throw badRequest(`单个文件不能超过 ${LIMITS.bytesPerFile / 1048576} MB`)
     const next = { ...r.workingFiles, [rel]: this.store.put(data) }
@@ -154,7 +153,7 @@ export class CustomSkillService {
   }
 
   deleteFile(id: string, rawPath: string): CustomSkillDetailDto {
-    const r = this.editable(id)
+    const r = this.row(id)
     const rel = normalizeRelPath(rawPath)
     if (rel === 'SKILL.md') throw badRequest('SKILL.md 不能删除')
     // 删除文件,或以该路径为前缀的整个目录
@@ -166,7 +165,7 @@ export class CustomSkillService {
 
   /** 重命名文件或目录 */
   move(id: string, rawFrom: string, rawTo: string): CustomSkillDetailDto {
-    const r = this.editable(id)
+    const r = this.row(id)
     const from = normalizeRelPath(rawFrom)
     const to = normalizeRelPath(rawTo)
     if (from === 'SKILL.md' || to === 'SKILL.md') throw badRequest('SKILL.md 不能重命名')
@@ -192,7 +191,7 @@ export class CustomSkillService {
 
   /** 把编辑中的改动保存为新版本(内容无变化时不产生新版本),可同时发布 */
   save(id: string, opts: { baseVersion: number; publish: boolean }): CustomSkillDetailDto {
-    const r = this.editable(id)
+    const r = this.row(id)
     if (opts.baseVersion !== r.latestVersion) throw conflict('version_conflict', `技能已更新到版本 ${r.latestVersion},请刷新后再保存`)
     const latest = this.version(id, r.latestVersion).files
     const changed = changesBetween(latest, r.workingFiles)
@@ -244,15 +243,6 @@ export class CustomSkillService {
     return this.detail(id)
   }
 
-  /** 导入的技能转为可编辑:之后与来源脱钩,不再同步 */
-  detach(id: string): CustomSkillDetailDto {
-    const r = this.row(id)
-    if (r.origin !== 'imported') throw badRequest('这个技能已经是可编辑的')
-    this.db.update(customSkills).set({ origin: 'authored', sourceId: null, updatedAt: nowIso() }).where(eq(customSkills.id, id)).run()
-    this.audit(`自定义技能 ${r.name} 转为可编辑(与来源脱钩)`)
-    return this.detail(id)
-  }
-
   delete(id: string) {
     const r = this.row(id)
     const sid = skillId(r.name)
@@ -270,45 +260,53 @@ export class CustomSkillService {
     this.audit(`删除自定义技能 ${r.name}`)
   }
 
-  // ---- 导入(由技能源调用) ----
+  // ---- 审核通过的推送(docs/design/18-skill-registry.md) ----
 
-  /** 导入一份文件清单:新技能直接创建;已从同一来源导入过的生成新版本。导入后立即发布 */
-  /** sourceId 为 null 表示上传的 zip:作为可编辑的技能导入,同名技能(不属于任何技能源)覆盖为新版本 */
-  importFiles(input: { name: string; files: FileMap; sourceId: string | null; description: string }): { id: string; version: number; created: boolean } {
-    checkSkillLimits(input.files)
-    const existing = this.db.select().from(customSkills).where(eq(customSkills.name, input.name)).get()
-    if (existing && existing.sourceId !== input.sourceId) throw badRequest(`已存在同名技能 ${input.name},且来源不同`)
-    if (!existing) {
-      this.checkCapacity(input.name)
-      const id = newId('csk')
-      this.db.transaction(tx => {
-        tx.insert(customSkills).values({
-          id, name: input.name, description: input.description, origin: input.sourceId ? 'imported' : 'authored', sourceId: input.sourceId, latestVersion: 1, workingFiles: input.files,
-        }).run()
-        tx.insert(customSkillVersions).values({ skillId: id, version: 1, reason: 'import', files: input.files, changed: changesBetween({}, input.files) }).run()
-      })
-      this.publish(id, 1)
-      return { id, version: 1, created: true }
-    }
-    const v = this.addVersion(existing, input.files, input.sourceId ? 'sync' : 'import', input.description)
-    this.setWorking(existing.id, input.files)
-    this.publish(existing.id, v)
-    return { id: existing.id, version: v, created: false }
+  /** 同名技能的当前状态(审核时对比用) */
+  byName(name: string): SkillRow | null {
+    return this.db.select().from(customSkills).where(eq(customSkills.name, name)).get() ?? null
   }
 
-  latestFiles(name: string): { files: FileMap; sourceId: string | null; sourceName: string | null; version: number; updatedAt: string; unsaved: boolean } | null {
-    const r = this.db.select().from(customSkills).where(eq(customSkills.name, name)).get()
-    if (!r) return null
-    const files = this.version(r.id, r.latestVersion).files
-    const sourceName = r.sourceId ? this.db.select({ name: skillSources.name }).from(skillSources).where(eq(skillSources.id, r.sourceId)).get()?.name ?? null : null
-    return { files, sourceId: r.sourceId, sourceName, version: r.latestVersion, updatedAt: r.updatedAt, unsaved: mapDigest(files) !== mapDigest(r.workingFiles) }
+  /** 技能的最新版本(审核时对比用):文件、SKILL.md 声明的版本号、是否有尚未保存的编辑 */
+  latest(id: string): { id: string; name: string; version: number; files: FileMap; declaredVersion: string | null; unsaved: boolean; updatedAt: string } {
+    const r = this.row(id)
+    const files = this.version(id, r.latestVersion).files
+    const md = files['SKILL.md']
+    return {
+      id, name: r.name, version: r.latestVersion, files, updatedAt: r.updatedAt, unsaved: mapDigest(files) !== mapDigest(r.workingFiles),
+      declaredVersion: md ? declaredVersion(this.store.get(md.sha).toString('utf8')) : null,
+    }
   }
 
-  /** 技能当前版本 SKILL.md 中声明的版本号(frontmatter 的 version 或 metadata.version) */
-  declaredVersion(name: string): string | null {
-    const r = this.db.select().from(customSkills).where(eq(customSkills.name, name)).get()
-    const md = r && this.version(r.id, r.latestVersion).files['SKILL.md']
-    return md ? declaredVersion(this.store.get(md.sha).toString('utf8')) : null
+  /** 审核通过:新建技能,或覆盖已有技能为新版本(expectedVersion 为审核时看到的版本);可同时发布 */
+  applySubmission(
+    target: { action: 'create'; name: string } | { action: 'overwrite'; id: string; expectedVersion: number },
+    files: FileMap, note: SkillVersionNote, publish: boolean,
+  ): { id: string; name: string; version: number } {
+    checkSkillLimits(files)
+    const name = target.action === 'create' ? target.name : this.row(target.id).name
+    const md = files['SKILL.md'] ? this.store.get(files['SKILL.md'].sha) : undefined
+    const { description } = checkSkillMd(name, md, true)
+    let id: string
+    let version: number
+    if (target.action === 'create') {
+      if (!NAME_RE.test(name)) throw badRequest('技能名只能用小写字母、数字和连字符(1-64 位,以字母或数字开头)')
+      this.checkCapacity(name)
+      id = newId('csk')
+      version = 1
+      this.db.transaction(tx => {
+        tx.insert(customSkills).values({ id, name, description, latestVersion: 1, workingFiles: files }).run()
+        tx.insert(customSkillVersions).values({ skillId: id, version: 1, reason: 'submission', files, changed: changesBetween({}, files), note }).run()
+      })
+    } else {
+      const r = this.row(target.id)
+      if (r.latestVersion !== target.expectedVersion) throw conflict('version_conflict', `技能 ${r.name} 已更新到版本 ${r.latestVersion},请刷新差异后再确认`)
+      id = r.id
+      version = this.addVersion(r, files, 'submission', description, note)
+      this.setWorking(id, files)
+    }
+    if (publish) this.publish(id, version)
+    return { id, name, version }
   }
 
   // ---- 版本对比与导出 ----
@@ -317,8 +315,11 @@ export class CustomSkillService {
   diff(id: string, from: number | 'working', to: number | 'working'): FileDiffDto[] {
     const r = this.row(id)
     const files = (v: number | 'working') => (v === 'working' ? r.workingFiles : this.version(id, v).files)
-    const a = files(from)
-    const b = files(to)
+    return this.diffFiles(files(from), files(to), String(from), String(to))
+  }
+
+  /** 两份文件清单的差异(文本文件给出 unified diff) */
+  diffFiles(a: FileMap, b: FileMap, from: string, to: string): FileDiffDto[] {
     const c = changesBetween(a, b)
     const text = (p: string, m: FileMap) => {
       const f = m[p]
@@ -326,7 +327,7 @@ export class CustomSkillService {
     }
     const patch = (p: string, oldText: string | null, newText: string | null) =>
       isTextPath(p) && (oldText !== null || newText !== null)
-        ? createTwoFilesPatch(`a/${p}`, `b/${p}`, oldText ?? '', newText ?? '', String(from), String(to), { context: 3 })
+        ? createTwoFilesPatch(`a/${p}`, `b/${p}`, oldText ?? '', newText ?? '', from, to, { context: 3 })
         : null
     return [
       ...c.added.map(p => ({ path: p, status: 'added' as const, binary: !isTextPath(p), patch: patch(p, null, text(p, b)) })),
@@ -391,9 +392,10 @@ export class CustomSkillService {
     }
   }
 
-  /** 删除不再被任何版本或编辑中改动引用的文件内容 */
+  /** 删除不再被任何版本、编辑中改动或推送的提交引用的文件内容 */
   gc(): number {
     const refs = new Set<string>()
+    for (const v of this.db.select({ files: skillSubmissionItems.files }).from(skillSubmissionItems).all()) for (const f of Object.values(v.files)) refs.add(f.sha)
     for (const v of this.db.select({ files: customSkillVersions.files }).from(customSkillVersions).all()) for (const f of Object.values(v.files)) refs.add(f.sha)
     for (const s of this.db.select({ files: customSkills.workingFiles }).from(customSkills).all()) for (const f of Object.values(s.files)) refs.add(f.sha)
     return this.store.gc(refs)
@@ -411,23 +413,17 @@ export class CustomSkillService {
     return r
   }
 
-  private editable(id: string): SkillRow {
-    const r = this.row(id)
-    if (r.origin === 'imported') throw new AppError(409, 'imported_skill', '导入的技能只读(与来源保持一致);需要修改时先"转为可编辑"')
-    return r
-  }
-
   private version(id: string, version: number): VersionRow {
     const v = this.db.select().from(customSkillVersions).where(and(eq(customSkillVersions.skillId, id), eq(customSkillVersions.version, version))).get()
     if (!v) throw notFound(`版本 ${version}`)
     return v
   }
 
-  private addVersion(r: SkillRow, files: FileMap, reason: Reason, description: string): number {
+  private addVersion(r: SkillRow, files: FileMap, reason: Reason, description: string, note: SkillVersionNote | null = null): number {
     const prev = this.version(r.id, r.latestVersion).files
     const version = r.latestVersion + 1
     this.db.transaction(tx => {
-      tx.insert(customSkillVersions).values({ skillId: r.id, version, reason, files, changed: changesBetween(prev, files) }).run()
+      tx.insert(customSkillVersions).values({ skillId: r.id, version, reason, files, changed: changesBetween(prev, files), note }).run()
       tx.update(customSkills).set({ latestVersion: version, description, updatedAt: nowIso() }).where(eq(customSkills.id, r.id)).run()
     })
     return version
@@ -479,15 +475,11 @@ export class CustomSkillService {
     return out
   }
 
-  private sourceNames(): Map<string, string> {
-    return new Map(this.db.select({ id: skillSources.id, name: skillSources.name }).from(skillSources).all().map(s => [s.id, s.name]))
-  }
-
-  private toDto(r: SkillRow, usage: Map<string, string[]>, sources: Map<string, string>): CustomSkillDto {
+  private toDto(r: SkillRow, usage: Map<string, string[]>): CustomSkillDto {
     const latest = this.db.select({ files: customSkillVersions.files }).from(customSkillVersions)
       .where(and(eq(customSkillVersions.skillId, r.id), eq(customSkillVersions.version, r.latestVersion))).get()
     return {
-      id: r.id, name: r.name, description: r.description, origin: r.origin, sourceId: r.sourceId, sourceName: r.sourceId ? sources.get(r.sourceId) ?? null : null,
+      id: r.id, name: r.name, description: r.description,
       enabled: r.enabled, includeInAll: r.includeInAll, publishedVersion: r.publishedVersion, latestVersion: r.latestVersion,
       hasUnsavedChanges: !!latest && mapDigest(latest.files) !== mapDigest(r.workingFiles),
       profileNames: usage.get(skillId(r.name)) ?? [], createdAt: r.createdAt, updatedAt: r.updatedAt,
@@ -502,7 +494,7 @@ function fileList(m: FileMap): CustomSkillFileDto[] {
 
 function toVersionDto(v: VersionRow): CustomSkillVersionDto {
   const files = Object.values(v.files)
-  return { version: v.version, createdAt: v.createdAt, reason: v.reason, changed: v.changed, fileCount: files.length, size: files.reduce((n, f) => n + f.size, 0) }
+  return { version: v.version, createdAt: v.createdAt, reason: v.reason, changed: v.changed, fileCount: files.length, size: files.reduce((n, f) => n + f.size, 0), note: v.note ?? null }
 }
 
 function readMarker(f: string): { version: number; digest: string } | null {

+ 0 - 485
apps/server/src/custom-skills/sources.ts

@@ -1,485 +0,0 @@
-// 技能源(docs/design/12-custom-skills.md §3.2 / FR-1):服务器目录;上传的 zip 是一次性导入(不登记为技能源,同名技能可以选择覆盖)。
-// 扫描 → 管理员审阅差异 → 按扫描摘要导入(摘要不一致时拒绝,避免导入未审阅的内容);自动同步定时扫描并导入有变化的技能。
-// 只复制普通文件,跳过符号链接;zip 按实际解压字节数限制大小,拒绝越出解压目录的路径
-import fs from 'node:fs'
-import path from 'node:path'
-import { eq } from 'drizzle-orm'
-import { Unzip, UnzipInflate } from 'fflate'
-import type { AppSettings, SkillScanDto, SkillScanItemDto, SkillSourceDto, ServerDirListDto, SkillSourceInput, SkillUploadDto } from '@agent-server/shared'
-import { config, skillUploadsDir } from '../config'
-import type { DB } from '../db'
-import { customSkills, skillSources, type FileMap } from '../db/schema'
-import { AppError, badRequest, notFound } from '../lib/errors'
-import { newId, nowIso } from '../lib/ids'
-import { declaredVersion, frontmatter } from '../lib/skill-files'
-import { expandPath, isWithin, realpathLoose } from '../lib/paths'
-import { globalDenyList, isCredentialPath } from '../services/access-policy'
-import { checkSkillMd, type CustomSkillService } from './service'
-import { changesBetween, checkSkillLimits, LIMITS, listRegularFiles, mapDigest, NAME_RE, normalizeRelPath, safeEntries, sha256, type ScannedFile } from './store'
-
-type SourceRow = typeof skillSources.$inferSelect
-
-/** 扫描对象:登记的服务器目录,或上传后等待确认的 zip(sourceId 为 null) */
-interface Target {
-  id: string
-  name: string
-  root: string
-  sourceId: string | null
-  skipped: string[]
-}
-
-interface Upload {
-  name: string
-  dir: string
-  skipped: string[]
-  createdAt: number
-}
-
-interface ScannedSkill {
-  item: SkillScanItemDto
-  files: ScannedFile[]
-  map: FileMap
-}
-
-/** 上传的 zip 等待确认导入的时间 */
-const UPLOAD_TTL_MS = 3600_000
-/** 选择目录时最多列出的子目录数 */
-const DIR_LIST_MAX = 500
-
-export class SkillSourceService {
-  private uploads = new Map<string, Upload>()
-  private timer: NodeJS.Timeout | null = null
-  private syncing = new Set<string>()
-
-  constructor(
-    private db: DB,
-    private skills: CustomSkillService,
-    private settings: () => AppSettings,
-    private audit: (summary: string, detail?: unknown) => void,
-  ) {}
-
-  /** 自动同步:每分钟检查一次到期的技能源,并清理过期的上传 */
-  start() {
-    this.migrateUploadSources()
-    fs.rmSync(skillUploadsDir(), { recursive: true, force: true })
-    this.timer = setInterval(() => {
-      this.sweepUploads()
-      void this.autoSyncDue().catch(() => {})
-    }, 60_000)
-    this.timer.unref()
-  }
-
-  stop() {
-    if (this.timer) clearInterval(this.timer)
-  }
-
-  list(): SkillSourceDto[] {
-    const bySource = new Map<string, string[]>()
-    for (const s of this.db.select({ name: customSkills.name, sourceId: customSkills.sourceId }).from(customSkills).all()) {
-      if (s.sourceId) bySource.set(s.sourceId, [...(bySource.get(s.sourceId) ?? []), s.name])
-    }
-    return this.db.select().from(skillSources).orderBy(skillSources.createdAt).all().filter(r => r.kind === 'directory').map(r => ({
-      id: r.id, name: r.name, description: r.description, kind: 'directory' as const, path: r.path,
-      syncMode: r.syncMode, intervalMin: r.intervalMin, lastSyncAt: r.lastSyncAt, status: r.status, lastError: r.lastError,
-      skillNames: (bySource.get(r.id) ?? []).sort(), createdAt: r.createdAt,
-    }))
-  }
-
-  create(input: SkillSourceInput): SkillSourceDto {
-    const data = this.validate(input)
-    const id = newId('src')
-    this.db.insert(skillSources).values({ id, kind: 'directory', ...data }).run()
-    this.audit(`登记技能源 ${data.name}(${data.path})`)
-    return this.get(id)
-  }
-
-  update(id: string, input: SkillSourceInput): SkillSourceDto {
-    this.row(id)
-    const data = this.validate(input)
-    this.db.update(skillSources).set({ ...data, updatedAt: nowIso() }).where(eq(skillSources.id, id)).run()
-    this.audit(`修改技能源 ${data.name}`)
-    return this.get(id)
-  }
-
-  /** 删除技能源:从它导入的技能保留,转为可编辑(与来源脱钩) */
-  delete(id: string) {
-    const r = this.row(id)
-    const imported = this.detachAll(id)
-    this.db.delete(skillSources).where(eq(skillSources.id, id)).run()
-    this.audit(`删除技能源 ${r.name}${imported.length ? `(${imported.join('、')} 转为可编辑)` : ''}`)
-  }
-
-  scan(id: string): SkillScanDto {
-    const r = this.row(id)
-    try {
-      return this.toDto(this.target(r), this.scanTarget(this.target(r)))
-    } catch (err) {
-      this.db.update(skillSources).set({ status: 'error', lastError: (err as Error).message, updatedAt: nowIso() }).where(eq(skillSources.id, id)).run()
-      throw err
-    }
-  }
-
-  /** 按扫描结果导入选中的技能;扫描摘要变化时拒绝 */
-  import(id: string, names: string[], digest: string): { imported: { name: string; version: number; created: boolean }[]; scan: SkillScanDto } {
-    const t = this.target(this.row(id))
-    const scanned = this.scanTarget(t)
-    if (this.digest(scanned) !== digest) throw new AppError(409, 'scan_changed', '来源内容在扫描后发生了变化,请重新扫描并确认')
-    const imported = this.importScanned(t, scanned.skills.filter(s => names.includes(s.item.name)), 'manual')
-    return { imported, scan: this.toDto(t, this.scanTarget(t)) }
-  }
-
-  /** 上传 zip:解压到数据目录等待确认,返回扫描结果;不登记为技能源 */
-  upload(filename: string, data: Buffer): SkillUploadDto {
-    if (data.length > LIMITS.zipBytes) throw badRequest(`zip 文件不能超过 ${LIMITS.zipBytes / 1048576} MB`)
-    this.sweepUploads()
-    const id = newId('upl')
-    const dir = path.join(skillUploadsDir(), id)
-    const skipped = extractZip(data, dir)
-    const name = path.basename(filename || 'upload.zip').slice(0, 100)
-    this.uploads.set(id, { name, dir, skipped, createdAt: Date.now() })
-    this.audit(`上传技能包 ${name}(${(data.length / 1024).toFixed(1)} KB,sha256 ${sha256(data).slice(0, 16)}…)${skipped.length ? `,跳过 ${skipped.length} 个不安全的条目` : ''}`, { skipped })
-    const t = this.uploadTarget(id)
-    return { uploadId: id, name, scan: this.toDto(t, this.scanTarget(t)) }
-  }
-
-  /** 导入上传的 zip 中选中的技能(新技能,或覆盖同名技能为新版本),完成后删除上传的文件 */
-  importUpload(id: string, names: string[], digest: string): { imported: { name: string; version: number; created: boolean }[] } {
-    const t = this.uploadTarget(id)
-    const scanned = this.scanTarget(t)
-    if (this.digest(scanned) !== digest) throw new AppError(409, 'scan_changed', '技能在审阅后发生了变化,请重新上传并确认')
-    const imported = this.importScanned(t, scanned.skills.filter(s => names.includes(s.item.name)), 'manual')
-    this.discardUpload(id)
-    return { imported }
-  }
-
-  /** 取消上传:删除解压的文件 */
-  discardUpload(id: string) {
-    const u = this.uploads.get(id)
-    if (!u) return
-    this.uploads.delete(id)
-    fs.rmSync(u.dir, { recursive: true, force: true })
-  }
-
-  /** 选择目录(技能源、资料库):列出服务进程看到的子目录(只列目录名,不列文件;登录凭证所在目录不列出);支持 ~ */
-  listDirs(raw: string): ServerDirListDto {
-    const p = raw.trim() || '/'
-    if (!path.isAbsolute(p) && p !== '~' && !p.startsWith('~/')) throw badRequest('路径必须是绝对路径')
-    const real = realpathLoose(expandPath(p))
-    if (!fs.existsSync(real) || !fs.statSync(real).isDirectory()) throw badRequest(`目录不存在或服务看不到:${p}`)
-    if (isCredentialPath(real)) throw badRequest('不能浏览登录凭证所在的目录')
-    let names: fs.Dirent[]
-    try {
-      names = fs.readdirSync(real, { withFileTypes: true })
-    } catch {
-      throw badRequest(`服务没有读取权限:${p}`)
-    }
-    const dirs: ServerDirListDto['dirs'] = []
-    for (const e of names) {
-      const abs = path.join(real, e.name)
-      const isDir = e.isDirectory() || (e.isSymbolicLink() && (() => {
-        try {
-          return fs.statSync(abs).isDirectory()
-        } catch {
-          return false
-        }
-      })())
-      if (!isDir || isCredentialPath(abs)) continue
-      dirs.push({ name: e.name, skill: fs.existsSync(path.join(abs, 'SKILL.md')) })
-    }
-    dirs.sort((a, b) => Number(a.name.startsWith('.')) - Number(b.name.startsWith('.')) || a.name.localeCompare(b.name))
-    let skillCount = 0
-    try {
-      skillCount = detectSkillDirs(real).length
-    } catch {}
-    return { path: real, parent: real === '/' ? null : path.dirname(real), dirs: dirs.slice(0, DIR_LIST_MAX), truncated: dirs.length > DIR_LIST_MAX, skillCount }
-  }
-
-  // ---- 内部 ----
-
-  private get(id: string): SkillSourceDto {
-    const s = this.list().find(x => x.id === id)
-    if (!s) throw notFound('技能源')
-    return s
-  }
-
-  private row(id: string): SourceRow {
-    const r = this.db.select().from(skillSources).where(eq(skillSources.id, id)).get()
-    if (!r || r.kind !== 'directory') throw notFound('技能源')
-    return r
-  }
-
-  private target(r: SourceRow): Target {
-    return { id: r.id, name: r.name, root: r.path, sourceId: r.id, skipped: [] }
-  }
-
-  private uploadTarget(id: string): Target {
-    const u = this.uploads.get(id)
-    if (!u) throw notFound('上传的技能包(已过期或已处理,请重新上传)')
-    return { id, name: u.name, root: u.dir, sourceId: null, skipped: u.skipped }
-  }
-
-  private detachAll(sourceId: string): string[] {
-    const imported = this.db.select().from(customSkills).where(eq(customSkills.sourceId, sourceId)).all()
-    for (const s of imported) this.db.update(customSkills).set({ origin: 'authored', sourceId: null, updatedAt: nowIso() }).where(eq(customSkills.id, s.id)).run()
-    return imported.map(s => s.name)
-  }
-
-  /** 早期版本把上传的 zip 登记为技能源:删除这些记录,从中导入的技能转为可编辑 */
-  private migrateUploadSources() {
-    for (const r of this.db.select().from(skillSources).where(eq(skillSources.kind, 'upload')).all()) {
-      this.detachAll(r.id)
-      this.db.delete(skillSources).where(eq(skillSources.id, r.id)).run()
-    }
-  }
-
-  private sweepUploads() {
-    for (const [id, u] of this.uploads) if (Date.now() - u.createdAt > UPLOAD_TTL_MS) this.discardUpload(id)
-  }
-
-  private validateMeta(input: SkillSourceInput) {
-    const name = input.name?.trim()
-    if (!name || name.length > 60) throw badRequest('请填写名称(不超过 60 个字)')
-    const intervalMin = Math.round(Number(input.intervalMin ?? 10))
-    if (!(intervalMin >= 1 && intervalMin <= 1440)) throw badRequest('同步间隔应在 1 ~ 1440 分钟之间')
-    return { name, description: (input.description ?? '').slice(0, 500), intervalMin }
-  }
-
-  /** 目录:存在、服务进程可读、不是服务数据目录或会话工作区、不命中全局禁止项 */
-  private validate(input: SkillSourceInput) {
-    const meta = this.validateMeta(input)
-    const raw = input.path?.trim() ?? ''
-    if (!path.isAbsolute(raw)) throw badRequest('路径必须是绝对路径')
-    const real = realpathLoose(raw)
-    if (!fs.existsSync(real)) {
-      const hint = /^\/(home|root)\//.test(real) ? '。服务以专用账号运行并启用了 ProtectHome,看不到 home 下的目录:请放到 home 以外,或为服务配置绑定挂载' : ''
-      throw badRequest(`目录不存在或服务看不到:${raw}${hint}`)
-    }
-    if (!fs.statSync(real).isDirectory()) throw badRequest(`不是目录:${raw}`)
-    try {
-      fs.accessSync(real, fs.constants.R_OK | fs.constants.X_OK)
-    } catch {
-      throw badRequest(`服务没有读取权限:${raw}`)
-    }
-    const s = this.settings()
-    for (const p of [config.dataDir, s.paths.workspaceRoot]) {
-      if (isWithin(real, realpathLoose(p)) || isWithin(realpathLoose(p), real)) throw badRequest('不能使用服务数据目录或会话工作区')
-    }
-    const hit = globalDenyList(s).find(d => !d.includes('*') && (isWithin(real, d) || isWithin(d, real)))
-    if (hit) throw badRequest(`路径与全局禁止项冲突:${hit}`)
-    return { ...meta, path: real, syncMode: input.syncMode === 'auto' ? 'auto' as const : 'manual' as const }
-  }
-
-  private scanTarget(t: Target): { skills: ScannedSkill[]; missing: string[]; skipped: string[] } {
-    if (!fs.existsSync(t.root)) throw badRequest(`来源目录不存在或服务看不到:${t.root}`)
-    const skipped = [...t.skipped]
-    const skills = detectSkillDirs(t.root).map(d => this.scanSkill(t, d, skipped))
-    const found = new Set(skills.map(s => s.item.name))
-    const missing = t.sourceId
-      ? this.db.select({ name: customSkills.name }).from(customSkills).where(eq(customSkills.sourceId, t.sourceId)).all().map(s => s.name).filter(n => !found.has(n)).sort()
-      : []
-    return { skills, missing, skipped }
-  }
-
-  private scanSkill(t: Target, d: { rel: string; abs: string }, skipped: string[]): ScannedSkill {
-    const local: string[] = []
-    const files = listRegularFiles(d.abs, local)
-    skipped.push(...local.map(x => `${d.rel === '.' ? '' : `${d.rel}/`}${x}`))
-    const map: FileMap = {}
-    const base = {
-      path: d.rel, fileCount: files.length, size: files.reduce((n, f) => n + f.size, 0), warnings: [] as string[], changes: { added: [], modified: [], removed: [] },
-      declaredVersion: null as string | null, existing: null as SkillScanItemDto['existing'],
-    }
-    const md = files.find(f => f.rel === 'SKILL.md')
-    const mdText = md ? fs.readFileSync(md.abs, 'utf8').slice(0, 20_000) : ''
-    const fm = frontmatter(mdText)
-    base.declaredVersion = declaredVersion(mdText)
-    const name = fm.name && NAME_RE.test(fm.name) ? fm.name : path.basename(d.rel === '.' ? d.abs : d.rel)
-    const invalid = (reason: string): ScannedSkill => ({ item: { ...base, name, description: fm.description ?? '', status: 'invalid', reason }, files, map })
-    if (!NAME_RE.test(name)) return invalid(`技能名 ${name} 不符合要求(小写字母、数字、连字符,1-64 位)`)
-    for (const f of files) {
-      try {
-        normalizeRelPath(f.rel)
-      } catch {
-        return invalid(`文件路径不正确:${f.rel}`)
-      }
-      if (f.size > LIMITS.bytesPerFile) return invalid(`文件 ${f.rel} 超过 ${LIMITS.bytesPerFile / 1048576} MB`)
-    }
-    try {
-      checkSkillLimits(Object.fromEntries(files.map(f => [f.rel, { sha: '', size: f.size }])))
-    } catch (err) {
-      return invalid((err as Error).message)
-    }
-    for (const f of files) map[f.rel] = { sha: sha256(fs.readFileSync(f.abs)), size: f.size }
-    let description = ''
-    try {
-      const c = checkSkillMd(name, md ? fs.readFileSync(md.abs) : undefined, false)
-      description = c.description
-      base.warnings.push(...c.warnings)
-    } catch (err) {
-      return invalid((err as Error).message)
-    }
-    const existing = this.skills.latestFiles(name)
-    if (existing) {
-      base.existing = { version: existing.version, declaredVersion: this.skills.declaredVersion(name), updatedAt: existing.updatedAt, unsaved: existing.unsaved, sourceName: existing.sourceName }
-    }
-    if (existing && existing.sourceId !== t.sourceId) {
-      const reason = t.sourceId
-        ? `已存在同名技能 ${name},且不是从这个来源导入的`
-        : `同名技能 ${name} 来自技能源「${existing.sourceName ?? '未知'}」,请在技能源中更新;也可以先在技能详情中"转为可编辑",再上传覆盖`
-      return { item: { ...base, name, description, status: 'conflict', reason }, files, map }
-    }
-    const changes = changesBetween(existing?.files ?? {}, map)
-    const status = !existing ? 'new' : mapDigest(existing.files) === mapDigest(map) ? 'unchanged' : 'changed'
-    if (status === 'changed' && existing?.unsaved && !t.sourceId) base.warnings.push('这个技能有编辑中、尚未保存为版本的改动,覆盖后这些改动会丢失')
-    return { item: { ...base, name, description, status, reason: null, changes }, files, map }
-  }
-
-  private digest(s: { skills: ScannedSkill[] }): string {
-    return sha256(JSON.stringify(s.skills.map(x => [x.item.name, x.item.status, mapDigest(x.map)]))).slice(0, 32)
-  }
-
-  private toDto(t: Target, s: { skills: ScannedSkill[]; missing: string[]; skipped: string[] }): SkillScanDto {
-    return { sourceId: t.id, digest: this.digest(s), skills: s.skills.map(x => x.item), missing: s.missing, skipped: s.skipped }
-  }
-
-  private importScanned(t: Target, selected: ScannedSkill[], mode: 'manual' | 'auto') {
-    const out: { name: string; version: number; created: boolean }[] = []
-    for (const s of selected) {
-      if (s.item.status !== 'new' && s.item.status !== 'changed') continue
-      // 按扫描时的摘要读取并核对内容
-      const map: FileMap = {}
-      for (const f of s.files) {
-        const stored = this.skills.store.put(fs.readFileSync(f.abs))
-        if (stored.sha !== s.map[f.rel]!.sha) throw new AppError(409, 'scan_changed', `${s.item.name}/${f.rel} 在导入过程中发生了变化,请重新扫描`)
-        map[f.rel] = stored
-      }
-      const res = this.skills.importFiles({ name: s.item.name, files: map, sourceId: t.sourceId, description: s.item.description })
-      out.push({ name: s.item.name, version: res.version, created: res.created })
-    }
-    if (t.sourceId) {
-      const now = nowIso()
-      this.db.update(skillSources).set({ lastSyncAt: now, status: 'ok', lastError: null, updatedAt: now }).where(eq(skillSources.id, t.sourceId)).run()
-    }
-    if (out.length) {
-      const what = t.sourceId ? `${mode === 'auto' ? '自动同步' : '导入'}技能源 ${t.name}` : `导入技能包 ${t.name}`
-      this.audit(`${what}:${out.map(x => `${x.name}${x.created ? '(新)' : t.sourceId ? '' : '(覆盖)'} v${x.version}`).join('、')}`,
-        selected.filter(s => out.some(o => o.name === s.item.name)).map(s => ({ name: s.item.name, changes: s.item.changes })))
-    }
-    return out
-  }
-
-  private async autoSyncDue() {
-    const now = Date.now()
-    for (const r of this.db.select().from(skillSources).all()) {
-      if (r.kind !== 'directory' || r.syncMode !== 'auto' || this.syncing.has(r.id)) continue
-      if (r.lastSyncAt && now - Date.parse(r.lastSyncAt) < r.intervalMin * 60_000) continue
-      this.syncing.add(r.id)
-      try {
-        const t = this.target(r)
-        // 自动同步只导入新增与变化的技能;来源中已删除的技能不自动删除;同名冲突跳过
-        this.importScanned(t, this.scanTarget(t).skills, 'auto')
-      } catch (err) {
-        this.db.update(skillSources).set({ lastSyncAt: nowIso(), status: 'error', lastError: (err as Error).message, updatedAt: nowIso() }).where(eq(skillSources.id, r.id)).run()
-      } finally {
-        this.syncing.delete(r.id)
-      }
-    }
-  }
-
-  /** 测试用:立即执行一次自动同步检查 */
-  runAutoSyncNow() {
-    return this.autoSyncDue()
-  }
-}
-
-/**
- * 在来源中找技能目录:单个技能(根目录有 SKILL.md)、插件格式(skills/<name>/SKILL.md)或技能集合(<name>/SKILL.md)。
- * 根目录只有一个子目录时(zip 常见的顶层目录)先进入它
- */
-export function detectSkillDirs(root: string): { rel: string; abs: string }[] {
-  const has = (d: string) => fs.existsSync(path.join(d, 'SKILL.md')) && fs.lstatSync(path.join(d, 'SKILL.md')).isFile()
-  if (has(root)) return [{ rel: '.', abs: root }]
-  const found: { rel: string; abs: string }[] = []
-  for (const parent of ['skills', '.']) {
-    const dir = path.join(root, parent)
-    for (const e of safeEntries(dir)) {
-      if (!e.isDirectory() || e.name.startsWith('.')) continue
-      const abs = path.join(dir, e.name)
-      const rel = parent === '.' ? e.name : `${parent}/${e.name}`
-      if (has(abs) && !found.some(f => f.rel === rel)) found.push({ rel, abs })
-    }
-  }
-  if (!found.length) {
-    const entries = safeEntries(root).filter(e => !e.name.startsWith('.'))
-    if (entries.length === 1 && entries[0]!.isDirectory()) {
-      const only = entries[0]!.name
-      return detectSkillDirs(path.join(root, only)).map(d => ({ rel: d.rel === '.' ? only : `${only}/${d.rel}`, abs: d.abs }))
-    }
-  }
-  return found.sort((a, b) => a.rel.localeCompare(b.rel))
-}
-
-/**
- * 解压 zip:按实际解压出的字节数计算大小(不信任文件头中的声明,防压缩炸弹);
- * 路径不安全的条目跳过并返回;写入只发生在 dir 之内
- */
-export function extractZip(data: Buffer, dir: string): string[] {
-  const skipped: string[] = []
-  const files = new Map<string, Uint8Array[]>()
-  let total = 0
-  let entries = 0
-  let error: Error | null = null
-  const unzip = new Unzip(file => {
-    if (error) return
-    if (file.name.endsWith('/')) return
-    if (++entries > LIMITS.zipEntries) {
-      error = badRequest(`zip 中的文件超过 ${LIMITS.zipEntries} 个`)
-      return
-    }
-    let rel: string
-    try {
-      rel = normalizeRelPath(file.name)
-    } catch {
-      skipped.push(`${file.name}(路径不安全)`)
-      return
-    }
-    if (rel.split('/').some(s => s === '__MACOSX')) return
-    const chunks: Uint8Array[] = []
-    files.set(rel, chunks)
-    file.ondata = (err, chunk, final) => {
-      if (err) {
-        error = badRequest(`zip 解压失败:${err.message}`)
-        return
-      }
-      total += chunk.length
-      if (total > LIMITS.unzipBytes) {
-        error = badRequest(`zip 解压后超过 ${LIMITS.unzipBytes / 1048576} MB`)
-        file.terminate()
-        return
-      }
-      chunks.push(chunk)
-      void final
-    }
-    file.start()
-  })
-  unzip.register(UnzipInflate)
-  try {
-    unzip.push(new Uint8Array(data), true)
-  } catch (err) {
-    throw badRequest(`不是有效的 zip 文件:${(err as Error).message}`)
-  }
-  if (error) throw error
-  if (!files.size) throw badRequest('zip 中没有文件')
-  fs.mkdirSync(dir, { recursive: true })
-  const root = path.resolve(dir)
-  for (const [rel, chunks] of files) {
-    const target = path.resolve(root, ...rel.split('/'))
-    if (!isWithin(target, root)) {
-      skipped.push(`${rel}(路径不安全)`)
-      continue
-    }
-    fs.mkdirSync(path.dirname(target), { recursive: true })
-    fs.writeFileSync(target, Buffer.concat(chunks))
-  }
-  return skipped
-}

+ 65 - 14
apps/server/src/db/schema.ts

@@ -254,8 +254,6 @@ export const customSkills = sqliteTable('custom_skills', {
   id: text('id').primaryKey(),
   name: text('name').notNull().unique(),
   description: text('description').notNull().default(''),
-  origin: text('origin', { enum: ['authored', 'imported'] }).notNull(),
-  sourceId: text('source_id'),
   enabled: integer('enabled', { mode: 'boolean' }).notNull().default(true),
   includeInAll: integer('include_in_all', { mode: 'boolean' }).notNull().default(false),
   publishedVersion: integer('published_version'),
@@ -269,29 +267,82 @@ export const customSkills = sqliteTable('custom_skills', {
 export const customSkillVersions = sqliteTable('custom_skill_versions', {
   skillId: text('skill_id').notNull(),
   version: integer('version').notNull(),
-  reason: text('reason', { enum: ['create', 'edit', 'import', 'sync', 'restore'] }).notNull(),
+  /** submission:审核通过的推送(docs/design/18-skill-registry.md);import / sync 为早期技能源导入的版本 */
+  reason: text('reason', { enum: ['create', 'edit', 'import', 'sync', 'restore', 'submission'] }).notNull(),
   files: text('files', { mode: 'json' }).$type<FileMap>().notNull(),
   changed: text('changed', { mode: 'json' }).$type<{ added: string[]; modified: string[]; removed: string[] }>().notNull(),
+  /** 来自推送时:提交说明等的副本(提交记录删除后仍可显示) */
+  note: text('note', { mode: 'json' }).$type<SkillVersionNote>(),
   createdAt: createdAt(),
 }, t => [primaryKey({ columns: [t.skillId, t.version] })])
 
-/** 技能源:服务器目录或上传的 zip(解压在数据目录中) */
-export const skillSources = sqliteTable('skill_sources', {
+export interface SkillVersionNote {
+  submissionId: string
+  keyName: string
+  message: string
+}
+
+/** 技能发布密钥(docs/design/18-skill-registry.md):名称即来源,只能推送待审核的技能 */
+export const skillPublishKeys = sqliteTable('skill_publish_keys', {
   id: text('id').primaryKey(),
   name: text('name').notNull(),
   description: text('description').notNull().default(''),
-  kind: text('kind', { enum: ['directory', 'upload'] }).notNull(),
-  path: text('path').notNull(),
-  syncMode: text('sync_mode', { enum: ['manual', 'auto'] }).notNull().default('manual'),
-  intervalMin: integer('interval_min').notNull().default(10),
-  lastSyncAt: text('last_sync_at'),
-  lastSyncDigest: text('last_sync_digest'),
-  status: text('status', { enum: ['never', 'ok', 'error'] }).notNull().default('never'),
-  lastError: text('last_error'),
+  keyHash: text('key_hash').notNull().unique(),
+  keyPrefix: text('key_prefix').notNull(),
+  enabled: integer('enabled', { mode: 'boolean' }).notNull().default(true),
+  expiresAt: text('expires_at'),
+  createdAt: createdAt(),
+  lastUsedAt: text('last_used_at'),
+  lastUsedIp: text('last_used_ip'),
+})
+
+/** 一次推送 */
+export const skillSubmissions = sqliteTable('skill_submissions', {
+  id: text('id').primaryKey(),
+  keyId: text('key_id').notNull(),
+  /** 密钥名称的副本(密钥删除后仍可显示) */
+  keyName: text('key_name').notNull(),
+  message: text('message').notNull().default(''),
+  meta: text('meta', { mode: 'json' }).$type<Record<string, string>>().notNull().default(sql`'{}'`),
+  packageSha: text('package_sha').notNull(),
+  packageSize: integer('package_size').notNull(),
   createdAt: createdAt(),
-  updatedAt: updatedAt(),
 })
 
+/** 推送中的一个技能:审核的单位 */
+export const skillSubmissionItems = sqliteTable('skill_submission_items', {
+  id: text('id').primaryKey(),
+  submissionId: text('submission_id').notNull(),
+  name: text('name').notNull(),
+  description: text('description').notNull().default(''),
+  declaredVersion: text('declared_version'),
+  /** 文件清单,内容在自定义技能的文件库中;invalid 时可能为空 */
+  files: text('files', { mode: 'json' }).$type<FileMap>().notNull(),
+  checks: text('checks', { mode: 'json' }).$type<SkillCheck[]>().notNull().default(sql`'[]'`),
+  status: text('status', { enum: ['pending', 'approved', 'rejected', 'withdrawn', 'superseded', 'invalid', 'unchanged'] }).notNull(),
+  /** invalid 的原因 */
+  reason: text('reason'),
+  reviewNote: text('review_note'),
+  reviewedAt: text('reviewed_at'),
+  result: text('result', { mode: 'json' }).$type<SkillSubmissionResult>(),
+  createdAt: createdAt(),
+})
+
+export interface SkillCheck {
+  level: 'info' | 'warning' | 'danger'
+  code: 'script' | 'secret' | 'url' | 'binary' | 'version' | 'skill_md'
+  message: string
+  files?: string[]
+}
+
+export interface SkillSubmissionResult {
+  action: 'create' | 'overwrite'
+  skillId: string
+  skillName: string
+  version: number
+  published: boolean
+}
+
 /** 机器人(docs/design/13-bot-teams.md):身份 + 关联的 Profile */
 export const bots = sqliteTable('bots', {
   id: text('id').primaryKey(),

+ 33 - 32
apps/server/src/http/routes/admin.ts

@@ -10,6 +10,7 @@ import { runClaudeCli } from '../../providers/claude/cli'
 import { CodexProvider } from '../../providers/codex/adapter'
 import { checkKnowledgePath, checkPathBasic, resolveAccess, toResolvedDto } from '../../services/access-policy'
 import { parse, type AuditKind, type KnowledgeInput } from '../../services/catalog'
+import { listServerDirs } from '../../services/server-dirs'
 import { defaultSettings } from '../../services/settings'
 import { ADMIN_COOKIE, isAdmin, requireAdmin } from '../auth'
 import { providerStatuses } from './v1'
@@ -317,46 +318,46 @@ export default async function adminRoutes(f: FastifyInstance, { app }: { app: Ap
       reply.header('Content-Disposition', `attachment; filename*=UTF-8''${encodeURIComponent(z_.name)}`)
       return reply.send(z_.data)
     })
-    g.post<SkillParams>('/custom-skills/:id/detach', async req => app.customSkills.detach(req.params.id))
 
-    // 技能源:服务器目录;上传的 zip 一次性导入
-    const sourceSchema = z.object({
-      name: z.string(), description: z.string().default(''), path: z.string().default(''),
-      syncMode: z.enum(['manual', 'auto']).default('manual'), intervalMin: z.number().default(10),
-    })
-    g.get('/skill-sources', async () => app.skillSources.list())
-    g.post('/skill-sources', async (req, reply) => {
+    // 技能发布平台(docs/design/18-skill-registry.md):发布密钥与审核
+    g.get('/skill-keys', async () => app.skillRegistry.listKeys())
+    g.post('/skill-keys', async (req, reply) => {
+      const b = parse(z.object({ name: z.string(), description: z.string().default(''), expiresInDays: z.number().int().nullable().default(365) }), req.body)
       reply.code(201)
-      return app.skillSources.create(parse(sourceSchema, req.body))
+      return app.skillRegistry.createKey(b)
     })
-    g.put<SkillParams>('/skill-sources/:id', async req => app.skillSources.update(req.params.id, parse(sourceSchema, req.body)))
-    g.delete<SkillParams>('/skill-sources/:id', async (req, reply) => {
-      app.skillSources.delete(req.params.id)
+    g.patch<SkillParams>('/skill-keys/:id', async req =>
+      app.skillRegistry.updateKey(req.params.id, parse(z.object({ name: z.string().optional(), description: z.string().optional(), enabled: z.boolean().optional() }), req.body)))
+    g.delete<SkillParams>('/skill-keys/:id', async (req, reply) => {
+      app.skillRegistry.deleteKey(req.params.id)
       reply.code(204)
     })
-    g.post<SkillParams>('/skill-sources/:id/scan', async req => app.skillSources.scan(req.params.id))
-    g.post<SkillParams>('/skill-sources/:id/import', async req => {
-      const b = parse(z.object({ skills: z.array(z.string()).min(1), digest: z.string() }), req.body)
-      return app.skillSources.import(req.params.id, b.skills, b.digest)
-    })
-    g.post('/skill-sources/upload', async (req, reply) => {
-      const part = await req.file({ limits: { fileSize: 50 * 1048576 } })
-      if (!part) throw badRequest('没有收到文件')
-      const data = await part.toBuffer()
-      if (part.file.truncated) throw new AppError(413, 'file_too_large', 'zip 文件不能超过 50 MB')
-      reply.code(201)
-      return app.skillSources.upload(part.filename, data)
-    })
-    g.post<SkillParams>('/skill-sources/uploads/:id/import', async req => {
-      const b = parse(z.object({ skills: z.array(z.string()).min(1), digest: z.string() }), req.body)
-      return app.skillSources.importUpload(req.params.id, b.skills, b.digest)
+    g.get('/skill-submissions/count', async () => ({ pending: app.skillRegistry.pendingCount() }))
+    g.get<{ Querystring: { status?: string } }>('/skill-submissions', async req => app.skillRegistry.listItems(req.query.status === 'done' ? 'done' : 'pending'))
+    g.get<SkillParams>('/skill-submissions/:id', async req => app.skillRegistry.item(req.params.id))
+    g.get<FileQuery>('/skill-submissions/:id/file', async (req, reply) => {
+      const f = app.skillRegistry.readFile(req.params.id, req.query.path ?? '')
+      reply.header('Content-Type', f.text ? 'text/plain; charset=utf-8' : 'application/octet-stream')
+      reply.header('Content-Disposition', `${f.text ? 'inline' : 'attachment'}; filename*=UTF-8''${encodeURIComponent(f.name)}`)
+      reply.header('X-Content-Type-Options', 'nosniff')
+      return reply.send(f.data)
     })
-    g.delete<SkillParams>('/skill-sources/uploads/:id', async (req, reply) => {
-      app.skillSources.discardUpload(req.params.id)
+    g.get<{ Params: { id: string }; Querystring: { target?: string; name?: string } }>('/skill-submissions/:id/diff', async req =>
+      app.skillRegistry.diff(req.params.id, { targetId: req.query.target || undefined, name: req.query.name || undefined }))
+    g.post<SkillParams>('/skill-submissions/:id/approve', async req => {
+      const b = parse(z.discriminatedUnion('action', [
+        z.object({ action: z.literal('create'), name: z.string(), publish: z.boolean().default(true) }),
+        z.object({ action: z.literal('overwrite'), targetId: z.string(), expectedVersion: z.number().int(), publish: z.boolean().default(true) }),
+      ]), req.body)
+      return app.skillRegistry.approve(req.params.id, b)
+    })
+    g.post<SkillParams>('/skill-submissions/:id/reject', async req => app.skillRegistry.reject(req.params.id, parse(z.object({ reason: z.string() }), req.body).reason))
+    g.delete<SkillParams>('/skill-submissions/:id', async (req, reply) => {
+      app.skillRegistry.deleteItem(req.params.id)
       reply.code(204)
     })
-    // 选择服务器目录(登记技能源、资料库时):只列子目录名
-    g.get<{ Querystring: { path?: string } }>('/server-dirs', async req => app.skillSources.listDirs(req.query.path ?? '/'))
+    // 选择服务器目录(登记资料库时):只列子目录名
+    g.get<{ Querystring: { path?: string } }>('/server-dirs', async req => listServerDirs(req.query.path ?? '/'))
 
     // 第三方技能(skills.sh 目录 → GitHub,两个引擎共用)
     g.get('/community-skills', async () => app.communitySkills.list())

+ 63 - 0
apps/server/src/http/routes/registry.ts

@@ -0,0 +1,63 @@
+// 技能发布接口(docs/design/18-skill-registry.md):/skills/registry/v1,只接受技能发布密钥(sk_pub_…)。
+// 推送只产生待审核的提交项;推送方只能看到用自己的密钥提交的内容
+import type { FastifyInstance, FastifyRequest } from 'fastify'
+import type { App } from '../../app'
+import { LIMITS } from '../../custom-skills/store'
+import { AppError, badRequest } from '../../lib/errors'
+
+/** 已注册的 /skills/registry/v1 路由(方法 + 路径),用于核对 API 协议文档是否完整(test/api-reference.test.ts) */
+export const registeredRegistryRoutes: { method: string; path: string }[] = []
+
+export default async function registryRoutes(f: FastifyInstance, { app }: { app: App }) {
+  f.addHook('onRoute', r => {
+    for (const m of [r.method].flat()) if (m !== 'HEAD' && m !== 'OPTIONS') registeredRegistryRoutes.push({ method: m, path: r.url })
+  })
+  const keyOf = (req: FastifyRequest) => {
+    const h = req.headers.authorization
+    const token = h?.startsWith('Bearer ') ? h.slice(7).trim() : ''
+    if (!token) throw new AppError(401, 'unauthorized', '缺少技能发布密钥(Authorization: Bearer sk_pub_…)')
+    const key = app.skillRegistry.verify(token, req.ip)
+    if (!key) throw new AppError(401, 'invalid_key', '技能发布密钥无效、已停用或已过期')
+    return key
+  }
+
+  f.post<{ Querystring: { dryRun?: string } }>('/submissions', async (req, reply) => {
+    const key = keyOf(req)
+    if (!req.isMultipart()) throw badRequest('请用 multipart/form-data 上传:file(zip)、message、meta(JSON)')
+    let data: Buffer | null = null
+    let message = ''
+    let meta: Record<string, string> = {}
+    for await (const part of req.parts({ limits: { fileSize: LIMITS.zipBytes } })) {
+      if (part.type === 'file') {
+        if (part.fieldname !== 'file') {
+          await part.toBuffer()
+          continue
+        }
+        data = await part.toBuffer()
+        if (part.file.truncated) throw new AppError(413, 'file_too_large', `技能包不能超过 ${LIMITS.zipBytes / 1048576} MB`)
+      } else if (part.fieldname === 'message') {
+        message = String(part.value ?? '')
+      } else if (part.fieldname === 'meta') {
+        try {
+          const v = JSON.parse(String(part.value || '{}')) as unknown
+          if (!v || typeof v !== 'object' || Array.isArray(v)) throw new Error()
+          meta = Object.fromEntries(Object.entries(v).map(([k, x]) => [k, typeof x === 'string' ? x : JSON.stringify(x)]))
+        } catch {
+          throw badRequest('meta 应为 JSON 对象,例如 {"repo":"fwh-app","commit":"abc123"}')
+        }
+      }
+    }
+    if (!data) throw badRequest('没有收到技能包(字段 file,zip 格式)')
+    const dryRun = req.query.dryRun === '1' || req.query.dryRun === 'true'
+    const r = app.skillRegistry.submit(key, { data, message, meta }, dryRun)
+    reply.code(dryRun ? 200 : 201)
+    return r
+  })
+  f.get<{ Querystring: { status?: string; limit?: string } }>('/submissions', async req => {
+    const key = keyOf(req)
+    const status = req.query.status === 'pending' || req.query.status === 'done' ? req.query.status : undefined
+    return app.skillRegistry.listSubmissions(key.id, { status, limit: req.query.limit ? Number(req.query.limit) : undefined })
+  })
+  f.get<{ Params: { id: string } }>('/submissions/:id', async req => app.skillRegistry.getSubmission(keyOf(req).id, req.params.id))
+  f.delete<{ Params: { id: string } }>('/submissions/:id', async req => app.skillRegistry.withdraw(keyOf(req).id, req.params.id))
+}

+ 2 - 0
apps/server/src/http/server.ts

@@ -14,6 +14,7 @@ import v1Routes from './routes/v1'
 import botRoutes from '../teams/routes'
 import { registerGateway } from '../mcp-gateway/server'
 import { registerMobileApp } from './mobile-app'
+import registryRoutes from './routes/registry'
 
 export async function buildServer(app: App, opts: { logger: FastifyServerOptions['loggerInstance'] }) {
   const f = Fastify({ loggerInstance: opts.logger, trustProxy: config.trustProxy, bodyLimit: 2 * 1048576 })
@@ -56,6 +57,7 @@ export async function buildServer(app: App, opts: { logger: FastifyServerOptions
   await f.register(v1Routes, { prefix: '/v1', app })
   await f.register(botRoutes, { prefix: '/bot/v1', app })
   registerGateway(f, app.gatewayTokens, { mcp: app.mcpServers, upstream: app.mcpUpstream, background: app.backgroundCalls, tasks: app.mcpTasks, elicitation: app.elicitation, content: app.mcpContent })
+  await f.register(registryRoutes, { prefix: '/skills/registry/v1', app })
   await f.register(adminRoutes, { prefix: '/admin/v1', app })
 
   // 生产环境由服务托管后台静态资源,同源访问

+ 1 - 1
apps/server/src/lib/ids.ts

@@ -1,6 +1,6 @@
 import { ulid } from 'ulid'
 
-export type IdPrefix = 'ses' | 'run' | 'itx' | 'file' | 'prf' | 'kb' | 'key' | 'adm' | 'msg' | 'mcp' | 'csk' | 'src' | 'bot' | 'bkey' | 'team' | 'tmsg' | 'job' | 'task' | 'step' | 'case' | 'sch' | 'bg' | 'upl'
+export type IdPrefix = 'ses' | 'run' | 'itx' | 'file' | 'prf' | 'kb' | 'key' | 'adm' | 'msg' | 'mcp' | 'csk' | 'src' | 'bot' | 'bkey' | 'team' | 'tmsg' | 'job' | 'task' | 'step' | 'case' | 'sch' | 'bg' | 'spk' | 'sub' | 'sbi'
 
 export const newId = (prefix: IdPrefix) => `${prefix}_${ulid().toLowerCase()}`
 

+ 0 - 3
apps/server/src/main.ts

@@ -15,7 +15,6 @@ const shutdown = async (signal: string) => {
   logger.info({ signal }, 'shutting down')
   await server.close()
   await app.mcpUpstream.closeAll()
-  app.skillSources.stop()
   app.db.$client.close()
   process.exit(0)
 }
@@ -23,6 +22,4 @@ process.on('SIGINT', () => void shutdown('SIGINT'))
 process.on('SIGTERM', () => void shutdown('SIGTERM'))
 
 await server.listen({ host: config.host, port: config.port })
-// 技能源自动同步
-app.skillSources.start()
 logger.info({ dataDir: config.dataDir }, `AgentServer 已启动:http://${config.host}:${config.port}`)

+ 40 - 0
apps/server/src/services/server-dirs.ts

@@ -0,0 +1,40 @@
+// 选择服务器目录(登记资料库时):列出服务进程看到的子目录。只列目录名,不列文件;登录凭证所在目录不列出;支持 ~
+import fs from 'node:fs'
+import path from 'node:path'
+import type { ServerDirListDto } from '@agent-server/shared'
+import { badRequest } from '../lib/errors'
+import { expandPath, realpathLoose } from '../lib/paths'
+import { isCredentialPath } from './access-policy'
+
+/** 最多列出的子目录数 */
+const DIR_LIST_MAX = 500
+
+export function listServerDirs(raw: string): ServerDirListDto {
+  const p = raw.trim() || '/'
+  if (!path.isAbsolute(p) && p !== '~' && !p.startsWith('~/')) throw badRequest('路径必须是绝对路径')
+  const real = realpathLoose(expandPath(p))
+  if (!fs.existsSync(real) || !fs.statSync(real).isDirectory()) throw badRequest(`目录不存在或服务看不到:${p}`)
+  if (isCredentialPath(real)) throw badRequest('不能浏览登录凭证所在的目录')
+  let entries: fs.Dirent[]
+  try {
+    entries = fs.readdirSync(real, { withFileTypes: true })
+  } catch {
+    throw badRequest(`服务没有读取权限:${p}`)
+  }
+  const dirs: ServerDirListDto['dirs'] = []
+  for (const e of entries) {
+    const abs = path.join(real, e.name)
+    if (!(e.isDirectory() || (e.isSymbolicLink() && isDir(abs))) || isCredentialPath(abs)) continue
+    dirs.push({ name: e.name })
+  }
+  dirs.sort((a, b) => Number(a.name.startsWith('.')) - Number(b.name.startsWith('.')) || a.name.localeCompare(b.name))
+  return { path: real, parent: real === '/' ? null : path.dirname(real), dirs: dirs.slice(0, DIR_LIST_MAX), truncated: dirs.length > DIR_LIST_MAX }
+}
+
+function isDir(p: string) {
+  try {
+    return fs.statSync(p).isDirectory()
+  } catch {
+    return false
+  }
+}

+ 16 - 1
apps/server/test/api-reference.test.ts

@@ -3,9 +3,11 @@ import pino from 'pino'
 import { createApp, type App } from '../src/app'
 import { API_EVENTS, API_GROUPS } from '../src/api-reference/reference'
 import { BOT_EVENTS, BOT_GROUPS } from '../src/api-reference/bot-reference'
+import { REGISTRY_GROUPS } from '../src/api-reference/registry-reference'
 import { renderApiReferenceHtml } from '../src/api-reference/render'
 import { findChrome } from '../src/api-reference/pdf'
 import { buildServer } from '../src/http/server'
+import { registeredRegistryRoutes } from '../src/http/routes/registry'
 import { registeredV1Routes } from '../src/http/routes/v1'
 import { registeredBotRoutes } from '../src/teams/routes'
 import type { EventPayloads, TeamEventPayloads } from '@agent-server/shared'
@@ -36,6 +38,15 @@ describe('API 协议文档', () => {
     expect([...documented].filter(x => !actual.has(x)), '文档中有、但实际不存在的接口').toEqual([])
   })
 
+  it('技能发布 API:与实际注册的 /skills/registry/v1 路由完全一致(新增或删除接口时必须同步修改 registry-reference.ts)', () => {
+    const key = (m: string, p: string) => `${m} ${p}`
+    const actual = new Set(registeredRegistryRoutes.map(r => key(r.method, r.path)))
+    const documented = new Set(REGISTRY_GROUPS.flatMap(g => g.endpoints.map(e => key(e.method, e.path))))
+    expect(actual.size).toBeGreaterThan(0)
+    expect([...actual].filter(x => !documented.has(x)), '未写入文档的接口').toEqual([])
+    expect([...documented].filter(x => !actual.has(x)), '文档中有、但实际不存在的接口').toEqual([])
+  })
+
   it('Bot API 事件类型与工作组事件模型一致', () => {
     const all: Record<keyof TeamEventPayloads, true> = {
       'message.created': true, 'bot.status': true, 'interaction.required': true, 'interaction.resolved': true, 'file.changed': true, 'team.updated': true,
@@ -61,6 +72,10 @@ describe('API 协议文档', () => {
     expect(html).toContain('第 1 部分 · Agent API')
     expect(html).toContain('第 2 部分 · Bot API')
     expect(html).toContain('https://agent.example.com/bot/v1/teams')
+    expect(html).toContain('第 3 部分 · 技能发布 API')
+    expect(html).toContain('https://agent.example.com/skills/registry/v1/submissions')
+    // 技能发布 API 没有事件,不出现"事件类型"一节
+    expect(html).not.toContain('id="registry-event-types"')
     expect(html).not.toContain('$BASE')
     expect(html).toContain('data-theme="dark"')
     expect(html).toContain('&lt;凭证&gt;')
@@ -81,7 +96,7 @@ describe('API 协议文档', () => {
   it('调试:协议 JSON 含概述与示例请求体;以平台身份调试时签发 10 分钟令牌,停用的 Key 不能调试', async () => {
     const cookies = { agent_admin: app.adminAuth.login('test-password').id }
     const ref = (await server.inject({ method: 'GET', url: '/admin/v1/api-reference.json', cookies })).json()
-    expect(ref.specs.map((s: { id: string }) => s.id)).toEqual(['agent', 'bot'])
+    expect(ref.specs.map((s: { id: string }) => s.id)).toEqual(['agent', 'bot', 'registry'])
     expect(ref.specs[0].overview.points.length).toBeGreaterThan(3)
     expect(ref.specs[0].groups.flatMap((g: { endpoints: { path: string; sampleBody?: unknown }[] }) => g.endpoints).find((e: { path: string }) => e.path === '/v1/sessions/:id/messages').sampleBody).toHaveProperty('text')
 

+ 174 - 159
apps/server/test/custom-skills.test.ts

@@ -1,201 +1,216 @@
 import fs from 'node:fs'
-import os from 'node:os'
 import path from 'node:path'
 import pino from 'pino'
 import { strToU8, unzipSync, zipSync } from 'fflate'
 import { afterAll, beforeAll, describe, expect, it } from 'vitest'
-import { eq } from 'drizzle-orm'
 import { createApp, type App } from '../src/app'
-import { config, customSkillsDir, customSkillStoreDir, skillUploadsDir } from '../src/config'
+import { config, customSkillsDir, customSkillStoreDir } from '../src/config'
+import { REGISTRY_LIMITS, renameSkillMd } from '../src/custom-skills/registry'
 import { LIMITS, normalizeRelPath } from '../src/custom-skills/store'
-import { customSkills, skillSources } from '../src/db/schema'
+import { buildServer } from '../src/http/server'
 import { resolveSkills } from '../src/providers/claude/skills'
 import { skillsToDisable } from '../src/providers/codex/skills'
 import { checkAccess, resolveAccess } from '../src/services/access-policy'
+import type { RegistrySubmissionDto } from '@agent-server/shared'
 
 let app: App
+let server: Awaited<ReturnType<typeof buildServer>>
+let base: string
 const audits: string[] = []
 const live = (name: string, f = 'SKILL.md') => path.join(customSkillsDir(), 'skills', name, f)
-const md = (name: string, desc = `${name} 的说明:处理某类业务数据的流程与规范`) => `---\nname: ${name}\ndescription: ${desc}\n---\n# ${name}\n`
-
-/** 一个插件格式的技能源目录:skills/alpha、skills/beta(含指向 /etc/passwd 的符号链接) */
-function makeSourceDir(): string {
-  const root = fs.mkdtempSync(path.join(os.tmpdir(), 'skill-src-'))
-  for (const n of ['alpha', 'beta']) {
-    fs.mkdirSync(path.join(root, 'skills', n, 'references'), { recursive: true })
-    fs.writeFileSync(path.join(root, 'skills', n, 'SKILL.md'), md(n))
-    fs.writeFileSync(path.join(root, 'skills', n, 'references', 'guide.md'), `${n} guide v1\nline2\n`)
-  }
-  fs.symlinkSync('/etc/passwd', path.join(root, 'skills', 'beta', 'passwd'))
-  return root
+const md = (name: string, desc = `${name} 的说明:处理某类业务数据的流程与规范`, extra = '') => `---\nname: ${name}\ndescription: ${desc}\n${extra}---\n# ${name}\n`
+const zip = (files: Record<string, string>) => Buffer.from(zipSync(Object.fromEntries(Object.entries(files).map(([k, v]) => [k, strToU8(v)]))))
+
+/** 用发布接口推送 */
+async function push(key: string, pkg: Buffer, opts: { message?: string; meta?: object; dryRun?: boolean } = {}) {
+  const form = new FormData()
+  form.append('file', new Blob([new Uint8Array(pkg)]), 'skills.zip')
+  form.append('message', opts.message ?? '')
+  if (opts.meta) form.append('meta', JSON.stringify(opts.meta))
+  const res = await fetch(`${base}/skills/registry/v1/submissions${opts.dryRun ? '?dryRun=1' : ''}`, { method: 'POST', headers: key ? { Authorization: `Bearer ${key}` } : {}, body: form })
+  return { status: res.status, body: await res.json() as RegistrySubmissionDto & { error?: { message: string } } }
 }
+const keyRow = (key: string) => app.skillRegistry.verify(key, '127.0.0.1')!
+const pending = (name: string) => app.skillRegistry.listItems('pending').find(i => i.name === name)!
 
-beforeAll(() => {
-  app = createApp(pino({ level: 'silent' }), { providers: {} })
+beforeAll(async () => {
+  const log = pino({ level: 'silent' })
+  app = createApp(log, { providers: {} })
   const orig = app.audit.log.bind(app.audit)
   app.audit.log = (e: Parameters<typeof orig>[0]) => { audits.push(e.summary); return orig(e) }
+  server = await buildServer(app, { logger: log })
+  base = await server.listen({ port: 0, host: '127.0.0.1' })
 })
-afterAll(() => app.skillSources.stop())
-
-describe('自定义技能:目录技能源导入与同步', () => {
-  let srcDir: string
-  let sourceId: string
-
-  it('C-01 扫描出 2 个技能并导入;两个引擎的 ID 为 custom-skills:<name>;符号链接被跳过(C-03)', () => {
-    srcDir = makeSourceDir()
-    const s = app.skillSources.create({ name: '业务仓库', description: '', path: srcDir, syncMode: 'manual', intervalMin: 10 })
-    sourceId = s.id
-    const scan = app.skillSources.scan(sourceId)
-    expect(scan.skills.map(x => [x.name, x.status, x.path, x.fileCount])).toEqual([['alpha', 'new', 'skills/alpha', 2], ['beta', 'new', 'skills/beta', 2]])
-    expect(scan.skipped).toEqual(['skills/beta/passwd(符号链接)'])
-
-    const r = app.skillSources.import(sourceId, ['alpha', 'beta'], scan.digest)
-    expect(r.imported.map(x => [x.name, x.version, x.created])).toEqual([['alpha', 1, true], ['beta', 1, true]])
-    expect(fs.readFileSync(live('alpha'), 'utf8')).toContain('name: alpha')
-    expect(fs.existsSync(live('beta', 'passwd'))).toBe(false)
-    // 内置的 team-lead 技能(组长 Profile 使用)随服务创建
-    expect(app.customSkills.list().filter(x => x.name !== 'team-lead').map(x => [x.name, x.origin, x.publishedVersion, x.sourceName])).toEqual([['alpha', 'imported', 1, '业务仓库'], ['beta', 'imported', 1, '业务仓库']])
-    expect(app.skillSources.list()[0]!.skillNames).toEqual(['alpha', 'beta'])
-    expect(app.customSkills.pluginRef()!.skills.filter(s => s.id !== 'custom-skills:team-lead').map(s => [s.id, s.optIn])).toEqual([['custom-skills:alpha', true], ['custom-skills:beta', true]])
+afterAll(async () => {
+  await server.close()
+})
+
+describe('技能发布平台:推送与审核(docs/design/18-skill-registry.md)', () => {
+  let key: string
+  let subId: string
+
+  it('C-01 推送:只接受有效的发布密钥;试运行不保存;包中的技能逐个待审核,附自动检查', async () => {
+    key = app.skillRegistry.createKey({ name: 'fwh-app · CI', description: '', expiresInDays: 365 }).key
+    expect(key).toMatch(/^sk_pub_/)
+    const pkg = zip({
+      'skills/alpha/SKILL.md': md('alpha'),
+      'skills/alpha/references/guide.md': 'alpha guide v1\nline2\n',
+      'skills/beta/SKILL.md': md('beta'),
+      'skills/beta/scripts/run.sh': 'curl https://api.example.com/v1/x\npassword = abcdefghijklmnop1234\n',
+      '../evil.md': 'x',
+    })
+    expect((await push('', pkg)).status).toBe(401)
+    expect((await push('sk_pub_nope', pkg)).status).toBe(401)
+    expect((await push('ak_wrong_kind', pkg)).status).toBe(401)
+
+    const dry = await push(key, pkg, { dryRun: true })
+    expect(dry.status).toBe(200)
+    expect([dry.body.id, dry.body.dryRun, dry.body.items.map(i => [i.name, i.status])]).toEqual(['', true, [['alpha', 'pending'], ['beta', 'pending']]])
+    expect(app.skillRegistry.pendingCount()).toBe(0)
+
+    const r = await push(key, pkg, { message: '新增 alpha 与 beta', meta: { repo: 'fwh-app', commit: 'abc123' } })
+    expect(r.status).toBe(201)
+    subId = r.body.id
+    expect(r.body.skipped).toEqual(['../evil.md(路径不安全)'])
+    expect(r.body.items.map(i => [i.name, i.status, i.existing])).toEqual([['alpha', 'pending', null], ['beta', 'pending', null]])
+    const beta = r.body.items[1]!
+    expect(beta.checks.map(c => [c.code, c.level])).toEqual([['script', 'warning'], ['secret', 'danger'], ['url', 'info']])
+    expect(beta.checks[0]!.files).toEqual(['scripts/run.sh'])
+    expect(beta.checks[2]!.message).toContain('api.example.com')
+
+    // 推送不改变任何技能;后台待审核列表中有两项
+    expect(fs.existsSync(live('alpha'))).toBe(false)
+    expect(app.skillRegistry.listItems('pending').map(i => [i.name, i.keyName, i.message, i.meta.commit])).toEqual([['alpha', 'fwh-app · CI', '新增 alpha 与 beta', 'abc123'], ['beta', 'fwh-app · CI', '新增 alpha 与 beta', 'abc123']])
+    expect(audits.some(a => a.startsWith('推送技能包(fwh-app · CI') && a.includes('alpha(待审核)'))).toBe(true)
+
+    // 推送方只能看到自己的提交
+    const other = app.skillRegistry.createKey({ name: '另一个项目', description: '', expiresInDays: null }).key
+    const get = (k: string) => fetch(`${base}/skills/registry/v1/submissions/${subId}`, { headers: { Authorization: `Bearer ${k}` } })
+    expect((await get(key)).status).toBe(200)
+    expect((await get(other)).status).toBe(404)
+    // 停用的密钥不能使用
+    const otherId = app.skillRegistry.listKeys().find(k => k.name === '另一个项目')!.id
+    app.skillRegistry.updateKey(otherId, { enabled: false })
+    expect((await push(other, pkg)).status).toBe(401)
+    expect(app.skillRegistry.listKeys().find(k => k.name === 'fwh-app · CI')).toMatchObject({ submissions: 1, pending: 2, lastUsedIp: '127.0.0.1' })
   })
 
-  it('C-02 修改来源中的一个参考文件:同步只显示该文件的变化,导入后生效目录更新', () => {
-    fs.writeFileSync(path.join(srcDir, 'skills', 'alpha', 'references', 'guide.md'), 'alpha guide v2\nline2\n')
-    const scan = app.skillSources.scan(sourceId)
-    const alpha = scan.skills.find(s => s.name === 'alpha')!
-    expect(alpha.status).toBe('changed')
-    expect(alpha.changes).toEqual({ added: [], modified: ['references/guide.md'], removed: [] })
-    expect(scan.skills.find(s => s.name === 'beta')!.status).toBe('unchanged')
-    // 扫描后来源又变了:拒绝导入未审阅的内容
-    fs.writeFileSync(path.join(srcDir, 'skills', 'alpha', 'extra.md'), 'x')
-    expect(() => app.skillSources.import(sourceId, ['alpha'], scan.digest)).toThrow(/重新扫描/)
-    fs.rmSync(path.join(srcDir, 'skills', 'alpha', 'extra.md'))
-    const again = app.skillSources.scan(sourceId)
-    expect(app.skillSources.import(sourceId, ['alpha'], again.digest).imported).toEqual([{ name: 'alpha', version: 2, created: false }])
-    expect(fs.readFileSync(live('alpha', 'references/guide.md'), 'utf8')).toBe('alpha guide v2\nline2\n')
-    const id = app.customSkills.list().find(s => s.name === 'alpha')!.id
-    expect(app.customSkills.diff(id, 1, 2)[0]).toMatchObject({ path: 'references/guide.md', status: 'modified' })
-    expect(app.customSkills.diff(id, 1, 2)[0]!.patch).toContain('-alpha guide v1')
+  it('C-02 批准为新技能(可以改名,SKILL.md 中的 name 随之改写);拒绝须填写原因,推送方可以看到', async () => {
+    const alpha = pending('alpha')
+    expect(app.skillRegistry.diff(alpha.id, {}).files.map(f => [f.path, f.status])).toEqual([['references/guide.md', 'added'], ['SKILL.md', 'added']])
+    const r = app.skillRegistry.approve(alpha.id, { action: 'create', name: 'alpha', publish: true })
+    expect(r.result).toMatchObject({ action: 'create', skillName: 'alpha', version: 1, published: true })
+    expect(fs.readFileSync(live('alpha', 'references/guide.md'), 'utf8')).toBe('alpha guide v1\nline2\n')
+    const a = app.customSkills.detail(r.result!.skillId)
+    expect(a.versions[0]).toMatchObject({ version: 1, reason: 'submission', note: { submissionId: subId, keyName: 'fwh-app · CI', message: '新增 alpha 与 beta' } })
+    // 推送来的技能可以直接编辑
+    app.customSkills.writeFile(a.id, 'notes.md', Buffer.from('x'))
+    app.customSkills.discard(a.id)
+
+    const beta = pending('beta')
+    const d = app.skillRegistry.diff(beta.id, { name: 'gamma' })
+    expect(d.renamed).toBe(true)
+    expect(d.files.find(f => f.path === 'SKILL.md')!.patch).toContain('+name: gamma')
+    expect(() => app.skillRegistry.approve(beta.id, { action: 'create', name: 'alpha', publish: true })).toThrow(/已被使用/)
+    expect(() => app.skillRegistry.approve(beta.id, { action: 'create', name: 'Bad Name', publish: true })).toThrow(/小写字母/)
+    app.skillRegistry.approve(beta.id, { action: 'create', name: 'gamma', publish: true })
+    expect(fs.readFileSync(live('gamma'), 'utf8')).toContain('name: gamma')
+    expect(() => app.skillRegistry.approve(beta.id, { action: 'create', name: 'gamma2', publish: true })).toThrow(/已经处理/)
+
+    const s = app.skillRegistry.getSubmission(keyRow(key).id, subId)
+    expect(s.status).toBe('done')
+    expect(s.items.map(i => [i.name, i.status, i.result?.skill, i.result?.version])).toEqual([['alpha', 'approved', 'alpha', 1], ['beta', 'approved', 'gamma', 1]])
+
+    const r2 = await push(key, zip({ 'delta/SKILL.md': md('delta') }))
+    const delta = pending('delta')
+    expect(() => app.skillRegistry.reject(delta.id, ' ')).toThrow(/原因/)
+    app.skillRegistry.reject(delta.id, '描述不清楚')
+    expect(app.skillRegistry.getSubmission(keyRow(key).id, r2.body.id).items[0]).toMatchObject({ status: 'rejected', reviewNote: '描述不清楚' })
+    expect(app.skillRegistry.listItems('done').find(i => i.name === 'delta')).toMatchObject({ status: 'rejected', reviewNote: '描述不清楚' })
   })
 
-  it('来源中删除的技能只提示,不自动删除;同名冲突拒绝;导入的技能只读,转为可编辑后可改', () => {
-    fs.rmSync(path.join(srcDir, 'skills', 'beta'), { recursive: true })
-    expect(app.skillSources.scan(sourceId).missing).toEqual(['beta'])
-    expect(fs.existsSync(live('beta'))).toBe(true)
+  it('C-03 再次推送:相同内容为无变化;同一密钥再推送替代旧的待审核;覆盖时以审核时看到的版本为准', async () => {
+    const same = await push(key, zip({ 'alpha/SKILL.md': md('alpha'), 'alpha/references/guide.md': 'alpha guide v1\nline2\n' }))
+    expect(same.body.items[0]).toMatchObject({ name: 'alpha', status: 'unchanged', existing: { version: 1 } })
 
-    const other = fs.mkdtempSync(path.join(os.tmpdir(), 'skill-src2-'))
-    fs.mkdirSync(path.join(other, 'alpha'))
-    fs.writeFileSync(path.join(other, 'alpha', 'SKILL.md'), md('alpha'))
-    const s2 = app.skillSources.create({ name: '另一个来源', description: '', path: other, syncMode: 'manual', intervalMin: 10 })
-    expect(app.skillSources.scan(s2.id).skills[0]).toMatchObject({ name: 'alpha', status: 'conflict' })
+    const v2 = (body: string, ver: string) => zip({ 'alpha/SKILL.md': md('alpha', undefined, `metadata:\n  version: "${ver}"\n`), 'alpha/references/guide.md': body })
+    const first = await push(key, v2('alpha guide v2\n', '2.0'))
+    const second = await push(key, v2('alpha guide v3\n', '2.0'))
+    expect(app.skillRegistry.getSubmission(keyRow(key).id, first.body.id).items[0]!.status).toBe('superseded')
+    const item = pending('alpha')
+    expect(item.submissionId).toBe(second.body.id)
+    expect(item.existing).toMatchObject({ version: 1 })
+    expect(second.body.items[0]!.changes).toEqual({ added: [], modified: ['SKILL.md', 'references/guide.md'], removed: [] })
+    expect(item.checks.find(c => c.code === 'version')!.message).toBe('声明版本号:未声明 → 2.0')
 
     const alphaId = app.customSkills.list().find(s => s.name === 'alpha')!.id
-    expect(() => app.customSkills.writeFile(alphaId, 'x.md', Buffer.from('x'))).toThrow(/只读/)
-    expect(app.customSkills.detach(alphaId).origin).toBe('authored')
-  })
-
-  it('自动同步:到期后导入有变化的技能并记入审计', async () => {
-    const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'skill-auto-'))
-    fs.writeFileSync(path.join(dir, 'SKILL.md'), md('gamma'))
-    const s = app.skillSources.create({ name: '自动', description: '', path: dir, syncMode: 'auto', intervalMin: 1 })
-    await app.skillSources.runAutoSyncNow()
-    expect(app.customSkills.list().find(x => x.name === 'gamma')).toMatchObject({ publishedVersion: 1, sourceId: s.id })
-    expect(audits.some(a => a.startsWith('自动同步技能源 自动'))).toBe(true)
-  })
-
-  it('技能源路径校验:不能使用服务数据目录、不存在的 home 路径给出提示', () => {
-    expect(() => app.skillSources.create({ name: 'x', description: '', path: config.dataDir, syncMode: 'manual', intervalMin: 10 })).toThrow(/服务数据目录/)
-    expect(() => app.skillSources.create({ name: 'x', description: '', path: '/home/nobody/none', syncMode: 'manual', intervalMin: 10 })).toThrow(/ProtectHome/)
+    const d = app.skillRegistry.diff(item.id, { targetId: alphaId })
+    expect(d.target).toMatchObject({ name: 'alpha', version: 1, unsaved: false })
+    expect(d.files.find(f => f.path === 'references/guide.md')!.patch).toContain('+alpha guide v3')
+    // 审核期间技能有了新版本:拒绝覆盖
+    expect(() => app.skillRegistry.approve(item.id, { action: 'overwrite', targetId: alphaId, expectedVersion: 0, publish: true })).toThrow(/刷新/)
+    const r = app.skillRegistry.approve(item.id, { action: 'overwrite', targetId: alphaId, expectedVersion: 1, publish: false })
+    expect(r.result).toMatchObject({ action: 'overwrite', skillName: 'alpha', version: 2, published: false })
+    expect(app.customSkills.detail(alphaId)).toMatchObject({ latestVersion: 2, publishedVersion: 1 })
+    expect(fs.readFileSync(live('alpha', 'references/guide.md'), 'utf8')).toBe('alpha guide v1\nline2\n')
+    app.customSkills.publish(alphaId, 2)
+    expect(fs.readFileSync(live('alpha', 'references/guide.md'), 'utf8')).toBe('alpha guide v3\n')
+
+    // 覆盖到不同名的技能:SKILL.md 的 name 改写为目标技能名
+    await push(key, zip({ 'alpha-next/SKILL.md': md('alpha-next') }))
+    const next = pending('alpha-next')
+    const gammaId = app.customSkills.list().find(s => s.name === 'gamma')!.id
+    app.skillRegistry.approve(next.id, { action: 'overwrite', targetId: gammaId, expectedVersion: 1, publish: true })
+    expect(fs.readFileSync(live('gamma'), 'utf8')).toContain('name: gamma')
+    expect(fs.readFileSync(live('gamma'), 'utf8')).toContain('# alpha-next')
   })
-})
 
-describe('自定义技能:zip 上传', () => {
-  it('上传 zip(带顶层目录)导入;不安全路径跳过并审计(C-03);不登记为技能源,导入后删除上传的文件', () => {
-    const zip = zipSync({
-      'pack/delta/SKILL.md': strToU8(md('delta')),
-      'pack/delta/templates/report.pptx': new Uint8Array([80, 75, 3, 4, 1, 2, 3]),
-      '../evil.md': strToU8('x'),
-      '__MACOSX/pack/._delta': strToU8('x'),
-    })
-    const { uploadId, name, scan } = app.skillSources.upload('pack.zip', Buffer.from(zip))
-    expect(name).toBe('pack.zip')
-    expect(scan.skills.map(s => [s.name, s.path, s.fileCount, s.status])).toEqual([['delta', 'pack/delta', 2, 'new']])
-    expect(scan.skipped).toEqual(['../evil.md(路径不安全)'])
-    expect(audits.some(a => a.includes('上传技能包 pack.zip') && a.includes('跳过 1 个'))).toBe(true)
-    expect(app.skillSources.list().map(s => s.kind)).not.toContain('upload')
-    expect(app.skillSources.importUpload(uploadId, ['delta'], scan.digest).imported).toEqual([{ name: 'delta', version: 1, created: true }])
-    expect([...fs.readFileSync(live('delta', 'templates/report.pptx'))]).toEqual([80, 75, 3, 4, 1, 2, 3])
-    // 从 zip 导入的技能可以编辑,不属于任何技能源
-    expect(app.customSkills.list().find(s => s.name === 'delta')).toMatchObject({ origin: 'authored', sourceId: null })
-    expect(fs.readdirSync(skillUploadsDir())).not.toContain(uploadId)
-    expect(() => app.skillSources.importUpload(uploadId, ['delta'], scan.digest)).toThrow(/重新上传/)
-  })
+  it('C-04 撤回;删除已处理的提交项(待审核的不能删),技能版本保留提交说明', async () => {
+    const r = await push(key, zip({ 'epsilon/SKILL.md': md('epsilon') }))
+    const res = await fetch(`${base}/skills/registry/v1/submissions/${r.body.id}`, { method: 'DELETE', headers: { Authorization: `Bearer ${key}` } })
+    expect(((await res.json()) as RegistrySubmissionDto).items[0]!.status).toBe('withdrawn')
 
-  it('再次上传同名技能:显示版本对比,可以覆盖为新版本或保留原版本;属于技能源的同名技能不能覆盖', () => {
-    const v2 = (body: string) => zipSync({ 'delta/SKILL.md': strToU8(`---\nname: delta\ndescription: delta 的说明:处理某类业务数据的流程与规范\nmetadata:\n  author: x\n  version: "2.0"\n---\n${body}\n`) })
-    const keep = app.skillSources.upload('delta-v2.zip', Buffer.from(v2('# 第二版')))
-    const item = keep.scan.skills[0]!
-    expect(item).toMatchObject({ status: 'changed', declaredVersion: '2.0', existing: { version: 1, declaredVersion: null, sourceName: null, unsaved: false } })
-    expect(item.changes.modified).toEqual(['SKILL.md'])
-    expect(item.changes.removed).toEqual(['templates/report.pptx'])
-    // 保留原版本:取消上传,技能不变
-    app.skillSources.discardUpload(keep.uploadId)
-    expect(app.customSkills.list().find(s => s.name === 'delta')!.latestVersion).toBe(1)
-
-    // 覆盖:作为新版本发布,旧版本仍在历史中
-    const over = app.skillSources.upload('delta-v2.zip', Buffer.from(v2('# 第二版')))
-    expect(app.skillSources.importUpload(over.uploadId, ['delta'], over.scan.digest).imported).toEqual([{ name: 'delta', version: 2, created: false }])
-    expect(fs.readFileSync(live('delta'), 'utf8')).toContain('# 第二版')
-    expect(app.customSkills.detail(app.customSkills.list().find(s => s.name === 'delta')!.id).versions.map(v => [v.version, v.reason])).toEqual([[2, 'import'], [1, 'import']])
-    expect(audits.some(a => a === '导入技能包 delta-v2.zip:delta(覆盖) v2')).toBe(true)
-
-    // 相同内容:无需导入
-    const same = app.skillSources.upload('delta-v2.zip', Buffer.from(v2('# 第二版')))
-    expect(same.scan.skills[0]).toMatchObject({ status: 'unchanged', existing: { version: 2, declaredVersion: '2.0' } })
-    app.skillSources.discardUpload(same.uploadId)
-
-    // beta 属于目录技能源「业务仓库」
-    const beta = app.skillSources.upload('beta.zip', Buffer.from(zipSync({ 'beta/SKILL.md': strToU8(md('beta')) })))
-    expect(beta.scan.skills[0]).toMatchObject({ status: 'conflict', existing: { sourceName: '业务仓库' } })
-    expect(beta.scan.skills[0]!.reason).toMatch(/技能源「业务仓库」/)
-    app.skillSources.discardUpload(beta.uploadId)
-  })
+    await push(key, zip({ 'zeta/SKILL.md': md('zeta') }))
+    expect(() => app.skillRegistry.deleteItem(pending('zeta').id)).toThrow(/先批准或拒绝/)
+    app.skillRegistry.reject(pending('zeta').id, '不需要')
 
-  it('早期登记为技能源的 zip 上传:启动时删除记录,从中导入的技能转为可编辑', () => {
-    const now = new Date().toISOString()
-    app.db.insert(skillSources).values({ id: 'src_legacy', name: 'old.zip', description: '', kind: 'upload', path: path.join(skillUploadsDir(), 'src_legacy'), syncMode: 'manual' }).run()
-    app.db.update(customSkills).set({ origin: 'imported', sourceId: 'src_legacy', updatedAt: now }).where(eq(customSkills.name, 'delta')).run()
-    app.skillSources.start()
-    app.skillSources.stop()
-    expect(app.db.select().from(skillSources).where(eq(skillSources.id, 'src_legacy')).get()).toBeUndefined()
-    expect(app.customSkills.list().find(s => s.name === 'delta')).toMatchObject({ origin: 'authored', sourceId: null })
+    const alphaId = app.customSkills.list().find(s => s.name === 'alpha')!.id
+    for (const i of app.skillRegistry.listItems('done')) app.skillRegistry.deleteItem(i.id)
+    expect(app.skillRegistry.listItems('done')).toEqual([])
+    expect(app.customSkills.detail(alphaId).versions[0]!.note).toMatchObject({ keyName: 'fwh-app · CI' })
+    expect(fs.readFileSync(live('alpha', 'references/guide.md'), 'utf8')).toBe('alpha guide v3\n')
   })
 
-  it('选择目录:只列出子目录,标出含 SKILL.md 的目录与找到的技能数', () => {
-    const root = makeSourceDir()
-    fs.writeFileSync(path.join(root, 'readme.md'), 'x')
-    const top = app.skillSources.listDirs(root)
-    expect(top.dirs).toEqual([{ name: 'skills', skill: false }])
-    expect(top.skillCount).toBe(2)
-    expect(top.parent).toBe(path.dirname(fs.realpathSync(root)))
-    expect(app.skillSources.listDirs(path.join(root, 'skills')).dirs).toEqual([{ name: 'alpha', skill: true }, { name: 'beta', skill: true }])
-    expect(app.skillSources.listDirs('/').parent).toBeNull()
-    expect(app.skillSources.listDirs('~').path).toBe(fs.realpathSync(os.homedir()))
-    expect(() => app.skillSources.listDirs('relative/path')).toThrow(/绝对路径/)
-    expect(() => app.skillSources.listDirs(path.join(root, 'none'))).toThrow(/不存在/)
-  })
+  it('C-05 校验:不合规的技能为 invalid;不是 zip、没有技能、压缩炸弹被拒绝;推送次数与待审核数有上限', async () => {
+    const r = await push(key, zip({ 'Bad_Name/SKILL.md': md('Bad_Name'), 'ok/SKILL.md': '# 没有 frontmatter\n', 'a/SKILL.md': md('dup'), 'b/SKILL.md': md('dup') }))
+    expect(r.body.items.map(i => [i.name, i.status])).toEqual([['dup', 'pending'], ['dup', 'invalid'], ['Bad_Name', 'invalid'], ['ok', 'pending']])
+    expect(r.body.items[1]!.reason).toMatch(/多个名为 dup/)
+    expect((await push(key, Buffer.from('not a zip'))).status).toBe(400)
+    expect((await push(key, zip({ 'readme.md': 'x' }))).body.error!.message).toMatch(/没有找到技能/)
 
-  it('按实际解压字节数限制大小(防压缩炸弹)', () => {
     const saved = LIMITS.unzipBytes
     LIMITS.unzipBytes = 1048576
     try {
-      const bomb = zipSync({ 'big/SKILL.md': new Uint8Array(3 * 1048576) }, { level: 9 })
-      expect(bomb.length).toBeLessThan(100_000)
-      expect(() => app.skillSources.upload('bomb.zip', Buffer.from(bomb))).toThrow(/解压后超过/)
+      const bomb = Buffer.from(zipSync({ 'big/SKILL.md': new Uint8Array(3 * 1048576) }, { level: 9 }))
+      expect((await push(key, bomb)).body.error!.message).toMatch(/解压后超过/)
     } finally {
       LIMITS.unzipBytes = saved
     }
-    expect(() => app.skillSources.upload('bad.zip', Buffer.from('not a zip'))).toThrow()
+
+    const k = app.skillRegistry.createKey({ name: '限额', description: '', expiresInDays: 1 }).key
+    const limits = { ...REGISTRY_LIMITS }
+    REGISTRY_LIMITS.pendingPerKey = 1
+    try {
+      expect((await push(k, zip({ 'q1/SKILL.md': md('q1') }))).status).toBe(201)
+      // 同名技能替代旧的,不占额度;新技能超出上限
+      expect((await push(k, zip({ 'q1/SKILL.md': md('q1', 'q1 的新说明:处理某类业务数据的流程与规范') }))).status).toBe(201)
+      expect((await push(k, zip({ 'q2/SKILL.md': md('q2') }))).status).toBe(429)
+      REGISTRY_LIMITS.submissionsPerHour = 2
+      expect((await push(k, zip({ 'q1/SKILL.md': md('q1') }))).status).toBe(429)
+    } finally {
+      Object.assign(REGISTRY_LIMITS, limits)
+    }
+    expect(renameSkillMd('# x\n', 'n')).toBe('---\nname: n\n---\n# x\n')
   })
 })
 

+ 1 - 1
apps/server/test/team-jobs.test.ts

@@ -116,7 +116,7 @@ describe('内置的组长 Profile', () => {
     expect(app.profiles.findBuiltinLead('codex')).toMatchObject({ provider: 'codex', builtinKey: 'lead' })
     expect(app.profiles.getBuiltin('claude').builtinKey).toBeNull()
     const skill = app.customSkills.list().find(x => x.name === 'team-lead')!
-    expect(skill).toMatchObject({ origin: 'authored', includeInAll: false, publishedVersion: 2 })
+    expect(skill).toMatchObject({ includeInAll: false, publishedVersion: 2 })
     expect((await server.inject({ method: 'DELETE', url: `/admin/v1/profiles/${lead.id}`, cookies: adminCookie })).statusCode).toBe(400)
   })
 })

+ 4 - 2
docs/README.md

@@ -24,12 +24,13 @@ docs/
 │  ├─ 09-admin-ui.md          管理后台与 Playground(含视觉规范)
 │  ├─ 10-deployment.md        部署与运维
 │  ├─ 11-roadmap.md           里程碑与待决事项
-│  ├─ 12-custom-skills.md     自定义技能(导入或在后台编写,两个引擎共用)
+│  ├─ 12-custom-skills.md     自定义技能(后台编写或推送审核,两个引擎共用)
 │  ├─ 13-bot-teams.md         Bot 工作组:多岗位机器人、组长分派任务、Bot API(一期至三期已实施)
 │  ├─ 14-schedules.md         定时任务:在对话中创建,到点自动在会话或工作组中执行
 │  ├─ 15-background-calls.md  后台调用:外部工具的长调用由服务端等待,完成后通知 AI 继续
 │  ├─ 16-mcp-support.md       MCP 支持完善:工具提示、结构化输出、长任务、询问用户、OAuth、资源与提示模板
-│  └─ 17-run-script.md        脚本调用工具:模型写 Python 脚本,由网关在沙箱中执行并批量调用 MCP 工具
+│  ├─ 17-run-script.md        脚本调用工具:模型写 Python 脚本,由网关在沙箱中执行并批量调用 MCP 工具
+│  └─ 18-skill-registry.md    技能发布平台:项目推送技能,管理员审核后新建或覆盖
 ├─ adr/                       架构决策记录(一事一档,只追加不改写;推翻时新增一条并标注 Superseded)
 └─ research/                  调研材料(选型依据、参考实现分析)
 ```
@@ -64,6 +65,7 @@ docs/
 | [0024](adr/0024-bot-api-separate-keys.md) | Bot API 使用独立路径和独立的 Bot Key | Accepted |
 | [0025](adr/0025-mcp-spec-features.md) | 按 MCP 规范补齐工具以外的能力(长任务、询问用户、OAuth、资源等) | Accepted |
 | [0026](adr/0026-run-script-in-gateway.md) | 脚本批量调用工具由网关在沙箱中执行 | Accepted |
+| [0027](adr/0027-skill-registry-push-and-review.md) | 自定义技能改为推送 + 审核,去掉技能源与上传 zip | Accepted |
 
 ## 调研
 

+ 18 - 0
docs/adr/0027-skill-registry-push-and-review.md

@@ -0,0 +1,18 @@
+# ADR-0027 自定义技能改为"推送 + 审核",去掉技能源与上传 zip
+
+- 状态:Accepted · 日期:2026-11-03 · 部分取代 ADR-0022 之外的自定义技能导入方式(12-custom-skills §3.2、FR-1) · 设计见 [18-skill-registry](../design/18-skill-registry.md)
+
+## 背景
+技能通常由其他项目生成或维护。服务以专用账号运行并启用 `ProtectHome`,读不到 `/home`,目录技能源只能靠手动拷贝;上传 zip 要登录后台手工操作,无法接入项目流程,也看不出来源。放开 `/home` 会让服务(和宽松模式下的 Agent)读到用户目录中所有可读的文件,不可取。
+
+## 决策
+- **推送**:项目用发布密钥(`sk_pub_…`)调用 `/skills/registry/v1/submissions` 上传技能包,可附提交说明与来源信息,支持试运行。服务器不需要访问项目所在的位置。
+- **审核后才生效**:推送只产生待审核的提交项,内容存入文件库且不可修改。管理员选择批准为新技能(可以改名,SKILL.md 的 name 随之改写)、覆盖已有技能(显示与目标最新版本的差异,二次确认,按审核时看到的版本号校验),或拒绝(填写原因,推送方可见)。已处理的记录可以删除,技能版本中保留提交说明的副本。
+- **不设发布者**:密钥名称即来源;不限定技能名,新建还是覆盖由管理员在审核时决定。
+- **只保留后台编写**:删除目录技能源、上传 zip 与"导入的技能只读";已有技能全部可编辑。
+
+## 后果
+- 发布密钥只能产生待审核的提交,泄露的影响有限,按 CI 加密变量 / 用户目录配置保管即可,不需要更复杂的密钥系统。
+- 推送来的技能批准后可以在后台修改;下次推送覆盖时,差异中会显示这些修改将被覆盖,由管理员决定。
+- 同一密钥再推送同名技能会替代之前待审核的那一份,管理员只审核最新的内容。
+- 客户端推送工具、浏览器登录签发密钥、审核通知留到二期。

+ 3 - 1
docs/design/12-custom-skills.md

@@ -1,6 +1,8 @@
 # 12 自定义技能
 
-> 状态:**已实施(2026-10-25)**,一、二、三期全部完成,实施中的调整见 §11。由 FranklinWH ToolBox 新 AI 助手的需要引出(业务平台需要自己的、非公开的技能),内容是通用功能,不含业务平台专有逻辑。基于 `0af6089` 的现有设计(ADR-0013、0016、0017,`providers/claude/skills.ts`、`providers/codex/skills.ts`、`services/community-skills.ts`)。
+> 状态:**已实施(2026-10-25)**,一、二、三期全部完成,实施中的调整见 §11。
+>
+> **2026-11-03 起**:目录技能源与上传 zip 已删除,改为"项目推送 + 管理员审核"([18-skill-registry](18-skill-registry.md),ADR-0027);"导入的技能只读"随之取消,所有自定义技能都可在后台编辑。下文 §3.2、FR-1 及相关接口、界面描述仅作为历史记录。由 FranklinWH ToolBox 新 AI 助手的需要引出(业务平台需要自己的、非公开的技能),内容是通用功能,不含业务平台专有逻辑。基于 `0af6089` 的现有设计(ADR-0013、0016、0017,`providers/claude/skills.ts`、`providers/codex/skills.ts`、`services/community-skills.ts`)。
 
 ## 1. 背景
 

+ 255 - 0
docs/design/18-skill-registry.md

@@ -0,0 +1,255 @@
+# 18 技能发布平台(推送 + 审核)
+
+> 状态:**一期已实施(2026-11-03)**,决策见 ADR-0027,实施中的调整见 §12。修改自定义技能([12-custom-skills.md](12-custom-skills.md))的来源:去掉目录技能源与上传 zip,改为"项目推送、管理员审核",保留后台编写。
+
+## 1. 背景
+
+技能通常由其他项目生成或维护(业务平台仓库、工具项目等),这些项目在别的用户目录、别的机器或 CI 中。现有的两种导入方式都不合适:
+
+- 目录技能源:服务以专用账号运行并启用了 `ProtectHome`,读不到 `/home`,只能手动拷贝到服务能看到的位置;
+- 上传 zip:每次都要手动打包、登录后台上传,无法接入项目的构建流程,也说不清技能从哪个项目、哪次提交来。
+
+## 2. 目标
+
+1. 项目通过接口**推送**技能包,服务器不需要能访问项目所在的位置;
+2. 推送的内容**审核通过后才能使用**;审核时决定新建技能还是覆盖已有技能,覆盖前看清差异并再次确认;
+3. 密钥简单、好管理:不引入"发布者",用密钥名称表示来源;
+4. 自定义技能只有两种产生方式:**后台编写**、**推送并审核**。
+
+不在本期:正式的客户端工具(先给 curl 示例)、公开目录、跨实例同步。
+
+## 3. 概念
+
+| 概念 | 说明 |
+| --- | --- |
+| 发布密钥 | `sk_pub_…`,由管理员在后台创建。**名称**就是来源标识,例如"fwh-app · GitHub Actions""张三 · 本机"。只能调用发布接口,不能创建会话或访问其他数据。 |
+| 提交 | 一次推送:一个技能包(可以包含多个技能)、提交说明、来源信息(仓库、分支、提交号,自由字段)。内容不可修改。 |
+| 提交项 | 提交中的一个技能,是审核的单位:每个技能单独批准或拒绝。 |
+
+不设"发布者",也不限定技能名:推送不会直接改变任何技能,新建还是覆盖、用什么名字,都由管理员在审核时决定。
+
+### 3.1 提交项的状态
+
+```
+                ┌── 批准:新建技能 ──────────────┐
+pending ────────┤                                ├──▶ approved(记录生成的技能与版本)
+                ├── 批准:覆盖已有技能(再次确认)┘
+                ├── 拒绝(填写原因)────────────────▶ rejected
+                ├── 撤回(推送方)──────────────────▶ withdrawn
+                └── 同一密钥又推送了同名技能 ────────▶ superseded
+```
+
+- 提交时就能判定的不进入审核:校验不通过为 `invalid`(附原因);与同名技能的当前版本完全相同为 `unchanged`。
+- 同一密钥推送同名技能时,之前待审核的那一份变为 `superseded`,管理员只审核最新的一份。
+- 管理员可以**删除**已处理的提交项(approved、rejected、withdrawn、superseded、invalid、unchanged)。删除只删提交记录,不影响已生成的技能版本:版本历史中保留提交说明与密钥名称的副本。待审核的提交项需要先批准或拒绝。
+
+### 3.2 与自定义技能的关系
+
+- 自定义技能只有一种来源类型:后台可编辑。推送来的技能审核通过后与后台编写的技能相同,可以直接在后台修改。
+- 下一次推送覆盖时,审核页的差异会显示管理员在后台改过的内容(这些改动会被覆盖),由管理员决定是否覆盖。
+- 版本号仍由平台分配(v1、v2…);SKILL.md 中声明的版本号(`version` / `metadata.version`)只用于展示和对比。
+- 版本历史中标出每个版本的来源:"编辑""恢复""来自提交 sub_…(fwh-app · GitHub Actions):提交说明"。
+
+## 4. 功能需求
+
+### FR-1 推送
+
+- 推送方用发布密钥调用接口上传技能包(zip:单个技能、技能集合 `<名称>/SKILL.md` 或插件格式 `skills/<名称>/SKILL.md`),可附提交说明、来源信息。
+- 服务端解压、逐个技能校验(只取普通文件、名称规则、大小与数量限额、SKILL.md 校验),文件存入现有的文件库,生成提交记录,立即返回每个技能的状态。
+- **试运行**(`dryRun=1`):只校验、不生成提交,便于在 CI 中先检查。
+- 幂等:同一密钥推送与待审核内容完全相同的技能,返回已有的提交项,不重复生成。
+- 限额:每个密钥每小时最多 30 次提交,待审核的提交项最多 50 个(可在设置中调整)。
+
+### FR-2 自动检查(辅助审核,不自动拒绝)
+
+| 检查 | 说明 |
+| --- | --- |
+| 结构 | SKILL.md 的 name / description、文件数、大小(不通过即 `invalid`) |
+| 可执行内容 | 列出脚本文件(`.sh`、`.py`、`.js` 等)及其变化,是审核重点 |
+| 疑似密钥 | 按常见格式匹配(各类 API Key、私钥、令牌、连接串),命中时标红 |
+| 外部地址 | 列出 SKILL.md 与脚本中出现的网址 / 主机名 |
+| 二进制文件 | 列出(模版等),可以下载查看 |
+| 声明版本号 | 与同名技能的当前版本对比,没变但内容变了时提示 |
+
+### FR-3 审核
+
+待审核列表:技能名、密钥名称、提交说明、来源信息、提交时间、检查结果摘要;是否已有同名技能。
+
+打开一个提交项,先查看内容(文件树、SKILL.md、检查结果),然后选择:
+
+**批准为新技能**
+
+- 技能名默认取 SKILL.md 中的 name,管理员可以修改(同名技能已存在时必须改名,否则请选择"覆盖");
+- 改名时自动把 SKILL.md frontmatter 中的 name 改为新名称(审核页显示这一处改动);
+- 可选"批准并发布"(默认)或"批准但暂不发布"(先在 Playground 中试用,之后在技能详情中发布)。
+
+**覆盖已有技能**
+
+- 目标技能默认是同名技能,也可以选择其他技能(名称不同时同样改写 SKILL.md 中的 name);
+- 显示新旧差异:逐文件对比(新增 / 修改 / 删除),以目标技能的**最新版本**为准,并单独提示:
+  - 目标技能有尚未保存的编辑(覆盖后丢失);
+  - 目标技能在推送之后有新版本(例如管理员刚修改过);
+  - 声明版本号的变化(如 2.0.0 → 2.1.0,或没变);
+- 点击"覆盖"后弹出二次确认("将用这次提交覆盖 fwh-assistant(v3 → v4),以上改动会生效"),确认后生成新版本;旧版本可以在版本历史中恢复。
+- 同样可选立即发布或暂不发布。
+
+**拒绝**
+
+- 必须填写原因;推送方可以通过接口看到状态与原因。
+- 已拒绝的提交项仍可在"已处理"中查看完整内容(文件、检查结果、原因),可以删除。
+
+### FR-4 推送方查询
+
+推送方可以查询用自己的密钥提交的内容:状态、审核意见、生成的技能名与版本号;可以撤回仍在待审核的提交项。看不到其他密钥的提交,也不能下载平台上的技能。
+
+### FR-5 密钥管理
+
+- 后台新建密钥:名称(必填,表示来源)、说明、有效期(默认 1 年,可选永久)。明文只在创建时显示一次,之后只显示前缀。
+- 可以停用 / 删除;显示最近使用时间与来源 IP、提交数、待审核数。
+- 删除密钥不影响已经批准的技能;它的待审核提交项保留,由管理员处理。
+
+### FR-6 审计
+
+推送(密钥名称与前缀、包大小与 sha256、来源信息)、批准(新建 / 覆盖、技能名、版本号)、拒绝(原因)、撤回、删除提交记录、密钥的增删改都记入审计日志。
+
+## 5. 密钥的保管
+
+发布密钥的权限很小:**只能产生待审核的提交**,不能直接改变任何技能。即使泄露,最坏的结果是出现一些需要拒绝的提交,在后台吊销即可。因此不需要复杂的密钥系统,按使用场景放在项目代码之外:
+
+| 场景 | 保管方式 |
+| --- | --- |
+| CI(GitHub Actions、GitLab CI 等) | 存为 CI 的加密变量(secret),如 `AGENT_SKILLS_TOKEN`;一个项目的流水线一个密钥,名称写明项目与 CI |
+| 开发者本机 | 存在用户目录的配置文件(如 `~/.config/agent-skills/credentials`,权限 600)或环境变量;一人一机一个密钥,名称写明人与机器 |
+| 项目仓库 | **不放密钥**;只放推送地址等不敏感的配置(如 `.agent-skills.json` 中的 `server`、技能目录) |
+
+推送工具(后续实现)按"环境变量 → 用户配置文件"的顺序读取密钥,项目之间无需各自保存。
+
+二期可以增加**浏览器登录**,免去复制密钥:推送工具执行 `login` 时显示一个验证码并打开后台页面,管理员确认后,平台为这台机器签发一个密钥并写入用户配置文件(与 `gh auth login` 的设备码流程相同)。
+
+## 6. 接口
+
+### 6.1 发布接口(发布密钥,`Authorization: Bearer sk_pub_…`)
+
+与服务同一端口,前缀 `/skills/registry/v1`。
+
+| 方法 | 路径 | 说明 |
+| --- | --- | --- |
+| POST | `/submissions` | multipart:`file`(zip)、`message`、`meta`(JSON,如 `{"repo","branch","commit"}`);`?dryRun=1` 只校验。返回提交与各技能的状态 |
+| GET | `/submissions?status=&limit=` | 用这个密钥提交的列表 |
+| GET | `/submissions/:id` | 详情:各技能的状态、检查结果、审核意见、生成的技能名与版本号 |
+| DELETE | `/submissions/:id` | 撤回其中仍在待审核的技能 |
+
+提交的返回示例:
+
+```json
+{
+  "id": "sub_01k…",
+  "status": "pending",
+  "items": [
+    { "id": "sbi_01k…", "name": "fwh-assistant", "status": "pending", "declaredVersion": "2.1.0",
+      "existing": { "version": 3, "declaredVersion": "2.0.0" },
+      "checks": [{ "level": "warning", "code": "script", "message": "包含脚本 scripts/build.py" }] },
+    { "id": "sbi_01k…", "name": "fwh-report", "status": "unchanged" }
+  ]
+}
+```
+
+审核后:
+
+```json
+{ "id": "sbi_01k…", "name": "fwh-assistant", "status": "approved",
+  "result": { "action": "overwrite", "skill": "fwh-assistant", "version": 4, "published": true } }
+{ "id": "sbi_01k…", "name": "fwh-draft", "status": "rejected", "reviewNote": "脚本中包含测试环境地址,请改为参数" }
+```
+
+CI 中推送示例(正式的推送工具后续再定):
+
+```bash
+cd skills && zip -qr /tmp/skills.zip . && \
+curl -fsS -H "Authorization: Bearer $AGENT_SKILLS_TOKEN" \
+  -F file=@/tmp/skills.zip -F message="$(git log -1 --pretty=%s)" \
+  -F meta="{\"repo\":\"fwh-app\",\"commit\":\"$(git rev-parse HEAD)\"}" \
+  https://agent.zenpower.cc/skills/registry/v1/submissions
+```
+
+### 6.2 后台接口(`/admin/v1`)
+
+| 方法 | 路径 | 说明 |
+| --- | --- | --- |
+| GET / POST / PATCH / DELETE | `/skill-keys[/:id]` | 发布密钥(新建时返回一次明文) |
+| GET | `/skill-submissions?status=pending\|done` | 提交项列表 |
+| GET | `/skill-submissions/:itemId` | 详情:文件清单、检查结果、同名技能信息 |
+| GET | `/skill-submissions/:itemId/file?path=` | 查看提交中的文件 |
+| GET | `/skill-submissions/:itemId/diff?target=<技能ID>&name=` | 与目标技能最新版本的差异(含改名对 SKILL.md 的改动) |
+| POST | `/skill-submissions/:itemId/approve` | `{ action: 'create', name, publish }` 或 `{ action: 'overwrite', target, expectedVersion, publish }` |
+| POST | `/skill-submissions/:itemId/reject` | `{ reason }` |
+| DELETE | `/skill-submissions/:itemId` | 删除已处理的提交项 |
+
+覆盖时提交 `expectedVersion`(审核页看到的目标技能版本号):目标技能在此期间又有新版本时拒绝并提示刷新,保证覆盖的就是对比过的那个版本。
+
+## 7. 数据模型
+
+新增:
+
+| 表 | 主要字段 |
+| --- | --- |
+| `skill_publish_keys` | `id`、`name`、`description`、`keyHash`、`keyPrefix`、`enabled`、`expiresAt`、`createdAt`、`lastUsedAt`、`lastUsedIp` |
+| `skill_submissions` | `id`、`keyId`、`keyName`(副本,密钥删除后仍可显示)、`message`、`meta`(JSON)、`packageSha`、`packageSize`、`createdAt` |
+| `skill_submission_items` | `id`、`submissionId`、`name`、`files`(文件清单,内容在文件库中)、`description`、`declaredVersion`、`checks`(JSON)、`status`、`reviewNote`、`reviewedAt`、`result`(JSON:新建 / 覆盖、技能 ID、版本号、是否发布) |
+
+修改:
+
+- `custom_skill_versions` 增加 `note`(JSON:提交 ID、密钥名称、提交说明),`reason` 增加 `submission`。
+- 去掉技能源:删除 `skill_sources` 表;`custom_skills` 的 `origin`、`sourceId` 不再使用(已有技能全部转为可编辑)。
+
+提交内容直接存入现有的文件库(按内容寻址,相同文件只存一份)。已删除的提交项引用的文件,若不再被任何版本或提交引用,定期清理。
+
+## 8. 去掉目录技能源与上传 zip
+
+- 删除后台"技能源"区域、"上传 zip 导入"按钮,以及对应的接口、自动同步定时任务、上传暂存目录。
+- 已有的从技能源导入的技能转为可编辑,版本历史保留。
+- 保留:后台新建 / 编辑技能、技能内单个文件的上传与替换、导出 zip、版本历史与恢复。
+- 目录选择框继续用于资料库登记。
+
+## 9. 后台界面
+
+"技能与插件 › 自定义技能"左侧:
+
+- 技能列表(新建技能);
+- "待审核 (N)":提交项列表,数字同时显示在导航"技能与插件"旁;
+- "已处理":已批准、已拒绝等提交项,可以查看与删除;
+- "发布密钥":名称、前缀、有效期、最近使用、提交数;新建 / 停用 / 删除。
+
+审核视图(右侧):
+
+- 顶部:技能名、密钥名称、提交说明、来源信息、提交时间;
+- 检查结果;文件树与文件内容;
+- 底部操作:"批准为新技能"(技能名输入框)/ "覆盖已有技能"(选择目标,显示差异,点击后二次确认)/ "拒绝"(填写原因);"立即发布"勾选框默认勾选。
+
+## 10. 安全
+
+- 发布密钥只哈希存储,有有效期;只能访问发布接口,只能看到自己的提交。
+- 未经批准的内容**不会写入生效目录**,引擎看不到;批准时按提交项保存的文件清单生成版本,提交后内容不可修改,因此批准的就是审核时看到的内容。
+- zip 解压沿用现有的防护(路径穿越、压缩炸弹、符号链接);包大小与技能限额与原上传 zip 相同(zip ≤ 50MB,单个技能 ≤ 500 个文件、≤ 20MB)。
+- 推送接口有频率与待审核数量限制。
+
+## 11. 分期
+
+| 阶段 | 内容 |
+| --- | --- |
+| 一期 | 发布密钥、推送接口(含试运行)、自动检查、审核(新建 / 覆盖 / 拒绝)、已处理记录与删除、推送方查询、去掉技能源与上传 zip、审计 |
+| 二期 | 推送工具(命令行,读取环境变量或用户配置)、浏览器登录签发密钥、审核结果通知 |
+
+## 12. 实施记录(2026-11-03)
+
+代码:`apps/server/src/custom-skills/registry.ts`(密钥、推送、检查、审核)、`package.ts`(解压与识别技能目录)、`http/routes/registry.ts`(发布接口);协议文档 `api-reference/registry-reference.ts`(后台"API 协议 › 技能发布 API",可填入发布密钥直接调试,也包含在下载的 PDF 中;测试核对文档与实际路由一致);后台 `SkillReviewPane.vue`、`SkillPublishKeys.vue`;测试 `test/custom-skills.test.ts`(C-01 ~ C-05)。迁移 `0019_skill_registry`、`0020_drop_skill_sources`。
+
+与上文的差异:
+
+| 设计 | 实施 | 原因 |
+| --- | --- | --- |
+| 幂等:重复推送相同内容返回已有的提交项 | 每次推送都生成提交;同一密钥的同名技能替代之前待审核的那一份 | 结果相同(只审核一份),不需要额外的去重逻辑 |
+| 推送限额可在设置中调整 | 固定为每个密钥每小时 30 次、待审核 50 个(`REGISTRY_LIMITS`) | 一期先固定,需要时再放进设置 |
+| 待审核数量显示在后台导航 | 显示在"技能与插件 › 自定义技能"标签上,左侧"待审核 (N)" | 导航只在分组层级,标签上更直接 |
+| 被拒绝、撤回的提交项保留 90 天后清理 | 由管理员在"已处理"中删除;删除后不再被引用的文件随之清理 | 数量不大,避免自动删除审核记录 |
+| 旧的技能源数据 | 迁移删除 `skill_sources` 表和 `custom_skills.origin / source_id`,所有技能变为可编辑 | 一并移除技能源 |

+ 102 - 60
packages/shared/src/api.ts

@@ -484,13 +484,13 @@ export interface ApiOverview {
 
 /** 一套协议:Agent API(/v1,会话)或 Bot API(/bot/v1,机器人与工作组) */
 export interface ApiSpec {
-  id: 'agent' | 'bot'
+  id: 'agent' | 'bot' | 'registry'
   title: string
   /** 一句话说明,以及使用哪种 Key */
   summary: string
   overview: ApiOverview
   groups: ApiEndpointGroup[]
-  /** 事件信封的类型名(数据结构中的一项) */
+  /** 事件信封的类型名(数据结构中的一项);没有事件时为空,不显示"事件类型"一节 */
   eventEnvelope: string
   types: ApiTypeDef[]
   events: ApiEventDef[]
@@ -692,11 +692,13 @@ export interface FileChangesDto {
 export interface CustomSkillVersionDto {
   version: number
   createdAt: string
-  /** create 新建;edit 编辑保存;import 首次导入;sync 同步;restore 恢复 */
-  reason: 'create' | 'edit' | 'import' | 'sync' | 'restore'
+  /** create 新建;edit 编辑保存;restore 恢复;submission 审核通过的推送;import / sync 为早期从技能源导入的版本 */
+  reason: 'create' | 'edit' | 'import' | 'sync' | 'restore' | 'submission'
   changed: FileChangesDto
   fileCount: number
   size: number
+  /** 来自推送时:提交 ID、密钥名称、提交说明 */
+  note: { submissionId: string; keyName: string; message: string } | null
 }
 
 export interface CustomSkillDto {
@@ -704,9 +706,6 @@ export interface CustomSkillDto {
   /** 技能名,也是目录名;技能 ID 为 custom-skills:<name> */
   name: string
   description: string
-  origin: 'authored' | 'imported'
-  sourceId: string | null
-  sourceName: string | null
   enabled: boolean
   /** 包含在 Profile 的"全部技能"中(默认否,需要单独勾选) */
   includeInAll: boolean
@@ -726,82 +725,125 @@ export interface CustomSkillDetailDto extends CustomSkillDto {
   versions: CustomSkillVersionDto[]
 }
 
-export interface SkillSourceInput {
+// ---- 技能发布平台(docs/design/18-skill-registry.md) ----
+
+export interface SkillPublishKeyInput {
+  /** 名称即来源,例如"fwh-app · GitHub Actions" */
   name: string
   description: string
-  /** 服务器上的绝对路径 */
-  path: string
-  syncMode: 'manual' | 'auto'
-  intervalMin: number
+  /** 有效期(天);null 为永久 */
+  expiresInDays: number | null
 }
 
-export interface SkillSourceDto extends SkillSourceInput {
+export interface SkillPublishKeyDto {
   id: string
-  /** 服务器目录(上传的 zip 是一次性导入,不登记为技能源) */
-  kind: 'directory'
-  lastSyncAt: string | null
-  status: 'never' | 'ok' | 'error'
-  lastError: string | null
-  /** 从这个来源导入的技能 */
-  skillNames: string[]
+  name: string
+  description: string
+  prefix: string
+  enabled: boolean
+  expiresAt: string | null
   createdAt: string
+  lastUsedAt: string | null
+  lastUsedIp: string | null
+  submissions: number
+  pending: number
+}
+
+export interface SkillCheckDto {
+  level: 'info' | 'warning' | 'danger'
+  /** script 脚本;secret 疑似密钥;url 外部地址;binary 二进制文件;version 声明版本号;skill_md SKILL.md 提示 */
+  code: 'script' | 'secret' | 'url' | 'binary' | 'version' | 'skill_md'
+  message: string
+  files?: string[]
 }
 
-export interface SkillScanItemDto {
+/** pending 待审核;approved 已批准;rejected 已拒绝;withdrawn 推送方撤回;superseded 被同一密钥更新的推送替代;invalid 校验不通过;unchanged 与同名技能当前版本相同 */
+export type SkillSubmissionStatus = 'pending' | 'approved' | 'rejected' | 'withdrawn' | 'superseded' | 'invalid' | 'unchanged'
+
+export interface SkillSubmissionResultDto {
+  /** create 新建技能;overwrite 覆盖已有技能 */
+  action: 'create' | 'overwrite'
+  skillId: string
+  skillName: string
+  version: number
+  published: boolean
+}
+
+/** 后台看到的提交项 */
+export interface SkillSubmissionItemDto {
+  id: string
+  submissionId: string
   name: string
   description: string
-  /** 在来源中的相对路径 */
-  path: string
+  declaredVersion: string | null
+  status: SkillSubmissionStatus
+  /** invalid 的原因 */
+  reason: string | null
+  checks: SkillCheckDto[]
   fileCount: number
   size: number
-  /** new 新技能;changed 有变化;unchanged 无变化;conflict 同名技能已存在且来源不同;invalid 不能导入 */
-  status: 'new' | 'changed' | 'unchanged' | 'conflict' | 'invalid'
+  keyName: string
+  message: string
+  meta: Record<string, string>
+  createdAt: string
+  /** 同名技能(如有) */
+  existing: { id: string; version: number; declaredVersion: string | null } | null
+  reviewNote: string | null
+  reviewedAt: string | null
+  result: SkillSubmissionResultDto | null
+}
+
+export interface SkillSubmissionItemDetailDto extends SkillSubmissionItemDto {
+  files: CustomSkillFileDto[]
+}
+
+/** 审核时的差异:新建(target 为 null,与空技能对比)或覆盖目标技能的最新版本 */
+export interface SkillSubmissionDiffDto {
+  target: { id: string; name: string; version: number; declaredVersion: string | null; unsaved: boolean; updatedAt: string } | null
+  /** 技能名与 SKILL.md 中的 name 不同时,批准时会改写 SKILL.md */
+  renamed: boolean
+  files: FileDiffDto[]
+}
+
+export type SkillApproveInput =
+  | { action: 'create'; name: string; publish: boolean }
+  | { action: 'overwrite'; targetId: string; expectedVersion: number; publish: boolean }
+
+/** 推送方(发布接口)看到的提交项 */
+export interface RegistryItemDto {
+  id: string
+  name: string
+  status: SkillSubmissionStatus
   reason: string | null
-  changes: FileChangesDto
-  warnings: string[]
-  /** SKILL.md 中声明的版本号(frontmatter 的 version 或 metadata.version) */
   declaredVersion: string | null
-  /** 已存在的同名技能 */
-  existing: {
-    version: number
-    declaredVersion: string | null
-    updatedAt: string
-    /** 有编辑中、尚未保存为版本的改动(覆盖后丢失) */
-    unsaved: boolean
-    /** 所属技能源;null 表示编写的或 zip 导入的 */
-    sourceName: string | null
-  } | null
-}
-
-export interface SkillScanDto {
-  /** 技能源 ID;上传的 zip 为上传 ID */
-  sourceId: string
-  /** 导入时回传,扫描结果变化时拒绝导入(避免导入未审阅的内容) */
-  digest: string
-  skills: SkillScanItemDto[]
-  /** 从这个来源导入过、但来源中已不存在的技能 */
-  missing: string[]
-  /** 跳过的文件(符号链接、特殊文件等) */
-  skipped: string[]
+  existing: { version: number; declaredVersion: string | null } | null
+  changes: FileChangesDto | null
+  checks: SkillCheckDto[]
+  reviewNote: string | null
+  result: { action: 'create' | 'overwrite'; skill: string; version: number; published: boolean } | null
 }
 
-/** 上传的 zip:解压后等待确认导入(一次性,导入或取消后删除;1 小时内有效) */
-export interface SkillUploadDto {
-  uploadId: string
-  /** zip 文件名 */
-  name: string
-  scan: SkillScanDto
+export interface RegistrySubmissionDto {
+  /** 试运行时为空 */
+  id: string
+  /** pending 有待审核的技能;done 都已处理 */
+  status: 'pending' | 'done'
+  message: string
+  meta: Record<string, string>
+  createdAt: string
+  dryRun: boolean
+  items: RegistryItemDto[]
+  /** 跳过的文件(符号链接、不安全的路径等) */
+  skipped: string[]
 }
 
-/** 选择服务器目录:服务进程看到的子目录 */
+/** 选择服务器目录(登记资料库时):服务进程看到的子目录 */
 export interface ServerDirListDto {
   /** 解析后的绝对路径 */
   path: string
   parent: string | null
-  dirs: { name: string; /** 包含 SKILL.md */ skill: boolean }[]
+  dirs: { name: string }[]
   truncated: boolean
-  /** 按技能源的规则在这个目录中找到的技能数 */
-  skillCount: number
 }
 
 export interface FileDiffDto {