goals.js 11.9 KB
/**
 * GoalsService — OKR 目标管理
 *
 * 数据目录: data/goals/
 * 每个目标: data/goals/G-xxx/goal.json
 * 全局索引: data/goals/_index.json
 *
 * ID 设计: 点分层级
 *   G-001          → Goal (第1层)
 *   G-001.1        → Objective (第2层)
 *   G-001.1.1      → Key Result (第3层)
 *   G-001.1.1.1    → Sub-Task (第4层+)
 *
 * 参考: docs/architecture-v5.md 第二章
 */

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

const GOALS_DIR = path.join(__dirname, '../../data/goals');

class GoalsService {

  constructor() {
    if (!fs.existsSync(GOALS_DIR)) {
      fs.mkdirSync(GOALS_DIR, { recursive: true });
    }
  }

  // ═══════════════════════════════════════
  //  Goal CRUD
  // ═══════════════════════════════════════

  /**
   * 创建新 Goal
   * @param {object} opts - { title, description?, owner }
   * @returns {object} 新创建的 goal
   */
  create({ title, description = '', owner }) {
    const id = this._nextGoalId();
    const now = new Date().toISOString();

    const goal = {
      id,
      title,
      description,
      owner,
      status: 'active',
      createdAt: now,
      updatedAt: now,
      nodes: {},
    };

    // 写入 goal.json
    const goalDir = path.join(GOALS_DIR, id);
    fs.mkdirSync(goalDir, { recursive: true });
    this._saveGoal(goal);

    // 写入初始 progress
    this._saveProgress(id, this._calcProgress(goal));

    // 更新索引
    this._rebuildIndex();

    // 变更日志
    this._log(id, `Goal 创建: ${title} (owner: ${owner})`);

    console.log(`[Goals] 🎯 创建目标 ${id}: ${title}`);
    return goal;
  }

  /**
   * 获取 Goal 完整数据
   */
  get(goalId) {
    const fp = path.join(GOALS_DIR, goalId, 'goal.json');
    if (!fs.existsSync(fp)) return null;
    return JSON.parse(fs.readFileSync(fp, 'utf-8'));
  }

  /**
   * 列出所有 Goal (轻量索引)
   */
  list() {
    const indexPath = path.join(GOALS_DIR, '_index.json');
    if (!fs.existsSync(indexPath)) {
      this._rebuildIndex();
    }
    try {
      return JSON.parse(fs.readFileSync(indexPath, 'utf-8'));
    } catch { return []; }
  }

  /**
   * 更新 Goal 元数据
   */
  updateGoal(goalId, updates) {
    const goal = this.get(goalId);
    if (!goal) return null;

    if (updates.title !== undefined) goal.title = updates.title;
    if (updates.description !== undefined) goal.description = updates.description;
    if (updates.status !== undefined) goal.status = updates.status;
    if (updates.owner !== undefined) goal.owner = updates.owner;
    goal.updatedAt = new Date().toISOString();

    this._saveGoal(goal);
    this._rebuildIndex();
    return goal;
  }

  // ═══════════════════════════════════════
  //  Node CRUD — OKR 节点操作
  // ═══════════════════════════════════════

  /**
   * 添加节点 — 自动分配下一个子 ID
   *
   * @param {string} goalId - G-001
   * @param {string} parentNodeId - null=顶级Objective, G-001.1=KR under O-1
   * @param {object} node - { title, owner, priority? }
   * @returns {object} { nodeId, goal }
   */
  addNode(goalId, parentNodeId, { title, owner, priority = 'P1' }) {
    const goal = this.get(goalId);
    if (!goal) return null;

    // 计算下一个子 ID
    const prefix = parentNodeId || goalId;
    const existingChildren = Object.keys(goal.nodes)
      .filter(id => {
        const parts = id.split('.');
        const prefixParts = prefix.split('.');
        // 必须是直接子节点: 层级 = parent + 1
        return parts.length === prefixParts.length + 1 && id.startsWith(prefix + '.');
      });

    const nextNum = existingChildren.length > 0
      ? Math.max(...existingChildren.map(id => parseInt(id.split('.').pop()))) + 1
      : 1;

    const nodeId = `${prefix}.${nextNum}`;

    // 创建节点
    goal.nodes[nodeId] = {
      title,
      owner,
      status: 'pending',
      priority,
      children: [],
      createdAt: new Date().toISOString(),
    };

    // 更新父节点的 children
    if (parentNodeId && goal.nodes[parentNodeId]) {
      if (!goal.nodes[parentNodeId].children) {
        goal.nodes[parentNodeId].children = [];
      }
      goal.nodes[parentNodeId].children.push(nodeId);
    }

    goal.updatedAt = new Date().toISOString();
    this._saveGoal(goal);
    this._saveProgress(goalId, this._calcProgress(goal));
    this._rebuildIndex();
    this._log(goalId, `添加节点 ${nodeId}: ${title} (owner: ${owner})`);

    console.log(`[Goals] 📋 ${goalId} 添加节点 ${nodeId}: ${title}`);
    return { nodeId, goal };
  }

