loop-controller.js 10.2 KB
/**
 * DevLoop · LoopController
 *
 * P0 已提供:
 *   - createProject({ company_id, mode, target_path, idea }) — 项目分配 + git init
 *   - commitAfterAgent(pid, agentName, summary) — Agent 收尾自动 commit
 *
 * P1 新增 (Docs MVP):
 *   - runDocsPhase(pid, requirement, opts) — 串行跑 PM → Architect (mock 模式),
 *     把 fixture.files 落盘到项目根,每个 Agent 收尾自动 commit
 *   - callAgent(pid, agentName, prompt, opts) — 单次 Agent 调用 (P1 仅支持 mock 模式)
 *
 * 规格:
 *   - docs/prd/newfunction/_spec/sdd-process.md · P-10 GitCommit
 *   - docs/prd/newfunction/_spec/arch-git-versioning.md
 *   - docs/prd/newfunction/_spec/arch-project-root.md
 *   - docs/prd/newfunction/_spec/plan-migration.md · P1 Docs MVP
 */

const fs = require('fs');
const path = require('path');

const pathResolver = require('../../../services/devloop/path-resolver');
const gitVault = require('../../../services/devloop/git-vault');
const timelineLogger = require('../../../services/devloop/timeline-logger');
const { ClaudeCliProtocol } = require('../../../../packages/acp-core/protocol/ClaudeCliProtocol');

const TEMPLATES_DIR = path.resolve(__dirname, '../../../../system/templates');

/**
 * 新建项目:
 *   1. allocateProjectRoot (sandbox / linked-new)
 *   2. 写 project.json (最小元数据)
 *   3. gitVault.init + 写 .gitignore (gitVault 内置 DEFAULT_GITIGNORE)
 *   4. 首次 commit "chore: init project"
 */
async function createProject({ company_id, mode = 'sandbox', target_path, idea = '' }) {
  if (!company_id) throw new Error('company_id required');

  const { pid, root_path } = pathResolver.allocateProjectRoot({
    company_id, mode, target_path,
  });

  // project.json
  const meta = {
    pid,
    company_id,
    mode,
    created_at: new Date().toISOString(),
    idea_summary: (idea || '').slice(0, 200),
  };
  fs.writeFileSync(
    pathResolver.resolveProjectJson(pid),
    JSON.stringify(meta, null, 2)
  );

  // git init (内置首个 init commit) — gitVault.init 已自己做 commit
  await gitVault.init(pid, {
    summary: idea ? idea.slice(0, 80) : `init ${pid}`,
  });

  // timeline (写在 init 之后,作为"项目创建"事件)
  try {
    timelineLogger.append(pid, {
      source: 'system',
      kind: 'project.created',
      ref: pid,
      summary: `Project ${pid} created (${mode})`,
      payload: { company_id, mode, root_path },
    });
  } catch (e) {
    console.warn('[LoopController] timeline append failed:', e.message);
  }

  // 把 project.json + timeline.ndjson 一并提交进第二个 commit (工作树留干净)
  const firstCommit = await gitVault.commit(pid, {
    kind: 'chore',
    summary: `chore: project metadata + first timeline entry for ${pid}`,
    author: { name: 'devloop-system', email: 'devloop@local' },
  });

  return { pid, root_path, mode, first_commit: firstCommit };
}

/**
 * Agent 结束后,把工作树新文件 auto-commit。
 * 调用方一般在 stream-executor 完成或 agent-manager.post-task-success 时调。
 *
 * @param {string} pid
 * @param {string} agentName  (devloop-pm / devloop-architect / ...)
 * @param {string} summary    一句话描述本次产出 (做 commit message 副标题)
 * @returns {{ sha, files_changed }} 或 null (无变更时)
 */
