coder-tools.js 5.57 KB
/**
 * 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 };