  /**
   * 更新节点状态/内容
   */
  updateNode(goalId, nodeId, updates) {
    const goal = this.get(goalId);
    if (!goal || !goal.nodes[nodeId]) return null;

    const node = goal.nodes[nodeId];
    const oldStatus = node.status;

    if (updates.title !== undefined) node.title = updates.title;
    if (updates.owner !== undefined) node.owner = updates.owner;
    if (updates.status !== undefined) {
      node.status = updates.status;
      if (updates.status === 'done' && !node.completedAt) {
        node.completedAt = new Date().toISOString();
      }
    }
    if (updates.priority !== undefined) node.priority = updates.priority;
    if (updates.taskId !== undefined) node.taskId = updates.taskId;
    if (updates.output !== undefined) node.output = updates.output;
    if (updates.summary !== undefined) node.summary = updates.summary;

    node.updatedAt = new Date().toISOString();
    goal.updatedAt = node.updatedAt;

    this._saveGoal(goal);
    this._saveProgress(goalId, this._calcProgress(goal));
    this._rebuildIndex();

    if (oldStatus !== node.status) {
      this._log(goalId, `节点 ${nodeId} 状态变更: ${oldStatus} → ${node.status}`);
    }

    return { nodeId, node, goal };
  }

  /**
   * 获取单个节点 + 上下文信息
   */
  getNode(goalId, nodeId) {
    const goal = this.get(goalId);
    if (!goal || !goal.nodes[nodeId]) return null;

    const parsed = this.parseNodeId(nodeId);
    const parentNode = parsed.parentId ? goal.nodes[parsed.parentId] : null;

    return {
      ...goal.nodes[nodeId],
      nodeId,
      goalId,
      goalTitle: goal.title,
      level: parsed.level,
      levelName: parsed.levelName,
      parentId: parsed.parentId,
      parentTitle: parentNode?.title || null,
    };
  }

  // ═══════════════════════════════════════
  //  进度计算
  // ═══════════════════════════════════════

  getProgress(goalId) {
    const fp = path.join(GOALS_DIR, goalId, 'progress.json');
    if (!fs.existsSync(fp)) {
      const goal = this.get(goalId);
      if (!goal) return null;
      const progress = this._calcProgress(goal);
      this._saveProgress(goalId, progress);
      return progress;
    }
    return JSON.parse(fs.readFileSync(fp, 'utf-8'));
  }

  // ═══════════════════════════════════════
  //  工具方法
  // ═══════════════════════════════════════

  /**
   * 解析节点 ID — 从 ID 提取层级信息
   */
  parseNodeId(nodeId) {
    const parts = nodeId.split('.');
    const levelNames = ['Goal', 'Objective', 'KeyResult', 'SubTask'];
    return {
      goalId: parts[0],
      level: parts.length,
      parentId: parts.length > 1 ? parts.slice(0, -1).join('.') : null,
      levelName: levelNames[Math.min(parts.length - 1, 3)],
    };
  }

