/** * DevLoop · coder-tools (3 个) * contract_bridge verify contract.json ↔ units.json hash 一致 * sandbox_check 扫本 Unit 周围 mtime,确认没写出沙箱 * append_coder_log 结构化追加 coder.log (强制 retry 模板字段) * * 规格: docs/prd/newfunction/_spec/agents-coder.md · §7 */ const fs = require('fs'); const path = require('path'); const pathResolver = require('../../../services/devloop/path-resolver'); const docStore = require('../../../services/devloop/doc-store'); const { ToolValidationError, ToolMissingDependencyError } = require('./_errors'); const contract_bridge = { name: 'contract_bridge', description: 'verify contract.json hash 与 units.json 中该 Unit 的 contract_hash 一致', inputSchema: { type: 'object', required: ['unit_id'], properties: { unit_id: { type: 'string' } }, }, async execute({ unit_id }, ctx) { _req(ctx, ['pid']); const unitDir = pathResolver.resolveUnitDir(ctx.pid, unit_id); const contractPath = path.join(unitDir, 'contract.json'); if (!fs.existsSync(contractPath)) { return { ok: false, reason: 'contract.json missing' }; } const contract = JSON.parse(fs.readFileSync(contractPath, 'utf8')); const units = docStore.readUnits(ctx.pid); if (!units) return { ok: false, reason: 'units.json missing' }; const unit = units.units.find(u => u.id === unit_id); if (!unit) return { ok: false, reason: 'unit not in units.json' }; const recomputed = docStore.hashContract(contract); if (contract.version !== recomputed) { return { ok: false, reason: 'contract.version self-inconsistent', claimed: contract.version, actual: recomputed, }; } if (recomputed !== unit.contract_hash) { return { ok: false, reason: 'contract does not match SDD-declared hash', expected: unit.contract_hash, actual: recomputed, hint: 'contract.json 与 SDD 不一致,改 code 让契约对齐 SDD', }; } return { ok: true, hash: recomputed }; }, }; const sandbox_check = { name: 'sandbox_check', description: '扫本 Unit 沙箱外的文件 mtime 变化,确认 Coder 没写出去', inputSchema: { type: 'object', required: ['unit_id', 'since'], properties: { unit_id: { type: 'string' }, since: { type: 'number', description: 'epoch ms; 之后被修改的文件才算' }, }, }, async execute({ unit_id, since }, ctx) { _req(ctx, ['pid']); const projectRoot = pathResolver.resolveProjectRoot(ctx.pid); const unitDir = pathResolver.resolveUnitDir(ctx.pid, unit_id); const allowedPrefixes = [ path.join(unitDir, 'code'), path.join(unitDir, 'contract.json'), path.join(unitDir, 'coder.log'), path.join(projectRoot, 'agent-state'), path.join(projectRoot, 'timeline.ndjson'), path.join(projectRoot, 'bugs.ndjson'), path.join(projectRoot, 'changelog.ndjson'), path.join(projectRoot, '.git'), ]; const violations = []; function walk(dir) { if (!fs.existsSync(dir)) return; for (const e of fs.readdirSync(dir, { withFileTypes: true })) { const p = path.join(dir, e.name); if (e.isDirectory()) walk(p); else { try { const st = fs.statSync(p); if (st.mtimeMs < since) continue; const within = allowedPrefixes.some(a => p === a || p.startsWith(a + path.sep)); if (!within) violations.push(p); } catch { /* ignore */ } } } } walk(projectRoot); return { ok: violations.length === 0, violations }; }, }; const append_coder_log = { name: 'append_coder_log', description: '结构化追加 coder.log。retry 模式必含 last_failure + last_cause 字段。', inputSchema: { type: 'object', required: ['unit_id', 'attempt', 'this_fix', 'rationale'], properties: { unit_id: { type: 'string' }, attempt: { type: 'integer', minimum: 1 }, ts: { type: 'string' }, last_failure: { type: 'string' }, last_cause: { type: 'string' }, this_fix: { type: 'string', minLength: 4 }, rationale: { type: 'string', minLength: 4 }, changed_files: { type: 'array', items: { type: 'string' } }, }, }, async execute(decision, ctx) { _req(ctx, ['pid']); if (decision.attempt > 1) { if (!decision.last_failure || !decision.last_cause) { throw new ToolValidationError( 'retry_decision', 'attempt > 1 requires last_failure + last_cause fields' ); } } const p = path.join(pathResolver.resolveUnitDir(ctx.pid, decision.unit_id), 'coder.log'); fs.mkdirSync(path.dirname(p), { recursive: true }); const fullEntry = { ts: decision.ts || new Date().toISOString(), attempt: decision.attempt, ...(decision.last_failure ? { last_failure: decision.last_failure } : {}), ...(decision.last_cause ? { last_cause: decision.last_cause } : {}), this_fix: decision.this_fix, rationale: decision.rationale, changed_files: decision.changed_files || [], }; const block = '---\n' + Object.entries(fullEntry).map(([k, v]) => { const val = typeof v === 'object' ? JSON.stringify(v) : v; return `${k}: ${val}`; }).join('\n') + '\n---\n\n'; fs.appendFileSync(p, block); return { ok: true, path: p, attempt: decision.attempt }; }, }; function _req(ctx, keys) { for (const k of keys) { if (!ctx || ctx[k] == null) throw new ToolMissingDependencyError(`ctx.${k}`); } } module.exports = { contract_bridge, sandbox_check, append_coder_log };