From d36b00bbec10de39594d319f04bdb3717878e983 Mon Sep 17 00:00:00 2001 From: Hong Jiarong Date: Sat, 11 Jul 2026 12:55:05 +0800 Subject: [PATCH] feat: make agent roles and skills dynamic --- AGENTS.md | 7 +- .../0017-agent-session-is-provider-bound.md | 10 + ...-execution-surface-bounded-by-workspace.md | 19 +- .../.claude-plugin/plugin.json | 5 - .../skills/data-processing-spec/SKILL.md | 72 ---- .../skills/data-processing-spec/disputes.md | 51 --- .../skills/data-processing-spec/exam-spec.md | 73 ---- .../data-processing-spec/scripts/conf.typ | 83 ----- .../data-processing-spec/scripts/disputes.typ | 311 ------------------ .../data-processing-spec/scripts/exam.typ | 115 ------- .../data-processing-spec/scripts/strict.typ | 130 -------- .../data-processing-spec/strict-spec.md | 85 ----- .../skills/lesson-project/SKILL.md | 24 -- .../skills/lesson-project/samples.md | 252 -------------- .../skills/lesson-project/structure.md | 37 --- .../skills/lesson-project/templates.md | 49 --- .../skills/lesson-project/workflow.md | 18 - .../skills/lesson-project/writing-style.md | 182 ---------- .../skills/outline/SKILL.md | 50 --- hub/deploy/README.md | 40 ++- hub/deploy/agent_config.sh | 28 ++ hub/deploy/backup_silo.sh | 10 +- hub/deploy/cph-hub.service | 1 + hub/deploy/install_service.sh | 6 +- hub/package-lock.json | 4 +- hub/package.json | 3 +- .../migration.sql | 71 ++++ hub/prisma/schema.prisma | 66 ++++ hub/src/agent/configuration.ts | 260 +++++++++++++++ hub/src/agent/curatedSkills.ts | 87 ----- hub/src/agent/models.ts | 8 + hub/src/agent/runner.ts | 15 +- hub/src/agent/security.ts | 24 +- hub/src/agent/skillStore.ts | 217 ++++++++++++ hub/src/deployment/agent-config-cli.ts | 157 +++++++++ hub/src/deployment/bootstrap-silo.ts | 16 + hub/src/feishu/slashCommands.ts | 12 +- hub/src/feishu/trigger.ts | 8 +- hub/src/settings/runtime.ts | 90 ++++- .../integration/agent-configuration.test.ts | 90 +++++ .../integration/agent-runtime-config.test.ts | 121 +++++++ .../integration/agent-sandbox-linux.test.ts | 14 +- hub/test/integration/helpers.ts | 3 + hub/test/integration/silo-bootstrap.test.ts | 8 + hub/test/unit/agent-security.test.ts | 16 +- hub/test/unit/curated-skills.test.ts | 71 ---- hub/test/unit/runner.test.ts | 48 ++- hub/test/unit/skill-store.test.ts | 71 ++++ 48 files changed, 1389 insertions(+), 1749 deletions(-) delete mode 100644 hub/curated-skills-plugin/.claude-plugin/plugin.json delete mode 100644 hub/curated-skills-plugin/skills/data-processing-spec/SKILL.md delete mode 100644 hub/curated-skills-plugin/skills/data-processing-spec/disputes.md delete mode 100644 hub/curated-skills-plugin/skills/data-processing-spec/exam-spec.md delete mode 100644 hub/curated-skills-plugin/skills/data-processing-spec/scripts/conf.typ delete mode 100644 hub/curated-skills-plugin/skills/data-processing-spec/scripts/disputes.typ delete mode 100644 hub/curated-skills-plugin/skills/data-processing-spec/scripts/exam.typ delete mode 100644 hub/curated-skills-plugin/skills/data-processing-spec/scripts/strict.typ delete mode 100644 hub/curated-skills-plugin/skills/data-processing-spec/strict-spec.md delete mode 100644 hub/curated-skills-plugin/skills/lesson-project/SKILL.md delete mode 100644 hub/curated-skills-plugin/skills/lesson-project/samples.md delete mode 100644 hub/curated-skills-plugin/skills/lesson-project/structure.md delete mode 100644 hub/curated-skills-plugin/skills/lesson-project/templates.md delete mode 100644 hub/curated-skills-plugin/skills/lesson-project/workflow.md delete mode 100644 hub/curated-skills-plugin/skills/lesson-project/writing-style.md delete mode 100644 hub/curated-skills-plugin/skills/outline/SKILL.md create mode 100755 hub/deploy/agent_config.sh create mode 100644 hub/prisma/migrations/20260711130000_dynamic_agent_roles_and_skills/migration.sql create mode 100644 hub/src/agent/configuration.ts delete mode 100644 hub/src/agent/curatedSkills.ts create mode 100644 hub/src/agent/skillStore.ts create mode 100644 hub/src/deployment/agent-config-cli.ts create mode 100644 hub/test/integration/agent-configuration.test.ts create mode 100644 hub/test/integration/agent-runtime-config.test.ts delete mode 100644 hub/test/unit/curated-skills.test.ts create mode 100644 hub/test/unit/skill-store.test.ts diff --git a/AGENTS.md b/AGENTS.md index 5d2f5ab..9cf5896 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -28,9 +28,10 @@ service identity、workspace、keyring 与 Feishu/provider connection;进程必须由 `HUB_SILO_ORGANIZATION_ID` fail-closed 绑定唯一 org,平台后台不开放。共享 SaaS 控制面与 Docker adapter 后置(见 ADR-0025)。 -- Agent skill 只来自 Hub release 内审核过的显式 allowlist,以 release-owned 只读 local - plugin 加载;`settingSources: []` 继续禁用项目/用户配置加载。不得把任意 workspace - `.claude` 配置或未审核 skill 变成运行时能力(见 ADR-0018)。 +- Agent role 与 skill 是 Organization-scoped 动态运行配置:role 组合 model、system prompt、 + tools 与已安装 skill;skill 版本进入 content-addressed 持久存储,run 只读加载所选快照。 + `settingSources: []` 继续禁用项目/用户配置加载,不得把任意 workspace `.claude` 配置变成 + 运行时能力(见 ADR-0018)。 ## 纪律 diff --git a/docs/adr/0017-agent-session-is-provider-bound.md b/docs/adr/0017-agent-session-is-provider-bound.md index 5960ef4..63db306 100644 --- a/docs/adr/0017-agent-session-is-provider-bound.md +++ b/docs/adr/0017-agent-session-is-provider-bound.md @@ -37,6 +37,16 @@ that cursor is the `result.session_id`; store it in `AgentSession.metadata` as tool surfaces can differ even when the underlying model is the same; `/draft` and `/review` must not resume the same Claude runtime cursor by accident. +Role definitions are Organization-scoped runtime data. A role bundle selects +its default model, system prompt, tool allowlist and installed Agent skill +versions. PostgreSQL is authoritative for role composition and skill metadata; +skill bytes live in a content-addressed persistent store selected only by the +recorded SHA-256 digest. Updating a role or binding skills takes effect without +a Hub release or process restart. A change to the role's execution surface +(model, prompt, tools, selected skill content) archives its active sessions so +the next run cannot resume a provider context created under stale instructions; +label and ordering-only changes preserve conversational continuity. + Environment variables: ``` ANTHROPIC_BASE_URL=https://openrouter.ai/api diff --git a/docs/adr/0018-agent-execution-surface-bounded-by-workspace.md b/docs/adr/0018-agent-execution-surface-bounded-by-workspace.md index f76a893..9fc181c 100644 --- a/docs/adr/0018-agent-execution-surface-bounded-by-workspace.md +++ b/docs/adr/0018-agent-execution-surface-bounded-by-workspace.md @@ -122,15 +122,16 @@ The boundary is enforced by the Claude Code SDK's built-in sandbox - `settingSources: []` and strict MCP configuration prevent an untrusted workspace or service-user config from widening tools, hooks, MCP servers, or sandbox paths. -- Platform-curated Agent skills are immutable Hub release assets, loaded as a - programmatic local plugin from a release-owned path. The sandbox exposes that - path read-only, and the SDK receives only plugin-qualified allowlist names - through its `skills` option; SDK-bundled skills are disabled. Filesystem - setting sources remain disabled, so a - project cannot register another skill or widen its tools through `.claude` - settings. Requested skill ids are recorded on `run.created`; SDK - initialization/results remain the authoritative evidence that loading - actually succeeded. +- Agent skills are Organization-scoped runtime configuration, not Hub release + assets. A controlled host-console installer imports each version into a + content-addressed persistent store and records its digest in PostgreSQL. A + role selects enabled Organization skills alongside its model, system prompt + and tool allowlist. Each run copies only those selected immutable versions + into a run-scoped plugin outside the project workspace; the sandbox exposes + that snapshot read-only and deletes it after the run. SDK-bundled skills and + filesystem setting sources remain disabled, so project `.claude` content + cannot register skills or widen tools. Requested skill versions are recorded + on `run.created`; SDK initialization remains authoritative loading evidence. - Network: open (see Open Questions). `bypassPermissions` is kept (headless server — no interactive prompts); the diff --git a/hub/curated-skills-plugin/.claude-plugin/plugin.json b/hub/curated-skills-plugin/.claude-plugin/plugin.json deleted file mode 100644 index 0e6db4d..0000000 --- a/hub/curated-skills-plugin/.claude-plugin/plugin.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "name": "cph-curated", - "description": "Reviewed curriculum-production skills shipped with the Curriculum Project Hub.", - "version": "0.0.2" -} diff --git a/hub/curated-skills-plugin/skills/data-processing-spec/SKILL.md b/hub/curated-skills-plugin/skills/data-processing-spec/SKILL.md deleted file mode 100644 index 8f13c24..0000000 --- a/hub/curated-skills-plugin/skills/data-processing-spec/SKILL.md +++ /dev/null @@ -1,72 +0,0 @@ ---- -name: data-processing-spec -description: 物理竞赛实验「数据处理」的两套作答规范——超严格版与考试版。当用户要出实验数据处理题、或要求题目答案/解析"按考试版写""按超严格版写""按严格规范作答",或问"什么是考试版/严格版""不确定度取几位""不确定度怎么修约""连算代入哪个值""拟合要不要算 B 类"等数据处理口径问题时使用。出题与批改时据此确定唯一口径。 ---- - -# 数据处理作答规范(超严格版 / 考试版) - -物理竞赛实验数据处理里,有效数字取位、不确定度修约、连算代入、拟合是否计 B 类等环节 -**各家做法不一致**。为避免"同一份数据出现多个都对的答案",本课程把这些争议点各拍板成 -两套自洽的口径: - -| 版本 | 用途 | 一句话特征 | -|------|------|-----------| -| **超严格版** | 严格训练 | 每一步贴近误差理论最规范做法,接受较繁的计算量 | -| **考试版** | 考试 / 日常训练 | 在规范前提下简化计算,贴近竞赛复赛阅卷习惯 | - -## 怎么用这个 skill - -1. **先确定版本。** 用户出题或批改时通常会说明"按考试版"还是"按超严格版"。 - - 用户明确指定 → 用该版。 - - 用户没指定 → **必须先问**用户要哪一版,不要自己默认。两版在四处刻意不同, - 选错会给出末位不同的答案。 -2. **读对应规范全文,再动手。** 选定版本后,完整阅读对应文件,按其中每一条口径生成 - 题目答案 / 解析 / 评分点: - - 超严格版 → [strict-spec.md](strict-spec.md) - - 考试版 → [exam-spec.md](exam-spec.md) -3. **全程只认一版。** 一道题(含所有小问)自始至终用同一版口径,不得中途混用。 -4. **需要解释"为什么有两版""某争议点各方怎么做"时** → 读 [disputes.md](disputes.md) - (中立罗列各方做法与依据,不拍板)。 - -## 两版差异一览(仅这四处不同) - -下面四项是两版**唯一的区别**;其余约定两版完全一致(见下一节)。出题/批改时重点核对这四项。 - -| 争议环节 | 超严格版 | 考试版 | -|----------|----------|--------| -| **不确定度取几位有效数字** | 首位为 1/2/3 取 2 位,其余取 1 位(A2) | 一律取 1 位(A1) | -| **不确定度的修约方向** | 只进不舍(偏保守,代表:北大) | 四舍六入五凑偶(代表:中科大、第 42 届复赛) | -| **多小问连算代入哪个值** | 代入前一问**未修约的真实值**,仅终值修约 | 代入前一问**已修约的填空值**,接受逐问舍入 | -| **线性拟合不确定度** | A 类 + B 类合成(需算 `u_Bk = u_By / √Σ(xi−x̄)²`) | 只算 A 类(`u_k = σ_k`) | - -> 测量值(中心值)的修约:**两版都用四舍六入五凑偶**——这一条不是差异项。 - -## 两版共同约定(不随版本变化) - -- **A 类不确定度**:取平均值的实验标准差 `u_A = √[Σ(xi−x̄)² / (n(n−1))]`,**不做 t 因子修正**。 -- **B 类不确定度**:`u_B = Δ仪 / √3`(仪器误差限按均匀分布折算)。合成 `u = √(u_A² + u_B²)`。 -- **单次测量**:不假设 A 类不确定度为无穷大,**直接以仪器误差限估算**该次测量不确定度 - (取 `u = Δ仪 / √3`)。出处:实验指导书"杨氏模量"实验对单次测量量的处理;措辞以本组 - 实际指导书为准。 -- **有效数字总原则**:测量值位数必须与不确定度对齐——不确定度精确到哪一位,测量值就写到哪一位。 -- **线性拟合 A 类**:斜率相对不确定度 `σ_k / k = √[ (1/(n−2)) · (1/γ² − 1) ]`(γ 为相关系数)。 - -## 出题/批改自检清单 - -确定版本后,逐项对照所选规范,确保答案在这些点上口径一致: - -- [ ] A 类是否用了"不做 t 修正"的标准差公式 -- [ ] B 类是否 `Δ仪/√3`;单次测量是否用仪器误差限 -- [ ] 不确定度取了几位(A2 还是 A1)—— **按版本** -- [ ] 不确定度末位修约方向(只进不舍 / 四舍六入五凑偶)—— **按版本** -- [ ] 测量值是否与不确定度对齐、是否用四舍六入五凑偶 -- [ ] 多小问连算代入的是真实值还是修约值 —— **按版本** -- [ ] 线性拟合是否计 B 类 —— **按版本** -- [ ] 全卷是否始终只用了这一版口径 - -## 配套 PDF 源码 - -`scripts/` 下保留了两版规范与争议点讨论的 Typst 源码,仅作为内容参考。当前 Educraft Agent -运行时不提供独立 `typst` 命令,不要尝试直接编译这些脚本,也不要安装运行时依赖。用户需要 -成品 PDF 时,明确说明当前能力边界;若内容要进入课程工程,应按 `lesson-project` 的 cph -0.0.2 结构落地并使用 `cph check/build`。 diff --git a/hub/curated-skills-plugin/skills/data-processing-spec/disputes.md b/hub/curated-skills-plugin/skills/data-processing-spec/disputes.md deleted file mode 100644 index f6b9b1f..0000000 --- a/hub/curated-skills-plugin/skills/data-processing-spec/disputes.md +++ /dev/null @@ -1,51 +0,0 @@ -# 数据处理争议点(中立罗列,不拍板) - -本文件解释"为什么会有超严格版 / 考试版两套口径"——每个环节各家做法不一致,本课程把它们各 -拍板成两版。这里**只中立罗列各方做法与依据**,不评对错。需要给学生/教练讲清来龙去脉时引用。 - -## 共同约定(无争议前提) -- A 类不确定度:实验标准差,**不做 t 因子修正**。 -- B 类不确定度:`u_B = Δ仪 / √3`(均匀分布)。 -- 测量值修约:四舍六入五凑偶。 -- 有效数字总原则:测量值位数跟着不确定度走(对齐)。 -- 单次测量:以仪器误差限估算,不假设 A 类无穷大(出处:实验指导书"杨氏模量"部分)。 - -## 争议点 A:不确定度取几位有效数字 -- **A1(考试版采用)**:一律 1 位。如 `0.034→0.03`、`0.12→0.1`。 -- **A2(超严格版采用)**:首位为 1/2/3 时取 2 位,其余取 1 位。如 `0.123→0.12`、`0.67→0.7`。 -- 分歧本质:修约不确定度本身引入的相对误差能容忍多大;A2 为压低该相对误差而保留 2 位。 - -## 争议点 B:有效数字"反向多取一位"变体 -- 设 `u=0.03`,再看测量值对齐位数字:≥3(如 1.87)正常对齐写 `(1.87±0.03)`;以 1/2/3 等更小 - 数起头(如 1.81)则允许测量值再多取一位、不确定度也反向多取一位 → `(1.812±0.034)`。 -- 与 A1/A2 不完全等价,是 A 的一个更细变体。本课程两版都未采用此变体(统一走 A1 或 A2), - 列出仅供识别学生可能用到的写法。 - -## 争议点 C:不确定度本身如何修约 -- **只进不舍(超严格版采用)**:末位一律进位,报告值偏保守。代表:北京大学。 -- **四舍六入五凑偶(考试版采用)**:与测量值同一规则。代表:中国科学技术大学、第 42 届复赛。 -- 提示:第 42 届全国中学生物理竞赛复赛对不确定度采用四舍六入五凑偶。 - -## 争议点 D:多小问连算代入哪个值 -- **代入未修约真实值(超严格版采用)**:用完整精度中间量,仅终值修约;避免舍入误差传播, - 误差理论上更规范。 -- **代入已修约填空值(考试版采用)**:用前一问写出来的修约值;便于逐问复算、阅卷可追溯。 -- 两者数值通常只差最后一两位,边界情形可能影响终值末位。 - -## 争议点 E:线性拟合是否计入 B 类 -- A 类无争议:`σ_k/k = √[ (1/(n−2))·(1/γ²−1) ]`。 -- **只算 A 类(考试版采用)**:直接 `u_k=σ_k`;相当多题目/教材实际只算 A 类,且常不说明理由。 -- **A 类 + B 类合成(超严格版采用)**:把斜率写成 `k=Σci·yi`,`ci=(xi−x̄)/Σ(xj−x̄)²`, - 得 `u_Bk = u_By / √(Σ(xi−x̄)²)`,再 `u_k=√(σ_k²+u_Bk²)`。 -- 为何常省略 B 类:点多、Σ(xi−x̄)² 大时 u_Bk 往往远小于 σ_k 被淹没——但这只是近似经验,非普遍成立。 - -## 速查对照 - -| 编号 | 争议内容 | 超严格版 | 考试版 | -|------|----------|----------|--------| -| A | 不确定度取几位 | 首位 1/2/3 取 2 位(A2) | 一律 1 位(A1) | -| C | 不确定度修约方向 | 只进不舍 | 四舍六入五凑偶 | -| D | 连算代入值 | 未修约真实值 | 已修约填空值 | -| E | 拟合是否计 B 类 | A 类 + B 类合成 | 只算 A 类 | - -> B 项(反向多取一位变体)两版均不采用,故不在版本差异表内。 diff --git a/hub/curated-skills-plugin/skills/data-processing-spec/exam-spec.md b/hub/curated-skills-plugin/skills/data-processing-spec/exam-spec.md deleted file mode 100644 index 1f6d7f1..0000000 --- a/hub/curated-skills-plugin/skills/data-processing-spec/exam-spec.md +++ /dev/null @@ -1,73 +0,0 @@ -# 数据处理规范 · 考试版 - -> 用于**考试与日常训练**。在保证规范性的前提下**简化计算**(不确定度一律 1 位、拟合只算 A 类、 -> 逐问代入修约值),贴近竞赛复赛阅卷习惯。评分以本规范为唯一口径。与超严格版在四处刻意不同 -> (有效数字取位、不确定度修约方向、连算代入值、拟合是否计 B 类)——同一份数据两版可能给出 -> 末位不同的答案,**全程只认本版,不可混用**。 - -## 共同约定(两版一致) - -### A 类不确定度 -多次测量,取平均值的实验标准差: - -``` -u_A = √[ Σ(xi − x̄)² / (n(n−1)) ] -``` - -- **不做 t 因子修正**,直接以上式为 u_A。 - -### B 类不确定度 -- `u_B = Δ仪 / √3`(仪器误差限按均匀分布折算)。 -- 合成:`u = √(u_A² + u_B²)`。 - -### 单次测量 -- 不假设 A 类不确定度为无穷大,**直接以仪器误差限估算**该次测量不确定度,取 `u = Δ仪 / √3`。 -- 出处批注:依据实验指导书"杨氏模量"实验对单次测量量的处理;措辞以本组实际指导书为准。 - -## 有效数字与修约(本版选定口径) - -### 有效数字总原则 -- 测量值(中心值)的位数**必须与不确定度对齐**:不确定度精确到哪一位,测量值就写到哪一位。 - -### 不确定度取几位有效数字 —— 统一 1 位 -- **不确定度一律保留 1 位有效数字**(无论首位是几)。测量值随之对齐到该位。 -- 示例:`u=0.123 → 0.1`,测量值 `1.8127 → 1.8`,记为 `(1.8 ± 0.1)`;`u=0.067 → 0.07`,对齐到该位。 - -### 测量值的修约 —— 四舍六入五凑偶 -- 测量值采用"四舍六入五凑偶"(逢四舍、逢六入、逢五凑偶)。 - -### 不确定度的修约 —— 四舍六入五凑偶 -- 不确定度也采用"四舍六入五凑偶",与测量值同一规则。 -- 提示:第 42 届全国中学生物理竞赛复赛对不确定度即采用四舍六入五凑偶。本版选此口径以贴近 - 近年复赛阅卷习惯(代表:中科大、第 42 届复赛)。 - -## 多小问连算 —— 代入上一问修约后的结果 -- 后一问用到前一问结果时,**代入前一问已修约、写进答题处的那个值**进行计算。 - 即接受每问修约带来的舍入误差,换取逐问可复算、便于阅卷。 -- 示例:杨氏模量第 1 问报告 `d = 1.8 mm`;第 2 问算 E 时**直接代入 1.8 mm**(而非未修约的 1.8127…)。 - -## 线性拟合 —— 只算 A 类 -设 `y = k x + b`。 - -- **斜率只计 A 类不确定度,不计 B 类。** 斜率相对不确定度由相关系数 γ 给出: - -``` -σ_k / k = √[ (1/(n−2)) · (1/γ² − 1) ] -``` - -- 即 `u_k = σ_k`,直接作为斜率不确定度上报。 -- 说明:数据点多、Σ(xi−x̄)² 较大时拟合的 B 类分量通常远小于 A 类而可忽略,本版据此**只算 A 类** - 以简化计算;如需完整合成请改用超严格版。 - -## 速查(考试版口径) - -| 项目 | 本版做法 | -|------|----------| -| A 类不确定度 | 实验标准差,不做 t 修正 | -| B 类不确定度 | Δ仪 / √3 | -| 单次测量 | 以仪器误差限估算 | -| 有效数字 | 不确定度一律 1 位 | -| 测量值修约 | 四舍六入五凑偶 | -| 不确定度修约 | 四舍六入五凑偶 | -| 连算代入 | 代入上一问修约后的结果 | -| 线性拟合 | 只算 A 类 | diff --git a/hub/curated-skills-plugin/skills/data-processing-spec/scripts/conf.typ b/hub/curated-skills-plugin/skills/data-processing-spec/scripts/conf.typ deleted file mode 100644 index 1ff688e..0000000 --- a/hub/curated-skills-plugin/skills/data-processing-spec/scripts/conf.typ +++ /dev/null @@ -1,83 +0,0 @@ -// 共享样式与语义框:两份规范(超严格版 / 考试版)共用 - -#let rule-color = rgb("#0b4f6c") -#let note-color = rgb("#6a4c00") -#let warn-color = rgb("#b3261e") - -// 规范条目框(蓝色):本规范选定的做法 -#let rule(body) = block( - width: 100%, - inset: 10pt, - radius: 4pt, - fill: rgb("#eaf2f6"), - stroke: (left: 3pt + rule-color), - body, -) - -// 批注 / 出处框(黄色) -#let sidenote(body) = block( - width: 100%, - inset: 9pt, - radius: 4pt, - fill: rgb("#fbf6e8"), - stroke: (left: 3pt + note-color), - text(size: 9.5pt, body), -) - -// 提醒框(红色) -#let warn(body) = block( - width: 100%, - inset: 9pt, - radius: 4pt, - fill: rgb("#fdeeec"), - stroke: (left: 3pt + warn-color), - text(size: 9.5pt, body), -) - -// 例子框(灰色) -#let example(body) = block( - width: 100%, - inset: 9pt, - radius: 4pt, - fill: rgb("#f3f3f3"), - stroke: (left: 3pt + rgb("#999")), - text(size: 9.5pt, body), -) - -// 全局配置 + 封面 -#let conf(title: "", subtitle: "", badge: "", badge-color: rgb("#0b4f6c"), doc) = { - set document(title: title, author: "竞赛实验教研组") - set page( - paper: "a4", - margin: (top: 2.4cm, bottom: 2.4cm, left: 2.4cm, right: 2.4cm), - numbering: "1 / 1", - number-align: center, - ) - set text(font: ("Noto Serif CJK SC",), size: 10.5pt, lang: "zh", region: "cn") - set par(justify: true, leading: 0.85em, first-line-indent: (amount: 2em, all: true)) - show heading: set text(font: ("Noto Sans CJK SC",)) - show heading: set block(above: 1.2em, below: 0.7em) - set heading(numbering: "1.1") - show math.equation: set text(font: "New Computer Modern Math") - - // 封面 - align(center)[ - #v(3cm) - #box( - inset: (x: 12pt, y: 6pt), - radius: 6pt, - fill: badge-color, - text(font: ("Noto Sans CJK SC",), size: 13pt, weight: "bold", fill: white, badge), - ) - #v(0.9cm) - #text(font: ("Noto Sans CJK SC",), size: 24pt, weight: "bold", title) - #v(0.5cm) - #text(size: 13pt, fill: rgb("#555"), subtitle) - #v(1.4cm) - #text(size: 11pt)[竞赛实验数据处理 · 评分口径规范] - #v(0.3cm) - #text(size: 10pt, fill: rgb("#777"))[供教练出题与学生研读使用] - ] - pagebreak() - doc -} diff --git a/hub/curated-skills-plugin/skills/data-processing-spec/scripts/disputes.typ b/hub/curated-skills-plugin/skills/data-processing-spec/scripts/disputes.typ deleted file mode 100644 index 47dad71..0000000 --- a/hub/curated-skills-plugin/skills/data-processing-spec/scripts/disputes.typ +++ /dev/null @@ -1,311 +0,0 @@ -// 物理竞赛中数据处理的争议点讨论 -// 定位:争议点讨论为主,只中立罗列各方做法,不给本课程拍板结论。 - -#set document(title: "物理竞赛中数据处理的争议点讨论", author: "竞赛实验教研组") - -// ---------- 字体与页面 ---------- -#set page( - paper: "a4", - margin: (top: 2.4cm, bottom: 2.4cm, left: 2.4cm, right: 2.4cm), - numbering: "1 / 1", - number-align: center, -) - -#set text( - font: ("Noto Serif CJK SC",), - size: 10.5pt, - lang: "zh", - region: "cn", -) -#set par(justify: true, leading: 0.85em, first-line-indent: (amount: 2em, all: true)) -#show heading: set text(font: ("Noto Sans CJK SC",)) -#show heading: set block(above: 1.2em, below: 0.7em) -#set heading(numbering: "1.1") - -// 数学字体不指定 CJK,公式用默认 New Computer Modern Math -#show math.equation: set text(font: "New Computer Modern Math") - -// ---------- 一些可复用的语义框 ---------- -#let dispute-color = rgb("#b3261e") -#let calm-color = rgb("#1b5e20") -#let note-color = rgb("#6a4c00") - -// 无争议约定框 -#let agreed(body) = block( - width: 100%, - inset: 10pt, - radius: 4pt, - fill: rgb("#eef6ee"), - stroke: (left: 3pt + calm-color), - body, -) - -// 争议点框 -#let dispute(body) = block( - width: 100%, - inset: 10pt, - radius: 4pt, - fill: rgb("#fdeeec"), - stroke: (left: 3pt + dispute-color), - body, -) - -// 批注 / 出处框 -#let sidenote(body) = block( - width: 100%, - inset: 9pt, - radius: 4pt, - fill: rgb("#fbf6e8"), - stroke: (left: 3pt + note-color), - text(size: 9.5pt, body), -) - -// 各方做法的小标签 -#let school(name) = box( - inset: (x: 5pt, y: 1.5pt), - radius: 3pt, - fill: rgb("#e8eef7"), - text(size: 9pt, weight: "bold", name), -) - -// ============================================================ -// 封面 -// ============================================================ -#align(center)[ - #v(3.2cm) - #text(font: ("Noto Sans CJK SC",), size: 24pt, weight: "bold")[ - 物理竞赛中数据处理的\ - 争议点讨论 - ] - #v(0.6cm) - #text(size: 13pt, fill: rgb("#555"))[—— 不确定度、有效数字与拟合的多种规范对照 ——] - #v(1.4cm) - #text(size: 11pt)[竞赛实验数据处理 · 教研与教学参考] - #v(0.4cm) - #text(size: 10pt, fill: rgb("#777"))[供教练备课与学生研读使用] -] - -#pagebreak() - -// ============================================================ -// 阅读说明 -// ============================================================ -= 这份文档怎么读 - -本文档的目的,是把竞赛实验数据处理中那些"两种甚至多种做法都在流传、却没有统一答案"的地方一次性摆清楚。它*不是*一份判定对错的评分标准,而是一份*争议点对照表*: - -- 凡是本领域已有共识、几乎不会引起争论的内容,归入 #text(fill: calm-color)[*"约定"*](绿色框),作为后续讨论的共同前提; -- 凡是各家(教材、命题、竞赛习惯)做法不一致的地方,单列为 #text(fill: dispute-color)[*"争议点"*](红色框),并尽量中立地列出每一方的做法与其依据; -- 个别需要交代来源或加以提醒的内容,用#text(fill: note-color)[*批注框*](黄色框)标出。 - -#sidenote[ - *关于"中立"。* 本文档对每个争议点*只罗列、不拍板*。哪一套规则作为本课程或某次测验的评分口径,由教练在使用时另行约定并提前告知学生——这一点务必在出题或考试前说清楚,否则同一份数据会出现多个"都对"的答案。 -] - -// ============================================================ -// 第一部分:共同约定 -// ============================================================ -= 共同约定(基本无争议) - -下面几条在我们的处理体系里是稳定的前提,先固定下来,后面讨论争议时不再反复。 - -== A 类不确定度 - -多次测量下,A 类不确定度按样本标准差给出(贝塞尔公式给出的实验标准差,再除以 $sqrt(n)$ 得到平均值的标准不确定度): - -$ u_A = s(overline(x)) = sqrt(frac(sum_(i=1)^n (x_i - overline(x))^2, n(n-1))) $ - -#agreed[ - *约定 1:A 类不确定度不做 $t$ 因子(学生 $t$ 分布)修正。* 即直接以上式作为 $u_A$,不再乘以与测量次数有关的 $t$ 因子或包含因子。这是本体系的固定口径。 -] - -== B 类不确定度(多次测量) - -B 类不确定度由仪器误差限 $Delta_"仪"$ 给出,按均匀分布折算: - -$ u_B = frac(Delta_"仪", sqrt(3)) $ - -#agreed[ - *约定 2:B 类不确定度 $= Delta_"仪" \/ sqrt(3)$。* 这里取 $sqrt(3)$ 对应仪器误差在 $plus.minus Delta_"仪"$ 区间内服从均匀分布的假设。 -] - -合成不确定度按方和根:$u = sqrt(u_A^2 + u_B^2)$。 - -== 测量值的修约方式 - -#agreed[ - *约定 3:测量值(中心值)一律采用"四舍六入五凑偶"修约。* 即逢四舍、逢六入,逢五时看前一位凑成偶数。注意:这一条只针对*测量值*;不确定度本身怎么修约是有争议的(见 @sec:round-u)。 -] - -#agreed[ - *约定 4:测量值的位数必须与不确定度对齐。* 不确定度精确到哪一位,测量值就写到哪一位,不多写也不少写。换言之,*有效数字跟着不确定度走*——这是整个有效数字问题的总原则。争议只在于"不确定度本身取几位"以及"末位怎么修约"。 -] - -// ============================================================ -// 第二部分:单次测量 -// ============================================================ -= 单次测量的特别约定 - -多次测量时 A 类、B 类各司其职。但有时只做*单次测量*,此时不能简单地认为"没有重复测量,A 类不确定度就趋于无穷大、结果无法估计"。 - -#agreed[ - *约定 5(单次测量):单次测量时,直接用仪器误差限来估算该次测量的不确定度*,即把 $Delta_"仪" \/ sqrt(3)$(或按所采用规范直接用 $Delta_"仪"$)作为这一次测量结果的不确定度,而*不*假设 A 类不确定度为无穷大。 -] - -#sidenote[ - *出处批注。* 此约定的依据来自*实验指导书中"杨氏模量"实验*的相应章节——该实验对某些只测一次的量(如仪器读数类的单次量)即采用"以仪器误差限估算单次测量误差"的处理。使用本文档时,若所在教学体系的指导书版本不同,请以本组实际采用的指导书"杨氏模量"部分为准核对此条措辞。 -] - -// ============================================================ -// 第三部分:争议点 -// ============================================================ -= 争议点 - -以下每一条都没有"唯一正确"的答案。请教练在使用前选定口径并告知学生。 - -== 争议点 A:不确定度取几位有效数字 - -总原则没有争议(约定 4:测量值跟着不确定度对齐)。争议在于*不确定度本身*保留几位有效数字。 - -#dispute[ - *做法 A1:不确定度一律取 1 位有效数字。* - 无论首位是几,不确定度都只写 1 位。例如 $u = 0.034 → 0.03$,$u = 0.12 → 0.1$。对应测量值也只对齐到该位。 - - *做法 A2:首位为 1、2、3 时取 2 位有效数字,其余取 1 位。* - 当不确定度首位较小(1、2、3)时,只留 1 位会带来较大的相对截断,故允许保留 2 位。例如 $u = 0.123 → 0.12$(首位 1,取 2 位),而 $u = 0.67 → 0.7$(首位 6,取 1 位)。 -] - -#sidenote[ - A1 与 A2 的分歧本质,是"修约不确定度本身引入的相对误差能容忍多大"。A2 的"1/2/3 取两位"正是为压低这一相对误差而设。两套都很常见,命题时必须二选一并写明。 -] - -== 争议点 B:有效数字的"反向多取一位"变体 - -这是争议点 A 的一个更细的变体,单独列出,因为它对测量值写法的影响最直接。 - -#dispute[ - *做法 B("看测量值末位决定是否多取一位"):* - 设不确定度形如 $u = 0.03$。再看测量值在对齐位上的数字: - - 若该位数字 $>= 3$(如测量值 $= 1.87$,末位 7),则*正常对齐*,写成 $(1.87 plus.minus 0.03)$; - - 若该位数字以 1、2、3 这类较小数字开头(如测量值 $= 1.81$),则*允许测量值再多取一位*,并*相应地让不确定度也反向多取一位*,写成 $(1.81 plus.minus 0.03) → (1.812 plus.minus 0.034)$ 之类。 -] - -#sidenote[ - 做法 B 的动机与 A2 一致——都是为了在数值较小时避免过度修约损失精度,只不过 B 是"由测量值末位反推是否多留一位,并让不确定度跟着多留一位"。它与 A1/A2 不完全等价,使用时要明确到底以哪条为准,避免学生在同一题里混用三套规则。 -] - -== 争议点 C:不确定度本身如何修约 - -测量值用四舍六入五凑偶已是约定(约定 3)。但*不确定度*的修约方向有分歧。 - -#dispute[ - *做法 C1(只进不舍 / 向上取整):* 不确定度修约时一律*只进不舍*,即末位无论被舍去的部分是多少都进位,使报告的不确定度偏保守(偏大)。 - #v(0.3em) - 代表口径:#school[北京大学] - - *做法 C2(四舍六入五凑偶):* 不确定度与测量值一样,采用四舍六入五凑偶修约。 - #v(0.3em) - 代表口径:#school[中国科学技术大学] #school[第 42 届物理竞赛复赛] -] - -#sidenote[ - *特别提示:* 第 42 届全国中学生物理竞赛复赛对不确定度采用的是*四舍六入五凑偶*(即做法 C2)。若以贴近近年竞赛复赛阅卷习惯为目标,这一点值得在教学时强调;但日常训练里两种都可能遇到,仍以"出题时声明口径"为准。 -] - -== 争议点 D:多小问连算时,代入哪一个值 - -一道大题常有多个小问,前一问的结果会被后一问用到。典型如杨氏模量:第 1 问先求直径 $d$(含不确定度),第 2 问再用 $d$ 求杨氏模量 $E$。问题是:算 $E$ 时代入哪个 $d$? - -#dispute[ - *做法 D1(代入未修约的"真实值"):* 用第 1 问计算过程中得到的*完整精度的 $d$*(小数点后很多位、未做修约)代入后续计算,最后只在终值处统一修约。 - #v(0.3em) - 依据:修约只应在*最终报告*时进行;中途代入修约值会引入*舍入误差*并逐级传播。从误差理论看这是更规范的做法。 - - *做法 D2(代入第 1 问已修约的填空值):* 用第 1 问*答题卡上已经修约、写进横线里的那个 $d$*(如 $d = 1.81 "mm"$)代入后续计算。 - #v(0.3em) - 依据:答题与阅卷的可追溯性——后一问的结果应当能由前一问"写出来的答案"复现;某些阅卷口径据此判分。 -] - -#sidenote[ - D1 是误差理论上更干净的做法(避免人为舍入误差累积),D2 则更贴合"按填写值逐问复算"的阅卷便利。两者在数值上通常只差最后一两位,但在边界情形可能影响终值修约后的末位。出题时应明确要求学生采用哪一种,并保持全卷一致。 -] - -== 争议点 E:线性拟合是否计入 B 类不确定度 - -线性拟合 $y = k x + b$ 中,斜率 $k$ 的*A 类*不确定度有成熟公式,无争议;争议在于*要不要再算 B 类并合成*。 - -=== A 类(无争议部分) - -斜率的 A 类相对不确定度可由相关系数 $gamma$ 表示: - -$ frac(sigma_k, k) = sqrt(frac(1, n-2) (frac(1, gamma^2) - 1)) $ - -其中 $gamma$ 为线性相关系数,$n$ 为数据点个数。这是线性拟合 A 类不确定度的常用表达,本身不引起争议。 - -#agreed[ - *无争议:* 线性拟合斜率的 A 类不确定度套用上面的 $sigma_k \/ k$(用 $gamma$ 表示)公式。 -] - -=== 争议:要不要再加 B 类 - -#dispute[ - *做法 E1(只算 A 类,不计 B 类):* 直接以拟合给出的 $sigma_k$ 作为斜率不确定度,不再考虑各测量点仪器误差带来的 B 类分量。 - #v(0.3em) - 现状:*相当多的题目与教材实际上只算 A 类*,而且常常*没有把"为什么忽略 B 类"说清楚*——这正是混乱的来源。 - - *做法 E2(A 类与 B 类合成):* 认为每个测量点都带有 B 类不确定度,应推导出斜率的 B 类分量后与 A 类方和根合成。 - #v(0.3em) - 现状:原则上更完整,但*少见教材给出现成公式*,需要自行推导(见下)。 -] - -=== 线性拟合 B 类不确定度的推导(供采用 E2 时参考) - -考虑最小二乘斜率的标准表达 - -$ k = frac(sum_(i) (x_i - overline(x))(y_i - overline(y)), sum_(i) (x_i - overline(x))^2) = frac(sum_i (x_i - overline(x)) y_i, sum_i (x_i - overline(x))^2). $ - -把 $k$ 看成各 $y_i$ 的线性组合 $k = sum_i c_i y_i$,其中权重 - -$ c_i = frac(x_i - overline(x), sum_j (x_j - overline(x))^2). $ - -若每个 $y_i$ 带有相互独立的 B 类不确定度 $u_(B,y)$(由纵轴量的仪器误差限给出,$u_(B,y) = Delta_("仪",y) \/ sqrt(3)$,且各点近似相同),按不确定度传播: - -$ u_(B,k) = sqrt(sum_i c_i^2 u_(B,y)^2) = u_(B,y) sqrt(sum_i c_i^2) = frac(u_(B,y), sqrt(sum_i (x_i - overline(x))^2)). $ - -即*斜率的 B 类不确定度等于纵轴单点 B 类不确定度,除以自变量的"离差平方和的平方根"* $sqrt(sum_i (x_i-overline(x))^2)$。 - -如横轴量 $x$ 的仪器误差也不可忽略,可类似地把它折算到 $y$ 方向(乘以斜率 $k$)后并入 $u_(B,y)$;此处从略。最终斜率的合成不确定度为 - -$ u_k = sqrt(sigma_k^2 + u_(B,k)^2). $ - -#sidenote[ - *为什么会有 E1 这种"只算 A 类"的现状?* 当数据点较多、且离差平方和 $sum_i (x_i-overline(x))^2$ 较大时,$u_(B,k) = u_(B,y) \/ sqrt(sum_i (x_i-overline(x))^2)$ 往往远小于 A 类的 $sigma_k$,于是 B 类被"淹没"而省略。但这只是*近似成立的经验*,并非普遍正确——所以是否计入 B 类、以及是否声明忽略理由,仍是一个需要出题时明确的争议点。 -] - -// ============================================================ -// 速查表 -// ============================================================ -#pagebreak() -= 争议点速查表 - -#table( - columns: (auto, 1fr, 1fr), - inset: 8pt, - align: (left + horizon, left, left), - stroke: 0.5pt + rgb("#cccccc"), - fill: (_, row) => if row == 0 { rgb("#e8eef7") } else { white }, - table.header( - [*编号*], [*争议内容*], [*主要分歧 / 代表口径*], - ), - [A], [不确定度取几位有效数字], [A1 一律 1 位 / A2 首位为 1·2·3 时取 2 位], - [B], [有效数字"反向多取一位"变体], [测量值末位 ≥3 正常对齐;以 1·2·3 起更小时,测量值与不确定度同时多取一位], - [C], [不确定度本身如何修约], [C1 只进不舍(北大) / C2 四舍六入五凑偶(中科大、第 42 届复赛)], - [D], [多小问连算代入哪个值], [D1 代入未修约真实值(误差理论更规范) / D2 代入第一问已修约的填空值(便于复算阅卷)], - [E], [线性拟合是否计入 B 类], [E1 只算 A 类(常见但常不说明理由) / E2 A 类与 B 类合成(需自行推导 $u_(B,k)=u_(B,y)\/sqrt(sum (x_i-overline(x))^2)$)], -) - -#v(0.6em) - -#sidenote[ - *使用建议:* 每次出题或测验前,针对表中 A–E 各项各选定一种口径,连同"A 类不做 $t$ 修正""$u_B=Delta_"仪"\/sqrt(3)$""单次测量以仪器误差限估算"等约定一并写在卷首说明里。口径一旦公布,全卷保持一致,避免同一份数据出现多个"都对"的答案。 -] diff --git a/hub/curated-skills-plugin/skills/data-processing-spec/scripts/exam.typ b/hub/curated-skills-plugin/skills/data-processing-spec/scripts/exam.typ deleted file mode 100644 index 047cfc2..0000000 --- a/hub/curated-skills-plugin/skills/data-processing-spec/scripts/exam.typ +++ /dev/null @@ -1,115 +0,0 @@ -#import "conf.typ": conf, rule, sidenote, warn, example - -#show: conf.with( - title: "数据处理规范\n考试版", - subtitle: "—— 贴近竞赛阅卷习惯、计算量适中的实用口径 ——", - badge: "考试版", - badge-color: rgb("#0b4f6c"), -) - -= 规范定位 - -本规范用于*考试与日常训练*场景,在保证规范性的前提下*简化计算*(不确定度一律 1 位、拟合只算 A 类、逐问代入修约值),贴近竞赛复赛的阅卷习惯。学生应严格按本规范作答;评分以本规范为唯一口径。 - -#warn[ - 本规范与《超严格版》在四处刻意不同(有效数字取位、不确定度修约方向、连算代入值、拟合是否计 B 类)。同一份数据在两版下可能给出末位不同的答案,*出题与作答务必只认其中一版*,不可混用。 -] - -= 共同约定(两版一致) - -== A 类不确定度 - -多次测量,A 类不确定度取平均值的实验标准差: - -$ u_A = sqrt(frac(sum_(i=1)^n (x_i - overline(x))^2, n(n-1))) $ - -#rule[*A 类不确定度不做 $t$ 因子修正*,直接以上式为 $u_A$。] - -== B 类不确定度 - -#rule[*B 类不确定度 $u_B = Delta_"仪" \/ sqrt(3)$*(仪器误差限按均匀分布折算)。] - -合成:$u = sqrt(u_A^2 + u_B^2)$。 - -== 单次测量 - -#rule[ - *单次测量*时,不假设 A 类不确定度为无穷大,*直接以仪器误差限估算该次测量的不确定度*(取 $u = Delta_"仪" \/ sqrt(3)$)。 -] - -#sidenote[ - *出处批注:* 此条依据实验指导书"杨氏模量"实验对单次测量量的处理。使用时以本组实际采用的指导书"杨氏模量"部分为准核对措辞。 -] - -= 有效数字与修约(本版选定口径) - -== 有效数字总原则 - -#rule[*测量值(中心值)的位数必须与不确定度对齐*:不确定度精确到哪一位,测量值就写到哪一位。] - -== 不确定度取几位有效数字 —— 统一 1 位 - -#rule[ - *不确定度一律保留 1 位有效数字*(无论首位是几)。测量值随之对齐到该位。 -] - -#example[ - $u = 0.123 → 0.1$,测量值 $1.8127 → 1.8$,记为 $(1.8 plus.minus 0.1)$; $u = 0.067 → 0.07$,对齐到该位。 -] - -== 测量值的修约 —— 四舍六入五凑偶 - -#rule[*测量值采用"四舍六入五凑偶"修约*(逢四舍、逢六入、逢五凑偶)。] - -== 不确定度的修约 —— 四舍六入五凑偶 - -#rule[ - *不确定度也采用"四舍六入五凑偶"修约*,与测量值同一规则。 -] - -#sidenote[ - *提示:* 第 42 届全国中学生物理竞赛复赛对不确定度即采用四舍六入五凑偶。本版选此口径以贴近近年复赛阅卷习惯(代表:中科大、第 42 届复赛)。 -] - -= 多小问连算 —— 代入上一问修约后的结果 - -#rule[ - 大题分多小问、后一问要用到前一问结果时,*代入前一问已修约、写进答题处的那个值*进行计算。即*接受每一问修约带来的舍入误差*,换取逐问可复算、便于阅卷。 -] - -#example[ - 杨氏模量:第 1 问报告 $d = 1.8 "mm"$。第 2 问算 $E$ 时*直接代入 $d = 1.8 "mm"$*(而非未修约的 $1.8127...$)。 -] - -= 线性拟合 —— 只算 A 类 - -设 $y = k x + b$。 - -#rule[ - *线性拟合斜率只计 A 类不确定度,不计 B 类。* 斜率相对不确定度由相关系数 $gamma$ 给出: - $ frac(sigma_k, k) = sqrt(frac(1, n-2) (frac(1, gamma^2) - 1)). $ - 即 $u_k = sigma_k$,直接作为斜率不确定度上报。 -] - -#sidenote[ - 当数据点较多、自变量离差平方和 $sum_i (x_i-overline(x))^2$ 较大时,拟合的 B 类分量通常远小于 A 类而可忽略。本版据此*只算 A 类*以简化计算;如需完整合成请改用《超严格版》。 -] - -= 速查(考试版口径) - -#table( - columns: (auto, 1fr), - inset: 8pt, - align: (left + horizon, left), - stroke: 0.5pt + rgb("#cccccc"), - fill: (_, row) => if row == 0 { rgb("#e3edf2") } else { white }, - table.header([*项目*], [*本版做法*]), - [A 类不确定度], [实验标准差,不做 $t$ 修正], - [B 类不确定度], [$Delta_"仪" \/ sqrt(3)$], - [单次测量], [以仪器误差限估算], - [有效数字], [不确定度一律 1 位], - [测量值修约], [四舍六入五凑偶], - [不确定度修约], [四舍六入五凑偶], - [连算代入], [代入上一问修约后的结果], - [线性拟合], [只算 A 类], -) diff --git a/hub/curated-skills-plugin/skills/data-processing-spec/scripts/strict.typ b/hub/curated-skills-plugin/skills/data-processing-spec/scripts/strict.typ deleted file mode 100644 index 91ea2dd..0000000 --- a/hub/curated-skills-plugin/skills/data-processing-spec/scripts/strict.typ +++ /dev/null @@ -1,130 +0,0 @@ -#import "conf.typ": conf, rule, sidenote, warn, example - -#show: conf.with( - title: "数据处理规范\n超严格版", - subtitle: "—— 每一步都按误差理论最规范的方法处理 ——", - badge: "超严格版", - badge-color: rgb("#7a1f1f"), -) - -= 规范定位 - -本规范用于*严格训练*场景,目标是让每一步都贴近误差理论上最规范的做法,*接受较繁的计算量以换取处理的严谨性*。学生应严格按本规范作答;评分以本规范为唯一口径。 - -#warn[ - 本规范与《考试版》在四处刻意不同(有效数字取位、不确定度修约方向、连算代入值、拟合是否计 B 类)。同一份数据在两版下可能给出末位不同的答案,*出题与作答务必只认其中一版*,不可混用。 -] - -= 共同约定(两版一致) - -== A 类不确定度 - -多次测量,A 类不确定度取平均值的实验标准差: - -$ u_A = sqrt(frac(sum_(i=1)^n (x_i - overline(x))^2, n(n-1))) $ - -#rule[*A 类不确定度不做 $t$ 因子修正*,直接以上式为 $u_A$。] - -== B 类不确定度 - -#rule[*B 类不确定度 $u_B = Delta_"仪" \/ sqrt(3)$*(仪器误差限按均匀分布折算)。] - -合成:$u = sqrt(u_A^2 + u_B^2)$。 - -== 单次测量 - -#rule[ - *单次测量*时,不假设 A 类不确定度为无穷大,*直接以仪器误差限估算该次测量的不确定度*(取 $u = Delta_"仪" \/ sqrt(3)$)。 -] - -#sidenote[ - *出处批注:* 此条依据实验指导书"杨氏模量"实验对单次测量量的处理。使用时以本组实际采用的指导书"杨氏模量"部分为准核对措辞。 -] - -= 有效数字与修约(本版选定口径) - -== 有效数字总原则 - -#rule[*测量值(中心值)的位数必须与不确定度对齐*:不确定度精确到哪一位,测量值就写到哪一位。] - -== 不确定度取几位有效数字 —— 采用 A2 - -#rule[ - *不确定度首位为 1、2、3 时保留 2 位有效数字;首位为 4\~9 时保留 1 位。* -] - -#example[ - $u = 0.123 → 0.12$(首位 1,取 2 位); $u = 0.067 → 0.07$(首位 6,取 1 位); $u = 0.28 → 0.28$(首位 2,取 2 位)。 -] - -== 测量值的修约 —— 四舍六入五凑偶 - -#rule[*测量值一律采用"四舍六入五凑偶"修约*(逢四舍、逢六入、逢五凑偶)。] - -== 不确定度的修约 —— 只进不舍 - -#rule[ - *不确定度修约时一律"只进不舍"*:在保留位之后只要有非零数字(乃至向上保守),末位即进位,使报告的不确定度偏保守(偏大)。 -] - -#example[ - $u = 0.121 → 0.13$(保留 2 位,末位进 1); $u = 0.341 → 0.4$(保留 1 位,进位)。 -] - -#sidenote[ - "只进不舍"是较保守的口径(代表:北京大学)。它确保报告的不确定度不会因修约而偏小。 -] - -= 多小问连算 —— 代入未修约真实值 - -#rule[ - 大题分多小问、后一问要用到前一问结果时,*一律代入前一问计算所得的完整精度数值(未修约的"真实值")*,仅在每问*最终报告*时按上面的规则修约。中途不得代入已修约的填空值。 -] - -#example[ - 杨氏模量:第 1 问算得 $d = 1.8127... "mm"$(报告时修约为 $1.81 "mm"$)。第 2 问算 $E$ 时*代入 $d = 1.8127...$*,而非 $1.81$,避免逐级累积舍入误差。 -] - -= 线性拟合 —— A 类与 B 类合成 - -设 $y = k x + b$。 - -== A 类 - -斜率 A 类相对不确定度由相关系数 $gamma$ 表示: - -$ frac(sigma_k, k) = sqrt(frac(1, n-2) (frac(1, gamma^2) - 1)) $ - -== B 类(本版必须计入) - -把斜率写成各 $y_i$ 的线性组合 $k = sum_i c_i y_i$,权重 $c_i = (x_i - overline(x)) \/ sum_j (x_j - overline(x))^2$。设各点纵轴 B 类不确定度近似相同、为 $u_(B,y) = Delta_("仪",y)\/sqrt(3)$,按传播: - -$ u_(B,k) = u_(B,y) sqrt(sum_i c_i^2) = frac(u_(B,y), sqrt(sum_i (x_i - overline(x))^2)). $ - -#rule[ - *斜率不确定度取 A 类与 B 类的方和根*: - $ u_k = sqrt(sigma_k^2 + u_(B,k)^2), wide u_(B,k) = frac(u_(B,y), sqrt(sum_i (x_i - overline(x))^2)). $ -] - -#sidenote[ - 若横轴量 $x$ 的仪器误差不可忽略,可乘以斜率 $k$ 折算到 $y$ 方向后并入 $u_(B,y)$。当数据点多、$sum_i (x_i-overline(x))^2$ 大时 $u_(B,k)$ 往往很小,但本版*不因此省略*,一律计入。 -] - -= 速查(超严格版口径) - -#table( - columns: (auto, 1fr), - inset: 8pt, - align: (left + horizon, left), - stroke: 0.5pt + rgb("#cccccc"), - fill: (_, row) => if row == 0 { rgb("#f0e6e6") } else { white }, - table.header([*项目*], [*本版做法*]), - [A 类不确定度], [实验标准差,不做 $t$ 修正], - [B 类不确定度], [$Delta_"仪" \/ sqrt(3)$], - [单次测量], [以仪器误差限估算], - [有效数字], [首位 1/2/3 取 2 位,其余 1 位(A2)], - [测量值修约], [四舍六入五凑偶], - [不确定度修约], [只进不舍], - [连算代入], [代入未修约真实值], - [线性拟合], [A 类 + B 类合成], -) diff --git a/hub/curated-skills-plugin/skills/data-processing-spec/strict-spec.md b/hub/curated-skills-plugin/skills/data-processing-spec/strict-spec.md deleted file mode 100644 index c5a6547..0000000 --- a/hub/curated-skills-plugin/skills/data-processing-spec/strict-spec.md +++ /dev/null @@ -1,85 +0,0 @@ -# 数据处理规范 · 超严格版 - -> 用于**严格训练**。目标:每一步贴近误差理论上最规范的做法,**接受较繁的计算量以换取严谨性**。 -> 评分以本规范为唯一口径。与考试版在四处刻意不同(有效数字取位、不确定度修约方向、连算代入值、 -> 拟合是否计 B 类)——同一份数据两版可能给出末位不同的答案,**全程只认本版,不可混用**。 - -## 共同约定(两版一致) - -### A 类不确定度 -多次测量,取平均值的实验标准差: - -``` -u_A = √[ Σ(xi − x̄)² / (n(n−1)) ] -``` - -- **不做 t 因子修正**,直接以上式为 u_A。 - -### B 类不确定度 -- `u_B = Δ仪 / √3`(仪器误差限按均匀分布折算)。 -- 合成:`u = √(u_A² + u_B²)`。 - -### 单次测量 -- 不假设 A 类不确定度为无穷大,**直接以仪器误差限估算**该次测量不确定度,取 `u = Δ仪 / √3`。 -- 出处批注:依据实验指导书"杨氏模量"实验对单次测量量的处理;措辞以本组实际指导书为准。 - -## 有效数字与修约(本版选定口径) - -### 有效数字总原则 -- 测量值(中心值)的位数**必须与不确定度对齐**:不确定度精确到哪一位,测量值就写到哪一位。 - -### 不确定度取几位有效数字 —— 采用 A2 -- **首位为 1、2、3 时保留 2 位有效数字;首位为 4~9 时保留 1 位。** -- 示例:`u=0.123 → 0.12`(首位 1,取 2 位);`u=0.067 → 0.07`(首位 6,取 1 位);`u=0.28 → 0.28`(首位 2,取 2 位)。 - -### 测量值的修约 —— 四舍六入五凑偶 -- 测量值一律采用"四舍六入五凑偶"(逢四舍、逢六入、逢五凑偶)。 - -### 不确定度的修约 —— 只进不舍 -- 不确定度修约时一律**只进不舍**:保留位之后只要有非零数字即向上进位,使报告值偏保守(偏大)。 -- 示例:`u=0.121 → 0.13`(保留 2 位,进位);`u=0.341 → 0.4`(保留 1 位,进位)。 -- 说明:"只进不舍"是较保守口径(代表:北京大学),确保报告的不确定度不因修约而偏小。 - -## 多小问连算 —— 代入未修约真实值 -- 后一问用到前一问结果时,**一律代入前一问计算所得的完整精度数值(未修约的真实值)**, - 仅在每问**最终报告**时修约。中途不得代入已修约的填空值。 -- 示例:杨氏模量第 1 问算得 `d = 1.8127… mm`(报告修约为 `1.81 mm`);第 2 问算 E 时 - **代入 1.8127…**,而非 1.81,避免逐级累积舍入误差。 - -## 线性拟合 —— A 类与 B 类合成 -设 `y = k x + b`。 - -### A 类 -斜率 A 类相对不确定度由相关系数 γ 表示: - -``` -σ_k / k = √[ (1/(n−2)) · (1/γ² − 1) ] -``` - -### B 类(本版必须计入) -把斜率写成各 yi 的线性组合 `k = Σ ci·yi`,权重 `ci = (xi − x̄) / Σ(xj − x̄)²`。 -设各点纵轴 B 类不确定度近似相同 `u_By = Δ仪,y / √3`,按传播: - -``` -u_Bk = u_By · √(Σ ci²) = u_By / √( Σ(xi − x̄)² ) -``` - -### 斜率不确定度(本版上报值) -``` -u_k = √( σ_k² + u_Bk² ), u_Bk = u_By / √( Σ(xi − x̄)² ) -``` -- 若横轴量 x 的仪器误差不可忽略,可乘以斜率 k 折算到 y 方向后并入 u_By。 -- 即使数据点多、Σ(xi−x̄)² 大致使 u_Bk 很小,本版**也不省略**,一律计入。 - -## 速查(超严格版口径) - -| 项目 | 本版做法 | -|------|----------| -| A 类不确定度 | 实验标准差,不做 t 修正 | -| B 类不确定度 | Δ仪 / √3 | -| 单次测量 | 以仪器误差限估算 | -| 有效数字 | 首位 1/2/3 取 2 位,其余 1 位(A2) | -| 测量值修约 | 四舍六入五凑偶 | -| 不确定度修约 | 只进不舍 | -| 连算代入 | 代入未修约真实值 | -| 线性拟合 | A 类 + B 类合成 | diff --git a/hub/curated-skills-plugin/skills/lesson-project/SKILL.md b/hub/curated-skills-plugin/skills/lesson-project/SKILL.md deleted file mode 100644 index 4143221..0000000 --- a/hub/curated-skills-plugin/skills/lesson-project/SKILL.md +++ /dev/null @@ -1,24 +0,0 @@ ---- -name: lesson-project -description: 把项目根目录 outline.md 落成符合 cph 0.0.2 的结构化讲义工程,并用 cph check/build 验证和生成教师版、学生版 PDF。 ---- - -# 把 outline.md 落成 cph 0.0.2 工程 - -只在当前项目 workspace 内工作。先完整阅读 `outline.md`,再依次阅读本 skill 的 `structure.md`、`templates.md`、`workflow.md` 和 `writing-style.md`。 - -## 不可违反的边界 - -- 当前唯一工程清单是 `manifest.toml`,版本契约是 `.cph-version`;不要创建旧格式 `project.toml`、`info.toml` 或根 `main.typ`。 -- element 只允许 `segment`、`lemma`、`example`、`sop`,字段以 `structure.md` 为准。 -- 不生成 commentary、hint、answer、instruction、handout、summary 等 cph 0.0.2 不支持的字段。 -- 忠实于 outline;缺题面、公式或关键结论时询问用户,不擅自补写。 -- 使用 `cph check .` 验证结构,使用 `cph build . --target student` 和 `cph build . --target teacher` 构建;不要直接调用 `typst compile`。 -- 任一命令失败都保留完整错误并修复根因,不删除内容来糊绿。 - -## 完成标准 - -1. `cph check .` 为 0 errors。 -2. 两个 `cph build` 命令退出码为 0。 -3. 产物位于 `build/student.pdf` 和 `build/teacher.pdf`。 -4. 简报列出落地的 element、仍需用户补充的内容和两份 PDF 路径。 diff --git a/hub/curated-skills-plugin/skills/lesson-project/samples.md b/hub/curated-skills-plugin/skills/lesson-project/samples.md deleted file mode 100644 index bd7579c..0000000 --- a/hub/curated-skills-plugin/skills/lesson-project/samples.md +++ /dev/null @@ -1,252 +0,0 @@ -# 写得好的样例片段 - -本文件从两份现行讲义里抽取代表性片段,按 element 类型分类。看这些片段是为了对齐"写出来 -就该是这样"的标准。文风、连贯性、推导风、归宿判断都靠这些样例校准——[writing-style.md] -讲方法论,本文件给出对应方法论的具体落地。 - -样例出处: -- EM-131 保角变换法(学生版讲义) -- 简正模(第 19 章) - ---- - -## segment:物理引入的范例 - -样例摘自简正模 §19.1.1 动能的表示。 - -> 要考察一个多自由度体系在平衡位置附近的小振动,一种普适的方法是写出体系的动能和势能 -> 然后代入拉格朗日方程。这里我们假设体系的广义坐标的为 $q_1, q_2, \dots, q_n$,那么动能 -> 一定可以写为 -> -> $$T = \frac{1}{2}\sum_{i,j} f_{i,j}(q_1, q_2, \dots, q_n)\,\dot q_i \dot q_j .$$ -> -> 其中 $f_{i,j}$ 是一个关于广义坐标的函数。例如当我们选择极坐标系描述二维空间中的运动 -> 的时候,有 -> -> $$T = \tfrac{1}{2} m\dot r^2 + \tfrac{1}{2} m r^2 \dot\theta^2 ,$$ -> -> 可见 $\dot\theta^2$ 对应的 $f$ 为 $mr^2$。考虑到这里我们考虑的振动是在平衡位置附近的 -> 小振动,广义坐标的导数 $\dot q_i$ 是小量,而在振动过程中 $f$ 的改变是一阶的,因此如 -> 果仅仅保留到二阶小量,我们可以将上式改写为 ……(接下来矩阵化、对角化) - -**为什么写得好**:第一句直接给出物理设置(多自由度小振动 + 普适方法)。引入一般动能形式 -之后立刻举一个最简单的极坐标例子让公式落地,然后顺着"小振动→二阶小量"的物理逻辑推进到 -矩阵化。整段没有"接下来要做的是""本节的核心是""为后面 X 节铺垫"这类编排话——下一步是 -什么由物理决定,不需要预告。 - ---- - -## segment:概念串联的范例 - -样例摘自保角变换 §1.2 复势的定义。 - -> 考虑一个二维静电场问题,电势 $\varphi(x,y)$ 满足拉普拉斯方程。由上一节的讨论,必然 -> 存在一个共轭调和函数 $\psi(x,y)$,使得 $\varphi$ 和 $\psi$ 共同构成一个解析函数 -> -> $$W(z) = \varphi(x,y) + \mathrm{i}\psi(x,y),$$ -> -> 称为复势。其中 $\varphi$ 为电势,$\psi$ 为流函数,电通量则正比于两条流线的流函数差值。 -> 等势线 $\varphi = \text{const}$ 与电力线 $\psi = \text{const}$ 处处正交,这与式 (3) -> 的几何意义完全吻合。 -> -> 从复势中提取电场只需要做一次求导。对上式求导得到 -> -> $$\frac{\mathrm{d}W}{\mathrm{d}z} = \frac{\partial\varphi}{\partial x} + \mathrm{i}\frac{\partial\psi}{\partial x} = -E_x + \mathrm{i}E_y,$$ -> -> 其中最后一步利用了 $E_x = -\partial\varphi/\partial x$ 以及式 (3) 给出的 $\partial\psi/\partial x = -\partial\varphi/\partial y = E_y$。 - -**为什么写得好**:用"由上一节的讨论""这与式 (3) 的几何意义完全吻合""利用了式 (3)"三次 -回引前文,每一次都是物理推导中真正用到了前文结论。回引方式简洁、点到为止,不展开复述。 -对比之下,错误的回引是"还记得我们在第 X 节讲的那个图吗,这里就是它的回扣"。 - ---- - -## lemma stmt:简洁陈述的范例 - -样例摘自保角变换 §1.1 末,柯西-黎曼条件的引出。 - -> 设复变量 $z = x + \mathrm{i}y$,考虑复变函数 $f(z) = u(x,y) + \mathrm{i}v(x,y)$, -> 其中 $u$ 和 $v$ 是两个实值函数。我们要求 $f$ 的导数在复平面上处处存在且与求导方向 -> 无关。沿实轴方向求导给出 ……,而沿虚轴方向求导给出 ……,两个表达式的实部和虚部分别 -> 相等,立即得到柯西-黎曼条件 -> -> $$\frac{\partial u}{\partial x} = \frac{\partial v}{\partial y}, \qquad \frac{\partial u}{\partial y} = -\frac{\partial v}{\partial x}.$$ -> -> 满足此式的函数称为解析函数。从此式可以读出一个重要的几何性质:$u$ 的梯度与 $v$ 的 -> 梯度正交。这意味着 $u = \text{const}$ 与 $v = \text{const}$ 两族曲线处处正交。 - -**为什么写得好**:定理陈述(柯西-黎曼条件)由前面的物理设置自然推出,给出公式之后用一 -两句话陈述它的几何含义。整段没有任何"这是核心定理""务必掌握""非常重要"的元评论,几何 -含义陈述本身就是对定理意义的最好说明。 - ---- - -## lemma proof:纯推导的范例 - -样例摘自简正模 §19.1.3,证明 $\frac{\partial}{\partial q_i}\bigl(\tfrac{1}{2}\boldsymbol{q}^\mathrm{T}\boldsymbol{M}\boldsymbol{q}\bigr)\boldsymbol{e}_i = \boldsymbol{M}\boldsymbol{q}$。 - -> 将被求导的式子展开,为 -> -> $$\tfrac{1}{2}\boldsymbol{q}^\mathrm{T}\boldsymbol{M}\boldsymbol{q} = \sum_{i,j}\tfrac{1}{2} m_{ij} q_i q_j = \sum_i \sum_j \tfrac{1}{2} m_{ij} q_i q_j .$$ -> -> 考察其中与 $q_i$ 有关的部分,有可能是第一个求和取 $i$,可能是第二个求和取 $i$,也可 -> 能是两个求和都取 $i$,把这三类相加为 -> -> $$\sum_{j\neq i}\tfrac{1}{2} m_{ij} q_i q_j + \sum_{j\neq i}\tfrac{1}{2} m_{ji} q_j q_i + \tfrac{1}{2} m_{ii} q_i^2 .$$ -> -> 代回原式得到 -> -> $$\text{left side} = \frac{\partial}{\partial q_i}\Bigl[\sum_{j\neq i}\tfrac{1}{2} m_{ij} q_i q_j + \sum_{j\neq i}\tfrac{1}{2} m_{ji} q_j q_i + \tfrac{1}{2} m_{ii} q_i^2\Bigr]\boldsymbol{e}_i$$ -> $$= \sum_{j\neq i}\bigl[\tfrac{1}{2} m_{ij} q_j + \tfrac{1}{2} m_{ji} q_j\bigr]\boldsymbol{e}_i + m_{ii} q_i \boldsymbol{e}_i$$ -> $$= \sum_j m_{ij} q_j \boldsymbol{e}_i = \boldsymbol{M}\boldsymbol{q} ,$$ -> -> 倒数第二个等号利用了 $\boldsymbol{M}$ 作为对称矩阵的性质。 - -**为什么写得好**:整段就是一连串公式加最短衔接词——"展开为""考察……部分""相加为""代回 -得到""利用了……的性质"。没有"我们要做的第一步是……""现在我们考虑……""注意到这一步非常 -关键……"这类讲解语言。推导自身的逻辑就是叙事,不需要再多一层元叙述。 - ---- - -## lemma proof:含分步推导的范例 - -样例摘自简正模 §19.1.1 末段(动能对角化的几步推进)。 - -> 显然我们可以适当分配交叉项使得 $\boldsymbol{M}$ 是一个对称矩阵,这意味着它可对角化。 -> 令 $\boldsymbol{M}$ 的对角化形式为 -> -> $$\boldsymbol{M} = \boldsymbol{P}\boldsymbol{\Lambda}\boldsymbol{P}^{-1} .$$ -> -> 此时动能可以改写为 -> -> $$T = \dot{\boldsymbol{q}}^\mathrm{T} \boldsymbol{P}\boldsymbol{\Lambda}\boldsymbol{P}^{-1} \dot{\boldsymbol{q}} .$$ -> -> 定义新的广义坐标 -> -> $$\boldsymbol{q}^* = \boldsymbol{P}^{-1} \boldsymbol{q} ,$$ -> -> 又由于 $\boldsymbol{P}^{-1}$ 的每一行都是 $\boldsymbol{M}$ 的本征矢量 $\boldsymbol{x}_i$, -> 也可以得到新广义坐标的各个分量为 -> -> $$q_i^* = \boldsymbol{x}_i \cdot \boldsymbol{q}_i .$$ -> -> 若令 $\boldsymbol{M}$ 的本征值为 $m_i$,则可以将动能写为不含广义坐标交叉项的形式,即 -> -> $$T = \sum_i \tfrac{1}{2} m_i (\dot q_i^*)^2 .$$ - -**为什么写得好**:每一步都是一行"陈述 + 公式",陈述部分极短("令 $\boldsymbol{M}$ 的 -对角化形式为""定义新的广义坐标""若令 $\boldsymbol{M}$ 的本征值为 $m_i$"),公式紧跟。 -六个公式块用五个衔接句串起来,每个衔接句平均不到 10 字。 - ---- - -## example problem:题面紧凑的范例 - -样例摘自保角变换 EM131.14、EM131.18。 - -> 例 EM131.14:空间中有两个半径分别为 $R_1$ 和 $R_2$ 的一大一小两个圆柱,其中心间距 -> 为 $D$,试在 $D < R_2 - R_1$ 的条件下计算两个圆柱之间的电容。 - -> 例 EM131.18:有一个半长轴为 $A$、短半轴为 $B$ 的无限长导体椭圆柱,将其置于沿长轴方 -> 向的均匀外电场 $E_0$ 中,试求椭圆柱外的电势分布和表面电荷密度。 - -**为什么写得好**:题面只给"物理设置 + 所求量"两件事,参数齐全、约束条件齐全。没有"为了 -练习……""下面这道题考察……""请同学们仔细思考"等元描述。 - ---- - -## example solution:纯推导的范例 - -样例摘自简正模例题 19.4。 - -> 解:不论通过对角化矩阵还是加减消元都可以很容易得到简正坐标为 -> -> $$\xi_{1,2} = x_1 \pm x_2 .$$ - -**为什么写得好**:solution 可以很短——所求量直接由前面建立的方法得到的话,给出结果即可, -不必为了凑字数把方法再讲一遍。"不论通过对角化矩阵还是加减消元"这句话指明可走的路径, -然后立刻给结果。 - ---- - -## example solution:分步推导的范例 - -样例摘自简正模例题 19.5(含约当正规型求解)。 - -> 重新定义 $\boldsymbol{\xi}$,它的两个分量分别为 $2 x_1 + x_2$ 与 $2 x_1 - x_2$,那么 -> 分量 $\xi_1$ 和 $\xi_2$ 满足的方程为 -> -> $$\ddot\xi_1 + \xi_1 + \xi_2 = 0 ,$$ -> $$\ddot\xi_2 + \xi_2 = 0 .$$ -> -> 先求解 $\xi_2$,很容易得到通解 -> -> $$\xi_2 = B \cos(t + \varphi_2) .$$ -> -> 再将 $\xi_2$ 代回 $\xi_1$ 满足的方程得到 -> -> $$\xi_1 = A \cos(t + \varphi_1) - \tfrac{B}{2} t \sin(t + \varphi_2) .$$ -> -> 通过 $\xi_1$ 和 $\xi_2$ 反解 $x_1$ 和 $x_2$,即 -> -> $$x_1 = \tfrac{\xi_1 + \xi_2}{4}, \quad x_2 = \tfrac{\xi_1 - \xi_2}{2} .$$ -> -> 最终有 -> -> $$x_1 = \tfrac{A}{4}\cos(t+\varphi_1) + \tfrac{B}{4}\cos(t+\varphi_2) - \tfrac{B}{8} t \sin(t+\varphi_2) ,$$ -> $$x_2 = \tfrac{A}{2}\cos(t+\varphi_1) - \tfrac{B}{2}\cos(t+\varphi_2) - \tfrac{B}{4} t \sin(t+\varphi_2) .$$ - -**为什么写得好**:分步走的求解里每一步都用"先求解""再将……代回""通过……反解""最终有" -之类的最短衔接。每个衔接词不超过三四个字,跟在公式之间纯粹起到流向指示的作用,不夹叙 -任何讲解。看完一遍这种 solution,下次自己写就该写成这个样子。 - ---- - -## 段与段之间的过渡:物理逻辑的范例 - -样例摘自简正模 §19.1.1 末到 §19.1.2 开头。 - -> 总结来说,在平衡位置附近,我们一定可以选择一组广义坐标,使得动能形式如 (19.9) 式。 -> -> ## 19.1.2 势能的表示 -> -> 在平衡位置附近,对振动有贡献的是势能的二阶项,不妨令其为 …… - -**为什么写得好**:§19.1.1 的最后一句是对该小节内容的客观归纳("我们一定可以选择一组广义 -坐标,使得动能形式如 (19.9)"),不是"接下来就讲势能"的预告。§19.1.2 第一句直接进入势能的 -设置——之所以能进入,是因为已经写完动能、还差势能就能进拉格朗日方程,这是物理逻辑要求 -的下一步,作者不需要在 19.1.1 末尾说"下一节会讲势能"。读者通过物理逻辑就能自然预期到 -下一节的内容。 - -**反例(不要写成这样)**: - -> ……我们看到动能可以通过对角化写成无交叉项的形式。**这只是动能这一半的工作**,**接下来 -> 我们要对势能做同样的事情,然后把两者代入拉格朗日方程,这是本节的核心目标**。 -> -> ## 势能的表示 -> -> 现在我们来处理势能 …… - -反例里加粗的两句完全是元叙述,物理上没有任何新信息——拿掉这两句读者照样知道下一节是 -势能。这种话出现在 textbook 里就是把大纲编排话误带进了讲义。 - ---- - -## 整体风格的负面对照 - -为了让样例的"好"更明显,把同样的物理内容用错误风格再写一遍。 - -错误版(不要这样写): - -> 我们现在面对的是一个学生最容易卡住的地方——多自由度系统的小振动看起来比单摆复杂得多。 -> 但其实只要找到一个统一的语言,问题就会变得清楚。这个统一的语言就是动能和势能的二次型 -> 展开,再加上拉格朗日方程。本节是整章的基础,建议同学们一定要把这一节的推导完整做一遍, -> 否则后面的内容都会跟不上。下面我们先来看动能的形式。 - -为什么错:第一句"学生最容易卡住""看起来比单摆复杂得多"是教研判断,不该出现在学生看的 -教材里;"统一的语言""会变得清楚"是情感修饰;"本节是整章的基础""建议同学们一定要……否则 -后面的内容都会跟不上"是讲师对学生的指令性叙述,不是物理陈述;"下面我们先来看……"是 -编排预告。 - -把这一段擦掉,直接写"要考察一个多自由度体系在平衡位置附近的小振动,一种普适的方法是 -写出体系的动能和势能然后代入拉格朗日方程"——这就是正确的范例。 diff --git a/hub/curated-skills-plugin/skills/lesson-project/structure.md b/hub/curated-skills-plugin/skills/lesson-project/structure.md deleted file mode 100644 index 7d694f8..0000000 --- a/hub/curated-skills-plugin/skills/lesson-project/structure.md +++ /dev/null @@ -1,37 +0,0 @@ -# cph 0.0.2 工程结构 - -```text -/ -├── .cph-version # 固定写 0.0.2 -├── manifest.toml -├── outline.md -├── exports/ -│ ├── student.typ -│ └── teacher.typ -├── segments/<名称>/ -│ ├── element.toml # kind = "segment" -│ └── textbook.typ -├── lemmas/<名称>/ -│ ├── element.toml # kind = "lemma" -│ ├── stmt.typ -│ └── proof.typ # 可选 -├── examples/<名称>/ -│ ├── element.toml # kind = "example";可有 source = "..." -│ ├── problem.typ -│ └── solution.typ -├── sop/<名称>/ -│ ├── element.toml # kind = "sop" -│ └── sop.typ -└── build/ -``` - -`manifest.toml` 中的 `[[parts]]` 顺序就是最终讲义顺序。每项只写 `kind` 与相对 `path`;可选字段是否存在由 cph 在构建时解析。目录名与清单路径必须逐字一致。 - -当前字段契约: - -- segment:必需 `textbook.typ`。 -- lemma:必需 `stmt.typ`,可选 `proof.typ`。 -- example:必需 `problem.typ` 与 `solution.typ`;`element.toml` 可写字符串 `source`。 -- sop:必需 `sop.typ`。 - -章节标题没有独立 kind。需要在讲义中显示章节过渡时,创建一个 segment,并在 `textbook.typ` 中用 Typst 标题表达。 diff --git a/hub/curated-skills-plugin/skills/lesson-project/templates.md b/hub/curated-skills-plugin/skills/lesson-project/templates.md deleted file mode 100644 index 3487159..0000000 --- a/hub/curated-skills-plugin/skills/lesson-project/templates.md +++ /dev/null @@ -1,49 +0,0 @@ -# cph 0.0.2 最小模板 - -## manifest.toml - -```toml -[project] -id = "local-" -name = "<项目名>" - -[info] -title = "<讲义标题>" -author = "范式教育教研组" - -[[parts]] -kind = "segment" -path = "segments/<名称>" - -[targets.student] -artifact = { type = "single-file", filepath = "build/student.pdf" } -[[targets.student.steps]] -type = "typst-compile" -template = "exports/student.typ" - -[targets.teacher] -artifact = { type = "single-file", filepath = "build/teacher.pdf" } -[[targets.teacher.steps]] -type = "typst-compile" -template = "exports/teacher.typ" -``` - -项目 id 必须稳定且只含安全字符;已有 id 不得改。`.cph-version` 内容固定为 `0.0.2` 加换行。 - -## element.toml - -```toml -kind = "segment" -``` - -将 kind 替换为对应类型。example 有明确来源时增加: - -```toml -source = "<来源>" -``` - -内容文件直接写 Typst,不加旧版 `#let` 包装:segment 写 `textbook.typ`,lemma 写 `stmt.typ`/可选 `proof.typ`,example 写 `problem.typ`/`solution.typ`,sop 写 `sop.typ`。 - -## exports 模板 - -不要凭记忆手写长模板。优先保留项目已有的 `exports/student.typ` 与 `exports/teacher.typ`。如果新项目缺失,先向用户说明需要当前 cph 0.0.2 标准模板;不要回退到旧版 `main.typ` 架构。 diff --git a/hub/curated-skills-plugin/skills/lesson-project/workflow.md b/hub/curated-skills-plugin/skills/lesson-project/workflow.md deleted file mode 100644 index c058023..0000000 --- a/hub/curated-skills-plugin/skills/lesson-project/workflow.md +++ /dev/null @@ -1,18 +0,0 @@ -# 从 outline.md 到 PDF - -1. 读取 `outline.md`,按出现顺序列出类型、名称、必需内容和可选要求。 -2. 检查现有工程。已有 `manifest.toml` 时保留 project id、既有内容和用户修改;不存在时使用 `templates.md` 创建最小工程。 -3. 为每条大纲创建对应 element 目录和文件。所有内容只来自大纲与用户提供的材料。 -4. 按大纲顺序更新 `manifest.toml` 的 `[[parts]]`。 -5. 运行 `cph check .`,逐条修复真实结构错误。 -6. 运行: - - ```bash - cph build . --target student - cph build . --target teacher - ``` - -7. 确认 `build/student.pdf`、`build/teacher.pdf` 存在且非空。 -8. 如用户需要,通过受控的 `send_file` 工具发送产物;不要用任意网络命令外传文件。 - -若 outline 缺少 example 的题面或解析、lemma 的明确结论,必须在创建不完整 element 前询问用户。不要留下能通过检查但内容虚假的占位文本。 diff --git a/hub/curated-skills-plugin/skills/lesson-project/writing-style.md b/hub/curated-skills-plugin/skills/lesson-project/writing-style.md deleted file mode 100644 index 36c5639..0000000 --- a/hub/curated-skills-plugin/skills/lesson-project/writing-style.md +++ /dev/null @@ -1,182 +0,0 @@ -## 撰写风格与格式规范 - -写工程文件时除了字段对、能编过,还要满足下面这些**风格与排版约束**。`textbook.typ`、 -`stmt.typ`、`proof.typ`、`problem.typ`、`solution.typ` 和 `sop.typ` 的内容都要遵守。 - -阅读样例 [samples.md](samples.md) 里收录的好片段。本文件给方法论与红线,samples.md 给 -具体的"写成那样就对"的例子。两份配合看。 - -## 核心思想:物理逻辑驱动行文 - -讲义和大纲的本质差,是讲义靠**物理因果链**把段落串起来,大纲靠**编排话**把条目列起来。 -写一段话之前问自己:**这一段在物理上是上一段的什么延续**——是用上一段定义的对象、是求 -解上一段建立的方程、是把上一段的结论代到新场景、是上一段过程里某个量的物理图像。如果 -回答得出,段与段就是连贯的物理推进;如果答不出,只是凭"我下面想讲 X"在串,那这一段就 -是大纲风。 - -样例 [简正模 19.1.1] 的推进顺序——动能的一般形式 → 二阶展开 → 矩阵化 → 对角化引入新 -广义坐标——每一步都是上一步的物理延续。我们写 textbook 要争取做到同样的连贯性。 - -## 写作视角 - -教材的对象是学生。**视角是教材作者在向学生陈述物理本身**,不是教研团队在讨论怎么讲这门 -课。前者用第一人称复数加陈述句,后者用讲师对自己的指令。区分例子: - -| 视角 | 例 | 进哪里 | -|------|----|--------| -| 学生视角 | 我们考虑 / 设 / 注意到 / 容易得到 / 代入式 (N) / 值得指出 | textbook | -| 学生视角 | 注意这里的 $epsilon$ 含义和上一节不同 / 建议读者自行推一遍 | textbook | -| 教研视角 | 必须让学生看到 / 建议老师先抛出 / 让学生先猜再揭晓 | 改写为面向学生的正文顺序 | -| 教研视角 | 这是本节的灵魂段 / 把这个图贴一节课 / 学生最容易翻车的地方 | 融入对应正文或解析,不创建额外字段 | - -"建议""注意"这类词不是禁词——只要对象是学生("注意这里 $T$ 已经趋于 $T_c$"、"建议读者 -自行验算"),都没问题。判断标准始终是**对象是不是学生**。 - -## 内容归宿判定 - -每一句话写下来之前先问归谁。 - -进 **textbook**:物理设置、定义、推导、结论、对结论的客观评议(量级、适用范围、与已知 -结论的对照、反直觉之处、可能误用的边界)、必要的举例与模型归纳、本节定位(如果 outline -的章首"说明"明确要求让学生有一个 general 感受,那就保留——但要用陈述物理的语气,例如 -"$sigma$ 是界面性质而非液面专有",不要用陈述教研策略的语气,例如"本节是大而全的建模专题")。 - -当前 cph 0.0.2 没有 commentary / instruction 字段。真正影响理解的易错点应改写为面向学生 -的 `textbook.typ`、`proof.typ` 或 `solution.typ`;只对教师有意义的内部动作建议不进入工程。 - -**最常见的错误**是把 outline 描述里"讲解策略"那段原样落到 textbook 里。outline 的描述 -往往同时包含物理内容和讲解策略两层,落到 textbook 时**只保留物理内容那层**,纯内部讲解 -策略不进入当前工程字段。 - -## 文风:理工男、性冷淡 - -行文应当**冷静、客观、信息密度高**。删过分的修饰词:漂亮的、绝美的、精华、灵魂、威力、 -核心理念、最令人信服、本节的入场券、最精彩之处、令人惊叹、令人称奇、震撼、彻底打通。 -保留必要的客观评议,例如反直觉的、值得指出的、量级正确的、与实测相符、超出本节范围、 -精度有限。客观评议不带情感色彩。 - -修饰语的判定标准是:拿掉之后物理陈述是否还成立。如果拿掉后陈述完整,那这个修饰语就是 -多余的。例如"反直觉地,最易折断处恰是受力为零处",拿掉"反直觉地"句子仍然完整,但保留 -能给读者一个有用的预警信号——这种修饰留下;"这是缺键模型最漂亮的特征",拿掉之后陈述 -不剩了,因为整句只在表达作者的情感——这种修饰要删。 - -## 关于"预告"与"回扣" - -物理上确实需要前后引用时,用最简洁的方式说出来,不做铺垫: - -- ✅ "下一节将用同一组论证处理固体表面。" -- ✅ "由式 (N),$L_m$ 随 $T$ 单调下降。" -- ❌ "这里埋一个伏笔——固体表面那一节会回扣,到时学生会看到……" -- ❌ "至此从微观键能到宏观浸润的整条物理链条全部建立。" - -判定标准:陈述未来内容用陈述句、不带情感、不带"伏笔""回扣""一里"等编排语言;要回引 -前文时直接用式号或一句"由前面的讨论"。 - -## proof 与 solution 也走纯推导风 - -proof / solution 是**一连串公式与最小衔接词**,不是讲解。一段证明里只允许出现: -公式、用于把上一行连到下一行的最短连接词(代入、由、化简得、即得、解出、注意到、令)、 -以及一两句必要的物理含义说明。**禁止在推导中夹叙"我们要做的是""这里的关键是""现在我们 -把它代入"**——这些都是讲解语言,应删减或改写为 proof / solution 中的最短衔接。 - -衔接词举例: - -``` -由 @骨架公式, -$ sigma_(L G) = Delta U dot n_s . $ -代入 @缺键-亏损能 与 @缺键-面密度 得 -$ sigma_(L G) = (1 - zeta) L_m / N_A dot (rho N_A / mu)^(2\/3) , $ -化简即得 @缺键一般式。 -``` - -注意几个特征:每一步都有式号引用、连接词不超过两个汉字、没有"先做 A 再做 B"的元叙述、 -也没有对结果的情感评议。 - -如果证明确实需要分步走,可以用"第一步""第二步"或者直接用陈述把每一步定位——但每一步 -内部仍然是公式驱动。看 [samples.md](samples.md) 的 proof 范例。 - -## 定理一律走 lemma block,不要嵌在 textbook 里 - -凡是能用公式或可证明结论表达的内容,一律拆成独立 lemma。textbook 只负责把读者引到那个 -定理跟前,**不要在 textbook 里复述定理结论本身**。 - -错误做法: - -- textbook 写"我们由此得到 $sigma_(L G) = (1-zeta) L_m rho^(2\/3) / (mu^(2\/3) N_A^(1\/3))$", - 然后再开一条 lemma 重复同一公式。 - -正确做法: - -- textbook 写到"代入骨架公式即得液气界面张力的解析式"为止,立刻接 lemma block。lemma 的 - stmt 给完整结论。 - -## 排版规则 - -不要对任何知识点、概念、公式或专有名词做加粗处理。Typst 里加粗的写法是 `bold(...)` -(**不是** `*...*`,星号是 markdown 的写法,与 typst 加粗语义混在一起容易踩坑)。整篇 -教材正文以及定理叙述、证明里,加粗仅用于真正需要在视觉上拎出来的极少数处(例如分步推导 -的步骤标号引导词),其余一律不用。 - -引入概念时不要在中文名后面加括号附上英文。英文术语只在该术语必须以英文形式被引用(如 -"LJ 势能"中的"LJ")或确有歧义需要消歧时才出现,否则只用中文。 - -## 数学排版(Typst 语法) - -公式下标只用阿拉伯数字、希腊字母或单个英文字母,禁止用一个有含义的英文词或缩写当下标。 -入射量用 `i`、出射量用 `o`、表面用 `s`、体相用 `b` 等,单字母即可,不要写 `"in"` / -`"out"` / `"surf"` / `"bulk"`。 - -求导符号里的 `d` 一定要用 Typst 的 `dif` 让它显示成正体。不要直接写 `d x`——那会被排 -成斜体的 d。例如: - -``` -sigma dif A -integral_0^L F dif x -(dif gamma) / (dif epsilon) -``` - -偏导符号用 `partial`,不要用 `diff`。`diff` 是 Typst 旧版本的偏导写法,新版本已经 -deprecated,写出来会触发 stderr 警告。 - -``` -(partial F) / (partial A) -((partial sigma) / (partial T))_(A, V) -``` - -虚数单位的 `i` 同理要用正体。Typst 里直接写 `i` 是斜体,需要先在文件开头定义一次 - -``` -#let ii = math.upright("i") -``` - -之后所有用到虚数的地方都写 `ii`,例如 `e^(ii omega t)`。 - -加粗的数学符号(如矢量)用 `bold(...)`,不要用 markdown 风的 `*...*`。例如: - -``` -bold(F) = m bold(a) -nabla times bold(E) = - (partial bold(B)) / (partial t) -``` - -正负号写 `plus.minus`,不要写 `pm`——后者在 Typst 数学里不存在。例如: - -``` -x = plus.minus sqrt(b^2 - 4 a c) -``` - -Typst 不存在 `varepsilon`。Epsilon 字母只有 `epsilon` 与 `epsilon.alt`,按需选用。 - -## 其它常用 Typst 数学排版备忘 - -- 标量斜体、矢量加粗(用 `bold(...)`)、单位与函数名正体(如 `op("sin")` 已内置,直接 - 写 `sin x`、`cos x`、`ln x` 即可)。 -- 公式编号通过 `<标签>` 标记,引用用 `@标签`。同一课程内标签必须全局唯一。 -- 数学块用 `$ ... $`(块状)或行内 `$...$`。块状公式两端的 `$` 要有空格隔开,否则会被 - 解析为行内。 -- 微分元等正体粒子(除 `dif` 外的几个):`partial`(偏导符号已经是正体)、单位向量带 - hat 用 `hat(x)`。 -- 希腊字母大小写区分:`sigma` / `Sigma`、`gamma` / `Gamma`。 - -如有更复杂的排版需求(如 cases 分支、矩阵、长公式断行)需要用到却不确定写法,**停下来 -问用户**或查 Typst 文档;不要凭直觉用 LaTeX 语法塞进去——很多 LaTeX 控制序列在 Typst -里都不存在或语义不同。 diff --git a/hub/curated-skills-plugin/skills/outline/SKILL.md b/hub/curated-skills-plugin/skills/outline/SKILL.md deleted file mode 100644 index 89ce0b8..0000000 --- a/hub/curated-skills-plugin/skills/outline/SKILL.md +++ /dev/null @@ -1,50 +0,0 @@ ---- -name: outline -description: 根据教研讨论结论生成结构化课程粗大纲并写入项目根目录 outline.md。用户要求写大纲、整理课程结构,或准备把讨论落成 cph 工程时使用。 ---- - -# 生成可落地为 cph 0.0.2 工程的课程大纲 - -将已经确认的教研结论写入项目根目录 `outline.md`。大纲是后续 `lesson-project` skill 的施工图,不是自由扩写的文章。 - -## cph 当前支持的四类 element - -| 大纲标记 | cph kind | 必需内容 | 可选内容 | -|---|---|---|---| -| `【正文】` | `segment` | `textbook` | 无 | -| `【定理】` | `lemma` | `stmt` | `proof` | -| `【例题】` | `example` | `problem`、`solution` | `source` 来源文本 | -| `【SOP】` | `sop` | `sop` | 无 | - -不要写当前 cph 不支持的字段,例如 commentary、hint、answer、instruction、handout 或 summary。需要保留的点评、提示、授课建议应明确并入对应正文、证明或解析的描述中。 - -## 写法 - -1. 用 Markdown 标题表达课程章节层级。 -2. 每个 element 单独成条,格式为 `- **【类型】名字**:描述`。 -3. 描述必须足以让后续作者直接写对应 `.typ` 文件;不能只有“介绍一下”“讲清楚”等空话。 -4. 定理必须给出明确结论或公式;若需要证明,在下一行写 ` > **证明要求**:...`。不需要证明时明确写无需证明。 -5. 例题必须给出完整题面,或清楚说明引用来源与必要改编;同时写 ` > **解析要求**:...`。若有来源,写 ` > **来源**:...`。 -6. 不替用户补充未确认的领域事实。缺关键题面、公式或结论时,停下来询问。 -7. `> **说明**:...` 只用于施工说明,不进入正式讲义内容。 - -## 最小示例 - -```markdown -# 表面张力 - -## 宏观图像 - -- **【正文】液面拉伸的本质**:解释增加液面面积为何需要外界做功,并引出表面能密度。 - -- **【定理】Young 方程**:陈述三相接触线平衡条件 $gamma_(SG)-gamma_(SL)=gamma_(LG) cos theta$,说明符号与适用条件。 - > **证明要求**:从总界面能对接触线位移的一阶变分推出。 - -- **【例题】接触角反演**:给定三种界面张力,求平衡接触角并判断完全浸润条件。 - > **解析要求**:先检查 Young 方程是否存在实数解,再讨论边界情形。 - > **来源**:自编。 - -- **【SOP】三相浸润判断流程**:形成“列界面能—检查完全浸润—求接触角—验证范围”的固定步骤。 -``` - -完成前检查:每条都能唯一映射到上表中的 cph 文件;所有公式、题面、证明和解析要求均来自已确认材料。 diff --git a/hub/deploy/README.md b/hub/deploy/README.md index 01151df..a4dd145 100644 --- a/hub/deploy/README.md +++ b/hub/deploy/README.md @@ -54,6 +54,34 @@ Default state paths are: /var/cache/cph-hub/org-a ``` +Organization Agent roles and skills are runtime configuration. Skill versions +are stored below the Silo state directory (`state/skills`) and are included in +`backup_silo.sh` as `agent-skills.tar`; PostgreSQL stores role bundles, skill +metadata and role-to-skill selection. Operate them as the Silo service user so +content ownership remains correct: + +```sh +sudo INSTANCE_ID=org-a \ + ENV_FILE=/srv/curriculum-project-hub/.secrets/org-a/platform.env \ + bash hub/deploy/agent_config.sh install-skill \ + --organization org-a --source /staging/typst --version 1 +sudo INSTANCE_ID=org-a \ + ENV_FILE=/srv/curriculum-project-hub/.secrets/org-a/platform.env \ + bash hub/deploy/agent_config.sh upsert-role \ + --organization org-a --role draft --label 草稿 --tools-json null +sudo INSTANCE_ID=org-a \ + ENV_FILE=/srv/curriculum-project-hub/.secrets/org-a/platform.env \ + bash hub/deploy/agent_config.sh set-role-skills \ + --organization org-a --role draft --skills outline,lesson-project,typst +sudo INSTANCE_ID=org-a \ + ENV_FILE=/srv/curriculum-project-hub/.secrets/org-a/platform.env \ + bash hub/deploy/agent_config.sh list --organization org-a +``` + +`--tools-json null` means the full registered tool surface; `[]` means no +ordinary tools. SDK-bundled skills and workspace/user setting sources remain +disabled regardless of runtime configuration. + ## Bootstrap the only Organization Prepare a root-owned `0600` JSON file containing @@ -129,16 +157,24 @@ sudo INSTANCE_ID=org-a \ bash hub/deploy/backup_silo.sh ``` -The business set contains the PostgreSQL custom dump and workspace archive. The +The business set contains the PostgreSQL custom dump, workspace archive and +`agent-skills.tar`. The separate recovery set contains the keyring and environment. Both include checksums; neither destination may be the live host's only disk. -Restore into a separate drill database/workspace, verify checksums, then run: +Restore into a separate drill database, workspace and skill-store directory; +verify checksums before extracting both tar archives, then run: ```sh set -a; . /path/to/restored/platform.env; set +a +mkdir -p "$HUB_PROJECT_WORKSPACE_ROOT" "$HUB_SKILL_STORE_ROOT" +tar -xf /path/to/business/workspaces.tar -C "$HUB_PROJECT_WORKSPACE_ROOT" +tar -xf /path/to/business/agent-skills.tar -C "$HUB_SKILL_STORE_ROOT" node hub/dist/deployment/restore-preflight.js \ --keyring-file /path/to/restored/secret-keyring.json +sudo INSTANCE_ID=org-a ENV_FILE=/path/to/restored/platform.env \ + HUB_DIR=/path/to/restored/release/hub \ + bash hub/deploy/agent_config.sh verify-store --organization org-a ``` Traffic stays disabled until the sole Organization and every Feishu/provider diff --git a/hub/deploy/agent_config.sh b/hub/deploy/agent_config.sh new file mode 100755 index 0000000..be1d23f --- /dev/null +++ b/hub/deploy/agent_config.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +set -euo pipefail + +INSTANCE_ID="${INSTANCE_ID:?INSTANCE_ID required}" +ENV_FILE="${ENV_FILE:?ENV_FILE required}" +SERVICE_USER="${SERVICE_USER:-cph-$INSTANCE_ID}" +HUB_DIR="${HUB_DIR:-/srv/curriculum-project-hub/current/hub}" + +[ "$(id -u)" -eq 0 ] || { echo "agent config console must run as root" >&2; exit 1; } +[ -r "$ENV_FILE" ] || { echo "environment file is not readable: $ENV_FILE" >&2; exit 1; } +[ -f "$HUB_DIR/dist/deployment/agent-config-cli.js" ] || { echo "Agent config CLI missing below $HUB_DIR" >&2; exit 1; } +id "$SERVICE_USER" >/dev/null 2>&1 || { echo "service user missing: $SERVICE_USER" >&2; exit 1; } + +set -a +# shellcheck disable=SC1090 +. "$ENV_FILE" +set +a +: "${DATABASE_URL:?DATABASE_URL missing from ENV_FILE}" +: "${HUB_SILO_ORGANIZATION_ID:?HUB_SILO_ORGANIZATION_ID missing from ENV_FILE}" + +exec runuser --user "$SERVICE_USER" -- \ + env -i \ + DATABASE_URL="$DATABASE_URL" \ + HUB_SILO_ORGANIZATION_ID="$HUB_SILO_ORGANIZATION_ID" \ + HUB_SKILL_STORE_ROOT="${HUB_SKILL_STORE_ROOT:-/var/lib/cph-hub/$INSTANCE_ID/state/skills}" \ + XDG_STATE_HOME="${XDG_STATE_HOME:-/var/lib/cph-hub/$INSTANCE_ID/state}" \ + PATH="${PATH:-/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin}" \ + node "$HUB_DIR/dist/deployment/agent-config-cli.js" "$@" diff --git a/hub/deploy/backup_silo.sh b/hub/deploy/backup_silo.sh index 66d3847..ffc6552 100755 --- a/hub/deploy/backup_silo.sh +++ b/hub/deploy/backup_silo.sh @@ -9,6 +9,7 @@ KEYRING_FILE="${KEYRING_FILE:?KEYRING_FILE required}" BUSINESS_BACKUP_DIR="${BUSINESS_BACKUP_DIR:?BUSINESS_BACKUP_DIR required}" RECOVERY_BACKUP_DIR="${RECOVERY_BACKUP_DIR:?RECOVERY_BACKUP_DIR required}" SERVICE_UNIT="cph-hub-$INSTANCE_ID.service" +SKILL_STORE_ROOT="${SKILL_STORE_ROOT:-/var/lib/cph-hub/$INSTANCE_ID/state/skills}" [ "$(id -u)" -eq 0 ] || { echo "backup must run as root" >&2; exit 1; } umask 077 @@ -37,12 +38,14 @@ set +a : "${DATABASE_URL:?DATABASE_URL missing from ENV_FILE}" : "${HUB_PROJECT_WORKSPACE_ROOT:?HUB_PROJECT_WORKSPACE_ROOT missing from ENV_FILE}" [ -d "$HUB_PROJECT_WORKSPACE_ROOT" ] || { echo "workspace root missing" >&2; exit 1; } +[ -d "$SKILL_STORE_ROOT" ] || { echo "skill store root missing" >&2; exit 1; } install -d -o root -g root -m 0700 "$BUSINESS_BACKUP_DIR" "$RECOVERY_BACKUP_DIR" business_root="$(realpath -m "$BUSINESS_BACKUP_DIR")" recovery_root="$(realpath -m "$RECOVERY_BACKUP_DIR")" workspace_root="$(realpath -m "$HUB_PROJECT_WORKSPACE_ROOT")" secret_root="$(realpath -m "$(dirname "$KEYRING_FILE")")" +skill_root="$(realpath -m "$SKILL_STORE_ROOT")" paths_overlap() { local left="$1" right="$2" [ "$left" = "$right" ] || [[ "$left/" == "$right/"* ]] || [[ "$right/" == "$left/"* ]] @@ -58,6 +61,10 @@ for pair in \ exit 1 fi done +if paths_overlap "$business_root" "$skill_root" || paths_overlap "$recovery_root" "$skill_root" || paths_overlap "$workspace_root" "$skill_root"; then + echo "backup destinations, workspace and skill store must not overlap: $skill_root" >&2 + exit 1 +fi stamp="$(date -u +%Y%m%dT%H%M%SZ)" business="$business_root/$INSTANCE_ID-$stamp" recovery="$recovery_root/$INSTANCE_ID-$stamp" @@ -65,13 +72,14 @@ install -d -o root -g root -m 0700 "$business" "$recovery" pg_dump --format=custom --file="$business/database.dump" "$DATABASE_URL" tar --create --file="$business/workspaces.tar" --directory="$HUB_PROJECT_WORKSPACE_ROOT" . +tar --create --file="$business/agent-skills.tar" --directory="$SKILL_STORE_ROOT" . cp --preserve=mode,ownership,timestamps "$KEYRING_FILE" "$recovery/secret-keyring.json" cp --preserve=mode,ownership,timestamps "$ENV_FILE" "$recovery/platform.env" chmod 0600 "$recovery/secret-keyring.json" "$recovery/platform.env" ( cd "$business" - sha256sum database.dump workspaces.tar > SHA256SUMS + sha256sum database.dump workspaces.tar agent-skills.tar > SHA256SUMS ) ( cd "$recovery" diff --git a/hub/deploy/cph-hub.service b/hub/deploy/cph-hub.service index eda8bac..5c1ce2a 100644 --- a/hub/deploy/cph-hub.service +++ b/hub/deploy/cph-hub.service @@ -18,6 +18,7 @@ EnvironmentFile=__ENV_FILE__ Environment=HOME=__SERVICE_HOME__ Environment=XDG_STATE_HOME=__STATE_DIR__ Environment=XDG_CACHE_HOME=__CACHE_DIR__ +Environment=HUB_SKILL_STORE_ROOT=__SKILL_STORE_ROOT__ Environment=PATH=__RUNTIME_PATH__ # ADR-0024: the root-owned source remains unreadable by the service account; # systemd materializes a read-only per-unit credential at runtime. diff --git a/hub/deploy/install_service.sh b/hub/deploy/install_service.sh index 500c448..3b2e31c 100755 --- a/hub/deploy/install_service.sh +++ b/hub/deploy/install_service.sh @@ -23,6 +23,7 @@ SERVICE_GROUP="${SERVICE_GROUP:-$SERVICE_USER}" SERVICE_HOME="${SERVICE_HOME:-/var/lib/cph-hub/$INSTANCE_ID/home}" STATE_DIR="${STATE_DIR:-/var/lib/cph-hub/$INSTANCE_ID/state}" CACHE_DIR="${CACHE_DIR:-/var/cache/cph-hub/$INSTANCE_ID}" +SKILL_STORE_ROOT="${SKILL_STORE_ROOT:-$STATE_DIR/skills}" WORKSPACE_ROOT="${WORKSPACE_ROOT:?WORKSPACE_ROOT required (use a short per-Silo path such as /w/997)}" HOST="${HOST:-127.0.0.1}" PORT="${PORT:?PORT is required and must be unique on the host}" @@ -105,6 +106,7 @@ for pair in \ "SERVICE_HOME:$SERVICE_HOME" \ "STATE_DIR:$STATE_DIR" \ "CACHE_DIR:$CACHE_DIR" \ + "SKILL_STORE_ROOT:$SKILL_STORE_ROOT" \ "WORKSPACE_ROOT:$WORKSPACE_ROOT" \ "ENV_FILE:$ENV_FILE" \ "KEYRING_FILE:$KEYRING_FILE" \ @@ -281,6 +283,7 @@ provision_directory() { provision_directory "$SERVICE_HOME" provision_directory "$STATE_DIR" provision_directory "$CACHE_DIR" +provision_directory "$SKILL_STORE_ROOT" provision_directory "$WORKSPACE_ROOT" # Resolve every provisioned path again and verify uid/gid/mode before writing @@ -297,6 +300,7 @@ sed \ -e "s|__SERVICE_HOME__|$SERVICE_HOME|g" \ -e "s|__STATE_DIR__|$STATE_DIR|g" \ -e "s|__CACHE_DIR__|$CACHE_DIR|g" \ + -e "s|__SKILL_STORE_ROOT__|$SKILL_STORE_ROOT|g" \ -e "s|__WORKSPACE_ROOT__|$WORKSPACE_ROOT|g" \ -e "s|__HUB_DIR__|$HUB_DIR|g" \ -e "s|__ENV_FILE__|$ENV_FILE|g" \ @@ -315,5 +319,5 @@ install -o root -g root -m 0644 "$TMP_UNIT" "$UNIT" systemctl daemon-reload systemctl enable "$SERVICE_UNIT" echo "[install] installed $SERVICE_UNIT for $SERVICE_USER:$SERVICE_GROUP" -echo "[install] home=$SERVICE_HOME state=$STATE_DIR cache=$CACHE_DIR workspaces=$WORKSPACE_ROOT" +echo "[install] home=$SERVICE_HOME state=$STATE_DIR cache=$CACHE_DIR skills=$SKILL_STORE_ROOT workspaces=$WORKSPACE_ROOT" echo "[install] start with: systemctl start $SERVICE_UNIT" diff --git a/hub/package-lock.json b/hub/package-lock.json index 825db59..bc26cdd 100644 --- a/hub/package-lock.json +++ b/hub/package-lock.json @@ -1,12 +1,12 @@ { "name": "@paradigm/hub", - "version": "0.0.9", + "version": "0.0.10", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@paradigm/hub", - "version": "0.0.9", + "version": "0.0.10", "dependencies": { "@anthropic-ai/claude-agent-sdk": "^0.3.202", "@fastify/cookie": "^11.0.2", diff --git a/hub/package.json b/hub/package.json index 327ba74..98883a2 100644 --- a/hub/package.json +++ b/hub/package.json @@ -1,6 +1,6 @@ { "name": "@paradigm/hub", - "version": "0.0.9", + "version": "0.0.10", "private": true, "type": "module", "engines": { @@ -38,6 +38,7 @@ "prisma:validate": "DATABASE_URL=${DATABASE_URL:-postgresql://stub:stub@127.0.0.1:5432/stub} prisma validate --schema prisma/schema.prisma", "prisma:migrate": "DATABASE_URL=${DATABASE_URL:-postgresql://paradigm:paradigm@127.0.0.1:5432/paradigm} prisma migrate deploy --schema prisma/schema.prisma", "secrets:rotate-kek": "node dist/deployment/rotate-secret-kek.js", + "agent-config": "node dist/deployment/agent-config-cli.js", "silo:bootstrap": "node dist/deployment/bootstrap-silo-cli.js", "silo:restore-preflight": "node dist/deployment/restore-preflight.js", "deploy": "bash deploy/deploy_platform.sh", diff --git a/hub/prisma/migrations/20260711130000_dynamic_agent_roles_and_skills/migration.sql b/hub/prisma/migrations/20260711130000_dynamic_agent_roles_and_skills/migration.sql new file mode 100644 index 0000000..6d76d07 --- /dev/null +++ b/hub/prisma/migrations/20260711130000_dynamic_agent_roles_and_skills/migration.sql @@ -0,0 +1,71 @@ +-- ADR-0017: Organization-scoped runtime role bundles and content-addressed +-- skills. Composite foreign keys make cross-Organization role/skill bindings +-- structurally impossible. +CREATE TABLE "OrganizationAgentSkill" ( + "id" TEXT NOT NULL, + "organizationId" TEXT NOT NULL, + "name" TEXT NOT NULL, + "version" TEXT NOT NULL, + "description" TEXT, + "contentDigest" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + "disabledAt" TIMESTAMP(3), + CONSTRAINT "OrganizationAgentSkill_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE "OrganizationAgentRole" ( + "id" TEXT NOT NULL, + "organizationId" TEXT NOT NULL, + "roleId" TEXT NOT NULL, + "label" TEXT NOT NULL, + "defaultModel" TEXT, + "systemPrompt" TEXT, + "tools" JSONB, + "sortOrder" INTEGER NOT NULL DEFAULT 0, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + "disabledAt" TIMESTAMP(3), + CONSTRAINT "OrganizationAgentRole_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE "OrganizationAgentRoleSkill" ( + "organizationId" TEXT NOT NULL, + "agentRoleId" TEXT NOT NULL, + "agentSkillId" TEXT NOT NULL, + "sortOrder" INTEGER NOT NULL DEFAULT 0, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "OrganizationAgentRoleSkill_pkey" PRIMARY KEY ("organizationId", "agentRoleId", "agentSkillId") +); + +CREATE UNIQUE INDEX "OrganizationAgentSkill_organizationId_name_key" ON "OrganizationAgentSkill"("organizationId", "name"); +CREATE UNIQUE INDEX "OrganizationAgentSkill_organizationId_id_key" ON "OrganizationAgentSkill"("organizationId", "id"); +CREATE INDEX "OrganizationAgentSkill_organizationId_disabledAt_idx" ON "OrganizationAgentSkill"("organizationId", "disabledAt"); +CREATE INDEX "OrganizationAgentSkill_contentDigest_idx" ON "OrganizationAgentSkill"("contentDigest"); +CREATE UNIQUE INDEX "OrganizationAgentRole_organizationId_roleId_key" ON "OrganizationAgentRole"("organizationId", "roleId"); +CREATE UNIQUE INDEX "OrganizationAgentRole_organizationId_id_key" ON "OrganizationAgentRole"("organizationId", "id"); +CREATE INDEX "OrganizationAgentRole_organizationId_disabledAt_sortOrder_idx" ON "OrganizationAgentRole"("organizationId", "disabledAt", "sortOrder"); +CREATE INDEX "OrganizationAgentRoleSkill_organizationId_agentRoleId_sortOrder_idx" ON "OrganizationAgentRoleSkill"("organizationId", "agentRoleId", "sortOrder"); +CREATE INDEX "OrganizationAgentRoleSkill_organizationId_agentSkillId_idx" ON "OrganizationAgentRoleSkill"("organizationId", "agentSkillId"); + +ALTER TABLE "OrganizationAgentSkill" ADD CONSTRAINT "OrganizationAgentSkill_organizationId_fkey" + FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "OrganizationAgentRole" ADD CONSTRAINT "OrganizationAgentRole_organizationId_fkey" + FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "OrganizationAgentRoleSkill" ADD CONSTRAINT "OrganizationAgentRoleSkill_organizationId_agentRoleId_fkey" + FOREIGN KEY ("organizationId", "agentRoleId") REFERENCES "OrganizationAgentRole"("organizationId", "id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "OrganizationAgentRoleSkill" ADD CONSTRAINT "OrganizationAgentRoleSkill_organizationId_agentSkillId_fkey" + FOREIGN KEY ("organizationId", "agentSkillId") REFERENCES "OrganizationAgentSkill"("organizationId", "id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- Preserve current alpha behavior while moving role definitions into data. +INSERT INTO "OrganizationAgentRole" ( + "id", "organizationId", "roleId", "label", "sortOrder", "updatedAt" +) +SELECT "id" || ':agent-role:draft', "id", 'draft', '草稿', 10, CURRENT_TIMESTAMP +FROM "Organization"; + +INSERT INTO "OrganizationAgentRole" ( + "id", "organizationId", "roleId", "label", "sortOrder", "updatedAt" +) +SELECT "id" || ':agent-role:review', "id", 'review', '审校', 20, CURRENT_TIMESTAMP +FROM "Organization"; diff --git a/hub/prisma/schema.prisma b/hub/prisma/schema.prisma index 632a55d..1691cf2 100644 --- a/hub/prisma/schema.prisma +++ b/hub/prisma/schema.prisma @@ -43,6 +43,8 @@ model Organization { externalDirectoryConnections ExternalDirectoryConnection[] providerConnections OrganizationProviderConnection[] feishuApplicationConnection OrganizationFeishuApplicationConnection? + agentSkills OrganizationAgentSkill[] + agentRoles OrganizationAgentRole[] auditEntries AuditEntry[] @relation("organizationAudit") @@index([status]) @@ -78,6 +80,70 @@ enum OrganizationMemberRole { MEMBER } +/// Organization-scoped, content-addressed Agent skill registration. The DB is +/// the runtime registry; `contentDigest` selects an immutable directory below +/// the platform-controlled skill store and is never interpreted as a path. +model OrganizationAgentSkill { + id String @id @default(cuid()) + organizationId String + name String + version String + description String? + contentDigest String + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + disabledAt DateTime? + + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) + roleBindings OrganizationAgentRoleSkill[] + + @@unique([organizationId, name]) + @@unique([organizationId, id]) + @@index([organizationId, disabledAt]) + @@index([contentDigest]) +} + +/// ADR-0017 runtime role bundle. Roles are Organization-owned data rather than +/// a code enum: model, system prompt, tool allowlist and skill selection change +/// without a Hub release or process restart. +model OrganizationAgentRole { + id String @id @default(cuid()) + organizationId String + roleId String + label String + defaultModel String? + systemPrompt String? + tools Json? + sortOrder Int @default(0) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + disabledAt DateTime? + + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) + skillBindings OrganizationAgentRoleSkill[] + + @@unique([organizationId, roleId]) + @@unique([organizationId, id]) + @@index([organizationId, disabledAt, sortOrder]) +} + +/// Same-Organization join enforced by both composite foreign keys. `sortOrder` +/// gives stable skill listing and prompt discovery order for a role bundle. +model OrganizationAgentRoleSkill { + organizationId String + agentRoleId String + agentSkillId String + sortOrder Int @default(0) + createdAt DateTime @default(now()) + + role OrganizationAgentRole @relation(fields: [organizationId, agentRoleId], references: [organizationId, id], onDelete: Cascade) + skill OrganizationAgentSkill @relation(fields: [organizationId, agentSkillId], references: [organizationId, id], onDelete: Cascade) + + @@id([organizationId, agentRoleId, agentSkillId]) + @@index([organizationId, agentRoleId, sortOrder]) + @@index([organizationId, agentSkillId]) +} + /// ADR-0021: org-level project onboarding policy. Ordinary Feishu users can /// create projects from unbound chats only when membersCanCreateProjects=true. model OrganizationProjectSettings { diff --git a/hub/src/agent/configuration.ts b/hub/src/agent/configuration.ts new file mode 100644 index 0000000..3c16fd1 --- /dev/null +++ b/hub/src/agent/configuration.ts @@ -0,0 +1,260 @@ +import type { PrismaClient } from "@prisma/client"; +import { Prisma } from "@prisma/client"; +import { assertSupportedRoleTools } from "./roleTools.js"; +import { importSkillDirectory } from "./skillStore.js"; + +const ROLE_ID_PATTERN = /^[a-z0-9][a-z0-9_-]{0,63}$/; + +/** + * Deep module for controlled host-console Agent configuration. It owns the + * filesystem/DB ordering, Organization checks and role-skill composition so + * callers never manipulate registry rows or content paths independently. + */ +export class OrganizationAgentConfiguration { + constructor( + private readonly prisma: PrismaClient, + private readonly skillStoreRoot: string, + ) {} + + async installSkill(input: { + readonly organizationId: string; + readonly sourceDir: string; + readonly version: string; + }): Promise<{ readonly id: string; readonly name: string; readonly contentDigest: string }> { + await this.requireActiveOrganization(input.organizationId); + const version = nonEmpty(input.version, "skill version"); + const imported = await importSkillDirectory({ + sourceDir: input.sourceDir, + storeRoot: this.skillStoreRoot, + }); + return this.prisma.$transaction(async (tx) => { + const previous = await tx.organizationAgentSkill.findUnique({ + where: { organizationId_name: { organizationId: input.organizationId, name: imported.name } }, + select: { contentDigest: true }, + }); + const skill = await tx.organizationAgentSkill.upsert({ + where: { + organizationId_name: { + organizationId: input.organizationId, + name: imported.name, + }, + }, + create: { + organizationId: input.organizationId, + name: imported.name, + version, + description: imported.description ?? null, + contentDigest: imported.contentDigest, + }, + update: { + version, + description: imported.description ?? null, + contentDigest: imported.contentDigest, + disabledAt: null, + }, + select: { + id: true, + name: true, + contentDigest: true, + roleBindings: { select: { role: { select: { roleId: true } } } }, + }, + }); + if (previous !== null && previous.contentDigest !== skill.contentDigest) { + await archiveRoleSessions( + tx, + input.organizationId, + skill.roleBindings.map((binding) => binding.role.roleId), + ); + } + await tx.auditEntry.create({ + data: { + organizationId: input.organizationId, + action: "agent_skill.installed", + metadata: { + name: skill.name, + version, + contentDigest: skill.contentDigest, + }, + }, + }); + return { id: skill.id, name: skill.name, contentDigest: skill.contentDigest }; + }); + } + + async upsertRole(input: { + readonly organizationId: string; + readonly roleId: string; + readonly label: string; + readonly defaultModel?: string | null | undefined; + readonly systemPrompt?: string | null | undefined; + readonly tools?: readonly string[] | null | undefined; + readonly sortOrder?: number | undefined; + }): Promise<{ readonly id: string; readonly roleId: string }> { + await this.requireActiveOrganization(input.organizationId); + if (!ROLE_ID_PATTERN.test(input.roleId)) throw new Error(`invalid role id: ${input.roleId}`); + const label = nonEmpty(input.label, "role label"); + if (input.tools !== undefined && input.tools !== null) assertSupportedRoleTools([...input.tools]); + const sortOrder = input.sortOrder ?? 0; + if (!Number.isSafeInteger(sortOrder)) throw new Error("role sortOrder must be an integer"); + const createTools = input.tools === undefined || input.tools === null + ? Prisma.DbNull + : [...input.tools]; + const updateTools = input.tools === undefined + ? undefined + : input.tools === null + ? Prisma.DbNull + : [...input.tools]; + return this.prisma.$transaction(async (tx) => { + const previous = await tx.organizationAgentRole.findUnique({ + where: { organizationId_roleId: { organizationId: input.organizationId, roleId: input.roleId } }, + select: { defaultModel: true, systemPrompt: true, tools: true }, + }); + const role = await tx.organizationAgentRole.upsert({ + where: { + organizationId_roleId: { + organizationId: input.organizationId, + roleId: input.roleId, + }, + }, + create: { + organizationId: input.organizationId, + roleId: input.roleId, + label, + defaultModel: normalizeOptionalText(input.defaultModel), + systemPrompt: normalizeOptionalText(input.systemPrompt), + tools: createTools, + sortOrder, + }, + update: { + label, + ...(input.defaultModel !== undefined ? { defaultModel: normalizeOptionalText(input.defaultModel) } : {}), + ...(input.systemPrompt !== undefined ? { systemPrompt: normalizeOptionalText(input.systemPrompt) } : {}), + ...(updateTools !== undefined ? { tools: updateTools } : {}), + sortOrder, + disabledAt: null, + }, + select: { id: true, roleId: true }, + }); + const executionSurfaceChanged = previous !== null && ( + (input.defaultModel !== undefined && normalizeOptionalText(input.defaultModel) !== previous.defaultModel) || + (input.systemPrompt !== undefined && normalizeOptionalText(input.systemPrompt) !== previous.systemPrompt) || + (input.tools !== undefined && JSON.stringify(input.tools) !== JSON.stringify(previous.tools)) + ); + if (executionSurfaceChanged) await archiveRoleSessions(tx, input.organizationId, [input.roleId]); + await tx.auditEntry.create({ + data: { + organizationId: input.organizationId, + action: "agent_role.upserted", + metadata: { + roleId: input.roleId, + label, + defaultModel: input.defaultModel === undefined ? "unchanged" : normalizeOptionalText(input.defaultModel), + systemPromptConfigured: input.systemPrompt === undefined + ? "unchanged" + : normalizeOptionalText(input.systemPrompt) !== null, + tools: input.tools === undefined ? "unchanged" : input.tools === null ? "all" : [...input.tools], + sortOrder, + }, + }, + }); + return role; + }); + } + + async setRoleSkills(input: { + readonly organizationId: string; + readonly roleId: string; + readonly skillNames: readonly string[]; + }): Promise { + const uniqueNames = new Set(input.skillNames); + if (uniqueNames.size !== input.skillNames.length) throw new Error("role skill names must be unique"); + await this.prisma.$transaction(async (tx) => { + const role = await tx.organizationAgentRole.findUnique({ + where: { + organizationId_roleId: { + organizationId: input.organizationId, + roleId: input.roleId, + }, + }, + select: { id: true, disabledAt: true }, + }); + if (role === null || role.disabledAt !== null) { + throw new Error(`active role not found in organization: ${input.roleId}`); + } + const skills = await tx.organizationAgentSkill.findMany({ + where: { + organizationId: input.organizationId, + name: { in: [...input.skillNames] }, + disabledAt: null, + }, + select: { id: true, name: true }, + }); + if (skills.length !== input.skillNames.length) { + const found = new Set(skills.map((skill) => skill.name)); + const missing = input.skillNames.filter((name) => !found.has(name)); + throw new Error(`active skills not found in organization: ${missing.join(", ")}`); + } + const byName = new Map(skills.map((skill) => [skill.name, skill.id])); + await tx.organizationAgentRoleSkill.deleteMany({ + where: { organizationId: input.organizationId, agentRoleId: role.id }, + }); + if (input.skillNames.length > 0) { + await tx.organizationAgentRoleSkill.createMany({ + data: input.skillNames.map((name, index) => ({ + organizationId: input.organizationId, + agentRoleId: role.id, + agentSkillId: byName.get(name)!, + sortOrder: index, + })), + }); + } + await archiveRoleSessions(tx, input.organizationId, [input.roleId]); + await tx.auditEntry.create({ + data: { + organizationId: input.organizationId, + action: "agent_role.skills_set", + metadata: { roleId: input.roleId, skillNames: [...input.skillNames] }, + }, + }); + }); + } + + private async requireActiveOrganization(organizationId: string): Promise { + const organization = await this.prisma.organization.findUnique({ + where: { id: organizationId }, + select: { status: true }, + }); + if (organization === null) throw new Error(`organization not found: ${organizationId}`); + if (organization.status !== "ACTIVE") { + throw new Error(`organization ${organizationId} is ${organization.status}`); + } + } +} + +async function archiveRoleSessions( + tx: Prisma.TransactionClient, + organizationId: string, + roleIds: readonly string[], +): Promise { + if (roleIds.length === 0) return; + await tx.agentSession.updateMany({ + where: { + roleId: { in: [...new Set(roleIds)] }, + archivedAt: null, + project: { organizationId }, + }, + data: { archivedAt: new Date() }, + }); +} + +function nonEmpty(value: string, label: string): string { + const normalized = value.trim(); + if (normalized === "") throw new Error(`${label} is required`); + return normalized; +} + +function normalizeOptionalText(value: string | null | undefined): string | null { + if (value === undefined || value === null) return null; + const normalized = value.trim(); + return normalized === "" ? null : normalized; +} diff --git a/hub/src/agent/curatedSkills.ts b/hub/src/agent/curatedSkills.ts deleted file mode 100644 index 85a8774..0000000 --- a/hub/src/agent/curatedSkills.ts +++ /dev/null @@ -1,87 +0,0 @@ -import { lstat, readFile, readdir } from "node:fs/promises"; -import { join } from "node:path"; -import { fileURLToPath } from "node:url"; - -export const CURATED_SKILL_PLUGIN_NAME = "cph-curated"; -export const CURATED_SKILL_NAMES = [ - "outline", - "lesson-project", - "data-processing-spec", -] as const; -export const CURATED_SKILL_IDS = CURATED_SKILL_NAMES.map( - (name) => `${CURATED_SKILL_PLUGIN_NAME}:${name}`, -); - -export interface CuratedSkillPlugin { - readonly root: string; - readonly skillIds: readonly string[]; -} - -/** Validate the immutable, release-owned plugin before exposing it read-only. */ -export async function validateCuratedSkillPlugin( - root = curatedSkillPluginRoot(), -): Promise { - await assertExactDirectoryEntries(root, [".claude-plugin", "skills"], ""); - await assertExactDirectoryEntries(join(root, ".claude-plugin"), ["plugin.json"], ".claude-plugin"); - await assertExactDirectoryEntries(join(root, "skills"), CURATED_SKILL_NAMES, "skills"); - - const pluginManifest = join(root, ".claude-plugin", "plugin.json"); - let pluginName: unknown; - try { - const stat = await lstat(pluginManifest); - if (!stat.isFile()) throw new Error(`not a regular file: ${pluginManifest}`); - pluginName = JSON.parse(await readFile(pluginManifest, "utf8")).name; - } catch (error) { - throw new Error(`curated skill plugin manifest invalid: ${pluginManifest}`, { cause: error }); - } - if (pluginName !== CURATED_SKILL_PLUGIN_NAME) { - throw new Error(`curated skill plugin name mismatch: expected ${CURATED_SKILL_PLUGIN_NAME}, got ${String(pluginName)}`); - } - - for (const name of CURATED_SKILL_NAMES) { - const manifest = join(root, "skills", name, "SKILL.md"); - try { - const stat = await lstat(manifest); - if (!stat.isFile()) throw new Error(`not a regular file: ${manifest}`); - const contents = await readFile(manifest, "utf8"); - const declaredName = /^name:\s*['"]?([^'"\r\n]+)['"]?\s*$/m.exec(contents)?.[1]?.trim(); - if (declaredName !== name) { - throw new Error(`curated skill manifest name mismatch: expected ${name}, got ${declaredName ?? "missing"}`); - } - } catch (error) { - if (error instanceof Error && error.message.startsWith("curated skill manifest name mismatch:")) throw error; - throw new Error(`curated skill source missing: ${manifest}`, { cause: error }); - } - } - return { root, skillIds: CURATED_SKILL_IDS }; -} - -export function curatedSkillPluginRoot(): string { - return fileURLToPath(new URL("../../curated-skills-plugin/", import.meta.url)); -} - -async function assertExactDirectoryEntries( - directory: string, - allowedNames: readonly string[], - relativeDirectory: string, -): Promise { - let entries; - try { - entries = await readdir(directory, { withFileTypes: true }); - } catch (error) { - throw new Error(`curated plugin directory missing: ${directory}`, { cause: error }); - } - const allowed = new Set(allowedNames); - for (const entry of entries) { - if (!allowed.has(entry.name)) { - const relativePath = relativeDirectory === "" ? entry.name : `${relativeDirectory}/${entry.name}`; - throw new Error(`unexpected curated plugin entry: ${relativePath}`); - } - } - for (const name of allowedNames) { - if (!entries.some((entry) => entry.name === name)) { - const relativePath = relativeDirectory === "" ? name : `${relativeDirectory}/${name}`; - throw new Error(`curated plugin entry missing: ${relativePath}`); - } - } -} diff --git a/hub/src/agent/models.ts b/hub/src/agent/models.ts index d10b27c..3987c70 100644 --- a/hub/src/agent/models.ts +++ b/hub/src/agent/models.ts @@ -40,6 +40,14 @@ export interface RoleEntry { * Invalid names fail fast when settings are loaded or the run is set up. */ readonly tools?: readonly string[] | undefined; + /** Immutable skill versions selected by this role at runtime. */ + readonly skills?: readonly RoleSkillEntry[] | undefined; +} + +export interface RoleSkillEntry { + readonly name: string; + readonly version: string; + readonly contentDigest: string; } /** A model the admin has enabled for use by the Hub. */ diff --git a/hub/src/agent/runner.ts b/hub/src/agent/runner.ts index 09099a7..586ead0 100644 --- a/hub/src/agent/runner.ts +++ b/hub/src/agent/runner.ts @@ -33,6 +33,7 @@ import { query, type HookCallback, type McpServerConfig, type SDKMessage, type S import type { PrismaClient } from "@prisma/client"; import { claudeSdkToolConfigForRole } from "./roleTools.js"; import { createAgentSecurityPolicy } from "./security.js"; +import type { RoleSkillEntry } from "./models.js"; export interface ProjectContext { readonly projectId: string; @@ -66,6 +67,7 @@ export interface RunRequest { * means no tools. */ readonly tools?: readonly string[] | undefined; + readonly skills?: readonly RoleSkillEntry[] | undefined; readonly mcpServers?: Record | undefined; readonly maxTurns?: number; readonly runId: string; @@ -135,6 +137,7 @@ export async function runAgent(req: RunRequest): Promise { let sdkSessionId: string | undefined; let initializedSkillIds: readonly string[] | undefined; let error: string | undefined; + let cleanupSecurity = async (): Promise => {}; try { await persistAgentMessage(req, "user", req.prompt); const toolConfig = claudeSdkToolConfigForRole(req.tools); @@ -143,17 +146,21 @@ export async function runAgent(req: RunRequest): Promise { throw new Error("Agent run requires the configured workspace root"); } const security = await createAgentSecurityPolicy({ + runId: req.runId, workspaceRoot, workspaceDir: req.project.workspaceDir, + skills: req.skills, providerProxyEnv: req.providerProxyEnv, }); + cleanupSecurity = security.cleanup; + const hasSkills = security.skillIds.length > 0; type QueryOptions = NonNullable[0]["options"]>; const options: QueryOptions = { cwd: security.cwd, // `skills` controls discovery/allowlisting, but an explicit `tools` // list still has to expose the Skill dispatcher itself. - tools: [...toolConfig.tools, "Skill"], + tools: [...toolConfig.tools, ...(hasSkills ? ["Skill"] : [])], allowedTools: [...toolConfig.allowedTools], maxTurns: cap, includePartialMessages: true, @@ -172,7 +179,9 @@ export async function runAgent(req: RunRequest): Promise { // settings that could widen tools, hooks, MCP servers, or sandbox paths. settingSources: [], settings: { disableBundledSkills: true }, - plugins: [{ type: "local", path: security.skillPluginRoot, skipMcpDiscovery: true }], + ...(hasSkills && security.skillPluginRoot !== undefined + ? { plugins: [{ type: "local" as const, path: security.skillPluginRoot, skipMcpDiscovery: true }] } + : {}), skills: [...security.skillIds], strictMcpConfig: true, // Claude Code 2.1.202 can honor the per-call opt-out despite @@ -316,6 +325,8 @@ export async function runAgent(req: RunRequest): Promise { ...(initializedSkillIds !== undefined ? { initializedSkillIds } : {}), ...(aborted ? {} : { error: e instanceof Error ? e.message : String(e) }), }; + } finally { + await cleanupSecurity(); } } diff --git a/hub/src/agent/security.ts b/hub/src/agent/security.ts index ec1968d..1f7dfc3 100644 --- a/hub/src/agent/security.ts +++ b/hub/src/agent/security.ts @@ -1,7 +1,8 @@ import { chmod, lstat, mkdir, realpath } from "node:fs/promises"; import { homedir } from "node:os"; import { isAbsolute, join, relative, resolve } from "node:path"; -import { validateCuratedSkillPlugin } from "./curatedSkills.js"; +import type { RoleSkillEntry } from "./models.js"; +import { prepareRunSkillPlugin, readSkillStoreRoot } from "./skillStore.js"; const PROVIDER_ENV_KEYS = new Set([ "ANTHROPIC_BASE_URL", @@ -33,8 +34,10 @@ const SANDBOX_HIDDEN_ENV_KEYS = [ const MAX_AGENT_TMP_PREFIX_BYTES = 56; export interface AgentSecurityInput { + readonly runId: string; readonly workspaceRoot: string; readonly workspaceDir: string; + readonly skills?: readonly RoleSkillEntry[] | undefined; /** Run-scoped loopback proxy capability; customer provider secrets are forbidden here. */ readonly providerProxyEnv?: Readonly> | undefined; readonly hostEnv?: Readonly> | undefined; @@ -62,7 +65,8 @@ export interface AgentSecurityPolicy { readonly workspaceRoot: string; readonly env: Record; readonly skillIds: readonly string[]; - readonly skillPluginRoot: string; + readonly skillPluginRoot?: string | undefined; + cleanup(): Promise; readonly sandbox: AgentSandboxPolicy; } @@ -82,7 +86,6 @@ export async function createAgentSecurityPolicy(input: AgentSecurityInput): Prom const agentState = await ensureDirectoryTree(runtimeRoot, ["state"]); const agentTmp = await ensureDirectoryTree(cphRoot, ["t"]); assertShortAgentTemp(agentTmp); - const skillPlugin = await validateCuratedSkillPlugin(); const path = hostEnv["PATH"]?.trim(); if (path === undefined || path === "") { @@ -119,12 +122,21 @@ export async function createAgentSecurityPolicy(input: AgentSecurityInput): Prom const sensitiveReadPaths = hostSensitiveReadPaths(hostEnv); const runtimeReadPaths = hostRuntimeReadPaths(hostEnv); + const selectedSkills = input.skills ?? []; + const skillPlugin = selectedSkills.length === 0 + ? null + : await prepareRunSkillPlugin({ + storeRoot: readSkillStoreRoot(hostEnv), + runId: input.runId, + skills: selectedSkills, + }); return { cwd: workspaceDir, workspaceRoot, env, - skillIds: skillPlugin.skillIds, - skillPluginRoot: skillPlugin.root, + skillIds: skillPlugin?.skillIds ?? [], + ...(skillPlugin !== null ? { skillPluginRoot: skillPlugin.root } : {}), + cleanup: skillPlugin?.cleanup ?? (async () => {}), sandbox: { enabled: true, failIfUnavailable: true, @@ -140,7 +152,7 @@ export async function createAgentSecurityPolicy(input: AgentSecurityInput): Prom // workspace plus the named system runtime needed to execute tools. // SDK allowRead takes precedence over matching denyRead paths. denyRead: ["/"], - allowRead: [workspaceDir, skillPlugin.root, ...runtimeReadPaths], + allowRead: [workspaceDir, ...(skillPlugin !== null ? [skillPlugin.root] : []), ...runtimeReadPaths], }, credentials: { files: sensitiveReadPaths.map((path) => ({ path, mode: "deny" as const })), diff --git a/hub/src/agent/skillStore.ts b/hub/src/agent/skillStore.ts new file mode 100644 index 0000000..e2422f0 --- /dev/null +++ b/hub/src/agent/skillStore.ts @@ -0,0 +1,217 @@ +import { createHash, randomUUID } from "node:crypto"; +import { + cp, + lstat, + mkdir, + readFile, + readdir, + rename, + rm, + writeFile, +} from "node:fs/promises"; +import { join, relative, resolve } from "node:path"; +import type { RoleSkillEntry } from "./models.js"; + +const MAX_SKILL_FILES = 512; +const MAX_SKILL_BYTES = 16 * 1024 * 1024; +const SKILL_NAME_PATTERN = /^[a-z0-9][a-z0-9-]{0,63}$/; +const DIGEST_PATTERN = /^[a-f0-9]{64}$/; +const RUNTIME_PLUGIN_NAME = "cph-runtime"; + +export interface ImportedSkillContent { + readonly name: string; + readonly description: string | undefined; + readonly contentDigest: string; +} + +export interface RunSkillPlugin { + readonly root: string; + readonly skillIds: readonly string[]; + cleanup(): Promise; +} + +export async function importSkillDirectory(input: { + readonly sourceDir: string; + readonly storeRoot: string; +}): Promise { + const source = await inspectSkillDirectory(input.sourceDir); + const versionsRoot = join(input.storeRoot, "versions"); + await mkdir(versionsRoot, { recursive: true, mode: 0o750 }); + const destination = join(versionsRoot, source.contentDigest); + + try { + const existing = await inspectSkillDirectory(destination); + if (existing.contentDigest !== source.contentDigest || existing.name !== source.name) { + throw new Error(`stored skill content digest mismatch: ${source.name}`); + } + return source; + } catch (error) { + if (!isMissingPath(error)) throw error; + } + + const temporary = join(versionsRoot, `.tmp-${randomUUID()}`); + try { + await cp(input.sourceDir, temporary, { recursive: true, force: false, errorOnExist: true }); + const copied = await inspectSkillDirectory(temporary); + if (copied.contentDigest !== source.contentDigest || copied.name !== source.name) { + throw new Error(`skill changed while importing: ${source.name}`); + } + await rename(temporary, destination); + } catch (error) { + await rm(temporary, { recursive: true, force: true }); + if (isDestinationExists(error)) { + const existing = await inspectSkillDirectory(destination); + if (existing.contentDigest === source.contentDigest && existing.name === source.name) return source; + } + throw error; + } + return source; +} + +export async function prepareRunSkillPlugin(input: { + readonly storeRoot: string; + readonly runId: string; + readonly skills: readonly RoleSkillEntry[]; +}): Promise { + if (input.skills.length === 0) return null; + const names = new Set(); + for (const skill of input.skills) { + requireSkillName(skill.name); + if (!DIGEST_PATTERN.test(skill.contentDigest)) { + throw new Error(`skill ${skill.name} has invalid content digest`); + } + if (names.has(skill.name)) throw new Error(`duplicate role skill: ${skill.name}`); + names.add(skill.name); + } + + const runtimeRoot = join(input.storeRoot, "runtime"); + await mkdir(runtimeRoot, { recursive: true, mode: 0o750 }); + const pluginRoot = join(runtimeRoot, `run-${randomUUID()}`); + try { + await mkdir(join(pluginRoot, ".claude-plugin"), { recursive: true, mode: 0o750 }); + await mkdir(join(pluginRoot, "skills"), { recursive: true, mode: 0o750 }); + await writeFile( + join(pluginRoot, ".claude-plugin", "plugin.json"), + `${JSON.stringify({ + name: RUNTIME_PLUGIN_NAME, + description: `Runtime skill snapshot for ${input.runId}`, + version: "1", + }, null, 2)}\n`, + { mode: 0o640 }, + ); + + for (const skill of input.skills) { + const sourceDir = join(input.storeRoot, "versions", skill.contentDigest); + const stored = await inspectSkillDirectory(sourceDir); + if (stored.contentDigest !== skill.contentDigest) { + throw new Error(`skill ${skill.name} content digest mismatch`); + } + if (stored.name !== skill.name) { + throw new Error(`skill name mismatch: expected ${skill.name}, got ${stored.name}`); + } + await cp(sourceDir, join(pluginRoot, "skills", skill.name), { + recursive: true, + force: false, + errorOnExist: true, + }); + } + } catch (error) { + await rm(pluginRoot, { recursive: true, force: true }); + throw error; + } + + return { + root: pluginRoot, + skillIds: input.skills.map((skill) => `${RUNTIME_PLUGIN_NAME}:${skill.name}`), + async cleanup() { + await rm(pluginRoot, { recursive: true, force: true }); + }, + }; +} + +export function readSkillStoreRoot(env: Readonly> = process.env): string { + const configured = env["HUB_SKILL_STORE_ROOT"]?.trim(); + if (configured !== undefined && configured !== "") return resolve(configured); + const stateRoot = env["XDG_STATE_HOME"]?.trim(); + if (stateRoot === undefined || stateRoot === "") { + throw new Error("HUB_SKILL_STORE_ROOT or XDG_STATE_HOME is required"); + } + return resolve(stateRoot, "skills"); +} + +export async function verifyStoredSkill(input: { + readonly storeRoot: string; + readonly name: string; + readonly contentDigest: string; +}): Promise { + requireSkillName(input.name); + if (!DIGEST_PATTERN.test(input.contentDigest)) { + throw new Error(`skill ${input.name} has invalid content digest`); + } + const stored = await inspectSkillDirectory(join(input.storeRoot, "versions", input.contentDigest)); + if (stored.name !== input.name || stored.contentDigest !== input.contentDigest) { + throw new Error(`stored skill verification failed: ${input.name}`); + } +} + +async function inspectSkillDirectory(directory: string): Promise { + const root = resolve(directory); + const rootStat = await lstat(root); + if (rootStat.isSymbolicLink() || !rootStat.isDirectory()) { + throw new Error(`skill root must be a real directory: ${root}`); + } + const files: Array<{ readonly path: string; readonly bytes: Buffer }> = []; + await walk(root, root, files); + if (files.length > MAX_SKILL_FILES) throw new Error(`skill has too many files: ${files.length}`); + const totalBytes = files.reduce((sum, file) => sum + file.bytes.byteLength, 0); + if (totalBytes > MAX_SKILL_BYTES) throw new Error(`skill is too large: ${totalBytes} bytes`); + const manifest = files.find((file) => file.path === "SKILL.md"); + if (manifest === undefined) throw new Error(`skill manifest missing: ${join(root, "SKILL.md")}`); + const frontmatter = manifest.bytes.toString("utf8"); + const name = /^name:\s*['"]?([^'"\r\n]+)['"]?\s*$/m.exec(frontmatter)?.[1]?.trim(); + if (name === undefined) throw new Error("skill manifest name missing"); + requireSkillName(name); + const description = /^description:\s*['"]?([^'"\r\n]+)['"]?\s*$/m.exec(frontmatter)?.[1]?.trim(); + + const hash = createHash("sha256"); + for (const file of files.sort((left, right) => left.path.localeCompare(right.path))) { + hash.update(`${Buffer.byteLength(file.path)}:`); + hash.update(file.path); + hash.update(`${file.bytes.byteLength}:`); + hash.update(file.bytes); + } + return { name, description, contentDigest: hash.digest("hex") }; +} + +async function walk( + root: string, + directory: string, + files: Array<{ readonly path: string; readonly bytes: Buffer }>, +): Promise { + const entries = await readdir(directory, { withFileTypes: true }); + for (const entry of entries) { + const fullPath = join(directory, entry.name); + if (entry.isSymbolicLink()) throw new Error(`skill symlink is forbidden: ${fullPath}`); + if (entry.isDirectory()) { + await walk(root, fullPath, files); + continue; + } + if (!entry.isFile()) throw new Error(`skill contains unsupported filesystem entry: ${fullPath}`); + const relativePath = relative(root, fullPath); + files.push({ path: relativePath, bytes: await readFile(fullPath) }); + if (files.length > MAX_SKILL_FILES) throw new Error(`skill has too many files: ${files.length}`); + } +} + +function requireSkillName(name: string): void { + if (!SKILL_NAME_PATTERN.test(name)) throw new Error(`invalid skill name: ${name}`); +} + +function isMissingPath(error: unknown): boolean { + return typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT"; +} + +function isDestinationExists(error: unknown): boolean { + return typeof error === "object" && error !== null && "code" in error && + (error.code === "EEXIST" || error.code === "ENOTEMPTY"); +} diff --git a/hub/src/deployment/agent-config-cli.ts b/hub/src/deployment/agent-config-cli.ts new file mode 100644 index 0000000..c1dc35e --- /dev/null +++ b/hub/src/deployment/agent-config-cli.ts @@ -0,0 +1,157 @@ +import { readFile } from "node:fs/promises"; +import { prisma } from "../db.js"; +import { OrganizationAgentConfiguration } from "../agent/configuration.js"; +import { readSkillStoreRoot, verifyStoredSkill } from "../agent/skillStore.js"; +import { readSiloOrganizationId } from "./silo.js"; + +async function main(argv: readonly string[]): Promise { + const [command, ...args] = argv; + if (command === undefined || command === "help" || command === "--help") { + printHelp(); + return; + } + const options = parseOptions(args); + const organizationId = required(options, "organization"); + const siloOrganizationId = readSiloOrganizationId(); + if (organizationId !== siloOrganizationId) { + throw new Error(`Silo Agent configuration is restricted to ${siloOrganizationId}`); + } + const configuration = new OrganizationAgentConfiguration(prisma, readSkillStoreRoot()); + + switch (command) { + case "install-skill": { + const installed = await configuration.installSkill({ + organizationId, + sourceDir: required(options, "source"), + version: required(options, "version"), + }); + console.log(JSON.stringify(installed)); + return; + } + case "upsert-role": { + const systemPromptFile = options.get("system-prompt-file"); + const toolsJson = options.get("tools-json"); + const tools = toolsJson === undefined + ? undefined + : toolsJson === "null" + ? null + : parseTools(toolsJson); + const sortOrderRaw = options.get("sort-order"); + const role = await configuration.upsertRole({ + organizationId, + roleId: required(options, "role"), + label: required(options, "label"), + ...(options.has("model") ? { defaultModel: options.get("model") ?? null } : {}), + ...(systemPromptFile !== undefined + ? { systemPrompt: await readFile(systemPromptFile, "utf8") } + : {}), + ...(tools !== undefined ? { tools } : {}), + ...(sortOrderRaw !== undefined ? { sortOrder: integer(sortOrderRaw, "sort-order") } : {}), + }); + console.log(JSON.stringify(role)); + return; + } + case "set-role-skills": { + const skills = required(options, "skills").split(",").map((name) => name.trim()).filter(Boolean); + await configuration.setRoleSkills({ + organizationId, + roleId: required(options, "role"), + skillNames: skills, + }); + console.log(JSON.stringify({ roleId: required(options, "role"), skills })); + return; + } + case "list": { + const roles = await prisma.organizationAgentRole.findMany({ + where: { organizationId }, + orderBy: [{ sortOrder: "asc" }, { roleId: "asc" }], + include: { + skillBindings: { + orderBy: [{ sortOrder: "asc" }, { agentSkillId: "asc" }], + include: { skill: { select: { name: true, version: true, disabledAt: true } } }, + }, + }, + }); + console.log(JSON.stringify(roles.map((role) => ({ + roleId: role.roleId, + label: role.label, + defaultModel: role.defaultModel, + systemPromptConfigured: role.systemPrompt !== null, + tools: role.tools, + disabled: role.disabledAt !== null, + skills: role.skillBindings.map((binding) => ({ + name: binding.skill.name, + version: binding.skill.version, + disabled: binding.skill.disabledAt !== null, + })), + })), null, 2)); + return; + } + case "verify-store": { + const skills = await prisma.organizationAgentSkill.findMany({ + where: { organizationId, disabledAt: null }, + select: { name: true, contentDigest: true }, + }); + for (const skill of skills) { + await verifyStoredSkill({ storeRoot: readSkillStoreRoot(), ...skill }); + } + console.log(JSON.stringify({ verifiedSkills: skills.length })); + return; + } + default: + throw new Error(`unknown Agent configuration command: ${command}`); + } +} + +function parseOptions(args: readonly string[]): Map { + const options = new Map(); + for (let index = 0; index < args.length; index += 2) { + const flag = args[index]; + const value = args[index + 1]; + if (flag === undefined || !flag.startsWith("--") || value === undefined) { + throw new Error(`expected --name value, got: ${args.slice(index).join(" ")}`); + } + const name = flag.slice(2); + if (options.has(name)) throw new Error(`duplicate option: --${name}`); + options.set(name, value); + } + return options; +} + +function required(options: ReadonlyMap, name: string): string { + const value = options.get(name)?.trim(); + if (value === undefined || value === "") throw new Error(`--${name} is required`); + return value; +} + +function parseTools(raw: string): readonly string[] { + const parsed = JSON.parse(raw) as unknown; + if (!Array.isArray(parsed) || parsed.some((value) => typeof value !== "string")) { + throw new Error("--tools-json must be null or a JSON string array"); + } + return parsed; +} + +function integer(raw: string, name: string): number { + const value = Number(raw); + if (!Number.isSafeInteger(value)) throw new Error(`--${name} must be an integer`); + return value; +} + +function printHelp(): void { + console.log(`Usage: + agent-config install-skill --organization ORG --source DIR --version VERSION + agent-config upsert-role --organization ORG --role ID --label LABEL [--model MODEL] [--system-prompt-file FILE] [--tools-json JSON] [--sort-order N] + agent-config set-role-skills --organization ORG --role ID --skills name,name + agent-config list --organization ORG + agent-config verify-store --organization ORG`); +} + +main(process.argv.slice(2)) + .catch((error) => { + console.error(error instanceof Error ? error.message : String(error)); + process.exitCode = 1; + }) + .finally(async () => { + await prisma.$disconnect(); + }); diff --git a/hub/src/deployment/bootstrap-silo.ts b/hub/src/deployment/bootstrap-silo.ts index 3af6397..d33adda 100644 --- a/hub/src/deployment/bootstrap-silo.ts +++ b/hub/src/deployment/bootstrap-silo.ts @@ -226,6 +226,22 @@ async function initializeSilo( const count = await tx.organization.count(); if (count !== 0) throw new Error(`Silo bootstrap requires an empty Organization set; found ${count}`); await tx.organization.create({ data: { ...input.organization } }); + await tx.organizationAgentRole.createMany({ + data: [ + { + organizationId: input.organization.id, + roleId: "draft", + label: "草稿", + sortOrder: 10, + }, + { + organizationId: input.organization.id, + roleId: "review", + label: "审校", + sortOrder: 20, + }, + ], + }); await tx.organizationProjectSettings.create({ data: { organizationId: input.organization.id, membersCanCreateProjects: true }, }); diff --git a/hub/src/feishu/slashCommands.ts b/hub/src/feishu/slashCommands.ts index 2bf1a7a..53e2c8c 100644 --- a/hub/src/feishu/slashCommands.ts +++ b/hub/src/feishu/slashCommands.ts @@ -391,10 +391,20 @@ function formatRoleSlashCommandHelp(role: RoleEntry): string { if (role.defaultModel !== undefined) { lines.push(`- 默认模型: ${role.defaultModel}`); } - lines.push(`- 工具范围: ${roleToolsDescription(role)}`, "", `帮助: /help ${role.id} 或 /${role.id} help`); + lines.push( + `- 工具范围: ${roleToolsDescription(role)}`, + `- Skills: ${roleSkillsDescription(role)}`, + "", + `帮助: /help ${role.id} 或 /${role.id} help`, + ); return lines.join("\n"); } +function roleSkillsDescription(role: RoleEntry): string { + if (role.skills === undefined || role.skills.length === 0) return "无"; + return role.skills.map((skill) => `${skill.name}@${skill.version}`).join(", "); +} + function roleToolsDescription(role: RoleEntry): string { if (role.tools === undefined) return "全部已注册工具"; if (role.tools.length === 0) return "无"; diff --git a/hub/src/feishu/trigger.ts b/hub/src/feishu/trigger.ts index ca990ec..5c943cc 100644 --- a/hub/src/feishu/trigger.ts +++ b/hub/src/feishu/trigger.ts @@ -37,7 +37,6 @@ import { createPermissionAuthorizer, type AuthorizationDecision, type Permission import { writeAudit } from "../audit.js"; import { formatRunCostLine } from "../agent/cost.js"; import { createAgentSdkStderrSink } from "../agent/diagnostics.js"; -import { CURATED_SKILL_IDS } from "../agent/curatedSkills.js"; import { InactiveOrganizationError, lockActiveOrganization } from "../org/status.js"; import { StreamingAgentCard } from "./card/streaming-card.js"; import { createFileDeliveryMcpServer } from "./fileDeliveryTool.js"; @@ -406,7 +405,11 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler { metadata: { roleId, model, - requestedSkills: [...CURATED_SKILL_IDS], + requestedSkills: (role?.skills ?? []).map((skill) => ({ + name: skill.name, + version: skill.version, + contentDigest: skill.contentDigest, + })), prompt: agentPrompt.slice(0, 200), sender: senderMetadata, feishuTriggerContext, @@ -482,6 +485,7 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler { providerProxyEnv: { ...providerLease.sdkEnv }, resumeSessionId: sessionMetadata.claudeSessionId, tools: roleTools, + skills: role?.skills, mcpServers: { cph_hub: fileDeliveryMcpServer }, maxTurns: runPolicy.maxTurns, runId: run.id, diff --git a/hub/src/settings/runtime.ts b/hub/src/settings/runtime.ts index 4c11330..4b68640 100644 --- a/hub/src/settings/runtime.ts +++ b/hub/src/settings/runtime.ts @@ -1,5 +1,5 @@ import type { Prisma, PrismaClient } from "@prisma/client"; -import { InMemoryModelRegistry, type ModelRegistry } from "../agent/models.js"; +import { InMemoryModelRegistry, type ModelRegistry, type RoleEntry, type RoleSkillEntry } from "../agent/models.js"; import { lockActiveOrganization } from "../org/status.js"; import { decryptStoredProviderCredential } from "../connections/providerConnections.js"; import { openProviderProxyLease, type AgentProviderLease, type ProviderUpstreamCredential } from "../connections/providerProxy.js"; @@ -141,8 +141,75 @@ export class DatabaseRuntimeSettings implements RuntimeSettings { }; } - modelRegistry(scope?: RuntimeScope): Promise { - return this.envSettings.modelRegistry(scope); + async modelRegistry(scope?: RuntimeScope): Promise { + const projectId = scope?.projectId?.trim(); + if (projectId === undefined || projectId === "") { + throw new Error("projectId is required to resolve Agent runtime configuration"); + } + const project = await this.prisma.project.findUnique({ + where: { id: projectId }, + select: { + archivedAt: true, + organization: { + select: { + id: true, + status: true, + agentRoles: { + where: { disabledAt: null }, + orderBy: [{ sortOrder: "asc" }, { roleId: "asc" }], + include: { + skillBindings: { + orderBy: [{ sortOrder: "asc" }, { agentSkillId: "asc" }], + include: { skill: true }, + }, + }, + }, + }, + }, + }, + }); + if (project === null || project.archivedAt !== null) { + throw new Error(`active project not found: ${projectId}`); + } + if (project.organization.status !== "ACTIVE") { + throw new Error(`organization ${project.organization.id} is ${project.organization.status}`); + } + if (project.organization.agentRoles.length === 0) { + throw new Error(`no active Agent roles configured for organization ${project.organization.id}`); + } + + const defaults = await this.envSettings.modelRegistry(scope); + const enabledModels = new Set(defaults.listModels().map((model) => model.id)); + const roles: RoleEntry[] = project.organization.agentRoles.map((role) => ({ + id: role.roleId, + label: role.label, + defaultModel: validateRoleModel(role.roleId, role.defaultModel, enabledModels), + ...(role.systemPrompt !== null ? { systemPrompt: role.systemPrompt } : {}), + ...(role.tools !== null ? { tools: roleToolsFromJson(role.roleId, role.tools) } : {}), + skills: role.skillBindings.map((binding): RoleSkillEntry => { + if (binding.skill.disabledAt !== null) { + throw new Error(`role ${role.roleId} selects disabled skill ${binding.skill.name}`); + } + if (!/^[a-f0-9]{64}$/.test(binding.skill.contentDigest)) { + throw new Error(`skill ${binding.skill.name} has invalid content digest`); + } + if (!/^[a-z0-9][a-z0-9-]{0,63}$/.test(binding.skill.name)) { + throw new Error(`skill has invalid name: ${binding.skill.name}`); + } + if (binding.skill.version.trim() === "") { + throw new Error(`skill ${binding.skill.name} has empty version`); + } + return { + name: binding.skill.name, + version: binding.skill.version, + contentDigest: binding.skill.contentDigest, + }; + }), + })); + if (!roles.some((role) => role.id === "draft")) { + throw new Error(`default Agent role draft is not configured for organization ${project.organization.id}`); + } + return new InMemoryModelRegistry(defaults.listModels(), roles); } runPolicy(input: RunPolicyInput): Promise { @@ -150,6 +217,23 @@ export class DatabaseRuntimeSettings implements RuntimeSettings { } } +function roleToolsFromJson(roleId: string, value: Prisma.JsonValue): readonly string[] { + if (!Array.isArray(value) || value.some((tool) => typeof tool !== "string")) { + throw new Error(`role ${roleId} tools must be a JSON string array`); + } + return value as string[]; +} + +function validateRoleModel( + roleId: string, + model: string | null, + enabledModels: ReadonlySet, +): string | undefined { + if (model === null) return undefined; + if (!enabledModels.has(model)) throw new Error(`role ${roleId} selects unavailable model ${model}`); + return model; +} + async function loadActiveProviderSecret( tx: Prisma.TransactionClient, projectId: string, diff --git a/hub/test/integration/agent-configuration.test.ts b/hub/test/integration/agent-configuration.test.ts new file mode 100644 index 0000000..384d614 --- /dev/null +++ b/hub/test/integration/agent-configuration.test.ts @@ -0,0 +1,90 @@ +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterAll, beforeEach, describe, expect, it } from "vitest"; +import { OrganizationAgentConfiguration } from "../../src/agent/configuration.js"; +import { DEFAULT_ORG_ID, prisma, resetDb, seedTestOrganization } from "./helpers.js"; + +describe("Organization Agent configuration management", () => { + let root: string; + let configuration: OrganizationAgentConfiguration; + + beforeEach(async () => { + await resetDb(); + root = await mkdtemp(join(tmpdir(), "cph-agent-config-")); + configuration = new OrganizationAgentConfiguration(prisma, join(root, "store")); + }); + + afterAll(async () => { + await prisma.$disconnect(); + }); + + it("installs versioned skills and selects them as part of a dynamic role bundle", async () => { + const typst = await makeSkill(root, "typst"); + const outline = await makeSkill(root, "outline"); + await configuration.installSkill({ organizationId: DEFAULT_ORG_ID, sourceDir: typst, version: "0.15.0" }); + await configuration.installSkill({ organizationId: DEFAULT_ORG_ID, sourceDir: outline, version: "1" }); + await configuration.upsertRole({ + organizationId: DEFAULT_ORG_ID, + roleId: "draft", + label: "课程草稿", + defaultModel: "anthropic/claude-sonnet-5", + systemPrompt: "write carefully", + tools: ["read_file", "write_file", "cph_build"], + sortOrder: 10, + }); + await prisma.project.create({ + data: { id: "project-a", organizationId: DEFAULT_ORG_ID, name: "A", workspaceDir: "/tmp/a" }, + }); + await prisma.agentSession.create({ + data: { + id: "session-old-role-config", + projectId: "project-a", + provider: "openrouter", + roleId: "draft", + model: "anthropic/claude-sonnet-5", + metadata: {}, + }, + }); + await configuration.setRoleSkills({ + organizationId: DEFAULT_ORG_ID, + roleId: "draft", + skillNames: ["outline", "typst"], + }); + + const role = await prisma.organizationAgentRole.findUniqueOrThrow({ + where: { organizationId_roleId: { organizationId: DEFAULT_ORG_ID, roleId: "draft" } }, + include: { skillBindings: { orderBy: { sortOrder: "asc" }, include: { skill: true } } }, + }); + expect(role).toMatchObject({ label: "课程草稿", systemPrompt: "write carefully" }); + expect(role.tools).toEqual(["read_file", "write_file", "cph_build"]); + expect(role.skillBindings.map((binding) => binding.skill.name)).toEqual(["outline", "typst"]); + await expect(prisma.agentSession.findUniqueOrThrow({ where: { id: "session-old-role-config" } })) + .resolves.toMatchObject({ archivedAt: expect.any(Date) }); + }); + + it("rejects unknown, disabled and cross-Organization skills", async () => { + await seedTestOrganization("org_other", "other"); + const typst = await makeSkill(root, "typst"); + await configuration.installSkill({ organizationId: "org_other", sourceDir: typst, version: "1" }); + await configuration.upsertRole({ + organizationId: DEFAULT_ORG_ID, + roleId: "draft", + label: "Draft", + tools: [], + }); + + await expect(configuration.setRoleSkills({ + organizationId: DEFAULT_ORG_ID, + roleId: "draft", + skillNames: ["typst"], + })).rejects.toThrow("active skills not found in organization"); + }); + + async function makeSkill(parent: string, name: string): Promise { + const source = join(parent, "sources", name); + await mkdir(source, { recursive: true }); + await writeFile(join(source, "SKILL.md"), `---\nname: ${name}\ndescription: ${name} skill\n---\n# ${name}\n`); + return source; + } +}); diff --git a/hub/test/integration/agent-runtime-config.test.ts b/hub/test/integration/agent-runtime-config.test.ts new file mode 100644 index 0000000..2664d4c --- /dev/null +++ b/hub/test/integration/agent-runtime-config.test.ts @@ -0,0 +1,121 @@ +import { afterAll, beforeEach, describe, expect, it } from "vitest"; +import { DatabaseRuntimeSettings } from "../../src/settings/runtime.js"; +import { DEFAULT_ORG_ID, prisma, resetDb, seedTestOrganization, testSecretEnvelope } from "./helpers.js"; + +describe("Organization-scoped Agent runtime configuration", () => { + beforeEach(async () => { + await resetDb(); + }); + + afterAll(async () => { + await prisma.$disconnect(); + }); + + it("resolves role prompt, model, tools and skills from the project Organization", async () => { + await seedTestOrganization("org_other", "other"); + await Promise.all([ + prisma.project.create({ + data: { id: "project-a", organizationId: DEFAULT_ORG_ID, name: "A", workspaceDir: "/tmp/a" }, + }), + prisma.project.create({ + data: { id: "project-b", organizationId: "org_other", name: "B", workspaceDir: "/tmp/b" }, + }), + ]); + const [skillA, skillB] = await Promise.all([ + prisma.organizationAgentSkill.create({ + data: { + id: "skill-a", + organizationId: DEFAULT_ORG_ID, + name: "typst", + version: "0.15.0", + contentDigest: "a".repeat(64), + }, + }), + prisma.organizationAgentSkill.create({ + data: { + id: "skill-b", + organizationId: "org_other", + name: "typst", + version: "other", + contentDigest: "b".repeat(64), + }, + }), + ]); + const [roleA, roleB] = await Promise.all([ + prisma.organizationAgentRole.create({ + data: { + id: "role-a", + organizationId: DEFAULT_ORG_ID, + roleId: "draft", + label: "A Draft", + defaultModel: "anthropic/claude-sonnet-5", + systemPrompt: "prompt-a", + tools: ["read_file", "cph_build"], + }, + }), + prisma.organizationAgentRole.create({ + data: { + id: "role-b", + organizationId: "org_other", + roleId: "draft", + label: "B Draft", + systemPrompt: "prompt-b", + tools: [], + }, + }), + ]); + await Promise.all([ + prisma.organizationAgentRoleSkill.create({ + data: { organizationId: DEFAULT_ORG_ID, agentRoleId: roleA.id, agentSkillId: skillA.id }, + }), + prisma.organizationAgentRoleSkill.create({ + data: { organizationId: "org_other", agentRoleId: roleB.id, agentSkillId: skillB.id }, + }), + ]); + const settings = new DatabaseRuntimeSettings(prisma, testSecretEnvelope, {}); + + const registryA = await settings.modelRegistry({ projectId: "project-a" }); + const registryB = await settings.modelRegistry({ projectId: "project-b" }); + + expect(registryA.role("draft")).toMatchObject({ + label: "A Draft", + systemPrompt: "prompt-a", + tools: ["read_file", "cph_build"], + skills: [{ name: "typst", version: "0.15.0", contentDigest: "a".repeat(64) }], + }); + expect(registryB.role("draft")).toMatchObject({ + label: "B Draft", + systemPrompt: "prompt-b", + tools: [], + skills: [{ name: "typst", version: "other", contentDigest: "b".repeat(64) }], + }); + }); + + it("fails closed for missing scope and disabled role skills", async () => { + await prisma.project.create({ + data: { id: "project-a", organizationId: DEFAULT_ORG_ID, name: "A", workspaceDir: "/tmp/a" }, + }); + const skill = await prisma.organizationAgentSkill.create({ + data: { + id: "skill-disabled", + organizationId: DEFAULT_ORG_ID, + name: "typst", + version: "0.15.0", + contentDigest: "c".repeat(64), + disabledAt: new Date(), + }, + }); + const role = await prisma.organizationAgentRole.create({ + data: { id: "role-a", organizationId: DEFAULT_ORG_ID, roleId: "draft", label: "Draft" }, + }); + await prisma.organizationAgentRoleSkill.create({ + data: { organizationId: DEFAULT_ORG_ID, agentRoleId: role.id, agentSkillId: skill.id }, + }); + const settings = new DatabaseRuntimeSettings(prisma, testSecretEnvelope, {}); + + await expect(settings.modelRegistry()).rejects.toThrow("projectId is required"); + await expect(settings.modelRegistry({ projectId: "project-a" })).rejects.toThrow( + "role draft selects disabled skill typst", + ); + }); +}); diff --git a/hub/test/integration/agent-sandbox-linux.test.ts b/hub/test/integration/agent-sandbox-linux.test.ts index 95e83ab..a8190d5 100644 --- a/hub/test/integration/agent-sandbox-linux.test.ts +++ b/hub/test/integration/agent-sandbox-linux.test.ts @@ -7,6 +7,7 @@ import { join } from "node:path"; import { promisify } from "node:util"; import { afterEach, describe, expect, it } from "vitest"; import { runAgent, type StreamEvent } from "../../src/agent/runner.js"; +import { importSkillDirectory } from "../../src/agent/skillStore.js"; const execFileAsync = promisify(execFile); const originalEnv = new Map(); @@ -52,7 +53,9 @@ describe("real Claude SDK sandbox boundary", () => { const workspace = join(workspaceRoot, "a", `p_${nonce.slice(0, 8)}`); const sibling = join(workspaceRoot, "b", `p_${nonce.slice(8, 16)}`); const serviceSecret = join(root, `s_${nonce.slice(16, 24)}`); - roots.push(workspace, sibling, serviceSecret); + const skillSource = join(root, `k_${nonce.slice(24, 28)}`); + const skillStore = join(root, `ks_${nonce.slice(28, 32)}`); + roots.push(workspace, sibling, serviceSecret, skillSource, skillStore); await Promise.all([ mkdir(workspace, { recursive: true }), mkdir(sibling, { recursive: true }), @@ -62,6 +65,9 @@ describe("real Claude SDK sandbox boundary", () => { writeFile(join(sibling, "secret.txt"), "sibling-secret\n"), writeFile(serviceSecret, "platform-secret\n"), ]); + await mkdir(skillSource, { recursive: true }); + await writeFile(join(skillSource, "SKILL.md"), "---\nname: outline\ndescription: Outline\n---\n"); + const installedSkill = await importSkillDirectory({ sourceDir: skillSource, storeRoot: skillStore }); const untrustedSkill = join(workspace, ".claude", "skills", "untrusted"); await mkdir(untrustedSkill, { recursive: true }); await writeFile(join(untrustedSkill, "SKILL.md"), "---\nname: untrusted\ndescription: must never load\n---\n"); @@ -86,6 +92,7 @@ describe("real Claude SDK sandbox boundary", () => { DATABASE_URL: "postgresql://platform-secret", FEISHU_APP_SECRET: "feishu-secret", HUB_SESSION_SECRET: "session-secret", + HUB_SKILL_STORE_ROOT: skillStore, }); const bashCommand = [ @@ -133,6 +140,7 @@ describe("real Claude SDK sandbox boundary", () => { ANTHROPIC_API_KEY: "", }, tools: ["bash"], + skills: [{ name: "outline", version: "1", contentDigest: installedSkill.contentDigest }], maxTurns: 3, runId: "sandbox-run", sessionId: "sandbox-session", @@ -150,9 +158,7 @@ describe("real Claude SDK sandbox boundary", () => { ).toBe("completed"); expect(stub.requestCount()).toBeGreaterThanOrEqual(3); expect(new Set(result.initializedSkillIds)).toEqual(new Set([ - "cph-curated:outline", - "cph-curated:lesson-project", - "cph-curated:data-processing-spec", + "cph-runtime:outline", ])); const toolResults = streamEvents.filter((event) => event.type === "tool-result"); expect(toolResults).toHaveLength(2); diff --git a/hub/test/integration/helpers.ts b/hub/test/integration/helpers.ts index a1cd403..54b7974 100644 --- a/hub/test/integration/helpers.ts +++ b/hub/test/integration/helpers.ts @@ -35,6 +35,9 @@ export const prisma = new PrismaClient({ /** Truncate all tables before each test for isolation. */ export async function resetDb(): Promise { const tables = [ + "OrganizationAgentRoleSkill", + "OrganizationAgentRole", + "OrganizationAgentSkill", "FeishuEventReceipt", "FeishuUserIdentity", "FeishuApplicationCredentialVersion", diff --git a/hub/test/integration/silo-bootstrap.test.ts b/hub/test/integration/silo-bootstrap.test.ts index abe1769..e611148 100644 --- a/hub/test/integration/silo-bootstrap.test.ts +++ b/hub/test/integration/silo-bootstrap.test.ts @@ -53,6 +53,14 @@ describe("Alpha Silo bootstrap", () => { expect(await prisma.team.count({ where: { slug: "teachers", archivedAt: null } })).toBe(1); expect(await prisma.teamMembership.count({ where: { revokedAt: null } })).toBe(1); expect(await prisma.organizationProviderConnection.count({ where: { status: "ACTIVE" } })).toBe(1); + await expect(prisma.organizationAgentRole.findMany({ + where: { organizationId: "org_alpha", disabledAt: null }, + orderBy: { sortOrder: "asc" }, + select: { roleId: true, label: true }, + })).resolves.toEqual([ + { roleId: "draft", label: "草稿" }, + { roleId: "review", label: "审校" }, + ]); const persisted = JSON.stringify({ feishu: await prisma.feishuApplicationCredentialVersion.findMany(), diff --git a/hub/test/unit/agent-security.test.ts b/hub/test/unit/agent-security.test.ts index 96dd344..e359a83 100644 --- a/hub/test/unit/agent-security.test.ts +++ b/hub/test/unit/agent-security.test.ts @@ -14,6 +14,7 @@ describe("agent subprocess security policy", () => { it("passes only the run proxy capability and safe runtime variables and protects the capability from tools", async () => { const { workspaceRoot, workspace } = await makeWorkspace(); const policy = await createAgentSecurityPolicy({ + runId: "run-test", workspaceRoot, workspaceDir: workspace, providerProxyEnv: { @@ -51,14 +52,8 @@ describe("agent subprocess security policy", () => { expect(policy.env.TEMP).toBe(policy.env.TMPDIR); expect(policy.env.TMPDIR).toBe(join(canonicalWorkspace, ".cph", "t")); expect(Buffer.byteLength(policy.env.TMPDIR!)).toBeLessThanOrEqual(56); - expect(policy.skillIds).toEqual([ - "cph-curated:outline", - "cph-curated:lesson-project", - "cph-curated:data-processing-spec", - ]); - expect(policy.skillPluginRoot.startsWith(canonicalWorkspace)).toBe(false); - expect(policy.sandbox.filesystem.allowRead).toContain(policy.skillPluginRoot); - expect(policy.sandbox.filesystem.allowWrite).not.toContain(policy.skillPluginRoot); + expect(policy.skillIds).toEqual([]); + expect(policy.skillPluginRoot).toBeUndefined(); expect(policy.sandbox).toMatchObject({ enabled: true, @@ -83,6 +78,7 @@ describe("agent subprocess security policy", () => { const { workspaceRoot, workspace } = await makeWorkspace(); await expect(createAgentSecurityPolicy({ + runId: "run-test", workspaceRoot, workspaceDir: workspace, providerProxyEnv: { @@ -96,6 +92,7 @@ describe("agent subprocess security policy", () => { it("keeps every SDK temp variable on a short path inside the project workspace", async () => { const { workspaceRoot, workspace } = await makeWorkspace(); const policy = await createAgentSecurityPolicy({ + runId: "run-test", workspaceRoot, workspaceDir: workspace, hostEnv: { PATH: "/usr/bin:/bin" }, @@ -121,6 +118,7 @@ describe("agent subprocess security policy", () => { await mkdir(workspace, { recursive: true }); await expect(createAgentSecurityPolicy({ + runId: "run-test", workspaceRoot, workspaceDir: workspace, hostEnv: { PATH: "/usr/bin:/bin" }, @@ -135,6 +133,7 @@ describe("agent subprocess security policy", () => { await symlink(outside, linked); await expect(createAgentSecurityPolicy({ + runId: "run-test", workspaceRoot, workspaceDir: linked, providerProxyEnv: { ANTHROPIC_AUTH_TOKEN: "run-proxy-capability" }, @@ -150,6 +149,7 @@ describe("agent subprocess security policy", () => { await symlink(sibling, linked); await expect(createAgentSecurityPolicy({ + runId: "run-test", workspaceRoot, workspaceDir: linked, providerProxyEnv: { ANTHROPIC_AUTH_TOKEN: "run-proxy-capability" }, diff --git a/hub/test/unit/curated-skills.test.ts b/hub/test/unit/curated-skills.test.ts deleted file mode 100644 index 7d5f9b4..0000000 --- a/hub/test/unit/curated-skills.test.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { afterEach, describe, expect, it } from "vitest"; -import { - CURATED_SKILL_IDS, - CURATED_SKILL_NAMES, - CURATED_SKILL_PLUGIN_NAME, - validateCuratedSkillPlugin, -} from "../../src/agent/curatedSkills.js"; - -describe("validateCuratedSkillPlugin", () => { - const roots: string[] = []; - - afterEach(async () => { - await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))); - }); - - it("accepts exactly the release-owned plugin and returns qualified skill ids", async () => { - const root = await skillPluginFixture(); - - await expect(validateCuratedSkillPlugin(root)).resolves.toEqual({ - root, - skillIds: CURATED_SKILL_IDS, - }); - }); - - it("fails closed when a curated skill is absent from the release", async () => { - const root = await skillPluginFixture(); - await rm(join(root, "skills", "outline"), { recursive: true }); - - await expect(validateCuratedSkillPlugin(root)).rejects.toThrow(/curated plugin entry missing: skills\/outline/); - }); - - it("fails closed when a skill manifest name does not match the allowlist", async () => { - const root = await skillPluginFixture(); - await writeFile(join(root, "skills", "outline", "SKILL.md"), "---\nname: other\n---\n"); - - await expect(validateCuratedSkillPlugin(root)).rejects.toThrow(/curated skill manifest name mismatch/); - }); - - it("rejects an extra skill directory", async () => { - const root = await skillPluginFixture(); - await mkdir(join(root, "skills", "extra")); - - await expect(validateCuratedSkillPlugin(root)).rejects.toThrow(/unexpected curated plugin entry: skills\/extra/); - }); - - it("rejects plugin capabilities outside the reviewed skill catalog", async () => { - const root = await skillPluginFixture(); - await mkdir(join(root, "hooks")); - - await expect(validateCuratedSkillPlugin(root)).rejects.toThrow(/unexpected curated plugin entry: hooks/); - }); - - async function skillPluginFixture(): Promise { - const root = await mkdtemp(join(tmpdir(), "cph-skills-")); - roots.push(root); - await mkdir(join(root, ".claude-plugin"), { recursive: true }); - await writeFile( - join(root, ".claude-plugin", "plugin.json"), - JSON.stringify({ name: CURATED_SKILL_PLUGIN_NAME }), - ); - for (const name of CURATED_SKILL_NAMES) { - const source = join(root, "skills", name); - await mkdir(source, { recursive: true }); - await writeFile(join(source, "SKILL.md"), `---\nname: ${name}\n---\n# ${name}\n`); - } - return root; - } -}); diff --git a/hub/test/unit/runner.test.ts b/hub/test/unit/runner.test.ts index 3752b50..7ddd450 100644 --- a/hub/test/unit/runner.test.ts +++ b/hub/test/unit/runner.test.ts @@ -1,8 +1,9 @@ -import { mkdir, mkdtemp, realpath, rm } from "node:fs/promises"; +import { mkdir, mkdtemp, realpath, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { runAgent } from "../../src/agent/runner.js"; +import { importSkillDirectory } from "../../src/agent/skillStore.js"; const queryMock = vi.hoisted(() => vi.fn()); @@ -76,7 +77,7 @@ describe("runAgent", () => { workspaceRoot = await realpath(workspaceRoot); workspace = await realpath(workspace); previousSecrets = Object.fromEntries( - ["DATABASE_URL", "FEISHU_APP_SECRET", "HUB_SESSION_SECRET"].map((name) => [name, process.env[name]]), + ["DATABASE_URL", "FEISHU_APP_SECRET", "HUB_SESSION_SECRET", "HUB_SKILL_STORE_ROOT"].map((name) => [name, process.env[name]]), ); }); @@ -113,8 +114,7 @@ describe("runAgent", () => { allowDangerouslySkipPermissions: true, settingSources: [], settings: { disableBundledSkills: true }, - plugins: [expect.objectContaining({ type: "local", skipMcpDiscovery: true })], - skills: ["cph-curated:outline", "cph-curated:lesson-project", "cph-curated:data-processing-spec"], + skills: [], strictMcpConfig: true, sandbox: expect.objectContaining({ enabled: true, @@ -149,7 +149,7 @@ describe("runAgent", () => { it("returns the skills actually reported by SDK initialization", async () => { queryMock.mockReturnValue(messages( - initMessage(["cph-curated:outline"]), + initMessage(["cph-runtime:outline"]), assistantMessage("fresh"), resultMessage("sdk-session-1"), )); @@ -164,7 +164,7 @@ describe("runAgent", () => { prisma: stubPrisma, }); - expect(result.initializedSkillIds).toEqual(["cph-curated:outline"]); + expect(result.initializedSkillIds).toEqual(["cph-runtime:outline"]); }); it("maps role tool ids to the Claude SDK tool whitelist", async () => { @@ -183,13 +183,13 @@ describe("runAgent", () => { expect(queryMock.mock.calls[0]?.[0]).toMatchObject({ options: { - tools: ["Read", "Bash", "Skill"], + tools: ["Read", "Bash"], allowedTools: ["Read", "Bash", "mcp__cph_hub__send_file"], }, }); }); - it("keeps only the curated Skill dispatcher for an empty role tool whitelist", async () => { + it("disables SDK tools for an empty role tool and skill selection", async () => { queryMock.mockReturnValue(messages(assistantMessage("ok"), resultMessage("sdk-session-1"))); await runAgent({ @@ -205,12 +205,42 @@ describe("runAgent", () => { expect(queryMock.mock.calls[0]?.[0]).toMatchObject({ options: { - tools: ["Skill"], + tools: [], allowedTools: [], }, }); }); + it("loads only the dynamic skills selected by the role", async () => { + const source = join(root, "skill-source"); + const storeRoot = join(root, "skill-store"); + await mkdir(source); + await writeFile(join(source, "SKILL.md"), "---\nname: typst\ndescription: Typst\n---\n"); + const installed = await importSkillDirectory({ sourceDir: source, storeRoot }); + process.env["HUB_SKILL_STORE_ROOT"] = storeRoot; + queryMock.mockReturnValue(messages(assistantMessage("ok"), resultMessage("sdk-session-1"))); + + await runAgent({ + prompt: "排版", + model: undefined, + project: { projectId: "p", boundChatId: "c", workspaceRoot, workspaceDir: workspace }, + systemPrompt: undefined, + tools: [], + skills: [{ name: "typst", version: "0.15.0", contentDigest: installed.contentDigest }], + runId: "run-skill", + sessionId: "hub-session-1", + prisma: stubPrisma, + }); + + expect(queryMock.mock.calls[0]?.[0]).toMatchObject({ + options: { + tools: ["Skill"], + plugins: [expect.objectContaining({ type: "local", skipMcpDiscovery: true })], + skills: ["cph-runtime:typst"], + }, + }); + }); + it("returns SDK-reported cost when present", async () => { queryMock.mockReturnValue(messages(assistantMessage("ok"), resultMessage("sdk-session-1", 0.0042))); diff --git a/hub/test/unit/skill-store.test.ts b/hub/test/unit/skill-store.test.ts new file mode 100644 index 0000000..2276b9d --- /dev/null +++ b/hub/test/unit/skill-store.test.ts @@ -0,0 +1,71 @@ +import { mkdir, mkdtemp, readFile, rm, symlink, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { importSkillDirectory, prepareRunSkillPlugin } from "../../src/agent/skillStore.js"; + +describe("content-addressed Agent skill store", () => { + const roots: string[] = []; + + afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))); + }); + + it("imports a skill into an immutable digest directory and materializes a selected run plugin", async () => { + const root = await makeRoot(); + const source = await makeSkill(root, "typst", "Typst help"); + const storeRoot = join(root, "store"); + + const installed = await importSkillDirectory({ sourceDir: source, storeRoot }); + expect(installed).toMatchObject({ name: "typst", description: "Typst help" }); + expect(installed.contentDigest).toMatch(/^[a-f0-9]{64}$/); + await expect(readFile(join(storeRoot, "versions", installed.contentDigest, "SKILL.md"), "utf8")) + .resolves.toContain("name: typst"); + + const plugin = await prepareRunSkillPlugin({ + storeRoot, + runId: "run-1", + skills: [{ name: "typst", version: "0.15.0", contentDigest: installed.contentDigest }], + }); + expect(plugin).not.toBeNull(); + expect(plugin?.skillIds).toEqual(["cph-runtime:typst"]); + await expect(readFile(join(plugin!.root, "skills", "typst", "reference.md"), "utf8")) + .resolves.toBe("reference\n"); + + await plugin?.cleanup(); + await expect(readFile(join(plugin!.root, ".claude-plugin", "plugin.json"), "utf8")) + .rejects.toMatchObject({ code: "ENOENT" }); + }); + + it("rejects symlinks and detects content tampering before a run", async () => { + const root = await makeRoot(); + const source = await makeSkill(root, "outline", "Outline"); + await symlink(join(source, "reference.md"), join(source, "link.md")); + await expect(importSkillDirectory({ sourceDir: source, storeRoot: join(root, "store") })) + .rejects.toThrow(/symlink/); + await rm(join(source, "link.md")); + + const storeRoot = join(root, "store"); + const installed = await importSkillDirectory({ sourceDir: source, storeRoot }); + await writeFile(join(storeRoot, "versions", installed.contentDigest, "reference.md"), "tampered\n"); + await expect(prepareRunSkillPlugin({ + storeRoot, + runId: "run-2", + skills: [{ name: "outline", version: "1", contentDigest: installed.contentDigest }], + })).rejects.toThrow(/content digest mismatch/); + }); + + async function makeRoot(): Promise { + const root = await mkdtemp(join(tmpdir(), "cph-skill-store-")); + roots.push(root); + return root; + } +}); + +async function makeSkill(root: string, name: string, description: string): Promise { + const source = join(root, "source", name); + await mkdir(source, { recursive: true }); + await writeFile(join(source, "SKILL.md"), `---\nname: ${name}\ndescription: ${description}\n---\n# ${name}\n`); + await writeFile(join(source, "reference.md"), "reference\n"); + return source; +}