Commit 73359308b82c62a4069e6b5e9450bbf0563a9adb

Authored by yibo
1 parent 83523ede

feat(devloop): P0.A tool layer — 10 modules, 88 unit tests passing

Adds the DevLoop Agent tool layer (CommonJS) under server/src/tools/devloop:
- _errors.js / _sandbox.js — shared infra (3 error classes + path allow-list w/ {unitId})
- tool-registry.js — loadAllTools / dispatch / listToolNames / getDevLoopTools
- persistence-tools.js (5) — write_todolist · update_todo_item · update_runtime_state · timeline_append · record_memory
- doc-tools.js (12) — 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
- coder-tools.js (3) — contract_bridge · sandbox_check · append_coder_log (retry fields required)
- tester-tools.js (5) — parse_vitest · hint_builder · bug_open · bug_transition · bug_list
- integrator-tools.js (6) — port_picker · spawn_units · health_wait · run_case · parse_placeholder · collect_stderr
- triager-tools.js (4) — extract_evidence · category_classifier · diff_contract_vs_impl · find_similar_bug
- bash-json-guard.js — preExecute hook blocking inline JSON in Bash (CLAUDE.md L91-99)

Total: 35 callable devloop tools.

Tests: 88 cases across 9 files, all passing; combined with P0.0 services
suite the project now has 159 green tests.

Specs:
- docs/prd/newfunction/_spec/arch-tool-registry.md
- docs/prd/newfunction/_spec/agents-{pm,architect,coder,tester,integrator,triager}.md

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
server/src/tools/devloop/_errors.js 0 → 100644
  1 +/**
  2 + * DevLoop tool layer · 标准化错误类型
  3 + *
  4 + * 用法:
  5 + * throw new ToolValidationError('summary', 'too short');
  6 + * throw new ToolSandboxError('/abs/path', 'pm');
  7 + *
  8 + * Claude CLI 看到 thrown error → 包装为 tool_result { is_error: true, content: msg }
  9 + */
  10 +
  11 +class ToolValidationError extends Error {
  12 + constructor(field, reason) {
  13 + super(`${field}: ${reason}`);
  14 + this.name = 'ToolValidationError';
  15 + this.field = field;
  16 + this.reason = reason;
  17 + }
  18 +}
  19 +
  20 +class ToolSandboxError extends Error {
  21 + constructor(p, agent) {
  22 + super(`Sandbox violation: ${agent} tried to write ${p}`);
  23 + this.name = 'ToolSandboxError';
  24 + this.path = p;
  25 + this.agent = agent;
  26 + }
  27 +}
  28 +
  29 +class ToolMissingDependencyError extends Error {
  30 + constructor(dep) {
  31 + super(`Tool requires ${dep} in ctx (LoopController injection)`);
  32 + this.name = 'ToolMissingDependencyError';
  33 + this.dep = dep;
  34 + }
  35 +}
  36 +
  37 +module.exports = {
  38 + ToolValidationError,
  39 + ToolSandboxError,
  40 + ToolMissingDependencyError,
  41 +};
server/src/tools/devloop/_sandbox.js 0 → 100644
  1 +/**
  2 + * Sandbox 边界校验 — Agent 写盘前的 path 检查。
  3 + *
  4 + * 用法:
  5 + * const { assertWritable } = require('./_sandbox');
  6 + * assertWritable(abs, allowList, ctx.agent);
  7 + *
  8 + * allowList 形式 (glob-like):
  9 + * "prd/**" → ${ctx.projectDir}/prd/** 任意子文件
  10 + * "units/{unitId}/code/**" → ${ctx.unitDir}/code/** (unitId 自动替换)
  11 + * "agent-state/**" → ${ctx.projectDir}/agent-state/** 任意
  12 + * "timeline.ndjson" → 精确文件
  13 + */
  14 +
  15 +const path = require('path');
  16 +const { ToolSandboxError } = require('./_errors');
  17 +
  18 +/**
  19 + * @param {string} abs - 待写绝对路径
  20 + * @param {string[]} allowList - 允许写的相对 pattern (相对 ctx.projectDir 或 ctx.unitDir)
  21 + * @param {object} ctx - { agent, projectDir, unitDir? }
  22 + */
  23 +function assertWritable(abs, allowList, ctx) {
  24 + const normalized = path.resolve(abs);
  25 + if (isWithinAllowList(normalized, allowList, ctx)) return;
  26 + throw new ToolSandboxError(normalized, ctx.agent || 'unknown');
  27 +}
  28 +
  29 +function isWithinAllowList(abs, allowList, ctx) {
  30 + for (const pattern of allowList) {
  31 + if (matchesPattern(abs, pattern, ctx)) return true;
  32 + }
  33 + return false;
  34 +}
  35 +
  36 +function matchesPattern(abs, pattern, ctx) {
  37 + // 替换 {unitId} 等占位符
  38 + const resolved = pattern
  39 + .replace('{unitId}', ctx.unit_id || '__no_unit__');
  40 +
  41 + // 决定 base:含 units/{unitId}/ 的 pattern → 用 unitDir 的父目录(=projectDir/units/)
  42 + // 其他 → 用 projectDir
  43 + // 简化:统一用 projectDir 作为 base 拼接 pattern
  44 + const base = ctx.projectDir || '';
  45 + const expected = path.join(base, resolved);
  46 +
  47 + // 处理 ** glob
  48 + if (expected.endsWith('/**')) {
  49 + const prefix = expected.slice(0, -3); // 去掉 "/**"
  50 + return abs === prefix || abs.startsWith(prefix + path.sep);
  51 + }
  52 +
  53 + return abs === expected;
  54 +}
  55 +
  56 +module.exports = {
  57 + assertWritable,
  58 + isWithinAllowList,
  59 + matchesPattern,
  60 +};