  /**
   * 为 Agent 生成 Goal 上下文注入文本
   */
  buildGoalContext(goalId, nodeId) {
    const goal = this.get(goalId);
    if (!goal) return '';

    const progress = this._calcProgress(goal);
    let text = `\n\n## 🎯 当前目标\n\n`;
    text += `**${goalId}** — ${goal.title}\n`;
    text += `全局进度: ${Math.round(progress.progress * 100)}% (${progress.done}/${progress.total} 完成)\n`;

    if (nodeId && goal.nodes[nodeId]) {
      const node = goal.nodes[nodeId];
      const parsed = this.parseNodeId(nodeId);
      const parentNode = parsed.parentId ? goal.nodes[parsed.parentId] : null;

      text += `\n你负责的节点:\n`;
      text += `- **${nodeId}**「${node.title}」\n`;
      text += `- 层级: ${parsed.levelName} (第${parsed.level}层)\n`;
      if (parentNode) {
        text += `- 父级: ${parsed.parentId}「${parentNode.title}」\n`;
      }
      text += `- 状态: ${node.status}\n`;
    }

    return text;
  }

  // ═══════════════════════════════════════
  //  私有方法
  // ═══════════════════════════════════════

  _nextGoalId() {
    // 扫描现有 G-xxx 目录
    if (!fs.existsSync(GOALS_DIR)) return 'G-001';
    const dirs = fs.readdirSync(GOALS_DIR, { withFileTypes: true })
      .filter(d => d.isDirectory() && d.name.startsWith('G-'))
      .map(d => parseInt(d.name.replace('G-', '')))
      .filter(n => !isNaN(n));

    const next = dirs.length > 0 ? Math.max(...dirs) + 1 : 1;
    return `G-${String(next).padStart(3, '0')}`;
  }

  _saveGoal(goal) {
    const goalDir = path.join(GOALS_DIR, goal.id);
    if (!fs.existsSync(goalDir)) fs.mkdirSync(goalDir, { recursive: true });
    fs.writeFileSync(path.join(goalDir, 'goal.json'), JSON.stringify(goal, null, 2), 'utf-8');
  }

  _saveProgress(goalId, progress) {
    const goalDir = path.join(GOALS_DIR, goalId);
    if (!fs.existsSync(goalDir)) return;
    fs.writeFileSync(path.join(goalDir, 'progress.json'), JSON.stringify(progress, null, 2), 'utf-8');
  }

  _calcProgress(goal) {
    const nodes = goal.nodes || {};
    const allIds = Object.keys(nodes);

    // 叶子节点 = 没有 children 或 children 为空的节点
    const leafIds = allIds.filter(id => {
      const n = nodes[id];
      return !n.children || n.children.length === 0;
    });

    const total = leafIds.length;
    const done = leafIds.filter(id => nodes[id].status === 'done').length;
    const inProgress = leafIds.filter(id => nodes[id].status === 'in_progress').length;
    const pending = total - done - inProgress;

    // 按 owner 分组
    const byOwner = {};
    for (const id of leafIds) {
      const owner = nodes[id].owner || 'unknown';
      if (!byOwner[owner]) byOwner[owner] = { total: 0, done: 0 };
      byOwner[owner].total++;
      if (nodes[id].status === 'done') byOwner[owner].done++;
    }

    return {
      goalId: goal.id,
      progress: total > 0 ? done / total : 0,
      total,
      done,
      inProgress,
      pending,
      byOwner,
      updatedAt: new Date().toISOString(),
    };
  }

  _rebuildIndex() {
    if (!fs.existsSync(GOALS_DIR)) return;
    const dirs = fs.readdirSync(GOALS_DIR, { withFileTypes: true })
      .filter(d => d.isDirectory() && d.name.startsWith('G-'));

    const index = dirs.map(d => {
      const goal = this.get(d.name);
      if (!goal) return null;
      const progress = this._calcProgress(goal);
      return {
        id: goal.id,
        title: goal.title,
        owner: goal.owner,
        status: goal.status,
        progress: progress.progress,
        nodeCount: Object.keys(goal.nodes).length,
        doneCount: progress.done,
        totalCount: progress.total,
        updatedAt: goal.updatedAt,
      };
    }).filter(Boolean);

    fs.writeFileSync(
      path.join(GOALS_DIR, '_index.json'),
      JSON.stringify(index, null, 2),
      'utf-8'
    );
  }

  _log(goalId, message) {
    const logPath = path.join(GOALS_DIR, goalId, 'history.log');
    const ts = new Date().toISOString();
    fs.appendFileSync(logPath, `[${ts}] ${message}\n`, 'utf-8');
  }
}

module.exports = { GoalsService };