/** * 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(/