server/src/tools/devloop/bash-json-guard.js 0 → 100644
  1 +/**
  2 + * DevLoop · Bash inline-JSON guard 中间件
  3 + *
  4 + * 拦截 Coder/Tester/Integrator 通过 Bash 工具发送的<b>inline JSON</b> 命令
  5 + * (CLAUDE.md L91-99 强约束)。挂到 Bash 工具的 preExecute 钩子里。
  6 + *
  7 + * 反例(禁): curl -d '{"k":"v"}' http://...
  8 + * 正例: echo '{"k":"v"}' > /tmp/x.json && curl -d @/tmp/x.json http://...
  9 + *
  10 + * 规格: docs/prd/newfunction/_spec/arch-tool-registry.md · §⑥
  11 + */
  12 +
  13 +// 检测 `-d '{...}'` `-d "{...}"` 或 `--data '{...}'` `--data-raw '{...}'`
  14 +const INLINE_JSON_PATTERNS = [
  15 + /-d\s+(['"])\s*\{[\s\S]*?\}\s*\1/,
  16 + /--data\s+(['"])\s*\{[\s\S]*?\}\s*\1/,
  17 + /--data-raw\s+(['"])\s*\{[\s\S]*?\}\s*\1/,
  18 + /--data-binary\s+(['"])\s*\{[\s\S]*?\}\s*\1/,
  19 +];
  20 +
  21 +function jsonGuard(cmd) {
  22 + if (typeof cmd !== 'string') return { ok: true };
  23 + for (const re of INLINE_JSON_PATTERNS) {
  24 + if (re.test(cmd)) {
  25 + return {
  26 + ok: false,
  27 + reason: 'inline JSON in Bash command is forbidden (see CLAUDE.md L91-99). ' +
  28 + 'Use: echo \'{"k":"v"}\' > /tmp/x.json && curl -d @/tmp/x.json',
  29 + matched_pattern: re.source,
  30 + };
  31 + }
  32 + }
  33 + return { ok: true };
  34 +}
  35 +
  36 +/**
  37 + * preExecute hook 注册形式(LemonBoss Bash 工具内部消费)
  38 + */
  39 +const bashHooks = {
  40 + preExecute: [
  41 + (cmd) => {
  42 + const r = jsonGuard(cmd);
  43 + if (!r.ok) {
  44 + const e = new Error(r.reason);
  45 + e.code = 'JSON_GUARD_BLOCK';
  46 + e.matched_pattern = r.matched_pattern;
  47 + throw e;
  48 + }
  49 + },
  50 + ],
  51 +};
  52 +
  53 +module.exports = { jsonGuard, bashHooks, INLINE_JSON_PATTERNS };
server/src/tools/devloop/coder-tools.js 0 → 100644
  1 +/**
  2 + * DevLoop · coder-tools (3 个)
  3 + * contract_bridge verify contract.json ↔ units.json hash 一致
  4 + * sandbox_check 扫本 Unit 周围 mtime,确认没写出沙箱
  5 + * append_coder_log 结构化追加 coder.log (强制 retry 模板字段)
  6 + *
  7 + * 规格: docs/prd/newfunction/_spec/agents-coder.md · §7
  8 + */
  9 +
  10 +const fs = require('fs');
  11 +const path = require('path');
  12 +
  13 +const pathResolver = require('../../../services/devloop/path-resolver');
  14 +const docStore = require('../../../services/devloop/doc-store');
  15 +const { ToolValidationError, ToolMissingDependencyError } = require('./_errors');
  16 +
  17 +const contract_bridge = {
  18 + name: 'contract_bridge',
  19 + description: 'verify contract.json hash 与 units.json 中该 Unit 的 contract_hash 一致',
  20 + inputSchema: {
  21 + type: 'object',
  22 + required: ['unit_id'],
  23 + properties: { unit_id: { type: 'string' } },
  24 + },
  25 + async execute({ unit_id }, ctx) {
  26 + _req(ctx, ['pid']);
  27 + const unitDir = pathResolver.resolveUnitDir(ctx.pid, unit_id);
  28 + const contractPath = path.join(unitDir, 'contract.json');
  29 + if (!fs.existsSync(contractPath)) {
  30 + return { ok: false, reason: 'contract.json missing' };
  31 + }
  32 + const contract = JSON.parse(fs.readFileSync(contractPath, 'utf8'));
  33 + const units = docStore.readUnits(ctx.pid);
  34 + if (!units) return { ok: false, reason: 'units.json missing' };
  35 + const unit = units.units.find(u => u.id === unit_id);
  36 + if (!unit) return { ok: false, reason: 'unit not in units.json' };
  37 +
  38 + const recomputed = docStore.hashContract(contract);
  39 + if (contract.version !== recomputed) {
  40 + return {
  41 + ok: false,
  42 + reason: 'contract.version self-inconsistent',
  43 + claimed: contract.version, actual: recomputed,
  44 + };
  45 + }
  46 + if (recomputed !== unit.contract_hash) {
  47 + return {
  48 + ok: false,
  49 + reason: 'contract does not match SDD-declared hash',
  50 + expected: unit.contract_hash, actual: recomputed,
  51 + hint: 'contract.json 与 SDD 不一致,改 code 让契约对齐 SDD',
  52 + };
  53 + }
  54 + return { ok: true, hash: recomputed };
  55 + },
  56 +};
  57 +
  58 +const sandbox_check = {
  59 + name: 'sandbox_check',
  60 + description: '扫本 Unit 沙箱外的文件 mtime 变化,确认 Coder 没写出去',
  61 + inputSchema: {
  62 + type: 'object',
  63 + required: ['unit_id', 'since'],
  64 + properties: {
  65 + unit_id: { type: 'string' },
  66 + since: { type: 'number', description: 'epoch ms; 之后被修改的文件才算' },
  67 + },
  68 + },
  69 + async execute({ unit_id, since }, ctx) {
  70 + _req(ctx, ['pid']);
  71 + const projectRoot = pathResolver.resolveProjectRoot(ctx.pid);
  72 + const unitDir = pathResolver.resolveUnitDir(ctx.pid, unit_id);
  73 +
  74 + const allowedPrefixes = [
  75 + path.join(unitDir, 'code'),
  76 + path.join(unitDir, 'contract.json'),
  77 + path.join(unitDir, 'coder.log'),
  78 + path.join(projectRoot, 'agent-state'),
  79 + path.join(projectRoot, 'timeline.ndjson'),
  80 + path.join(projectRoot, 'bugs.ndjson'),
  81 + path.join(projectRoot, 'changelog.ndjson'),
  82 + path.join(projectRoot, '.git'),
  83 + ];
  84 +
  85 + const violations = [];
  86 + function walk(dir) {
  87 + if (!fs.existsSync(dir)) return;
  88 + for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
  89 + const p = path.join(dir, e.name);
  90 + if (e.isDirectory()) walk(p);
  91 + else {
  92 + try {
  93 + const st = fs.statSync(p);
  94 + if (st.mtimeMs < since) continue;
  95 + const within = allowedPrefixes.some(a => p === a || p.startsWith(a + path.sep));
  96 + if (!within) violations.push(p);
  97 + } catch { /* ignore */ }
  98 + }
  99 + }
  100 + }
  101 + walk(projectRoot);
  102 +
  103 + return { ok: violations.length === 0, violations };
  104 + },
  105 +};
  106 +
  107 +const append_coder_log = {
  108 + name: 'append_coder_log',
  109 + description: '结构化追加 coder.log。retry 模式必含 last_failure + last_cause 字段。',
  110 + inputSchema: {
  111 + type: 'object',
  112 + required: ['unit_id', 'attempt', 'this_fix', 'rationale'],
  113 + properties: {
  114 + unit_id: { type: 'string' },
  115 + attempt: { type: 'integer', minimum: 1 },
  116 + ts: { type: 'string' },
  117 + last_failure: { type: 'string' },
  118 + last_cause: { type: 'string' },
  119 + this_fix: { type: 'string', minLength: 4 },
  120 + rationale: { type: 'string', minLength: 4 },
  121 + changed_files: { type: 'array', items: { type: 'string' } },
  122 + },
  123 + },
  124 + async execute(decision, ctx) {
  125 + _req(ctx, ['pid']);
  126 + if (decision.attempt > 1) {
  127 + if (!decision.last_failure || !decision.last_cause) {
  128 + throw new ToolValidationError(
  129 + 'retry_decision',
  130 + 'attempt > 1 requires last_failure + last_cause fields'
  131 + );
  132 + }
  133 + }
  134 + const p = path.join(pathResolver.resolveUnitDir(ctx.pid, decision.unit_id), 'coder.log');
  135 + fs.mkdirSync(path.dirname(p), { recursive: true });
  136 +
  137 + const fullEntry = {
  138 + ts: decision.ts || new Date().toISOString(),
  139 + attempt: decision.attempt,
  140 + ...(decision.last_failure ? { last_failure: decision.last_failure } : {}),
  141 + ...(decision.last_cause ? { last_cause: decision.last_cause } : {}),
  142 + this_fix: decision.this_fix,
  143 + rationale: decision.rationale,
  144 + changed_files: decision.changed_files || [],
  145 + };
  146 +
  147 + const block = '---\n' +
  148 + Object.entries(fullEntry).map(([k, v]) => {
  149 + const val = typeof v === 'object' ? JSON.stringify(v) : v;
  150 + return `${k}: ${val}`;
  151 + }).join('\n') + '\n---\n\n';
  152 + fs.appendFileSync(p, block);
  153 + return { ok: true, path: p, attempt: decision.attempt };
  154 + },
  155 +};
  156 +
  157 +function _req(ctx, keys) {
  158 + for (const k of keys) {
  159 + if (!ctx || ctx[k] == null) throw new ToolMissingDependencyError(`ctx.${k}`);
  160 + }
  161 +}
  162 +
  163 +module.exports = { contract_bridge, sandbox_check, append_coder_log };
server/src/tools/devloop/doc-tools.js 0 → 100644
  1 +/**
  2 + * DevLoop · doc-tools (PM + Architect 用,11 个工具)
  3 + *
  4 + * bootstrap_prd_site init prd/ 站点 (copy shell.css/js + 建目录)
  5 + * bootstrap_sdd_site 同上,SDD
  6 + * write_chapter 写 PRD chapter (走 docStore.writePrdChapter)
  7 + * write_mvcp_page 写 SDD MVCP 页(model/view/control/process 之一)
  8 + * archive_doc 把当前 prd|sdd|units 归档到 history/
  9 + * diff_html cheerio section 级 diff (返 added/changed/removed 列表)
  10 + * diff_units units.json 三态 diff
  11 + * echo_diff 发 SSE prd.updated/sdd.updated + change_summary
  12 + * update_search_index 重建 prd|sdd 站点的 search-index.json (复用 build-search-index 脚本)
  13 + * validate_prd_site 整站 cheerio 校验 + 8 类信息 ≥4
  14 + * validate_sdd_consistency SDD ↔ units.json ↔ integration/spec.json 一致性
  15 + * hash_contract 暴露 docStore.hashContract
  16 + *
  17 + * 规格: docs/prd/newfunction/_spec/sdd-control.md
  18 + * docs/prd/newfunction/_spec/agents-pm.md
  19 + * docs/prd/newfunction/_spec/agents-architect.md
  20 + */
  21 +
  22 +const fs = require('fs');
  23 +const path = require('path');
  24 +const { spawn } = require('child_process');
  25 +const cheerio = require('cheerio');
  26 +
  27 +const pathResolver = require('../../../services/devloop/path-resolver');
  28 +const docStore = require('../../../services/devloop/doc-store');
  29 +const unitRegistry = require('../../../services/devloop/unit-registry');
  30 +const changelogWriter = require('../../../services/devloop/changelog-writer');
  31 +const { ToolValidationError } = require('./_errors');
  32 +
  33 +// 模板目录:copy shell.css/shell.js 进项目站点
  34 +const TEMPLATE_ASSETS_DIR = path.resolve(__dirname, '../../../../docs/prd/newfunction/assets');
  35 +
  36 +// ────────────────────────────────────────────────────────────────
  37 +// bootstrap_prd_site / bootstrap_sdd_site
  38 +// ────────────────────────────────────────────────────────────────
  39 +
  40 +function _bootstrapSite(siteRoot, kind) {
  41 + const subdirs = kind === 'prd'
  42 + ? ['product', 'feature', 'flow', 'prototype', 'assets']
  43 + : ['mvcp', 'assets'];
  44 +
  45 + for (const d of subdirs) fs.mkdirSync(path.join(siteRoot, d), { recursive: true });
  46 +
  47 + // 复制 shell.css + shell.js (如模板存在)
  48 + for (const f of ['shell.css', 'shell.js']) {
  49 + const src = path.join(TEMPLATE_ASSETS_DIR, f);
  50 + const dst = path.join(siteRoot, 'assets', f);
  51 + if (fs.existsSync(src) && !fs.existsSync(dst)) {
  52 + fs.copyFileSync(src, dst);
  53 + } else if (!fs.existsSync(dst)) {
  54 + // 模板不存在时落空文件 (开发 / 测试场景)
  55 + fs.writeFileSync(dst, `/* ${f} placeholder; copy real template here */\n`);
  56 + }
  57 + }
  58 + // 空的 search-index.json
  59 + const idx = path.join(siteRoot, 'assets', 'search-index.json');
  60 + if (!fs.existsSync(idx)) {
  61 + fs.writeFileSync(idx, JSON.stringify({ version: '1', pages: 0, entries: [] }));
  62 + }
  63 + return { ok: true, root: siteRoot, dirs: subdirs.length, assets: 3 };
  64 +}
  65 +
  66 +const bootstrap_prd_site = {
  67 + name: 'bootstrap_prd_site',
  68 + description: '初始化项目 prd/ 站点目录(含 assets/shell.css + shell.js + search-index.json 占位)。',
  69 + inputSchema: { type: 'object', properties: {} },
  70 + async execute(_input, ctx) {
  71 + _req(ctx, ['pid']);
  72 + return _bootstrapSite(pathResolver.resolvePrdSite(ctx.pid), 'prd');
  73 + },
  74 +};
  75 +
  76 +const bootstrap_sdd_site = {
  77 + name: 'bootstrap_sdd_site',
  78 + description: '初始化项目 sdd/ 站点目录(含 mvcp/ + assets/)。',
  79 + inputSchema: { type: 'object', properties: {} },
  80 + async execute(_input, ctx) {
  81 + _req(ctx, ['pid']);
  82 + return _bootstrapSite(pathResolver.resolveSddSite(ctx.pid), 'sdd');
  83 + },
  84 +};
  85 +
  86 +// ────────────────────────────────────────────────────────────────
  87 +// write_chapter / write_mvcp_page
  88 +// ────────────────────────────────────────────────────────────────
  89 +
  90 +const write_chapter = {
  91 + name: 'write_chapter',
  92 + description: '写 PRD chapter (HTML)。kind=prd 时走 prd/{relPath};kind=sdd 时走 sdd/{relPath}。自动校验 + 归档旧版。',
  93 + inputSchema: {
  94 + type: 'object',
  95 + required: ['kind', 'relPath', 'html'],
  96 + properties: {
  97 + kind: { type: 'string', enum: ['prd', 'sdd'] },
  98 + relPath: { type: 'string', minLength: 1 },
  99 + html: { type: 'string', minLength: 1 },
  100 + },
  101 + },
  102 + async execute({ kind, relPath, html }, ctx) {
  103 + _req(ctx, ['pid']);
  104 + const writeFn = kind === 'prd' ? docStore.writePrdChapter : docStore.writeSddChapter;
  105 + try {
  106 + const r = writeFn(ctx.pid, relPath, html);
  107 + return { ok: true, ...r };
  108 + } catch (e) {
  109 + if (e.name === 'ValidationError') {
  110 + throw new ToolValidationError('html', `${e.message}: ${e.details?.join(' · ')}`);
  111 + }
  112 + throw e;
  113 + }
  114 + },
  115 +};
  116 +
  117 +const write_mvcp_page = {
  118 + name: 'write_mvcp_page',
  119 + description: '写 SDD MVCP 页 (mvcp/{model|view|control|process}.html)。等价于 write_chapter(kind=sdd, relPath=mvcp/{which}.html)。',
  120 + inputSchema: {
  121 + type: 'object',
  122 + required: ['which', 'html'],
  123 + properties: {
  124 + which: { type: 'string', enum: ['model', 'view', 'control', 'process'] },
  125 + html: { type: 'string', minLength: 1 },
  126 + },
  127 + },
  128 + async execute({ which, html }, ctx) {
  129 + return write_chapter.execute(
  130 + { kind: 'sdd', relPath: `mvcp/${which}.html`, html },
  131 + ctx
  132 + );
  133 + },
  134 +};
  135 +
  136 +// ────────────────────────────────────────────────────────────────
  137 +// archive_doc
  138 +// ────────────────────────────────────────────────────────────────
  139 +
  140 +const archive_doc = {
  141 + name: 'archive_doc',
  142 + description: '手动触发归档当前 prd/ 或 sdd/ 整站到 history/ (write_chapter 已自动归档单文件; 此工具用于整站快照)。',
  143 + inputSchema: {
  144 + type: 'object',
  145 + required: ['kind'],
  146 + properties: {
  147 + kind: { type: 'string', enum: ['prd', 'sdd'] },
  148 + },
  149 + },
  150 + async execute({ kind }, ctx) {
  151 + _req(ctx, ['pid']);
  152 + const siteRoot = kind === 'prd'
  153 + ? pathResolver.resolvePrdSite(ctx.pid)
  154 + : pathResolver.resolveSddSite(ctx.pid);
  155 + if (!fs.existsSync(siteRoot)) {
  156 + throw new ToolValidationError('kind', `${kind}/ does not exist`);
  157 + }
  158 +
  159 + // 找 rev (扫所有 chapter article data-rev 取最大)
  160 + const rev = _detectSiteRev(siteRoot, kind);
  161 +
  162 + const historyDir = path.join(
  163 + pathResolver.resolveHistoryDir(ctx.pid),
  164 + `${kind}-rev-${rev}`
  165 + );
  166 + _copyDir(siteRoot, historyDir);
  167 + return { ok: true, rev, archived_to: historyDir };
  168 + },
  169 +};
  170 +
  171 +function _detectSiteRev(siteRoot, kind) {
  172 + let maxRev = 0;
  173 + function walk(dir) {
  174 + for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
  175 + const full = path.join(dir, e.name);
  176 + if (e.isDirectory()) {
  177 + if (e.name !== 'assets') walk(full);
  178 + } else if (e.name.endsWith('.html')) {
  179 + try {
  180 + const $ = cheerio.load(fs.readFileSync(full, 'utf8'));
  181 + const r = $(`article[data-doc="${kind}"]`).first().attr('data-rev');
  182 + if (r && /^\d+$/.test(r)) {
  183 + const n = parseInt(r, 10);
  184 + if (n > maxRev) maxRev = n;
  185 + }
  186 + } catch { /* ignore */ }
  187 + }
  188 + }
  189 + }
  190 + walk(siteRoot);
  191 + return maxRev || 1;
  192 +}
  193 +
  194 +function _copyDir(src, dst) {
  195 + fs.mkdirSync(dst, { recursive: true });
  196 + for (const e of fs.readdirSync(src, { withFileTypes: true })) {
  197 + const s = path.join(src, e.name);
  198 + const d = path.join(dst, e.name);
  199 + if (e.isDirectory()) _copyDir(s, d);
  200 + else fs.copyFileSync(s, d);
  201 + }
  202 +}
  203 +
  204 +// ────────────────────────────────────────────────────────────────
  205 +// diff_html
  206 +// ────────────────────────────────────────────────────────────────
  207 +
  208 +const diff_html = {
  209 + name: 'diff_html',
  210 + description: 'cheerio section 级 diff:对比 oldHtml 和 newHtml,返 added/changed/removed section data-rev 列表。',
  211 + inputSchema: {
  212 + type: 'object',
  213 + required: ['oldHtml', 'newHtml'],
  214 + properties: {
  215 + oldHtml: { type: 'string' },
  216 + newHtml: { type: 'string' },
  217 + },
  218 + },
  219 + async execute({ oldHtml, newHtml }, _ctx) {
  220 + const $o = cheerio.load(oldHtml || '');
  221 + const $n = cheerio.load(newHtml || '');
  222 + const oldSecs = new Map();
  223 + const newSecs = new Map();
  224 + $o('section[data-rev]').each((_, el) => {
  225 + const r = $o(el).attr('data-rev');
  226 + oldSecs.set(r, $o(el).text().trim());
  227 + });
  228 + $n('section[data-rev]').each((_, el) => {
  229 + const r = $n(el).attr('data-rev');
  230 + newSecs.set(r, $n(el).text().trim());
  231 + });
  232 + const added = [], changed = [], removed = [];
  233 + for (const [r, txt] of newSecs) {
  234 + if (!oldSecs.has(r)) added.push(r);
  235 + else if (oldSecs.get(r) !== txt) changed.push(r);
  236 + }
  237 + for (const [r] of oldSecs) {
  238 + if (!newSecs.has(r)) removed.push(r);
  239 + }
  240 + return { added, changed, removed };
  241 + },
  242 +};
  243 +
  244 +// ────────────────────────────────────────────────────────────────
  245 +// diff_units
  246 +// ────────────────────────────────────────────────────────────────
  247 +
  248 +const diff_units = {
  249 + name: 'diff_units',
  250 + description: '对比新旧 units.json: added/changed(contract_hash 变化)/removed。如不传 oldUnits,从 history/最新 units-rev-N.json 读。',
  251 + inputSchema: {
  252 + type: 'object',
  253 + required: ['newUnits'],
  254 + properties: {
  255 + newUnits: { type: 'array' },
  256 + oldUnits: { type: 'array' },
  257 + },
  258 + },
  259 + async execute({ newUnits, oldUnits }, ctx) {
  260 + let oldList = oldUnits;
  261 + if (!oldList && ctx?.pid) {
  262 + // 尝试从 history/ 取最新归档
  263 + const historyDir = pathResolver.resolveHistoryDir(ctx.pid);
  264 + if (fs.existsSync(historyDir)) {
  265 + const files = fs.readdirSync(historyDir).filter(f => /^units-rev-\d+\.json$/.test(f));
  266 + if (files.length) {
  267 + files.sort();
  268 + const last = files[files.length - 1];
  269 + const old = JSON.parse(fs.readFileSync(path.join(historyDir, last), 'utf8'));
  270 + oldList = old.units || [];
  271 + }
  272 + }
  273 + }
  274 + return unitRegistry.diffUnits(oldList || [], newUnits);
  275 + },
  276 +};
  277 +
  278 +// ────────────────────────────────────────────────────────────────
  279 +// echo_diff
  280 +// ────────────────────────────────────────────────────────────────
  281 +
  282 +const echo_diff = {
  283 + name: 'echo_diff',
  284 + description: '发 SSE 事件 prd.updated 或 sdd.updated,告知 Dashboard 文档已变。',
  285 + inputSchema: {
  286 + type: 'object',
  287 + required: ['kind', 'rev', 'summary'],
  288 + properties: {
  289 + kind: { type: 'string', enum: ['prd', 'sdd'] },
  290 + rev: { type: 'integer' },
  291 + summary: { type: 'string', maxLength: 500 },
  292 + sections_diff: { type: 'object' },
  293 + },
  294 + },
  295 + async execute({ kind, rev, summary, sections_diff }, ctx) {
  296 + _req(ctx, ['pid']);
  297 + if (ctx.sseBroker) {
  298 + ctx.sseBroker.emit(ctx.pid, `${kind}.updated`, {
  299 + rev, summary, sections_diff: sections_diff || null,
  300 + });
  301 + }
  302 + // 同步写 changelog
  303 + try {
  304 + changelogWriter.append(ctx.pid, {
  305 + kind: `${kind}_rev`,
  306 + rev,
  307 + by: ctx.agent || 'system',
  308 + trigger: 'agent',
  309 + summary,
  310 + diff: sections_diff || {},
  311 + });
  312 + } catch (e) {
  313 + // 已存在该 rev → 跳过;其他错误抛
  314 + if (!/already/.test(e.message)) { /* swallow */ }
  315 + }
  316 + return { ok: true };
  317 + },
  318 +};
  319 +
  320 +// ────────────────────────────────────────────────────────────────
  321 +// update_search_index
  322 +// ────────────────────────────────────────────────────────────────
  323 +
  324 +const update_search_index = {
  325 + name: 'update_search_index',
  326 + description: '重建 prd/ 或 sdd/ 站点的 search-index.json (复用 scripts/build-search-index.js 的逻辑)。',
  327 + inputSchema: {
  328 + type: 'object',
  329 + properties: {
  330 + target: { type: 'string', enum: ['prd', 'sdd', 'both'], default: 'both' },
  331 + },
  332 + },
  333 + async execute({ target = 'both' }, ctx) {
  334 + _req(ctx, ['pid']);
  335 + const built = [];
  336 + if (target === 'prd' || target === 'both') {
  337 + built.push(_buildSiteIndex(pathResolver.resolvePrdSite(ctx.pid)));
  338 + }
  339 + if (target === 'sdd' || target === 'both') {
  340 + built.push(_buildSiteIndex(pathResolver.resolveSddSite(ctx.pid)));
  341 + }
  342 + return { ok: true, built };
  343 + },
  344 +};
  345 +
  346 +function _buildSiteIndex(siteRoot) {
  347 + if (!fs.existsSync(siteRoot)) return { skipped: true, reason: 'site not exist' };
  348 + const entries = [];
  349 + function walk(dir, prefix) {
  350 + for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
  351 + if (e.name === 'assets') continue;
  352 + const rel = prefix ? `${prefix}/${e.name}` : e.name;
  353 + const full = path.join(dir, e.name);
  354 + if (e.isDirectory()) walk(full, rel);
  355 + else if (e.name.endsWith('.html')) {
  356 + const html = fs.readFileSync(full, 'utf8');
  357 + const title = _extractTitle(html) || rel;
  358 + const text = _stripTagsKeepText(html).slice(0, 2000);
  359 + entries.push({ f: rel, title, content: text });
  360 + }
  361 + }
  362 + }
  363 + walk(siteRoot, '');
  364 +
  365 + const idx = {
  366 + version: '1', generated: new Date().toISOString(),
  367 + pages: entries.length, entries,
  368 + };
  369 + const out = path.join(siteRoot, 'assets', 'search-index.json');
  370 + fs.mkdirSync(path.dirname(out), { recursive: true });
  371 + fs.writeFileSync(out, JSON.stringify(idx));
  372 + return { ok: true, pages: entries.length, path: out };
  373 +}
  374 +
  375 +function _extractTitle(html) {
  376 + const m = html.match(/<title>([\s\S]*?)<\/title>/i);
  377 + return m ? m[1].trim() : null;
  378 +}
  379 +
  380 +function _stripTagsKeepText(s) {
  381 + return s
  382 + .replace(/<script[\s\S]*?<\/script>/gi, ' ')
  383 + .replace(/<style[\s\S]*?<\/style>/gi, ' ')
  384 + .replace(/<svg[\s\S]*?<\/svg>/gi, ' ')
  385 + .replace(/<[^>]+>/g, ' ')
  386 + .replace(/\s+/g, ' ')
  387 + .trim();
  388 +}
  389 +
  390 +// ────────────────────────────────────────────────────────────────
  391 +// validate_prd_site
  392 +// ────────────────────────────────────────────────────────────────
  393 +
  394 +const REQUIRED_PRD_CHAPTERS = [
  395 + 'index.html',
  396 + 'product/background.html',
  397 + 'product/personas.html',
  398 + 'product/scope.html',
  399 + 'feature/feature-tree.html',
  400 + 'flow/user-flows.html',
  401 +];
  402 +
  403 +const EIGHT_TYPES = {
  404 + Tables: $ => $('table').length > 0,
  405 + Code: $ => $('pre').length > 0,
  406 + Illustrations: $ => $('svg').length > 0,
  407 + Interaction: $ => $('.tabs, details, nav').length > 0,
  408 + Spatial: $ => $('pre').filter((_,el) => $(el).text().includes('├──') || $(el).text().includes('└──')).length > 0,
  409 + Design: $ => $('.card').length > 0 || $('[class*=card]').length > 0,
  410 + Images: $ => $('svg, img').length > 0,
  411 + Workflows: $ => $('svg').filter((_,el) => /marker-end|stroke-dasharray|arrow/.test($(el).html() || '')).length > 0,
  412 +};
  413 +
  414 +const validate_prd_site = {
  415 + name: 'validate_prd_site',
  416 + description: '整站校验 PRD: 必备章节存在 · cheerio 结构 · 8 类信息 ≥4。',
  417 + inputSchema: { type: 'object', properties: {} },
  418 + async execute(_input, ctx) {
  419 + _req(ctx, ['pid']);
  420 + const root = pathResolver.resolvePrdSite(ctx.pid);
  421 + const issues = [];
  422 + if (!fs.existsSync(root)) {
  423 + return { ok: false, issues: ['prd/ site not initialized'] };
  424 + }
  425 +
  426 + for (const f of REQUIRED_PRD_CHAPTERS) {
  427 + const p = path.join(root, f);
  428 + if (!fs.existsSync(p)) issues.push(`missing chapter: ${f}`);
  429 + }
  430 +
  431 + // 扫所有 chapter
  432 + const allFiles = [];
  433 + function walk(dir, prefix) {
  434 + for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
  435 + if (e.name === 'assets') continue;
  436 + const rel = prefix ? `${prefix}/${e.name}` : e.name;
  437 + const full = path.join(dir, e.name);
  438 + if (e.isDirectory()) walk(full, rel);
  439 + else if (e.name.endsWith('.html')) allFiles.push({ rel, full });
  440 + }
  441 + }
  442 + walk(root, '');
  443 +
  444 + for (const { rel, full } of allFiles) {
  445 + const html = fs.readFileSync(full, 'utf8');
  446 + const v = docStore.validateHTML(html, 'prd');
  447 + if (!v.ok) {
  448 + for (const e of v.errors) issues.push(`${rel}: ${e}`);
  449 + }
  450 + // 8 类信息检查
  451 + try {
  452 + const $ = cheerio.load(html);
  453 + const covered = Object.entries(EIGHT_TYPES).filter(([_, fn]) => fn($));
  454 + if (covered.length < 4) {
  455 + issues.push(`${rel}: only ${covered.length}/8 info types (need ≥4)`);
  456 + }
  457 + } catch { /* ignore */ }
  458 + }
  459 +
  460 + return { ok: issues.length === 0, issues };
  461 + },
  462 +};
  463 +
  464 +// ────────────────────────────────────────────────────────────────
  465 +// validate_sdd_consistency
  466 +// ────────────────────────────────────────────────────────────────
  467 +
  468 +const validate_sdd_consistency = {
  469 + name: 'validate_sdd_consistency',
  470 + description: 'SDD ↔ units.json ↔ integration/spec.json 三方一致性: 每 Unit 在 sdd/mvcp/ 出现 · involves ⊆ units · contract_hash 一致 · DAG 无环。',
  471 + inputSchema: { type: 'object', properties: {} },
  472 + async execute(_input, ctx) {
  473 + _req(ctx, ['pid']);
  474 + const issues = [];
  475 +
  476 + const unitsJson = docStore.readUnits(ctx.pid);
  477 + if (!unitsJson) return { ok: false, issues: ['units.json missing'] };
  478 +
  479 + const v = docStore.validateUnitsSchema(unitsJson);
  480 + if (!v.ok) issues.push(...v.errors);
  481 +
  482 + // Unit 在 sdd/mvcp/ 至少出现 1 次
  483 + const sddRoot = pathResolver.resolveSddSite(ctx.pid);
  484 + const mvcpDir = path.join(sddRoot, 'mvcp');
  485 + let sddText = '';
  486 + if (fs.existsSync(mvcpDir)) {
  487 + for (const f of fs.readdirSync(mvcpDir)) {
  488 + if (f.endsWith('.html')) sddText += fs.readFileSync(path.join(mvcpDir, f), 'utf8');
  489 + }
  490 + }
  491 + for (const u of unitsJson.units) {
  492 + if (!sddText.includes(`data-unit-id="${u.id}"`) &&
  493 + !sddText.includes(`data-def-name="${u.id}"`)) {
  494 + issues.push(`unit ${u.id} not referenced in sdd/mvcp/`);
  495 + }
  496 + }
  497 +
  498 + // integration/spec.json involves 检查
  499 + const spec = docStore.readIntegrationSpec(ctx.pid);
  500 + if (spec && Array.isArray(spec.cases)) {
  501 + const ids = new Set(unitsJson.units.map(u => u.id));
  502 + for (const c of spec.cases) {
  503 + for (const u of (c.involves || [])) {
  504 + if (!ids.has(u)) issues.push(`integration case ${c.id} involves unknown unit: ${u}`);
  505 + }
  506 + }
  507 + }
  508 +
  509 + return { ok: issues.length === 0, issues };
  510 + },
  511 +};
  512 +
  513 +// ────────────────────────────────────────────────────────────────
  514 +// hash_contract
  515 +// ────────────────────────────────────────────────────────────────
  516 +
  517 +const hash_contract = {
  518 + name: 'hash_contract',
  519 + description: 'canonical JSON + sha256 截 16 hex。算 Unit contract 的 hash。',
  520 + inputSchema: {
  521 + type: 'object',
  522 + required: ['contract'],
  523 + properties: {
  524 + contract: { type: 'object' },
  525 + },
  526 + },
  527 + async execute({ contract }, _ctx) {
  528 + return { ok: true, hash: docStore.hashContract(contract) };
  529 + },
  530 +};
  531 +
  532 +// ────────────────────────────────────────────────────────────────
  533 +function _req(ctx, keys) {
  534 + for (const k of keys) {
  535 + if (!ctx || ctx[k] == null) {
  536 + const { ToolMissingDependencyError } = require('./_errors');
  537 + throw new ToolMissingDependencyError(`ctx.${k}`);
  538 + }
  539 + }
  540 +}
  541 +
  542 +module.exports = {
  543 + bootstrap_prd_site,
  544 + bootstrap_sdd_site,
  545 + write_chapter,
  546 + write_mvcp_page,
  547 + archive_doc,
  548 + diff_html,
  549 + diff_units,
  550 + echo_diff,
  551 + update_search_index,
  552 + validate_prd_site,
  553 + validate_sdd_consistency,
  554 + hash_contract,
  555 +};
server/src/tools/devloop/integrator-tools.js 0 → 100644
  1 +/**
  2 + * DevLoop · integrator-tools (6 个)
  3 + * port_picker 分配空闲 localhost 端口(避开 reserved)
  4 + * spawn_units 批量启动 be Unit 进程,每个独立端口
  5 + * health_wait poll /api/health 直到 200 或超时
  6 + * run_case 执行 integration spec 单个 case(含 {{stepN.x}} 占位符)
  7 + * parse_placeholder 解析 {{stepN.field}} 形式占位符
  8 + * collect_stderr 从子进程取最近 stderr 行
  9 + *
  10 + * 规格: docs/prd/newfunction/_spec/agents-integrator.md · §7
  11 + */
  12 +
  13 +const net = require('net');
  14 +const fs = require('fs');
  15 +const path = require('path');
  16 +const { spawn } = require('child_process');
  17 +
  18 +const pathResolver = require('../../../services/devloop/path-resolver');
  19 +const { ToolValidationError, ToolMissingDependencyError } = require('./_errors');
  20 +
  21 +const RESERVED_PORTS = new Set([3000, 3010, 5173, 5432, 6379, 8080, 8443]);
  22 +
  23 +// ──────────────────── port_picker ────────────────────
  24 +
  25 +function isPortFree(port) {
  26 + return new Promise(resolve => {
  27 + const srv = net.createServer();
  28 + srv.once('error', () => resolve(false));
  29 + srv.once('listening', () => srv.close(() => resolve(true)));
  30 + srv.listen(port, '127.0.0.1');
  31 + });
  32 +}
  33 +
  34 +const port_picker = {
  35 + name: 'port_picker',
  36 + description: '分配 N 个空闲 localhost 端口(18000-19000),避开系统保留',
  37 + inputSchema: {
  38 + type: 'object',
  39 + required: ['count'],
  40 + properties: {
  41 + count: { type: 'integer', minimum: 1, maximum: 20 },
  42 + },
  43 + },
  44 + async execute({ count }, _ctx) {
  45 + const picked = [];
  46 + let attempts = 0;
  47 + while (picked.length < count && attempts < 100) {
  48 + attempts++;
  49 + const p = 18000 + Math.floor(Math.random() * 1000);
  50 + if (RESERVED_PORTS.has(p) || picked.includes(p)) continue;
  51 + if (await isPortFree(p)) picked.push(p);
  52 + }
  53 + if (picked.length < count) {
  54 + throw new Error(`no free port after ${attempts} attempts`);
  55 + }
  56 + return { ok: true, ports: picked };
  57 + },
  58 +};
  59 +
  60 +// ──────────────────── spawn_units ────────────────────
  61 +
  62 +const _activeHandles = new Map(); // pid → ProcessHandle[]
  63 +
  64 +const spawn_units = {
  65 + name: 'spawn_units',
  66 + description: '批量启动 be Unit 真实进程(每个独立端口 + env 注入)',
  67 + inputSchema: {
  68 + type: 'object',
  69 + required: ['be_units', 'ports', 'log_path'],
  70 + properties: {
  71 + be_units: { type: 'array', items: { type: 'string' } },
  72 + ports: { type: 'object' },
  73 + log_path: { type: 'string' },
  74 + },
  75 + },
  76 + async execute({ be_units, ports, log_path }, ctx) {
  77 + _req(ctx, ['pid']);
  78 + fs.mkdirSync(path.dirname(log_path), { recursive: true });
  79 + const logStream = fs.createWriteStream(log_path, { flags: 'a' });
  80 + const handles = [];
  81 +
  82 + for (const unitId of be_units) {
  83 + const port = ports[unitId];
  84 + if (!port) throw new ToolValidationError('ports', `no port for ${unitId}`);
  85 + const cwd = pathResolver.resolveUnitDir(ctx.pid, unitId);
  86 + const entry = path.join(cwd, 'code', 'server.js');
  87 + if (!fs.existsSync(entry)) {
  88 + throw new ToolValidationError('be_units', `${unitId} has no code/server.js`);
  89 + }
  90 + const child = spawn('node', [entry], {
  91 + cwd,
  92 + env: { ...process.env, PORT: String(port), HOST: '127.0.0.1', UNIT_ID: unitId },
  93 + detached: false,
  94 + stdio: ['ignore', 'pipe', 'pipe'],
  95 + });
  96 + const prefix = `[${unitId}:${port}] `;
  97 + child.stdout.on('data', c => logStream.write(prefix + c));
  98 + child.stderr.on('data', c => logStream.write(prefix + c));
  99 + handles.push({ unitId, port, pid: child.pid, proc: child });
  100 + }
  101 + _activeHandles.set(ctx.pid, handles);
  102 + return { ok: true, handles: handles.map(h => ({ unitId: h.unitId, port: h.port, pid: h.pid })) };
  103 + },
  104 +};
  105 +
  106 +async function killAll(pid, graceMs = 5000) {
  107 + const handles = _activeHandles.get(pid) || [];
  108 + for (const h of handles) {
  109 + try { h.proc.kill('SIGTERM'); } catch {}
  110 + }
  111 + await new Promise(r => setTimeout(r, graceMs));
  112 + for (const h of handles) {
  113 + try { if (!h.proc.killed) h.proc.kill('SIGKILL'); } catch {}
  114 + }
  115 + _activeHandles.delete(pid);
  116 +}
  117 +
  118 +// ──────────────────── health_wait ────────────────────
  119 +
  120 +const health_wait = {
  121 + name: 'health_wait',
  122 + description: 'poll /api/health (或指定 url) 直到 200 或超时',
  123 + inputSchema: {
  124 + type: 'object',
  125 + required: ['url'],
  126 + properties: {
  127 + url: { type: 'string' },
  128 + timeout_ms: { type: 'integer', default: 30_000 },
  129 + interval_ms: { type: 'integer', default: 500 },
  130 + },
  131 + },
  132 + async execute({ url, timeout_ms = 30_000, interval_ms = 500 }, _ctx) {
  133 + const deadline = Date.now() + timeout_ms;
  134 + while (Date.now() < deadline) {
  135 + try {
  136 + const r = await fetch(url);
  137 + if (r.ok) return { ok: true, status: r.status, waited_ms: timeout_ms - (deadline - Date.now()) };
  138 + } catch { /* not ready yet */ }
  139 + await new Promise(r => setTimeout(r, interval_ms));
  140 + }
  141 + return { ok: false, timeout: true };
  142 + },
  143 +};
  144 +
  145 +// ──────────────────── parse_placeholder ────────────────────
  146 +
  147 +function parsePlaceholders(obj, prev) {
  148 + const s = JSON.stringify(obj);
  149 + const resolved = s.replace(/\{\{step(\d+)\.([\w.]+)\}\}/g, (_match, n, p) => {
  150 + const r = prev[parseInt(n, 10)];
  151 + if (!r) throw new Error(`placeholder step${n} has no result yet`);
  152 + const parts = p.split('.');
  153 + let v = r.body;
  154 + for (const k of parts) v = v?.[k];
  155 + return v != null ? String(v) : '';
  156 + });
  157 + return JSON.parse(resolved);
  158 +}
  159 +
  160 +const parse_placeholder = {
  161 + name: 'parse_placeholder',
  162 + description: '解析 {{stepN.field}} 占位符,从前面 step 的 response.body 取值替换',
  163 + inputSchema: {
  164 + type: 'object',
  165 + required: ['template', 'previous_steps'],
  166 + properties: {
  167 + template: {},
  168 + previous_steps: { type: 'array' },
  169 + },
  170 + },
  171 + async execute({ template, previous_steps }, _ctx) {
  172 + return { ok: true, resolved: parsePlaceholders(template, previous_steps) };
  173 + },
  174 +};
  175 +
  176 +// ──────────────────── run_case ────────────────────
  177 +
  178 +const run_case = {
  179 + name: 'run_case',
  180 + description: '执行 integration spec 单个 case (含 placeholder 解析 + step expect 校验)',
  181 + inputSchema: {
  182 + type: 'object',
  183 + required: ['case_spec', 'ports'],
  184 + properties: {
  185 + case_spec: { type: 'object' },
  186 + ports: { type: 'object' },
  187 + timeout_ms: { type: 'integer', default: 30_000 },
  188 + },
  189 + },
  190 + async execute({ case_spec, ports, timeout_ms = 30_000 }, _ctx) {
  191 + const deadline = Date.now() + timeout_ms;
  192 + const stepResults = [];
  193 + const httpTrace = [];
  194 +
  195 + for (let i = 0; i < case_spec.steps.length; i++) {
  196 + if (Date.now() > deadline) {
  197 + return { ok: false, failed_step: i, reason: 'timeout', httpTrace };
  198 + }
  199 + const step = case_spec.steps[i];
  200 + let body, headers;
  201 + try {
  202 + body = parsePlaceholders(step.body || {}, stepResults);
  203 + headers = parsePlaceholders(step.headers || {}, stepResults);
  204 + } catch (e) {
  205 + return { ok: false, failed_step: i, reason: e.message, httpTrace };
  206 + }
  207 +
  208 + const [method, p] = step.call.split(' ');
  209 + const port = ports[step.unit];
  210 + if (!port) {
  211 + return { ok: false, failed_step: i, reason: `no port for unit ${step.unit}`, httpTrace };
  212 + }
  213 + const url = `http://127.0.0.1:${port}` + p;
  214 + const startedAt = Date.now();
  215 + let r, json = {};
  216 + try {
  217 + r = await fetch(url, {
  218 + method,
  219 + headers: { 'Content-Type': 'application/json', ...headers },
  220 + body: method === 'GET' ? undefined : JSON.stringify(body),
  221 + });
  222 + try { json = await r.json(); } catch { /* maybe empty body */ }
  223 + } catch (e) {
  224 + return { ok: false, failed_step: i, got: { status: 0, error: e.message }, httpTrace };
  225 + }
  226 +
  227 + httpTrace.push({
  228 + ts: startedAt, step: i, unit: step.unit, method, url,
  229 + status: r.status, body: json, duration_ms: Date.now() - startedAt,
  230 + });
  231 + stepResults.push({ status: r.status, body: json });
  232 +
  233 + if (!_checkExpect(step.expect, { status: r.status, body: json })) {
  234 + return { ok: false, failed_step: i, got: { status: r.status, body: json }, httpTrace };
  235 + }
  236 + }
  237 + return { ok: true, httpTrace, steps_ran: case_spec.steps.length };
  238 + },
  239 +};
  240 +
  241 +function _checkExpect(expect, got) {
  242 + if (!expect) return true;
  243 + if (expect.status && expect.status !== got.status) return false;
  244 + // expect.json 简化检查:仅检查 key 存在性 + 类型;不做 JSONPath
  245 + if (expect.json && typeof expect.json === 'object') {
  246 + for (const [k, v] of Object.entries(expect.json)) {
  247 + if (got.body?.[k] === undefined) return false;
  248 + if (typeof v === 'string' && v.startsWith('$.')) continue; // JSONPath 简化:存在即可
  249 + }
  250 + }
  251 + return true;
  252 +}
  253 +
  254 +// ──────────────────── collect_stderr ────────────────────
  255 +
  256 +const collect_stderr = {
  257 + name: 'collect_stderr',
  258 + description: '从 process.log 取最近 N 行 stderr 节选(供 BugTriager 用)',
  259 + inputSchema: {
  260 + type: 'object',
  261 + required: ['log_path'],
  262 + properties: {
  263 + log_path: { type: 'string' },
  264 + lines: { type: 'integer', default: 20 },
  265 + },
  266 + },
  267 + async execute({ log_path, lines = 20 }, _ctx) {
  268 + if (!fs.existsSync(log_path)) return { ok: false, reason: 'log not exist', excerpt: '' };
  269 + const raw = fs.readFileSync(log_path, 'utf8');
  270 + const all = raw.split('\n');
  271 + const excerpt = all.slice(-lines).join('\n');
  272 + return { ok: true, excerpt };
  273 + },
  274 +};
  275 +
  276 +function _req(ctx, keys) {
  277 + for (const k of keys) {
  278 + if (!ctx || ctx[k] == null) throw new ToolMissingDependencyError(`ctx.${k}`);
  279 + }
  280 +}
  281 +
  282 +module.exports = {
  283 + port_picker,
  284 + spawn_units,
  285 + health_wait,
  286 + run_case,
  287 + parse_placeholder,
  288 + collect_stderr,
  289 + // 内部导出(kill / 测试用)
  290 + killAll,
  291 +};
server/src/tools/devloop/persistence-tools.js 0 → 100644
  1 +/**
  2 + * DevLoop · persistence-tools
  3 + *
  4 + * 持久化 5 件套 (M-10/11/12/13 接入):
  5 + * - write_todolist 初始化 agent-state/{agent}-todo.json
  6 + * - update_todo_item 更新单条 item + 同步 progress_pct
  7 + * - update_runtime_state 修 agent-state/{agent}.json (current_step/total_steps)
  8 + * - timeline_append append timeline.ndjson
  9 + * - record_memory 写 memory/devloop/{agent}/ 下 .md
  10 + *
  11 + * 所有 Agent 都能用(toolPolicy 里写)。
  12 + * 每个工具签名: { name, description, inputSchema, execute(input, ctx) }
  13 + *
  14 + * 规格: docs/prd/newfunction/_spec/sdd-control.md · C-15
  15 + * docs/prd/newfunction/_spec/sdd-model.md · M-10/11/12/13
  16 + * docs/prd/newfunction/_spec/arch-tool-registry.md · §③
  17 + */
  18 +
  19 +const fs = require('fs');
  20 +const path = require('path');
  21 +
  22 +const pathResolver = require('../../../services/devloop/path-resolver');
  23 +const timelineLogger = require('../../../services/devloop/timeline-logger');
  24 +const { ToolValidationError, ToolMissingDependencyError } = require('./_errors');
  25 +
  26 +// ────────────────────────────────────────────────────────────────
  27 +// write_todolist
  28 +// ────────────────────────────────────────────────────────────────
  29 +
  30 +const write_todolist = {
  31 + name: 'write_todolist',
  32 + description: '初始化 agent-state/{agent}-todo.json,写入完整 todolist。Agent 开工第一件事。',
  33 + inputSchema: {
  34 + type: 'object',
  35 + required: ['items'],
  36 + properties: {
  37 + items: {
  38 + type: 'array',
  39 + minItems: 1,
  40 + items: {
  41 + type: 'object',
  42 + required: ['id', 'description'],
  43 + properties: {
  44 + id: { type: 'string', pattern: '^step-\\d{3}$' },
  45 + description: { type: 'string' },
  46 + depends_on: { type: 'array', items: { type: 'string' } },
  47 + },
  48 + },
  49 + },
  50 + },
  51 + },
  52 + async execute({ items }, ctx) {
  53 + _requireCtx(ctx, ['pid', 'agent', 'call_id']);
  54 + const p = pathResolver.resolveAgentTodolist(ctx.pid, ctx.agent);
  55 + const todolist = {
  56 + agent: ctx.agent,
  57 + call_id: ctx.call_id,
  58 + created_at: new Date().toISOString(),
  59 + items: items.map((it, i) => ({
  60 + id: it.id,
  61 + description: it.description,
  62 + depends_on: it.depends_on || [],
  63 + status: i === 0 ? 'doing' : 'todo',
  64 + ...(i === 0 ? { started_at: new Date().toISOString() } : {}),
  65 + })),
  66 + current: items[0]?.id || null,
  67 + progress_pct: 0,
  68 + };
  69 + fs.mkdirSync(path.dirname(p), { recursive: true });
  70 + fs.writeFileSync(p, JSON.stringify(todolist, null, 2));
  71 +
  72 + if (ctx.sseBroker) {
  73 + ctx.sseBroker.emit(ctx.pid, 'agent.todolist', {
  74 + agent: ctx.agent, call_id: ctx.call_id, items: todolist.items,
  75 + });
  76 + }
  77 + return { ok: true, total: items.length, path: p };
  78 + },
  79 +};
  80 +
  81 +// ────────────────────────────────────────────────────────────────
  82 +// update_todo_item
  83 +// ────────────────────────────────────────────────────────────────
  84 +
  85 +const update_todo_item = {
  86 + name: 'update_todo_item',
  87 + description: '更新 todolist 中单条 item 的 status;自动重算 progress_pct + 发 SSE。',
  88 + inputSchema: {
  89 + type: 'object',
  90 + required: ['id', 'status'],
  91 + properties: {
  92 + id: { type: 'string', pattern: '^step-\\d{3}$' },
  93 + status: {
  94 + type: 'string',
  95 + enum: ['todo', 'doing', 'done', 'failed'],
  96 + },
  97 + output_ref: { type: 'string' },
  98 + error: { type: 'string' },
  99 + },
  100 + },
  101 + async execute({ id, status, output_ref, error }, ctx) {
  102 + _requireCtx(ctx, ['pid', 'agent']);
  103 + const p = pathResolver.resolveAgentTodolist(ctx.pid, ctx.agent);
  104 + if (!fs.existsSync(p)) {
  105 + throw new ToolValidationError('todolist', `not found at ${p} (call write_todolist first)`);
  106 + }
  107 + const todolist = JSON.parse(fs.readFileSync(p, 'utf8'));
  108 + const item = todolist.items.find(it => it.id === id);
  109 + if (!item) {
  110 + throw new ToolValidationError('id', `unknown step: ${id}`);
  111 + }
  112 + const from = item.status;
  113 + item.status = status;
  114 + if (status === 'doing' && !item.started_at) {
  115 + item.started_at = new Date().toISOString();
  116 + }
  117 + if (status === 'done' || status === 'failed') {
  118 + item.finished_at = new Date().toISOString();
  119 + if (output_ref) item.output_ref = output_ref;
  120 + if (error) item.error = error;
  121 + }
  122 +
  123 + // 重算 progress_pct
  124 + const done = todolist.items.filter(it => it.status === 'done').length;
  125 + todolist.progress_pct = Math.round((done / todolist.items.length) * 100);
  126 +
  127 + // 找下一个 todo 设为 doing
  128 + if (status === 'done') {
  129 + const next = todolist.items.find(it => it.status === 'todo');
  130 + if (next) {
  131 + next.status = 'doing';
  132 + next.started_at = new Date().toISOString();
  133 + todolist.current = next.id;
  134 + } else {
  135 + todolist.current = null;
  136 + }
  137 + }
  138 +
  139 + fs.writeFileSync(p, JSON.stringify(todolist, null, 2));
  140 +
  141 + if (ctx.sseBroker) {
  142 + ctx.sseBroker.emit(ctx.pid, 'agent.step', {
  143 + agent: ctx.agent, call_id: ctx.call_id || todolist.call_id,
  144 + item_id: id, from, to: status,
  145 + progress_pct: todolist.progress_pct,
  146 + current_desc: todolist.items.find(it => it.id === todolist.current)?.description,
  147 + });
  148 + }
  149 + return { ok: true, progress_pct: todolist.progress_pct };
  150 + },
  151 +};
  152 +
  153 +// ────────────────────────────────────────────────────────────────
  154 +// update_runtime_state
  155 +// ────────────────────────────────────────────────────────────────
  156 +
  157 +const update_runtime_state = {
  158 + name: 'update_runtime_state',
  159 + description: '更新 M-10 AgentRuntimeState 的 current_step / total_steps(不改 status)。',
  160 + inputSchema: {
  161 + type: 'object',
  162 + required: ['current_step', 'total_steps'],
  163 + properties: {
  164 + current_step: { type: 'integer', minimum: 0 },
  165 + total_steps: { type: 'integer', minimum: 1 },
  166 + },
  167 + },
  168 + async execute({ current_step, total_steps }, ctx) {
  169 + _requireCtx(ctx, ['pid', 'agent']);
  170 + const p = pathResolver.resolveAgentState(ctx.pid, ctx.agent);
  171 + if (!fs.existsSync(p)) {
  172 + throw new ToolValidationError('state', `runtime state not found at ${p}`);
  173 + }
  174 + const state = JSON.parse(fs.readFileSync(p, 'utf8'));
  175 + state.current_step = current_step;
  176 + state.total_steps = total_steps;
  177 + state.updated_at = new Date().toISOString();
  178 + fs.writeFileSync(p, JSON.stringify(state, null, 2));
  179 + return { ok: true, current_step, total_steps };
  180 + },
  181 +};
  182 +
  183 +// ────────────────────────────────────────────────────────────────
  184 +// timeline_append
  185 +// ────────────────────────────────────────────────────────────────
  186 +
  187 +const timeline_append = {
  188 + name: 'timeline_append',
  189 + description: 'Append 一条事件到 timeline.ndjson (M-13)。',
  190 + inputSchema: {
  191 + type: 'object',
  192 + required: ['kind', 'summary'],
  193 + properties: {
  194 + kind: { type: 'string' },
  195 + ref: { type: 'string' },
  196 + summary: { type: 'string', maxLength: 280 },
  197 + payload: { type: 'object' },
  198 + },
  199 + },
  200 + async execute({ kind, ref, summary, payload }, ctx) {
  201 + _requireCtx(ctx, ['pid', 'agent']);
  202 + const ev = timelineLogger.append(ctx.pid, {
  203 + source: ctx.agent,
  204 + kind, ref, summary, payload,
  205 + });
  206 + return { ok: true, ts: ev.ts };
  207 + },
  208 +};
  209 +
  210 +// ────────────────────────────────────────────────────────────────
  211 +// record_memory
  212 +// ────────────────────────────────────────────────────────────────
  213 +
  214 +const record_memory = {
  215 + name: 'record_memory',
  216 + description: '在 memory/devloop/{agent}/ 下写一条 .md (M-11)。同 topic 是 upsert。',
  217 + inputSchema: {
  218 + type: 'object',
  219 + required: ['topic', 'content', 'relevance'],
  220 + properties: {
  221 + topic: { type: 'string', minLength: 2 },
  222 + content: { type: 'string', minLength: 10 },
  223 + relevance: { type: 'number', minimum: 0, maximum: 1 },
  224 + },
  225 + },
  226 + async execute({ topic, content, relevance }, ctx) {
  227 + _requireCtx(ctx, ['agent', 'company_id']);
  228 + const dir = pathResolver.resolveMemoryDir(ctx.company_id, ctx.agent);
  229 + fs.mkdirSync(dir, { recursive: true });
  230 +
  231 + const slug = topic.toLowerCase()
  232 + .replace(/[^a-z0-9一-龥]+/g, '-')
  233 + .replace(/^-+|-+$/g, '');
  234 + const p = path.join(dir, `${slug}.md`);
  235 +
  236 + let existing = null;
  237 + if (fs.existsSync(p)) {
  238 + const raw = fs.readFileSync(p, 'utf8');
  239 + const m = raw.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/);
  240 + if (m) {
  241 + const fm = {};
  242 + for (const line of m[1].split('\n')) {
  243 + const kv = line.match(/^(\w+):\s*(.*)$/);
  244 + if (kv) fm[kv[1]] = kv[2].trim();
  245 + }
  246 + existing = { fm, body: m[2] };
  247 + }
  248 + }
  249 +
  250 + const projects = existing?.fm?.project_refs
  251 + ? existing.fm.project_refs.replace(/[\[\]]/g, '').split(',').map(s => s.trim()).filter(Boolean)
  252 + : [];
  253 + if (ctx.pid && !projects.includes(ctx.pid)) projects.push(ctx.pid);
  254 +
  255 + const hits = existing?.fm?.hits ? (parseInt(existing.fm.hits, 10) + 1) : 1;
  256 + const frontmatter = [
  257 + '---',
  258 + `agent: ${ctx.agent}`,
  259 + `topic: ${topic}`,
  260 + `project_refs: [${projects.join(', ')}]`,
  261 + `last_updated: ${new Date().toISOString()}`,
  262 + `relevance_score: ${relevance}`,
  263 + `hits: ${hits}`,
  264 + '---',
  265 + '',
  266 + ].join('\n');
  267 +
  268 + fs.writeFileSync(p, frontmatter + content + '\n');
  269 + return { ok: true, path: p, upserted: !!existing, hits };
  270 + },
  271 +};
  272 +
  273 +// ────────────────────────────────────────────────────────────────
  274 +// Helpers
  275 +// ────────────────────────────────────────────────────────────────
  276 +
  277 +function _requireCtx(ctx, keys) {
  278 + for (const k of keys) {
  279 + if (ctx[k] == null) throw new ToolMissingDependencyError(`ctx.${k}`);
  280 + }
  281 +}
  282 +
  283 +module.exports = {
  284 + write_todolist,
  285 + update_todo_item,
  286 + update_runtime_state,
  287 + timeline_append,
  288 + record_memory,
  289 +};
server/src/tools/devloop/tester-tools.js 0 → 100644
  1 +/**
  2 + * DevLoop · tester-tools (5 个)
  3 + * parse_vitest vitest JSON reporter 解析 → result.json + hint 生成
  4 + * hint_builder 从 failure stack + expected/got 启发式生成 hint
  5 + * bug_open 调 bugTracker.open + 自动 dedup
  6 + * bug_transition bug 状态转换
  7 + * bug_list 按 filter 查 bug
  8 + *
  9 + * 规格: docs/prd/newfunction/_spec/agents-tester.md · §7
  10 + */
  11 +
  12 +const fs = require('fs');
  13 +const path = require('path');
  14 +
  15 +const pathResolver = require('../../../services/devloop/path-resolver');
  16 +const bugTracker = require('../../../services/devloop/bug-tracker');
  17 +const { ToolValidationError, ToolMissingDependencyError } = require('./_errors');
  18 +
  19 +// ──────────────────── hint_builder ────────────────────
  20 +
  21 +const HINT_PATTERNS = [
  22 + { match: s => /cannot read.*?of undefined/i.test(s),
  23 + hint: () => '某个对象/字段是 undefined,先做存在性判断再访问' },
  24 + { match: s => /TypeError.*compare/i.test(s),
  25 + hint: () => '调用 bcrypt.compare 前先确认 user 存在;否则 null 参数会抛 TypeError' },
  26 + { match: s => /SQLITE_CONSTRAINT/i.test(s),
  27 + hint: () => '数据库约束冲突(unique/foreign key),业务层应该先查再写' },
  28 + { match: s => /timeout/i.test(s),
  29 + hint: () => '某个异步操作超时;检查是否漏 await 或外部依赖未 mock' },
  30 +];
  31 +
  32 +function buildHint(ctx) {
  33 + const stack = ctx.stack || '';
  34 + for (const p of HINT_PATTERNS) {
  35 + if (p.match(stack)) return p.hint(ctx);
  36 + }
  37 + // status code 启发式
  38 + const m = String(stack).match(/[Ee]xpected[:\s]+(\d{3})[\s\S]{0,200}?[Rr]eceived[:\s]+(\d{3})/);
  39 + const e = m ? parseInt(m[1], 10) : ctx.expected;
  40 + const g = m ? parseInt(m[2], 10) : ctx.got;
  41 + if (typeof e === 'number' && typeof g === 'number') {
  42 + if (e === 401 && g === 500) return '凭据错误应返 401 不是 500;检查认证路径是否 try/catch 兜底';
  43 + if (e === 400 && g === 500) return '参数校验错应返 400 不是 500;在 route 层检查后再传 service';
  44 + if (e === 404 && g === 200) return '不存在的资源不应返 200;返回前判断查询结果';
  45 + return `期望 ${e} 收 ${g},检查对应分支处理`;
  46 + }
  47 + return `${ctx.testName || 'unknown'}: 检查 stack 推断原因 (Tester 应补充)`;
  48 +}
  49 +
  50 +const hint_builder = {
  51 + name: 'hint_builder',
  52 + description: '从 failure stack/expected/got 生成给 Coder 反思用的 hint(1-2 句人话)',
  53 + inputSchema: {
  54 + type: 'object',
  55 + properties: {
  56 + testName: { type: 'string' },
  57 + expected: {},
  58 + got: {},
  59 + stack: { type: 'string' },
  60 + },
  61 + },
  62 + async execute(failure, _ctx) {
  63 + return { ok: true, hint: buildHint(failure) };
  64 + },
  65 +};
  66 +
  67 +// ──────────────────── parse_vitest ────────────────────
  68 +
  69 +const parse_vitest = {
  70 + name: 'parse_vitest',
  71 + description: 'vitest --reporter=json 输出 → 结构化 result.json + auto-fill hint for failures',
  72 + inputSchema: {
  73 + type: 'object',
  74 + required: ['raw_json_path'],
  75 + properties: {
  76 + raw_json_path: { type: 'string' },
  77 + result_out_path: { type: 'string', description: '可选,result.json 写入位置' },
  78 + },
  79 + },
  80 + async execute({ raw_json_path, result_out_path }, _ctx) {
  81 + if (!fs.existsSync(raw_json_path)) {
  82 + return {
  83 + ok: false, crashed: true,
  84 + crash_reason: 'vitest raw json missing — process likely crashed early',
  85 + summary: { total: 0, passed: 0, failed: 0, skipped: 0 },
  86 + failures: [],
  87 + };
  88 + }
  89 + let raw;
  90 + try {
  91 + raw = JSON.parse(fs.readFileSync(raw_json_path, 'utf8'));
  92 + } catch (e) {
  93 + return { ok: false, crashed: true, crash_reason: 'invalid vitest json: ' + e.message };
  94 + }
  95 +
  96 + const summary = { total: 0, passed: 0, failed: 0, skipped: 0 };
  97 + const failures = [];
  98 +
  99 + const testResults = raw.testResults || raw.tests || [];
  100 + for (const file of testResults) {
  101 + const asserts = file.assertionResults || file.assertions || [];
  102 + for (const a of asserts) {
  103 + summary.total++;
  104 + if (a.status === 'passed') summary.passed++;
  105 + else if (a.status === 'failed') {
  106 + summary.failed++;
  107 + const stack = (a.failureMessages || a.errors || []).join('\n');
  108 + const { expected, got } = _extractAssertion(stack);
  109 + failures.push({
  110 + test: a.fullName || a.title,
  111 + expected, got, stack,
  112 + hint: buildHint({ testName: a.fullName, expected, got, stack }),
  113 + });
  114 + } else summary.skipped++;
  115 + }
  116 + }
  117 +
  118 + const result = {
  119 + ok: summary.failed === 0 && summary.total > 0,
  120 + crashed: false,
  121 + summary, failures,
  122 + duration_ms: raw.testResults?.[0]?.perfStats?.runtime || 0,
  123 + };
  124 +
  125 + if (result_out_path) {
  126 + fs.mkdirSync(path.dirname(result_out_path), { recursive: true });
  127 + fs.writeFileSync(result_out_path, JSON.stringify(result, null, 2));
  128 + }
  129 + return result;
  130 + },
  131 +};
  132 +
  133 +function _extractAssertion(stack) {
  134 + const exp = stack.match(/Expected[:\s]+([^\n]+)/);
  135 + const got = stack.match(/(?:Received|Actual)[:\s]+([^\n]+)/);
  136 + return {
  137 + expected: exp ? exp[1].trim() : null,
  138 + got: got ? got[1].trim() : null,
  139 + };
  140 +}
  141 +
  142 +// ──────────────────── bug_open / transition / list ────────────────────
  143 +
  144 +const bug_open = {
  145 + name: 'bug_open',
  146 + description: 'append bugs.ndjson 一条 status=open;dedupKey 命中 open/fixing 时复用旧 id',
  147 + inputSchema: {
  148 + type: 'object',
  149 + required: ['title'],
  150 + properties: {
  151 + source: { type: 'string', enum: ['unit_test','integration_test','triage','human'] },
  152 + unit_id: { type: 'string' },
  153 + run_id: { type: 'string' },
  154 + test_name: { type: 'string' },
  155 + category: { type: 'string' },
  156 + severity: { type: 'string', enum: ['critical','high','medium','low'] },
  157 + title: { type: 'string', maxLength: 120 },
  158 + detail: { type: 'string' },
  159 + affected_units: { type: 'array', items: { type: 'string' } },
  160 + },
  161 + },
  162 + async execute(bug, ctx) {
  163 + _req(ctx, ['pid']);
  164 + // 注入 sseBroker + timelineLogger (如果 ctx 有)
  165 + if (ctx.sseBroker) bugTracker.attachSseBroker(ctx.sseBroker);
  166 + if (ctx.timelineLogger) bugTracker.attachTimelineLogger(ctx.timelineLogger);
  167 + const b = bugTracker.open(ctx.pid, { ...bug, by: ctx.agent || 'tester' });
  168 + return { ok: true, bug_id: b.id, dedup: b.discovered_at !== new Date().toISOString().slice(0, b.discovered_at.length) };
  169 + },
  170 +};
  171 +
  172 +const bug_transition = {
  173 + name: 'bug_transition',
  174 + description: 'bug 状态转换 (open → fixing → fixed → verified 等),走 P-9 状态机',
  175 + inputSchema: {
  176 + type: 'object',
  177 + required: ['bug_id', 'to'],
  178 + properties: {
  179 + bug_id: { type: 'string' },
  180 + to: { type: 'string', enum: ['open','fixing','fixed','verified','wontfix','duplicate'] },
  181 + note: { type: 'string' },
  182 + fix_refs: { type: 'array', items: { type: 'string' } },
  183 + duplicate_of: { type: 'string' },
  184 + },
  185 + },
  186 + async execute({ bug_id, to, note, fix_refs, duplicate_of }, ctx) {
  187 + _req(ctx, ['pid']);
  188 + if (ctx.sseBroker) bugTracker.attachSseBroker(ctx.sseBroker);
  189 + if (ctx.timelineLogger) bugTracker.attachTimelineLogger(ctx.timelineLogger);
  190 + try {
  191 + bugTracker.transition(ctx.pid, bug_id, {
  192 + to, by: ctx.agent || 'system', note, fix_refs, duplicate_of,
  193 + });
  194 + return { ok: true };
  195 + } catch (e) {
  196 + if (e.name === 'InvalidBugTransitionError') {
  197 + throw new ToolValidationError('to', e.message);
  198 + }
  199 + throw e;
  200 + }
  201 + },
  202 +};
  203 +
  204 +const bug_list = {
  205 + name: 'bug_list',
  206 + description: '列 bug,可按 status/severity/unit_id 过滤;返回每个 bug.id 的最新态',
  207 + inputSchema: {
  208 + type: 'object',
  209 + properties: {
  210 + status: { type: 'array', items: { type: 'string' } },
  211 + severity: { type: 'array', items: { type: 'string' } },
  212 + unit_id: { type: 'string' },
  213 + since: { type: 'string' },
  214 + },
  215 + },
  216 + async execute(filter, ctx) {
  217 + _req(ctx, ['pid']);
  218 + const bugs = bugTracker.list(ctx.pid, filter || {});
  219 + return { ok: true, bugs, total: bugs.length };
  220 + },
  221 +};
  222 +
  223 +function _req(ctx, keys) {
  224 + for (const k of keys) {
  225 + if (!ctx || ctx[k] == null) throw new ToolMissingDependencyError(`ctx.${k}`);
  226 + }
  227 +}
  228 +
  229 +module.exports = { hint_builder, parse_vitest, bug_open, bug_transition, bug_list };
server/src/tools/devloop/tool-registry.js 0 → 100644
  1 +/**
  2 + * DevLoop Tool Registry — loadAllTools()
  3 + *
  4 + * 把 server/src/tools/devloop/ 下所有 *-tools.js 加载并按工具 name 聚合到 Map。
  5 + * tool-manager / ClaudeCliProtocol 启动 Agent 时通过 toolPolicy 取这里的 ToolDef。
  6 + *
  7 + * 规格: docs/prd/newfunction/_spec/arch-tool-registry.md · §④
  8 + */
  9 +
  10 +const path = require('path');
  11 +const fs = require('fs');
  12 +
  13 +// 工具模块清单(手动 require,避免动态 require 出错)
  14 +const TOOL_MODULES = [
  15 + './persistence-tools',
  16 + './doc-tools',
  17 + './coder-tools',
  18 + './tester-tools',
  19 + './integrator-tools',
  20 + './triager-tools',
  21 +];
  22 +
  23 +let cached = null;
  24 +
  25 +function loadAllTools() {
  26 + if (cached) return cached;
  27 +
  28 + const registry = new Map();
  29 +
  30 + for (const mod of TOOL_MODULES) {
  31 + const fullPath = path.join(__dirname, mod + '.js');
  32 + if (!fs.existsSync(fullPath)) continue;
  33 + const tools = require(mod);
  34 + for (const key of Object.keys(tools)) {
  35 + const tool = tools[key];
  36 + if (tool && typeof tool === 'object' && tool.name && typeof tool.execute === 'function') {
  37 + if (registry.has(tool.name)) {
  38 + throw new Error(`Duplicate tool name: ${tool.name} (in ${mod})`);
  39 + }
  40 + registry.set(tool.name, tool);
  41 + }
  42 + }
  43 + }
  44 +
  45 + cached = registry;
  46 + return registry;
  47 +}
  48 +
  49 +/**
  50 + * 列工具名(用于 _registry.json toolPolicy 校验)
  51 + */
  52 +function listToolNames() {
  53 + return Array.from(loadAllTools().keys()).sort();
  54 +}
  55 +
  56 +/**
  57 + * 按 toolPolicy 取一组工具(含 LemonBoss 既有工具是允许的,本函数只过滤 devloop 的)
  58 + */
  59 +function getDevLoopTools(toolPolicy) {
  60 + const all = loadAllTools();
  61 + const out = [];
  62 + for (const name of toolPolicy) {
  63 + if (all.has(name)) out.push(all.get(name));
  64 + }
  65 + return out;
  66 +}
  67 +
  68 +/**
  69 + * 调度:根据 name 找工具,执行(中央调用入口,外部 ACP 调用方使用)。
  70 + * - 校验工具存在
  71 + * - 校验 input 符合 inputSchema(简化版:只校验 required + type)
  72 + * - 调 tool.execute(input, ctx)
  73 + * - 错误标准化
  74 + */
  75 +async function dispatch(name, input, ctx) {
  76 + const all = loadAllTools();
  77 + const tool = all.get(name);
  78 + if (!tool) {
  79 + throw new Error(`Unknown tool: ${name}`);
  80 + }
  81 + _validateInput(input, tool.inputSchema, name);
  82 + return tool.execute(input, ctx);
  83 +}
  84 +
  85 +function _validateInput(input, schema, name) {
  86 + if (!schema || typeof schema !== 'object') return;
  87 + if (schema.required && Array.isArray(schema.required)) {
  88 + for (const k of schema.required) {
  89 + if (input == null || input[k] === undefined) {
  90 + const e = new Error(`Tool ${name}: missing required field "${k}"`);
  91 + e.code = 'TOOL_INPUT_INVALID';
  92 + throw e;
  93 + }
  94 + }
  95 + }
  96 +}
  97 +
  98 +function _resetForTesting() {
  99 + cached = null;
  100 +}
  101 +
  102 +module.exports = {
  103 + loadAllTools,
  104 + listToolNames,
  105 + getDevLoopTools,
  106 + dispatch,
  107 + _resetForTesting,
  108 + TOOL_MODULES,
  109 +};
server/src/tools/devloop/triager-tools.js 0 → 100644
  1 +/**
  2 + * DevLoop · triager-tools (4 个)
  3 + * extract_evidence 验证 evidence 字符串在原文 grep 命中
  4 + * category_classifier 启发式 4 类粗判(contract_mismatch / impl_bug / spec_ambiguous / infra_flake)
  5 + * diff_contract_vs_impl 对比 contract.json 声明 vs code 实际行为(轻量,看 endpoint 存在性)
  6 + * find_similar_bug 在 bugs.ndjson 找历史相似 bug
  7 + *
  8 + * 规格: docs/prd/newfunction/_spec/agents-triager.md · §7
  9 + */
  10 +
  11 +const fs = require('fs');
  12 +const path = require('path');
  13 +
  14 +const pathResolver = require('../../../services/devloop/path-resolver');
  15 +const bugTracker = require('../../../services/devloop/bug-tracker');
  16 +const docStore = require('../../../services/devloop/doc-store');
  17 +const { ToolMissingDependencyError } = require('./_errors');
  18 +
  19 +// ──────────────────── extract_evidence ────────────────────
  20 +
  21 +function verifyOneEvidence(projectRoot, evidence) {
  22 + const sep = evidence.indexOf(': ');
  23 + if (sep < 0) return { ok: false, reason: 'malformed: need "path: quote"' };
  24 + const relPath = evidence.slice(0, sep).replace(/^['"]|['"]$/g, '').trim();
  25 + const quote = evidence.slice(sep + 2).replace(/^['"]|['"]$/g, '').trim();
  26 + const norm = s => s.replace(/\s+/g, ' ').toLowerCase();
  27 +
  28 + // 支持 "path:lineno" 形式
  29 + let pathOnly = relPath;
  30 + let line = null;
  31 + const lineMatch = relPath.match(/^(.+):(\d+)$/);
  32 + if (lineMatch) {
  33 + pathOnly = lineMatch[1];
  34 + line = parseInt(lineMatch[2], 10);
  35 + }
  36 + const abs = path.join(projectRoot, pathOnly);
  37 + if (!fs.existsSync(abs)) {
  38 + return { ok: false, reason: 'file not found: ' + pathOnly };
  39 + }
  40 + const content = fs.readFileSync(abs, 'utf8');
  41 + if (line) {
  42 + const lines = content.split('\n');
  43 + const lineContent = lines[line - 1] || '';
  44 + if (norm(lineContent).includes(norm(quote))) {
  45 + return { ok: true, hit_file: pathOnly, line };
  46 + }
  47 + }
  48 + if (norm(content).includes(norm(quote))) {
  49 + return { ok: true, hit_file: pathOnly };
  50 + }
  51 + return { ok: false, reason: 'quote not found in file', quote, file: pathOnly };
  52 +}
  53 +
  54 +const extract_evidence = {
  55 + name: 'extract_evidence',
  56 + description: '验证 evidence 列表每条能在原文 grep 命中(防 Triager 编造证据)',
  57 + inputSchema: {
  58 + type: 'object',
  59 + required: ['evidence'],
  60 + properties: {
  61 + evidence: { type: 'array', items: { type: 'string' } },
  62 + },
  63 + },
  64 + async execute({ evidence }, ctx) {
  65 + _req(ctx, ['pid']);
  66 + const projectRoot = pathResolver.resolveProjectRoot(ctx.pid);
  67 + const results = evidence.map(e => ({ evidence: e, ...verifyOneEvidence(projectRoot, e) }));
  68 + return {
  69 + ok: results.every(r => r.ok),
  70 + results,
  71 + failed: results.filter(r => !r.ok),
  72 + };
  73 + },
  74 +};
  75 +
  76 +// ──────────────────── category_classifier ────────────────────
  77 +
  78 +const category_classifier = {
  79 + name: 'category_classifier',
  80 + description: '启发式粗判:contract_mismatch / impl_bug / spec_ambiguous / infra_flake',
  81 + inputSchema: {
  82 + type: 'object',
  83 + required: ['result_json', 'process_log'],
  84 + properties: {
  85 + result_json: { type: 'object' },
  86 + process_log: { type: 'string' },
  87 + sdd_text: { type: 'string' },
  88 + },
  89 + },
  90 + async execute({ result_json, process_log, sdd_text = '' }, _ctx) {
  91 + const hints = [];
  92 + const score = { contract_mismatch: 0, impl_bug: 0, spec_ambiguous: 0, infra_flake: 0 };
  93 +
  94 + // infra_flake 信号
  95 + const infraSignals = [
  96 + /EADDRINUSE/, /ECONNREFUSED/, /ETIMEDOUT/, /EAI_AGAIN/,
  97 + /Cannot find module/, /port already in use/i,
  98 + /health check timeout/i,
  99 + ];
  100 + if (infraSignals.some(re => re.test(process_log || ''))) {
  101 + score.infra_flake += 3;
  102 + hints.push('infra signal in process.log');
  103 + }
  104 +
  105 + // spec_ambiguous 信号
  106 + const failTxt = JSON.stringify(result_json || {});
  107 + const failTokens = [...new Set(failTxt.toLowerCase().match(/[a-z]{4,}/g) || [])]
  108 + .filter(t => !['function','expected','received','stack','message','error'].includes(t));
  109 + const sddLc = sdd_text.toLowerCase();
  110 + const tokensNotInSdd = failTokens.filter(t => sddLc && !sddLc.includes(t));
  111 + if (failTokens.length > 0 && tokensNotInSdd.length >= Math.max(2, failTokens.length / 2)) {
  112 + score.spec_ambiguous += 2;
  113 + hints.push('key tokens not found in SDD: ' + tokensNotInSdd.slice(0, 5).join(','));
  114 + }
  115 +
  116 + // impl_bug 信号:5xx got vs 4xx expected
  117 + const fail0 = result_json?.failures?.[0];
  118 + if (fail0?.got?.status >= 500 && fail0?.expected?.status >= 400 && fail0?.expected?.status < 500) {
  119 + score.impl_bug += 2;
  120 + hints.push('5xx got vs 4xx expected → impl_bug');
  121 + }
  122 +
  123 + // contract_mismatch 兜底(默认)
  124 + if (Object.values(score).every(v => v === 0)) {
  125 + score.contract_mismatch += 1;
  126 + hints.push('no strong signal → default contract_mismatch');
  127 + }
  128 +
  129 + const sorted = Object.entries(score).sort(([,a],[,b]) => b - a);
  130 + const [topCat, topScore] = sorted[0];
  131 + let confidence;
  132 + if (topScore >= 3) confidence = 0.85;
  133 + else if (topScore === 2) confidence = 0.65;
  134 + else if (topScore === 1) confidence = 0.45;
  135 + else confidence = 0.30;
  136 +
  137 + return { ok: true, category: topCat, confidence, hints, score };
  138 + },
  139 +};
  140 +
  141 +// ──────────────────── diff_contract_vs_impl ────────────────────
  142 +
  143 +const diff_contract_vs_impl = {
  144 + name: 'diff_contract_vs_impl',
  145 + description: '轻量对比:contract.json 声明的 endpoint 是否在 code/ 文本中出现(只看签名,不验语义)',
  146 + inputSchema: {
  147 + type: 'object',
  148 + required: ['unit_id'],
  149 + properties: { unit_id: { type: 'string' } },
  150 + },
  151 + async execute({ unit_id }, ctx) {
  152 + _req(ctx, ['pid']);
  153 + const unitDir = pathResolver.resolveUnitDir(ctx.pid, unit_id);
  154 + const contractPath = path.join(unitDir, 'contract.json');
  155 + if (!fs.existsSync(contractPath)) {
  156 + return { ok: false, reason: 'contract.json missing' };
  157 + }
  158 + const contract = JSON.parse(fs.readFileSync(contractPath, 'utf8'));
  159 + const codeDir = path.join(unitDir, 'code');
  160 + if (!fs.existsSync(codeDir)) {
  161 + return { ok: false, reason: 'code/ missing' };
  162 + }
  163 +
  164 + // 把 code/ 全部文本拼起来
  165 + let codeBlob = '';
  166 + function walk(dir) {
  167 + for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
  168 + const full = path.join(dir, e.name);
  169 + if (e.isDirectory()) walk(full);
  170 + else if (/\.(js|ts|vue|jsx|tsx|mjs|cjs)$/.test(e.name)) {
  171 + codeBlob += fs.readFileSync(full, 'utf8') + '\n';
  172 + }
  173 + }
  174 + }
  175 + walk(codeDir);
  176 +
  177 + const divergences = [];
  178 + const endpoints = contract.exports?.http || [];
  179 + for (const ep of endpoints) {
  180 + if (!codeBlob.includes(ep.path)) {
  181 + divergences.push(`declared ${ep.method} ${ep.path} not found in code`);
  182 + }
  183 + }
  184 + return { matches: divergences.length === 0, divergences };
  185 + },
  186 +};
  187 +
  188 +// ──────────────────── find_similar_bug ────────────────────
  189 +
  190 +const find_similar_bug = {
  191 + name: 'find_similar_bug',
  192 + description: '在 bugs.ndjson 找历史相似 bug(同 unit + 类似 test_name)',
  193 + inputSchema: {
  194 + type: 'object',
  195 + required: ['signature'],
  196 + properties: {
  197 + signature: {
  198 + type: 'object',
  199 + properties: {
  200 + unit_id: { type: 'string' },
  201 + test_name: { type: 'string' },
  202 + status: { type: 'array', items: { type: 'string' } },
  203 + },
  204 + },
  205 + },
  206 + },
  207 + async execute({ signature }, ctx) {
  208 + _req(ctx, ['pid']);
  209 + const bugs = bugTracker.list(ctx.pid, {
  210 + unit_id: signature.unit_id,
  211 + status: signature.status,
  212 + });
  213 + const matches = bugs.filter(b => {
  214 + if (!signature.test_name) return true;
  215 + return b.test_name === signature.test_name ||
  216 + (b.test_name && b.test_name.includes(signature.test_name));
  217 + });
  218 + return { ok: true, matches, total: matches.length };
  219 + },
  220 +};
  221 +
  222 +function _req(ctx, keys) {
  223 + for (const k of keys) {
  224 + if (!ctx || ctx[k] == null) throw new ToolMissingDependencyError(`ctx.${k}`);
  225 + }
  226 +}
  227 +
  228 +module.exports = {
  229 + extract_evidence,
  230 + category_classifier,
  231 + diff_contract_vs_impl,
  232 + find_similar_bug,
  233 +};
server/tests/devloop/tools/bash-json-guard.test.js 0 → 100644
  1 +/**
  2 + * bash-json-guard 单测
  3 + */
  4 +
  5 +const { jsonGuard, bashHooks, INLINE_JSON_PATTERNS } = require('../../../src/tools/devloop/bash-json-guard');
  6 +
  7 +describe('bash-json-guard', () => {
  8 + describe('jsonGuard', () => {
  9 + it('passes plain commands', () => {
  10 + expect(jsonGuard('ls -la').ok).toBe(true);
  11 + expect(jsonGuard('node server.js').ok).toBe(true);
  12 + });
  13 +
  14 + it('passes curl with @file', () => {
  15 + expect(jsonGuard('curl -X POST -d @/tmp/payload.json http://x').ok).toBe(true);
  16 + });
  17 +
  18 + it('blocks curl -d single-quoted inline JSON', () => {
  19 + const r = jsonGuard(`curl -d '{"k":"v"}' http://x`);
  20 + expect(r.ok).toBe(false);
  21 + expect(r.reason).toMatch(/inline JSON/i);
  22 + });
  23 +
  24 + it('blocks curl --data inline JSON', () => {
  25 + const r = jsonGuard(`curl --data '{"k":"v"}' http://x`);
  26 + expect(r.ok).toBe(false);
  27 + });
  28 +
  29 + it('blocks curl --data-raw inline JSON', () => {
  30 + const r = jsonGuard(`curl --data-raw '{"a":1}' http://x`);
  31 + expect(r.ok).toBe(false);
  32 + });
  33 +
  34 + it('blocks curl --data-binary inline JSON', () => {
  35 + const r = jsonGuard(`curl --data-binary '{"big":true}' http://x`);
  36 + expect(r.ok).toBe(false);
  37 + });
  38 +
  39 + it('blocks double-quoted form too', () => {
  40 + const r = jsonGuard(`curl -d "{\\"k\\":\\"v\\"}" http://x`);
  41 + expect(r.ok).toBe(false);
  42 + });
  43 +
  44 + it('returns matched_pattern on block', () => {
  45 + const r = jsonGuard(`curl -d '{}' http://x`);
  46 + expect(r.ok).toBe(false);
  47 + expect(typeof r.matched_pattern).toBe('string');
  48 + });
  49 +
  50 + it('passes non-string input safely', () => {
  51 + expect(jsonGuard(null).ok).toBe(true);
  52 + expect(jsonGuard(undefined).ok).toBe(true);
  53 + expect(jsonGuard(123).ok).toBe(true);
  54 + });
  55 + });
  56 +
  57 + describe('bashHooks.preExecute', () => {
  58 + it('throws JSON_GUARD_BLOCK on offending command', () => {
  59 + const hook = bashHooks.preExecute[0];
  60 + let err = null;
  61 + try { hook(`curl -d '{"x":1}' http://x`); } catch (e) { err = e; }
  62 + expect(err).not.toBeNull();
  63 + expect(err.code).toBe('JSON_GUARD_BLOCK');
  64 + });
  65 +
  66 + it('returns silently on safe command', () => {
  67 + const hook = bashHooks.preExecute[0];
  68 + expect(() => hook('echo hello')).not.toThrow();
  69 + });
  70 + });
  71 +
  72 + it('exports 4 INLINE_JSON_PATTERNS', () => {
  73 + expect(INLINE_JSON_PATTERNS.length).toBe(4);
  74 + });
  75 +});
server/tests/devloop/tools/coder-tools.test.js 0 → 100644
  1 +/**
  2 + * coder-tools 单测
  3 + */
  4 +
  5 +const fs = require('fs');
  6 +const path = require('path');
  7 +const os = require('os');
  8 +
  9 +const TEST_HOME = fs.mkdtempSync(path.join(os.tmpdir(), 'devloop-ct-'));
  10 +process.env.HOME = TEST_HOME;
  11 +
  12 +const pathResolver = require('../../../services/devloop/path-resolver');
  13 +const docStore = require('../../../services/devloop/doc-store');
  14 +const tools = require('../../../src/tools/devloop/coder-tools');
  15 +
  16 +function setupProject() {
  17 + pathResolver._resetForTesting();
  18 + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ct-proj-'));
  19 + const pid = 'p-codetest';
  20 + const reg = {
  21 + [pid]: { root_path: root, mode: 'linked-new', company_id: 'c-test' },
  22 + };
  23 + fs.mkdirSync(path.join(TEST_HOME, '.devloop'), { recursive: true });
  24 + fs.writeFileSync(
  25 + path.join(TEST_HOME, '.devloop', 'linked-projects.json'),
  26 + JSON.stringify(reg)
  27 + );
  28 + pathResolver._resetForTesting();
  29 + return { pid, root };
  30 +}
  31 +
  32 +describe('coder-tools', () => {
  33 + let env, ctx;
  34 + beforeEach(() => {
  35 + env = setupProject();
  36 + ctx = { pid: env.pid, agent: 'coder' };
  37 + });
  38 + afterEach(() => {
  39 + fs.rmSync(env.root, { recursive: true, force: true });
  40 + });
  41 +
  42 + describe('contract_bridge', () => {
  43 + function buildContract(exports) {
  44 + // hashContract uses contract.exports || contract, so version field stays stable
  45 + const c = { exports };
  46 + c.version = docStore.hashContract(c);
  47 + return c;
  48 + }
  49 +
  50 + it('returns ok when hash matches', async () => {
  51 + const unitId = 'u-be-auth';
  52 + const unitDir = path.join(env.root, 'units', unitId);
  53 + fs.mkdirSync(unitDir, { recursive: true });
  54 + const contract = buildContract({ http: [{ method: 'POST', path: '/api/login' }] });
  55 + fs.writeFileSync(
  56 + path.join(unitDir, 'contract.json'),
  57 + JSON.stringify(contract, null, 2)
  58 + );
  59 + fs.writeFileSync(
  60 + path.join(env.root, 'units.json'),
  61 + JSON.stringify({
  62 + units: [{ id: unitId, kind: 'be', contract_hash: contract.version, depends_on: [] }],
  63 + })
  64 + );
  65 + const r = await tools.contract_bridge.execute({ unit_id: unitId }, ctx);
  66 + expect(r.ok).toBe(true);
  67 + expect(r.hash).toBe(contract.version);
  68 + });
  69 +
  70 + it('flags hash mismatch (SDD-declared hash differs)', async () => {
  71 + const unitId = 'u-be-mis';
  72 + const unitDir = path.join(env.root, 'units', unitId);
  73 + fs.mkdirSync(unitDir, { recursive: true });
  74 + const contract = buildContract({ http: [{ method: 'GET', path: '/api/x' }] });
  75 + fs.writeFileSync(
  76 + path.join(unitDir, 'contract.json'),
  77 + JSON.stringify(contract)
  78 + );
  79 + fs.writeFileSync(
  80 + path.join(env.root, 'units.json'),
  81 + JSON.stringify({
  82 + units: [{ id: unitId, kind: 'be', contract_hash: 'sha256:deadbeefdeadbeef', depends_on: [] }],
  83 + })
  84 + );
  85 + const r = await tools.contract_bridge.execute({ unit_id: unitId }, ctx);
  86 + expect(r.ok).toBe(false);
  87 + expect(r.reason).toMatch(/does not match SDD/);
  88 + });
  89 +
  90 + it('returns ok=false when contract missing', async () => {
  91 + const r = await tools.contract_bridge.execute({ unit_id: 'u-nope' }, ctx);
  92 + expect(r.ok).toBe(false);
  93 + expect(r.reason).toMatch(/contract\.json missing/);
  94 + });
  95 + });
  96 +
  97 + describe('append_coder_log', () => {
  98 + it('writes attempt=1 entry without retry fields', async () => {
  99 + const r = await tools.append_coder_log.execute({
  100 + unit_id: 'u-foo', attempt: 1,
  101 + this_fix: 'init', rationale: 'first',
  102 + }, ctx);
  103 + expect(r.ok).toBe(true);
  104 + const content = fs.readFileSync(r.path, 'utf8');
  105 + expect(content).toMatch(/attempt: 1/);
  106 + expect(content).toMatch(/this_fix: init/);
  107 + });
  108 +
  109 + it('rejects retry without last_failure/last_cause', async () => {
  110 + await expect(tools.append_coder_log.execute({
  111 + unit_id: 'u-foo', attempt: 2,
  112 + this_fix: 'try again', rationale: 'hmm',
  113 + }, ctx)).rejects.toThrow(/last_failure.*last_cause/);
  114 + });
  115 +
  116 + it('accepts retry with required fields', async () => {
  117 + const r = await tools.append_coder_log.execute({
  118 + unit_id: 'u-foo', attempt: 2,
  119 + last_failure: 'auth returns 500',
  120 + last_cause: 'user undefined before bcrypt.compare',
  121 + this_fix: 'add user null check',
  122 + rationale: 'guard against missing user',
  123 + }, ctx);
  124 + expect(r.ok).toBe(true);
  125 + expect(r.attempt).toBe(2);
  126 + });
  127 + });
  128 +
  129 + describe('sandbox_check', () => {
  130 + it('returns ok when no out-of-sandbox writes', async () => {
  131 + const unitId = 'u-x';
  132 + const unitDir = path.join(env.root, 'units', unitId, 'code');
  133 + fs.mkdirSync(unitDir, { recursive: true });
  134 + const since = Date.now() - 10_000;
  135 + fs.writeFileSync(path.join(unitDir, 'a.js'), 'console.log(1);');
  136 + const r = await tools.sandbox_check.execute({ unit_id: unitId, since }, ctx);
  137 + expect(r.ok).toBe(true);
  138 + expect(r.violations).toEqual([]);
  139 + });
  140 +
  141 + it('flags writes outside sandbox', async () => {
  142 + const unitId = 'u-y';
  143 + fs.mkdirSync(path.join(env.root, 'units', unitId, 'code'), { recursive: true });
  144 + const since = Date.now() - 1_000;
  145 + // 写一个非沙箱目录文件
  146 + const badPath = path.join(env.root, 'rogue', 'evil.js');
  147 + fs.mkdirSync(path.dirname(badPath), { recursive: true });
  148 + fs.writeFileSync(badPath, 'oops');
  149 + const r = await tools.sandbox_check.execute({ unit_id: unitId, since }, ctx);
  150 + expect(r.ok).toBe(false);
  151 + expect(r.violations.length).toBeGreaterThan(0);
  152 + });
  153 + });
  154 +});
server/tests/devloop/tools/doc-tools.test.js 0 → 100644
  1 +/**
  2 + * doc-tools 单测 (PM + Architect)
  3 + */
  4 +
  5 +const fs = require('fs');
  6 +const path = require('path');
  7 +const os = require('os');
  8 +
  9 +const TEST_HOME = fs.mkdtempSync(path.join(os.tmpdir(), 'devloop-dt-'));
  10 +process.env.HOME = TEST_HOME;
  11 +
  12 +const pathResolver = require('../../../services/devloop/path-resolver');
  13 +const docStore = require('../../../services/devloop/doc-store');
  14 +const tools = require('../../../src/tools/devloop/doc-tools');
  15 +
  16 +function setupProject() {
  17 + pathResolver._resetForTesting();
  18 + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'dt-proj-'));
  19 + const pid = 'p-doctest';
  20 + const reg = {
  21 + [pid]: { root_path: root, mode: 'linked-new', company_id: 'c-test' },
  22 + };
  23 + fs.mkdirSync(path.join(TEST_HOME, '.devloop'), { recursive: true });
  24 + fs.writeFileSync(
  25 + path.join(TEST_HOME, '.devloop', 'linked-projects.json'),
  26 + JSON.stringify(reg)
  27 + );
  28 + pathResolver._resetForTesting();
  29 + return { pid, root };
  30 +}
  31 +
  32 +const VALID_PRD_HTML = `
  33 +<!doctype html>
  34 +<html><head><title>Scope</title></head><body>
  35 +<article data-doc="prd" data-rev="1">
  36 + <section data-rev="1">
  37 + <h3>S1</h3><p>desc</p>
  38 + <table><tr><td>T</td></tr></table>
  39 + <svg></svg>
  40 + <pre>code block</pre>
  41 + <div class="card">card</div>
  42 + </section>
  43 +</article>
  44 +</body></html>`;
  45 +
  46 +const VALID_SDD_HTML = `
  47 +<!doctype html>
  48 +<html><head><title>Model</title></head><body>
  49 +<article data-doc="sdd" data-rev="1">
  50 + <section data-rev="1"><h3>M-1</h3><p>x</p></section>
  51 +</article>
  52 +</body></html>`;
  53 +
  54 +describe('doc-tools', () => {
  55 + let env;
  56 + let ctx;
  57 + beforeEach(() => {
  58 + env = setupProject();
  59 + ctx = { pid: env.pid, agent: 'pm' };
  60 + });
  61 + afterEach(() => {
  62 + fs.rmSync(env.root, { recursive: true, force: true });
  63 + });
  64 +
  65 + describe('bootstrap_prd_site / bootstrap_sdd_site', () => {
  66 + it('creates prd site skeleton', async () => {
  67 + const r = await tools.bootstrap_prd_site.execute({}, ctx);
  68 + expect(r.ok).toBe(true);
  69 + expect(fs.existsSync(path.join(env.root, 'prd', 'product'))).toBe(true);
  70 + expect(fs.existsSync(path.join(env.root, 'prd', 'assets', 'shell.css'))).toBe(true);
  71 + expect(fs.existsSync(path.join(env.root, 'prd', 'assets', 'search-index.json'))).toBe(true);
  72 + });
  73 +
  74 + it('creates sdd site skeleton with mvcp/', async () => {
  75 + const r = await tools.bootstrap_sdd_site.execute({}, ctx);
  76 + expect(r.ok).toBe(true);
  77 + expect(fs.existsSync(path.join(env.root, 'sdd', 'mvcp'))).toBe(true);
  78 + });
  79 + });
  80 +
  81 + describe('write_chapter', () => {
  82 + it('writes valid PRD chapter', async () => {
  83 + await tools.bootstrap_prd_site.execute({}, ctx);
  84 + const r = await tools.write_chapter.execute({
  85 + kind: 'prd', relPath: 'product/scope.html', html: VALID_PRD_HTML,
  86 + }, ctx);
  87 + expect(r.ok).toBe(true);
  88 + expect(fs.existsSync(path.join(env.root, 'prd', 'product', 'scope.html'))).toBe(true);
  89 + });
  90 +
  91 + it('rejects invalid HTML', async () => {
  92 + await tools.bootstrap_prd_site.execute({}, ctx);
  93 + await expect(tools.write_chapter.execute({
  94 + kind: 'prd', relPath: 'product/scope.html', html: '<p>no article tag</p>',
  95 + }, ctx)).rejects.toThrow();
  96 + });
  97 + });
  98 +
  99 + describe('write_mvcp_page', () => {
  100 + it('writes sdd/mvcp/model.html', async () => {
  101 + await tools.bootstrap_sdd_site.execute({}, ctx);
  102 + const r = await tools.write_mvcp_page.execute({
  103 + which: 'model', html: VALID_SDD_HTML,
  104 + }, ctx);
  105 + expect(r.ok).toBe(true);
  106 + expect(fs.existsSync(path.join(env.root, 'sdd', 'mvcp', 'model.html'))).toBe(true);
  107 + });
  108 + });
  109 +
  110 + describe('diff_html', () => {
  111 + it('detects added/changed/removed sections', async () => {
  112 + const oldHtml = `<article><section data-rev="1">A</section><section data-rev="2">B</section></article>`;
  113 + const newHtml = `<article><section data-rev="1">A</section><section data-rev="2">B-changed</section><section data-rev="3">C</section></article>`;
  114 + const r = await tools.diff_html.execute({ oldHtml, newHtml }, ctx);
  115 + expect(r.added).toEqual(['3']);
  116 + expect(r.changed).toEqual(['2']);
  117 + expect(r.removed).toEqual([]);
  118 + });
  119 + });
  120 +
  121 + describe('diff_units', () => {
  122 + it('detects added unit', async () => {
  123 + const r = await tools.diff_units.execute({
  124 + newUnits: [
  125 + { id: 'u-a', contract_hash: 'h1' },
  126 + { id: 'u-b', contract_hash: 'h2' },
  127 + ],
  128 + oldUnits: [{ id: 'u-a', contract_hash: 'h1' }],
  129 + }, ctx);
  130 + expect(r.added).toContain('u-b');
  131 + });
  132 +
  133 + it('detects changed unit (contract_hash diff)', async () => {
  134 + const r = await tools.diff_units.execute({
  135 + newUnits: [{ id: 'u-a', contract_hash: 'h2' }],
  136 + oldUnits: [{ id: 'u-a', contract_hash: 'h1' }],
  137 + }, ctx);
  138 + expect(r.changed).toContain('u-a');
  139 + });
  140 + });
  141 +
  142 + describe('echo_diff', () => {
  143 + it('emits SSE prd.updated', async () => {
  144 + const events = [];
  145 + ctx.sseBroker = { emit: (pid, k, p) => events.push({ pid, k, p }) };
  146 + const r = await tools.echo_diff.execute({
  147 + kind: 'prd', rev: 2, summary: 'scope rev 2', sections_diff: { added: ['s2'] },
  148 + }, ctx);
  149 + expect(r.ok).toBe(true);
  150 + expect(events).toHaveLength(1);
  151 + expect(events[0].k).toBe('prd.updated');
  152 + });
  153 + });
  154 +
  155 + describe('update_search_index', () => {
  156 + it('builds prd index after writes', async () => {
  157 + await tools.bootstrap_prd_site.execute({}, ctx);
  158 + await tools.write_chapter.execute({
  159 + kind: 'prd', relPath: 'product/scope.html', html: VALID_PRD_HTML,
  160 + }, ctx);
  161 + const r = await tools.update_search_index.execute({ target: 'prd' }, ctx);
  162 + expect(r.ok).toBe(true);
  163 + expect(r.built[0].pages).toBeGreaterThan(0);
  164 + });
  165 + });
  166 +
  167 + describe('hash_contract', () => {
  168 + it('returns sha256-prefixed 16-hex hash', async () => {
  169 + const r = await tools.hash_contract.execute({
  170 + contract: { name: 'login', method: 'POST', path: '/api/login' },
  171 + }, ctx);
  172 + expect(r.hash).toMatch(/^sha256:[a-f0-9]{16}$/);
  173 + });
  174 +
  175 + it('is deterministic for canonical-equivalent contracts', async () => {
  176 + const a = await tools.hash_contract.execute({
  177 + contract: { a: 1, b: 2 },
  178 + }, ctx);
  179 + const b = await tools.hash_contract.execute({
  180 + contract: { b: 2, a: 1 },
  181 + }, ctx);
  182 + expect(a.hash).toBe(b.hash);
  183 + });
  184 + });
  185 +
  186 + describe('validate_prd_site', () => {
  187 + it('flags missing required chapters', async () => {
  188 + await tools.bootstrap_prd_site.execute({}, ctx);
  189 + const r = await tools.validate_prd_site.execute({}, ctx);
  190 + expect(r.ok).toBe(false);
  191 + expect(r.issues.some(i => i.includes('missing chapter'))).toBe(true);
  192 + });
  193 + });
  194 +});
server/tests/devloop/tools/integrator-tools.test.js 0 → 100644
  1 +/**
  2 + * integrator-tools 单测
  3 + */
  4 +
  5 +const fs = require('fs');
  6 +const path = require('path');
  7 +const os = require('os');
  8 +const http = require('http');
  9 +
  10 +const TEST_HOME = fs.mkdtempSync(path.join(os.tmpdir(), 'devloop-it-'));
  11 +process.env.HOME = TEST_HOME;
  12 +
  13 +const pathResolver = require('../../../services/devloop/path-resolver');
  14 +const tools = require('../../../src/tools/devloop/integrator-tools');
  15 +
  16 +function setupProject() {
  17 + pathResolver._resetForTesting();
  18 + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'it-proj-'));
  19 + const pid = 'p-integtest';
  20 + const reg = {
  21 + [pid]: { root_path: root, mode: 'linked-new', company_id: 'c-test' },
  22 + };
  23 + fs.mkdirSync(path.join(TEST_HOME, '.devloop'), { recursive: true });
  24 + fs.writeFileSync(
  25 + path.join(TEST_HOME, '.devloop', 'linked-projects.json'),
  26 + JSON.stringify(reg)
  27 + );
  28 + pathResolver._resetForTesting();
  29 + return { pid, root };
  30 +}
  31 +
  32 +describe('integrator-tools', () => {
  33 + let env, ctx;
  34 + beforeEach(() => {
  35 + env = setupProject();
  36 + ctx = { pid: env.pid, agent: 'integrator' };
  37 + });
  38 + afterEach(() => {
  39 + fs.rmSync(env.root, { recursive: true, force: true });
  40 + });
  41 +
  42 + describe('port_picker', () => {
  43 + it('allocates the requested number of free ports', async () => {
  44 + const r = await tools.port_picker.execute({ count: 3 }, ctx);
  45 + expect(r.ok).toBe(true);
  46 + expect(r.ports).toHaveLength(3);
  47 + for (const p of r.ports) {
  48 + expect(p).toBeGreaterThanOrEqual(18000);
  49 + expect(p).toBeLessThan(19000);
  50 + }
  51 + expect(new Set(r.ports).size).toBe(3); // unique
  52 + });
  53 + });
  54 +
  55 + describe('parse_placeholder', () => {
  56 + it('resolves {{step0.body.field}} from prev results', async () => {
  57 + const r = await tools.parse_placeholder.execute({
  58 + template: { token: '{{step0.token}}' },
  59 + previous_steps: [{ body: { token: 'abc123' } }],
  60 + }, ctx);
  61 + expect(r.resolved.token).toBe('abc123');
  62 + });
  63 +
  64 + it('throws when step ref missing', async () => {
  65 + await expect(tools.parse_placeholder.execute({
  66 + template: { x: '{{step5.foo}}' },
  67 + previous_steps: [],
  68 + }, ctx)).rejects.toThrow(/placeholder step5/);
  69 + });
  70 + });
  71 +
  72 + describe('health_wait', () => {
  73 + it('returns ok=true when server responds 200', async () => {
  74 + const srv = http.createServer((_req, res) => {
  75 + res.statusCode = 200; res.end('ok');
  76 + });
  77 + await new Promise(r => srv.listen(0, '127.0.0.1', r));
  78 + const port = srv.address().port;
  79 +
  80 + const r = await tools.health_wait.execute({
  81 + url: `http://127.0.0.1:${port}/api/health`,
  82 + timeout_ms: 2000, interval_ms: 50,
  83 + }, ctx);
  84 + srv.close();
  85 + expect(r.ok).toBe(true);
  86 + expect(r.status).toBe(200);
  87 + });
  88 +
  89 + it('returns ok=false on timeout', async () => {
  90 + const r = await tools.health_wait.execute({
  91 + url: 'http://127.0.0.1:1/never',
  92 + timeout_ms: 300, interval_ms: 100,
  93 + }, ctx);
  94 + expect(r.ok).toBe(false);
  95 + expect(r.timeout).toBe(true);
  96 + });
  97 + });
  98 +
  99 + describe('collect_stderr', () => {
  100 + it('returns last N lines from log', async () => {
  101 + const p = path.join(env.root, 'process.log');
  102 + fs.writeFileSync(p, ['l1','l2','l3','l4','l5'].join('\n'));
  103 + const r = await tools.collect_stderr.execute({ log_path: p, lines: 2 }, ctx);
  104 + expect(r.ok).toBe(true);
  105 + expect(r.excerpt).toBe('l4\nl5');
  106 + });
  107 +
  108 + it('returns ok=false when log missing', async () => {
  109 + const r = await tools.collect_stderr.execute({
  110 + log_path: path.join(env.root, 'nope.log'),
  111 + }, ctx);
  112 + expect(r.ok).toBe(false);
  113 + });
  114 + });
  115 +
  116 + describe('run_case', () => {
  117 + it('executes a single GET case against a test server', async () => {
  118 + const srv = http.createServer((req, res) => {
  119 + if (req.url === '/api/ping') {
  120 + res.setHeader('Content-Type', 'application/json');
  121 + res.end(JSON.stringify({ pong: true }));
  122 + return;
  123 + }
  124 + res.statusCode = 404; res.end();
  125 + });
  126 + await new Promise(r => srv.listen(0, '127.0.0.1', r));
  127 + const port = srv.address().port;
  128 +
  129 + const r = await tools.run_case.execute({
  130 + case_spec: {
  131 + steps: [
  132 + { unit: 'u-x', call: 'GET /api/ping', expect: { status: 200 } },
  133 + ],
  134 + },
  135 + ports: { 'u-x': port },
  136 + }, ctx);
  137 + srv.close();
  138 + expect(r.ok).toBe(true);
  139 + expect(r.steps_ran).toBe(1);
  140 + });
  141 +
  142 + it('reports failed step when status mismatches', async () => {
  143 + const srv = http.createServer((_req, res) => {
  144 + res.statusCode = 500; res.end('{}');
  145 + });
  146 + await new Promise(r => srv.listen(0, '127.0.0.1', r));
  147 + const port = srv.address().port;
  148 + const r = await tools.run_case.execute({
  149 + case_spec: {
  150 + steps: [{ unit: 'u-x', call: 'GET /api/x', expect: { status: 200 } }],
  151 + },
  152 + ports: { 'u-x': port },
  153 + }, ctx);
  154 + srv.close();
  155 + expect(r.ok).toBe(false);
  156 + expect(r.failed_step).toBe(0);
  157 + expect(r.got.status).toBe(500);
  158 + });
  159 + });
  160 +});
server/tests/devloop/tools/persistence-tools.test.js 0 → 100644
  1 +/**
  2 + * persistence-tools 单测
  3 + *
  4 + * 覆盖 5 个工具:
  5 + * write_todolist / update_todo_item / update_runtime_state /
  6 + * timeline_append / record_memory
  7 + */
  8 +
  9 +const fs = require('fs');
  10 +const path = require('path');
  11 +const os = require('os');
  12 +
  13 +const TEST_HOME = fs.mkdtempSync(path.join(os.tmpdir(), 'devloop-pt-'));
  14 +process.env.HOME = TEST_HOME;
  15 +
  16 +const pathResolver = require('../../../services/devloop/path-resolver');
  17 +const tools = require('../../../src/tools/devloop/persistence-tools');
  18 +
  19 +function setupProject() {
  20 + pathResolver._resetForTesting();
  21 + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'pt-proj-'));
  22 + const pid = 'p-test01';
  23 + // 注入 cache (绕过 register)
  24 + pathResolver.init();
  25 + // hack: 直接写 project.json + 让 init 重扫不可行,改为构造 linked 注册表
  26 + const reg = {
  27 + [pid]: { root_path: root, mode: 'linked-new', company_id: 'c-test' },
  28 + };
  29 + fs.mkdirSync(path.join(TEST_HOME, '.devloop'), { recursive: true });
  30 + fs.writeFileSync(
  31 + path.join(TEST_HOME, '.devloop', 'linked-projects.json'),
  32 + JSON.stringify(reg)
  33 + );
  34 + pathResolver._resetForTesting();
  35 + return { pid, root, company_id: 'c-test' };
  36 +}
  37 +
  38 +describe('persistence-tools', () => {
  39 + let ctx;
  40 + let env;
  41 +
  42 + beforeEach(() => {
  43 + env = setupProject();
  44 + ctx = {
  45 + pid: env.pid,
  46 + agent: 'pm',
  47 + call_id: 'call-1',
  48 + company_id: env.company_id,
  49 + };
  50 + });
  51 +
  52 + afterEach(() => {
  53 + fs.rmSync(env.root, { recursive: true, force: true });
  54 + });
  55 +
  56 + describe('write_todolist', () => {
  57 + it('writes initial todolist with first item doing', async () => {
  58 + const r = await tools.write_todolist.execute({
  59 + items: [
  60 + { id: 'step-001', description: 'first step' },
  61 + { id: 'step-002', description: 'second step' },
  62 + ],
  63 + }, ctx);
  64 + expect(r.ok).toBe(true);
  65 + expect(r.total).toBe(2);
  66 + const written = JSON.parse(fs.readFileSync(r.path, 'utf8'));
  67 + expect(written.items[0].status).toBe('doing');
  68 + expect(written.items[1].status).toBe('todo');
  69 + expect(written.current).toBe('step-001');
  70 + expect(written.progress_pct).toBe(0);
  71 + });
  72 +
  73 + it('requires call_id in ctx', async () => {
  74 + await expect(tools.write_todolist.execute({
  75 + items: [{ id: 'step-001', description: 'x' }],
  76 + }, { pid: env.pid, agent: 'pm' })).rejects.toThrow(/call_id/);
  77 + });
  78 +
  79 + it('emits SSE when broker present', async () => {
  80 + const emitted = [];
  81 + ctx.sseBroker = { emit: (pid, kind, p) => emitted.push({ pid, kind, p }) };
  82 + await tools.write_todolist.execute({
  83 + items: [{ id: 'step-001', description: 'x' }],
  84 + }, ctx);
  85 + expect(emitted).toHaveLength(1);
  86 + expect(emitted[0].kind).toBe('agent.todolist');
  87 + });
  88 + });
  89 +
  90 + describe('update_todo_item', () => {
  91 + beforeEach(async () => {
  92 + await tools.write_todolist.execute({
  93 + items: [
  94 + { id: 'step-001', description: 'a' },
  95 + { id: 'step-002', description: 'b' },
  96 + ],
  97 + }, ctx);
  98 + });
  99 +
  100 + it('transitions doing → done and advances current to next', async () => {
  101 + const r = await tools.update_todo_item.execute({
  102 + id: 'step-001', status: 'done', output_ref: 'units.json',
  103 + }, ctx);
  104 + expect(r.ok).toBe(true);
  105 + expect(r.progress_pct).toBe(50);
  106 + const p = pathResolver.resolveAgentTodolist(env.pid, 'pm');
  107 + const tl = JSON.parse(fs.readFileSync(p, 'utf8'));
  108 + expect(tl.items[0].status).toBe('done');
  109 + expect(tl.items[0].output_ref).toBe('units.json');
  110 + expect(tl.items[1].status).toBe('doing');
  111 + expect(tl.current).toBe('step-002');
  112 + });
  113 +
  114 + it('rejects unknown step id', async () => {
  115 + await expect(tools.update_todo_item.execute({
  116 + id: 'step-999', status: 'done',
  117 + }, ctx)).rejects.toThrow(/unknown step/);
  118 + });
  119 + });
  120 +
  121 + describe('timeline_append', () => {
  122 + it('appends an event to timeline.ndjson', async () => {
  123 + const r = await tools.timeline_append.execute({
  124 + kind: 'doc.updated',
  125 + summary: 'PRD scope rev 2',
  126 + ref: 'prd/scope.html',
  127 + }, ctx);
  128 + expect(r.ok).toBe(true);
  129 + expect(r.ts).toBeTruthy();
  130 + const p = pathResolver.resolveTimelineLog(env.pid);
  131 + const content = fs.readFileSync(p, 'utf8').trim().split('\n');
  132 + const lastEvent = JSON.parse(content[content.length - 1]);
  133 + expect(lastEvent.kind).toBe('doc.updated');
  134 + expect(lastEvent.source).toBe('pm');
  135 + });
  136 + });
  137 +
  138 + describe('record_memory', () => {
  139 + let uniqueAgent;
  140 + beforeEach(() => {
  141 + // memory dir is under DATA_DIR/companies/<cid>/memory — not isolated by env.root.
  142 + // Use a unique agent per test to avoid cross-test pollution; cleanup after.
  143 + uniqueAgent = 'test-mem-' + Math.random().toString(36).slice(2, 8);
  144 + ctx.agent = uniqueAgent;
  145 + });
  146 + afterEach(() => {
  147 + const dir = pathResolver.resolveMemoryDir(env.company_id, uniqueAgent);
  148 + fs.rmSync(dir, { recursive: true, force: true });
  149 + });
  150 +
  151 + it('writes memory .md with frontmatter (new)', async () => {
  152 + const r = await tools.record_memory.execute({
  153 + topic: 'jwt-auth-points',
  154 + content: 'use jose lib with ES256 signing alg',
  155 + relevance: 0.9,
  156 + }, ctx);
  157 + expect(r.ok).toBe(true);
  158 + expect(r.upserted).toBe(false);
  159 + expect(r.hits).toBe(1);
  160 + const content = fs.readFileSync(r.path, 'utf8');
  161 + expect(content).toMatch(/relevance_score: 0\.9/);
  162 + expect(content).toMatch(/jose lib/);
  163 + });
  164 +
  165 + it('upserts on same topic with hits++', async () => {
  166 + await tools.record_memory.execute({
  167 + topic: 'jwt-auth', content: 'first version content here', relevance: 0.5,
  168 + }, ctx);
  169 + const r2 = await tools.record_memory.execute({
  170 + topic: 'jwt-auth', content: 'updated version content here', relevance: 0.7,
  171 + }, ctx);
  172 + expect(r2.upserted).toBe(true);
  173 + expect(r2.hits).toBe(2);
  174 + });
  175 + });
  176 +});
