diff --git a/server/src/tools/devloop/_errors.js b/server/src/tools/devloop/_errors.js new file mode 100644 index 0000000..d82165d --- /dev/null +++ b/server/src/tools/devloop/_errors.js @@ -0,0 +1,41 @@ +/** + * DevLoop tool layer · 标准化错误类型 + * + * 用法: + * throw new ToolValidationError('summary', 'too short'); + * throw new ToolSandboxError('/abs/path', 'pm'); + * + * Claude CLI 看到 thrown error → 包装为 tool_result { is_error: true, content: msg } + */ + +class ToolValidationError extends Error { + constructor(field, reason) { + super(`${field}: ${reason}`); + this.name = 'ToolValidationError'; + this.field = field; + this.reason = reason; + } +} + +class ToolSandboxError extends Error { + constructor(p, agent) { + super(`Sandbox violation: ${agent} tried to write ${p}`); + this.name = 'ToolSandboxError'; + this.path = p; + this.agent = agent; + } +} + +class ToolMissingDependencyError extends Error { + constructor(dep) { + super(`Tool requires ${dep} in ctx (LoopController injection)`); + this.name = 'ToolMissingDependencyError'; + this.dep = dep; + } +} + +module.exports = { + ToolValidationError, + ToolSandboxError, + ToolMissingDependencyError, +}; diff --git a/server/src/tools/devloop/_sandbox.js b/server/src/tools/devloop/_sandbox.js new file mode 100644 index 0000000..f1d09f1 --- /dev/null +++ b/server/src/tools/devloop/_sandbox.js @@ -0,0 +1,60 @@ +/** + * Sandbox 边界校验 — Agent 写盘前的 path 检查。 + * + * 用法: + * const { assertWritable } = require('./_sandbox'); + * assertWritable(abs, allowList, ctx.agent); + * + * allowList 形式 (glob-like): + * "prd/**" → ${ctx.projectDir}/prd/** 任意子文件 + * "units/{unitId}/code/**" → ${ctx.unitDir}/code/** (unitId 自动替换) + * "agent-state/**" → ${ctx.projectDir}/agent-state/** 任意 + * "timeline.ndjson" → 精确文件 + */ + +const path = require('path'); +const { ToolSandboxError } = require('./_errors'); + +/** + * @param {string} abs - 待写绝对路径 + * @param {string[]} allowList - 允许写的相对 pattern (相对 ctx.projectDir 或 ctx.unitDir) + * @param {object} ctx - { agent, projectDir, unitDir? } + */ +function assertWritable(abs, allowList, ctx) { + const normalized = path.resolve(abs); + if (isWithinAllowList(normalized, allowList, ctx)) return; + throw new ToolSandboxError(normalized, ctx.agent || 'unknown'); +} + +function isWithinAllowList(abs, allowList, ctx) { + for (const pattern of allowList) { + if (matchesPattern(abs, pattern, ctx)) return true; + } + return false; +} + +function matchesPattern(abs, pattern, ctx) { + // 替换 {unitId} 等占位符 + const resolved = pattern + .replace('{unitId}', ctx.unit_id || '__no_unit__'); + + // 决定 base:含 units/{unitId}/ 的 pattern → 用 unitDir 的父目录(=projectDir/units/) + // 其他 → 用 projectDir + // 简化:统一用 projectDir 作为 base 拼接 pattern + const base = ctx.projectDir || ''; + const expected = path.join(base, resolved); + + // 处理 ** glob + if (expected.endsWith('/**')) { + const prefix = expected.slice(0, -3); // 去掉 "/**" + return abs === prefix || abs.startsWith(prefix + path.sep); + } + + return abs === expected; +} + +module.exports = { + assertWritable, + isWithinAllowList, + matchesPattern, +}; diff --git a/server/src/tools/devloop/bash-json-guard.js b/server/src/tools/devloop/bash-json-guard.js new file mode 100644 index 0000000..fab968d --- /dev/null +++ b/server/src/tools/devloop/bash-json-guard.js @@ -0,0 +1,53 @@ +/** + * DevLoop · Bash inline-JSON guard 中间件 + * + * 拦截 Coder/Tester/Integrator 通过 Bash 工具发送的inline JSON 命令 + * (CLAUDE.md L91-99 强约束)。挂到 Bash 工具的 preExecute 钩子里。 + * + * 反例(禁): curl -d '{"k":"v"}' http://... + * 正例: echo '{"k":"v"}' > /tmp/x.json && curl -d @/tmp/x.json http://... + * + * 规格: docs/prd/newfunction/_spec/arch-tool-registry.md · §⑥ + */ + +// 检测 `-d '{...}'` `-d "{...}"` 或 `--data '{...}'` `--data-raw '{...}'` +const INLINE_JSON_PATTERNS = [ + /-d\s+(['"])\s*\{[\s\S]*?\}\s*\1/, + /--data\s+(['"])\s*\{[\s\S]*?\}\s*\1/, + /--data-raw\s+(['"])\s*\{[\s\S]*?\}\s*\1/, + /--data-binary\s+(['"])\s*\{[\s\S]*?\}\s*\1/, +]; + +function jsonGuard(cmd) { + if (typeof cmd !== 'string') return { ok: true }; + for (const re of INLINE_JSON_PATTERNS) { + if (re.test(cmd)) { + return { + ok: false, + reason: 'inline JSON in Bash command is forbidden (see CLAUDE.md L91-99). ' + + 'Use: echo \'{"k":"v"}\' > /tmp/x.json && curl -d @/tmp/x.json', + matched_pattern: re.source, + }; + } + } + return { ok: true }; +} + +/** + * preExecute hook 注册形式(LemonBoss Bash 工具内部消费) + */ +const bashHooks = { + preExecute: [ + (cmd) => { + const r = jsonGuard(cmd); + if (!r.ok) { + const e = new Error(r.reason); + e.code = 'JSON_GUARD_BLOCK'; + e.matched_pattern = r.matched_pattern; + throw e; + } + }, + ], +}; + +module.exports = { jsonGuard, bashHooks, INLINE_JSON_PATTERNS }; diff --git a/server/src/tools/devloop/coder-tools.js b/server/src/tools/devloop/coder-tools.js new file mode 100644 index 0000000..9a62a92 --- /dev/null +++ b/server/src/tools/devloop/coder-tools.js @@ -0,0 +1,163 @@ +/** + * DevLoop · coder-tools (3 个) + * contract_bridge verify contract.json ↔ units.json hash 一致 + * sandbox_check 扫本 Unit 周围 mtime,确认没写出沙箱 + * append_coder_log 结构化追加 coder.log (强制 retry 模板字段) + * + * 规格: docs/prd/newfunction/_spec/agents-coder.md · §7 + */ + +const fs = require('fs'); +const path = require('path'); + +const pathResolver = require('../../../services/devloop/path-resolver'); +const docStore = require('../../../services/devloop/doc-store'); +const { ToolValidationError, ToolMissingDependencyError } = require('./_errors'); + +const contract_bridge = { + name: 'contract_bridge', + description: 'verify contract.json hash 与 units.json 中该 Unit 的 contract_hash 一致', + inputSchema: { + type: 'object', + required: ['unit_id'], + properties: { unit_id: { type: 'string' } }, + }, + async execute({ unit_id }, ctx) { + _req(ctx, ['pid']); + const unitDir = pathResolver.resolveUnitDir(ctx.pid, unit_id); + const contractPath = path.join(unitDir, 'contract.json'); + if (!fs.existsSync(contractPath)) { + return { ok: false, reason: 'contract.json missing' }; + } + const contract = JSON.parse(fs.readFileSync(contractPath, 'utf8')); + const units = docStore.readUnits(ctx.pid); + if (!units) return { ok: false, reason: 'units.json missing' }; + const unit = units.units.find(u => u.id === unit_id); + if (!unit) return { ok: false, reason: 'unit not in units.json' }; + + const recomputed = docStore.hashContract(contract); + if (contract.version !== recomputed) { + return { + ok: false, + reason: 'contract.version self-inconsistent', + claimed: contract.version, actual: recomputed, + }; + } + if (recomputed !== unit.contract_hash) { + return { + ok: false, + reason: 'contract does not match SDD-declared hash', + expected: unit.contract_hash, actual: recomputed, + hint: 'contract.json 与 SDD 不一致,改 code 让契约对齐 SDD', + }; + } + return { ok: true, hash: recomputed }; + }, +}; + +const sandbox_check = { + name: 'sandbox_check', + description: '扫本 Unit 沙箱外的文件 mtime 变化,确认 Coder 没写出去', + inputSchema: { + type: 'object', + required: ['unit_id', 'since'], + properties: { + unit_id: { type: 'string' }, + since: { type: 'number', description: 'epoch ms; 之后被修改的文件才算' }, + }, + }, + async execute({ unit_id, since }, ctx) { + _req(ctx, ['pid']); + const projectRoot = pathResolver.resolveProjectRoot(ctx.pid); + const unitDir = pathResolver.resolveUnitDir(ctx.pid, unit_id); + + const allowedPrefixes = [ + path.join(unitDir, 'code'), + path.join(unitDir, 'contract.json'), + path.join(unitDir, 'coder.log'), + path.join(projectRoot, 'agent-state'), + path.join(projectRoot, 'timeline.ndjson'), + path.join(projectRoot, 'bugs.ndjson'), + path.join(projectRoot, 'changelog.ndjson'), + path.join(projectRoot, '.git'), + ]; + + const violations = []; + function walk(dir) { + if (!fs.existsSync(dir)) return; + for (const e of fs.readdirSync(dir, { withFileTypes: true })) { + const p = path.join(dir, e.name); + if (e.isDirectory()) walk(p); + else { + try { + const st = fs.statSync(p); + if (st.mtimeMs < since) continue; + const within = allowedPrefixes.some(a => p === a || p.startsWith(a + path.sep)); + if (!within) violations.push(p); + } catch { /* ignore */ } + } + } + } + walk(projectRoot); + + return { ok: violations.length === 0, violations }; + }, +}; + +const append_coder_log = { + name: 'append_coder_log', + description: '结构化追加 coder.log。retry 模式必含 last_failure + last_cause 字段。', + inputSchema: { + type: 'object', + required: ['unit_id', 'attempt', 'this_fix', 'rationale'], + properties: { + unit_id: { type: 'string' }, + attempt: { type: 'integer', minimum: 1 }, + ts: { type: 'string' }, + last_failure: { type: 'string' }, + last_cause: { type: 'string' }, + this_fix: { type: 'string', minLength: 4 }, + rationale: { type: 'string', minLength: 4 }, + changed_files: { type: 'array', items: { type: 'string' } }, + }, + }, + async execute(decision, ctx) { + _req(ctx, ['pid']); + if (decision.attempt > 1) { + if (!decision.last_failure || !decision.last_cause) { + throw new ToolValidationError( + 'retry_decision', + 'attempt > 1 requires last_failure + last_cause fields' + ); + } + } + const p = path.join(pathResolver.resolveUnitDir(ctx.pid, decision.unit_id), 'coder.log'); + fs.mkdirSync(path.dirname(p), { recursive: true }); + + const fullEntry = { + ts: decision.ts || new Date().toISOString(), + attempt: decision.attempt, + ...(decision.last_failure ? { last_failure: decision.last_failure } : {}), + ...(decision.last_cause ? { last_cause: decision.last_cause } : {}), + this_fix: decision.this_fix, + rationale: decision.rationale, + changed_files: decision.changed_files || [], + }; + + const block = '---\n' + + Object.entries(fullEntry).map(([k, v]) => { + const val = typeof v === 'object' ? JSON.stringify(v) : v; + return `${k}: ${val}`; + }).join('\n') + '\n---\n\n'; + fs.appendFileSync(p, block); + return { ok: true, path: p, attempt: decision.attempt }; + }, +}; + +function _req(ctx, keys) { + for (const k of keys) { + if (!ctx || ctx[k] == null) throw new ToolMissingDependencyError(`ctx.${k}`); + } +} + +module.exports = { contract_bridge, sandbox_check, append_coder_log }; diff --git a/server/src/tools/devloop/doc-tools.js b/server/src/tools/devloop/doc-tools.js new file mode 100644 index 0000000..06c5d47 --- /dev/null +++ b/server/src/tools/devloop/doc-tools.js @@ -0,0 +1,555 @@ +/** + * 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(/