Commit 34584a526081b0faaaf20e5b49c247ac317caf5e
1 parent
2c2cde85
feat(company): implement Phase 3 - Company-scoped OKR + GoalsService refactor
- GoalsService refactored: constructor accepts optional companyId
- With companyId: goals stored in data/{companyId}/goals/
- Without: backward-compatible data/goals/ (global)
- Company Goals REST API: full OKR CRUD under /api/company/:id/goals
- OKR isolation verified: different companies have independent goal spaces
- E2E test: 81/81 passed (Phase 1 + 2 + 3)
Showing
4 changed files
with
177 additions
and
27 deletions
data/templates/_registry.json
| @@ -6,7 +6,7 @@ | @@ -6,7 +6,7 @@ | ||
| 6 | "description": "公司战略规划、团队管理、OKR 制定、任务委派", | 6 | "description": "公司战略规划、团队管理、OKR 制定、任务委派", |
| 7 | "benchmarkScore": 97, | 7 | "benchmarkScore": 97, |
| 8 | "trainedAt": "2026-03-20", | 8 | "trainedAt": "2026-03-20", |
| 9 | - "usedCount": 2 | 9 | + "usedCount": 3 |
| 10 | }, | 10 | }, |
| 11 | { | 11 | { |
| 12 | "id": "qa-judge", | 12 | "id": "qa-judge", |
| @@ -15,6 +15,6 @@ | @@ -15,6 +15,6 @@ | ||
| 15 | "description": "质量验收、OKR 进度审查、标准化仲裁", | 15 | "description": "质量验收、OKR 进度审查、标准化仲裁", |
| 16 | "benchmarkScore": 95, | 16 | "benchmarkScore": 95, |
| 17 | "trainedAt": "2026-03-25", | 17 | "trainedAt": "2026-03-25", |
| 18 | - "usedCount": 2 | 18 | + "usedCount": 3 |
| 19 | } | 19 | } |
| 20 | ] | 20 | ] |
| 21 | \ No newline at end of file | 21 | \ No newline at end of file |
server/routes/company.js
| @@ -115,4 +115,81 @@ router.get('/:companyId/agents', (req, res) => { | @@ -115,4 +115,81 @@ router.get('/:companyId/agents', (req, res) => { | ||
| 115 | res.json(agents); | 115 | res.json(agents); |
| 116 | }); | 116 | }); |
| 117 | 117 | ||
| 118 | +// ═══════════════════════════════════════ | ||
| 119 | +// Company Goals / OKR (公司级 OKR) | ||
| 120 | +// ═══════════════════════════════════════ | ||
| 121 | + | ||
| 122 | +const { GoalsService } = require('../services/goals'); | ||
| 123 | + | ||
| 124 | +// GET /api/company/:companyId/goals | ||
| 125 | +router.get('/:companyId/goals', (req, res) => { | ||
| 126 | + const goals = new GoalsService(req.params.companyId); | ||
| 127 | + res.json(goals.list()); | ||
| 128 | +}); | ||
| 129 | + | ||
| 130 | +// POST /api/company/:companyId/goals | ||
| 131 | +router.post('/:companyId/goals', (req, res) => { | ||
| 132 | + try { | ||
| 133 | + const { title, description, owner } = req.body; | ||
| 134 | + if (!title || !owner) { | ||
| 135 | + return res.status(400).json({ error: 'title 和 owner 为必填项' }); | ||
| 136 | + } | ||
| 137 | + const goals = new GoalsService(req.params.companyId); | ||
| 138 | + const goal = goals.create({ title, description, owner }); | ||
| 139 | + res.status(201).json(goal); | ||
| 140 | + } catch (err) { | ||
| 141 | + res.status(500).json({ error: err.message }); | ||
| 142 | + } | ||
| 143 | +}); | ||
| 144 | + | ||
| 145 | +// GET /api/company/:companyId/goals/:goalId | ||
| 146 | +router.get('/:companyId/goals/:goalId', (req, res) => { | ||
| 147 | + const goals = new GoalsService(req.params.companyId); | ||
| 148 | + const goal = goals.get(req.params.goalId); | ||
| 149 | + if (!goal) return res.status(404).json({ error: 'Goal not found' }); | ||
| 150 | + res.json(goal); | ||
| 151 | +}); | ||
| 152 | + | ||
| 153 | +// PUT /api/company/:companyId/goals/:goalId | ||
| 154 | +router.put('/:companyId/goals/:goalId', (req, res) => { | ||
| 155 | + const goals = new GoalsService(req.params.companyId); | ||
| 156 | + const result = goals.updateGoal(req.params.goalId, req.body); | ||
| 157 | + if (!result) return res.status(404).json({ error: 'Goal not found' }); | ||
| 158 | + res.json(result); | ||
| 159 | +}); | ||
| 160 | + | ||
| 161 | +// POST /api/company/:companyId/goals/:goalId/nodes | ||
| 162 | +router.post('/:companyId/goals/:goalId/nodes', (req, res) => { | ||
| 163 | + try { | ||
| 164 | + const { parentNodeId, title, owner, priority } = req.body; | ||
| 165 | + if (!title || !owner) { | ||
| 166 | + return res.status(400).json({ error: 'title 和 owner 为必填项' }); | ||
| 167 | + } | ||
| 168 | + const goals = new GoalsService(req.params.companyId); | ||
| 169 | + const result = goals.addNode(req.params.goalId, parentNodeId || null, { | ||
| 170 | + title, owner, priority, | ||
| 171 | + }); | ||
| 172 | + if (!result) return res.status(404).json({ error: 'Goal not found' }); | ||
| 173 | + res.status(201).json({ nodeId: result.nodeId, node: result.goal.nodes[result.nodeId] }); | ||
| 174 | + } catch (err) { | ||
| 175 | + res.status(500).json({ error: err.message }); | ||
| 176 | + } | ||
| 177 | +}); | ||
| 178 | + | ||
| 179 | +// PUT /api/company/:companyId/goals/:goalId/nodes/:nodeId | ||
| 180 | +router.put('/:companyId/goals/:goalId/nodes/:nodeId', (req, res) => { | ||
| 181 | + const goals = new GoalsService(req.params.companyId); | ||
| 182 | + const result = goals.updateNode(req.params.goalId, req.params.nodeId, req.body); | ||
| 183 | + if (!result) return res.status(404).json({ error: 'Node not found' }); | ||
| 184 | + res.json({ nodeId: result.nodeId, node: result.node }); | ||
| 185 | +}); | ||
| 186 | + | ||
| 187 | +// GET /api/company/:companyId/goals/:goalId/progress | ||
| 188 | +router.get('/:companyId/goals/:goalId/progress', (req, res) => { | ||
| 189 | + const goals = new GoalsService(req.params.companyId); | ||
| 190 | + const progress = goals.getProgress(req.params.goalId); | ||
| 191 | + if (!progress) return res.status(404).json({ error: 'Goal not found' }); | ||
| 192 | + res.json(progress); | ||
| 193 | +}); | ||
| 194 | + | ||
| 118 | module.exports = router; | 195 | module.exports = router; |
server/scripts/test-company-e2e.js
| @@ -18,6 +18,7 @@ const fs = require('fs'); | @@ -18,6 +18,7 @@ const fs = require('fs'); | ||
| 18 | 18 | ||
| 19 | // 直接引入 service 层 (不需要 HTTP) | 19 | // 直接引入 service 层 (不需要 HTTP) |
| 20 | const { CompanyService } = require('../services/company-service'); | 20 | const { CompanyService } = require('../services/company-service'); |
| 21 | +const { GoalsService } = require('../services/goals'); | ||
| 21 | 22 | ||
| 22 | const DATA_DIR = path.resolve(__dirname, '../../data'); | 23 | const DATA_DIR = path.resolve(__dirname, '../../data'); |
| 23 | const TEST_COMPANY_ID = 'test-media-co'; | 24 | const TEST_COMPANY_ID = 'test-media-co'; |
| @@ -230,8 +231,69 @@ async function main() { | @@ -230,8 +231,69 @@ async function main() { | ||
| 230 | const firedConfig = JSON.parse(fs.readFileSync(path.join(TEST_COMPANY_DIR, 'agents', 'qa-judge', 'config.json'), 'utf-8')); | 231 | const firedConfig = JSON.parse(fs.readFileSync(path.join(TEST_COMPANY_DIR, 'agents', 'qa-judge', 'config.json'), 'utf-8')); |
| 231 | assert(firedConfig.status === 'suspended', 'qa-judge config 状态为 suspended'); | 232 | assert(firedConfig.status === 'suspended', 'qa-judge config 状态为 suspended'); |
| 232 | 233 | ||
| 233 | - // ─── 13. 更新公司 ─── | ||
| 234 | - console.log('\n✏️ Phase 13: 更新公司'); | 234 | + // ─── 13. Phase 3: Company Goals (公司级 OKR) ─── |
| 235 | + console.log('\n🎯 Phase 13: Company Goals (公司级 OKR)'); | ||
| 236 | + | ||
| 237 | + const companyGoals = new GoalsService(TEST_COMPANY_ID); | ||
| 238 | + | ||
| 239 | + // 13a: 创建 Goal | ||
| 240 | + const goal = companyGoals.create({ | ||
| 241 | + title: '30天粉丝破万', | ||
| 242 | + description: '通过日更优质内容实现公众号粉丝增长', | ||
| 243 | + owner: 'ceo', | ||
| 244 | + }); | ||
| 245 | + assert(goal.id === 'G-001', `Goal ID = G-001 (实际: ${goal.id})`); | ||
| 246 | + assert(goal.title === '30天粉丝破万', 'Goal 标题正确'); | ||
| 247 | + | ||
| 248 | + // 13b: 检查数据存储在公司目录下 | ||
| 249 | + const goalFilePath = path.join(TEST_COMPANY_DIR, 'goals', 'G-001', 'goal.json'); | ||
| 250 | + assert(fs.existsSync(goalFilePath), 'Goal 存储在公司目录下'); | ||
| 251 | + | ||
| 252 | + // 13c: 添加节点 (Objective + Key Results) | ||
| 253 | + const obj = companyGoals.addNode('G-001', null, { | ||
| 254 | + title: '建立内容产出流水线', | ||
| 255 | + owner: 'ceo', | ||
| 256 | + priority: 'P0', | ||
| 257 | + }); | ||
| 258 | + assert(obj.nodeId === 'G-001.1', `Objective ID = G-001.1 (实际: ${obj.nodeId})`); | ||
| 259 | + | ||
| 260 | + const kr1 = companyGoals.addNode('G-001', 'G-001.1', { | ||
| 261 | + title: '日更1篇高质量文章', | ||
| 262 | + owner: 'content-writer', | ||
| 263 | + priority: 'P0', | ||
| 264 | + }); | ||
| 265 | + assert(kr1.nodeId === 'G-001.1.1', `KR ID = G-001.1.1 (实际: ${kr1.nodeId})`); | ||
| 266 | + | ||
| 267 | + const kr2 = companyGoals.addNode('G-001', 'G-001.1', { | ||
| 268 | + title: '建立选题数据库 (≥100 条)', | ||
| 269 | + owner: 'hot-topic-hunter', | ||
| 270 | + priority: 'P1', | ||
| 271 | + }); | ||
| 272 | + assert(kr2.nodeId === 'G-001.1.2', `KR2 ID = G-001.1.2 (实际: ${kr2.nodeId})`); | ||
| 273 | + | ||
| 274 | + // 13d: 更新节点状态 | ||
| 275 | + companyGoals.updateNode('G-001', 'G-001.1.1', { status: 'in_progress' }); | ||
| 276 | + const updatedNode = companyGoals.getNode('G-001', 'G-001.1.1'); | ||
| 277 | + assert(updatedNode.status === 'in_progress', '节点状态更新为 in_progress'); | ||
| 278 | + | ||
| 279 | + // 13e: 进度计算 | ||
| 280 | + const progress = companyGoals.getProgress('G-001'); | ||
| 281 | + assert(progress.total === 2, `叶子节点总数 = 2 (实际: ${progress.total})`); | ||
| 282 | + assert(progress.inProgress === 1, `进行中 = 1 (实际: ${progress.inProgress})`); | ||
| 283 | + | ||
| 284 | + // 13f: Goal list | ||
| 285 | + const goalList = companyGoals.list(); | ||
| 286 | + assert(goalList.length === 1, `Goal 列表长度 = 1 (实际: ${goalList.length})`); | ||
| 287 | + assert(goalList[0].id === 'G-001', '列表中 Goal ID 正确'); | ||
| 288 | + | ||
| 289 | + // 13g: 验证 OKR 是公司隔离的 (全局 GoalsService 看不到公司的目标) | ||
| 290 | + const globalGoals = new GoalsService(); | ||
| 291 | + const globalList = globalGoals.list(); | ||
| 292 | + const hasCompanyGoal = globalList.some(g => g.id === 'G-001' && g.title === '30天粉丝破万'); | ||
| 293 | + assert(!hasCompanyGoal, '全局 GoalsService 看不到公司级 OKR (隔离)'); | ||
| 294 | + | ||
| 295 | + // ─── 14. 更新公司 ─── | ||
| 296 | + console.log('\n✏️ Phase 14: 更新公司'); | ||
| 235 | const updated = svc.update(TEST_COMPANY_ID, { | 297 | const updated = svc.update(TEST_COMPANY_ID, { |
| 236 | status: 'running', | 298 | status: 'running', |
| 237 | heartbeat: { enabled: true }, | 299 | heartbeat: { enabled: true }, |
| @@ -239,8 +301,8 @@ async function main() { | @@ -239,8 +301,8 @@ async function main() { | ||
| 239 | assert(updated.status === 'running', '状态更新为 running'); | 301 | assert(updated.status === 'running', '状态更新为 running'); |
| 240 | assert(updated.heartbeat.enabled === true, '心跳已开启'); | 302 | assert(updated.heartbeat.enabled === true, '心跳已开启'); |
| 241 | 303 | ||
| 242 | - // ─── 14. 关停公司 ─── | ||
| 243 | - console.log('\n🛑 Phase 14: 关停公司'); | 304 | + // ─── 15. 关停公司 ─── |
| 305 | + console.log('\n🛑 Phase 15: 关停公司'); | ||
| 244 | const shutdown = svc.shutdown(TEST_COMPANY_ID); | 306 | const shutdown = svc.shutdown(TEST_COMPANY_ID); |
| 245 | assert(shutdown.status === 'shutdown', '状态变为 shutdown'); | 307 | assert(shutdown.status === 'shutdown', '状态变为 shutdown'); |
| 246 | assert(shutdown.heartbeat.enabled === false, '心跳已关闭'); | 308 | assert(shutdown.heartbeat.enabled === false, '心跳已关闭'); |
| @@ -262,7 +324,7 @@ async function main() { | @@ -262,7 +324,7 @@ async function main() { | ||
| 262 | console.log(`\n${'═'.repeat(50)}`); | 324 | console.log(`\n${'═'.repeat(50)}`); |
| 263 | console.log(` 📊 测试结果: ${passed} 通过, ${failed} 失败 (共 ${passed + failed})`); | 325 | console.log(` 📊 测试结果: ${passed} 通过, ${failed} 失败 (共 ${passed + failed})`); |
| 264 | if (failed === 0) { | 326 | if (failed === 0) { |
| 265 | - console.log(' 🎉 ALL PASSED — Phase 1 + Phase 2 生产就绪!\n'); | 327 | + console.log(' 🎉 ALL PASSED — Phase 1 + Phase 2 + Phase 3 生产就绪!\n'); |
| 266 | } else { | 328 | } else { |
| 267 | console.log(` ⚠️ ${failed} 个测试未通过\n`); | 329 | console.log(` ⚠️ ${failed} 个测试未通过\n`); |
| 268 | process.exit(1); | 330 | process.exit(1); |
server/services/goals.js
| 1 | /** | 1 | /** |
| 2 | * GoalsService — OKR 目标管理 | 2 | * GoalsService — OKR 目标管理 |
| 3 | * | 3 | * |
| 4 | - * 数据目录: data/goals/ | ||
| 5 | - * 每个目标: data/goals/G-xxx/goal.json | ||
| 6 | - * 全局索引: data/goals/_index.json | 4 | + * 数据目录: |
| 5 | + * - 公司级: data/{companyId}/goals/ | ||
| 6 | + * - 全局(向后兼容): data/goals/ | ||
| 7 | + * | ||
| 8 | + * 每个目标: {goalsDir}/G-xxx/goal.json | ||
| 9 | + * 索引文件: {goalsDir}/_index.json | ||
| 7 | * | 10 | * |
| 8 | * ID 设计: 点分层级 | 11 | * ID 设计: 点分层级 |
| 9 | * G-001 → Goal (第1层) | 12 | * G-001 → Goal (第1层) |
| @@ -17,13 +20,21 @@ | @@ -17,13 +20,21 @@ | ||
| 17 | const fs = require('fs'); | 20 | const fs = require('fs'); |
| 18 | const path = require('path'); | 21 | const path = require('path'); |
| 19 | 22 | ||
| 20 | -const GOALS_DIR = path.join(__dirname, '../../data/goals'); | 23 | +const DATA_DIR = path.join(__dirname, '../../data'); |
| 21 | 24 | ||
| 22 | class GoalsService { | 25 | class GoalsService { |
| 23 | 26 | ||
| 24 | - constructor() { | ||
| 25 | - if (!fs.existsSync(GOALS_DIR)) { | ||
| 26 | - fs.mkdirSync(GOALS_DIR, { recursive: true }); | 27 | + /** |
| 28 | + * @param {string} [companyId] - 公司 ID。不传则使用 data/goals/ (向后兼容) | ||
| 29 | + */ | ||
| 30 | + constructor(companyId) { | ||
| 31 | + this.companyId = companyId || null; | ||
| 32 | + this.goalsDir = companyId | ||
| 33 | + ? path.join(DATA_DIR, companyId, 'goals') | ||
| 34 | + : path.join(DATA_DIR, 'goals'); | ||
| 35 | + | ||
| 36 | + if (!fs.existsSync(this.goalsDir)) { | ||
| 37 | + fs.mkdirSync(this.goalsDir, { recursive: true }); | ||
| 27 | } | 38 | } |
| 28 | } | 39 | } |
| 29 | 40 | ||
| @@ -52,7 +63,7 @@ class GoalsService { | @@ -52,7 +63,7 @@ class GoalsService { | ||
| 52 | }; | 63 | }; |
| 53 | 64 | ||
| 54 | // 写入 goal.json | 65 | // 写入 goal.json |
| 55 | - const goalDir = path.join(GOALS_DIR, id); | 66 | + const goalDir = path.join(this.goalsDir, id); |
| 56 | fs.mkdirSync(goalDir, { recursive: true }); | 67 | fs.mkdirSync(goalDir, { recursive: true }); |
| 57 | this._saveGoal(goal); | 68 | this._saveGoal(goal); |
| 58 | 69 | ||
| @@ -73,7 +84,7 @@ class GoalsService { | @@ -73,7 +84,7 @@ class GoalsService { | ||
| 73 | * 获取 Goal 完整数据 | 84 | * 获取 Goal 完整数据 |
| 74 | */ | 85 | */ |
| 75 | get(goalId) { | 86 | get(goalId) { |
| 76 | - const fp = path.join(GOALS_DIR, goalId, 'goal.json'); | 87 | + const fp = path.join(this.goalsDir, goalId, 'goal.json'); |
| 77 | if (!fs.existsSync(fp)) return null; | 88 | if (!fs.existsSync(fp)) return null; |
| 78 | return JSON.parse(fs.readFileSync(fp, 'utf-8')); | 89 | return JSON.parse(fs.readFileSync(fp, 'utf-8')); |
| 79 | } | 90 | } |
| @@ -82,7 +93,7 @@ class GoalsService { | @@ -82,7 +93,7 @@ class GoalsService { | ||
| 82 | * 列出所有 Goal (轻量索引) | 93 | * 列出所有 Goal (轻量索引) |
| 83 | */ | 94 | */ |
| 84 | list() { | 95 | list() { |
| 85 | - const indexPath = path.join(GOALS_DIR, '_index.json'); | 96 | + const indexPath = path.join(this.goalsDir, '_index.json'); |
| 86 | if (!fs.existsSync(indexPath)) { | 97 | if (!fs.existsSync(indexPath)) { |
| 87 | this._rebuildIndex(); | 98 | this._rebuildIndex(); |
| 88 | } | 99 | } |
| @@ -233,7 +244,7 @@ class GoalsService { | @@ -233,7 +244,7 @@ class GoalsService { | ||
| 233 | // ═══════════════════════════════════════ | 244 | // ═══════════════════════════════════════ |
| 234 | 245 | ||
| 235 | getProgress(goalId) { | 246 | getProgress(goalId) { |
| 236 | - const fp = path.join(GOALS_DIR, goalId, 'progress.json'); | 247 | + const fp = path.join(this.goalsDir, goalId, 'progress.json'); |
| 237 | if (!fs.existsSync(fp)) { | 248 | if (!fs.existsSync(fp)) { |
| 238 | const goal = this.get(goalId); | 249 | const goal = this.get(goalId); |
| 239 | if (!goal) return null; | 250 | if (!goal) return null; |
| @@ -295,7 +306,7 @@ class GoalsService { | @@ -295,7 +306,7 @@ class GoalsService { | ||
| 295 | * 获取某 Agent 当前参与的活跃 Goal 上下文文本(汇总所有活跃节点) | 306 | * 获取某 Agent 当前参与的活跃 Goal 上下文文本(汇总所有活跃节点) |
| 296 | */ | 307 | */ |
| 297 | getActiveContextForAgent(agentId) { | 308 | getActiveContextForAgent(agentId) { |
| 298 | - if (!fs.existsSync(GOALS_DIR)) return ''; | 309 | + if (!fs.existsSync(this.goalsDir)) return ''; |
| 299 | const index = this.list(); | 310 | const index = this.list(); |
| 300 | let combinedText = ''; | 311 | let combinedText = ''; |
| 301 | 312 | ||
| @@ -340,8 +351,8 @@ class GoalsService { | @@ -340,8 +351,8 @@ class GoalsService { | ||
| 340 | 351 | ||
| 341 | _nextGoalId() { | 352 | _nextGoalId() { |
| 342 | // 扫描现有 G-xxx 目录 | 353 | // 扫描现有 G-xxx 目录 |
| 343 | - if (!fs.existsSync(GOALS_DIR)) return 'G-001'; | ||
| 344 | - const dirs = fs.readdirSync(GOALS_DIR, { withFileTypes: true }) | 354 | + if (!fs.existsSync(this.goalsDir)) return 'G-001'; |
| 355 | + const dirs = fs.readdirSync(this.goalsDir, { withFileTypes: true }) | ||
| 345 | .filter(d => d.isDirectory() && d.name.startsWith('G-')) | 356 | .filter(d => d.isDirectory() && d.name.startsWith('G-')) |
| 346 | .map(d => parseInt(d.name.replace('G-', ''))) | 357 | .map(d => parseInt(d.name.replace('G-', ''))) |
| 347 | .filter(n => !isNaN(n)); | 358 | .filter(n => !isNaN(n)); |
| @@ -351,13 +362,13 @@ class GoalsService { | @@ -351,13 +362,13 @@ class GoalsService { | ||
| 351 | } | 362 | } |
| 352 | 363 | ||
| 353 | _saveGoal(goal) { | 364 | _saveGoal(goal) { |
| 354 | - const goalDir = path.join(GOALS_DIR, goal.id); | 365 | + const goalDir = path.join(this.goalsDir, goal.id); |
| 355 | if (!fs.existsSync(goalDir)) fs.mkdirSync(goalDir, { recursive: true }); | 366 | if (!fs.existsSync(goalDir)) fs.mkdirSync(goalDir, { recursive: true }); |
| 356 | fs.writeFileSync(path.join(goalDir, 'goal.json'), JSON.stringify(goal, null, 2), 'utf-8'); | 367 | fs.writeFileSync(path.join(goalDir, 'goal.json'), JSON.stringify(goal, null, 2), 'utf-8'); |
| 357 | } | 368 | } |
| 358 | 369 | ||
| 359 | _saveProgress(goalId, progress) { | 370 | _saveProgress(goalId, progress) { |
| 360 | - const goalDir = path.join(GOALS_DIR, goalId); | 371 | + const goalDir = path.join(this.goalsDir, goalId); |
| 361 | if (!fs.existsSync(goalDir)) return; | 372 | if (!fs.existsSync(goalDir)) return; |
| 362 | fs.writeFileSync(path.join(goalDir, 'progress.json'), JSON.stringify(progress, null, 2), 'utf-8'); | 373 | fs.writeFileSync(path.join(goalDir, 'progress.json'), JSON.stringify(progress, null, 2), 'utf-8'); |
| 363 | } | 374 | } |
| @@ -399,8 +410,8 @@ class GoalsService { | @@ -399,8 +410,8 @@ class GoalsService { | ||
| 399 | } | 410 | } |
| 400 | 411 | ||
| 401 | _rebuildIndex() { | 412 | _rebuildIndex() { |
| 402 | - if (!fs.existsSync(GOALS_DIR)) return; | ||
| 403 | - const dirs = fs.readdirSync(GOALS_DIR, { withFileTypes: true }) | 413 | + if (!fs.existsSync(this.goalsDir)) return; |
| 414 | + const dirs = fs.readdirSync(this.goalsDir, { withFileTypes: true }) | ||
| 404 | .filter(d => d.isDirectory() && d.name.startsWith('G-')); | 415 | .filter(d => d.isDirectory() && d.name.startsWith('G-')); |
| 405 | 416 | ||
| 406 | const index = dirs.map(d => { | 417 | const index = dirs.map(d => { |
| @@ -421,14 +432,14 @@ class GoalsService { | @@ -421,14 +432,14 @@ class GoalsService { | ||
| 421 | }).filter(Boolean); | 432 | }).filter(Boolean); |
| 422 | 433 | ||
| 423 | fs.writeFileSync( | 434 | fs.writeFileSync( |
| 424 | - path.join(GOALS_DIR, '_index.json'), | 435 | + path.join(this.goalsDir, '_index.json'), |
| 425 | JSON.stringify(index, null, 2), | 436 | JSON.stringify(index, null, 2), |
| 426 | 'utf-8' | 437 | 'utf-8' |
| 427 | ); | 438 | ); |
| 428 | } | 439 | } |
| 429 | 440 | ||
| 430 | _log(goalId, message) { | 441 | _log(goalId, message) { |
| 431 | - const logPath = path.join(GOALS_DIR, goalId, 'history.log'); | 442 | + const logPath = path.join(this.goalsDir, goalId, 'history.log'); |
| 432 | const ts = new Date().toISOString(); | 443 | const ts = new Date().toISOString(); |
| 433 | fs.appendFileSync(logPath, `[${ts}] ${message}\n`, 'utf-8'); | 444 | fs.appendFileSync(logPath, `[${ts}] ${message}\n`, 'utf-8'); |
| 434 | } | 445 | } |