server/tests/devloop/tools/sandbox-and-errors.test.js 0 → 100644
  1 +/**
  2 + * _sandbox + _errors 单测
  3 + */
  4 +
  5 +const path = require('path');
  6 +const {
  7 + ToolValidationError,
  8 + ToolSandboxError,
  9 + ToolMissingDependencyError,
  10 +} = require('../../../src/tools/devloop/_errors');
  11 +const sandbox = require('../../../src/tools/devloop/_sandbox');
  12 +
  13 +describe('errors', () => {
  14 + it('ToolValidationError carries field + reason', () => {
  15 + const e = new ToolValidationError('html', 'too short');
  16 + expect(e.name).toBe('ToolValidationError');
  17 + expect(e.field).toBe('html');
  18 + expect(e.reason).toBe('too short');
  19 + expect(e.message).toContain('too short');
  20 + });
  21 +
  22 + it('ToolSandboxError reports agent + path', () => {
  23 + const e = new ToolSandboxError('/etc/passwd', 'coder');
  24 + expect(e.name).toBe('ToolSandboxError');
  25 + expect(e.path).toBe('/etc/passwd');
  26 + expect(e.agent).toBe('coder');
  27 + });
  28 +
  29 + it('ToolMissingDependencyError names missing dep', () => {
  30 + const e = new ToolMissingDependencyError('ctx.pid');
  31 + expect(e.message).toMatch(/ctx\.pid/);
  32 + });
  33 +});
  34 +
  35 +describe('_sandbox', () => {
  36 + const ctx = {
  37 + agent: 'coder',
  38 + projectDir: '/tmp/my-proj',
  39 + unit_id: 'u-be-auth',
  40 + };
  41 +
  42 + it('matches exact file pattern', () => {
  43 + expect(sandbox.matchesPattern(
  44 + '/tmp/my-proj/timeline.ndjson', 'timeline.ndjson', ctx
  45 + )).toBe(true);
  46 + });
  47 +
  48 + it('matches /** glob prefix', () => {
  49 + expect(sandbox.matchesPattern(
  50 + '/tmp/my-proj/prd/feature/login.html', 'prd/**', ctx
  51 + )).toBe(true);
  52 + });
  53 +
  54 + it('matches /** root itself', () => {
  55 + expect(sandbox.matchesPattern(
  56 + '/tmp/my-proj/prd', 'prd/**', ctx
  57 + )).toBe(true);
  58 + });
  59 +
  60 + it('substitutes {unitId} placeholder', () => {
  61 + expect(sandbox.matchesPattern(
  62 + '/tmp/my-proj/units/u-be-auth/code/server.js',
  63 + 'units/{unitId}/code/**',
  64 + ctx
  65 + )).toBe(true);
  66 + });
  67 +
  68 + it('rejects paths outside projectDir', () => {
  69 + expect(sandbox.matchesPattern(
  70 + '/etc/passwd', 'prd/**', ctx
  71 + )).toBe(false);
  72 + });
  73 +
  74 + it('isWithinAllowList allows when any pattern matches', () => {
  75 + expect(sandbox.isWithinAllowList(
  76 + '/tmp/my-proj/units/u-be-auth/contract.json',
  77 + ['prd/**', 'units/{unitId}/contract.json'],
  78 + ctx
  79 + )).toBe(true);
  80 + });
  81 +
  82 + it('assertWritable throws ToolSandboxError on violation', () => {
  83 + expect(() => sandbox.assertWritable(
  84 + '/etc/passwd', ['prd/**'], ctx
  85 + )).toThrow(ToolSandboxError);
  86 + });
  87 +
  88 + it('assertWritable is silent on allowed path', () => {
  89 + expect(() => sandbox.assertWritable(
  90 + '/tmp/my-proj/prd/index.html', ['prd/**'], ctx
  91 + )).not.toThrow();
  92 + });
  93 +});
