Просмотр исходного кода

feat(v7): M4 P3——宿主壳生成器 + drift check(确定性) + package validator + CI

- generate.js:零依赖条件块渲染器({{#if}}/{{#unless}}/{{a.b}});codex→TOML、余→md;
  category/severity/schema 从 schema.js 单源注入(角色与校验器不双表)
- validator.js:registry schema / 一级宿主 support.md / SKILL description ≤8k / 角色 frontmatter / 无本机绝对路径;
  driftCheck=同输入连跑两次逐字节一致 + validator
- scripts/build-host-shells.mjs:--target/--check 薄 CLI;dist/ 入 .gitignore(生成物不提交)
- v7-ci.yml 加 drift check 步骤(双平台)
- 12 测试绿(全量 241);schema.js 补 SCHEMA_EXAMPLE 单源
lingfengQAQ 3 часов назад
Родитель
Сommit
402af5e8a9

+ 2 - 0
.github/workflows/v7-ci.yml

@@ -26,5 +26,7 @@ jobs:
         run: npm ci
       - name: 单元测试(含中文路径用例)
         run: node --test
+      - name: 宿主壳 drift check(生成器确定性 + package validator)
+        run: node scripts/build-host-shells.mjs --check
       - name: 版本门槛冒烟
         run: node bin/webnovel-writer.js --version

+ 6 - 6
.trellis/tasks/06-27-m4-ai-roles/implement.md

@@ -53,12 +53,12 @@ P5 AC 复核 + CI 双平台;真模型 smoke 推迟文档
 
 ## P3 生成器 + drift check + validator(R4 JS 面)
 
-- [ ] P3.1 `src/host-shells/generate.js`:读 roles/skills/registry → 各平台壳;手写条件块渲染器(零依赖)。先红:fixture 源 → 期望产物
-- [ ] P3.2 drift check:`--check` 同输入连跑两次逐字节一致 + 生成物过 validator
-- [ ] P3.3 `src/host-shells/validator.js`:registry schema / support.md 存在 / description 长度 / 无绝对路径 / roles frontmatter
-- [ ] P3.4 `scripts/build-host-shells.mjs`:薄 CLI(`--target all|<host>`、`--check`)。
-- [ ] P3.5 `test/host-shells/`:生成 Claude Code/Codex 壳、降级编译、drift 确定性、validator 各拒绝项
-- [ ] P3.6 CI:`.github/workflows/v7-ci.yml` 加 `build-host-shells --check` + validator 步骤(双平台)
+- [x] P3.1 `src/host-shells/generate.js`:零依赖条件块渲染器(`{{#if}}`/`{{#unless}}`/`{{a.b}}`)+ 多平台壳(codex→TOML,余→md)+ schema 从 schema.js 单源注入
+- [x] P3.2 drift check:`driftCheck` 同输入连跑两次逐字节一致 + validator 通过
+- [x] P3.3 `src/host-shells/validator.js`:registry schema / 一级宿主 support.md 存在 / SKILL description ≤ 8k / 角色 frontmatter / 无本机绝对路径(源 + 生成物)
+- [x] P3.4 `scripts/build-host-shells.mjs`:薄 CLI(`--target all|<host>`、`--check`);dist/ 入 .gitignore
+- [x] P3.5 `test/host-shells/{generate,validator}.test.js`(12):条件块、TOML、占位符注入、drift 确定性、validator 各拒绝项 + 真实资产过
+- [x] P3.6 CI:`v7-ci.yml` 加「drift check」步骤(双平台)。全量 241 绿;CLI 冒烟 4 平台壳生成、codex TOML、dist 被忽略
 
 **验证 P3**:`node --test test/host-shells/` + `node scripts/build-host-shells.mjs --check` 全绿
 **提交 P3**:`feat(v7): M4 P3——宿主壳生成器 + drift check(确定性)+ package validator + CI`

+ 3 - 0
v7/.gitignore

@@ -3,3 +3,6 @@
 
 # 依赖
 node_modules/
+
+# 宿主壳生成物:由生成器产出 + 安装器分发,不入库(多智能体 spec v3.4 §6.2)
+dist/

+ 45 - 0
v7/scripts/build-host-shells.mjs

@@ -0,0 +1,45 @@
+#!/usr/bin/env node
+import path from 'node:path'
+import { fileURLToPath } from 'node:url'
+import { writeHostShells, generateHostShells } from '../src/host-shells/generate.js'
+import { driftCheck } from '../src/host-shells/validator.js'
+
+// scripts/ → 上一级是 v7 包根
+const v7 = path.join(path.dirname(fileURLToPath(import.meta.url)), '..')
+
+const args = process.argv.slice(2)
+const has = (f) => args.includes(f)
+const getOpt = (k) => {
+  const a = args.find((x) => x.startsWith(`${k}=`))
+  return a ? a.slice(k.length + 1) : undefined
+}
+
+async function main() {
+  if (has('--check')) {
+    const r = await driftCheck(v7)
+    if (!r.ok) {
+      console.error(`drift check 失败:${r.error}`)
+      process.exit(1)
+    }
+    console.log('drift check 通过:生成器确定 + validator 通过')
+    return
+  }
+
+  const target = getOpt('--target') || 'all'
+  const distDir = path.join(v7, 'dist')
+  const all = await generateHostShells(v7)
+  const hosts = target === 'all' ? Object.keys(all) : [target]
+  for (const host of hosts) {
+    if (!all[host]) {
+      console.error(`未知宿主:${target}(可选:${Object.keys(all).join('、')})`)
+      process.exit(1)
+    }
+  }
+  await writeHostShells(v7, distDir)
+  console.log(`已生成宿主壳到 dist/:${hosts.join('、')}`)
+}
+
+main().catch((e) => {
+  console.error(e.message)
+  process.exit(1)
+})

+ 89 - 0
v7/src/host-shells/generate.js

@@ -0,0 +1,89 @@
+import { promises as fs } from 'node:fs'
+import path from 'node:path'
+import { parseFrontMatter } from '../storage/parsers/front-matter.js'
+import { FACT_CATEGORIES, EDIT_CATEGORIES, SEVERITIES, SCHEMA_EXAMPLE } from '../review/schema.js'
+
+/**
+ * 宿主壳生成器(多智能体 spec v3.4 §6.3):读 roles/ + skills/ + adapters/registry.json,
+ * 渲染平台条件块 + 注入 schema 单源,产各平台 agent 壳与编译后 SKILL。
+ * 确定性:同输入必同输出(drift check 基础)。不联网、不改业务源。
+ */
+
+/** schema 单源注入上下文(category/severity/范例来自 schema.js,角色与校验器不双表) */
+function schemaContext() {
+  return {
+    categories: { factCheck: FACT_CATEGORIES.join('、'), editorial: EDIT_CATEGORIES.join('、') },
+    severities: SEVERITIES.join(' | '),
+    schema: { example: JSON.stringify(SCHEMA_EXAMPLE, null, 2) },
+  }
+}
+
+/** 零依赖模板渲染:{{#if v}}/{{#unless v}} 条件块(非嵌套)+ {{a.b}} 变量插值 */
+export function renderTemplate(text, ctx) {
+  let t = text.replace(/\{\{#if (\w+)\}\}\n?([\s\S]*?)\{\{\/if\}\}\n?/g, (_, v, b) => (ctx[v] ? b : ''))
+  t = t.replace(/\{\{#unless (\w+)\}\}\n?([\s\S]*?)\{\{\/unless\}\}\n?/g, (_, v, b) => (!ctx[v] ? b : ''))
+  t = t.replace(/\{\{([\w.]+)\}\}/g, (_, key) => {
+    const val = key.split('.').reduce((o, k) => (o == null ? undefined : o[k]), ctx)
+    return val == null ? '' : String(val)
+  })
+  return t
+}
+
+async function readRoles(rolesDir) {
+  const files = (await fs.readdir(rolesDir)).filter((f) => f.endsWith('.md')).sort()
+  const roles = []
+  for (const f of files) {
+    const parsed = parseFrontMatter(await fs.readFile(path.join(rolesDir, f), 'utf8'))
+    roles.push({ name: parsed.data.name, description: parsed.data.description, body: parsed.body })
+  }
+  return roles
+}
+
+function roleToMarkdown(role, ctx) {
+  return `---\nname: ${role.name}\ndescription: ${role.description}\n---\n${renderTemplate(role.body, ctx)}`
+}
+function roleToToml(role, ctx) {
+  return `name = "${role.name}"\ndescription = "${role.description}"\n\ninstructions = """\n${renderTemplate(role.body, ctx)}\n"""\n`
+}
+
+/**
+ * 生成所有宿主壳(内存态,确定性)。
+ * @param {string} baseDir v7 包根(含 roles/ skills/ adapters/)
+ * @returns {Promise<Object>} { <host>: { <相对路径>: 内容 } },host 与文件按字典序
+ */
+export async function generateHostShells(baseDir) {
+  const registry = JSON.parse(await fs.readFile(path.join(baseDir, 'adapters', 'registry.json'), 'utf8'))
+  const roles = await readRoles(path.join(baseDir, 'roles'))
+  const skillRaw = await fs.readFile(path.join(baseDir, 'skills', 'webnovel-writer', 'SKILL.md'), 'utf8')
+  const sc = schemaContext()
+
+  const out = {}
+  const hosts = Object.keys(registry.hosts)
+    .filter((h) => h !== '_default')
+    .sort()
+  for (const host of hosts) {
+    const h = registry.hosts[host]
+    const ctx = { ...sc, agentCapable: !!h.agentCapable, hasHooks: !!h.hasHooks }
+    const files = {}
+    files['skills/webnovel-writer/SKILL.md'] = renderTemplate(skillRaw, ctx)
+    for (const role of roles) {
+      if (host === 'codex') files[`agents/${role.name}.toml`] = roleToToml(role, ctx)
+      else files[`agents/${role.name}.md`] = roleToMarkdown(role, ctx)
+    }
+    out[host] = files
+  }
+  return out
+}
+
+/** 写生成物到 dist/<host>/(dist 不提交) */
+export async function writeHostShells(baseDir, distDir) {
+  const out = await generateHostShells(baseDir)
+  for (const [host, files] of Object.entries(out)) {
+    for (const [rel, content] of Object.entries(files)) {
+      const full = path.join(distDir, host, rel)
+      await fs.mkdir(path.dirname(full), { recursive: true })
+      await fs.writeFile(full, content, 'utf8')
+    }
+  }
+  return out
+}

+ 84 - 0
v7/src/host-shells/validator.js

@@ -0,0 +1,84 @@
+import { promises as fs } from 'node:fs'
+import path from 'node:path'
+import { parseFrontMatter } from '../storage/parsers/front-matter.js'
+import { generateHostShells } from './generate.js'
+
+/** package validator(多智能体 spec v3.4 §9):registry/support.md/description 预算/无本机绝对路径 */
+
+// windows 盘符路径 或 unix 用户目录绝对路径
+const ABS_PATH = /(?:[A-Za-z]:\\)|(?:\/(?:Users|home)\/)/
+const CODEX_DESC_BUDGET = 8192
+
+export async function validatePackage(baseDir) {
+  const errors = []
+
+  let registry
+  try {
+    registry = JSON.parse(await fs.readFile(path.join(baseDir, 'adapters', 'registry.json'), 'utf8'))
+  } catch (e) {
+    return { ok: false, errors: [`registry.json 读取/解析失败:${e.message}`] }
+  }
+  if (!registry.schema_version) errors.push('registry 缺 schema_version')
+  if (!registry.hosts || typeof registry.hosts !== 'object') {
+    return { ok: false, errors: [...errors, 'registry 缺 hosts'] }
+  }
+  for (const [host, h] of Object.entries(registry.hosts)) {
+    if (host === '_default') continue
+    if (h.tier == null) errors.push(`${host} 缺 tier`)
+    if (h.tier === 1) {
+      try {
+        await fs.access(path.join(baseDir, 'adapters', host, 'support.md'))
+      } catch {
+        errors.push(`一级宿主 ${host} 缺 support.md`)
+      }
+    }
+  }
+
+  const rolesDir = path.join(baseDir, 'roles')
+  let roleFiles = []
+  try {
+    roleFiles = (await fs.readdir(rolesDir)).filter((f) => f.endsWith('.md'))
+  } catch {
+    errors.push('缺 roles/ 目录')
+  }
+  for (const f of roleFiles) {
+    const raw = await fs.readFile(path.join(rolesDir, f), 'utf8')
+    const parsed = parseFrontMatter(raw)
+    if (!parsed.ok || !parsed.data.name) errors.push(`角色 ${f} 缺 frontmatter name`)
+    if (!parsed.ok || !parsed.data.description) errors.push(`角色 ${f} 缺 description`)
+    if (ABS_PATH.test(raw)) errors.push(`角色 ${f} 含本机绝对路径`)
+  }
+
+  try {
+    const skillRaw = await fs.readFile(path.join(baseDir, 'skills', 'webnovel-writer', 'SKILL.md'), 'utf8')
+    const skill = parseFrontMatter(skillRaw)
+    const desc = (skill.ok && skill.data.description) || ''
+    if (desc.length > CODEX_DESC_BUDGET) errors.push(`SKILL description 超 Codex 8k 预算:${desc.length}`)
+    if (ABS_PATH.test(skillRaw)) errors.push('SKILL.md 含本机绝对路径')
+  } catch (e) {
+    errors.push(`SKILL.md 读取失败:${e.message}`)
+  }
+
+  // 生成物无本机绝对路径
+  try {
+    const out = await generateHostShells(baseDir)
+    for (const [host, files] of Object.entries(out)) {
+      for (const [rel, content] of Object.entries(files)) {
+        if (ABS_PATH.test(content)) errors.push(`生成物 ${host}/${rel} 含本机绝对路径`)
+      }
+    }
+  } catch (e) {
+    errors.push(`生成器运行失败:${e.message}`)
+  }
+
+  return { ok: errors.length === 0, errors }
+}
+
+/** drift check:同输入连跑两次逐字节一致(确定性) + validator 通过。CI 必跑。 */
+export async function driftCheck(baseDir) {
+  const a = JSON.stringify(await generateHostShells(baseDir))
+  const b = JSON.stringify(await generateHostShells(baseDir))
+  if (a !== b) return { ok: false, error: '生成器输出非确定(drift)' }
+  const v = await validatePackage(baseDir)
+  return { ok: v.ok, error: v.ok ? '' : `validator 失败:${v.errors.join(';')}` }
+}

+ 20 - 0
v7/src/review/schema.js

@@ -10,6 +10,26 @@ export const FACT_CATEGORIES = [
 export const EDIT_CATEGORIES = ['structure', 'pacing', 'commercial', 'motivation']
 export const SEVERITIES = ['critical', 'high', 'medium', 'low']
 
+/** 审稿单 JSON 形态范例(单源:生成器注入角色任务书,角色与校验器不双表) */
+export const SCHEMA_EXAMPLE = {
+  chapter: 100,
+  issues: [
+    {
+      severity: 'critical',
+      category: 'setting',
+      location: '第3段',
+      description: '问题描述',
+      evidence: '草稿引用 vs 输入数据',
+      fix_hint: '修复方向',
+      blocking: true,
+    },
+  ],
+  issues_count: 1,
+  blocking_count: 1,
+  has_blocking: true,
+  summary: 'N个问题:X个阻断,Y个高优',
+}
+
 const REQUIRED_FIELDS = ['location', 'description', 'evidence', 'fix_hint']
 
 /**

+ 51 - 0
v7/test/host-shells/generate.test.js

@@ -0,0 +1,51 @@
+import { test } from 'node:test'
+import assert from 'node:assert/strict'
+import { fileURLToPath } from 'node:url'
+import { generateHostShells, renderTemplate } from '../../src/host-shells/generate.js'
+
+const V7 = fileURLToPath(new URL('../../', import.meta.url))
+
+test('renderTemplate:if/unless 条件块 + 变量插值', () => {
+  const t = '{{#if a}}A入{{/if}}{{#unless a}}A去{{/unless}} {{x.y}}'
+  assert.equal(renderTemplate(t, { a: true, x: { y: '值' } }).trim(), 'A入 值')
+  assert.equal(renderTemplate(t, { a: false, x: { y: '值' } }).trim(), 'A去 值')
+})
+
+test('renderTemplate:agentCapable=false → 渲染兼容(降级)模式块', () => {
+  const t = '{{#if agentCapable}}完整{{/if}}{{#unless agentCapable}}兼容模式{{/unless}}'
+  assert.match(renderTemplate(t, { agentCapable: false }), /兼容模式/)
+  assert.ok(!renderTemplate(t, { agentCapable: false }).includes('完整'))
+})
+
+test('生成 claude-code 壳:hasHooks 块入、unless 块去;两审完整模式;占位符全渲染', async () => {
+  const out = await generateHostShells(V7)
+  const skill = out['claude-code']['skills/webnovel-writer/SKILL.md']
+  assert.match(skill, /SessionStart 已注入/)
+  assert.ok(!skill.includes('扫描含'), 'hasHooks=true 应去掉 unless 块')
+  assert.match(skill, /完整模式/)
+  assert.ok(!skill.includes('{{'), '占位符应全部渲染')
+})
+
+test('生成 codex 壳:无 hook → unless 块入;角色输出 TOML', async () => {
+  const out = await generateHostShells(V7)
+  const skill = out['codex']['skills/webnovel-writer/SKILL.md']
+  assert.match(skill, /读工作目录/)
+  assert.ok(!skill.includes('SessionStart 已注入'))
+  const role = out['codex']['agents/事实审查.toml']
+  assert.match(role, /name = "事实审查"/)
+  assert.match(role, /instructions = """/)
+})
+
+test('角色占位符注入 category(来自 schema.js 单源)', async () => {
+  const out = await generateHostShells(V7)
+  const role = out['claude-code']['agents/事实审查.md']
+  assert.match(role, /unregistered_thread/)
+  assert.ok(!role.includes('{{categories'), 'category 占位符已渲染')
+  assert.ok(!role.includes('{{schema'), 'schema 占位符已渲染')
+})
+
+test('drift check:同输入连跑两次逐字节一致', async () => {
+  const a = await generateHostShells(V7)
+  const b = await generateHostShells(V7)
+  assert.deepEqual(a, b)
+})

+ 73 - 0
v7/test/host-shells/validator.test.js

@@ -0,0 +1,73 @@
+import { test } from 'node:test'
+import assert from 'node:assert/strict'
+import os from 'node:os'
+import path from 'node:path'
+import { promises as fs } from 'node:fs'
+import { fileURLToPath } from 'node:url'
+import { validatePackage, driftCheck } from '../../src/host-shells/validator.js'
+
+const V7 = fileURLToPath(new URL('../../', import.meta.url))
+
+async function makePkg() {
+  const root = await fs.mkdtemp(path.join(os.tmpdir(), 'wnw-pkg-'))
+  const w = async (rel, c) => {
+    const full = path.join(root, rel)
+    await fs.mkdir(path.dirname(full), { recursive: true })
+    await fs.writeFile(full, c, 'utf8')
+  }
+  await w('adapters/registry.json', JSON.stringify({ schema_version: 'webnovel-host-registry/v2', hosts: { 'claude-code': { tier: 1, agentCapable: true, hasHooks: true } } }))
+  await w('adapters/claude-code/support.md', '# claude-code 核验\n')
+  await w('roles/事实审查.md', '---\nname: 事实审查\ndescription: d\n---\nbody {{categories.factCheck}}')
+  await w('skills/webnovel-writer/SKILL.md', '---\nname: webnovel-writer\ndescription: d\n---\nbody')
+  return { root, w, cleanup: () => fs.rm(root, { recursive: true, force: true }) }
+}
+
+test('validatePackage:真实 v7 资产通过', async () => {
+  const r = await validatePackage(V7)
+  assert.equal(r.ok, true, '真实资产应过 validator:' + r.errors.join(';'))
+})
+
+test('validatePackage:一级宿主缺 support.md → 报错', async () => {
+  const { root, cleanup } = await makePkg()
+  try {
+    await fs.rm(path.join(root, 'adapters/claude-code/support.md'))
+    const r = await validatePackage(root)
+    assert.equal(r.ok, false)
+    assert.ok(r.errors.some((e) => e.includes('support.md')))
+  } finally { await cleanup() }
+})
+
+test('validatePackage:角色缺 description → 报错', async () => {
+  const { root, w, cleanup } = await makePkg()
+  try {
+    await w('roles/事实审查.md', '---\nname: 事实审查\n---\nbody')
+    const r = await validatePackage(root)
+    assert.equal(r.ok, false)
+    assert.ok(r.errors.some((e) => e.includes('description')))
+  } finally { await cleanup() }
+})
+
+test('validatePackage:SKILL description 超 8k → 报错', async () => {
+  const { root, w, cleanup } = await makePkg()
+  try {
+    await w('skills/webnovel-writer/SKILL.md', `---\nname: w\ndescription: ${'描'.repeat(9000)}\n---\nbody`)
+    const r = await validatePackage(root)
+    assert.equal(r.ok, false)
+    assert.ok(r.errors.some((e) => e.includes('8k') || e.includes('预算')))
+  } finally { await cleanup() }
+})
+
+test('validatePackage:含本机绝对路径 → 报错', async () => {
+  const { root, w, cleanup } = await makePkg()
+  try {
+    await w('roles/事实审查.md', '---\nname: 事实审查\ndescription: d\n---\n见 C:\\\\Users\\\\x\\\\a.md')
+    const r = await validatePackage(root)
+    assert.equal(r.ok, false)
+    assert.ok(r.errors.some((e) => e.includes('绝对路径')))
+  } finally { await cleanup() }
+})
+
+test('driftCheck:真实 v7 确定性 + validator 通过', async () => {
+  const r = await driftCheck(V7)
+  assert.equal(r.ok, true, r.error)
+})