async function commitAfterAgent(pid, agentName, summary = '') {
  // commit kind 推断
  const kindMap = {
    'devloop-pm': 'prd',
    'devloop-architect': 'sdd',
    'devloop-coder': 'code',
    'devloop-tester': 'code',
    'devloop-integrator': 'integ',
    'devloop-triager': 'triage',
  };
  const kind = kindMap[agentName] || 'chore';
  const msg = `${kind}(${agentName}): ${summary || 'agent update'}`;
  const commit = await gitVault.commit(pid, {
    kind,
    summary: msg,
    author: { name: agentName, email: `${agentName}@devloop.local` },
  });

  if (commit.skipped) {
    return null;
  }

  try {
    timelineLogger.append(pid, {
      source: agentName,
      kind: 'git.commit',
      ref: commit.sha,
      summary: msg.slice(0, 200),
      payload: { sha: commit.sha },
    });
  } catch { /* swallow */ }

  return commit;
}

/**
 * 单次 Agent 调用 (P1 mock 实现):
 *   1. 读 system/templates/{agentName}/prompt.md (系统提示)
 *   2. 用 ClaudeCliProtocol(mock=true) 跑一遍
 *   3. 收集 chunks / files / result
 *   4. 把 fixture.files 落盘到项目根
 *   5. 触发 commitAfterAgent
 *
 * @param {string} pid
 * @param {string} agentName  e.g. "devloop-pm"
 * @param {string} userPrompt 用户/上一步追加的提示
 * @param {object} opts
 *   - onChunk?: (text) => void   流式回调
 *   - mock?: boolean             默认走 LEMONBOSS_CLAUDE_MODE=mock
 *   - skipCommit?: boolean       默认 false
 * @returns { ok, agent, files_written, commit, result_text }
 */
async function callAgent(pid, agentName, userPrompt, opts = {}) {
  const projectRoot = pathResolver.resolveProjectRoot(pid);

  // 1. 系统提示 (可选 — 没文件就空串)
  const promptPath = path.join(TEMPLATES_DIR, agentName, 'prompt.md');
  const systemPrompt = fs.existsSync(promptPath)
    ? fs.readFileSync(promptPath, 'utf8')
    : '';
  const fullPrompt = systemPrompt
    ? `${systemPrompt}\n\n## User Task\n${userPrompt}`
    : userPrompt;

  // 2. 启动 protocol
  const protocol = new ClaudeCliProtocol(
    { name: agentName },
    { mock: opts.mock !== false }, // 默认 mock=true (P1 阶段)
  );

  let resultParams = null;
  const filesFromMock = [];
  protocol.on('session_update', (u) => {
    if (u.update?.sessionUpdate === 'agent_message_chunk' && opts.onChunk) {
      opts.onChunk(u.update.content.text);
    }
  });
  protocol.on('acp_event', (ev) => {
    if (ev.method === 'session/result') resultParams = ev.params;
    else if (ev.method === 'mock/files' && Array.isArray(ev.params?.files)) {
      filesFromMock.push(...ev.params.files);
    }
  });

  try {
    await protocol.connect(null, projectRoot);
    await protocol.sendPrompt(fullPrompt);
  } finally {
    protocol.disconnect();
  }

  // 3. 落盘 fixture.files (path 相对 projectRoot)
  const filesWritten = [];
  for (const f of filesFromMock) {
    if (!f?.path || typeof f.content !== 'string') continue;
    if (path.isAbsolute(f.path) || f.path.includes('..')) {
      throw new Error(`unsafe mock file path: ${f.path}`);
    }
    const abs = path.join(projectRoot, f.path);
    fs.mkdirSync(path.dirname(abs), { recursive: true });
    fs.writeFileSync(abs, f.content);
    filesWritten.push(f.path);
  }

  // 4. timeline + commit
  try {
    timelineLogger.append(pid, {
      source: agentName,
      kind: 'agent.call',
      ref: resultParams?.session_id || 'mock',
      summary: (resultParams?.result || '').slice(0, 200) || `${agentName} ran (mock)`,
      payload: { files_written: filesWritten.length, mock: !!resultParams?.mock },
    });
  } catch { /* swallow */ }

  let commit = null;
  if (!opts.skipCommit) {
    const summary = (resultParams?.result || '').slice(0, 80) || `${agentName} produced ${filesWritten.length} files`;
    commit = await commitAfterAgent(pid, agentName, summary);
  }

  return {
    ok: true,
    agent: agentName,
    files_written: filesWritten,
    commit,
    result_text: resultParams?.result || '',
    mock: !!resultParams?.mock,
  };
}