server/tests/devloop/tools/tester-tools.test.js 0 → 100644
  1 +/**
  2 + * tester-tools 单测
  3 + */
  4 +
  5 +const fs = require('fs');
  6 +const path = require('path');
  7 +const os = require('os');
  8 +
  9 +const TEST_HOME = fs.mkdtempSync(path.join(os.tmpdir(), 'devloop-tt-'));
  10 +process.env.HOME = TEST_HOME;
  11 +
  12 +const pathResolver = require('../../../services/devloop/path-resolver');
  13 +const tools = require('../../../src/tools/devloop/tester-tools');
  14 +
  15 +function setupProject() {
  16 + pathResolver._resetForTesting();
  17 + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'tt-proj-'));
  18 + const pid = 'p-testertest';
  19 + const reg = {
  20 + [pid]: { root_path: root, mode: 'linked-new', company_id: 'c-test' },
  21 + };
  22 + fs.mkdirSync(path.join(TEST_HOME, '.devloop'), { recursive: true });
  23 + fs.writeFileSync(
  24 + path.join(TEST_HOME, '.devloop', 'linked-projects.json'),
  25 + JSON.stringify(reg)
  26 + );
  27 + pathResolver._resetForTesting();
  28 + return { pid, root };
  29 +}
  30 +
  31 +describe('tester-tools', () => {
  32 + let env, ctx;
  33 + beforeEach(() => {
  34 + env = setupProject();
  35 + ctx = { pid: env.pid, agent: 'tester' };
  36 + });
  37 + afterEach(() => {
  38 + fs.rmSync(env.root, { recursive: true, force: true });
  39 + });
  40 +
  41 + describe('hint_builder', () => {
  42 + it('hints on TypeError compare (bcrypt-like)', async () => {
  43 + const r = await tools.hint_builder.execute({
  44 + stack: 'TypeError: Illegal arguments: undefined,string compare',
  45 + }, ctx);
  46 + expect(r.hint).toMatch(/bcrypt\.compare/);
  47 + });
  48 +
  49 + it('hints on 401 expected vs 500 got', async () => {
  50 + const r = await tools.hint_builder.execute({
  51 + expected: 401, got: 500, stack: '',
  52 + }, ctx);
  53 + expect(r.hint).toMatch(/401/);
  54 + });
  55 +
  56 + it('parses status code from stack', async () => {
  57 + const r = await tools.hint_builder.execute({
  58 + stack: 'Expected: 200\nReceived: 404',
  59 + }, ctx);
  60 + expect(r.hint).toMatch(/200/);
  61 + });
  62 + });
  63 +
  64 + describe('parse_vitest', () => {
  65 + it('returns crashed when raw json missing', async () => {
  66 + const r = await tools.parse_vitest.execute({
  67 + raw_json_path: path.join(env.root, 'nope.json'),
  68 + }, ctx);
  69 + expect(r.ok).toBe(false);
  70 + expect(r.crashed).toBe(true);
  71 + });
  72 +
  73 + it('parses passing vitest output', async () => {
  74 + const rawPath = path.join(env.root, 'raw.json');
  75 + fs.writeFileSync(rawPath, JSON.stringify({
  76 + testResults: [{
  77 + assertionResults: [
  78 + { status: 'passed', fullName: 'a' },
  79 + { status: 'passed', fullName: 'b' },
  80 + ],
  81 + }],
  82 + }));
  83 + const r = await tools.parse_vitest.execute({ raw_json_path: rawPath }, ctx);
  84 + expect(r.ok).toBe(true);
  85 + expect(r.summary.passed).toBe(2);
  86 + expect(r.failures).toHaveLength(0);
  87 + });
  88 +
  89 + it('extracts failure with hint', async () => {
  90 + const rawPath = path.join(env.root, 'raw2.json');
  91 + fs.writeFileSync(rawPath, JSON.stringify({
  92 + testResults: [{
  93 + assertionResults: [{
  94 + status: 'failed', fullName: 'login returns 401',
  95 + failureMessages: ['Expected: 401\nReceived: 500\nat handler'],
  96 + }],
  97 + }],
  98 + }));
  99 + const r = await tools.parse_vitest.execute({ raw_json_path: rawPath }, ctx);
  100 + expect(r.ok).toBe(false);
  101 + expect(r.summary.failed).toBe(1);
  102 + expect(r.failures[0].hint).toMatch(/401/);
  103 + });
  104 +
  105 + it('writes result.json when result_out_path given', async () => {
  106 + const rawPath = path.join(env.root, 'raw3.json');
  107 + fs.writeFileSync(rawPath, JSON.stringify({
  108 + testResults: [{ assertionResults: [{ status: 'passed', fullName: 't' }] }],
  109 + }));
  110 + const out = path.join(env.root, 'result.json');
  111 + await tools.parse_vitest.execute({
  112 + raw_json_path: rawPath, result_out_path: out,
  113 + }, ctx);
  114 + expect(fs.existsSync(out)).toBe(true);
  115 + });
  116 + });
  117 +
  118 + describe('bug_open / bug_list / bug_transition', () => {
  119 + it('opens a bug then lists it', async () => {
  120 + const r = await tools.bug_open.execute({
  121 + title: 'login 500 instead of 401',
  122 + unit_id: 'u-be-auth',
  123 + test_name: 'login wrong password',
  124 + severity: 'high',
  125 + }, ctx);
  126 + expect(r.ok).toBe(true);
  127 + expect(r.bug_id).toBeTruthy();
  128 +
  129 + const ls = await tools.bug_list.execute({ unit_id: 'u-be-auth' }, ctx);
  130 + expect(ls.total).toBeGreaterThanOrEqual(1);
  131 + });
  132 +
  133 + it('transitions bug open → fixing', async () => {
  134 + const opened = await tools.bug_open.execute({
  135 + title: 'x', unit_id: 'u-a', test_name: 't',
  136 + }, ctx);
  137 + const r = await tools.bug_transition.execute({
  138 + bug_id: opened.bug_id, to: 'fixing', note: 'coder picked up',
  139 + }, ctx);
  140 + expect(r.ok).toBe(true);
  141 + });
  142 +
  143 + it('rejects invalid bug transition', async () => {
  144 + const opened = await tools.bug_open.execute({
  145 + title: 'y', unit_id: 'u-b', test_name: 't',
  146 + }, ctx);
  147 + await expect(tools.bug_transition.execute({
  148 + bug_id: opened.bug_id, to: 'verified',
  149 + }, ctx)).rejects.toThrow();
  150 + });
  151 + });
  152 +});
