/** * 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 };