/**
 * Docs phase (P1): PM → Architect 串行跑。
 *
 * @param {string} pid
 * @param {string} requirement 用户原始需求一段话
 * @param {object} opts
 *   - onChunk?: (agent, text) => void
 * @returns { ok, pm, architect }
 */
async function runDocsPhase(pid, requirement, opts = {}) {
  if (!requirement || typeof requirement !== 'string') {
    throw new Error('requirement (string) required');
  }

  const onChunk = opts.onChunk
    ? (agent) => (text) => opts.onChunk(agent, text)
    : null;

  const pm = await callAgent(pid, 'devloop-pm',
    `Requirement:\n${requirement}\n\nmode: init`,
    { onChunk: onChunk ? onChunk('devloop-pm') : undefined });

  const architect = await callAgent(pid, 'devloop-architect',
    `Read prd/ from project root and produce SDD + units.json + integration/spec.json.\nRequirement context:\n${requirement}`,
    { onChunk: onChunk ? onChunk('devloop-architect') : undefined });

  // timeline 总结
  try {
    timelineLogger.append(pid, {
      source: 'system',
      kind: 'docs.complete',
      ref: pid,
      summary: `Docs phase complete: PM (${pm.files_written.length} files) + Architect (${architect.files_written.length} files)`,
      payload: {
        pm_files: pm.files_written.length,
        architect_files: architect.files_written.length,
      },
    });
  } catch { /* swallow */ }

  return { ok: true, pm, architect };
}

/**
 * 读项目当前状态 (P1 用,routes/devloop.js GET /state 后端):
 *   - project.json
 *   - units.json (if any)
 *   - PRD/SDD revs (扫 article[data-rev] 最大值)
 *   - 最近一次 commit
 *   - timeline 最近 5 条
 *   - bugs 计数
 */
function getProjectState(pid) {
  const root = pathResolver.resolveProjectRoot(pid);
  const out = { pid, root_path: root };

  const projectJson = pathResolver.resolveProjectJson(pid);
  if (fs.existsSync(projectJson)) {
    try { out.project = JSON.parse(fs.readFileSync(projectJson, 'utf8')); } catch {}
  }

  const unitsJson = pathResolver.resolveUnitsJson(pid);
  if (fs.existsSync(unitsJson)) {
    try {
      const u = JSON.parse(fs.readFileSync(unitsJson, 'utf8'));
      out.units = u.units || [];
      out.units_rev = u.rev || 0;
    } catch {}
  }

  // PRD/SDD rev = 站点根 index.html 的 article[data-rev]
  for (const kind of ['prd', 'sdd']) {
    const idx = path.join(root, kind, 'index.html');
    if (fs.existsSync(idx)) {
      const html = fs.readFileSync(idx, 'utf8');
      const m = html.match(/<article[^>]*data-doc="(prd|sdd)"[^>]*data-rev="(\d+)"/);
      out[`${kind}_rev`] = m ? parseInt(m[2], 10) : 1;
      out[`${kind}_site`] = true;
    } else {
      out[`${kind}_site`] = false;
    }
  }

  // timeline tail
  const tl = pathResolver.resolveTimelineLog(pid);
  if (fs.existsSync(tl)) {
    const lines = fs.readFileSync(tl, 'utf8').trim().split('\n').filter(Boolean);
    out.timeline_tail = lines.slice(-5).map(l => {
      try { return JSON.parse(l); } catch { return null; }
    }).filter(Boolean);
  }

  return out;
}

module.exports = {
  createProject,
  commitAfterAgent,
  callAgent,
  runDocsPhase,
  getProjectState,
};