server/tests/devloop/tools/tool-registry.test.js 0 → 100644
  1 +/**
  2 + * tool-registry 单测
  3 + */
  4 +
  5 +const registry = require('../../../src/tools/devloop/tool-registry');
  6 +
  7 +describe('tool-registry', () => {
  8 + beforeEach(() => registry._resetForTesting());
  9 +
  10 + it('loadAllTools returns a Map with unique tool names', () => {
  11 + const all = registry.loadAllTools();
  12 + expect(all instanceof Map).toBe(true);
  13 + expect(all.size).toBeGreaterThanOrEqual(30);
  14 + for (const [name, tool] of all) {
  15 + expect(typeof name).toBe('string');
  16 + expect(tool.name).toBe(name);
  17 + expect(typeof tool.execute).toBe('function');
  18 + }
  19 + });
  20 +
  21 + it('listToolNames includes core 5 persistence tools', () => {
  22 + const names = registry.listToolNames();
  23 + expect(names).toContain('write_todolist');
  24 + expect(names).toContain('update_todo_item');
  25 + expect(names).toContain('timeline_append');
  26 + expect(names).toContain('record_memory');
  27 + expect(names).toContain('update_runtime_state');
  28 + });
  29 +
  30 + it('listToolNames includes PM/Architect doc tools', () => {
  31 + const names = registry.listToolNames();
  32 + expect(names).toContain('write_chapter');
  33 + expect(names).toContain('write_mvcp_page');
  34 + expect(names).toContain('hash_contract');
  35 + expect(names).toContain('validate_prd_site');
  36 + });
  37 +
  38 + it('listToolNames includes Coder/Tester/Integrator/Triager tools', () => {
  39 + const names = registry.listToolNames();
  40 + expect(names).toContain('contract_bridge'); // coder
  41 + expect(names).toContain('parse_vitest'); // tester
  42 + expect(names).toContain('port_picker'); // integrator
  43 + expect(names).toContain('extract_evidence'); // triager
  44 + });
  45 +
  46 + it('getDevLoopTools filters by toolPolicy', () => {
  47 + const subset = registry.getDevLoopTools(['hash_contract', 'parse_vitest', 'nope']);
  48 + expect(subset).toHaveLength(2);
  49 + expect(subset.map(t => t.name).sort()).toEqual(['hash_contract', 'parse_vitest']);
  50 + });
  51 +
  52 + it('dispatch invokes a known tool', async () => {
  53 + const r = await registry.dispatch('hash_contract', {
  54 + contract: { name: 'login', method: 'POST' },
  55 + }, {});
  56 + expect(r.ok).toBe(true);
  57 + expect(r.hash).toMatch(/^sha256:[a-f0-9]{16}$/);
  58 + });
  59 +
  60 + it('dispatch rejects unknown tool', async () => {
  61 + await expect(registry.dispatch('does_not_exist', {}, {})).rejects.toThrow(/Unknown tool/);
  62 + });
  63 +
  64 + it('dispatch validates required fields', async () => {
  65 + await expect(registry.dispatch('hash_contract', {}, {})).rejects.toThrow(/missing required field "contract"/);
  66 + });
  67 +});
