skills.spec.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236
  1. import { describe, expect, it } from 'vitest'
  2. import * as fs from 'node:fs'
  3. import * as path from 'node:path'
  4. import { parse as parseYaml } from 'yaml'
  5. /**
  6. * 主控+节点+聚合技能(全生命周期 10 份)。
  7. * 目录名即 frontmatter name(Agent Skills 规范硬要求:name 必须与父目录名一致)。
  8. */
  9. const EXPECTED_SKILLS: ReadonlyArray<{ readonly name: string; readonly 中文名: string }> = [
  10. { name: 'novel-director', 中文名: '工作台总控' },
  11. { name: 'novel-inspiration', 中文名: '灵感与立项' },
  12. { name: 'novel-design', 中文名: '定调设计' },
  13. { name: 'novel-outline', 中文名: '细纲备料' },
  14. { name: 'novel-drafting', 中文名: '正文起草' },
  15. { name: 'novel-review', 中文名: '全面审读' },
  16. { name: 'novel-polish', 中文名: '精修润色' },
  17. { name: 'novel-revision', 中文名: '改稿' },
  18. { name: 'novel-settle', 中文名: '定稿沉淀' },
  19. { name: 'novel-export', 中文名: '最小导出' },
  20. ]
  21. const BLACKLIST = ['家位', '门控', '闭环', '抓手', '世代']
  22. function skillPath(name: string): string {
  23. return path.join(__dirname, '..', 'skills', name, 'SKILL.md')
  24. }
  25. function readSkill(name: string): string {
  26. return fs.readFileSync(skillPath(name), 'utf8')
  27. }
  28. const SKILL_NAME_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/
  29. /**
  30. * 解析 SKILL.md frontmatter,走真 YAML 解析而非逐行正则。
  31. *
  32. * 必须真解析(2026-09-01 真机实证):novel-settle 的 description 里含未加引号的
  33. * `ch: 提交`,YAML 读成嵌套映射直接抛错,dsh 的 parseSkillFile 拿不到 name/description
  34. * 便 warn-and-drop —— 技能静默不装载,真机上从未生效。而逐行正则不在乎 YAML 合法性,
  35. * 一路绿灯,所以这个缺陷此前测试完全测不出来。
  36. */
  37. function readFrontmatter(text: string): Record<string, unknown> {
  38. const m = /^---\r?\n([\s\S]*?)\r?\n---/.exec(text)
  39. if (m === null) throw new Error('缺少 frontmatter')
  40. const parsed: unknown = parseYaml(m[1]!)
  41. if (parsed === null || typeof parsed !== 'object') throw new Error('frontmatter 不是映射')
  42. return parsed as Record<string, unknown>
  43. }
  44. describe('聚合 skills 体系(渐进式披露与 Subagent 协同)', () => {
  45. it('交付 10 份 SKILL.md,文件名一一对应', () => {
  46. for (const s of EXPECTED_SKILLS) {
  47. expect(fs.existsSync(skillPath(s.name)), `${s.name}/SKILL.md 应存在`).toBe(true)
  48. }
  49. // 反向核对:磁盘上不得多出未登记的技能。此前《改稿》在盘上却不在表内、
  50. // 《技法路由》退役后残留也测不出来,都因为只做了单向检查。
  51. const onDisk = fs
  52. .readdirSync(path.join(__dirname, '..', 'skills'), { withFileTypes: true })
  53. .filter((e) => e.isDirectory())
  54. .map((e) => e.name)
  55. .sort()
  56. expect(onDisk).toEqual([...EXPECTED_SKILLS.map((s) => s.name)].sort())
  57. })
  58. it('工作台总控:含状态校准第一律 + 六大铁律 + 节点路由 + 子代理交接 + 章内接续', () => {
  59. const text = readSkill('novel-director')
  60. expect(text).toContain('novel_get_story_status')
  61. expect(text).toContain('状态校准第一律')
  62. expect(text).toContain('六大铁律')
  63. expect(text).toContain('严禁代写正文')
  64. expect(text).toContain('节点路由')
  65. expect(text).toContain('novel_create_book')
  66. expect(text).toContain('Subagent')
  67. expect(text).toContain('自包含')
  68. expect(text).toContain('发现项')
  69. expect(text).toContain('建议稿')
  70. // 章内接续:机械步骤直接做下一步;决定点(发现项处置/定稿裁决/新选择)仍停稳
  71. expect(text).toContain('章内接续')
  72. expect(text).toContain('直接做下一步,不结束回合等一句「继续」')
  73. expect(text).toContain('不自动开下一章')
  74. })
  75. it('灵感与立项:含三步法 + 七要素 + 建书门槛', () => {
  76. const text = readSkill('novel-inspiration')
  77. expect(text).toContain('随手记')
  78. expect(text).toContain('七要素')
  79. expect(text).toContain('核心创意')
  80. expect(text).toContain('差异化')
  81. expect(text).toContain('禁区')
  82. expect(text).toContain('novel_create_book')
  83. expect(text).toContain('novel_seed_min_design')
  84. expect(text).toContain('未经作者明确同意,严禁进入建书')
  85. })
  86. it('定调设计:含契约六部 + 双路径 + 分部更新纪律 + 强制多方向', () => {
  87. const text = readSkill('novel-design')
  88. expect(text).toContain('契约六部')
  89. expect(text).toContain('novel_seed_min_design')
  90. expect(text).toContain('novel_update_contract')
  91. expect(text).toContain('题材与读者定位')
  92. expect(text).toContain('创作禁区与不可妥协项')
  93. expect(text).toContain('留白')
  94. expect(text).toContain('分部更新')
  95. expect(text).toContain('强制多方向')
  96. expect(text).toContain('不参与发散')
  97. expect(text).toContain('典型版')
  98. expect(text).toContain('代价与放弃项')
  99. expect(text).toContain('方向选择')
  100. })
  101. it('细纲备料:含定位段/细纲段 + 材料包十段 + 事实播报', () => {
  102. const text = readSkill('novel-outline')
  103. expect(text).toContain('定位段')
  104. expect(text).toContain('细纲段')
  105. expect(text).toContain('近期窗口')
  106. expect(text).toContain('材料清单.json')
  107. expect(text).toContain('条目切片')
  108. expect(text).toContain('来源与版本')
  109. expect(text).toContain('不是开写门槛')
  110. expect(text).toContain('本章形状')
  111. expect(text).toContain('已排除走向')
  112. // 任务19:已定稿章细纲勘误走候选→再确认,只改设计工件无需吃书补偿
  113. expect(text).toContain('已定稿章的细纲勘误')
  114. expect(text).toContain('窗口条目已消费不影响确认')
  115. expect(text).toContain('无需吃书补偿')
  116. })
  117. it('正文起草:含自包含任务包 + 子代理派发 + 单点重试', () => {
  118. const text = readSkill('novel-drafting')
  119. expect(text).toContain('起草子代理')
  120. expect(text).toContain('自包含')
  121. expect(text).toContain('确认细纲全文')
  122. expect(text).toContain('材料包切片')
  123. expect(text).toContain('文风基准')
  124. expect(text).toContain('单点重试')
  125. expect(text).toContain('绝不亲自写正文')
  126. expect(text).toContain('已排除走向')
  127. })
  128. it('定稿沉淀:含三步流程 + 七件 + 作者裁决 + 原子沉淀', () => {
  129. const text = readSkill('novel-settle')
  130. expect(text).toContain('novel_prepare_pack')
  131. expect(text).toContain('novel_settle_chapter')
  132. expect(text).toContain('待定稿包')
  133. expect(text).toContain('卷对账')
  134. expect(text).toContain('作者裁决')
  135. expect(text).toContain('未获作者明确批准')
  136. expect(text).toContain('只增不改')
  137. expect(text).toContain('吃书补偿')
  138. // 任务21 件一:卷末与卷摘要段(候选脚本+确认落盘+待核对不写无偏离)
  139. expect(text).toContain('卷摘要候选.mjs')
  140. expect(text).toContain('目标: 卷摘要')
  141. expect(text).toContain('待核对')
  142. expect(text).toContain('不设上限、不截断')
  143. })
  144. it('全面审读:含 8 维标准 + Subagent 派发指引 + 发现项规范', () => {
  145. const text = readSkill('novel-review')
  146. expect(text).toContain('章节结构')
  147. expect(text).toContain('人物与关系')
  148. expect(text).toContain('情节与因果')
  149. expect(text).toContain('信息披露')
  150. expect(text).toContain('线索与伏笔')
  151. expect(text).toContain('节奏与张力')
  152. expect(text).toContain('读者承诺')
  153. expect(text).toContain('文风与表达')
  154. expect(text).toContain('Subagent')
  155. expect(text).toContain('发现项')
  156. expect(text).toContain('绝不直接修改正文')
  157. })
  158. it('精修润色:含 7 道工序 + 保真红线 + 逐道回退机制', () => {
  159. const text = readSkill('novel-polish')
  160. expect(text).toContain('去 AI 痕迹')
  161. expect(text).toContain('语言自然化')
  162. expect(text).toContain('句段节奏调整')
  163. expect(text).toContain('对话润色')
  164. expect(text).toContain('文风贴合')
  165. expect(text).toContain('冗余精简')
  166. expect(text).toContain('修辞与意象调整')
  167. expect(text).toContain('只改表达,不改事实')
  168. expect(text).toContain('可回退')
  169. })
  170. it('最小导出:含范围选择 + 冲突不覆盖 + 清单最后落盘 + 逐字节一致', () => {
  171. const text = readSkill('novel-export')
  172. expect(text).toContain('最小导出.mjs')
  173. expect(text).toContain('已定稿')
  174. expect(text).toContain('--校验 true')
  175. expect(text).toContain('首版不支持原位覆盖')
  176. expect(text).toContain('校验.ok:true')
  177. expect(text).toContain('不覆盖')
  178. expect(text).toContain('逐字节一致')
  179. expect(text).toContain('SHA-256')
  180. })
  181. it('作者介入:五份章节节点技能各含「作者介入」小节与作者稿通道', () => {
  182. for (const name of ['novel-drafting', 'novel-polish', 'novel-review', 'novel-revision', 'novel-settle']) {
  183. const text = readSkill(name)
  184. expect(text, `${name}: 含「作者介入」小节`).toContain('## 作者介入')
  185. }
  186. expect(readSkill('novel-drafting')).toContain('作者手写')
  187. expect(readSkill('novel-revision')).toContain('novel_apply_revision_batch')
  188. expect(readSkill('novel-settle')).toContain('底本/沉淀对账.md')
  189. })
  190. it('零机器味:全部不含黑名单词', () => {
  191. for (const s of EXPECTED_SKILLS) {
  192. const text = readSkill(s.name)
  193. for (const w of BLACKLIST) {
  194. expect(text, `${s.中文名}: 不应含「${w}」`).not.toContain(w)
  195. }
  196. }
  197. })
  198. it('frontmatter:name 合法且与父目录名一致 + 非空中文 description', () => {
  199. for (const s of EXPECTED_SKILLS) {
  200. const text = readSkill(s.name)
  201. // 非法 YAML 在此直接抛错,与 dsh 的 warn-and-drop 静默丢弃形成对照
  202. const fm = readFrontmatter(text)
  203. const name = fm['name']
  204. const description = fm['description']
  205. expect(typeof name, `${s.中文名}: name 应是字符串`).toBe('string')
  206. expect(typeof description, `${s.中文名}: description 应是字符串`).toBe('string')
  207. if (typeof name === 'string' && typeof description === 'string') {
  208. expect(SKILL_NAME_RE.test(name), `${s.中文名}: name 应是英文 kebab-case`).toBe(true)
  209. expect(name, `${s.中文名}: name 应与父目录名一致`).toBe(s.name)
  210. expect(name.length, `${s.中文名}: name ≤64`).toBeLessThanOrEqual(64)
  211. expect(description.trim().length, `${s.中文名}: description 非空`).toBeGreaterThan(0)
  212. expect(description.length, `${s.中文名}: description ≤1024`).toBeLessThanOrEqual(1024)
  213. expect(description, `${s.中文名}: description 中文`).toMatch(/[一-鿿]/)
  214. }
  215. }
  216. })
  217. })