/** * DevLoop · doc-tools (PM + Architect 用,11 个工具) * * bootstrap_prd_site init prd/ 站点 (copy shell.css/js + 建目录) * bootstrap_sdd_site 同上,SDD * write_chapter 写 PRD chapter (走 docStore.writePrdChapter) * write_mvcp_page 写 SDD MVCP 页(model/view/control/process 之一) * archive_doc 把当前 prd|sdd|units 归档到 history/ * diff_html cheerio section 级 diff (返 added/changed/removed 列表) * diff_units units.json 三态 diff * echo_diff 发 SSE prd.updated/sdd.updated + change_summary * update_search_index 重建 prd|sdd 站点的 search-index.json (复用 build-search-index 脚本) * validate_prd_site 整站 cheerio 校验 + 8 类信息 ≥4 * validate_sdd_consistency SDD ↔ units.json ↔ integration/spec.json 一致性 * hash_contract 暴露 docStore.hashContract * * 规格: docs/prd/newfunction/_spec/sdd-control.md * docs/prd/newfunction/_spec/agents-pm.md * docs/prd/newfunction/_spec/agents-architect.md */ const fs = require('fs'); const path = require('path'); const { spawn } = require('child_process'); const cheerio = require('cheerio'); const pathResolver = require('../../../services/devloop/path-resolver'); const docStore = require('../../../services/devloop/doc-store'); const unitRegistry = require('../../../services/devloop/unit-registry'); const changelogWriter = require('../../../services/devloop/changelog-writer'); const { ToolValidationError } = require('./_errors'); // 模板目录:copy shell.css/shell.js 进项目站点 const TEMPLATE_ASSETS_DIR = path.resolve(__dirname, '../../../../docs/prd/newfunction/assets'); // ──────────────────────────────────────────────────────────────── // bootstrap_prd_site / bootstrap_sdd_site // ──────────────────────────────────────────────────────────────── function _bootstrapSite(siteRoot, kind) { const subdirs = kind === 'prd' ? ['product', 'feature', 'flow', 'prototype', 'assets'] : ['mvcp', 'assets']; for (const d of subdirs) fs.mkdirSync(path.join(siteRoot, d), { recursive: true }); // 复制 shell.css + shell.js (如模板存在) for (const f of ['shell.css', 'shell.js']) { const src = path.join(TEMPLATE_ASSETS_DIR, f); const dst = path.join(siteRoot, 'assets', f); if (fs.existsSync(src) && !fs.existsSync(dst)) { fs.copyFileSync(src, dst); } else if (!fs.existsSync(dst)) { // 模板不存在时落空文件 (开发 / 测试场景) fs.writeFileSync(dst, `/* ${f} placeholder; copy real template here */\n`); } } // 空的 search-index.json const idx = path.join(siteRoot, 'assets', 'search-index.json'); if (!fs.existsSync(idx)) { fs.writeFileSync(idx, JSON.stringify({ version: '1', pages: 0, entries: [] })); } return { ok: true, root: siteRoot, dirs: subdirs.length, assets: 3 }; } const bootstrap_prd_site = { name: 'bootstrap_prd_site', description: '初始化项目 prd/ 站点目录(含 assets/shell.css + shell.js + search-index.json 占位)。', inputSchema: { type: 'object', properties: {} }, async execute(_input, ctx) { _req(ctx, ['pid']); return _bootstrapSite(pathResolver.resolvePrdSite(ctx.pid), 'prd'); }, }; const bootstrap_sdd_site = { name: 'bootstrap_sdd_site', description: '初始化项目 sdd/ 站点目录(含 mvcp/ + assets/)。', inputSchema: { type: 'object', properties: {} }, async execute(_input, ctx) { _req(ctx, ['pid']); return _bootstrapSite(pathResolver.resolveSddSite(ctx.pid), 'sdd'); }, }; // ──────────────────────────────────────────────────────────────── // write_chapter / write_mvcp_page // ──────────────────────────────────────────────────────────────── const write_chapter = { name: 'write_chapter', description: '写 PRD chapter (HTML)。kind=prd 时走 prd/{relPath};kind=sdd 时走 sdd/{relPath}。自动校验 + 归档旧版。', inputSchema: { type: 'object', required: ['kind', 'relPath', 'html'], properties: { kind: { type: 'string', enum: ['prd', 'sdd'] }, relPath: { type: 'string', minLength: 1 }, html: { type: 'string', minLength: 1 }, }, }, async execute({ kind, relPath, html }, ctx) { _req(ctx, ['pid']); const writeFn = kind === 'prd' ? docStore.writePrdChapter : docStore.writeSddChapter; try { const r = writeFn(ctx.pid, relPath, html); return { ok: true, ...r }; } catch (e) { if (e.name === 'ValidationError') { throw new ToolValidationError('html', `${e.message}: ${e.details?.join(' · ')}`); } throw e; } }, }; const write_mvcp_page = { name: 'write_mvcp_page', description: '写 SDD MVCP 页 (mvcp/{model|view|control|process}.html)。等价于 write_chapter(kind=sdd, relPath=mvcp/{which}.html)。', inputSchema: { type: 'object', required: ['which', 'html'], properties: { which: { type: 'string', enum: ['model', 'view', 'control', 'process'] }, html: { type: 'string', minLength: 1 }, }, }, async execute({ which, html }, ctx) { return write_chapter.execute( { kind: 'sdd', relPath: `mvcp/${which}.html`, html }, ctx ); }, }; // ──────────────────────────────────────────────────────────────── // archive_doc // ──────────────────────────────────────────────────────────────── const archive_doc = { name: 'archive_doc', description: '手动触发归档当前 prd/ 或 sdd/ 整站到 history/ (write_chapter 已自动归档单文件; 此工具用于整站快照)。', inputSchema: { type: 'object', required: ['kind'], properties: { kind: { type: 'string', enum: ['prd', 'sdd'] }, }, }, async execute({ kind }, ctx) { _req(ctx, ['pid']); const siteRoot = kind === 'prd' ? pathResolver.resolvePrdSite(ctx.pid) : pathResolver.resolveSddSite(ctx.pid); if (!fs.existsSync(siteRoot)) { throw new ToolValidationError('kind', `${kind}/ does not exist`); } // 找 rev (扫所有 chapter article data-rev 取最大) const rev = _detectSiteRev(siteRoot, kind); const historyDir = path.join( pathResolver.resolveHistoryDir(ctx.pid), `${kind}-rev-${rev}` ); _copyDir(siteRoot, historyDir); return { ok: true, rev, archived_to: historyDir }; }, }; function _detectSiteRev(siteRoot, kind) { let maxRev = 0; function walk(dir) { for (const e of fs.readdirSync(dir, { withFileTypes: true })) { const full = path.join(dir, e.name); if (e.isDirectory()) { if (e.name !== 'assets') walk(full); } else if (e.name.endsWith('.html')) { try { const $ = cheerio.load(fs.readFileSync(full, 'utf8')); const r = $(`article[data-doc="${kind}"]`).first().attr('data-rev'); if (r && /^\d+$/.test(r)) { const n = parseInt(r, 10); if (n > maxRev) maxRev = n; } } catch { /* ignore */ } } } } walk(siteRoot); return maxRev || 1; } function _copyDir(src, dst) { fs.mkdirSync(dst, { recursive: true }); for (const e of fs.readdirSync(src, { withFileTypes: true })) { const s = path.join(src, e.name); const d = path.join(dst, e.name); if (e.isDirectory()) _copyDir(s, d); else fs.copyFileSync(s, d); } } // ──────────────────────────────────────────────────────────────── // diff_html // ──────────────────────────────────────────────────────────────── const diff_html = { name: 'diff_html', description: 'cheerio section 级 diff:对比 oldHtml 和 newHtml,返 added/changed/removed section data-rev 列表。', inputSchema: { type: 'object', required: ['oldHtml', 'newHtml'], properties: { oldHtml: { type: 'string' }, newHtml: { type: 'string' }, }, }, async execute({ oldHtml, newHtml }, _ctx) { const $o = cheerio.load(oldHtml || ''); const $n = cheerio.load(newHtml || ''); const oldSecs = new Map(); const newSecs = new Map(); $o('section[data-rev]').each((_, el) => { const r = $o(el).attr('data-rev'); oldSecs.set(r, $o(el).text().trim()); }); $n('section[data-rev]').each((_, el) => { const r = $n(el).attr('data-rev'); newSecs.set(r, $n(el).text().trim()); }); const added = [], changed = [], removed = []; for (const [r, txt] of newSecs) { if (!oldSecs.has(r)) added.push(r); else if (oldSecs.get(r) !== txt) changed.push(r); } for (const [r] of oldSecs) { if (!newSecs.has(r)) removed.push(r); } return { added, changed, removed }; }, }; // ──────────────────────────────────────────────────────────────── // diff_units // ──────────────────────────────────────────────────────────────── const diff_units = { name: 'diff_units', description: '对比新旧 units.json: added/changed(contract_hash 变化)/removed。如不传 oldUnits,从 history/最新 units-rev-N.json 读。', inputSchema: { type: 'object', required: ['newUnits'], properties: { newUnits: { type: 'array' }, oldUnits: { type: 'array' }, }, }, async execute({ newUnits, oldUnits }, ctx) { let oldList = oldUnits; if (!oldList && ctx?.pid) { // 尝试从 history/ 取最新归档 const historyDir = pathResolver.resolveHistoryDir(ctx.pid); if (fs.existsSync(historyDir)) { const files = fs.readdirSync(historyDir).filter(f => /^units-rev-\d+\.json$/.test(f)); if (files.length) { files.sort(); const last = files[files.length - 1]; const old = JSON.parse(fs.readFileSync(path.join(historyDir, last), 'utf8')); oldList = old.units || []; } } } return unitRegistry.diffUnits(oldList || [], newUnits); }, }; // ──────────────────────────────────────────────────────────────── // echo_diff // ──────────────────────────────────────────────────────────────── const echo_diff = { name: 'echo_diff', description: '发 SSE 事件 prd.updated 或 sdd.updated,告知 Dashboard 文档已变。', inputSchema: { type: 'object', required: ['kind', 'rev', 'summary'], properties: { kind: { type: 'string', enum: ['prd', 'sdd'] }, rev: { type: 'integer' }, summary: { type: 'string', maxLength: 500 }, sections_diff: { type: 'object' }, }, }, async execute({ kind, rev, summary, sections_diff }, ctx) { _req(ctx, ['pid']); if (ctx.sseBroker) { ctx.sseBroker.emit(ctx.pid, `${kind}.updated`, { rev, summary, sections_diff: sections_diff || null, }); } // 同步写 changelog try { changelogWriter.append(ctx.pid, { kind: `${kind}_rev`, rev, by: ctx.agent || 'system', trigger: 'agent', summary, diff: sections_diff || {}, }); } catch (e) { // 已存在该 rev → 跳过;其他错误抛 if (!/already/.test(e.message)) { /* swallow */ } } return { ok: true }; }, }; // ──────────────────────────────────────────────────────────────── // update_search_index // ──────────────────────────────────────────────────────────────── const update_search_index = { name: 'update_search_index', description: '重建 prd/ 或 sdd/ 站点的 search-index.json (复用 scripts/build-search-index.js 的逻辑)。', inputSchema: { type: 'object', properties: { target: { type: 'string', enum: ['prd', 'sdd', 'both'], default: 'both' }, }, }, async execute({ target = 'both' }, ctx) { _req(ctx, ['pid']); const built = []; if (target === 'prd' || target === 'both') { built.push(_buildSiteIndex(pathResolver.resolvePrdSite(ctx.pid))); } if (target === 'sdd' || target === 'both') { built.push(_buildSiteIndex(pathResolver.resolveSddSite(ctx.pid))); } return { ok: true, built }; }, }; function _buildSiteIndex(siteRoot) { if (!fs.existsSync(siteRoot)) return { skipped: true, reason: 'site not exist' }; const entries = []; function walk(dir, prefix) { for (const e of fs.readdirSync(dir, { withFileTypes: true })) { if (e.name === 'assets') continue; const rel = prefix ? `${prefix}/${e.name}` : e.name; const full = path.join(dir, e.name); if (e.isDirectory()) walk(full, rel); else if (e.name.endsWith('.html')) { const html = fs.readFileSync(full, 'utf8'); const title = _extractTitle(html) || rel; const text = _stripTagsKeepText(html).slice(0, 2000); entries.push({ f: rel, title, content: text }); } } } walk(siteRoot, ''); const idx = { version: '1', generated: new Date().toISOString(), pages: entries.length, entries, }; const out = path.join(siteRoot, 'assets', 'search-index.json'); fs.mkdirSync(path.dirname(out), { recursive: true }); fs.writeFileSync(out, JSON.stringify(idx)); return { ok: true, pages: entries.length, path: out }; } function _extractTitle(html) { const m = html.match(/([\s\S]*?)<\/title>/i); return m ? m[1].trim() : null; } function _stripTagsKeepText(s) { return s .replace(/<script[\s\S]*?<\/script>/gi, ' ') .replace(/<style[\s\S]*?<\/style>/gi, ' ') .replace(/<svg[\s\S]*?<\/svg>/gi, ' ') .replace(/<[^>]+>/g, ' ') .replace(/\s+/g, ' ') .trim(); } // ──────────────────────────────────────────────────────────────── // validate_prd_site // ──────────────────────────────────────────────────────────────── const REQUIRED_PRD_CHAPTERS = [ 'index.html', 'product/background.html', 'product/personas.html', 'product/scope.html', 'feature/feature-tree.html', 'flow/user-flows.html', ]; const EIGHT_TYPES = { Tables: $ => $('table').length > 0, Code: $ => $('pre').length > 0, Illustrations: $ => $('svg').length > 0, Interaction: $ => $('.tabs, details, nav').length > 0, Spatial: $ => $('pre').filter((_,el) => $(el).text().includes('├──') || $(el).text().includes('└──')).length > 0, Design: $ => $('.card').length > 0 || $('[class*=card]').length > 0, Images: $ => $('svg, img').length > 0, Workflows: $ => $('svg').filter((_,el) => /marker-end|stroke-dasharray|arrow/.test($(el).html() || '')).length > 0, }; const validate_prd_site = { name: 'validate_prd_site', description: '整站校验 PRD: 必备章节存在 · cheerio 结构 · 8 类信息 ≥4。', inputSchema: { type: 'object', properties: {} }, async execute(_input, ctx) { _req(ctx, ['pid']); const root = pathResolver.resolvePrdSite(ctx.pid); const issues = []; if (!fs.existsSync(root)) { return { ok: false, issues: ['prd/ site not initialized'] }; } for (const f of REQUIRED_PRD_CHAPTERS) { const p = path.join(root, f); if (!fs.existsSync(p)) issues.push(`missing chapter: ${f}`); } // 扫所有 chapter const allFiles = []; function walk(dir, prefix) { for (const e of fs.readdirSync(dir, { withFileTypes: true })) { if (e.name === 'assets') continue; const rel = prefix ? `${prefix}/${e.name}` : e.name; const full = path.join(dir, e.name); if (e.isDirectory()) walk(full, rel); else if (e.name.endsWith('.html')) allFiles.push({ rel, full }); } } walk(root, ''); for (const { rel, full } of allFiles) { const html = fs.readFileSync(full, 'utf8'); const v = docStore.validateHTML(html, 'prd'); if (!v.ok) { for (const e of v.errors) issues.push(`${rel}: ${e}`); } // 8 类信息检查 try { const $ = cheerio.load(html); const covered = Object.entries(EIGHT_TYPES).filter(([_, fn]) => fn($)); if (covered.length < 4) { issues.push(`${rel}: only ${covered.length}/8 info types (need ≥4)`); } } catch { /* ignore */ } } return { ok: issues.length === 0, issues }; }, }; // ──────────────────────────────────────────────────────────────── // validate_sdd_consistency // ──────────────────────────────────────────────────────────────── const validate_sdd_consistency = { name: 'validate_sdd_consistency', description: 'SDD ↔ units.json ↔ integration/spec.json 三方一致性: 每 Unit 在 sdd/mvcp/ 出现 · involves ⊆ units · contract_hash 一致 · DAG 无环。', inputSchema: { type: 'object', properties: {} }, async execute(_input, ctx) { _req(ctx, ['pid']); const issues = []; const unitsJson = docStore.readUnits(ctx.pid); if (!unitsJson) return { ok: false, issues: ['units.json missing'] }; const v = docStore.validateUnitsSchema(unitsJson); if (!v.ok) issues.push(...v.errors); // Unit 在 sdd/mvcp/ 至少出现 1 次 const sddRoot = pathResolver.resolveSddSite(ctx.pid); const mvcpDir = path.join(sddRoot, 'mvcp'); let sddText = ''; if (fs.existsSync(mvcpDir)) { for (const f of fs.readdirSync(mvcpDir)) { if (f.endsWith('.html')) sddText += fs.readFileSync(path.join(mvcpDir, f), 'utf8'); } } for (const u of unitsJson.units) { if (!sddText.includes(`data-unit-id="${u.id}"`) && !sddText.includes(`data-def-name="${u.id}"`)) { issues.push(`unit ${u.id} not referenced in sdd/mvcp/`); } } // integration/spec.json involves 检查 const spec = docStore.readIntegrationSpec(ctx.pid); if (spec && Array.isArray(spec.cases)) { const ids = new Set(unitsJson.units.map(u => u.id)); for (const c of spec.cases) { for (const u of (c.involves || [])) { if (!ids.has(u)) issues.push(`integration case ${c.id} involves unknown unit: ${u}`); } } } return { ok: issues.length === 0, issues }; }, }; // ──────────────────────────────────────────────────────────────── // hash_contract // ──────────────────────────────────────────────────────────────── const hash_contract = { name: 'hash_contract', description: 'canonical JSON + sha256 截 16 hex。算 Unit contract 的 hash。', inputSchema: { type: 'object', required: ['contract'], properties: { contract: { type: 'object' }, }, }, async execute({ contract }, _ctx) { return { ok: true, hash: docStore.hashContract(contract) }; }, }; // ──────────────────────────────────────────────────────────────── function _req(ctx, keys) { for (const k of keys) { if (!ctx || ctx[k] == null) { const { ToolMissingDependencyError } = require('./_errors'); throw new ToolMissingDependencyError(`ctx.${k}`); } } } module.exports = { bootstrap_prd_site, bootstrap_sdd_site, write_chapter, write_mvcp_page, archive_doc, diff_html, diff_units, echo_diff, update_search_index, validate_prd_site, validate_sdd_consistency, hash_contract, };