server/tests/devloop/tools/triager-tools.test.js 0 → 100644
  1 +/**
  2 + * triager-tools 单测
  3 + */
  4 +
  5 +const fs = require('fs');
  6 +const path = require('path');
  7 +const os = require('os');
  8 +
  9 +const TEST_HOME = fs.mkdtempSync(path.join(os.tmpdir(), 'devloop-tr-'));
  10 +process.env.HOME = TEST_HOME;
  11 +
  12 +const pathResolver = require('../../../services/devloop/path-resolver');
  13 +const docStore = require('../../../services/devloop/doc-store');
  14 +const tools = require('../../../src/tools/devloop/triager-tools');
  15 +
  16 +function setupProject() {
  17 + pathResolver._resetForTesting();
  18 + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'tr-proj-'));
  19 + const pid = 'p-triagertest';
  20 + const reg = {
  21 + [pid]: { root_path: root, mode: 'linked-new', company_id: 'c-test' },
  22 + };
  23 + fs.mkdirSync(path.join(TEST_HOME, '.devloop'), { recursive: true });
  24 + fs.writeFileSync(
  25 + path.join(TEST_HOME, '.devloop', 'linked-projects.json'),
  26 + JSON.stringify(reg)
  27 + );
  28 + pathResolver._resetForTesting();
  29 + return { pid, root };
  30 +}
  31 +
  32 +describe('triager-tools', () => {
  33 + let env, ctx;
  34 + beforeEach(() => {
  35 + env = setupProject();
  36 + ctx = { pid: env.pid, agent: 'triager' };
  37 + });
  38 + afterEach(() => {
  39 + fs.rmSync(env.root, { recursive: true, force: true });
  40 + });
  41 +
  42 + describe('extract_evidence', () => {
  43 + it('verifies a quote present in a file', async () => {
  44 + const target = path.join(env.root, 'unit-x.js');
  45 + fs.writeFileSync(target, 'const VAL = 42;\nfunction foo() { return VAL; }\n');
  46 + const r = await tools.extract_evidence.execute({
  47 + evidence: ['unit-x.js: function foo() { return VAL'],
  48 + }, ctx);
  49 + expect(r.ok).toBe(true);
  50 + expect(r.results[0].ok).toBe(true);
  51 + });
  52 +
  53 + it('rejects malformed evidence', async () => {
  54 + const r = await tools.extract_evidence.execute({
  55 + evidence: ['no-colon-no-quote'],
  56 + }, ctx);
  57 + expect(r.ok).toBe(false);
  58 + expect(r.failed[0].reason).toMatch(/malformed/);
  59 + });
  60 +
  61 + it('reports quote not found', async () => {
  62 + const target = path.join(env.root, 'file.js');
  63 + fs.writeFileSync(target, 'function bar() {}');
  64 + const r = await tools.extract_evidence.execute({
  65 + evidence: ['file.js: never-going-to-find-this'],
  66 + }, ctx);
  67 + expect(r.ok).toBe(false);
  68 + expect(r.failed[0].reason).toMatch(/not found/);
  69 + });
  70 + });
  71 +
  72 + describe('category_classifier', () => {
  73 + it('flags infra_flake on EADDRINUSE in process log', async () => {
  74 + const r = await tools.category_classifier.execute({
  75 + result_json: { failures: [] },
  76 + process_log: 'Error: listen EADDRINUSE 0.0.0.0:3000',
  77 + }, ctx);
  78 + expect(r.category).toBe('infra_flake');
  79 + });
  80 +
  81 + it('flags impl_bug on 500 vs 4xx', async () => {
  82 + const r = await tools.category_classifier.execute({
  83 + result_json: { failures: [{ got: { status: 500 }, expected: { status: 401 } }] },
  84 + process_log: '',
  85 + }, ctx);
  86 + expect(r.category).toBe('impl_bug');
  87 + });
  88 +
  89 + it('falls back to contract_mismatch with low confidence', async () => {
  90 + const r = await tools.category_classifier.execute({
  91 + result_json: { failures: [] },
  92 + process_log: '',
  93 + sdd_text: 'this sdd describes everything',
  94 + }, ctx);
  95 + expect(r.category).toBe('contract_mismatch');
  96 + expect(r.confidence).toBeLessThanOrEqual(0.5);
  97 + });
  98 + });
  99 +
  100 + describe('diff_contract_vs_impl', () => {
  101 + it('reports divergence when endpoint not in code', async () => {
  102 + const unitId = 'u-x';
  103 + const unitDir = path.join(env.root, 'units', unitId);
  104 + fs.mkdirSync(path.join(unitDir, 'code'), { recursive: true });
  105 + fs.writeFileSync(
  106 + path.join(unitDir, 'contract.json'),
  107 + JSON.stringify({
  108 + exports: { http: [{ method: 'POST', path: '/api/login' }] },
  109 + })
  110 + );
  111 + fs.writeFileSync(
  112 + path.join(unitDir, 'code', 'server.js'),
  113 + 'function nothing(){}'
  114 + );
  115 + const r = await tools.diff_contract_vs_impl.execute({ unit_id: unitId }, ctx);
  116 + expect(r.matches).toBe(false);
  117 + expect(r.divergences.some(d => d.includes('/api/login'))).toBe(true);
  118 + });
  119 +
  120 + it('matches when endpoint appears in code', async () => {
  121 + const unitId = 'u-y';
  122 + const unitDir = path.join(env.root, 'units', unitId);
  123 + fs.mkdirSync(path.join(unitDir, 'code'), { recursive: true });
  124 + fs.writeFileSync(
  125 + path.join(unitDir, 'contract.json'),
  126 + JSON.stringify({
  127 + exports: { http: [{ method: 'GET', path: '/api/ping' }] },
  128 + })
  129 + );
  130 + fs.writeFileSync(
  131 + path.join(unitDir, 'code', 'server.js'),
  132 + `app.get('/api/ping', (q,r) => r.send('pong'))`
  133 + );
  134 + const r = await tools.diff_contract_vs_impl.execute({ unit_id: unitId }, ctx);
  135 + expect(r.matches).toBe(true);
  136 + });
  137 + });
  138 +
  139 + describe('find_similar_bug', () => {
  140 + it('returns empty when no bugs.ndjson yet', async () => {
  141 + const r = await tools.find_similar_bug.execute({
  142 + signature: { unit_id: 'u-none', test_name: 't' },
  143 + }, ctx);
  144 + expect(r.ok).toBe(true);
  145 + expect(r.total).toBe(0);
  146 + });
  147 + });
  148 +});